Skip to content

Commit 7443c04

Browse files
feat(cc-search-bar): add component with internal filtering
The component takes the full list of sections and filters them as the user types. Filter syntax follows console3: tokens are split on whitespace, partitioned into keyword tokens (is:<value>, matched against each item's matchers — derived from itemType plus an optional explicit list) and text tokens (case-insensitive includes against the label). Tokens combine with AND logic, sections with no matching items are hidden, and an empty query shows the empty state.
1 parent b1dfacb commit 7443c04

5 files changed

Lines changed: 623 additions & 0 deletions

File tree

Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
1+
import { css, html, LitElement, nothing } from 'lit';
2+
import { createRef, ref } from 'lit/directives/ref.js';
3+
import {
4+
iconRemixArrowRightSLine as iconArrowRight,
5+
iconRemixExternalLinkLine as iconExternalLink,
6+
iconRemixSearchLine as iconSearch,
7+
} from '../../assets/cc-remix.icons.js';
8+
import { isExternalUrl } from '../../lib/utils.js';
9+
import { i18n } from '../../translations/translation.js';
10+
import '../cc-badge/cc-badge.js';
11+
import '../cc-dialog/cc-dialog.js';
12+
import '../cc-icon/cc-icon.js';
13+
import '../cc-input-text/cc-input-text.js';
14+
15+
/**
16+
* @import { SearchBarItem, SearchBarItemType, SearchBarSection } from './cc-search-bar.types.js'
17+
* @import { BadgeIntent } from '../cc-badge/cc-badge.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+
const KEYWORD_TOKEN_REGEX = /^is:./;
24+
25+
/**
26+
* Filters sections based on a search query, using a token-based syntax.
27+
*
28+
* Tokens are split on whitespace and partitioned into:
29+
* - keyword tokens (`is:<value>`): an item passes only if every keyword token
30+
* is present in its derived matchers (`is:<itemType>` and explicit `matchers`).
31+
* - text tokens: an item passes only if every text token is `includes`'d in its
32+
* lowercased label.
33+
*
34+
* @param {SearchBarSection[]} sections
35+
* @param {string} value
36+
* @returns {SearchBarSection[]}
37+
*/
38+
function filterSections(sections, value) {
39+
const query = value.trim().toLowerCase();
40+
if (query === '') {
41+
return [];
42+
}
43+
const tokens = query.split(/\s+/);
44+
const keywordTokens = tokens.filter((token) => KEYWORD_TOKEN_REGEX.test(token));
45+
const textTokens = tokens.filter((token) => !KEYWORD_TOKEN_REGEX.test(token));
46+
47+
return sections
48+
.map((section) => ({
49+
...section,
50+
items: section.items.filter((item) => {
51+
const itemMatchers = [...(item.itemType != null ? [`is:${item.itemType}`] : []), ...(item.matchers ?? [])];
52+
const keywordsMatch = keywordTokens.every((keyword) => itemMatchers.includes(keyword));
53+
if (!keywordsMatch) {
54+
return false;
55+
}
56+
const label = item.label.toLowerCase();
57+
return textTokens.every((token) => label.includes(token));
58+
}),
59+
}))
60+
.filter((section) => section.items.length > 0);
61+
}
62+
63+
/** @type {Record<SearchBarItemType, { label: string, intent: BadgeIntent }>} */
64+
const ITEM_TYPE_BADGE = {
65+
app: { label: 'APP', intent: 'success' },
66+
addon: { label: 'ADDON', intent: 'warning' },
67+
'network-group': { label: 'NG', intent: 'neutral' },
68+
cke: { label: 'KUBE', intent: 'neutral' },
69+
};
70+
71+
/**
72+
* A search bar dialog that displays categorized results.
73+
*
74+
* ## Details
75+
*
76+
* The component wraps a search input and a list of sections inside a `cc-dialog`.
77+
* Items can have optional badges and external link indicators.
78+
*
79+
* @cssdisplay contents
80+
*
81+
*/
82+
export class CcSearchBar extends LitElement {
83+
static get properties() {
84+
return {
85+
open: { type: Boolean, reflect: true },
86+
sections: { type: Array },
87+
value: { type: String },
88+
};
89+
}
90+
91+
constructor() {
92+
super();
93+
94+
/** @type {boolean} Displays or hides the search bar dialog. */
95+
this.open = false;
96+
97+
/** @type {SearchBarSection[]} The sections to display, each containing a label, icon, and items. */
98+
this.sections = [];
99+
100+
/** @type {string} The current search input value. */
101+
this.value = '';
102+
103+
/** @type {Ref<CcInputText>} */
104+
this._inputRef = createRef();
105+
}
106+
107+
/**
108+
* Focuses the search input.
109+
*/
110+
focus() {
111+
this._inputRef.value?.focus();
112+
}
113+
114+
/** Opens the search bar dialog by setting the `open` property to true. */
115+
show() {
116+
this.open = true;
117+
}
118+
119+
/** Closes the search bar dialog by setting the `open` property to false. */
120+
hide() {
121+
this.open = false;
122+
}
123+
124+
_onDialogClose() {
125+
this.open = false;
126+
}
127+
128+
/** @param {CcInputEvent} e */
129+
_onInput(e) {
130+
this.value = e.detail;
131+
}
132+
133+
render() {
134+
const filteredSections = filterSections(this.sections, this.value);
135+
const hasItems = filteredSections.length > 0;
136+
return html`
137+
<cc-dialog ?open="${this.open}" @cc-close="${this._onDialogClose}">
138+
<h1 slot="heading" class="heading">${i18n('cc-search-bar.heading')}</h1>
139+
<div class="search-bar">
140+
${this._renderSearchInput()}
141+
${hasItems
142+
? html`<div class="sections">${filteredSections.map((section) => this._renderSection(section))}</div>`
143+
: this._renderEmpty()}
144+
</div>
145+
</cc-dialog>
146+
`;
147+
}
148+
149+
_renderEmpty() {
150+
return html`
151+
<div class="empty">
152+
<cc-icon class="empty-icon" .icon="${iconSearch}" size="xl"></cc-icon>
153+
<p class="empty-title">${i18n('cc-search-bar.empty.title')}</p>
154+
<p class="empty-description">${i18n('cc-search-bar.empty.description')}</p>
155+
</div>
156+
`;
157+
}
158+
159+
_renderSearchInput() {
160+
return html`
161+
<div class="input-wrapper">
162+
<span class="input-label">${i18n('cc-search-bar.label')}</span>
163+
<div class="input-field">
164+
<cc-input-text
165+
hidden-label
166+
label="${i18n('cc-search-bar.label.a11y')}"
167+
placeholder="${i18n('cc-search-bar.placeholder')}"
168+
value="${this.value}"
169+
@cc-input="${this._onInput}"
170+
${ref(this._inputRef)}
171+
></cc-input-text>
172+
<cc-icon class="search-icon" .icon="${iconSearch}" size="sm"></cc-icon>
173+
</div>
174+
</div>
175+
`;
176+
}
177+
178+
/** @param {SearchBarSection} section */
179+
_renderSection(section) {
180+
return html`
181+
<div class="section">
182+
<h2 class="section-header">
183+
<cc-icon .icon="${section.icon}" size="md"></cc-icon>
184+
<span class="section-header-label">${section.label}</span>
185+
</h2>
186+
<ul class="section-items">
187+
${section.items.map((item) => this._renderItem(item))}
188+
</ul>
189+
</div>
190+
`;
191+
}
192+
193+
/** @param {SearchBarItem} item */
194+
_renderItem(item) {
195+
const isExternal = isExternalUrl(item.href);
196+
const badge = item.itemType != null ? ITEM_TYPE_BADGE[item.itemType] : null;
197+
const title = isExternal ? i18n('cc-search-bar.external-link.title', { linkText: item.label }) : nothing;
198+
return html`
199+
<li>
200+
<a
201+
class="item"
202+
href="${item.href}"
203+
target="${isExternal ? '_blank' : nothing}"
204+
rel="${isExternal ? 'noreferrer' : nothing}"
205+
title="${title}"
206+
>
207+
<span class="item-label">${item.label}</span>
208+
${badge != null ? html` <cc-badge intent="${badge.intent}" weight="dimmed">${badge.label}</cc-badge> ` : ''}
209+
${isExternal
210+
? html`
211+
<cc-icon
212+
class="external-link-icon"
213+
.icon="${iconExternalLink}"
214+
size="md"
215+
a11y-name="${i18n('cc-search-bar.external-link.name')}"
216+
></cc-icon>
217+
`
218+
: html`<cc-icon class="hover-chevron" .icon="${iconArrowRight}" size="md"></cc-icon>`}
219+
</a>
220+
</li>
221+
`;
222+
}
223+
224+
static get styles() {
225+
return [
226+
// language=CSS
227+
css`
228+
:host {
229+
display: contents;
230+
}
231+
232+
.search-bar {
233+
display: flex;
234+
flex-direction: column;
235+
}
236+
237+
.heading {
238+
font-size: 1.125em;
239+
margin: 0;
240+
}
241+
242+
.input-wrapper {
243+
display: flex;
244+
flex-direction: column;
245+
gap: 0.35em;
246+
padding: 0.5em 0.5em 1em;
247+
}
248+
249+
.input-label {
250+
color: var(--cc-color-text-default, #000);
251+
font-size: 0.9em;
252+
font-weight: normal;
253+
}
254+
255+
.input-field {
256+
position: relative;
257+
}
258+
259+
.input-field cc-input-text {
260+
display: block;
261+
font-size: 0.8em;
262+
width: 100%;
263+
}
264+
265+
.search-icon {
266+
color: var(--cc-color-text-default, #000);
267+
pointer-events: none;
268+
position: absolute;
269+
right: 0.67em;
270+
top: 50%;
271+
transform: translateY(-50%);
272+
}
273+
274+
.sections {
275+
flex: 1;
276+
overflow-y: auto;
277+
padding: 0 0.5em;
278+
}
279+
280+
.empty {
281+
align-items: center;
282+
color: var(--cc-color-text-default, #000);
283+
display: flex;
284+
flex-direction: column;
285+
gap: 0.5em;
286+
padding: 1.5em 1em;
287+
text-align: center;
288+
}
289+
290+
.empty-icon {
291+
color: var(--cc-color-text-primary-strongest, #000);
292+
margin-bottom: 0.25em;
293+
}
294+
295+
.empty-title {
296+
font-size: 0.78em;
297+
font-weight: bold;
298+
margin: 0;
299+
}
300+
301+
.empty-description {
302+
color: var(--cc-color-text-weak, #666);
303+
font-size: 0.78em;
304+
line-height: 1.5;
305+
margin: 0;
306+
}
307+
308+
.section:not(:last-child) {
309+
border-bottom: solid 1px var(--cc-color-border-neutral-weak, #e7e7e7);
310+
padding-bottom: 1em;
311+
}
312+
313+
.section:not(:first-child) {
314+
padding-top: 0.5em;
315+
}
316+
317+
.section-header {
318+
align-items: center;
319+
color: var(--cc-color-text-weak, #666);
320+
display: flex;
321+
font-size: 1em;
322+
font-weight: normal;
323+
gap: 0.4em;
324+
line-height: 1.3;
325+
margin: 0;
326+
padding: 1em 0;
327+
}
328+
329+
.section-header-label {
330+
font-size: 0.75em;
331+
}
332+
333+
.section-items {
334+
display: flex;
335+
flex-direction: column;
336+
gap: 0.5em;
337+
list-style: none;
338+
margin: 0;
339+
padding: 0.33em 0;
340+
}
341+
342+
.item {
343+
align-items: center;
344+
border-radius: 0.5em;
345+
color: var(--cc-color-text-default, #000);
346+
display: flex;
347+
font-weight: normal;
348+
gap: 0.5em;
349+
letter-spacing: -0.15px;
350+
padding: 0.5em;
351+
text-decoration: none;
352+
}
353+
354+
.item:hover {
355+
background: var(--cc-color-bg-neutral, #f5f5f5);
356+
}
357+
358+
.item:focus-visible {
359+
border-radius: 0.625em;
360+
outline: var(--cc-focus-outline);
361+
outline-offset: var(--cc-focus-outline-offset, 2px);
362+
}
363+
364+
.item-label {
365+
flex: 1;
366+
font-size: 0.875em;
367+
min-width: 0;
368+
overflow: hidden;
369+
text-overflow: ellipsis;
370+
white-space: nowrap;
371+
}
372+
373+
.external-link-icon {
374+
color: var(--cc-color-text-primary-strongest, #012a51);
375+
flex-shrink: 0;
376+
}
377+
378+
.hover-chevron {
379+
color: var(--cc-color-text-primary, #1a51b3);
380+
display: none;
381+
flex-shrink: 0;
382+
}
383+
384+
.item:hover .hover-chevron {
385+
display: inline-block;
386+
}
387+
388+
.item:focus-visible .item-label {
389+
overflow: visible;
390+
text-overflow: clip;
391+
white-space: normal;
392+
}
393+
`,
394+
];
395+
}
396+
}
397+
398+
window.customElements.define('cc-search-bar', CcSearchBar);

0 commit comments

Comments
 (0)