From cd4dbc467d065373b955567190d1bc9fb83e9eda Mon Sep 17 00:00:00 2001 From: Barsnes Date: Thu, 18 Jun 2026 09:35:02 +0200 Subject: [PATCH] feat: add search to /components sidebar --- .../_components/sidebar/sidebar.module.css | 10 ++ apps/www/app/_components/sidebar/sidebar.tsx | 93 ++++++++++++++++++- apps/www/app/layouts/components/layout.tsx | 11 +++ apps/www/app/locales/en.ts | 4 + apps/www/app/locales/no.ts | 4 + 5 files changed, 119 insertions(+), 3 deletions(-) diff --git a/apps/www/app/_components/sidebar/sidebar.module.css b/apps/www/app/_components/sidebar/sidebar.module.css index 73adef1504..7a0aa6de3e 100644 --- a/apps/www/app/_components/sidebar/sidebar.module.css +++ b/apps/www/app/_components/sidebar/sidebar.module.css @@ -26,6 +26,16 @@ margin-bottom: var(--ds-size-5); } +.search { + margin-bottom: var(--ds-size-5); + padding: 0 var(--ds-size-4); +} + +.noResults { + padding: 0 var(--ds-size-4); + color: var(--ds-color-neutral-text-subtle); +} + .list { margin: 0; padding: 0; diff --git a/apps/www/app/_components/sidebar/sidebar.tsx b/apps/www/app/_components/sidebar/sidebar.tsx index 0d8d0dae23..74f2f42f2a 100644 --- a/apps/www/app/_components/sidebar/sidebar.tsx +++ b/apps/www/app/_components/sidebar/sidebar.tsx @@ -1,7 +1,14 @@ -import { Button, Link, Paragraph } from '@digdir/designsystemet-react'; +import { Button, Link, Paragraph, Search } from '@digdir/designsystemet-react'; +import { useDebounceCallback } from '@internal/components'; import { ChevronRightLastIcon, XMarkIcon } from '@navikt/aksel-icons'; import cl from 'clsx/lite'; -import { type HTMLAttributes, useRef } from 'react'; +import { + type HTMLAttributes, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useTranslation } from 'react-i18next'; import { NavLink } from 'react-router'; import classes from './sidebar.module.css'; @@ -11,10 +18,16 @@ export type SidebarProps = { [key: string]: { title: string; url: string; + keywords?: string; }[]; }; title: string; hideCatTitle?: boolean; + /** + * Show a search input above the sidebar navigation that filters items by + * title and keywords. + */ + searchable?: boolean; /** * mapped list of suffixes to it's category key */ @@ -27,12 +40,61 @@ export const Sidebar = ({ cats, title, hideCatTitle = false, + searchable = false, suffix = {}, className, ...props }: SidebarProps) => { const { t } = useTranslation(); const closeMenuRef = useRef(null); + const [query, setQuery] = useState(''); + + const filteredCats = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return cats; + } + + const result: SidebarProps['cats'] = {}; + for (const [key, value] of Object.entries(cats)) { + const items = value.filter((item) => { + const itemTitle = t(`sidebar.items.${item.title}`, item.title); + const haystack = + `${itemTitle} ${item.title} ${item.keywords ?? ''}`.toLowerCase(); + return haystack.includes(normalizedQuery); + }); + if (items.length) { + result[key] = items; + } + } + return result; + }, [cats, query, t]); + + const hasResults = Object.values(filteredCats).some( + (value) => value.length > 0, + ); + + const resultCount = useMemo( + () => + Object.values(filteredCats).reduce((sum, value) => sum + value.length, 0), + [filteredCats], + ); + + const [announce, setAnnounce] = useState(''); + /* delay announce so it is not interrupted while the user is typing */ + const debouncedAnnounce = useDebounceCallback((value: string) => { + setAnnounce(value); + }, 1000); + + useEffect(() => { + if (!searchable || !query.trim()) { + debouncedAnnounce(''); + return; + } + debouncedAnnounce( + `${t('search.srA')} ${resultCount} ${t('search.srB')} ${query}`, + ); + }, [searchable, query, resultCount, t, debouncedAnnounce]); return (
)} + {searchable ? ( + + setQuery(e.target.value)} + /> + setQuery('')} /> + + ) : null} + {searchable ? ( +
+ {announce} +
+ ) : null} + {searchable && !hasResults ? ( + + {t('sidebar.search.noResults', 'No results')} + + ) : null}
    - {Object.entries(cats).map(([key, value]) => { + {Object.entries(filteredCats).map(([key, value]) => { if (!value.length) { return null; } diff --git a/apps/www/app/layouts/components/layout.tsx b/apps/www/app/layouts/components/layout.tsx index 379deded44..b3dc8d87f5 100644 --- a/apps/www/app/layouts/components/layout.tsx +++ b/apps/www/app/layouts/components/layout.tsx @@ -31,6 +31,7 @@ export const loader = async ({ title: string; url: string; order?: number; + keywords?: string; }[]; } = { ' ': [], @@ -84,6 +85,7 @@ export const loader = async ({ file.relativePath.replace('.mdx', ''), url: `/${lang}/components/${folder}/${slug}`, order: parseInt(result.frontmatter.order, 10) || 9999, + keywords: result.frontmatter.search_terms || '', }); } @@ -114,10 +116,18 @@ export const loader = async ({ const parsedMetadata = JSON.parse(metadataJson); const category = parsedMetadata.category || 'components'; + const overviewMdx = getFileFromContentDir( + join('components', folder, lang, 'overview.mdx'), + ); + const keywords = overviewMdx + ? (await generateFromMdx(overviewMdx)).frontmatter.search_terms || '' + : ''; + return { category, title: parsedMetadata[lang].title || folder, url: `/${lang}/components/docs/${folder}`, + keywords, }; }), ); @@ -187,6 +197,7 @@ export default function Layout({ title={'Components'} suffix={sidebarSuffix} hideCatTitle + searchable />
    diff --git a/apps/www/app/locales/en.ts b/apps/www/app/locales/en.ts index 88039f0457..5fb8bbc27e 100644 --- a/apps/www/app/locales/en.ts +++ b/apps/www/app/locales/en.ts @@ -107,6 +107,10 @@ export default { show: 'Show', hide: 'Hide', sidebar: 'sidebar', + search: { + label: 'Search', + noResults: 'No results', + }, }, navigation: { intro: 'Intro', diff --git a/apps/www/app/locales/no.ts b/apps/www/app/locales/no.ts index 104b2744d8..f71683ee96 100644 --- a/apps/www/app/locales/no.ts +++ b/apps/www/app/locales/no.ts @@ -106,6 +106,10 @@ export default { show: 'Vis', hide: 'Skjul', sidebar: 'sidemeny', + search: { + label: 'Søk', + noResults: 'Ingen treff', + }, }, navigation: { intro: 'Intro',