diff --git a/package-lock.json b/package-lock.json index e0784127..2cdf0331 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "classnames": "^2.5.1", + "fuse.js": "^7.4.1", "graphql": "^16.9.0", "highlight.js": "^11.10.0", "lit": "^3.2.1", @@ -10544,6 +10545,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.4.1.tgz", + "integrity": "sha512-AY7lKAXK71hi3WgUvDy6oZL67UEHOOtvCAwVdOXHyJd6ZzftBy7QqxuXt4HxmmAhYjmp/YCuOELZtIvAdlZ+fw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/krisk" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", diff --git a/package.json b/package.json index 9b102643..5e7eb6d9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "classnames": "^2.5.1", + "fuse.js": "^7.4.1", "graphql": "^16.9.0", "highlight.js": "^11.10.0", "lit": "^3.2.1", diff --git a/src/components/content-display/content-display.tsx b/src/components/content-display/content-display.tsx index 5186bfa7..35475c50 100644 --- a/src/components/content-display/content-display.tsx +++ b/src/components/content-display/content-display.tsx @@ -7,6 +7,7 @@ import Button from 'components/custom-components/buttons/button/button'; import { useClipboard } from 'hooks/useClipboard'; import { useContentTabs } from 'hooks/useContentTabs'; import { useCriteriaManager } from 'hooks/useCriteriaManager'; +import { useSearch } from 'hooks/useSearch'; import React, { useEffect, useRef } from 'react'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { Icons } from 'shared/Icons'; @@ -16,7 +17,7 @@ import Cards from '../custom-components/cards/cards'; import { SideNavItem } from '../navigation/nav.types'; import MarkdownContent from './markdown-content/markdown-content'; import { Criteria } from './markdown-content/markdown-content.types'; - +import SearchBar from '../custom-components/search/search'; import Breadcrumb from '../breadcrumb/breadcrumb'; import { useBreadcrumbs } from 'hooks/useBreadcrumbs'; import '../../styles/_code-blocks.scss'; @@ -35,7 +36,7 @@ const ContentDisplay: React.FC = ({ items, onToggleSideNav, }) => { - const [searchParams] = useSearchParams(); + const [pageParams] = useSearchParams(); const navigate = useNavigate(); const location = useLocation(); const tabsRef = useRef(null); @@ -52,10 +53,12 @@ const ContentDisplay: React.FC = ({ const breadcrumbs = useBreadcrumbs(documentation, items); + const { query, setQuery, results } = useSearch(currentItem?.children ?? []); + useEffect(() => { if (!tabs.length) return; - const tabFromURL = searchParams.get('tab'); + const tabFromURL = pageParams.get('tab'); if (tabFromURL) { const tabIndex = parseInt(tabFromURL, 10); if (!isNaN(tabIndex) && tabIndex >= 0 && tabIndex < tabs.length) { @@ -64,10 +67,10 @@ const ContentDisplay: React.FC = ({ } else { setActiveTab(0); } - }, [searchParams, setActiveTab, tabs.length]); + }, [pageParams, setActiveTab, tabs.length]); useEffect(() => { - const tabFromURL = searchParams.get('tab'); + const tabFromURL = pageParams.get('tab'); if (tabFromURL) { const tabIndex = parseInt(tabFromURL, 10); if (!isNaN(tabIndex) && tabIndex >= 0 && tabIndex < tabs.length) { @@ -76,7 +79,7 @@ const ContentDisplay: React.FC = ({ } else { setActiveTab(0); } - }, [searchParams, setActiveTab, tabs.length]); + }, [pageParams, setActiveTab, tabs.length]); // Track active tab changes useEffect(() => { @@ -106,6 +109,8 @@ const ContentDisplay: React.FC = ({ if (!currentItem) return
No content available
; const { label, generalNotes, children } = currentItem; + const cardItems = results ?? children; // content to render in overview cards + const cardsListId = `overviewCard-${label}`; let actionsButtonsVisible = Object.values(Criteria).some( (criteria) => criteria === tabs[activeTab]?.label @@ -135,16 +140,20 @@ const ContentDisplay: React.FC = ({ id="criteria-button"> - {isOverviewRoute && children && children.length > 0 && ( - ({ - title: child.label, - description: child.generalNotes || undefined, - link: `${location.pathname.replace(/\/overview$/, '')}/${ - child.name - }`, - }))} - /> + {isOverviewRoute && cardItems && cardItems.length > 0 && ( + <> + + ({ + title: child.label, + description: child.generalNotes || undefined, + link: `${location.pathname.replace(/\/overview$/, '')}/${ + child.name + }`, + }))} + /> + )} {!isOverviewRoute && tabs.length > 0 && ( diff --git a/src/components/custom-components/cards/cards.tsx b/src/components/custom-components/cards/cards.tsx index 127a937e..e7a31875 100644 --- a/src/components/custom-components/cards/cards.tsx +++ b/src/components/custom-components/cards/cards.tsx @@ -11,12 +11,17 @@ interface Card { interface CardsProps { items: Card[]; + id?: string; } -const Cards: React.FC = ({ items }) => { +const Cards: React.FC = ({ items, id }) => { return ( -
    - {items.map((item) => ( +
      + {items.map((item) => { + if (item.link.includes('no-results')) return ( +

      No results found.

      + ); + return (
    • {item.title}

      @@ -28,6 +33,8 @@ const Cards: React.FC = ({ items }) => {
    • - ))} + ); + })}
    ); }; diff --git a/src/components/custom-components/search/search.scss b/src/components/custom-components/search/search.scss new file mode 100644 index 00000000..ac7d4bcb --- /dev/null +++ b/src/components/custom-components/search/search.scss @@ -0,0 +1,19 @@ +.searchbar { + display: flex; + justify-content: space-between; + align-items: center; + + &__label { + margin-right: 1rem; + font: var(--magentaa11y-typeset-body-xl-strong); + } + + &__input { + height: 50px; + flex-grow: 1; + border-color: var(--color-text-link); + border-radius: 25px; + font: var(--magentaa11y-typeset-body-lg-normal); + padding: 0 1rem; + } +} \ No newline at end of file diff --git a/src/components/custom-components/search/search.tsx b/src/components/custom-components/search/search.tsx new file mode 100644 index 00000000..b435ae66 --- /dev/null +++ b/src/components/custom-components/search/search.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import './search.scss'; + +interface SearchBarProps { + controlsId: string; + resultCount: number; + query: string; + onQueryChange: (q: string) => void; +} + +const SearchBar: React.FC = ({ controlsId, resultCount, query, onQueryChange }) => { + const resultsString = resultCount === 1 ? 'No results found' : `${resultCount} result${resultCount !== 1 ? 's' : ''} found`; + + return ( +
    + + onQueryChange(e.target.value)} // returns new value to setQuery in hook + aria-controls={controlsId} + aria-expanded="false" + aria-haspopup="false" + /> + + {resultsString} + +
    + ); +}; + +export default SearchBar; diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts new file mode 100644 index 00000000..6f2ed4fa --- /dev/null +++ b/src/hooks/useSearch.ts @@ -0,0 +1,31 @@ +import { useMemo, useState } from 'react'; +import Fuse from 'fuse.js'; + +interface ContentItem { + label: string; + name: string; + generalNotes?: string | null; + developerNotes?: string | null; +} + +export const useSearch = (items: ContentItem[]) => { + const [query, setQuery] = useState(''); // start with query being '' + + const fuse = useMemo( + () => + new Fuse(items, { + keys: ['label', 'developerNotes', 'generalNotes'], + threshold: 0.3, + // useTokenSearch: true, // can add in fuzzier search .. or perhaps toggle this at some point for pro folks who want to search "card" or "aria-busy" etc + includeScore: true, + }), + [items] + ); + + const matches = query ? fuse.search(query).map((r) => r.item) : null; + const results = matches && matches.length === 0 + ? [{ label: 'No results found', name: 'no-results', generalNotes: "No criteria matches this search."}] + : matches; + + return { query, setQuery, results }; +};