A list of options where one can be selected, with typeahead.
Analytics
Billing
Checkout
Docs
Onboarding
View codeHide code
import { Listbox, ListboxOption } from '@syncclouds/ui'
const PROJECTS = ['Analytics', 'Billing', 'Checkout', 'Docs', 'Onboarding']
export function ListboxDemo() {
return (
<Listbox aria-label="Project" defaultValue="billing" className="w-56">
{PROJECTS.map(name => (
<ListboxOption key={name} value={name.toLowerCase()}>
{name}
</ListboxOption>
))}
</Listbox>
)
}Installation
Available at launch
pnpm add @syncclouds/uinpm install @syncclouds/uiyarn add @syncclouds/uibun add @syncclouds/uiAvailable at launch
pnpm dlx shadcn@latest add https://ui.syncclouds.com/r/listbox.jsonnpx shadcn@latest add https://ui.syncclouds.com/r/listbox.jsonyarn dlx shadcn@latest add https://ui.syncclouds.com/r/listbox.jsonbunx --bun shadcn@latest add https://ui.syncclouds.com/r/listbox.jsonThe CLI copies 3 files into your ui folder.
1. Copy these files into one folder
listbox.tsxShowHide
'use client'
import {
createContext,
use,
useId,
type ComponentPropsWithRef,
type KeyboardEvent
} from 'react'
import { useControllableState } from './sui-use-controllable-state'
import { idSafe, nextIndex, ownItems, useTypeahead } from './sui-keyboard'
export interface ListboxKeyAction {
key: string
value: string
label: string
}
interface ListboxContextValue {
baseId: string
value: string
select: (value: string) => void
}
const ListboxContext = createContext<ListboxContextValue | null>(null)
const useListbox = (part: string) => {
const context = use(ListboxContext)
if (!context) throw new Error(`<${part}> must be used inside <Listbox>`)
return context
}
const optionId = (baseId: string, value: string) =>
`${baseId}-option-${idSafe(value)}`
const OPTIONS = '[role="option"]'
const ROOT = '[data-scope="listbox"][data-part="root"]'
export interface ListboxProps
extends Omit<ComponentPropsWithRef<'div'>, 'defaultValue' | 'onChange'> {
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
/** Called after a key moves the selection, with the key and the new option. */
onKeyAction?: (action: ListboxKeyAction) => void
}
/**
* A single-select listbox (APG Listbox). Focus stays on the list and
* aria-activedescendant points at the selected option, which follows the
* arrow keys, Home, End and typeahead. Name it with aria-label or
* aria-labelledby.
*/
export function Listbox({
value,
defaultValue = '',
onValueChange,
onKeyAction,
onKeyDown,
children,
...props
}: ListboxProps) {
const baseId = useId()
const [current, setCurrent] = useControllableState({
prop: value,
defaultProp: defaultValue,
onChange: onValueChange
})
const typeahead = useTypeahead()
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
const options = ownItems(event.currentTarget, OPTIONS, ROOT)
const index = options.findIndex(option => option.dataset.value === current)
let next = nextIndex(event.key, index, options.length, 'vertical')
if (next === null) {
next = typeahead(
event.key,
options.map(option => option.textContent ?? ''),
index
)
}
if (next === null) return
event.preventDefault()
const option = options[next]
const nextValue = option.dataset.value ?? ''
setCurrent(nextValue)
option.scrollIntoView?.({ block: 'nearest' })
onKeyAction?.({
key: event.key,
value: nextValue,
label: option.textContent?.trim() ?? ''
})
}
return (
<ListboxContext value={{ baseId, value: current, select: setCurrent }}>
<div
tabIndex={0}
{...props}
role="listbox"
aria-activedescendant={current ? optionId(baseId, current) : undefined}
data-scope="listbox"
data-part="root"
onKeyDown={handleKeyDown}
>
{children}
</div>
</ListboxContext>
)
}
export interface ListboxOptionProps extends ComponentPropsWithRef<'div'> {
value: string
disabled?: boolean
}
export function ListboxOption({
value,
disabled = false,
onClick,
...props
}: ListboxOptionProps) {
const { baseId, value: current, select } = useListbox('ListboxOption')
const selected = current === value
return (
<div
{...props}
id={optionId(baseId, value)}
role="option"
aria-selected={selected}
aria-disabled={disabled || undefined}
data-scope="listbox"
data-part="option"
data-state={selected ? 'selected' : 'unselected'}
data-disabled={disabled ? '' : undefined}
data-value={value}
onClick={event => {
onClick?.(event)
if (!event.defaultPrevented && !disabled) select(value)
}}
/>
)
}
sui-use-controllable-state.tsShowHide
import { useCallback, useState } from 'react'
/**
* Controlled when `prop` is defined, uncontrolled otherwise. `onChange`
* fires for every change either way.
*/
export function useControllableState<T>({
prop,
defaultProp,
onChange
}: {
prop: T | undefined
defaultProp: T
onChange?: (value: T) => void
}) {
const [uncontrolled, setUncontrolled] = useState(defaultProp)
const controlled = prop !== undefined
const value = controlled ? prop : uncontrolled
const setValue = useCallback(
(next: T) => {
if (!controlled) setUncontrolled(next)
if (!Object.is(next, value)) onChange?.(next)
},
[controlled, onChange, value]
)
return [value, setValue] as const
}
sui-keyboard.tsShowHide
import { useRef } from 'react'
export type Orientation = 'horizontal' | 'vertical'
/**
* The next index for a list navigated with arrow keys, Home and End, or
* null when the key is not a navigation key. Lists wrap, as the APG
* patterns used here recommend. `both` accepts all four arrows (radio).
*/
export function nextIndex(
key: string,
current: number,
count: number,
orientation: Orientation | 'both'
): number | null {
if (count === 0) return null
const forward =
(orientation !== 'horizontal' && key === 'ArrowDown') ||
(orientation !== 'vertical' && key === 'ArrowRight')
const backward =
(orientation !== 'horizontal' && key === 'ArrowUp') ||
(orientation !== 'vertical' && key === 'ArrowLeft')
if (key === 'Home') return 0
if (key === 'End') return count - 1
if (forward) return current < 0 ? 0 : (current + 1) % count
if (backward) return current < 0 ? count - 1 : (current - 1 + count) % count
return null
}
/**
* Items that belong to this container, skipping disabled ones and any that
* sit inside a nested instance of the same component.
*/
export function ownItems(
container: HTMLElement,
itemSelector: string,
containerSelector: string
): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(itemSelector)).filter(
el =>
el.closest(containerSelector) === container &&
!el.hasAttribute('disabled') &&
el.getAttribute('aria-disabled') !== 'true'
)
}
/**
* Typeahead: printable keys typed within half a second build a search
* string, and the first matching label after the current item wins.
*/
export function useTypeahead() {
const buffer = useRef('')
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
return (key: string, labels: string[], current: number): number | null => {
if (key.length !== 1 || key === ' ') return null
clearTimeout(timer.current)
timer.current = setTimeout(() => (buffer.current = ''), 500)
buffer.current += key.toLowerCase()
const search = buffer.current
const repeated = search.split('').every(char => char === search[0])
const query = repeated ? search[0] : search
const start = repeated ? current + 1 : current
for (let i = 0; i < labels.length; i++) {
const index = (start + i + labels.length) % labels.length
if (labels[index].trim().toLowerCase().startsWith(query)) return index
}
return null
}
}
/** Makes a value safe to use inside an id attribute. */
export const idSafe = (value: string) => value.replace(/[^\w-]/g, '_')
2. Import from that folder
The files import each other with relative paths, so keep them together, for example in components/ui.
Usage
import { Listbox, ListboxOption } from '@syncclouds/ui'<Listbox aria-label="Project" defaultValue="billing">
<ListboxOption value="analytics">Analytics</ListboxOption>
<ListboxOption value="billing">Billing</ListboxOption>
</Listbox>Anatomy
Listbox
└── ListboxOptionControlled
Keep the value in your own state. Disabled options are skipped.
Berlin
Karachi
New York
Singapore
Sydney
Time zone: Karachi
View codeHide code
'use client'
import { useState } from 'react'
import { Listbox, ListboxOption } from '@syncclouds/ui'
const TIMEZONES = [
{ value: 'europe-berlin', label: 'Berlin' },
{ value: 'asia-karachi', label: 'Karachi' },
{ value: 'america-new-york', label: 'New York' },
{ value: 'asia-singapore', label: 'Singapore' },
{ value: 'australia-sydney', label: 'Sydney', disabled: true }
]
export function ListboxControlled() {
const [value, setValue] = useState('asia-karachi')
const selected = TIMEZONES.find(zone => zone.value === value)
return (
<div className="flex flex-col items-center gap-4">
<Listbox aria-label="Time zone" value={value} onValueChange={setValue} className="w-56">
{TIMEZONES.map(zone => (
<ListboxOption key={zone.value} value={zone.value} disabled={zone.disabled}>
{zone.label}
</ListboxOption>
))}
</Listbox>
<p className="text-sm">Time zone: {selected?.label}</p>
</div>
)
}Key log
onKeyAction reports every key that moved the selection, with the new option.
Analytics
Billing
Checkout
Docs
Onboarding
- Focus the list and press a key.
View codeHide code
'use client'
import { useState } from 'react'
import { Listbox, ListboxOption } from '@syncclouds/ui'
const PROJECTS = ['Analytics', 'Billing', 'Checkout', 'Docs', 'Onboarding']
/* onKeyAction reports every key that moved the selection. */
export function ListboxKeyLog() {
const [log, setLog] = useState<string[]>([])
return (
<div className="flex w-full max-w-md gap-6">
<Listbox
aria-label="Project"
defaultValue="analytics"
className="w-44 shrink-0"
onKeyAction={({ key, label }) =>
setLog(entries => [`${key} moved to ${label}`, ...entries].slice(0, 5))
}
>
{PROJECTS.map(name => (
<ListboxOption key={name} value={name.toLowerCase()}>
{name}
</ListboxOption>
))}
</Listbox>
<ol aria-label="Key log" aria-live="polite" className="font-mono text-sm">
{log.length === 0 && <li>Focus the list and press a key.</li>}
{log.map((entry, i) => (
<li key={`${entry}-${i}`}>{entry}</li>
))}
</ol>
</div>
)
}Without the theme
Small
Medium
Large
Extra large
View codeHide code
import { Listbox, ListboxOption } from '@syncclouds/ui'
const SIZES = ['Small', 'Medium', 'Large', 'Extra large']
export function ListboxUnstyled() {
return (
<Listbox
aria-label="Size"
defaultValue="medium"
className="w-48 rounded-lg border border-zinc-300 p-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:border-zinc-600"
>
{SIZES.map(size => (
<ListboxOption
key={size}
value={size.toLowerCase()}
className="cursor-pointer rounded-md px-3 py-1.5 text-sm text-zinc-900 data-[state=selected]:bg-indigo-600 data-[state=selected]:text-white dark:text-zinc-100 dark:data-[state=selected]:bg-indigo-400 dark:data-[state=selected]:text-zinc-950"
>
{size}
</ListboxOption>
))}
</Listbox>
)
}Accessibility
Follows the WAI-ARIA Listbox pattern.
| Keys | What they do |
|---|---|
| Tab | Moves focus into and out of the list. It is one tab stop. |
| Arrow Down, Arrow Up | Selects the next or previous option, wrapping at the ends. |
| Home, End | Selects the first or last option. |
| Letters | Selects the next option whose name starts with what you type. |
- Selection follows focus, as the APG recommends for single-select lists.
- Name the list with aria-label or aria-labelledby.
API reference
Every part also accepts the props of the element it renders, including className and ref.
Listboxrenders <div>
The list. It takes focus itself and points aria-activedescendant at the selected option.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | none | The selected option, when you control it. |
defaultValue | string | "" | The option selected at first. |
onValueChange | (value: string) => void | none | Called when the selection changes, by keyboard or pointer. |
onKeyAction | (action: { key: string; value: string; label: string }) => void | none | Called after a key moves the selection, with the key and the new option. |
| Data attribute | Values |
|---|---|
data-scope | "listbox" |
data-part | "root" |
ListboxOptionrenders <div>
One option. Its text is used for typeahead.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | none | Required. Identifies the option. |
disabled | boolean | false | Skipped by the keyboard and ignores clicks. |
| Data attribute | Values |
|---|---|
data-state | "selected" | "unselected" |
data-disabled | present when disabled |