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}