diff --git a/src/components/cc-link/cc-link.js b/src/components/cc-link/cc-link.js index e56228981..345733c30 100644 --- a/src/components/cc-link/cc-link.js +++ b/src/components/cc-link/cc-link.js @@ -2,7 +2,7 @@ import { css, html, LitElement } from 'lit'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { iconRemixExternalLinkLine as externalLinkIcon, iconRemixDownloadLine } from '../../assets/cc-remix.icons.js'; -import { isStringEmpty } from '../../lib/utils.js'; +import { isExternalUrl, isStringEmpty } from '../../lib/utils.js'; import { skeletonStyles } from '../../styles/skeleton.js'; import { i18n } from '../../translations/translation.js'; import '../cc-icon/cc-icon.js'; @@ -83,24 +83,6 @@ export class CcLink extends LitElement { this._title = ''; } - /** - * Checks if a given URL points to a different origin than the current page. - * If the URL is invalid, it is considered as a different origin for security. - * - * @param {string} rawUrl - The URL to check. - * @returns {boolean} True if the URL points to a different origin, false otherwise. - * @private - */ - _isDifferentOrigin(rawUrl) { - try { - const url = new URL(rawUrl, location.href); - return url.origin !== location.origin; - } catch { - // Consider bad URLs as different origin - return true; - } - } - /** * Determines the appropriate title for the link * @@ -140,7 +122,7 @@ export class CcLink extends LitElement { render() { const href = this.href != null && !this.skeleton ? this.href : null; - const isDifferentOrigin = this._isDifferentOrigin(href); + const isDifferentOrigin = isExternalUrl(href); const target = isDifferentOrigin ? '_blank' : null; const rel = isDifferentOrigin ? 'noreferrer' : null; const disableExternalIcon = this.disableExternalLinkIcon || this.mode !== 'default'; diff --git a/src/components/cc-search-bar/cc-search-bar.js b/src/components/cc-search-bar/cc-search-bar.js new file mode 100644 index 000000000..b1a4f8a6d --- /dev/null +++ b/src/components/cc-search-bar/cc-search-bar.js @@ -0,0 +1,391 @@ +import { css, html, LitElement, nothing } from 'lit'; +import { + iconRemixArrowRightSLine as iconArrowRight, + iconRemixExternalLinkLine as iconExternalLink, + iconRemixSearchLine as iconSearch, +} from '../../assets/cc-remix.icons.js'; +import { isExternalUrl } from '../../lib/utils.js'; +import { i18n } from '../../translations/translation.js'; +import '../cc-badge/cc-badge.js'; +import '../cc-dialog/cc-dialog.js'; +import '../cc-icon/cc-icon.js'; +import '../cc-input-text/cc-input-text.js'; + +/** + * @import { SearchBarItem, SearchBarItemType, SearchBarSection } from './cc-search-bar.types.js' + * @import { BadgeIntent } from '../cc-badge/cc-badge.types.js' + * @import { CcInputEvent } from '../common.events.js' + */ + +const KEYWORD_TOKEN_REGEX = /^is:./; + +/** @type {Record} */ +const ITEM_TYPE_BADGE = { + app: { label: 'APP', intent: 'success' }, + addon: { label: 'ADDON', intent: 'warning' }, + 'network-group': { label: 'NG', intent: 'neutral' }, + cke: { label: 'KUBE', intent: 'neutral' }, +}; + +/** + * A search bar dialog that displays categorized results. + * + * ## Details + * + * The component wraps a search input and a list of sections inside a `cc-dialog`. + * Items can have optional badges and external link indicators. + * + * @cssdisplay contents + * + */ +export class CcSearchBar extends LitElement { + static get properties() { + return { + open: { type: Boolean, reflect: true }, + sections: { type: Array }, + value: { type: String }, + }; + } + + constructor() { + super(); + + /** @type {boolean} Displays or hides the search bar dialog. */ + this.open = false; + + /** @type {SearchBarSection[]} The sections to display, each containing a label, icon, and items. */ + this.sections = []; + + /** @type {string} The current search input value. */ + this.value = ''; + } + + /** Opens the search bar dialog by setting the `open` property to true. */ + show() { + this.open = true; + } + + /** Closes the search bar dialog by setting the `open` property to false. */ + hide() { + this.open = false; + } + + /** + * Filters `this.sections` based on `this.value`, using a token-based syntax. + * + * Tokens are split on whitespace and partitioned into: + * - keyword tokens (`is:`): an item passes only if every keyword token + * is present in its derived matchers (`is:` and explicit `matchers`). + * - text tokens: an item passes only if every text token is `includes`'d in its + * lowercased label or its lowercased id. + * + * @returns {SearchBarSection[]} + */ + _getFilteredSections() { + const query = this.value.trim().toLowerCase(); + if (query === '') { + return []; + } + const tokens = query.split(/\s+/); + const keywordTokens = tokens.filter((token) => KEYWORD_TOKEN_REGEX.test(token)); + const textTokens = tokens.filter((token) => !KEYWORD_TOKEN_REGEX.test(token)); + + return this.sections + .map((section) => ({ + ...section, + items: section.items.filter((item) => { + const itemMatchers = [...(item.itemType != null ? [`is:${item.itemType}`] : []), ...(item.matchers ?? [])]; + const keywordsMatch = keywordTokens.every((keyword) => itemMatchers.includes(keyword)); + if (!keywordsMatch) { + return false; + } + const label = item.label.toLowerCase(); + const id = item.id?.toLowerCase() ?? ''; + return textTokens.every((token) => label.includes(token) || id.includes(token)); + }), + })) + .filter((section) => section.items.length > 0); + } + + _onDialogClose() { + this.open = false; + } + + /** @param {CcInputEvent} e */ + _onInput(e) { + this.value = e.detail; + } + + render() { + const filteredSections = this._getFilteredSections(); + const hasItems = filteredSections.length > 0; + return html` + +

${i18n('cc-search-bar.heading')}

+ +
+ `; + } + + _renderEmpty() { + const isInitial = this.value.trim() === ''; + return html` +
+ +

+ ${isInitial ? i18n('cc-search-bar.initial.title') : i18n('cc-search-bar.no-result.title')} +

+

+ ${isInitial ? i18n('cc-search-bar.initial.description') : i18n('cc-search-bar.no-result.description')} +

+
+ `; + } + + _renderSearchInput() { + return html` +
+
+ + +
+
+ `; + } + + /** @param {SearchBarSection} section */ + _renderSection(section) { + return html` +
+

+ + ${section.label} +

+
    + ${section.items.map((item) => this._renderItem(item))} +
+
+ `; + } + + /** @param {SearchBarItem} item */ + _renderItem(item) { + const isExternal = isExternalUrl(item.href); + const badge = item.itemType != null ? ITEM_TYPE_BADGE[item.itemType] : null; + const title = isExternal ? i18n('cc-search-bar.external-link.title', { linkText: item.label }) : nothing; + return html` +
  • + + ${item.label} + ${badge != null ? html` ${badge.label} ` : ''} + ${isExternal + ? html` + + ` + : html``} + +
  • + `; + } + + static get styles() { + return [ + // language=CSS + css` + :host { + display: contents; + } + + cc-dialog::part(dialog) { + margin-block: 1em auto; + overflow: hidden; + } + + .search-bar { + display: flex; + flex-direction: column; + } + + .heading { + font-size: 1.125em; + margin: 0; + } + + .input-wrapper { + display: flex; + flex-direction: column; + gap: 0.35em; + padding: 0.5em 0.5em 1em; + } + + .input-field { + position: relative; + } + + .input-field cc-input-text { + display: block; + font-size: 0.8em; + width: 100%; + } + + .search-icon { + bottom: 0.8em; + color: var(--cc-color-text-default, #000); + pointer-events: none; + position: absolute; + right: 0.67em; + transform: translateY(50%); + } + + .sections { + flex: 1; + max-height: calc(100dvh - 16em); + overflow-y: auto; + padding: 0 0.5em; + } + + .empty { + align-items: center; + color: var(--cc-color-text-default, #000); + display: flex; + flex-direction: column; + gap: 0.5em; + padding: 1.5em 1em; + text-align: center; + } + + .empty-icon { + color: var(--cc-color-text-primary-strongest, #000); + margin-bottom: 0.25em; + } + + .empty-title { + font-size: 0.78em; + font-weight: bold; + margin: 0; + } + + .empty-description { + color: var(--cc-color-text-weak, #666); + font-size: 0.78em; + line-height: 1.5; + margin: 0; + } + + .empty-description code { + background-color: var(--cc-color-bg-neutral, #f5f5f5); + border-radius: var(--cc-border-radius-default, 0.25em); + font-family: var(--cc-ff-monospace, monospace); + padding: 0.1em 0.3em; + } + + .section:not(:last-child) { + border-bottom: solid 1px var(--cc-color-border-neutral-weak, #e7e7e7); + padding-bottom: 1em; + } + + .section:not(:first-child) { + padding-top: 0.5em; + } + + .section-header { + align-items: center; + color: var(--cc-color-text-weak, #666); + display: flex; + font-size: 1em; + font-weight: normal; + gap: 0.4em; + line-height: 1.3; + margin: 0; + padding: 1em 0; + } + + .section-header-label { + font-size: 0.75em; + } + + .section-items { + display: flex; + flex-direction: column; + gap: 0.5em; + list-style: none; + margin: 0; + padding: 0.33em 0; + } + + .item { + align-items: center; + border-radius: 0.5em; + color: var(--cc-color-text-default, #000); + display: flex; + font-weight: normal; + gap: 0.5em; + letter-spacing: -0.15px; + padding: 0.5em; + text-decoration: none; + } + + .item:hover { + background: var(--cc-color-bg-neutral, #f5f5f5); + } + + .item:focus-visible { + border-radius: 0.625em; + outline: var(--cc-focus-outline); + outline-offset: var(--cc-focus-outline-offset, 2px); + } + + .item-label { + flex: 1; + font-size: 0.875em; + max-width: 80%; + min-width: 0; + overflow-wrap: anywhere; + } + + .item-label + * { + margin-left: auto; + } + + .external-link-icon { + color: var(--cc-color-text-primary-strongest, #012a51); + flex-shrink: 0; + } + + .hover-chevron { + color: var(--cc-color-text-primary, #1a51b3); + display: none; + flex-shrink: 0; + } + + .item:hover .hover-chevron { + display: inline-block; + } + `, + ]; + } +} + +window.customElements.define('cc-search-bar', CcSearchBar); diff --git a/src/components/cc-search-bar/cc-search-bar.stories.js b/src/components/cc-search-bar/cc-search-bar.stories.js new file mode 100644 index 000000000..a96f8c7f9 --- /dev/null +++ b/src/components/cc-search-bar/cc-search-bar.stories.js @@ -0,0 +1,198 @@ +import { html, render } from 'lit'; +import { + iconRemixBook_2Line as iconDocumentation, + iconRemixBuilding_4Line as iconOrganisations, + iconRemixGlobalLine as iconOtherResources, + iconRemixArticleLine as iconPages, + iconRemixCloudLine as iconResources, +} from '../../assets/cc-remix.icons.js'; +import { makeStory } from '../../stories/lib/make-story.js'; +import '../cc-button/cc-button.js'; +import './cc-search-bar.js'; + +/** @import { CcSearchBar } from './cc-search-bar.js' */ + +/** @param {HTMLElement} container */ +const getCcSearchBar = (container) => /** @type {CcSearchBar} */ (container.querySelector('cc-search-bar')); + +export default { + tags: ['autodocs'], + title: '🧬 Atoms/', + component: 'cc-search-bar', +}; + +const conf = { + component: 'cc-search-bar', + css: ` + cc-search-bar { + max-width: 500px; + } + `, +}; + +/** @type {import('./cc-search-bar.types.js').SearchBarSection[]} */ +const defaultSections = [ + { + label: 'Organisations', + icon: iconOrganisations, + items: [ + { label: 'ACME BAR', href: '#acme-bar' }, + { label: 'ACME FOO', href: '#acme-foo' }, + ], + }, + { + label: 'Quick access pages', + icon: iconPages, + items: [ + { label: 'API token', href: '#api-token' }, + { label: 'Labs', href: '#labs' }, + ], + }, + { + label: 'Ressources in this organisation', + icon: iconResources, + items: [ + { label: 'APM --0307-42dc-af99-b485ecc44536', href: '#apm-1', itemType: 'app' }, + { label: 'fs-matomot-with-posthog', href: '#matomot-1', itemType: 'addon' }, + { label: 'APM - apps-0307-42dc-af99-b485ecc44536', href: '#apm-2', itemType: 'app' }, + { + label: 'network-groupups_4d65a2d6-0307-af99-b485ecc44536', + href: '#ng-1', + itemType: 'network-group', + }, + { + label: 'fs-matomot-with-posthog - 9a84bf38-f874-4caf-a8608-f874-4caf-a8608-f874-4caf-a8608', + href: '#matomot-2', + itemType: 'app', + }, + { label: 'test-kube-config', href: '#kube-1', itemType: 'cke' }, + { label: 'test-addon-pulsar', href: '#pulsar-1', itemType: 'addon' }, + ], + }, + { + label: 'Ressources in others organisations', + icon: iconOtherResources, + items: [ + { label: 'APM --0307-42dc-af99-b485ecc44536', href: '#apm-3', itemType: 'app' }, + { label: 'fs-matomot-with-posthog', href: '#matomot-3', itemType: 'addon' }, + { label: 'APM - apps-0307-42dc-af99-b485ecc44536', href: '#apm-4', itemType: 'app' }, + { + label: 'network-groupups_4d65a2d6-0307-af99-b485ecc44536', + href: '#ng-2', + itemType: 'network-group', + }, + { + label: + 'fs-matomot-with-posthog - 9a84bf38-f874-4caf-a8608-f874-4caf-a8608-f874-4caf-a8608-f874-4caf-a860-7d37b0df2a175', + href: '#matomot-4', + itemType: 'app', + }, + { label: 'test-kube-config', href: '#kube-2', itemType: 'cke' }, + { label: 'test-addon-pulsar', href: '#pulsar-2', itemType: 'addon' }, + ], + }, + { + label: 'Documentation', + icon: iconDocumentation, + items: [ + { label: 'Request the API', href: 'https://www.clever-cloud.com/developers/api/' }, + { + label: 'Network Groups', + href: 'https://www.clever-cloud.com/developers/doc/network-groups/', + }, + { label: 'Operators', href: 'https://www.clever-cloud.com/developers/doc/addons/' }, + { + label: 'Pulsar policies', + href: 'https://www.clever-cloud.com/developers/doc/addons/pulsar/', + }, + ], + }, +]; + +export const defaultStory = makeStory(conf, { + docs: ` +Shows the populated state with a sample query (\`a\`) so the categorized results are visible: organizations, +quick access pages, resources, and documentation. Each section has its own icon and label, and items can have +an optional badge or external link indicator. With an empty query, see the \`empty\` story. + `, + /** @param {HTMLElement} container */ + dom: (container) => { + render( + html` + Open Search Bar + + `, + container, + ); + }, +}); + +export const empty = makeStory(conf, { + docs: ` +Shows the initial state — the search bar is open but no search has been performed yet. The text invites the +user to start searching and lists what can be searched. + `, + /** @param {HTMLElement} container */ + dom: (container) => { + render( + html` + Open Search Bar + + `, + container, + ); + }, +}); + +export const noResult = makeStory(conf, { + docs: ` +Shows the no-result state — a search has been performed but no item matches. The text invites the user to try +different keywords. + `, + /** @param {HTMLElement} container */ + dom: (container) => { + render( + html` + Open Search Bar + + `, + container, + ); + }, +}); + +export const withValue = makeStory(conf, { + docs: ` +Shows the search bar with a pre-filled value — items whose label includes the query (case-insensitive) are kept, +empty sections are hidden. + `, + /** @param {HTMLElement} container */ + dom: (container) => { + render( + html` + Open Search Bar + + `, + container, + ); + }, +}); + +export const withKeywordFilter = makeStory(conf, { + docs: ` +The query supports \`is:\` keyword tokens. Items match a keyword token when it is in their derived +matchers — \`is:\` is added automatically for items with an \`itemType\`, and additional matchers can +be provided via the \`matchers\` field. Tokens can be combined: \`is:app apm\` keeps only \`itemType: 'app'\` +items whose label includes \`apm\`. + `, + /** @param {HTMLElement} container */ + dom: (container) => { + render( + html` + Open Search Bar + + `, + container, + ); + }, +}); diff --git a/src/components/cc-search-bar/cc-search-bar.types.d.ts b/src/components/cc-search-bar/cc-search-bar.types.d.ts new file mode 100644 index 000000000..c256e1a94 --- /dev/null +++ b/src/components/cc-search-bar/cc-search-bar.types.d.ts @@ -0,0 +1,17 @@ +import { IconModel } from '../common.types.js'; + +export type SearchBarItemType = 'app' | 'addon' | 'network-group' | 'cke'; + +export interface SearchBarItem { + label: string; + href: string; + id?: string; + itemType?: SearchBarItemType; + matchers?: string[]; +} + +export interface SearchBarSection { + label: string; + icon: IconModel; + items: SearchBarItem[]; +} diff --git a/src/lib/utils.js b/src/lib/utils.js index bdc38183a..fb42f0aa3 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -255,6 +255,22 @@ export function clampNumber(number, min, max) { return Math.min(Math.max(number, min ?? -Infinity), max ?? Infinity); } +/** + * Checks if the given URL points to a different origin than the current page. + * Invalid URLs are considered external for security. + * + * @param {string} rawUrl + * @return {boolean} + */ +export function isExternalUrl(rawUrl) { + try { + const url = new URL(rawUrl, location.href); + return url.origin !== location.origin; + } catch { + return true; + } +} + /** * @param {string} string * @return {boolean} diff --git a/src/translations/translations.en.js b/src/translations/translations.en.js index ea72c6239..bdf11ba48 100644 --- a/src/translations/translations.en.js +++ b/src/translations/translations.en.js @@ -1864,6 +1864,19 @@ export const translations = { 'cc-range-selector.summary.selected': `selected`, 'cc-range-selector.summary.unselected': `unselected`, //#endregion + //#region cc-search-bar + 'cc-search-bar.external-link.name': `new window`, + 'cc-search-bar.external-link.title': /** @param {{linkText: string}} _ */ ({ linkText }) => + `${linkText} - new window`, + 'cc-search-bar.heading': `Search bar`, + 'cc-search-bar.initial.description': () => + sanitize`Start searching by keywords, id or filter (for example: is:app, is:addon…).
    You can search across organizations, resources (applications, add-ons, etc.), pages and documentation.`, + 'cc-search-bar.initial.title': `Search across all your content`, + 'cc-search-bar.label': `What are you looking for?`, + 'cc-search-bar.no-result.description': `Try different keywords or check the spelling`, + 'cc-search-bar.no-result.title': `No result found`, + 'cc-search-bar.placeholder': `Search by keywords...`, + //#endregion //#region cc-select 'cc-select.error.empty': `You must select a value`, 'cc-select.required': `required`, diff --git a/src/translations/translations.fr.js b/src/translations/translations.fr.js index 58b3ff63f..693fe4c4d 100644 --- a/src/translations/translations.fr.js +++ b/src/translations/translations.fr.js @@ -1887,6 +1887,19 @@ export const translations = { 'cc-range-selector.summary.selected': `sélectionné`, 'cc-range-selector.summary.unselected': `non sélectionné`, //#endregion + //#region cc-search-bar + 'cc-search-bar.external-link.name': `nouvelle fenêtre`, + 'cc-search-bar.external-link.title': /** @param {{linkText: string}} _ */ ({ linkText }) => + `${linkText} - nouvelle fenêtre`, + 'cc-search-bar.heading': `Barre de recherche`, + 'cc-search-bar.initial.description': () => + sanitize`Commencez à chercher par mots-clés, id ou filtre (par exemple : is:app, is:addon…).
    Vous pouvez effectuer une recherche parmi les organisations, les ressources (applications, add-ons, etc.), les pages et la documentation.`, + 'cc-search-bar.initial.title': `Cherchez parmi tous vos contenus`, + 'cc-search-bar.label': `Que cherchez-vous ?`, + 'cc-search-bar.no-result.description': `Essayez d'autres mots-clés ou vérifiez l'orthographe`, + 'cc-search-bar.no-result.title': `Aucun résultat`, + 'cc-search-bar.placeholder': `Recherche par mots-clés...`, + //#endregion //#region cc-select 'cc-select.error.empty': `Sélectionnez une valeur`, 'cc-select.required': `obligatoire`,