Skip to content

Instantly share code, notes, and snippets.

@Mrtly
Last active February 7, 2025 13:59
Show Gist options
  • Save Mrtly/d0f977a450108b56c8a2f110d30c04a0 to your computer and use it in GitHub Desktop.
Save Mrtly/d0f977a450108b56c8a2f110d30c04a0 to your computer and use it in GitHub Desktop.
a Search Widget/Modal solution inspired by algolia's widget, with improved HTML & accessibility

It is a violation of the HTML5 specification to include a button tag inside an anchor tag or vice-versa.

Screenshot 2025-01-21 at 12 04 02 PM

The a element can be wrapped around entire paragraphs, lists, tables, and so forth, even entire sections, so long as there is no interactive content within (e.g., buttons or other links).

Read more...


This is a SearchModal component that looks like algolia's but each anchor and button tag are separate within the list item of the results. Much better accessibility!

Screenshot 2025-01-21 at 12 14 09 PM
//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 }
const useFocusIndex = (size: number) => {
const [currentFocus, setCurrentFocus] = useState(0)
useKeyPressEvent('ArrowDown', () =>
setCurrentFocus(currentFocus === size - 1 ? 0 : currentFocus + 1)
)
useKeyPressEvent('ArrowUp', () =>
setCurrentFocus(currentFocus === 0 ? size - 1 : currentFocus - 1)
)
return useMemo(() => [currentFocus, setCurrentFocus] as const, [currentFocus])
}
export { useFocusIndex }
@Mrtly
Copy link
Author

Mrtly commented Jan 21, 2025

Screenshot 2025-01-21 at 12 04 02 PM

@Mrtly
Copy link
Author

Mrtly commented Jan 21, 2025

Screenshot 2025-01-21 at 12 14 09 PM

@Mrtly
Copy link
Author

Mrtly commented Jan 21, 2025

HTML Spec https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-a-element

The a element can be wrapped around entire paragraphs, lists, tables, and so forth, even entire sections, so long as there is no interactive content within (e.g., buttons or other links).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment