'use client' import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from 'react' import { cn } from '../../lib/cn' import { Checkbox } from '../Checkbox' import { Input } from '../Input' import { Switch } from '../Switch' import { SearchIcon } from '../internal/icons' export interface SearchableCheckboxListItem { id: string label: string /** Secondary text on the same row. Searched by the filter too. */ description?: string /** Shown but cannot be ticked or unticked. */ disabled?: boolean } export interface SearchableCheckboxListProps { /** Every option. Thousands are fine: only the rows in view are rendered. */ items: SearchableCheckboxListItem[] /** Controlled ticked ids. */ value?: string[] /** Starting ticked ids when uncontrolled. */ defaultValue?: string[] /** Called with the ticked ids, in item order, after every change. */ onValueChange?: (value: string[]) => void /** Names the list and its filter, such as “Countries”. */ label: string /** Placeholder of the filter field. */ placeholder?: string /** Height of the scrolling list, in pixels. */ height?: number /** Blocks interaction and dims the control. */ disabled?: boolean /** Merged last, so it wins. */ className?: string } const ROW = 36 const OVERSCAN = 8 /** * A long list of checkboxes with a filter, for picking many from hundreds — * countries, tags, repositories, recipients. * * “Select all” means all that are *shown*: filter to “united” and it ticks the * matches, not the whole world, and its mixed state describes the same set. The * selected count always counts everything, and “Show selected only” answers the * question every long checklist raises — what did I tick? * * Rows are real checkboxes, so Space and screen readers behave as they always * do; only those in view (plus a margin) exist in the DOM, which is what keeps * a few thousand options instant. Arrow keys move between rows and scroll the * next one into existence before focusing it. */ export function SearchableCheckboxList({ items, value, defaultValue = [], onValueChange, label, placeholder = 'Filter', height = 280, disabled = false, className, }: SearchableCheckboxListProps) { const uid = useId() const scroller = useRef(null) const focusNext = useRef(null) const [uncontrolled, setUncontrolled] = useState(defaultValue) const [query, setQuery] = useState('') const [selectedOnly, setSelectedOnly] = useState(false) const [scrollTop, setScrollTop] = useState(0) const chosen = useMemo(() => new Set(value ?? uncontrolled), [value, uncontrolled]) const needle = query.trim().toLowerCase() const visible = useMemo( () => items.filter( (item) => (!selectedOnly || chosen.has(item.id)) && (!needle || `${item.label} ${item.description ?? ''}`.toLowerCase().includes(needle)), ), [items, needle, selectedOnly, chosen], ) const enabled = visible.filter((item) => !item.disabled) const tickedHere = enabled.filter((item) => chosen.has(item.id)).length const allState = tickedHere === 0 ? false : tickedHere === enabled.length ? true : 'mixed' const commit = (next: Set) => { const ordered = items.filter((item) => next.has(item.id)).map((item) => item.id) if (value === undefined) setUncontrolled(ordered) onValueChange?.(ordered) } const toggle = (id: string, on: boolean) => { const next = new Set(chosen) if (on) next.add(id) else next.delete(id) commit(next) } const toggleAll = () => { const next = new Set(chosen) enabled.forEach((item) => (allState === true ? next.delete(item.id) : next.add(item.id))) commit(next) } // A new filter starts at the top of its results. useEffect(() => { if (scroller.current) scroller.current.scrollTop = 0 setScrollTop(0) }, [needle, selectedOnly]) useEffect(() => { if (focusNext.current === null) return scroller.current?.querySelector(`[data-row="${focusNext.current}"] input`)?.focus() focusNext.current = null }) const first = Math.max(0, Math.floor(scrollTop / ROW) - OVERSCAN) const last = Math.min(visible.length, Math.ceil((scrollTop + height) / ROW) + OVERSCAN) const onKeyDown = (event: KeyboardEvent) => { const row = Number((event.target as HTMLElement).closest('[data-row]')?.dataset.row) if (Number.isNaN(row)) return const moves: Record = { ArrowDown: row + 1, ArrowUp: row - 1, Home: 0, End: visible.length - 1 } if (!(event.key in moves)) return event.preventDefault() const target = Math.max(0, Math.min(visible.length - 1, moves[event.key])) const node = scroller.current if (node) { const top = target * ROW if (top < node.scrollTop) node.scrollTop = top else if (top + ROW > node.scrollTop + height) node.scrollTop = top + ROW - height const rendered = node.querySelector(`[data-row="${target}"] input`) if (rendered) rendered.focus() else focusNext.current = target setScrollTop(node.scrollTop) } } return (
{label} {chosen.size.toLocaleString()} selected
setQuery(event.target.value)} leading={} disabled={disabled} />
setScrollTop(event.currentTarget.scrollTop)} onKeyDown={onKeyDown} className="relative overflow-y-auto" style={{ height }} >
    {visible.slice(first, last).map((item, offset) => { const index = first + offset return (
  • ) })}
{visible.length === 0 && (

{selectedOnly && chosen.size === 0 ? 'Nothing selected yet' : 'No matches'}

)}
) }