Files
technitium-console/src/ui/Select.tsx
T
DenozordecandClaude Sonnet 5.5 f3531c64f9 Move the console onto ReUI PRO
Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move
the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel,
Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame
(DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale.

Design only: behaviour, texts and roles are unchanged. Tests that asserted the old
DOM were updated; the tests now format numbers in English wherever they run.
docs/reui-migration-plan.md records the plan and its decisions.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 00:06:06 +07:00

468 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Children,
isValidElement,
useEffect,
useId,
useRef,
useState,
type ReactNode,
type Ref,
} from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Icon } from './Icon'
import styles from './Select.module.css'
/*
The dropdown, built here.
It used to be the operating system's `<select>` with a chevron painted on top:
the closed box almost passed, but on opening it the system list appeared (the
OS's font, size, colours and corners) and the console changed appearance
depending on the machine. And that is why it "looked very much the same": half
the control was not ours.
It is rebuilt as a listbox following the ARIA specification's rules:
· the trigger is a `combobox`, with `aria-expanded` and `aria-activedescendant`
· the list is a `listbox` and each line an `option` with `aria-selected`
· full keyboard: ↑ ↓ Home End, Enter and Space open and choose, Esc closes and
returns focus, and typing jumps to the option starting with it
· focus does not move into the list, so the screen reader keeps announcing the
field and its label
The list is `position: fixed` computed from the trigger, not absolute: otherwise,
inside a modal (which has `overflow: auto`) it would be clipped. For the same
reason it closes on scroll, which is what the native `select` does.
`searchable` is the console's own, and opt-in: only Blocking › Lists turns it on,
for a Quick Add catalogue too long to scroll through. It puts a search field at
the top of the open list. The field takes the focus and is the combobox while the
list is open (`aria-autocomplete="list"`); typing filters the options by their
label, or by the `data-search` text an `<option>` may carry, case-insensitively;
↑ ↓ move through what is left, Enter chooses, Esc closes and returns focus to
the trigger, and Home, End and Space stay with the text, as in any text field.
No match leaves a `noMatchText` line, announced as a status. The list keeps the
width it opened with (and, opened upwards, its height), so filtering does not
move the field. Without `searchable` nothing here changes: same DOM, same keys,
same look.
`<optgroup>` children become a `group` labelled by their `label`, a line that
cannot be chosen, as in the native element. Nothing used them before the Quick
Add of Blocking › Lists, so no other dropdown changes.
*/
export interface Option {
value: string
label: string
disabled?: boolean
/** The `<optgroup>` it sits in, by its label. */
group?: string
/** What a search matches besides the label: the option's `data-search`. */
search?: string
}
/*
The options are read from the `<option>` children, as in the element it replaces.
It could have taken an array and forced a rewrite of the twenty-five places that
use it, but then the change would stop being design-only: each of those places is
a list with its own logic (TSIG keys, catalogs, record types, DNSSEC algorithms),
and rewriting it is an opportunity to break it.
*/
function optionsFromChildren(children: ReactNode, group?: string): Option[] {
const outside: Option[] = []
for (const child of Children.toArray(children)) {
if (!isValidElement(child)) continue
if (child.type === 'optgroup') {
const g = child.props as { label?: string; children?: ReactNode }
outside.push(...optionsFromChildren(g.children, g.label ?? ''))
continue
}
if (child.type !== 'option') continue
const p = child.props as {
value?: string | number; children?: ReactNode; disabled?: boolean; 'data-search'?: string
}
const text = typeof p.children === 'string' || typeof p.children === 'number' ? String(p.children) : ''
const o: Option = {
value: p.value != null ? String(p.value) : text,
label: text,
disabled: p.disabled,
}
if (group != null) o.group = group
if (p['data-search'] != null) o.search = p['data-search']
outside.push(o)
}
return outside
}
/** The indices of the options a search shows, in order: all of them for an empty one. */
function visibleFor(options: Option[], query: string): number[] {
const needle = query.trim().toLowerCase()
if (needle === '') return options.map((_, i) => i)
const out: number[] = []
options.forEach((o, i) => {
if (o.label.toLowerCase().includes(needle) || (o.search ?? '').toLowerCase().includes(needle)) out.push(i)
})
return out
}
export function Select({
id,
value,
children,
onChange,
disabled = false,
placeholder,
className,
ref,
'aria-label': ariaLabel,
searchable = false,
searchLabel = 'Search',
noMatchText = 'No matches',
}: {
id?: string
value: string | number
children?: ReactNode
/** The same signature as the native element, so existing callers are untouched. */
onChange?: (e: { target: { value: string } }) => void
disabled?: boolean
/** What to show when the value is not among the options. */
placeholder?: string
className?: string
ref?: Ref<HTMLButtonElement>
'aria-label'?: string
/** A search field on top of the open list (see the header). Off by default. */
searchable?: boolean
/** The search field's accessible name and placeholder. */
searchLabel?: string
/** The line shown when the search matches nothing. */
noMatchText?: string
}) {
const options = optionsFromChildren(children)
const [open, setOpen] = useState(false)
const [active, setActive] = useState(0)
const [query, setQuery] = useState('')
const trigger = useRef<HTMLButtonElement>(null)
/** The floating box: the listbox itself, or the box holding search and listbox. */
const list = useRef<HTMLDivElement>(null)
const search = useRef<HTMLInputElement>(null)
const keys2 = useRef({ text: '', until: 0 })
const listId = useId()
const visible = searchable ? visibleFor(options, query) : options.map((_, i) => i)
const selectedIndex = options.findIndex((o) => o.value === String(value))
const chosen = selectedIndex >= 0 ? options[selectedIndex] : undefined
const activeShown = open && visible.includes(active)
function openList() {
if (disabled) return
setQuery('')
const all = options.map((_, i) => i)
setActive(selectedIndex >= 0 ? selectedIndex : firstUsable(options, all, 0, 1))
setOpen(true)
}
/* The focus goes back to the trigger once the list has gone (see the effect): the
element that held it is removed, and the browser would leave it on the body. */
const giveBack = useRef(false)
function close(returnFocus = true) {
giveBack.current = returnFocus
setOpen(false)
}
useEffect(() => {
if (!open && giveBack.current) {
giveBack.current = false
trigger.current?.focus()
}
}, [open])
function choose(i: number) {
const o = options[i]
if (o == null || o.disabled || !visible.includes(i)) return
/* As the native element: `change` only when the value changes. Picking the
option already chosen just closes the list. */
if (o.value !== String(value)) onChange?.({ target: { value: o.value } })
close()
}
/*
Where the list goes is ReUI's Popover's job: it takes the list out of any clipping
ancestor, opens upwards where it does not fit below, is at least as wide as its
trigger (`--anchor-width`) and closes on an outside press. It still closes on
scroll, as the native element does; scrolling the list itself does not.
*/
useEffect(() => {
if (!open) return
const onScroll = (e: Event) => {
if (e.target instanceof Node && list.current?.contains(e.target)) return
setOpen(false)
}
// `true` so it also hears a container's scroll, not only the page's:
// inside a modal the thing that scrolls is the modal.
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onScroll)
return () => {
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', onScroll)
}
}, [open])
// The selected option is kept in view when moving with the keyboard.
useEffect(() => {
if (!open) return
const checked = list.current?.querySelector('[data-active="true"]')
// `scrollIntoView` does not exist in jsdom, and nothing breaks without it.
checked?.scrollIntoView?.({ block: 'nearest' })
}, [open, active])
function onKeyDown(e: React.KeyboardEvent) {
if (disabled) return
if (!open) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
openList()
}
return
}
switch (e.key) {
case 'Escape':
e.preventDefault()
close()
return
case 'Tab':
setOpen(false)
return
case 'Enter':
case ' ':
e.preventDefault()
choose(active)
return
case 'ArrowDown':
e.preventDefault()
setActive((i) => step(options, visible, i, 1))
return
case 'ArrowUp':
e.preventDefault()
setActive((i) => step(options, visible, i, -1))
return
case 'Home':
e.preventDefault()
setActive(firstUsable(options, visible, 0, 1))
return
case 'End':
e.preventDefault()
setActive(firstUsable(options, visible, visible.length - 1, -1))
return
default:
break
}
// Typing jumps to the option starting with what was typed, like the native one.
if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {
const now = Date.now()
keys2.current.text = now > keys2.current.until ? e.key : keys2.current.text + e.key
keys2.current.until = now + 600
const wanted = keys2.current.text.toLowerCase()
const i = options.findIndex((o) => !o.disabled && o.label.toLowerCase().startsWith(wanted))
if (i >= 0) setActive(i)
}
}
/* The search field's keys: the list's, minus the ones a text field keeps (Home,
End, Space and the letters, which are the search). */
function onSearchKeyDown(e: React.KeyboardEvent) {
switch (e.key) {
case 'Escape':
e.preventDefault()
close()
return
case 'Tab':
setOpen(false)
return
case 'Enter':
e.preventDefault()
if (activeShown) choose(active)
return
case 'ArrowDown':
e.preventDefault()
setActive((i) => step(options, visible, i, 1))
return
case 'ArrowUp':
e.preventDefault()
setActive((i) => step(options, visible, i, -1))
return
default:
break
}
}
function onSearch(text: string) {
setQuery(text)
const shown = visibleFor(options, text)
const first = firstUsable(options, shown, 0, 1, -1)
setActive(first)
}
/* Consecutive options of one `<optgroup>` are drawn inside one group. */
const runs: { group?: string; items: number[] }[] = []
for (const i of visible) {
const g = options[i].group
const last = runs[runs.length - 1]
if (last != null && last.group === g) last.items.push(i)
else runs.push({ group: g, items: [i] })
}
const option = (i: number) => {
const o = options[i]
return (
<div
key={o.value}
id={`${listId}-${i}`}
role="option"
aria-selected={o.value === String(value)}
aria-disabled={o.disabled}
data-active={i === active}
className={styles.option}
onMouseEnter={() => !o.disabled && setActive(i)}
onClick={() => choose(i)}
>
<span className={styles.brand}>
{o.value === String(value) && <Icon name="check" size={13} />}
</span>
{o.label === '' ? <span className={styles.isEmpty}>—</span> : o.label}
</div>
)
}
const lines = runs.flatMap((run, n) =>
run.group == null
? run.items.map(option)
: [
/* The label sits beside the group and names it, not inside it: a group
holds options only. */
<div key={`group-${n}`} role="presentation">
<div id={`${listId}-g${n}`} role="presentation" className={styles.groupLabel}>
{run.group}
</div>
<div role="group" aria-labelledby={`${listId}-g${n}`}>
{run.items.map(option)}
</div>
</div>,
],
)
const trigger_ = (
<button
type="button"
id={id}
ref={merge(trigger, ref)}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? listId : undefined}
aria-activedescendant={open && !searchable ? `${listId}-${active}` : undefined}
aria-label={ariaLabel}
disabled={disabled}
className={[styles.trigger, className].filter(Boolean).join(' ')}
onKeyDown={onKeyDown}
>
{/*
An option with no label (a filter's "any") is drawn with a dash, just as
it is inside the list. Completely empty, the control reads as a broken box
rather than as "nothing is selected".
*/}
<span className={chosen?.label ? styles.value : styles.emptyText}>
{chosen?.label || placeholder || '—'}
</span>
<Icon name="chevronDown" size={14} className={styles.chevron} />
</button>
)
return (
<Popover
open={open}
onOpenChange={(next) => {
if (next) openList()
else setOpen(false)
}}
>
<PopoverTrigger render={trigger_} />
<PopoverContent
side="bottom"
align="start"
initialFocus={false}
finalFocus={false}
className="w-auto max-w-[calc(100vw-1rem)] min-w-(--anchor-width) gap-0 overflow-hidden p-0"
>
{searchable ? (
<div ref={list} className={`${styles.list} ${styles.searching}`}>
<input
ref={(el) => {
search.current = el
el?.focus({ preventScroll: true })
}}
type="text"
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={activeShown ? `${listId}-${active}` : undefined}
aria-label={searchLabel}
placeholder={searchLabel}
autoComplete="off"
spellCheck={false}
className={styles.search}
value={query}
onChange={(e) => onSearch(e.target.value)}
onKeyDown={onSearchKeyDown}
/>
<div id={listId} role="listbox" aria-label={ariaLabel} className={styles.scroll}>
{lines}
</div>
<div role="status" className={visible.length === 0 ? styles.none : undefined}>
{visible.length === 0 ? noMatchText : ''}
</div>
</div>
) : (
<div ref={list} id={listId} role="listbox" className={styles.list}>
{lines}
</div>
)}
</PopoverContent>
</Popover>
)
}
/*
Moving is over the options shown (`shown`, indices into `options`): all of them
unless a search is filtering. `from` and the result are positions in `shown`
turned back into indices.
*/
/** The first usable option from position `from` in that direction; if there is
* none, `fallback` (by default the option at `from`). */
function firstUsable(options: Option[], shown: number[], from: number, dir: number, fallback?: number): number {
for (let p = from; p >= 0 && p < shown.length; p += dir) {
if (!options[shown[p]].disabled) return shown[p]
}
return fallback ?? shown[from] ?? -1
}
/** The next usable option after `current` in that direction; if there is none, it
* stays put. From an option not shown, it starts at the first or the last. */
function step(options: Option[], shown: number[], current: number, dir: number): number {
const p = shown.indexOf(current)
const from = p < 0 ? (dir > 0 ? 0 : shown.length - 1) : p + dir
return firstUsable(options, shown, from, dir, p < 0 ? -1 : current)
}
/** Merges the internal ref with whatever the caller may pass. */
function merge(own: React.RefObject<HTMLButtonElement | null>, outside?: Ref<HTMLButtonElement>) {
return (node: HTMLButtonElement | null) => {
own.current = node
if (typeof outside === 'function') outside(node)
else if (outside != null) (outside as { current: HTMLButtonElement | null }).current = node
}
}