|
//this was build for Next.js - can be adapted to React & other routing |
|
|
|
import React, { useCallback, useEffect, useState } from 'react' |
|
|
|
import { |
|
Icon, |
|
IconButton, |
|
ScrollArea, |
|
ModalDialog, |
|
ModalDialogContent, |
|
ModalDialogTrigger, |
|
ModalDialogTitle, |
|
ModalDialogDescription, |
|
} from '@ui' //UI lib - ScrollArea & Modal are build on Radix Primitives |
|
|
|
import { usePathname, useRouter } from 'next/navigation' //next.js navigation |
|
|
|
import { Route, type NavigationItem } from '../component-library/_data/navigation' //local navigation types |
|
|
|
import { useFocusIndex } from './hooks/useFocusIndex' //hook for arrow key navigation |
|
|
|
import { cn } from '~/utils/cn' //classNames helper lib |
|
|
|
//the modal state is shared across sidenav & topnav to support desktop/mobile view changes |
|
import { useSearchModalContext } from '../component-library/searchModalContext' |
|
|
|
// Inspiration: |
|
// modal and list UI : shadcn's Command component (uses the cmdk lib) |
|
// recent/saved items functionality : Algolia search |
|
|
|
// -- Features -- |
|
// list items under their category (given in the componentsNavigation array) |
|
// filter by component name |
|
// click on an item navigates to that page |
|
// arrow (up/down) keyboard navigation within list of items |
|
// items visited through this component are saved in 'recent' list (localStorage) |
|
// can save item from recent to favorites (localStorage) |
|
|
|
// ------------------------------------- SearchWidget |
|
function filterItemsNotInLibrary(items: ItemType[], library: string): ItemType[] { |
|
return items.filter((item) => item.path.includes(library)) |
|
} |
|
|
|
const SearchWidget = ({ searchItems }: { searchItems: NavigationItem[] }) => { |
|
//modal state |
|
const { showSearchModal, setShowSearchModal } = useSearchModalContext() |
|
|
|
//key listener for opening Modal with cmd/ctrl+k or / |
|
const handleKeyPress = useCallback( |
|
(e: KeyboardEvent) => { |
|
if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || e.key === '/') { |
|
e.preventDefault() |
|
setShowSearchModal(true) |
|
} |
|
}, |
|
[setShowSearchModal] |
|
) |
|
useEffect(() => { |
|
document.addEventListener('keydown', handleKeyPress) |
|
return () => { |
|
document.removeEventListener('keydown', handleKeyPress) |
|
} |
|
}, [handleKeyPress]) |
|
|
|
//filter input value |
|
const [filterValue, setFilterValue] = useState<string>('') |
|
|
|
//map items for display |
|
const filteredComponents = searchItems |
|
.map((section: NavigationItem) => ({ |
|
...section, |
|
children: section.children.filter((child) => |
|
child.title.toLowerCase().includes(filterValue?.toLowerCase()) |
|
), |
|
})) |
|
.filter((filteredItem) => filteredItem.children.length > 0) |
|
|
|
//arrow key navigation |
|
const [focus, setFocus] = useFocusIndex(0) |
|
let linearIndex = 0 |
|
|
|
//routing |
|
const router = useRouter() |
|
const handleItemClick = async (path: string) => { |
|
await router.push(`${path}`) |
|
setShowSearchModal(false) |
|
} |
|
|
|
const path = usePathname() |
|
const isDocumentation = path.startsWith('/web/documentation') |
|
const isComponents = path.startsWith('/web/component-library') |
|
const isPatterns = path.startsWith('/web/pattern-library') |
|
const isPrototypes = path.startsWith('/web/prototypes') |
|
|
|
//favorites & recent (localStorage) |
|
const [favorites, setFavorites] = useState<ItemType[]>([]) |
|
const [recent, setRecent] = useState<ItemType[]>([]) |
|
|
|
//runs once on mounted & is called by the Items on item action |
|
const updateLocalStorage = useCallback(() => { |
|
const savedSearches = JSON.parse(localStorage.getItem('savedSearches') || '[]') |
|
const filteredSavedSearches = filterItemsNotInLibrary( |
|
savedSearches, |
|
isDocumentation |
|
? '/documentation' |
|
: isPatterns |
|
? '/pattern-library' |
|
: isComponents |
|
? '/component-library' |
|
: isPrototypes |
|
? '/prototypes' |
|
: '' |
|
) |
|
setFavorites(filteredSavedSearches) |
|
|
|
const recentSearches = JSON.parse(localStorage.getItem('recentSearches') || '[]') |
|
const filteredRecentSearches = filterItemsNotInLibrary( |
|
recentSearches, |
|
isDocumentation |
|
? '/documentation' |
|
: isPatterns |
|
? '/pattern-library' |
|
: isComponents |
|
? '/component-library' |
|
: isPrototypes |
|
? '/prototypes' |
|
: '' |
|
) |
|
setRecent(filteredRecentSearches) |
|
}, [isDocumentation, isPatterns, isComponents, isPrototypes, setFavorites, setRecent]) |
|
|
|
useEffect(() => { |
|
updateLocalStorage() |
|
}, [updateLocalStorage]) |
|
|
|
//cleanup filter & focus values when modal closes |
|
useEffect(() => { |
|
if (!showSearchModal) { |
|
setFilterValue('') |
|
setFocus(0) |
|
} |
|
}, [showSearchModal, setFocus]) |
|
|
|
//no results |
|
const noResults = filterValue && !filteredComponents.length |
|
|
|
const modalTitle = isDocumentation |
|
? 'search documentation' |
|
: isComponents |
|
? 'search components' |
|
: isPatterns |
|
? 'search patterns' |
|
: isPrototypes |
|
? 'search prototypes' |
|
: 'search library' |
|
|
|
//TODO fix throws a warning on open about description or describedby |
|
|
|
return ( |
|
<ModalDialog open={showSearchModal} onOpenChange={setShowSearchModal}> |
|
<ModalDialogTrigger asChild> |
|
<SearchWidgetTrigger /> |
|
</ModalDialogTrigger> |
|
<ModalDialogContent |
|
showCloseButton={false} |
|
className="p-0 max-w-[400px] max-h-[50vh] gap-0 overflow-hidden" |
|
> |
|
{/* title & description are sr-only (required by the component for ally) */} |
|
<ModalDialogTitle className="sr-only">{`${modalTitle} dialog`}</ModalDialogTitle> |
|
<ModalDialogDescription className="sr-only">{`start typing to ${modalTitle}`}</ModalDialogDescription> |
|
<div className="border-b flex items-center px-3 py-1"> |
|
<Icon name="Search" size="md" className="text-gray-400" aria-label="search" /> |
|
<label htmlFor="search-components" className="sr-only"> |
|
{modalTitle} |
|
</label> |
|
<input |
|
id="search-components" |
|
placeholder={ |
|
isDocumentation |
|
? 'search documentation' |
|
: isComponents |
|
? 'search components' |
|
: isPatterns |
|
? 'search patterns' |
|
: isPrototypes |
|
? 'search prototypes' |
|
: 'search library' |
|
} |
|
autoComplete="off" |
|
autoCorrect="off" |
|
autoFocus={true} |
|
className="w-full mr-8 outline-none border-0 focus:outline-none focus:ring-0 placeholder:text-gray-400 placeholder:font-light placeholder:italic" |
|
onChange={(e) => { |
|
setFilterValue(e.target.value.trim()) //trim spaces for better results |
|
setFocus(0) //if tabbing up from list, focus gets stuck |
|
}} |
|
/> |
|
<button |
|
aria-label="close dialog" |
|
title="Close dialog" |
|
onClick={() => setShowSearchModal(false)} |
|
className="text-gray-500 hover:text-gray-600 rounded-md hover:bg-gray-200 p-0.5 focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 outline-none transition-colors duration-100" |
|
> |
|
<Icon |
|
name="X" |
|
size="sm" |
|
className="text-gray-500 hover:text-gray-600 transition-colors duration-100" |
|
/> |
|
</button> |
|
</div> |
|
|
|
<ScrollArea className="h-[42vh] p-4"> |
|
<ul aria-label="items list" className="flex flex-col gap-3"> |
|
{noResults && ( |
|
<div className="text-center pt-2 pb-5 italic text-gray-500 border-b"> |
|
No results found. |
|
</div> |
|
)} |
|
{(!filterValue || noResults) && !!recent.length && ( |
|
<ListSection title="Recent"> |
|
{recent.map((item) => ( |
|
<ListItem |
|
isRecent |
|
key={item.title} |
|
item={item} |
|
index={linearIndex++} |
|
focus={focus === linearIndex} |
|
setFocus={setFocus} |
|
handleItemClick={handleItemClick} |
|
onItemAction={updateLocalStorage} |
|
/> |
|
))} |
|
</ListSection> |
|
)} |
|
{(!filterValue || noResults) && !!favorites.length && ( |
|
<ListSection title="Favorites"> |
|
{favorites.map((item) => ( |
|
<ListItem |
|
isFavorite |
|
key={item.title} |
|
item={item} |
|
index={linearIndex++} |
|
focus={focus === linearIndex} |
|
setFocus={setFocus} |
|
handleItemClick={handleItemClick} |
|
onItemAction={updateLocalStorage} |
|
/> |
|
))} |
|
</ListSection> |
|
)} |
|
{(!!filterValue || (!favorites.length && !recent.length)) && |
|
filteredComponents.map((section) => ( |
|
<ListSection key={section.title} title={section.title}> |
|
{section.children.map((child) => ( |
|
<ListItem |
|
handleItemClick={handleItemClick} |
|
key={child.title} |
|
item={child as Route} |
|
index={linearIndex++} |
|
focus={focus === linearIndex} |
|
setFocus={setFocus} |
|
onItemAction={updateLocalStorage} |
|
searchValue={filterValue} |
|
/> |
|
))} |
|
</ListSection> |
|
))} |
|
</ul> |
|
</ScrollArea> |
|
</ModalDialogContent> |
|
</ModalDialog> |
|
) |
|
} |
|
|
|
// ------------------------------------- ListSection |
|
|
|
type ListSectionProps = { |
|
title: string |
|
children: React.ReactNode |
|
} |
|
|
|
const ListSection = ({ title, children }: ListSectionProps) => { |
|
return ( |
|
<li aria-label={title} className="flex flex-col gap-1"> |
|
<div className="pl-1 text-sm text-gray-500 font-medium tracking-wider uppercase">{title}</div> |
|
<ul>{children}</ul> |
|
</li> |
|
) |
|
} |
|
|
|
// ------------------------------------- ListItem |
|
|
|
type ItemType = { title: string; path: string } |
|
|
|
type ListItemProps = { |
|
item: ItemType |
|
index: number |
|
focus: boolean |
|
isFavorite?: boolean |
|
isRecent?: boolean |
|
searchValue?: string |
|
handleItemClick: (path: string) => void |
|
setFocus: (index: number) => void |
|
onItemAction: () => void |
|
} |
|
const ListItem = ({ |
|
item, |
|
index, |
|
focus, |
|
isFavorite, |
|
isRecent, |
|
searchValue, |
|
handleItemClick, |
|
setFocus, |
|
onItemAction, |
|
}: ListItemProps) => { |
|
const focusRef = React.useRef<HTMLButtonElement | null>(null) |
|
|
|
useEffect(() => { |
|
if (focus && focusRef?.current) { |
|
focusRef?.current.focus() |
|
} |
|
}, [focus]) |
|
|
|
const handleKeyDown = () => { |
|
setFocus(index + 1) |
|
} |
|
|
|
//handle favorites & recent |
|
const listAction = ( |
|
action: 'add' | 'remove', |
|
listName: 'recentSearches' | 'savedSearches', |
|
list: [] |
|
) => { |
|
if (action === 'add') { |
|
list.unshift(item as never) //unshift adds item to the top of array |
|
} else if (action === 'remove') { |
|
const indexToRemove = list.findIndex((i: ItemType) => i.title === item.title) |
|
list.splice(indexToRemove, 1) |
|
} |
|
localStorage.setItem(`${listName}`, JSON.stringify(list)) |
|
} |
|
|
|
const onActionButton = (type: 'saveFav' | 'saveRecent' | 'removeFav' | 'removeRecent') => { |
|
//stored arrays |
|
const savedSearches = JSON.parse(localStorage.getItem('savedSearches') || '[]') |
|
const recentSearches = JSON.parse(localStorage.getItem('recentSearches') || '[]') |
|
//check if item exists |
|
const itemExistsInSaved = savedSearches.some((i: ItemType) => i.title === item.title) |
|
const itemExistsInRecent = recentSearches.some((i: ItemType) => i.title === item.title) |
|
//favorites |
|
if (type === 'saveFav' && !itemExistsInSaved) { |
|
listAction('add', 'savedSearches', savedSearches) |
|
//also remove it from recent if adding to favs |
|
listAction('remove', 'recentSearches', recentSearches) |
|
} |
|
if (type === 'removeFav' && itemExistsInSaved) { |
|
listAction('remove', 'savedSearches', savedSearches) |
|
} |
|
//recent - add to recent only if it doesn't exist in either list |
|
if (type === 'saveRecent' && !itemExistsInRecent && !itemExistsInSaved) { |
|
listAction('add', 'recentSearches', recentSearches) |
|
} |
|
if (type === 'removeRecent' && itemExistsInRecent) { |
|
listAction('remove', 'recentSearches', recentSearches) |
|
} |
|
//update local state in parent so the list UI updates |
|
onItemAction() |
|
} |
|
|
|
//match title to searchVal, so the partial match is styled in the item title |
|
const parts = item.title.split(new RegExp(`(${searchValue})`, 'gi')) |
|
|
|
return ( |
|
<li |
|
className={cn( |
|
'flex items-center justify-between text-base w-full p-1 rounded', |
|
'hover:bg-gray-100 [&:has(:focus-visible)]:bg-gray-100 transition-colors duration-100' |
|
)} |
|
> |
|
<button |
|
ref={focusRef} |
|
className={cn( |
|
'flex items-center gap-2 w-full rounded-sm', |
|
'focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2', |
|
'outline-none transition-colors duration-100', |
|
(isFavorite || isRecent) && 'mr-2' |
|
)} |
|
onKeyDown={handleKeyDown} |
|
onClick={() => { |
|
handleItemClick(item.path) |
|
onActionButton('saveRecent') |
|
}} |
|
> |
|
<span className="text-gray-500"> |
|
{!isRecent && !isFavorite && <DocIcon aria-hidden="true" />} |
|
{isFavorite && <Icon name="Star" size="sm" />} |
|
{isRecent && <RecentIcon aria-hidden="true" />} |
|
</span> |
|
<div className="text-gray-700 font-light"> |
|
{!searchValue && <span>{item.title}</span>} |
|
{searchValue && |
|
parts.map((part, index) => ( |
|
<span |
|
key={index} |
|
className={cn( |
|
part.toLowerCase() === searchValue.toLowerCase() && 'text-primary-500 font-medium' |
|
)} |
|
> |
|
{part} |
|
</span> |
|
))} |
|
</div> |
|
</button> |
|
<div className="flex items-center gap-2"> |
|
{isRecent && ( |
|
<button |
|
title="Save this item to favorites" |
|
aria-label={`Save item ${item.title} to favorites`} |
|
onClick={() => onActionButton('saveFav')} |
|
className={cn( |
|
'p-0.5 rounded', |
|
'focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2', |
|
'outline-none hover:bg-gray-200 transition-colors duration-100' |
|
)} |
|
> |
|
<Icon |
|
name="Star" |
|
size="sm" |
|
className="text-gray-500 hover:text-gray-600 hover:fill-gray-600 transition-colors duration-200" |
|
/> |
|
</button> |
|
)} |
|
{(isFavorite || isRecent) && ( |
|
<button |
|
title="Remove this item from favorites" |
|
aria-label={`Remove item ${item.title} from favorites`} |
|
onClick={() => { |
|
isFavorite && onActionButton('removeFav') |
|
isRecent && onActionButton('removeRecent') |
|
}} |
|
className={cn( |
|
'p-0.5 rounded', |
|
'focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2', |
|
'outline-none hover:bg-gray-200 transition-colors duration-100' |
|
)} |
|
> |
|
<Icon |
|
name="X" |
|
size="sm" |
|
className="text-gray-500 hover:text-gray-600 transition-colors duration-200" |
|
/> |
|
</button> |
|
)} |
|
</div> |
|
</li> |
|
) |
|
} |
|
|
|
// ------------------------------------- Doc & Recent icons used in ListItem |
|
|
|
const DocIcon = (className: { className?: string }) => { |
|
return ( |
|
<svg |
|
width="15" |
|
height="15" |
|
viewBox="0 0 15 15" |
|
xmlns="http://www.w3.org/2000/svg" |
|
className={cn('size-4', className)} |
|
> |
|
<path |
|
d="M3.5 2C3.22386 2 3 2.22386 3 2.5V12.5C3 12.7761 3.22386 13 3.5 13H11.5C11.7761 13 12 12.7761 12 12.5V6H8.5C8.22386 6 8 5.77614 8 5.5V2H3.5ZM9 2.70711L11.2929 5H9V2.70711ZM2 2.5C2 1.67157 2.67157 1 3.5 1H8.5C8.63261 1 8.75979 1.05268 8.85355 1.14645L12.8536 5.14645C12.9473 5.24021 13 5.36739 13 5.5V12.5C13 13.3284 12.3284 14 11.5 14H3.5C2.67157 14 2 13.3284 2 12.5V2.5Z" |
|
fill="currentColor" |
|
></path> |
|
</svg> |
|
) |
|
} |
|
|
|
const RecentIcon = (className: { className?: string }) => { |
|
return ( |
|
<svg width="20" height="20" viewBox="0 0 20 20" className={cn('size-4', className)}> |
|
<g stroke="currentColor" fill="none" strokeWidth={1.5}> |
|
<path d="M3.18 6.6a8.23 8.23 0 1112.93 9.94h0a8.23 8.23 0 01-11.63 0"></path> |
|
<path d="M6.44 7.25H2.55V3.36M10.45 6v5.6M10.45 11.6L13 13"></path> |
|
</g> |
|
</svg> |
|
) |
|
} |
|
|
|
// ------------------------------------- SearchWidgetTrigger |
|
|
|
type TriggerProps = { |
|
onClick?: () => void |
|
className?: string |
|
} |
|
|
|
const SearchWidgetTrigger = React.forwardRef<HTMLButtonElement, TriggerProps>( |
|
({ className, onClick, ...props }, ref) => { |
|
return ( |
|
<button |
|
{...props} |
|
ref={ref} |
|
className={cn( |
|
'border border-gray-500 rounded-md p-2 flex items-center hover:bg-gray-100 transition-colors duration-200', |
|
'w-full justify-between gap-1 px-2 tracking-wider text-sm text-gray-500 group', |
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-blue-800 focus-visible:ring-offset-2', |
|
className |
|
)} |
|
onClick={onClick} |
|
> |
|
<div className="flex items-center gap-2"> |
|
<Icon name="Search" size="md" /> <span>Search...</span> |
|
</div> |
|
<kbd className="py-0.5 px-1 rounded-sm bg-gray-100 group-hover:text-gray-500 delay-75 duration-100"> |
|
<span className="text-base mr-0.5">⌘</span>K |
|
</kbd> |
|
</button> |
|
) |
|
} |
|
) |
|
SearchWidgetTrigger.displayName = 'SearchWidgetTrigger' |
|
|
|
// ------------------------------------- SearchWidgetTopNavTrigger that shows the modal from a different location (web top nav) |
|
|
|
const SearchWidgetTopNavTrigger = () => { |
|
const { showSearchModal, setShowSearchModal } = useSearchModalContext() |
|
|
|
return ( |
|
<> |
|
<SearchWidgetTrigger |
|
className="p-[5px] hidden md:flex lg:hidden" |
|
onClick={() => setShowSearchModal(!showSearchModal)} |
|
/> |
|
<IconButton |
|
variant="secondary" |
|
size="sm" |
|
iconName="Search" |
|
ariaLabel="search" |
|
className="md:hidden" |
|
title="search" |
|
onClick={() => setShowSearchModal(!showSearchModal)} |
|
/> |
|
</> |
|
) |
|
} |
|
// ------------------------------------- exports |
|
|
|
export { SearchWidget, SearchWidgetTopNavTrigger } |