wrolpi/app/src/components/ui/SearchBox.tsx
Roland 18dc90b4c2 ui: nine leaf files off Semantic, and the components they needed
Phase 2, part 2.  Baseline 72 -> 63.

Migrated: Tables, SortableTable, DonatePage, Share, Headline, ErrorBoundary,
and the QR code, VIN decoder, and temperature calculators.

New in the library, all in the gallery and tested:

  SearchBox     Semantic's `<Search category>`, keeping its grouped `results`
                shape because four call sites already build it.  A real
                combobox: arrows move, Enter takes the highlighted suggestion
                or submits the raw text, Escape closes without clearing.
  Pagination    Semantic's prop names, but onPageChange gets the page number
                rather than an (event, data) pair.
  TabBar        the strip and its house style only; routing stays at the call
                site, which is what decides what is active.
  IconStack     two glyphs composed into one symbol, named once for assistive
                technology (Semantic's IconGroup).
  Table.HeaderCell gains `sorted` and `onSort`.

That last one is a fix, not just a port.  Semantic's `sorted` prop set
`aria-sort`; migrating a table by hand dropped it, leaving a clickable <th>
that no keyboard could reach and no screen reader could describe.  Putting it
in the library means the next table gets it right without anyone remembering.

Night mode holes closed along the way: the QR codes on the donate, share, and
calculator pages are white by necessity — a scanner needs the contrast — and
they render as SVG, which the media filter does not target.  In night mode that
was a lit panel.  They now carry `.media`, so the filter remaps the block as a
unit and the contrast survives the mapping.

Also: `paypal` joins the icon map rather than being imported ad hoc in a page,
and FileBrowser stops passing Semantic-only table props into a DOM element.
2026-07-30 11:55:39 -06:00

230 lines
8.8 KiB
TypeScript

import React, {useEffect, useId, useRef, useState} from 'react';
import {Icon} from './Icon';
import {IconButton} from './Button';
import {Loader} from './Feedback';
/*
* The search box, with optional grouped suggestions.
*
* Replaces Semantic's `<Search category>`, and deliberately keeps its `results`
* shape — `{groupKey: {name, results: [{title, description}]}}` — because four
* call sites already build that structure from the API.
*
* Suggestions are a combobox: the input keeps focus and owns the keyboard, while
* `aria-activedescendant` points at the highlighted option. Arrow keys move,
* Enter takes the highlighted suggestion or submits what was typed, Escape
* closes the list without clearing the text.
*/
export interface SearchResult {
/** The value put into the input when chosen. Also the visible first line. */
title: string;
/** Second line, for context: a channel name, a domain. */
description?: string;
[key: string]: any;
}
export interface SearchResultGroup {
/** Group heading. */
name: string;
results: SearchResult[];
}
export type SearchResults = Record<string, SearchResultGroup>;
export interface SearchBoxProps {
value?: string;
onChange?: (value: string) => void;
/** Enter, or the search button. Receives the current text. */
onSubmit?: (value: string) => void;
/** Called with the chosen suggestion. */
onResultSelect?: (result: SearchResult) => void;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
results?: SearchResults;
/** Suggestions are being fetched; shows a spinner rather than "no results". */
loading?: boolean;
placeholder?: string;
/** Show a button that empties the input. */
clearable?: boolean;
/** Force the clear button's disabled state; defaults to "nothing typed". */
clearDisabled?: boolean | null;
onClear?: () => void;
disabled?: boolean;
required?: boolean;
inputRef?: React.Ref<HTMLInputElement>;
/** Accessible name. Falls back to the placeholder. */
label?: string;
className?: string;
autoFocus?: boolean;
name?: string;
}
/** Flatten the groups into the order they are displayed, for keyboard movement. */
const flatten = (results?: SearchResults): SearchResult[] =>
Object.values(results ?? {}).flatMap(group => group?.results ?? []);
export function SearchBox({
value,
onChange,
onSubmit,
onResultSelect,
onBlur,
results,
loading,
placeholder = 'Search...',
clearable,
clearDisabled = null,
onClear,
disabled,
required,
inputRef,
label,
className,
autoFocus,
name,
}: SearchBoxProps) {
const [open, setOpen] = useState(false);
const [highlighted, setHighlighted] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const listId = useId();
const text = value ?? '';
const flat = flatten(results);
const hasSuggestions = onResultSelect !== undefined || !!results;
// Close when focus or a click leaves the box entirely. A blur handler alone would
// fire before a click on a suggestion registers.
useEffect(() => {
if (!open) return;
const onDocumentClick = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onDocumentClick);
return () => document.removeEventListener('mousedown', onDocumentClick);
}, [open]);
const choose = (result: SearchResult) => {
setOpen(false);
setHighlighted(-1);
onResultSelect?.(result);
}
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setHighlighted(-1);
if (hasSuggestions) setOpen(true);
onChange?.(event.currentTarget.value);
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
if (!flat.length) return;
event.preventDefault();
setOpen(true);
const step = event.key === 'ArrowDown' ? 1 : -1;
setHighlighted(current => {
// Wrap, so holding a key cannot strand the user at either end.
const next = current + step;
if (next < 0) return flat.length - 1;
if (next >= flat.length) return 0;
return next;
});
} else if (event.key === 'Enter') {
event.preventDefault();
if (open && highlighted >= 0 && flat[highlighted]) {
choose(flat[highlighted]);
} else {
setOpen(false);
onSubmit?.(text);
}
} else if (event.key === 'Escape') {
// Closes the list; the typed text is left alone.
setOpen(false);
setHighlighted(-1);
}
}
const handleClear = () => {
onChange?.('');
onSubmit?.('');
onClear?.();
setOpen(false);
}
let index = -1;
return <div
ref={containerRef}
className={['wrolpi-searchbox', className].filter(Boolean).join(' ')}
>
<div className='wrolpi-searchbox-control'>
<span className='wrolpi-searchbox-icon'><Icon name='search'/></span>
<input
ref={inputRef}
type='text'
name={name}
className='wrolpi-searchbox-input'
placeholder={placeholder}
aria-label={label ?? placeholder}
value={text}
disabled={disabled}
required={required}
autoFocus={autoFocus}
onChange={handleChange}
onKeyDown={handleKeyDown}
// Open on focus when there is something to say — suggestions, or the fact
// that they are still being fetched. Not when the list would just read
// "No results" at a caller that has not searched yet.
onFocus={() => hasSuggestions && (flat.length > 0 || loading) && setOpen(true)}
onBlur={onBlur}
role={hasSuggestions ? 'combobox' : undefined}
aria-expanded={hasSuggestions ? open : undefined}
aria-controls={hasSuggestions ? listId : undefined}
aria-autocomplete={hasSuggestions ? 'list' : undefined}
aria-activedescendant={open && highlighted >= 0 ? `${listId}-${highlighted}` : undefined}
/>
{loading && <span className='wrolpi-searchbox-loading'><Loader size='xs'/></span>}
{clearable && <IconButton
icon='close'
label='Clear search'
className='wrolpi-searchbox-clear'
onClick={handleClear}
disabled={clearDisabled !== null ? clearDisabled : !text}
/>}
</div>
{open && hasSuggestions && <div className='wrolpi-searchbox-results' id={listId} role='listbox'>
{loading && !flat.length && <div className='wrolpi-searchbox-empty'>Searching…</div>}
{!loading && !flat.length && <div className='wrolpi-searchbox-empty'>No results</div>}
{Object.entries(results ?? {}).map(([key, group]) => {
if (!group?.results?.length) return null;
return <div key={key} className='wrolpi-searchbox-group'>
<div className='wrolpi-searchbox-group-name'>{group.name}</div>
{group.results.map((result, resultIndex) => {
index += 1;
const optionIndex = index;
return <div
key={`${key}-${resultIndex}`}
id={`${listId}-${optionIndex}`}
role='option'
aria-selected={optionIndex === highlighted}
className={`wrolpi-searchbox-result${
optionIndex === highlighted ? ' wrolpi-searchbox-result-active' : ''}`}
// mousedown, not click: the input must not blur first.
onMouseDown={event => {
event.preventDefault();
choose(result);
}}
onMouseEnter={() => setHighlighted(optionIndex)}
>
<div className='wrolpi-searchbox-result-title'>{result.title}</div>
{result.description && <div className='wrolpi-searchbox-result-description'>
{result.description}
</div>}
</div>
})}
</div>
})}
</div>}
</div>
}