Skip to content

Commit cc15826

Browse files
feat(cc-search-bar): init component
1 parent f8c9d69 commit cc15826

7 files changed

Lines changed: 592 additions & 0 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { CcEvent } from '../../lib/events.js';
2+
3+
/**
4+
* Dispatched when the search input value changes.
5+
* @extends {CcEvent<string>}
6+
*/
7+
export class CcSearchBarInputEvent extends CcEvent {
8+
static TYPE = 'cc-search-bar-input';
9+
10+
/**
11+
* @param {string} detail
12+
*/
13+
constructor(detail) {
14+
super(CcSearchBarInputEvent.TYPE, detail);
15+
}
16+
}
Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
1+
import { css, html, LitElement, nothing } from 'lit';
2+
import { createRef, ref } from 'lit/directives/ref.js';
3+
import {
4+
iconRemixCloseLine as iconClose,
5+
iconRemixExternalLinkLine as iconExternalLink,
6+
iconRemixSearchLine as iconSearch,
7+
} from '../../assets/cc-remix.icons.js';
8+
import { accessibilityStyles } from '../../styles/accessibility.js';
9+
import { i18n } from '../../translations/translation.js';
10+
import '../cc-badge/cc-badge.js';
11+
import '../cc-icon/cc-icon.js';
12+
import '../cc-input-text/cc-input-text.js';
13+
import { CcCloseEvent } from '../common.events.js';
14+
import { CcSearchBarInputEvent } from './cc-search-bar.events.js';
15+
16+
/**
17+
* @import { SearchBarItem, SearchBarSection } from './cc-search-bar.types.js'
18+
* @import { CcInputText } from '../cc-input-text/cc-input-text.js'
19+
* @import { CcInputEvent } from '../common.events.js'
20+
* @import { Ref } from 'lit/directives/ref.js'
21+
*/
22+
23+
/**
24+
* A search bar / command palette component that displays categorized results.
25+
*
26+
* ## Details
27+
*
28+
* The component displays a search input and a list of sections, each containing items.
29+
* Items can have optional badges and external link indicators.
30+
*
31+
* @cssdisplay block
32+
*
33+
* @fires {CcSearchBarInputEvent} cc-search-bar:input - Fires when the search input value changes.
34+
* @fires {CcCloseEvent} cc-close - Fires when the search bar is closed.
35+
*/
36+
export class CcSearchBar extends LitElement {
37+
static get properties() {
38+
return {
39+
sections: { type: Array },
40+
value: { type: String },
41+
};
42+
}
43+
44+
constructor() {
45+
super();
46+
47+
/** @type {SearchBarSection[]} The sections to display, each containing a label, icon, and items. */
48+
this.sections = [];
49+
50+
/** @type {string} The current search input value. */
51+
this.value = '';
52+
53+
/** @type {Ref<CcInputText>} */
54+
this._inputRef = createRef();
55+
}
56+
57+
/**
58+
* Focuses the search input.
59+
*/
60+
focus() {
61+
this._inputRef.value?.focus();
62+
}
63+
64+
_onClose() {
65+
this.dispatchEvent(new CcCloseEvent());
66+
}
67+
68+
/** @param {CcInputEvent} e */
69+
_onInput(e) {
70+
this.value = e.detail;
71+
this.dispatchEvent(new CcSearchBarInputEvent(this.value));
72+
}
73+
74+
render() {
75+
const hasItems = this.sections.some((section) => section.items.length > 0);
76+
return html`
77+
<div class="search-bar">
78+
${this._renderHeader()} ${this._renderSearchInput()}
79+
${hasItems
80+
? html`<div class="sections">${this.sections.map((section) => this._renderSection(section))}</div>`
81+
: this._renderEmpty()}
82+
</div>
83+
`;
84+
}
85+
86+
_renderEmpty() {
87+
return html`
88+
<div class="empty">
89+
<cc-icon class="empty-icon" .icon="${iconSearch}" size="xl"></cc-icon>
90+
<p class="empty-title">${i18n('cc-search-bar.empty.title')}</p>
91+
<p class="empty-description">${i18n('cc-search-bar.empty.description')}</p>
92+
</div>
93+
`;
94+
}
95+
96+
_renderHeader() {
97+
return html`
98+
<div class="header">
99+
<h2 class="heading">${i18n('cc-search-bar.heading')}</h2>
100+
<button
101+
type="button"
102+
class="close-button"
103+
@click="${this._onClose}"
104+
aria-label="${i18n('cc-search-bar.close')}"
105+
>
106+
<cc-icon .icon="${iconClose}" size="md"></cc-icon>
107+
</button>
108+
</div>
109+
`;
110+
}
111+
112+
_renderSearchInput() {
113+
return html`
114+
<div class="input-wrapper">
115+
<span class="input-label">${i18n('cc-search-bar.label')}</span>
116+
<div class="input-field">
117+
<cc-input-text
118+
hidden-label
119+
label="${i18n('cc-search-bar.label')}"
120+
placeholder="${i18n('cc-search-bar.placeholder')}"
121+
value="${this.value}"
122+
@cc-input="${this._onInput}"
123+
${ref(this._inputRef)}
124+
></cc-input-text>
125+
<cc-icon class="search-icon" .icon="${iconSearch}" size="sm"></cc-icon>
126+
</div>
127+
</div>
128+
`;
129+
}
130+
131+
/** @param {SearchBarSection} section */
132+
_renderSection(section) {
133+
return html`
134+
<div class="section">
135+
<h3 class="section-header">
136+
<cc-icon .icon="${section.icon}" size="md"></cc-icon>
137+
<span>${section.label}</span>
138+
</h3>
139+
<ul class="section-items" aria-label="${section.label}">
140+
${section.items.map((item) => this._renderItem(item))}
141+
</ul>
142+
</div>
143+
`;
144+
}
145+
146+
/** @param {SearchBarItem} item */
147+
_renderItem(item) {
148+
return html`
149+
<li>
150+
<a
151+
class="item"
152+
href="${item.href}"
153+
target="${item.externalLink ? '_blank' : nothing}"
154+
rel="${item.externalLink ? 'noopener noreferrer' : nothing}"
155+
>
156+
<span class="item-label">${item.label}</span>
157+
${item.badge != null
158+
? html` <cc-badge intent="${item.badgeIntent ?? 'neutral'}" weight="strong"> ${item.badge} </cc-badge> `
159+
: ''}
160+
${item.externalLink
161+
? html`
162+
<span class="visually-hidden">${i18n('cc-search-bar.external-link.new-tab')}</span>
163+
<cc-icon class="external-link-icon" .icon="${iconExternalLink}" size="sm"></cc-icon>
164+
`
165+
: ''}
166+
</a>
167+
</li>
168+
`;
169+
}
170+
171+
static get styles() {
172+
return [
173+
accessibilityStyles,
174+
// language=CSS
175+
css`
176+
:host {
177+
display: block;
178+
}
179+
180+
.search-bar {
181+
background: var(--cc-color-bg-default, #fff);
182+
border: 1px solid var(--cc-color-border-neutral-weak, #e7e7e7);
183+
border-radius: var(--cc-border-radius-default, 0.25em);
184+
box-shadow: 0 8px 30px rgb(0 0 0 / 12%);
185+
display: flex;
186+
flex-direction: column;
187+
max-height: 80vh;
188+
overflow: hidden;
189+
padding: 1em 1.25em;
190+
width: 100%;
191+
}
192+
193+
.header {
194+
align-items: center;
195+
display: flex;
196+
justify-content: space-between;
197+
padding-bottom: 0.5em;
198+
}
199+
200+
.heading {
201+
color: var(--cc-color-text-primary-strongest, #000);
202+
font-size: 0.89em;
203+
font-weight: bold;
204+
margin: 0;
205+
}
206+
207+
.close-button {
208+
align-items: center;
209+
background: none;
210+
border: none;
211+
border-radius: 0.22em;
212+
color: var(--cc-color-text-weak, #666);
213+
cursor: pointer;
214+
display: flex;
215+
height: 1.67em;
216+
justify-content: center;
217+
padding: 0;
218+
width: 1.67em;
219+
}
220+
221+
.close-button:hover {
222+
background: var(--color-grey-10, #f5f5f5);
223+
}
224+
225+
.close-button:focus-visible {
226+
outline: var(--cc-focus-outline);
227+
outline-offset: var(--cc-focus-outline-offset, 2px);
228+
}
229+
230+
.input-wrapper {
231+
display: flex;
232+
flex-direction: column;
233+
gap: 0.35em;
234+
padding: 0.5em 0 1em;
235+
}
236+
237+
.input-label {
238+
color: var(--cc-color-text-default, #000);
239+
font-size: 0.89em;
240+
font-weight: normal;
241+
}
242+
243+
.input-field {
244+
position: relative;
245+
}
246+
247+
.input-field cc-input-text {
248+
display: block;
249+
font-size: 0.78em;
250+
width: 100%;
251+
}
252+
253+
.search-icon {
254+
color: var(--cc-color-text-default, #000);
255+
pointer-events: none;
256+
position: absolute;
257+
right: 0.67em;
258+
top: 50%;
259+
transform: translateY(-50%);
260+
}
261+
262+
.sections {
263+
flex: 1;
264+
overflow-y: auto;
265+
}
266+
267+
.empty {
268+
align-items: center;
269+
color: var(--cc-color-text-default, #000);
270+
display: flex;
271+
flex-direction: column;
272+
gap: 0.5em;
273+
padding: 1.5em 1em;
274+
text-align: center;
275+
}
276+
277+
.empty-icon {
278+
color: var(--cc-color-text-primary-strongest, #000);
279+
margin-bottom: 0.25em;
280+
}
281+
282+
.empty-title {
283+
font-size: 0.78em;
284+
font-weight: bold;
285+
margin: 0;
286+
}
287+
288+
.empty-description {
289+
color: var(--cc-color-text-weak, #666);
290+
font-size: 0.78em;
291+
line-height: 1.5;
292+
margin: 0;
293+
}
294+
295+
.section:not(:last-child) {
296+
border-bottom: solid 1px var(--cc-color-border-neutral-weak, #e7e7e7);
297+
padding-bottom: 1.25em;
298+
}
299+
300+
.section:not(:first-child) {
301+
padding-top: 0.25em;
302+
}
303+
304+
.section-header {
305+
align-items: center;
306+
color: var(--cc-color-text-weak, #666);
307+
display: flex;
308+
font-size: 0.75em;
309+
font-weight: normal;
310+
gap: 0.4em;
311+
line-height: 1.3;
312+
margin: 0;
313+
padding: 0.5em 0;
314+
}
315+
316+
.section-items {
317+
display: flex;
318+
flex-direction: column;
319+
gap: 0.5em;
320+
list-style: none;
321+
margin: 0;
322+
padding: 0.33em 0.44em;
323+
}
324+
325+
.item {
326+
align-items: center;
327+
border-radius: 0.22em;
328+
color: var(--cc-color-text-default, #000);
329+
display: flex;
330+
font-size: 0.78em;
331+
font-weight: normal;
332+
gap: 0.5em;
333+
letter-spacing: -0.15px;
334+
padding: 0.28em 0.44em;
335+
text-decoration: none;
336+
}
337+
338+
.item:hover {
339+
background: var(--color-grey-10, #f5f5f5);
340+
}
341+
342+
.item:focus-visible {
343+
outline: var(--cc-focus-outline);
344+
outline-offset: var(--cc-focus-outline-offset, 2px);
345+
}
346+
347+
.item-label {
348+
flex: 1;
349+
min-width: 0;
350+
overflow: hidden;
351+
text-overflow: ellipsis;
352+
white-space: nowrap;
353+
}
354+
355+
.external-link-icon {
356+
color: var(--cc-color-text-weak, #888);
357+
flex-shrink: 0;
358+
}
359+
`,
360+
];
361+
}
362+
}
363+
364+
window.customElements.define('cc-search-bar', CcSearchBar);

0 commit comments

Comments
 (0)