Skip to content

Commit 5d8ba18

Browse files
feat(cc-search-bar): init component
1 parent 5d0ec9c commit 5d8ba18

5 files changed

Lines changed: 632 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)