@llui/components
Current package version: 0.21.0
Headless UI components for LLui. Pure state machines with no DOM opinions -- you own the markup and styling via data-scope / data-part attributes. Component state is JSON-serializable: numeric inputs reject non-finite runtime values, use finite-or-absent bounds, and validate positive divisors such as pagination pageSize and numeric crop aspectRatio.
Install
pnpm add @llui/components @llui/dom @llui/interactions
@llui/dom and @llui/interactions are peer dependencies. Resolve one instance of each across the application and its libraries so signal state and the focus/dismissal/nested-layer registries are shared.
Usage
Each component exports init, update, connect, and a barrel object:
import { component, div, button, text } from '@llui/dom'
import { tabs } from '@llui/components/tabs'
type State = { tabs: tabs.TabsState }
type Msg = { type: 'tabs'; msg: tabs.TabsMsg }
const App = component<State, Msg>({
name: 'App',
init: () => ({ tabs: tabs.init({ items: ['a', 'b', 'c'], value: 'a' }) }),
update: (s, m) => {
const [t] = tabs.update(s.tabs, m.msg)
return [{ tabs: t }, []]
},
// `connect` takes the sliced SIGNAL handle (`state.at('tabs')`), not an accessor.
view: ({ state, send }) => {
const t = tabs.connect(state.at('tabs'), (m) => send({ type: 'tabs', msg: m }), { id: 'demo' })
return [
div({ ...t.root }, [
div({ ...t.list }, [
button({ ...t.item('a').trigger }, [text('Tab A')]),
button({ ...t.item('b').trigger }, [text('Tab B')]),
button({ ...t.item('c').trigger }, [text('Tab C')]),
]),
div({ ...t.item('a').panel }, [text('Content A')]),
div({ ...t.item('b').panel }, [text('Content B')]),
div({ ...t.item('c').panel }, [text('Content C')]),
]),
]
},
})
Pattern
init(opts?)-- creates the initial stateupdate(state, msg)-- pure reducer, returns[newState, effects[]]connect(state, send, opts?)-- takes the slicedSignalhandle; returns parts objects with reactive props, ARIA attributes, and event handlers. Spread parts onto your elements:div({ ...parts.root }, [...])- Overlay helpers (dialog, popover, menu, etc.) --
overlay()wires up portals, focus traps, dismiss layers, and positioning
Composition: delegated update
The parent owns the component's state as a slice and routes its messages through the
parent's Msg union — a flat switch, no special combinator. connect(state, send, opts?)
takes the sliced signal handle and returns spreadable, signal-based parts.
import { tabs } from '@llui/components/tabs'
import { div } from '@llui/dom'
type State = { tabs: tabs.TabsState /* … */ }
type Msg = { type: 'tabs'; msg: tabs.TabsMsg } /* | … */
update: (state, msg) => {
switch (msg.type) {
case 'tabs':
return [{ ...state, tabs: tabs.update(state.tabs, msg.msg)[0] }, []]
// … other slices
}
}
// view — connect() takes the sliced signal and a routed `send`:
view: ({ state, send }) => {
const parts = tabs.connect(state.at('tabs'), (m) => send({ type: 'tabs', msg: m }))
return [
div({ ...parts.root }, [
/* … */
]),
]
}
Components
The product contract lists 95 components. @llui/components/<name> imports a component's headless machine (behaviour, state, ARIA) when it has one; llui add <name> copies styled source into your app. They are different artifacts, and a component may have either, both, or names that differ between the two.
| Kind | Components | Headless import | Copied by llui add |
|---|---|---|---|
| machine | 69 | 69 | 64 |
| pattern | 6 | 6 | 3 |
| presentational | 19 | none, by design | 19 |
| skin, app-owned state | 1 | none, by design | 1 |
| all | 95 | 75 | 89 add names, 6 aliases among them |
Every component, with its import, add names, styling support and a stable Component Gallery link, is in the component catalog.
75 headless machine and pattern subpaths, each imported as @llui/components/<subpath>, grouped by category:
- Controls —
accordion,angle-slider,checkbox,clipboard,collapsible,color-picker,editable,gradient-picker,listbox,number-input,password-input,pin-input,radio-group,rating-group,search-field,select,slider,switch,tags-input,theme-switch,time-picker,timer,toggle,toggle-group - Forms —
cascade-select,combobox,date-input,date-picker,field,fieldset,file-upload,form - Navigation —
breadcrumbs,menu,menubar,navigation-menu,pagination,tabs,toc,toolbar,tree-view - Overlays —
alert-dialog,context-menu,dialog,drawer,floating-panel,hover-card,popover,tooltip,tour - Feedback —
async-list,meter,progress,toast - Data display —
avatar,carousel,chart,marquee,qr-code,sparkline,table - Layout —
scroll-area,sortable,splitter,steps - Media —
image-cropper,signature-pad - Patterns —
patterns/command-menu,patterns/confirm-dialog,patterns/data-table,patterns/form-field,patterns/searchable-select,patterns/wizard - Utilities —
in-view,presence
Utilities
Shared helpers used internally and exported for advanced use:
| Utility | Purpose |
|---|---|
typeahead |
First-letter search across menu, select, listbox, tree-view |
TreeCollection |
Indexed tree traversal -- visibleItems, labels, indeterminate computation |
floating |
@floating-ui/dom wrapper for popover/menu positioning |
focus-trap |
Stack-based focus containment for modals |
dismissable |
Esc / outside-click dismiss layer stack |
aria-hidden |
aria-hidden on siblings of a modal for screen readers |
remove-scroll |
Body scroll lock for modals/drawers |
Styling (opt-in)
Components are fully headless by default. Two independent styling paths are available; choose one per app. Styling is the full guide.
Baseline theme — theme.css
Import once at your app root for a complete default look based on data-scope/data-part
attribute selectors:
import '@llui/components/styles/theme.css'
This is ordinary published CSS. It requires no Tailwind dependency, plugin, configuration, or preprocessing step, and it includes dark mode, reduced-motion and forced-colors handling. Override semantic tokens in plain CSS:
:root {
--primary: oklch(0.55 0.21 258);
--llui-radius-lg: 1rem;
}
For a smaller bundle, import the modules individually — Styling → entry points lists every stylesheet in dependency order, generated from this package's exports. Upgrading from an older release? See the migration guide.
Registry skins — copied source, Tailwind v4
Class recipes do not ship in this package. They are registry source you own and edit,
copied into your project by @llui/cli and verified against a real Tailwind v4 build:
npx @llui/cli add tabs
llui add <name> copies a skin, pattern adapter or presentational component; this package's
@llui/components/<name> is the headless machine the skin spreads. The two are different
artifacts and their names can differ — the component catalog lists
both for every component. Import tokens.css and tokens-dark.css in your Tailwind
stylesheet — not theme.css, whose unlayered baseline rules would override every recipe.
Variant engine
createVariants builds a typed variant-to-class function for your own recipes (the registry's copied recipes use it too):
import { createVariants, cx } from '@llui/components/styles'
const button = createVariants({
base: 'inline-flex items-center font-medium',
variants: {
size: { sm: 'px-2 py-1 text-sm', md: 'px-4 py-2' },
intent: { primary: 'bg-primary text-white', ghost: 'bg-transparent' },
},
defaultVariants: { size: 'md', intent: 'primary' },
compoundVariants: [{ size: 'sm', intent: 'ghost', class: 'font-normal' }],
})
button({ size: 'sm', intent: 'ghost' }) // -> class string
Sub-path imports
Every component has its own entry point for tree-shaking:
import { tabs } from '@llui/components/tabs'
import { dialog } from '@llui/components/dialog'
import { timer } from '@llui/components/timer'
Validation
Input components accept an optional validate callback on ConnectOptions that gates state changes:
const parts = editable.connect<S>(get, send, {
validate: (value) => {
if (value.length < 3) return ['Too short']
return null // valid
},
})
Supported on: editable, number-input, tags-input, pin-input, file-upload.
Component Reference
All 69 components follow the same pattern:
import { componentName } from '@llui/components/component-name'
// State machine
const state = componentName.init({ /* options */ })
const [newState, effects] = componentName.update(state, msg)
// Connect to DOM
const parts = componentName.connect(state.at('component'), send, { id: '...' })
// Use parts: div({ ...parts.root }, [button({ ...parts.trigger }, [...])])
Accordion
State (AccordionState):
| Field | Type |
|---|---|
value |
string[] |
multiple |
boolean |
collapsible |
boolean |
disabled |
boolean |
items |
string[] |
closing |
string[] |
exitGenerations |
RetainedExitGeneration<string>[] |
exitSequence |
number |
animated |
boolean |
Messages: setItems, focusNext, focusPrev, focusFirst, focusLast, exitComplete
Init options: value?: string[], multiple?: boolean, collapsible?: boolean, disabled?: boolean, items?: string[], animated?: boolean
Connect options: ConnectOptions
Parts: root, exitCompletion, close, item
Utilities: focusTarget()
Alert Dialog
State: AlertDialogState (see parent component)
Connect options: AlertDialogConnectOptions
Utilities: overlay(), isMounted(), isPresent()
Angle Slider
State (AngleSliderState):
| Field | Type |
|---|---|
value |
number |
min |
number |
max |
number |
step |
number |
disabled |
boolean |
readonly |
boolean |
dir |
'ltr' | 'rtl' |
Messages: setValue, increment, decrement, setMin, setMax, setDir
Init options: value?: number, min?: number, max?: number, step?: number, disabled?: boolean, readonly?: boolean, dir?: 'ltr' | 'rtl'
Connect options: ConnectOptions
Parts: root, control, thumb, valueText, hiddenInput
Utilities: angleFromPoint(), pointFromAngle()
Async List
State (AsyncListState):
| Field | Type |
|---|---|
items |
T[] |
page |
number |
hasMore |
boolean |
status |
AsyncStatus |
error |
string | null |
Messages: loadMore, pageLoaded, pageFailed, reset, setItems, retry
Init options: items?: T[], page?: number, hasMore?: boolean
Parts: root, sentinel, loadMoreTrigger, retryTrigger, errorText
Utilities: isLoading(), isError(), isEmpty(), watchSentinel()
Avatar
State (AvatarState):
| Field | Type |
|---|---|
status |
ImageStatus |
Messages: loadStart, loaded, error, reset
Init options: status?: ImageStatus
Connect options: ConnectOptions
Parts: root, image, fallback
Breadcrumbs
State (BreadcrumbsState):
| Field | Type |
|---|---|
items |
BreadcrumbItem[] |
maxVisible |
number | null |
expanded |
boolean |
Messages: setItems, expand, collapse
Init options: items?: BreadcrumbItem[], maxVisible?: number | null, expanded?: boolean
Connect options: ConnectOptions
Parts: root, list, item, link, separator, ellipsisTrigger
Utilities: visibleItems()
Carousel
State (CarouselState):
| Field | Type |
|---|---|
current |
number |
count |
number |
loop |
boolean |
autoplay |
boolean |
interval |
number |
paused |
boolean |
hovered |
boolean |
focusWithin |
boolean |
direction |
'forward' | 'backward' |
swipeThreshold |
number |
dragging |
CarouselDrag | null |
dir |
'ltr' | 'rtl' |
dirSource |
DirectionSource |
Messages: goTo, next, prev, setCount, pause, resume, setHovered, setFocusWithin, setAutoplay, autoplayTick, dragStart, dragMove, dragEnd, setDir, syncDomDir
Init options: current?: number, count?: number, loop?: boolean, autoplay?: boolean, interval?: number, swipeThreshold?: number, dir?: 'ltr' | 'rtl'
Connect options: ConnectOptions
Parts: root, viewport, track, indicatorGroup, nextTrigger, prevTrigger, slide, directionSync
Utilities: canGoNext(), canGoPrev(), swipeDecision(), isAutoplayRunning(), autoplayEffects()
Cascade Select
State (CascadeSelectState):
| Field | Type |
|---|---|
levels |
CascadeLevel[] |
values |
(string | null)[] |
disabled |
boolean |
Messages: setLevels, setValue, clear
Init options: levels?: CascadeLevel[], values?: (string | null)[], disabled?: boolean
Connect options: ConnectOptions
Parts: root, clearTrigger, level
Utilities: isLevelReady(), isComplete(), completeValues()
Chart
State (ChartState):
| Field | Type |
|---|---|
coord |
ChartCoord |
domain |
ChartDomain |
series |
ChartSeries[] |
rows |
ChartRow[] |
label |
string |
description |
string |
width |
number |
height |
number |
padding |
ChartPadding |
min |
number |
max |
number |
tickCount |
number |
stacked |
boolean |
activeIndex |
number | null |
activeSeries |
string | null |
innerRadius |
number |
horizontal |
boolean |
Messages: setCoord, setDomain, setActive, moveActive, firstActive, lastActive, setActiveSeries, setRows, setMin, setMax, setStacked, setSize, setInnerRadius, setHorizontal
Init options: series: readonly ChartSeries[], rows?: readonly ChartRow[], coord?: ChartCoord, domain?: ChartDomain, label?: string, description?: string, width?: number, height?: number, padding?: Partial<ChartPadding>, min?: number, max?: number, tickCount?: number, stacked?: boolean, innerRadius?: number, horizontal?: boolean
Connect options: ChartConnectOptions
Parts: root, svg, title, desc, table, tooltip, layer, grid, axisLabel, dotProps, legendItem, legendSwatch, markProps, marks, vertices, gridLines, categoryTicks, tooltipRows, activeLabel, rows, series
Utilities: geometry(), chartForcedColorPatterns()
Checkbox
State (CheckboxState):
| Field | Type |
|---|---|
checked |
CheckedState |
disabled |
boolean |
required |
boolean |
Messages: toggle, setChecked, setDisabled
Init options: checked?: CheckedState, disabled?: boolean, required?: boolean
Parts: root, hiddenInput, indicator
Clipboard
State (ClipboardState):
| Field | Type |
|---|---|
value |
string |
copied |
boolean |
failed |
boolean |
Messages: setValue, copy, copied, copyFailed, reset
Init options: value?: string
Connect options: ConnectOptions
Parts: root, trigger, input, indicator
Utilities: copyToClipboard()
Collapsible
State (CollapsibleState):
| Field | Type |
|---|---|
open |
boolean |
disabled |
boolean |
closing |
boolean |
exitGeneration |
number |
animated |
boolean |
Messages: exitComplete
Init options: open?: boolean, disabled?: boolean, animated?: boolean
Connect options: ConnectOptions
Parts: root, trigger, content, exitCompletion, close
Color Picker
State (ColorPickerState):
| Field | Type |
|---|---|
color |
`| { model: 'hsv'; h: number; s: number; v: number } |
\| { model: 'oklch'; l: number; c: number; h: number }` |
| alpha | number |
| disabled | boolean |
| maxChroma | number |
| eyeDropperSupported | boolean |
Messages: setModel, setHsl, setHue, setSaturation, setLightness, setAlpha, setHex, setSv, nudgeSv, setColor, setOklch, setChroma, setOklchLightness, setLc, nudgeLc, setEyeDropperSupported, eyeDropperFailed
Init options: model?: ColorModel, color?: string, hsl?: Hsl, hsv?: Hsv, oklch?: Oklch, alpha?: number, disabled?: boolean, maxChroma?: number
Connect options: ConnectOptions
Parts: root, modelToggle, hueSlider, saturationSlider, lightnessSlider, chromaSlider, oklchLightnessSlider, hexInput, preview, area, areaCanvas, areaThumb, alphaSlider, eyeDropperTrigger, swatchGroup, swatch
Utilities: stateHsl(), stateHsv(), stateOklch(), isOutOfGamut(), toHex(), toHex8(), toCss(), colorFromPoint(), lcFromPoint(), hsvToOklchPreserving(), oklchToHsvPreserving(), supportsEyeDropper(), openEyeDropper(), oklchPlanePixels(), paintOklchPlane(), oklchHueRampGradient(), hslToHsv(), hsvToHsl(), sanitizeHsv(), sanitizeOklch(), pickerColorToCssColor(), cssColorToPickerColor(), pickerColorToCss(), eyeDropperSupportMount(), areaCanvasBinding()
Constants: DEFAULT_MAX_CHROMA
Combobox
State (ComboboxState):
| Field | Type |
|---|---|
open |
boolean |
value |
string[] |
inputValue |
string |
items |
string[] |
groups |
ComboboxGroup[] |
disabledItems |
string[] |
filteredItems |
string[] |
highlightedValue |
string | null |
selectionMode |
SelectionMode |
disabled |
boolean |
allowCreate |
boolean |
status |
AsyncStatus |
requestId |
number |
error |
string | null |
Messages: open, close, setInputValue, selectOption, setValue, clear, highlightNext, highlightPrev, highlightFirst, highlightLast, highlight, selectHighlighted, setItems, loadStart, loadSuccess, loadError
Init options: value?: string[], inputValue?: string, items?: string[], groups?: ComboboxGroup[], disabledItems?: string[], selectionMode?: SelectionMode, disabled?: boolean, allowCreate?: boolean
Connect options: ConnectOptions
Parts: root, input, trigger, positioner, content, loadState, item, group, liveRegion, empty
Utilities: overlay(), isCreateOption(), loadProjection()
Constants: CREATE_OPTION_VALUE
Context Menu
State (ContextMenuState):
| Field | Type |
|---|---|
x |
number |
y |
number |
Messages: openAt, close, highlight, highlightNext, highlightPrev, highlightFirst, highlightLast, selectHighlighted, select, openSub, closeSub, setItems, typeahead, setDir, syncDomDir, animationEnd
Init options: items?: ContextMenuItem[], checked?: string[], closeOnSelect?: boolean, dir?: TextDirection, skipAnimations?: boolean
Connect options: ConnectOptions
Parts: trigger, positioner, content, item, checkboxItem, radioItem, group, separator, subTrigger, subPositioner, subContent, directionSync
Utilities: overlay(), subOverlay(), isPresent(), isMounted()
Date Input
State (DateInputState):
| Field | Type |
|---|---|
input |
string |
value |
IsoDate | null |
min |
IsoDate | null |
max |
IsoDate | null |
error |
DateError |
disabled |
boolean |
readonly |
boolean |
required |
boolean |
Messages: setInput, setValue, clear, setMin, setMax, setDisabled
Init options: input?: string, value?: IsoDate | null, min?: IsoDate | null, max?: IsoDate | null, disabled?: boolean, readonly?: boolean, required?: boolean
Connect options: ConnectOptions
Parts: root, input, clearTrigger, errorText
Utilities: parseDate(), formatDate(), toIsoDate()
Date Picker
State (DatePickerState):
| Field | Type |
|---|---|
mode |
DatePickerMode |
value |
string | null |
start |
string | null |
end |
string | null |
hoverDate |
string | null |
visibleMonth |
number |
visibleYear |
number |
months |
number |
focused |
string |
min |
string | null |
max |
string | null |
weekStartsOn |
0 | 1 |
disabled |
boolean |
today |
string | null |
unavailable |
string[] |
Messages: setValue, setRange, setFocused, setHover, clearHover, prevMonth, nextMonth, prevYear, nextYear, selectFocused, moveFocus, focusStartOfWeek, focusEndOfWeek, focusToday, moveFocusMonths, clear, setToday, setUnavailable
Init options: mode?: DatePickerMode, value?: string | null, start?: string | null, end?: string | null, visibleMonth?: number, visibleYear?: number, months?: number, min?: string | null, max?: string | null, weekStartsOn?: 0 | 1, disabled?: boolean, today?: string | null, unavailable?: string[]
Connect options: ConnectOptions
Parts: root, grid, row, prevMonthTrigger, nextMonthTrigger, dayCell, preset
Utilities: monthGrid(), weekRows(), monthLabel(), weekdayLabels(), todayInTimeZone()
Dialog
State (DialogState):
| Field | Type |
|---|---|
open |
boolean |
status |
PresenceStatus |
skipAnimations |
boolean |
Messages: open, close, toggle, setOpen, animationEnd, transitionEnd
Init options: open?: boolean, skipAnimations?: boolean
Connect options: ConnectOptions
Parts: trigger, backdrop, positioner, content, title, description, closeTrigger
Utilities: overlay(), isMounted(), isPresent()
Drawer
State (DrawerState):
| Field | Type |
|---|---|
open |
boolean |
status |
PresenceStatus |
skipAnimations |
boolean |
Messages: open, close, toggle, setOpen, animationEnd, transitionEnd
Init options: open?: boolean, skipAnimations?: boolean
Connect options: ConnectOptions
Parts: trigger, backdrop, positioner, content, title, description, closeTrigger
Utilities: overlay(), isMounted(), isPresent()
Editable
State (EditableState):
| Field | Type |
|---|---|
value |
string |
editing |
boolean |
draft |
string |
disabled |
boolean |
Messages: edit, setDraft, submit, cancel, setValue
Init options: value?: string, editing?: boolean, disabled?: boolean
Connect options: ConnectOptions
Parts: root, preview, input, submitTrigger, cancelTrigger, editTrigger
Field
State (FieldState):
| Field | Type |
|---|---|
id |
string |
invalid |
boolean |
required |
boolean |
disabled |
boolean |
readonly |
boolean |
touched |
boolean |
Messages: setInvalid, setRequired, setDisabled, setReadonly, setTouched
Init options: id: string, invalid?: boolean, required?: boolean, disabled?: boolean, readonly?: boolean, touched?: boolean
Connect options: FieldConnectOptions
Parts: root, label, control, description, errorText
Fieldset
State (FieldsetState):
| Field | Type |
|---|---|
id |
string |
disabled |
boolean |
invalid |
boolean |
Messages: setDisabled, setInvalid
Init options: id: string, disabled?: boolean, invalid?: boolean
Connect options: FieldsetConnectOptions
Parts: root, legend, errorText
File Upload
State (FileUploadState):
| Field | Type |
|---|---|
files |
FileMeta[] |
rejectedFiles |
RejectedFile[] |
disabled |
boolean |
multiple |
boolean |
accept |
AcceptValue |
maxFiles |
number |
maxSize |
number |
minFileSize |
number |
required |
boolean |
readonly |
boolean |
invalid |
boolean |
dragging |
boolean |
dragDepth |
number |
uploads |
Record<string, FileUploadProgress> |
Messages: setFiles, addFiles, removeFile, removeRejected, clear, clearRejected, dragEnter, dragLeave, drop, setInvalid, uploadProgress, uploadSucceeded, uploadFailed, retryUpload
Init options: files?: FileMeta[], disabled?: boolean, multiple?: boolean, accept?: AcceptValue, maxFiles?: number, maxSize?: number, minFileSize?: number, required?: boolean, readonly?: boolean, invalid?: boolean
Connect options: ConnectOptions
Parts: root, dropzone, trigger, hiddenInput, label, clearTrigger, itemGroup, item
Utilities: totalSize(), acceptToString(), fileMatchesAccept(), validateFiles(), preventDocumentDrop(), uploadOf(), trackFile(), trackFiles(), getFile(), releaseFile(), releaseFiles(), releaseAllFiles(), trackedFileCount(), releaseDropped(), releaseUnlanded(), effectiveMaxFiles()
Floating Panel
State (FloatingPanelState):
| Field | Type |
|---|---|
position |
{ x: number; y: number } |
size |
{ width: number; height: number } |
minSize |
{ width: number; height: number } |
maxSize |
{ width?: number; height?: number } | null |
open |
boolean |
minimized |
boolean |
maximized |
boolean |
dragging |
boolean |
resizing |
ResizeHandle | null |
restoreBounds |
{ x: number; y: number; width: number; height: number } | null |
disabled |
boolean |
Messages: open, close, minimize, restoreFromMinimized, maximize, restoreFromMaximized, toggleMinimize, toggleMaximize, dragStart, dragMove, dragEnd, resizeStart, resizeMove, resizeEnd, setPosition, setSize, moveBy, resizeBy
Init options: position?: { x: number; y: number }, size?: { width: number; height: number }, minSize?: { width?: number; height?: number }, maxSize?: { width?: number; height?: number } | null, open?: boolean, disabled?: boolean
Connect options: ConnectOptions
Parts: root, dragHandle, content, minimizeTrigger, maximizeTrigger, closeTrigger, resizeHandle
Form
State (FormState):
| Field | Type |
|---|---|
status |
FormStatus |
touched |
Record<string, boolean> |
submitError |
string | null |
Messages: touch, touchAll, submit, submitSuccess, submitError, reset
Connect options: ConnectOptions
Parts: root, field, submit
Utilities: validateSchema(), validateSchemaAsync()
Gradient Picker
State (GradientPickerState):
| Field | Type |
|---|---|
kind |
GradientKind |
repeating |
boolean |
direction |
GradientDirection |
conicAngle |
number |
center |
GradientCenter |
shape |
RadialShape |
size |
RadialSize |
interpolation |
GradientInterpolation |
stops |
GradientStop[] |
selectedId |
string |
nextId |
number |
defaultModel |
ColorModel |
maxChroma |
number |
minStops |
number |
maxStops |
number |
disabled |
boolean |
dir |
'ltr' | 'rtl' |
eyeDropperSupported |
boolean |
cssDraft |
string | null |
cssError |
string | null |
Messages: addStop, removeStop, selectStop, moveStop, nudgeStop, setKind, setRepeating, setAngle, setCenter, setShape, setSize, setInterpolation, reverse, distribute, setGradientDraft, setGradient, picker, selectNextStop, selectPrevStop, setDir
Init options: css?: string, stops?: GradientStopInit[], kind?: GradientKind, repeating?: boolean, angle?: number, center?: Partial<GradientCenter>, shape?: RadialShape, size?: RadialSize, interpolation?: Partial<GradientInterpolation>, defaultModel?: ColorModel, maxChroma?: number, minStops?: number, maxStops?: number, disabled?: boolean, dir?: 'ltr' | 'rtl'
Connect options: ConnectOptions
Parts: root, preview, track, stop, addStopButton, removeStopButton, kindToggle, repeatingToggle, angleInput, centerArea, centerThumb, shapeOption, sizeOption, interpolationSpaceSelect, interpolationHueSelect, reverseButton, distributeButton, cssInput, cssError, picker
Utilities: toCss(), colorAt(), parseGradient(), pickerStateOf()
Hover Card
State (HoverCardState):
| Field | Type |
|---|---|
open |
boolean |
status |
PresenceStatus |
skipAnimations |
boolean |
Messages: show, hide, setOpen, animationEnd, transitionEnd
Init options: open?: boolean, skipAnimations?: boolean
Connect options: ConnectOptions
Parts: trigger, positioner, content, arrow
Utilities: overlay(), isMounted(), isPresent()
Image Cropper
State (ImageCropperState):
| Field | Type |
|---|---|
image |
{ width: number; height: number } |
crop |
CropRect |
aspectRatio |
number | null |
minSize |
number |
dragging |
boolean |
resizing |
ResizeHandle | null |
disabled |
boolean |
Messages: setImage, setCrop, setAspectRatio, dragStart, dragMove, dragEnd, resizeStart, resizeMove, resizeEnd, reset, centerFill, nudge, zoom
Init options: image?: { width: number; height: number }, crop?: CropRect, aspectRatio?: number | null, minSize?: number, disabled?: boolean
Connect options: ConnectOptions
Parts: root, image, cropBox, resizeHandle, resetTrigger
Utilities: centerFill()
In View
State (InViewState):
| Field | Type |
|---|---|
visible |
boolean |
Messages: enter, leave
Connect options: ConnectOptions
Parts: root
Utilities: createObserver()
Listbox
State (ListboxState):
| Field | Type |
|---|---|
value |
string[] |
items |
string[] |
disabledItems |
string[] |
disabled |
boolean |
selectionMode |
SelectionMode |
highlightedIndex |
number | null |
typeahead |
string |
typeaheadExpiresAt |
number |
Messages: select, setValue, clear, highlight, highlightNext, highlightPrev, highlightFirst, highlightLast, selectHighlighted, setItems, typeahead
Init options: value?: string[], items?: string[], disabledItems?: string[], disabled?: boolean, selectionMode?: SelectionMode
Connect options: ConnectOptions
Parts: root, item
Marquee
State (MarqueeState):
| Field | Type |
|---|---|
running |
boolean |
direction |
MarqueeDirection |
durationSec |
number |
pauseOnHover |
boolean |
hovered |
boolean |
disabled |
boolean |
Messages: play, pause, toggle, hoverPause, hoverResume, setDirection, setDuration
Init options: running?: boolean, direction?: MarqueeDirection, durationSec?: number, pauseOnHover?: boolean, disabled?: boolean
Parts: root, content
Utilities: isRunning(), cssAnimationDirection(), axis()
Menu
State (MenuState):
| Field | Type |
|---|---|
open |
boolean |
status |
PresenceStatus |
skipAnimations |
boolean |
items |
MenuItem[] |
highlights |
Record<string, string | null> |
openPath |
string[] |
checked |
string[] |
closeOnSelect |
boolean |
typeahead |
string |
typeaheadExpiresAt |
number |
Messages: open, close, toggle, highlight, highlightNext, highlightPrev, highlightFirst, highlightLast, selectHighlighted, select, openSub, closeSub, setItems, typeahead, setDir, syncDomDir, animationEnd
Init options: open?: boolean, items?: MenuItem[], highlighted?: string | null, checked?: string[], closeOnSelect?: boolean, dir?: TextDirection, skipAnimations?: boolean
Connect options: ConnectOptions
Parts: trigger, positioner, content, item, checkboxItem, radioItem, group, separator, subTrigger, subPositioner, subContent, directionSync
Utilities: overlay(), subOverlay(), isPresent(), isMounted()
Menubar
State (MenubarState):
| Field | Type |
|---|---|
menus |
string[] |
open |
string | null |
focused |
string | null |
disabledMenus |
string[] |
menuStates |
Record<string, MenuState> |
dir |
TextDirection |
dirSource |
DirectionSource |
Messages: openMenu, closeMenu, focusMenu, syncTriggerFocus, focusNext, focusPrev, menuMsg, setDir, syncDomDir
Init options: menus: MenubarMenu[], focused?: string | null, dir?: TextDirection
Connect options: ConnectOptions
Parts: root, menuTrigger, menu, directionSync
Utilities: overlay(), subOverlay()
Meter
State (MeterState):
| Field | Type |
|---|---|
value |
number |
min |
number |
max |
number |
bands |
MeterBand[] |
Messages: setValue, setMax, setBands
Init options: value?: number, min?: number, max?: number, low?: number, high?: number, optimum?: number, bands?: readonly MeterBandInit[]
Connect options: ConnectOptions
Parts: root, track, range, marker, label, bands, bandProps, band, valueText, bandLabel
Utilities: percent(), bandAt(), toneAt()
Navigation Menu
State (NavMenuState):
| Field | Type |
|---|---|
open |
string[] |
focused |
string | null |
items |
string[] |
disabled |
boolean |
dir |
'ltr' | 'rtl' |
dirSource |
DirectionSource |
Messages: openBranch, closeBranch, toggleBranch, closeAll, focus, setDir, syncDomDir, setItems
Init options: open?: string[], focused?: string | null, items?: string[], disabled?: boolean, dir?: 'ltr' | 'rtl'
Connect options: ConnectOptions
Parts: root, indicator, directionSync, item
Utilities: isOpen(), watchNavMenuIndicator()
Number Input
State (NumberInputState):
| Field | Type |
|---|---|
value |
number | null |
min |
number |
max |
number |
step |
number |
disabled |
boolean |
readonly |
boolean |
rawText |
string |
Messages: setValue, setRawText, commit, increment, decrement, toMin, toMax, setDisabled
Init options: value?: number | null, min?: number, max?: number, step?: number, disabled?: boolean, readonly?: boolean
Connect options: ConnectOptions
Parts: root, input, increment, decrement
Pagination
State (PaginationState):
| Field | Type |
|---|---|
page |
number |
pageSize |
number |
total |
number |
siblings |
number |
boundaries |
number |
disabled |
boolean |
dir |
TextDirection |
dirSource |
DirectionSource |
Messages: goTo, next, prev, first, last, setPageSize, setTotal, setDir, syncDomDir
Init options: page?: number, pageSize?: number, total?: number, siblings?: number, boundaries?: number, disabled?: boolean, dir?: TextDirection
Connect options: ConnectOptions
Parts: root, prevTrigger, nextTrigger, item, ellipsis, directionSync
Utilities: totalPages(), pageItems(), onControlKeyDown()
Password Input
State (PasswordInputState):
| Field | Type |
|---|---|
value |
string |
visible |
boolean |
disabled |
boolean |
Messages: setValue, toggleVisibility, setVisible
Init options: value?: string, visible?: boolean, disabled?: boolean
Connect options: ConnectOptions
Parts: root, input, visibilityTrigger
Pin Input
State (PinInputState):
| Field | Type |
|---|---|
values |
string[] |
length |
number |
type |
PinType |
mask |
boolean |
disabled |
boolean |
focusedIndex |
number |
Messages: setValue, setAll, focus, clear, backspace, setDisabled
Init options: length?: number, type?: PinType, mask?: boolean, disabled?: boolean, values?: string[]
Connect options: ConnectOptions
Parts: root, label, input
Utilities: isComplete(), getValue(), acceptedChars()
Popover
State (PopoverState):
| Field | Type |
|---|---|
open |
boolean |
status |
PresenceStatus |
skipAnimations |
boolean |
Messages: open, close, toggle, setOpen, animationEnd, transitionEnd
Init options: open?: boolean, skipAnimations?: boolean
Connect options: ConnectOptions
Parts: trigger, positioner, content, title, description, arrow, closeTrigger
Utilities: overlay(), isMounted(), isPresent()
Presence
State (PresenceState):
| Field | Type |
|---|---|
status |
PresenceStatus |
unmountOnExit |
boolean |
Messages: open, close, toggle, animationEnd, setPresent
Init options: present?: boolean, unmountOnExit?: boolean
Parts: root
Utilities: isMounted(), isVisible(), isAnimating(), presenceOpen(), presenceClose(), presenceEnd()
Progress
State (ProgressState):
| Field | Type |
|---|---|
value |
number | null |
min |
number |
max |
number |
orientation |
ProgressOrientation |
Messages: setValue, setMax
Init options: value?: number | null, min?: number, max?: number, orientation?: ProgressOrientation
Connect options: ConnectOptions
Parts: root, track, range, label, valueText
Utilities: percent(), valueState()
Qr Code
State (QrCodeState):
| Field | Type |
|---|---|
value |
string |
matrix |
boolean[][] |
errorCorrection |
ErrorCorrectionLevel |
Messages: setValue, setMatrix, setErrorCorrection
Init options: value?: string, matrix?: boolean[][], errorCorrection?: ErrorCorrectionLevel
Connect options: ConnectOptions
Parts: root, svg, background, foreground, downloadTrigger
Utilities: size(), toSvgPath(), toDataUrl()
Radio Group
State (RadioGroupState):
| Field | Type |
|---|---|
value |
string | null |
items |
string[] |
disabledItems |
string[] |
disabled |
boolean |
orientation |
Orientation |
loopFocus |
boolean |
dir |
'ltr' | 'rtl' |
Messages: setValue, setItems, selectNext, selectPrev, selectFirst, selectLast, setDir
Init options: value?: string | null, items?: string[], disabledItems?: string[], disabled?: boolean, orientation?: Orientation, loopFocus?: boolean, dir?: 'ltr' | 'rtl'
Connect options: ConnectOptions
Parts: root, item
Rating Group
State (RatingGroupState):
| Field | Type |
|---|---|
value |
number |
count |
number |
allowHalf |
boolean |
disabled |
boolean |
readonly |
boolean |
hoveredValue |
number | null |
dir |
'ltr' | 'rtl' |
Messages: setValue, hover, clickItem, hoverItem, incrementValue, decrementValue, toEnd, setDir
Init options: value?: number, count?: number, allowHalf?: boolean, disabled?: boolean, readonly?: boolean, dir?: 'ltr' | 'rtl'
Connect options: ConnectOptions
Parts: root, item
Utilities: itemFill()
Scroll Area
State (ScrollAreaState):
| Field | Type |
|---|---|
overflowX |
boolean |
overflowY |
boolean |
scrolling |
boolean |
hovered |
boolean |
visibility |
ScrollbarVisibility |
Messages: setScroll, setScrolling, setHovered
Init options: visibility?: ScrollbarVisibility
Parts: root, viewport, content, scrollbarX, scrollbarY, thumbX, thumbY, corner
Utilities: showScrollbars(), thumbPosition(), thumbSize()
Search Field
State (SearchFieldState):
| Field | Type |
|---|---|
value |
string |
disabled |
boolean |
Messages: setValue, clear, submit
Init options: value?: string, disabled?: boolean
Connect options: ConnectOptions
Parts: root, label, input, clearTrigger
Select
State (SelectState):
| Field | Type |
|---|---|
open |
boolean |
value |
string[] |
items |
string[] |
groups |
SelectGroup[] |
disabledItems |
string[] |
selectionMode |
SelectionMode |
highlightedValue |
string | null |
disabled |
boolean |
required |
boolean |
typeahead |
string |
typeaheadExpiresAt |
number |
Messages: open, close, toggle, selectOption, setValue, clear, highlight, highlightNext, highlightPrev, highlightFirst, highlightLast, selectHighlighted, setItems, typeahead
Init options: value?: string[], items?: string[], groups?: SelectGroup[], disabledItems?: string[], selectionMode?: SelectionMode, disabled?: boolean, required?: boolean
Connect options: ConnectOptions
Parts: trigger, positioner, content, hiddenSelect, hiddenOption, item, group, valueText
Utilities: overlay()
Signature Pad
State (SignaturePadState):
| Field | Type |
|---|---|
strokes |
Stroke[] |
current |
Stroke | null |
drawing |
boolean |
disabled |
boolean |
readonly |
boolean |
cleared |
Stroke[] | null |
Messages: strokeStart, strokePoint, strokeEnd, strokeCancel, undo, redo, clear, setStrokes
Init options: strokes?: Stroke[], disabled?: boolean, readonly?: boolean
Connect options: ConnectOptions
Parts: root, control, clearTrigger, undoTrigger, guide, hiddenInput
Utilities: isEmpty(), pointCount(), getBounds(), canUndo()
Slider
State (SliderState):
| Field | Type |
|---|---|
value |
number[] |
min |
number |
max |
number |
step |
number |
disabled |
boolean |
orientation |
Orientation |
minStepsBetweenThumbs |
number |
dir |
'ltr' | 'rtl' |
Messages: setValue, setThumb, increment, decrement, toMin, toMax, setDisabled, setDir
Init options: value?: number[], min?: number, max?: number, step?: number, disabled?: boolean, orientation?: Orientation, minStepsBetweenThumbs?: number, dir?: 'ltr' | 'rtl'
Parts: thumb, root, control, track, range, thumb, value
Utilities: valueFromPoint(), closestThumbIndex()
Sortable
State (SortableState):
| Field | Type |
|---|---|
id |
string |
startIndex |
number |
currentIndex |
number |
fromContainer |
string |
toContainer |
string |
startX |
number |
startY |
number |
currentX |
number |
currentY |
number |
count |
number |
dragging |
DragState | null |
announcement |
SortableAnnouncement | null |
Messages: start, move, drop, cancel, toggleGrab, moveBy
Connect options: ConnectOptions
Parts: liveRegion, instructions, root, item, handle
Utilities: reorder(), droppedMove()
Sparkline
State (SparklineState):
| Field | Type |
|---|---|
points |
SparklinePoint[] |
band |
SparklineBand |
now |
number |
min |
number |
max |
number |
width |
number |
height |
number |
padding |
SparklinePadding |
curve |
Curve |
trim |
SparklineTrim | null |
track |
SparklineTrack | null |
calendar |
SparklineCalendar |
activeIndex |
number | null |
Messages: setActive, moveActive, firstActive, lastActive, setPoints, setNow, setBand, setSize
Init options: points?: readonly SparklinePoint[], band?: SparklineBand, now?: number, min?: number, max?: number, width?: number, height?: number, padding?: Partial<SparklinePadding>, curve?: Curve, trim?: boolean | Partial<SparklineTrim>, track?: SparklineTrack | null, calendar?: Partial<SparklineCalendar>
Connect options: SparklineConnectOptions
Parts: root, svg, title, desc, table, band, line, now, layer, tickProps, dotProps, spanProps, tooltip, ticks, dots, spans, rows, activeDot, label, summary
Utilities: geometry(), sparklineGeometry(), createSparklineGeometry(), locateIndex(), trimLeadingOutliers(), sparklineLabel(), isoDay()
Splitter
State (SplitterState):
| Field | Type |
|---|---|
position |
number |
min |
number |
max |
number |
step |
number |
orientation |
Orientation |
disabled |
boolean |
dragging |
boolean |
dir |
'ltr' | 'rtl' |
Messages: setPosition, increment, decrement, toMin, toMax, startDrag, endDrag, setDir
Init options: position?: number, min?: number, max?: number, step?: number, orientation?: Orientation, disabled?: boolean, dir?: 'ltr' | 'rtl'
Parts: root, primaryPanel, secondaryPanel, resizeTrigger
Utilities: positionFromPoint()
Steps
State (StepsState):
| Field | Type |
|---|---|
current |
number |
completed |
number[] |
errors |
number[] |
steps |
string[] |
linear |
boolean |
disabled |
boolean |
Messages: goTo, next, prev, complete, markError, clearError, reset
Init options: current?: number, completed?: number[], steps?: string[], linear?: boolean, disabled?: boolean
Connect options: ConnectOptions
Parts: root, nextTrigger, prevTrigger, item
Utilities: stepStatus()
Switch
State (SwitchState):
| Field | Type |
|---|---|
checked |
boolean |
disabled |
boolean |
Messages: toggle, setChecked, setDisabled
Init options: checked?: boolean, disabled?: boolean
Parts: root, track, thumb, hiddenInput
Table
State (TableState):
| Field | Type |
|---|---|
columns |
TableColumn[] |
rows |
string[] |
sort |
TableSort | null |
selection |
string[] |
selectionMode |
TableSelectionMode |
focusedCell |
TableCellCoord | null |
rangeAnchor |
number | null |
pageSize |
number |
descFirst |
boolean |
disabled |
boolean |
Messages: toggleSort, setSort, toggleRow, selectAll, clearSelection, toggleAll, setSelection, selectRange, activateRow, setRows, setColumns, focusCell, moveCell, rowStart, rowEnd, gridStart, gridEnd, pageDown, pageUp
Init options: columns?: TableColumn[], rows?: string[], sort?: TableSort | null, selection?: string[], selectionMode?: TableSelectionMode, focusedCell?: TableCellCoord | null, pageSize?: number, descFirst?: boolean, disabled?: boolean
Connect options: ConnectOptions
Parts: viewport, root, columnHeader, row, cell, selectAllCheckbox, rowCheckbox
Utilities: isRowSelected(), isAllSelected(), isSomeSelected(), sortDirectionFor()
Constants: HEADER_ROW_INDEX
Tabs
State (TabsState):
| Field | Type |
|---|---|
value |
string |
items |
string[] |
disabledItems |
string[] |
orientation |
Orientation |
activation |
Activation |
focused |
string | null |
loopFocus |
boolean |
deselectable |
boolean |
dir |
'ltr' | 'rtl' |
dirSource |
DirectionSource |
Messages: setValue, setItems, focusTab, activateTab, focusNext, focusPrev, focusFirst, focusLast, activateFocused, setDir, syncDomDir
Init options: value?: string, items?: string[], disabledItems?: string[], orientation?: Orientation, activation?: Activation, loopFocus?: boolean, deselectable?: boolean, dir?: 'ltr' | 'rtl'
Connect options: ConnectOptions
Parts: root, list, indicator, item, directionSync
Utilities: watchTabIndicator()
Tags Input
State (TagsInputState):
| Field | Type |
|---|---|
value |
string[] |
inputValue |
string |
disabled |
boolean |
max |
number |
unique |
boolean |
focusedIndex |
number | null |
Messages: setInput, addTag, removeTag, removeLast, setValue, focusTag, focusTagNext, focusTagPrev, clearAll
Init options: value?: string[], inputValue?: string, disabled?: boolean, max?: number, unique?: boolean
Connect options: ConnectOptions
Parts: root, input, tag, clearTrigger
Theme Switch
State (ThemeSwitchState):
| Field | Type |
|---|---|
theme |
Theme |
Messages: setTheme, toggle
Connect options: ConnectOptions
Parts: root, option, toggle
Utilities: resolveTheme(), applyTheme(), watchSystemTheme()
Time Picker
State (TimePickerState):
| Field | Type |
|---|---|
value |
TimeValue |
format |
TimeFormat |
minuteStep |
number |
secondStep |
number |
showSeconds |
boolean |
disabled |
boolean |
Messages: setValue, setHours, setMinutes, setSeconds, incrementHours, decrementHours, incrementMinutes, decrementMinutes, toggleAmPm, setDisabled
Init options: value?: TimeValue, format?: TimeFormat, minuteStep?: number, secondStep?: number, showSeconds?: boolean, disabled?: boolean
Connect options: ConnectOptions
Parts: root, hoursInput, minutesInput, periodTrigger
Utilities: displayHours(), hoursFromDisplay(), period(), formatTime()
Timer
State (TimerState):
| Field | Type |
|---|---|
running |
boolean |
direction |
Direction |
targetMs |
number |
elapsedMs |
number |
startedAt |
number | null |
Messages: start, pause, reset, tick, setTarget
Init options: direction?: Direction, targetMs?: number, elapsedMs?: number
Connect options: ConnectOptions
Parts: root, display, startTrigger, pauseTrigger, resetTrigger
Utilities: display(), isComplete(), parts(), formatMs()
Toast
State (ToasterState):
| Field | Type |
|---|---|
toasts |
Toast[] |
max |
number |
placement |
ToastPlacement |
animated |
boolean |
Messages: create, dismiss, dismissAll, setPlacement, update, tick, pause, resume, pauseAll, resumeAll, animationEnd
Init options: max?: number, placement?: ToastPlacement, animated?: boolean
Connect options: ConnectOptions
Parts: region, toast, progress, isPresent
Utilities: nextToastId(), politeness(), progress(), isPresent(), isPaused()
Toc
State (TocState):
| Field | Type |
|---|---|
items |
TocEntry[] |
activeId |
string | null |
expanded |
string[] |
Messages: setItems, setActive, toggleExpanded, expandAll, collapseAll
Init options: items?: TocEntry[], activeId?: string | null, expanded?: string[]
Connect options: ConnectOptions
Parts: root, list, item
Utilities: isActive(), isExpanded(), watchActiveHeading()
Toggle Group
State (ToggleGroupState):
| Field | Type |
|---|---|
value |
string[] |
type |
'single' | 'multiple' |
items |
string[] |
disabledItems |
string[] |
disabled |
boolean |
orientation |
Orientation |
deselectable |
boolean |
focused |
string | null |
loopFocus |
boolean |
dir |
'ltr' | 'rtl' |
Messages: toggle, setValue, setItems, focusNext, focusPrev, focusItem, setDir
Init options: value?: string[], type?: 'single' | 'multiple', items?: string[], disabledItems?: string[], disabled?: boolean, orientation?: Orientation, deselectable?: boolean, focused?: string | null, loopFocus?: boolean, dir?: 'ltr' | 'rtl'
Parts: root, item
Toggle
State (ToggleState):
| Field | Type |
|---|---|
pressed |
boolean |
disabled |
boolean |
Messages: toggle, setPressed, setDisabled
Init options: pressed?: boolean, disabled?: boolean
Parts: root
Toolbar
State (ToolbarState):
| Field | Type |
|---|---|
items |
string[] |
disabledItems |
string[] |
focused |
string | null |
orientation |
Orientation |
loopFocus |
boolean |
disabled |
boolean |
Messages: setItems, setFocused, focusNext, focusPrev, focusFirst, focusLast
Init options: items?: string[], disabledItems?: string[], focused?: string | null, orientation?: Orientation, loopFocus?: boolean, disabled?: boolean
Connect options: ConnectOptions
Parts: root, separator, item, group
Tooltip
State (TooltipState):
| Field | Type |
|---|---|
open |
boolean |
status |
PresenceStatus |
animated |
boolean |
Messages: show, hide, toggle, setOpen, animationEnd
Init options: open?: boolean, animated?: boolean
Connect options: ConnectOptions
Parts: trigger, positioner, content, arrow
Utilities: overlay(), isMounted()
Tour
State (TourState):
| Field | Type |
|---|---|
steps |
TourStep[] |
open |
boolean |
index |
number |
visited |
string[] |
Messages: start, stop, next, prev, goto, setSteps
Init options: steps?: TourStep[], open?: boolean, index?: number
Connect options: ConnectOptions
Parts: root, backdrop, spotlight, title, description, progressText, prevTrigger, nextTrigger, closeTrigger
Utilities: currentStep(), isFirst(), isLast(), progress()
Tree View
State (TreeViewState):
| Field | Type |
|---|---|
expanded |
string[] |
selected |
string[] |
checked |
string[] |
indeterminate |
string[] |
focused |
string | null |
selectionMode |
SelectionMode |
visibleItems |
string[] |
visibleLabels |
string[] |
disabled |
boolean |
typeahead |
string |
typeaheadExpiresAt |
number |
renaming |
string | null |
renameDraft |
string |
loading |
string[] |
nodes |
Record<string, TreeNodeMeta> |
roots |
string[] |
loaded |
string[] |
loadFailed |
string[] |
Messages: toggleBranch, expand, collapse, expandAll, collapseAll, select, setSelected, focus, focusNext, focusPrev, focusFirst, focusLast, setVisibleItems, typeahead, arrowLeftFrom, arrowRightFrom, toggleChecked, setChecked, setIndeterminate, renameStart, renameChange, renameCommit, renameCancel, loadingStart, loadingEnd, setNodes, childrenLoaded, childrenLoadFailed
Init options: expanded?: string[], selected?: string[], checked?: string[], indeterminate?: string[], selectionMode?: SelectionMode, disabled?: boolean, visibleItems?: string[], visibleLabels?: string[], nodes?: Record<string, TreeNodeMeta>, roots?: string[], loaded?: string[], loadFailed?: string[]
Connect options: ConnectOptions
Parts: root, item
Utilities: isExpanded(), isSelected(), isChecked(), isIndeterminate(), isRenaming(), isLoading(), isLoaded(), isLoadFailed()
Top-Level Exports
Besides the per-component objects (import { menu } from '@llui/components'), the components barrel re-exports these members directly, and @llui/components passes them through. Where the barrel name differs from the component's own, the barrel name is the only one that resolves on @llui/components — the component's own spelling is reachable through its component object or its subpath entry (@llui/components/table).
This table covers the component modules only. The package root additionally re-exports the locale surface (en, LocaleContext), the format/ helpers and the shared utils/ helpers (see Utilities above); those are not listed here.
From @llui/components |
Component | Declared as |
|---|---|---|
validateSchema() |
form |
— |
validateSchemaAsync() |
form |
— |
reorder() |
sortable |
— |
resolveTheme() |
theme-switch |
— |
applyTheme() |
theme-switch |
— |
watchSystemTheme() |
theme-switch |
— |
visibleItems() |
breadcrumbs |
— |
isRowSelected() |
table |
— |
isAllSelected() |
table |
— |
isSomeSelected() |
table |
— |
sortDirectionFor() |
table |
— |
TABLE_HEADER_ROW_INDEX |
table |
HEADER_ROW_INDEX (aliased) |
menubarInit() |
menubar |
init() (aliased) |
menubarUpdate() |
menubar |
update() (aliased) |
menubarConnect() |
menubar |
connect() (aliased) |
menubarOverlay() |
menubar |
overlay() (aliased) |
menubarMachine |
menubar |
menubar (aliased) |
Complete Public API
The component summaries above optimize for everyday authoring. This appendix is generated from every concrete TypeScript entry point declared in package.json#exports and includes the complete importable surface.
@llui/components
Functions
addUnits() from @llui/components
Advance an instant by n whole units, in the offset's calendar.
function addUnits(at: number, unit: CalendarUnit, n: number, opts: CalendarOptions = {}): number
allFiniteNumbers() from @llui/components
Whether every number nested in one atomic runtime payload is usable. Non-numeric leaves are ignored; arrays and plain payload objects are walked so callers cannot accidentally validate one coordinate while committing a bad sibling. Cycles are harmless because an already-seen object contains no new numeric leaves.
function allFiniteNumbers(...values: readonly unknown[]): boolean
anatomy() from @llui/components
function anatomy<P extends string>(name: string, parts: readonly P[]): Anatomy<P>
annularSectorPath() from @llui/components
An annular sector — the polar answer to a bar. r0 is the inner radius (0
gives a pie wedge, > 0 a donut segment); angles follow {@link polarPoint}.
A sweep of a full turn or more is drawn as a complete ring, because the arc endpoints would otherwise coincide and SVG would draw nothing at all.
function annularSectorPath(cx: number, cy: number, r0: number, r1: number, a0: number, a1: number): string
applySelection() from @llui/components
Apply a click/Enter on value to the current selection. Single mode
replaces, multiple toggles, and a disabled item changes nothing — returning
the SAME array reference so the reducer's no-op stays a no-op for the
reference-equality reconciler.
function applySelection(current: string[], value: string, opts: { mode: SelectionMode; disabled?: readonly string[] }): string[]
applyTheme() from @llui/components
Publish the user's PREFERENCE on <html>: data-theme="light" / "dark"
for an explicit choice, and the attribute REMOVED for 'system'.
Removing it is not "doing nothing" — it is how 'system' is spelled, and
tokens-dark.css answers all three states from that one attribute:
@media (prefers-color-scheme: dark) {
:root:where(:not([data-theme='light'])):where(:not(.light)) { … } // absent -> OS
}
.dark,
[data-theme='dark'] { … } // "dark" -> dark
with :root in tokens.css as the light default. So an absent attribute
follows the OS in CSS, and data-theme="light" opts a subtree back out of
the media query.
This takes a {@link Theme}, not a {@link ResolvedTheme}, deliberately (#233).
Three costs to resolving 'system' in JS and pinning the answer, none of
which the CSS route pays:
watchSystemThemebecomes MANDATORY to track OS changes the media query would have followed for free;- SSR has no correct attribute to render, since the server cannot know the
client's
prefers-color-scheme; - the attribute stops naming the PREFERENCE.
data-theme="dark"then means either "the user chose dark" or "the user chose system and the OS is dark", and nothing on the page can tell them apart.
It does NOT cost a first-paint flash — a claim worth not repeating, because it
is false and easy to check: resolveTheme('system') reads the same media
query the first paint used, so the attribute it writes agrees with what is
already on screen.
Use resolveTheme() only when you genuinely need the resolved value IN JS —
canvas theming, a <meta name="theme-color"> — never merely to honour the OS
setting.
THIS FUNCTION DELIBERATELY WRITES NO .dark CLASS (#242), and that is the
decision, not an omission. tokens-dark.css still ships a .dark selector for
consumers whose own tooling writes it (next-themes, an SSR-rendered class), but
maintaining it HERE was rejected on measurement: .dark names a RESOLVED
theme, so a 'system' preference would have to resolve prefers-color-scheme
in JS, and the class then goes stale the moment the OS flips. A stale class
does not merely fail to help — it SUPPRESSES the media query that is correct
with no JS at all. Measured in Chromium against the shipped stylesheets, with
the preference on 'system' throughout:
os=light class=(none) -> LIGHT ok os=dark class=(none) -> DARK ok
os=light class=dark -> DARK !!WRONG!! os=dark class=dark -> DARK ok
os=light class=light -> LIGHT ok os=dark class=light -> LIGHT !!WRONG!!
So the class buys one cell (a consumer's .dark-scoped override under
'system' on a dark OS) at the price of making watchSystemTheme mandatory,
leaving SSR a class it cannot render, and turning a zero-JS-correct cell into
one that is wrong until JS runs. The supported answer for that cell is a CSS
twin block under the media guard — see site/content/styling.md.
The media guard quoted above is :where()-wrapped so it is specificity
(0,1,0), not (0,3,0) (#241). At (0,3,0) it outranked EVERY consumer override of
a base token on a dark OS, including [data-theme='dark'] under an explicit
'dark' preference, where the consumer's selector matches and still lost. Do
not "simplify" the :where()s away, and do not wrap :root itself — that is
(0,0,0) and loses to tokens.css's own :root, breaking dark mode outright.
function applyTheme(theme: Theme): void
areaPath() from @llui/components
A filled region between an upper run and a lower run. The lower run is walked BACKWARDS and appended, then the path is closed — one subpath, so a fill-rule cannot punch a hole in it the way two separate subpaths can.
function areaPath(upper: readonly Point[], lower: readonly Point[], curve: Curve, axis: CurveAxis = 'x'): string
attachFloating() from @llui/components
Position floating relative to anchor with live updates on scroll/resize.
Owns position, top, left, transform and the two available-size
custom properties ({@link FLOATING_AVAILABLE_HEIGHT}) on floating; position and
all four physical inset properties on an optional arrow; and placement
attributes on stateTarget. The arrow's static-side inset is half its
untransformed layout size, so a square arrow straddles the resolved edge.
Cleanup is idempotent, suppresses pending writes/callbacks, and restores the
exact prior values (including priority) or absence of those properties.
export declare function attachFloating(opts: FloatingOptions): () => void;
bandCenter() from @llui/components
The centre of band i in normalized u.
function bandCenter(i: number, band: Band): number
bandExtent() from @llui/components
The [start, end] extent of band i in normalized u.
function bandExtent(i: number, band: Band): [number, number]
calendarTicks() from @llui/components
Calendar boundaries inside [start, end], ascending.
An EMPTY array for a non-finite or reversed range, and for a request that would exceed {@link MAX_CALENDAR_TICKS} — a chart with no data is a normal state, and a truncated axis is a lie.
function calendarTicks(start: number, end: number, opts: CalendarStepOptions = {}): CalendarTick[]
cartesianProjection() from @llui/components
function cartesianProjection(frame: Frame, opts: CartesianOptions = {}): Projection
chooseCalendarStep() from @llui/components
The finest rung whose boundaries fit inside maxTicks.
A FORCED unit is honoured as given (stride 1), except on year, where the
stride still grows to fit — "yearly" over four centuries has to mean
something, and 400 gridlines is not it.
function chooseCalendarStep(start: number, end: number, opts: CalendarStepOptions = {}): CalendarStep
circlePath() from @llui/components
A full circle, as two arcs — one arc of 360° is degenerate and draws nothing, which is the classic way a polar gridline disappears.
function circlePath(cx: number, cy: number, r: number): string
clamp() from @llui/components
Bound n into [min, max]. The result is always FINITE: a non-finite input
maps to a defined legal value instead of being stored verbatim.
Every comparison against NaN is false, so NaN used to fall straight
through to return n and land in state — package-wide, since this is the one
clamp every mutation path routes through (#152). It is not merely a wrong
number: JSON.stringify(NaN) (and Infinity) is null, so a non-finite
value breaks the State-is-JSON-serializable invariant and with it devtools
time-travel, @llui/test replay, agent state snapshots and SSR rehydration.
Rejecting at this boundary is what lets every caller state its own
postcondition — e.g. slider's withThumb — without a finiteness caveat.
function clamp(n: number, min: number, max: number): number
clampToStep() from @llui/components
Clamp into the range AND snap onto the grid. The result is always within
[min, max]: snapping can leave the range when an endpoint is not itself on
the grid (min 0, max 10, step 4 → 10 snaps up to 12), and the answer there is
the last grid value INSIDE the range, not an out-of-range or off-grid one.
It is always FINITE too — the clamp rejects a non-finite input first (#152).
function clampToStep(value: number, grid: NumericGrid): number
countCalendarTicks() from @llui/components
How many boundaries of step fall inside [start, end] — WITHOUT building
them. Constant time at every rung, which is what lets the ladder reject a
finer unit for a century-long span for free.
One informational edge, in the safe direction: within a month of the extremes
of the representable range (year ±271821), flooring to a period start falls
outside what a Date can hold and {@link utcAt} answers NaN, so the count
is 0 and {@link calendarTicks} draws no gridlines. It neither hangs nor
throws; the axis is simply unlabelled at the end of time.
function countCalendarTicks(start: number, end: number, step: CalendarStep, opts: CalendarOptions = {}): number
cssColorAlpha() from @llui/components
Resolved alpha (none -> 1, CSS Color 4's used value for a missing
alpha OUTSIDE interpolation). Inside {@link interpolateColor}, a missing
alpha instead carries the OTHER endpoint's alpha — see its doc comment.
function cssColorAlpha(c: CssColor): number
cssColorToHex() from @llui/components
Gamut-map (if needed) and convert to a displayable hex string.
#rrggbb when alpha is (resolved to) 1, #rrggbbaa otherwise.
function cssColorToHex(c: CssColor): string
cssColorToSrgb() from @llui/components
Convert any {@link CssColor} into fully-resolved (non-null) Srgb,
treating none as 0 and NOT gamut-mapping (an oklch/oklab input outside
sRGB stays outside [0,1]).
function cssColorToSrgb(c: CssColor): Srgb
curvePath() from @llui/components
Join a run of points with the named curve. closed applies to linear
only — the other two are defined on an open, x-increasing run.
function curvePath(points: readonly Point[], curve: Curve, closed = false, axis: CurveAxis = 'x'): string
decimalPlaces() from @llui/components
Fraction digits n is written with, INCLUDING exponential notation —
String(1e-7) is '1e-7', which a scan for '.' reads as zero decimals.
function decimalPlaces(n: number): number
denormalize() from @llui/components
Inverse of {@link normalize}.
function denormalize(u: number, domain: Domain): number
deriveOnce() from @llui/components
Wrap a ONE-ARGUMENT compute so that repeating a call with the same argument
returns the previous result. One cell — the first item of an update pays for
the derivation and the rest read it, so the cost is per UPDATE, not per item
and not per render.
This is the shape that runs per ROW per BINDING per update, so it takes a
FIXED parameter and compares with one Object.is. The variadic deriveOnceN
below materialises a fresh arguments array on every call — one per row per
binding per update — which is pure overhead on the hit path: over a pass of
N items x 4 bindings, 0.00056 -> 0.00041 ms at N=20, 0.00467 -> 0.00337 at
N=200 and 0.05680 -> 0.03823 at N=2000 (~25-33%). Reach for deriveOnceN
only where the derivation genuinely takes several inputs.
NO RUNTIME ARITY GUARD, deliberately. Calling the returned function with a
second argument silently ignores it — g(1,'x') and g(1,'y') both return
the g(1) result from one computation — so a JS consumer, or a TS consumer
who casts, can get a wrong answer with no error. That is accepted because
the only ways to observe arity at runtime are an arguments object (absent
in an arrow) or a rest parameter, and a rest parameter re-materialises the
per-row-per-binding array whose removal is this function's entire reason to
exist — paying the 25-33% back on the hottest path in the package, on every
hit, to catch a call TypeScript already rejects (TS2554). Do NOT "fix" this
by widening the signature. If a derivation needs more than one input, that
is what deriveOnceN is for.
function deriveOnce<A, R>(compute: (arg: A) => R): (arg: A) => R
deriveOnceN() from @llui/components
deriveOnce for a derivation with several inputs (the roving tab stop reads
three or four). Memoized on ARGUMENT IDENTITY, position by position.
function deriveOnceN<A extends readonly unknown[], R>(compute: (...args: A) => R): (...args: A) => R
engineFocus() from @llui/components
Focus el as an engine-initiated move (see runEngineFocus).
export declare function engineFocus(el: HTMLElement, options?: FocusOptions): void;
finiteBound() from @llui/components
A bound as STATE may hold it: the finite number itself, or undefined for
"no bound on this side". THE ONE normalizer for a bound, mirroring clamp's
role for a value (#177).
±Infinity and an ABSENT bound already mean the same thing to every clamp in
the package — clampToStep expands grid.min ?? -Infinity — but only one of
the two spellings survives JSON.stringify, which writes null for both
Infinity and NaN. State must be JSON-serializable (devtools time-travel,
@llui/test replay, agent state snapshots, SSR rehydration all compare
serialized state), so the infinite spelling belongs to the RUNTIME expansion
and never to state: normalize at every write, let the grid expand the absence
again. An unbounded number-input used to store min: -Infinity and
rehydrate as min: null — a number field holding null, on the DEFAULT
configuration.
NaN collapses here too, and that is the half a ?? cannot rescue: NaN is
not nullish, so a NaN bound reached clamp, every comparison against it
was false, and THAT SIDE OF THE RANGE STOPPED CLAMPING — angle-slider after
setMin: NaN stored -9999 for setValue(-9999).
Callers decide what an absent bound means for them, and there are exactly two idioms:
- UNBOUNDED-CAPABLE (
number-input): store theundefinedby OMITTING the key, so the state shape IS aNumericGridand round-trips identically. - INTRINSICALLY BOUNDED (
angle-slider,slider,splitter, …): a requiredmin: numbercannot spell "unbounded", so?? DEFAULTatinitand REJECT the write in asetMin/setMaxreducer — dropping a meaningless bound keeps the range the component already had, which is the only answer that cannot silently disable clamping.
function finiteBound(raw: number | null | undefined): number | undefined
finiteOrDefault() from @llui/components
A component-owned number that has no range to clamp into. Initialization
replaces an unusable input with the field's ordinary default; runtime
reducers use {@link allFiniteNumbers} to refuse the whole message instead.
Keeping those two policies here prevents a free position or timestamp from
accidentally inheriting either the grid-value policy (clamp) or the
optional-bound policy (finiteBound).
function finiteOrDefault(raw: number | null | undefined, fallback: number): number
firstEnabled() from @llui/components
Internal value navigation used by the public roving-focus primitive.
export declare function firstEnabled(items: readonly string[], disabled: readonly string[]): string | null;
firstEnabledIndex() from @llui/components
function firstEnabledIndex(items: readonly string[], disabled: readonly string[]): number | null
flipArrow() from @llui/components
Map a horizontal arrow key to its logical direction, accounting for RTL. This is the SINGLE SOURCE OF TRUTH every component routes horizontal arrow interpretation through. Under rtl, ArrowLeft and ArrowRight swap meaning; vertical arrows (Up/Down), Home/End, PageUp/PageDown and every non-arrow key pass through unchanged.
The second argument is the direction source:
- an explicit
'ltr' | 'rtl'— used directly (the authoritative form when a component storesdirin its own State and passes it in); - an
Element— direction is resolved by walking up the DOM (dir="rtl"ancestor ordocument.documentElement.dir); null— treated as'ltr'(no-op).
export declare function flipArrow(key: string, source: Element | null | TextDirection): string;
floorToUnit() from @llui/components
Floor an instant to the start of its unit, in the offset's calendar.
function floorToUnit(at: number, unit: CalendarUnit, opts: CalendarOptions = {}): number
fmt() from @llui/components
Format a coordinate. A non-finite input becomes 0 — a NaN in a path
silently voids the WHOLE path element, taking every later command with it.
function fmt(n: number): string
focusLingeredInside() from @llui/components
Whether focus LINGERED INSIDE the layer, i.e. whether restoring it to the anchor respects the user rather than overriding them.
function focusLingeredInside(query: FocusRestoreQuery): boolean
focusRovingItem() from @llui/components
Move DOM focus to the roving item identified by value within the same
widget instance as origin.
Roving-tabindex widgets track the active index in STATE, but assistive tech
follows real DOM focus — so after a keyboard move the handler MUST also move
focus, or arrow keys are silent for AT. origin is the event's
currentTarget (the item that received the key); its closest
[data-scope][data-part="root"] ancestor scopes the search so sibling
widgets of the same scope never cross-focus. No-op if nothing matches.
send() is synchronous and items already exist in the DOM, so this can be
called immediately after the navigation send.
export declare function focusRovingItem(origin: Element | null, scope: string, value: string, opts?: {
itemPart?: string;
attr?: string;
}): void;
focusRovingTab() from @llui/components
Move DOM focus to the trigger whose data-value matches, within
container. Relies only on the role="tab" + data-value contract
(shared by components/tabs and any hand-rolled tablist). No-op when no
trigger matches. Call after the DOM reflects the new active tab (e.g. in
a microtask if activation triggers a re-render).
export declare function focusRovingTab(container: Element, value: string): void;
formatDate() from @llui/components
function formatDate(value: DateValue, opts: FormatDateOptions = {}): string
formatDateTime() from @llui/components
function formatDateTime(value: DateValue, opts: FormatDateTimeOptions = {}): string
formatDisplayName() from @llui/components
function formatDisplayName(value: string, type: DisplayNameType, opts: FormatDisplayNameOptions = {}): string | undefined
formatFileSize() from @llui/components
function formatFileSize(value: number | bigint, opts: FormatFileSizeOptions = {}): string
formatHex() from @llui/components
function formatHex(rgb: Rgb255): string
formatHex8() from @llui/components
function formatHex8(rgb: Rgb255, alpha: number): string
formatList() from @llui/components
function formatList(value: string[], opts: FormatListOptions = {}): string
formatNumber() from @llui/components
function formatNumber(value: number, opts: FormatNumberOptions = {}): string
formatOklch() from @llui/components
oklch(L C H) / oklch(L C H / A). Fixed precision: L as a 0–1
fraction at 4 dp, C at 4 dp, H at 2 dp — chosen so the string is stable
and round-trips through {@link parseCssColor} without drifting.
function formatOklch(ok: Oklch, alpha = 1): string
formatPlural() from @llui/components
function formatPlural(value: number, messages: PluralMessages, opts: FormatPluralOptions = {}): string
formatRelativeTime() from @llui/components
function formatRelativeTime(value: number, unit: RelativeTimeUnit, opts: FormatRelativeTimeOptions = {}): string
formatRgb() from @llui/components
function formatRgb(rgb: Rgb255, alpha = 1): string
formatTime() from @llui/components
function formatTime(value: DateValue, opts: FormatTimeOptions = {}): string
gamutMapOklchToSrgb() from @llui/components
CSS Color 4 §13.2 gamut mapping: reduce OKLCH chroma by binary search until
simple-clipping the result into sRGB is perceptually indistinguishable
(deltaEOK < the 0.02 JND) from the reduced color, then clip. Given an
already-in-gamut color this returns it converted (and defensively clipped
for float noise) rather than a no-op, so the result is always safe to hand
straight to {@link srgbToRgb255}. test/utils/color.test.ts pins the
converged chroma for a known out-of-gamut color as a regression value
(this file's own binary search, not an independent oracle — see that
test's comment for what it does and does not prove).
function gamutMapOklchToSrgb(ok: Oklch): Srgb
getFocusables() from @llui/components
export declare function getFocusables(container: Element): HTMLElement[];
getNestedLayers() from @llui/components
Currently-registered nested-layer elements (resolvers re-read live).
With an aspect, only registrations that participate in it; without one, all
of them. With a within boundary, only registrations nested inside it (see
the module comment); without one, the flat, layer-agnostic answer.
export declare function getNestedLayers(aspect?: NestedLayerAspect, within?: NestedLayerScope): Element[];
hslToHsv() from @llui/components
Convert HSL (h 0-360, s/l 0-100) to HSV (h 0-360, s/v 0-100), float.
function hslToHsv(hsl: Hsl): Hsv
hslToRgb255() from @llui/components
Convert HSL (h 0-360, s/l 0-100) to RGB (0-255 each, rounded).
function hslToRgb255(hsl: Hsl): Rgb255
hsvToHsl() from @llui/components
Convert HSV (h 0-360, s/v 0-100) to HSL (h 0-360, s/l 0-100), float.
function hsvToHsl(hsv: Hsv): Hsl
hsvToOklch() from @llui/components
function hsvToOklch(hsv: Hsv): Oklch
hsvToRgb255() from @llui/components
HSV -> RGB255, via the DIRECT (not HSL-routed) float conversion.
function hsvToRgb255(hsv: Hsv): Rgb255
icon() from @llui/components
Render one Iconify glyph as a real <svg>.
name is prefix:name ('lucide:star', 'simple-icons:github'; a bare
name is a Lucide glyph) or a ReadSignal<string> of one, in which case the glyph
follows the signal. props spread onto the <svg>; class is passed through
as given, so a caller sizes and colours the glyph from there.
function icon(name: string | ReadSignal<string>, props?: ElProps): Mountable
indexMap() from @llui/components
A position lookup over a state array: positions(s.items).get(item) in place
of s.items.indexOf(item). First occurrence wins, matching indexOf.
function indexMap<T>(): (values: readonly T[] | null | undefined) => ReadonlyMap<T, number>
inSrgbGamut() from @llui/components
Whether ok, converted to sRGB, falls inside [0,1] on every channel
(within epsilon — floating-point round-trip noise).
function inSrgbGamut(ok: Oklch, epsilon = 1e-4): boolean
interpolateColor() from @llui/components
Interpolate between two colors per CSS Color 4 §12: convert both into
space, resolve missing (none, or a cross-space-converted achromatic
color's undefined) components — including alpha itself, which carries the
OTHER endpoint's alpha when missing rather than defaulting to 1 (CSS Color
4 §12.2's analogous-component rule applies to alpha too — rgb(255 0 0 / none) mixed with rgb(0 0 255 / 0.2) has alpha exactly 0.2 throughout,
not a lerp toward 1; pinned in test/utils/color.test.ts) —
premultiply non-hue channels by alpha, lerp, then un-premultiply.
hueMethod controls which way a hue-bearing space (hsl, oklch)
rotates, normalizing both hues to [0,360) FIRST (see {@link adjustHue});
ignored for spaces with no hue.
function interpolateColor(a: CssColor, b: CssColor, t: number, space: InterpolationSpace, hueMethod: HueInterpolationMethod = 'shorter'): CssColor
isAllSelected() from @llui/components
function isAllSelected(state: TableState): boolean
isDateOnly() from @llui/components
function isDateOnly(value: DateValue): value is string
isEnabledItem() from @llui/components
An item counts as navigable only while it is in the list AND not disabled.
function isEnabledItem(items: readonly string[], disabled: readonly string[], value: string): boolean
isEngineFocusInProgress() from @llui/components
Whether an engine-initiated focus move is in flight. Consulted by
watchInteractOutside to gate its focusin path.
export declare function isEngineFocusInProgress(): boolean;
isFocusable() from @llui/components
Find focusable descendants within a container.
export declare function isFocusable(el: Element): boolean;
isInNestedLayer() from @llui/components
Whether target is inside (or equal to) a registered nested layer that
participates in aspect (any layer when aspect is omitted) and is nested
inside within (any layer when within is omitted).
export declare function isInNestedLayer(target: Node | null, aspect?: NestedLayerAspect, within?: NestedLayerScope): boolean;
isRowSelected() from @llui/components
function isRowSelected(state: TableState, id: string): boolean
isSomeSelected() from @llui/components
function isSomeSelected(state: TableState): boolean
isTypeaheadKey() from @llui/components
Returns true if the key event should trigger a typeahead query — i.e., a
single printable character that isn't a modified keyboard shortcut. Use
this in onKeyDown handlers to decide whether to dispatch a typeahead
message.
function isTypeaheadKey(e: KeyboardEvent): boolean
lastEnabled() from @llui/components
export declare function lastEnabled(items: readonly string[], disabled: readonly string[]): string | null;
lastEnabledIndex() from @llui/components
function lastEnabledIndex(items: readonly string[], disabled: readonly string[]): number | null
linearPath() from @llui/components
Straight segments through every point.
function linearPath(points: readonly Point[], closed = false): string
linearSrgbToOklab() from @llui/components
function linearSrgbToOklab(s: Srgb): Oklab
linearToSrgb() from @llui/components
function linearToSrgb(s: Srgb): Srgb
loadIcon() from @llui/components
Resolve one glyph, batched: one HTTP request per PREFIX per tick, not one per icon. A page rendering a dozen chevrons asks Iconify once; the batch is flushed on a macrotask so every icon mounted during the same view build joins it. Exposed so a consumer can warm the cache ahead of a mount.
function loadIcon(name: string): Promise<IconData | null>
lockBodyScroll() from @llui/components
Lock body scroll while an overlay is open, preserving scrollbar width to avoid layout shift. Reference-counted so nested locks compose cleanly.
export declare function lockBodyScroll(): () => void;
membershipSet() from @llui/components
A membership lookup over a state array: set(s.value).has(item) in place of
s.value.includes(item). An absent collection reads as empty.
An EMPTY array shares EMPTY_SET rather than allocating: most of the ~16
call sites are a disabled/disabledItems list that is empty in the common
case, and an empty Set is the one input where the memo would otherwise pay
an allocation to answer false to everything.
function membershipSet<T>(): (values: readonly T[] | null | undefined) => ReadonlySet<T>
menubarConnect() from @llui/components
function menubarConnect(state: ReadSignal<MenubarState>, send: Send<MenubarMsg>, opts: ConnectOptions): MenubarParts
menubarInit() from @llui/components
function menubarInit(opts: MenubarInit): MenubarState
menubarOverlay() from @llui/components
Render one top-level menu's dropdown. Mirrors menu.overlay but is gated on
state.open === menuId and dismisses by closing the menubar (returning
focus to the top-level trigger). Submenu unwinding goes through the same
dismissable stack the menu machine uses.
function menubarOverlay(opts: MenubarOverlayOptions): Mountable
menubarUpdate() from @llui/components
function menubarUpdate(state: MenubarState, msg: MenubarMsg): [MenubarState, never[]]
monotonePath() from @llui/components
Monotone cubic interpolation (Fritsch–Carlson). Its defining property is that the curve NEVER OVERSHOOTS the data: between two samples it stays within their values, so a series that only rises cannot dip below a point on the way. A plain Catmull-Rom or cardinal spline does overshoot, and on a chart that means drawing a number nobody measured.
Defined on a function y = f(x) with strictly increasing x. It is therefore
meaningless on a closed angular loop, which is why polarProjection declines
it — see projection.ts.
function monotonePath(points: readonly Point[], axis: CurveAxis = 'x'): string
nearestBand() from @llui/components
The band index nearest a normalized position — the hit test behind hover and
pointer tracking. Returns null for an empty axis.
Nearest-CENTRE, not containment: the gaps between bands belong to whichever band is closer, so a pointer never falls into a dead zone between two bars.
function nearestBand(u: number, band: Band): number | null
nextEnabled() from @llui/components
export declare function nextEnabled(items: readonly string[], disabled: readonly string[], from: string, delta: 1 | -1, loop: boolean): string | null;
nextEnabledIndex() from @llui/components
The index of the next enabled item delta steps from from, wrapping.
from === null starts before the first item (delta 1) or after the last
(delta -1), so the first/last enabled index comes back.
function nextEnabledIndex(items: readonly string[], disabled: readonly string[], from: number | null, delta: 1 | -1): number | null
niceDomain() from @llui/components
Extend [min, max] outward to the nearest round tick boundaries, so the axis
ends on a labelled value instead of mid-air. Iterates because widening the
domain can change the chosen step, which can change the boundaries again;
d3's nice does the same, and it converges in at most a few rounds.
function niceDomain(min: number, max: number, count: number): Domain
normalize() from @llui/components
Map a value into [0, 1] across domain. A DEGENERATE domain (min === max)
maps everything to 0.5 rather than dividing by zero — a single-valued series
draws as a flat line through the middle, which is the only reading that is
not a crash or a NaN in a path string.
function normalize(value: number, domain: Domain): number
oklabToLinearSrgb() from @llui/components
function oklabToLinearSrgb(lab: Oklab): Srgb
oklabToOklch() from @llui/components
function oklabToOklch(lab: Oklab): Oklch
oklabToSrgb() from @llui/components
NOT gamut-mapped — may return channels outside [0,1] for an OKLab/OKLCH color outside sRGB. Use {@link gamutMapOklchToSrgb} for a displayable result.
function oklabToSrgb(lab: Oklab): Srgb
oklchToHsv() from @llui/components
Gamut-maps into sRGB first — HSV cannot represent an out-of-gamut color.
function oklchToHsv(ok: Oklch): Hsv
oklchToOklab() from @llui/components
function oklchToOklab(ok: Oklch): Oklab
oklchToSrgb() from @llui/components
NOT gamut-mapped. See {@link oklabToSrgb}.
function oklchToSrgb(ok: Oklch): Srgb
parseCssColor() from @llui/components
Parse a CSS color string into its typed model. Supports #rgb/#rgba/
#rrggbb/#rrggbbaa, rgb()/rgba(), hsl()/hsla() (both legacy comma
and modern space/slash syntax, with the legacy grammar's none-forbidden
and uniform-percentage-or-number rules enforced), hwb(), oklch(),
oklab(), lab(), lch(), color() (predefined spaces srgb,
srgb-linear, display-p3, a98-rgb, prophoto-rgb, rec2020, xyz,
xyz-d50, xyz-d65), none components, transparent, and the 148 CSS
Color 4 named colors. Returns null on anything else — never throws.
function parseCssColor(input: string): CssColor | null
parseDateValue() from @llui/components
function parseDateValue(value: DateValue): ParsedDateValue
parseHexColor() from @llui/components
Parse #rgb, #rgba, #rrggbb, #rrggbbaa. Returns null on anything
else, never throws.
function parseHexColor(hex: string): { rgb: Rgb255; alpha: number } | null
pointerDragHandlers() from @llui/components
Build the five pointer handlers for one drag track. One call per live
connect() instance — dragging/activePointerId are closure-scoped per
instance, the same shape as this package's other per-instance mutable state
(e.g. color-picker's eyedropper pendingEyeDropper AbortController).
- Primary button only on
pointerdown(e.button !== 0is ignored), AND only when no drag is already live — a second finger/pointer touching down mid-drag is ignored rather than hijackingactivePointerId. - Every subsequent event (
pointermove/pointerup/pointercancel/onLostPointerCapture) is checked againstactivePointerId: a second pointer's events never affect a drag it didn't start. setPointerCaptureonpointerdown'scurrentTarget, sopointermovekeeps firing even once the pointer leaves the element's bounds — a plain drag with no capture stalls out at the track's edge instead of saturating like a native<input type="range">.- Released on
pointerup/pointercancel, guarded byhasPointerCaptureso releasing twice (or without ever capturing) is a harmless no-op. draggingis unconditionally reset ononLostPointerCapturefor the active pointer, even though that handler never callsrelease()itself (capture is already gone by definition when this fires).
function pointerDragHandlers(callbacks: PointerDragCallbacks): PointerDragHandlers
polarPoint() from @llui/components
A point on a circle. Angles are RADIANS CLOCKWISE FROM 12 O'CLOCK, which is where every reader expects a chart to start; SVG's own 0 is 3 o'clock.
function polarPoint(cx: number, cy: number, r: number, angle: number): Point
polarProjection() from @llui/components
function polarProjection(frame: Frame, opts: PolarOptions = {}): Projection
positiveFinite() from @llui/components
A finite number strictly greater than zero, or undefined when unusable.
function positiveFinite(raw: number | null | undefined): number | undefined
positiveFiniteOrDefault() from @llui/components
A positive finite number, or the field's ordinary initialization default.
function positiveFiniteOrDefault(raw: number | null | undefined, fallback: number): number
presenceEndHandler() from @llui/components
Guard a presence "animation/transition ended" handler so it only advances the
presence machine when the event fired on the element the listener is bound to
(e.target === e.currentTarget) — never on a bubbling descendant.
Overlay content (dialog, popover, menu, toast) reflects its exit phase via
data-state="closing" and stays mounted until an animationend/transitionend
dispatches animationEnd/transitionEnd. Without this guard, ANY descendant
animation or transition ending during the exit — a spinner, a ripple, a child
fade — bubbles up and prematurely unmounts the overlay before its own exit
animation completes.
Mirrors the e.target === el guard the transitions runtime applies in
waitForEnd (@llui/transitions).
function presenceEndHandler<E extends AnimationEvent | TransitionEvent>(handler: (e: E) => void): (e: E) => void
projectionFor() from @llui/components
Build the projection a chart's coordinate setting names.
function projectionFor(coord: ChartCoord, frame: Frame, opts: CartesianOptions & PolarOptions = {}): Projection
pruneToEnabled() from @llui/components
Keep value only while it still names an enabled item, else null. Every
reducer that replaces the item list owes this to whatever it holds as
focused/selected — a dangling reference is the tab-stop bug.
function pruneToEnabled(items: readonly string[], disabled: readonly string[], value: string | null): string | null
pushDismissable() from @llui/components
Register a dismissable layer. Escape is offered to the layers top-down until
one CLAIMS it (a layer declines via disableEscape or an onEscape router
returning false, and the key then falls through to the layer beneath);
outside-click is topmost-only. Returns a cleanup that removes the layer from
the stack.
Push a layer even when both dismissal routes are disabled: the layer is the caller's PLACE ON THE STACK, which is what stops the layer beneath from treating an interaction inside this one as an outside interaction.
export declare function pushDismissable(opts: DismissableOptions): () => void;
pushFocusTrap() from @llui/components
Push a focus trap onto the stack. Tab/Shift+Tab will cycle within the container's focusable descendants. Returns a cleanup that removes the trap and (optionally) restores focus to the element active before push.
export declare function pushFocusTrap(opts: FocusTrapOptions): () => void;
rectPath() from @llui/components
An axis-aligned rectangle, corners in any order.
function rectPath(x0: number, y0: number, x1: number, y1: number): string
registerNestedLayer() from @llui/components
Register source (an element, array of elements, or a resolver returning
either) as a nested layer. Returns a cleanup that removes the registration.
Prefer the resolver form for a portaled overlay: register once on mount and
return the live root only while open ([] when closed), so a single
registration tracks the overlay's open/closed lifecycle without churn.
Pass opts.owner when scoped consumers must exempt the layer. Missing or
unresolved ownership fails closed and warns in development.
export declare function registerNestedLayer(source: ElementSource, opts?: NestedLayerOptions): () => void;
reorder() from @llui/components
Move an item in an array from one index to another, returning a new array. Out-of-range indices are clamped to array bounds.
function reorder<T>(arr: readonly T[], from: number, to: number): T[]
resetAnatomyIdCounter() from @llui/components
Reset the internal id counter — tests only.
function resetAnatomyIdCounter(): void
resolveDir() from @llui/components
Resolve the text direction for an element by walking up the DOM tree. Returns 'rtl' or 'ltr' (default).
The walk crosses SHADOW boundaries: Element.closest() stops at the
nearest shadow root and cannot see a dir set on an ancestor of the host,
so an element inside a shadow tree whose host (or the host's own
ancestors) declares dir would otherwise silently read as ltr. Continuing
from root.host after getRootNode() returns a ShadowRoot walks out to
the light-DOM ancestor and keeps going, arbitrarily many shadow levels
deep. The final fallback reads dir off the element's OWN document
(ownerDocument), never the global document — the global binding names a
DIFFERENT document inside an iframe or any other multi-document context.
export declare function resolveDir(el: Element): TextDirection;
resolveNone() from @llui/components
A null (CSS none) component behaves as 0 outside interpolation.
function resolveNone(n: number | null): number
resolvePluralCategory() from @llui/components
function resolvePluralCategory(value: number, opts: FormatPluralOptions = {}): PluralCategory
resolveRovingMove() from @llui/components
Map a keyboard key + the current tab value to a roving-tablist move,
or null when the key isn't a navigation/activation key or the move is
a no-op (empty list, no enabled sibling). Pure — does not touch the DOM
or call preventDefault; the caller decides (typically: prevent default
iff the result is non-null).
export declare function resolveRovingMove(key: string, current: string, items: readonly RovingItem[], opts?: RovingOptions): RovingMove | null;
resolveTextDirection() from @llui/components
Normalize any accepted direction source to a concrete TextDirection.
An explicit 'ltr' | 'rtl' wins; an Element is resolved from the DOM;
null / undefined default to 'ltr'.
export declare function resolveTextDirection(source: Element | null | undefined | TextDirection): TextDirection;
resolveTheme() from @llui/components
Resolve a theme preference to the actual theme to apply. Returns 'dark' or
'light' based on the user's setting, consulting prefers-color-scheme for
'system'.
function resolveTheme(theme: Theme): ResolvedTheme
rgb255ToHsl() from @llui/components
Convert RGB (0-255 each) to HSL (h 0-360, s/l 0-100), float.
function rgb255ToHsl(rgb: Rgb255): Hsl
rgb255ToHsv() from @llui/components
RGB255 -> HSV, via the DIRECT (not HSL-routed) float conversion.
function rgb255ToHsv(rgb: Rgb255): Hsv
rovingTabStop() from @llui/components
The single item that carries tabindex="0".
WAI-ARIA's roving-tabindex pattern requires EXACTLY ONE tab stop in a
composite widget: with none, Tab skips the widget entirely and it becomes
keyboard-unreachable. So a preferred candidate (the focused item, the
checked radio, …) is honoured only while it is still an enabled member, and
the first enabled item answers otherwise. Null only when nothing is enabled.
Every roving-tabindex widget in the package routes through here (#145 closed
the last three: menubar, navigation-menu and tags-input). Keep it that way —
an inline focused === x ? 0 : -1 has no fallback, and since nothing prunes
focused against the current list, removing or disabling the focused item
leaves EVERY item at -1 and the widget disappears from the Tab order.
tags-input is index-keyed and passes String(i) as the item identity (its
data-index, and the only identity that survives duplicate tag values);
navigation-menu passes either the membership list its consumer maintains or
the ids handed to its own item(), filtered first to the ones not sealed
inside a closed submenu — membership alone would seat the stop on an element
inside a hidden panel, which is present, unique and untabbable.
Null when nothing is enabled is deliberate and is a caller's problem to
notice: a widget whose items are ALL disabled ends up with no tab stop at
all. That is right for radio-group/toggle-group/toolbar/tree-view,
whose items are genuinely disabled and therefore unfocusable anyway, and it
is a 1 -> 0 change for menubar, whose triggers carry only aria-disabled
and stay focusable. Any revision belongs here, applying to every caller at
once — not in one component.
function rovingTabStop(items: readonly string[], disabled: readonly string[], ...preferred: readonly (string | null | undefined)[]): string | null
runEngineFocus() from @llui/components
Run body with engine-focus suppression active. Any focusin raised inside
it is invisible to watchInteractOutside — including one raised by a focus
move that re-entrant consumer code makes from a focusin listener (see the
module comment: the window is the synchronous transitive closure, not just
the .focus() call).
SYNCHRONOUS BY CONTRACT, AND THE CONTRACT IS ENFORCED (#172). The suppression
is released when body RETURNS. An async body returns its promise at the
first await, so the depth counter drops immediately and the focus move that
eventually happens gets NO protection at all — a call that looks correct,
compiles, and does nothing. The failure is safe (no protection, never a stuck
guard: the decrement is in a finally), which is exactly why it is invisible,
and this is a PUBLIC export documented as the thing a custom overlay "must"
route its engine-initiated focus moves through. A consumer following that
advice with an async body would reintroduce #155 in their app while believing
they had prevented it.
Two guards, because neither covers the other's case:
- The SIGNATURE rejects a promise-returning body at compile time. It is the real guard — it fires before the code ever runs. Its one blind spot is a body whose return type is an unresolved type parameter (a generic pass-through wrapper): the conditional is deferred, so such a wrapper is rejected too and must carry its own constraint. No caller does.
- A DEV-MODE warning catches what the type system cannot see: a JavaScript
consumer, an
any-typed body, or a body that returns a thenable without being declared as returning one. It cannot restore the protection — by the time a thenable is in hand the guard is already released — so it only reports.
Deliberately NOT offered: an async-aware variant that holds the guard across
an await. The guard is safe because no user event can be delivered inside
its window (see the module comment); holding it across a suspension point
hands the event loop back and would start swallowing genuine interactions.
export declare function runEngineFocus<T>(body: () => SyncEngineFocusBody<T>): T;
setAriaHiddenOutside() from @llui/components
Hide sibling subtrees from assistive tech while an overlay is open.
Walks from target up to the document root, applying aria-hidden="true"
and inert to every sibling at each level. Previous attribute values are
recorded and restored on cleanup.
Two kinds of element are EXEMPT: registered nested layers (see
registerNestedLayer) and live regions — aria-live, role="alert",
role="status", role="log". A live region under aria-hidden is simply
never read out, so a modal that sweeps one silences the app's announcement
channel for exactly as long as it is open (#123). Live regions are matched by
selector rather than registration because they are plain part bags with no
mount hook of their own, and because that also covers consumer-authored ones.
An exempt element does NOT spare its whole ancestor subtree: the sweep
descends through any element that merely CONTAINS one and hides everything
hanging off the path down to it. (Skipping the ancestor wholesale would leave
the entire app interactive behind the modal the moment a form somewhere held
an aria-live error message.) inert and aria-hidden both inherit, so
leaving the path clear is the only way an exempt element stays reachable.
Nested calls are supported — each layer only touches elements that haven't been claimed by a higher layer (tracked via a WeakMap reference count).
TWO KNOWN LIMITS of the live-region exemption, both deliberate:
inertcannot be split fromaria-hidden— sparing a region spares its WHOLE SUBTREE from both, so an interactive live region (arole="log"transcript containing links) stays Tab-reachable behind a modal. Keep live regions to announcement text; put controls outside them, or register the modal's own layer for the interactive part.document.querySelectorAlldoes not pierce shadow roots, so a live region inside one is not exempt. The sweep itself only ever walks light-DOM ancestors oftarget, so this only bites when the region and the modal live in different trees.
export declare function setAriaHiddenOutside(target: Element): () => void;
snapToStep() from @llui/components
Nearest multiple of step from origin. A non-positive step is a no-op.
A non-finite value names no position on the grid, so it snaps to the grid's
own anchor — the same policy clamp applies to the range (#152). This is
unreachable from clampToStep, which clamps first; it keeps the util's
direct consumers on the same rule.
function snapToStep(value: number, step: number, origin = 0): number
sortDirectionFor() from @llui/components
function sortDirectionFor(state: TableState, columnId: string): SortDirection | null
srgb255ToSrgb() from @llui/components
function srgb255ToSrgb(rgb: Rgb255): Srgb
srgbToLinear() from @llui/components
function srgbToLinear(s: Srgb): Srgb
srgbToOklab() from @llui/components
function srgbToOklab(s: Srgb): Oklab
srgbToOklch() from @llui/components
function srgbToOklch(s: Srgb): Oklch
srgbToRgb255() from @llui/components
Rounds AND clamps into 0-255 — the boundary where an out-of-gamut float (an un-mapped OKLCH conversion) must finally become a displayable byte.
function srgbToRgb255(s: Srgb): Rgb255
stepBy() from @llui/components
Move count whole steps (negative to step down), then clamp+snap.
From an OFF-GRID value one call moves to the nearest grid value in the
direction of travel and stops there — that jump is the whole change, however
large count is. This is HTML's stepUp/stepDown (step 3 of the
value-stepping algorithm) and it is what makes increment land on the grid
instead of dragging an off-grid value along forever.
ONE DELIBERATE DIVERGENCE from the spec: HTML's step base falls back min ->
the value CONTENT ATTRIBUTE -> 0; gridOrigin goes min -> 0. A headless
machine has no content attributes — the seed value is just the initial state,
and anchoring the grid on it would make two components with the same
min/max/step disagree about which values are legal depending on where they
happened to start.
function stepBy(value: number, count: number, grid: NumericGrid): number
stepPath() from @llui/components
Axis-aligned staircase: hold each value until halfway along the independent axis, then step.
function stepPath(points: readonly Point[], axis: CurveAxis = 'x'): string
tickIncrement() from @llui/components
The step size a nice axis would use to fit about count ticks into
[start, stop]. A negative return means "one tick every 1/-step units" —
d3's encoding, kept because the reciprocal form avoids the float drift that
10 ** -power introduces for small steps.
function tickIncrement(start: number, stop: number, count: number): number
ticks() from @llui/components
Round, human-readable tick values covering [start, stop]. Returns an EMPTY
array for a non-finite or degenerate range rather than looping forever —
a chart with no data is a normal state, not an error.
function ticks(start: number, stop: number, count: number): number[]
typeaheadAccumulate() from @llui/components
Advance the typeahead query based on a new keystroke and the previous
expiration time. Returns the new query string; callers combine this with
typeaheadMatch() to produce a new highlight index.
function typeaheadAccumulate(prev: string, char: string, now: number, expiresAt: number): string
typeaheadMatch() from @llui/components
Find the first enabled item whose label starts with the query
(case-insensitive). labels and disabledMask are parallel arrays.
startFrom is the current highlighted index; for single-character
queries the search begins at startFrom + 1 (so repeated "s" keys
cycle), for multi-character queries it begins at startFrom (inclusive).
Returns the matching index, or null if no enabled item matches.
function typeaheadMatch(labels: string[], disabledMask: boolean[], query: string, startFrom: number | null): number | null
typeaheadMatchByItems() from @llui/components
Convenience: pass a disabled list of values instead of a boolean mask.
Builds the mask by checking membership via === on the raw string values.
function typeaheadMatchByItems(items: string[], disabled: readonly string[], query: string, startFrom: number | null): number | null
validateSchema() from @llui/components
Run a Standard Schema synchronously against a values object. Throws if the schema returns a Promise — use sync validation only for form submit.
Works with any library implementing the Standard Schema spec: Zod (v3.24+), Valibot (v1+), ArkType, etc.
function validateSchema<T>(schema: StandardSchemaV1<T>, values: unknown): ValidateResult<T>
validateSchemaAsync() from @llui/components
Async variant — returns a Promise. Use when the schema performs async validation (e.g. uniqueness checks against a backend).
function validateSchemaAsync<T>(schema: StandardSchemaV1<T>, values: unknown): Promise<ValidateResult<T>>
valueDomain() from @llui/components
The value-axis domain for a set of series.
min / max are UNBOUNDED-CAPABLE bounds in the sense of finiteBound
(#177): an absent one means "derive it", never ±Infinity. A derived domain
always INCLUDES ZERO, because a bar whose baseline is not zero misrepresents
its own length — the one axis default worth being opinionated about. A caller
who wants otherwise passes both bounds.
function valueDomain(values: readonly number[], opts: { min?: number; max?: number; tickCount: number }): Domain
visibleItems() from @llui/components
Compute the visible breadcrumb trail. When maxVisible is set and exceeded
and the trail is not expanded, collapse the middle to:
[first] … [last (maxVisible - 1) items]. The final item is always current.
function visibleItems(state: BreadcrumbsState): VisibleBreadcrumb[]
watchInteractOutside() from @llui/components
Watch for pointer or focus events outside a given element. Returns a
cleanup function. Uses the capture phase so upstream stopPropagation
calls cannot hide events.
- pointerdown (or mousedown/touchstart fallback) triggers "outside" if the
target is not contained by
elementorignore. - focusin triggers "outside" when focus moves outside the element, except
when the new target is in
ignore.
export declare function watchInteractOutside(opts: InteractOutsideOptions): () => void;
watchSystemTheme() from @llui/components
Listen for system theme changes (when user has selected 'system'). Returns
a cleanup function. Call this in onMount and dispatch setTheme on
change if you want the UI to auto-follow OS settings.
function watchSystemTheme(callback: (theme: ResolvedTheme) => void): () => void
Types
AcceptValue from @llui/components
File upload — input element + drag-and-drop zone. Tracks selected files, drag state, accept filters, validation errors. Multiple or single selection.
accept can be either a raw HTML-accept string ("image/*,.pdf") or a
MIME-object ({ 'image/*': ['.png', '.jpg'], 'application/pdf': [] }).
The object form is validated client-side per file; the raw string form
only drives the browser's native picker filter.
Files that fail validation (too large, too small, wrong type, over the
count limit) flow into rejectedFiles with a list of FileError codes
attached. The view can render them alongside accepted files.
State holds FileMeta records — plain JSON — never the live File
objects: State must be JSON-serializable (CLAUDE.md), and a File came
back from a round-trip as {}, wiping every name and turning totalSize
into NaN (#119). The handles live in a module-scoped registry keyed by
FileMeta.id; see trackFile/getFile/releaseDropped.
A restored State has no handles. Serializability is exactly what makes
that so: SSR hydration, replayTrace and an agent state snapshot carry the
FileMeta records and nothing else, so after a restore getFile() returns
undefined for every one of them. That is correct and unavoidable — a File
cannot cross the wire — but it is invisible unless you are told, so a view
must treat a missing handle as normal (render the metadata, skip the object-URL
preview) and a re-upload needs a fresh selection from the user.
export type AcceptValue = string | Record<string, string[]>
AccordionMsg from @llui/components
export type AccordionMsg =
/** @intent("Toggle the named accordion item open/closed") */
| ({ type: 'toggle'; value: string } & Retain)
/** @intent("Open the named accordion item") */
| ({ type: 'open'; value: string } & Retain)
/** @intent("Close the named accordion item") */
| ({ type: 'close'; value: string } & Retain)
/** @intent("Replace the set of currently-open items with the provided values") */
| ({ type: 'setValue'; value: string[] } & Retain)
/** @humanOnly */
| { type: 'setItems'; items: string[] }
/** @humanOnly */
| { type: 'focusNext'; value: string }
/** @humanOnly */
| { type: 'focusPrev'; value: string }
/** @humanOnly */
| { type: 'focusFirst' }
/** @humanOnly */
| { type: 'focusLast' }
/** @humanOnly — sent by the retained content's own animation end/cancel event,
* or by `exitCompletion`'s own cleanup settling every still-closing item once
* the last watcher for this `id` detaches. */
| { type: 'exitComplete'; value: string; generation: number }
Activation from @llui/components
export type Activation = 'automatic' | 'manual'
AlertDialogConnectOptions from @llui/components
Connect options — the dialog options minus role (fixed to alertdialog).
export type AlertDialogConnectOptions = Omit<DialogConnectOptions, 'role'>
AlertDialogMsg from @llui/components
export type DialogMsg =
/** @intent("Open the dialog") */
| { type: 'open' }
/** @intent("Close the dialog") */
| { type: 'close' }
/** @intent("Toggle the dialog open/closed") */
| { type: 'toggle' }
/** @intent("Set the dialog's open state to a specific value") */
| { type: 'setOpen'; open: boolean }
/** @humanOnly */
| { type: 'animationEnd' }
/** @humanOnly */
| { type: 'transitionEnd' }
AlertDialogParts from @llui/components
export type AlertDialogParts = DialogParts
AngleSliderMsg from @llui/components
export type AngleSliderMsg =
/** @intent("Set the angle in degrees (clamped to min/max, snapped to step)") */
| { type: 'setValue'; value: number }
/** @intent("Increase the angle by `steps` × step (default: 1 step)") */
| { type: 'increment'; steps?: number }
/** @intent("Decrease the angle by `steps` × step (default: 1 step)") */
| { type: 'decrement'; steps?: number }
/** @humanOnly */
| { type: 'setMin'; min: number }
/** @humanOnly */
| { type: 'setMax'; max: number }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
AsyncListMsg from @llui/components
export type AsyncListMsg<T = unknown> =
/** @intent("Request the next page of items") */
| { type: 'loadMore' }
/** @humanOnly */
| { type: 'pageLoaded'; items: T[]; hasMore: boolean }
/** @humanOnly */
| { type: 'pageFailed'; error: string }
/** @intent("Discard the loaded items and reset back to page 0") */
| { type: 'reset' }
/** @humanOnly */
| { type: 'setItems'; items: T[]; hasMore?: boolean }
/** @intent("Retry the last failed page request") */
| { type: 'retry' }
AsyncStatus from @llui/components
Async list — paginated/infinite-scroll list that accumulates pages.
The machine is generic over the item type; the consumer runs the
actual fetch in response to loadMore (via a custom handler or
effect) and dispatches pageLoaded/pageFailed when the request
completes.
Typical flow in consumer's update handler:
(state, msg) => {
if (msg.type === 'loadMore') {
fetch(/api/items?page=${state.list.page + 1})
.then(r => r.json())
.then(items => send({type: 'pageLoaded', items, hasMore: items.length === PAGE_SIZE}))
.catch(e => send({type: 'pageFailed', error: String(e)}))
}
}
export type AsyncStatus = 'idle' | 'loading' | 'loaded' | 'error'
AvatarMsg from @llui/components
export type AvatarMsg =
/** @humanOnly */
| { type: 'loadStart' }
/** @humanOnly */
| { type: 'loaded' }
/** @humanOnly */
| { type: 'error' }
/** @intent("Reset the avatar's load status back to idle") */
| { type: 'reset' }
BreadcrumbsMsg from @llui/components
export type BreadcrumbsMsg =
/** @intent("Replace the breadcrumb trail with a new list of items") */
| { type: 'setItems'; items: BreadcrumbItem[] }
/** @intent("Expand the collapsed middle of the trail to reveal all items") */
| { type: 'expand' }
/** @intent("Collapse the trail back to its truncated form") */
| { type: 'collapse' }
CalendarUnit from @llui/components
Rungs of the ladder, finest first.
export type CalendarUnit = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'
CarouselEffect from @llui/components
Effects emitted by the carousel machine. Running the timer is the consumer's job — the machine only says when it should run (see the module header).
export type CarouselEffect =
/**
* Run the autoplay timer: dispatch `autoplayTick` every `interval` ms.
* Re-emitted to RESTART a timer already running (manual navigation, an
* `interval` change), so the handler must replace rather than add.
*/
| { type: 'startAutoplay'; interval: number }
/** Retire the autoplay timer. */
| { type: 'stopAutoplay' }
CarouselMsg from @llui/components
export type CarouselMsg =
/** @intent("Jump to a specific slide by zero-based index") */
| { type: 'goTo'; index: number }
/** @intent("Advance to the next slide (wraps if loop is enabled)") */
| { type: 'next' }
/** @intent("Go back to the previous slide (wraps if loop is enabled)") */
| { type: 'prev' }
/** @humanOnly */
| { type: 'setCount'; count: number }
/** @intent("Pause autoplay (typically while user hovers or focuses the carousel)") */
| { type: 'pause' }
/** @intent("Resume autoplay after a pause") */
| { type: 'resume' }
/** @humanOnly */
| { type: 'setHovered'; hovered: boolean }
/** @humanOnly */
| { type: 'setFocusWithin'; focusWithin: boolean }
/** @intent("Turn autoplay on or off") */
| { type: 'setAutoplay'; autoplay: boolean }
/**
* The autoplay timer fired. Advances exactly like `next`, but does NOT
* restart the timer — it IS the timer. Manual navigation restarts it; a tick
* must not, or the period would be re-armed on every fire.
*
* `@humanOnly` because it is the TIMER's message, not a user intent: with no
* tag it defaulted to dispatchMode `'shared'` and an agent could fire the
* timer directly, advancing the carousel without re-arming the period
* (#138 review, item 8). An agent that wants the next slide sends `next`.
*
* @humanOnly
*/
| { type: 'autoplayTick' }
/** @humanOnly */
| { type: 'dragStart'; x: number }
/** @humanOnly */
| { type: 'dragMove'; x: number }
/** @humanOnly */
| { type: 'dragEnd' }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
/** @humanOnly — synchronized from the mounted root's live ancestor direction. */
| { type: 'syncDomDir'; dir: 'ltr' | 'rtl' }
CascadeSelectMsg from @llui/components
export type CascadeSelectMsg =
/** @humanOnly */
| { type: 'setLevels'; levels: CascadeLevel[] }
/** @intent("Pick a value at the given level (clears selections at deeper levels)") */
| { type: 'setValue'; levelIndex: number; value: string | null }
/** @intent("Clear every level's selection") */
| { type: 'clear' }
ChartCoord from @llui/components
export type ChartCoord = 'cartesian' | 'polar'
ChartMsg from @llui/components
export type ChartMsg =
/** @intent("Switch between cartesian and polar projection") */
| { type: 'setCoord'; coord: ChartCoord }
/** @intent("Allocate the independent axis by equal slots or by share of the total") */
| { type: 'setDomain'; domain: ChartDomain }
/** @intent("Set the row under the cursor, or clear it with null") */
| { type: 'setActive'; index: number | null }
/** @intent("Move the keyboard cursor along the rows by delta, wrapping") */
| { type: 'moveActive'; delta: number }
/** @intent("Move the keyboard cursor to the first row") */
| { type: 'firstActive' }
/** @intent("Move the keyboard cursor to the last row") */
| { type: 'lastActive' }
/** @intent("Isolate one series, or show them all again with null") */
| { type: 'setActiveSeries'; key: string | null }
/** @intent("Replace the plotted rows") */
| { type: 'setRows'; rows: ChartRow[] }
/** @intent("Pin the low end of the value axis, or derive it again with null") */
| { type: 'setMin'; value: number | null }
/** @intent("Pin the high end of the value axis, or derive it again with null") */
| { type: 'setMax'; value: number | null }
/** @intent("Stack the series instead of overlaying them") */
| { type: 'setStacked'; stacked: boolean }
/** @intent("Set the viewBox size in user units") */
| { type: 'setSize'; width: number; height: number }
/** @intent("Set the polar inner radius as a fraction of the outer") */
| { type: 'setInnerRadius'; value: number }
/** @intent("Swap the cartesian axes for a horizontal bar chart") */
| { type: 'setHorizontal'; horizontal: boolean }
CheckboxMsg from @llui/components
export type CheckboxMsg =
/** @intent("Toggle the checkbox between checked and unchecked") */
| { type: 'toggle' }
/** @intent("Set the checkbox state to checked, unchecked, or indeterminate") */
| { type: 'setChecked'; checked: CheckedState }
/** @humanOnly */
| { type: 'setDisabled'; disabled: boolean }
CheckedState from @llui/components
Checkbox — a tri-state form control (checked / unchecked / indeterminate).
The indeterminate state is a visual-only state used to represent "partial"
selection (e.g. a parent whose children are mixed checked).
Rendering typically uses two elements: a visual indicator (the styled box)
and a hidden native <input type="checkbox"> for form participation +
accessibility. connect() returns props for both.
export type CheckedState = boolean | 'indeterminate'
ClipboardMsg from @llui/components
export type ClipboardMsg =
/** @intent("Update the value to be copied") */
| { type: 'setValue'; value: string }
/** @intent("Initiate a clipboard copy of the current value") */
| { type: 'copy' }
/** @humanOnly */
| { type: 'copied' }
/** @humanOnly */
| { type: 'copyFailed' }
/** @intent("Clear the transient \"copied\" / \"failed\" feedback state") */
| { type: 'reset' }
CollapsibleMsg from @llui/components
export type CollapsibleMsg =
/** @intent("Toggle the collapsible panel open/closed") */
| ({ type: 'toggle' } & Retain)
/** @intent("Expand the collapsible panel") */
| ({ type: 'open' } & Retain)
/** @intent("Collapse the panel") */
| ({ type: 'close' } & Retain)
/** @intent("Set the panel's open state to a specific value") */
| ({ type: 'setOpen'; open: boolean } & Retain)
/** @humanOnly — sent by the retained content's own animation end/cancel event,
* or by `exitCompletion`'s own cleanup settling a still-closing panel once
* the last watcher for this `id` detaches. */
| { type: 'exitComplete'; generation: number }
ColorModel from @llui/components
Color picker — HSV and OKLCH color selection.
The canonical color lives in whichever model is ACTIVE (state.color.model)
and is lossless within that model. Every message that edits the color goes
through a PROJECTION into the model it naturally operates in (HSV, HSL, or
OKLCH), applies the edit there, then converts the result into the active
model — so setSaturation (an HSL concept) and setChroma (an OKLCH
concept) both work regardless of which model is currently active, and
setHue sets the hue of whichever model IS active (HSV hue and OKLCH hue
are numerically different rulers for the same wheel).
HUE STABILITY ON ACHROMATIC COLORS is the reason HSV was chosen as the
original canonical store (S or V at 0 loses no information about hue,
unlike HSL's black/white axis) and it is preserved across the HSV<->OKLCH
boundary too: {@link hsvToOklchPreserving}/{@link oklchToHsvPreserving}
carry the stored hue straight across whenever the color is achromatic in
the SOURCE model, rather than recomputing an arbitrary hue-of-zero-chroma.
setModel and every cross-model projection route through these two.
export type ColorModel = 'hsv' | 'oklch'
ColorPickerMsg from @llui/components
export type ColorPickerMsg =
/** @intent("Switch the active color model, converting the stored color and keeping its hue stable if it is achromatic") */
| { type: 'setModel'; model: ColorModel }
/** @intent("Set the full HSL color at once") */
| { type: 'setHsl'; hsl: Hsl }
/** @intent("Set the hue of the ACTIVE model (0–360)") */
| { type: 'setHue'; h: number }
/** @intent("Set the HSL saturation channel (0–100)") */
| { type: 'setSaturation'; s: number }
/** @intent("Set the HSL lightness channel (0–100)") */
| { type: 'setLightness'; l: number }
/** @intent("Set the alpha channel (0–1)") */
| { type: 'setAlpha'; alpha: number }
/** @intent("Set the color from any CSS color string (hex, rgb(), hsl(), oklch(), oklab(), a named color)") */
| { type: 'setHex'; hex: string }
/** @intent("Set saturation and value (HSV, 0–100 each) from the 2D area") */
| { type: 'setSv'; s: number; v: number }
/** @intent("Nudge saturation/value (HSV) by signed deltas — used by area arrow keys") */
| { type: 'nudgeSv'; ds: number; dv: number }
/** @intent("Set the color from a swatch, an eyedropper pick, or any CSS color string") */
| { type: 'setColor'; color: string }
/** @intent("Set the full OKLCH color at once (L 0–1, C >= 0, H degrees)") */
| { type: 'setOklch'; l: number; c: number; h: number }
/** @intent("Set the OKLCH chroma channel") */
| { type: 'setChroma'; c: number }
/** @intent("Set the OKLCH lightness channel (0–1)") */
| { type: 'setOklchLightness'; l: number }
/** @intent("Set chroma and lightness (OKLCH, x/y of the 2D area) at once") */
| { type: 'setLc'; c: number; l: number }
/** @intent("Nudge chroma/lightness (OKLCH) by signed deltas — used by area arrow keys") */
| { type: 'nudgeLc'; dc: number; dl: number }
/** @humanOnly */
| { type: 'setEyeDropperSupported'; supported: boolean }
/** @humanOnly */
| { type: 'eyeDropperFailed'; message: string }
ComboboxAsyncStatus from @llui/components
export type AsyncStatus = 'idle' | 'loading' | 'loaded' | 'error'
ComboboxEffect from @llui/components
Effects emitted by the combobox machine. Creation is owned by the consumer:
when a create sentinel is selected the machine surfaces the typed text as a
createOption effect rather than mutating its own value.
export type ComboboxEffect =
/** @intent("The user asked to create a brand-new option from the typed text") */
{ type: 'createOption'; value: string }
ComboboxMsg from @llui/components
export type ComboboxMsg =
/** @intent("Open the combobox dropdown") */
| { type: 'open' }
/** @intent("Close the combobox dropdown") */
| { type: 'close' }
/** @intent("Set the text input contents (re-runs the filter)") */
| { type: 'setInputValue'; value: string }
/** @intent("Pick the option with the given value (toggles in multi-select)") */
| { type: 'selectOption'; value: string }
/** @intent("Replace the selected values with the provided list") */
| { type: 'setValue'; value: string[] }
/** @intent("Clear all selected values and the input text") */
| { type: 'clear' }
/** @humanOnly */
| { type: 'highlightNext' }
/** @humanOnly */
| { type: 'highlightPrev' }
/** @humanOnly */
| { type: 'highlightFirst' }
/** @humanOnly */
| { type: 'highlightLast' }
/** @humanOnly */
| { type: 'highlight'; value: string | null }
/** @intent("Pick the currently-highlighted option in the filtered list") */
| { type: 'selectHighlighted' }
/**
* @humanOnly
*
* Replace `items` (and optionally `disabled`) synchronously — the same
* highlight-resolution policy as `loadSuccess` applies: a highlight that no
* longer survives the fresh list moves to the first enabled match WHILE
* OPEN, or to `null` WHILE CLOSED (#265 G3), since a background refresh
* must not manufacture a highlight before the control is ever opened.
*/
| { type: 'setItems'; items: string[]; disabled?: string[] }
/** @intent("Mark an async option fetch as started; pass the request's id") */
| { type: 'loadStart'; requestId: number }
/**
* @humanOnly
*
* Atomic replacement: `items` is required. `disabled` is an OPTIONAL
* companion that replaces `disabledItems` when present (omitted ⇒
* unchanged). `groups` is different: omitting it RESETS to no groups
* (`[]`), the same as `init()` with no `groups` option — a fresh
* replacement with no `groups` describes a flat result, and carrying a
* PREVIOUS load's groups forward would keep describing options this
* replacement never mentioned as belonging to a group that may no longer
* apply (#265 A3). Every field the fresh `items`/`disabled`/`groups` makes
* inconsistent is reconciled in this SAME reducer step, never in a
* follow-up message. `value` (selection) is dropped when it no longer
* names a value in the new `items` (after the new `disabled` is applied).
* `highlightedValue` is kept only when it is BOTH still in the fresh
* filtered list AND not newly disabled. Otherwise the fallback depends on
* whether the listbox is open: WHILE OPEN it moves to the first enabled
* match (or `null` when none is enabled) — never left dangling for a
* render in between, and never left naming an option that is now
* disabled. WHILE CLOSED it always resolves to `null` instead, even when
* the fresh list has enabled options: the listbox content is unmounted
* while closed, so there is no option `aria-activedescendant` could
* correctly name, and a background load (a prefetch, a poll) must not
* manufacture a highlight before the control is ever opened — re-opening
* always reseeds the highlight itself (#265 G3).
*/
| {
type: 'loadSuccess'
requestId: number
items: string[]
groups?: ComboboxGroup[]
disabled?: string[]
}
/** @humanOnly */
| { type: 'loadError'; requestId: number; error: string }
ContextMenuCheckItemParts from @llui/components
export type ContextMenuCheckItemParts = MenuCheckItemPartsOf<'context-menu'>
ContextMenuGroupParts from @llui/components
export type ContextMenuGroupParts = MenuGroupPartsOf<'context-menu'>
ContextMenuItem from @llui/components
A single node in the context-menu item tree (JSON-serializable). Shared with
menu via the {@link MenuNode} machine type.
export type ContextMenuItem = MenuNode
ContextMenuItemKind from @llui/components
Kind of a context-menu item.
export type ContextMenuItemKind = MenuNodeKind
ContextMenuItemParts from @llui/components
export type ContextMenuItemParts = MenuItemPartsOf<'context-menu'>
ContextMenuMsg from @llui/components
export type ContextMenuMsg =
/** @humanOnly */
| { type: 'openAt'; x: number; y: number }
/** @intent("Close the context menu") */
| { type: 'close' }
/** @humanOnly */
| { type: 'highlight'; level: string; value: string | null }
/** @humanOnly */
| { type: 'highlightNext'; level: string }
/** @humanOnly */
| { type: 'highlightPrev'; level: string }
/** @humanOnly */
| { type: 'highlightFirst'; level: string }
/** @humanOnly */
| { type: 'highlightLast'; level: string }
/** @intent("Activate the currently-highlighted item at the given level") */
| { type: 'selectHighlighted'; level: string }
/** @intent("Activate the menu item with the given value") */
| { type: 'select'; value: string }
/** @intent("Open the submenu for the given parent item") */
| { type: 'openSub'; value: string }
/** @intent("Close the deepest open submenu") */
| { type: 'closeSub' }
/** @humanOnly */
| { type: 'setItems'; items: ContextMenuItem[] }
/** @humanOnly */
| { type: 'typeahead'; level: string; char: string; now: number }
/** @intent("Set the reading direction — 'ltr' or 'rtl'") */
| { type: 'setDir'; dir: TextDirection }
/** @humanOnly — synchronized from the mounted root's live ancestor direction. */
| { type: 'syncDomDir'; dir: TextDirection }
/** @humanOnly */
| { type: 'animationEnd' }
ContextMenuSeparatorParts from @llui/components
export type ContextMenuSeparatorParts = MenuSeparatorPartsOf<'context-menu'>
ContextMenuSubContentParts from @llui/components
export type ContextMenuSubContentParts = MenuSubContentPartsOf<'context-menu'>
ContextMenuSubPositionerParts from @llui/components
export type ContextMenuSubPositionerParts = MenuSubPositionerPartsOf<'context-menu'>
ContextMenuSubTriggerParts from @llui/components
export type ContextMenuSubTriggerParts = MenuSubTriggerPartsOf<'context-menu'>
CssColor from @llui/components
A color as CSS Color 4 sees it. lab()/lch()/wide-gamut color() are
NOT separate tags here — see the module doc — they resolve to 'oklch' (or
'srgb' for color(srgb ...)/color(srgb-linear ...)) at parse time.
export type CssColor =
| { space: 'srgb'; r: number | null; g: number | null; b: number | null; alpha: number | null }
| {
space: 'srgb-linear'
r: number | null
g: number | null
b: number | null
alpha: number | null
}
| { space: 'hsl'; h: number | null; s: number | null; l: number | null; alpha: number | null }
| { space: 'oklab'; l: number | null; a: number | null; b: number | null; alpha: number | null }
| { space: 'oklch'; l: number | null; c: number | null; h: number | null; alpha: number | null }
Curve from @llui/components
How a run of points is joined.
export type Curve = 'linear' | 'monotone' | 'step'
DateError from @llui/components
Date input — keyboard-only date field with masked parsing. Unlike date-picker, this is a plain that parses ISO-ish date strings as the user types. Separate from date-picker to keep each focused.
The machine holds the raw input string + the parsed date as an ISO
YYYY-MM-DD string (null until a complete/valid value is entered) —
never a Date, so the state stays JSON-serializable like date-picker's
(#119). Min/max bounds are validated on every change, populating error
when out of range, and an unparseable value — from init or setValue
alike — sets error: 'invalid' rather than vanishing.
export type DateError = 'invalid' | 'before-min' | 'after-max' | null
DateInputMsg from @llui/components
export type DateInputMsg =
/** @intent("Update the raw text the user has typed (re-parses to a date)") */
| { type: 'setInput'; value: string }
/** @intent("Set the parsed date directly as YYYY-MM-DD (also updates the displayed text)") */
| { type: 'setValue'; value: IsoDate | null }
/** @intent("Clear the input and the parsed date") */
| { type: 'clear' }
/** @humanOnly */
| { type: 'setMin'; min: IsoDate | null }
/** @humanOnly */
| { type: 'setMax'; max: IsoDate | null }
/** @humanOnly */
| { type: 'setDisabled'; disabled: boolean }
DatePickerMode from @llui/components
Selection mode: a single date or a start/end range.
export type DatePickerMode = 'single' | 'range'
DatePickerMsg from @llui/components
export type DatePickerMsg =
/** @intent("Set the selected date (YYYY-MM-DD), or null to clear") */
| { type: 'setValue'; value: string | null }
/** @intent("Set the selected date range (YYYY-MM-DD start/end); endpoints are normalized so start <= end") */
| { type: 'setRange'; start: string | null; end: string | null }
/** @humanOnly */
| { type: 'setFocused'; date: string }
/** @humanOnly */
| { type: 'setHover'; date: string }
/** @humanOnly */
| { type: 'clearHover' }
/** @intent("Show the previous month in the calendar") */
| { type: 'prevMonth' }
/** @intent("Show the next month in the calendar") */
| { type: 'nextMonth' }
/** @intent("Show the previous year (same month)") */
| { type: 'prevYear' }
/** @intent("Show the next year (same month)") */
| { type: 'nextYear' }
/** @intent("Select the currently-focused date (anchors or completes a range in range mode)") */
| { type: 'selectFocused' }
/** @humanOnly */
| { type: 'moveFocus'; days: number }
/** @humanOnly */
| { type: 'focusStartOfWeek' }
/** @humanOnly */
| { type: 'focusEndOfWeek' }
/** @humanOnly */
| { type: 'focusToday' }
/** @humanOnly */
| { type: 'moveFocusMonths'; months: number }
/** @intent("Clear the current selection") */
| { type: 'clear' }
/** @humanOnly */
| { type: 'setToday'; today: string | null }
/** @intent("Replace the set of individually unavailable dates (YYYY-MM-DD)") */
| { type: 'setUnavailable'; dates: string[] }
DateValue from @llui/components
Date-only handling — the ONE place that decides whether a value denotes an INSTANT or a bare CALENDAR DATE.
new Date('2026-01-15') parses a date-only string as UTC midnight (ES spec),
so formatting it against the ambient zone renders the PREVIOUS day everywhere
west of UTC: formatDate('2026-01-15') printed "January 14, 2026" under
America/New_York and the right answer under Europe/Rome, which is why it
survived (#125 defect 4).
A calendar date carries no instant and therefore no zone, so it is anchored
at UTC midnight and its consumers must render it in UTC — see dateOnly.
export type DateValue = Date | string | number
DialogMsg from @llui/components
export type DialogMsg =
/** @intent("Open the dialog") */
| { type: 'open' }
/** @intent("Close the dialog") */
| { type: 'close' }
/** @intent("Toggle the dialog open/closed") */
| { type: 'toggle' }
/** @intent("Set the dialog's open state to a specific value") */
| { type: 'setOpen'; open: boolean }
/** @humanOnly */
| { type: 'animationEnd' }
/** @humanOnly */
| { type: 'transitionEnd' }
DismissSource from @llui/components
Reason a dismissable layer was closed.
export type DismissSource = 'escape' | 'outside';
DisplayNameType from @llui/components
export type DisplayNameType =
| 'language'
| 'region'
| 'script'
| 'currency'
| 'calendar'
| 'dateTimeField'
DrawerMsg from @llui/components
export type DrawerMsg =
/** @intent("Open the drawer") */
| { type: 'open' }
/** @intent("Close the drawer") */
| { type: 'close' }
/** @intent("Toggle the drawer open/closed") */
| { type: 'toggle' }
/** @intent("Set the drawer's open state to a specific value") */
| { type: 'setOpen'; open: boolean }
/** @humanOnly */
| { type: 'animationEnd' }
/** @humanOnly */
| { type: 'transitionEnd' }
DrawerSide from @llui/components
Drawer — a panel that slides in from a screen edge. Structurally
identical to dialog (portal + focus trap + dismissable + aria-hidden +
scroll lock), but adds a side so styling can animate from that edge.
export type DrawerSide = 'left' | 'right' | 'top' | 'bottom'
EditableMsg from @llui/components
export type EditableMsg =
/** @intent("Enter edit mode (seeds the draft from the current value)") */
| { type: 'edit' }
/** @intent("Update the in-progress draft as the user types") */
| { type: 'setDraft'; draft: string }
/** @intent("Commit the draft as the new value and exit edit mode") */
| { type: 'submit' }
/** @intent("Discard the draft and exit edit mode without changing the value") */
| { type: 'cancel' }
/** @intent("Set the value directly without going through edit mode") */
| { type: 'setValue'; value: string }
ElementSource from @llui/components
Shared DOM helpers used by interaction utilities.
export type ElementSource<T extends Element = Element> = T | T[] | (() => T | T[] | null)
ErrorCorrectionLevel from @llui/components
QR code — renders a QR matrix as SVG. llui does not bundle a QR
encoder (encoders are sizable and consumer apps typically already
have one); instead, the consumer provides the encoded matrix via
setMatrix (or through the optional encode callback on
ConnectOptions, invoked when the value changes).
Minimum usage with a BYOE (bring-your-own-encoder) library — the
consumer dispatches setMatrix with the encoded bits from their
update handler:
import QRCode from 'qrcode-generator'
update: (state, msg) => { if (msg.type === 'updateQr') { const q = QRCode(0, state.qr.errorCorrection) q.addData(msg.value); q.make() const n = q.getModuleCount() const matrix: boolean[][] = [] for (let y = 0; y < n; y++) { const row: boolean[] = [] for (let x = 0; x < n; x++) row.push(q.isDark(y, x)) matrix.push(row) } return [{ ...state, qr: { ...state.qr, value: msg.value, matrix } }, []] } }
export type ErrorCorrectionLevel = 'L' | 'M' | 'Q' | 'H'
FieldMsg from @llui/components
export type FieldMsg =
/** @intent("Mark the field as valid or invalid (drives aria-invalid + the error region)") */
| { type: 'setInvalid'; invalid: boolean }
/** @intent("Mark the field as required or optional") */
| { type: 'setRequired'; required: boolean }
/** @intent("Enable or disable the field's control") */
| { type: 'setDisabled'; disabled: boolean }
/** @intent("Make the field's control read-only or editable") */
| { type: 'setReadonly'; readonly: boolean }
/** @intent("Mark the field as touched (typically after first blur)") */
| { type: 'setTouched'; touched: boolean }
FieldsetMsg from @llui/components
export type FieldsetMsg =
/** @intent("Enable or disable the whole group (propagates to every contained control)") */
| { type: 'setDisabled'; disabled: boolean }
/** @intent("Mark the group as valid or invalid (drives the group-level error region)") */
| { type: 'setInvalid'; invalid: boolean }
FileError from @llui/components
export type FileError =
| { code: 'TOO_LARGE'; max: number }
| { code: 'TOO_SMALL'; min: number }
| { code: 'INVALID_TYPE' }
| { code: 'TOO_MANY'; max: number }
| { code: 'CUSTOM'; message: string }
FileUploadMsg from @llui/components
export type FileUploadMsg =
/** @humanOnly */
| { type: 'setFiles'; files: FileMeta[]; customRejected?: RejectedFile[] }
/** @humanOnly */
| { type: 'addFiles'; files: FileMeta[]; customRejected?: RejectedFile[] }
/** @intent("Remove the accepted file at the given index") */
| { type: 'removeFile'; index: number }
/** @intent("Remove the rejected file at the given index") */
| { type: 'removeRejected'; index: number }
/** @intent("Clear all accepted files") */
| { type: 'clear' }
/** @intent("Clear the rejected-files list") */
| { type: 'clearRejected' }
/** @humanOnly */
| { type: 'dragEnter' }
/** @humanOnly */
| { type: 'dragLeave' }
/** @humanOnly */
| { type: 'drop' }
/** @humanOnly */
| { type: 'setInvalid'; invalid: boolean }
/** @humanOnly */
| { type: 'uploadProgress'; id: string; progress: number }
/** @humanOnly */
| { type: 'uploadSucceeded'; id: string }
/** @humanOnly */
| { type: 'uploadFailed'; id: string; error: string }
/** @intent("Retry the failed upload of the file with the given id") */
| { type: 'retryUpload'; id: string }
FileUploadStatus from @llui/components
export type FileUploadStatus = 'uploading' | 'done' | 'error'
FloatingPanelHandle from @llui/components
Floating panel — a draggable + resizable window-like surface, useful for dev tools overlays, pop-out inspectors, preview panels, etc. The state machine tracks position and size; the view layer wires pointer events on the drag handle and resize grips and dispatches the corresponding messages.
Coordinates are in pixels relative to the positioning container
(typically position: fixed relative to the viewport).
export type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
FloatingPanelMsg from @llui/components
export type FloatingPanelMsg =
/** @intent("Open the floating panel") */
| { type: 'open' }
/** @intent("Close the floating panel") */
| { type: 'close' }
/** @intent("Minimize the panel (collapses to title bar)") */
| { type: 'minimize' }
/** @intent("Restore the panel from its minimized state") */
| { type: 'restoreFromMinimized' }
/** @intent("Maximize the panel (fills the viewport)") */
| { type: 'maximize' }
/** @intent("Restore the panel to its pre-maximize geometry") */
| { type: 'restoreFromMaximized' }
/** @intent("Toggle between minimized and normal") */
| { type: 'toggleMinimize' }
/** @intent("Toggle between maximized and normal") */
| { type: 'toggleMaximize' }
/** @humanOnly */
| { type: 'dragStart' }
/** @humanOnly */
| { type: 'dragMove'; dx: number; dy: number }
/** @humanOnly */
| { type: 'dragEnd' }
/** @humanOnly */
| { type: 'resizeStart'; handle: ResizeHandle }
/** @humanOnly */
| { type: 'resizeMove'; dx: number; dy: number }
/** @humanOnly */
| { type: 'resizeEnd' }
/** @intent("Set the panel's top-left position in pixels") */
| { type: 'setPosition'; x: number; y: number }
/** @intent("Set the panel's size in pixels (clamped to min/max)") */
| { type: 'setSize'; width: number; height: number }
/** @intent("Move the panel by a pixel offset (x right, y down)") */
| { type: 'moveBy'; dx: number; dy: number }
/** @intent("Resize the panel from one edge or corner by a pixel offset (clamped to min/max)") */
| { type: 'resizeBy'; handle: ResizeHandle; dx: number; dy: number }
FormMsg from @llui/components
export type FormMsg =
/** @intent("Mark a single field as touched (typically on blur)") */
| { type: 'touch'; field: string }
/** @intent("Mark every named field as touched (typically on submit attempt)") */
| { type: 'touchAll'; fields: string[] }
/** @intent("Begin form submission — validate and dispatch the save effect") */
| { type: 'submit' }
/** @intent("Mark the in-flight submission as successful") */
| { type: 'submitSuccess' }
/** @intent("Mark the in-flight submission as failed with the given error message") */
| { type: 'submitError'; error: string }
/** @intent("Reset the form to its initial state (clears touched flags and submit status)") */
| { type: 'reset' }
FormStatus from @llui/components
Form — submit lifecycle + touched tracking + Standard Schema validation.
Values live in the parent component's state; form tracks submit status
and which fields have been interacted with (blur), so errors are shown
only after touch instead of immediately.
Bring your own validation library — any Standard Schema-compatible schema works (Zod, Valibot, ArkType, etc.). See https://standardschema.dev.
import { z } from 'zod'
import { form, validateSchema } from '@llui/components/form'
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
type Values = z.infer<typeof schema>
type State = { values: Values; form: FormState }
update: (state, msg) => {
switch (msg.type) {
case 'submit': {
const result = validateSchema(schema, state.values)
if (!result.isValid) {
return [{ ...state, form: { ...state.form, touched: { email: true, password: true } } }, []]
}
return [{ ...state, form: { ...state.form, status: 'submitting' } }, [saveUserEffect]]
}
}
}
export type FormStatus = 'idle' | 'submitting' | 'submitted' | 'error'
GradientKind from @llui/components
Gradient picker — CSS linear-gradient()/radial-gradient()/
conic-gradient() (and their repeating- forms) authoring: a horizontal
stop ramp plus per-kind controls, with a full color-picker embedded for
whichever stop is selected.
The embedded picker edits the SELECTED STOP'S OWN model
There is exactly one color store per stop (GradientStop.color, the same
PickerColor union color-picker itself uses) and exactly one place that
projects it into a live ColorPickerState: {@link pickerStateOf}. It hands
the stop's color through UNCHANGED — no HSV<->OKLCH re-projection — so a
picker message that only touches one channel (setAlpha, setHue, …)
leaves every OTHER channel of an out-of-gamut OKLCH stop bit-identical.
update()'s 'picker' case runs colorPicker.update on that DERIVED
state and writes color/alpha straight back onto the SELECTED stop; a
stop's MODEL only ever changes via an explicit picker: { msg: { type: 'setModel' } } }, which is itself just color-picker's own reducer
producing a same-shape next.color in the new model — there is no
separate "gradient-level model" to keep in sync. defaultModel is a
DIFFERENT, narrower thing: which model a freshly-added stop with no other
way to pick one starts in (see addStop/buildStopsFromOptions).
The stop-position domain is always one cycle, 0–100
repeating only changes the CSS repeating- prefix (how the SAME 0–100
ramp tiles across the painted box) — it never changes what a stop's
position number means. colorAt/addStop always read position as a
percentage of one cycle; colorAt WRAPS a query outside [first, last]
by the stop range's own period when repeating is true (matching a real
repeating-linear-gradient's paint), and clamps to the end stops
otherwise. toCss's stop list is always the one un-repeated cycle,
matching what {@link parseGradient} reads back.
export type GradientKind = 'linear' | 'radial' | 'conic'
GradientPickerMsg from @llui/components
@intent group
export type GradientPickerMsg =
/** @intent("Add a stop at `position` (0-100), colored EXACTLY what the gradient already renders there") */
| { type: 'addStop'; position: number }
/** @intent("Remove the stop with the given id. Refused below `minStops`; a removed selection reselects a neighbour") */
| { type: 'removeStop'; id: string }
/** @humanOnly */
| { type: 'selectStop'; id: string }
/** @intent("Move the stop with the given id to `position` (0-100, clamped), keeping sort order and selection") */
| { type: 'moveStop'; id: string; position: number }
/** @intent("Move the stop with the given id by a signed `delta` (0-100 units)") */
| { type: 'nudgeStop'; id: string; delta: number }
/** @intent("Switch the gradient kind (linear/radial/conic)") */
| { type: 'setKind'; kind: GradientKind }
/** @intent("Toggle the `repeating-` form of the current gradient kind") */
| { type: 'setRepeating'; repeating: boolean }
/** @intent("Set an EXPLICIT angle in degrees — linear's direction (converting away from a to-keyword direction) or conic's `from` angle. No-op for radial. Normalized to [0,360)") */
| { type: 'setAngle'; angle: number }
/** @intent("Set the center position (percent 0-100 each axis) — radial and conic only") */
| { type: 'setCenter'; x: number; y: number }
/** @intent("Set the radial gradient's shape (circle/ellipse)") */
| { type: 'setShape'; shape: RadialShape }
/** @intent("Set the radial gradient's extent keyword") */
| { type: 'setSize'; size: RadialSize }
/** @intent("Set the color-interpolation-method: the space, and (polar spaces only) the hue method") */
| { type: 'setInterpolation'; space: InterpolationSpace; hue?: HueInterpolationMethod }
/** @intent("Reverse the stop order (mirrors every stop's position about the middle)") */
| { type: 'reverse' }
/** @intent("Space every stop evenly across 0-100, preserving relative order") */
| { type: 'distribute' }
/** @humanOnly */
| { type: 'setGradientDraft'; value: string }
/** @intent("Parse `css` and, if valid AND within min/maxStops, replace the whole gradient. Invalid input (or a stop count outside min/maxStops) sets a describable error and leaves the gradient unchanged") */
| { type: 'setGradient'; css: string }
/** @intent("Edit the color/alpha of the SELECTED stop through the embedded color-picker's own message set") */
| { type: 'picker'; msg: ColorPickerMsg }
/** @humanOnly */
| { type: 'selectNextStop' }
/** @humanOnly */
| { type: 'selectPrevStop' }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
HoverCardMsg from @llui/components
export type HoverCardMsg =
| { type: 'show' }
| { type: 'hide' }
| { type: 'setOpen'; open: boolean }
/** @humanOnly */
| { type: 'animationEnd' }
/** @humanOnly */
| { type: 'transitionEnd' }
HueInterpolationMethod from @llui/components
export type HueInterpolationMethod = 'shorter' | 'longer' | 'increasing' | 'decreasing'
ImageCropperMsg from @llui/components
export type ImageCropperMsg =
/** @humanOnly */
| { type: 'setImage'; width: number; height: number }
/** @intent("Set the crop rectangle (x/y/width/height in image-native pixels)") */
| { type: 'setCrop'; crop: CropRect }
/** @intent("Lock the crop to a specific aspect ratio (width/height), or null for free-form") */
| { type: 'setAspectRatio'; ratio: number | null }
/** @humanOnly */
| { type: 'dragStart' }
/** @humanOnly */
| { type: 'dragMove'; dx: number; dy: number }
/** @humanOnly */
| { type: 'dragEnd' }
/** @humanOnly */
| { type: 'resizeStart'; handle: ResizeHandle }
/** @humanOnly */
| { type: 'resizeMove'; dx: number; dy: number }
/** @humanOnly */
| { type: 'resizeEnd' }
/** @intent("Reset the crop to a default selection (full image or aspect-fit)") */
| { type: 'reset' }
/** @intent("Set the crop to a maximum-area centered selection") */
| { type: 'centerFill' }
/** @intent("Move the crop area by a percentage of the image size (x right, y down)") */
| { type: 'nudge'; x: number; y: number }
/** @intent("Zoom the crop area about its centre: factor > 1 zooms in, < 1 zooms out") */
| { type: 'zoom'; factor: number }
ImageStatus from @llui/components
Avatar — image with automatic fallback. Tracks image load status so consumers can render the image, a fallback (initials, icon), or a loading placeholder.
export type ImageStatus = 'idle' | 'loading' | 'loaded' | 'error'
InterpolationSpace from @llui/components
Interpolation / generic-conversion target spaces (CSS Color 4 §12).
export type InterpolationSpace = 'srgb' | 'srgb-linear' | 'oklab' | 'oklch' | 'hsl'
InViewMsg from @llui/components
export type InViewMsg = { type: 'enter' } | { type: 'leave' }
IsoDate from @llui/components
A calendar date as YYYY-MM-DD. Zero-padded, so plain </> order it.
export type IsoDate = string
ItemFill from @llui/components
export type ItemFill = 'full' | 'half' | 'empty'
ListboxMsg from @llui/components
export type ListboxMsg =
/** @intent("Pick the option with the given value (toggles in multi-select)") */
| { type: 'select'; value: string }
/** @intent("Replace the selected values with the provided list") */
| { type: 'setValue'; value: string[] }
/** @intent("Clear all selected values") */
| { type: 'clear' }
/** @humanOnly */
| { type: 'highlight'; index: number | null }
/** @humanOnly */
| { type: 'highlightNext' }
/** @humanOnly */
| { type: 'highlightPrev' }
/** @humanOnly */
| { type: 'highlightFirst' }
/** @humanOnly */
| { type: 'highlightLast' }
/** @intent("Pick the currently-highlighted option") */
| { type: 'selectHighlighted' }
/** @humanOnly */
| { type: 'setItems'; items: string[]; disabled?: string[] }
/** @humanOnly */
| { type: 'typeahead'; char: string; now: number }
MarkType from @llui/components
export type MarkType = 'line' | 'area' | 'bar'
MarqueeDirection from @llui/components
Marquee — continuously-scrolling content. The state machine tracks play/pause + direction + speed; the scrolling itself is driven by CSS animations or JS requestAnimationFrame (the consumer owns that).
Expose the active state via CSS custom properties the consumer reads in their stylesheet: --marquee-duration: {N}s --marquee-direction: 'normal' | 'reverse' --marquee-playstate: 'running' | 'paused'
export type MarqueeDirection = 'left' | 'right' | 'up' | 'down'
MarqueeMsg from @llui/components
export type MarqueeMsg =
/** @intent("Resume the marquee scrolling") */
| { type: 'play' }
/** @intent("Pause the marquee scrolling") */
| { type: 'pause' }
/** @intent("Toggle the marquee between playing and paused") */
| { type: 'toggle' }
/** @humanOnly */
| { type: 'hoverPause' }
/** @humanOnly */
| { type: 'hoverResume' }
/** @intent("Change the scroll direction (left/right/up/down)") */
| { type: 'setDirection'; direction: MarqueeDirection }
/** @intent("Change the loop duration in seconds (larger = slower)") */
| { type: 'setDuration'; durationSec: number }
MenubarMsg from @llui/components
export type MenubarMsg =
/** @intent("Open the menu with the given id and focus its first item") */
| { type: 'openMenu'; id: string }
/** @intent("Close the currently-open menu") */
| { type: 'closeMenu' }
/** @intent("Move roving focus to the menu with the given id (switches the open menu in open mode)") */
| { type: 'focusMenu'; id: string }
/** @humanOnly */
| { type: 'syncTriggerFocus'; id: string }
/** @humanOnly */
| { type: 'focusNext' }
/** @humanOnly */
| { type: 'focusPrev' }
/** @humanOnly */
| { type: 'menuMsg'; id: string; msg: MenuMsg }
/** @intent("Set the reading direction") */
| { type: 'setDir'; dir: TextDirection }
/** @humanOnly — synchronized from the mounted root's live ancestor direction. */
| { type: 'syncDomDir'; dir: TextDirection }
MenuCheckItemParts from @llui/components
export type MenuCheckItemParts = MenuCheckItemPartsOf<'menu'>
MenuGroupParts from @llui/components
export type MenuGroupParts = MenuGroupPartsOf<'menu'>
MenuItem from @llui/components
A single node in the menu item tree (JSON-serializable). Shared with
context-menu (and menubar) via the {@link MenuNode} machine type.
export type MenuItem = MenuNode
MenuItemKind from @llui/components
Kind of a menu item.
export type MenuItemKind = MenuNodeKind
MenuItemParts from @llui/components
export type MenuItemParts = MenuItemPartsOf<'menu'>
MenuMsg from @llui/components
export type MenuMsg =
/** @intent("Open the menu") */
| { type: 'open' }
/** @intent("Close the menu") */
| { type: 'close' }
/** @intent("Toggle the menu open/closed") */
| { type: 'toggle' }
/** @humanOnly */
| { type: 'highlight'; level: string; value: string | null }
/** @humanOnly */
| { type: 'highlightNext'; level: string }
/** @humanOnly */
| { type: 'highlightPrev'; level: string }
/** @humanOnly */
| { type: 'highlightFirst'; level: string }
/** @humanOnly */
| { type: 'highlightLast'; level: string }
/** @intent("Activate the currently-highlighted item at the given level") */
| { type: 'selectHighlighted'; level: string }
/** @intent("Activate the menu item with the given value") */
| { type: 'select'; value: string }
/** @intent("Open the submenu for the given parent item") */
| { type: 'openSub'; value: string }
/** @intent("Close the deepest open submenu") */
| { type: 'closeSub' }
/** @humanOnly */
| { type: 'setItems'; items: MenuItem[] }
/** @humanOnly */
| { type: 'typeahead'; level: string; char: string; now: number }
/** @intent("Set the reading direction — 'ltr' or 'rtl'") */
| { type: 'setDir'; dir: TextDirection }
/** @humanOnly — synchronized from the mounted root's live ancestor direction. */
| { type: 'syncDomDir'; dir: TextDirection }
/** @humanOnly */
| { type: 'animationEnd' }
MenuSeparatorParts from @llui/components
export type MenuSeparatorParts = MenuSeparatorPartsOf<'menu'>
MenuSubContentParts from @llui/components
export type MenuSubContentParts = MenuSubContentPartsOf<'menu'>
MenuSubPositionerParts from @llui/components
export type MenuSubPositionerParts = MenuSubPositionerPartsOf<'menu'>
MenuSubTriggerParts from @llui/components
export type MenuSubTriggerParts = MenuSubTriggerPartsOf<'menu'>
MeterMsg from @llui/components
export type MeterMsg =
/** @humanOnly */
| { type: 'setValue'; value: number }
/** @humanOnly */
| { type: 'setMax'; max: number }
/** @humanOnly */
| { type: 'setBands'; bands: readonly MeterBandInit[] }
NavMenuMsg from @llui/components
export type NavMenuMsg =
/** @intent("Open the submenu identified by id, closing any open siblings") */
| { type: 'openBranch'; id: string; ancestorIds: string[] }
/** @intent("Close the submenu identified by id (also closes its descendants)") */
| { type: 'closeBranch'; id: string }
/** @intent("Toggle the submenu identified by id open/closed") */
| { type: 'toggleBranch'; id: string; ancestorIds: string[] }
/** @intent("Close every open submenu") */
| { type: 'closeAll' }
/** @humanOnly */
| { type: 'focus'; id: string | null }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
/** @humanOnly — synchronized from the mounted root's live ancestor direction. */
| { type: 'syncDomDir'; dir: 'ltr' | 'rtl' }
/** @intent("Replace the list of ids eligible for the roving tab stop, in document order") */
| { type: 'setItems'; items: string[] }
NestedLayerAspect from @llui/components
A consumer of the registry. A registration participates only in the aspects it
names, because a single answer is wrong for at least one consumer: engine
overlays leave outside to the ordered dismissable stack (see the module
comment).
The dialog-with-an-inner-select case is NOT what the aspect list protects.
That one is covered by a modal never registering AT ALL, whatever aspects it
would have named.
outside— {@link watchInteractOutside} does not treat interactions inside the layer as outside interactions.focus— {@link pushFocusTrap} includes the layer as an extra focusable container, so Tab/Shift+Tab can reach it.hide— {@link setAriaHiddenOutside} hides AROUND the layer rather than hiding it.
export type NestedLayerAspect = 'outside' | 'focus' | 'hide';
NestedLayerScope from @llui/components
The asking layer's own boundary — what "nested inside ME" is measured against. Omit it for the flat, layer-agnostic answer.
export type NestedLayerScope = ElementSource;
NumberInputMsg from @llui/components
export type NumberInputMsg =
/** @intent("Set the numeric value (clamped to min/max, snapped to step)") */
| { type: 'setValue'; value: number | null }
/** @humanOnly */
| { type: 'setRawText'; text: string }
/** @intent("Commit the in-progress text input — parse, clamp, snap, and update value. Ignored while disabled or readonly") */
| { type: 'commit' }
/** @intent("Increase value by step (or step × multiplier). Ignored while disabled or readonly") */
| { type: 'increment'; multiplier?: number }
/** @intent("Decrease value by step (or step × multiplier). Ignored while disabled or readonly") */
| { type: 'decrement'; multiplier?: number }
/** @intent("Snap value to the configured minimum. Ignored while disabled or readonly") */
| { type: 'toMin' }
/** @intent("Snap value to the configured maximum. Ignored while disabled or readonly") */
| { type: 'toMax' }
/** @intent("Enable or disable the input — a host/agent write, never gated") */
| { type: 'setDisabled'; disabled: boolean }
PageItem from @llui/components
export type PageItem =
| { type: 'page'; page: number }
| { type: 'ellipsis'; position: 'start' | 'end' }
PaginationMsg from @llui/components
export type PaginationMsg =
/** @intent("Jump to a specific 1-based page number") */
| { type: 'goTo'; page: number }
/** @intent("Advance to the next page") */
| { type: 'next' }
/** @intent("Go back to the previous page") */
| { type: 'prev' }
/** @intent("Jump to the first page") */
| { type: 'first' }
/** @intent("Jump to the last page") */
| { type: 'last' }
/** @intent("Change how many items each page contains") */
| { type: 'setPageSize'; pageSize: number }
/** @humanOnly */
| { type: 'setTotal'; total: number }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: TextDirection }
/** @humanOnly — synchronized from the mounted root's live ancestor direction. */
| { type: 'syncDomDir'; dir: TextDirection }
ParseGradientResult from @llui/components
export type ParseGradientResult =
| { ok: true; value: ParsedGradient }
| { ok: false; reason: string }
PasswordInputMsg from @llui/components
export type PasswordInputMsg =
/** @intent("Update the password value as the user types") */
| { type: 'setValue'; value: string }
/** @intent("Toggle the show/hide-password state") */
| { type: 'toggleVisibility' }
/** @intent("Set the show/hide-password state to a specific value") */
| { type: 'setVisible'; visible: boolean }
PickerColor from @llui/components
The canonical stored color union — HSV or OKLCH, tagged by model.
Exported as a named type for gradient-picker, whose stops each store one
of these (never a hex string) so a gray stop keeps its hue and an
out-of-gamut OKLCH stop stays exact.
export type PickerColor = ColorPickerState['color']
PinInputMsg from @llui/components
export type PinInputMsg =
/** @intent("Set the character at a given field index (auto-advances focus on accept)") */
| { type: 'setValue'; index: number; value: string }
/** @intent("Replace every field at once (typically from paste)") */
| { type: 'setAll'; values: string[] }
/** @humanOnly */
| { type: 'focus'; index: number }
/** @intent("Clear every field") */
| { type: 'clear' }
/** @humanOnly */
| { type: 'backspace'; index: number }
/** @intent("Enable or disable the pin-input — a host/agent write, never gated") */
| { type: 'setDisabled'; disabled: boolean }
PinType from @llui/components
Pin input — a sequence of single-character fields for OTP codes, etc. Auto-advances on input, handles backspace to previous field, supports paste-to-fill across multiple fields.
export type PinType = 'numeric' | 'alphanumeric' | 'alphabetic'
Placement from @llui/components
export declare type Placement = Prettify<Side | AlignedPlacement>;
PluralCategory from @llui/components
export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'
PluralMessages from @llui/components
export type PluralMessages = Partial<Record<PluralCategory, string>> & { other: string }
PopoverMsg from @llui/components
export type PopoverMsg =
/** @intent("Open the popover") */
| { type: 'open' }
/** @intent("Close the popover") */
| { type: 'close' }
/** @intent("Toggle the popover open/closed") */
| { type: 'toggle' }
/** @intent("Set the popover's open state to a specific value") */
| { type: 'setOpen'; open: boolean }
/** @humanOnly */
| { type: 'animationEnd' }
/** @humanOnly */
| { type: 'transitionEnd' }
PresenceMsg from @llui/components
export type PresenceMsg =
/** @intent("Begin opening the element (closed → opening, plays enter animation)") */
| { type: 'open' }
/** @intent("Begin closing the element (open → closing, plays exit animation)") */
| { type: 'close' }
/** @intent("Toggle between open and closed states") */
| { type: 'toggle' }
/** @humanOnly */
| { type: 'animationEnd' }
/** @intent("Set the desired presence directly (true = open, false = closed)") */
| { type: 'setPresent'; present: boolean }
PresenceStatus from @llui/components
Presence — track mount/unmount lifecycle with exit-delay support.
In many components (dialogs, tooltips, menus) the consumer wants to:
- close the overlay (fire exit animation)
- keep it mounted long enough for the animation to finish
- unmount it
LLui already provides @llui/transitions for most of this, but a
presence machine is useful when you want to coordinate multiple
elements or expose state outside the transition primitive.
State flow: closed → (open) → opening → open open → (close) → closing → closed
The consumer fires animationEnd to advance past opening/closing.
If unmountOnExit is true, closed means "safe to remove from DOM";
otherwise the element stays mounted even when closed (display:none).
export type PresenceStatus = 'closed' | 'opening' | 'open' | 'closing'
ProgressMsg from @llui/components
export type ProgressMsg =
/** @humanOnly */
| { type: 'setValue'; value: number | null }
/** @humanOnly */
| { type: 'setMax'; max: number }
QrCodeMsg from @llui/components
export type QrCodeMsg =
/** @intent("Set the encoded value (consumer encodes externally and dispatches setMatrix)") */
| { type: 'setValue'; value: string }
/** @humanOnly */
| { type: 'setMatrix'; matrix: boolean[][] }
/** @intent("Change the QR error-correction level (L/M/Q/H)") */
| { type: 'setErrorCorrection'; level: ErrorCorrectionLevel }
RadialShape from @llui/components
export type RadialShape = 'circle' | 'ellipse'
RadialSize from @llui/components
export type RadialSize = 'closest-side' | 'closest-corner' | 'farthest-side' | 'farthest-corner'
RadioGroupMsg from @llui/components
export type RadioGroupMsg =
/** @intent("Pick the radio option with the given value") */
| { type: 'setValue'; value: string }
/** @humanOnly */
| { type: 'setItems'; items: string[]; disabled?: string[] }
/** @intent("Move selection to the next enabled option after the given value") */
| { type: 'selectNext'; from: string }
/** @intent("Move selection to the previous enabled option before the given value") */
| { type: 'selectPrev'; from: string }
/** @intent("Select the first enabled option") */
| { type: 'selectFirst' }
/** @intent("Select the last enabled option") */
| { type: 'selectLast' }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
RatingGroupMsg from @llui/components
export type RatingGroupMsg =
/** @intent("Set the rating value directly (clamped to 0..count, snapped to 0.5 if allowHalf)") */
| { type: 'setValue'; value: number }
/** @humanOnly */
| { type: 'hover'; value: number | null }
/** @humanOnly */
| { type: 'clickItem'; index: number; isStartHalf: boolean }
/** @humanOnly */
| { type: 'hoverItem'; index: number; isStartHalf: boolean }
/** @intent("Increase the rating by step (default: 0.5 if allowHalf, else 1)") */
| { type: 'incrementValue'; step?: number }
/** @intent("Decrease the rating by step (default: 0.5 if allowHalf, else 1)") */
| { type: 'decrementValue'; step?: number }
/** @intent("Snap the rating to its maximum (count)") */
| { type: 'toEnd' }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
RelativeTimeUnit from @llui/components
export type RelativeTimeUnit =
| 'year'
| 'quarter'
| 'month'
| 'week'
| 'day'
| 'hour'
| 'minute'
| 'second'
ResolvedTheme from @llui/components
export type ResolvedTheme = 'light' | 'dark'
RovingMove from @llui/components
The navigation a key implies on a roving tablist.
export type RovingMove =
/** An arrow / Home / End resolved to a (different, enabled) tab value. */
{
type: 'focus';
value: string;
}
/** Enter or Space — activate the currently focused tab (manual mode). */
| {
type: 'activate';
};
RovingOrientation from @llui/components
Headless roving-tablist navigation — the keyboard logic of a WAI-ARIA tablist, decoupled from any particular DOM contract.
components/tabs.ts builds its reactive part-bags on top of this; a
consumer that wants its OWN markup (different classes, ids, no
data-scope/data-part) can drive the same keyboard behaviour by
calling resolveRovingMove from its trigger's onKeyDown and
focusRovingTab to move DOM focus — without adopting the component's
markup or its connect() state machine.
The resolver is pure (key + current value + items → a move); the only
shared DOM assumption lives in focusRovingTab, and it is the minimal
one both surfaces already satisfy: triggers carry role="tab" and
data-value="<value>".
The list walk itself lives in list-navigation.ts — this module is the
keyboard + DOM-focus surface over it, nothing more.
export type RovingOrientation = 'horizontal' | 'vertical';
ScrollAreaMsg from @llui/components
export type ScrollAreaMsg =
/** @humanOnly */
| {
type: 'setScroll'
scrollTop: number
scrollLeft: number
scrollWidth: number
scrollHeight: number
clientWidth: number
clientHeight: number
}
/** @humanOnly */
| { type: 'setScrolling'; scrolling: boolean }
/** @humanOnly */
| { type: 'setHovered'; hovered: boolean }
ScrollbarVisibility from @llui/components
Scroll area — custom-styled scroll container with scrollbars that
can be hidden/shown based on scroll activity or hover. The state
machine tracks scroll position + overflow flags (whether content
actually overflows each axis); the view layer installs listeners
that populate these via setScroll / setOverflow.
This component is primarily a structural shell — the real scrolling is done by the browser (overflow: auto) on the viewport element. The machine just tracks position so the view can render custom thumbs positioned proportionally.
Typical onMount wiring:
const viewport = root.querySelector('[data-part="viewport"]') const sync = () => send({type:'setScroll', ...dimsOf(viewport)}) viewport.addEventListener('scroll', sync) const ro = new ResizeObserver(sync); ro.observe(viewport); ro.observe(content) sync() // initial
export type ScrollbarVisibility = 'auto' | 'always' | 'hover' | 'scroll'
SearchFieldMsg from @llui/components
export type SearchFieldMsg =
/** @humanOnly */
| { type: 'setValue'; value: string }
/** @intent("Clear the search field") */
| { type: 'clear' }
/** @intent("Submit the current search query") */
| { type: 'submit'; value: string }
SelectionMode from @llui/components
Listbox — a list of selectable options. Supports single and multiple
selection, keyboard navigation (arrows, Home, End), typeahead, and
disabled items. Renders as role="listbox" with role="option" items.
export type SelectionMode = 'single' | 'multiple'
SelectMsg from @llui/components
export type SelectMsg =
/** @intent("Open the select dropdown") */
| { type: 'open' }
/** @intent("Close the select dropdown") */
| { type: 'close' }
/** @intent("Toggle the select dropdown open/closed") */
| { type: 'toggle' }
/** @intent("Pick the option with the given value (toggles in multi-select)") */
| { type: 'selectOption'; value: string }
/** @intent("Replace the selected values with the provided list") */
| { type: 'setValue'; value: string[] }
/** @intent("Clear all selected values") */
| { type: 'clear' }
/** @humanOnly */
| { type: 'highlight'; value: string | null }
/** @humanOnly */
| { type: 'highlightNext' }
/** @humanOnly */
| { type: 'highlightPrev' }
/** @humanOnly */
| { type: 'highlightFirst' }
/** @humanOnly */
| { type: 'highlightLast' }
/** @intent("Pick the currently-highlighted option") */
| { type: 'selectHighlighted' }
/** @humanOnly */
| { type: 'setItems'; items: string[]; disabled?: string[] }
/** @humanOnly */
| { type: 'typeahead'; char: string; now: number }
SignaturePadMsg from @llui/components
export type SignaturePadMsg =
/** @humanOnly */
| { type: 'strokeStart'; x: number; y: number; pressure?: number }
/** @humanOnly */
| { type: 'strokePoint'; x: number; y: number; pressure?: number }
/** @humanOnly */
| { type: 'strokeEnd' }
/** @humanOnly */
| { type: 'strokeCancel' }
/** @intent("Undo the last completed stroke") */
| { type: 'undo' }
/** @humanOnly */
| { type: 'redo'; stroke: Stroke }
/** @intent("Erase the entire signature") */
| { type: 'clear' }
/** @humanOnly */
| { type: 'setStrokes'; strokes: Stroke[] }
SignatureStroke from @llui/components
export type Stroke = Point[]
SliderMsg from @llui/components
export type SliderMsg =
/** @intent("Replace all thumb values at once") */
| { type: 'setValue'; value: number[] }
/** @intent("Set the value of the thumb at the given index. Ignored while disabled") */
| { type: 'setThumb'; index: number; value: number }
/** @intent("Move the thumb at the given index up by one step (or step × multiplier). Ignored while disabled") */
| { type: 'increment'; index: number; multiplier?: number }
/** @intent("Move the thumb at the given index down by one step (or step × multiplier). Ignored while disabled") */
| { type: 'decrement'; index: number; multiplier?: number }
/** @intent("Snap the thumb at the given index to the slider's minimum. Ignored while disabled") */
| { type: 'toMin'; index: number }
/** @intent("Snap the thumb at the given index to the slider's maximum. Ignored while disabled") */
| { type: 'toMax'; index: number }
/** @intent("Enable or disable the slider — a host/agent write, never gated") */
| { type: 'setDisabled'; disabled: boolean }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
SortableMsg from @llui/components
export type SortableMsg =
/** @humanOnly */
| {
type: 'start'
id: string
index: number
/** Items in the origin container (for "item X of N"). */
count: number
container: string
x: number
y: number
}
/** @humanOnly */
| { type: 'move'; index: number; container: string; x: number; y: number }
/** @humanOnly */
| { type: 'drop' }
/** @humanOnly */
| { type: 'cancel' }
/** @humanOnly */
| { type: 'toggleGrab'; id: string; index: number; count: number; container: string }
/** @humanOnly */
| { type: 'moveBy'; delta: number }
SortDirection from @llui/components
Table / data grid — a headless machine for sortable columns, row
selection, and APG grid keyboard navigation. It is NOT a rendering
engine: row DATA stays in the consumer; the machine tracks only row
IDs (in display order), sort state, the selected-id set, and the
focused cell coordinate. The consumer renders the grid (via each or
virtualEach) and performs the actual data sort — so server-side sort
works by feeding pre-sorted rows back in. focusedCell is addressed
by index, robust to virtualization.
export type SortDirection = 'asc' | 'desc'
SparklineBandKind from @llui/components
export type SparklineBandKind = 'between' | 'below' | 'above'
SparklineMsg from @llui/components
export type SparklineMsg =
/** @intent("Set the point under the cursor, or clear it with null") */
| { type: 'setActive'; index: number | null }
/** @intent("Move the cursor along the points by delta, clamping at the ends") */
| { type: 'moveActive'; delta: number }
/** @intent("Move the cursor to the first point") */
| { type: 'firstActive' }
/** @intent("Move the cursor to the last point") */
| { type: 'lastActive' }
/** @intent("Replace the plotted points") */
| { type: 'setPoints'; points: SparklinePoint[] }
/** @intent("Set the instant the right edge denotes, or derive it with null") */
| { type: 'setNow'; at: number | null }
/** @intent("Set the reference band; null on a side leaves it unbounded") */
| { type: 'setBand'; low: number | null; high: number | null }
/** @intent("Set the viewBox size in user units") */
| { type: 'setSize'; width: number; height: number }
SparklineTone from @llui/components
export type SparklineTone = 'below' | 'in' | 'above' | 'none'
SplitterMsg from @llui/components
export type SplitterMsg =
/** @intent("Set the splitter handle position (0–100, clamped to min/max)") */
| { type: 'setPosition'; position: number }
/** @intent("Move the handle by step (or step × multiplier) toward max") */
| { type: 'increment'; multiplier?: number }
/** @intent("Move the handle by step (or step × multiplier) toward min") */
| { type: 'decrement'; multiplier?: number }
/** @intent("Snap the handle to its minimum position") */
| { type: 'toMin' }
/** @intent("Snap the handle to its maximum position") */
| { type: 'toMax' }
/** @humanOnly */
| { type: 'startDrag' }
/** @humanOnly */
| { type: 'endDrag' }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
StepsMsg from @llui/components
export type StepsMsg =
/** @intent("Jump to a specific step by zero-based index") */
| { type: 'goTo'; step: number }
/** @intent("Advance to the next step") */
| { type: 'next' }
/** @intent("Go back to the previous step") */
| { type: 'prev' }
/** @intent("Mark the given step as completed") */
| { type: 'complete'; step: number }
/** @intent("Mark the given step as having an error") */
| { type: 'markError'; step: number }
/** @intent("Clear the error flag on the given step") */
| { type: 'clearError'; step: number }
/** @intent("Reset progress back to the first step (clears completed and errors)") */
| { type: 'reset' }
StepStatus from @llui/components
Steps — progress indicator for multi-step flows (wizards, checkouts). Tracks current step and completed steps; supports linear and non-linear navigation.
export type StepStatus = 'pending' | 'current' | 'completed' | 'error'
SwitchMsg from @llui/components
export type SwitchMsg =
/** @intent("Flip the switch on/off") */
| { type: 'toggle' }
/** @intent("Set the switch's checked state to a specific value") */
| { type: 'setChecked'; checked: boolean }
/** @humanOnly */
| { type: 'setDisabled'; disabled: boolean }
SyncEngineFocusBodyRequired from @llui/components
The type an ASYNC body collapses to in {@link runEngineFocus}'s parameter
position. Nothing is assignable to it, so runEngineFocus(async () => …) is a
compile error naming the contract rather than a silently inert call.
export type SyncEngineFocusBodyRequired = {
readonly [SYNC_BODY_REQUIRED]: 'runEngineFocus requires a SYNCHRONOUS body — the guard is released the moment body returns';
};
TableMsg from @llui/components
export type TableMsg =
/** @intent("Cycle the sort on the given column (asc → desc → none, or desc → asc → none when descFirst)") */
| { type: 'toggleSort'; columnId: string }
/** @intent("Set an explicit sort, or null to clear sorting") */
| { type: 'setSort'; sort: TableSort | null }
/** @intent("Toggle selection of the row with the given id at the given display index") */
| { type: 'toggleRow'; id: string; index: number }
/** @intent("Select every row (multiple mode only)") */
| { type: 'selectAll' }
/** @intent("Clear the entire selection") */
| { type: 'clearSelection' }
/** @intent("Toggle between select-all and clear, based on whether every row is selected") */
| { type: 'toggleAll' }
/** @intent("Replace the selected-id set with the provided list") */
| { type: 'setSelection'; ids: string[] }
/** @intent("Select the inclusive range from the current anchor to the given index (Shift+click)") */
| { type: 'selectRange'; index: number }
/** @intent("Activate (open/confirm) the row with the given id at the given index") */
| { type: 'activateRow'; id: string; index: number }
/** @intent("Replace the row-id list (display order); drops selection for ids no longer present") */
| { type: 'setRows'; rows: string[] }
/** @intent("Replace the column descriptors") */
| { type: 'setColumns'; columns: TableColumn[] }
/** @humanOnly */
| { type: 'focusCell'; rowIndex: number; colIndex: number }
/** @humanOnly */
| { type: 'moveCell'; dRow: number; dCol: number }
/** @humanOnly */
| { type: 'rowStart' }
/** @humanOnly */
| { type: 'rowEnd' }
/** @humanOnly */
| { type: 'gridStart' }
/** @humanOnly */
| { type: 'gridEnd' }
/** @humanOnly */
| { type: 'pageDown' }
/** @humanOnly */
| { type: 'pageUp' }
TableSelectionMode from @llui/components
export type TableSelectionMode = 'none' | 'single' | 'multiple'
TabsMsg from @llui/components
export type TabsMsg =
/** @intent("Switch to the tab with the given value") */
| { type: 'setValue'; value: string }
/** @humanOnly */
| { type: 'setItems'; items: string[]; disabled?: string[] }
/** @humanOnly */
| { type: 'focusTab'; value: string }
/**
* @intent("Activate the tab with the given value — the click/press action (deselects it when `deselectable` and it is already active)")
*/
| { type: 'activateTab'; value: string }
/** @humanOnly */
| { type: 'focusNext'; from: string }
/** @humanOnly */
| { type: 'focusPrev'; from: string }
/** @humanOnly */
| { type: 'focusFirst' }
/** @humanOnly */
| { type: 'focusLast' }
/** @intent("Activate the currently-focused tab (for manual activation mode)") */
| { type: 'activateFocused' }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
/** @humanOnly — synchronized from the mounted root's live ancestor direction. */
| { type: 'syncDomDir'; dir: 'ltr' | 'rtl' }
TagsInputMsg from @llui/components
export type TagsInputMsg =
/** @intent("Update the in-progress text in the input field") */
| { type: 'setInput'; value: string }
/** @intent("Commit a new tag (defaults to the current input value)") */
| { type: 'addTag'; value?: string }
/** @intent("Remove the tag at the given index") */
| { type: 'removeTag'; index: number }
/** @intent("Remove the last tag (typically backspace on empty input)") */
| { type: 'removeLast' }
/** @intent("Replace the full tag list with the provided values") */
| { type: 'setValue'; value: string[] }
/** @humanOnly */
| { type: 'focusTag'; index: number | null }
/** @humanOnly */
| { type: 'focusTagNext' }
/** @humanOnly */
| { type: 'focusTagPrev' }
/** @intent("Remove every tag and reset the input") */
| { type: 'clearAll' }
TextDirection from @llui/components
Text reading direction. The single shared RTL vocabulary for the package.
export type TextDirection = 'ltr' | 'rtl';
Theme from @llui/components
Theme Switch — light/dark/system theme toggle.
State machine tracks the user's explicit preference (light, dark, or
system). applyTheme() publishes that PREFERENCE on <html> — writing
data-theme for an explicit choice and REMOVING it for 'system', which is
the state tokens-dark.css's prefers-color-scheme block answers in pure
CSS. Do not resolve 'system' in JS just to honour the OS setting: that
makes watchSystemTheme mandatory, gives SSR nothing correct to render, and
overwrites the preference with its answer.
Typically wired via onMount or in app init:
onMount(() => {
applyTheme(state.theme.theme)
})
resolveTheme() and watchSystemTheme() remain for consumers who need the
resolved value IN JS — canvas theming, a <meta name="theme-color"> — but
they are no longer on the critical path for merely following the OS.
For persistence, the app reducer reads/writes localStorage.theme in its
init/update — the state machine itself is storage-agnostic.
export type Theme = 'light' | 'dark' | 'system'
ThemeSwitchMsg from @llui/components
export type ThemeSwitchMsg = { type: 'setTheme'; theme: Theme } | { type: 'toggle' }
TimeFormat from @llui/components
Time picker — hours and minutes input with increment/decrement buttons. 12 or 24-hour format; optional seconds; step for minutes/seconds.
export type TimeFormat = '12' | '24'
TimePickerMsg from @llui/components
export type TimePickerMsg =
/** @intent("Set the full time value (hours/minutes/seconds)") */
| { type: 'setValue'; value: TimeValue }
/** @intent("Set the hours field directly — 0-23 in 24-hour format, 1-12 in 12-hour format where the current AM/PM is kept") */
| { type: 'setHours'; hours: number }
/** @intent("Set the minutes field directly") */
| { type: 'setMinutes'; minutes: number }
/** @intent("Set the seconds field directly") */
| { type: 'setSeconds'; seconds: number }
/** @intent("Bump hours up by 1 (wraps at 24/12). Ignored while disabled") */
| { type: 'incrementHours' }
/** @intent("Bump hours down by 1. Ignored while disabled") */
| { type: 'decrementHours' }
/** @intent("Bump minutes up by minuteStep. Ignored while disabled") */
| { type: 'incrementMinutes' }
/** @intent("Bump minutes down by minuteStep. Ignored while disabled") */
| { type: 'decrementMinutes' }
/** @intent("Flip between AM and PM (12-hour format only). Ignored while disabled") */
| { type: 'toggleAmPm' }
/** @intent("Enable or disable the time-picker — a host/agent write, never gated") */
| { type: 'setDisabled'; disabled: boolean }
TimerDirection from @llui/components
Timer — counts elapsed time up from zero, or down from a configured
target. The machine is pure: it doesn't own the ticking interval.
The consumer runs setInterval(() => send({type:'tick', now: Date.now()}), 100)
(or whatever granularity) while the timer is running, and dispatches
start / pause / reset in response to user input.
// `div`, `button`, `text`, `mapSend` are imports from '@llui/dom'.
view: ({ state, send }) => {
const timerState = state.at('timer')
const timerSend = mapSend<Msg, timer.TimerMsg>(send, (msg) => ({
type: 'timer',
msg,
}))
const t = timer.connect(timerState, timerSend)
return [
div({ ...t.root }, [
div({ ...t.display }, [
text(timerState.map((s) => timer.formatMs(timer.display(s), 'mm:ss'))),
]),
button({ ...t.startTrigger }, [text('Start')]),
button({ ...t.pauseTrigger }, [text('Pause')]),
button({ ...t.resetTrigger }, [text('Reset')]),
]),
]
}
export type Direction = 'up' | 'down'
TimerMsg from @llui/components
export type TimerMsg =
/** @intent("Start (or resume) the timer running") */
| { type: 'start'; now: number }
/** @intent("Pause the timer (preserves accumulated elapsed time)") */
| { type: 'pause'; now: number }
/** @intent("Reset the timer back to zero elapsed and pause it") */
| { type: 'reset' }
/** @humanOnly */
| { type: 'tick'; now: number }
/** @intent("Set the countdown target (in milliseconds; 0 disables countdown)") */
| { type: 'setTarget'; targetMs: number }
ToasterMsg from @llui/components
export type ToasterMsg =
/** @intent("Show a new toast notification") */
| { type: 'create'; toast: ToastInput }
/** @intent("Dismiss the toast with the given id") */
| { type: 'dismiss'; id: string }
/** @intent("Dismiss every toast currently visible") */
| { type: 'dismissAll' }
/** @intent("Move the toast region to a different corner/edge of the viewport") */
| { type: 'setPlacement'; placement: ToastPlacement }
/** @intent("Patch mutable presentation fields on the toast with the given id (title, description, type, etc.); `id` cannot be patched") */
| { type: 'update'; id: string; patch: ToastPatch }
/** @humanOnly Advance the countdown for one toast by `elapsedMs` since the last tick. */
| { type: 'tick'; id: string; elapsedMs: number }
/** @intent("Pause auto-dismiss countdown for the toast with the given id") */
| { type: 'pause'; id: string; reason?: ToastPauseReason }
/** @intent("Resume auto-dismiss countdown for the toast with the given id (releases only the given reason, default manual)") */
| { type: 'resume'; id: string; reason?: ToastPauseReason }
/** @intent("Pause auto-dismiss for every visible toast") */
| { type: 'pauseAll'; reason?: ToastPauseReason }
/** @intent("Resume auto-dismiss for every visible toast (releases only the given reason, default manual)") */
| { type: 'resumeAll'; reason?: ToastPauseReason }
/** @humanOnly Exit animation finished for the toast with the given id — remove it from the queue. */
| { type: 'animationEnd'; id: string }
ToastInput from @llui/components
A new toast as supplied to create. remainingMs/pausedBy/status are
optional — seeded from duration/[]/'open' when omitted.
export type ToastInput = Omit<Toast, 'remainingMs' | 'pausedBy' | 'status'> & {
remainingMs?: number
pausedBy?: readonly ToastPauseReason[]
status?: PresenceStatus
}
ToastPatch from @llui/components
Fields an update message may patch on a mounted toast. id is the ONE
truly immutable field — a toast is created once and dismissed once, never
structurally replaced with a different id for the same row — so it is
excluded here rather than silently ignored by the reducer. type and
ariaLive (among others) ARE genuine mutable presentation fields (the
toast.promise-style loading→success/error flow patches type, title
and description on the same mounted toast) and connect()'s toast()
builder binds every one of these reactively (never via a one-shot
peek()), so a patch here is visible wherever it renders.
status/remainingMs/pausedBy are excluded: they are LIFECYCLE fields the
reducer itself owns (presence transitions, the tick-driven countdown,
pause/resume) and a patch is the wrong channel for them — dismiss/tick/
pause/resume already exist and a caller patching remainingMs directly
would race the reducer's own countdown math. duration stays patchable
(it IS presentation — the toast.promise flow moves a sticky loading
toast to a finite success/error duration), so update re-seeds
remainingMs from the new duration whenever duration is part of the
patch (#265 A2) — otherwise a toast created sticky (duration: null,
remainingMs frozen at 0) that is later patched to a finite duration would
inherit that frozen 0 and dismiss on the very next tick instead of lasting
its new duration.
undefined on a REQUIRED field (type, duration, dismissable) means
"not patched", the same as an absent key (#265 G6): { duration: undefined }
leaves the countdown alone rather than re-seeding it to 0 — patch
duration: null to make a toast sticky. On an OPTIONAL field (title,
description, ariaLive) undefined clears it, so a loading -> success
patch can drop a description the success toast should not keep.
export type ToastPatch = Partial<Omit<Toast, 'id' | 'status' | 'remainingMs' | 'pausedBy'>>
ToastPauseReason from @llui/components
Why a toast's countdown is paused. The row's own handlers use 'hover' and
'focus'; a pause/resume message without a reason uses 'manual'.
export type ToastPauseReason = (typeof PAUSE_REASONS)[number]
ToastPlacement from @llui/components
export type ToastPlacement =
| 'top'
| 'top-start'
| 'top-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
ToastPoliteness from @llui/components
aria-live politeness for a toast's announcement region.
export type ToastPoliteness = 'polite' | 'assertive'
ToastType from @llui/components
Toast — ephemeral non-modal notifications rendered in a fixed region. Multiple toasts can be active at once. Each has a duration after which it auto-dismisses (unless paused or sticky).
Architecture (timer-free, tick-driven — same division of labor as timer.ts):
toast.toasterstate manages a collection of toasts. Each toast carries its own countdown in state:duration(null = sticky),remainingMs, andpausedBy(the set of reasons currently holding it paused).- The machine owns NO interval. The consumer drives the countdown with a
tick(id, elapsedMs)message (e.g. via @llui/effectsinterval), subtracting the elapsed wall time since the last tick. A paused toast (any reason held) freezes itsremainingMs(ticks are ignored). - Pause REASONS are independent (#265 G6): the row's hover pauses/resumes
'hover', focus entering/leaving the row pauses/resumes'focus', andpause/resumewith no reason use'manual'. Pointer-leave therefore never resumes a toast whose close button still has keyboard focus. - When
remainingMshits 0 the REDUCER dismisses that toast itself, so there is no consumer/runtime race over who removes it.
Presence (exit animation) — additive, opt-in via init({ animated: true }):
- Each toast carries a presence
status(closed/opening/open/closing). A freshly created toast is born'open'(no enter-animation gate — toasts appear immediately). - Dismissing a toast (manually or when its countdown reaches 0) moves it to
'closing'and KEEPS IT MOUNTED so it can play an exit animation; ananimationEnd(id)message then removes it from the queue. - When the toaster is NOT animated, dismiss removes the toast SYNCHRONOUSLY (today's behavior) — never waiting for an animationend that won't fire.
export type ToastType = 'info' | 'success' | 'warning' | 'error' | 'loading' | 'custom'
TocMsg from @llui/components
export type TocMsg =
/** @humanOnly */
| { type: 'setItems'; items: TocEntry[] }
/** @humanOnly */
| { type: 'setActive'; id: string | null }
/** @intent("Toggle the expanded state of the entry with the given id") */
| { type: 'toggleExpanded'; id: string }
/** @intent("Expand every collapsible entry") */
| { type: 'expandAll' }
/** @intent("Collapse every expanded entry") */
| { type: 'collapseAll' }
ToggleGroupMsg from @llui/components
export type ToggleGroupMsg =
/** @intent("Toggle the button with the given value (in single mode, replaces selection)") */
| { type: 'toggle'; value: string }
/** @intent("Replace the pressed-value set with the provided list") */
| { type: 'setValue'; value: string[] }
/** @humanOnly */
| { type: 'setItems'; items: string[]; disabled?: string[] }
/** @humanOnly */
| { type: 'focusNext'; from: string }
/** @humanOnly */
| { type: 'focusPrev'; from: string }
/** @humanOnly */
| { type: 'focusItem'; value: string }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
ToggleItemPart from @llui/components
export type ToggleItemPart = 'kind-toggle' | 'shape-option' | 'size-option'
ToggleMsg from @llui/components
export type ToggleMsg =
/** @intent("Flip the toggle button's pressed state") */
| { type: 'toggle' }
/** @intent("Set the toggle's pressed state to a specific value") */
| { type: 'setPressed'; pressed: boolean }
/** @humanOnly */
| { type: 'setDisabled'; disabled: boolean }
ToolbarMsg from @llui/components
export type ToolbarMsg =
/** @humanOnly */
| { type: 'setItems'; items: string[]; disabled?: string[] }
/** @humanOnly */
| { type: 'setFocused'; value: string }
/** @humanOnly */
| { type: 'focusNext'; from: string }
/** @humanOnly */
| { type: 'focusPrev'; from: string }
/** @humanOnly */
| { type: 'focusFirst' }
/** @humanOnly */
| { type: 'focusLast' }
TooltipMsg from @llui/components
export type TooltipMsg =
/** @intent("Show the tooltip") */
| { type: 'show' }
/** @intent("Hide the tooltip") */
| { type: 'hide' }
/** @intent("Toggle the tooltip's visibility") */
| { type: 'toggle' }
/** @intent("Set the tooltip's open state to a specific value") */
| { type: 'setOpen'; open: boolean }
/** @humanOnly */
| { type: 'animationEnd' }
TourMsg from @llui/components
export type TourMsg =
/** @intent("Begin the tour at the first step (or current index if resuming)") */
| { type: 'start' }
/** @intent("Close the tour without finishing (does not reset progress)") */
| { type: 'stop' }
/** @intent("Advance to the next step (closes the tour after the last step)") */
| { type: 'next' }
/** @intent("Go back to the previous step") */
| { type: 'prev' }
/** @intent("Jump to a specific step by zero-based index") */
| { type: 'goto'; index: number }
/** @humanOnly */
| { type: 'setSteps'; steps: TourStep[] }
TreeViewEffect from @llui/components
Effects emitted by the tree-view machine for the consumer's onEffect.
export type TreeViewEffect =
/** Fetch the children of `id` lazily, then reply with `childrenLoaded`/`childrenLoadFailed`. */
{ type: 'loadChildren'; id: string }
TreeViewMsg from @llui/components
export type TreeViewMsg =
/** @intent("Toggle the branch with the given id expanded/collapsed") */
| { type: 'toggleBranch'; id: string }
/** @intent("Expand the branch with the given id") */
| { type: 'expand'; id: string }
/** @intent("Collapse the branch with the given id") */
| { type: 'collapse'; id: string }
/** @intent("Expand every branch in the provided id list") */
| { type: 'expandAll'; ids: string[] }
/** @intent("Collapse every expanded branch") */
| { type: 'collapseAll' }
/** @intent("Select the item with the given id (additive=true extends multi-selection)") */
| { type: 'select'; id: string; additive?: boolean }
/** @intent("Replace the selected-id set with the provided list") */
| { type: 'setSelected'; ids: string[] }
/** @humanOnly */
| { type: 'focus'; id: string | null }
/** @humanOnly */
| { type: 'focusNext' }
/** @humanOnly */
| { type: 'focusPrev' }
/** @humanOnly */
| { type: 'focusFirst' }
/** @humanOnly */
| { type: 'focusLast' }
/** @humanOnly */
| { type: 'setVisibleItems'; ids: string[]; labels?: string[] }
/** @humanOnly */
| { type: 'typeahead'; char: string; now: number }
/** @humanOnly */
| { type: 'arrowLeftFrom'; id: string; isBranch: boolean; parentId: string | null }
/** @humanOnly */
| { type: 'arrowRightFrom'; id: string }
/** @intent("Toggle the checkbox on the item with the given id (descendantIds drives recursive check)") */
| { type: 'toggleChecked'; id: string; descendantIds?: string[] }
/** @intent("Replace the checked-id set with the provided list") */
| { type: 'setChecked'; ids: string[] }
/** @humanOnly */
| { type: 'setIndeterminate'; ids: string[] }
/** @intent("Begin renaming the item with the given id (seeds the rename input with `initial`)") */
| { type: 'renameStart'; id: string; initial: string }
/** @intent("Update the rename draft as the user types") */
| { type: 'renameChange'; value: string }
/** @intent("Commit the in-progress rename (clears the rename state)") */
| { type: 'renameCommit' }
/** @intent("Cancel the in-progress rename without applying changes") */
| { type: 'renameCancel' }
/** @intent("Mark the branch with the given id as loading children (typically before an async fetch)") */
| { type: 'loadingStart'; id: string }
/** @intent("Clear the loading state for the given branch id (after async fetch completes)") */
| { type: 'loadingEnd'; id: string }
/** @intent("Replace the whole tree structure (adjacency record + root ids)") */
| { type: 'setNodes'; nodes: Record<string, TreeNodeMeta>; roots: string[] }
/** @intent("Supply the lazily-loaded children of branch `id` (clears loading, marks loaded)") */
| { type: 'childrenLoaded'; id: string; items: TreeNodeInput[] }
/** @intent("Report that the lazy load of branch `id` failed (allows retry on re-expand)") */
| { type: 'childrenLoadFailed'; id: string }
VisibleBreadcrumb from @llui/components
export type VisibleBreadcrumb =
| { type: 'item'; id: string; label: string; current: boolean }
| { type: 'ellipsis' }
Interfaces
AccordionInit from @llui/components
export interface AccordionInit {
value?: string[]
multiple?: boolean
collapsible?: boolean
disabled?: boolean
items?: string[]
/**
* Retain closing content for an exit animation. Off by default so a missing
* stylesheet/event cannot leave hidden semantic content mounted forever.
*/
animated?: boolean
}
AccordionItemParts from @llui/components
export interface AccordionItemParts {
trigger: {
type: 'button'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
id: string
'data-state': ReadSignal<'open' | 'closed'>
'data-disabled': ReadSignal<'' | undefined>
disabled: ReadSignal<boolean>
'data-scope': 'accordion'
'data-part': 'trigger'
'data-value': string
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
content: {
role: 'region'
id: string
'aria-labelledby': string
'data-state': ReadSignal<'open' | 'closing' | 'closed'>
'data-scope': 'accordion'
'data-part': 'content'
'data-value': string
hidden: ReadSignal<boolean>
'aria-hidden': ReadSignal<'true' | undefined>
inert: ReadSignal<boolean>
onAnimationStart: (e: AnimationEvent) => void
onAnimationEnd: (e: AnimationEvent) => void
onAnimationCancel: (e: AnimationEvent) => void
onTransitionStart: (e: TransitionEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
onTransitionCancel: (e: TransitionEvent) => void
}
item: {
'data-state': ReadSignal<'open' | 'closed'>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'accordion'
'data-part': 'item'
'data-value': string
}
}
AccordionParts from @llui/components
export interface AccordionParts {
root: {
// No role on the root: an accordion is a set of disclosure buttons, and
// labelling the whole container `region` (without an accessible name) just
// adds an unlabeled landmark. The meaningful regions are the per-item
// panels, each a `role="region"` with `aria-labelledby` its trigger.
'data-scope': 'accordion'
'data-part': 'root'
'data-orientation': 'vertical'
}
item: (value: string) => AccordionItemParts
/**
* Settles a retained `closing` item once its content's own exit
* animation/transition ends — or immediately, if the skin runs no exit
* motion at all. This only ever has anything to settle for a
* PROGRAMMATIC close/toggle/setValue when that message carried
* `retain: true`, which `parts.close(value)` stamps for you; a raw
* `send({ type: 'close', value })` with no `retain` closes instantly and
* never enters `closing` at all, so there is nothing here to settle for
* it. Its mount ALSO reports whether it is placed at all (#264 item F1):
* `animated: true` only ever retains `closing` content while this is
* mounted — forgetting to place it degrades gracefully to an instant
* close (with a one-time dev warning) rather than hanging `closing` +
* `inert` forever, so placing it is no longer required for SAFETY, only
* for the requested exit animation to actually run on a retained close.
* A click-driven close is still safety-netted synchronously inside the
* trigger regardless of whether this is placed.
*/
exitCompletion: Mountable
/**
* An animated-aware programmatic close (#264 review-264j): a raw
* `send({ type: 'close', value })` from app/agent code carries no
* `retain` and therefore closes INSTANTLY, even with `animated: true` and
* `exitCompletion` placed — documented, fail-safe behavior, since a bare
* message has no way to know whether a watcher happens to be mounted.
* `parts.close(value)` is the correct way for a host to close an item
* programmatically and still get the animated exit: it stamps `retain`
* from the SAME runtime registry the trigger handlers read.
*/
close: (value: string) => void
}
AccordionState from @llui/components
Accordion — a stack of expandable panels. Items are identified by a string
value. Either a single item is expandable at a time (default) or many
(multiple: true). collapsible: false prevents closing the only open
item in single mode.
Items themselves are provided by the user's view (accordion is agnostic to
item data). The connect() API returns a root prop set and an item(value)
factory that produces trigger and content prop sets scoped to that item.
export interface AccordionState {
/** Values of currently-expanded items. */
value: string[]
multiple: boolean
collapsible: boolean
disabled: boolean
/** Ordered list of item values (for keyboard navigation). */
items: string[]
/** Presentation-only items retained while their opted-in exit animation runs. */
closing: string[]
/** Monotonic exit generation per item, used to reject stale end events. */
exitGenerations: RetainedExitGeneration<string>[]
/** Monotonic source for collision-free exit generations; never grows by id. */
exitSequence: number
/** Whether closed content is retained until its own animation end/cancel event. */
animated: boolean
}
AlertDialogOverlayOptions from @llui/components
export interface AlertDialogOverlayOptions {
/**
* Class applied to the positioner — the wrapper `div` `dialog.overlay` builds
* around the content. Forwarded verbatim; the consumer supplies the layer's
* `fixed inset-0` and `z-index`, since the part bag carries only `data-*`.
*
* This interface does not spread `DialogOverlayOptions`, so a new dialog
* option has to be restated here to reach `dialogOverlay` through the `...opts`
* below — which is exactly how this one was missed the first time.
*/
positionerClass?: string
state: ReadSignal<DialogState>
send: Send<DialogMsg>
parts: AlertDialogParts
content: () => Renderable
/**
* Optional enter/leave transition for the alert-dialog content (from
* `@llui/transitions`), forwarded to `dialog.overlay`. `enter` animates it in
* on open; `leave` defers the unmount until its promise resolves, so the close
* plays an exit animation. Keep `skipAnimations` at its default (true) here.
*
* @example alertDialog.overlay({ state, send, parts, content, transition: fade({ duration: 150 }) })
*/
transition?: TransitionOptions
closeOnEscape?: boolean
/** Whether outside-click should dismiss (default: false for alert dialogs). */
closeOnOutsideClick?: boolean
trapFocus?: boolean
lockScroll?: boolean
hideSiblings?: boolean
target?: string | HTMLElement
initialFocus?: Element | (() => Element | null)
restoreFocus?: boolean
}
AlertDialogState from @llui/components
Dialog — modal / non-modal overlay. Ties together focus-trap, dismissable, body scroll lock, sibling aria-hidden, and portal-to-body rendering into a single view helper.
Two layers:
- state machine (
init,update,connect) — pure, minimal. overlay()view helper — opens the dialog's DOM tree inside a body portal, wires up all accessibility utilities on mount, tears them down on close, restores focus to the trigger.
view: ({ state, send }) => {
const dialogState = state.at('dialog')
const dialogSend = mapSend<Msg, dialog.DialogMsg>(send, (msg) => ({
type: 'dialog',
msg,
}))
const parts = dialog.connect(dialogState, dialogSend, { id: 'dialog' })
return [
button({ ...parts.trigger, class: 'btn' }, [text('Delete')]),
dialog.overlay({
state: dialogState,
send: dialogSend,
parts,
content: () => [
div({ ...parts.content, class: 'dialog' }, [
h2({ ...parts.title }, [text('Are you sure?')]),
button({ ...parts.closeTrigger, class: 'btn' }, [text('Cancel')]),
]),
],
}),
]
}
export interface DialogState {
open: boolean
/** Presence lifecycle — drives data-state and keeps the node mounted through exit
* animations. Optional: a partial `{ open }` bridge (e.g. a pattern passing a
* slice to `overlay`) omits it, and the runtime falls back to `open` for instant,
* backward-compatible mount/visibility. `init` always sets it. */
status?: PresenceStatus
/** When true, close transitions go straight to 'closed' (no exit-animation wait).
* Optional for the same partial-slice reason as `status`; `init` always sets it. */
skipAnimations?: boolean
}
Anatomy from @llui/components
export interface Anatomy<P extends string> {
readonly name: string
readonly parts: readonly P[]
/** Create a new scope instance. Pass an explicit id to force a value (SSR). */
scope(id?: string): AnatomyScope<P>
}
AnatomyScope from @llui/components
export interface AnatomyScope<P extends string> {
/** Instance id — unique across all anatomy scopes. */
readonly id: string
/** Resolve the id for a specific part (for ARIA wiring). */
idFor(part: P): string
/** Build the common data-attrs + id for a part. */
attrs(part: P): { id: string; 'data-scope': string; 'data-part': P }
}
AngleSliderInit from @llui/components
export interface AngleSliderInit {
value?: number
min?: number
max?: number
step?: number
disabled?: boolean
readonly?: boolean
dir?: 'ltr' | 'rtl'
}
AngleSliderParts from @llui/components
export interface AngleSliderParts {
root: {
role: 'slider'
'aria-valuemin': ReadSignal<number>
'aria-valuemax': ReadSignal<number>
'aria-valuenow': ReadSignal<number>
'aria-valuetext': ReadSignal<string>
'aria-orientation': 'horizontal'
'aria-disabled': ReadSignal<'true' | undefined>
'aria-readonly': ReadSignal<'true' | undefined>
tabindex: ReadSignal<number>
'data-scope': 'angle-slider'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
onKeyDown: (e: KeyboardEvent) => void
}
control: {
'data-scope': 'angle-slider'
'data-part': 'control'
}
/**
* The draggable thumb element. Its position is typically computed via
* CSS custom properties `--angle` (0..360) that the consumer sets from
* `state.value` using pointFromAngle() or a CSS `transform: rotate()`.
*/
thumb: {
'data-scope': 'angle-slider'
'data-part': 'thumb'
'data-value': ReadSignal<string>
}
valueText: {
'data-scope': 'angle-slider'
'data-part': 'value-text'
}
/** A hidden input for form participation. */
hiddenInput: {
type: 'hidden'
value: ReadSignal<string>
name?: string
'data-scope': 'angle-slider'
'data-part': 'hidden-input'
}
}
AngleSliderState from @llui/components
Angle slider — a circular input that selects a value in 0..360 degrees by dragging a thumb around a control. The state machine tracks the current angle; the view layer computes angles from pointer positions (helpers exported for that purpose).
Typical view wiring: on pointerdown/pointermove, read the control
element's bounding rect, compute the angle from (pointerX, pointerY)
to the rect center via angleFromPoint(), and dispatch setValue.
Keyboard: Arrow keys adjust by step; Home/End jump to min/max;
PageUp/PageDown adjust by step * 10.
export interface AngleSliderState {
value: number
min: number
max: number
step: number
disabled: boolean
readonly: boolean
/** Reading direction. Under 'rtl' horizontal arrow keys are flipped. */
dir: 'ltr' | 'rtl'
}
AsyncListInit from @llui/components
export interface AsyncListInit<T = unknown> {
items?: T[]
page?: number
hasMore?: boolean
}
AsyncListParts from @llui/components
export interface AsyncListParts {
root: {
'data-scope': 'async-list'
'data-part': 'root'
'data-status': ReadSignal<AsyncStatus>
/** `'true'` while a page request is in flight — the list's content is changing. */
'aria-busy': ReadSignal<'true' | 'false'>
/** Present once a load has SETTLED with zero items: the empty-state hook. */
'data-empty': ReadSignal<'' | undefined>
/** Present when the source reports no further pages. */
'data-exhausted': ReadSignal<'' | undefined>
}
sentinel: {
'data-scope': 'async-list'
'data-part': 'sentinel'
'aria-hidden': 'true'
}
loadMoreTrigger: {
type: 'button'
disabled: ReadSignal<boolean>
'data-scope': 'async-list'
'data-part': 'load-more-trigger'
onClick: (e: MouseEvent) => void
}
retryTrigger: {
type: 'button'
'data-scope': 'async-list'
'data-part': 'retry-trigger'
hidden: ReadSignal<boolean>
onClick: (e: MouseEvent) => void
}
errorText: {
role: 'alert'
'aria-live': 'polite'
'data-scope': 'async-list'
'data-part': 'error-text'
hidden: ReadSignal<boolean>
}
}
AsyncListState from @llui/components
export interface AsyncListState<T = unknown> {
items: T[]
page: number
hasMore: boolean
status: AsyncStatus
error: string | null
}
AvatarInit from @llui/components
export interface AvatarInit {
status?: ImageStatus
}
AvatarParts from @llui/components
export interface AvatarParts {
root: {
'data-scope': 'avatar'
'data-part': 'root'
'data-status': ReadSignal<ImageStatus>
'data-density': AvatarDensity | undefined
}
image: {
'data-scope': 'avatar'
'data-part': 'image'
'data-status': ReadSignal<ImageStatus>
hidden: ReadSignal<boolean>
alt: string
onLoad: (e: Event) => void
onError: (e: Event) => void
onLoadStart: (e: Event) => void
}
fallback: {
'data-scope': 'avatar'
'data-part': 'fallback'
'data-status': ReadSignal<ImageStatus>
hidden: ReadSignal<boolean>
'aria-hidden': ReadSignal<'true' | undefined>
}
}
AvatarState from @llui/components
export interface AvatarState {
status: ImageStatus
}
Band from @llui/components
A discrete axis of count slots laid out across [0, 1].
export interface Band {
count: number
/** Gap between adjacent bands, as a fraction of a slot. */
paddingInner: number
/** Gap before the first and after the last band, as a fraction of a slot. */
paddingOuter: number
}
BreadcrumbItem from @llui/components
Breadcrumbs — a hierarchical trail of links to ancestor pages.
The last item is the current page. When maxVisible is set and the trail
is longer, the middle collapses to: first item + ellipsis + last N items,
until the user expands it.
export interface BreadcrumbItem {
id: string
label: string
}
BreadcrumbsInit from @llui/components
export interface BreadcrumbsInit {
items?: BreadcrumbItem[]
maxVisible?: number | null
expanded?: boolean
}
BreadcrumbsParts from @llui/components
export interface BreadcrumbsParts {
root: {
'aria-label': string
'data-scope': 'breadcrumbs'
'data-part': 'root'
}
list: {
'data-scope': 'breadcrumbs'
'data-part': 'list'
}
item: (id: string) => {
'data-scope': 'breadcrumbs'
'data-part': 'item'
'data-value': string
}
link: (id: string) => {
'aria-current': ReadSignal<'page' | undefined>
'data-scope': 'breadcrumbs'
'data-part': 'link'
'data-value': string
'data-current': ReadSignal<'' | undefined>
}
separator: {
'aria-hidden': 'true'
'data-scope': 'breadcrumbs'
'data-part': 'separator'
}
ellipsisTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'breadcrumbs'
'data-part': 'ellipsis-trigger'
onClick: (e: MouseEvent) => void
}
}
BreadcrumbsState from @llui/components
export interface BreadcrumbsState {
items: BreadcrumbItem[]
maxVisible: number | null
expanded: boolean
}
CalendarOptions from @llui/components
export interface CalendarOptions {
/**
* Minutes east of UTC that boundaries are measured in. Default 0 (UTC). A
* FIXED offset — see the module note; `-300` is US Eastern standard time and
* stays at −5 across a DST transition rather than following it.
*/
offsetMinutes?: number
/** First day of the week for the `week` rung: 0 = Sunday … 6 = Saturday.
* Default 1 (Monday), matching ISO 8601. */
weekStartsOn?: number
}
CalendarStep from @llui/components
A chosen rung, plus how many of it each boundary advances. stride is
always 1 except on year.
export interface CalendarStep {
unit: CalendarUnit
stride: number
}
CalendarStepOptions from @llui/components
export interface CalendarStepOptions extends CalendarOptions {
/** Upper bound on the number of boundaries. Default 6. */
maxTicks?: number
/** Force a rung instead of choosing one from the span. */
unit?: CalendarUnit
}
CalendarTick from @llui/components
export interface CalendarTick {
/** The boundary instant, in epoch milliseconds. */
at: number
unit: CalendarUnit
}
CarouselDrag from @llui/components
Live pointer-swipe state. JSON-serializable: just the start X and the accumulated horizontal delta (positive = dragged right, negative = left). The view supplies pointer coordinates; the machine does pure math.
export interface CarouselDrag {
startX: number
deltaX: number
}
CarouselInit from @llui/components
export interface CarouselInit {
current?: number
count?: number
loop?: boolean
autoplay?: boolean
interval?: number
swipeThreshold?: number
dir?: 'ltr' | 'rtl'
}
CarouselParts from @llui/components
export interface CarouselParts {
root: {
id: string
role: 'region'
'aria-roledescription': 'carousel'
'aria-label': string
'data-scope': 'carousel'
'data-part': 'root'
'data-paused': ReadSignal<'' | undefined>
onPointerEnter: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
onFocusIn: (e: FocusEvent) => void
onFocusOut: (e: FocusEvent) => void
}
viewport: {
'data-scope': 'carousel'
'data-part': 'viewport'
/**
* Set while a pointer swipe is in flight — consumers gate the slide-track
* transition off (`[data-dragging] { transition: none }`) so the track
* follows the finger 1:1 instead of easing.
*/
'data-dragging': ReadSignal<'' | undefined>
/** Live track offset (px) to follow the finger: `translateX(var)`. */
'data-drag-offset': ReadSignal<string | undefined>
/** Physical pointer delta consumed by either skin's track transform. */
'style.--carousel-drag-offset': ReadSignal<string | undefined>
onPointerDown: (e: PointerEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerUp: (e: PointerEvent) => void
onPointerCancel: (e: PointerEvent) => void
}
/** Place all slides directly inside this transform-bearing track. */
track: {
'data-scope': 'carousel'
'data-part': 'track'
}
indicatorGroup: {
role: 'tablist'
'aria-label': string
'data-scope': 'carousel'
'data-part': 'indicator-group'
}
nextTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'carousel'
'data-part': 'next-trigger'
onClick: (e: MouseEvent) => void
}
prevTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'carousel'
'data-part': 'prev-trigger'
onClick: (e: MouseEvent) => void
}
slide: (index: number) => CarouselSlideParts
/** Place once anywhere in the same build to keep automatic direction live. */
directionSync: Mountable
}
CarouselSlideParts from @llui/components
export interface CarouselSlideParts {
slide: {
role: 'tabpanel'
id: string
'aria-roledescription': 'slide'
'aria-label': string
'data-scope': 'carousel'
'data-part': 'slide'
'data-index': string
'data-active': ReadSignal<'' | undefined>
hidden: ReadSignal<boolean>
}
indicator: {
type: 'button'
role: 'tab'
/** APG roving tab stop: only the selected indicator participates in Tab. */
tabindex: ReadSignal<0 | -1>
'aria-label': string
'aria-selected': ReadSignal<boolean>
'aria-controls': string
'data-scope': 'carousel'
'data-part': 'indicator'
'data-index': string
'data-active': ReadSignal<'' | undefined>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
}
CarouselState from @llui/components
export interface CarouselState {
current: number
count: number
loop: boolean
autoplay: boolean
interval: number
/** Explicit application pause, independent from hover/focus interaction. */
paused: boolean
/** Pointer is currently over the carousel root. */
hovered: boolean
/** DOM focus is currently contained by the carousel root. */
focusWithin: boolean
/** Direction of the last transition — useful for entry animations. */
direction: 'forward' | 'backward'
/**
* Minimum absolute horizontal distance (px) a swipe must cross to commit
* to the previous/next slide. Below this the drag snaps back.
*/
swipeThreshold: number
/** Active pointer swipe, or null when idle. */
dragging: CarouselDrag | null
/** Reading direction. Under 'rtl' indicator horizontal arrow keys are flipped. */
dir: 'ltr' | 'rtl'
/** Whether direction follows the mounted DOM or explicit init/setDir configuration. */
dirSource: DirectionSource
}
CartesianOptions from @llui/components
export interface CartesianOptions {
/** Swap the axes: `u` runs top-to-bottom and `v` left-to-right. This is what
* turns a column chart into a horizontal bar chart, and it is a PROJECTION
* concern — no mark, scale or hit test changes. */
horizontal?: boolean
}
CascadeLevel from @llui/components
Cascade select — a series of dependent selects where each level's choice filters the options of the next. Classic example: country → state → city. The machine stores a flat list of selections (one per level, or null) and the options at each level; filtering logic is left to the view/consumer.
Level shape: the consumer passes an array of Level descriptors on setLevels, each with its own options. Selecting at level N clears selections at levels > N.
export interface CascadeLevel {
id: string
label: string
options: Array<{ value: string; label: string; disabled?: boolean }>
}
CascadeLevelParts from @llui/components
export interface CascadeLevelParts {
label: {
for: string
'data-scope': 'cascade-select'
'data-part': 'level-label'
}
select: {
id: string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'cascade-select'
'data-part': 'level-select'
'data-level': string
'data-ready': ReadSignal<'' | undefined>
onChange: (e: Event) => void
}
}
CascadeSelectInit from @llui/components
export interface CascadeSelectInit {
levels?: CascadeLevel[]
values?: (string | null)[]
disabled?: boolean
}
CascadeSelectParts from @llui/components
export interface CascadeSelectParts {
root: {
'data-scope': 'cascade-select'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
'data-complete': ReadSignal<'' | undefined>
}
clearTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'cascade-select'
'data-part': 'clear-trigger'
onClick: (e: MouseEvent) => void
}
level: (index: number) => CascadeLevelParts
}
CascadeSelectState from @llui/components
export interface CascadeSelectState {
levels: CascadeLevel[]
/** Parallel to levels: one value per level, or null. */
values: (string | null)[]
disabled: boolean
}
ChartCategoryTick from @llui/components
export interface ChartCategoryTick {
index: number
label: string
x: number
y: number
anchor: 'start' | 'middle' | 'end'
baseline: 'auto' | 'middle' | 'hanging'
active: boolean
}
ChartConnectOptions from @llui/components
export interface ChartConnectOptions {
/** Base id; the title and description ids derive from it. */
id: string
/**
* Whether the consumer renders the `desc` part (default: true). When false,
* the svg's `aria-labelledby` names only the title, never an element that
* does not exist (the `dialog` `hasDescription` rule, #268). The TITLE is
* the image's name — required for `role="img"` — and is always referenced.
*/
hasDescription?: boolean
}
ChartGeometry from @llui/components
export interface ChartGeometry {
frame: Frame
domain: Domain
band: Band
/**
* The proportional allocation of the independent axis under
* `ChartState.domain === 'share'`, and `null` otherwise. It is the axis
* itself, not a decoration: the pointer hit test reads it instead of `band`.
*
* With more than one bar series each ring gets its OWN allocation from its
* own values — that is what makes a nested donut's rings each proportional —
* so a single `u` names different rows on different rings and the pointer
* has to follow one of them. It follows the FIRST bar series, which is the
* whole story for the single-series pie that is the common case.
*/
slices: ShareSlice[] | null
projection: Projection
marks: ChartMark[]
vertices: ChartVertex[]
gridLines: ChartGridLine[]
categoryTicks: ChartCategoryTick[]
/** Anchor for the tooltip, in user units. `null` when nothing is active. */
tooltipAt: { x: number; y: number } | null
tooltipRows: ChartTooltipRow[]
}
ChartGridLine from @llui/components
export interface ChartGridLine {
value: number
label: string
d: string
x: number
y: number
anchor: 'start' | 'middle' | 'end'
baseline: 'auto' | 'middle' | 'hanging'
}
ChartInit from @llui/components
export interface ChartInit {
series: readonly ChartSeries[]
rows?: readonly ChartRow[]
coord?: ChartCoord
domain?: ChartDomain
label?: string
description?: string
width?: number
height?: number
padding?: Partial<ChartPadding>
min?: number
max?: number
tickCount?: number
stacked?: boolean
innerRadius?: number
horizontal?: boolean
}
ChartMark from @llui/components
A drawn mark: one series, one path.
export interface ChartMark {
seriesKey: string
seriesCue: ChartSeriesCue
label: string
mark: MarkType
/** The SVG path `d`. */
d: string
/** Row index, for a `bar`/wedge — `null` for a whole-series line or area. */
index: number | null
active: boolean
dimmed: boolean
}
ChartPadding from @llui/components
export interface ChartPadding {
top: number
right: number
bottom: number
left: number
}
ChartParts from @llui/components
export interface ChartParts {
root: {
'data-scope': 'chart'
'data-part': 'root'
// Spelled out rather than aliased: `scripts/test/registry-attrs.test.ts`
// reads part-bag VALUES syntactically, and an imported alias reads as an
// open type it declines to give a verdict on.
'data-coord': ReadSignal<'cartesian' | 'polar'>
'data-domain': ReadSignal<'value' | 'share'>
'data-active': ReadSignal<'' | undefined>
/** Per-instance forced-colors fill custom properties — see `connect()`'s
* own doc for why these must be per-chart rather than a shared global. */
style: string
}
/**
* The `<svg>`. `role="img"` with a name and description is what a screen
* reader announces; the real data path is {@link ChartParts.table}, because
* AT support for the WAI-ARIA graphics roles is still thin enough that a
* chart relying on them alone is unreadable.
*
* `tabindex="0"` makes the plot itself the keyboard target: arrows walk the
* rows, Home/End jump to the ends, Escape clears. There is no
* `aria-activedescendant` — it is not valid on `role="img"`, and the tooltip
* live region is what actually announces the cursor.
*/
svg: {
'data-scope': 'chart'
'data-part': 'svg'
role: 'img'
'aria-labelledby': string
viewBox: ReadSignal<string>
tabindex: 0
onKeyDown: (e: KeyboardEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
onBlur: (e: FocusEvent) => void
}
title: { id: string; 'data-scope': 'chart'; 'data-part': 'title' }
desc: { id: string; 'data-scope': 'chart'; 'data-part': 'desc' }
/** The visually-hidden `<table>` fallback. Render it with the rows below. */
table: {
'data-scope': 'chart'
'data-part': 'table'
'aria-label': ReadSignal<string>
}
/** Tooltip ATTRIBUTES — spreadable, with its own reactive `hidden`. */
tooltip: {
'data-scope': 'chart'
'data-part': 'tooltip'
role: 'status'
'aria-live': 'polite'
hidden: ReadSignal<boolean>
style: ReadSignal<string>
}
/** A `<g>` stacking layer. Static — spread it on each layer group. */
layer: { 'data-scope': 'chart'; 'data-part': 'layer' }
/** A value gridline. Pass `d` from the {@link ChartGridLine}. */
grid: { 'data-scope': 'chart'; 'data-part': 'grid' }
/** An axis label — value ticks and category names. */
axisLabel: { 'data-scope': 'chart'; 'data-part': 'axis-label' }
/** Attributes for one vertex dot on a line or area series. */
dotProps: (vertex: ChartVertex) => {
'data-scope': 'chart'
'data-part': 'dot'
'data-series': string
'data-series-cue': ChartSeriesCue
'data-active': '' | undefined
cx: number
cy: number
}
legendItem: (key: string) => {
type: 'button'
'data-scope': 'chart'
'data-part': 'legend-item'
'data-series': string
'data-series-cue': ReadSignal<ChartSeriesCue>
'data-dimmed': ReadSignal<'' | undefined>
'aria-pressed': ReadSignal<boolean>
onClick: (e: MouseEvent) => void
}
/** The legend's colour chip. Spreadable onto its own element (a `<span>` in
* both skins) so a forced-colors rule can key off `data-series-cue` the
* SAME way a mark does — a legend swatch that only carries `--mark-color`
* paints identically for every series once forced colors flattens author
* colour, which is the accessibility gap #264 review item 7 names. */
legendSwatch: (key: string) => {
'data-scope': 'chart'
'data-part': 'legend-swatch'
'data-series-cue': ReadSignal<ChartSeriesCue>
}
/** Attributes for one drawn mark. Spread onto a `<path>` and pass `d`. */
markProps: (mark: ChartMark) => {
'data-scope': 'chart'
'data-part': 'mark'
'data-mark': 'line' | 'area' | 'bar'
'data-series': string
'data-series-cue': ChartSeriesCue
'data-active': '' | undefined
'data-dimmed': '' | undefined
d: string
onPointerEnter: (e: PointerEvent) => void
}
// Derived geometry, as signals the view renders with `each`.
marks: ReadSignal<ChartMark[]>
vertices: ReadSignal<ChartVertex[]>
gridLines: ReadSignal<ChartGridLine[]>
categoryTicks: ReadSignal<ChartCategoryTick[]>
tooltipRows: ReadSignal<ChartTooltipRow[]>
activeLabel: ReadSignal<string>
rows: ReadSignal<ChartRow[]>
series: ReadSignal<ChartSeries[]>
}
ChartRow from @llui/components
One row of the independent axis: a category and its value per series.
export interface ChartRow {
label: string
values: Record<string, number>
}
ChartSeries from @llui/components
export interface ChartSeries {
/** Stable key. Also the `--color-<key>` variable a skin reads. */
key: string
/** Human label, used by the legend, the tooltip and the a11y table. */
label: string
mark: MarkType
/** Ignored by a polar projection, which supports `linear` only. */
curve?: Curve
}
ChartState from @llui/components
export interface ChartState {
coord: ChartCoord
/**
* Equal slots per category (`value`), or slots proportional to the value
* (`share`). See {@link ChartDomain}; `share` + `coord: 'polar'` is a pie.
*
* Under `share` only `bar` series are drawn: a line or an area along an axis
* whose spacing already encodes the magnitude would plot each point at a
* position that means something other than where it sits, so they are
* DECLINED rather than approximated — the same call `polarProjection` makes
* about `monotone`. `stacked` is ignored for the same reason, each category
* being its own slice already.
*/
domain: ChartDomain
series: ChartSeries[]
rows: ChartRow[]
/** Accessible name for the whole chart. */
label: string
/** Longer description, announced with the name. */
description: string
width: number
height: number
padding: ChartPadding
/**
* Explicit value-axis bounds. UNBOUNDED-CAPABLE in the sense of `finiteBound`
* (#177): "derive it" is spelled by OMITTING the key, never by `undefined`,
* `null` or `±Infinity` — only the omission survives a JSON round trip
* unchanged.
*/
min?: number
max?: number
tickCount: number
/** Stack series instead of overlaying them. */
stacked: boolean
/** Row index under the pointer or the keyboard cursor. */
activeIndex: number | null
/** Series key the legend has isolated, or `null` for all. */
activeSeries: string | null
/** Polar only: inner radius as a fraction of the outer (0 = pie, .5 = donut). */
innerRadius: number
/** Cartesian only: swap the axes for a horizontal bar chart. */
horizontal: boolean
}
ChartTooltipRow from @llui/components
export interface ChartTooltipRow {
seriesKey: string
label: string
value: number
/**
* The row's fraction of its series' total under a share domain — what a pie
* tooltip shows as a percentage — and `null` under a value domain, where a
* share of an axis that may cross zero is not defined.
*/
share: number | null
}
ChartVertex from @llui/components
A vertex on a line or area series, for the dot layer and hit feedback.
export interface ChartVertex {
seriesKey: string
seriesCue: ChartSeriesCue
index: number
x: number
y: number
active: boolean
}
CheckboxInit from @llui/components
export interface CheckboxInit {
checked?: CheckedState
disabled?: boolean
required?: boolean
}
CheckboxParts from @llui/components
export interface CheckboxParts {
/** The visual box/container — `role="checkbox"` for accessibility. */
root: {
role: 'checkbox'
'aria-checked': ReadSignal<'true' | 'false' | 'mixed'>
'aria-disabled': ReadSignal<'true' | undefined>
'aria-required': ReadSignal<'true' | undefined>
'data-state': ReadSignal<'checked' | 'unchecked' | 'indeterminate'>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'checkbox'
'data-part': 'root'
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
/** A native hidden input for form participation. */
hiddenInput: {
type: 'checkbox'
'aria-hidden': 'true'
tabindex: -1
style: string
checked: ReadSignal<boolean>
indeterminate: ReadSignal<boolean>
disabled: ReadSignal<boolean>
required: ReadSignal<boolean>
'data-scope': 'checkbox'
'data-part': 'hidden-input'
}
/** Optional indicator child (the checkmark). */
indicator: {
'data-state': ReadSignal<'checked' | 'unchecked' | 'indeterminate'>
'data-scope': 'checkbox'
'data-part': 'indicator'
}
}
CheckboxState from @llui/components
export interface CheckboxState {
checked: CheckedState
disabled: boolean
required: boolean
}
ClipboardInit from @llui/components
export interface ClipboardInit {
value?: string
}
ClipboardParts from @llui/components
export interface ClipboardParts {
root: {
'data-scope': 'clipboard'
'data-part': 'root'
'data-copied': ReadSignal<'' | undefined>
'data-failed': ReadSignal<'' | undefined>
}
trigger: {
type: 'button'
'aria-label': string
'data-scope': 'clipboard'
'data-part': 'trigger'
'data-copied': ReadSignal<'' | undefined>
'data-failed': ReadSignal<'' | undefined>
onClick: (e: MouseEvent) => void
}
input: {
type: 'text'
readonly: true
value: ReadSignal<string>
'data-scope': 'clipboard'
'data-part': 'input'
onFocus: (e: FocusEvent) => void
}
indicator: {
'data-scope': 'clipboard'
'data-part': 'indicator'
'data-copied': ReadSignal<'' | undefined>
'data-failed': ReadSignal<'' | undefined>
'aria-live': 'polite'
}
}
ClipboardState from @llui/components
Clipboard — copy-to-clipboard with transient "copied" feedback. The actual clipboard write is performed by the consumer via an effect (or inline in the trigger's onClick handler). Reducer tracks the success state flag.
copy is a REQUEST and does not set copied: the write can fail
(permission denied, insecure context, browser policy) and indicator
carries aria-live="polite", so a flag set before the promise resolves
ANNOUNCES a success that never happened (#232). Dispatch copied from
the resolved write, copyFailed from the rejected one, and reset to clear
the feedback:
onEffect(effect, send) {
copyToClipboard(effect.value).then(
() => send({ type: 'copied' }),
() => send({ type: 'copyFailed' }), // refused — say so, never "copied"
)
}
copyFailed publishes data-failed (root, trigger, indicator); the skin
tells the user the write was refused and that the value is still selectable
in the read-only input (#266).
export interface ClipboardState {
value: string
/** The last write RESOLVED. Exclusive with `failed`. */
copied: boolean
/**
* The last write was REFUSED (permission denied, insecure context, browser
* policy). Exclusive with `copied`. The read-only `input` is the fallback: the
* user can still select the value and copy it by hand, which is what a skin
* should say when this is set (#266).
*/
failed: boolean
}
CollapsibleInit from @llui/components
export interface CollapsibleInit {
open?: boolean
disabled?: boolean
/** Opt into retained exit motion. Defaults off to guarantee no event/no hang. */
animated?: boolean
}
CollapsibleParts from @llui/components
export interface CollapsibleParts {
root: {
'data-state': ReadSignal<'open' | 'closed'>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'collapsible'
'data-part': 'root'
}
trigger: {
type: 'button'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
id: string
disabled: ReadSignal<boolean>
'data-state': ReadSignal<'open' | 'closed'>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'collapsible'
'data-part': 'trigger'
onClick: (e: MouseEvent) => void
}
content: {
role: 'region'
id: string
'aria-labelledby': string
hidden: ReadSignal<boolean>
'data-state': ReadSignal<'open' | 'closing' | 'closed'>
'data-scope': 'collapsible'
'data-part': 'content'
'aria-hidden': ReadSignal<'true' | undefined>
inert: ReadSignal<boolean>
onAnimationStart: (e: AnimationEvent) => void
onAnimationEnd: (e: AnimationEvent) => void
onAnimationCancel: (e: AnimationEvent) => void
onTransitionStart: (e: TransitionEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
onTransitionCancel: (e: TransitionEvent) => void
}
/**
* Settles a retained `closing` panel once the content's own exit
* animation/transition ends — or immediately, if the skin runs no exit
* motion at all. This only ever has anything to settle for a
* PROGRAMMATIC close/toggle/setOpen when that message carried
* `retain: true`, which `parts.close()` stamps for you; a raw
* `send({ type: 'close' })` with no `retain` closes instantly and never
* enters `closing` at all. Its mount ALSO reports whether it is placed at
* all: `animated: true` only ever retains `closing` while this is mounted
* (#264 item F1) — forgetting to place it degrades gracefully to an
* instant close (with a one-time dev warning) rather than hanging
* `closing` + `inert` forever, so placing it is no longer required for
* SAFETY, only for the requested exit animation to actually run on a
* retained close. A click-driven close is still safety-netted
* synchronously inside the trigger regardless of whether this is placed.
*/
exitCompletion: Mountable
/**
* An animated-aware programmatic close (#264 review-264j) — see
* `accordion.ts`'s identical part for the full rationale. A raw
* `send({ type: 'close' })` carries no `retain` and closes INSTANTLY;
* `parts.close()` stamps `retain` from the runtime registry.
*/
close: () => void
}
CollapsibleState from @llui/components
Collapsible — a single expandable/collapsible section. Simpler than accordion (no grouping, no keyboard navigation between siblings).
export interface CollapsibleState {
open: boolean
disabled: boolean
/** Presentation-only retention while an opted-in exit animation runs. */
closing: boolean
/** Monotonic generation used to reject stale animation completion events. */
exitGeneration: number
/** Whether close waits for the content's own animation end/cancel event. */
animated: boolean
}
ColorPickerConnectOptions from @llui/components
export interface ConnectOptions {
hueLabel?: string
saturationLabel?: string
lightnessLabel?: string
chromaLabel?: string
oklchLightnessLabel?: string
hexLabel?: string
eyeDropperLabel?: string
/** aria-label for the 2D area thumb. */
areaLabel?: string
/** aria-label for the alpha slider. */
alphaLabel?: string
/** aria-label for the swatch group container. */
swatchGroupLabel?: string
/** Fine keyboard step for the area thumb (S/V units, or the OKLCH
* equivalent scaled by `state.maxChroma`). Default 1. */
step?: number
/** Coarse keyboard step for the area thumb when Shift is held. Default 10. */
coarseStep?: number
}
ColorPickerInit from @llui/components
export interface ColorPickerInit {
/** Which model the canonical color is stored in. Default `'hsv'`. */
model?: ColorModel
/** Initial color as any CSS color string the shared parser accepts (hex,
* `rgb()`, `hsl()`, `oklch()`, `oklab()`, a named color). Takes precedence
* over `hsl`/`hsv`/`oklch` below, and over `alpha` for the color's own
* alpha component (an explicit `alpha` option still wins). */
color?: string
/** Initial color as HSL (converted into the canonical store). */
hsl?: Hsl
/** Initial color as HSV. Takes precedence over `hsl`. */
hsv?: Hsv
/** Initial color as OKLCH. Takes precedence over `hsl`/`hsv`. */
oklch?: Oklch
alpha?: number
disabled?: boolean
/** Upper bound for OKLCH chroma. Must be finite and > 0 — anything else
* (including omission) falls back to {@link DEFAULT_MAX_CHROMA}. */
maxChroma?: number
}
ColorPickerParts from @llui/components
export interface ColorPickerParts {
root: {
'data-scope': 'color-picker'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
'data-model': ReadSignal<ColorModel>
/** Bare boolean (package convention): present when the current OKLCH
* color falls outside sRGB. Always absent in HSV mode (an HSV color is
* an sRGB parameterization by construction). */
'data-out-of-gamut': ReadSignal<'' | undefined>
}
/** Cycles the active model between `'hsv'` and `'oklch'`. */
modelToggle: {
type: 'button'
'aria-label': ReadSignal<string>
disabled: ReadSignal<boolean>
'data-scope': 'color-picker'
'data-part': 'model-toggle'
'data-model': ReadSignal<ColorModel>
onClick: (e: MouseEvent) => void
}
hueSlider: {
type: 'range'
min: 0
max: 360
step: 1
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'color-picker'
'data-part': 'hue-slider'
onInput: (e: Event) => void
}
saturationSlider: {
type: 'range'
min: 0
max: 100
step: 1
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
style: ReadSignal<string>
'data-scope': 'color-picker'
'data-part': 'saturation-slider'
onInput: (e: Event) => void
}
lightnessSlider: {
type: 'range'
min: 0
max: 100
step: 1
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
style: ReadSignal<string>
'data-scope': 'color-picker'
'data-part': 'lightness-slider'
onInput: (e: Event) => void
}
/** OKLCH chroma, `0..state.maxChroma`. */
chromaSlider: {
type: 'range'
min: 0
max: ReadSignal<number>
step: ReadSignal<number>
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
style: ReadSignal<string>
'data-scope': 'color-picker'
'data-part': 'chroma-slider'
onInput: (e: Event) => void
}
/** OKLCH perceptual lightness, `0..1`. */
oklchLightnessSlider: {
type: 'range'
min: 0
max: 1
step: number
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
style: ReadSignal<string>
'data-scope': 'color-picker'
'data-part': 'oklch-lightness-slider'
onInput: (e: Event) => void
}
hexInput: {
type: 'text'
autocomplete: 'off'
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'color-picker'
'data-part': 'hex-input'
onInput: (e: Event) => void
}
/** Static preview swatch showing the currently-selected color. */
preview: {
'data-scope': 'color-picker'
'data-part': 'preview'
'aria-hidden': 'true'
style: ReadSignal<string>
}
/** The 2D area track. The machine owns the pointer-drag lifecycle
* (capture on down, released on up/cancel, primary button only, ignored
* while disabled): it computes from `currentTarget.getBoundingClientRect()`
* and dispatches `setSv` (HSV mode, via `colorFromPoint`) or `setLc`
* (OKLCH mode, via `lcFromPoint`) — see `utils/pointer-drag.ts`. Keyboard
* continues from wherever a drag left off because `pointerdown` focuses
* `areaThumb`. */
area: {
'data-scope': 'color-picker'
'data-part': 'area'
'data-model': ReadSignal<ColorModel>
// HSV-mode hue backdrop only; empty in OKLCH mode, where `areaCanvas`
// paints the plane instead (the sRGB gamut boundary is not expressible
// as a CSS gradient).
style: ReadSignal<string>
onPointerDown: (e: PointerEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerUp: (e: PointerEvent) => void
onPointerCancel: (e: PointerEvent) => void
onLostPointerCapture: (e: PointerEvent) => void
}
/** Decorative `<canvas>` seam for the OKLCH area — headless, so the
* consumer owns creating and sizing the real `<canvas>` element (give it
* an id and spread these props), but never its own repaint wiring: place
* {@link areaCanvasBinding}`(state, thatSameId)` anywhere in the view and
* it stays painted, on mount and on every hue/`maxChroma` change. Absent/
* inert in HSV mode. */
areaCanvas: {
'data-scope': 'color-picker'
'data-part': 'area-canvas'
'aria-hidden': 'true'
}
/** The draggable thumb inside the 2D area. Keyboard-operable (arrows move
* S/V or C/L depending on the active model; Shift = coarse) with
* role="slider" and a 2D aria-valuetext. `aria-label` follows the ACTIVE
* model ("Saturation / Value" in HSV mode, "Chroma / Lightness" in OKLCH),
* not a fixed string — the axes it labels are literally different. */
areaThumb: {
role: 'slider'
'aria-label': ReadSignal<string>
'aria-valuemin': ReadSignal<number>
'aria-valuemax': ReadSignal<number>
'aria-valuenow': ReadSignal<number>
'aria-valuetext': ReadSignal<string>
'aria-disabled': ReadSignal<'true' | undefined>
tabindex: ReadSignal<number>
'data-scope': 'color-picker'
'data-part': 'area-thumb'
style: ReadSignal<string>
onKeyDown: (e: KeyboardEvent) => void
}
/** Alpha (opacity) range input, 0..1. Wired to the existing alpha state. */
alphaSlider: {
type: 'range'
min: 0
max: 1
step: number
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
style: ReadSignal<string>
'data-scope': 'color-picker'
'data-part': 'alpha-slider'
onInput: (e: Event) => void
}
/** EyeDropper API trigger. Always PLACED, but `hidden`/`disabled`/
* `data-unsupported` all derive from `state.eyeDropperSupported` — a
* consumer writes no feature-detect code of its own; see
* {@link eyeDropperSupportMount}. */
eyeDropperTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
hidden: ReadSignal<boolean>
/** Bare boolean (package convention). */
'data-unsupported': ReadSignal<'' | undefined>
'data-scope': 'color-picker'
'data-part': 'eyedropper-trigger'
onClick: (e: MouseEvent) => void
}
/** Container for the preset swatch buttons. */
swatchGroup: {
role: 'group'
'aria-label': string
'data-scope': 'color-picker'
'data-part': 'swatch-group'
}
/** Factory for a preset swatch button dispatching a single `setColor`. */
swatch: (color: string) => SwatchParts
}
ColorPickerState from @llui/components
export interface ColorPickerState {
color:
| { model: 'hsv'; h: number; s: number; v: number }
| { model: 'oklch'; l: number; c: number; h: number }
/** Alpha channel 0..1. */
alpha: number
disabled: boolean
/** Upper bound for OKLCH chroma — the ONE source of truth for the reducer's
* clamp (`setOklch`/`setChroma`/`setLc`/`nudgeLc`) AND for `connect()`'s
* chroma slider/area rendering range, so the two can no longer disagree
* the way a `ConnectOptions.maxChroma` rendering-only override could.
* Always finite and > 0 (`init` validates it; there is no setter — it is
* fixed for the component's lifetime, like `slider`'s `min`/`max`). */
maxChroma: number
/** Whether the browser's EyeDropper API is available. ALWAYS `false` at
* `init()` — this is what keeps SSR and the client's first paint identical
* (see the module doc on {@link eyeDropperSupportMount}, the ONLY thing
* that ever flips it, from a real mount-time feature check). A consumer
* never feature-detects on its own: placing that one Mountable is the
* whole contract. */
eyeDropperSupported: boolean
}
ComboboxGroup from @llui/components
A labelled section of options (rendered like <optgroup>). items are the
option VALUES belonging to the group, in visual order. Groups are an
additive, parallel structure: the flat items list always remains the
source of truth for navigation/highlight indices and item ids — when
groups is provided without an explicit items list, init derives the
flat list by concatenating each group's items in order. A plain flat
string[] (no groups) keeps working unchanged. Group LABELS are never
options, so highlight/arrow navigation skips over them for free.
Mirrors select's SelectGroup shape exactly.
export interface ComboboxGroup {
id: string
label: string
items: string[]
}
ComboboxGroupParts from @llui/components
export interface ComboboxGroupParts {
group: {
role: 'group'
/** The group label's id, or absent when `hasLabel: false`. */
'aria-labelledby': string | undefined
'data-scope': 'combobox'
'data-part': 'group'
'data-group': string
}
groupLabel: {
id: string
'aria-hidden': 'true'
'data-scope': 'combobox'
'data-part': 'group-label'
'data-group': string
}
}
ComboboxInit from @llui/components
export interface ComboboxInit {
value?: string[]
inputValue?: string
items?: string[]
/** Optional labelled sections. When provided without `items`, the flat
* `items` list is derived by concatenating each group's `items` in order. */
groups?: ComboboxGroup[]
disabledItems?: string[]
selectionMode?: SelectionMode
disabled?: boolean
/** Enable creatable mode: a synthetic create option is offered when the
* typed text matches no existing item. */
allowCreate?: boolean
}
ComboboxItemParts from @llui/components
export interface ComboboxItemParts {
item: {
role: 'option'
id: string
'aria-selected': ReadSignal<boolean>
'aria-disabled': ReadSignal<'true' | undefined>
'data-state': ReadSignal<'selected' | undefined>
'data-highlighted': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
'data-create': '' | undefined
'data-scope': 'combobox'
'data-part': 'item'
'data-value': string
/** The option's live position in the FILTERED list (reactive — reused rows
* never report a stale index). */
'data-index': ReadSignal<string>
onClick: (e: MouseEvent) => void
onPointerMove: (e: PointerEvent) => void
}
}
ComboboxOverlayOptions from @llui/components
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
state: ReadSignal<ComboboxState>
send: Send<ComboboxMsg>
parts: ComboboxParts
content: () => Renderable
/**
* Optional enter/leave transition for the combobox listbox (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, giving the raw-`open` combobox an exit
* animation for free. Omitted ⇒ the listbox closes synchronously as before.
*
* @example combobox.overlay({ state, send, parts, content, transition: fade({ duration: 120 }) })
*/
transition?: TransitionOptions
placement?: Placement
offset?: number
flip?: boolean
shift?: boolean
sameWidth?: boolean
target?: string | HTMLElement
}
ComboboxParts from @llui/components
export interface ComboboxParts {
root: {
'data-scope': 'combobox'
'data-part': 'root'
'data-state': ReadSignal<'open' | 'closed'>
}
input: {
type: 'text'
role: 'combobox'
autocomplete: 'off'
'aria-autocomplete': 'list'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
'aria-activedescendant': ReadSignal<string | undefined>
'aria-disabled': ReadSignal<'true' | undefined>
id: string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'combobox'
'data-part': 'input'
onInput: (e: Event) => void
onKeyDown: (e: KeyboardEvent) => void
onFocus: (e: FocusEvent) => void
}
trigger: {
type: 'button'
'aria-label': string
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
tabindex: -1
'data-scope': 'combobox'
'data-part': 'trigger'
onClick: (e: MouseEvent) => void
}
positioner: {
'data-scope': 'combobox'
'data-part': 'positioner'
style: string
}
content: {
role: 'listbox'
id: string
'aria-labelledby': string
'aria-busy': ReadSignal<'true' | undefined>
tabindex: -1
'data-state': ReadSignal<'open' | 'closed'>
'data-status': ReadSignal<AsyncStatus>
/** The mutually-exclusive load projection (#265 finding 11) — see
* {@link LoadProjection}. Mirrors the top-level `loadState` signal. */
'data-load-state': ReadSignal<LoadProjection>
'data-scope': 'combobox'
'data-part': 'content'
}
/** The mutually-exclusive async load projection: `'initial-empty'` |
* `'loading'` | `'stale-results'` | `'success'` | `'error'`. A single
* signal instead of independent `isLoading`/`isEmpty`/`hasError` booleans,
* so it can never contradict itself. See {@link LoadProjection}. */
loadState: ReadSignal<LoadProjection>
/** Build the parts for an option by VALUE. The optional `index` is accepted
* for call-site convenience only — it is NOT used for identity (highlight,
* selection and ids are all value-keyed), so a reused row is never stale. */
item: (value: string, index?: number) => ComboboxItemParts
/** Parts for a labelled option group (`<optgroup>`-style section). Pass the
* group id; render the section element with `group` and its label element
* (referenced by `aria-labelledby`) with `groupLabel`. Group labels are not
* options, so navigation skips them automatically. Mirrors `select`. */
/**
* A labelled group's parts. The group names its `label` part through
* `aria-labelledby`; a consumer that renders the group WITHOUT that label
* passes `{ hasLabel: false }` so it never names an element that does not
* exist (an unlabelled `role="group"` is valid; a broken idref is not — the
* `dialog` `hasDescription` rule, #268). Default: true.
*/
group: (id: string, options?: { readonly hasLabel?: boolean }) => ComboboxGroupParts
/** Polite live region announcing the result count / error to screen readers
* as the async filter resolves. Render a visually-hidden element with these
* attributes and the `text` signal as its content. */
liveRegion: {
role: 'status'
'aria-live': 'polite'
'aria-atomic': 'true'
'data-scope': 'combobox'
'data-part': 'live-region'
text: ReadSignal<string>
}
empty: {
'data-scope': 'combobox'
'data-part': 'empty'
}
}
ComboboxState from @llui/components
export interface ComboboxState {
open: boolean
value: string[]
inputValue: string
items: string[]
groups: ComboboxGroup[]
disabledItems: string[]
filteredItems: string[]
/** The highlighted option's VALUE (not its index) in the FILTERED list.
* Value-based identity keeps the highlight pinned to the right option as the
* list is filtered/reordered and value-keyed rows are reused. May hold the
* create sentinel ({@link CREATE_OPTION_VALUE}) in creatable mode. */
highlightedValue: string | null
selectionMode: SelectionMode
disabled: boolean
allowCreate: boolean
status: AsyncStatus
requestId: number
error: string | null
}
ContextMenuInit from @llui/components
export interface ContextMenuInit {
items?: ContextMenuItem[]
checked?: string[]
closeOnSelect?: boolean
/** Omit to follow the page's own direction (see {@link ContextMenuState}'s
* `dir`/`dirSource`, resolved from the mounted trigger by `directionSync`). */
dir?: TextDirection
/** When false, opening and closing play enter/exit animations and the content
* stays mounted (status 'opening' or 'closing') until an `animationEnd`.
* Default true: instant. */
skipAnimations?: boolean
}
ContextMenuOverlayOptions from @llui/components
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
state: ReadSignal<ContextMenuState>
send: Send<ContextMenuMsg>
parts: ContextMenuParts
content: () => Renderable
/**
* Optional enter/leave transition for the context-menu content (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, so the close plays an exit animation.
* Keep `skipAnimations` at its default (true) when driving exits this way.
*
* @example contextMenu.overlay({ state, send, parts, content, transition: fade({ duration: 120 }) })
*/
transition?: TransitionOptions
target?: string | HTMLElement
}
ContextMenuParts from @llui/components
export interface ContextMenuParts {
/** The element users right-click to open the menu. `id` is REQUIRED — it is
* the scope `directionSync` (below) observes for live ancestor `dir`
* changes, since it (unlike `content`) is always mounted (#265 finding 6). */
trigger: {
id: string
'data-scope': 'context-menu'
'data-part': 'trigger'
onContextMenu: (e: MouseEvent) => void
}
positioner: {
'data-scope': 'context-menu'
'data-part': 'positioner'
style: ReadSignal<string>
}
content: {
role: 'menu'
id: string
/** Virtually-focused (highlighted) item id at the root level. */
'aria-activedescendant': ReadSignal<string | undefined>
tabindex: -1
/** Reflects the presence lifecycle: 'opening' | 'open' | 'closing' | 'closed'.
* Stays mounted while 'closing' so the exit animation can run. */
'data-state': ReadSignal<PresenceStatus>
'data-scope': 'context-menu'
'data-part': 'content'
onKeyDown: (e: KeyboardEvent) => void
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
item: (value: string) => ContextMenuItemParts
checkboxItem: (value: string) => ContextMenuCheckItemParts
radioItem: (value: string) => ContextMenuCheckItemParts
/**
* A labelled group's parts. The group names its `label` part through
* `aria-labelledby`; a consumer that renders the group WITHOUT that label
* passes `{ hasLabel: false }` so it never names an element that does not
* exist (an unlabelled `role="group"` is valid; a broken idref is not — the
* `dialog` `hasDescription` rule, #268). Default: true.
*/
group: (id: string, options?: { readonly hasLabel?: boolean }) => ContextMenuGroupParts
separator: () => ContextMenuSeparatorParts
subTrigger: (value: string) => ContextMenuSubTriggerParts
subPositioner: (value: string) => ContextMenuSubPositionerParts
subContent: (value: string) => ContextMenuSubContentParts
/** Place once anywhere in the same build to keep automatic direction live —
* the shared `@llui/interactions` direction-sync seam (#265 finding 6),
* observing the TRIGGER (always mounted, unlike the content) as this
* instance's root. A discarded `Mountable` is inert, so this must be placed
* in the view. */
directionSync: Mountable
}
ContextMenuState from @llui/components
Context-menu state — the shared menu-tree state plus the pointer (x, y) the root content is positioned at.
export interface ContextMenuState extends MenuTreeState {
x: number
y: number
}
CropRect from @llui/components
export interface CropRect {
x: number
y: number
width: number
height: number
}
DateInputInit from @llui/components
export interface DateInputInit {
input?: string
value?: IsoDate | null
min?: IsoDate | null
max?: IsoDate | null
disabled?: boolean
readonly?: boolean
required?: boolean
}
DateInputParts from @llui/components
export interface DateInputParts {
root: {
'data-scope': 'date-input'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
'data-invalid': ReadSignal<'' | undefined>
}
input: {
type: 'text'
inputmode: 'numeric'
autocomplete: 'off'
spellcheck: false
value: ReadSignal<string>
disabled: ReadSignal<boolean>
readonly: ReadSignal<boolean>
required: ReadSignal<boolean>
'aria-invalid': ReadSignal<'true' | undefined>
/**
* The error text's id while the value is invalid — so the field names its
* own error rather than relying on the one-shot `role="alert"`
* announcement (#268). Absent without a `ConnectOptions.id`.
*/
'aria-describedby': ReadSignal<string | undefined>
placeholder?: string
'data-scope': 'date-input'
'data-part': 'input'
onInput: (e: Event) => void
onBlur: (e: FocusEvent) => void
}
clearTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'date-input'
'data-part': 'clear-trigger'
onClick: (e: MouseEvent) => void
}
errorText: {
/** `<id>:error` when `ConnectOptions.id` is given. */
id: string | undefined
role: 'alert'
'aria-live': 'polite'
'data-scope': 'date-input'
'data-part': 'error-text'
hidden: ReadSignal<boolean>
}
}
DateInputState from @llui/components
export interface DateInputState {
/** Raw string as typed by the user. */
input: string
/** Parsed date as `YYYY-MM-DD`, or null if empty/invalid. */
value: IsoDate | null
/** Optional lower bound (inclusive), `YYYY-MM-DD`. */
min: IsoDate | null
/** Optional upper bound (inclusive), `YYYY-MM-DD`. */
max: IsoDate | null
error: DateError
disabled: boolean
readonly: boolean
required: boolean
}
DatePickerInit from @llui/components
export interface DatePickerInit {
mode?: DatePickerMode
value?: string | null
start?: string | null
end?: string | null
visibleMonth?: number
visibleYear?: number
months?: number
min?: string | null
max?: string | null
weekStartsOn?: 0 | 1
disabled?: boolean
/** Pin "today" (YYYY-MM-DD); omit or `null` to read the runtime clock. */
today?: string | null
/** Individually unavailable dates (YYYY-MM-DD). */
unavailable?: string[]
}
DatePickerParts from @llui/components
export interface DatePickerParts {
root: {
'data-scope': 'date-picker'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
}
/**
* Grid part factory. `offset` (default 0) selects which month this grid
* renders in a multi-month view — the `aria-label` is the localized
* "Month YYYY" of `visibleMonth + offset`.
*/
grid: (offset?: number) => {
role: 'grid'
'aria-label': ReadSignal<string>
'data-scope': 'date-picker'
'data-part': 'grid'
'data-month-offset': number
}
row: {
role: 'row'
'data-scope': 'date-picker'
'data-part': 'row'
}
prevMonthTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'date-picker'
'data-part': 'prev-month-trigger'
onClick: (e: MouseEvent) => void
}
nextMonthTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'date-picker'
'data-part': 'next-month-trigger'
onClick: (e: MouseEvent) => void
}
dayCell: (cell: DayCell) => DayCellParts
/** Preset part factory — clicking dispatches a single `setRange`. */
preset: (range: PresetRange) => PresetParts
}
DatePickerState from @llui/components
export interface DatePickerState {
/** Selection mode. Defaults to 'single'. */
mode: DatePickerMode
/** Selected date as YYYY-MM-DD, or null. Used in 'single' mode. */
value: string | null
/** Range start as YYYY-MM-DD, or null. Used in 'range' mode. */
start: string | null
/** Range end as YYYY-MM-DD, or null. Used in 'range' mode. */
end: string | null
/** Date currently hovered/previewed while a range is being completed. */
hoverDate: string | null
/** The month currently visible (1-indexed, 1-12) — the first/leftmost month. */
visibleMonth: number
/** The year currently visible. */
visibleYear: number
/** Number of months rendered side-by-side. Defaults to 1. */
months: number
/** The date currently focused by the keyboard (YYYY-MM-DD). */
focused: string
/** Minimum selectable date, inclusive. */
min: string | null
/** Maximum selectable date, inclusive. */
max: string | null
/** 0=Sunday, 1=Monday. */
weekStartsOn: 0 | 1
disabled: boolean
/**
* A pinned "today" (YYYY-MM-DD), or `null` to read the runtime clock. Pin it
* for deterministic rendering (SSR + hydration, galleries, tests) or to show
* today in a specific time zone — see `todayInTimeZone` (#266).
*/
today: string | null
/**
* Individually unavailable dates (YYYY-MM-DD) — bookings, holidays. Disabled
* like an out-of-bounds date, published separately as `data-unavailable`, and
* a range may not be completed across one (#266).
*/
unavailable: string[]
}
DayCell from @llui/components
export interface DayCell {
iso: string
day: number
inMonth: boolean
isToday: boolean
isSelected: boolean
isFocused: boolean
isDisabled: boolean
/** True for the start endpoint of a (committed or previewed) range. */
isRangeStart: boolean
/** True for the end endpoint of a (committed or previewed) range. */
isRangeEnd: boolean
/** True for dates strictly between the range endpoints. */
isInRange: boolean
/** True for a date listed in `unavailable` (also `isDisabled`). */
isUnavailable: boolean
}
DayCellParts from @llui/components
export interface DayCellParts {
cell: {
role: 'gridcell'
// Signals, not plain values: `view()` runs once, so a snapshot here freezes
// every flag at build time and no selection, focus move or range preview
// ever reaches the DOM. See `live` in `connect`.
'aria-selected': ReadSignal<boolean>
'aria-disabled': ReadSignal<'true' | undefined>
tabindex: ReadSignal<number>
'data-scope': 'date-picker'
'data-part': 'day-cell'
/** The cell's identity — the one genuinely static attribute. */
'data-date': string
'data-in-month': ReadSignal<'' | undefined>
'data-today': ReadSignal<'' | undefined>
'data-selected': ReadSignal<'' | undefined>
'data-focused': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
'data-range-start': ReadSignal<'' | undefined>
'data-range-end': ReadSignal<'' | undefined>
'data-in-range': ReadSignal<'' | undefined>
/** An individually unavailable date (also disabled) — distinct from out-of-bounds. */
'data-unavailable': ReadSignal<'' | undefined>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
onFocus: (e: FocusEvent) => void
onPointerEnter: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
}
}
DialogInit from @llui/components
export interface DialogInit {
open?: boolean
/** Skip enter/exit animations — close unmounts synchronously (default: true). */
skipAnimations?: boolean
}
DialogOverlayOptions from @llui/components
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
/** Dialog state slice as a Signal. */
state: ReadSignal<DialogState>
/** Send dispatcher for dialog messages. */
send: Send<DialogMsg>
/** Parts from `connect()` — used to locate the content element by id. */
parts: DialogParts
/** Content rendering. */
content: () => Renderable
/**
* Optional enter/leave transition for the dialog content (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, so the close plays an exit animation.
* Keep `skipAnimations` at its default (true) when driving exits this way.
*
* @example dialog.overlay({ state, send, parts, content, transition: fade({ duration: 150 }) })
*/
transition?: TransitionOptions
/** Close on Escape key (default: true). */
closeOnEscape?: boolean
/** Close on click outside content (default: true). */
closeOnOutsideClick?: boolean
/** Trap focus inside the dialog while open (default: true for modal). */
trapFocus?: boolean
/** Lock body scroll while open (default: true for modal). */
lockScroll?: boolean
/** Apply aria-hidden to sibling trees (default: true for modal). */
hideSiblings?: boolean
/** Target element / selector for the portal (default: 'body'). */
target?: string | HTMLElement
/** Element to focus initially (default: first focusable inside content). */
initialFocus?: Element | (() => Element | null)
/** Restore focus on close (default: true). */
restoreFocus?: boolean
}
DialogParts from @llui/components
export interface DialogParts {
trigger: {
type: 'button'
'aria-haspopup': 'dialog'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
id: string
'data-state': ReadSignal<'open' | 'closed'>
'data-scope': 'dialog'
'data-part': 'trigger'
onClick: (e: MouseEvent) => void
}
backdrop: {
'data-state': ReadSignal<PresenceStatus>
'data-scope': 'dialog'
'data-part': 'backdrop'
'aria-hidden': 'true'
}
positioner: {
'data-scope': 'dialog'
'data-part': 'positioner'
}
content: {
role: 'dialog' | 'alertdialog'
id: string
'aria-modal': 'true' | undefined
'aria-labelledby': string
/** The description part's id, or absent when `hasDescription: false`. */
'aria-describedby': string | undefined
tabindex: -1
'data-state': ReadSignal<PresenceStatus>
'data-scope': 'dialog'
'data-part': 'content'
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
title: {
id: string
'data-scope': 'dialog'
'data-part': 'title'
}
description: {
id: string
'data-scope': 'dialog'
'data-part': 'description'
}
closeTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'dialog'
'data-part': 'close-trigger'
onClick: (e: MouseEvent) => void
}
}
DialogState from @llui/components
Dialog — modal / non-modal overlay. Ties together focus-trap, dismissable, body scroll lock, sibling aria-hidden, and portal-to-body rendering into a single view helper.
Two layers:
- state machine (
init,update,connect) — pure, minimal. overlay()view helper — opens the dialog's DOM tree inside a body portal, wires up all accessibility utilities on mount, tears them down on close, restores focus to the trigger.
view: ({ state, send }) => {
const dialogState = state.at('dialog')
const dialogSend = mapSend<Msg, dialog.DialogMsg>(send, (msg) => ({
type: 'dialog',
msg,
}))
const parts = dialog.connect(dialogState, dialogSend, { id: 'dialog' })
return [
button({ ...parts.trigger, class: 'btn' }, [text('Delete')]),
dialog.overlay({
state: dialogState,
send: dialogSend,
parts,
content: () => [
div({ ...parts.content, class: 'dialog' }, [
h2({ ...parts.title }, [text('Are you sure?')]),
button({ ...parts.closeTrigger, class: 'btn' }, [text('Cancel')]),
]),
],
}),
]
}
export interface DialogState {
open: boolean
/** Presence lifecycle — drives data-state and keeps the node mounted through exit
* animations. Optional: a partial `{ open }` bridge (e.g. a pattern passing a
* slice to `overlay`) omits it, and the runtime falls back to `open` for instant,
* backward-compatible mount/visibility. `init` always sets it. */
status?: PresenceStatus
/** When true, close transitions go straight to 'closed' (no exit-animation wait).
* Optional for the same partial-slice reason as `status`; `init` always sets it. */
skipAnimations?: boolean
}
DismissableOptions from @llui/components
export interface DismissableOptions {
/** The layer element (e.g. a dialog content or popover). */
element: ElementSource;
/** Trigger / anchor elements that should not count as outside interactions. */
ignore?: ElementSource;
/** Called when the user dismisses the layer. */
onDismiss: (source: DismissSource, event: Event) => void;
/**
* Custom Escape router. When provided it runs for the Escape key INSTEAD of
* `onDismiss('escape', …)`, letting the layer unwind an internal level first
* (e.g. a menu closes its open submenu before closing the whole menu). Return
* `false` to decline — the event is not claimed and propagates as if this
* layer had `disableEscape`. Any other return (incl. `undefined`) claims it.
*/
onEscape?: (event: KeyboardEvent) => boolean | void;
/** Disable outside-click dismissal (default: false). */
disableOutside?: boolean;
/** Disable Escape-key dismissal (default: false). */
disableEscape?: boolean;
}
Domain from @llui/components
A closed numeric interval. Both ends are finite by construction.
export interface Domain {
min: number
max: number
}
DragState from @llui/components
Sortable — pointer-based reorderable list.
State machine tracks the currently-dragged item and where it's hovering.
The app owns the actual array: droppedMove(prev, msg) names the move a
message COMPLETES — a pointer drop, or a keyboard toggleGrab while an item
is grabbed — and reorder(arr, from, to) applies it. Watch currentIndex
during a drag for a live preview.
type State = { items: string[]; sort: SortableState }
update: (state, msg) => {
switch (msg.type) {
case 'sort': {
const moved = sortable.droppedMove(state.sort, msg.msg)
const [sort] = sortable.update(state.sort, msg.msg)
const items = moved ? reorder(state.items, moved.from, moved.to) : state.items
return [{ items, sort }, []]
}
}
}
// `each`, `ul`, `li`, `div`, `text`, `mapSend` are imports from '@llui/dom';
// the view bag provides only `state` (a Signal) and `send`.
view: ({ state, send }) => {
const sortableState = state.at('sort')
const sortableSend = mapSend<Msg, sortable.SortableMsg>(send, (msg) => ({
type: 'sort',
msg,
}))
const s = sortable.connect(sortableState, sortableSend, { id: 'list' })
return [
ul({ ...s.root, class: 'list' }, [
...each({
items: (st) => st.items,
key: (x) => x,
render: ({ item, index }) => [
li({ ...s.item(item(), index()), class: 'item' }, [
div({ ...s.handle(item(), index()), class: 'handle' }, [text('⋮⋮')]),
text(item),
]),
],
}),
]),
]
}
Hook up pointermove/pointerup at the root (attachPointerHandlers) — or
wire them directly via onPointerMove / onPointerUp on the root part.
Screen readers. A keyboard user who grabs, moves, drops or cancels needs to HEAR it — nothing on screen tells them where the item is. The machine therefore owns two more parts, and both must be rendered:
liveRegion— a polite, atomicrole="status"region whosetext(a Signal, rendered as the region's CHILD, never spread as an attribute) announces "Picked up Apple, item 2 of 5.", "Apple moved to position 3 of 5.", the drop and a cancel. Render it visually hidden (sr-only), neverdisplay: none— a hidden live region is never announced. PassitemLabelto name the item; without it the announcements say "item".instructions— the how-to text,hidden, which every handle references througharia-describedby(a directly referenced hidden element still provides a description, and stays out of the reading order). A consumer that supplies its own keyboard help and does not render this part passeshasInstructions: false, so no handle names an element that does not exist (thedialoghasDescriptionprecedent, #268).
const { text: live, ...liveAttrs } = s.liveRegion
const { text: howTo, ...howToAttrs } = s.instructions
div({ ...liveAttrs, class: 'sr-only' }, [text(live)])
div({ ...howToAttrs }, [text(howTo)])
The grab is aria-pressed on the handle (a toggle button: pressed while
the item is carried). aria-grabbed is NOT used: ARIA 1.1 deprecated it and
aria-dropeffect with no replacement, and screen readers never broadly
exposed either — the live region is how every mature implementation (dnd-kit,
React Aria) conveys a drag. aria-roledescription is deliberately not set:
it would replace "toggle button", the one cue that Space operates the handle.
All text comes from LocaleContext (Locale['sortable']).
export interface DragState {
id: string
startIndex: number
currentIndex: number
/**
* Container the drag originated from. Defaults to the connect's `id` for
* single-container sortables. Set when multiple sortables share state.
*/
fromContainer: string
/**
* Container the pointer is currently over. Same as `fromContainer` for
* single-container sortables. Differs when dragging across containers.
*/
toContainer: string
/**
* Pointer X at drag start (viewport coordinates). Used by 2D layouts
* to compute `deltaX = currentX - startX` alongside the Y axis. In 1D
* layouts X is tracked but ignored by the renderer.
*/
startX: number
/**
* Pointer Y at drag start (viewport coordinates). Used by CSS / the
* library's `style.transform` binding to make the dragged item follow
* the pointer.
*/
startY: number
/**
* Current pointer X (viewport coordinates). `deltaX = currentX - startX`.
*/
currentX: number
/**
* Current pointer Y (viewport coordinates). `deltaY = currentY - startY`.
*/
currentY: number
/**
* How many items the origin container held when the drag started — the "N"
* in "item 2 of N", and the last slot a keyboard `moveBy` may reach.
*/
count: number
}
DrawerInit from @llui/components
export interface DrawerInit {
open?: boolean
/** Skip enter/exit animations — close unmounts synchronously (default: true). */
skipAnimations?: boolean
}
DrawerOverlayOptions from @llui/components
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
state: ReadSignal<DrawerState>
send: Send<DrawerMsg>
parts: DrawerParts
content: () => Renderable
/**
* Optional enter/leave transition for the drawer content (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, so the close plays an exit animation.
* Keep `skipAnimations` at its default (true) when driving exits this way.
*
* @example drawer.overlay({ state, send, parts, content, transition: slide({ duration: 200 }) })
*/
transition?: TransitionOptions
closeOnEscape?: boolean
closeOnOutsideClick?: boolean
trapFocus?: boolean
lockScroll?: boolean
hideSiblings?: boolean
target?: string | HTMLElement
initialFocus?: Element | (() => Element | null)
restoreFocus?: boolean
}
DrawerParts from @llui/components
export interface DrawerParts {
trigger: {
type: 'button'
'aria-haspopup': 'dialog'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
id: string
'data-state': ReadSignal<'open' | 'closed'>
'data-scope': 'drawer'
'data-part': 'trigger'
onClick: (e: MouseEvent) => void
}
backdrop: {
'data-state': ReadSignal<PresenceStatus>
'data-scope': 'drawer'
'data-part': 'backdrop'
'aria-hidden': 'true'
}
positioner: {
'data-scope': 'drawer'
'data-part': 'positioner'
'data-side': DrawerSide
}
content: {
role: 'dialog'
id: string
'aria-modal': 'true'
'aria-labelledby': string
tabindex: -1
'data-state': ReadSignal<PresenceStatus>
'data-scope': 'drawer'
'data-part': 'content'
'data-side': DrawerSide
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
title: {
id: string
'data-scope': 'drawer'
'data-part': 'title'
}
description: {
id: string
'data-scope': 'drawer'
'data-part': 'description'
}
closeTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'drawer'
'data-part': 'close-trigger'
onClick: (e: MouseEvent) => void
}
}
DrawerState from @llui/components
export interface DrawerState {
open: boolean
/** Presence lifecycle — drives data-state and keeps the node mounted through exit animations. */
status: PresenceStatus
/** When true, close transitions go straight to 'closed' (no exit-animation wait). */
skipAnimations: boolean
}
EditableInit from @llui/components
export interface EditableInit {
value?: string
editing?: boolean
disabled?: boolean
}
EditableParts from @llui/components
export interface EditableParts {
root: {
'data-scope': 'editable'
'data-part': 'root'
'data-editing': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
}
preview: {
tabindex: ReadSignal<number>
'aria-disabled': ReadSignal<'true' | undefined>
'data-scope': 'editable'
'data-part': 'preview'
hidden: ReadSignal<boolean>
onClick: (e: MouseEvent) => void
onFocus: (e: FocusEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
input: {
'data-scope': 'editable'
'data-part': 'input'
hidden: ReadSignal<boolean>
value: ReadSignal<string>
disabled: ReadSignal<boolean>
onInput: (e: Event) => void
onKeyDown: (e: KeyboardEvent) => void
onBlur: (e: FocusEvent) => void
}
submitTrigger: {
type: 'button'
/** Disabled with the editable: a disabled instance ignores the message
* this trigger sends, so an enabled trigger would be a dead control
* (#268 audit). */
disabled: ReadSignal<boolean>
'data-scope': 'editable'
'data-part': 'submit-trigger'
onClick: (e: MouseEvent) => void
}
cancelTrigger: {
type: 'button'
/** Disabled with the editable: a disabled instance ignores the message
* this trigger sends, so an enabled trigger would be a dead control
* (#268 audit). */
disabled: ReadSignal<boolean>
'data-scope': 'editable'
'data-part': 'cancel-trigger'
onClick: (e: MouseEvent) => void
}
editTrigger: {
type: 'button'
/** Disabled with the editable: a disabled instance ignores the message
* this trigger sends, so an enabled trigger would be a dead control
* (#268 audit). */
disabled: ReadSignal<boolean>
'data-scope': 'editable'
'data-part': 'edit-trigger'
onClick: (e: MouseEvent) => void
}
}
EditableState from @llui/components
Editable — inline text editor. Click preview to enter edit mode, Enter
to commit, Escape to cancel. Reports the committed value via onSubmit.
export interface EditableState {
value: string
editing: boolean
draft: string
disabled: boolean
}
EyeDropperOpenOptions from @llui/components
export interface EyeDropperOpenOptions {
signal?: AbortSignal
}
EyeDropperResult from @llui/components
export interface EyeDropperResult {
sRGBHex: string
}
FieldConnectOptions from @llui/components
export interface FieldConnectOptions {
/** Base id; if omitted, falls back to the id stored in state. */
id?: string
/**
* Whether a description element is rendered. When true, the description id is
* always present in `aria-describedby`; when false it is omitted entirely.
*/
hasDescription?: boolean
}
FieldInit from @llui/components
export interface FieldInit {
id: string
invalid?: boolean
required?: boolean
disabled?: boolean
readonly?: boolean
touched?: boolean
}
FieldParts from @llui/components
export interface FieldParts {
/** The field wrapper. */
root: {
'data-scope': 'field'
'data-part': 'root'
'data-invalid': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
'aria-disabled': ReadSignal<'true' | undefined>
}
/** The `<label>`. `htmlFor` focuses the control on click. */
label: {
id: string
htmlFor: string
'data-scope': 'field'
'data-part': 'label'
}
/** Spread onto the input/select/textarea (or a custom control via aria-labelledby). */
control: {
id: string
'aria-labelledby': string
'aria-describedby': ReadSignal<string | undefined>
'aria-invalid': ReadSignal<'true' | undefined>
'aria-required': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
readOnly: ReadSignal<boolean>
'data-scope': 'field'
'data-part': 'control'
onBlur: (e: FocusEvent) => void
}
/** The description / hint text. Render it only when there is a description to show. */
description: {
id: string
'data-scope': 'field'
'data-part': 'description'
}
/** The error message — a polite live region, intended to be rendered only while invalid. */
errorText: {
id: string
role: 'alert'
'aria-live': 'polite'
'data-scope': 'field'
'data-part': 'error'
}
}
FieldsetConnectOptions from @llui/components
export interface FieldsetConnectOptions {
/** Base id; if omitted, falls back to the id stored in state. */
id?: string
/**
* Whether the consumer renders the `legend` part (default: true). When
* false the group omits `aria-labelledby` rather than naming an element
* that does not exist (the `dialog` `hasDescription` rule, #268). A
* `<fieldset>` without a legend is valid HTML; a broken idref is not.
*/
hasLegend?: boolean
}
FieldsetInit from @llui/components
export interface FieldsetInit {
id: string
disabled?: boolean
invalid?: boolean
}
FieldsetParts from @llui/components
export interface FieldsetParts {
/** Spread onto a native `<fieldset>` element (role `group`). */
root: {
role: 'group'
/** The legend's id, or absent when `hasLegend: false`. */
'aria-labelledby': string | undefined
'aria-disabled': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
'data-scope': 'fieldset'
'data-part': 'root'
'data-invalid': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
}
/** The `<legend>` naming the group. */
legend: {
id: string
'data-scope': 'fieldset'
'data-part': 'legend'
}
/** Group-level error message — a polite live region, rendered only while invalid. */
errorText: {
id: string
role: 'alert'
'aria-live': 'polite'
'data-scope': 'fieldset'
'data-part': 'error'
}
}
FieldsetState from @llui/components
Fieldset — group wiring for a set of related controls (e.g. an address block).
The root is a native <fieldset> (role group) labelled by a <legend>.
Setting the group disabled disables every contained control natively (the
native disabled attribute on <fieldset> propagates to descendants), and is
mirrored to aria-disabled for assistive tech. An optional group-level error
region is exposed for cross-field validation messages.
const g = fieldset.connect(state.at('billing'), send, { id: 'billing' })
el('fieldset', g.root, [
el('legend', g.legend, [text('Billing address')]),
// ...fields...
show(state.map((s) => s.billing.invalid),
() => el('p', g.errorText, [text('Address is incomplete.')])),
])
export interface FieldsetState {
/** Base id from which the legend / error ids derive. */
id: string
disabled: boolean
invalid: boolean
}
FieldState from @llui/components
Field — label / description / error ARIA wiring for a single form control.
Generates a stable family of ids from one base id and wires them together
so the consumer never hand-writes for / aria-describedby / aria-invalid:
label.htmlFor→ the control id (clicking the label focuses the control natively)label.id→ exposed ascontrol['aria-labelledby']for CUSTOM controls (combobox, listbox, etc.) that aren't a native labellable elementcontrol['aria-describedby']references the description id whenever a description is rendered, and ADDS the error id only whileinvaliderrorTextis a polite live region intended to be rendered only while invalid
const f = field.connect(state.at('field'), send, { id: 'email', hasDescription: true })
el('div', f.root, [
el('label', f.label, [text('Email')]),
el('input', { ...f.control, type: 'email' }),
el('p', f.description, [text('We never share it.')]),
show(state.map((s) => s.field.invalid),
() => el('p', f.errorText, [text('Enter a valid email.')])),
])
export interface FieldState {
/** Base id from which the control / label / description / error ids derive. */
id: string
invalid: boolean
required: boolean
disabled: boolean
readonly: boolean
touched: boolean
}
FileLike from @llui/components
Everything fileMatchesAccept needs — satisfied by both File and FileMeta.
export interface FileLike {
name: string
type: string
}
FileMeta from @llui/components
The serializable half of a selected file. id is the registry key for the
live handle; the rest mirrors the File fields a view needs.
export interface FileMeta {
id: string
name: string
size: number
type: string
lastModified: number
}
FileUploadInit from @llui/components
export interface FileUploadInit {
files?: FileMeta[]
disabled?: boolean
multiple?: boolean
accept?: AcceptValue
maxFiles?: number
maxSize?: number
minFileSize?: number
required?: boolean
readonly?: boolean
invalid?: boolean
}
FileUploadItemParts from @llui/components
export interface FileUploadItemParts {
item: {
'data-scope': 'file-upload'
'data-part': 'item'
'data-index': string
/** The file's upload status; absent until an upload is reported (#266). */
'data-upload-status': ReadSignal<FileUploadStatus | undefined>
}
/** A labelled progressbar, shown only while the file is uploading (#266). */
itemProgress: {
role: 'progressbar'
'aria-label': string
'aria-valuemin': 0
'aria-valuemax': 100
'aria-valuenow': ReadSignal<number | undefined>
hidden: ReadSignal<boolean>
'data-scope': 'file-upload'
'data-part': 'item-progress'
}
/** The progress fill; its width is written inline as a percentage. */
itemProgressRange: {
'data-scope': 'file-upload'
'data-part': 'item-progress-range'
style: ReadSignal<string>
}
/**
* Live region for the file's failure message, shown only on error. Its text
* is `uploadOf(state, id)?.error` — the view renders it as a child.
*/
itemErrorText: {
role: 'alert'
hidden: ReadSignal<boolean>
'data-scope': 'file-upload'
'data-part': 'item-error-text'
}
/** Shown only for a failed upload; dispatches `retryUpload` for this file. */
itemRetryTrigger: {
type: 'button'
'aria-label': string
hidden: ReadSignal<boolean>
'data-scope': 'file-upload'
'data-part': 'item-retry-trigger'
onClick: (e: MouseEvent) => void
}
itemName: {
'data-scope': 'file-upload'
'data-part': 'item-name'
}
itemSizeText: {
'data-scope': 'file-upload'
'data-part': 'item-size-text'
}
itemPreview: {
'data-scope': 'file-upload'
'data-part': 'item-preview'
}
removeTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'file-upload'
'data-part': 'item-remove'
onClick: (e: MouseEvent) => void
}
/** Zag-aligned alias for removeTrigger. Same wiring. */
itemDeleteTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'file-upload'
'data-part': 'item-delete-trigger'
onClick: (e: MouseEvent) => void
}
}
FileUploadParts from @llui/components
export interface FileUploadParts {
root: {
'data-scope': 'file-upload'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
'aria-disabled': ReadSignal<'true' | undefined>
'data-dragging': ReadSignal<'' | undefined>
'data-invalid': ReadSignal<'' | undefined>
'data-readonly': ReadSignal<'' | undefined>
/** Present while any accepted file is uploading (#266). */
'data-uploading': ReadSignal<'' | undefined>
}
dropzone: {
'data-scope': 'file-upload'
'data-part': 'dropzone'
'data-dragging': ReadSignal<'' | undefined>
onClick: (e: MouseEvent) => void
onDragEnter: (e: DragEvent) => void
onDragOver: (e: DragEvent) => void
onDragLeave: (e: DragEvent) => void
onDrop: (e: DragEvent) => void
}
trigger: {
type: 'button'
'data-scope': 'file-upload'
'data-part': 'trigger'
disabled: ReadSignal<boolean>
onClick: (e: MouseEvent) => void
}
hiddenInput: {
type: 'file'
tabindex: -1
'aria-hidden': 'true'
style: string
disabled: ReadSignal<boolean>
multiple: ReadSignal<boolean>
accept: ReadSignal<string>
required: ReadSignal<boolean>
'aria-invalid': ReadSignal<'true' | undefined>
capture?: string | boolean
webkitdirectory?: '' | undefined
'data-scope': 'file-upload'
'data-part': 'hidden-input'
id: string
onChange: (e: Event) => void
}
label: {
for: string
'data-scope': 'file-upload'
'data-part': 'label'
}
clearTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'file-upload'
'data-part': 'clear-trigger'
onClick: (e: MouseEvent) => void
}
itemGroup: {
'data-scope': 'file-upload'
'data-part': 'item-group'
}
item: (index: number) => FileUploadItemParts
}
FileUploadProgress from @llui/components
export interface FileUploadProgress {
status: FileUploadStatus
/** 0..1 */
progress: number
/** The failure message while `status === 'error'`, else `null`. */
error: string | null
}
FileUploadState from @llui/components
export interface FileUploadState {
files: FileMeta[]
rejectedFiles: RejectedFile[]
disabled: boolean
multiple: boolean
accept: AcceptValue
maxFiles: number
maxSize: number
minFileSize: number
required: boolean
readonly: boolean
invalid: boolean
/** `dragDepth > 0`, materialized so views bind one boolean. */
dragging: boolean
/**
* Nesting depth of the in-flight drag. `dragenter`/`dragleave` both bubble
* and fire in the order enter@child → leave@parent, so a plain boolean flips
* off while the pointer is still inside the dropzone (#119).
*/
dragDepth: number
/**
* Upload lifecycle per accepted file, keyed by `FileMeta.id` — never by
* index, which shifts when an earlier file is removed. A file with no entry
* has not started uploading. The machine does not upload: the consumer's
* effect does, and reports back with `uploadProgress` / `uploadSucceeded` /
* `uploadFailed`; `retryUpload` is the request to try again (#266).
*/
uploads: Record<string, FileUploadProgress>
}
FloatingOptions from @llui/components
export interface FloatingOptions {
/** The reference element (trigger/anchor). */
anchor: Element;
/** The floating element (content). */
floating: HTMLElement;
/**
* Element that receives the resolved full `data-placement` and physical
* `data-side`. Defaults to `floating`. Use a separate content element when
* `floating` is a geometry-only positioner wrapper.
*/
stateTarget?: HTMLElement;
/** Preferred placement (default: 'bottom'). */
placement?: Placement;
/** Gap between anchor and floating, in px (default: 0). */
offset?: number;
/** Flip to opposite side when there isn't enough room (default: true). */
flip?: boolean;
/** Shift along axis to stay in view (default: padding 8 unless false). */
shift?: boolean | {
padding?: number;
};
/**
* Reading direction. Under `'rtl'`, logical `*-start`/`*-end` placements
* track the inline-start/inline-end edges. When given it is AUTHORITATIVE —
* it overrides the direction the floating element happens to compute to,
* which for a portaled overlay is the direction of wherever it landed.
* Omit it to leave that decision to the page, as floating-ui does by default.
*/
dir?: TextDirection;
/** Optional arrow element to position. */
arrow?: HTMLElement;
/** Notify after each position computation. */
onUpdate?: (data: {
x: number;
y: number;
placement: Placement;
arrow?: {
x?: number;
y?: number;
};
}) => void;
}
FloatingPanelInit from @llui/components
export interface FloatingPanelInit {
position?: { x: number; y: number }
size?: { width: number; height: number }
minSize?: { width?: number; height?: number }
maxSize?: { width?: number; height?: number } | null
open?: boolean
disabled?: boolean
}
FloatingPanelParts from @llui/components
export interface FloatingPanelParts {
root: {
role: 'dialog'
'aria-label': string
'data-scope': 'floating-panel'
'data-part': 'root'
'data-dragging': ReadSignal<'' | undefined>
'data-resizing': ReadSignal<'' | undefined>
'data-minimized': ReadSignal<'' | undefined>
'data-maximized': ReadSignal<'' | undefined>
hidden: ReadSignal<boolean>
style: ReadSignal<string>
}
/**
* Pointer drag starts here, and it is also a keyboard stop (#266): arrows
* move the panel 10px (50px with Shift). Physical under RTL — the panel is
* positioned with physical `left`/`top`.
*/
dragHandle: {
/**
* A focusable, NAMED group: without a role an `aria-label` is prohibited
* on a generic element and assistive tech announced nothing (#268 audit).
* `group`, not `button`: the handle is the title bar and CONTAINS the
* minimize/maximize/close buttons (a button may not), and its action is
* the arrow keys `aria-keyshortcuts` names, not an activation.
*/
role: 'group'
tabindex: 0
'aria-label': string
'aria-keyshortcuts': string
'data-scope': 'floating-panel'
'data-part': 'drag-handle'
onPointerDown: (e: PointerEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
content: {
'data-scope': 'floating-panel'
'data-part': 'content'
hidden: ReadSignal<boolean>
}
minimizeTrigger: {
type: 'button'
'aria-label': string
/** A toggle: `'true'` while minimized. */
'aria-pressed': ReadSignal<'true' | 'false'>
'data-scope': 'floating-panel'
'data-part': 'minimize-trigger'
onClick: (e: MouseEvent) => void
}
maximizeTrigger: {
type: 'button'
'aria-label': string
/** A toggle: `'true'` while maximized. */
'aria-pressed': ReadSignal<'true' | 'false'>
'data-scope': 'floating-panel'
'data-part': 'maximize-trigger'
onClick: (e: MouseEvent) => void
}
closeTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'floating-panel'
'data-part': 'close-trigger'
onClick: (e: MouseEvent) => void
}
/** A resize grip; also a keyboard stop whose arrows resize from this grip (#266). */
resizeHandle: (handle: ResizeHandle) => {
/** A focusable, named group (see `dragHandle`): its action is the arrow
* keys, and a thin edge grip is a window-chrome affordance, not a
* pointer button. */
role: 'group'
tabindex: 0
'aria-label': string
'aria-keyshortcuts': string
'data-scope': 'floating-panel'
'data-part': 'resize-handle'
'data-handle': ResizeHandle
onPointerDown: (e: PointerEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
}
FloatingPanelState from @llui/components
export interface FloatingPanelState {
position: { x: number; y: number }
size: { width: number; height: number }
minSize: { width: number; height: number }
/**
* The upper size bound. `null` — or an absent dimension — is unbounded on
* that axis, which is what `clampSize` already spelled `?? Infinity` at the
* point of use. A bound in state is finite or absent, never an infinity
* (`JSON.stringify` writes `null` for one) and never `NaN` (which is not
* nullish, so it survives the `??` and switches that axis's clamp off) —
* #177.
*/
maxSize: { width?: number; height?: number } | null
open: boolean
minimized: boolean
maximized: boolean
dragging: boolean
resizing: ResizeHandle | null
/** Snapshot of the pre-maximize geometry (for restore). */
restoreBounds: { x: number; y: number; width: number; height: number } | null
disabled: boolean
}
FocusRestoreQuery from @llui/components
The one rule for "should this layer pull focus back to its anchor?".
Restoring focus to the trigger is right when the layer was closed with focus still resting inside it (Escape, a close button, a programmatic close) — the user's focus would otherwise be left on a detached node. It is WRONG when the dismissal was caused by focus moving somewhere the user chose: yanking it back to the trigger takes focus away from the control they just reached, and can leave a still-open layer with focus outside it (#173).
document.body (and a null activeElement) counts as "inside" because that
is where focus lands when the focused element is removed — nobody chose it, so
the anchor is a better home than the body.
Both callers must ask BEFORE tearing anything down: the focus trap's release
and the aria-hidden/inert sweep both move or invalidate activeElement,
so a decision taken after them is a decision about the engine's own cleanup.
export interface FocusRestoreQuery {
/** The region that counts as "inside" this layer. */
boundary: Element
/** The element focus would be restored TO. */
anchor?: Element | null
/**
* Also treat the anchor itself being focused as "inside" (`select`, which
* focuses its own trigger on open — without this its restore reads as "the
* user moved focus to the trigger" and never runs).
*/
allowAnchorActive?: boolean
}
FocusTrapOptions from @llui/components
export interface FocusTrapOptions {
/** The container whose focusable descendants form the trap. */
container: ElementSource;
/** Element to focus when the trap activates. Defaults to the first
* tab-reachable descendant, else the container itself (given a temporary
* `tabindex="-1"` when it has none). */
initialFocus?: Element | (() => Element | null);
/** Restore focus to the previously active element on release (default: true). */
restoreFocus?: boolean;
}
FormatDateOptions from @llui/components
export interface FormatDateOptions {
locale?: string
dateStyle?: DateStyle
calendar?: string
numberingSystem?: string
timeZone?: string
weekday?: 'long' | 'short' | 'narrow'
year?: 'numeric' | '2-digit'
month?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow'
day?: 'numeric' | '2-digit'
era?: 'long' | 'short' | 'narrow'
}
FormatDateTimeOptions from @llui/components
export interface FormatDateTimeOptions {
locale?: string
dateStyle?: DateStyle
timeStyle?: DateStyle
timeZone?: string
calendar?: string
hour12?: boolean
hourCycle?: 'h11' | 'h12' | 'h23' | 'h24'
}
FormatDisplayNameOptions from @llui/components
export interface FormatDisplayNameOptions {
locale?: string
style?: 'long' | 'short' | 'narrow'
languageDisplay?: 'dialect' | 'standard'
fallback?: 'code' | 'none'
}
FormatFileSizeOptions from @llui/components
export interface FormatFileSizeOptions {
locale?: string
units?: string[]
decimalPlaces?: number
}
FormatListOptions from @llui/components
export interface FormatListOptions {
locale?: string
type?: 'conjunction' | 'disjunction' | 'unit'
style?: 'long' | 'short' | 'narrow'
}
FormatNumberOptions from @llui/components
export interface FormatNumberOptions {
locale?: string
style?: 'decimal' | 'currency' | 'percent' | 'unit'
currency?: string
currencyDisplay?: 'symbol' | 'narrowSymbol' | 'code' | 'name'
signDisplay?: 'auto' | 'never' | 'always' | 'exceptZero'
notation?: 'standard' | 'scientific' | 'engineering' | 'compact'
compactDisplay?: 'short' | 'long'
unit?: string
unitDisplay?: 'short' | 'long' | 'narrow'
useGrouping?: boolean
minimumIntegerDigits?: number
minimumFractionDigits?: number
maximumFractionDigits?: number
minimumSignificantDigits?: number
maximumSignificantDigits?: number
}
FormatPluralOptions from @llui/components
export interface FormatPluralOptions {
locale?: string
type?: 'cardinal' | 'ordinal'
minimumIntegerDigits?: number
minimumFractionDigits?: number
maximumFractionDigits?: number
minimumSignificantDigits?: number
maximumSignificantDigits?: number
}
FormatRelativeTimeOptions from @llui/components
export interface FormatRelativeTimeOptions {
locale?: string
numeric?: 'always' | 'auto'
style?: 'long' | 'short' | 'narrow'
}
FormatTimeOptions from @llui/components
export interface FormatTimeOptions {
locale?: string
timeStyle?: DateStyle
timeZone?: string
hour12?: boolean
hourCycle?: 'h11' | 'h12' | 'h23' | 'h24'
hour?: 'numeric' | '2-digit'
minute?: 'numeric' | '2-digit'
second?: 'numeric' | '2-digit'
fractionalSecondDigits?: 0 | 1 | 2 | 3
timeZoneName?: 'long' | 'short' | 'shortOffset' | 'longOffset' | 'shortGeneric' | 'longGeneric'
dayPeriod?: 'narrow' | 'short' | 'long'
}
FormParts from @llui/components
export interface FormParts {
root: {
'data-scope': 'form'
'data-part': 'root'
'data-state': ReadSignal<FormStatus>
'aria-busy': ReadSignal<'true' | undefined>
}
field: (name: string) => {
'data-scope': 'form'
'data-part': 'field'
'data-touched': ReadSignal<'' | undefined>
touched: ReadSignal<boolean>
onBlur: (e: FocusEvent) => void
}
submit: {
type: 'submit'
'data-scope': 'form'
'data-part': 'submit'
'data-state': ReadSignal<FormStatus>
disabled: ReadSignal<boolean>
}
}
FormState from @llui/components
export interface FormState {
status: FormStatus
touched: Record<string, boolean>
submitError: string | null
}
Frame from @llui/components
The plot area in user units — the box marks are drawn inside, already inset by whatever padding the axes need.
export interface Frame {
x: number
y: number
width: number
height: number
}
GradientCenter from @llui/components
export interface GradientCenter {
x: number
y: number
}
GradientInterpolation from @llui/components
export interface GradientInterpolation {
space: InterpolationSpace
hue: HueInterpolationMethod
}
GradientPickerConnectOptions from @llui/components
export interface ConnectOptions {
id: string
trackLabel?: string
addStopLabel?: string
removeStopLabel?: string
kindLabels?: Partial<Record<GradientKind, string>>
repeatingLabel?: string
angleLabel?: string
centerLabel?: string
shapeLabels?: Partial<Record<RadialShape, string>>
sizeLabels?: Partial<Record<RadialSize, string>>
interpolationSpaceLabel?: string
interpolationHueLabel?: string
reverseLabel?: string
distributeLabel?: string
cssLabel?: string
/** Fine keyboard step for a stop / the center thumb (percent units).
* Default 1. */
step?: number
/** Coarse keyboard step when Shift is held. Default 10. */
coarseStep?: number
/** Forwarded to the embedded `color-picker`'s own `connect()` (labels,
* area-thumb step/coarseStep). */
pickerOptions?: ColorPickerConnectOptions
}
GradientPickerInit from @llui/components
export interface GradientPickerInit {
/** Parse an initial gradient from a CSS string (via {@link parseGradient}).
* Takes precedence over `stops`/`kind`/… below. Invalid CSS (or a stop
* count outside `minStops`/`maxStops`) falls back to the `stops`/defaults
* path below, same as `color-picker`'s `color` option falling back on an
* unparsable string. */
css?: string
/** Explicit initial stops (ignored if `css` parses). An EMPTY array is
* treated the same as omitting the option (never an empty gradient — see
* finding #7); fewer than `minStops` are PADDED. Sorted by `position` on
* init; ids are assigned sequentially in the SORTED order. */
stops?: GradientStopInit[]
kind?: GradientKind
repeating?: boolean
/** Sets the initial direction as an explicit angle (linear) or `from`
* angle (conic). For a `to <side>` initial direction, use `css` instead. */
angle?: number
center?: Partial<GradientCenter>
shape?: RadialShape
size?: RadialSize
interpolation?: Partial<GradientInterpolation>
/** Which model a stop with no explicit `PickerColor`/parseable string
* starts in. Default `'hsv'`. */
defaultModel?: ColorModel
maxChroma?: number
minStops?: number
maxStops?: number
disabled?: boolean
dir?: 'ltr' | 'rtl'
}
GradientPickerParts from @llui/components
export interface GradientPickerParts {
root: {
'data-scope': 'gradient-picker'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
'data-kind': ReadSignal<GradientKind>
'data-repeating': ReadSignal<'' | undefined>
}
preview: {
'data-scope': 'gradient-picker'
'data-part': 'preview'
'aria-hidden': 'true'
style: ReadSignal<string>
}
/** The horizontal stop ramp: a plain `in <space>` linear ramp of the SAME
* stop list `toCss` serializes (finding #3 — one shared builder, never two
* strings that can drift), including the hue method and the
* `repeating-`/rtl-mirrored angle so what the track PAINTS matches
* `colorAt` at every position, regardless of `state.kind`. Pointerdown on
* the bare track (not on an existing stop, which calls
* `stopPropagation()`) adds a new stop at that position and starts
* dragging it immediately. */
track: {
'data-scope': 'gradient-picker'
'data-part': 'track'
style: ReadSignal<string>
onPointerDown: (e: PointerEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerUp: (e: PointerEvent) => void
onPointerCancel: (e: PointerEvent) => void
onLostPointerCapture: (e: PointerEvent) => void
}
stop: (id: string) => GradientStopParts
addStopButton: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'gradient-picker'
'data-part': 'add-stop-button'
onClick: (e: MouseEvent) => void
}
removeStopButton: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'gradient-picker'
'data-part': 'remove-stop-button'
onClick: (e: MouseEvent) => void
}
kindToggle: (kind: GradientKind) => ToggleItemParts
repeatingToggle: {
type: 'button'
'aria-label': string
'aria-pressed': ReadSignal<boolean>
disabled: ReadSignal<boolean>
'data-scope': 'gradient-picker'
'data-part': 'repeating-toggle'
'data-state': ReadSignal<'on' | 'off'>
onClick: (e: MouseEvent) => void
}
angleInput: {
type: 'range'
min: 0
max: 360
step: 1
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'gradient-picker'
'data-part': 'angle-input'
onInput: (e: Event) => void
}
centerArea: {
'data-scope': 'gradient-picker'
'data-part': 'center-area'
onPointerDown: (e: PointerEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerUp: (e: PointerEvent) => void
onPointerCancel: (e: PointerEvent) => void
onLostPointerCapture: (e: PointerEvent) => void
}
centerThumb: {
role: 'slider'
'aria-label': string
'aria-valuemin': 0
'aria-valuemax': 100
'aria-valuenow': ReadSignal<number>
'aria-valuetext': ReadSignal<string>
'aria-disabled': ReadSignal<'true' | undefined>
tabindex: ReadSignal<number>
'data-scope': 'gradient-picker'
'data-part': 'center-thumb'
style: ReadSignal<string>
onKeyDown: (e: KeyboardEvent) => void
}
shapeOption: (shape: RadialShape) => ToggleItemParts
sizeOption: (size: RadialSize) => ToggleItemParts
interpolationSpaceSelect: {
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<InterpolationSpace>
'data-scope': 'gradient-picker'
'data-part': 'interpolation-space-select'
onInput: (e: Event) => void
}
interpolationHueSelect: {
'aria-label': string
/** Disabled when the whole picker is disabled, OR the current space has
* no hue (only `hsl`/`oklch` do). */
disabled: ReadSignal<boolean>
value: ReadSignal<HueInterpolationMethod>
'data-scope': 'gradient-picker'
'data-part': 'interpolation-hue-select'
onInput: (e: Event) => void
}
reverseButton: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'gradient-picker'
'data-part': 'reverse-button'
onClick: (e: MouseEvent) => void
}
distributeButton: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'gradient-picker'
'data-part': 'distribute-button'
onClick: (e: MouseEvent) => void
}
/** The draft, NOT the committed value, while editing — see
* `GradientPickerState.cssDraft`. Commits on `change`/Enter, never on
* every keystroke, so the caret never jumps mid-edit; `aria-invalid` +
* `aria-describedby` (pointing at `cssError`'s `id`) publish validity. */
cssInput: {
type: 'text'
autocomplete: 'off'
spellcheck: 'false'
'aria-label': string
'aria-invalid': ReadSignal<'true' | undefined>
'aria-describedby': ReadSignal<string | undefined>
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'gradient-picker'
'data-part': 'css-input'
onInput: (e: Event) => void
onChange: (e: Event) => void
onKeyDown: (e: KeyboardEvent) => void
}
/** The describable error region `cssInput`'s `aria-describedby` points at
* — empty/hidden text when there is no error, matching `form-field`'s
* `errorText` convention (attributes + a sibling `visible`/`message`
* rather than folding non-attribute content into the same bag). */
cssError: {
id: string
role: 'alert'
'data-scope': 'gradient-picker'
'data-part': 'css-error'
visible: ReadSignal<boolean>
message: ReadSignal<string>
}
/**
* The embedded `color-picker`'s FULL part bag, connected over the DERIVED
* `pickerStateOf(state)` — eyedropper, OKLCH canvas, model toggle, every
* slider, all included with zero glue. Messages are wrapped as
* `{ type: 'picker'; msg }` via `wrapChildSend` (which also tags the
* dispatcher `__lluiVariants: ['picker']`, so every handler `color-picker`
* builds from it reports the truthful PARENT-visible type) and unwrapped
* by this component's `update()` — see the module doc comment.
*/
picker: ColorPickerParts
}
GradientPickerState from @llui/components
export interface GradientPickerState {
kind: GradientKind
repeating: boolean
/** Linear only. Ignored (but still stored — the machine always publishes
* both per-kind clusters; the view branches on `kind`) for radial/conic. */
direction: GradientDirection
/** Degrees, conic's `from` angle only. Conic has no `to <side>` form in
* CSS at all, so — unlike linear — it never needs the `GradientDirection`
* union; it is always a plain angle. */
conicAngle: number
/** Percent 0–100. Radial + conic only. */
center: GradientCenter
/** Radial only. */
shape: RadialShape
/** Radial only. */
size: RadialSize
interpolation: GradientInterpolation
/** Kept sorted by `position` ascending; ties keep insertion order. */
stops: GradientStop[]
selectedId: string
/** The next deterministic stop id counter (`` `s${nextId}` ``) — the reducer
* stays pure, so ids can never come from `Math.random()`/`crypto`. MONOTONE
* across a `setGradient`: a successful replace continues the counter
* rather than restarting at `s1`, so an id a consumer captured earlier
* (a DOM id, a test assertion, an undo/redo log) is never reissued to a
* DIFFERENT stop. */
nextId: number
/** Which model a freshly-added stop starts in when nothing else determines
* one (`buildStopsFromOptions`'s fallback color). Distinct from any
* EXISTING stop's own stored model, which `picker`/`setModel` edits
* in-place — see the module doc comment. */
defaultModel: ColorModel
/** Upper bound for OKLCH chroma — shared with the embedded picker. NEVER
* clamps an author-specified chroma down (CLAUDE.md's "never clamp/drop"
* rule, review finding #2c): `init`/`setGradient` RAISE this to fit
* whatever chroma the input actually contains instead. */
maxChroma: number
/** `removeStop` refuses to go below this many stops; `init` PADS up to it
* if given fewer; `setGradient` REJECTS a parse that would drop below it
* (never truncates/pads a user-authored CSS gradient — see the module doc
* on `setGradient`). Default 2. Always a positive integer. */
minStops: number
/**
* Upper bound on stop count, or ABSENT for unbounded. Follows the
* package's UNBOUNDED-CAPABLE idiom (CLAUDE.md #177, `breadcrumbs.
* maxVisible`): the key is OMITTED rather than holding `null`, so a
* `JSON.parse(JSON.stringify(state))` round trip is a key-for-key
* identity. Always a positive integer `>= minStops` when present — `init`
* normalizes a non-integer or too-low value rather than producing a
* config that could never be satisfied.
*/
maxStops?: number
disabled: boolean
/** Reading direction. Under `rtl`, the track's physical left/right (both
* the stop ramp's own background angle and each stop's `left%`) mirrors —
* see `mirrorForDir`'s doc comment on why this differs from
* `color-picker`'s (native-input) hue slider. */
dir: 'ltr' | 'rtl'
/** Whether the browser's EyeDropper API is available — mirrors
* `color-picker`'s own `eyeDropperSupported` field (never `true` at
* `init()`; only `eyeDropperSupportMount`, wired through the `picker`
* wrapper in `connect()`, ever flips it). Lives at the GRADIENT level, not
* per-stop — it is a mount-time browser capability, not a color. */
eyeDropperSupported: boolean
/** The uncommitted `cssInput` text while the user is actively editing, or
* `null` when not editing (display falls back to the live `toCss(state)`
* value). Kept OUT of the committed gradient so the input's own caret
* never jumps mid-keystroke — see `setGradientDraft`/`setGradient`. */
cssDraft: string | null
/** The reason the last `setGradient` attempt failed, or `null`. Cleared by
* the NEXT successful `setGradient`; typing (`setGradientDraft`) does NOT
* clear it, so the error stays visible while the user is mid-correction. */
cssError: string | null
}
GradientStop from @llui/components
export interface GradientStop {
id: string
color: PickerColor
alpha: number
/** 0–100, one cycle — see the module doc comment. */
position: number
}
GradientStopInit from @llui/components
export interface GradientStopInit {
color?: PickerColor | string
alpha?: number
position: number
}
GradientStopParts from @llui/components
export interface GradientStopParts {
id: string
role: 'slider'
'aria-label': string
'aria-valuemin': 0
'aria-valuemax': 100
'aria-valuenow': ReadSignal<number>
'aria-valuetext': ReadSignal<string>
'aria-disabled': ReadSignal<'true' | undefined>
/** Every stop is independently tabbable (never -1 while enabled) — the
* package's existing multi-thumb precedent (`slider.ts`'s `SliderThumbParts`)
* rather than a roving single-tab-stop composite: APG's roving-tabindex
* pattern is for ONE composite control with ONE conceptual focus (a
* toolbar, a listbox); a multi-stop gradient ramp is closer to several
* independent sliders, which is exactly the case `slider.ts` already
* chose this way for. Tab therefore visits stops in DOM order, which
* tracks position order since `each()` renders the reducer's own
* position-sorted `stops` array. */
tabindex: ReadSignal<number>
'data-scope': 'gradient-picker'
'data-part': 'stop'
'data-value': string
'data-selected': ReadSignal<'' | undefined>
style: ReadSignal<string>
onPointerDown: (e: PointerEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerUp: (e: PointerEvent) => void
onPointerCancel: (e: PointerEvent) => void
onLostPointerCapture: (e: PointerEvent) => void
onFocus: (e: FocusEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
HoverCardInit from @llui/components
export interface HoverCardInit {
open?: boolean
/** Skip enter/exit animations — hide unmounts synchronously (default: true). */
skipAnimations?: boolean
}
HoverCardOverlayOptions from @llui/components
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
state: ReadSignal<HoverCardState>
send: Send<HoverCardMsg>
parts: HoverCardParts
content: () => Renderable
/**
* Optional enter/leave transition for the hover-card content (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, so the close plays an exit animation.
* Keep `skipAnimations` at its default (true) when driving exits this way.
*
* @example hoverCard.overlay({ state, send, parts, content, transition: fade({ duration: 150 }) })
*/
transition?: TransitionOptions
placement?: Placement
offset?: number
flip?: boolean
shift?: boolean
target?: string | HTMLElement
/** Selector for an arrow rendered inside the content element. When present,
* floating positioning owns its absolute edge geometry transactionally. */
arrowSelector?: string
}
HoverCardParts from @llui/components
export interface HoverCardParts {
trigger: {
id: string
'aria-controls': string
'aria-expanded': ReadSignal<boolean>
'data-state': ReadSignal<'open' | 'closed'>
'data-scope': 'hover-card'
'data-part': 'trigger'
onPointerEnter: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
onFocus: (e: FocusEvent) => void
onBlur: (e: FocusEvent) => void
}
positioner: {
'data-scope': 'hover-card'
'data-part': 'positioner'
style: string
}
content: {
id: string
'data-state': ReadSignal<PresenceStatus>
'data-scope': 'hover-card'
'data-part': 'content'
onPointerEnter: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
arrow: {
'data-scope': 'hover-card'
'data-part': 'arrow'
}
}
HoverCardState from @llui/components
Hover card — richer tooltip-like popup triggered by hover or focus.
Unlike tooltip, it uses role="dialog" (not role="tooltip") and
allows interactive content. Content can be hovered without closing.
export interface HoverCardState {
open: boolean
/** Presence lifecycle — drives data-state and keeps the node mounted through exit animations. */
status: PresenceStatus
/** When true, hide transitions go straight to 'closed' (no exit-animation wait). */
skipAnimations: boolean
}
Hsl from @llui/components
HSL color. h 0–360 degrees, s/l 0–100. Float — see the module doc.
export interface Hsl {
h: number
s: number
l: number
}
Hsv from @llui/components
HSV color. h 0–360 degrees, s/v 0–100. Float — see the module doc.
export interface Hsv {
h: number
s: number
v: number
}
IconData from @llui/components
One resolved glyph: its body markup and the box it was drawn in.
export interface IconData {
body: string
width: number
height: number
}
ImageCropperInit from @llui/components
export interface ImageCropperInit {
image?: { width: number; height: number }
crop?: CropRect
aspectRatio?: number | null
minSize?: number
disabled?: boolean
}
ImageCropperParts from @llui/components
export interface ImageCropperParts {
root: {
'data-scope': 'image-cropper'
'data-part': 'root'
'data-dragging': ReadSignal<'' | undefined>
'data-resizing': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
}
image: {
'data-scope': 'image-cropper'
'data-part': 'image'
onLoad: (e: Event) => void
draggable: false
}
/**
* The crop area: a focusable, named `group` (#266). Arrow keys move it 1% of
* the image (10% with Shift), `+`/`-` zoom it. Keys stay PHYSICAL under
* `dir="rtl"`: the image is never mirrored, so neither is the box on it.
* Geometry is written as an inline `style` in PERCENT of the image — a skin
* must not set position or size.
*/
cropBox: {
role: 'group'
tabindex: 0
'aria-label': ReadSignal<string>
'aria-keyshortcuts': string
'data-scope': 'image-cropper'
'data-part': 'crop-box'
style: ReadSignal<string>
onPointerDown: (e: PointerEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
resizeHandle: (handle: ResizeHandle) => {
'data-scope': 'image-cropper'
'data-part': 'resize-handle'
'data-handle': ResizeHandle
onPointerDown: (e: PointerEvent) => void
}
resetTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'image-cropper'
'data-part': 'reset-trigger'
onClick: (e: MouseEvent) => void
}
}
ImageCropperState from @llui/components
export interface ImageCropperState {
/** Natural dimensions of the source image. */
image: { width: number; height: number }
crop: CropRect
/** Constrain the crop to this aspect ratio (width / height), or null to free-form. */
aspectRatio: number | null
minSize: number
dragging: boolean
resizing: ResizeHandle | null
disabled: boolean
}
InteractOutsideOptions from @llui/components
export interface InteractOutsideOptions {
/** Element(s) that define the "inside" region. */
element: ElementSource;
/** Additional elements whose interactions should not count as outside (e.g. triggers). */
ignore?: ElementSource;
/** Called on pointerdown or focus outside the inside region. */
onInteractOutside: (event: Event) => void;
/**
* If provided, called first with the event. Return `false` to suppress the
* outside callback (for an in-flight layer to claim the event).
*/
shouldDispatch?: (event: Event) => boolean;
}
InViewObserverOptions from @llui/components
export interface ObserverOptions {
threshold?: number
rootMargin?: string
once?: boolean
}
InViewParts from @llui/components
export interface InViewParts {
root: {
'data-scope': 'in-view'
'data-part': 'root'
'data-state': ReadSignal<'visible' | 'hidden'>
}
}
InViewState from @llui/components
In View — tracks whether an element is visible in the viewport using IntersectionObserver.
State machine: { visible: false } → enter → { visible: true } → leave → …
With once: true, the observer disconnects after the first enter,
keeping visible: true permanently. Useful for lazy-load and
scroll-triggered animations.
view: ({ state, send }) => {
const inViewState = state.at('inView')
const inViewSend = mapSend<Msg, inView.InViewMsg>(send, (msg) => ({
type: 'inView',
msg,
}))
const parts = inView.connect(inViewState, inViewSend, { id: 'hero' })
return [
div({ ...parts.root, class: inViewState.at('visible').map((v) => (v ? 'fade-in' : '')) }, [
onMount((el) =>
inView.createObserver(el, inViewSend, { threshold: 0.5, once: true }),
),
]),
]
}
export interface InViewState {
visible: boolean
}
ListboxInit from @llui/components
export interface ListboxInit {
value?: string[]
items?: string[]
disabledItems?: string[]
disabled?: boolean
selectionMode?: SelectionMode
}
ListboxItemParts from @llui/components
export interface ListboxItemParts {
root: {
role: 'option'
id: string
'aria-selected': ReadSignal<boolean>
'aria-disabled': ReadSignal<'true' | undefined>
'data-state': ReadSignal<'selected' | undefined>
'data-highlighted': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'listbox'
'data-part': 'item'
'data-value': string
'data-index': string
onClick: (e: MouseEvent) => void
onPointerMove: (e: PointerEvent) => void
}
}
ListboxParts from @llui/components
export interface ListboxParts {
root: {
role: 'listbox'
'aria-multiselectable': ReadSignal<'true' | undefined>
'aria-disabled': ReadSignal<'true' | undefined>
'aria-activedescendant': ReadSignal<string | undefined>
tabindex: ReadSignal<number>
id: string
'data-scope': 'listbox'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
onKeyDown: (e: KeyboardEvent) => void
}
item: (value: string, index: number) => ListboxItemParts
}
ListboxState from @llui/components
export interface ListboxState {
value: string[]
items: string[]
disabledItems: string[]
disabled: boolean
selectionMode: SelectionMode
highlightedIndex: number | null
typeahead: string
typeaheadExpiresAt: number
}
Locale from @llui/components
Per-component locale strings. Only components with user-facing text have entries.
export interface Locale {
/**
* App-wide reading direction. When unset, components fall back to their own
* `dir` option or DOM resolution; an explicit component direction wins.
*/
direction?: TextDirection
carousel: {
label: string
indicators: string
next: string
prev: string
slide: (index: number) => string
goToSlide: (index: number) => string
}
cascadeSelect: { clear: string }
clipboard: { copy: string }
colorPicker: {
hue: string
saturation: string
lightness: string
value: string
chroma: string
oklchLightness: string
hex: string
alpha: string
eyeDropper: string
switchToOklch: string
switchToHsv: string
swatchGroup: string
}
combobox: { toggle: string; resultCount: (n: number) => string }
dateInput: { clear: string }
datePicker: {
prev: string
next: string
monthNames: string[]
grid: (year: number, month: number) => string
}
dialog: { close: string }
drawer: { close: string }
fileUpload: { remove: string; clear: string; retry: string; progress: string }
floatingPanel: {
label: string
minimize: string
maximize: string
close: string
move: string
resize: string
}
gradientPicker: {
track: string
stop: (index: number, count: number, color: string, position: number) => string
addStop: string
removeStop: string
linear: string
radial: string
conic: string
repeating: string
angle: string
center: string
shapeCircle: string
shapeEllipse: string
sizeClosestSide: string
sizeClosestCorner: string
sizeFarthestSide: string
sizeFarthestCorner: string
interpolationSpace: string
interpolationHue: string
reverse: string
distribute: string
css: string
cssError: (reason: string) => string
}
imageCropper: { reset: string; cropArea: string }
navigationMenu: { label: string }
numberInput: { increment: string; decrement: string }
pagination: { label: string; prev: string; next: string; page: (n: number) => string }
passwordInput: { show: string; hide: string }
pinInput: { input: (index: number) => string }
popover: { close: string }
progress: { loading: string }
qrCode: { label: string; download: string }
signaturePad: { label: string; clear: string; undo: string }
/**
* The sortable's accessible text. `item` is the consumer's `itemLabel(id)` (or `undefined`
* without one); positions and counts are 1-based, as a listener counts them. The four
* announcements are read by the machine's polite live region; `instructions` is the hidden
* description every handle references through `aria-describedby`.
*/
sortable: {
handle: (item: string | undefined) => string
instructions: string
grabbed: (item: string | undefined, position: number, count: number) => string
moved: (item: string | undefined, position: number, count: number) => string
dropped: (item: string | undefined, from: number, to: number, count: number) => string
cancelled: (item: string | undefined, position: number, count: number) => string
}
/** The composed accessible name of a sparkline. `from`/`to` arrive as
* `YYYY-MM-DD` in the sparkline's own calendar offset. */
sparkline: { empty: string; range: (count: number, from: string, to: string) => string }
steps: { label: string }
/** Accessible names of the table's selection checkboxes (#268). */
table: { selectAll: string; selectRow: string }
tagsInput: { input: string; remove: string; clear: string }
timePicker: { label: string; hours: string; minutes: string; period: string }
timer: { start: string; pause: string; reset: string }
toast: { region: string; dismiss: string }
toc: { label: string; expand: string }
tour: { close: string }
}
MarqueeInit from @llui/components
export interface MarqueeInit {
running?: boolean
direction?: MarqueeDirection
durationSec?: number
pauseOnHover?: boolean
disabled?: boolean
}
MarqueeParts from @llui/components
export interface MarqueeParts {
root: {
'data-scope': 'marquee'
'data-part': 'root'
'data-running': ReadSignal<'' | undefined>
'data-direction': ReadSignal<MarqueeDirection>
'data-axis': ReadSignal<'horizontal' | 'vertical'>
'data-disabled': ReadSignal<'' | undefined>
'aria-disabled': ReadSignal<'true' | undefined>
style: ReadSignal<string>
onMouseEnter: (e: MouseEvent) => void
onMouseLeave: (e: MouseEvent) => void
}
content: {
'data-scope': 'marquee'
'data-part': 'content'
}
}
MarqueeState from @llui/components
export interface MarqueeState {
/** User-intended running state (what play/pause/toggle set). The actual
* effective state is derived via `isRunning()` — it combines this with
* `hovered` + `pauseOnHover`. */
running: boolean
direction: MarqueeDirection
/** Duration of one full loop in seconds. Larger = slower. */
durationSec: number
pauseOnHover: boolean
hovered: boolean
disabled: boolean
}
MenubarInit from @llui/components
export interface MenubarInit {
menus: MenubarMenu[]
/** Initially-focused menu id (defaults to the first enabled menu). */
focused?: string | null
/** Reading direction for horizontal keys and delegated menus. Omit to
* follow the page's own direction (see {@link MenubarState}'s `dir`/
* `dirSource`, resolved from the mounted root by `directionSync`). */
dir?: TextDirection
}
MenubarMenu from @llui/components
Declarative description of one top-level menu in the bar.
export interface MenubarMenu {
id: string
items: MenuItem[]
disabled?: boolean
/** When true, selecting a checkbox/radio also closes this menu. */
closeOnSelect?: boolean
}
MenubarOverlayOptions from @llui/components
export interface MenubarOverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
state: ReadSignal<MenubarState>
send: Send<MenubarMsg>
/** The menu id this overlay renders. */
menuId: string
/** The delegated per-menu bag — `connect(...).menu(menuId)`. Its
* `trigger.id` is the id the bar's `menuTrigger(menuId)` renders, so it
* doubles as the overlay's anchor. */
parts: MenuParts
content: () => Renderable
/**
* Optional enter/leave transition for the menubar menu content (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, so the close plays an exit animation.
* Keep `skipAnimations` at its default (true) when driving exits this way.
*
* @example menubar.overlay({ state, send, parts, content, transition: fade({ duration: 120 }) })
*/
transition?: TransitionOptions
placement?: Placement
offset?: number
flip?: boolean
shift?: boolean
target?: string | HTMLElement
}
MenubarParts from @llui/components
export interface MenubarParts {
root: {
// `id` is REQUIRED — it is the scope `directionSync` (below) looks the
// live root up by, the same contract `navigation-menu`'s own `root.id`
// already honours (#265 finding 6). A consumer that overrides it with a
// DIFFERENT id breaks the direction sync silently.
id: string
role: 'menubar'
'aria-label': string
'data-scope': 'menubar'
'data-part': 'root'
}
menuTrigger: (id: string) => MenubarTriggerParts
/** Delegated per-menu part bag (content/item/checkboxItem/submenu/…). */
menu: (id: string) => MenuParts
/** Place once anywhere in the same build to keep automatic direction live —
* the shared `@llui/interactions` direction-sync seam (#265 finding 6). A
* discarded `Mountable` is inert, so this must be placed in the view. */
directionSync: Mountable
}
MenubarState from @llui/components
export interface MenubarState {
/** Top-level menu ids, in bar order. */
menus: string[]
/** The id of the currently-open menu, or null. */
open: string | null
/** The id of the top-level trigger that holds roving focus. */
focused: string | null
/** Ids of disabled menus (cannot be opened/focused). */
disabledMenus: string[]
/** Embedded per-menu machine states, keyed by menu id. */
menuStates: Record<string, MenuState>
/** Reading direction for both the bar and its delegated menu trees. Routed
* through the shared `@llui/interactions` direction-sync seam
* (`../utils/direction.js`) rather than a second resolver — `dirSource`
* tracks whether `dir` came from explicit config/`setDir` or from the
* mounted root's live ancestor `dir` attribute (#265 finding 6). Every
* embedded `MenuState`'s own `dir` is kept explicitly in sync with this one
* (see `syncEmbeddedDir` below), so a delegated menu never disagrees with
* the bar that owns it. */
dir: TextDirection
dirSource: DirectionSource
}
MenubarTriggerParts from @llui/components
export interface MenubarTriggerParts {
role: 'menuitem'
id: string
'aria-haspopup': 'menu'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
'aria-disabled': ReadSignal<'true' | undefined>
'data-scope': 'menubar'
'data-part': 'trigger'
'data-state': ReadSignal<'open' | 'closed'>
'data-value': string
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onPointerEnter: (e: PointerEvent) => void
onFocus: (e: FocusEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
MenuInit from @llui/components
export interface MenuInit {
open?: boolean
items?: MenuItem[]
highlighted?: string | null
checked?: string[]
closeOnSelect?: boolean
/** Omit to follow the page's own direction (see {@link MenuState}'s `dir`/
* `dirSource`, resolved from the mounted root by `directionSync`). */
dir?: TextDirection
/** When false, opening and closing play enter/exit animations and the content
* stays mounted (status 'opening' or 'closing') until an `animationEnd`.
* Default true: instant. */
skipAnimations?: boolean
}
MenuOverlayOptions from @llui/components
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
state: ReadSignal<MenuState>
send: Send<MenuMsg>
parts: MenuParts
content: () => Renderable
/**
* Optional enter/leave transition for the menu content (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, so the close plays an exit animation.
* Keep `skipAnimations` at its default (true) when driving exits this way.
*
* @example menu.overlay({ state, send, parts, content, transition: fade({ duration: 120 }) })
*/
transition?: TransitionOptions
placement?: Placement
offset?: number
flip?: boolean
shift?: boolean
target?: string | HTMLElement
}
MenuParts from @llui/components
export interface MenuParts {
trigger: {
type: 'button'
'aria-haspopup': 'menu'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
id: string
'data-state': ReadSignal<'open' | 'closed'>
'data-scope': 'menu'
'data-part': 'trigger'
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
positioner: {
'data-scope': 'menu'
'data-part': 'positioner'
style: string
}
content: {
role: 'menu'
id: string
'aria-labelledby': string
/** The id of the virtually-focused (highlighted) item at the root level, so
* assistive tech announces it while DOM focus stays on the container. */
'aria-activedescendant': ReadSignal<string | undefined>
tabindex: -1
/** Reflects the presence lifecycle: 'opening' | 'open' | 'closing' | 'closed'.
* Stays mounted while 'closing' so the exit animation can run. */
'data-state': ReadSignal<PresenceStatus>
'data-scope': 'menu'
'data-part': 'content'
onKeyDown: (e: KeyboardEvent) => void
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
item: (value: string) => MenuItemParts
checkboxItem: (value: string) => MenuCheckItemParts
radioItem: (value: string) => MenuCheckItemParts
/**
* A labelled group's parts. The group names its `label` part through
* `aria-labelledby`; a consumer that renders the group WITHOUT that label
* passes `{ hasLabel: false }` so it never names an element that does not
* exist (an unlabelled `role="group"` is valid; a broken idref is not — the
* `dialog` `hasDescription` rule, #268). Default: true.
*/
group: (id: string, options?: { readonly hasLabel?: boolean }) => MenuGroupParts
separator: () => MenuSeparatorParts
subTrigger: (value: string) => MenuSubTriggerParts
subPositioner: (value: string) => MenuSubPositionerParts
subContent: (value: string) => MenuSubContentParts
/** Place once anywhere in the same build to keep automatic direction live —
* the shared `@llui/interactions` direction-sync seam (#265 finding 6),
* observing the TRIGGER (always mounted, unlike the content) as this
* instance's root. A discarded `Mountable` is inert, so this must be placed
* in the view. */
directionSync: Mountable
}
MenuState from @llui/components
export interface MenuState extends MenuTreeState {
open: boolean
/**
* Presence lifecycle of the root content, layered over `open` for exit
* animations. `open` stays the logical "should be visible/interactive" flag;
* `status` tracks 'opening'/'open'/'closing'/'closed' so the content can stay
* mounted while its exit animation runs (status 'closing'). When
* `skipAnimations` is true (the default) a close jumps straight to 'closed'.
*/
status: PresenceStatus
/** When true (default), a close goes straight to 'closed' synchronously — no
* exit animation, no waiting for an `animationEnd` that may never fire. */
skipAnimations: boolean
items: MenuItem[]
/** Highlighted value per open level. Key `''` is the root; otherwise the parent subTrigger value. */
highlights: Record<string, string | null>
/** Chain of subTrigger values whose submenus are open (deepest last). */
openPath: string[]
/** Checked checkbox / radio values. */
checked: string[]
/** When true, selecting a checkbox/radio also closes the menu (default false). */
closeOnSelect: boolean
/** Accumulator for typeahead search (scoped to the deepest matching level). */
typeahead: string
typeaheadExpiresAt: number
// `dir` + `dirSource` come from `MenuTreeState` (extends `DirectionState`) —
// the shared `@llui/interactions` direction-sync seam (#265 finding 6).
// Under 'rtl', ArrowLeft/ArrowRight swap meaning, and the overlay's
// `*-start`/`*-end` alignment tracks the inline-start/inline-end edge. An
// EXPLICIT direction is AUTHORITATIVE once it reaches `attachFloating`: a
// silently-overridden page direction laid a menu on `<html dir="rtl">` out
// LTR (#138 review, blocking 4) — `dirSource: 'explicit'` is what keeps
// that override from being overwritten by the next DOM observation.
}
MeterInit from @llui/components
export interface MeterInit {
value?: number
min?: number
max?: number
/**
* Native `<meter>` spelling for the three-segment case, compiled into
* {@link MeterState.bands} as `low` / `middle` / `high`. Ignored when
* `bands` is given and yields at least one usable band — which is what makes
* `bands: []` keep the single-optimum behaviour.
*/
low?: number
high?: number
optimum?: number
bands?: readonly MeterBandInit[]
}
MeterParts from @llui/components
export interface MeterParts {
root: {
role: 'meter'
'aria-valuemin': ReadSignal<number>
'aria-valuemax': ReadSignal<number>
'aria-valuenow': ReadSignal<number>
'aria-valuetext': ReadSignal<string>
'aria-label': string | undefined
// Spelled out rather than aliased: `scripts/test/registry-attrs.test.ts`
// reads part-bag VALUES syntactically and gives an ALIAS no verdict, which
// is how the shipped skin came to style `data-[state=critical]` against a
// machine emitting `low`/`high` — two rules of dead CSS, for a release.
'data-state': ReadSignal<'optimal' | 'suboptimal' | 'critical' | 'neutral'>
/** The current band's id — absent when the reading is in no band. */
'data-band': ReadSignal<string | undefined>
'data-scope': 'meter'
'data-part': 'root'
}
track: {
'data-state': ReadSignal<'optimal' | 'suboptimal' | 'critical' | 'neutral'>
'data-scope': 'meter'
'data-part': 'track'
}
/** The filled bar of a classic gauge: `inline-size` up to the reading. */
range: {
'data-state': ReadSignal<'optimal' | 'suboptimal' | 'critical' | 'neutral'>
'data-scope': 'meter'
'data-part': 'range'
style: ReadSignal<string>
}
/** The reading itself, for a BANDED track: positioned, not filled. */
marker: {
'data-state': ReadSignal<'optimal' | 'suboptimal' | 'critical' | 'neutral'>
'data-band': ReadSignal<string | undefined>
'data-scope': 'meter'
'data-part': 'marker'
style: ReadSignal<string>
}
label: {
'data-scope': 'meter'
'data-part': 'label'
}
/** The laid-out bands, for `each` — key rows on `band.id`. */
bands: ReadSignal<MeterBandGeometry[]>
/**
* Attributes for one laid-out band, taking the ROW HANDLE `each` hands the
* render function. It returns signals rather than plain values so the bag is
* spreadable AS IS — `chart`'s `markProps` takes a snapshot instead and every
* call site has to `peek()` the row and then re-bind the fields that move.
*/
bandProps: (band: Signal<MeterBandGeometry>) => {
'data-scope': 'meter'
'data-part': 'band'
'data-band': ReadSignal<string>
'data-state': ReadSignal<'optimal' | 'suboptimal' | 'critical' | 'neutral'>
'data-current': ReadSignal<'' | undefined>
style: ReadSignal<string>
}
/**
* One band by id, for a view that names its bands statically. An id no band
* carries yields an INERT bag — `hidden`, zero-width, `neutral` — rather than
* throwing: `connect` runs while the view is being built, so a typo would
* take the whole page down instead of leaving one stripe undrawn.
*/
band: (id: string) => {
'data-scope': 'meter'
'data-part': 'band'
'data-band': string
'data-state': ReadSignal<'optimal' | 'suboptimal' | 'critical' | 'neutral'>
'data-current': ReadSignal<'' | undefined>
hidden: ReadSignal<boolean>
style: ReadSignal<string>
}
/** The formatted reading, WITHOUT the band name. */
valueText: ReadSignal<string>
/** The current band's announced name, or `''`. */
bandLabel: ReadSignal<string>
}
MeterState from @llui/components
export interface MeterState {
value: number
min: number
max: number
/**
* The reference range, in AUTHOR ORDER. Nothing is sorted or merged: order
* decides which band wins an overlap (the first match) and the order the
* view draws them in, and re-ordering the author's ids would silently change
* both.
*/
bands: MeterBand[]
}
NavItemParts from @llui/components
export interface NavItemParts {
trigger: {
type: 'button'
id: string
/** For a branch item this is the disclosure button controlling its panel;
* `undefined` for a plain link trigger. */
'aria-controls': string | undefined
'aria-expanded': ReadSignal<boolean | undefined>
'data-scope': 'navigation-menu'
'data-part': 'trigger'
'data-state': ReadSignal<'open' | 'closed'>
'data-value': string
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onPointerEnter: (e: PointerEvent) => void
onFocus: (e: FocusEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
content: {
id: string
'aria-labelledby': string
'data-scope': 'navigation-menu'
'data-part': 'content'
'data-state': ReadSignal<'open' | 'closed'>
hidden: ReadSignal<boolean>
onPointerEnter: (e: PointerEvent) => void
}
}
NavMenuInit from @llui/components
export interface NavMenuInit {
open?: string[]
focused?: string | null
/** Ids eligible for the roving tab stop, in document order — see
* `NavMenuState.items`. */
items?: string[]
disabled?: boolean
dir?: 'ltr' | 'rtl'
}
NavMenuParts from @llui/components
export interface NavMenuParts {
root: {
// `id` is REQUIRED — it is the scope `directionSyncMount` (below) looks
// the live root up by, the same contract `tabs`/`carousel`/`pagination`'s
// own `root.id` already honours. A consumer that overrides it with a
// DIFFERENT id breaks the direction sync silently (#265 finding 6): the
// watcher would observe nothing, since `getElementByIdInScope` would
// never find this element under the id it was given.
id: string
// Site navigation is NOT an application menu: it uses a `nav` landmark with
// disclosure buttons, not menubar/menu/menuitem roles. Render the root as a
// `<nav>` element; `aria-label` names the landmark.
'aria-label': string
'data-scope': 'navigation-menu'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
onPointerLeave: (e: PointerEvent) => void
onPointerEnter: (e: PointerEvent) => void
}
/**
* The arrow that tracks the open top-level trigger — Radix's
* `NavigationMenuIndicator`, whose `data-state="visible" | "hidden"` drives
* the enter/exit animation. Render it as a sibling of the trigger list,
* inside the positioned root.
*
* It stays MOUNTED in both states: the recipe animates it out with
* `data-[state=hidden]:animate-out fade-out`, and a `show(...)` would unmount
* the node the exit animation is running on. Same reason `formField`'s live
* region is hidden rather than gated.
*
* `data-state` is all the MACHINE can answer — where the arrow sits is a
* layout fact, not state (`NavMenuState` is JSON-serializable and holds no
* pixels), so the position arrives through `watchNavMenuIndicator`, which
* writes `--indicator-left`/`--indicator-width` onto this element. That is
* the same split `tabs` uses for its own indicator.
*/
indicator: {
// Pure decoration: it duplicates no information a screen reader cannot get
// from the trigger's own `aria-expanded`, and it is always in the tree.
'aria-hidden': 'true'
'data-scope': 'navigation-menu'
'data-part': 'indicator'
'data-state': ReadSignal<'visible' | 'hidden'>
}
/** Place once anywhere in the same build to keep automatic direction live —
* the shared `@llui/interactions` direction-sync seam (#265 finding 6),
* same as `tabs`/`carousel`/`pagination`'s own `directionSync` part. A
* discarded `Mountable` is inert, so this must be placed in the view. */
directionSync: Mountable
/**
* Parts for one trigger (+ its panel when it is a branch).
*
* `ancestorIds` is the open-path this item lives under, root-first. It drives
* sibling-closing in `openBranch` AND the roving tab stop: an item whose
* ancestors are not all open is inside a `hidden` panel and is skipped when
* the stop is resolved.
*
* REQUIRED on every NESTED item, leaf ones included. It used to be read only
* inside `isBranch` guards, so passing it on a leaf was optional in practice;
* since #145 it is what makes a leaf's tabbability knowable. Omitting it
* reads as "top level", which lets the tab stop sit inside a closed submenu
* where no Tab press can reach it.
*/
item: (id: string, options: { isBranch: boolean; ancestorIds?: string[] }) => NavItemParts
}
NavMenuState from @llui/components
Navigation menu — multi-level menu bar with hover/focus-triggered
submenus. Unlike menu (a single dropdown), navigation-menu supports
nested submenus arbitrarily deep and is typically used for primary
site navigation.
State tracks the currently focused item id and the ids of all currently-open branches. The consumer provides the tree structure (items with optional children); the machine doesn't index the hierarchy itself — it just maintains open-paths and lets the view handle traversal.
Typical interaction model (delay-based):
- Pointer enter on a branch → openBranch after openDelay
- Pointer leave of the whole tree → closeAll after closeDelay
- Click/keyboard activation → toggleBranch immediately
The consumer is responsible for debouncing via setTimeout; the machine just responds to the dispatched messages.
export interface NavMenuState {
/** Ids of open branches, in open order (root-first). Closing an
* ancestor automatically closes its descendants. */
open: string[]
focused: string | null
/**
* Ids of the items ELIGIBLE for the roving tab stop, in document order, when
* the consumer renders a DYNAMIC list — the top-level items in the usual
* case; add deeper ids if a submenu item should be able to own the stop.
*
* It is both the fallback and the membership list: while nothing is focused
* the first entry owns the nav's single tab stop, and a `focused` id that is
* not one of these entries has been removed, so the stop falls back rather
* than vanishing (#145).
*
* Leave it empty for a static menu — `connect` then uses the ids handed to
* `item()`, in call order, as the membership list instead, which is document
* order for any depth-first view. Same escape hatch as `radio-group`/`tabs`,
* which keep their `items` list in state for exactly this reason.
*
* Either way the candidates are filtered to the ones currently TABBABLE: an
* id whose `ancestorIds` are not all in `open` sits inside a `hidden`
* submenu panel and cannot carry the stop.
*/
items: string[]
disabled: boolean
/** Reading direction. Under 'rtl', ArrowLeft/ArrowRight swap meaning.
* Routed through the shared `@llui/interactions` direction-sync seam
* (`../utils/direction.js`) rather than a second resolver — `dirSource`
* tracks whether `dir` came from explicit config/`setDir` or from the
* mounted root's live ancestor `dir` attribute (#265 finding 6). */
dir: 'ltr' | 'rtl'
dirSource: DirectionSource
}
NestedLayerOptions from @llui/components
export interface NestedLayerOptions {
/**
* Consumers this registration participates in. Defaults to all of them, which
* is what a surface with no dismissable layer of its own needs. Narrow it when
* another mechanism already covers an aspect — see the module comment.
*/
aspects?: readonly NestedLayerAspect[];
/**
* The element this layer is logically nested INSIDE — its trigger/anchor, or
* the host element it belongs to. This is what makes the registry per-layer:
* an asking layer exempts this registration only when the owner is inside the
* asker's own boundary (transitively through other nested layers).
*
* The owner is NOT the layer's portal root — that is the `source` argument.
* It is the thing in the main document tree that the portal speaks for.
*
* Resolver form is supported and re-read on every lookup, so an owner that
* mounts and unmounts with its component can be named once.
*
* A missing or unresolved owner grants no scoped exemption and emits a
* development warning. Even a registration used only through the unscoped
* registry-wide view should name its logical owner to keep the contract
* explicit.
*/
owner?: ElementSource;
}
NumberInputInit from @llui/components
export interface NumberInputInit {
value?: number | null
min?: number
max?: number
step?: number
disabled?: boolean
readonly?: boolean
}
NumberInputParts from @llui/components
export interface NumberInputParts {
root: {
'data-scope': 'number-input'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
}
input: {
type: 'text'
role: 'spinbutton'
inputmode: 'decimal'
'aria-valuemin': ReadSignal<number | undefined>
'aria-valuemax': ReadSignal<number | undefined>
'aria-valuenow': ReadSignal<number | undefined>
'aria-disabled': ReadSignal<'true' | undefined>
'aria-readonly': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
readonly: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'number-input'
'data-part': 'input'
onInput: (e: Event) => void
onBlur: (e: FocusEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
increment: {
type: 'button'
'aria-label': string
'aria-disabled': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
'data-scope': 'number-input'
'data-part': 'increment'
tabindex: -1
onClick: (e: MouseEvent) => void
}
decrement: {
type: 'button'
'aria-label': string
'aria-disabled': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
'data-scope': 'number-input'
'data-part': 'decrement'
tabindex: -1
onClick: (e: MouseEvent) => void
}
}
NumberInputState from @llui/components
Number input — numeric field with increment/decrement buttons. Clamps to min/max and snaps to step. Keyboard: Arrow Up/Down, PageUp/PageDown, Home/End.
export interface NumberInputState {
value: number | null
/**
* The bounds, ABSENT when that side is unbounded — the state's `min`/`max`/
* `step` ARE a `NumericGrid`, which is what lets `clampToStep(value, state)`
* take the state object straight in.
*
* A bound is never `±Infinity` and never `NaN`: this is the ONE component in
* the package whose DEFAULT range is unbounded, and it used to spell that
* `min: -Infinity` / `max: Infinity` in state, which `JSON.stringify` writes
* as `null` — so its default state did not survive a round trip and the
* rehydrated object held `null` in a `number` field (#177). Absence is the
* serializable spelling of the same fact; `finiteBound` is the one place it
* is decided.
*/
min?: number
max?: number
step: number
disabled: boolean
readonly: boolean
/** Allow a free-text input value while the user is typing. */
rawText: string
}
NumericGrid from @llui/components
A stepped numeric range. min/max default to unbounded and step to 0
(= continuous). Component states name their fields the same way, so a state
object can be passed straight in.
export interface NumericGrid {
min?: number
max?: number
step?: number
}
Oklab from @llui/components
Oklab. l is 0–1 lightness; a/b are unbounded (±0.4 roughly spans
sRGB).
export interface Oklab {
l: number
a: number
b: number
}
Oklch from @llui/components
OKLCH. l 0–1, c >= 0 (unbounded — maxChroma in the color picker is a
UI choice, not a property of the color space), h in degrees.
export interface Oklch {
l: number
c: number
h: number
}
PaginationInit from @llui/components
export interface PaginationInit {
page?: number
pageSize?: number
total?: number
siblings?: number
boundaries?: number
disabled?: boolean
dir?: TextDirection
}
PaginationParts from @llui/components
export interface PaginationParts {
root: {
id: string
role: 'navigation'
'aria-label': string
'data-scope': 'pagination'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
}
prevTrigger: {
type: 'button'
'aria-label': string
'aria-disabled': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
'data-scope': 'pagination'
'data-part': 'prev-trigger'
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
nextTrigger: {
type: 'button'
'aria-label': string
'aria-disabled': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
'data-scope': 'pagination'
'data-part': 'next-trigger'
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
item: (page: number) => {
type: 'button'
'aria-label': string
'aria-current': ReadSignal<'page' | undefined>
'data-selected': ReadSignal<'' | undefined>
'data-scope': 'pagination'
'data-part': 'item'
'data-value': string
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
ellipsis: (position: 'start' | 'end') => {
'aria-hidden': 'true'
'data-scope': 'pagination'
'data-part': 'ellipsis'
'data-position': 'start' | 'end'
}
/** Place once anywhere in the same build to keep automatic direction live. */
directionSync: Mountable
}
PaginationState from @llui/components
Pagination — page navigation with ellipses for large ranges.
page is 1-based. Siblings are the count of pages shown on each side
of the current page. Boundaries are shown at the start/end.
export interface PaginationState {
page: number
pageSize: number
total: number
siblings: number
boundaries: number
disabled: boolean
dir: TextDirection
dirSource: DirectionSource
}
ParsedDateValue from @llui/components
export interface ParsedDateValue {
date: Date
/**
* True when the input was a bare calendar date. Formatters MUST then render
* in UTC (where the anchor was taken), otherwise the ambient zone shifts the
* rendered day.
*/
dateOnly: boolean
}
ParsedGradient from @llui/components
export interface ParsedGradient {
kind: GradientKind
repeating: boolean
direction: GradientDirection
conicAngle: number
center: GradientCenter
shape: RadialShape
size: RadialSize
interpolation: GradientInterpolation
stops: Array<{ color: PickerColor; alpha: number; position: number }>
}
PasswordInputInit from @llui/components
export interface PasswordInputInit {
value?: string
visible?: boolean
disabled?: boolean
}
PasswordInputParts from @llui/components
export interface PasswordInputParts {
root: {
'data-scope': 'password-input'
'data-part': 'root'
'data-visible': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
}
input: {
type: ReadSignal<'text' | 'password'>
autocomplete: string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'password-input'
'data-part': 'input'
onInput: (e: Event) => void
}
visibilityTrigger: {
type: 'button'
'aria-label': ReadSignal<string>
'aria-pressed': ReadSignal<boolean>
disabled: ReadSignal<boolean>
tabindex: ReadSignal<number>
'data-scope': 'password-input'
'data-part': 'visibility-trigger'
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
}
PasswordInputState from @llui/components
Password input — text input with show/hide visibility toggle.
export interface PasswordInputState {
value: string
visible: boolean
disabled: boolean
}
PinInputInit from @llui/components
export interface PinInputInit {
length?: number
type?: PinType
mask?: boolean
disabled?: boolean
values?: string[]
}
PinInputParts from @llui/components
export interface PinInputParts {
root: {
role: 'group'
/** The label part's id, or absent when `hasLabel: false`. */
'aria-labelledby': string | undefined
'data-scope': 'pin-input'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
}
label: {
id: string
'data-scope': 'pin-input'
'data-part': 'label'
}
/** Props for the input at a given index. */
input: (index: number) => {
type: ReadSignal<'text' | 'password'>
inputmode: ReadSignal<'numeric' | 'text'>
pattern: ReadSignal<string>
maxlength: 1
autocomplete: 'off'
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'pin-input'
'data-part': 'input'
'data-index': string
onInput: (e: Event) => void
onKeyDown: (e: KeyboardEvent) => void
onFocus: (e: FocusEvent) => void
onPaste: (e: ClipboardEvent) => void
}
}
PinInputState from @llui/components
export interface PinInputState {
values: string[]
length: number
type: PinType
mask: boolean
disabled: boolean
focusedIndex: number
}
Point from @llui/components
export interface Point {
x: number
y: number
}
PointerDragCallbacks from @llui/components
One call site's drag callbacks.
export interface PointerDragCallbacks {
/** Checked on `pointerdown` only — a drag that is already underway keeps
* running even if this flips true mid-drag (the reducer's own `disabled`
* guard is the actual safety net for a message dispatched from `onDrag`
* after that point; this only decides whether a NEW drag may start). */
isDisabled: () => boolean
/** Fires once, on `pointerdown`, after capture is acquired and BEFORE the
* first `onDrag` call — the hook for a one-time side effect like moving
* focus onto a thumb element. */
onDragStart?: (e: PointerEvent) => void
/** Fires on `pointerdown` (immediately after `onDragStart`) and again on
* every `pointermove` while the drag is live. */
onDrag: (e: PointerEvent) => void
/** Fires once on `pointerup`/`pointercancel`, but ONLY when a drag was
* actually live (never on a stray up/cancel with no matching down) — the
* counterpart to `onDragStart`, for a caller that tracks per-drag identity
* across the lifecycle (`gradient-picker`'s track drag creates a new stop
* on `pointerdown` and must stop targeting it once the drag ends). Runs
* AFTER pointer capture is released. */
onDragEnd?: (e: PointerEvent) => void
}
PointerDragHandlers from @llui/components
export interface PointerDragHandlers {
onPointerDown: (e: PointerEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerUp: (e: PointerEvent) => void
onPointerCancel: (e: PointerEvent) => void
/** The browser can revoke pointer capture without ever firing `pointerup`/
* `pointercancel` (an OS-level interruption, a nested capture request, …).
* Left unwired, `dragging` would stay `true` forever and a later, UNRELATED
* pointer's `pointermove` would be misread as a continuation of this drag.
* Spread onto the same element as the other four. */
onLostPointerCapture: (e: PointerEvent) => void
}
PolarOptions from @llui/components
export interface PolarOptions {
/** Inner radius as a fraction of the outer. `0` is a pie, `0.5` a donut, and
* a radar usually wants `0`. */
innerRadius?: number
/** Where `u = 0` sits, in radians clockwise from 12 o'clock. */
startAngle?: number
/** Total sweep in radians. Defaults to a full turn, which is what makes the
* projection `closed`. */
sweep?: number
/**
* Gridline shape. `ring` is a circle at the given magnitude — right for a
* pie or a rose. `web` joins the `spokes` sample positions with straight
* segments — right for a radar, where a circular grid reads as a different
* chart from the polygonal data on top of it.
*/
grid?: 'ring' | 'web'
/** How many spokes a `web` gridline has. Ignored for `ring`. */
spokes?: number
/**
* Swap the axes, exactly as {@link CartesianOptions.horizontal} does: the
* INDEPENDENT axis moves off the angle and onto the RADIUS, so each category
* becomes its own concentric ring and magnitude is read as arc length. That
* is a radial bar chart, and it is the polar reading of the same flag that
* turns a column chart on its side — one field, the same meaning in both
* coordinate systems, so a consumer flipping `coord` keeps whatever
* orientation they asked for.
*
* The independent axis is then the radius, which is NOT a loop, so the
* projection reports `closed: false` however wide the sweep is.
*/
horizontal?: boolean
}
PopoverInit from @llui/components
export interface PopoverInit {
open?: boolean
/** Skip enter/exit animations — close unmounts synchronously (default: true). */
skipAnimations?: boolean
}
PopoverOverlayOptions from @llui/components
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
state: ReadSignal<PopoverState>
send: Send<PopoverMsg>
parts: PopoverParts
content: () => Renderable
/**
* Optional enter/leave transition for the popover content (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, so the close plays an exit animation.
* Keep `skipAnimations` at its default (true) when driving exits this way.
*
* @example popover.overlay({ state, send, parts, content, transition: fade({ duration: 150 }) })
*/
transition?: TransitionOptions
/** Placement preference — bottom | top | right | left with -start/-end variants. */
placement?: Placement
/** Offset between trigger and content, px (default: 8). */
offset?: number
/** Auto-flip to opposite side (default: true). */
flip?: boolean
/** Shift to keep in viewport (default: true). */
shift?: boolean
/** Close on Escape (default: true). */
closeOnEscape?: boolean
/** Close on outside click (default: true). */
closeOnOutsideClick?: boolean
/** Trap focus inside popover while open (default: false — non-modal). */
trapFocus?: boolean
/** Restore focus to trigger on close (default: true). */
restoreFocus?: boolean
/** Portal target (default: 'body'). */
target?: string | HTMLElement
/** Selector for an arrow rendered inside the content element. When present,
* floating positioning owns its absolute edge geometry transactionally. */
arrowSelector?: string
}
PopoverParts from @llui/components
export interface PopoverParts {
trigger: {
type: 'button'
'aria-haspopup': 'dialog'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
id: string
'data-state': ReadSignal<'open' | 'closed'>
'data-scope': 'popover'
'data-part': 'trigger'
onClick: (e: MouseEvent) => void
}
positioner: {
'data-scope': 'popover'
'data-part': 'positioner'
style: string
}
content: {
role: 'dialog'
id: string
'aria-labelledby': string
tabindex: -1
'data-state': ReadSignal<PresenceStatus>
'data-scope': 'popover'
'data-part': 'content'
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
title: {
id: string
'data-scope': 'popover'
'data-part': 'title'
}
description: {
id: string
'data-scope': 'popover'
'data-part': 'description'
}
arrow: {
'data-scope': 'popover'
'data-part': 'arrow'
}
closeTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'popover'
'data-part': 'close-trigger'
onClick: (e: MouseEvent) => void
}
}
PopoverState from @llui/components
Popover — click-triggered, non-modal floating overlay anchored to its trigger. Use for menus, date pickers, color pickers, filters, etc.
Like dialog, has a pure state machine + a view helper (overlay()) that
wires floating-ui positioning, dismissable, and optional focus trapping.
export interface PopoverState {
open: boolean
/** Presence lifecycle — drives data-state and keeps the node mounted through exit animations. */
status: PresenceStatus
/** When true, close transitions go straight to 'closed' (no exit-animation wait). */
skipAnimations: boolean
}
PresenceInit from @llui/components
export interface PresenceInit {
/** Initial presence — true starts in 'open', false starts in 'closed'. */
present?: boolean
/** Whether 'closed' means "unmount" (true) or "hidden but mounted" (false). Default: true. */
unmountOnExit?: boolean
}
PresenceParts from @llui/components
export interface PresenceParts {
root: {
'data-scope': 'presence'
'data-part': 'root'
'data-state': ReadSignal<PresenceStatus>
hidden: ReadSignal<boolean>
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
}
PresenceState from @llui/components
export interface PresenceState {
status: PresenceStatus
unmountOnExit: boolean
}
PresetParts from @llui/components
export interface PresetParts {
type: 'button'
'data-scope': 'date-picker'
'data-part': 'preset'
onClick: (e: MouseEvent) => void
}
PresetRange from @llui/components
A named preset range a consumer can render as a quick-select button.
export interface PresetRange {
start: string | null
end: string | null
}
ProgressInit from @llui/components
export interface ProgressInit {
value?: number | null
min?: number
max?: number
orientation?: ProgressOrientation
}
ProgressParts from @llui/components
export interface ProgressParts {
root: {
role: 'progressbar'
'aria-valuemin': ReadSignal<number>
'aria-valuemax': ReadSignal<number>
'aria-valuenow': ReadSignal<number | undefined>
'aria-label': string | undefined
'data-state': ReadSignal<'indeterminate' | 'complete' | 'loading'>
'data-orientation': ReadSignal<ProgressOrientation>
'data-scope': 'progress'
'data-part': 'root'
}
track: {
'data-state': ReadSignal<'indeterminate' | 'complete' | 'loading'>
'data-orientation': ReadSignal<ProgressOrientation>
'data-scope': 'progress'
'data-part': 'track'
}
range: {
'data-state': ReadSignal<'indeterminate' | 'complete' | 'loading'>
'data-orientation': ReadSignal<ProgressOrientation>
'data-scope': 'progress'
'data-part': 'range'
style: ReadSignal<string>
}
label: {
'data-scope': 'progress'
'data-part': 'label'
}
valueText: ReadSignal<string>
}
ProgressState from @llui/components
export interface ProgressState {
value: number | null
min: number
max: number
orientation: ProgressOrientation
}
Projection from @llui/components
export interface Projection {
readonly kind: ChartCoord
/**
* Whether the independent axis is a CLOSED loop. `true` for a polar
* projection covering a full turn, which is what makes a radar outline join
* back to its first vertex instead of leaving a gap.
*/
readonly closed: boolean
/**
* Curves this projection can honour. A caller asking for one outside the set
* gets `linear` — see the polar note below for why this is the honest answer
* rather than a silent approximation.
*/
readonly curves: readonly Curve[]
/** Normalized (u, v) → a point in user units. */
point(u: number, v: number): Point
/** A run of samples joined along increasing `u`. */
line(samples: readonly Sample[], curve: Curve): string
/** The region between an upper run and a lower run of the same length. */
area(upper: readonly Sample[], lower: readonly Sample[], curve: Curve): string
/** A band spanning `u0..u1` and `v0..v1`: a bar, or a wedge. */
band(u0: number, u1: number, v0: number, v1: number): string
/** The iso-magnitude line at `v` — a value gridline. */
gridline(v: number): string
/** Where the independent-axis tick for `u` belongs. */
tick(u: number): TickPlacement
/** Where the DEPENDENT-axis tick for `v` belongs. */
valueTick(v: number): TickPlacement
/**
* Invert a point in user units back to `u`, for pointer hit testing. Returns
* `null` when the point carries no meaningful `u` — the exact centre of a
* polar chart, where every angle is equally close.
*/
locate(x: number, y: number): number | null
}
QrCodeInit from @llui/components
export interface QrCodeInit {
value?: string
matrix?: boolean[][]
errorCorrection?: ErrorCorrectionLevel
}
QrCodeParts from @llui/components
export interface QrCodeParts {
/**
* A labelled `group` around the code and its download action. `aria-label` on
* a bare `<div>` (role `generic`) is prohibited ARIA and ignored by most
* assistive technology, which is why the role is stated.
*/
root: {
role: 'group'
'data-scope': 'qr-code'
'data-part': 'root'
'aria-label': string
/** Present while no matrix has been supplied — the empty state hook. */
'data-empty': ReadSignal<'' | undefined>
}
/**
* The `role="img"` graphic carries its OWN accessible name. A screen-reader
* user cannot scan the modules, so the name includes the encoded value.
*/
svg: {
'data-scope': 'qr-code'
'data-part': 'svg'
role: 'img'
'aria-label': ReadSignal<string>
viewBox: ReadSignal<string>
'shape-rendering': 'crispEdges'
}
/** Spread onto a `<rect>`: sized to the module grid so it covers the quiet background. */
background: {
'data-scope': 'qr-code'
'data-part': 'background'
width: ReadSignal<string>
height: ReadSignal<string>
}
/** Spread onto a `<path>`: one sub-path per dark module. */
foreground: {
'data-scope': 'qr-code'
'data-part': 'foreground'
d: ReadSignal<string>
}
downloadTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'qr-code'
'data-part': 'download-trigger'
/** Nothing to download until a matrix exists. */
disabled: ReadSignal<boolean>
onClick: (e: MouseEvent) => void
}
}
QrCodeState from @llui/components
export interface QrCodeState {
value: string
/** NxN boolean matrix — true means dark (filled) module. */
matrix: boolean[][]
errorCorrection: ErrorCorrectionLevel
}
RadioGroupInit from @llui/components
export interface RadioGroupInit {
value?: string | null
items?: string[]
disabledItems?: string[]
disabled?: boolean
orientation?: Orientation
loopFocus?: boolean
dir?: 'ltr' | 'rtl'
}
RadioGroupParts from @llui/components
export interface RadioGroupParts {
root: {
role: 'radiogroup'
'aria-orientation': ReadSignal<Orientation>
'aria-disabled': ReadSignal<'true' | undefined>
'data-scope': 'radio-group'
'data-part': 'root'
'data-orientation': ReadSignal<Orientation>
'data-disabled': ReadSignal<'' | undefined>
}
item: (value: string) => RadioItemParts
}
RadioGroupState from @llui/components
export interface RadioGroupState {
value: string | null
items: string[]
disabledItems: string[]
disabled: boolean
orientation: Orientation
/** Whether arrow navigation wraps at the ends. Default true (WAI-ARIA radio
* groups wrap). Present on every roving widget in the package — its absence
* here was pure drift between the copies of the navigation code (#126). */
loopFocus: boolean
/** Reading direction. Under 'rtl', ArrowLeft/ArrowRight swap meaning. */
dir: 'ltr' | 'rtl'
}
RadioItemParts from @llui/components
export interface RadioItemParts {
root: {
role: 'radio'
id: string
'aria-checked': ReadSignal<boolean>
'aria-disabled': ReadSignal<'true' | undefined>
'data-state': ReadSignal<'checked' | 'unchecked'>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'radio-group'
'data-part': 'item'
'data-value': string
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
label: {
'data-scope': 'radio-group'
'data-part': 'label'
'data-value': string
for: string
}
indicator: {
'data-state': ReadSignal<'checked' | 'unchecked'>
'data-scope': 'radio-group'
'data-part': 'indicator'
}
}
RatingGroupInit from @llui/components
export interface RatingGroupInit {
value?: number
count?: number
allowHalf?: boolean
disabled?: boolean
readonly?: boolean
dir?: 'ltr' | 'rtl'
}
RatingGroupParts from @llui/components
export interface RatingGroupParts {
root: {
role: 'radiogroup'
'aria-label': string | undefined
'aria-disabled': ReadSignal<'true' | undefined>
'aria-readonly': ReadSignal<'true' | undefined>
'data-scope': 'rating-group'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
'data-readonly': ReadSignal<'' | undefined>
}
item: (index: number) => RatingItemParts
}
RatingGroupState from @llui/components
Rating group — a sequence of clickable items (stars) representing a discrete rating. Supports half-step ratings and keyboard navigation.
export interface RatingGroupState {
value: number
count: number
/** If true, allows values like 1.5 (half-stars). */
allowHalf: boolean
disabled: boolean
readonly: boolean
hoveredValue: number | null
/** Reading direction. Under 'rtl', ArrowLeft/ArrowRight swap meaning. */
dir: 'ltr' | 'rtl'
}
RatingItemParts from @llui/components
export interface RatingItemParts {
root: {
role: 'radio'
'aria-checked': ReadSignal<boolean>
'data-fill': ReadSignal<ItemFill>
'data-scope': 'rating-group'
'data-part': 'item'
'data-value': string
'data-disabled': ReadSignal<'' | undefined>
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
}
RejectedFile from @llui/components
export interface RejectedFile {
file: FileMeta
errors: FileError[]
}
Rgb255 from @llui/components
8-bit-per-channel sRGB, 0–255 (integers once produced by this module).
export interface Rgb255 {
r: number
g: number
b: number
}
RovingItem from @llui/components
export interface RovingItem {
value: string;
/** Disabled items are skipped by arrow/Home/End navigation. */
disabled?: boolean;
}
RovingOptions from @llui/components
export interface RovingOptions {
/** Arrow axis — 'horizontal' uses Left/Right, 'vertical' uses Up/Down. Default 'horizontal'. */
orientation?: RovingOrientation;
/** Whether arrow navigation wraps at the ends. Default true. */
loop?: boolean;
/**
* An element used to resolve text direction for RTL arrow flipping
* (typically the event's `currentTarget`). When it resolves to
* `dir="rtl"`, ArrowLeft/ArrowRight swap. Optional.
*/
element?: Element | null;
}
Sample from @llui/components
A normalized sample: position along the independent axis, and magnitude.
export interface Sample {
u: number
v: number
}
ScrollAreaInit from @llui/components
export interface ScrollAreaInit {
visibility?: ScrollbarVisibility
}
ScrollAreaParts from @llui/components
export interface ScrollAreaParts {
root: {
'data-scope': 'scroll-area'
'data-part': 'root'
'data-scrolling': ReadSignal<'' | undefined>
'data-hovered': ReadSignal<'' | undefined>
onMouseEnter: (e: MouseEvent) => void
onMouseLeave: (e: MouseEvent) => void
}
viewport: {
tabindex: 0
'data-scope': 'scroll-area'
'data-part': 'viewport'
onScroll: (e: Event) => void
}
content: {
'data-scope': 'scroll-area'
'data-part': 'content'
}
scrollbarX: {
'data-scope': 'scroll-area'
'data-part': 'scrollbar'
'data-axis': 'x'
'data-visible': ReadSignal<'' | undefined>
}
scrollbarY: {
'data-scope': 'scroll-area'
'data-part': 'scrollbar'
'data-axis': 'y'
'data-visible': ReadSignal<'' | undefined>
}
thumbX: {
'data-scope': 'scroll-area'
'data-part': 'thumb'
'data-axis': 'x'
style: ReadSignal<string>
}
thumbY: {
'data-scope': 'scroll-area'
'data-part': 'thumb'
'data-axis': 'y'
style: ReadSignal<string>
}
corner: {
'data-scope': 'scroll-area'
'data-part': 'corner'
'data-visible': ReadSignal<'' | undefined>
}
}
ScrollAreaState from @llui/components
export interface ScrollAreaState extends ScrollDims {
overflowX: boolean
overflowY: boolean
/** Whether the user is currently scrolling (set/cleared by the consumer
* via a debounced scroll handler). */
scrolling: boolean
/** Whether the pointer is over the scroll area. */
hovered: boolean
visibility: ScrollbarVisibility
}
ScrollDims from @llui/components
export interface ScrollDims {
scrollTop: number
scrollLeft: number
scrollWidth: number
scrollHeight: number
clientWidth: number
clientHeight: number
}
SearchFieldInit from @llui/components
export interface SearchFieldInit {
value?: string
disabled?: boolean
}
SearchFieldParts from @llui/components
export interface SearchFieldParts {
root: {
role: 'search'
'data-scope': 'search-field'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
}
label: {
'data-scope': 'search-field'
'data-part': 'label'
}
input: {
type: 'search'
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'search-field'
'data-part': 'input'
onInput: (e: Event) => void
onKeyDown: (e: KeyboardEvent) => void
}
clearTrigger: {
type: 'button'
'aria-label': string
hidden: ReadSignal<boolean>
tabindex: -1
'data-scope': 'search-field'
'data-part': 'clear-trigger'
onClick: (e: MouseEvent) => void
}
}
SearchFieldState from @llui/components
Search field — a role="search" landmark wrapping a type="search" input
with a clear button. Escape clears the field (when non-empty), Enter submits
the current value.
Debounced live search is intentionally NOT built into this machine. Keep it
consumer-side: debounce the setValue message (or a derived "search" effect)
with debounce from @llui/effects so the search trigger fires once the user
pauses typing, rather than on every keystroke.
export interface SearchFieldState {
value: string
disabled: boolean
}
SelectGroup from @llui/components
A labelled section of options (rendered like <optgroup>). items are the
option VALUES belonging to the group, in visual order. Groups are an
additive, parallel structure: the flat items list always remains the
source of truth for navigation/highlight indices and item ids — when
groups is provided without an explicit items list, init derives the
flat list by concatenating each group's items in order. A plain flat
string[] (no groups) keeps working unchanged. Group LABELS are never
options, so highlight/typeahead/arrow navigation skips over them for free.
export interface SelectGroup {
id: string
label: string
items: string[]
}
SelectGroupParts from @llui/components
export interface SelectGroupParts {
group: {
role: 'group'
/** The group label's id, or absent when `hasLabel: false`. */
'aria-labelledby': string | undefined
'data-scope': 'select'
'data-part': 'group'
'data-group': string
}
groupLabel: {
id: string
'aria-hidden': 'true'
'data-scope': 'select'
'data-part': 'group-label'
'data-group': string
}
}
SelectInit from @llui/components
export interface SelectInit {
value?: string[]
items?: string[]
/** Optional labelled sections. When provided without `items`, the flat
* `items` list is derived by concatenating each group's `items` in order. */
groups?: SelectGroup[]
disabledItems?: string[]
selectionMode?: SelectionMode
disabled?: boolean
required?: boolean
}
SelectItemParts from @llui/components
export interface SelectItemParts {
item: {
role: 'option'
id: string
'aria-selected': ReadSignal<boolean>
'aria-disabled': ReadSignal<'true' | undefined>
'data-state': ReadSignal<'selected' | undefined>
'data-highlighted': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'select'
'data-part': 'item'
'data-value': string
/** The option's live position in the flat item list (reactive — reused rows
* never report a stale index). */
'data-index': ReadSignal<string>
onClick: (e: MouseEvent) => void
onPointerMove: (e: PointerEvent) => void
}
}
SelectOverlayOptions from @llui/components
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
state: ReadSignal<SelectState>
send: Send<SelectMsg>
parts: SelectParts
content: () => Renderable
/**
* Optional enter/leave transition for the select listbox (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, giving the raw-`open` select an exit
* animation for free. Omitted ⇒ the listbox closes synchronously as before.
*
* @example select.overlay({ state, send, parts, content, transition: fade({ duration: 120 }) })
*/
transition?: TransitionOptions
placement?: Placement
offset?: number
flip?: boolean
shift?: boolean
/** Match content width to trigger width (default: true). */
sameWidth?: boolean
target?: string | HTMLElement
}
SelectParts from @llui/components
export interface SelectParts {
trigger: {
type: 'button'
role: 'combobox'
'aria-haspopup': 'listbox'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
'aria-activedescendant': ReadSignal<string | undefined>
'aria-disabled': ReadSignal<'true' | undefined>
'aria-required': ReadSignal<'true' | undefined>
id: string
disabled: ReadSignal<boolean>
'data-state': ReadSignal<'open' | 'closed'>
/** Present while the trigger is showing the PLACEHOLDER rather than a
* value. `valueText` already falls back to the placeholder string, but a
* string is not something CSS can branch on, so without this the
* placeholder renders at full foreground weight and reads as a real
* selection. This is the attribute every shadcn Select greys it from. */
'data-placeholder': ReadSignal<'' | undefined>
'data-scope': 'select'
'data-part': 'trigger'
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
positioner: {
'data-scope': 'select'
'data-part': 'positioner'
style: string
}
content: {
role: 'listbox'
id: string
'aria-multiselectable': ReadSignal<'true' | undefined>
'aria-labelledby': string
tabindex: -1
'data-state': ReadSignal<'open' | 'closed'>
'data-scope': 'select'
'data-part': 'content'
onKeyDown: (e: KeyboardEvent) => void
}
hiddenSelect: {
'aria-hidden': 'true'
tabindex: -1
style: string
/** Native form field name, or `undefined` when `name` was not supplied. */
name: string | undefined
disabled: ReadSignal<boolean>
multiple: ReadSignal<boolean>
required: ReadSignal<boolean>
'data-scope': 'select'
'data-part': 'hidden-select'
}
/** An `<option>` for the hidden native `<select>`. Render one per item inside
* `hiddenSelect` so the browser submits the selection under the form `name`. */
hiddenOption: (value: string) => {
value: string
selected: ReadSignal<boolean>
'data-scope': 'select'
'data-part': 'hidden-option'
}
/** Build the parts for an option by VALUE. The optional `index` is accepted
* for call-site convenience only — it is NOT used for identity (highlight,
* selection and ids are all value-keyed), so a reused row is never stale. */
item: (value: string, index?: number) => SelectItemParts
/** Parts for a labelled option group (`<optgroup>`-style section). Pass the
* group id; render the section element with `group` and its label element
* (referenced by `aria-labelledby`) with `groupLabel`. Group labels are not
* options, so navigation skips them automatically. */
/**
* A labelled group's parts. The group names its `label` part through
* `aria-labelledby`; a consumer that renders the group WITHOUT that label
* passes `{ hasLabel: false }` so it never names an element that does not
* exist (an unlabelled `role="group"` is valid; a broken idref is not — the
* `dialog` `hasDescription` rule, #268). Default: true.
*/
group: (id: string, options?: { readonly hasLabel?: boolean }) => SelectGroupParts
/** Selected value(s) — use for rendering the trigger label. */
valueText: ReadSignal<string>
}
SelectState from @llui/components
export interface SelectState {
open: boolean
value: string[]
items: string[]
groups: SelectGroup[]
disabledItems: string[]
selectionMode: SelectionMode
/** The highlighted option's VALUE (not its index). Value-based identity keeps
* the highlight pinned to the right option when the list is filtered or
* reordered and rows are reused (value-keyed `each`). */
highlightedValue: string | null
disabled: boolean
required: boolean
typeahead: string
typeaheadExpiresAt: number
}
SignaturePadInit from @llui/components
export interface SignaturePadInit {
strokes?: Stroke[]
disabled?: boolean
readonly?: boolean
}
SignaturePadParts from @llui/components
export interface SignaturePadParts {
root: {
role: 'application'
'aria-label': string
'data-scope': 'signature-pad'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
'data-readonly': ReadSignal<'' | undefined>
'data-drawing': ReadSignal<'' | undefined>
/** Present while nothing has been drawn — the placeholder hook. */
'data-empty': ReadSignal<'' | undefined>
}
control: {
'data-scope': 'signature-pad'
'data-part': 'control'
}
clearTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'signature-pad'
'data-part': 'clear-trigger'
onClick: (e: MouseEvent) => void
}
undoTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'signature-pad'
'data-part': 'undo-trigger'
onClick: (e: MouseEvent) => void
}
guide: {
'data-scope': 'signature-pad'
'data-part': 'guide'
'aria-hidden': 'true'
}
hiddenInput: {
type: 'hidden'
value: ReadSignal<string>
name?: string
'data-scope': 'signature-pad'
'data-part': 'hidden-input'
}
}
SignaturePadPoint from @llui/components
Signature pad — capture free-form strokes on a canvas. The state machine tracks strokes as arrays of points; the view renders them onto a
Pointer event wiring in the view layer:
onPointerDown: (e) => { canvas.setPointerCapture(e.pointerId) send({ type: 'strokeStart', x: e.offsetX, y: e.offsetY }) } onPointerMove: (e) => { if (state.drawing) send({ type: 'strokePoint', x: e.offsetX, y: e.offsetY }) } onPointerUp: () => send({ type: 'strokeEnd' })
export interface Point {
x: number
y: number
/** Pressure 0..1 (optional; from PointerEvent.pressure). */
pressure?: number
}
SignaturePadState from @llui/components
export interface SignaturePadState {
strokes: Stroke[]
/** Stroke currently being drawn, or null. */
current: Stroke | null
drawing: boolean
disabled: boolean
readonly: boolean
/**
* The strokes the last `clear` removed, until the next edit — what makes a
* destructive clear undoable (`undo` right after `clear` restores them).
* `null` when there is nothing to restore (#266).
*/
cleared: Stroke[] | null
}
SliderInit from @llui/components
export interface SliderInit {
value?: number[]
min?: number
max?: number
step?: number
disabled?: boolean
orientation?: Orientation
minStepsBetweenThumbs?: number
dir?: 'ltr' | 'rtl'
}
SliderParts from @llui/components
export interface SliderParts {
root: {
'data-scope': 'slider'
'data-part': 'root'
'data-orientation': ReadSignal<Orientation>
'data-disabled': ReadSignal<'' | undefined>
}
control: {
'data-scope': 'slider'
'data-part': 'control'
'data-orientation': ReadSignal<Orientation>
onPointerDown: (e: PointerEvent) => void
}
track: {
'data-scope': 'slider'
'data-part': 'track'
'data-orientation': ReadSignal<Orientation>
}
range: {
'data-scope': 'slider'
'data-part': 'range'
'data-orientation': ReadSignal<Orientation>
style: ReadSignal<string>
}
thumb: (index: number) => SliderThumbParts
/** Current raw values — reactive convenience. */
value: ReadSignal<number[]>
}
SliderState from @llui/components
export interface SliderState {
/** One value per thumb. For a single-value slider, a one-element array. */
value: number[]
min: number
max: number
step: number
disabled: boolean
orientation: Orientation
/** Minimum gap enforced between adjacent thumbs (range slider). */
minStepsBetweenThumbs: number
/** Reading direction. Under 'rtl' horizontal arrow keys are flipped. */
dir: 'ltr' | 'rtl'
}
SliderThumbParts from @llui/components
export interface SliderThumbParts {
thumb: {
role: 'slider'
'aria-valuemin': ReadSignal<number>
'aria-valuemax': ReadSignal<number>
'aria-valuenow': ReadSignal<number>
'aria-orientation': ReadSignal<Orientation>
'aria-disabled': ReadSignal<'true' | undefined>
'data-orientation': ReadSignal<Orientation>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'slider'
'data-part': 'thumb'
'data-index': string
tabindex: ReadSignal<number>
onKeyDown: (e: KeyboardEvent) => void
style: ReadSignal<string>
}
}
SortableParts from @llui/components
export interface SortableParts {
root: {
'data-scope': 'sortable'
'data-part': 'root'
'data-container-id': string
'data-dragging': ReadSignal<'' | undefined>
onPointerMove: (e: PointerEvent) => void
onPointerUp: (e: PointerEvent) => void
onPointerCancel: (e: PointerEvent) => void
}
item: (
id: string,
index: number,
) => {
'data-scope': 'sortable'
'data-part': 'item'
'data-index': string
'data-id': string
'data-dragging': ReadSignal<'' | undefined>
'data-over': ReadSignal<'' | undefined>
'data-shift': ReadSignal<'up' | 'down' | undefined>
'style.transform': ReadSignal<string | undefined>
'style.zIndex': ReadSignal<string | undefined>
}
handle: (
id: string,
index: number,
) => {
'data-scope': 'sortable'
'data-part': 'handle'
role: 'button'
tabindex: 0
/** A toggle button: pressed while this handle's item is carried. */
'aria-pressed': ReadSignal<boolean>
'aria-label': string
/** The `instructions` part's id, or absent when `hasInstructions: false`. */
'aria-describedby': string | undefined
onPointerDown: (e: PointerEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
/**
* The polite live region announcing grab / move / drop / cancel. `text` is
* the region's CHILD (a Signal), not an attribute: spread the rest and render
* `text(text)` inside. Keep it visually hidden but rendered (`sr-only`) — a
* `display: none` region is never announced. Only the ORIGIN container's
* region speaks, so connects that share one state never announce twice.
*/
liveRegion: {
role: 'status'
'aria-live': 'polite'
'aria-atomic': 'true'
'data-scope': 'sortable'
'data-part': 'live-region'
text: ReadSignal<string>
}
/**
* The keyboard instructions every handle's `aria-describedby` points at.
* `hidden`: a directly referenced hidden element still supplies the
* description, and stays out of the reading order. `text` is its CHILD.
*/
instructions: {
id: string
hidden: true
'data-scope': 'sortable'
'data-part': 'instructions'
text: string
}
}
SortableState from @llui/components
export interface SortableState {
dragging: DragState | null
/** The latest announcement; `null` before any drag and after a cross-container drop. */
announcement: SortableAnnouncement | null
}
SparklineBand from @llui/components
A reference band. Both bounds are UNBOUNDED-CAPABLE in the sense of
finiteBound (#177): "no bound on this side" is spelled by OMITTING the key,
never by undefined, null or ±Infinity — only the omission survives a
JSON round trip key-for-key, and this lives in state.
The three readings are not a convenience, they are the whole point: both bounds shade BETWEEN them, a high alone shades everything BELOW it, and a low alone shades everything ABOVE it. "Acceptable is under 140" and "acceptable is between 90 and 120" are different statements and must not both come out as a stripe.
export interface SparklineBand {
low?: number
high?: number
}
SparklineBandGeometry from @llui/components
export interface SparklineBandGeometry {
kind: SparklineBandKind
low: number | null
high: number | null
d: string
}
SparklineCalendar from @llui/components
The serializable subset of {@link CalendarStepOptions} this state carries.
export interface SparklineCalendar {
offsetMinutes: number
weekStartsOn: number
maxTicks: number
/** Force a rung instead of choosing one from the span. */
unit?: CalendarUnit
}
SparklineConnectOptions from @llui/components
export interface SparklineConnectOptions {
/** Base id; the title and description ids derive from it. */
id: string
/** Override the composed accessible name entirely. */
label?: string
/** Longer description, announced with the name. Defaults to empty. */
description?: string
/**
* Whether the consumer renders the `desc` part (default: true). When false,
* the svg's `aria-labelledby` names only the title, never an element that
* does not exist (the `dialog` `hasDescription` rule, #268). The TITLE is
* the image's name — required for `role="img"` — and is always referenced.
*/
hasDescription?: boolean
}
SparklineDot from @llui/components
export interface SparklineDot {
index: number
at: number
value: number
x: number
y: number
tone: SparklineTone
/** True for the most recent drawn point — the reading the row is about. */
last: boolean
/** True for the point under the cursor. Follows `chart`: the cursor is part
* of the derived picture, so one signal per row carries everything a dot
* renders from and nothing has to combine two signals at the call site. */
active: boolean
key: string
}
SparklineGeometry from @llui/components
export interface SparklineGeometry {
frame: Frame
time: Domain
value: Domain
/** The points actually drawn: finite, sorted, and after trimming. */
points: SparklinePoint[]
trimmed: number
/** The trend line. Empty string when there is nothing to draw. */
path: string
band: SparklineBandGeometry | null
ticks: SparklineTick[]
dots: SparklineDot[]
spans: SparklineSpan[]
now: SparklineNow
summary: SparklineSummary
/** Built-in English composition of {@link summary}. `connect` replaces it
* with the locale's phrasing. */
label: string
rows: SparklineRow[]
}
SparklineGeometryOptions from @llui/components
export interface SparklineGeometryOptions {
band?: SparklineBand
now?: number
min?: number
max?: number
width?: number
height?: number
padding?: Partial<SparklinePadding>
curve?: Curve
trim?: SparklineTrim | null
track?: SparklineTrack | null
calendar?: Partial<SparklineCalendar>
/** Which drawn point the cursor is on. Indexes the DRAWN list — after
* non-finite samples are dropped and after trimming — so it is the same
* index `locateIndex` returns. */
activeIndex?: number | null
}
SparklineInit from @llui/components
export interface SparklineInit {
points?: readonly SparklinePoint[]
band?: SparklineBand
now?: number
min?: number
max?: number
width?: number
height?: number
padding?: Partial<SparklinePadding>
curve?: Curve
/** `true` for the documented defaults, an object to tune them, absent for
* OFF — trimming discards data, so it is never on by accident. */
trim?: boolean | Partial<SparklineTrim>
track?: SparklineTrack | null
calendar?: Partial<SparklineCalendar>
}
SparklineNow from @llui/components
export interface SparklineNow {
at: number
x: number
d: string
/** True when the right edge is later than the last reading — the series
* trails off. */
stale: boolean
}
SparklinePadding from @llui/components
export interface SparklinePadding {
top: number
right: number
bottom: number
left: number
}
SparklineParts from @llui/components
export interface SparklineParts {
root: {
'data-scope': 'sparkline'
'data-part': 'root'
'data-stale': ReadSignal<'' | undefined>
'data-active': ReadSignal<'' | undefined>
}
/**
* The `<svg>`. `role="img"` named through its own `<title>`/`<desc>`; the
* numbers live in {@link SparklineParts.table}, because an SVG polyline is
* otherwise silent and the WAI-ARIA graphics roles are not carried well
* enough to be the only route to the data.
*/
svg: {
'data-scope': 'sparkline'
'data-part': 'svg'
role: 'img'
'aria-labelledby': string
viewBox: ReadSignal<string>
tabindex: 0
onKeyDown: (e: KeyboardEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
onBlur: (e: FocusEvent) => void
}
title: { id: string; 'data-scope': 'sparkline'; 'data-part': 'title' }
desc: { id: string; 'data-scope': 'sparkline'; 'data-part': 'desc' }
/** The visually-hidden `<table>` fallback. Render it with {@link rows}. */
table: {
'data-scope': 'sparkline'
'data-part': 'table'
'aria-label': ReadSignal<string>
}
/** The reference band. Stays MOUNTED and hides itself, so a band appearing or
* disappearing does not rebuild the layer. */
band: {
'data-scope': 'sparkline'
'data-part': 'band'
'data-band': ReadSignal<'between' | 'below' | 'above' | undefined>
d: ReadSignal<string>
hidden: ReadSignal<boolean>
}
line: { 'data-scope': 'sparkline'; 'data-part': 'line'; d: ReadSignal<string> }
/** The right edge. `data-stale` is set when it is later than the last
* reading. */
now: {
'data-scope': 'sparkline'
'data-part': 'now'
'data-stale': ReadSignal<'' | undefined>
d: ReadSignal<string>
}
/** A `<g>` stacking layer. Static — spread it on each layer group. */
layer: { 'data-scope': 'sparkline'; 'data-part': 'layer' }
/**
* Per-row attribute bags. Each takes the ROW SIGNAL an `each` hands its
* render function, not a snapshot value, and every geometry-derived
* attribute comes back as a Signal.
*
* That is not decoration. A keyed `each` REUSES a row whose key is unchanged,
* so a dot whose key is `index:instant` survives a change of time domain —
* and a static `cx` read at build time would then be frozen at the position
* the point had under the OLD domain, with the key giving no hint that
* anything moved. Taking the row signal is what makes the reuse safe.
*/
tickProps: (tick: Signal<SparklineTick>) => {
'data-scope': 'sparkline'
'data-part': 'grid'
'data-unit': ReadSignal<'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'>
d: ReadSignal<string>
}
dotProps: (dot: Signal<SparklineDot>) => {
'data-scope': 'sparkline'
'data-part': 'dot'
'data-tone': ReadSignal<'below' | 'in' | 'above' | 'none'>
'data-last': ReadSignal<'' | undefined>
'data-active': ReadSignal<'' | undefined>
cx: ReadSignal<number>
cy: ReadSignal<number>
}
spanProps: (span: Signal<SparklineSpan>) => {
'data-scope': 'sparkline'
'data-part': 'span'
'data-grain': ReadSignal<string>
d: ReadSignal<string>
}
/** Tooltip ATTRIBUTES — spreadable, with its own reactive `hidden`. */
tooltip: {
'data-scope': 'sparkline'
'data-part': 'tooltip'
role: 'status'
'aria-live': 'polite'
hidden: ReadSignal<boolean>
style: ReadSignal<string>
}
ticks: ReadSignal<SparklineTick[]>
dots: ReadSignal<SparklineDot[]>
spans: ReadSignal<SparklineSpan[]>
rows: ReadSignal<SparklineRow[]>
/** The dot under the cursor, or `null`. */
activeDot: ReadSignal<SparklineDot | null>
/** The composed accessible name — the locale's phrasing of
* {@link SparklineGeometry.summary}, or `opts.label` when given. */
label: ReadSignal<string>
summary: ReadSignal<SparklineSummary>
}
SparklinePoint from @llui/components
Sparkline — "this value, over time, against a reference band", at the size of a table cell.
Why this is not chart
chart is a plotting machine: ChartState, a projection seam, a legend, a
keyboard cursor over CATEGORIES, and numeric-nice value ticks. A sparkline is
a pure function of its points — dozens appear in one table, and the only
interaction any of them has is a hover readout. So the load-bearing export
here is {@link sparklineGeometry}, which takes points and options and returns
path strings; it needs no signal, no send, no mount and no state.
init/update/connect exist BESIDE it, not instead of it, and add exactly
one fact: which point the cursor is on. A consumer rendering fifty trends in
a table calls the pure function fifty times and pays for nothing else; one
that wants a tooltip opts into the machine for that column. The stateless
middle ground works too — connect(constant(state), noSend, { id }) gives
the full part bag with the cursor pinned wherever the state says.
What it reuses
The geometry is utils/scale.ts + utils/path.ts + utils/projection.ts,
the same three files chart is built from, so every coordinate goes through
fmt and a path recomputed from unchanged data is BYTE-IDENTICAL — the
reconciler commits on output-equality, and a sparkline in a table is exactly
the place where a spuriously-changing path string costs the most.
Two things it does NOT reuse, each for a stated reason:
valueDomain, because it always includes zero. That is right for a bar, whose LENGTH states the magnitude and therefore lies if the baseline is not zero; it is wrong for a trend line, where the shape is the message. A series reading 118–142 against a 90–120 band has to fill the box, not sit as a flat smear across the top of a 0–142 axis.ticks/niceDomain, because they are NUMERIC. Seeutils/calendar-ticks.ts: rounding an epoch to a nice number never lands on the 1st of March.
There is no coord and no polar projection. A sparkline is inline text
furniture; a round one is a different picture, and chart already draws it.
export interface SparklinePoint {
/** Instant, in epoch milliseconds. */
at: number
value: number
/**
* Sampling resolution of this reading — "spot", "session", "daily", whatever
* the domain calls it. Free-form and never interpreted: consecutive points
* sharing a tag become one segment of the granularity track, so a line whose
* sampling changes SAYS so instead of implying uniform data.
*/
grain?: string
}
SparklineRow from @llui/components
One row of the visually-hidden <table> fallback.
export interface SparklineRow {
at: number
/** `YYYY-MM-DD` in the configured offset's calendar. Locale-neutral by
* design: the table is a data fallback, not a formatted report. */
day: string
value: number
tone: SparklineTone
grain: string | null
}
SparklineSpan from @llui/components
export interface SparklineSpan {
grain: string
/** First and last instant the segment covers. */
from: number
to: number
x0: number
x1: number
d: string
key: string
}
SparklineState from @llui/components
export interface SparklineState {
points: SparklinePoint[]
/** Omit a side to leave it unbounded — see {@link SparklineBand}. */
band: SparklineBand
/**
* The instant the RIGHT EDGE denotes. A stale series then visibly trails off
* instead of looking current, which is the difference between "the last
* reading was fine" and "the last reading was fine, eight months ago".
*
* UNBOUNDED-CAPABLE: omit it and the edge is the last reading's own instant.
* It is deliberately NOT defaulted to `Date.now()` anywhere in this module —
* geometry has to be a pure function of state or an SSR render and the first
* client render disagree, and `Date.now()` would additionally make the path
* string change on every recomputation, which is the one thing `fmt` exists
* to prevent. A consumer that wants "now" puts a clock in its own state.
*/
now?: number
/** Value-axis bounds. UNBOUNDED-CAPABLE (#177): omit to derive. */
min?: number
max?: number
width: number
height: number
padding: SparklinePadding
curve: Curve
/** `null` = no trimming. See {@link SparklineTrim}. */
trim: SparklineTrim | null
/** `null` = no granularity track. */
track: SparklineTrack | null
/** Fixed offset from UTC that calendar boundaries are measured in, and the
* ladder's ceiling. See `utils/calendar-ticks.ts` on why it is an offset and
* not a zone. */
calendar: SparklineCalendar
/** Index of the point under the pointer or keyboard cursor. */
activeIndex: number | null
}
SparklineSummary from @llui/components
The facts a label is composed from. Separated from the label itself so the
same numbers can be phrased by the locale in connect and by the built-in
English template in the pure path.
export interface SparklineSummary {
count: number
/** `YYYY-MM-DD` of the first and last drawn reading; empty strings when there
* is no data. */
from: string
to: string
trimmed: number
stale: boolean
}
SparklineTick from @llui/components
export interface SparklineTick {
at: number
unit: CalendarUnit
x: number
/** The vertical gridline, in user units. */
d: string
/** Stable key for a keyed `each`. */
key: string
}
SparklineTrack from @llui/components
The granularity track: a thin bar under the plot, in the bottom padding.
export interface SparklineTrack {
height: number
/** Distance between the bottom of the plot frame and the top of the track. */
gap: number
}
SparklineTrim from @llui/components
Leading-outlier trimming. A HEURISTIC that DISCARDS REAL DATA, so it is off
unless asked for: trim: null is the default, and a chart that silently
drops a reading is worse than a squashed one.
The rule: while more than floor points remain, compare the FIRST gap
against the MEDIAN of the gaps after it, and drop the leading point when the
first gap is more than factor times that median. Median rather than mean so
a second ancient reading cannot inflate the yardstick it is being measured
against; factor > 1 is required, and a median of zero (every remaining
reading at the same instant) stops the walk rather than eating the series.
trimmed in the geometry reports how many points went, so a consumer can say
so on the page.
export interface SparklineTrim {
/** How many times the median gap the leading gap must exceed. Default 4. */
factor: number
/** Never trim below this many points. Default 3, floored at 2 — one point is
* not a trend and cannot be drawn as a line. */
floor: number
}
SplitterInit from @llui/components
export interface SplitterInit {
position?: number
min?: number
max?: number
step?: number
orientation?: Orientation
disabled?: boolean
dir?: 'ltr' | 'rtl'
}
SplitterParts from @llui/components
export interface SplitterParts {
/**
* A layout container: its panels hold the CONSUMER's content. It carries no
* disabled state — the resize trigger is the control and publishes its own
* (`aria-disabled`, out of the tab order). A root `data-disabled` made every
* disabled-state stylesheet dim the panels with it, taking ordinary text to
* 3.69:1 (#268 audit).
*/
root: {
'data-scope': 'splitter'
'data-part': 'root'
'data-orientation': ReadSignal<Orientation>
'data-dragging': ReadSignal<'' | undefined>
}
primaryPanel: {
'data-scope': 'splitter'
'data-part': 'primary-panel'
style: ReadSignal<string>
}
secondaryPanel: {
'data-scope': 'splitter'
'data-part': 'secondary-panel'
style: ReadSignal<string>
}
resizeTrigger: {
role: 'separator'
'aria-orientation': ReadSignal<Orientation>
'aria-valuemin': ReadSignal<number>
'aria-valuemax': ReadSignal<number>
'aria-valuenow': ReadSignal<number>
'aria-disabled': ReadSignal<'true' | undefined>
'data-scope': 'splitter'
'data-part': 'resize-trigger'
'data-orientation': ReadSignal<Orientation>
tabindex: ReadSignal<number>
onKeyDown: (e: KeyboardEvent) => void
onPointerDown: (e: PointerEvent) => void
}
}
SplitterState from @llui/components
export interface SplitterState {
position: number
min: number
max: number
step: number
orientation: Orientation
disabled: boolean
dragging: boolean
/** Reading direction. Under 'rtl' horizontal arrow keys are flipped. */
dir: 'ltr' | 'rtl'
}
Srgb from @llui/components
sRGB, gamma-encoded, 0–1 per channel. May fall outside [0,1] as an intermediate value (e.g. converted from an out-of-gamut OKLCH) — callers that need an in-gamut result go through {@link gamutMapOklchToSrgb}.
export interface Srgb {
r: number
g: number
b: number
}
StepsInit from @llui/components
export interface StepsInit {
current?: number
completed?: number[]
steps?: string[]
linear?: boolean
disabled?: boolean
}
StepsItemParts from @llui/components
export interface StepsItemParts {
item: {
'data-scope': 'steps'
'data-part': 'item'
'data-status': ReadSignal<StepStatus>
'data-index': string
'aria-current': ReadSignal<'step' | undefined>
}
trigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'steps'
'data-part': 'trigger'
'data-status': ReadSignal<StepStatus>
onClick: (e: MouseEvent) => void
}
separator: {
'data-scope': 'steps'
'data-part': 'separator'
'data-status': ReadSignal<StepStatus>
'aria-hidden': 'true'
}
}
StepsParts from @llui/components
export interface StepsParts {
root: {
role: 'group'
'aria-label': string
'data-scope': 'steps'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
}
nextTrigger: {
type: 'button'
disabled: ReadSignal<boolean>
'data-scope': 'steps'
'data-part': 'next-trigger'
onClick: (e: MouseEvent) => void
}
prevTrigger: {
type: 'button'
disabled: ReadSignal<boolean>
'data-scope': 'steps'
'data-part': 'prev-trigger'
onClick: (e: MouseEvent) => void
}
item: (index: number) => StepsItemParts
}
StepsState from @llui/components
export interface StepsState {
current: number
completed: number[]
errors: number[]
steps: string[]
/** If linear, users cannot skip steps. */
linear: boolean
disabled: boolean
}
SwatchParts from @llui/components
export interface SwatchParts {
type: 'button'
'aria-label': string
'aria-pressed': ReadSignal<boolean>
'data-scope': 'color-picker'
'data-part': 'swatch'
'data-value': string
'data-state': ReadSignal<'selected' | undefined>
style: string
onClick: (e: MouseEvent) => void
}
SwitchInit from @llui/components
export interface SwitchInit {
checked?: boolean
disabled?: boolean
}
SwitchParts from @llui/components
export interface SwitchParts {
root: {
role: 'switch'
'aria-checked': ReadSignal<boolean>
'aria-disabled': ReadSignal<'true' | undefined>
'data-state': ReadSignal<'checked' | 'unchecked'>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'switch'
'data-part': 'root'
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
track: {
'data-state': ReadSignal<'checked' | 'unchecked'>
'data-scope': 'switch'
'data-part': 'track'
}
thumb: {
'data-state': ReadSignal<'checked' | 'unchecked'>
'data-scope': 'switch'
'data-part': 'thumb'
}
hiddenInput: {
type: 'checkbox'
role: 'switch'
'aria-hidden': 'true'
tabindex: -1
style: string
checked: ReadSignal<boolean>
disabled: ReadSignal<boolean>
'data-scope': 'switch'
'data-part': 'hidden-input'
}
}
SwitchState from @llui/components
Switch — two-state on/off control. Semantically like a checkbox but
visually a toggle track + thumb. Uses role="switch" for ARIA.
export interface SwitchState {
checked: boolean
disabled: boolean
}
TableCellCoord from @llui/components
export interface TableCellCoord {
/** Row index into `rows`, or {@link HEADER_ROW_INDEX} for the header row. */
rowIndex: number
colIndex: number
}
TableCellParts from @llui/components
export interface TableCellParts {
role: 'gridcell'
'aria-colindex': number
tabindex: ReadSignal<number>
'data-scope': 'table'
'data-part': 'cell'
/** 0-based row index — addresses the cell for roving DOM focus. Reactive
* for the same reason `TableRowParts`'s `aria-rowindex` is. */
'data-row-index': ReadSignal<number>
/** 0-based column index — addresses the cell for roving DOM focus. Columns
* do not reorder, so this stays a plain number. */
'data-col-index': number
'data-focused': ReadSignal<'' | undefined>
onFocus: (e: FocusEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
TableCheckboxParts from @llui/components
export interface TableCheckboxParts {
role: 'checkbox'
/**
* The checkbox's accessible name (localized; `Locale['table']`). A
* `role="checkbox"` span has no content to be named by, so without it
* assistive tech announced an unnamed checkbox in every row (#268).
*/
'aria-label': string
'aria-checked': ReadSignal<'true' | 'false' | 'mixed'>
'data-scope': 'table'
'data-part': 'select-all' | 'row-checkbox'
'data-state': ReadSignal<'checked' | 'unchecked' | 'indeterminate'>
/** Always `-1`: a `role="grid"` has exactly ONE tab stop, the roving cell. */
tabindex: -1
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
TableColumn from @llui/components
export interface TableColumn {
/** Opaque column id. */
id: string
/** Whether this column participates in sorting. Defaults to false. */
sortable?: boolean
}
TableColumnHeaderParts from @llui/components
export interface TableColumnHeaderParts {
role: 'columnheader'
id: string
'aria-sort': ReadSignal<'ascending' | 'descending' | 'none' | undefined>
/**
* Roving tab stop. The header row participates in the grid's single-tab-stop
* sequence, because it hosts controls — the sort toggle on every sortable
* column, and the select-all checkbox — that are otherwise unreachable by
* keyboard.
*/
tabindex: ReadSignal<number>
'data-scope': 'table'
'data-part': 'column-header'
'data-column': string
/** Always {@link HEADER_ROW_INDEX} — addresses the header for roving DOM focus. */
'data-row-index': typeof HEADER_ROW_INDEX
/** 0-based column index (`-1` for a column not in `columns`). */
'data-col-index': ReadSignal<number>
'data-focused': ReadSignal<'' | undefined>
'data-sortable': ReadSignal<'' | undefined>
'data-sort': ReadSignal<SortDirection | undefined>
onFocus: (e: FocusEvent) => void
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
TableConnectOptions from @llui/components
export interface ConnectOptions {
id: string
/** Presentation density published on the grid root for either skin to consume. */
density?: TableDensity
}
TableInit from @llui/components
export interface TableInit {
columns?: TableColumn[]
rows?: string[]
sort?: TableSort | null
selection?: string[]
selectionMode?: TableSelectionMode
focusedCell?: TableCellCoord | null
pageSize?: number
descFirst?: boolean
disabled?: boolean
}
TableParts from @llui/components
export interface TableParts {
/**
* Place the native table carrying {@link root} directly inside this owned
* scroll viewport. Wide grids then scroll locally without changing the
* table's native layout/ARIA semantics or overflowing the page.
*/
viewport: {
'data-scope': 'table'
'data-part': 'viewport'
}
root: {
role: 'grid'
id: string
'aria-multiselectable': ReadSignal<'true' | undefined>
'aria-rowcount': ReadSignal<number>
'aria-colcount': ReadSignal<number>
'aria-disabled': ReadSignal<'true' | undefined>
'data-scope': 'table'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
'data-density': TableDensity | undefined
}
columnHeader: (columnId: string) => TableColumnHeaderParts
/**
* `index` accepts a plain `number` OR a `ReadSignal<number>` (the row handle
* `each`/`virtualEach` passes its render callback) — a keyed row is REUSED
* (moved, not rebuilt) on reorder, so a plain number captured at build time
* would freeze `aria-rowindex` and the row's own `toggleRow`/`selectRange`
* dispatch at the row's ORIGINAL position forever. Pass the row's reactive
* index handle whenever rows can reorder (sorting, filtering); a plain
* number is still accepted for a table that never reorders.
*/
row: (id: string, index: Reactive<number>) => TableRowParts
/** `rowIndex` has the same `Reactive<number>` contract as {@link row}'s
* `index` — the column index does not reorder and stays a plain `number`. */
cell: (rowIndex: Reactive<number>, colIndex: number) => TableCellParts
/**
* The select-all checkbox, for the `columnheader` of `columnId`.
*
* The column id is a PARAMETER rather than a `connect()` option because the
* checkbox is unreachable by keyboard without it: it has no gridcell of its
* own, and every part inside a `role="grid"` except the one roving stop is
* `tabindex="-1"`, so its only keyboard route is Enter/Space on the roving
* header that hosts it — and the machine can only route that key if it knows
* which header. As an option it was forgettable, and forgetting it failed
* SILENTLY (no warning, no error; the key sorted the column or did nothing).
* As a required argument you cannot render the checkbox without answering the
* question, so the failure mode is gone at compile time.
*
* `columnId` must be a column in `state.columns` — that is what gives the
* header a colIndex to rove to. A column not in the list can never take the
* roving stop, and its header will not send `toggleAll` either.
*/
selectAllCheckbox: (columnId: string) => TableCheckboxParts
/** Same `Reactive<number>` contract as {@link row}'s `index`. */
rowCheckbox: (id: string, index: Reactive<number>) => TableCheckboxParts
}
TableRowParts from @llui/components
export interface TableRowParts {
role: 'row'
'aria-selected': ReadSignal<boolean | undefined>
/** Reactive: a row's DISPLAY position can change after sort/reorder without
* this row being rebuilt (`each` reuses rows by key), so the index this
* addresses must follow the row's live position rather than freeze at
* whatever it was when the row was first built. */
'aria-rowindex': ReadSignal<number>
'data-scope': 'table'
'data-part': 'row'
'data-row': string
'data-selected': ReadSignal<'' | undefined>
onClick: (e: MouseEvent) => void
}
TableSort from @llui/components
export interface TableSort {
columnId: string
direction: SortDirection
}
TableState from @llui/components
export interface TableState {
/** Column descriptors in display order. */
columns: TableColumn[]
/** Row IDs in display order. Row DATA stays in the consumer. */
rows: string[]
/** Active sort, or null when unsorted. */
sort: TableSort | null
/** Selected row IDs. */
selection: string[]
selectionMode: TableSelectionMode
/** Focused cell coordinate (header row excluded; rowIndex addresses `rows`). */
focusedCell: TableCellCoord | null
/** Index of the last row toggled — the anchor for shift-range selection. */
rangeAnchor: number | null
/** Rows moved per PageUp/PageDown. */
pageSize: number
/** When true, the sort cycle starts at desc instead of asc. */
descFirst: boolean
disabled: boolean
}
TabsInit from @llui/components
export interface TabsInit {
value?: string
items?: string[]
disabledItems?: string[]
orientation?: Orientation
activation?: Activation
loopFocus?: boolean
deselectable?: boolean
dir?: 'ltr' | 'rtl'
}
TabsItemParts from @llui/components
export interface TabsItemParts {
trigger: {
type: 'button'
role: 'tab'
'aria-selected': ReadSignal<boolean>
'aria-controls': string
'aria-disabled': ReadSignal<'true' | undefined>
id: string
'data-state': ReadSignal<'active' | 'inactive'>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'tabs'
'data-part': 'trigger'
'data-value': string
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
onFocus: (e: FocusEvent) => void
}
panel: {
role: 'tabpanel'
id: string
'aria-labelledby': string
tabindex: 0
hidden: ReadSignal<boolean>
'data-state': ReadSignal<'active' | 'inactive'>
'data-scope': 'tabs'
'data-part': 'panel'
'data-value': string
}
}
TabsParts from @llui/components
export interface TabsParts {
root: {
id: string
'data-scope': 'tabs'
'data-part': 'root'
'data-orientation': ReadSignal<Orientation>
}
/**
* A movable underline/highlight element. Position tracks the active
* trigger via CSS custom properties written by `watchTabIndicator()`:
* `--indicator-left`, `--indicator-top`, `--indicator-width`,
* `--indicator-height` — all in pixels.
* The consumer styles the indicator using these properties (e.g.
* `transform: translateX(var(--indicator-left))`).
*/
indicator: {
'data-scope': 'tabs'
'data-part': 'indicator'
'data-orientation': ReadSignal<Orientation>
}
list: {
role: 'tablist'
'aria-orientation': ReadSignal<Orientation>
'data-scope': 'tabs'
'data-part': 'list'
}
item: (value: string) => TabsItemParts
/** Place once anywhere in the same build to keep automatic direction live. */
directionSync: Mountable
}
TabsState from @llui/components
export interface TabsState {
value: string
items: string[]
disabledItems: string[]
orientation: Orientation
activation: Activation
/** The currently focused (but not necessarily active) tab. For manual mode. */
focused: string | null
/** Whether Arrow navigation wraps at the ends of the tab list. Default: true. */
loopFocus: boolean
/** Whether clicking the active tab deselects it (empty value). Default: false. */
deselectable: boolean
/** Reading direction. Under 'rtl', ArrowLeft/ArrowRight swap meaning. */
dir: 'ltr' | 'rtl'
dirSource: DirectionSource
}
TagItemParts from @llui/components
export interface TagItemParts {
root: {
tabindex: ReadSignal<number>
'data-scope': 'tags-input'
'data-part': 'tag'
'data-value': string
'data-index': string
'data-focused': ReadSignal<'' | undefined>
onKeyDown: (e: KeyboardEvent) => void
onFocus: (e: FocusEvent) => void
}
remove: {
type: 'button'
'aria-label': string
tabindex: -1
'data-scope': 'tags-input'
'data-part': 'tag-remove'
onClick: (e: MouseEvent) => void
}
}
TagsInputInit from @llui/components
export interface TagsInputInit {
value?: string[]
inputValue?: string
disabled?: boolean
max?: number
unique?: boolean
}
TagsInputParts from @llui/components
export interface TagsInputParts {
root: {
role: 'group'
'aria-disabled': ReadSignal<'true' | undefined>
'data-scope': 'tags-input'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
}
input: {
type: 'text'
autocomplete: 'off'
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'tags-input'
'data-part': 'input'
onInput: (e: Event) => void
onKeyDown: (e: KeyboardEvent) => void
onBlur: (e: FocusEvent) => void
}
tag: (value: string, index: number) => TagItemParts
clearTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'tags-input'
'data-part': 'clear-trigger'
onClick: (e: MouseEvent) => void
}
}
TagsInputState from @llui/components
Tags input — text input that creates chips (tags) on commit keys (Enter, comma, blur). Backspace on empty input removes the last tag. Each tag is focusable via arrow keys.
export interface TagsInputState {
value: string[]
inputValue: string
disabled: boolean
/** Maximum tag count. 0 = unlimited. */
max: number
/** Only allow unique values. */
unique: boolean
/** Currently-focused tag index, or null. */
focusedIndex: number | null
}
ThemeSwitchParts from @llui/components
export interface ThemeSwitchParts {
root: {
'data-scope': 'theme-switch'
'data-part': 'root'
role: 'group'
'aria-label': string
}
option: (theme: Theme) => {
type: 'button'
'data-scope': 'theme-switch'
'data-part': 'option'
'data-theme': Theme
'aria-pressed': ReadSignal<boolean>
'aria-label': string
onClick: (e: MouseEvent) => void
}
toggle: {
type: 'button'
'data-scope': 'theme-switch'
'data-part': 'toggle'
'data-theme': ReadSignal<Theme>
'aria-label': string
onClick: (e: MouseEvent) => void
}
}
ThemeSwitchState from @llui/components
export interface ThemeSwitchState {
theme: Theme
}
TickPlacement from @llui/components
Where an independent-axis tick label sits, and how to align it there.
export interface TickPlacement {
x: number
y: number
/** SVG `text-anchor`. */
anchor: 'start' | 'middle' | 'end'
/** SVG `dominant-baseline`. */
baseline: 'auto' | 'middle' | 'hanging'
}
TimePickerInit from @llui/components
export interface TimePickerInit {
value?: TimeValue
format?: TimeFormat
minuteStep?: number
secondStep?: number
showSeconds?: boolean
disabled?: boolean
}
TimePickerParts from @llui/components
export interface TimePickerParts {
root: {
role: 'group'
'aria-label': string
'data-scope': 'time-picker'
'data-part': 'root'
'data-format': ReadSignal<TimeFormat>
'data-disabled': ReadSignal<'' | undefined>
}
hoursInput: {
type: 'number'
role: 'spinbutton'
'aria-label': string
'aria-valuemin': ReadSignal<number>
'aria-valuemax': ReadSignal<number>
'aria-valuenow': ReadSignal<number>
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'time-picker'
'data-part': 'hours-input'
onInput: (e: Event) => void
onKeyDown: (e: KeyboardEvent) => void
}
minutesInput: {
type: 'number'
role: 'spinbutton'
'aria-label': string
'aria-valuemin': 0
'aria-valuemax': 59
'aria-valuenow': ReadSignal<number>
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'time-picker'
'data-part': 'minutes-input'
onInput: (e: Event) => void
onKeyDown: (e: KeyboardEvent) => void
}
periodTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'time-picker'
'data-part': 'period-trigger'
'data-period': ReadSignal<'AM' | 'PM'>
onClick: (e: MouseEvent) => void
hidden: ReadSignal<boolean>
}
}
TimePickerState from @llui/components
export interface TimePickerState {
value: TimeValue
format: TimeFormat
minuteStep: number
secondStep: number
showSeconds: boolean
disabled: boolean
}
TimerInit from @llui/components
export interface TimerInit {
direction?: Direction
targetMs?: number
elapsedMs?: number
}
TimerParts from @llui/components
export interface TimerParts {
root: {
'data-scope': 'timer'
'data-part': 'root'
'data-running': ReadSignal<'' | undefined>
'data-direction': ReadSignal<Direction>
/** Present once a countdown has reached its target (see `isComplete`). */
'data-complete': ReadSignal<'' | undefined>
}
display: {
role: 'timer'
'aria-live': 'off' | 'polite'
'data-scope': 'timer'
'data-part': 'display'
}
startTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'timer'
'data-part': 'start-trigger'
disabled: ReadSignal<boolean>
onClick: (e: MouseEvent) => void
}
pauseTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'timer'
'data-part': 'pause-trigger'
disabled: ReadSignal<boolean>
onClick: (e: MouseEvent) => void
}
resetTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'timer'
'data-part': 'reset-trigger'
onClick: (e: MouseEvent) => void
}
}
TimerState from @llui/components
export interface TimerState {
running: boolean
direction: Direction
/** Target in milliseconds for countdown (0 = no target, runs indefinitely). */
targetMs: number
/** Accumulated elapsed time, excluding the current running interval. */
elapsedMs: number
/** Timestamp when the current running interval started (null when paused). */
startedAt: number | null
}
TimeValue from @llui/components
export interface TimeValue {
hours: number
minutes: number
seconds: number
}
Toast from @llui/components
export interface Toast {
id: string
type: ToastType
title?: string
description?: string
/** ms until auto-dismiss. `null` = sticky (never auto-dismisses). */
duration: number | null
/** ms left before auto-dismiss. Counts down via `tick`. */
remainingMs: number
/** Whether the USER can dismiss the toast: `false` hides the close trigger
* (`closeTrigger.hidden`) and ignores its click. A `dismiss` message from
* code still removes it. Patchable. */
dismissable: boolean
/** The reasons currently holding the countdown paused, as a canonical set
* (unique, in {@link ToastPauseReason} order). Empty = running. Owned by the
* reducer: change it with `pause`/`resume`, read it with {@link isPaused}. */
pausedBy: ToastPauseReason[]
/** Optional per-toast politeness override; otherwise derived from `type`. */
ariaLive?: ToastPoliteness
/**
* Presence lifecycle for this toast (closed/opening/open/closing). Born
* `'open'`; a dismiss moves it to `'closing'` (when the toaster is animated)
* so it can play an exit animation before `animationEnd` removes it.
*/
status: PresenceStatus
}
ToasterInit from @llui/components
export interface ToasterInit {
max?: number
placement?: ToastPlacement
/** Play an exit animation on dismiss (toasts go to `'closing'` and stay
* mounted until `animationEnd`). Default false — instant removal. */
animated?: boolean
}
ToasterParts from @llui/components
export interface ToasterParts {
region: {
role: 'region'
'aria-label': string
tabindex: -1
'data-scope': 'toast'
'data-part': 'region'
'data-placement': ReadSignal<ToastPlacement>
}
/**
* Build the per-row part descriptors for one toast. Takes the row's
* `ReadSignal<Toast>` (e.g. the `item` from `each`) rather than a snapshot, so
* consumers don't `.peek()` in a reactive slot (which the signal compiler
* rejects). Only `id` is immutable for a toast's lifetime — created then
* dismissed, never structurally replaced — so this reads `id` once
* internally to build id-derived wiring (the keyed `each` rebuilds the row
* if `id` changes); every other field (`type`, `ariaLive`, `status`, …) is
* bound reactively so an `update` patch renders wherever it appears.
*/
toast: (toast: ReadSignal<Toast>) => ToastItemParts
/**
* Reactive fraction (in [0,1]) of the countdown remaining for the toast with
* `id` — for a countdown progress bar. Sticky toasts report 1; a dismissed /
* missing toast reports 0.
*/
progress: (id: string) => ReadSignal<number>
/**
* Reactive presence: whether the toast with `id` is still in the queue (i.e.
* should be mounted). Stays true through `'closing'` so the exit animation can
* play; flips false once `animationEnd` removes it. The keyed `each` over
* `toasts` already handles the actual mount/unmount — this is for consumers
* coordinating other elements off a single toast's lifecycle.
*/
isPresent: (id: string) => ReadSignal<boolean>
}
ToasterState from @llui/components
export interface ToasterState {
toasts: Toast[]
max: number
placement: ToastPlacement
/**
* Whether dismissed toasts play an exit animation. When true a dismiss moves
* the toast to `'closing'` (kept mounted) until `animationEnd` removes it;
* when false (default) dismiss removes synchronously — today's behavior, no
* wait for an animationend that won't fire.
*/
animated: boolean
}
ToastItemParts from @llui/components
export interface ToastItemParts {
root: {
/**
* Reactive: derived from the toast's current `type`/`ariaLive` (see
* {@link politeness}), never frozen at mount — an `update` patching
* either is visible here.
*/
role: ReadSignal<'status' | 'alert'>
'aria-atomic': 'true'
/** Reactive — see `role` above. */
'aria-live': ReadSignal<ToastPoliteness>
id: string
'data-scope': 'toast'
'data-part': 'root'
/** Reactive: an `update` patching `type` (e.g. a promise toast moving
* loading → success) is visible here, not frozen at mount. */
'data-type': ReadSignal<ToastType>
'data-id': string
/** Reactive presence status (closed/opening/open/closing) for CSS-driven
* enter/exit animations. */
'data-state': ReadSignal<PresenceStatus>
onPointerEnter: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
/** Bubbling — a plain (non-bubbling) `onFocus`/`onBlur` here would never
* fire for the only naturally focusable descendant, the close button,
* so pause-on-focus would be dead: nothing but the row itself receives
* `focus`/`blur` directly, and it carries no `tabindex`. */
onFocusIn: (e: FocusEvent) => void
onFocusOut: (e: FocusEvent) => void
/** Advance past the exit animation: a `'closing'` toast is removed from the
* queue once its animation/transition ends. */
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
title: {
id: string
'data-scope': 'toast'
'data-part': 'title'
}
description: {
id: string
'data-scope': 'toast'
'data-part': 'description'
}
closeTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'toast'
'data-part': 'close-trigger'
/** Reactive: true while the toast is not `dismissable` — the button leaves
* the accessibility tree and the tab order. Hiding it while it has focus
* is safe for the `'focus'` pause reason: the browser's focus fixup fires
* `blur`/`focusout` on the row (verified in Chromium), which releases it. */
hidden: ReadSignal<boolean>
/** Dismisses the toast; ignored while it is not `dismissable`. */
onClick: (e: MouseEvent) => void
}
}
TocEntry from @llui/components
Table of contents — a navigation list that tracks which heading is
currently visible in the main scroll area and highlights it. The
state machine tracks the flat list of heading ids and the currently
active one; the view layer installs an IntersectionObserver in
onMount to detect which heading is on screen and dispatches
setActive.
Typical setup in onMount:
const headings = document.querySelectorAll('h2[id], h3[id]') const io = new IntersectionObserver((entries) => { for (const e of entries) { if (e.isIntersecting) send({ type: 'setActive', id: e.target.id }) } }, { rootMargin: '0px 0px -80% 0px' }) headings.forEach((h) => io.observe(h)) return () => io.disconnect()
export interface TocEntry {
id: string
label: string
/** Nesting level (1 = top-level). */
level: number
}
TocInit from @llui/components
export interface TocInit {
items?: TocEntry[]
activeId?: string | null
expanded?: string[]
}
TocParts from @llui/components
export interface TocParts {
root: {
role: 'navigation'
'aria-label': string
'data-scope': 'toc'
'data-part': 'root'
}
list: {
role: 'list'
'data-scope': 'toc'
'data-part': 'list'
}
item: (entry: TocEntry) => TocItemParts
}
TocState from @llui/components
export interface TocState {
items: TocEntry[]
activeId: string | null
/** Ids of entries the user has manually expanded (for collapsible sub-levels). */
expanded: string[]
}
ToggleGroupInit from @llui/components
export interface ToggleGroupInit {
value?: string[]
type?: 'single' | 'multiple'
items?: string[]
disabledItems?: string[]
disabled?: boolean
orientation?: Orientation
deselectable?: boolean
focused?: string | null
loopFocus?: boolean
dir?: 'ltr' | 'rtl'
}
ToggleGroupItemParts from @llui/components
export interface ToggleGroupItemParts {
root: {
type: 'button'
role: 'button'
'aria-pressed': ReadSignal<boolean>
'aria-disabled': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
'data-state': ReadSignal<'on' | 'off'>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'toggle-group'
'data-part': 'item'
'data-value': string
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
onFocus: (e: FocusEvent) => void
}
}
ToggleGroupParts from @llui/components
export interface ToggleGroupParts {
root: {
role: 'group'
'aria-disabled': ReadSignal<'true' | undefined>
'data-scope': 'toggle-group'
'data-part': 'root'
'data-orientation': ReadSignal<Orientation>
'data-disabled': ReadSignal<'' | undefined>
}
item: (value: string) => ToggleGroupItemParts
}
ToggleGroupState from @llui/components
export interface ToggleGroupState {
value: string[]
type: 'single' | 'multiple'
items: string[]
disabledItems: string[]
disabled: boolean
orientation: Orientation
/** In single mode, whether the active item can be deselected. */
deselectable: boolean
/** The currently roving-focused item (independent of the pressed value). */
focused: string | null
/** Whether Arrow navigation wraps at the ends of the group. Default: true. */
loopFocus: boolean
/** Reading direction. Under 'rtl', ArrowLeft/ArrowRight swap meaning. */
dir: 'ltr' | 'rtl'
}
ToggleInit from @llui/components
export interface ToggleInit {
pressed?: boolean
disabled?: boolean
}
ToggleItemParts from @llui/components
export interface ToggleItemParts {
type: 'button'
'aria-label': string
'aria-pressed': ReadSignal<boolean>
'data-scope': 'gradient-picker'
'data-part': ToggleItemPart
'data-value': string
'data-state': ReadSignal<'on' | 'off'>
disabled: ReadSignal<boolean>
onClick: (e: MouseEvent) => void
}
ToggleParts from @llui/components
export interface ToggleParts {
root: {
type: 'button'
role: 'button'
'aria-pressed': ReadSignal<boolean>
'aria-disabled': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
'data-state': ReadSignal<'on' | 'off'>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'toggle'
'data-part': 'root'
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
}
ToggleState from @llui/components
Toggle button — a button that can be pressed or not. Unlike a checkbox, a toggle represents an action that is applied immediately (e.g. "bold" in a text editor toolbar).
export interface ToggleState {
pressed: boolean
disabled: boolean
}
ToolbarGroupParts from @llui/components
export interface ToolbarGroupParts {
root: {
role: 'group'
'data-scope': 'toolbar'
'data-part': 'group'
/** The label part's id, or absent when `hasLabel: false`. */
'aria-labelledby': string | undefined
}
label: {
id: string
'data-scope': 'toolbar'
'data-part': 'group-label'
}
}
ToolbarInit from @llui/components
export interface ToolbarInit {
items?: string[]
disabledItems?: string[]
focused?: string | null
orientation?: Orientation
loopFocus?: boolean
disabled?: boolean
}
ToolbarItemParts from @llui/components
export interface ToolbarItemParts {
root: {
'data-scope': 'toolbar'
'data-part': 'item'
'data-value': string
'data-disabled': ReadSignal<'' | undefined>
'aria-disabled': ReadSignal<'true' | undefined>
tabindex: ReadSignal<number>
onKeyDown: (e: KeyboardEvent) => void
onFocus: () => void
}
}
ToolbarParts from @llui/components
export interface ToolbarParts {
root: {
role: 'toolbar'
'aria-orientation': ReadSignal<Orientation>
'aria-label': string | undefined
'aria-disabled': ReadSignal<'true' | undefined>
'data-scope': 'toolbar'
'data-part': 'root'
'data-orientation': ReadSignal<Orientation>
'data-disabled': ReadSignal<'' | undefined>
}
separator: {
role: 'separator'
'aria-orientation': ReadSignal<Orientation>
'data-scope': 'toolbar'
'data-part': 'separator'
}
item: (value: string) => ToolbarItemParts
/**
* A labelled group's parts. The group names its `label` part through
* `aria-labelledby`; a consumer that renders the group WITHOUT that label
* passes `{ hasLabel: false }` so it never names an element that does not
* exist (an unlabelled `role="group"` is valid; a broken idref is not — the
* `dialog` `hasDescription` rule, #268). Default: true.
*/
group: (label: string, options?: { readonly hasLabel?: boolean }) => ToolbarGroupParts
}
ToolbarState from @llui/components
export interface ToolbarState {
items: string[]
disabledItems: string[]
focused: string | null
orientation: Orientation
loopFocus: boolean
disabled: boolean
}
TooltipInit from @llui/components
export interface TooltipInit {
open?: boolean
/**
* Enable the exit-animation lifecycle: a close enters `closing` and stays
* mounted until `animationEnd`. Default false (instant unmount). The
* `overlay()` helper turns this on automatically when given a `transition`.
*/
animated?: boolean
}
TooltipOverlayOptions from @llui/components
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
state: ReadSignal<TooltipState>
send: Send<TooltipMsg>
parts: TooltipParts
content: () => Renderable
/**
* Optional enter/leave transition for the tooltip content (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, so the close plays an exit animation.
* Opt-in only — supplying this does not turn animation on automatically.
*
* @example tooltip.overlay({ state, send, parts, content, transition: fade({ duration: 100 }) })
*/
transition?: TransitionOptions
placement?: Placement
offset?: number
flip?: boolean
shift?: boolean
target?: string | HTMLElement
/** Selector for an arrow rendered inside the content element. When present,
* floating positioning owns its absolute edge geometry transactionally. */
arrowSelector?: string
/** Dismiss on Escape regardless of where focus is (default: true). */
closeOnEscape?: boolean
}
TooltipParts from @llui/components
export interface TooltipParts {
trigger: {
id: string
'aria-describedby': ReadSignal<string | undefined>
'data-state': ReadSignal<'open' | 'closed'>
'data-scope': 'tooltip'
'data-part': 'trigger'
onPointerEnter: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
onFocus: (e: FocusEvent) => void
onBlur: (e: FocusEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
positioner: {
'data-scope': 'tooltip'
'data-part': 'positioner'
style: string
}
content: {
role: 'tooltip'
id: string
style: string
'data-state': ReadSignal<PresenceStatus>
'data-scope': 'tooltip'
'data-part': 'content'
onPointerEnter: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
onKeyDown: (e: KeyboardEvent) => void
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
arrow: {
'data-scope': 'tooltip'
'data-part': 'arrow'
}
}
TooltipState from @llui/components
Tooltip — hover / focus-triggered, positioned. Opens after a short delay to avoid flicker from passing pointers, closes immediately on blur or after a grace period on pointer leave.
Pure reducer handles only the boolean open state; timing (delays,
debouncing) lives in the event handlers returned from connect(), which
close over per-instance timers.
export interface TooltipState {
/**
* Whether the tooltip is intended to be visible (true while `opening`/`open`,
* false once a close is requested). Flips to false at close-request time —
* exactly as today — so existing consumers reading `open` are unaffected.
* The DOM node is kept mounted through an exit animation via `status`, not
* `open`. Backed by the presence lifecycle in `status`.
*/
open: boolean
/**
* Full presence lifecycle. `closed → opening → open → closing → closed`.
* When `animated` is false (the default) a close skips `closing` and lands
* on `closed` synchronously, matching today's instant unmount.
*/
status: PresenceStatus
/**
* Whether an exit animation is configured. When false, closing is
* synchronous (no `closing` state, no waiting for `animationEnd`).
*/
animated: boolean
}
TourInit from @llui/components
export interface TourInit {
steps?: TourStep[]
open?: boolean
index?: number
}
TourParts from @llui/components
export interface TourParts {
root: {
role: 'dialog'
'aria-modal': 'false'
'aria-labelledby': string
/** The description part's id, or absent when `hasDescription: false`. */
'aria-describedby': string | undefined
'data-scope': 'tour'
'data-part': 'root'
hidden: ReadSignal<boolean>
}
backdrop: {
'data-scope': 'tour'
'data-part': 'backdrop'
'aria-hidden': 'true'
onClick: (e: MouseEvent) => void
}
spotlight: {
'data-scope': 'tour'
'data-part': 'spotlight'
'aria-hidden': 'true'
}
title: {
id: string
'data-scope': 'tour'
'data-part': 'title'
}
description: {
id: string
'data-scope': 'tour'
'data-part': 'description'
}
progressText: {
'data-scope': 'tour'
'data-part': 'progress-text'
}
prevTrigger: {
type: 'button'
disabled: ReadSignal<boolean>
'data-scope': 'tour'
'data-part': 'prev-trigger'
onClick: (e: MouseEvent) => void
}
nextTrigger: {
type: 'button'
'data-scope': 'tour'
'data-part': 'next-trigger'
'data-last': ReadSignal<'' | undefined>
onClick: (e: MouseEvent) => void
}
closeTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'tour'
'data-part': 'close-trigger'
onClick: (e: MouseEvent) => void
}
}
TourState from @llui/components
export interface TourState {
steps: TourStep[]
open: boolean
index: number
/** Ids of steps already visited. */
visited: string[]
}
TourStep from @llui/components
Tour — guided walkthrough over a sequence of steps, each targeting an element on the page with a pop-up explanation. The state machine tracks the current step index and open/closed; positioning of the pop-up relative to the target selector is done in the view layer (typically via onMount + attachFloating).
There is no overlay() helper: connect() returns the part bags and you
render/position the pop-up yourself (typically onMount + attachFloating
against the current step's target).
view: ({ state, send }) => {
const t = tour.connect(state.at('tour'), send, { id: 'tour' })
const step = tour.currentStep(state.peek().tour)
return [
div({ ...t.root }, [
h3({ ...t.title }, [text(step.title)]),
p({ ...t.description }, [text(step.description)]),
button({ ...t.prevTrigger }, [text('Back')]),
button({ ...t.nextTrigger }, [text('Next')]),
]),
]
}
export interface TourStep {
id: string
title: string
description: string
/** CSS selector or element ref for the tour target. */
target: string
/** Placement hint for the pop-up. */
placement?: 'top' | 'bottom' | 'left' | 'right'
/** Whether to show the highlight ring around the target. */
spotlight?: boolean
}
TreeItemParts from @llui/components
export interface TreeItemParts {
item: {
role: 'treeitem'
id: string
'aria-expanded': ReadSignal<boolean | undefined>
'aria-selected': ReadSignal<boolean | undefined>
'aria-level': number
'aria-busy': ReadSignal<'true' | undefined>
tabindex: ReadSignal<number>
'data-scope': 'tree-view'
'data-part': 'item'
'data-value': string
'data-depth': string
'data-selected': ReadSignal<'' | undefined>
'data-focused': ReadSignal<'' | undefined>
'data-loading': ReadSignal<'' | undefined>
'data-load-failed': ReadSignal<'' | undefined>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
onFocus: (e: FocusEvent) => void
}
/** For branch items — expand/collapse disclosure trigger. */
branchTrigger: {
'data-scope': 'tree-view'
'data-part': 'branch-trigger'
'data-state': ReadSignal<'open' | 'closed'>
onClick: (e: MouseEvent) => void
}
/**
* Checkbox element (only meaningful when `selectionMode === 'checkbox'`).
* `aria-checked` is the tri-state string ('true' | 'false' | 'mixed').
* The consumer must render a checkbox input or a visual proxy and
* dispatch `toggleChecked` via the `onClick` binding. For branches,
* pass the branch's descendant ids via `descendantIds` on the message
* so children are propagated in a single reducer step.
*/
checkbox: {
role: 'checkbox'
'aria-checked': ReadSignal<'true' | 'false' | 'mixed'>
'data-scope': 'tree-view'
'data-part': 'checkbox'
'data-state': ReadSignal<'checked' | 'unchecked' | 'indeterminate'>
}
}
TreeNode from @llui/components
TreeCollection — helper for building tree-view payloads from a nested data structure. Tree-view's state machine only knows about flat lists (visibleItems, visibleLabels) and opaque ids; the collection owns the structure and derives those flat arrays on demand.
Typical flow:
const col = new TreeCollection(data) state.visibleItems = col.visibleItems(state.expanded) state.visibleLabels = col.visibleLabels(state.expanded)
After every expand / collapse message, the consumer dispatches
setVisibleItems with the updated arrays. The collection itself is
immutable — build a new one when the tree structure changes.
export interface TreeNode {
id: string
label?: string
disabled?: boolean
children?: TreeNode[]
}
TreeNodeInput from @llui/components
Shape of a lazily-loaded child handed back via childrenLoaded.
export interface TreeNodeInput {
id: string
/** Eagerly-known children of this freshly-loaded node, if any. */
children?: string[]
disabled?: boolean
/** Mark this freshly-loaded node as itself lazily-loadable. */
hasChildren?: boolean
}
TreeNodeMeta from @llui/components
JSON-serializable adjacency entry for one tree node. The reducer owns the
tree structure (as a flat record) so it can traverse descendants/ancestors
for automatic indeterminate derivation and lazy-load bookkeeping without
any external collection. Build the record from a {@link TreeCollection} or
by hand; seed it via init({ nodes, roots }) or the setNodes message.
export interface TreeNodeMeta {
/** Ordered ids of this node's loaded children (empty until loaded). */
children: string[]
/** Parent id, or null for a root. */
parentId: string | null
/** When true, descendant cascade and checked-derivation skip this node. */
disabled?: boolean
/**
* Declares the node as a branch whose children are loaded lazily. Expanding
* a `hasChildren` node that has not yet been loaded emits a `loadChildren`
* effect; the consumer fetches and replies with `childrenLoaded`.
*/
hasChildren?: boolean
}
TreeViewInit from @llui/components
export interface TreeViewInit {
expanded?: string[]
selected?: string[]
checked?: string[]
indeterminate?: string[]
selectionMode?: SelectionMode
disabled?: boolean
visibleItems?: string[]
visibleLabels?: string[]
nodes?: Record<string, TreeNodeMeta>
roots?: string[]
loaded?: string[]
loadFailed?: string[]
}
TreeViewParts from @llui/components
export interface TreeViewParts {
root: {
role: 'tree'
'aria-multiselectable': ReadSignal<'true' | undefined>
'aria-disabled': ReadSignal<'true' | undefined>
'data-scope': 'tree-view'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
}
item: (id: string, depth: number, isBranch: boolean, parentId?: string | null) => TreeItemParts
}
TreeViewState from @llui/components
export interface TreeViewState {
/** Ids of expanded branches. */
expanded: string[]
/** Ids of selected items. */
selected: string[]
/** Ids of checked items (checkbox selection mode). */
checked: string[]
/** Ids known to be in the indeterminate tri-state (some-but-not-all
* descendants checked). Consumer-computed via propagation logic or the
* `toggleChecked` message's `descendantIds` parameter. */
indeterminate: string[]
/** Currently focused item id. */
focused: string | null
selectionMode: SelectionMode
/** Ordered list of currently-visible item ids (updated by consumer via setVisible). */
visibleItems: string[]
/** Parallel array of visible-item labels for typeahead. If empty, typeahead
* matches against ids directly. Updated alongside visibleItems via the
* optional `labels` field on `setVisibleItems`. */
visibleLabels: string[]
disabled: boolean
/** Typeahead accumulator buffer. */
typeahead: string
typeaheadExpiresAt: number
/** Id of item currently being renamed, or null. */
renaming: string | null
/** Draft value during rename. */
renameDraft: string
/**
* Ids of branches currently loading their children asynchronously. Item
* parts expose `aria-busy` while loading so assistive tech announces the
* in-progress state. This is now driven by the machine itself: expanding a
* `hasChildren` node sets `loading` and emits a `loadChildren` effect; the
* `childrenLoaded` / `childrenLoadFailed` replies clear it. The legacy
* `loadingStart` / `loadingEnd` messages remain for manual control.
*/
loading: string[]
/**
* Flat tree structure (adjacency record). Owned by the reducer for
* descendant/ancestor traversal. JSON-serializable.
*/
nodes: Record<string, TreeNodeMeta>
/** Ids of the top-level (root) nodes, in order. */
roots: string[]
/**
* Ids of branches whose children have been loaded (distinguishes a
* loaded-but-empty branch from a not-yet-fetched one so we never refetch).
*/
loaded: string[]
/**
* Ids of branches whose last lazy load failed. Re-expanding such a branch
* retries the load (clears the flag and re-emits `loadChildren`).
*/
loadFailed: string[]
}
ValidateResult from @llui/components
export interface ValidateResult<T> {
isValid: boolean
/** Field name → first error message. Field name is derived from the issue's path. */
errors: Partial<Record<keyof T, string>>
/** All issues from the schema validator, unaltered. */
issues: readonly StandardSchemaV1.Issue[]
}
Classes
TreeCollection from @llui/components
class TreeCollection {
roots: TreeNode[]
info: Map<string, NodeInfo>
constructor(roots: TreeNode | TreeNode[])
index(): void
getNode(id: string): TreeNode | null
getLabel(id: string): string
getParent(id: string): string | null
getDepth(id: string): number
getChildren(id: string): string[]
getDescendants(id: string): string[]
isBranch(id: string): boolean
isDisabled(id: string): boolean
visibleItems(expanded: string[]): string[]
visibleLabels(expanded: string[]): string[]
computeIndeterminate(checked: Set<string>): string[]
}
Constants
accordion from @llui/components
const accordion
alertDialog from @llui/components
const alertDialog
ALL_NESTED_LAYER_ASPECTS from @llui/components
Every aspect — the default for a registration that names none.
const ALL_NESTED_LAYER_ASPECTS: readonly NestedLayerAspect[]
angleSlider from @llui/components
const angleSlider
asyncList from @llui/components
const asyncList
avatar from @llui/components
const avatar
breadcrumbs from @llui/components
const breadcrumbs
carousel from @llui/components
const carousel
cascadeSelect from @llui/components
const cascadeSelect
chart from @llui/components
const chart
checkbox from @llui/components
const checkbox
clipboard from @llui/components
const clipboard
collapsible from @llui/components
const collapsible
colorPicker from @llui/components
const colorPicker
combobox from @llui/components
const combobox
contextMenu from @llui/components
const contextMenu
dateInput from @llui/components
const dateInput
datePicker from @llui/components
const datePicker
dialog from @llui/components
const dialog
drawer from @llui/components
const drawer
editable from @llui/components
const editable
en from @llui/components
English locale — used as the default when no provider is in the tree.
const en: Locale
field from @llui/components
const field
fieldset from @llui/components
const fieldset
fileUpload from @llui/components
const fileUpload
floatingPanel from @llui/components
const floatingPanel
form from @llui/components
const form
gradientPicker from @llui/components
const gradientPicker
hoverCard from @llui/components
const hoverCard
iconConfig from @llui/components
Where glyphs are fetched from. Point this at a self-hosted Iconify to drop the third-party CDN; the path shape is identical.
const iconConfig
imageCropper from @llui/components
const imageCropper
inView from @llui/components
const inView
listbox from @llui/components
const listbox
LocaleContext from @llui/components
Locale context. Components resolve their own English fallback so component
subpath bundles only carry that component's strings. This public context and
the lightweight component context share an id, preserving provide()
behavior while retaining the complete English locale as the public default.
const LocaleContext: Context<Locale>
marquee from @llui/components
const marquee
MAX_CALENDAR_TICKS from @llui/components
A hard ceiling on the boundaries one call will produce. Reached only by
FORCING a fine unit across an enormous span; the automatic ladder can never
exceed maxTicks. Over the ceiling the answer is an EMPTY array — no
gridlines — rather than a truncated one, because a truncated axis silently
claims the data ends where the array does.
const MAX_CALENDAR_TICKS
menu from @llui/components
const menu
menubar from @llui/components
const menubar
menubarMachine from @llui/components
const menubarMachine
meter from @llui/components
const meter
navigationMenu from @llui/components
const navigationMenu
numberInput from @llui/components
const numberInput
pagination from @llui/components
const pagination
passwordInput from @llui/components
const passwordInput
pinInput from @llui/components
const pinInput
popover from @llui/components
const popover
presence from @llui/components
const presence
progress from @llui/components
const progress
qrCode from @llui/components
const qrCode
radioGroup from @llui/components
const radioGroup
ratingGroup from @llui/components
const ratingGroup
scrollArea from @llui/components
const scrollArea
searchField from @llui/components
const searchField
select from @llui/components
const select
signaturePad from @llui/components
const signaturePad
slider from @llui/components
const slider
sortable from @llui/components
const sortable
sparkline from @llui/components
const sparkline
splitter from @llui/components
const splitter
steps from @llui/components
const steps
switchMachine from @llui/components
const switchMachine
table from @llui/components
The namespace object. {@link HEADER_ROW_INDEX} is a member again now that
issue #151 is fixed — site/src/generate-api.ts classifies namespace members
by kind instead of rendering every one with a hard-coded (). It remains a
module-level export too, re-exported from the barrel as
TABLE_HEADER_ROW_INDEX.
const table
TABLE_HEADER_ROW_INDEX from @llui/components
The row index of the HEADER row. The header is part of the grid's roving
sequence — APG's data-grid examples make column headers focusable exactly
because they carry controls (sort here, plus the select-all checkbox) — so it
needs a coordinate. -1 is the natural one: aria-rowindex already models
the header as row 1 with data row i at i + 2, so the header sits one row
above data row 0.
const TABLE_HEADER_ROW_INDEX
tabs from @llui/components
const tabs
tagsInput from @llui/components
const tagsInput
themeSwitch from @llui/components
const themeSwitch
timePicker from @llui/components
const timePicker
timer from @llui/components
const timer
toast from @llui/components
const toast
toc from @llui/components
const toc
toggle from @llui/components
const toggle
toggleGroup from @llui/components
const toggleGroup
toolbar from @llui/components
const toolbar
tooltip from @llui/components
const tooltip
tour from @llui/components
const tour
treeView from @llui/components
const treeView
TYPEAHEAD_TIMEOUT_MS from @llui/components
Typeahead search — accumulates keystrokes into a query while the user types rapidly, then matches the first item whose label starts with the query. Used by listbox, menu, select, combobox, tree-view to support WAI-ARIA keyboard navigation patterns.
Behavior:
- If a keystroke arrives within
TYPEAHEAD_TIMEOUT_MSof the previous one, append to the existing query (so typing "sa" finds "Saturn" even if the highlight is currently on "Jupiter"). - Otherwise, start a fresh single-character query.
- Single-character queries advance past the current position (jump to the next item starting with that letter), which is the standard WAI-ARIA behavior — rapid repeated presses of "s" cycle through items beginning with "s".
- Multi-character queries search from the current cursor position (inclusive) so if the cursor is already on a matching item, it stays — typing "ap" while on "apricot" keeps focus on "apricot".
const TYPEAHEAD_TIMEOUT_MS
@llui/components/utils
Functions
addUnits() from @llui/components/utils
Advance an instant by n whole units, in the offset's calendar.
function addUnits(at: number, unit: CalendarUnit, n: number, opts: CalendarOptions = {}): number
allFiniteNumbers() from @llui/components/utils
Whether every number nested in one atomic runtime payload is usable. Non-numeric leaves are ignored; arrays and plain payload objects are walked so callers cannot accidentally validate one coordinate while committing a bad sibling. Cycles are harmless because an already-seen object contains no new numeric leaves.
function allFiniteNumbers(...values: readonly unknown[]): boolean
anatomy() from @llui/components/utils
function anatomy<P extends string>(name: string, parts: readonly P[]): Anatomy<P>
annularSectorPath() from @llui/components/utils
An annular sector — the polar answer to a bar. r0 is the inner radius (0
gives a pie wedge, > 0 a donut segment); angles follow {@link polarPoint}.
A sweep of a full turn or more is drawn as a complete ring, because the arc endpoints would otherwise coincide and SVG would draw nothing at all.
function annularSectorPath(cx: number, cy: number, r0: number, r1: number, a0: number, a1: number): string
applySelection() from @llui/components/utils
Apply a click/Enter on value to the current selection. Single mode
replaces, multiple toggles, and a disabled item changes nothing — returning
the SAME array reference so the reducer's no-op stays a no-op for the
reference-equality reconciler.
function applySelection(current: string[], value: string, opts: { mode: SelectionMode; disabled?: readonly string[] }): string[]
areaPath() from @llui/components/utils
A filled region between an upper run and a lower run. The lower run is walked BACKWARDS and appended, then the path is closed — one subpath, so a fill-rule cannot punch a hole in it the way two separate subpaths can.
function areaPath(upper: readonly Point[], lower: readonly Point[], curve: Curve, axis: CurveAxis = 'x'): string
attachFloating() from @llui/components/utils
Position floating relative to anchor with live updates on scroll/resize.
Owns position, top, left, transform and the two available-size
custom properties ({@link FLOATING_AVAILABLE_HEIGHT}) on floating; position and
all four physical inset properties on an optional arrow; and placement
attributes on stateTarget. The arrow's static-side inset is half its
untransformed layout size, so a square arrow straddles the resolved edge.
Cleanup is idempotent, suppresses pending writes/callbacks, and restores the
exact prior values (including priority) or absence of those properties.
export declare function attachFloating(opts: FloatingOptions): () => void;
bandCenter() from @llui/components/utils
The centre of band i in normalized u.
function bandCenter(i: number, band: Band): number
bandExtent() from @llui/components/utils
The [start, end] extent of band i in normalized u.
function bandExtent(i: number, band: Band): [number, number]
calendarTicks() from @llui/components/utils
Calendar boundaries inside [start, end], ascending.
An EMPTY array for a non-finite or reversed range, and for a request that would exceed {@link MAX_CALENDAR_TICKS} — a chart with no data is a normal state, and a truncated axis is a lie.
function calendarTicks(start: number, end: number, opts: CalendarStepOptions = {}): CalendarTick[]
cartesianProjection() from @llui/components/utils
function cartesianProjection(frame: Frame, opts: CartesianOptions = {}): Projection
chooseCalendarStep() from @llui/components/utils
The finest rung whose boundaries fit inside maxTicks.
A FORCED unit is honoured as given (stride 1), except on year, where the
stride still grows to fit — "yearly" over four centuries has to mean
something, and 400 gridlines is not it.
function chooseCalendarStep(start: number, end: number, opts: CalendarStepOptions = {}): CalendarStep
circlePath() from @llui/components/utils
A full circle, as two arcs — one arc of 360° is degenerate and draws nothing, which is the classic way a polar gridline disappears.
function circlePath(cx: number, cy: number, r: number): string
clamp() from @llui/components/utils
Bound n into [min, max]. The result is always FINITE: a non-finite input
maps to a defined legal value instead of being stored verbatim.
Every comparison against NaN is false, so NaN used to fall straight
through to return n and land in state — package-wide, since this is the one
clamp every mutation path routes through (#152). It is not merely a wrong
number: JSON.stringify(NaN) (and Infinity) is null, so a non-finite
value breaks the State-is-JSON-serializable invariant and with it devtools
time-travel, @llui/test replay, agent state snapshots and SSR rehydration.
Rejecting at this boundary is what lets every caller state its own
postcondition — e.g. slider's withThumb — without a finiteness caveat.
function clamp(n: number, min: number, max: number): number
clampToStep() from @llui/components/utils
Clamp into the range AND snap onto the grid. The result is always within
[min, max]: snapping can leave the range when an endpoint is not itself on
the grid (min 0, max 10, step 4 → 10 snaps up to 12), and the answer there is
the last grid value INSIDE the range, not an out-of-range or off-grid one.
It is always FINITE too — the clamp rejects a non-finite input first (#152).
function clampToStep(value: number, grid: NumericGrid): number
countCalendarTicks() from @llui/components/utils
How many boundaries of step fall inside [start, end] — WITHOUT building
them. Constant time at every rung, which is what lets the ladder reject a
finer unit for a century-long span for free.
One informational edge, in the safe direction: within a month of the extremes
of the representable range (year ±271821), flooring to a period start falls
outside what a Date can hold and {@link utcAt} answers NaN, so the count
is 0 and {@link calendarTicks} draws no gridlines. It neither hangs nor
throws; the axis is simply unlabelled at the end of time.
function countCalendarTicks(start: number, end: number, step: CalendarStep, opts: CalendarOptions = {}): number
cssColorAlpha() from @llui/components/utils
Resolved alpha (none -> 1, CSS Color 4's used value for a missing
alpha OUTSIDE interpolation). Inside {@link interpolateColor}, a missing
alpha instead carries the OTHER endpoint's alpha — see its doc comment.
function cssColorAlpha(c: CssColor): number
cssColorToHex() from @llui/components/utils
Gamut-map (if needed) and convert to a displayable hex string.
#rrggbb when alpha is (resolved to) 1, #rrggbbaa otherwise.
function cssColorToHex(c: CssColor): string
cssColorToSrgb() from @llui/components/utils
Convert any {@link CssColor} into fully-resolved (non-null) Srgb,
treating none as 0 and NOT gamut-mapping (an oklch/oklab input outside
sRGB stays outside [0,1]).
function cssColorToSrgb(c: CssColor): Srgb
curvePath() from @llui/components/utils
Join a run of points with the named curve. closed applies to linear
only — the other two are defined on an open, x-increasing run.
function curvePath(points: readonly Point[], curve: Curve, closed = false, axis: CurveAxis = 'x'): string
decimalPlaces() from @llui/components/utils
Fraction digits n is written with, INCLUDING exponential notation —
String(1e-7) is '1e-7', which a scan for '.' reads as zero decimals.
function decimalPlaces(n: number): number
denormalize() from @llui/components/utils
Inverse of {@link normalize}.
function denormalize(u: number, domain: Domain): number
deriveOnce() from @llui/components/utils
Wrap a ONE-ARGUMENT compute so that repeating a call with the same argument
returns the previous result. One cell — the first item of an update pays for
the derivation and the rest read it, so the cost is per UPDATE, not per item
and not per render.
This is the shape that runs per ROW per BINDING per update, so it takes a
FIXED parameter and compares with one Object.is. The variadic deriveOnceN
below materialises a fresh arguments array on every call — one per row per
binding per update — which is pure overhead on the hit path: over a pass of
N items x 4 bindings, 0.00056 -> 0.00041 ms at N=20, 0.00467 -> 0.00337 at
N=200 and 0.05680 -> 0.03823 at N=2000 (~25-33%). Reach for deriveOnceN
only where the derivation genuinely takes several inputs.
NO RUNTIME ARITY GUARD, deliberately. Calling the returned function with a
second argument silently ignores it — g(1,'x') and g(1,'y') both return
the g(1) result from one computation — so a JS consumer, or a TS consumer
who casts, can get a wrong answer with no error. That is accepted because
the only ways to observe arity at runtime are an arguments object (absent
in an arrow) or a rest parameter, and a rest parameter re-materialises the
per-row-per-binding array whose removal is this function's entire reason to
exist — paying the 25-33% back on the hottest path in the package, on every
hit, to catch a call TypeScript already rejects (TS2554). Do NOT "fix" this
by widening the signature. If a derivation needs more than one input, that
is what deriveOnceN is for.
function deriveOnce<A, R>(compute: (arg: A) => R): (arg: A) => R
deriveOnceN() from @llui/components/utils
deriveOnce for a derivation with several inputs (the roving tab stop reads
three or four). Memoized on ARGUMENT IDENTITY, position by position.
function deriveOnceN<A extends readonly unknown[], R>(compute: (...args: A) => R): (...args: A) => R
engineFocus() from @llui/components/utils
Focus el as an engine-initiated move (see runEngineFocus).
export declare function engineFocus(el: HTMLElement, options?: FocusOptions): void;
finiteBound() from @llui/components/utils
A bound as STATE may hold it: the finite number itself, or undefined for
"no bound on this side". THE ONE normalizer for a bound, mirroring clamp's
role for a value (#177).
±Infinity and an ABSENT bound already mean the same thing to every clamp in
the package — clampToStep expands grid.min ?? -Infinity — but only one of
the two spellings survives JSON.stringify, which writes null for both
Infinity and NaN. State must be JSON-serializable (devtools time-travel,
@llui/test replay, agent state snapshots, SSR rehydration all compare
serialized state), so the infinite spelling belongs to the RUNTIME expansion
and never to state: normalize at every write, let the grid expand the absence
again. An unbounded number-input used to store min: -Infinity and
rehydrate as min: null — a number field holding null, on the DEFAULT
configuration.
NaN collapses here too, and that is the half a ?? cannot rescue: NaN is
not nullish, so a NaN bound reached clamp, every comparison against it
was false, and THAT SIDE OF THE RANGE STOPPED CLAMPING — angle-slider after
setMin: NaN stored -9999 for setValue(-9999).
Callers decide what an absent bound means for them, and there are exactly two idioms:
- UNBOUNDED-CAPABLE (
number-input): store theundefinedby OMITTING the key, so the state shape IS aNumericGridand round-trips identically. - INTRINSICALLY BOUNDED (
angle-slider,slider,splitter, …): a requiredmin: numbercannot spell "unbounded", so?? DEFAULTatinitand REJECT the write in asetMin/setMaxreducer — dropping a meaningless bound keeps the range the component already had, which is the only answer that cannot silently disable clamping.
function finiteBound(raw: number | null | undefined): number | undefined
finiteOrDefault() from @llui/components/utils
A component-owned number that has no range to clamp into. Initialization
replaces an unusable input with the field's ordinary default; runtime
reducers use {@link allFiniteNumbers} to refuse the whole message instead.
Keeping those two policies here prevents a free position or timestamp from
accidentally inheriting either the grid-value policy (clamp) or the
optional-bound policy (finiteBound).
function finiteOrDefault(raw: number | null | undefined, fallback: number): number
firstEnabled() from @llui/components/utils
Internal value navigation used by the public roving-focus primitive.
export declare function firstEnabled(items: readonly string[], disabled: readonly string[]): string | null;
firstEnabledIndex() from @llui/components/utils
function firstEnabledIndex(items: readonly string[], disabled: readonly string[]): number | null
flipArrow() from @llui/components/utils
Map a horizontal arrow key to its logical direction, accounting for RTL. This is the SINGLE SOURCE OF TRUTH every component routes horizontal arrow interpretation through. Under rtl, ArrowLeft and ArrowRight swap meaning; vertical arrows (Up/Down), Home/End, PageUp/PageDown and every non-arrow key pass through unchanged.
The second argument is the direction source:
- an explicit
'ltr' | 'rtl'— used directly (the authoritative form when a component storesdirin its own State and passes it in); - an
Element— direction is resolved by walking up the DOM (dir="rtl"ancestor ordocument.documentElement.dir); null— treated as'ltr'(no-op).
export declare function flipArrow(key: string, source: Element | null | TextDirection): string;
floorToUnit() from @llui/components/utils
Floor an instant to the start of its unit, in the offset's calendar.
function floorToUnit(at: number, unit: CalendarUnit, opts: CalendarOptions = {}): number
fmt() from @llui/components/utils
Format a coordinate. A non-finite input becomes 0 — a NaN in a path
silently voids the WHOLE path element, taking every later command with it.
function fmt(n: number): string
focusLingeredInside() from @llui/components/utils
Whether focus LINGERED INSIDE the layer, i.e. whether restoring it to the anchor respects the user rather than overriding them.
function focusLingeredInside(query: FocusRestoreQuery): boolean
focusRovingItem() from @llui/components/utils
Move DOM focus to the roving item identified by value within the same
widget instance as origin.
Roving-tabindex widgets track the active index in STATE, but assistive tech
follows real DOM focus — so after a keyboard move the handler MUST also move
focus, or arrow keys are silent for AT. origin is the event's
currentTarget (the item that received the key); its closest
[data-scope][data-part="root"] ancestor scopes the search so sibling
widgets of the same scope never cross-focus. No-op if nothing matches.
send() is synchronous and items already exist in the DOM, so this can be
called immediately after the navigation send.
export declare function focusRovingItem(origin: Element | null, scope: string, value: string, opts?: {
itemPart?: string;
attr?: string;
}): void;
focusRovingTab() from @llui/components/utils
Move DOM focus to the trigger whose data-value matches, within
container. Relies only on the role="tab" + data-value contract
(shared by components/tabs and any hand-rolled tablist). No-op when no
trigger matches. Call after the DOM reflects the new active tab (e.g. in
a microtask if activation triggers a re-render).
export declare function focusRovingTab(container: Element, value: string): void;
formatHex() from @llui/components/utils
function formatHex(rgb: Rgb255): string
formatHex8() from @llui/components/utils
function formatHex8(rgb: Rgb255, alpha: number): string
formatOklch() from @llui/components/utils
oklch(L C H) / oklch(L C H / A). Fixed precision: L as a 0–1
fraction at 4 dp, C at 4 dp, H at 2 dp — chosen so the string is stable
and round-trips through {@link parseCssColor} without drifting.
function formatOklch(ok: Oklch, alpha = 1): string
formatRgb() from @llui/components/utils
function formatRgb(rgb: Rgb255, alpha = 1): string
gamutMapOklchToSrgb() from @llui/components/utils
CSS Color 4 §13.2 gamut mapping: reduce OKLCH chroma by binary search until
simple-clipping the result into sRGB is perceptually indistinguishable
(deltaEOK < the 0.02 JND) from the reduced color, then clip. Given an
already-in-gamut color this returns it converted (and defensively clipped
for float noise) rather than a no-op, so the result is always safe to hand
straight to {@link srgbToRgb255}. test/utils/color.test.ts pins the
converged chroma for a known out-of-gamut color as a regression value
(this file's own binary search, not an independent oracle — see that
test's comment for what it does and does not prove).
function gamutMapOklchToSrgb(ok: Oklch): Srgb
getFocusables() from @llui/components/utils
export declare function getFocusables(container: Element): HTMLElement[];
getNestedLayers() from @llui/components/utils
Currently-registered nested-layer elements (resolvers re-read live).
With an aspect, only registrations that participate in it; without one, all
of them. With a within boundary, only registrations nested inside it (see
the module comment); without one, the flat, layer-agnostic answer.
export declare function getNestedLayers(aspect?: NestedLayerAspect, within?: NestedLayerScope): Element[];
hslToHsv() from @llui/components/utils
Convert HSL (h 0-360, s/l 0-100) to HSV (h 0-360, s/v 0-100), float.
function hslToHsv(hsl: Hsl): Hsv
hslToRgb255() from @llui/components/utils
Convert HSL (h 0-360, s/l 0-100) to RGB (0-255 each, rounded).
function hslToRgb255(hsl: Hsl): Rgb255
hsvToHsl() from @llui/components/utils
Convert HSV (h 0-360, s/v 0-100) to HSL (h 0-360, s/l 0-100), float.
function hsvToHsl(hsv: Hsv): Hsl
hsvToOklch() from @llui/components/utils
function hsvToOklch(hsv: Hsv): Oklch
hsvToRgb255() from @llui/components/utils
HSV -> RGB255, via the DIRECT (not HSL-routed) float conversion.
function hsvToRgb255(hsv: Hsv): Rgb255
indexMap() from @llui/components/utils
A position lookup over a state array: positions(s.items).get(item) in place
of s.items.indexOf(item). First occurrence wins, matching indexOf.
function indexMap<T>(): (values: readonly T[] | null | undefined) => ReadonlyMap<T, number>
inSrgbGamut() from @llui/components/utils
Whether ok, converted to sRGB, falls inside [0,1] on every channel
(within epsilon — floating-point round-trip noise).
function inSrgbGamut(ok: Oklch, epsilon = 1e-4): boolean
interpolateColor() from @llui/components/utils
Interpolate between two colors per CSS Color 4 §12: convert both into
space, resolve missing (none, or a cross-space-converted achromatic
color's undefined) components — including alpha itself, which carries the
OTHER endpoint's alpha when missing rather than defaulting to 1 (CSS Color
4 §12.2's analogous-component rule applies to alpha too — rgb(255 0 0 / none) mixed with rgb(0 0 255 / 0.2) has alpha exactly 0.2 throughout,
not a lerp toward 1; pinned in test/utils/color.test.ts) —
premultiply non-hue channels by alpha, lerp, then un-premultiply.
hueMethod controls which way a hue-bearing space (hsl, oklch)
rotates, normalizing both hues to [0,360) FIRST (see {@link adjustHue});
ignored for spaces with no hue.
function interpolateColor(a: CssColor, b: CssColor, t: number, space: InterpolationSpace, hueMethod: HueInterpolationMethod = 'shorter'): CssColor
isDateOnly() from @llui/components/utils
function isDateOnly(value: DateValue): value is string
isEnabledItem() from @llui/components/utils
An item counts as navigable only while it is in the list AND not disabled.
function isEnabledItem(items: readonly string[], disabled: readonly string[], value: string): boolean
isEngineFocusInProgress() from @llui/components/utils
Whether an engine-initiated focus move is in flight. Consulted by
watchInteractOutside to gate its focusin path.
export declare function isEngineFocusInProgress(): boolean;
isFocusable() from @llui/components/utils
Find focusable descendants within a container.
export declare function isFocusable(el: Element): boolean;
isInNestedLayer() from @llui/components/utils
Whether target is inside (or equal to) a registered nested layer that
participates in aspect (any layer when aspect is omitted) and is nested
inside within (any layer when within is omitted).
export declare function isInNestedLayer(target: Node | null, aspect?: NestedLayerAspect, within?: NestedLayerScope): boolean;
isTypeaheadKey() from @llui/components/utils
Returns true if the key event should trigger a typeahead query — i.e., a
single printable character that isn't a modified keyboard shortcut. Use
this in onKeyDown handlers to decide whether to dispatch a typeahead
message.
function isTypeaheadKey(e: KeyboardEvent): boolean
lastEnabled() from @llui/components/utils
export declare function lastEnabled(items: readonly string[], disabled: readonly string[]): string | null;
lastEnabledIndex() from @llui/components/utils
function lastEnabledIndex(items: readonly string[], disabled: readonly string[]): number | null
linearPath() from @llui/components/utils
Straight segments through every point.
function linearPath(points: readonly Point[], closed = false): string
linearSrgbToOklab() from @llui/components/utils
function linearSrgbToOklab(s: Srgb): Oklab
linearToSrgb() from @llui/components/utils
function linearToSrgb(s: Srgb): Srgb
lockBodyScroll() from @llui/components/utils
Lock body scroll while an overlay is open, preserving scrollbar width to avoid layout shift. Reference-counted so nested locks compose cleanly.
export declare function lockBodyScroll(): () => void;
membershipSet() from @llui/components/utils
A membership lookup over a state array: set(s.value).has(item) in place of
s.value.includes(item). An absent collection reads as empty.
An EMPTY array shares EMPTY_SET rather than allocating: most of the ~16
call sites are a disabled/disabledItems list that is empty in the common
case, and an empty Set is the one input where the memo would otherwise pay
an allocation to answer false to everything.
function membershipSet<T>(): (values: readonly T[] | null | undefined) => ReadonlySet<T>
monotonePath() from @llui/components/utils
Monotone cubic interpolation (Fritsch–Carlson). Its defining property is that the curve NEVER OVERSHOOTS the data: between two samples it stays within their values, so a series that only rises cannot dip below a point on the way. A plain Catmull-Rom or cardinal spline does overshoot, and on a chart that means drawing a number nobody measured.
Defined on a function y = f(x) with strictly increasing x. It is therefore
meaningless on a closed angular loop, which is why polarProjection declines
it — see projection.ts.
function monotonePath(points: readonly Point[], axis: CurveAxis = 'x'): string
nearestBand() from @llui/components/utils
The band index nearest a normalized position — the hit test behind hover and
pointer tracking. Returns null for an empty axis.
Nearest-CENTRE, not containment: the gaps between bands belong to whichever band is closer, so a pointer never falls into a dead zone between two bars.
function nearestBand(u: number, band: Band): number | null
nextEnabled() from @llui/components/utils
export declare function nextEnabled(items: readonly string[], disabled: readonly string[], from: string, delta: 1 | -1, loop: boolean): string | null;
nextEnabledIndex() from @llui/components/utils
The index of the next enabled item delta steps from from, wrapping.
from === null starts before the first item (delta 1) or after the last
(delta -1), so the first/last enabled index comes back.
function nextEnabledIndex(items: readonly string[], disabled: readonly string[], from: number | null, delta: 1 | -1): number | null
niceDomain() from @llui/components/utils
Extend [min, max] outward to the nearest round tick boundaries, so the axis
ends on a labelled value instead of mid-air. Iterates because widening the
domain can change the chosen step, which can change the boundaries again;
d3's nice does the same, and it converges in at most a few rounds.
function niceDomain(min: number, max: number, count: number): Domain
normalize() from @llui/components/utils
Map a value into [0, 1] across domain. A DEGENERATE domain (min === max)
maps everything to 0.5 rather than dividing by zero — a single-valued series
draws as a flat line through the middle, which is the only reading that is
not a crash or a NaN in a path string.
function normalize(value: number, domain: Domain): number
oklabToLinearSrgb() from @llui/components/utils
function oklabToLinearSrgb(lab: Oklab): Srgb
oklabToOklch() from @llui/components/utils
function oklabToOklch(lab: Oklab): Oklch
oklabToSrgb() from @llui/components/utils
NOT gamut-mapped — may return channels outside [0,1] for an OKLab/OKLCH color outside sRGB. Use {@link gamutMapOklchToSrgb} for a displayable result.
function oklabToSrgb(lab: Oklab): Srgb
oklchToHsv() from @llui/components/utils
Gamut-maps into sRGB first — HSV cannot represent an out-of-gamut color.
function oklchToHsv(ok: Oklch): Hsv
oklchToOklab() from @llui/components/utils
function oklchToOklab(ok: Oklch): Oklab
oklchToSrgb() from @llui/components/utils
NOT gamut-mapped. See {@link oklabToSrgb}.
function oklchToSrgb(ok: Oklch): Srgb
parseCssColor() from @llui/components/utils
Parse a CSS color string into its typed model. Supports #rgb/#rgba/
#rrggbb/#rrggbbaa, rgb()/rgba(), hsl()/hsla() (both legacy comma
and modern space/slash syntax, with the legacy grammar's none-forbidden
and uniform-percentage-or-number rules enforced), hwb(), oklch(),
oklab(), lab(), lch(), color() (predefined spaces srgb,
srgb-linear, display-p3, a98-rgb, prophoto-rgb, rec2020, xyz,
xyz-d50, xyz-d65), none components, transparent, and the 148 CSS
Color 4 named colors. Returns null on anything else — never throws.
function parseCssColor(input: string): CssColor | null
parseDateValue() from @llui/components/utils
function parseDateValue(value: DateValue): ParsedDateValue
parseHexColor() from @llui/components/utils
Parse #rgb, #rgba, #rrggbb, #rrggbbaa. Returns null on anything
else, never throws.
function parseHexColor(hex: string): { rgb: Rgb255; alpha: number } | null
pointerDragHandlers() from @llui/components/utils
Build the five pointer handlers for one drag track. One call per live
connect() instance — dragging/activePointerId are closure-scoped per
instance, the same shape as this package's other per-instance mutable state
(e.g. color-picker's eyedropper pendingEyeDropper AbortController).
- Primary button only on
pointerdown(e.button !== 0is ignored), AND only when no drag is already live — a second finger/pointer touching down mid-drag is ignored rather than hijackingactivePointerId. - Every subsequent event (
pointermove/pointerup/pointercancel/onLostPointerCapture) is checked againstactivePointerId: a second pointer's events never affect a drag it didn't start. setPointerCaptureonpointerdown'scurrentTarget, sopointermovekeeps firing even once the pointer leaves the element's bounds — a plain drag with no capture stalls out at the track's edge instead of saturating like a native<input type="range">.- Released on
pointerup/pointercancel, guarded byhasPointerCaptureso releasing twice (or without ever capturing) is a harmless no-op. draggingis unconditionally reset ononLostPointerCapturefor the active pointer, even though that handler never callsrelease()itself (capture is already gone by definition when this fires).
function pointerDragHandlers(callbacks: PointerDragCallbacks): PointerDragHandlers
polarPoint() from @llui/components/utils
A point on a circle. Angles are RADIANS CLOCKWISE FROM 12 O'CLOCK, which is where every reader expects a chart to start; SVG's own 0 is 3 o'clock.
function polarPoint(cx: number, cy: number, r: number, angle: number): Point
polarProjection() from @llui/components/utils
function polarProjection(frame: Frame, opts: PolarOptions = {}): Projection
positiveFinite() from @llui/components/utils
A finite number strictly greater than zero, or undefined when unusable.
function positiveFinite(raw: number | null | undefined): number | undefined
positiveFiniteOrDefault() from @llui/components/utils
A positive finite number, or the field's ordinary initialization default.
function positiveFiniteOrDefault(raw: number | null | undefined, fallback: number): number
presenceEndHandler() from @llui/components/utils
Guard a presence "animation/transition ended" handler so it only advances the
presence machine when the event fired on the element the listener is bound to
(e.target === e.currentTarget) — never on a bubbling descendant.
Overlay content (dialog, popover, menu, toast) reflects its exit phase via
data-state="closing" and stays mounted until an animationend/transitionend
dispatches animationEnd/transitionEnd. Without this guard, ANY descendant
animation or transition ending during the exit — a spinner, a ripple, a child
fade — bubbles up and prematurely unmounts the overlay before its own exit
animation completes.
Mirrors the e.target === el guard the transitions runtime applies in
waitForEnd (@llui/transitions).
function presenceEndHandler<E extends AnimationEvent | TransitionEvent>(handler: (e: E) => void): (e: E) => void
projectionFor() from @llui/components/utils
Build the projection a chart's coordinate setting names.
function projectionFor(coord: ChartCoord, frame: Frame, opts: CartesianOptions & PolarOptions = {}): Projection
pruneToEnabled() from @llui/components/utils
Keep value only while it still names an enabled item, else null. Every
reducer that replaces the item list owes this to whatever it holds as
focused/selected — a dangling reference is the tab-stop bug.
function pruneToEnabled(items: readonly string[], disabled: readonly string[], value: string | null): string | null
pushDismissable() from @llui/components/utils
Register a dismissable layer. Escape is offered to the layers top-down until
one CLAIMS it (a layer declines via disableEscape or an onEscape router
returning false, and the key then falls through to the layer beneath);
outside-click is topmost-only. Returns a cleanup that removes the layer from
the stack.
Push a layer even when both dismissal routes are disabled: the layer is the caller's PLACE ON THE STACK, which is what stops the layer beneath from treating an interaction inside this one as an outside interaction.
export declare function pushDismissable(opts: DismissableOptions): () => void;
pushFocusTrap() from @llui/components/utils
Push a focus trap onto the stack. Tab/Shift+Tab will cycle within the container's focusable descendants. Returns a cleanup that removes the trap and (optionally) restores focus to the element active before push.
export declare function pushFocusTrap(opts: FocusTrapOptions): () => void;
rectPath() from @llui/components/utils
An axis-aligned rectangle, corners in any order.
function rectPath(x0: number, y0: number, x1: number, y1: number): string
registerNestedLayer() from @llui/components/utils
Register source (an element, array of elements, or a resolver returning
either) as a nested layer. Returns a cleanup that removes the registration.
Prefer the resolver form for a portaled overlay: register once on mount and
return the live root only while open ([] when closed), so a single
registration tracks the overlay's open/closed lifecycle without churn.
Pass opts.owner when scoped consumers must exempt the layer. Missing or
unresolved ownership fails closed and warns in development.
export declare function registerNestedLayer(source: ElementSource, opts?: NestedLayerOptions): () => void;
resetAnatomyIdCounter() from @llui/components/utils
Reset the internal id counter — tests only.
function resetAnatomyIdCounter(): void
resolveDir() from @llui/components/utils
Resolve the text direction for an element by walking up the DOM tree. Returns 'rtl' or 'ltr' (default).
The walk crosses SHADOW boundaries: Element.closest() stops at the
nearest shadow root and cannot see a dir set on an ancestor of the host,
so an element inside a shadow tree whose host (or the host's own
ancestors) declares dir would otherwise silently read as ltr. Continuing
from root.host after getRootNode() returns a ShadowRoot walks out to
the light-DOM ancestor and keeps going, arbitrarily many shadow levels
deep. The final fallback reads dir off the element's OWN document
(ownerDocument), never the global document — the global binding names a
DIFFERENT document inside an iframe or any other multi-document context.
export declare function resolveDir(el: Element): TextDirection;
resolveNone() from @llui/components/utils
A null (CSS none) component behaves as 0 outside interpolation.
function resolveNone(n: number | null): number
resolveRovingMove() from @llui/components/utils
Map a keyboard key + the current tab value to a roving-tablist move,
or null when the key isn't a navigation/activation key or the move is
a no-op (empty list, no enabled sibling). Pure — does not touch the DOM
or call preventDefault; the caller decides (typically: prevent default
iff the result is non-null).
export declare function resolveRovingMove(key: string, current: string, items: readonly RovingItem[], opts?: RovingOptions): RovingMove | null;
resolveTextDirection() from @llui/components/utils
Normalize any accepted direction source to a concrete TextDirection.
An explicit 'ltr' | 'rtl' wins; an Element is resolved from the DOM;
null / undefined default to 'ltr'.
export declare function resolveTextDirection(source: Element | null | undefined | TextDirection): TextDirection;
rgb255ToHsl() from @llui/components/utils
Convert RGB (0-255 each) to HSL (h 0-360, s/l 0-100), float.
function rgb255ToHsl(rgb: Rgb255): Hsl
rgb255ToHsv() from @llui/components/utils
RGB255 -> HSV, via the DIRECT (not HSL-routed) float conversion.
function rgb255ToHsv(rgb: Rgb255): Hsv
rovingTabStop() from @llui/components/utils
The single item that carries tabindex="0".
WAI-ARIA's roving-tabindex pattern requires EXACTLY ONE tab stop in a
composite widget: with none, Tab skips the widget entirely and it becomes
keyboard-unreachable. So a preferred candidate (the focused item, the
checked radio, …) is honoured only while it is still an enabled member, and
the first enabled item answers otherwise. Null only when nothing is enabled.
Every roving-tabindex widget in the package routes through here (#145 closed
the last three: menubar, navigation-menu and tags-input). Keep it that way —
an inline focused === x ? 0 : -1 has no fallback, and since nothing prunes
focused against the current list, removing or disabling the focused item
leaves EVERY item at -1 and the widget disappears from the Tab order.
tags-input is index-keyed and passes String(i) as the item identity (its
data-index, and the only identity that survives duplicate tag values);
navigation-menu passes either the membership list its consumer maintains or
the ids handed to its own item(), filtered first to the ones not sealed
inside a closed submenu — membership alone would seat the stop on an element
inside a hidden panel, which is present, unique and untabbable.
Null when nothing is enabled is deliberate and is a caller's problem to
notice: a widget whose items are ALL disabled ends up with no tab stop at
all. That is right for radio-group/toggle-group/toolbar/tree-view,
whose items are genuinely disabled and therefore unfocusable anyway, and it
is a 1 -> 0 change for menubar, whose triggers carry only aria-disabled
and stay focusable. Any revision belongs here, applying to every caller at
once — not in one component.
function rovingTabStop(items: readonly string[], disabled: readonly string[], ...preferred: readonly (string | null | undefined)[]): string | null
runEngineFocus() from @llui/components/utils
Run body with engine-focus suppression active. Any focusin raised inside
it is invisible to watchInteractOutside — including one raised by a focus
move that re-entrant consumer code makes from a focusin listener (see the
module comment: the window is the synchronous transitive closure, not just
the .focus() call).
SYNCHRONOUS BY CONTRACT, AND THE CONTRACT IS ENFORCED (#172). The suppression
is released when body RETURNS. An async body returns its promise at the
first await, so the depth counter drops immediately and the focus move that
eventually happens gets NO protection at all — a call that looks correct,
compiles, and does nothing. The failure is safe (no protection, never a stuck
guard: the decrement is in a finally), which is exactly why it is invisible,
and this is a PUBLIC export documented as the thing a custom overlay "must"
route its engine-initiated focus moves through. A consumer following that
advice with an async body would reintroduce #155 in their app while believing
they had prevented it.
Two guards, because neither covers the other's case:
- The SIGNATURE rejects a promise-returning body at compile time. It is the real guard — it fires before the code ever runs. Its one blind spot is a body whose return type is an unresolved type parameter (a generic pass-through wrapper): the conditional is deferred, so such a wrapper is rejected too and must carry its own constraint. No caller does.
- A DEV-MODE warning catches what the type system cannot see: a JavaScript
consumer, an
any-typed body, or a body that returns a thenable without being declared as returning one. It cannot restore the protection — by the time a thenable is in hand the guard is already released — so it only reports.
Deliberately NOT offered: an async-aware variant that holds the guard across
an await. The guard is safe because no user event can be delivered inside
its window (see the module comment); holding it across a suspension point
hands the event loop back and would start swallowing genuine interactions.
export declare function runEngineFocus<T>(body: () => SyncEngineFocusBody<T>): T;
setAriaHiddenOutside() from @llui/components/utils
Hide sibling subtrees from assistive tech while an overlay is open.
Walks from target up to the document root, applying aria-hidden="true"
and inert to every sibling at each level. Previous attribute values are
recorded and restored on cleanup.
Two kinds of element are EXEMPT: registered nested layers (see
registerNestedLayer) and live regions — aria-live, role="alert",
role="status", role="log". A live region under aria-hidden is simply
never read out, so a modal that sweeps one silences the app's announcement
channel for exactly as long as it is open (#123). Live regions are matched by
selector rather than registration because they are plain part bags with no
mount hook of their own, and because that also covers consumer-authored ones.
An exempt element does NOT spare its whole ancestor subtree: the sweep
descends through any element that merely CONTAINS one and hides everything
hanging off the path down to it. (Skipping the ancestor wholesale would leave
the entire app interactive behind the modal the moment a form somewhere held
an aria-live error message.) inert and aria-hidden both inherit, so
leaving the path clear is the only way an exempt element stays reachable.
Nested calls are supported — each layer only touches elements that haven't been claimed by a higher layer (tracked via a WeakMap reference count).
TWO KNOWN LIMITS of the live-region exemption, both deliberate:
inertcannot be split fromaria-hidden— sparing a region spares its WHOLE SUBTREE from both, so an interactive live region (arole="log"transcript containing links) stays Tab-reachable behind a modal. Keep live regions to announcement text; put controls outside them, or register the modal's own layer for the interactive part.document.querySelectorAlldoes not pierce shadow roots, so a live region inside one is not exempt. The sweep itself only ever walks light-DOM ancestors oftarget, so this only bites when the region and the modal live in different trees.
export declare function setAriaHiddenOutside(target: Element): () => void;
snapToStep() from @llui/components/utils
Nearest multiple of step from origin. A non-positive step is a no-op.
A non-finite value names no position on the grid, so it snaps to the grid's
own anchor — the same policy clamp applies to the range (#152). This is
unreachable from clampToStep, which clamps first; it keeps the util's
direct consumers on the same rule.
function snapToStep(value: number, step: number, origin = 0): number
srgb255ToSrgb() from @llui/components/utils
function srgb255ToSrgb(rgb: Rgb255): Srgb
srgbToLinear() from @llui/components/utils
function srgbToLinear(s: Srgb): Srgb
srgbToOklab() from @llui/components/utils
function srgbToOklab(s: Srgb): Oklab
srgbToOklch() from @llui/components/utils
function srgbToOklch(s: Srgb): Oklch
srgbToRgb255() from @llui/components/utils
Rounds AND clamps into 0-255 — the boundary where an out-of-gamut float (an un-mapped OKLCH conversion) must finally become a displayable byte.
function srgbToRgb255(s: Srgb): Rgb255
stepBy() from @llui/components/utils
Move count whole steps (negative to step down), then clamp+snap.
From an OFF-GRID value one call moves to the nearest grid value in the
direction of travel and stops there — that jump is the whole change, however
large count is. This is HTML's stepUp/stepDown (step 3 of the
value-stepping algorithm) and it is what makes increment land on the grid
instead of dragging an off-grid value along forever.
ONE DELIBERATE DIVERGENCE from the spec: HTML's step base falls back min ->
the value CONTENT ATTRIBUTE -> 0; gridOrigin goes min -> 0. A headless
machine has no content attributes — the seed value is just the initial state,
and anchoring the grid on it would make two components with the same
min/max/step disagree about which values are legal depending on where they
happened to start.
function stepBy(value: number, count: number, grid: NumericGrid): number
stepPath() from @llui/components/utils
Axis-aligned staircase: hold each value until halfway along the independent axis, then step.
function stepPath(points: readonly Point[], axis: CurveAxis = 'x'): string
tickIncrement() from @llui/components/utils
The step size a nice axis would use to fit about count ticks into
[start, stop]. A negative return means "one tick every 1/-step units" —
d3's encoding, kept because the reciprocal form avoids the float drift that
10 ** -power introduces for small steps.
function tickIncrement(start: number, stop: number, count: number): number
ticks() from @llui/components/utils
Round, human-readable tick values covering [start, stop]. Returns an EMPTY
array for a non-finite or degenerate range rather than looping forever —
a chart with no data is a normal state, not an error.
function ticks(start: number, stop: number, count: number): number[]
typeaheadAccumulate() from @llui/components/utils
Advance the typeahead query based on a new keystroke and the previous
expiration time. Returns the new query string; callers combine this with
typeaheadMatch() to produce a new highlight index.
function typeaheadAccumulate(prev: string, char: string, now: number, expiresAt: number): string
typeaheadMatch() from @llui/components/utils
Find the first enabled item whose label starts with the query
(case-insensitive). labels and disabledMask are parallel arrays.
startFrom is the current highlighted index; for single-character
queries the search begins at startFrom + 1 (so repeated "s" keys
cycle), for multi-character queries it begins at startFrom (inclusive).
Returns the matching index, or null if no enabled item matches.
function typeaheadMatch(labels: string[], disabledMask: boolean[], query: string, startFrom: number | null): number | null
typeaheadMatchByItems() from @llui/components/utils
Convenience: pass a disabled list of values instead of a boolean mask.
Builds the mask by checking membership via === on the raw string values.
function typeaheadMatchByItems(items: string[], disabled: readonly string[], query: string, startFrom: number | null): number | null
valueDomain() from @llui/components/utils
The value-axis domain for a set of series.
min / max are UNBOUNDED-CAPABLE bounds in the sense of finiteBound
(#177): an absent one means "derive it", never ±Infinity. A derived domain
always INCLUDES ZERO, because a bar whose baseline is not zero misrepresents
its own length — the one axis default worth being opinionated about. A caller
who wants otherwise passes both bounds.
function valueDomain(values: readonly number[], opts: { min?: number; max?: number; tickCount: number }): Domain
watchInteractOutside() from @llui/components/utils
Watch for pointer or focus events outside a given element. Returns a
cleanup function. Uses the capture phase so upstream stopPropagation
calls cannot hide events.
- pointerdown (or mousedown/touchstart fallback) triggers "outside" if the
target is not contained by
elementorignore. - focusin triggers "outside" when focus moves outside the element, except
when the new target is in
ignore.
export declare function watchInteractOutside(opts: InteractOutsideOptions): () => void;
Types
CalendarUnit from @llui/components/utils
Rungs of the ladder, finest first.
export type CalendarUnit = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'
CssColor from @llui/components/utils
A color as CSS Color 4 sees it. lab()/lch()/wide-gamut color() are
NOT separate tags here — see the module doc — they resolve to 'oklch' (or
'srgb' for color(srgb ...)/color(srgb-linear ...)) at parse time.
export type CssColor =
| { space: 'srgb'; r: number | null; g: number | null; b: number | null; alpha: number | null }
| {
space: 'srgb-linear'
r: number | null
g: number | null
b: number | null
alpha: number | null
}
| { space: 'hsl'; h: number | null; s: number | null; l: number | null; alpha: number | null }
| { space: 'oklab'; l: number | null; a: number | null; b: number | null; alpha: number | null }
| { space: 'oklch'; l: number | null; c: number | null; h: number | null; alpha: number | null }
Curve from @llui/components/utils
How a run of points is joined.
export type Curve = 'linear' | 'monotone' | 'step'
DateValue from @llui/components/utils
Date-only handling — the ONE place that decides whether a value denotes an INSTANT or a bare CALENDAR DATE.
new Date('2026-01-15') parses a date-only string as UTC midnight (ES spec),
so formatting it against the ambient zone renders the PREVIOUS day everywhere
west of UTC: formatDate('2026-01-15') printed "January 14, 2026" under
America/New_York and the right answer under Europe/Rome, which is why it
survived (#125 defect 4).
A calendar date carries no instant and therefore no zone, so it is anchored
at UTC midnight and its consumers must render it in UTC — see dateOnly.
export type DateValue = Date | string | number
DismissSource from @llui/components/utils
Reason a dismissable layer was closed.
export type DismissSource = 'escape' | 'outside';
ElementSource from @llui/components/utils
Shared DOM helpers used by interaction utilities.
export type ElementSource<T extends Element = Element> = T | T[] | (() => T | T[] | null)
HueInterpolationMethod from @llui/components/utils
export type HueInterpolationMethod = 'shorter' | 'longer' | 'increasing' | 'decreasing'
InterpolationSpace from @llui/components/utils
Interpolation / generic-conversion target spaces (CSS Color 4 §12).
export type InterpolationSpace = 'srgb' | 'srgb-linear' | 'oklab' | 'oklch' | 'hsl'
NestedLayerAspect from @llui/components/utils
A consumer of the registry. A registration participates only in the aspects it
names, because a single answer is wrong for at least one consumer: engine
overlays leave outside to the ordered dismissable stack (see the module
comment).
The dialog-with-an-inner-select case is NOT what the aspect list protects.
That one is covered by a modal never registering AT ALL, whatever aspects it
would have named.
outside— {@link watchInteractOutside} does not treat interactions inside the layer as outside interactions.focus— {@link pushFocusTrap} includes the layer as an extra focusable container, so Tab/Shift+Tab can reach it.hide— {@link setAriaHiddenOutside} hides AROUND the layer rather than hiding it.
export type NestedLayerAspect = 'outside' | 'focus' | 'hide';
NestedLayerScope from @llui/components/utils
The asking layer's own boundary — what "nested inside ME" is measured against. Omit it for the flat, layer-agnostic answer.
export type NestedLayerScope = ElementSource;
Placement from @llui/components/utils
export declare type Placement = Prettify<Side | AlignedPlacement>;
RovingMove from @llui/components/utils
The navigation a key implies on a roving tablist.
export type RovingMove =
/** An arrow / Home / End resolved to a (different, enabled) tab value. */
{
type: 'focus';
value: string;
}
/** Enter or Space — activate the currently focused tab (manual mode). */
| {
type: 'activate';
};
RovingOrientation from @llui/components/utils
Headless roving-tablist navigation — the keyboard logic of a WAI-ARIA tablist, decoupled from any particular DOM contract.
components/tabs.ts builds its reactive part-bags on top of this; a
consumer that wants its OWN markup (different classes, ids, no
data-scope/data-part) can drive the same keyboard behaviour by
calling resolveRovingMove from its trigger's onKeyDown and
focusRovingTab to move DOM focus — without adopting the component's
markup or its connect() state machine.
The resolver is pure (key + current value + items → a move); the only
shared DOM assumption lives in focusRovingTab, and it is the minimal
one both surfaces already satisfy: triggers carry role="tab" and
data-value="<value>".
The list walk itself lives in list-navigation.ts — this module is the
keyboard + DOM-focus surface over it, nothing more.
export type RovingOrientation = 'horizontal' | 'vertical';
SyncEngineFocusBodyRequired from @llui/components/utils
The type an ASYNC body collapses to in {@link runEngineFocus}'s parameter
position. Nothing is assignable to it, so runEngineFocus(async () => …) is a
compile error naming the contract rather than a silently inert call.
export type SyncEngineFocusBodyRequired = {
readonly [SYNC_BODY_REQUIRED]: 'runEngineFocus requires a SYNCHRONOUS body — the guard is released the moment body returns';
};
TextDirection from @llui/components/utils
Text reading direction. The single shared RTL vocabulary for the package.
export type TextDirection = 'ltr' | 'rtl';
Interfaces
Anatomy from @llui/components/utils
export interface Anatomy<P extends string> {
readonly name: string
readonly parts: readonly P[]
/** Create a new scope instance. Pass an explicit id to force a value (SSR). */
scope(id?: string): AnatomyScope<P>
}
AnatomyScope from @llui/components/utils
export interface AnatomyScope<P extends string> {
/** Instance id — unique across all anatomy scopes. */
readonly id: string
/** Resolve the id for a specific part (for ARIA wiring). */
idFor(part: P): string
/** Build the common data-attrs + id for a part. */
attrs(part: P): { id: string; 'data-scope': string; 'data-part': P }
}
Band from @llui/components/utils
A discrete axis of count slots laid out across [0, 1].
export interface Band {
count: number
/** Gap between adjacent bands, as a fraction of a slot. */
paddingInner: number
/** Gap before the first and after the last band, as a fraction of a slot. */
paddingOuter: number
}
CalendarOptions from @llui/components/utils
export interface CalendarOptions {
/**
* Minutes east of UTC that boundaries are measured in. Default 0 (UTC). A
* FIXED offset — see the module note; `-300` is US Eastern standard time and
* stays at −5 across a DST transition rather than following it.
*/
offsetMinutes?: number
/** First day of the week for the `week` rung: 0 = Sunday … 6 = Saturday.
* Default 1 (Monday), matching ISO 8601. */
weekStartsOn?: number
}
CalendarStep from @llui/components/utils
A chosen rung, plus how many of it each boundary advances. stride is
always 1 except on year.
export interface CalendarStep {
unit: CalendarUnit
stride: number
}
CalendarStepOptions from @llui/components/utils
export interface CalendarStepOptions extends CalendarOptions {
/** Upper bound on the number of boundaries. Default 6. */
maxTicks?: number
/** Force a rung instead of choosing one from the span. */
unit?: CalendarUnit
}
CalendarTick from @llui/components/utils
export interface CalendarTick {
/** The boundary instant, in epoch milliseconds. */
at: number
unit: CalendarUnit
}
CartesianOptions from @llui/components/utils
export interface CartesianOptions {
/** Swap the axes: `u` runs top-to-bottom and `v` left-to-right. This is what
* turns a column chart into a horizontal bar chart, and it is a PROJECTION
* concern — no mark, scale or hit test changes. */
horizontal?: boolean
}
DismissableOptions from @llui/components/utils
export interface DismissableOptions {
/** The layer element (e.g. a dialog content or popover). */
element: ElementSource;
/** Trigger / anchor elements that should not count as outside interactions. */
ignore?: ElementSource;
/** Called when the user dismisses the layer. */
onDismiss: (source: DismissSource, event: Event) => void;
/**
* Custom Escape router. When provided it runs for the Escape key INSTEAD of
* `onDismiss('escape', …)`, letting the layer unwind an internal level first
* (e.g. a menu closes its open submenu before closing the whole menu). Return
* `false` to decline — the event is not claimed and propagates as if this
* layer had `disableEscape`. Any other return (incl. `undefined`) claims it.
*/
onEscape?: (event: KeyboardEvent) => boolean | void;
/** Disable outside-click dismissal (default: false). */
disableOutside?: boolean;
/** Disable Escape-key dismissal (default: false). */
disableEscape?: boolean;
}
Domain from @llui/components/utils
A closed numeric interval. Both ends are finite by construction.
export interface Domain {
min: number
max: number
}
FloatingOptions from @llui/components/utils
export interface FloatingOptions {
/** The reference element (trigger/anchor). */
anchor: Element;
/** The floating element (content). */
floating: HTMLElement;
/**
* Element that receives the resolved full `data-placement` and physical
* `data-side`. Defaults to `floating`. Use a separate content element when
* `floating` is a geometry-only positioner wrapper.
*/
stateTarget?: HTMLElement;
/** Preferred placement (default: 'bottom'). */
placement?: Placement;
/** Gap between anchor and floating, in px (default: 0). */
offset?: number;
/** Flip to opposite side when there isn't enough room (default: true). */
flip?: boolean;
/** Shift along axis to stay in view (default: padding 8 unless false). */
shift?: boolean | {
padding?: number;
};
/**
* Reading direction. Under `'rtl'`, logical `*-start`/`*-end` placements
* track the inline-start/inline-end edges. When given it is AUTHORITATIVE —
* it overrides the direction the floating element happens to compute to,
* which for a portaled overlay is the direction of wherever it landed.
* Omit it to leave that decision to the page, as floating-ui does by default.
*/
dir?: TextDirection;
/** Optional arrow element to position. */
arrow?: HTMLElement;
/** Notify after each position computation. */
onUpdate?: (data: {
x: number;
y: number;
placement: Placement;
arrow?: {
x?: number;
y?: number;
};
}) => void;
}
FocusRestoreQuery from @llui/components/utils
The one rule for "should this layer pull focus back to its anchor?".
Restoring focus to the trigger is right when the layer was closed with focus still resting inside it (Escape, a close button, a programmatic close) — the user's focus would otherwise be left on a detached node. It is WRONG when the dismissal was caused by focus moving somewhere the user chose: yanking it back to the trigger takes focus away from the control they just reached, and can leave a still-open layer with focus outside it (#173).
document.body (and a null activeElement) counts as "inside" because that
is where focus lands when the focused element is removed — nobody chose it, so
the anchor is a better home than the body.
Both callers must ask BEFORE tearing anything down: the focus trap's release
and the aria-hidden/inert sweep both move or invalidate activeElement,
so a decision taken after them is a decision about the engine's own cleanup.
export interface FocusRestoreQuery {
/** The region that counts as "inside" this layer. */
boundary: Element
/** The element focus would be restored TO. */
anchor?: Element | null
/**
* Also treat the anchor itself being focused as "inside" (`select`, which
* focuses its own trigger on open — without this its restore reads as "the
* user moved focus to the trigger" and never runs).
*/
allowAnchorActive?: boolean
}
FocusTrapOptions from @llui/components/utils
export interface FocusTrapOptions {
/** The container whose focusable descendants form the trap. */
container: ElementSource;
/** Element to focus when the trap activates. Defaults to the first
* tab-reachable descendant, else the container itself (given a temporary
* `tabindex="-1"` when it has none). */
initialFocus?: Element | (() => Element | null);
/** Restore focus to the previously active element on release (default: true). */
restoreFocus?: boolean;
}
Frame from @llui/components/utils
The plot area in user units — the box marks are drawn inside, already inset by whatever padding the axes need.
export interface Frame {
x: number
y: number
width: number
height: number
}
Hsl from @llui/components/utils
HSL color. h 0–360 degrees, s/l 0–100. Float — see the module doc.
export interface Hsl {
h: number
s: number
l: number
}
Hsv from @llui/components/utils
HSV color. h 0–360 degrees, s/v 0–100. Float — see the module doc.
export interface Hsv {
h: number
s: number
v: number
}
InteractOutsideOptions from @llui/components/utils
export interface InteractOutsideOptions {
/** Element(s) that define the "inside" region. */
element: ElementSource;
/** Additional elements whose interactions should not count as outside (e.g. triggers). */
ignore?: ElementSource;
/** Called on pointerdown or focus outside the inside region. */
onInteractOutside: (event: Event) => void;
/**
* If provided, called first with the event. Return `false` to suppress the
* outside callback (for an in-flight layer to claim the event).
*/
shouldDispatch?: (event: Event) => boolean;
}
NestedLayerOptions from @llui/components/utils
export interface NestedLayerOptions {
/**
* Consumers this registration participates in. Defaults to all of them, which
* is what a surface with no dismissable layer of its own needs. Narrow it when
* another mechanism already covers an aspect — see the module comment.
*/
aspects?: readonly NestedLayerAspect[];
/**
* The element this layer is logically nested INSIDE — its trigger/anchor, or
* the host element it belongs to. This is what makes the registry per-layer:
* an asking layer exempts this registration only when the owner is inside the
* asker's own boundary (transitively through other nested layers).
*
* The owner is NOT the layer's portal root — that is the `source` argument.
* It is the thing in the main document tree that the portal speaks for.
*
* Resolver form is supported and re-read on every lookup, so an owner that
* mounts and unmounts with its component can be named once.
*
* A missing or unresolved owner grants no scoped exemption and emits a
* development warning. Even a registration used only through the unscoped
* registry-wide view should name its logical owner to keep the contract
* explicit.
*/
owner?: ElementSource;
}
NumericGrid from @llui/components/utils
A stepped numeric range. min/max default to unbounded and step to 0
(= continuous). Component states name their fields the same way, so a state
object can be passed straight in.
export interface NumericGrid {
min?: number
max?: number
step?: number
}
Oklab from @llui/components/utils
Oklab. l is 0–1 lightness; a/b are unbounded (±0.4 roughly spans
sRGB).
export interface Oklab {
l: number
a: number
b: number
}
Oklch from @llui/components/utils
OKLCH. l 0–1, c >= 0 (unbounded — maxChroma in the color picker is a
UI choice, not a property of the color space), h in degrees.
export interface Oklch {
l: number
c: number
h: number
}
ParsedDateValue from @llui/components/utils
export interface ParsedDateValue {
date: Date
/**
* True when the input was a bare calendar date. Formatters MUST then render
* in UTC (where the anchor was taken), otherwise the ambient zone shifts the
* rendered day.
*/
dateOnly: boolean
}
Point from @llui/components/utils
export interface Point {
x: number
y: number
}
PointerDragCallbacks from @llui/components/utils
One call site's drag callbacks.
export interface PointerDragCallbacks {
/** Checked on `pointerdown` only — a drag that is already underway keeps
* running even if this flips true mid-drag (the reducer's own `disabled`
* guard is the actual safety net for a message dispatched from `onDrag`
* after that point; this only decides whether a NEW drag may start). */
isDisabled: () => boolean
/** Fires once, on `pointerdown`, after capture is acquired and BEFORE the
* first `onDrag` call — the hook for a one-time side effect like moving
* focus onto a thumb element. */
onDragStart?: (e: PointerEvent) => void
/** Fires on `pointerdown` (immediately after `onDragStart`) and again on
* every `pointermove` while the drag is live. */
onDrag: (e: PointerEvent) => void
/** Fires once on `pointerup`/`pointercancel`, but ONLY when a drag was
* actually live (never on a stray up/cancel with no matching down) — the
* counterpart to `onDragStart`, for a caller that tracks per-drag identity
* across the lifecycle (`gradient-picker`'s track drag creates a new stop
* on `pointerdown` and must stop targeting it once the drag ends). Runs
* AFTER pointer capture is released. */
onDragEnd?: (e: PointerEvent) => void
}
PointerDragHandlers from @llui/components/utils
export interface PointerDragHandlers {
onPointerDown: (e: PointerEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerUp: (e: PointerEvent) => void
onPointerCancel: (e: PointerEvent) => void
/** The browser can revoke pointer capture without ever firing `pointerup`/
* `pointercancel` (an OS-level interruption, a nested capture request, …).
* Left unwired, `dragging` would stay `true` forever and a later, UNRELATED
* pointer's `pointermove` would be misread as a continuation of this drag.
* Spread onto the same element as the other four. */
onLostPointerCapture: (e: PointerEvent) => void
}
PolarOptions from @llui/components/utils
export interface PolarOptions {
/** Inner radius as a fraction of the outer. `0` is a pie, `0.5` a donut, and
* a radar usually wants `0`. */
innerRadius?: number
/** Where `u = 0` sits, in radians clockwise from 12 o'clock. */
startAngle?: number
/** Total sweep in radians. Defaults to a full turn, which is what makes the
* projection `closed`. */
sweep?: number
/**
* Gridline shape. `ring` is a circle at the given magnitude — right for a
* pie or a rose. `web` joins the `spokes` sample positions with straight
* segments — right for a radar, where a circular grid reads as a different
* chart from the polygonal data on top of it.
*/
grid?: 'ring' | 'web'
/** How many spokes a `web` gridline has. Ignored for `ring`. */
spokes?: number
/**
* Swap the axes, exactly as {@link CartesianOptions.horizontal} does: the
* INDEPENDENT axis moves off the angle and onto the RADIUS, so each category
* becomes its own concentric ring and magnitude is read as arc length. That
* is a radial bar chart, and it is the polar reading of the same flag that
* turns a column chart on its side — one field, the same meaning in both
* coordinate systems, so a consumer flipping `coord` keeps whatever
* orientation they asked for.
*
* The independent axis is then the radius, which is NOT a loop, so the
* projection reports `closed: false` however wide the sweep is.
*/
horizontal?: boolean
}
Projection from @llui/components/utils
export interface Projection {
readonly kind: ChartCoord
/**
* Whether the independent axis is a CLOSED loop. `true` for a polar
* projection covering a full turn, which is what makes a radar outline join
* back to its first vertex instead of leaving a gap.
*/
readonly closed: boolean
/**
* Curves this projection can honour. A caller asking for one outside the set
* gets `linear` — see the polar note below for why this is the honest answer
* rather than a silent approximation.
*/
readonly curves: readonly Curve[]
/** Normalized (u, v) → a point in user units. */
point(u: number, v: number): Point
/** A run of samples joined along increasing `u`. */
line(samples: readonly Sample[], curve: Curve): string
/** The region between an upper run and a lower run of the same length. */
area(upper: readonly Sample[], lower: readonly Sample[], curve: Curve): string
/** A band spanning `u0..u1` and `v0..v1`: a bar, or a wedge. */
band(u0: number, u1: number, v0: number, v1: number): string
/** The iso-magnitude line at `v` — a value gridline. */
gridline(v: number): string
/** Where the independent-axis tick for `u` belongs. */
tick(u: number): TickPlacement
/** Where the DEPENDENT-axis tick for `v` belongs. */
valueTick(v: number): TickPlacement
/**
* Invert a point in user units back to `u`, for pointer hit testing. Returns
* `null` when the point carries no meaningful `u` — the exact centre of a
* polar chart, where every angle is equally close.
*/
locate(x: number, y: number): number | null
}
Rgb255 from @llui/components/utils
8-bit-per-channel sRGB, 0–255 (integers once produced by this module).
export interface Rgb255 {
r: number
g: number
b: number
}
RovingItem from @llui/components/utils
export interface RovingItem {
value: string;
/** Disabled items are skipped by arrow/Home/End navigation. */
disabled?: boolean;
}
RovingOptions from @llui/components/utils
export interface RovingOptions {
/** Arrow axis — 'horizontal' uses Left/Right, 'vertical' uses Up/Down. Default 'horizontal'. */
orientation?: RovingOrientation;
/** Whether arrow navigation wraps at the ends. Default true. */
loop?: boolean;
/**
* An element used to resolve text direction for RTL arrow flipping
* (typically the event's `currentTarget`). When it resolves to
* `dir="rtl"`, ArrowLeft/ArrowRight swap. Optional.
*/
element?: Element | null;
}
Sample from @llui/components/utils
A normalized sample: position along the independent axis, and magnitude.
export interface Sample {
u: number
v: number
}
Srgb from @llui/components/utils
sRGB, gamma-encoded, 0–1 per channel. May fall outside [0,1] as an intermediate value (e.g. converted from an out-of-gamut OKLCH) — callers that need an in-gamut result go through {@link gamutMapOklchToSrgb}.
export interface Srgb {
r: number
g: number
b: number
}
TickPlacement from @llui/components/utils
Where an independent-axis tick label sits, and how to align it there.
export interface TickPlacement {
x: number
y: number
/** SVG `text-anchor`. */
anchor: 'start' | 'middle' | 'end'
/** SVG `dominant-baseline`. */
baseline: 'auto' | 'middle' | 'hanging'
}
TreeNode from @llui/components/utils
TreeCollection — helper for building tree-view payloads from a nested data structure. Tree-view's state machine only knows about flat lists (visibleItems, visibleLabels) and opaque ids; the collection owns the structure and derives those flat arrays on demand.
Typical flow:
const col = new TreeCollection(data) state.visibleItems = col.visibleItems(state.expanded) state.visibleLabels = col.visibleLabels(state.expanded)
After every expand / collapse message, the consumer dispatches
setVisibleItems with the updated arrays. The collection itself is
immutable — build a new one when the tree structure changes.
export interface TreeNode {
id: string
label?: string
disabled?: boolean
children?: TreeNode[]
}
Classes
TreeCollection from @llui/components/utils
class TreeCollection {
roots: TreeNode[]
info: Map<string, NodeInfo>
constructor(roots: TreeNode | TreeNode[])
index(): void
getNode(id: string): TreeNode | null
getLabel(id: string): string
getParent(id: string): string | null
getDepth(id: string): number
getChildren(id: string): string[]
getDescendants(id: string): string[]
isBranch(id: string): boolean
isDisabled(id: string): boolean
visibleItems(expanded: string[]): string[]
visibleLabels(expanded: string[]): string[]
computeIndeterminate(checked: Set<string>): string[]
}
Constants
ALL_NESTED_LAYER_ASPECTS from @llui/components/utils
Every aspect — the default for a registration that names none.
const ALL_NESTED_LAYER_ASPECTS: readonly NestedLayerAspect[]
MAX_CALENDAR_TICKS from @llui/components/utils
A hard ceiling on the boundaries one call will produce. Reached only by
FORCING a fine unit across an enormous span; the automatic ladder can never
exceed maxTicks. Over the ceiling the answer is an EMPTY array — no
gridlines — rather than a truncated one, because a truncated axis silently
claims the data ends where the array does.
const MAX_CALENDAR_TICKS
TYPEAHEAD_TIMEOUT_MS from @llui/components/utils
Typeahead search — accumulates keystrokes into a query while the user types rapidly, then matches the first item whose label starts with the query. Used by listbox, menu, select, combobox, tree-view to support WAI-ARIA keyboard navigation patterns.
Behavior:
- If a keystroke arrives within
TYPEAHEAD_TIMEOUT_MSof the previous one, append to the existing query (so typing "sa" finds "Saturn" even if the highlight is currently on "Jupiter"). - Otherwise, start a fresh single-character query.
- Single-character queries advance past the current position (jump to the next item starting with that letter), which is the standard WAI-ARIA behavior — rapid repeated presses of "s" cycle through items beginning with "s".
- Multi-character queries search from the current cursor position (inclusive) so if the cursor is already on a matching item, it stays — typing "ap" while on "apricot" keeps focus on "apricot".
const TYPEAHEAD_TIMEOUT_MS
@llui/components/utils/anatomy
Functions
anatomy() from @llui/components/utils/anatomy
function anatomy<P extends string>(name: string, parts: readonly P[]): Anatomy<P>
resetAnatomyIdCounter() from @llui/components/utils/anatomy
Reset the internal id counter — tests only.
function resetAnatomyIdCounter(): void
Interfaces
Anatomy from @llui/components/utils/anatomy
export interface Anatomy<P extends string> {
readonly name: string
readonly parts: readonly P[]
/** Create a new scope instance. Pass an explicit id to force a value (SSR). */
scope(id?: string): AnatomyScope<P>
}
AnatomyScope from @llui/components/utils/anatomy
export interface AnatomyScope<P extends string> {
/** Instance id — unique across all anatomy scopes. */
readonly id: string
/** Resolve the id for a specific part (for ARIA wiring). */
idFor(part: P): string
/** Build the common data-attrs + id for a part. */
attrs(part: P): { id: string; 'data-scope': string; 'data-part': P }
}
@llui/components/utils/aria-hidden
Functions
setAriaHiddenOutside() from @llui/components/utils/aria-hidden
Hide sibling subtrees from assistive tech while an overlay is open.
Walks from target up to the document root, applying aria-hidden="true"
and inert to every sibling at each level. Previous attribute values are
recorded and restored on cleanup.
Two kinds of element are EXEMPT: registered nested layers (see
registerNestedLayer) and live regions — aria-live, role="alert",
role="status", role="log". A live region under aria-hidden is simply
never read out, so a modal that sweeps one silences the app's announcement
channel for exactly as long as it is open (#123). Live regions are matched by
selector rather than registration because they are plain part bags with no
mount hook of their own, and because that also covers consumer-authored ones.
An exempt element does NOT spare its whole ancestor subtree: the sweep
descends through any element that merely CONTAINS one and hides everything
hanging off the path down to it. (Skipping the ancestor wholesale would leave
the entire app interactive behind the modal the moment a form somewhere held
an aria-live error message.) inert and aria-hidden both inherit, so
leaving the path clear is the only way an exempt element stays reachable.
Nested calls are supported — each layer only touches elements that haven't been claimed by a higher layer (tracked via a WeakMap reference count).
TWO KNOWN LIMITS of the live-region exemption, both deliberate:
inertcannot be split fromaria-hidden— sparing a region spares its WHOLE SUBTREE from both, so an interactive live region (arole="log"transcript containing links) stays Tab-reachable behind a modal. Keep live regions to announcement text; put controls outside them, or register the modal's own layer for the interactive part.document.querySelectorAlldoes not pierce shadow roots, so a live region inside one is not exempt. The sweep itself only ever walks light-DOM ancestors oftarget, so this only bites when the region and the modal live in different trees.
export declare function setAriaHiddenOutside(target: Element): () => void;
@llui/components/utils/calendar-ticks
Functions
addUnits() from @llui/components/utils/calendar-ticks
Advance an instant by n whole units, in the offset's calendar.
function addUnits(at: number, unit: CalendarUnit, n: number, opts: CalendarOptions = {}): number
calendarTicks() from @llui/components/utils/calendar-ticks
Calendar boundaries inside [start, end], ascending.
An EMPTY array for a non-finite or reversed range, and for a request that would exceed {@link MAX_CALENDAR_TICKS} — a chart with no data is a normal state, and a truncated axis is a lie.
function calendarTicks(start: number, end: number, opts: CalendarStepOptions = {}): CalendarTick[]
chooseCalendarStep() from @llui/components/utils/calendar-ticks
The finest rung whose boundaries fit inside maxTicks.
A FORCED unit is honoured as given (stride 1), except on year, where the
stride still grows to fit — "yearly" over four centuries has to mean
something, and 400 gridlines is not it.
function chooseCalendarStep(start: number, end: number, opts: CalendarStepOptions = {}): CalendarStep
countCalendarTicks() from @llui/components/utils/calendar-ticks
How many boundaries of step fall inside [start, end] — WITHOUT building
them. Constant time at every rung, which is what lets the ladder reject a
finer unit for a century-long span for free.
One informational edge, in the safe direction: within a month of the extremes
of the representable range (year ±271821), flooring to a period start falls
outside what a Date can hold and {@link utcAt} answers NaN, so the count
is 0 and {@link calendarTicks} draws no gridlines. It neither hangs nor
throws; the axis is simply unlabelled at the end of time.
function countCalendarTicks(start: number, end: number, step: CalendarStep, opts: CalendarOptions = {}): number
floorToUnit() from @llui/components/utils/calendar-ticks
Floor an instant to the start of its unit, in the offset's calendar.
function floorToUnit(at: number, unit: CalendarUnit, opts: CalendarOptions = {}): number
Types
CalendarUnit from @llui/components/utils/calendar-ticks
Rungs of the ladder, finest first.
export type CalendarUnit = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'
Interfaces
CalendarOptions from @llui/components/utils/calendar-ticks
export interface CalendarOptions {
/**
* Minutes east of UTC that boundaries are measured in. Default 0 (UTC). A
* FIXED offset — see the module note; `-300` is US Eastern standard time and
* stays at −5 across a DST transition rather than following it.
*/
offsetMinutes?: number
/** First day of the week for the `week` rung: 0 = Sunday … 6 = Saturday.
* Default 1 (Monday), matching ISO 8601. */
weekStartsOn?: number
}
CalendarStep from @llui/components/utils/calendar-ticks
A chosen rung, plus how many of it each boundary advances. stride is
always 1 except on year.
export interface CalendarStep {
unit: CalendarUnit
stride: number
}
CalendarStepOptions from @llui/components/utils/calendar-ticks
export interface CalendarStepOptions extends CalendarOptions {
/** Upper bound on the number of boundaries. Default 6. */
maxTicks?: number
/** Force a rung instead of choosing one from the span. */
unit?: CalendarUnit
}
CalendarTick from @llui/components/utils/calendar-ticks
export interface CalendarTick {
/** The boundary instant, in epoch milliseconds. */
at: number
unit: CalendarUnit
}
Constants
MAX_CALENDAR_TICKS from @llui/components/utils/calendar-ticks
A hard ceiling on the boundaries one call will produce. Reached only by
FORCING a fine unit across an enormous span; the automatic ladder can never
exceed maxTicks. Over the ceiling the answer is an EMPTY array — no
gridlines — rather than a truncated one, because a truncated axis silently
claims the data ends where the array does.
const MAX_CALENDAR_TICKS
@llui/components/utils/child-send
Functions
wrapChildSend() from @llui/components/utils/child-send
Wrap a child machine's send into the PARENT's own message type, the way
menubar.ts's per-submenu menuSend and gradient-picker.ts's embedded
pickerSend both do — and additionally mark the returned function's
__lluiVariants, which plain mapSend (@llui/dom) does not.
That tag is what makes every tagSend(...) call built INSIDE the child's
own connect() report the PARENT's wrapper variant(s) instead of the
child's own specific ones (binding-descriptors.ts's tagSend: a
send.__lluiVariants tag on the dispatcher itself always wins over the
libraryVariants array a given tagSend call names). That is the
TRUTHFUL external view: an agent driving the parent only ever sees the
wrapper type dispatched ('picker', 'menuMsg', …), with the child's
real action nested inside msg — not menu.ts's internal 'highlight'/
'selectItem'/… vocabulary leaking out as if the parent could dispatch
those directly. Left unset (the untagged mapSend), a submenu's own
tagSend(send, ['highlightNext'], …) call reports 'highlightNext' to
anything introspecting the PARENT's variants — a message type the parent's
own Msg union does not even have a case for.
function wrapChildSend<Outer extends { type: string }, Inner>(send: Send<Outer>, wrap: (inner: Inner) => Outer, variants: readonly Outer['type'][]): Send<Inner>
@llui/components/utils/color
Functions
cssColorAlpha() from @llui/components/utils/color
Resolved alpha (none -> 1, CSS Color 4's used value for a missing
alpha OUTSIDE interpolation). Inside {@link interpolateColor}, a missing
alpha instead carries the OTHER endpoint's alpha — see its doc comment.
function cssColorAlpha(c: CssColor): number
cssColorToHex() from @llui/components/utils/color
Gamut-map (if needed) and convert to a displayable hex string.
#rrggbb when alpha is (resolved to) 1, #rrggbbaa otherwise.
function cssColorToHex(c: CssColor): string
cssColorToSrgb() from @llui/components/utils/color
Convert any {@link CssColor} into fully-resolved (non-null) Srgb,
treating none as 0 and NOT gamut-mapping (an oklch/oklab input outside
sRGB stays outside [0,1]).
function cssColorToSrgb(c: CssColor): Srgb
formatHex() from @llui/components/utils/color
function formatHex(rgb: Rgb255): string
formatHex8() from @llui/components/utils/color
function formatHex8(rgb: Rgb255, alpha: number): string
formatHsl() from @llui/components/utils/color
hsl(H S% L%) / hsl(H S% L% / A). gradient-picker's colorAt needs
this so an in hsl interpolation result can be serialized without an extra
(lossy) round trip through sRGB bytes.
function formatHsl(hsl: Hsl, alpha = 1): string
formatOklab() from @llui/components/utils/color
oklab(L A B) / oklab(L A B / A), same precision convention as
{@link formatOklch} — gradient-picker's colorAt needs this to stay
exact when the interpolation space itself is oklab (interpolating in
oklch and in oklab are different color spaces, and only one of them has
a serializer here otherwise).
function formatOklab(lab: Oklab, alpha = 1): string
formatOklch() from @llui/components/utils/color
oklch(L C H) / oklch(L C H / A). Fixed precision: L as a 0–1
fraction at 4 dp, C at 4 dp, H at 2 dp — chosen so the string is stable
and round-trips through {@link parseCssColor} without drifting.
function formatOklch(ok: Oklch, alpha = 1): string
formatRgb() from @llui/components/utils/color
function formatRgb(rgb: Rgb255, alpha = 1): string
gamutMapOklchToSrgb() from @llui/components/utils/color
CSS Color 4 §13.2 gamut mapping: reduce OKLCH chroma by binary search until
simple-clipping the result into sRGB is perceptually indistinguishable
(deltaEOK < the 0.02 JND) from the reduced color, then clip. Given an
already-in-gamut color this returns it converted (and defensively clipped
for float noise) rather than a no-op, so the result is always safe to hand
straight to {@link srgbToRgb255}. test/utils/color.test.ts pins the
converged chroma for a known out-of-gamut color as a regression value
(this file's own binary search, not an independent oracle — see that
test's comment for what it does and does not prove).
function gamutMapOklchToSrgb(ok: Oklch): Srgb
hslToHsv() from @llui/components/utils/color
Convert HSL (h 0-360, s/l 0-100) to HSV (h 0-360, s/v 0-100), float.
function hslToHsv(hsl: Hsl): Hsv
hslToRgb255() from @llui/components/utils/color
Convert HSL (h 0-360, s/l 0-100) to RGB (0-255 each, rounded).
function hslToRgb255(hsl: Hsl): Rgb255
hslToSrgb() from @llui/components/utils/color
HSL -> sRGB, float, lossless.
function hslToSrgb(hsl: Hsl): Srgb
hsvToHsl() from @llui/components/utils/color
Convert HSV (h 0-360, s/v 0-100) to HSL (h 0-360, s/l 0-100), float.
function hsvToHsl(hsv: Hsv): Hsl
hsvToOklch() from @llui/components/utils/color
function hsvToOklch(hsv: Hsv): Oklch
hsvToRgb255() from @llui/components/utils/color
HSV -> RGB255, via the DIRECT (not HSL-routed) float conversion.
function hsvToRgb255(hsv: Hsv): Rgb255
hsvToSrgb() from @llui/components/utils/color
HSV -> sRGB, DIRECT (not routed through HSL), float, lossless.
function hsvToSrgb(hsv: Hsv): Srgb
hwbToSrgb() from @llui/components/utils/color
HWB -> sRGB (CSS Color 4 §8.4).
function hwbToSrgb(hwb: Hwb): Srgb
inSrgbGamut() from @llui/components/utils/color
Whether ok, converted to sRGB, falls inside [0,1] on every channel
(within epsilon — floating-point round-trip noise).
function inSrgbGamut(ok: Oklch, epsilon = 1e-4): boolean
interpolateColor() from @llui/components/utils/color
Interpolate between two colors per CSS Color 4 §12: convert both into
space, resolve missing (none, or a cross-space-converted achromatic
color's undefined) components — including alpha itself, which carries the
OTHER endpoint's alpha when missing rather than defaulting to 1 (CSS Color
4 §12.2's analogous-component rule applies to alpha too — rgb(255 0 0 / none) mixed with rgb(0 0 255 / 0.2) has alpha exactly 0.2 throughout,
not a lerp toward 1; pinned in test/utils/color.test.ts) —
premultiply non-hue channels by alpha, lerp, then un-premultiply.
hueMethod controls which way a hue-bearing space (hsl, oklch)
rotates, normalizing both hues to [0,360) FIRST (see {@link adjustHue});
ignored for spaces with no hue.
function interpolateColor(a: CssColor, b: CssColor, t: number, space: InterpolationSpace, hueMethod: HueInterpolationMethod = 'shorter'): CssColor
labToLch() from @llui/components/utils/color
function labToLch(lab: Lab): Lch
labToXyzD50() from @llui/components/utils/color
Lab (D50) -> XYZ (D50).
function labToXyzD50(lab: Lab): Xyz
lchToLab() from @llui/components/utils/color
function lchToLab(lch: Lch): Lab
linearSrgbToOklab() from @llui/components/utils/color
function linearSrgbToOklab(s: Srgb): Oklab
linearToSrgb() from @llui/components/utils/color
function linearToSrgb(s: Srgb): Srgb
oklabToLinearSrgb() from @llui/components/utils/color
function oklabToLinearSrgb(lab: Oklab): Srgb
oklabToOklch() from @llui/components/utils/color
function oklabToOklch(lab: Oklab): Oklch
oklabToSrgb() from @llui/components/utils/color
NOT gamut-mapped — may return channels outside [0,1] for an OKLab/OKLCH color outside sRGB. Use {@link gamutMapOklchToSrgb} for a displayable result.
function oklabToSrgb(lab: Oklab): Srgb
oklchToHsv() from @llui/components/utils/color
Gamut-maps into sRGB first — HSV cannot represent an out-of-gamut color.
function oklchToHsv(ok: Oklch): Hsv
oklchToOklab() from @llui/components/utils/color
function oklchToOklab(ok: Oklch): Oklab
oklchToSrgb() from @llui/components/utils/color
NOT gamut-mapped. See {@link oklabToSrgb}.
function oklchToSrgb(ok: Oklch): Srgb
parseCssColor() from @llui/components/utils/color
Parse a CSS color string into its typed model. Supports #rgb/#rgba/
#rrggbb/#rrggbbaa, rgb()/rgba(), hsl()/hsla() (both legacy comma
and modern space/slash syntax, with the legacy grammar's none-forbidden
and uniform-percentage-or-number rules enforced), hwb(), oklch(),
oklab(), lab(), lch(), color() (predefined spaces srgb,
srgb-linear, display-p3, a98-rgb, prophoto-rgb, rec2020, xyz,
xyz-d50, xyz-d65), none components, transparent, and the 148 CSS
Color 4 named colors. Returns null on anything else — never throws.
function parseCssColor(input: string): CssColor | null
parseHexColor() from @llui/components/utils/color
Parse #rgb, #rgba, #rrggbb, #rrggbbaa. Returns null on anything
else, never throws.
function parseHexColor(hex: string): { rgb: Rgb255; alpha: number } | null
resolveNone() from @llui/components/utils/color
A null (CSS none) component behaves as 0 outside interpolation.
function resolveNone(n: number | null): number
rgb255ToHsl() from @llui/components/utils/color
Convert RGB (0-255 each) to HSL (h 0-360, s/l 0-100), float.
function rgb255ToHsl(rgb: Rgb255): Hsl
rgb255ToHsv() from @llui/components/utils/color
RGB255 -> HSV, via the DIRECT (not HSL-routed) float conversion.
function rgb255ToHsv(rgb: Rgb255): Hsv
srgb255ToSrgb() from @llui/components/utils/color
function srgb255ToSrgb(rgb: Rgb255): Srgb
srgbToHsl() from @llui/components/utils/color
sRGB -> HSL, float, lossless (no byte quantization).
function srgbToHsl(s: Srgb): Hsl
srgbToHsv() from @llui/components/utils/color
sRGB -> HSV, DIRECT (not routed through HSL), float, lossless.
function srgbToHsv(s: Srgb): Hsv
srgbToHwb() from @llui/components/utils/color
sRGB -> HWB.
function srgbToHwb(s: Srgb): Hwb
srgbToLinear() from @llui/components/utils/color
function srgbToLinear(s: Srgb): Srgb
srgbToOklab() from @llui/components/utils/color
function srgbToOklab(s: Srgb): Oklab
srgbToOklch() from @llui/components/utils/color
function srgbToOklch(s: Srgb): Oklch
srgbToRgb255() from @llui/components/utils/color
Rounds AND clamps into 0-255 — the boundary where an out-of-gamut float (an un-mapped OKLCH conversion) must finally become a displayable byte.
function srgbToRgb255(s: Srgb): Rgb255
xyzD50ToLab() from @llui/components/utils/color
XYZ (D50) -> Lab (D50).
function xyzD50ToLab(xyz: Xyz): Lab
xyzD65ToD50() from @llui/components/utils/color
XYZ (D65) -> XYZ (D50), Bradford chromatic adaptation. The inverse of
{@link xyzD50ToD65} — not on this module's own parse/serialize path (every
D50-native input, lab()/lch()/color(xyz-d50 ...), converts TO D65 to
reach OKLab), but exported as the natural symmetric counterpart for a
caller that needs to go the other way (e.g. producing a lab()/lch()
string from an OKLCH color).
function xyzD65ToD50(xyz: Xyz): Xyz
Types
CssColor from @llui/components/utils/color
A color as CSS Color 4 sees it. lab()/lch()/wide-gamut color() are
NOT separate tags here — see the module doc — they resolve to 'oklch' (or
'srgb' for color(srgb ...)/color(srgb-linear ...)) at parse time.
export type CssColor =
| { space: 'srgb'; r: number | null; g: number | null; b: number | null; alpha: number | null }
| {
space: 'srgb-linear'
r: number | null
g: number | null
b: number | null
alpha: number | null
}
| { space: 'hsl'; h: number | null; s: number | null; l: number | null; alpha: number | null }
| { space: 'oklab'; l: number | null; a: number | null; b: number | null; alpha: number | null }
| { space: 'oklch'; l: number | null; c: number | null; h: number | null; alpha: number | null }
HueInterpolationMethod from @llui/components/utils/color
export type HueInterpolationMethod = 'shorter' | 'longer' | 'increasing' | 'decreasing'
InterpolationSpace from @llui/components/utils/color
Interpolation / generic-conversion target spaces (CSS Color 4 §12).
export type InterpolationSpace = 'srgb' | 'srgb-linear' | 'oklab' | 'oklch' | 'hsl'
PredefinedRgbSpace from @llui/components/utils/color
export type PredefinedRgbSpace =
| 'srgb'
| 'srgb-linear'
| 'display-p3'
| 'a98-rgb'
| 'prophoto-rgb'
| 'rec2020'
Interfaces
Hsl from @llui/components/utils/color
HSL color. h 0–360 degrees, s/l 0–100. Float — see the module doc.
export interface Hsl {
h: number
s: number
l: number
}
Hsv from @llui/components/utils/color
HSV color. h 0–360 degrees, s/v 0–100. Float — see the module doc.
export interface Hsv {
h: number
s: number
v: number
}
Hwb from @llui/components/utils/color
HWB color. h 0–360 degrees, w (whiteness)/bk (blackness) 0–100.
export interface Hwb {
h: number
w: number
bk: number
}
Lab from @llui/components/utils/color
CIE Lab, D50-relative (CSS Color 4 lab()). l 0–100; a/b unbounded
(CSS's reference range is ±125, but the value itself is not clamped).
export interface Lab {
l: number
a: number
b: number
}
Lch from @llui/components/utils/color
CIE LCh, the polar form of {@link Lab}. l 0–100, c >= 0, h degrees.
export interface Lch {
l: number
c: number
h: number
}
Oklab from @llui/components/utils/color
Oklab. l is 0–1 lightness; a/b are unbounded (±0.4 roughly spans
sRGB).
export interface Oklab {
l: number
a: number
b: number
}
Oklch from @llui/components/utils/color
OKLCH. l 0–1, c >= 0 (unbounded — maxChroma in the color picker is a
UI choice, not a property of the color space), h in degrees.
export interface Oklch {
l: number
c: number
h: number
}
Rgb255 from @llui/components/utils/color
8-bit-per-channel sRGB, 0–255 (integers once produced by this module).
export interface Rgb255 {
r: number
g: number
b: number
}
Srgb from @llui/components/utils/color
sRGB, gamma-encoded, 0–1 per channel. May fall outside [0,1] as an intermediate value (e.g. converted from an out-of-gamut OKLCH) — callers that need an in-gamut result go through {@link gamutMapOklchToSrgb}.
export interface Srgb {
r: number
g: number
b: number
}
Xyz from @llui/components/utils/color
CIE XYZ tristimulus values, relative to whichever white point the function producing them documents (D65 unless named otherwise).
export interface Xyz {
x: number
y: number
z: number
}
@llui/components/utils/date
Functions
isDateOnly() from @llui/components/utils/date
function isDateOnly(value: DateValue): value is string
parseDateValue() from @llui/components/utils/date
function parseDateValue(value: DateValue): ParsedDateValue
Types
DateValue from @llui/components/utils/date
Date-only handling — the ONE place that decides whether a value denotes an INSTANT or a bare CALENDAR DATE.
new Date('2026-01-15') parses a date-only string as UTC midnight (ES spec),
so formatting it against the ambient zone renders the PREVIOUS day everywhere
west of UTC: formatDate('2026-01-15') printed "January 14, 2026" under
America/New_York and the right answer under Europe/Rome, which is why it
survived (#125 defect 4).
A calendar date carries no instant and therefore no zone, so it is anchored
at UTC midnight and its consumers must render it in UTC — see dateOnly.
export type DateValue = Date | string | number
Interfaces
ParsedDateValue from @llui/components/utils/date
export interface ParsedDateValue {
date: Date
/**
* True when the input was a bare calendar date. Formatters MUST then render
* in UTC (where the anchor was taken), otherwise the ambient zone shifts the
* rendered day.
*/
dateOnly: boolean
}
@llui/components/utils/derive
Functions
deriveOnce() from @llui/components/utils/derive
Wrap a ONE-ARGUMENT compute so that repeating a call with the same argument
returns the previous result. One cell — the first item of an update pays for
the derivation and the rest read it, so the cost is per UPDATE, not per item
and not per render.
This is the shape that runs per ROW per BINDING per update, so it takes a
FIXED parameter and compares with one Object.is. The variadic deriveOnceN
below materialises a fresh arguments array on every call — one per row per
binding per update — which is pure overhead on the hit path: over a pass of
N items x 4 bindings, 0.00056 -> 0.00041 ms at N=20, 0.00467 -> 0.00337 at
N=200 and 0.05680 -> 0.03823 at N=2000 (~25-33%). Reach for deriveOnceN
only where the derivation genuinely takes several inputs.
NO RUNTIME ARITY GUARD, deliberately. Calling the returned function with a
second argument silently ignores it — g(1,'x') and g(1,'y') both return
the g(1) result from one computation — so a JS consumer, or a TS consumer
who casts, can get a wrong answer with no error. That is accepted because
the only ways to observe arity at runtime are an arguments object (absent
in an arrow) or a rest parameter, and a rest parameter re-materialises the
per-row-per-binding array whose removal is this function's entire reason to
exist — paying the 25-33% back on the hottest path in the package, on every
hit, to catch a call TypeScript already rejects (TS2554). Do NOT "fix" this
by widening the signature. If a derivation needs more than one input, that
is what deriveOnceN is for.
function deriveOnce<A, R>(compute: (arg: A) => R): (arg: A) => R
deriveOnceN() from @llui/components/utils/derive
deriveOnce for a derivation with several inputs (the roving tab stop reads
three or four). Memoized on ARGUMENT IDENTITY, position by position.
function deriveOnceN<A extends readonly unknown[], R>(compute: (...args: A) => R): (...args: A) => R
indexMap() from @llui/components/utils/derive
A position lookup over a state array: positions(s.items).get(item) in place
of s.items.indexOf(item). First occurrence wins, matching indexOf.
function indexMap<T>(): (values: readonly T[] | null | undefined) => ReadonlyMap<T, number>
membershipSet() from @llui/components/utils/derive
A membership lookup over a state array: set(s.value).has(item) in place of
s.value.includes(item). An absent collection reads as empty.
An EMPTY array shares EMPTY_SET rather than allocating: most of the ~16
call sites are a disabled/disabledItems list that is empty in the common
case, and an empty Set is the one input where the memo would otherwise pay
an allocation to answer false to everything.
function membershipSet<T>(): (values: readonly T[] | null | undefined) => ReadonlySet<T>
@llui/components/utils/direction
Functions
directionSyncMount() from @llui/components/utils/direction
Observe the exact component root and its live ancestor chain after mount. Child-list observation covers relocation between differently directed ancestors; the Mountable cleanup disconnects all observation.
function directionSyncMount(rootId: string, sync: (dir: 'ltr' | 'rtl') => void): Mountable
eventDirection() from @llui/components/utils/direction
Resolve direction at event time so same-tick ancestor changes are correct.
Routes through @llui/interactions' resolveDir — the package's documented
single source of truth for DOM-derived direction — rather than a second,
independently-maintained ancestor walk.
function eventDirection(state: DirectionState, origin: Element | null): 'ltr' | 'rtl'
flipArrow() from @llui/components/utils/direction
Map a horizontal arrow key to its logical direction, accounting for RTL. This is the SINGLE SOURCE OF TRUTH every component routes horizontal arrow interpretation through. Under rtl, ArrowLeft and ArrowRight swap meaning; vertical arrows (Up/Down), Home/End, PageUp/PageDown and every non-arrow key pass through unchanged.
The second argument is the direction source:
- an explicit
'ltr' | 'rtl'— used directly (the authoritative form when a component storesdirin its own State and passes it in); - an
Element— direction is resolved by walking up the DOM (dir="rtl"ancestor ordocument.documentElement.dir); null— treated as'ltr'(no-op).
export declare function flipArrow(key: string, source: Element | null | TextDirection): string;
floatingDir() from @llui/components/utils/direction
The EXPLICIT direction to hand an overlay's floating.dir, or undefined
while direction is still automatic (dirSource === 'dom'). undefined is
not "unknown": the overlay engine then resolves the direction from the
overlay's placement ANCHOR — the trigger, in the app's own container — and
writes it on the portaled floating element, so geometry, CSS and key
handlers inside the portal all agree with the trigger (#265 finding 6).
Passing the state's dir unconditionally (#265 A1) mirrored every floating
surface on an RTL page as LTR: the untouched state default said 'ltr'
while the DOM said 'rtl'. Once a consumer calls setDir/passes dir,
dirSource is 'explicit' and that value wins over the DOM.
function floatingDir(state: DirectionState): 'ltr' | 'rtl' | undefined
initDirection() from @llui/components/utils/direction
Deterministic on the server; omitted direction is resolved only after mount.
function initDirection(dir: 'ltr' | 'rtl' | undefined): DirectionState
resolveDir() from @llui/components/utils/direction
Resolve the text direction for an element by walking up the DOM tree. Returns 'rtl' or 'ltr' (default).
The walk crosses SHADOW boundaries: Element.closest() stops at the
nearest shadow root and cannot see a dir set on an ancestor of the host,
so an element inside a shadow tree whose host (or the host's own
ancestors) declares dir would otherwise silently read as ltr. Continuing
from root.host after getRootNode() returns a ShadowRoot walks out to
the light-DOM ancestor and keeps going, arbitrarily many shadow levels
deep. The final fallback reads dir off the element's OWN document
(ownerDocument), never the global document — the global binding names a
DIFFERENT document inside an iframe or any other multi-document context.
export declare function resolveDir(el: Element): TextDirection;
resolveTextDirection() from @llui/components/utils/direction
Normalize any accepted direction source to a concrete TextDirection.
An explicit 'ltr' | 'rtl' wins; an Element is resolved from the DOM;
null / undefined default to 'ltr'.
export declare function resolveTextDirection(source: Element | null | undefined | TextDirection): TextDirection;
setDirection() from @llui/components/utils/direction
Public configuration becomes authoritative over future ancestor observation.
function setDirection<T extends DirectionState>(state: T, dir: 'ltr' | 'rtl'): T
syncDomDirection() from @llui/components/utils/direction
Apply an internal DOM observation only while direction remains automatic.
function syncDomDirection<T extends DirectionState>(state: T, dir: 'ltr' | 'rtl'): T
watchDirection() from @llui/components/utils/direction
Watch every dir that can decide target()'s resolved direction: dir
attributes on the element and its live ancestor chain (across shadow
roots), and child-list changes that relocate it between differently
directed ancestors. Calls onChange after any such mutation — the caller
re-resolves — then re-observes the chain target() names NOW, so a
relocated or replaced element keeps being watched. Returns a disconnect.
A no-op where MutationObserver does not exist (SSR).
function watchDirection(target: () => Element | null, onChange: () => void): () => void
Types
DirectionSource from @llui/components/utils/direction
export type DirectionSource = 'dom' | 'explicit'
TextDirection from @llui/components/utils/direction
Text reading direction. The single shared RTL vocabulary for the package.
export type TextDirection = 'ltr' | 'rtl';
Interfaces
DirectionState from @llui/components/utils/direction
export interface DirectionState {
readonly dir: 'ltr' | 'rtl'
readonly dirSource: DirectionSource
}
@llui/components/utils/dismissable
Functions
_dismissableStackSize() from @llui/components/utils/dismissable
@internal — for tests
export declare function _dismissableStackSize(): number;
pushDismissable() from @llui/components/utils/dismissable
Register a dismissable layer. Escape is offered to the layers top-down until
one CLAIMS it (a layer declines via disableEscape or an onEscape router
returning false, and the key then falls through to the layer beneath);
outside-click is topmost-only. Returns a cleanup that removes the layer from
the stack.
Push a layer even when both dismissal routes are disabled: the layer is the caller's PLACE ON THE STACK, which is what stops the layer beneath from treating an interaction inside this one as an outside interaction.
export declare function pushDismissable(opts: DismissableOptions): () => void;
Types
DismissSource from @llui/components/utils/dismissable
Reason a dismissable layer was closed.
export type DismissSource = 'escape' | 'outside';
Interfaces
DismissableOptions from @llui/components/utils/dismissable
export interface DismissableOptions {
/** The layer element (e.g. a dialog content or popover). */
element: ElementSource;
/** Trigger / anchor elements that should not count as outside interactions. */
ignore?: ElementSource;
/** Called when the user dismisses the layer. */
onDismiss: (source: DismissSource, event: Event) => void;
/**
* Custom Escape router. When provided it runs for the Escape key INSTEAD of
* `onDismiss('escape', …)`, letting the layer unwind an internal level first
* (e.g. a menu closes its open submenu before closing the whole menu). Return
* `false` to decline — the event is not claimed and propagates as if this
* layer had `disableEscape`. Any other return (incl. `undefined`) claims it.
*/
onEscape?: (event: KeyboardEvent) => boolean | void;
/** Disable outside-click dismissal (default: false). */
disableOutside?: boolean;
/** Disable Escape-key dismissal (default: false). */
disableEscape?: boolean;
}
@llui/components/utils/dom
Functions
composedTarget() from @llui/components/utils/dom
The innermost, non-retargeted target of an event.
At a shadow-DOM boundary the browser RETARGETS event.target to the shadow
host for any listener outside the shadow tree — so a document-level capture
listener sees the host element, not the element actually interacted with
inside the shadow root. event.composedPath()[0] is the real deepest target
(the path is composed across shadow boundaries and is populated while the
event is dispatching, which is when capture-phase handlers run). Fall back to
event.target when composedPath is unavailable or empty (non-DOM env / an
already-dispatched event). Essential for outside-interaction detection to work
when a component is mounted inside a shadow root (isolate mode) — otherwise
every in-shadow interaction reads as the host and is misclassified.
function composedTarget(event: Event): Node | null
containsOrEquals() from @llui/components/utils/dom
function containsOrEquals(container: Element, target: Node | null): boolean
isInAnyElement() from @llui/components/utils/dom
function isInAnyElement(target: Node | null, elements: Element[]): boolean
resolveElements() from @llui/components/utils/dom
function resolveElements<T extends Element>(source: ElementSource<T>): T[]
Types
ElementSource from @llui/components/utils/dom
Shared DOM helpers used by interaction utilities.
export type ElementSource<T extends Element = Element> = T | T[] | (() => T | T[] | null)
@llui/components/utils/engine-focus
Functions
engineFocus() from @llui/components/utils/engine-focus
Focus el as an engine-initiated move (see runEngineFocus).
export declare function engineFocus(el: HTMLElement, options?: FocusOptions): void;
isEngineFocusInProgress() from @llui/components/utils/engine-focus
Whether an engine-initiated focus move is in flight. Consulted by
watchInteractOutside to gate its focusin path.
export declare function isEngineFocusInProgress(): boolean;
runEngineFocus() from @llui/components/utils/engine-focus
Run body with engine-focus suppression active. Any focusin raised inside
it is invisible to watchInteractOutside — including one raised by a focus
move that re-entrant consumer code makes from a focusin listener (see the
module comment: the window is the synchronous transitive closure, not just
the .focus() call).
SYNCHRONOUS BY CONTRACT, AND THE CONTRACT IS ENFORCED (#172). The suppression
is released when body RETURNS. An async body returns its promise at the
first await, so the depth counter drops immediately and the focus move that
eventually happens gets NO protection at all — a call that looks correct,
compiles, and does nothing. The failure is safe (no protection, never a stuck
guard: the decrement is in a finally), which is exactly why it is invisible,
and this is a PUBLIC export documented as the thing a custom overlay "must"
route its engine-initiated focus moves through. A consumer following that
advice with an async body would reintroduce #155 in their app while believing
they had prevented it.
Two guards, because neither covers the other's case:
- The SIGNATURE rejects a promise-returning body at compile time. It is the real guard — it fires before the code ever runs. Its one blind spot is a body whose return type is an unresolved type parameter (a generic pass-through wrapper): the conditional is deferred, so such a wrapper is rejected too and must carry its own constraint. No caller does.
- A DEV-MODE warning catches what the type system cannot see: a JavaScript
consumer, an
any-typed body, or a body that returns a thenable without being declared as returning one. It cannot restore the protection — by the time a thenable is in hand the guard is already released — so it only reports.
Deliberately NOT offered: an async-aware variant that holds the guard across
an await. The guard is safe because no user event can be delivered inside
its window (see the module comment); holding it across a suspension point
hands the event loop back and would start swallowing genuine interactions.
export declare function runEngineFocus<T>(body: () => SyncEngineFocusBody<T>): T;
Types
SyncEngineFocusBodyRequired from @llui/components/utils/engine-focus
The type an ASYNC body collapses to in {@link runEngineFocus}'s parameter
position. Nothing is assignable to it, so runEngineFocus(async () => …) is a
compile error naming the contract rather than a silently inert call.
export type SyncEngineFocusBodyRequired = {
readonly [SYNC_BODY_REQUIRED]: 'runEngineFocus requires a SYNCHRONOUS body — the guard is released the moment body returns';
};
@llui/components/utils/floating
Functions
attachFloating() from @llui/components/utils/floating
Position floating relative to anchor with live updates on scroll/resize.
Owns position, top, left, transform and the two available-size
custom properties ({@link FLOATING_AVAILABLE_HEIGHT}) on floating; position and
all four physical inset properties on an optional arrow; and placement
attributes on stateTarget. The arrow's static-side inset is half its
untransformed layout size, so a square arrow straddles the resolved edge.
Cleanup is idempotent, suppresses pending writes/callbacks, and restores the
exact prior values (including priority) or absence of those properties.
export declare function attachFloating(opts: FloatingOptions): () => void;
restoreInlineStyles() from @llui/components/utils/floating
export declare function restoreInlineStyles(element: HTMLElement, snapshots: readonly InlineStyleSnapshot[], hadStyleAttribute: boolean): void;
snapshotInlineStyle() from @llui/components/utils/floating
export declare function snapshotInlineStyle(element: HTMLElement, property: string): InlineStyleSnapshot;
Types
InlineStyleSnapshot from @llui/components/utils/floating
One inline style property's exact prior state (present or absent, value and
priority), so restoreInlineStyles can put it back byte-for-byte rather
than merely clearing whatever this attachment wrote. Exported so a caller
that imperatively sets a SINGLE inline style outside attachFloating
itself (overlay-engine.ts's sameWidth handling) shares this snapshot
discipline instead of re-implementing it (#265 LOW).
export type InlineStyleSnapshot = {
property: string;
present: boolean;
value: string;
priority: string;
};
Placement from @llui/components/utils/floating
export declare type Placement = Prettify<Side | AlignedPlacement>;
Interfaces
FloatingOptions from @llui/components/utils/floating
export interface FloatingOptions {
/** The reference element (trigger/anchor). */
anchor: Element;
/** The floating element (content). */
floating: HTMLElement;
/**
* Element that receives the resolved full `data-placement` and physical
* `data-side`. Defaults to `floating`. Use a separate content element when
* `floating` is a geometry-only positioner wrapper.
*/
stateTarget?: HTMLElement;
/** Preferred placement (default: 'bottom'). */
placement?: Placement;
/** Gap between anchor and floating, in px (default: 0). */
offset?: number;
/** Flip to opposite side when there isn't enough room (default: true). */
flip?: boolean;
/** Shift along axis to stay in view (default: padding 8 unless false). */
shift?: boolean | {
padding?: number;
};
/**
* Reading direction. Under `'rtl'`, logical `*-start`/`*-end` placements
* track the inline-start/inline-end edges. When given it is AUTHORITATIVE —
* it overrides the direction the floating element happens to compute to,
* which for a portaled overlay is the direction of wherever it landed.
* Omit it to leave that decision to the page, as floating-ui does by default.
*/
dir?: TextDirection;
/** Optional arrow element to position. */
arrow?: HTMLElement;
/** Notify after each position computation. */
onUpdate?: (data: {
x: number;
y: number;
placement: Placement;
arrow?: {
x?: number;
y?: number;
};
}) => void;
}
@llui/components/utils/focus-restore
Functions
focusLingeredInside() from @llui/components/utils/focus-restore
Whether focus LINGERED INSIDE the layer, i.e. whether restoring it to the anchor respects the user rather than overriding them.
function focusLingeredInside(query: FocusRestoreQuery): boolean
Interfaces
FocusRestoreQuery from @llui/components/utils/focus-restore
The one rule for "should this layer pull focus back to its anchor?".
Restoring focus to the trigger is right when the layer was closed with focus still resting inside it (Escape, a close button, a programmatic close) — the user's focus would otherwise be left on a detached node. It is WRONG when the dismissal was caused by focus moving somewhere the user chose: yanking it back to the trigger takes focus away from the control they just reached, and can leave a still-open layer with focus outside it (#173).
document.body (and a null activeElement) counts as "inside" because that
is where focus lands when the focused element is removed — nobody chose it, so
the anchor is a better home than the body.
Both callers must ask BEFORE tearing anything down: the focus trap's release
and the aria-hidden/inert sweep both move or invalidate activeElement,
so a decision taken after them is a decision about the engine's own cleanup.
export interface FocusRestoreQuery {
/** The region that counts as "inside" this layer. */
boundary: Element
/** The element focus would be restored TO. */
anchor?: Element | null
/**
* Also treat the anchor itself being focused as "inside" (`select`, which
* focuses its own trigger on open — without this its restore reads as "the
* user moved focus to the trigger" and never runs).
*/
allowAnchorActive?: boolean
}
@llui/components/utils/focus-trap
Functions
_focusTrapStackSize() from @llui/components/utils/focus-trap
@internal — tests only
export declare function _focusTrapStackSize(): number;
pushFocusTrap() from @llui/components/utils/focus-trap
Push a focus trap onto the stack. Tab/Shift+Tab will cycle within the container's focusable descendants. Returns a cleanup that removes the trap and (optionally) restores focus to the element active before push.
export declare function pushFocusTrap(opts: FocusTrapOptions): () => void;
Interfaces
FocusTrapOptions from @llui/components/utils/focus-trap
export interface FocusTrapOptions {
/** The container whose focusable descendants form the trap. */
container: ElementSource;
/** Element to focus when the trap activates. Defaults to the first
* tab-reachable descendant, else the container itself (given a temporary
* `tabindex="-1"` when it has none). */
initialFocus?: Element | (() => Element | null);
/** Restore focus to the previously active element on release (default: true). */
restoreFocus?: boolean;
}
@llui/components/utils/focusables
Functions
getFocusables() from @llui/components/utils/focusables
export declare function getFocusables(container: Element): HTMLElement[];
isFocusable() from @llui/components/utils/focusables
Find focusable descendants within a container.
export declare function isFocusable(el: Element): boolean;
@llui/components/utils/follow-active-descendant
Functions
followActiveDescendant() from @llui/components/utils/follow-active-descendant
Keep the item an aria-activedescendant names scrolled into view (#268).
Every LLui list-like overlay — menu, select, combobox, searchable-select,
context menu, menubar — keeps DOM focus on its content (or input) and moves
a HIGHLIGHT through aria-activedescendant. Nothing scrolled the
highlighted item into view, so in a list taller than its popup the keyboard
highlight walked off the visible area: a keyboard user pressing End saw
nothing happen, and with a pointer out of the way there was no other cue.
(Native focus scrolls itself; an active descendant is not focus.)
One observer per open overlay, filtered to the one attribute and disconnected
on teardown. It watches the whole document because the owner of the
attribute is not always inside the popup — a combobox's lives on its INPUT —
and acts only when the named item IS inside container, so a sibling
widget's highlight never scrolls this one.
function followActiveDescendant(container: HTMLElement): () => void
@llui/components/utils/index
Functions
addUnits() from @llui/components/utils/index
Advance an instant by n whole units, in the offset's calendar.
function addUnits(at: number, unit: CalendarUnit, n: number, opts: CalendarOptions = {}): number
allFiniteNumbers() from @llui/components/utils/index
Whether every number nested in one atomic runtime payload is usable. Non-numeric leaves are ignored; arrays and plain payload objects are walked so callers cannot accidentally validate one coordinate while committing a bad sibling. Cycles are harmless because an already-seen object contains no new numeric leaves.
function allFiniteNumbers(...values: readonly unknown[]): boolean
anatomy() from @llui/components/utils/index
function anatomy<P extends string>(name: string, parts: readonly P[]): Anatomy<P>
annularSectorPath() from @llui/components/utils/index
An annular sector — the polar answer to a bar. r0 is the inner radius (0
gives a pie wedge, > 0 a donut segment); angles follow {@link polarPoint}.
A sweep of a full turn or more is drawn as a complete ring, because the arc endpoints would otherwise coincide and SVG would draw nothing at all.
function annularSectorPath(cx: number, cy: number, r0: number, r1: number, a0: number, a1: number): string
applySelection() from @llui/components/utils/index
Apply a click/Enter on value to the current selection. Single mode
replaces, multiple toggles, and a disabled item changes nothing — returning
the SAME array reference so the reducer's no-op stays a no-op for the
reference-equality reconciler.
function applySelection(current: string[], value: string, opts: { mode: SelectionMode; disabled?: readonly string[] }): string[]
areaPath() from @llui/components/utils/index
A filled region between an upper run and a lower run. The lower run is walked BACKWARDS and appended, then the path is closed — one subpath, so a fill-rule cannot punch a hole in it the way two separate subpaths can.
function areaPath(upper: readonly Point[], lower: readonly Point[], curve: Curve, axis: CurveAxis = 'x'): string
attachFloating() from @llui/components/utils/index
Position floating relative to anchor with live updates on scroll/resize.
Owns position, top, left, transform and the two available-size
custom properties ({@link FLOATING_AVAILABLE_HEIGHT}) on floating; position and
all four physical inset properties on an optional arrow; and placement
attributes on stateTarget. The arrow's static-side inset is half its
untransformed layout size, so a square arrow straddles the resolved edge.
Cleanup is idempotent, suppresses pending writes/callbacks, and restores the
exact prior values (including priority) or absence of those properties.
export declare function attachFloating(opts: FloatingOptions): () => void;
bandCenter() from @llui/components/utils/index
The centre of band i in normalized u.
function bandCenter(i: number, band: Band): number
bandExtent() from @llui/components/utils/index
The [start, end] extent of band i in normalized u.
function bandExtent(i: number, band: Band): [number, number]
calendarTicks() from @llui/components/utils/index
Calendar boundaries inside [start, end], ascending.
An EMPTY array for a non-finite or reversed range, and for a request that would exceed {@link MAX_CALENDAR_TICKS} — a chart with no data is a normal state, and a truncated axis is a lie.
function calendarTicks(start: number, end: number, opts: CalendarStepOptions = {}): CalendarTick[]
cartesianProjection() from @llui/components/utils/index
function cartesianProjection(frame: Frame, opts: CartesianOptions = {}): Projection
chooseCalendarStep() from @llui/components/utils/index
The finest rung whose boundaries fit inside maxTicks.
A FORCED unit is honoured as given (stride 1), except on year, where the
stride still grows to fit — "yearly" over four centuries has to mean
something, and 400 gridlines is not it.
function chooseCalendarStep(start: number, end: number, opts: CalendarStepOptions = {}): CalendarStep
circlePath() from @llui/components/utils/index
A full circle, as two arcs — one arc of 360° is degenerate and draws nothing, which is the classic way a polar gridline disappears.
function circlePath(cx: number, cy: number, r: number): string
clamp() from @llui/components/utils/index
Bound n into [min, max]. The result is always FINITE: a non-finite input
maps to a defined legal value instead of being stored verbatim.
Every comparison against NaN is false, so NaN used to fall straight
through to return n and land in state — package-wide, since this is the one
clamp every mutation path routes through (#152). It is not merely a wrong
number: JSON.stringify(NaN) (and Infinity) is null, so a non-finite
value breaks the State-is-JSON-serializable invariant and with it devtools
time-travel, @llui/test replay, agent state snapshots and SSR rehydration.
Rejecting at this boundary is what lets every caller state its own
postcondition — e.g. slider's withThumb — without a finiteness caveat.
function clamp(n: number, min: number, max: number): number
clampToStep() from @llui/components/utils/index
Clamp into the range AND snap onto the grid. The result is always within
[min, max]: snapping can leave the range when an endpoint is not itself on
the grid (min 0, max 10, step 4 → 10 snaps up to 12), and the answer there is
the last grid value INSIDE the range, not an out-of-range or off-grid one.
It is always FINITE too — the clamp rejects a non-finite input first (#152).
function clampToStep(value: number, grid: NumericGrid): number
countCalendarTicks() from @llui/components/utils/index
How many boundaries of step fall inside [start, end] — WITHOUT building
them. Constant time at every rung, which is what lets the ladder reject a
finer unit for a century-long span for free.
One informational edge, in the safe direction: within a month of the extremes
of the representable range (year ±271821), flooring to a period start falls
outside what a Date can hold and {@link utcAt} answers NaN, so the count
is 0 and {@link calendarTicks} draws no gridlines. It neither hangs nor
throws; the axis is simply unlabelled at the end of time.
function countCalendarTicks(start: number, end: number, step: CalendarStep, opts: CalendarOptions = {}): number
cssColorAlpha() from @llui/components/utils/index
Resolved alpha (none -> 1, CSS Color 4's used value for a missing
alpha OUTSIDE interpolation). Inside {@link interpolateColor}, a missing
alpha instead carries the OTHER endpoint's alpha — see its doc comment.
function cssColorAlpha(c: CssColor): number
cssColorToHex() from @llui/components/utils/index
Gamut-map (if needed) and convert to a displayable hex string.
#rrggbb when alpha is (resolved to) 1, #rrggbbaa otherwise.
function cssColorToHex(c: CssColor): string
cssColorToSrgb() from @llui/components/utils/index
Convert any {@link CssColor} into fully-resolved (non-null) Srgb,
treating none as 0 and NOT gamut-mapping (an oklch/oklab input outside
sRGB stays outside [0,1]).
function cssColorToSrgb(c: CssColor): Srgb
curvePath() from @llui/components/utils/index
Join a run of points with the named curve. closed applies to linear
only — the other two are defined on an open, x-increasing run.
function curvePath(points: readonly Point[], curve: Curve, closed = false, axis: CurveAxis = 'x'): string
decimalPlaces() from @llui/components/utils/index
Fraction digits n is written with, INCLUDING exponential notation —
String(1e-7) is '1e-7', which a scan for '.' reads as zero decimals.
function decimalPlaces(n: number): number
denormalize() from @llui/components/utils/index
Inverse of {@link normalize}.
function denormalize(u: number, domain: Domain): number
deriveOnce() from @llui/components/utils/index
Wrap a ONE-ARGUMENT compute so that repeating a call with the same argument
returns the previous result. One cell — the first item of an update pays for
the derivation and the rest read it, so the cost is per UPDATE, not per item
and not per render.
This is the shape that runs per ROW per BINDING per update, so it takes a
FIXED parameter and compares with one Object.is. The variadic deriveOnceN
below materialises a fresh arguments array on every call — one per row per
binding per update — which is pure overhead on the hit path: over a pass of
N items x 4 bindings, 0.00056 -> 0.00041 ms at N=20, 0.00467 -> 0.00337 at
N=200 and 0.05680 -> 0.03823 at N=2000 (~25-33%). Reach for deriveOnceN
only where the derivation genuinely takes several inputs.
NO RUNTIME ARITY GUARD, deliberately. Calling the returned function with a
second argument silently ignores it — g(1,'x') and g(1,'y') both return
the g(1) result from one computation — so a JS consumer, or a TS consumer
who casts, can get a wrong answer with no error. That is accepted because
the only ways to observe arity at runtime are an arguments object (absent
in an arrow) or a rest parameter, and a rest parameter re-materialises the
per-row-per-binding array whose removal is this function's entire reason to
exist — paying the 25-33% back on the hottest path in the package, on every
hit, to catch a call TypeScript already rejects (TS2554). Do NOT "fix" this
by widening the signature. If a derivation needs more than one input, that
is what deriveOnceN is for.
function deriveOnce<A, R>(compute: (arg: A) => R): (arg: A) => R
deriveOnceN() from @llui/components/utils/index
deriveOnce for a derivation with several inputs (the roving tab stop reads
three or four). Memoized on ARGUMENT IDENTITY, position by position.
function deriveOnceN<A extends readonly unknown[], R>(compute: (...args: A) => R): (...args: A) => R
engineFocus() from @llui/components/utils/index
Focus el as an engine-initiated move (see runEngineFocus).
export declare function engineFocus(el: HTMLElement, options?: FocusOptions): void;
finiteBound() from @llui/components/utils/index
A bound as STATE may hold it: the finite number itself, or undefined for
"no bound on this side". THE ONE normalizer for a bound, mirroring clamp's
role for a value (#177).
±Infinity and an ABSENT bound already mean the same thing to every clamp in
the package — clampToStep expands grid.min ?? -Infinity — but only one of
the two spellings survives JSON.stringify, which writes null for both
Infinity and NaN. State must be JSON-serializable (devtools time-travel,
@llui/test replay, agent state snapshots, SSR rehydration all compare
serialized state), so the infinite spelling belongs to the RUNTIME expansion
and never to state: normalize at every write, let the grid expand the absence
again. An unbounded number-input used to store min: -Infinity and
rehydrate as min: null — a number field holding null, on the DEFAULT
configuration.
NaN collapses here too, and that is the half a ?? cannot rescue: NaN is
not nullish, so a NaN bound reached clamp, every comparison against it
was false, and THAT SIDE OF THE RANGE STOPPED CLAMPING — angle-slider after
setMin: NaN stored -9999 for setValue(-9999).
Callers decide what an absent bound means for them, and there are exactly two idioms:
- UNBOUNDED-CAPABLE (
number-input): store theundefinedby OMITTING the key, so the state shape IS aNumericGridand round-trips identically. - INTRINSICALLY BOUNDED (
angle-slider,slider,splitter, …): a requiredmin: numbercannot spell "unbounded", so?? DEFAULTatinitand REJECT the write in asetMin/setMaxreducer — dropping a meaningless bound keeps the range the component already had, which is the only answer that cannot silently disable clamping.
function finiteBound(raw: number | null | undefined): number | undefined
finiteOrDefault() from @llui/components/utils/index
A component-owned number that has no range to clamp into. Initialization
replaces an unusable input with the field's ordinary default; runtime
reducers use {@link allFiniteNumbers} to refuse the whole message instead.
Keeping those two policies here prevents a free position or timestamp from
accidentally inheriting either the grid-value policy (clamp) or the
optional-bound policy (finiteBound).
function finiteOrDefault(raw: number | null | undefined, fallback: number): number
firstEnabled() from @llui/components/utils/index
Internal value navigation used by the public roving-focus primitive.
export declare function firstEnabled(items: readonly string[], disabled: readonly string[]): string | null;
firstEnabledIndex() from @llui/components/utils/index
function firstEnabledIndex(items: readonly string[], disabled: readonly string[]): number | null
flipArrow() from @llui/components/utils/index
Map a horizontal arrow key to its logical direction, accounting for RTL. This is the SINGLE SOURCE OF TRUTH every component routes horizontal arrow interpretation through. Under rtl, ArrowLeft and ArrowRight swap meaning; vertical arrows (Up/Down), Home/End, PageUp/PageDown and every non-arrow key pass through unchanged.
The second argument is the direction source:
- an explicit
'ltr' | 'rtl'— used directly (the authoritative form when a component storesdirin its own State and passes it in); - an
Element— direction is resolved by walking up the DOM (dir="rtl"ancestor ordocument.documentElement.dir); null— treated as'ltr'(no-op).
export declare function flipArrow(key: string, source: Element | null | TextDirection): string;
floorToUnit() from @llui/components/utils/index
Floor an instant to the start of its unit, in the offset's calendar.
function floorToUnit(at: number, unit: CalendarUnit, opts: CalendarOptions = {}): number
fmt() from @llui/components/utils/index
Format a coordinate. A non-finite input becomes 0 — a NaN in a path
silently voids the WHOLE path element, taking every later command with it.
function fmt(n: number): string
focusLingeredInside() from @llui/components/utils/index
Whether focus LINGERED INSIDE the layer, i.e. whether restoring it to the anchor respects the user rather than overriding them.
function focusLingeredInside(query: FocusRestoreQuery): boolean
focusRovingItem() from @llui/components/utils/index
Move DOM focus to the roving item identified by value within the same
widget instance as origin.
Roving-tabindex widgets track the active index in STATE, but assistive tech
follows real DOM focus — so after a keyboard move the handler MUST also move
focus, or arrow keys are silent for AT. origin is the event's
currentTarget (the item that received the key); its closest
[data-scope][data-part="root"] ancestor scopes the search so sibling
widgets of the same scope never cross-focus. No-op if nothing matches.
send() is synchronous and items already exist in the DOM, so this can be
called immediately after the navigation send.
export declare function focusRovingItem(origin: Element | null, scope: string, value: string, opts?: {
itemPart?: string;
attr?: string;
}): void;
focusRovingTab() from @llui/components/utils/index
Move DOM focus to the trigger whose data-value matches, within
container. Relies only on the role="tab" + data-value contract
(shared by components/tabs and any hand-rolled tablist). No-op when no
trigger matches. Call after the DOM reflects the new active tab (e.g. in
a microtask if activation triggers a re-render).
export declare function focusRovingTab(container: Element, value: string): void;
formatHex() from @llui/components/utils/index
function formatHex(rgb: Rgb255): string
formatHex8() from @llui/components/utils/index
function formatHex8(rgb: Rgb255, alpha: number): string
formatOklch() from @llui/components/utils/index
oklch(L C H) / oklch(L C H / A). Fixed precision: L as a 0–1
fraction at 4 dp, C at 4 dp, H at 2 dp — chosen so the string is stable
and round-trips through {@link parseCssColor} without drifting.
function formatOklch(ok: Oklch, alpha = 1): string
formatRgb() from @llui/components/utils/index
function formatRgb(rgb: Rgb255, alpha = 1): string
gamutMapOklchToSrgb() from @llui/components/utils/index
CSS Color 4 §13.2 gamut mapping: reduce OKLCH chroma by binary search until
simple-clipping the result into sRGB is perceptually indistinguishable
(deltaEOK < the 0.02 JND) from the reduced color, then clip. Given an
already-in-gamut color this returns it converted (and defensively clipped
for float noise) rather than a no-op, so the result is always safe to hand
straight to {@link srgbToRgb255}. test/utils/color.test.ts pins the
converged chroma for a known out-of-gamut color as a regression value
(this file's own binary search, not an independent oracle — see that
test's comment for what it does and does not prove).
function gamutMapOklchToSrgb(ok: Oklch): Srgb
getFocusables() from @llui/components/utils/index
export declare function getFocusables(container: Element): HTMLElement[];
getNestedLayers() from @llui/components/utils/index
Currently-registered nested-layer elements (resolvers re-read live).
With an aspect, only registrations that participate in it; without one, all
of them. With a within boundary, only registrations nested inside it (see
the module comment); without one, the flat, layer-agnostic answer.
export declare function getNestedLayers(aspect?: NestedLayerAspect, within?: NestedLayerScope): Element[];
hslToHsv() from @llui/components/utils/index
Convert HSL (h 0-360, s/l 0-100) to HSV (h 0-360, s/v 0-100), float.
function hslToHsv(hsl: Hsl): Hsv
hslToRgb255() from @llui/components/utils/index
Convert HSL (h 0-360, s/l 0-100) to RGB (0-255 each, rounded).
function hslToRgb255(hsl: Hsl): Rgb255
hsvToHsl() from @llui/components/utils/index
Convert HSV (h 0-360, s/v 0-100) to HSL (h 0-360, s/l 0-100), float.
function hsvToHsl(hsv: Hsv): Hsl
hsvToOklch() from @llui/components/utils/index
function hsvToOklch(hsv: Hsv): Oklch
hsvToRgb255() from @llui/components/utils/index
HSV -> RGB255, via the DIRECT (not HSL-routed) float conversion.
function hsvToRgb255(hsv: Hsv): Rgb255
indexMap() from @llui/components/utils/index
A position lookup over a state array: positions(s.items).get(item) in place
of s.items.indexOf(item). First occurrence wins, matching indexOf.
function indexMap<T>(): (values: readonly T[] | null | undefined) => ReadonlyMap<T, number>
inSrgbGamut() from @llui/components/utils/index
Whether ok, converted to sRGB, falls inside [0,1] on every channel
(within epsilon — floating-point round-trip noise).
function inSrgbGamut(ok: Oklch, epsilon = 1e-4): boolean
interpolateColor() from @llui/components/utils/index
Interpolate between two colors per CSS Color 4 §12: convert both into
space, resolve missing (none, or a cross-space-converted achromatic
color's undefined) components — including alpha itself, which carries the
OTHER endpoint's alpha when missing rather than defaulting to 1 (CSS Color
4 §12.2's analogous-component rule applies to alpha too — rgb(255 0 0 / none) mixed with rgb(0 0 255 / 0.2) has alpha exactly 0.2 throughout,
not a lerp toward 1; pinned in test/utils/color.test.ts) —
premultiply non-hue channels by alpha, lerp, then un-premultiply.
hueMethod controls which way a hue-bearing space (hsl, oklch)
rotates, normalizing both hues to [0,360) FIRST (see {@link adjustHue});
ignored for spaces with no hue.
function interpolateColor(a: CssColor, b: CssColor, t: number, space: InterpolationSpace, hueMethod: HueInterpolationMethod = 'shorter'): CssColor
isDateOnly() from @llui/components/utils/index
function isDateOnly(value: DateValue): value is string
isEnabledItem() from @llui/components/utils/index
An item counts as navigable only while it is in the list AND not disabled.
function isEnabledItem(items: readonly string[], disabled: readonly string[], value: string): boolean
isEngineFocusInProgress() from @llui/components/utils/index
Whether an engine-initiated focus move is in flight. Consulted by
watchInteractOutside to gate its focusin path.
export declare function isEngineFocusInProgress(): boolean;
isFocusable() from @llui/components/utils/index
Find focusable descendants within a container.
export declare function isFocusable(el: Element): boolean;
isInNestedLayer() from @llui/components/utils/index
Whether target is inside (or equal to) a registered nested layer that
participates in aspect (any layer when aspect is omitted) and is nested
inside within (any layer when within is omitted).
export declare function isInNestedLayer(target: Node | null, aspect?: NestedLayerAspect, within?: NestedLayerScope): boolean;
isTypeaheadKey() from @llui/components/utils/index
Returns true if the key event should trigger a typeahead query — i.e., a
single printable character that isn't a modified keyboard shortcut. Use
this in onKeyDown handlers to decide whether to dispatch a typeahead
message.
function isTypeaheadKey(e: KeyboardEvent): boolean
lastEnabled() from @llui/components/utils/index
export declare function lastEnabled(items: readonly string[], disabled: readonly string[]): string | null;
lastEnabledIndex() from @llui/components/utils/index
function lastEnabledIndex(items: readonly string[], disabled: readonly string[]): number | null
linearPath() from @llui/components/utils/index
Straight segments through every point.
function linearPath(points: readonly Point[], closed = false): string
linearSrgbToOklab() from @llui/components/utils/index
function linearSrgbToOklab(s: Srgb): Oklab
linearToSrgb() from @llui/components/utils/index
function linearToSrgb(s: Srgb): Srgb
lockBodyScroll() from @llui/components/utils/index
Lock body scroll while an overlay is open, preserving scrollbar width to avoid layout shift. Reference-counted so nested locks compose cleanly.
export declare function lockBodyScroll(): () => void;
membershipSet() from @llui/components/utils/index
A membership lookup over a state array: set(s.value).has(item) in place of
s.value.includes(item). An absent collection reads as empty.
An EMPTY array shares EMPTY_SET rather than allocating: most of the ~16
call sites are a disabled/disabledItems list that is empty in the common
case, and an empty Set is the one input where the memo would otherwise pay
an allocation to answer false to everything.
function membershipSet<T>(): (values: readonly T[] | null | undefined) => ReadonlySet<T>
monotonePath() from @llui/components/utils/index
Monotone cubic interpolation (Fritsch–Carlson). Its defining property is that the curve NEVER OVERSHOOTS the data: between two samples it stays within their values, so a series that only rises cannot dip below a point on the way. A plain Catmull-Rom or cardinal spline does overshoot, and on a chart that means drawing a number nobody measured.
Defined on a function y = f(x) with strictly increasing x. It is therefore
meaningless on a closed angular loop, which is why polarProjection declines
it — see projection.ts.
function monotonePath(points: readonly Point[], axis: CurveAxis = 'x'): string
nearestBand() from @llui/components/utils/index
The band index nearest a normalized position — the hit test behind hover and
pointer tracking. Returns null for an empty axis.
Nearest-CENTRE, not containment: the gaps between bands belong to whichever band is closer, so a pointer never falls into a dead zone between two bars.
function nearestBand(u: number, band: Band): number | null
nextEnabled() from @llui/components/utils/index
export declare function nextEnabled(items: readonly string[], disabled: readonly string[], from: string, delta: 1 | -1, loop: boolean): string | null;
nextEnabledIndex() from @llui/components/utils/index
The index of the next enabled item delta steps from from, wrapping.
from === null starts before the first item (delta 1) or after the last
(delta -1), so the first/last enabled index comes back.
function nextEnabledIndex(items: readonly string[], disabled: readonly string[], from: number | null, delta: 1 | -1): number | null
niceDomain() from @llui/components/utils/index
Extend [min, max] outward to the nearest round tick boundaries, so the axis
ends on a labelled value instead of mid-air. Iterates because widening the
domain can change the chosen step, which can change the boundaries again;
d3's nice does the same, and it converges in at most a few rounds.
function niceDomain(min: number, max: number, count: number): Domain
normalize() from @llui/components/utils/index
Map a value into [0, 1] across domain. A DEGENERATE domain (min === max)
maps everything to 0.5 rather than dividing by zero — a single-valued series
draws as a flat line through the middle, which is the only reading that is
not a crash or a NaN in a path string.
function normalize(value: number, domain: Domain): number
oklabToLinearSrgb() from @llui/components/utils/index
function oklabToLinearSrgb(lab: Oklab): Srgb
oklabToOklch() from @llui/components/utils/index
function oklabToOklch(lab: Oklab): Oklch
oklabToSrgb() from @llui/components/utils/index
NOT gamut-mapped — may return channels outside [0,1] for an OKLab/OKLCH color outside sRGB. Use {@link gamutMapOklchToSrgb} for a displayable result.
function oklabToSrgb(lab: Oklab): Srgb
oklchToHsv() from @llui/components/utils/index
Gamut-maps into sRGB first — HSV cannot represent an out-of-gamut color.
function oklchToHsv(ok: Oklch): Hsv
oklchToOklab() from @llui/components/utils/index
function oklchToOklab(ok: Oklch): Oklab
oklchToSrgb() from @llui/components/utils/index
NOT gamut-mapped. See {@link oklabToSrgb}.
function oklchToSrgb(ok: Oklch): Srgb
parseCssColor() from @llui/components/utils/index
Parse a CSS color string into its typed model. Supports #rgb/#rgba/
#rrggbb/#rrggbbaa, rgb()/rgba(), hsl()/hsla() (both legacy comma
and modern space/slash syntax, with the legacy grammar's none-forbidden
and uniform-percentage-or-number rules enforced), hwb(), oklch(),
oklab(), lab(), lch(), color() (predefined spaces srgb,
srgb-linear, display-p3, a98-rgb, prophoto-rgb, rec2020, xyz,
xyz-d50, xyz-d65), none components, transparent, and the 148 CSS
Color 4 named colors. Returns null on anything else — never throws.
function parseCssColor(input: string): CssColor | null
parseDateValue() from @llui/components/utils/index
function parseDateValue(value: DateValue): ParsedDateValue
parseHexColor() from @llui/components/utils/index
Parse #rgb, #rgba, #rrggbb, #rrggbbaa. Returns null on anything
else, never throws.
function parseHexColor(hex: string): { rgb: Rgb255; alpha: number } | null
pointerDragHandlers() from @llui/components/utils/index
Build the five pointer handlers for one drag track. One call per live
connect() instance — dragging/activePointerId are closure-scoped per
instance, the same shape as this package's other per-instance mutable state
(e.g. color-picker's eyedropper pendingEyeDropper AbortController).
- Primary button only on
pointerdown(e.button !== 0is ignored), AND only when no drag is already live — a second finger/pointer touching down mid-drag is ignored rather than hijackingactivePointerId. - Every subsequent event (
pointermove/pointerup/pointercancel/onLostPointerCapture) is checked againstactivePointerId: a second pointer's events never affect a drag it didn't start. setPointerCaptureonpointerdown'scurrentTarget, sopointermovekeeps firing even once the pointer leaves the element's bounds — a plain drag with no capture stalls out at the track's edge instead of saturating like a native<input type="range">.- Released on
pointerup/pointercancel, guarded byhasPointerCaptureso releasing twice (or without ever capturing) is a harmless no-op. draggingis unconditionally reset ononLostPointerCapturefor the active pointer, even though that handler never callsrelease()itself (capture is already gone by definition when this fires).
function pointerDragHandlers(callbacks: PointerDragCallbacks): PointerDragHandlers
polarPoint() from @llui/components/utils/index
A point on a circle. Angles are RADIANS CLOCKWISE FROM 12 O'CLOCK, which is where every reader expects a chart to start; SVG's own 0 is 3 o'clock.
function polarPoint(cx: number, cy: number, r: number, angle: number): Point
polarProjection() from @llui/components/utils/index
function polarProjection(frame: Frame, opts: PolarOptions = {}): Projection
positiveFinite() from @llui/components/utils/index
A finite number strictly greater than zero, or undefined when unusable.
function positiveFinite(raw: number | null | undefined): number | undefined
positiveFiniteOrDefault() from @llui/components/utils/index
A positive finite number, or the field's ordinary initialization default.
function positiveFiniteOrDefault(raw: number | null | undefined, fallback: number): number
presenceEndHandler() from @llui/components/utils/index
Guard a presence "animation/transition ended" handler so it only advances the
presence machine when the event fired on the element the listener is bound to
(e.target === e.currentTarget) — never on a bubbling descendant.
Overlay content (dialog, popover, menu, toast) reflects its exit phase via
data-state="closing" and stays mounted until an animationend/transitionend
dispatches animationEnd/transitionEnd. Without this guard, ANY descendant
animation or transition ending during the exit — a spinner, a ripple, a child
fade — bubbles up and prematurely unmounts the overlay before its own exit
animation completes.
Mirrors the e.target === el guard the transitions runtime applies in
waitForEnd (@llui/transitions).
function presenceEndHandler<E extends AnimationEvent | TransitionEvent>(handler: (e: E) => void): (e: E) => void
projectionFor() from @llui/components/utils/index
Build the projection a chart's coordinate setting names.
function projectionFor(coord: ChartCoord, frame: Frame, opts: CartesianOptions & PolarOptions = {}): Projection
pruneToEnabled() from @llui/components/utils/index
Keep value only while it still names an enabled item, else null. Every
reducer that replaces the item list owes this to whatever it holds as
focused/selected — a dangling reference is the tab-stop bug.
function pruneToEnabled(items: readonly string[], disabled: readonly string[], value: string | null): string | null
pushDismissable() from @llui/components/utils/index
Register a dismissable layer. Escape is offered to the layers top-down until
one CLAIMS it (a layer declines via disableEscape or an onEscape router
returning false, and the key then falls through to the layer beneath);
outside-click is topmost-only. Returns a cleanup that removes the layer from
the stack.
Push a layer even when both dismissal routes are disabled: the layer is the caller's PLACE ON THE STACK, which is what stops the layer beneath from treating an interaction inside this one as an outside interaction.
export declare function pushDismissable(opts: DismissableOptions): () => void;
pushFocusTrap() from @llui/components/utils/index
Push a focus trap onto the stack. Tab/Shift+Tab will cycle within the container's focusable descendants. Returns a cleanup that removes the trap and (optionally) restores focus to the element active before push.
export declare function pushFocusTrap(opts: FocusTrapOptions): () => void;
rectPath() from @llui/components/utils/index
An axis-aligned rectangle, corners in any order.
function rectPath(x0: number, y0: number, x1: number, y1: number): string
registerNestedLayer() from @llui/components/utils/index
Register source (an element, array of elements, or a resolver returning
either) as a nested layer. Returns a cleanup that removes the registration.
Prefer the resolver form for a portaled overlay: register once on mount and
return the live root only while open ([] when closed), so a single
registration tracks the overlay's open/closed lifecycle without churn.
Pass opts.owner when scoped consumers must exempt the layer. Missing or
unresolved ownership fails closed and warns in development.
export declare function registerNestedLayer(source: ElementSource, opts?: NestedLayerOptions): () => void;
resetAnatomyIdCounter() from @llui/components/utils/index
Reset the internal id counter — tests only.
function resetAnatomyIdCounter(): void
resolveDir() from @llui/components/utils/index
Resolve the text direction for an element by walking up the DOM tree. Returns 'rtl' or 'ltr' (default).
The walk crosses SHADOW boundaries: Element.closest() stops at the
nearest shadow root and cannot see a dir set on an ancestor of the host,
so an element inside a shadow tree whose host (or the host's own
ancestors) declares dir would otherwise silently read as ltr. Continuing
from root.host after getRootNode() returns a ShadowRoot walks out to
the light-DOM ancestor and keeps going, arbitrarily many shadow levels
deep. The final fallback reads dir off the element's OWN document
(ownerDocument), never the global document — the global binding names a
DIFFERENT document inside an iframe or any other multi-document context.
export declare function resolveDir(el: Element): TextDirection;
resolveNone() from @llui/components/utils/index
A null (CSS none) component behaves as 0 outside interpolation.
function resolveNone(n: number | null): number
resolveRovingMove() from @llui/components/utils/index
Map a keyboard key + the current tab value to a roving-tablist move,
or null when the key isn't a navigation/activation key or the move is
a no-op (empty list, no enabled sibling). Pure — does not touch the DOM
or call preventDefault; the caller decides (typically: prevent default
iff the result is non-null).
export declare function resolveRovingMove(key: string, current: string, items: readonly RovingItem[], opts?: RovingOptions): RovingMove | null;
resolveTextDirection() from @llui/components/utils/index
Normalize any accepted direction source to a concrete TextDirection.
An explicit 'ltr' | 'rtl' wins; an Element is resolved from the DOM;
null / undefined default to 'ltr'.
export declare function resolveTextDirection(source: Element | null | undefined | TextDirection): TextDirection;
rgb255ToHsl() from @llui/components/utils/index
Convert RGB (0-255 each) to HSL (h 0-360, s/l 0-100), float.
function rgb255ToHsl(rgb: Rgb255): Hsl
rgb255ToHsv() from @llui/components/utils/index
RGB255 -> HSV, via the DIRECT (not HSL-routed) float conversion.
function rgb255ToHsv(rgb: Rgb255): Hsv
rovingTabStop() from @llui/components/utils/index
The single item that carries tabindex="0".
WAI-ARIA's roving-tabindex pattern requires EXACTLY ONE tab stop in a
composite widget: with none, Tab skips the widget entirely and it becomes
keyboard-unreachable. So a preferred candidate (the focused item, the
checked radio, …) is honoured only while it is still an enabled member, and
the first enabled item answers otherwise. Null only when nothing is enabled.
Every roving-tabindex widget in the package routes through here (#145 closed
the last three: menubar, navigation-menu and tags-input). Keep it that way —
an inline focused === x ? 0 : -1 has no fallback, and since nothing prunes
focused against the current list, removing or disabling the focused item
leaves EVERY item at -1 and the widget disappears from the Tab order.
tags-input is index-keyed and passes String(i) as the item identity (its
data-index, and the only identity that survives duplicate tag values);
navigation-menu passes either the membership list its consumer maintains or
the ids handed to its own item(), filtered first to the ones not sealed
inside a closed submenu — membership alone would seat the stop on an element
inside a hidden panel, which is present, unique and untabbable.
Null when nothing is enabled is deliberate and is a caller's problem to
notice: a widget whose items are ALL disabled ends up with no tab stop at
all. That is right for radio-group/toggle-group/toolbar/tree-view,
whose items are genuinely disabled and therefore unfocusable anyway, and it
is a 1 -> 0 change for menubar, whose triggers carry only aria-disabled
and stay focusable. Any revision belongs here, applying to every caller at
once — not in one component.
function rovingTabStop(items: readonly string[], disabled: readonly string[], ...preferred: readonly (string | null | undefined)[]): string | null
runEngineFocus() from @llui/components/utils/index
Run body with engine-focus suppression active. Any focusin raised inside
it is invisible to watchInteractOutside — including one raised by a focus
move that re-entrant consumer code makes from a focusin listener (see the
module comment: the window is the synchronous transitive closure, not just
the .focus() call).
SYNCHRONOUS BY CONTRACT, AND THE CONTRACT IS ENFORCED (#172). The suppression
is released when body RETURNS. An async body returns its promise at the
first await, so the depth counter drops immediately and the focus move that
eventually happens gets NO protection at all — a call that looks correct,
compiles, and does nothing. The failure is safe (no protection, never a stuck
guard: the decrement is in a finally), which is exactly why it is invisible,
and this is a PUBLIC export documented as the thing a custom overlay "must"
route its engine-initiated focus moves through. A consumer following that
advice with an async body would reintroduce #155 in their app while believing
they had prevented it.
Two guards, because neither covers the other's case:
- The SIGNATURE rejects a promise-returning body at compile time. It is the real guard — it fires before the code ever runs. Its one blind spot is a body whose return type is an unresolved type parameter (a generic pass-through wrapper): the conditional is deferred, so such a wrapper is rejected too and must carry its own constraint. No caller does.
- A DEV-MODE warning catches what the type system cannot see: a JavaScript
consumer, an
any-typed body, or a body that returns a thenable without being declared as returning one. It cannot restore the protection — by the time a thenable is in hand the guard is already released — so it only reports.
Deliberately NOT offered: an async-aware variant that holds the guard across
an await. The guard is safe because no user event can be delivered inside
its window (see the module comment); holding it across a suspension point
hands the event loop back and would start swallowing genuine interactions.
export declare function runEngineFocus<T>(body: () => SyncEngineFocusBody<T>): T;
setAriaHiddenOutside() from @llui/components/utils/index
Hide sibling subtrees from assistive tech while an overlay is open.
Walks from target up to the document root, applying aria-hidden="true"
and inert to every sibling at each level. Previous attribute values are
recorded and restored on cleanup.
Two kinds of element are EXEMPT: registered nested layers (see
registerNestedLayer) and live regions — aria-live, role="alert",
role="status", role="log". A live region under aria-hidden is simply
never read out, so a modal that sweeps one silences the app's announcement
channel for exactly as long as it is open (#123). Live regions are matched by
selector rather than registration because they are plain part bags with no
mount hook of their own, and because that also covers consumer-authored ones.
An exempt element does NOT spare its whole ancestor subtree: the sweep
descends through any element that merely CONTAINS one and hides everything
hanging off the path down to it. (Skipping the ancestor wholesale would leave
the entire app interactive behind the modal the moment a form somewhere held
an aria-live error message.) inert and aria-hidden both inherit, so
leaving the path clear is the only way an exempt element stays reachable.
Nested calls are supported — each layer only touches elements that haven't been claimed by a higher layer (tracked via a WeakMap reference count).
TWO KNOWN LIMITS of the live-region exemption, both deliberate:
inertcannot be split fromaria-hidden— sparing a region spares its WHOLE SUBTREE from both, so an interactive live region (arole="log"transcript containing links) stays Tab-reachable behind a modal. Keep live regions to announcement text; put controls outside them, or register the modal's own layer for the interactive part.document.querySelectorAlldoes not pierce shadow roots, so a live region inside one is not exempt. The sweep itself only ever walks light-DOM ancestors oftarget, so this only bites when the region and the modal live in different trees.
export declare function setAriaHiddenOutside(target: Element): () => void;
snapToStep() from @llui/components/utils/index
Nearest multiple of step from origin. A non-positive step is a no-op.
A non-finite value names no position on the grid, so it snaps to the grid's
own anchor — the same policy clamp applies to the range (#152). This is
unreachable from clampToStep, which clamps first; it keeps the util's
direct consumers on the same rule.
function snapToStep(value: number, step: number, origin = 0): number
srgb255ToSrgb() from @llui/components/utils/index
function srgb255ToSrgb(rgb: Rgb255): Srgb
srgbToLinear() from @llui/components/utils/index
function srgbToLinear(s: Srgb): Srgb
srgbToOklab() from @llui/components/utils/index
function srgbToOklab(s: Srgb): Oklab
srgbToOklch() from @llui/components/utils/index
function srgbToOklch(s: Srgb): Oklch
srgbToRgb255() from @llui/components/utils/index
Rounds AND clamps into 0-255 — the boundary where an out-of-gamut float (an un-mapped OKLCH conversion) must finally become a displayable byte.
function srgbToRgb255(s: Srgb): Rgb255
stepBy() from @llui/components/utils/index
Move count whole steps (negative to step down), then clamp+snap.
From an OFF-GRID value one call moves to the nearest grid value in the
direction of travel and stops there — that jump is the whole change, however
large count is. This is HTML's stepUp/stepDown (step 3 of the
value-stepping algorithm) and it is what makes increment land on the grid
instead of dragging an off-grid value along forever.
ONE DELIBERATE DIVERGENCE from the spec: HTML's step base falls back min ->
the value CONTENT ATTRIBUTE -> 0; gridOrigin goes min -> 0. A headless
machine has no content attributes — the seed value is just the initial state,
and anchoring the grid on it would make two components with the same
min/max/step disagree about which values are legal depending on where they
happened to start.
function stepBy(value: number, count: number, grid: NumericGrid): number
stepPath() from @llui/components/utils/index
Axis-aligned staircase: hold each value until halfway along the independent axis, then step.
function stepPath(points: readonly Point[], axis: CurveAxis = 'x'): string
tickIncrement() from @llui/components/utils/index
The step size a nice axis would use to fit about count ticks into
[start, stop]. A negative return means "one tick every 1/-step units" —
d3's encoding, kept because the reciprocal form avoids the float drift that
10 ** -power introduces for small steps.
function tickIncrement(start: number, stop: number, count: number): number
ticks() from @llui/components/utils/index
Round, human-readable tick values covering [start, stop]. Returns an EMPTY
array for a non-finite or degenerate range rather than looping forever —
a chart with no data is a normal state, not an error.
function ticks(start: number, stop: number, count: number): number[]
typeaheadAccumulate() from @llui/components/utils/index
Advance the typeahead query based on a new keystroke and the previous
expiration time. Returns the new query string; callers combine this with
typeaheadMatch() to produce a new highlight index.
function typeaheadAccumulate(prev: string, char: string, now: number, expiresAt: number): string
typeaheadMatch() from @llui/components/utils/index
Find the first enabled item whose label starts with the query
(case-insensitive). labels and disabledMask are parallel arrays.
startFrom is the current highlighted index; for single-character
queries the search begins at startFrom + 1 (so repeated "s" keys
cycle), for multi-character queries it begins at startFrom (inclusive).
Returns the matching index, or null if no enabled item matches.
function typeaheadMatch(labels: string[], disabledMask: boolean[], query: string, startFrom: number | null): number | null
typeaheadMatchByItems() from @llui/components/utils/index
Convenience: pass a disabled list of values instead of a boolean mask.
Builds the mask by checking membership via === on the raw string values.
function typeaheadMatchByItems(items: string[], disabled: readonly string[], query: string, startFrom: number | null): number | null
valueDomain() from @llui/components/utils/index
The value-axis domain for a set of series.
min / max are UNBOUNDED-CAPABLE bounds in the sense of finiteBound
(#177): an absent one means "derive it", never ±Infinity. A derived domain
always INCLUDES ZERO, because a bar whose baseline is not zero misrepresents
its own length — the one axis default worth being opinionated about. A caller
who wants otherwise passes both bounds.
function valueDomain(values: readonly number[], opts: { min?: number; max?: number; tickCount: number }): Domain
watchInteractOutside() from @llui/components/utils/index
Watch for pointer or focus events outside a given element. Returns a
cleanup function. Uses the capture phase so upstream stopPropagation
calls cannot hide events.
- pointerdown (or mousedown/touchstart fallback) triggers "outside" if the
target is not contained by
elementorignore. - focusin triggers "outside" when focus moves outside the element, except
when the new target is in
ignore.
export declare function watchInteractOutside(opts: InteractOutsideOptions): () => void;
Types
CalendarUnit from @llui/components/utils/index
Rungs of the ladder, finest first.
export type CalendarUnit = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'
CssColor from @llui/components/utils/index
A color as CSS Color 4 sees it. lab()/lch()/wide-gamut color() are
NOT separate tags here — see the module doc — they resolve to 'oklch' (or
'srgb' for color(srgb ...)/color(srgb-linear ...)) at parse time.
export type CssColor =
| { space: 'srgb'; r: number | null; g: number | null; b: number | null; alpha: number | null }
| {
space: 'srgb-linear'
r: number | null
g: number | null
b: number | null
alpha: number | null
}
| { space: 'hsl'; h: number | null; s: number | null; l: number | null; alpha: number | null }
| { space: 'oklab'; l: number | null; a: number | null; b: number | null; alpha: number | null }
| { space: 'oklch'; l: number | null; c: number | null; h: number | null; alpha: number | null }
Curve from @llui/components/utils/index
How a run of points is joined.
export type Curve = 'linear' | 'monotone' | 'step'
DateValue from @llui/components/utils/index
Date-only handling — the ONE place that decides whether a value denotes an INSTANT or a bare CALENDAR DATE.
new Date('2026-01-15') parses a date-only string as UTC midnight (ES spec),
so formatting it against the ambient zone renders the PREVIOUS day everywhere
west of UTC: formatDate('2026-01-15') printed "January 14, 2026" under
America/New_York and the right answer under Europe/Rome, which is why it
survived (#125 defect 4).
A calendar date carries no instant and therefore no zone, so it is anchored
at UTC midnight and its consumers must render it in UTC — see dateOnly.
export type DateValue = Date | string | number
DismissSource from @llui/components/utils/index
Reason a dismissable layer was closed.
export type DismissSource = 'escape' | 'outside';
ElementSource from @llui/components/utils/index
Shared DOM helpers used by interaction utilities.
export type ElementSource<T extends Element = Element> = T | T[] | (() => T | T[] | null)
HueInterpolationMethod from @llui/components/utils/index
export type HueInterpolationMethod = 'shorter' | 'longer' | 'increasing' | 'decreasing'
InterpolationSpace from @llui/components/utils/index
Interpolation / generic-conversion target spaces (CSS Color 4 §12).
export type InterpolationSpace = 'srgb' | 'srgb-linear' | 'oklab' | 'oklch' | 'hsl'
NestedLayerAspect from @llui/components/utils/index
A consumer of the registry. A registration participates only in the aspects it
names, because a single answer is wrong for at least one consumer: engine
overlays leave outside to the ordered dismissable stack (see the module
comment).
The dialog-with-an-inner-select case is NOT what the aspect list protects.
That one is covered by a modal never registering AT ALL, whatever aspects it
would have named.
outside— {@link watchInteractOutside} does not treat interactions inside the layer as outside interactions.focus— {@link pushFocusTrap} includes the layer as an extra focusable container, so Tab/Shift+Tab can reach it.hide— {@link setAriaHiddenOutside} hides AROUND the layer rather than hiding it.
export type NestedLayerAspect = 'outside' | 'focus' | 'hide';
NestedLayerScope from @llui/components/utils/index
The asking layer's own boundary — what "nested inside ME" is measured against. Omit it for the flat, layer-agnostic answer.
export type NestedLayerScope = ElementSource;
Placement from @llui/components/utils/index
export declare type Placement = Prettify<Side | AlignedPlacement>;
RovingMove from @llui/components/utils/index
The navigation a key implies on a roving tablist.
export type RovingMove =
/** An arrow / Home / End resolved to a (different, enabled) tab value. */
{
type: 'focus';
value: string;
}
/** Enter or Space — activate the currently focused tab (manual mode). */
| {
type: 'activate';
};
RovingOrientation from @llui/components/utils/index
Headless roving-tablist navigation — the keyboard logic of a WAI-ARIA tablist, decoupled from any particular DOM contract.
components/tabs.ts builds its reactive part-bags on top of this; a
consumer that wants its OWN markup (different classes, ids, no
data-scope/data-part) can drive the same keyboard behaviour by
calling resolveRovingMove from its trigger's onKeyDown and
focusRovingTab to move DOM focus — without adopting the component's
markup or its connect() state machine.
The resolver is pure (key + current value + items → a move); the only
shared DOM assumption lives in focusRovingTab, and it is the minimal
one both surfaces already satisfy: triggers carry role="tab" and
data-value="<value>".
The list walk itself lives in list-navigation.ts — this module is the
keyboard + DOM-focus surface over it, nothing more.
export type RovingOrientation = 'horizontal' | 'vertical';
SyncEngineFocusBodyRequired from @llui/components/utils/index
The type an ASYNC body collapses to in {@link runEngineFocus}'s parameter
position. Nothing is assignable to it, so runEngineFocus(async () => …) is a
compile error naming the contract rather than a silently inert call.
export type SyncEngineFocusBodyRequired = {
readonly [SYNC_BODY_REQUIRED]: 'runEngineFocus requires a SYNCHRONOUS body — the guard is released the moment body returns';
};
TextDirection from @llui/components/utils/index
Text reading direction. The single shared RTL vocabulary for the package.
export type TextDirection = 'ltr' | 'rtl';
Interfaces
Anatomy from @llui/components/utils/index
export interface Anatomy<P extends string> {
readonly name: string
readonly parts: readonly P[]
/** Create a new scope instance. Pass an explicit id to force a value (SSR). */
scope(id?: string): AnatomyScope<P>
}
AnatomyScope from @llui/components/utils/index
export interface AnatomyScope<P extends string> {
/** Instance id — unique across all anatomy scopes. */
readonly id: string
/** Resolve the id for a specific part (for ARIA wiring). */
idFor(part: P): string
/** Build the common data-attrs + id for a part. */
attrs(part: P): { id: string; 'data-scope': string; 'data-part': P }
}
Band from @llui/components/utils/index
A discrete axis of count slots laid out across [0, 1].
export interface Band {
count: number
/** Gap between adjacent bands, as a fraction of a slot. */
paddingInner: number
/** Gap before the first and after the last band, as a fraction of a slot. */
paddingOuter: number
}
CalendarOptions from @llui/components/utils/index
export interface CalendarOptions {
/**
* Minutes east of UTC that boundaries are measured in. Default 0 (UTC). A
* FIXED offset — see the module note; `-300` is US Eastern standard time and
* stays at −5 across a DST transition rather than following it.
*/
offsetMinutes?: number
/** First day of the week for the `week` rung: 0 = Sunday … 6 = Saturday.
* Default 1 (Monday), matching ISO 8601. */
weekStartsOn?: number
}
CalendarStep from @llui/components/utils/index
A chosen rung, plus how many of it each boundary advances. stride is
always 1 except on year.
export interface CalendarStep {
unit: CalendarUnit
stride: number
}
CalendarStepOptions from @llui/components/utils/index
export interface CalendarStepOptions extends CalendarOptions {
/** Upper bound on the number of boundaries. Default 6. */
maxTicks?: number
/** Force a rung instead of choosing one from the span. */
unit?: CalendarUnit
}
CalendarTick from @llui/components/utils/index
export interface CalendarTick {
/** The boundary instant, in epoch milliseconds. */
at: number
unit: CalendarUnit
}
CartesianOptions from @llui/components/utils/index
export interface CartesianOptions {
/** Swap the axes: `u` runs top-to-bottom and `v` left-to-right. This is what
* turns a column chart into a horizontal bar chart, and it is a PROJECTION
* concern — no mark, scale or hit test changes. */
horizontal?: boolean
}
DismissableOptions from @llui/components/utils/index
export interface DismissableOptions {
/** The layer element (e.g. a dialog content or popover). */
element: ElementSource;
/** Trigger / anchor elements that should not count as outside interactions. */
ignore?: ElementSource;
/** Called when the user dismisses the layer. */
onDismiss: (source: DismissSource, event: Event) => void;
/**
* Custom Escape router. When provided it runs for the Escape key INSTEAD of
* `onDismiss('escape', …)`, letting the layer unwind an internal level first
* (e.g. a menu closes its open submenu before closing the whole menu). Return
* `false` to decline — the event is not claimed and propagates as if this
* layer had `disableEscape`. Any other return (incl. `undefined`) claims it.
*/
onEscape?: (event: KeyboardEvent) => boolean | void;
/** Disable outside-click dismissal (default: false). */
disableOutside?: boolean;
/** Disable Escape-key dismissal (default: false). */
disableEscape?: boolean;
}
Domain from @llui/components/utils/index
A closed numeric interval. Both ends are finite by construction.
export interface Domain {
min: number
max: number
}
FloatingOptions from @llui/components/utils/index
export interface FloatingOptions {
/** The reference element (trigger/anchor). */
anchor: Element;
/** The floating element (content). */
floating: HTMLElement;
/**
* Element that receives the resolved full `data-placement` and physical
* `data-side`. Defaults to `floating`. Use a separate content element when
* `floating` is a geometry-only positioner wrapper.
*/
stateTarget?: HTMLElement;
/** Preferred placement (default: 'bottom'). */
placement?: Placement;
/** Gap between anchor and floating, in px (default: 0). */
offset?: number;
/** Flip to opposite side when there isn't enough room (default: true). */
flip?: boolean;
/** Shift along axis to stay in view (default: padding 8 unless false). */
shift?: boolean | {
padding?: number;
};
/**
* Reading direction. Under `'rtl'`, logical `*-start`/`*-end` placements
* track the inline-start/inline-end edges. When given it is AUTHORITATIVE —
* it overrides the direction the floating element happens to compute to,
* which for a portaled overlay is the direction of wherever it landed.
* Omit it to leave that decision to the page, as floating-ui does by default.
*/
dir?: TextDirection;
/** Optional arrow element to position. */
arrow?: HTMLElement;
/** Notify after each position computation. */
onUpdate?: (data: {
x: number;
y: number;
placement: Placement;
arrow?: {
x?: number;
y?: number;
};
}) => void;
}
FocusRestoreQuery from @llui/components/utils/index
The one rule for "should this layer pull focus back to its anchor?".
Restoring focus to the trigger is right when the layer was closed with focus still resting inside it (Escape, a close button, a programmatic close) — the user's focus would otherwise be left on a detached node. It is WRONG when the dismissal was caused by focus moving somewhere the user chose: yanking it back to the trigger takes focus away from the control they just reached, and can leave a still-open layer with focus outside it (#173).
document.body (and a null activeElement) counts as "inside" because that
is where focus lands when the focused element is removed — nobody chose it, so
the anchor is a better home than the body.
Both callers must ask BEFORE tearing anything down: the focus trap's release
and the aria-hidden/inert sweep both move or invalidate activeElement,
so a decision taken after them is a decision about the engine's own cleanup.
export interface FocusRestoreQuery {
/** The region that counts as "inside" this layer. */
boundary: Element
/** The element focus would be restored TO. */
anchor?: Element | null
/**
* Also treat the anchor itself being focused as "inside" (`select`, which
* focuses its own trigger on open — without this its restore reads as "the
* user moved focus to the trigger" and never runs).
*/
allowAnchorActive?: boolean
}
FocusTrapOptions from @llui/components/utils/index
export interface FocusTrapOptions {
/** The container whose focusable descendants form the trap. */
container: ElementSource;
/** Element to focus when the trap activates. Defaults to the first
* tab-reachable descendant, else the container itself (given a temporary
* `tabindex="-1"` when it has none). */
initialFocus?: Element | (() => Element | null);
/** Restore focus to the previously active element on release (default: true). */
restoreFocus?: boolean;
}
Frame from @llui/components/utils/index
The plot area in user units — the box marks are drawn inside, already inset by whatever padding the axes need.
export interface Frame {
x: number
y: number
width: number
height: number
}
Hsl from @llui/components/utils/index
HSL color. h 0–360 degrees, s/l 0–100. Float — see the module doc.
export interface Hsl {
h: number
s: number
l: number
}
Hsv from @llui/components/utils/index
HSV color. h 0–360 degrees, s/v 0–100. Float — see the module doc.
export interface Hsv {
h: number
s: number
v: number
}
InteractOutsideOptions from @llui/components/utils/index
export interface InteractOutsideOptions {
/** Element(s) that define the "inside" region. */
element: ElementSource;
/** Additional elements whose interactions should not count as outside (e.g. triggers). */
ignore?: ElementSource;
/** Called on pointerdown or focus outside the inside region. */
onInteractOutside: (event: Event) => void;
/**
* If provided, called first with the event. Return `false` to suppress the
* outside callback (for an in-flight layer to claim the event).
*/
shouldDispatch?: (event: Event) => boolean;
}
NestedLayerOptions from @llui/components/utils/index
export interface NestedLayerOptions {
/**
* Consumers this registration participates in. Defaults to all of them, which
* is what a surface with no dismissable layer of its own needs. Narrow it when
* another mechanism already covers an aspect — see the module comment.
*/
aspects?: readonly NestedLayerAspect[];
/**
* The element this layer is logically nested INSIDE — its trigger/anchor, or
* the host element it belongs to. This is what makes the registry per-layer:
* an asking layer exempts this registration only when the owner is inside the
* asker's own boundary (transitively through other nested layers).
*
* The owner is NOT the layer's portal root — that is the `source` argument.
* It is the thing in the main document tree that the portal speaks for.
*
* Resolver form is supported and re-read on every lookup, so an owner that
* mounts and unmounts with its component can be named once.
*
* A missing or unresolved owner grants no scoped exemption and emits a
* development warning. Even a registration used only through the unscoped
* registry-wide view should name its logical owner to keep the contract
* explicit.
*/
owner?: ElementSource;
}
NumericGrid from @llui/components/utils/index
A stepped numeric range. min/max default to unbounded and step to 0
(= continuous). Component states name their fields the same way, so a state
object can be passed straight in.
export interface NumericGrid {
min?: number
max?: number
step?: number
}
Oklab from @llui/components/utils/index
Oklab. l is 0–1 lightness; a/b are unbounded (±0.4 roughly spans
sRGB).
export interface Oklab {
l: number
a: number
b: number
}
Oklch from @llui/components/utils/index
OKLCH. l 0–1, c >= 0 (unbounded — maxChroma in the color picker is a
UI choice, not a property of the color space), h in degrees.
export interface Oklch {
l: number
c: number
h: number
}
ParsedDateValue from @llui/components/utils/index
export interface ParsedDateValue {
date: Date
/**
* True when the input was a bare calendar date. Formatters MUST then render
* in UTC (where the anchor was taken), otherwise the ambient zone shifts the
* rendered day.
*/
dateOnly: boolean
}
Point from @llui/components/utils/index
export interface Point {
x: number
y: number
}
PointerDragCallbacks from @llui/components/utils/index
One call site's drag callbacks.
export interface PointerDragCallbacks {
/** Checked on `pointerdown` only — a drag that is already underway keeps
* running even if this flips true mid-drag (the reducer's own `disabled`
* guard is the actual safety net for a message dispatched from `onDrag`
* after that point; this only decides whether a NEW drag may start). */
isDisabled: () => boolean
/** Fires once, on `pointerdown`, after capture is acquired and BEFORE the
* first `onDrag` call — the hook for a one-time side effect like moving
* focus onto a thumb element. */
onDragStart?: (e: PointerEvent) => void
/** Fires on `pointerdown` (immediately after `onDragStart`) and again on
* every `pointermove` while the drag is live. */
onDrag: (e: PointerEvent) => void
/** Fires once on `pointerup`/`pointercancel`, but ONLY when a drag was
* actually live (never on a stray up/cancel with no matching down) — the
* counterpart to `onDragStart`, for a caller that tracks per-drag identity
* across the lifecycle (`gradient-picker`'s track drag creates a new stop
* on `pointerdown` and must stop targeting it once the drag ends). Runs
* AFTER pointer capture is released. */
onDragEnd?: (e: PointerEvent) => void
}
PointerDragHandlers from @llui/components/utils/index
export interface PointerDragHandlers {
onPointerDown: (e: PointerEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerUp: (e: PointerEvent) => void
onPointerCancel: (e: PointerEvent) => void
/** The browser can revoke pointer capture without ever firing `pointerup`/
* `pointercancel` (an OS-level interruption, a nested capture request, …).
* Left unwired, `dragging` would stay `true` forever and a later, UNRELATED
* pointer's `pointermove` would be misread as a continuation of this drag.
* Spread onto the same element as the other four. */
onLostPointerCapture: (e: PointerEvent) => void
}
PolarOptions from @llui/components/utils/index
export interface PolarOptions {
/** Inner radius as a fraction of the outer. `0` is a pie, `0.5` a donut, and
* a radar usually wants `0`. */
innerRadius?: number
/** Where `u = 0` sits, in radians clockwise from 12 o'clock. */
startAngle?: number
/** Total sweep in radians. Defaults to a full turn, which is what makes the
* projection `closed`. */
sweep?: number
/**
* Gridline shape. `ring` is a circle at the given magnitude — right for a
* pie or a rose. `web` joins the `spokes` sample positions with straight
* segments — right for a radar, where a circular grid reads as a different
* chart from the polygonal data on top of it.
*/
grid?: 'ring' | 'web'
/** How many spokes a `web` gridline has. Ignored for `ring`. */
spokes?: number
/**
* Swap the axes, exactly as {@link CartesianOptions.horizontal} does: the
* INDEPENDENT axis moves off the angle and onto the RADIUS, so each category
* becomes its own concentric ring and magnitude is read as arc length. That
* is a radial bar chart, and it is the polar reading of the same flag that
* turns a column chart on its side — one field, the same meaning in both
* coordinate systems, so a consumer flipping `coord` keeps whatever
* orientation they asked for.
*
* The independent axis is then the radius, which is NOT a loop, so the
* projection reports `closed: false` however wide the sweep is.
*/
horizontal?: boolean
}
Projection from @llui/components/utils/index
export interface Projection {
readonly kind: ChartCoord
/**
* Whether the independent axis is a CLOSED loop. `true` for a polar
* projection covering a full turn, which is what makes a radar outline join
* back to its first vertex instead of leaving a gap.
*/
readonly closed: boolean
/**
* Curves this projection can honour. A caller asking for one outside the set
* gets `linear` — see the polar note below for why this is the honest answer
* rather than a silent approximation.
*/
readonly curves: readonly Curve[]
/** Normalized (u, v) → a point in user units. */
point(u: number, v: number): Point
/** A run of samples joined along increasing `u`. */
line(samples: readonly Sample[], curve: Curve): string
/** The region between an upper run and a lower run of the same length. */
area(upper: readonly Sample[], lower: readonly Sample[], curve: Curve): string
/** A band spanning `u0..u1` and `v0..v1`: a bar, or a wedge. */
band(u0: number, u1: number, v0: number, v1: number): string
/** The iso-magnitude line at `v` — a value gridline. */
gridline(v: number): string
/** Where the independent-axis tick for `u` belongs. */
tick(u: number): TickPlacement
/** Where the DEPENDENT-axis tick for `v` belongs. */
valueTick(v: number): TickPlacement
/**
* Invert a point in user units back to `u`, for pointer hit testing. Returns
* `null` when the point carries no meaningful `u` — the exact centre of a
* polar chart, where every angle is equally close.
*/
locate(x: number, y: number): number | null
}
Rgb255 from @llui/components/utils/index
8-bit-per-channel sRGB, 0–255 (integers once produced by this module).
export interface Rgb255 {
r: number
g: number
b: number
}
RovingItem from @llui/components/utils/index
export interface RovingItem {
value: string;
/** Disabled items are skipped by arrow/Home/End navigation. */
disabled?: boolean;
}
RovingOptions from @llui/components/utils/index
export interface RovingOptions {
/** Arrow axis — 'horizontal' uses Left/Right, 'vertical' uses Up/Down. Default 'horizontal'. */
orientation?: RovingOrientation;
/** Whether arrow navigation wraps at the ends. Default true. */
loop?: boolean;
/**
* An element used to resolve text direction for RTL arrow flipping
* (typically the event's `currentTarget`). When it resolves to
* `dir="rtl"`, ArrowLeft/ArrowRight swap. Optional.
*/
element?: Element | null;
}
Sample from @llui/components/utils/index
A normalized sample: position along the independent axis, and magnitude.
export interface Sample {
u: number
v: number
}
Srgb from @llui/components/utils/index
sRGB, gamma-encoded, 0–1 per channel. May fall outside [0,1] as an intermediate value (e.g. converted from an out-of-gamut OKLCH) — callers that need an in-gamut result go through {@link gamutMapOklchToSrgb}.
export interface Srgb {
r: number
g: number
b: number
}
TickPlacement from @llui/components/utils/index
Where an independent-axis tick label sits, and how to align it there.
export interface TickPlacement {
x: number
y: number
/** SVG `text-anchor`. */
anchor: 'start' | 'middle' | 'end'
/** SVG `dominant-baseline`. */
baseline: 'auto' | 'middle' | 'hanging'
}
TreeNode from @llui/components/utils/index
TreeCollection — helper for building tree-view payloads from a nested data structure. Tree-view's state machine only knows about flat lists (visibleItems, visibleLabels) and opaque ids; the collection owns the structure and derives those flat arrays on demand.
Typical flow:
const col = new TreeCollection(data) state.visibleItems = col.visibleItems(state.expanded) state.visibleLabels = col.visibleLabels(state.expanded)
After every expand / collapse message, the consumer dispatches
setVisibleItems with the updated arrays. The collection itself is
immutable — build a new one when the tree structure changes.
export interface TreeNode {
id: string
label?: string
disabled?: boolean
children?: TreeNode[]
}
Classes
TreeCollection from @llui/components/utils/index
class TreeCollection {
roots: TreeNode[]
info: Map<string, NodeInfo>
constructor(roots: TreeNode | TreeNode[])
index(): void
getNode(id: string): TreeNode | null
getLabel(id: string): string
getParent(id: string): string | null
getDepth(id: string): number
getChildren(id: string): string[]
getDescendants(id: string): string[]
isBranch(id: string): boolean
isDisabled(id: string): boolean
visibleItems(expanded: string[]): string[]
visibleLabels(expanded: string[]): string[]
computeIndeterminate(checked: Set<string>): string[]
}
Constants
ALL_NESTED_LAYER_ASPECTS from @llui/components/utils/index
Every aspect — the default for a registration that names none.
const ALL_NESTED_LAYER_ASPECTS: readonly NestedLayerAspect[]
MAX_CALENDAR_TICKS from @llui/components/utils/index
A hard ceiling on the boundaries one call will produce. Reached only by
FORCING a fine unit across an enormous span; the automatic ladder can never
exceed maxTicks. Over the ceiling the answer is an EMPTY array — no
gridlines — rather than a truncated one, because a truncated axis silently
claims the data ends where the array does.
const MAX_CALENDAR_TICKS
TYPEAHEAD_TIMEOUT_MS from @llui/components/utils/index
Typeahead search — accumulates keystrokes into a query while the user types rapidly, then matches the first item whose label starts with the query. Used by listbox, menu, select, combobox, tree-view to support WAI-ARIA keyboard navigation patterns.
Behavior:
- If a keystroke arrives within
TYPEAHEAD_TIMEOUT_MSof the previous one, append to the existing query (so typing "sa" finds "Saturn" even if the highlight is currently on "Jupiter"). - Otherwise, start a fresh single-character query.
- Single-character queries advance past the current position (jump to the next item starting with that letter), which is the standard WAI-ARIA behavior — rapid repeated presses of "s" cycle through items beginning with "s".
- Multi-character queries search from the current cursor position (inclusive) so if the cursor is already on a matching item, it stays — typing "ap" while on "apricot" keeps focus on "apricot".
const TYPEAHEAD_TIMEOUT_MS
@llui/components/utils/interact-outside
Functions
watchInteractOutside() from @llui/components/utils/interact-outside
Watch for pointer or focus events outside a given element. Returns a
cleanup function. Uses the capture phase so upstream stopPropagation
calls cannot hide events.
- pointerdown (or mousedown/touchstart fallback) triggers "outside" if the
target is not contained by
elementorignore. - focusin triggers "outside" when focus moves outside the element, except
when the new target is in
ignore.
export declare function watchInteractOutside(opts: InteractOutsideOptions): () => void;
Interfaces
InteractOutsideOptions from @llui/components/utils/interact-outside
export interface InteractOutsideOptions {
/** Element(s) that define the "inside" region. */
element: ElementSource;
/** Additional elements whose interactions should not count as outside (e.g. triggers). */
ignore?: ElementSource;
/** Called on pointerdown or focus outside the inside region. */
onInteractOutside: (event: Event) => void;
/**
* If provided, called first with the event. Return `false` to suppress the
* outside callback (for an in-flight layer to claim the event).
*/
shouldDispatch?: (event: Event) => boolean;
}
@llui/components/utils/lifecycle
Functions
onScopeTeardown() from @llui/components/utils/lifecycle
Run fn when the enclosing build's scope tears down — but only if there is a
build to hook.
connect() in this package is a pure part-bag builder: it must stay callable
from a unit test with no build context, so a bare onTeardown (which throws
outside a build) cannot be used directly. __currentBuildInfo() returns
null outside a build, giving a non-throwing predicate.
Best-effort by design. Outside a build this is a no-op, so anything registered here must be a CLEANUP for something that is already safe on its own — a pending hover timer whose message is dropped by a detached-element guard, say. Cancelling it is the tidy-up; the guard is the correctness.
function onScopeTeardown(fn: () => void): void
@llui/components/utils/list-navigation
Functions
applySelection() from @llui/components/utils/list-navigation
Apply a click/Enter on value to the current selection. Single mode
replaces, multiple toggles, and a disabled item changes nothing — returning
the SAME array reference so the reducer's no-op stays a no-op for the
reference-equality reconciler.
function applySelection(current: string[], value: string, opts: { mode: SelectionMode; disabled?: readonly string[] }): string[]
firstEnabled() from @llui/components/utils/list-navigation
Internal value navigation used by the public roving-focus primitive.
export declare function firstEnabled(items: readonly string[], disabled: readonly string[]): string | null;
firstEnabledIndex() from @llui/components/utils/list-navigation
function firstEnabledIndex(items: readonly string[], disabled: readonly string[]): number | null
isEnabledItem() from @llui/components/utils/list-navigation
An item counts as navigable only while it is in the list AND not disabled.
function isEnabledItem(items: readonly string[], disabled: readonly string[], value: string): boolean
lastEnabled() from @llui/components/utils/list-navigation
export declare function lastEnabled(items: readonly string[], disabled: readonly string[]): string | null;
lastEnabledIndex() from @llui/components/utils/list-navigation
function lastEnabledIndex(items: readonly string[], disabled: readonly string[]): number | null
nextEnabled() from @llui/components/utils/list-navigation
export declare function nextEnabled(items: readonly string[], disabled: readonly string[], from: string, delta: 1 | -1, loop: boolean): string | null;
nextEnabledIndex() from @llui/components/utils/list-navigation
The index of the next enabled item delta steps from from, wrapping.
from === null starts before the first item (delta 1) or after the last
(delta -1), so the first/last enabled index comes back.
function nextEnabledIndex(items: readonly string[], disabled: readonly string[], from: number | null, delta: 1 | -1): number | null
pruneToEnabled() from @llui/components/utils/list-navigation
Keep value only while it still names an enabled item, else null. Every
reducer that replaces the item list owes this to whatever it holds as
focused/selected — a dangling reference is the tab-stop bug.
function pruneToEnabled(items: readonly string[], disabled: readonly string[], value: string | null): string | null
rovingTabStop() from @llui/components/utils/list-navigation
The single item that carries tabindex="0".
WAI-ARIA's roving-tabindex pattern requires EXACTLY ONE tab stop in a
composite widget: with none, Tab skips the widget entirely and it becomes
keyboard-unreachable. So a preferred candidate (the focused item, the
checked radio, …) is honoured only while it is still an enabled member, and
the first enabled item answers otherwise. Null only when nothing is enabled.
Every roving-tabindex widget in the package routes through here (#145 closed
the last three: menubar, navigation-menu and tags-input). Keep it that way —
an inline focused === x ? 0 : -1 has no fallback, and since nothing prunes
focused against the current list, removing or disabling the focused item
leaves EVERY item at -1 and the widget disappears from the Tab order.
tags-input is index-keyed and passes String(i) as the item identity (its
data-index, and the only identity that survives duplicate tag values);
navigation-menu passes either the membership list its consumer maintains or
the ids handed to its own item(), filtered first to the ones not sealed
inside a closed submenu — membership alone would seat the stop on an element
inside a hidden panel, which is present, unique and untabbable.
Null when nothing is enabled is deliberate and is a caller's problem to
notice: a widget whose items are ALL disabled ends up with no tab stop at
all. That is right for radio-group/toggle-group/toolbar/tree-view,
whose items are genuinely disabled and therefore unfocusable anyway, and it
is a 1 -> 0 change for menubar, whose triggers carry only aria-disabled
and stay focusable. Any revision belongs here, applying to every caller at
once — not in one component.
function rovingTabStop(items: readonly string[], disabled: readonly string[], ...preferred: readonly (string | null | undefined)[]): string | null
Types
SelectionMode from @llui/components/utils/list-navigation
export type SelectionMode = 'single' | 'multiple'
@llui/components/utils/nested-layer
Functions
_nestedLayerCount() from @llui/components/utils/nested-layer
@internal — tests only
export declare function _nestedLayerCount(): number;
getNestedLayers() from @llui/components/utils/nested-layer
Currently-registered nested-layer elements (resolvers re-read live).
With an aspect, only registrations that participate in it; without one, all
of them. With a within boundary, only registrations nested inside it (see
the module comment); without one, the flat, layer-agnostic answer.
export declare function getNestedLayers(aspect?: NestedLayerAspect, within?: NestedLayerScope): Element[];
isInNestedLayer() from @llui/components/utils/nested-layer
Whether target is inside (or equal to) a registered nested layer that
participates in aspect (any layer when aspect is omitted) and is nested
inside within (any layer when within is omitted).
export declare function isInNestedLayer(target: Node | null, aspect?: NestedLayerAspect, within?: NestedLayerScope): boolean;
registerNestedLayer() from @llui/components/utils/nested-layer
Register source (an element, array of elements, or a resolver returning
either) as a nested layer. Returns a cleanup that removes the registration.
Prefer the resolver form for a portaled overlay: register once on mount and
return the live root only while open ([] when closed), so a single
registration tracks the overlay's open/closed lifecycle without churn.
Pass opts.owner when scoped consumers must exempt the layer. Missing or
unresolved ownership fails closed and warns in development.
export declare function registerNestedLayer(source: ElementSource, opts?: NestedLayerOptions): () => void;
Types
NestedLayerAspect from @llui/components/utils/nested-layer
A consumer of the registry. A registration participates only in the aspects it
names, because a single answer is wrong for at least one consumer: engine
overlays leave outside to the ordered dismissable stack (see the module
comment).
The dialog-with-an-inner-select case is NOT what the aspect list protects.
That one is covered by a modal never registering AT ALL, whatever aspects it
would have named.
outside— {@link watchInteractOutside} does not treat interactions inside the layer as outside interactions.focus— {@link pushFocusTrap} includes the layer as an extra focusable container, so Tab/Shift+Tab can reach it.hide— {@link setAriaHiddenOutside} hides AROUND the layer rather than hiding it.
export type NestedLayerAspect = 'outside' | 'focus' | 'hide';
NestedLayerScope from @llui/components/utils/nested-layer
The asking layer's own boundary — what "nested inside ME" is measured against. Omit it for the flat, layer-agnostic answer.
export type NestedLayerScope = ElementSource;
Interfaces
NestedLayerOptions from @llui/components/utils/nested-layer
export interface NestedLayerOptions {
/**
* Consumers this registration participates in. Defaults to all of them, which
* is what a surface with no dismissable layer of its own needs. Narrow it when
* another mechanism already covers an aspect — see the module comment.
*/
aspects?: readonly NestedLayerAspect[];
/**
* The element this layer is logically nested INSIDE — its trigger/anchor, or
* the host element it belongs to. This is what makes the registry per-layer:
* an asking layer exempts this registration only when the owner is inside the
* asker's own boundary (transitively through other nested layers).
*
* The owner is NOT the layer's portal root — that is the `source` argument.
* It is the thing in the main document tree that the portal speaks for.
*
* Resolver form is supported and re-read on every lookup, so an owner that
* mounts and unmounts with its component can be named once.
*
* A missing or unresolved owner grants no scoped exemption and emits a
* development warning. Even a registration used only through the unscoped
* registry-wide view should name its logical owner to keep the contract
* explicit.
*/
owner?: ElementSource;
}
Constants
ALL_NESTED_LAYER_ASPECTS from @llui/components/utils/nested-layer
Every aspect — the default for a registration that names none.
const ALL_NESTED_LAYER_ASPECTS: readonly NestedLayerAspect[]
@llui/components/utils/number
Functions
allFiniteNumbers() from @llui/components/utils/number
Whether every number nested in one atomic runtime payload is usable. Non-numeric leaves are ignored; arrays and plain payload objects are walked so callers cannot accidentally validate one coordinate while committing a bad sibling. Cycles are harmless because an already-seen object contains no new numeric leaves.
function allFiniteNumbers(...values: readonly unknown[]): boolean
clamp() from @llui/components/utils/number
Bound n into [min, max]. The result is always FINITE: a non-finite input
maps to a defined legal value instead of being stored verbatim.
Every comparison against NaN is false, so NaN used to fall straight
through to return n and land in state — package-wide, since this is the one
clamp every mutation path routes through (#152). It is not merely a wrong
number: JSON.stringify(NaN) (and Infinity) is null, so a non-finite
value breaks the State-is-JSON-serializable invariant and with it devtools
time-travel, @llui/test replay, agent state snapshots and SSR rehydration.
Rejecting at this boundary is what lets every caller state its own
postcondition — e.g. slider's withThumb — without a finiteness caveat.
function clamp(n: number, min: number, max: number): number
clampToStep() from @llui/components/utils/number
Clamp into the range AND snap onto the grid. The result is always within
[min, max]: snapping can leave the range when an endpoint is not itself on
the grid (min 0, max 10, step 4 → 10 snaps up to 12), and the answer there is
the last grid value INSIDE the range, not an out-of-range or off-grid one.
It is always FINITE too — the clamp rejects a non-finite input first (#152).
function clampToStep(value: number, grid: NumericGrid): number
decimalPlaces() from @llui/components/utils/number
Fraction digits n is written with, INCLUDING exponential notation —
String(1e-7) is '1e-7', which a scan for '.' reads as zero decimals.
function decimalPlaces(n: number): number
finiteBound() from @llui/components/utils/number
A bound as STATE may hold it: the finite number itself, or undefined for
"no bound on this side". THE ONE normalizer for a bound, mirroring clamp's
role for a value (#177).
±Infinity and an ABSENT bound already mean the same thing to every clamp in
the package — clampToStep expands grid.min ?? -Infinity — but only one of
the two spellings survives JSON.stringify, which writes null for both
Infinity and NaN. State must be JSON-serializable (devtools time-travel,
@llui/test replay, agent state snapshots, SSR rehydration all compare
serialized state), so the infinite spelling belongs to the RUNTIME expansion
and never to state: normalize at every write, let the grid expand the absence
again. An unbounded number-input used to store min: -Infinity and
rehydrate as min: null — a number field holding null, on the DEFAULT
configuration.
NaN collapses here too, and that is the half a ?? cannot rescue: NaN is
not nullish, so a NaN bound reached clamp, every comparison against it
was false, and THAT SIDE OF THE RANGE STOPPED CLAMPING — angle-slider after
setMin: NaN stored -9999 for setValue(-9999).
Callers decide what an absent bound means for them, and there are exactly two idioms:
- UNBOUNDED-CAPABLE (
number-input): store theundefinedby OMITTING the key, so the state shape IS aNumericGridand round-trips identically. - INTRINSICALLY BOUNDED (
angle-slider,slider,splitter, …): a requiredmin: numbercannot spell "unbounded", so?? DEFAULTatinitand REJECT the write in asetMin/setMaxreducer — dropping a meaningless bound keeps the range the component already had, which is the only answer that cannot silently disable clamping.
function finiteBound(raw: number | null | undefined): number | undefined
finiteOrDefault() from @llui/components/utils/number
A component-owned number that has no range to clamp into. Initialization
replaces an unusable input with the field's ordinary default; runtime
reducers use {@link allFiniteNumbers} to refuse the whole message instead.
Keeping those two policies here prevents a free position or timestamp from
accidentally inheriting either the grid-value policy (clamp) or the
optional-bound policy (finiteBound).
function finiteOrDefault(raw: number | null | undefined, fallback: number): number
positiveFinite() from @llui/components/utils/number
A finite number strictly greater than zero, or undefined when unusable.
function positiveFinite(raw: number | null | undefined): number | undefined
positiveFiniteOrDefault() from @llui/components/utils/number
A positive finite number, or the field's ordinary initialization default.
function positiveFiniteOrDefault(raw: number | null | undefined, fallback: number): number
snapToStep() from @llui/components/utils/number
Nearest multiple of step from origin. A non-positive step is a no-op.
A non-finite value names no position on the grid, so it snaps to the grid's
own anchor — the same policy clamp applies to the range (#152). This is
unreachable from clampToStep, which clamps first; it keeps the util's
direct consumers on the same rule.
function snapToStep(value: number, step: number, origin = 0): number
stepBy() from @llui/components/utils/number
Move count whole steps (negative to step down), then clamp+snap.
From an OFF-GRID value one call moves to the nearest grid value in the
direction of travel and stops there — that jump is the whole change, however
large count is. This is HTML's stepUp/stepDown (step 3 of the
value-stepping algorithm) and it is what makes increment land on the grid
instead of dragging an off-grid value along forever.
ONE DELIBERATE DIVERGENCE from the spec: HTML's step base falls back min ->
the value CONTENT ATTRIBUTE -> 0; gridOrigin goes min -> 0. A headless
machine has no content attributes — the seed value is just the initial state,
and anchoring the grid on it would make two components with the same
min/max/step disagree about which values are legal depending on where they
happened to start.
function stepBy(value: number, count: number, grid: NumericGrid): number
Interfaces
NumericGrid from @llui/components/utils/number
A stepped numeric range. min/max default to unbounded and step to 0
(= continuous). Component states name their fields the same way, so a state
object can be passed straight in.
export interface NumericGrid {
min?: number
max?: number
step?: number
}
@llui/components/utils/overlay-engine
Functions
createOverlay() from @llui/components/utils/overlay-engine
function createOverlay<S>(opts: OverlayEngineOptions<S>): Mountable
positionerProps() from @llui/components/utils/overlay-engine
Merge a consumer-supplied class into a positioner part bag.
createOverlay BUILDS the positioner div itself (div(opts.positioner, opts.content())), so a consumer styling an overlay had no way to reach it —
the one node in the tree they could not class. That is fine while the opt-in
baseline stylesheet is doing the work (it targets
[data-scope][data-part='positioner'] directly), and a real gap for anyone
styling with utilities instead, who could not put a z-index on the floating
wrapper at all.
Returns base UNCHANGED when no class is supplied, so every existing call
site keeps its exact props object and allocates nothing extra on the overlay
mount path.
function positionerProps(base: ElProps, className: string | undefined): ElProps
Types
OverlayElementReference from @llui/components/utils/overlay-engine
A live DOM relationship resolved when an overlay's interaction phase mounts.
export type OverlayElementReference = { id: string } | { resolve: () => Element | null }
Interfaces
OverlayDismissConfig from @llui/components/utils/overlay-engine
export interface OverlayDismissConfig {
disableEscape?: boolean
disableOutside?: boolean
/** Dismiss boundary element (default: `'content'`). `'floating'` extends the
* boundary to the whole popup (searchable-select's filter input is a sibling
* of content inside the popup). */
boundary?: 'content' | 'floating'
/** Extra side effect after the standard `onDismiss()` — popover refocuses the
* trigger on dismiss. */
extra?: (els: OverlayElements) => void
/**
* Custom Escape router. When provided it runs for the Escape key instead of
* the standard `onDismiss()`, letting the component unwind an internal level
* first — e.g. a menu closes its open submenu before closing the whole menu.
* Return `false` to let Escape propagate (decline); any other return claims it.
*/
onEscape?: (els: OverlayElements, event: KeyboardEvent) => boolean | void
}
OverlayElements from @llui/components/utils/overlay-engine
The live elements resolved for the interaction phase.
export interface OverlayElements {
/** The overlay content element (resolved by `contentId`). */
content: HTMLElement
/** Element used only for floating placement. */
placementAnchor: HTMLElement | null
/** Elements ignored only by outside-dismissal detection. */
dismissIgnore: Element[]
/** Element used only as the explicit focus-return target. */
focusReturnTarget: HTMLElement | null
/** The floating element — the nearest `[data-part="positioner"]` ancestor of
* `content`, or `content` itself when there is no positioner. */
floating: HTMLElement
}
OverlayEngineOptions from @llui/components/utils/overlay-engine
export interface OverlayEngineOptions<S> {
state: ReadSignal<S>
/** Resolved portal host (see `resolvePortalTarget`). */
host: Element | undefined
/** The positioner part props spread onto the wrapping `div`. */
positioner: ElProps
content: () => Renderable
contentId: string
/** Explicit, independent DOM relationships used by this overlay. */
relationships: OverlayRelationships
/** Id resolution strategy. `'scope'` (default) resolves within the node's root
* (shadow-DOM safe); `'document'` uses the global `document` (dialog family). */
idScope?: 'scope' | 'document'
/** Keep the node mounted while this holds (through the exit animation). */
mountWhen: (s: S) => boolean
/** When provided, the interaction phase is wrapped in an inner `show` gated on
* this so it unwinds at the close request while the node lingers. */
visibleWhen?: (s: S) => boolean
/** Fired when the overlay is dismissed (Escape / outside click). */
onDismiss: () => void
/** Fired after the interaction phase has fully unwound, including on dispose. */
onInteractionEnd?: () => void
floating?: OverlayFloatingConfig
dismiss?: OverlayDismissConfig
focusTrap?: OverlayFocusTrapConfig
lockScroll?: boolean
hideSiblings?: boolean
/**
* Whether this overlay registers its live content as a NESTED LAYER while the
* interaction phase is up (see `registerNestedLayer`). Defaults to "this
* overlay is not modal" — `!(focusTrap || hideSiblings)`.
*
* WHY non-modal only. Every one of these overlays portals to a body-level
* sibling, so an overlay opened from inside an open dialog lands OUTSIDE the
* dialog's focus trap and its `inert` sweep. Registering makes Tab reach it and
* keeps it out of the sweep. A MODAL surface must NOT register: it is the layer
* everything else is nested in, and registering it would let a trap on the
* layer beneath Tab into it and would make its own CONTENT read as "inside a
* nested layer" for an overlay open on top of it — so a click anywhere in the
* dialog's panel would stop dismissing an inner `select`. (`content` is the
* element registered below, and it is only the panel: `dialog` renders
* `backdrop`, `positioner` and `content` as three separate parts, so a click
* on the dialog's BACKGROUND is outside the registered element either way.)
*
* The aspects are narrowed further (see below): outside-click cooperation
* between engine overlays comes from the dismissable STACK, not the registry.
*/
nestedLayer?: boolean
/** Element id to focus once the overlay opens. */
focusOnOpenId?: string
/** Select the focused input's existing value (searchable-select prefill). */
focusOnOpenSelect?: boolean
/**
* Optional element-level enter/leave transition (from `@llui/transitions` —
* e.g. `fade({ duration: 150 })`). Threaded onto the OUTER `show(mountWhen)`
* gate — the single show that keeps the popup content in the DOM — so `enter`
* animates the content in when the overlay opens and `leave` defers the final
* unmount until its promise resolves (giving raw-open overlays an exit
* animation for free).
*
* Coordination with the presence (`data-state`) machinery: the JS transition
* and the CSS presence exit are two mechanisms for the SAME job (defer unmount
* for the exit animation), gated on mutually-exclusive status transitions — the
* CSS exit plays on `status: 'closing'`, the JS `leave` fires only when the
* outer gate finally goes false (`status: 'closed'`). With the components'
* default `skipAnimations: true` there is no `'closing'` phase, so a supplied
* transition is the SOLE exit driver — no double-animation, no hang. Supplying
* BOTH a JS transition AND `skipAnimations: false` would run them in sequence
* (CSS then JS); keep `skipAnimations` at its default when driving exits with a
* JS transition.
*/
transition?: TransitionOptions
}
OverlayFloatingConfig from @llui/components/utils/overlay-engine
export interface OverlayFloatingConfig {
/**
* Preferred placement. A function so it can be resolved AT ATTACH TIME
* (#265 A4) — a per-level submenu chooses its physical side (`right-start`
* under 'ltr', `left-start` under 'rtl') from the reading direction in
* effect when the level opens, the same way `dir` below is already
* resolved lazily rather than captured at declaration time.
*/
placement: Placement | (() => Placement)
offset: number
flip: boolean
shift: boolean
/** CSS selector (within content) for the arrow element to position. */
arrowSelector?: string
/** Match the floating element's min-width to the anchor's width. */
sameWidth?: boolean
/**
* An EXPLICIT reading direction — a function so it can be peeked at attach
* time (menu). Return `undefined` (or omit it) while direction is automatic:
* the engine then resolves it from the placement ANCHOR, never from where
* the portal landed (#265 finding 6). Whichever wins is handed to
* `attachFloating` and written as `dir` on the floating element, so the
* whole portaled subtree — CSS logical properties, key handlers resolving
* from `e.currentTarget`, and nested overlays anchored inside it — reads
* the same direction as the trigger it belongs to.
*/
dir?: TextDirection | (() => TextDirection | undefined)
/** Attach positioning in the MOUNT phase (survives the exit animation) rather
* than the interaction phase. Required when `visibleWhen` unwinds interactions
* before `mountWhen` releases retained exit content; the engine rejects that
* two-phase lifetime unless placement persists with the mounted node. */
persistent?: boolean
/**
* Re-run floating attachment (detach then reattach, re-evaluating the
* `placement`/`dir` thunks fresh) whenever this key's value CHANGES while
* mounted. Needed because `placement`/`dir` are otherwise resolved ONCE at
* attach and `autoUpdate` never re-polls them — a physical `placement`
* string (`'right-start'`) encodes a reading-direction decision that a
* later `computePosition` pass with the same closed-over string cannot
* correct (#265 A4: a submenu whose menu tree flips direction while the
* level stays open must re-place, not just re-run the same geometry). A DOM
* direction change needs no key — the engine watches the anchor's
* direction itself; this is for STATE the DOM does not show (an explicit
* `setDir`).
*
* There is no public imperative signal-subscribe seam for framework-internal
* code running inside a mount callback (`@llui/dom`'s reactivity is
* binding-driven, not subscription-driven) — a caller declares this key by
* binding it reactively as a `data-llui-reattach-key` attribute on the
* content element or an ancestor it controls (`subOverlay` puts it on the
* positioner it builds), and the engine watches that ATTRIBUTE with a
* `MutationObserver`, the same declarative-binding-to-DOM-observation idiom
* `direction.ts:watchDirection` uses in the other direction. The marker is
* found with `content.closest('[data-llui-reattach-key]')`; supplying
* `reattachKey` without one is an authoring error (`LluiFrameworkError`).
*/
reattachKey?: () => string | number
}
OverlayFocusReturnConfig from @llui/components/utils/overlay-engine
export interface OverlayFocusReturnConfig {
target: OverlayElementReference
/** Boundary used to decide whether focus is still "inside" the overlay at
* teardown (default: `'content'`). */
boundary?: 'content' | 'floating'
/** Also treat the return target itself being focused as "inside" (select). */
allowTargetActive?: boolean
/** Restore during interaction teardown (default true). Popover opts out and
* performs its conditional dismissal-time return in `dismiss.extra`. */
restoreOnTeardown?: boolean
}
OverlayFocusTrapConfig from @llui/components/utils/overlay-engine
export interface OverlayFocusTrapConfig {
initialFocus?: Element | (() => Element | null)
restoreFocus?: boolean
}
OverlayRelationships from @llui/components/utils/overlay-engine
Independent DOM relationships for an overlay. A declaration must opt into each behavior separately: naming a placement anchor never also changes layer ownership, dismissal, or focus return.
export interface OverlayRelationships {
placementAnchor?: OverlayElementReference
nestedLayerOwner?: OverlayElementReference
dismissIgnore?: readonly OverlayElementReference[]
focusReturn?: OverlayFocusReturnConfig
}
@llui/components/utils/path
Functions
annularSectorPath() from @llui/components/utils/path
An annular sector — the polar answer to a bar. r0 is the inner radius (0
gives a pie wedge, > 0 a donut segment); angles follow {@link polarPoint}.
A sweep of a full turn or more is drawn as a complete ring, because the arc endpoints would otherwise coincide and SVG would draw nothing at all.
function annularSectorPath(cx: number, cy: number, r0: number, r1: number, a0: number, a1: number): string
areaPath() from @llui/components/utils/path
A filled region between an upper run and a lower run. The lower run is walked BACKWARDS and appended, then the path is closed — one subpath, so a fill-rule cannot punch a hole in it the way two separate subpaths can.
function areaPath(upper: readonly Point[], lower: readonly Point[], curve: Curve, axis: CurveAxis = 'x'): string
circlePath() from @llui/components/utils/path
A full circle, as two arcs — one arc of 360° is degenerate and draws nothing, which is the classic way a polar gridline disappears.
function circlePath(cx: number, cy: number, r: number): string
curvePath() from @llui/components/utils/path
Join a run of points with the named curve. closed applies to linear
only — the other two are defined on an open, x-increasing run.
function curvePath(points: readonly Point[], curve: Curve, closed = false, axis: CurveAxis = 'x'): string
fmt() from @llui/components/utils/path
Format a coordinate. A non-finite input becomes 0 — a NaN in a path
silently voids the WHOLE path element, taking every later command with it.
function fmt(n: number): string
linearPath() from @llui/components/utils/path
Straight segments through every point.
function linearPath(points: readonly Point[], closed = false): string
monotonePath() from @llui/components/utils/path
Monotone cubic interpolation (Fritsch–Carlson). Its defining property is that the curve NEVER OVERSHOOTS the data: between two samples it stays within their values, so a series that only rises cannot dip below a point on the way. A plain Catmull-Rom or cardinal spline does overshoot, and on a chart that means drawing a number nobody measured.
Defined on a function y = f(x) with strictly increasing x. It is therefore
meaningless on a closed angular loop, which is why polarProjection declines
it — see projection.ts.
function monotonePath(points: readonly Point[], axis: CurveAxis = 'x'): string
polarPoint() from @llui/components/utils/path
A point on a circle. Angles are RADIANS CLOCKWISE FROM 12 O'CLOCK, which is where every reader expects a chart to start; SVG's own 0 is 3 o'clock.
function polarPoint(cx: number, cy: number, r: number, angle: number): Point
rectPath() from @llui/components/utils/path
An axis-aligned rectangle, corners in any order.
function rectPath(x0: number, y0: number, x1: number, y1: number): string
stepPath() from @llui/components/utils/path
Axis-aligned staircase: hold each value until halfway along the independent axis, then step.
function stepPath(points: readonly Point[], axis: CurveAxis = 'x'): string
Types
Curve from @llui/components/utils/path
How a run of points is joined.
export type Curve = 'linear' | 'monotone' | 'step'
CurveAxis from @llui/components/utils/path
Which coordinate a curve is a FUNCTION OF. monotone and step are both
defined on w = f(t) with increasing t; this names which of x/y plays t.
It is not a formatting detail. A horizontal bar/line chart puts the
independent axis on y, and running the curve along x regardless offsets every
control point along the wrong axis: measured, a four-point series emitted
M150,0C190,25 230,75 270,75, where the second control already sits at the
segment's end instead of two thirds along it. The endpoints stay correct in
both orientations, which is why it reads as a smoothing artefact rather than
a bug — and why asserting the path advances monotonically catches nothing.
export type CurveAxis = 'x' | 'y'
Interfaces
Point from @llui/components/utils/path
export interface Point {
x: number
y: number
}
@llui/components/utils/pointer-drag
Functions
pointerDragHandlers() from @llui/components/utils/pointer-drag
Build the five pointer handlers for one drag track. One call per live
connect() instance — dragging/activePointerId are closure-scoped per
instance, the same shape as this package's other per-instance mutable state
(e.g. color-picker's eyedropper pendingEyeDropper AbortController).
- Primary button only on
pointerdown(e.button !== 0is ignored), AND only when no drag is already live — a second finger/pointer touching down mid-drag is ignored rather than hijackingactivePointerId. - Every subsequent event (
pointermove/pointerup/pointercancel/onLostPointerCapture) is checked againstactivePointerId: a second pointer's events never affect a drag it didn't start. setPointerCaptureonpointerdown'scurrentTarget, sopointermovekeeps firing even once the pointer leaves the element's bounds — a plain drag with no capture stalls out at the track's edge instead of saturating like a native<input type="range">.- Released on
pointerup/pointercancel, guarded byhasPointerCaptureso releasing twice (or without ever capturing) is a harmless no-op. draggingis unconditionally reset ononLostPointerCapturefor the active pointer, even though that handler never callsrelease()itself (capture is already gone by definition when this fires).
function pointerDragHandlers(callbacks: PointerDragCallbacks): PointerDragHandlers
Interfaces
PointerDragCallbacks from @llui/components/utils/pointer-drag
One call site's drag callbacks.
export interface PointerDragCallbacks {
/** Checked on `pointerdown` only — a drag that is already underway keeps
* running even if this flips true mid-drag (the reducer's own `disabled`
* guard is the actual safety net for a message dispatched from `onDrag`
* after that point; this only decides whether a NEW drag may start). */
isDisabled: () => boolean
/** Fires once, on `pointerdown`, after capture is acquired and BEFORE the
* first `onDrag` call — the hook for a one-time side effect like moving
* focus onto a thumb element. */
onDragStart?: (e: PointerEvent) => void
/** Fires on `pointerdown` (immediately after `onDragStart`) and again on
* every `pointermove` while the drag is live. */
onDrag: (e: PointerEvent) => void
/** Fires once on `pointerup`/`pointercancel`, but ONLY when a drag was
* actually live (never on a stray up/cancel with no matching down) — the
* counterpart to `onDragStart`, for a caller that tracks per-drag identity
* across the lifecycle (`gradient-picker`'s track drag creates a new stop
* on `pointerdown` and must stop targeting it once the drag ends). Runs
* AFTER pointer capture is released. */
onDragEnd?: (e: PointerEvent) => void
}
PointerDragHandlers from @llui/components/utils/pointer-drag
export interface PointerDragHandlers {
onPointerDown: (e: PointerEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerUp: (e: PointerEvent) => void
onPointerCancel: (e: PointerEvent) => void
/** The browser can revoke pointer capture without ever firing `pointerup`/
* `pointercancel` (an OS-level interruption, a nested capture request, …).
* Left unwired, `dragging` would stay `true` forever and a later, UNRELATED
* pointer's `pointermove` would be misread as a continuation of this drag.
* Spread onto the same element as the other four. */
onLostPointerCapture: (e: PointerEvent) => void
}
@llui/components/utils/portal-target
Functions
resolvePortalTarget() from @llui/components/utils/portal-target
Resolve an overlay's portal target SSR-safely.
An overlay's host is resolved at overlay() build time, which runs on the
SERVER too (SSR renders the whole view). Touching document there throws
ReferenceError: document is not defined. A string selector is therefore
resolved against document ONLY in a browser; on the server we return
undefined and let portal() fall back to the env's doc.body. Overlays are
gated behind show(state.open), so a closed overlay never mounts its portal on
the server anyway — the guard just keeps the eager host resolution from
crashing the SSR render.
function resolvePortalTarget(target: string | Element): Element | undefined
@llui/components/utils/presence-end
Functions
presenceEndHandler() from @llui/components/utils/presence-end
Guard a presence "animation/transition ended" handler so it only advances the
presence machine when the event fired on the element the listener is bound to
(e.target === e.currentTarget) — never on a bubbling descendant.
Overlay content (dialog, popover, menu, toast) reflects its exit phase via
data-state="closing" and stays mounted until an animationend/transitionend
dispatches animationEnd/transitionEnd. Without this guard, ANY descendant
animation or transition ending during the exit — a spinner, a ripple, a child
fade — bubbles up and prematurely unmounts the overlay before its own exit
animation completes.
Mirrors the e.target === el guard the transitions runtime applies in
waitForEnd (@llui/transitions).
function presenceEndHandler<E extends AnimationEvent | TransitionEvent>(handler: (e: E) => void): (e: E) => void
presenceEndProps() from @llui/components/utils/presence-end
Build BOTH end handlers for a part, guarded and tagSend-tagged.
Wiring them one at a time is how four surfaces ended up guarded and five did
not (#126) — dialog/popover/menu/toast remembered the guard while
drawer/hover-card/tooltip/context-menu and presence itself forgot it, and a
descendant animation ending mid-exit unmounted those overlays early. Taking
the pair from one factory makes "guarded" the only thing a caller can build.
transitionMsg defaults to animationMsg for the components that treat the
two events as one message.
function presenceEndProps<M extends { type: string }>(send: (msg: M) => void, animationMsg: M, transitionMsg: M = animationMsg): PresenceEndProps
Interfaces
PresenceEndProps from @llui/components/utils/presence-end
The pair of end handlers a presence-bearing part spreads.
export interface PresenceEndProps {
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
@llui/components/utils/projection
Functions
cartesianProjection() from @llui/components/utils/projection
function cartesianProjection(frame: Frame, opts: CartesianOptions = {}): Projection
polarProjection() from @llui/components/utils/projection
function polarProjection(frame: Frame, opts: PolarOptions = {}): Projection
projectionFor() from @llui/components/utils/projection
Build the projection a chart's coordinate setting names.
function projectionFor(coord: ChartCoord, frame: Frame, opts: CartesianOptions & PolarOptions = {}): Projection
Types
ChartCoord from @llui/components/utils/projection
export type ChartCoord = 'cartesian' | 'polar'
Interfaces
CartesianOptions from @llui/components/utils/projection
export interface CartesianOptions {
/** Swap the axes: `u` runs top-to-bottom and `v` left-to-right. This is what
* turns a column chart into a horizontal bar chart, and it is a PROJECTION
* concern — no mark, scale or hit test changes. */
horizontal?: boolean
}
Frame from @llui/components/utils/projection
The plot area in user units — the box marks are drawn inside, already inset by whatever padding the axes need.
export interface Frame {
x: number
y: number
width: number
height: number
}
PolarOptions from @llui/components/utils/projection
export interface PolarOptions {
/** Inner radius as a fraction of the outer. `0` is a pie, `0.5` a donut, and
* a radar usually wants `0`. */
innerRadius?: number
/** Where `u = 0` sits, in radians clockwise from 12 o'clock. */
startAngle?: number
/** Total sweep in radians. Defaults to a full turn, which is what makes the
* projection `closed`. */
sweep?: number
/**
* Gridline shape. `ring` is a circle at the given magnitude — right for a
* pie or a rose. `web` joins the `spokes` sample positions with straight
* segments — right for a radar, where a circular grid reads as a different
* chart from the polygonal data on top of it.
*/
grid?: 'ring' | 'web'
/** How many spokes a `web` gridline has. Ignored for `ring`. */
spokes?: number
/**
* Swap the axes, exactly as {@link CartesianOptions.horizontal} does: the
* INDEPENDENT axis moves off the angle and onto the RADIUS, so each category
* becomes its own concentric ring and magnitude is read as arc length. That
* is a radial bar chart, and it is the polar reading of the same flag that
* turns a column chart on its side — one field, the same meaning in both
* coordinate systems, so a consumer flipping `coord` keeps whatever
* orientation they asked for.
*
* The independent axis is then the radius, which is NOT a loop, so the
* projection reports `closed: false` however wide the sweep is.
*/
horizontal?: boolean
}
Projection from @llui/components/utils/projection
export interface Projection {
readonly kind: ChartCoord
/**
* Whether the independent axis is a CLOSED loop. `true` for a polar
* projection covering a full turn, which is what makes a radar outline join
* back to its first vertex instead of leaving a gap.
*/
readonly closed: boolean
/**
* Curves this projection can honour. A caller asking for one outside the set
* gets `linear` — see the polar note below for why this is the honest answer
* rather than a silent approximation.
*/
readonly curves: readonly Curve[]
/** Normalized (u, v) → a point in user units. */
point(u: number, v: number): Point
/** A run of samples joined along increasing `u`. */
line(samples: readonly Sample[], curve: Curve): string
/** The region between an upper run and a lower run of the same length. */
area(upper: readonly Sample[], lower: readonly Sample[], curve: Curve): string
/** A band spanning `u0..u1` and `v0..v1`: a bar, or a wedge. */
band(u0: number, u1: number, v0: number, v1: number): string
/** The iso-magnitude line at `v` — a value gridline. */
gridline(v: number): string
/** Where the independent-axis tick for `u` belongs. */
tick(u: number): TickPlacement
/** Where the DEPENDENT-axis tick for `v` belongs. */
valueTick(v: number): TickPlacement
/**
* Invert a point in user units back to `u`, for pointer hit testing. Returns
* `null` when the point carries no meaningful `u` — the exact centre of a
* polar chart, where every angle is equally close.
*/
locate(x: number, y: number): number | null
}
TickPlacement from @llui/components/utils/projection
Where an independent-axis tick label sits, and how to align it there.
export interface TickPlacement {
x: number
y: number
/** SVG `text-anchor`. */
anchor: 'start' | 'middle' | 'end'
/** SVG `dominant-baseline`. */
baseline: 'auto' | 'middle' | 'hanging'
}
@llui/components/utils/remove-scroll
Functions
_scrollLockCount() from @llui/components/utils/remove-scroll
@internal — tests only
export declare function _scrollLockCount(): number;
lockBodyScroll() from @llui/components/utils/remove-scroll
Lock body scroll while an overlay is open, preserving scrollbar width to avoid layout shift. Reference-counted so nested locks compose cleanly.
export declare function lockBodyScroll(): () => void;
@llui/components/utils/root-scope
Functions
getElementByIdInScope() from @llui/components/utils/root-scope
Resolve an element by id within the DOM tree that ref belongs to.
ref.getRootNode() returns the enclosing Document in light DOM, or the
ShadowRoot when ref lives inside a shadow tree — both expose
getElementById. Overlays resolve their trigger/content parts through this
(passing the onMount root, which shares the parts' tree) so floating
positioning still anchors when the component is mounted inside a shadow root
(isolate mode): the global document.getElementById cannot see into shadow
trees and silently returns null, which no-ops the anchor.
Light-DOM behavior is identical to document.getElementById: a node attached
to the main document roots to that Document. A detached ref (its root is a
bare element/fragment without getElementById) falls back to the global
document so callers keep working.
function getElementByIdInScope(ref: Node, id: string): HTMLElement | null
@llui/components/utils/roving
Functions
firstEnabled() from @llui/components/utils/roving
Internal value navigation used by the public roving-focus primitive.
export declare function firstEnabled(items: readonly string[], disabled: readonly string[]): string | null;
focusRovingItem() from @llui/components/utils/roving
Move DOM focus to the roving item identified by value within the same
widget instance as origin.
Roving-tabindex widgets track the active index in STATE, but assistive tech
follows real DOM focus — so after a keyboard move the handler MUST also move
focus, or arrow keys are silent for AT. origin is the event's
currentTarget (the item that received the key); its closest
[data-scope][data-part="root"] ancestor scopes the search so sibling
widgets of the same scope never cross-focus. No-op if nothing matches.
send() is synchronous and items already exist in the DOM, so this can be
called immediately after the navigation send.
export declare function focusRovingItem(origin: Element | null, scope: string, value: string, opts?: {
itemPart?: string;
attr?: string;
}): void;
focusRovingTab() from @llui/components/utils/roving
Move DOM focus to the trigger whose data-value matches, within
container. Relies only on the role="tab" + data-value contract
(shared by components/tabs and any hand-rolled tablist). No-op when no
trigger matches. Call after the DOM reflects the new active tab (e.g. in
a microtask if activation triggers a re-render).
export declare function focusRovingTab(container: Element, value: string): void;
lastEnabled() from @llui/components/utils/roving
export declare function lastEnabled(items: readonly string[], disabled: readonly string[]): string | null;
nextEnabled() from @llui/components/utils/roving
export declare function nextEnabled(items: readonly string[], disabled: readonly string[], from: string, delta: 1 | -1, loop: boolean): string | null;
resolveRovingMove() from @llui/components/utils/roving
Map a keyboard key + the current tab value to a roving-tablist move,
or null when the key isn't a navigation/activation key or the move is
a no-op (empty list, no enabled sibling). Pure — does not touch the DOM
or call preventDefault; the caller decides (typically: prevent default
iff the result is non-null).
export declare function resolveRovingMove(key: string, current: string, items: readonly RovingItem[], opts?: RovingOptions): RovingMove | null;
Types
RovingMove from @llui/components/utils/roving
The navigation a key implies on a roving tablist.
export type RovingMove =
/** An arrow / Home / End resolved to a (different, enabled) tab value. */
{
type: 'focus';
value: string;
}
/** Enter or Space — activate the currently focused tab (manual mode). */
| {
type: 'activate';
};
RovingOrientation from @llui/components/utils/roving
Headless roving-tablist navigation — the keyboard logic of a WAI-ARIA tablist, decoupled from any particular DOM contract.
components/tabs.ts builds its reactive part-bags on top of this; a
consumer that wants its OWN markup (different classes, ids, no
data-scope/data-part) can drive the same keyboard behaviour by
calling resolveRovingMove from its trigger's onKeyDown and
focusRovingTab to move DOM focus — without adopting the component's
markup or its connect() state machine.
The resolver is pure (key + current value + items → a move); the only
shared DOM assumption lives in focusRovingTab, and it is the minimal
one both surfaces already satisfy: triggers carry role="tab" and
data-value="<value>".
The list walk itself lives in list-navigation.ts — this module is the
keyboard + DOM-focus surface over it, nothing more.
export type RovingOrientation = 'horizontal' | 'vertical';
Interfaces
RovingItem from @llui/components/utils/roving
export interface RovingItem {
value: string;
/** Disabled items are skipped by arrow/Home/End navigation. */
disabled?: boolean;
}
RovingOptions from @llui/components/utils/roving
export interface RovingOptions {
/** Arrow axis — 'horizontal' uses Left/Right, 'vertical' uses Up/Down. Default 'horizontal'. */
orientation?: RovingOrientation;
/** Whether arrow navigation wraps at the ends. Default true. */
loop?: boolean;
/**
* An element used to resolve text direction for RTL arrow flipping
* (typically the event's `currentTarget`). When it resolves to
* `dir="rtl"`, ArrowLeft/ArrowRight swap. Optional.
*/
element?: Element | null;
}
@llui/components/utils/scale
Functions
bandCenter() from @llui/components/utils/scale
The centre of band i in normalized u.
function bandCenter(i: number, band: Band): number
bandExtent() from @llui/components/utils/scale
The [start, end] extent of band i in normalized u.
function bandExtent(i: number, band: Band): [number, number]
denormalize() from @llui/components/utils/scale
Inverse of {@link normalize}.
function denormalize(u: number, domain: Domain): number
nearestBand() from @llui/components/utils/scale
The band index nearest a normalized position — the hit test behind hover and
pointer tracking. Returns null for an empty axis.
Nearest-CENTRE, not containment: the gaps between bands belong to whichever band is closer, so a pointer never falls into a dead zone between two bars.
function nearestBand(u: number, band: Band): number | null
nearestShare() from @llui/components/utils/scale
The slice containing a normalized position — the hit test behind hover on a pie.
CONTAINMENT, not the nearest centre that {@link nearestBand} uses. Bands have gaps, so a pointer between two bars has to be given to one of them; slices tile the axis with no gaps, so containment is exact and nearest-centre would be actively wrong — a thin slice beside a wide one would lose its own interior to the wide one's centre.
Zero-width slices are unhittable by construction. u outside [0, 1] and an
axis with no positive total both answer null.
function nearestShare(u: number, slices: readonly ShareSlice[]): number | null
niceDomain() from @llui/components/utils/scale
Extend [min, max] outward to the nearest round tick boundaries, so the axis
ends on a labelled value instead of mid-air. Iterates because widening the
domain can change the chosen step, which can change the boundaries again;
d3's nice does the same, and it converges in at most a few rounds.
function niceDomain(min: number, max: number, count: number): Domain
normalize() from @llui/components/utils/scale
Map a value into [0, 1] across domain. A DEGENERATE domain (min === max)
maps everything to 0.5 rather than dividing by zero — a single-valued series
draws as a flat line through the middle, which is the only reading that is
not a crash or a NaN in a path string.
function normalize(value: number, domain: Domain): number
shareExtents() from @llui/components/utils/scale
Allocate the independent axis [0, 1] in PROPORTION to each value, instead of giving every category an equal slot the way {@link bandExtent} does.
This is the whole of what a pie chart is. Under a polar projection each slice
is a wedge whose angle states its share; under a cartesian one the same
slices are the segments of a single full-width 100%-share bar. Neither the
projection nor any mark needs to know which it is drawing — the magnitude has
simply moved from v (a bar's height) to u (a slice's extent), which is
why no new mark type is involved.
Three rules, each a correctness statement rather than a default:
- There is NO padding, and there cannot be.
bandExtenttakespaddingInner/paddingOuterbecause a bar's slot is arbitrary and the gaps only cost whitespace. Here the extent IS the datum: gaps would make the slices sum to less than the whole, so every slice would overstate or understate its share by however much padding was chosen, and a full turn would no longer be 100%. Separate slices with a stroke in the skin (what shadcn's own pie does), never with a gap in the scale. - A NEGATIVE value takes no arc. A share of a negative quantity is not
defined, and the two silent readings are both wrong: using the magnitude
draws a slice for a number nobody measured, and letting it subtract makes
the remaining slices sum past 1. Contributing zero is the only reading
that neither invents data nor breaks the total. Same argument as
polarProjectiondecliningmonotonerather than approximating it. - A total of zero yields zero-width slices, not
NaN. No data is a normal state; aNaNin aubecomes aNaNin a path string, and one of those voids the whole path element.
The final slice is closed at exactly 1 rather than at the accumulated sum, so float drift cannot leave a hairline wedge of background at the end of the axis — visible on a full-turn pie as a seam at 12 o'clock.
function shareExtents(values: readonly number[]): ShareSlice[]
tickIncrement() from @llui/components/utils/scale
The step size a nice axis would use to fit about count ticks into
[start, stop]. A negative return means "one tick every 1/-step units" —
d3's encoding, kept because the reciprocal form avoids the float drift that
10 ** -power introduces for small steps.
function tickIncrement(start: number, stop: number, count: number): number
ticks() from @llui/components/utils/scale
Round, human-readable tick values covering [start, stop]. Returns an EMPTY
array for a non-finite or degenerate range rather than looping forever —
a chart with no data is a normal state, not an error.
function ticks(start: number, stop: number, count: number): number[]
valueDomain() from @llui/components/utils/scale
The value-axis domain for a set of series.
min / max are UNBOUNDED-CAPABLE bounds in the sense of finiteBound
(#177): an absent one means "derive it", never ±Infinity. A derived domain
always INCLUDES ZERO, because a bar whose baseline is not zero misrepresents
its own length — the one axis default worth being opinionated about. A caller
who wants otherwise passes both bounds.
function valueDomain(values: readonly number[], opts: { min?: number; max?: number; tickCount: number }): Domain
Interfaces
Band from @llui/components/utils/scale
A discrete axis of count slots laid out across [0, 1].
export interface Band {
count: number
/** Gap between adjacent bands, as a fraction of a slot. */
paddingInner: number
/** Gap before the first and after the last band, as a fraction of a slot. */
paddingOuter: number
}
Domain from @llui/components/utils/scale
A closed numeric interval. Both ends are finite by construction.
export interface Domain {
min: number
max: number
}
Sample from @llui/components/utils/scale
A normalized sample: position along the independent axis, and magnitude.
export interface Sample {
u: number
v: number
}
ShareSlice from @llui/components/utils/scale
One slice of a proportionally-allocated independent axis.
export interface ShareSlice {
/** Start of the slice in normalized u. */
start: number
/** End of the slice in normalized u. */
end: number
/** The slice's fraction of the whole — `end - start`, named for readability. */
share: number
}
@llui/components/utils/tree-collection
Interfaces
TreeNode from @llui/components/utils/tree-collection
TreeCollection — helper for building tree-view payloads from a nested data structure. Tree-view's state machine only knows about flat lists (visibleItems, visibleLabels) and opaque ids; the collection owns the structure and derives those flat arrays on demand.
Typical flow:
const col = new TreeCollection(data) state.visibleItems = col.visibleItems(state.expanded) state.visibleLabels = col.visibleLabels(state.expanded)
After every expand / collapse message, the consumer dispatches
setVisibleItems with the updated arrays. The collection itself is
immutable — build a new one when the tree structure changes.
export interface TreeNode {
id: string
label?: string
disabled?: boolean
children?: TreeNode[]
}
Classes
TreeCollection from @llui/components/utils/tree-collection
class TreeCollection {
roots: TreeNode[]
info: Map<string, NodeInfo>
constructor(roots: TreeNode | TreeNode[])
index(): void
getNode(id: string): TreeNode | null
getLabel(id: string): string
getParent(id: string): string | null
getDepth(id: string): number
getChildren(id: string): string[]
getDescendants(id: string): string[]
isBranch(id: string): boolean
isDisabled(id: string): boolean
visibleItems(expanded: string[]): string[]
visibleLabels(expanded: string[]): string[]
computeIndeterminate(checked: Set<string>): string[]
}
@llui/components/utils/typeahead
Functions
isTypeaheadKey() from @llui/components/utils/typeahead
Returns true if the key event should trigger a typeahead query — i.e., a
single printable character that isn't a modified keyboard shortcut. Use
this in onKeyDown handlers to decide whether to dispatch a typeahead
message.
function isTypeaheadKey(e: KeyboardEvent): boolean
typeaheadAccumulate() from @llui/components/utils/typeahead
Advance the typeahead query based on a new keystroke and the previous
expiration time. Returns the new query string; callers combine this with
typeaheadMatch() to produce a new highlight index.
function typeaheadAccumulate(prev: string, char: string, now: number, expiresAt: number): string
typeaheadMatch() from @llui/components/utils/typeahead
Find the first enabled item whose label starts with the query
(case-insensitive). labels and disabledMask are parallel arrays.
startFrom is the current highlighted index; for single-character
queries the search begins at startFrom + 1 (so repeated "s" keys
cycle), for multi-character queries it begins at startFrom (inclusive).
Returns the matching index, or null if no enabled item matches.
function typeaheadMatch(labels: string[], disabledMask: boolean[], query: string, startFrom: number | null): number | null
typeaheadMatchByItems() from @llui/components/utils/typeahead
Convenience: pass a disabled list of values instead of a boolean mask.
Builds the mask by checking membership via === on the raw string values.
function typeaheadMatchByItems(items: string[], disabled: readonly string[], query: string, startFrom: number | null): number | null
Constants
TYPEAHEAD_TIMEOUT_MS from @llui/components/utils/typeahead
Typeahead search — accumulates keystrokes into a query while the user types rapidly, then matches the first item whose label starts with the query. Used by listbox, menu, select, combobox, tree-view to support WAI-ARIA keyboard navigation patterns.
Behavior:
- If a keystroke arrives within
TYPEAHEAD_TIMEOUT_MSof the previous one, append to the existing query (so typing "sa" finds "Saturn" even if the highlight is currently on "Jupiter"). - Otherwise, start a fresh single-character query.
- Single-character queries advance past the current position (jump to the next item starting with that letter), which is the standard WAI-ARIA behavior — rapid repeated presses of "s" cycle through items beginning with "s".
- Multi-character queries search from the current cursor position (inclusive) so if the cursor is already on a matching item, it stays — typing "ap" while on "apricot" keeps focus on "apricot".
const TYPEAHEAD_TIMEOUT_MS
@llui/components/format/cache
Functions
cached() from @llui/components/format/cache
function cached<T>(key: string, create: () => T): T
cacheKey() from @llui/components/format/cache
A cache key for a formatter built from opts (any Intl.*Options object):
its defined entries in key order, so equal options give equal keys whatever
order they were written in.
function cacheKey(prefix: string, locale: string, opts: object): string
@llui/components/format/defaults
Functions
defaultLocale() from @llui/components/format/defaults
Default locale: navigator.language in browsers, 'en' in SSR/tests.
function defaultLocale(): string
@llui/components/format/format-date
Functions
formatDate() from @llui/components/format/format-date
function formatDate(value: DateValue, opts: FormatDateOptions = {}): string
formatDateTime() from @llui/components/format/format-date
function formatDateTime(value: DateValue, opts: FormatDateTimeOptions = {}): string
formatTime() from @llui/components/format/format-date
function formatTime(value: DateValue, opts: FormatTimeOptions = {}): string
Types
DateValue from @llui/components/format/format-date
Date-only handling — the ONE place that decides whether a value denotes an INSTANT or a bare CALENDAR DATE.
new Date('2026-01-15') parses a date-only string as UTC midnight (ES spec),
so formatting it against the ambient zone renders the PREVIOUS day everywhere
west of UTC: formatDate('2026-01-15') printed "January 14, 2026" under
America/New_York and the right answer under Europe/Rome, which is why it
survived (#125 defect 4).
A calendar date carries no instant and therefore no zone, so it is anchored
at UTC midnight and its consumers must render it in UTC — see dateOnly.
export type DateValue = Date | string | number
Interfaces
FormatDateOptions from @llui/components/format/format-date
export interface FormatDateOptions {
locale?: string
dateStyle?: DateStyle
calendar?: string
numberingSystem?: string
timeZone?: string
weekday?: 'long' | 'short' | 'narrow'
year?: 'numeric' | '2-digit'
month?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow'
day?: 'numeric' | '2-digit'
era?: 'long' | 'short' | 'narrow'
}
FormatDateTimeOptions from @llui/components/format/format-date
export interface FormatDateTimeOptions {
locale?: string
dateStyle?: DateStyle
timeStyle?: DateStyle
timeZone?: string
calendar?: string
hour12?: boolean
hourCycle?: 'h11' | 'h12' | 'h23' | 'h24'
}
FormatTimeOptions from @llui/components/format/format-date
export interface FormatTimeOptions {
locale?: string
timeStyle?: DateStyle
timeZone?: string
hour12?: boolean
hourCycle?: 'h11' | 'h12' | 'h23' | 'h24'
hour?: 'numeric' | '2-digit'
minute?: 'numeric' | '2-digit'
second?: 'numeric' | '2-digit'
fractionalSecondDigits?: 0 | 1 | 2 | 3
timeZoneName?: 'long' | 'short' | 'shortOffset' | 'longOffset' | 'shortGeneric' | 'longGeneric'
dayPeriod?: 'narrow' | 'short' | 'long'
}
@llui/components/format/format-display-name
Functions
formatDisplayName() from @llui/components/format/format-display-name
function formatDisplayName(value: string, type: DisplayNameType, opts: FormatDisplayNameOptions = {}): string | undefined
Types
DisplayNameType from @llui/components/format/format-display-name
export type DisplayNameType =
| 'language'
| 'region'
| 'script'
| 'currency'
| 'calendar'
| 'dateTimeField'
Interfaces
FormatDisplayNameOptions from @llui/components/format/format-display-name
export interface FormatDisplayNameOptions {
locale?: string
style?: 'long' | 'short' | 'narrow'
languageDisplay?: 'dialect' | 'standard'
fallback?: 'code' | 'none'
}
@llui/components/format/format-file-size
Functions
formatFileSize() from @llui/components/format/format-file-size
function formatFileSize(value: number | bigint, opts: FormatFileSizeOptions = {}): string
Interfaces
FormatFileSizeOptions from @llui/components/format/format-file-size
export interface FormatFileSizeOptions {
locale?: string
units?: string[]
decimalPlaces?: number
}
@llui/components/format/format-list
Functions
formatList() from @llui/components/format/format-list
function formatList(value: string[], opts: FormatListOptions = {}): string
Interfaces
FormatListOptions from @llui/components/format/format-list
export interface FormatListOptions {
locale?: string
type?: 'conjunction' | 'disjunction' | 'unit'
style?: 'long' | 'short' | 'narrow'
}
@llui/components/format/format-number
Functions
formatNumber() from @llui/components/format/format-number
function formatNumber(value: number, opts: FormatNumberOptions = {}): string
Interfaces
FormatNumberOptions from @llui/components/format/format-number
export interface FormatNumberOptions {
locale?: string
style?: 'decimal' | 'currency' | 'percent' | 'unit'
currency?: string
currencyDisplay?: 'symbol' | 'narrowSymbol' | 'code' | 'name'
signDisplay?: 'auto' | 'never' | 'always' | 'exceptZero'
notation?: 'standard' | 'scientific' | 'engineering' | 'compact'
compactDisplay?: 'short' | 'long'
unit?: string
unitDisplay?: 'short' | 'long' | 'narrow'
useGrouping?: boolean
minimumIntegerDigits?: number
minimumFractionDigits?: number
maximumFractionDigits?: number
minimumSignificantDigits?: number
maximumSignificantDigits?: number
}
@llui/components/format/format-plural
Functions
formatPlural() from @llui/components/format/format-plural
function formatPlural(value: number, messages: PluralMessages, opts: FormatPluralOptions = {}): string
resolvePluralCategory() from @llui/components/format/format-plural
function resolvePluralCategory(value: number, opts: FormatPluralOptions = {}): PluralCategory
Types
PluralCategory from @llui/components/format/format-plural
export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'
PluralMessages from @llui/components/format/format-plural
export type PluralMessages = Partial<Record<PluralCategory, string>> & { other: string }
Interfaces
FormatPluralOptions from @llui/components/format/format-plural
export interface FormatPluralOptions {
locale?: string
type?: 'cardinal' | 'ordinal'
minimumIntegerDigits?: number
minimumFractionDigits?: number
maximumFractionDigits?: number
minimumSignificantDigits?: number
maximumSignificantDigits?: number
}
@llui/components/format/format-relative-time
Functions
formatRelativeTime() from @llui/components/format/format-relative-time
function formatRelativeTime(value: number, unit: RelativeTimeUnit, opts: FormatRelativeTimeOptions = {}): string
Types
RelativeTimeUnit from @llui/components/format/format-relative-time
export type RelativeTimeUnit =
| 'year'
| 'quarter'
| 'month'
| 'week'
| 'day'
| 'hour'
| 'minute'
| 'second'
Interfaces
FormatRelativeTimeOptions from @llui/components/format/format-relative-time
export interface FormatRelativeTimeOptions {
locale?: string
numeric?: 'always' | 'auto'
style?: 'long' | 'short' | 'narrow'
}
@llui/components/format/index
Functions
formatDate() from @llui/components/format/index
function formatDate(value: DateValue, opts: FormatDateOptions = {}): string
formatDateTime() from @llui/components/format/index
function formatDateTime(value: DateValue, opts: FormatDateTimeOptions = {}): string
formatDisplayName() from @llui/components/format/index
function formatDisplayName(value: string, type: DisplayNameType, opts: FormatDisplayNameOptions = {}): string | undefined
formatFileSize() from @llui/components/format/index
function formatFileSize(value: number | bigint, opts: FormatFileSizeOptions = {}): string
formatList() from @llui/components/format/index
function formatList(value: string[], opts: FormatListOptions = {}): string
formatNumber() from @llui/components/format/index
function formatNumber(value: number, opts: FormatNumberOptions = {}): string
formatPlural() from @llui/components/format/index
function formatPlural(value: number, messages: PluralMessages, opts: FormatPluralOptions = {}): string
formatRelativeTime() from @llui/components/format/index
function formatRelativeTime(value: number, unit: RelativeTimeUnit, opts: FormatRelativeTimeOptions = {}): string
formatTime() from @llui/components/format/index
function formatTime(value: DateValue, opts: FormatTimeOptions = {}): string
resolvePluralCategory() from @llui/components/format/index
function resolvePluralCategory(value: number, opts: FormatPluralOptions = {}): PluralCategory
Types
DateValue from @llui/components/format/index
Date-only handling — the ONE place that decides whether a value denotes an INSTANT or a bare CALENDAR DATE.
new Date('2026-01-15') parses a date-only string as UTC midnight (ES spec),
so formatting it against the ambient zone renders the PREVIOUS day everywhere
west of UTC: formatDate('2026-01-15') printed "January 14, 2026" under
America/New_York and the right answer under Europe/Rome, which is why it
survived (#125 defect 4).
A calendar date carries no instant and therefore no zone, so it is anchored
at UTC midnight and its consumers must render it in UTC — see dateOnly.
export type DateValue = Date | string | number
DisplayNameType from @llui/components/format/index
export type DisplayNameType =
| 'language'
| 'region'
| 'script'
| 'currency'
| 'calendar'
| 'dateTimeField'
PluralCategory from @llui/components/format/index
export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'
PluralMessages from @llui/components/format/index
export type PluralMessages = Partial<Record<PluralCategory, string>> & { other: string }
RelativeTimeUnit from @llui/components/format/index
export type RelativeTimeUnit =
| 'year'
| 'quarter'
| 'month'
| 'week'
| 'day'
| 'hour'
| 'minute'
| 'second'
Interfaces
FormatDateOptions from @llui/components/format/index
export interface FormatDateOptions {
locale?: string
dateStyle?: DateStyle
calendar?: string
numberingSystem?: string
timeZone?: string
weekday?: 'long' | 'short' | 'narrow'
year?: 'numeric' | '2-digit'
month?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow'
day?: 'numeric' | '2-digit'
era?: 'long' | 'short' | 'narrow'
}
FormatDateTimeOptions from @llui/components/format/index
export interface FormatDateTimeOptions {
locale?: string
dateStyle?: DateStyle
timeStyle?: DateStyle
timeZone?: string
calendar?: string
hour12?: boolean
hourCycle?: 'h11' | 'h12' | 'h23' | 'h24'
}
FormatDisplayNameOptions from @llui/components/format/index
export interface FormatDisplayNameOptions {
locale?: string
style?: 'long' | 'short' | 'narrow'
languageDisplay?: 'dialect' | 'standard'
fallback?: 'code' | 'none'
}
FormatFileSizeOptions from @llui/components/format/index
export interface FormatFileSizeOptions {
locale?: string
units?: string[]
decimalPlaces?: number
}
FormatListOptions from @llui/components/format/index
export interface FormatListOptions {
locale?: string
type?: 'conjunction' | 'disjunction' | 'unit'
style?: 'long' | 'short' | 'narrow'
}
FormatNumberOptions from @llui/components/format/index
export interface FormatNumberOptions {
locale?: string
style?: 'decimal' | 'currency' | 'percent' | 'unit'
currency?: string
currencyDisplay?: 'symbol' | 'narrowSymbol' | 'code' | 'name'
signDisplay?: 'auto' | 'never' | 'always' | 'exceptZero'
notation?: 'standard' | 'scientific' | 'engineering' | 'compact'
compactDisplay?: 'short' | 'long'
unit?: string
unitDisplay?: 'short' | 'long' | 'narrow'
useGrouping?: boolean
minimumIntegerDigits?: number
minimumFractionDigits?: number
maximumFractionDigits?: number
minimumSignificantDigits?: number
maximumSignificantDigits?: number
}
FormatPluralOptions from @llui/components/format/index
export interface FormatPluralOptions {
locale?: string
type?: 'cardinal' | 'ordinal'
minimumIntegerDigits?: number
minimumFractionDigits?: number
maximumFractionDigits?: number
minimumSignificantDigits?: number
maximumSignificantDigits?: number
}
FormatRelativeTimeOptions from @llui/components/format/index
export interface FormatRelativeTimeOptions {
locale?: string
numeric?: 'always' | 'auto'
style?: 'long' | 'short' | 'narrow'
}
FormatTimeOptions from @llui/components/format/index
export interface FormatTimeOptions {
locale?: string
timeStyle?: DateStyle
timeZone?: string
hour12?: boolean
hourCycle?: 'h11' | 'h12' | 'h23' | 'h24'
hour?: 'numeric' | '2-digit'
minute?: 'numeric' | '2-digit'
second?: 'numeric' | '2-digit'
fractionalSecondDigits?: 0 | 1 | 2 | 3
timeZoneName?: 'long' | 'short' | 'shortOffset' | 'longOffset' | 'shortGeneric' | 'longGeneric'
dayPeriod?: 'narrow' | 'short' | 'long'
}
@llui/components/toggle
Functions
connect() from @llui/components/toggle
function connect(state: ReadSignal<ToggleState>, send: Send<ToggleMsg>): ToggleParts
init() from @llui/components/toggle
function init(opts: ToggleInit = {}): ToggleState
update() from @llui/components/toggle
function update(state: ToggleState, msg: ToggleMsg): [ToggleState, never[]]
Types
ToggleMsg from @llui/components/toggle
export type ToggleMsg =
/** @intent("Flip the toggle button's pressed state") */
| { type: 'toggle' }
/** @intent("Set the toggle's pressed state to a specific value") */
| { type: 'setPressed'; pressed: boolean }
/** @humanOnly */
| { type: 'setDisabled'; disabled: boolean }
Interfaces
ToggleInit from @llui/components/toggle
export interface ToggleInit {
pressed?: boolean
disabled?: boolean
}
ToggleParts from @llui/components/toggle
export interface ToggleParts {
root: {
type: 'button'
role: 'button'
'aria-pressed': ReadSignal<boolean>
'aria-disabled': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
'data-state': ReadSignal<'on' | 'off'>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'toggle'
'data-part': 'root'
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
}
ToggleState from @llui/components/toggle
Toggle button — a button that can be pressed or not. Unlike a checkbox, a toggle represents an action that is applied immediately (e.g. "bold" in a text editor toolbar).
export interface ToggleState {
pressed: boolean
disabled: boolean
}
Constants
toggle from @llui/components/toggle
const toggle
@llui/components/checkbox
Functions
connect() from @llui/components/checkbox
function connect(state: ReadSignal<CheckboxState>, send: Send<CheckboxMsg>): CheckboxParts
init() from @llui/components/checkbox
function init(opts: CheckboxInit = {}): CheckboxState
update() from @llui/components/checkbox
function update(state: CheckboxState, msg: CheckboxMsg): [CheckboxState, never[]]
Types
CheckboxMsg from @llui/components/checkbox
export type CheckboxMsg =
/** @intent("Toggle the checkbox between checked and unchecked") */
| { type: 'toggle' }
/** @intent("Set the checkbox state to checked, unchecked, or indeterminate") */
| { type: 'setChecked'; checked: CheckedState }
/** @humanOnly */
| { type: 'setDisabled'; disabled: boolean }
CheckedState from @llui/components/checkbox
Checkbox — a tri-state form control (checked / unchecked / indeterminate).
The indeterminate state is a visual-only state used to represent "partial"
selection (e.g. a parent whose children are mixed checked).
Rendering typically uses two elements: a visual indicator (the styled box)
and a hidden native <input type="checkbox"> for form participation +
accessibility. connect() returns props for both.
export type CheckedState = boolean | 'indeterminate'
Interfaces
CheckboxInit from @llui/components/checkbox
export interface CheckboxInit {
checked?: CheckedState
disabled?: boolean
required?: boolean
}
CheckboxParts from @llui/components/checkbox
export interface CheckboxParts {
/** The visual box/container — `role="checkbox"` for accessibility. */
root: {
role: 'checkbox'
'aria-checked': ReadSignal<'true' | 'false' | 'mixed'>
'aria-disabled': ReadSignal<'true' | undefined>
'aria-required': ReadSignal<'true' | undefined>
'data-state': ReadSignal<'checked' | 'unchecked' | 'indeterminate'>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'checkbox'
'data-part': 'root'
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
/** A native hidden input for form participation. */
hiddenInput: {
type: 'checkbox'
'aria-hidden': 'true'
tabindex: -1
style: string
checked: ReadSignal<boolean>
indeterminate: ReadSignal<boolean>
disabled: ReadSignal<boolean>
required: ReadSignal<boolean>
'data-scope': 'checkbox'
'data-part': 'hidden-input'
}
/** Optional indicator child (the checkmark). */
indicator: {
'data-state': ReadSignal<'checked' | 'unchecked' | 'indeterminate'>
'data-scope': 'checkbox'
'data-part': 'indicator'
}
}
CheckboxState from @llui/components/checkbox
export interface CheckboxState {
checked: CheckedState
disabled: boolean
required: boolean
}
Constants
checkbox from @llui/components/checkbox
const checkbox
@llui/components/accordion
Functions
connect() from @llui/components/accordion
function connect(state: ReadSignal<AccordionState>, send: Send<AccordionMsg>, opts: ConnectOptions): AccordionParts
focusTarget() from @llui/components/accordion
Helper: compute the next/prev item value given a focus message + current state. Users' view/onMount can use this to move DOM focus to the correct trigger.
function focusTarget(state: AccordionState, msg: Extract<AccordionMsg, { type: `focus${string}` }>): string | null
init() from @llui/components/accordion
function init(opts: AccordionInit = {}): AccordionState
update() from @llui/components/accordion
function update(state: AccordionState, msg: AccordionMsg): [AccordionState, never[]]
Types
AccordionMsg from @llui/components/accordion
export type AccordionMsg =
/** @intent("Toggle the named accordion item open/closed") */
| ({ type: 'toggle'; value: string } & Retain)
/** @intent("Open the named accordion item") */
| ({ type: 'open'; value: string } & Retain)
/** @intent("Close the named accordion item") */
| ({ type: 'close'; value: string } & Retain)
/** @intent("Replace the set of currently-open items with the provided values") */
| ({ type: 'setValue'; value: string[] } & Retain)
/** @humanOnly */
| { type: 'setItems'; items: string[] }
/** @humanOnly */
| { type: 'focusNext'; value: string }
/** @humanOnly */
| { type: 'focusPrev'; value: string }
/** @humanOnly */
| { type: 'focusFirst' }
/** @humanOnly */
| { type: 'focusLast' }
/** @humanOnly — sent by the retained content's own animation end/cancel event,
* or by `exitCompletion`'s own cleanup settling every still-closing item once
* the last watcher for this `id` detaches. */
| { type: 'exitComplete'; value: string; generation: number }
Interfaces
AccordionInit from @llui/components/accordion
export interface AccordionInit {
value?: string[]
multiple?: boolean
collapsible?: boolean
disabled?: boolean
items?: string[]
/**
* Retain closing content for an exit animation. Off by default so a missing
* stylesheet/event cannot leave hidden semantic content mounted forever.
*/
animated?: boolean
}
AccordionItemParts from @llui/components/accordion
export interface AccordionItemParts {
trigger: {
type: 'button'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
id: string
'data-state': ReadSignal<'open' | 'closed'>
'data-disabled': ReadSignal<'' | undefined>
disabled: ReadSignal<boolean>
'data-scope': 'accordion'
'data-part': 'trigger'
'data-value': string
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
content: {
role: 'region'
id: string
'aria-labelledby': string
'data-state': ReadSignal<'open' | 'closing' | 'closed'>
'data-scope': 'accordion'
'data-part': 'content'
'data-value': string
hidden: ReadSignal<boolean>
'aria-hidden': ReadSignal<'true' | undefined>
inert: ReadSignal<boolean>
onAnimationStart: (e: AnimationEvent) => void
onAnimationEnd: (e: AnimationEvent) => void
onAnimationCancel: (e: AnimationEvent) => void
onTransitionStart: (e: TransitionEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
onTransitionCancel: (e: TransitionEvent) => void
}
item: {
'data-state': ReadSignal<'open' | 'closed'>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'accordion'
'data-part': 'item'
'data-value': string
}
}
AccordionParts from @llui/components/accordion
export interface AccordionParts {
root: {
// No role on the root: an accordion is a set of disclosure buttons, and
// labelling the whole container `region` (without an accessible name) just
// adds an unlabeled landmark. The meaningful regions are the per-item
// panels, each a `role="region"` with `aria-labelledby` its trigger.
'data-scope': 'accordion'
'data-part': 'root'
'data-orientation': 'vertical'
}
item: (value: string) => AccordionItemParts
/**
* Settles a retained `closing` item once its content's own exit
* animation/transition ends — or immediately, if the skin runs no exit
* motion at all. This only ever has anything to settle for a
* PROGRAMMATIC close/toggle/setValue when that message carried
* `retain: true`, which `parts.close(value)` stamps for you; a raw
* `send({ type: 'close', value })` with no `retain` closes instantly and
* never enters `closing` at all, so there is nothing here to settle for
* it. Its mount ALSO reports whether it is placed at all (#264 item F1):
* `animated: true` only ever retains `closing` content while this is
* mounted — forgetting to place it degrades gracefully to an instant
* close (with a one-time dev warning) rather than hanging `closing` +
* `inert` forever, so placing it is no longer required for SAFETY, only
* for the requested exit animation to actually run on a retained close.
* A click-driven close is still safety-netted synchronously inside the
* trigger regardless of whether this is placed.
*/
exitCompletion: Mountable
/**
* An animated-aware programmatic close (#264 review-264j): a raw
* `send({ type: 'close', value })` from app/agent code carries no
* `retain` and therefore closes INSTANTLY, even with `animated: true` and
* `exitCompletion` placed — documented, fail-safe behavior, since a bare
* message has no way to know whether a watcher happens to be mounted.
* `parts.close(value)` is the correct way for a host to close an item
* programmatically and still get the animated exit: it stamps `retain`
* from the SAME runtime registry the trigger handlers read.
*/
close: (value: string) => void
}
AccordionState from @llui/components/accordion
Accordion — a stack of expandable panels. Items are identified by a string
value. Either a single item is expandable at a time (default) or many
(multiple: true). collapsible: false prevents closing the only open
item in single mode.
Items themselves are provided by the user's view (accordion is agnostic to
item data). The connect() API returns a root prop set and an item(value)
factory that produces trigger and content prop sets scoped to that item.
export interface AccordionState {
/** Values of currently-expanded items. */
value: string[]
multiple: boolean
collapsible: boolean
disabled: boolean
/** Ordered list of item values (for keyboard navigation). */
items: string[]
/** Presentation-only items retained while their opted-in exit animation runs. */
closing: string[]
/** Monotonic exit generation per item, used to reject stale end events. */
exitGenerations: RetainedExitGeneration<string>[]
/** Monotonic source for collision-free exit generations; never grows by id. */
exitSequence: number
/** Whether closed content is retained until its own animation end/cancel event. */
animated: boolean
}
ConnectOptions from @llui/components/accordion
export interface ConnectOptions {
/** Namespace prefix for part ids (for ARIA wiring). Should be unique per instance. */
id: string
}
Constants
accordion from @llui/components/accordion
const accordion
@llui/components/tabs
Functions
connect() from @llui/components/tabs
function connect(state: ReadSignal<TabsState>, send: Send<TabsMsg>, opts: ConnectOptions): TabsParts
init() from @llui/components/tabs
function init(opts: TabsInit = {}): TabsState
update() from @llui/components/tabs
function update(state: TabsState, msg: TabsMsg): [TabsState, never[]]
watchTabIndicator() from @llui/components/tabs
Track the active tab trigger and update CSS custom properties on the
indicator element so it can be animated into position. Call from
onMount with the tabs root element; the returned function removes
the observers.
PASS THE TABS ROOT, NOT WHATEVER onMount HANDED YOU. onMount receives the
BUILD's root container, not the element the call sits inside, so forwarding
it resolves the lookups below in document order across the whole build — and
a page with two tab sets has both tracking the first one's indicator.
Sets --indicator-left, --indicator-top, --indicator-width,
--indicator-height on the indicator element every time the active
trigger changes or the list resizes. Style the indicator with:
transform: translate(var(--indicator-left), var(--indicator-top));
width: var(--indicator-width);
height: var(--indicator-height);
function watchTabIndicator(root: HTMLElement): () => void
Types
Activation from @llui/components/tabs
export type Activation = 'automatic' | 'manual'
Orientation from @llui/components/tabs
Tabs — tabbed interface with keyboard navigation. Each tab has a value (string) that identifies both the trigger and the associated panel.
Two activation modes:
'automatic'(default): focusing a trigger also activates it.'manual': arrow keys move focus without activating; Enter/Space activates.
export type Orientation = 'horizontal' | 'vertical'
TabsMsg from @llui/components/tabs
export type TabsMsg =
/** @intent("Switch to the tab with the given value") */
| { type: 'setValue'; value: string }
/** @humanOnly */
| { type: 'setItems'; items: string[]; disabled?: string[] }
/** @humanOnly */
| { type: 'focusTab'; value: string }
/**
* @intent("Activate the tab with the given value — the click/press action (deselects it when `deselectable` and it is already active)")
*/
| { type: 'activateTab'; value: string }
/** @humanOnly */
| { type: 'focusNext'; from: string }
/** @humanOnly */
| { type: 'focusPrev'; from: string }
/** @humanOnly */
| { type: 'focusFirst' }
/** @humanOnly */
| { type: 'focusLast' }
/** @intent("Activate the currently-focused tab (for manual activation mode)") */
| { type: 'activateFocused' }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
/** @humanOnly — synchronized from the mounted root's live ancestor direction. */
| { type: 'syncDomDir'; dir: 'ltr' | 'rtl' }
Interfaces
ConnectOptions from @llui/components/tabs
export interface ConnectOptions {
id: string
/**
* Called whenever a tab is clicked/activated. Useful for anchor-style
* navigation where the tab's value is a URL path and you want to push
* to the history or router.
*/
onNavigate?: (value: string) => void
}
TabsInit from @llui/components/tabs
export interface TabsInit {
value?: string
items?: string[]
disabledItems?: string[]
orientation?: Orientation
activation?: Activation
loopFocus?: boolean
deselectable?: boolean
dir?: 'ltr' | 'rtl'
}
TabsItemParts from @llui/components/tabs
export interface TabsItemParts {
trigger: {
type: 'button'
role: 'tab'
'aria-selected': ReadSignal<boolean>
'aria-controls': string
'aria-disabled': ReadSignal<'true' | undefined>
id: string
'data-state': ReadSignal<'active' | 'inactive'>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'tabs'
'data-part': 'trigger'
'data-value': string
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
onFocus: (e: FocusEvent) => void
}
panel: {
role: 'tabpanel'
id: string
'aria-labelledby': string
tabindex: 0
hidden: ReadSignal<boolean>
'data-state': ReadSignal<'active' | 'inactive'>
'data-scope': 'tabs'
'data-part': 'panel'
'data-value': string
}
}
TabsParts from @llui/components/tabs
export interface TabsParts {
root: {
id: string
'data-scope': 'tabs'
'data-part': 'root'
'data-orientation': ReadSignal<Orientation>
}
/**
* A movable underline/highlight element. Position tracks the active
* trigger via CSS custom properties written by `watchTabIndicator()`:
* `--indicator-left`, `--indicator-top`, `--indicator-width`,
* `--indicator-height` — all in pixels.
* The consumer styles the indicator using these properties (e.g.
* `transform: translateX(var(--indicator-left))`).
*/
indicator: {
'data-scope': 'tabs'
'data-part': 'indicator'
'data-orientation': ReadSignal<Orientation>
}
list: {
role: 'tablist'
'aria-orientation': ReadSignal<Orientation>
'data-scope': 'tabs'
'data-part': 'list'
}
item: (value: string) => TabsItemParts
/** Place once anywhere in the same build to keep automatic direction live. */
directionSync: Mountable
}
TabsState from @llui/components/tabs
export interface TabsState {
value: string
items: string[]
disabledItems: string[]
orientation: Orientation
activation: Activation
/** The currently focused (but not necessarily active) tab. For manual mode. */
focused: string | null
/** Whether Arrow navigation wraps at the ends of the tab list. Default: true. */
loopFocus: boolean
/** Whether clicking the active tab deselects it (empty value). Default: false. */
deselectable: boolean
/** Reading direction. Under 'rtl', ArrowLeft/ArrowRight swap meaning. */
dir: 'ltr' | 'rtl'
dirSource: DirectionSource
}
Constants
tabs from @llui/components/tabs
const tabs
@llui/components/slider
Functions
closestThumbIndex() from @llui/components/slider
Determine which thumb index is closest to a given raw value.
function closestThumbIndex(state: SliderState, raw: number): number
connect() from @llui/components/slider
function connect(state: ReadSignal<SliderState>, send: Send<SliderMsg>): SliderParts
init() from @llui/components/slider
function init(opts: SliderInit = {}): SliderState
update() from @llui/components/slider
function update(state: SliderState, msg: SliderMsg): [SliderState, never[]]
valueFromPoint() from @llui/components/slider
Compute the slider value at a given pointer position within the control's bounding rect. Returns null if the pointer is outside the track.
function valueFromPoint(state: SliderState, rect: DOMRect, clientX: number, clientY: number): number
Types
Orientation from @llui/components/slider
Slider — numeric input controlled by drag or keyboard. Supports multiple
thumbs (range slider) and horizontal/vertical orientations. The machine is
pure; pointer drag handling (pointermove listeners during a drag) is done
by the consumer via startThumbDrag() helper which returns a cleanup.
export type Orientation = 'horizontal' | 'vertical'
SliderMsg from @llui/components/slider
export type SliderMsg =
/** @intent("Replace all thumb values at once") */
| { type: 'setValue'; value: number[] }
/** @intent("Set the value of the thumb at the given index. Ignored while disabled") */
| { type: 'setThumb'; index: number; value: number }
/** @intent("Move the thumb at the given index up by one step (or step × multiplier). Ignored while disabled") */
| { type: 'increment'; index: number; multiplier?: number }
/** @intent("Move the thumb at the given index down by one step (or step × multiplier). Ignored while disabled") */
| { type: 'decrement'; index: number; multiplier?: number }
/** @intent("Snap the thumb at the given index to the slider's minimum. Ignored while disabled") */
| { type: 'toMin'; index: number }
/** @intent("Snap the thumb at the given index to the slider's maximum. Ignored while disabled") */
| { type: 'toMax'; index: number }
/** @intent("Enable or disable the slider — a host/agent write, never gated") */
| { type: 'setDisabled'; disabled: boolean }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
Interfaces
SliderInit from @llui/components/slider
export interface SliderInit {
value?: number[]
min?: number
max?: number
step?: number
disabled?: boolean
orientation?: Orientation
minStepsBetweenThumbs?: number
dir?: 'ltr' | 'rtl'
}
SliderParts from @llui/components/slider
export interface SliderParts {
root: {
'data-scope': 'slider'
'data-part': 'root'
'data-orientation': ReadSignal<Orientation>
'data-disabled': ReadSignal<'' | undefined>
}
control: {
'data-scope': 'slider'
'data-part': 'control'
'data-orientation': ReadSignal<Orientation>
onPointerDown: (e: PointerEvent) => void
}
track: {
'data-scope': 'slider'
'data-part': 'track'
'data-orientation': ReadSignal<Orientation>
}
range: {
'data-scope': 'slider'
'data-part': 'range'
'data-orientation': ReadSignal<Orientation>
style: ReadSignal<string>
}
thumb: (index: number) => SliderThumbParts
/** Current raw values — reactive convenience. */
value: ReadSignal<number[]>
}
SliderState from @llui/components/slider
export interface SliderState {
/** One value per thumb. For a single-value slider, a one-element array. */
value: number[]
min: number
max: number
step: number
disabled: boolean
orientation: Orientation
/** Minimum gap enforced between adjacent thumbs (range slider). */
minStepsBetweenThumbs: number
/** Reading direction. Under 'rtl' horizontal arrow keys are flipped. */
dir: 'ltr' | 'rtl'
}
SliderThumbParts from @llui/components/slider
export interface SliderThumbParts {
thumb: {
role: 'slider'
'aria-valuemin': ReadSignal<number>
'aria-valuemax': ReadSignal<number>
'aria-valuenow': ReadSignal<number>
'aria-orientation': ReadSignal<Orientation>
'aria-disabled': ReadSignal<'true' | undefined>
'data-orientation': ReadSignal<Orientation>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'slider'
'data-part': 'thumb'
'data-index': string
tabindex: ReadSignal<number>
onKeyDown: (e: KeyboardEvent) => void
style: ReadSignal<string>
}
}
Constants
slider from @llui/components/slider
const slider
@llui/components/dialog
Functions
connect() from @llui/components/dialog
function connect(state: ReadSignal<DialogState>, send: Send<DialogMsg>, opts: ConnectOptions): DialogParts
init() from @llui/components/dialog
function init(opts: DialogInit = {}): DialogState
isMounted() from @llui/components/dialog
Whether the dialog node should be in the DOM — true through the exit animation.
Tolerates a partial slice without status (e.g. the { open } bridge a pattern
passes to overlay): it falls back to open for instant, backward-compatible unmount.
function isMounted(state: DialogState): boolean
isPresent() from @llui/components/dialog
Alias of {@link isMounted} — whether the dialog is currently present in the DOM.
function isPresent(state: DialogState): boolean
overlay() from @llui/components/dialog
Build the dialog's DOM tree and wire up all accessibility utilities.
Returns a show() structural block gated on isMounted(state) so the node
stays mounted through an exit animation (status 'closing') and is removed at
animation end; with skipAnimations (the default) close unmounts synchronously.
function overlay(opts: OverlayOptions): Mountable
update() from @llui/components/dialog
function update(state: DialogState, msg: DialogMsg): [DialogState, never[]]
Types
DialogMsg from @llui/components/dialog
export type DialogMsg =
/** @intent("Open the dialog") */
| { type: 'open' }
/** @intent("Close the dialog") */
| { type: 'close' }
/** @intent("Toggle the dialog open/closed") */
| { type: 'toggle' }
/** @intent("Set the dialog's open state to a specific value") */
| { type: 'setOpen'; open: boolean }
/** @humanOnly */
| { type: 'animationEnd' }
/** @humanOnly */
| { type: 'transitionEnd' }
Interfaces
ConnectOptions from @llui/components/dialog
export interface ConnectOptions {
/** Unique id per dialog instance (used for ARIA wiring). */
id: string
/** ARIA role (default: 'dialog'). Use 'alertdialog' for destructive confirmations. */
role?: 'dialog' | 'alertdialog'
/** Modal dialogs trap focus and lock scroll (default: true). */
modal?: boolean
/** Accessible label for the close button (default: 'Close'). */
closeLabel?: string
/**
* Whether the consumer renders the `description` part (default: true).
* When false, the content omits `aria-describedby` — which would otherwise
* name an element that does not exist, a broken reference assistive tech
* reports as a missing description (#268). The title stays mandatory: a
* dialog without a name is not accessible.
*/
hasDescription?: boolean
}
DialogInit from @llui/components/dialog
export interface DialogInit {
open?: boolean
/** Skip enter/exit animations — close unmounts synchronously (default: true). */
skipAnimations?: boolean
}
DialogParts from @llui/components/dialog
export interface DialogParts {
trigger: {
type: 'button'
'aria-haspopup': 'dialog'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
id: string
'data-state': ReadSignal<'open' | 'closed'>
'data-scope': 'dialog'
'data-part': 'trigger'
onClick: (e: MouseEvent) => void
}
backdrop: {
'data-state': ReadSignal<PresenceStatus>
'data-scope': 'dialog'
'data-part': 'backdrop'
'aria-hidden': 'true'
}
positioner: {
'data-scope': 'dialog'
'data-part': 'positioner'
}
content: {
role: 'dialog' | 'alertdialog'
id: string
'aria-modal': 'true' | undefined
'aria-labelledby': string
/** The description part's id, or absent when `hasDescription: false`. */
'aria-describedby': string | undefined
tabindex: -1
'data-state': ReadSignal<PresenceStatus>
'data-scope': 'dialog'
'data-part': 'content'
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
title: {
id: string
'data-scope': 'dialog'
'data-part': 'title'
}
description: {
id: string
'data-scope': 'dialog'
'data-part': 'description'
}
closeTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'dialog'
'data-part': 'close-trigger'
onClick: (e: MouseEvent) => void
}
}
DialogState from @llui/components/dialog
Dialog — modal / non-modal overlay. Ties together focus-trap, dismissable, body scroll lock, sibling aria-hidden, and portal-to-body rendering into a single view helper.
Two layers:
- state machine (
init,update,connect) — pure, minimal. overlay()view helper — opens the dialog's DOM tree inside a body portal, wires up all accessibility utilities on mount, tears them down on close, restores focus to the trigger.
view: ({ state, send }) => {
const dialogState = state.at('dialog')
const dialogSend = mapSend<Msg, dialog.DialogMsg>(send, (msg) => ({
type: 'dialog',
msg,
}))
const parts = dialog.connect(dialogState, dialogSend, { id: 'dialog' })
return [
button({ ...parts.trigger, class: 'btn' }, [text('Delete')]),
dialog.overlay({
state: dialogState,
send: dialogSend,
parts,
content: () => [
div({ ...parts.content, class: 'dialog' }, [
h2({ ...parts.title }, [text('Are you sure?')]),
button({ ...parts.closeTrigger, class: 'btn' }, [text('Cancel')]),
]),
],
}),
]
}
export interface DialogState {
open: boolean
/** Presence lifecycle — drives data-state and keeps the node mounted through exit
* animations. Optional: a partial `{ open }` bridge (e.g. a pattern passing a
* slice to `overlay`) omits it, and the runtime falls back to `open` for instant,
* backward-compatible mount/visibility. `init` always sets it. */
status?: PresenceStatus
/** When true, close transitions go straight to 'closed' (no exit-animation wait).
* Optional for the same partial-slice reason as `status`; `init` always sets it. */
skipAnimations?: boolean
}
OverlayOptions from @llui/components/dialog
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
/** Dialog state slice as a Signal. */
state: ReadSignal<DialogState>
/** Send dispatcher for dialog messages. */
send: Send<DialogMsg>
/** Parts from `connect()` — used to locate the content element by id. */
parts: DialogParts
/** Content rendering. */
content: () => Renderable
/**
* Optional enter/leave transition for the dialog content (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, so the close plays an exit animation.
* Keep `skipAnimations` at its default (true) when driving exits this way.
*
* @example dialog.overlay({ state, send, parts, content, transition: fade({ duration: 150 }) })
*/
transition?: TransitionOptions
/** Close on Escape key (default: true). */
closeOnEscape?: boolean
/** Close on click outside content (default: true). */
closeOnOutsideClick?: boolean
/** Trap focus inside the dialog while open (default: true for modal). */
trapFocus?: boolean
/** Lock body scroll while open (default: true for modal). */
lockScroll?: boolean
/** Apply aria-hidden to sibling trees (default: true for modal). */
hideSiblings?: boolean
/** Target element / selector for the portal (default: 'body'). */
target?: string | HTMLElement
/** Element to focus initially (default: first focusable inside content). */
initialFocus?: Element | (() => Element | null)
/** Restore focus on close (default: true). */
restoreFocus?: boolean
}
Constants
dialog from @llui/components/dialog
const dialog
@llui/components/popover
Functions
connect() from @llui/components/popover
function connect(state: ReadSignal<PopoverState>, send: Send<PopoverMsg>, opts: ConnectOptions): PopoverParts
init() from @llui/components/popover
function init(opts: PopoverInit = {}): PopoverState
isMounted() from @llui/components/popover
Whether the popover node should be in the DOM — true through the exit animation.
function isMounted(state: PopoverState): boolean
isPresent() from @llui/components/popover
Alias of {@link isMounted} — whether the popover is currently present in the DOM.
function isPresent(state: PopoverState): boolean
overlay() from @llui/components/popover
function overlay(opts: OverlayOptions): Mountable
update() from @llui/components/popover
function update(state: PopoverState, msg: PopoverMsg): [PopoverState, never[]]
Types
PopoverMsg from @llui/components/popover
export type PopoverMsg =
/** @intent("Open the popover") */
| { type: 'open' }
/** @intent("Close the popover") */
| { type: 'close' }
/** @intent("Toggle the popover open/closed") */
| { type: 'toggle' }
/** @intent("Set the popover's open state to a specific value") */
| { type: 'setOpen'; open: boolean }
/** @humanOnly */
| { type: 'animationEnd' }
/** @humanOnly */
| { type: 'transitionEnd' }
Interfaces
ConnectOptions from @llui/components/popover
export interface ConnectOptions {
id: string
closeLabel?: string
}
OverlayOptions from @llui/components/popover
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
state: ReadSignal<PopoverState>
send: Send<PopoverMsg>
parts: PopoverParts
content: () => Renderable
/**
* Optional enter/leave transition for the popover content (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, so the close plays an exit animation.
* Keep `skipAnimations` at its default (true) when driving exits this way.
*
* @example popover.overlay({ state, send, parts, content, transition: fade({ duration: 150 }) })
*/
transition?: TransitionOptions
/** Placement preference — bottom | top | right | left with -start/-end variants. */
placement?: Placement
/** Offset between trigger and content, px (default: 8). */
offset?: number
/** Auto-flip to opposite side (default: true). */
flip?: boolean
/** Shift to keep in viewport (default: true). */
shift?: boolean
/** Close on Escape (default: true). */
closeOnEscape?: boolean
/** Close on outside click (default: true). */
closeOnOutsideClick?: boolean
/** Trap focus inside popover while open (default: false — non-modal). */
trapFocus?: boolean
/** Restore focus to trigger on close (default: true). */
restoreFocus?: boolean
/** Portal target (default: 'body'). */
target?: string | HTMLElement
/** Selector for an arrow rendered inside the content element. When present,
* floating positioning owns its absolute edge geometry transactionally. */
arrowSelector?: string
}
PopoverInit from @llui/components/popover
export interface PopoverInit {
open?: boolean
/** Skip enter/exit animations — close unmounts synchronously (default: true). */
skipAnimations?: boolean
}
PopoverParts from @llui/components/popover
export interface PopoverParts {
trigger: {
type: 'button'
'aria-haspopup': 'dialog'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
id: string
'data-state': ReadSignal<'open' | 'closed'>
'data-scope': 'popover'
'data-part': 'trigger'
onClick: (e: MouseEvent) => void
}
positioner: {
'data-scope': 'popover'
'data-part': 'positioner'
style: string
}
content: {
role: 'dialog'
id: string
'aria-labelledby': string
tabindex: -1
'data-state': ReadSignal<PresenceStatus>
'data-scope': 'popover'
'data-part': 'content'
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
title: {
id: string
'data-scope': 'popover'
'data-part': 'title'
}
description: {
id: string
'data-scope': 'popover'
'data-part': 'description'
}
arrow: {
'data-scope': 'popover'
'data-part': 'arrow'
}
closeTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'popover'
'data-part': 'close-trigger'
onClick: (e: MouseEvent) => void
}
}
PopoverState from @llui/components/popover
Popover — click-triggered, non-modal floating overlay anchored to its trigger. Use for menus, date pickers, color pickers, filters, etc.
Like dialog, has a pure state machine + a view helper (overlay()) that
wires floating-ui positioning, dismissable, and optional focus trapping.
export interface PopoverState {
open: boolean
/** Presence lifecycle — drives data-state and keeps the node mounted through exit animations. */
status: PresenceStatus
/** When true, close transitions go straight to 'closed' (no exit-animation wait). */
skipAnimations: boolean
}
Constants
popover from @llui/components/popover
const popover
@llui/components/tooltip
Functions
connect() from @llui/components/tooltip
function connect(state: ReadSignal<TooltipState>, send: Send<TooltipMsg>, opts: ConnectOptions): TooltipParts
init() from @llui/components/tooltip
function init(opts: TooltipInit = {}): TooltipState
isMounted() from @llui/components/tooltip
Whether the tooltip should be in the DOM (mounted through the exit animation).
function isMounted(state: TooltipState): boolean
overlay() from @llui/components/tooltip
function overlay(opts: OverlayOptions): Mountable
update() from @llui/components/tooltip
function update(state: TooltipState, msg: TooltipMsg): [TooltipState, never[]]
Types
TooltipMsg from @llui/components/tooltip
export type TooltipMsg =
/** @intent("Show the tooltip") */
| { type: 'show' }
/** @intent("Hide the tooltip") */
| { type: 'hide' }
/** @intent("Toggle the tooltip's visibility") */
| { type: 'toggle' }
/** @intent("Set the tooltip's open state to a specific value") */
| { type: 'setOpen'; open: boolean }
/** @humanOnly */
| { type: 'animationEnd' }
Interfaces
ConnectOptions from @llui/components/tooltip
export interface ConnectOptions {
id: string
/** ms to wait before opening (default: 300). */
delayOpen?: number
/** ms to wait before closing after pointer leaves (default: 100). */
delayClose?: number
/** Open immediately on focus without delay (default: true). */
openOnFocus?: boolean
}
OverlayOptions from @llui/components/tooltip
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
state: ReadSignal<TooltipState>
send: Send<TooltipMsg>
parts: TooltipParts
content: () => Renderable
/**
* Optional enter/leave transition for the tooltip content (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, so the close plays an exit animation.
* Opt-in only — supplying this does not turn animation on automatically.
*
* @example tooltip.overlay({ state, send, parts, content, transition: fade({ duration: 100 }) })
*/
transition?: TransitionOptions
placement?: Placement
offset?: number
flip?: boolean
shift?: boolean
target?: string | HTMLElement
/** Selector for an arrow rendered inside the content element. When present,
* floating positioning owns its absolute edge geometry transactionally. */
arrowSelector?: string
/** Dismiss on Escape regardless of where focus is (default: true). */
closeOnEscape?: boolean
}
TooltipInit from @llui/components/tooltip
export interface TooltipInit {
open?: boolean
/**
* Enable the exit-animation lifecycle: a close enters `closing` and stays
* mounted until `animationEnd`. Default false (instant unmount). The
* `overlay()` helper turns this on automatically when given a `transition`.
*/
animated?: boolean
}
TooltipParts from @llui/components/tooltip
export interface TooltipParts {
trigger: {
id: string
'aria-describedby': ReadSignal<string | undefined>
'data-state': ReadSignal<'open' | 'closed'>
'data-scope': 'tooltip'
'data-part': 'trigger'
onPointerEnter: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
onFocus: (e: FocusEvent) => void
onBlur: (e: FocusEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
positioner: {
'data-scope': 'tooltip'
'data-part': 'positioner'
style: string
}
content: {
role: 'tooltip'
id: string
style: string
'data-state': ReadSignal<PresenceStatus>
'data-scope': 'tooltip'
'data-part': 'content'
onPointerEnter: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
onKeyDown: (e: KeyboardEvent) => void
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
arrow: {
'data-scope': 'tooltip'
'data-part': 'arrow'
}
}
TooltipState from @llui/components/tooltip
Tooltip — hover / focus-triggered, positioned. Opens after a short delay to avoid flicker from passing pointers, closes immediately on blur or after a grace period on pointer leave.
Pure reducer handles only the boolean open state; timing (delays,
debouncing) lives in the event handlers returned from connect(), which
close over per-instance timers.
export interface TooltipState {
/**
* Whether the tooltip is intended to be visible (true while `opening`/`open`,
* false once a close is requested). Flips to false at close-request time —
* exactly as today — so existing consumers reading `open` are unaffected.
* The DOM node is kept mounted through an exit animation via `status`, not
* `open`. Backed by the presence lifecycle in `status`.
*/
open: boolean
/**
* Full presence lifecycle. `closed → opening → open → closing → closed`.
* When `animated` is false (the default) a close skips `closing` and lands
* on `closed` synchronously, matching today's instant unmount.
*/
status: PresenceStatus
/**
* Whether an exit animation is configured. When false, closing is
* synchronous (no `closing` state, no waiting for `animationEnd`).
*/
animated: boolean
}
Constants
tooltip from @llui/components/tooltip
const tooltip
@llui/components/menu
Functions
connect() from @llui/components/menu
function connect(state: ReadSignal<MenuState>, send: Send<MenuMsg>, opts: ConnectOptions): MenuParts
init() from @llui/components/menu
function init(opts: MenuInit = {}): MenuState
isPresent() from @llui/components/menu
Whether the root menu content should be in the DOM. True for every status except 'closed' — so the content stays mounted through the exit animation.
function isPresent(state: MenuState): boolean
overlay() from @llui/components/menu
function overlay(opts: OverlayOptions): Mountable
subOverlay() from @llui/components/menu
Engine-owned floating overlay for one submenu level (#265 A4). Call once per
children-bearing item, alongside its subTrigger, in place of the old
hand-rolled show(isOpen, () => [div(subPositioner, [div(subContent, …)])]):
subOverlay builds both wrapper divs itself and owns mount/floating/
nested-layer-ownership. See menu-machine.ts:subOverlay's doc comment for
the full contract (direction resolution, no-dismiss design, runtime
direction-change re-placement).
function subOverlay(opts: SubOverlayOptions): Mountable
update() from @llui/components/menu
function update(state: MenuState, msg: MenuMsg): [MenuState, never[]]
Types
MenuCheckItemParts from @llui/components/menu
export type MenuCheckItemParts = MenuCheckItemPartsOf<'menu'>
MenuGroupParts from @llui/components/menu
export type MenuGroupParts = MenuGroupPartsOf<'menu'>
MenuItem from @llui/components/menu
A single node in the menu item tree (JSON-serializable). Shared with
context-menu (and menubar) via the {@link MenuNode} machine type.
export type MenuItem = MenuNode
MenuItemKind from @llui/components/menu
Kind of a menu item.
export type MenuItemKind = MenuNodeKind
MenuItemParts from @llui/components/menu
export type MenuItemParts = MenuItemPartsOf<'menu'>
MenuMsg from @llui/components/menu
export type MenuMsg =
/** @intent("Open the menu") */
| { type: 'open' }
/** @intent("Close the menu") */
| { type: 'close' }
/** @intent("Toggle the menu open/closed") */
| { type: 'toggle' }
/** @humanOnly */
| { type: 'highlight'; level: string; value: string | null }
/** @humanOnly */
| { type: 'highlightNext'; level: string }
/** @humanOnly */
| { type: 'highlightPrev'; level: string }
/** @humanOnly */
| { type: 'highlightFirst'; level: string }
/** @humanOnly */
| { type: 'highlightLast'; level: string }
/** @intent("Activate the currently-highlighted item at the given level") */
| { type: 'selectHighlighted'; level: string }
/** @intent("Activate the menu item with the given value") */
| { type: 'select'; value: string }
/** @intent("Open the submenu for the given parent item") */
| { type: 'openSub'; value: string }
/** @intent("Close the deepest open submenu") */
| { type: 'closeSub' }
/** @humanOnly */
| { type: 'setItems'; items: MenuItem[] }
/** @humanOnly */
| { type: 'typeahead'; level: string; char: string; now: number }
/** @intent("Set the reading direction — 'ltr' or 'rtl'") */
| { type: 'setDir'; dir: TextDirection }
/** @humanOnly — synchronized from the mounted root's live ancestor direction. */
| { type: 'syncDomDir'; dir: TextDirection }
/** @humanOnly */
| { type: 'animationEnd' }
MenuSeparatorParts from @llui/components/menu
export type MenuSeparatorParts = MenuSeparatorPartsOf<'menu'>
MenuSubContentParts from @llui/components/menu
export type MenuSubContentParts = MenuSubContentPartsOf<'menu'>
MenuSubPositionerParts from @llui/components/menu
export type MenuSubPositionerParts = MenuSubPositionerPartsOf<'menu'>
MenuSubTriggerParts from @llui/components/menu
export type MenuSubTriggerParts = MenuSubTriggerPartsOf<'menu'>
Interfaces
ConnectOptions from @llui/components/menu
export interface ConnectOptions {
id: string
/** Called when an item is activated (Enter/Space/click). */
onSelect?: (value: string) => void
/** ms to wait before opening a submenu on hover (default: 200). */
hoverDelay?: number
/** ms to wait before closing a submenu after the pointer leaves (default: 300). */
hoverCloseDelay?: number
}
MenuInit from @llui/components/menu
export interface MenuInit {
open?: boolean
items?: MenuItem[]
highlighted?: string | null
checked?: string[]
closeOnSelect?: boolean
/** Omit to follow the page's own direction (see {@link MenuState}'s `dir`/
* `dirSource`, resolved from the mounted root by `directionSync`). */
dir?: TextDirection
/** When false, opening and closing play enter/exit animations and the content
* stays mounted (status 'opening' or 'closing') until an `animationEnd`.
* Default true: instant. */
skipAnimations?: boolean
}
MenuParts from @llui/components/menu
export interface MenuParts {
trigger: {
type: 'button'
'aria-haspopup': 'menu'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
id: string
'data-state': ReadSignal<'open' | 'closed'>
'data-scope': 'menu'
'data-part': 'trigger'
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
positioner: {
'data-scope': 'menu'
'data-part': 'positioner'
style: string
}
content: {
role: 'menu'
id: string
'aria-labelledby': string
/** The id of the virtually-focused (highlighted) item at the root level, so
* assistive tech announces it while DOM focus stays on the container. */
'aria-activedescendant': ReadSignal<string | undefined>
tabindex: -1
/** Reflects the presence lifecycle: 'opening' | 'open' | 'closing' | 'closed'.
* Stays mounted while 'closing' so the exit animation can run. */
'data-state': ReadSignal<PresenceStatus>
'data-scope': 'menu'
'data-part': 'content'
onKeyDown: (e: KeyboardEvent) => void
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
item: (value: string) => MenuItemParts
checkboxItem: (value: string) => MenuCheckItemParts
radioItem: (value: string) => MenuCheckItemParts
/**
* A labelled group's parts. The group names its `label` part through
* `aria-labelledby`; a consumer that renders the group WITHOUT that label
* passes `{ hasLabel: false }` so it never names an element that does not
* exist (an unlabelled `role="group"` is valid; a broken idref is not — the
* `dialog` `hasDescription` rule, #268). Default: true.
*/
group: (id: string, options?: { readonly hasLabel?: boolean }) => MenuGroupParts
separator: () => MenuSeparatorParts
subTrigger: (value: string) => MenuSubTriggerParts
subPositioner: (value: string) => MenuSubPositionerParts
subContent: (value: string) => MenuSubContentParts
/** Place once anywhere in the same build to keep automatic direction live —
* the shared `@llui/interactions` direction-sync seam (#265 finding 6),
* observing the TRIGGER (always mounted, unlike the content) as this
* instance's root. A discarded `Mountable` is inert, so this must be placed
* in the view. */
directionSync: Mountable
}
MenuState from @llui/components/menu
export interface MenuState extends MenuTreeState {
open: boolean
/**
* Presence lifecycle of the root content, layered over `open` for exit
* animations. `open` stays the logical "should be visible/interactive" flag;
* `status` tracks 'opening'/'open'/'closing'/'closed' so the content can stay
* mounted while its exit animation runs (status 'closing'). When
* `skipAnimations` is true (the default) a close jumps straight to 'closed'.
*/
status: PresenceStatus
/** When true (default), a close goes straight to 'closed' synchronously — no
* exit animation, no waiting for an `animationEnd` that may never fire. */
skipAnimations: boolean
items: MenuItem[]
/** Highlighted value per open level. Key `''` is the root; otherwise the parent subTrigger value. */
highlights: Record<string, string | null>
/** Chain of subTrigger values whose submenus are open (deepest last). */
openPath: string[]
/** Checked checkbox / radio values. */
checked: string[]
/** When true, selecting a checkbox/radio also closes the menu (default false). */
closeOnSelect: boolean
/** Accumulator for typeahead search (scoped to the deepest matching level). */
typeahead: string
typeaheadExpiresAt: number
// `dir` + `dirSource` come from `MenuTreeState` (extends `DirectionState`) —
// the shared `@llui/interactions` direction-sync seam (#265 finding 6).
// Under 'rtl', ArrowLeft/ArrowRight swap meaning, and the overlay's
// `*-start`/`*-end` alignment tracks the inline-start/inline-end edge. An
// EXPLICIT direction is AUTHORITATIVE once it reaches `attachFloating`: a
// silently-overridden page direction laid a menu on `<html dir="rtl">` out
// LTR (#138 review, blocking 4) — `dirSource: 'explicit'` is what keeps
// that override from being overwritten by the next DOM observation.
}
OverlayOptions from @llui/components/menu
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
state: ReadSignal<MenuState>
send: Send<MenuMsg>
parts: MenuParts
content: () => Renderable
/**
* Optional enter/leave transition for the menu content (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, so the close plays an exit animation.
* Keep `skipAnimations` at its default (true) when driving exits this way.
*
* @example menu.overlay({ state, send, parts, content, transition: fade({ duration: 120 }) })
*/
transition?: TransitionOptions
placement?: Placement
offset?: number
flip?: boolean
shift?: boolean
target?: string | HTMLElement
}
SubOverlayOptions from @llui/components/menu
export interface SubOverlayOptions {
/** The subTrigger value this level opens under. */
value: string
state: ReadSignal<MenuState>
parts: Pick<MenuParts, 'subTrigger' | 'subPositioner' | 'subContent'>
content: () => Renderable
target?: string | HTMLElement
positionerClass?: string
align?: 'start' | 'end'
offset?: number
flip?: boolean
shift?: boolean
}
Constants
isMounted from @llui/components/menu
Alias of {@link isPresent} for parity with the presence-convention naming.
const isMounted
menu from @llui/components/menu
const menu
@llui/components/switch
Functions
connect() from @llui/components/switch
function connect(state: ReadSignal<SwitchState>, send: Send<SwitchMsg>): SwitchParts
init() from @llui/components/switch
function init(opts: SwitchInit = {}): SwitchState
update() from @llui/components/switch
function update(state: SwitchState, msg: SwitchMsg): [SwitchState, never[]]
Types
SwitchMsg from @llui/components/switch
export type SwitchMsg =
/** @intent("Flip the switch on/off") */
| { type: 'toggle' }
/** @intent("Set the switch's checked state to a specific value") */
| { type: 'setChecked'; checked: boolean }
/** @humanOnly */
| { type: 'setDisabled'; disabled: boolean }
Interfaces
SwitchInit from @llui/components/switch
export interface SwitchInit {
checked?: boolean
disabled?: boolean
}
SwitchParts from @llui/components/switch
export interface SwitchParts {
root: {
role: 'switch'
'aria-checked': ReadSignal<boolean>
'aria-disabled': ReadSignal<'true' | undefined>
'data-state': ReadSignal<'checked' | 'unchecked'>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'switch'
'data-part': 'root'
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
track: {
'data-state': ReadSignal<'checked' | 'unchecked'>
'data-scope': 'switch'
'data-part': 'track'
}
thumb: {
'data-state': ReadSignal<'checked' | 'unchecked'>
'data-scope': 'switch'
'data-part': 'thumb'
}
hiddenInput: {
type: 'checkbox'
role: 'switch'
'aria-hidden': 'true'
tabindex: -1
style: string
checked: ReadSignal<boolean>
disabled: ReadSignal<boolean>
'data-scope': 'switch'
'data-part': 'hidden-input'
}
}
SwitchState from @llui/components/switch
Switch — two-state on/off control. Semantically like a checkbox but
visually a toggle track + thumb. Uses role="switch" for ARIA.
export interface SwitchState {
checked: boolean
disabled: boolean
}
Constants
switchMachine from @llui/components/switch
const switchMachine
@llui/components/radio-group
Functions
connect() from @llui/components/radio-group
function connect(state: ReadSignal<RadioGroupState>, send: Send<RadioGroupMsg>, opts: ConnectOptions): RadioGroupParts
init() from @llui/components/radio-group
function init(opts: RadioGroupInit = {}): RadioGroupState
update() from @llui/components/radio-group
function update(state: RadioGroupState, msg: RadioGroupMsg): [RadioGroupState, never[]]
Types
Orientation from @llui/components/radio-group
Radio group — a set of mutually-exclusive options. Users select one value at a time. Supports keyboard arrow navigation and disabled items.
export type Orientation = 'horizontal' | 'vertical'
RadioGroupMsg from @llui/components/radio-group
export type RadioGroupMsg =
/** @intent("Pick the radio option with the given value") */
| { type: 'setValue'; value: string }
/** @humanOnly */
| { type: 'setItems'; items: string[]; disabled?: string[] }
/** @intent("Move selection to the next enabled option after the given value") */
| { type: 'selectNext'; from: string }
/** @intent("Move selection to the previous enabled option before the given value") */
| { type: 'selectPrev'; from: string }
/** @intent("Select the first enabled option") */
| { type: 'selectFirst' }
/** @intent("Select the last enabled option") */
| { type: 'selectLast' }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
Interfaces
ConnectOptions from @llui/components/radio-group
export interface ConnectOptions {
id: string
}
RadioGroupInit from @llui/components/radio-group
export interface RadioGroupInit {
value?: string | null
items?: string[]
disabledItems?: string[]
disabled?: boolean
orientation?: Orientation
loopFocus?: boolean
dir?: 'ltr' | 'rtl'
}
RadioGroupParts from @llui/components/radio-group
export interface RadioGroupParts {
root: {
role: 'radiogroup'
'aria-orientation': ReadSignal<Orientation>
'aria-disabled': ReadSignal<'true' | undefined>
'data-scope': 'radio-group'
'data-part': 'root'
'data-orientation': ReadSignal<Orientation>
'data-disabled': ReadSignal<'' | undefined>
}
item: (value: string) => RadioItemParts
}
RadioGroupState from @llui/components/radio-group
export interface RadioGroupState {
value: string | null
items: string[]
disabledItems: string[]
disabled: boolean
orientation: Orientation
/** Whether arrow navigation wraps at the ends. Default true (WAI-ARIA radio
* groups wrap). Present on every roving widget in the package — its absence
* here was pure drift between the copies of the navigation code (#126). */
loopFocus: boolean
/** Reading direction. Under 'rtl', ArrowLeft/ArrowRight swap meaning. */
dir: 'ltr' | 'rtl'
}
RadioItemParts from @llui/components/radio-group
export interface RadioItemParts {
root: {
role: 'radio'
id: string
'aria-checked': ReadSignal<boolean>
'aria-disabled': ReadSignal<'true' | undefined>
'data-state': ReadSignal<'checked' | 'unchecked'>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'radio-group'
'data-part': 'item'
'data-value': string
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
label: {
'data-scope': 'radio-group'
'data-part': 'label'
'data-value': string
for: string
}
indicator: {
'data-state': ReadSignal<'checked' | 'unchecked'>
'data-scope': 'radio-group'
'data-part': 'indicator'
}
}
Constants
radioGroup from @llui/components/radio-group
const radioGroup
@llui/components/collapsible
Functions
connect() from @llui/components/collapsible
function connect(state: ReadSignal<CollapsibleState>, send: Send<CollapsibleMsg>, opts: ConnectOptions): CollapsibleParts
init() from @llui/components/collapsible
function init(opts: CollapsibleInit = {}): CollapsibleState
update() from @llui/components/collapsible
function update(state: CollapsibleState, msg: CollapsibleMsg): [CollapsibleState, never[]]
Types
CollapsibleMsg from @llui/components/collapsible
export type CollapsibleMsg =
/** @intent("Toggle the collapsible panel open/closed") */
| ({ type: 'toggle' } & Retain)
/** @intent("Expand the collapsible panel") */
| ({ type: 'open' } & Retain)
/** @intent("Collapse the panel") */
| ({ type: 'close' } & Retain)
/** @intent("Set the panel's open state to a specific value") */
| ({ type: 'setOpen'; open: boolean } & Retain)
/** @humanOnly — sent by the retained content's own animation end/cancel event,
* or by `exitCompletion`'s own cleanup settling a still-closing panel once
* the last watcher for this `id` detaches. */
| { type: 'exitComplete'; generation: number }
Interfaces
CollapsibleInit from @llui/components/collapsible
export interface CollapsibleInit {
open?: boolean
disabled?: boolean
/** Opt into retained exit motion. Defaults off to guarantee no event/no hang. */
animated?: boolean
}
CollapsibleParts from @llui/components/collapsible
export interface CollapsibleParts {
root: {
'data-state': ReadSignal<'open' | 'closed'>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'collapsible'
'data-part': 'root'
}
trigger: {
type: 'button'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
id: string
disabled: ReadSignal<boolean>
'data-state': ReadSignal<'open' | 'closed'>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'collapsible'
'data-part': 'trigger'
onClick: (e: MouseEvent) => void
}
content: {
role: 'region'
id: string
'aria-labelledby': string
hidden: ReadSignal<boolean>
'data-state': ReadSignal<'open' | 'closing' | 'closed'>
'data-scope': 'collapsible'
'data-part': 'content'
'aria-hidden': ReadSignal<'true' | undefined>
inert: ReadSignal<boolean>
onAnimationStart: (e: AnimationEvent) => void
onAnimationEnd: (e: AnimationEvent) => void
onAnimationCancel: (e: AnimationEvent) => void
onTransitionStart: (e: TransitionEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
onTransitionCancel: (e: TransitionEvent) => void
}
/**
* Settles a retained `closing` panel once the content's own exit
* animation/transition ends — or immediately, if the skin runs no exit
* motion at all. This only ever has anything to settle for a
* PROGRAMMATIC close/toggle/setOpen when that message carried
* `retain: true`, which `parts.close()` stamps for you; a raw
* `send({ type: 'close' })` with no `retain` closes instantly and never
* enters `closing` at all. Its mount ALSO reports whether it is placed at
* all: `animated: true` only ever retains `closing` while this is mounted
* (#264 item F1) — forgetting to place it degrades gracefully to an
* instant close (with a one-time dev warning) rather than hanging
* `closing` + `inert` forever, so placing it is no longer required for
* SAFETY, only for the requested exit animation to actually run on a
* retained close. A click-driven close is still safety-netted
* synchronously inside the trigger regardless of whether this is placed.
*/
exitCompletion: Mountable
/**
* An animated-aware programmatic close (#264 review-264j) — see
* `accordion.ts`'s identical part for the full rationale. A raw
* `send({ type: 'close' })` carries no `retain` and closes INSTANTLY;
* `parts.close()` stamps `retain` from the runtime registry.
*/
close: () => void
}
CollapsibleState from @llui/components/collapsible
Collapsible — a single expandable/collapsible section. Simpler than accordion (no grouping, no keyboard navigation between siblings).
export interface CollapsibleState {
open: boolean
disabled: boolean
/** Presentation-only retention while an opted-in exit animation runs. */
closing: boolean
/** Monotonic generation used to reject stale animation completion events. */
exitGeneration: number
/** Whether close waits for the content's own animation end/cancel event. */
animated: boolean
}
ConnectOptions from @llui/components/collapsible
export interface ConnectOptions {
id: string
}
Constants
collapsible from @llui/components/collapsible
const collapsible
@llui/components/toggle-group
Functions
connect() from @llui/components/toggle-group
function connect(state: ReadSignal<ToggleGroupState>, send: Send<ToggleGroupMsg>): ToggleGroupParts
init() from @llui/components/toggle-group
function init(opts: ToggleGroupInit = {}): ToggleGroupState
update() from @llui/components/toggle-group
function update(state: ToggleGroupState, msg: ToggleGroupMsg): [ToggleGroupState, never[]]
Types
Orientation from @llui/components/toggle-group
Toggle group — a set of toggle buttons. type: 'single' enforces
one-active-at-a-time (like a radio group but visually toggles).
type: 'multiple' allows any subset to be pressed.
export type Orientation = 'horizontal' | 'vertical'
ToggleGroupMsg from @llui/components/toggle-group
export type ToggleGroupMsg =
/** @intent("Toggle the button with the given value (in single mode, replaces selection)") */
| { type: 'toggle'; value: string }
/** @intent("Replace the pressed-value set with the provided list") */
| { type: 'setValue'; value: string[] }
/** @humanOnly */
| { type: 'setItems'; items: string[]; disabled?: string[] }
/** @humanOnly */
| { type: 'focusNext'; from: string }
/** @humanOnly */
| { type: 'focusPrev'; from: string }
/** @humanOnly */
| { type: 'focusItem'; value: string }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
Interfaces
ToggleGroupInit from @llui/components/toggle-group
export interface ToggleGroupInit {
value?: string[]
type?: 'single' | 'multiple'
items?: string[]
disabledItems?: string[]
disabled?: boolean
orientation?: Orientation
deselectable?: boolean
focused?: string | null
loopFocus?: boolean
dir?: 'ltr' | 'rtl'
}
ToggleGroupItemParts from @llui/components/toggle-group
export interface ToggleGroupItemParts {
root: {
type: 'button'
role: 'button'
'aria-pressed': ReadSignal<boolean>
'aria-disabled': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
'data-state': ReadSignal<'on' | 'off'>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'toggle-group'
'data-part': 'item'
'data-value': string
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
onFocus: (e: FocusEvent) => void
}
}
ToggleGroupParts from @llui/components/toggle-group
export interface ToggleGroupParts {
root: {
role: 'group'
'aria-disabled': ReadSignal<'true' | undefined>
'data-scope': 'toggle-group'
'data-part': 'root'
'data-orientation': ReadSignal<Orientation>
'data-disabled': ReadSignal<'' | undefined>
}
item: (value: string) => ToggleGroupItemParts
}
ToggleGroupState from @llui/components/toggle-group
export interface ToggleGroupState {
value: string[]
type: 'single' | 'multiple'
items: string[]
disabledItems: string[]
disabled: boolean
orientation: Orientation
/** In single mode, whether the active item can be deselected. */
deselectable: boolean
/** The currently roving-focused item (independent of the pressed value). */
focused: string | null
/** Whether Arrow navigation wraps at the ends of the group. Default: true. */
loopFocus: boolean
/** Reading direction. Under 'rtl', ArrowLeft/ArrowRight swap meaning. */
dir: 'ltr' | 'rtl'
}
Constants
toggleGroup from @llui/components/toggle-group
const toggleGroup
@llui/components/number-input
Functions
connect() from @llui/components/number-input
function connect(state: ReadSignal<NumberInputState>, send: Send<NumberInputMsg>, opts: ConnectOptions = {}): NumberInputParts
init() from @llui/components/number-input
function init(opts: NumberInputInit = {}): NumberInputState
update() from @llui/components/number-input
function update(state: NumberInputState, msg: NumberInputMsg): [NumberInputState, never[]]
Types
NumberInputMsg from @llui/components/number-input
export type NumberInputMsg =
/** @intent("Set the numeric value (clamped to min/max, snapped to step)") */
| { type: 'setValue'; value: number | null }
/** @humanOnly */
| { type: 'setRawText'; text: string }
/** @intent("Commit the in-progress text input — parse, clamp, snap, and update value. Ignored while disabled or readonly") */
| { type: 'commit' }
/** @intent("Increase value by step (or step × multiplier). Ignored while disabled or readonly") */
| { type: 'increment'; multiplier?: number }
/** @intent("Decrease value by step (or step × multiplier). Ignored while disabled or readonly") */
| { type: 'decrement'; multiplier?: number }
/** @intent("Snap value to the configured minimum. Ignored while disabled or readonly") */
| { type: 'toMin' }
/** @intent("Snap value to the configured maximum. Ignored while disabled or readonly") */
| { type: 'toMax' }
/** @intent("Enable or disable the input — a host/agent write, never gated") */
| { type: 'setDisabled'; disabled: boolean }
Interfaces
ConnectOptions from @llui/components/number-input
export interface ConnectOptions {
incrementLabel?: string
decrementLabel?: string
/** Validate the numeric value before committing. Non-empty array blocks setValue. */
validate?: (value: number) => string[] | null
}
NumberInputInit from @llui/components/number-input
export interface NumberInputInit {
value?: number | null
min?: number
max?: number
step?: number
disabled?: boolean
readonly?: boolean
}
NumberInputParts from @llui/components/number-input
export interface NumberInputParts {
root: {
'data-scope': 'number-input'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
}
input: {
type: 'text'
role: 'spinbutton'
inputmode: 'decimal'
'aria-valuemin': ReadSignal<number | undefined>
'aria-valuemax': ReadSignal<number | undefined>
'aria-valuenow': ReadSignal<number | undefined>
'aria-disabled': ReadSignal<'true' | undefined>
'aria-readonly': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
readonly: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'number-input'
'data-part': 'input'
onInput: (e: Event) => void
onBlur: (e: FocusEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
increment: {
type: 'button'
'aria-label': string
'aria-disabled': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
'data-scope': 'number-input'
'data-part': 'increment'
tabindex: -1
onClick: (e: MouseEvent) => void
}
decrement: {
type: 'button'
'aria-label': string
'aria-disabled': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
'data-scope': 'number-input'
'data-part': 'decrement'
tabindex: -1
onClick: (e: MouseEvent) => void
}
}
NumberInputState from @llui/components/number-input
Number input — numeric field with increment/decrement buttons. Clamps to min/max and snaps to step. Keyboard: Arrow Up/Down, PageUp/PageDown, Home/End.
export interface NumberInputState {
value: number | null
/**
* The bounds, ABSENT when that side is unbounded — the state's `min`/`max`/
* `step` ARE a `NumericGrid`, which is what lets `clampToStep(value, state)`
* take the state object straight in.
*
* A bound is never `±Infinity` and never `NaN`: this is the ONE component in
* the package whose DEFAULT range is unbounded, and it used to spell that
* `min: -Infinity` / `max: Infinity` in state, which `JSON.stringify` writes
* as `null` — so its default state did not survive a round trip and the
* rehydrated object held `null` in a `number` field (#177). Absence is the
* serializable spelling of the same fact; `finiteBound` is the one place it
* is decided.
*/
min?: number
max?: number
step: number
disabled: boolean
readonly: boolean
/** Allow a free-text input value while the user is typing. */
rawText: string
}
Constants
numberInput from @llui/components/number-input
const numberInput
@llui/components/pin-input
Functions
acceptedChars() from @llui/components/pin-input
Accepted characters of a pasted sequence, in order and WITHOUT the holes a rejected character used to leave. Sanitizing per SLOT dropped '123-456' into ['1','2','3','','4','5'] — the separator ate a slot and the last digit fell off the end (#125). Entries may hold more than one character — the paste handler passes the clipboard text WHOLE, which is what makes the code-point iteration below reachable: a surrogate pair is judged (and rejected) as ONE character rather than as two lone halves. Splitting by UTF-16 code unit before the call throws that away, which is what the caller used to do.
function acceptedChars(values: readonly string[], type: PinType): string[]
connect() from @llui/components/pin-input
function connect(state: ReadSignal<PinInputState>, send: Send<PinInputMsg>, opts: ConnectOptions): PinInputParts
getValue() from @llui/components/pin-input
function getValue(state: PinInputState): string
init() from @llui/components/pin-input
function init(opts: PinInputInit = {}): PinInputState
isComplete() from @llui/components/pin-input
function isComplete(state: PinInputState): boolean
update() from @llui/components/pin-input
function update(state: PinInputState, msg: PinInputMsg): [PinInputState, never[]]
Types
PinInputMsg from @llui/components/pin-input
export type PinInputMsg =
/** @intent("Set the character at a given field index (auto-advances focus on accept)") */
| { type: 'setValue'; index: number; value: string }
/** @intent("Replace every field at once (typically from paste)") */
| { type: 'setAll'; values: string[] }
/** @humanOnly */
| { type: 'focus'; index: number }
/** @intent("Clear every field") */
| { type: 'clear' }
/** @humanOnly */
| { type: 'backspace'; index: number }
/** @intent("Enable or disable the pin-input — a host/agent write, never gated") */
| { type: 'setDisabled'; disabled: boolean }
PinType from @llui/components/pin-input
Pin input — a sequence of single-character fields for OTP codes, etc. Auto-advances on input, handles backspace to previous field, supports paste-to-fill across multiple fields.
export type PinType = 'numeric' | 'alphanumeric' | 'alphabetic'
Interfaces
ConnectOptions from @llui/components/pin-input
export interface ConnectOptions {
id: string
inputLabel?: (index: number) => string
/** Validate each character before setting. Non-empty array blocks setDigit. */
validate?: (value: string) => string[] | null
/**
* Whether the consumer renders the `label` part (default: true). When false
* — the group is named some other way, or not at all, which `role="group"`
* allows — the root omits `aria-labelledby` rather than naming an element
* that does not exist (the `dialog` `hasDescription` rule, #268).
*/
hasLabel?: boolean
}
PinInputInit from @llui/components/pin-input
export interface PinInputInit {
length?: number
type?: PinType
mask?: boolean
disabled?: boolean
values?: string[]
}
PinInputParts from @llui/components/pin-input
export interface PinInputParts {
root: {
role: 'group'
/** The label part's id, or absent when `hasLabel: false`. */
'aria-labelledby': string | undefined
'data-scope': 'pin-input'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
}
label: {
id: string
'data-scope': 'pin-input'
'data-part': 'label'
}
/** Props for the input at a given index. */
input: (index: number) => {
type: ReadSignal<'text' | 'password'>
inputmode: ReadSignal<'numeric' | 'text'>
pattern: ReadSignal<string>
maxlength: 1
autocomplete: 'off'
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'pin-input'
'data-part': 'input'
'data-index': string
onInput: (e: Event) => void
onKeyDown: (e: KeyboardEvent) => void
onFocus: (e: FocusEvent) => void
onPaste: (e: ClipboardEvent) => void
}
}
PinInputState from @llui/components/pin-input
export interface PinInputState {
values: string[]
length: number
type: PinType
mask: boolean
disabled: boolean
focusedIndex: number
}
Constants
pinInput from @llui/components/pin-input
const pinInput
@llui/components/progress
Functions
connect() from @llui/components/progress
function connect(state: ReadSignal<ProgressState>, _send: Send<ProgressMsg>, opts: ConnectOptions = {}): ProgressParts
init() from @llui/components/progress
function init(opts: ProgressInit = {}): ProgressState
percent() from @llui/components/progress
function percent(state: ProgressState): number | null
update() from @llui/components/progress
function update(state: ProgressState, msg: ProgressMsg): [ProgressState, never[]]
valueState() from @llui/components/progress
function valueState(state: ProgressState): 'indeterminate' | 'complete' | 'loading'
Types
ProgressMsg from @llui/components/progress
export type ProgressMsg =
/** @humanOnly */
| { type: 'setValue'; value: number | null }
/** @humanOnly */
| { type: 'setMax'; max: number }
ProgressOrientation from @llui/components/progress
Progress — linear or circular progress indicator. Determinate (0..max) or
indeterminate (value: null).
export type ProgressOrientation = 'horizontal' | 'vertical'
Interfaces
ConnectOptions from @llui/components/progress
export interface ConnectOptions {
label?: string
/** Custom formatter for value text. */
format?: (value: number | null, max: number) => string
}
ProgressInit from @llui/components/progress
export interface ProgressInit {
value?: number | null
min?: number
max?: number
orientation?: ProgressOrientation
}
ProgressParts from @llui/components/progress
export interface ProgressParts {
root: {
role: 'progressbar'
'aria-valuemin': ReadSignal<number>
'aria-valuemax': ReadSignal<number>
'aria-valuenow': ReadSignal<number | undefined>
'aria-label': string | undefined
'data-state': ReadSignal<'indeterminate' | 'complete' | 'loading'>
'data-orientation': ReadSignal<ProgressOrientation>
'data-scope': 'progress'
'data-part': 'root'
}
track: {
'data-state': ReadSignal<'indeterminate' | 'complete' | 'loading'>
'data-orientation': ReadSignal<ProgressOrientation>
'data-scope': 'progress'
'data-part': 'track'
}
range: {
'data-state': ReadSignal<'indeterminate' | 'complete' | 'loading'>
'data-orientation': ReadSignal<ProgressOrientation>
'data-scope': 'progress'
'data-part': 'range'
style: ReadSignal<string>
}
label: {
'data-scope': 'progress'
'data-part': 'label'
}
valueText: ReadSignal<string>
}
ProgressState from @llui/components/progress
export interface ProgressState {
value: number | null
min: number
max: number
orientation: ProgressOrientation
}
Constants
progress from @llui/components/progress
const progress
@llui/components/rating-group
Functions
connect() from @llui/components/rating-group
function connect(state: ReadSignal<RatingGroupState>, send: Send<RatingGroupMsg>, opts: ConnectOptions = {}): RatingGroupParts
init() from @llui/components/rating-group
function init(opts: RatingGroupInit = {}): RatingGroupState
itemFill() from @llui/components/rating-group
function itemFill(state: RatingGroupState, index: number): ItemFill
update() from @llui/components/rating-group
function update(state: RatingGroupState, msg: RatingGroupMsg): [RatingGroupState, never[]]
Types
ItemFill from @llui/components/rating-group
export type ItemFill = 'full' | 'half' | 'empty'
RatingGroupMsg from @llui/components/rating-group
export type RatingGroupMsg =
/** @intent("Set the rating value directly (clamped to 0..count, snapped to 0.5 if allowHalf)") */
| { type: 'setValue'; value: number }
/** @humanOnly */
| { type: 'hover'; value: number | null }
/** @humanOnly */
| { type: 'clickItem'; index: number; isStartHalf: boolean }
/** @humanOnly */
| { type: 'hoverItem'; index: number; isStartHalf: boolean }
/** @intent("Increase the rating by step (default: 0.5 if allowHalf, else 1)") */
| { type: 'incrementValue'; step?: number }
/** @intent("Decrease the rating by step (default: 0.5 if allowHalf, else 1)") */
| { type: 'decrementValue'; step?: number }
/** @intent("Snap the rating to its maximum (count)") */
| { type: 'toEnd' }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
Interfaces
ConnectOptions from @llui/components/rating-group
export interface ConnectOptions {
label?: string
}
RatingGroupInit from @llui/components/rating-group
export interface RatingGroupInit {
value?: number
count?: number
allowHalf?: boolean
disabled?: boolean
readonly?: boolean
dir?: 'ltr' | 'rtl'
}
RatingGroupParts from @llui/components/rating-group
export interface RatingGroupParts {
root: {
role: 'radiogroup'
'aria-label': string | undefined
'aria-disabled': ReadSignal<'true' | undefined>
'aria-readonly': ReadSignal<'true' | undefined>
'data-scope': 'rating-group'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
'data-readonly': ReadSignal<'' | undefined>
}
item: (index: number) => RatingItemParts
}
RatingGroupState from @llui/components/rating-group
Rating group — a sequence of clickable items (stars) representing a discrete rating. Supports half-step ratings and keyboard navigation.
export interface RatingGroupState {
value: number
count: number
/** If true, allows values like 1.5 (half-stars). */
allowHalf: boolean
disabled: boolean
readonly: boolean
hoveredValue: number | null
/** Reading direction. Under 'rtl', ArrowLeft/ArrowRight swap meaning. */
dir: 'ltr' | 'rtl'
}
RatingItemParts from @llui/components/rating-group
export interface RatingItemParts {
root: {
role: 'radio'
'aria-checked': ReadSignal<boolean>
'data-fill': ReadSignal<ItemFill>
'data-scope': 'rating-group'
'data-part': 'item'
'data-value': string
'data-disabled': ReadSignal<'' | undefined>
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
}
Constants
ratingGroup from @llui/components/rating-group
const ratingGroup
@llui/components/pagination
Functions
connect() from @llui/components/pagination
function connect(state: ReadSignal<PaginationState>, send: Send<PaginationMsg>, opts: ConnectOptions): PaginationParts
init() from @llui/components/pagination
function init(opts: PaginationInit = {}): PaginationState
onControlKeyDown() from @llui/components/pagination
onKeyDown for every focusable pagination control. Moves DOM focus across
the controls (ArrowLeft/ArrowRight, Home/End), skipping ellipsis + disabled
prev/next. Page triggers stay real <button>s, so Enter/Space activate
natively — this handler deliberately ignores them.
dir is the explicit reading direction (from State); omit it to resolve the
direction from the DOM instead.
function onControlKeyDown(e: KeyboardEvent, dir?: TextDirection): void
pageItems() from @llui/components/pagination
Compute the visible page buttons with ellipses:
[first ..boundaries] … [siblings around current] … [boundaries ..last].
function pageItems(state: PaginationState): PageItem[]
totalPages() from @llui/components/pagination
function totalPages(state: PaginationState): number
update() from @llui/components/pagination
function update(state: PaginationState, msg: PaginationMsg): [PaginationState, never[]]
Types
PageItem from @llui/components/pagination
export type PageItem =
| { type: 'page'; page: number }
| { type: 'ellipsis'; position: 'start' | 'end' }
PaginationMsg from @llui/components/pagination
export type PaginationMsg =
/** @intent("Jump to a specific 1-based page number") */
| { type: 'goTo'; page: number }
/** @intent("Advance to the next page") */
| { type: 'next' }
/** @intent("Go back to the previous page") */
| { type: 'prev' }
/** @intent("Jump to the first page") */
| { type: 'first' }
/** @intent("Jump to the last page") */
| { type: 'last' }
/** @intent("Change how many items each page contains") */
| { type: 'setPageSize'; pageSize: number }
/** @humanOnly */
| { type: 'setTotal'; total: number }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: TextDirection }
/** @humanOnly — synchronized from the mounted root's live ancestor direction. */
| { type: 'syncDomDir'; dir: TextDirection }
Interfaces
ConnectOptions from @llui/components/pagination
export interface ConnectOptions {
id: string
label?: string
prevLabel?: string
nextLabel?: string
pageLabel?: (page: number) => string
}
PaginationInit from @llui/components/pagination
export interface PaginationInit {
page?: number
pageSize?: number
total?: number
siblings?: number
boundaries?: number
disabled?: boolean
dir?: TextDirection
}
PaginationParts from @llui/components/pagination
export interface PaginationParts {
root: {
id: string
role: 'navigation'
'aria-label': string
'data-scope': 'pagination'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
}
prevTrigger: {
type: 'button'
'aria-label': string
'aria-disabled': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
'data-scope': 'pagination'
'data-part': 'prev-trigger'
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
nextTrigger: {
type: 'button'
'aria-label': string
'aria-disabled': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
'data-scope': 'pagination'
'data-part': 'next-trigger'
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
item: (page: number) => {
type: 'button'
'aria-label': string
'aria-current': ReadSignal<'page' | undefined>
'data-selected': ReadSignal<'' | undefined>
'data-scope': 'pagination'
'data-part': 'item'
'data-value': string
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
ellipsis: (position: 'start' | 'end') => {
'aria-hidden': 'true'
'data-scope': 'pagination'
'data-part': 'ellipsis'
'data-position': 'start' | 'end'
}
/** Place once anywhere in the same build to keep automatic direction live. */
directionSync: Mountable
}
PaginationState from @llui/components/pagination
Pagination — page navigation with ellipses for large ranges.
page is 1-based. Siblings are the count of pages shown on each side
of the current page. Boundaries are shown at the start/end.
export interface PaginationState {
page: number
pageSize: number
total: number
siblings: number
boundaries: number
disabled: boolean
dir: TextDirection
dirSource: DirectionSource
}
Constants
pagination from @llui/components/pagination
const pagination
@llui/components/alert-dialog
Functions
connect() from @llui/components/alert-dialog
function connect(state: ReadSignal<DialogState>, send: Send<DialogMsg>, opts: AlertDialogConnectOptions): AlertDialogParts
init() from @llui/components/alert-dialog
function init(opts: DialogInit = {}): DialogState
isMounted() from @llui/components/alert-dialog
Whether the dialog node should be in the DOM — true through the exit animation.
Tolerates a partial slice without status (e.g. the { open } bridge a pattern
passes to overlay): it falls back to open for instant, backward-compatible unmount.
function isMounted(state: DialogState): boolean
isPresent() from @llui/components/alert-dialog
Alias of {@link isMounted} — whether the dialog is currently present in the DOM.
function isPresent(state: DialogState): boolean
overlay() from @llui/components/alert-dialog
function overlay(opts: AlertDialogOverlayOptions): Mountable
update() from @llui/components/alert-dialog
function update(state: DialogState, msg: DialogMsg): [DialogState, never[]]
Types
AlertDialogConnectOptions from @llui/components/alert-dialog
Connect options — the dialog options minus role (fixed to alertdialog).
export type AlertDialogConnectOptions = Omit<DialogConnectOptions, 'role'>
AlertDialogMsg from @llui/components/alert-dialog
export type DialogMsg =
/** @intent("Open the dialog") */
| { type: 'open' }
/** @intent("Close the dialog") */
| { type: 'close' }
/** @intent("Toggle the dialog open/closed") */
| { type: 'toggle' }
/** @intent("Set the dialog's open state to a specific value") */
| { type: 'setOpen'; open: boolean }
/** @humanOnly */
| { type: 'animationEnd' }
/** @humanOnly */
| { type: 'transitionEnd' }
AlertDialogParts from @llui/components/alert-dialog
export type AlertDialogParts = DialogParts
Interfaces
AlertDialogOverlayOptions from @llui/components/alert-dialog
export interface AlertDialogOverlayOptions {
/**
* Class applied to the positioner — the wrapper `div` `dialog.overlay` builds
* around the content. Forwarded verbatim; the consumer supplies the layer's
* `fixed inset-0` and `z-index`, since the part bag carries only `data-*`.
*
* This interface does not spread `DialogOverlayOptions`, so a new dialog
* option has to be restated here to reach `dialogOverlay` through the `...opts`
* below — which is exactly how this one was missed the first time.
*/
positionerClass?: string
state: ReadSignal<DialogState>
send: Send<DialogMsg>
parts: AlertDialogParts
content: () => Renderable
/**
* Optional enter/leave transition for the alert-dialog content (from
* `@llui/transitions`), forwarded to `dialog.overlay`. `enter` animates it in
* on open; `leave` defers the unmount until its promise resolves, so the close
* plays an exit animation. Keep `skipAnimations` at its default (true) here.
*
* @example alertDialog.overlay({ state, send, parts, content, transition: fade({ duration: 150 }) })
*/
transition?: TransitionOptions
closeOnEscape?: boolean
/** Whether outside-click should dismiss (default: false for alert dialogs). */
closeOnOutsideClick?: boolean
trapFocus?: boolean
lockScroll?: boolean
hideSiblings?: boolean
target?: string | HTMLElement
initialFocus?: Element | (() => Element | null)
restoreFocus?: boolean
}
AlertDialogState from @llui/components/alert-dialog
Dialog — modal / non-modal overlay. Ties together focus-trap, dismissable, body scroll lock, sibling aria-hidden, and portal-to-body rendering into a single view helper.
Two layers:
- state machine (
init,update,connect) — pure, minimal. overlay()view helper — opens the dialog's DOM tree inside a body portal, wires up all accessibility utilities on mount, tears them down on close, restores focus to the trigger.
view: ({ state, send }) => {
const dialogState = state.at('dialog')
const dialogSend = mapSend<Msg, dialog.DialogMsg>(send, (msg) => ({
type: 'dialog',
msg,
}))
const parts = dialog.connect(dialogState, dialogSend, { id: 'dialog' })
return [
button({ ...parts.trigger, class: 'btn' }, [text('Delete')]),
dialog.overlay({
state: dialogState,
send: dialogSend,
parts,
content: () => [
div({ ...parts.content, class: 'dialog' }, [
h2({ ...parts.title }, [text('Are you sure?')]),
button({ ...parts.closeTrigger, class: 'btn' }, [text('Cancel')]),
]),
],
}),
]
}
export interface DialogState {
open: boolean
/** Presence lifecycle — drives data-state and keeps the node mounted through exit
* animations. Optional: a partial `{ open }` bridge (e.g. a pattern passing a
* slice to `overlay`) omits it, and the runtime falls back to `open` for instant,
* backward-compatible mount/visibility. `init` always sets it. */
status?: PresenceStatus
/** When true, close transitions go straight to 'closed' (no exit-animation wait).
* Optional for the same partial-slice reason as `status`; `init` always sets it. */
skipAnimations?: boolean
}
Constants
alertDialog from @llui/components/alert-dialog
const alertDialog
@llui/components/drawer
Functions
connect() from @llui/components/drawer
function connect(state: ReadSignal<DrawerState>, send: Send<DrawerMsg>, opts: ConnectOptions): DrawerParts
init() from @llui/components/drawer
function init(opts: DrawerInit = {}): DrawerState
isMounted() from @llui/components/drawer
Whether the drawer node should be in the DOM — true through the exit animation.
Tolerates a partial slice without status by falling back to open (instant unmount).
function isMounted(state: DrawerState): boolean
isPresent() from @llui/components/drawer
Alias of {@link isMounted} — whether the drawer is currently present in the DOM.
function isPresent(state: DrawerState): boolean
overlay() from @llui/components/drawer
function overlay(opts: OverlayOptions): Mountable
update() from @llui/components/drawer
function update(state: DrawerState, msg: DrawerMsg): [DrawerState, never[]]
Types
DrawerMsg from @llui/components/drawer
export type DrawerMsg =
/** @intent("Open the drawer") */
| { type: 'open' }
/** @intent("Close the drawer") */
| { type: 'close' }
/** @intent("Toggle the drawer open/closed") */
| { type: 'toggle' }
/** @intent("Set the drawer's open state to a specific value") */
| { type: 'setOpen'; open: boolean }
/** @humanOnly */
| { type: 'animationEnd' }
/** @humanOnly */
| { type: 'transitionEnd' }
DrawerSide from @llui/components/drawer
Drawer — a panel that slides in from a screen edge. Structurally
identical to dialog (portal + focus trap + dismissable + aria-hidden +
scroll lock), but adds a side so styling can animate from that edge.
export type DrawerSide = 'left' | 'right' | 'top' | 'bottom'
Interfaces
ConnectOptions from @llui/components/drawer
export interface ConnectOptions {
id: string
side?: DrawerSide
closeLabel?: string
}
DrawerInit from @llui/components/drawer
export interface DrawerInit {
open?: boolean
/** Skip enter/exit animations — close unmounts synchronously (default: true). */
skipAnimations?: boolean
}
DrawerParts from @llui/components/drawer
export interface DrawerParts {
trigger: {
type: 'button'
'aria-haspopup': 'dialog'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
id: string
'data-state': ReadSignal<'open' | 'closed'>
'data-scope': 'drawer'
'data-part': 'trigger'
onClick: (e: MouseEvent) => void
}
backdrop: {
'data-state': ReadSignal<PresenceStatus>
'data-scope': 'drawer'
'data-part': 'backdrop'
'aria-hidden': 'true'
}
positioner: {
'data-scope': 'drawer'
'data-part': 'positioner'
'data-side': DrawerSide
}
content: {
role: 'dialog'
id: string
'aria-modal': 'true'
'aria-labelledby': string
tabindex: -1
'data-state': ReadSignal<PresenceStatus>
'data-scope': 'drawer'
'data-part': 'content'
'data-side': DrawerSide
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
title: {
id: string
'data-scope': 'drawer'
'data-part': 'title'
}
description: {
id: string
'data-scope': 'drawer'
'data-part': 'description'
}
closeTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'drawer'
'data-part': 'close-trigger'
onClick: (e: MouseEvent) => void
}
}
DrawerState from @llui/components/drawer
export interface DrawerState {
open: boolean
/** Presence lifecycle — drives data-state and keeps the node mounted through exit animations. */
status: PresenceStatus
/** When true, close transitions go straight to 'closed' (no exit-animation wait). */
skipAnimations: boolean
}
OverlayOptions from @llui/components/drawer
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
state: ReadSignal<DrawerState>
send: Send<DrawerMsg>
parts: DrawerParts
content: () => Renderable
/**
* Optional enter/leave transition for the drawer content (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, so the close plays an exit animation.
* Keep `skipAnimations` at its default (true) when driving exits this way.
*
* @example drawer.overlay({ state, send, parts, content, transition: slide({ duration: 200 }) })
*/
transition?: TransitionOptions
closeOnEscape?: boolean
closeOnOutsideClick?: boolean
trapFocus?: boolean
lockScroll?: boolean
hideSiblings?: boolean
target?: string | HTMLElement
initialFocus?: Element | (() => Element | null)
restoreFocus?: boolean
}
Constants
drawer from @llui/components/drawer
const drawer
@llui/components/toast
Functions
connect() from @llui/components/toast
function connect(state: ReadSignal<ToasterState>, send: Send<ToasterMsg>, opts: ConnectOptions = {}): ToasterParts
init() from @llui/components/toast
function init(opts: ToasterInit = {}): ToasterState
isPaused() from @llui/components/toast
Whether any reason currently holds the toast's countdown paused.
function isPaused(t: Pick<Toast, 'pausedBy'>): boolean
isPresent() from @llui/components/toast
Whether a toast with the given id is in the queue (mounted). Stays true
through 'closing'; false once animationEnd removes it.
function isPresent(state: ToasterState, id: string): boolean
nextToastId() from @llui/components/toast
function nextToastId(): string
politeness() from @llui/components/toast
Resolve the announcement politeness for a toast: explicit override, else
error → assertive, everything else → polite.
function politeness(toast: Pick<Toast, 'type' | 'ariaLive'>): ToastPoliteness
progress() from @llui/components/toast
Fraction of the countdown remaining for a given toast, in [0,1]. Sticky toasts report 1; a missing toast reports 0.
function progress(state: ToasterState, id: string): number
update() from @llui/components/toast
function update(state: ToasterState, msg: ToasterMsg): [ToasterState, never[]]
Types
ToasterMsg from @llui/components/toast
export type ToasterMsg =
/** @intent("Show a new toast notification") */
| { type: 'create'; toast: ToastInput }
/** @intent("Dismiss the toast with the given id") */
| { type: 'dismiss'; id: string }
/** @intent("Dismiss every toast currently visible") */
| { type: 'dismissAll' }
/** @intent("Move the toast region to a different corner/edge of the viewport") */
| { type: 'setPlacement'; placement: ToastPlacement }
/** @intent("Patch mutable presentation fields on the toast with the given id (title, description, type, etc.); `id` cannot be patched") */
| { type: 'update'; id: string; patch: ToastPatch }
/** @humanOnly Advance the countdown for one toast by `elapsedMs` since the last tick. */
| { type: 'tick'; id: string; elapsedMs: number }
/** @intent("Pause auto-dismiss countdown for the toast with the given id") */
| { type: 'pause'; id: string; reason?: ToastPauseReason }
/** @intent("Resume auto-dismiss countdown for the toast with the given id (releases only the given reason, default manual)") */
| { type: 'resume'; id: string; reason?: ToastPauseReason }
/** @intent("Pause auto-dismiss for every visible toast") */
| { type: 'pauseAll'; reason?: ToastPauseReason }
/** @intent("Resume auto-dismiss for every visible toast (releases only the given reason, default manual)") */
| { type: 'resumeAll'; reason?: ToastPauseReason }
/** @humanOnly Exit animation finished for the toast with the given id — remove it from the queue. */
| { type: 'animationEnd'; id: string }
ToastInput from @llui/components/toast
A new toast as supplied to create. remainingMs/pausedBy/status are
optional — seeded from duration/[]/'open' when omitted.
export type ToastInput = Omit<Toast, 'remainingMs' | 'pausedBy' | 'status'> & {
remainingMs?: number
pausedBy?: readonly ToastPauseReason[]
status?: PresenceStatus
}
ToastPatch from @llui/components/toast
Fields an update message may patch on a mounted toast. id is the ONE
truly immutable field — a toast is created once and dismissed once, never
structurally replaced with a different id for the same row — so it is
excluded here rather than silently ignored by the reducer. type and
ariaLive (among others) ARE genuine mutable presentation fields (the
toast.promise-style loading→success/error flow patches type, title
and description on the same mounted toast) and connect()'s toast()
builder binds every one of these reactively (never via a one-shot
peek()), so a patch here is visible wherever it renders.
status/remainingMs/pausedBy are excluded: they are LIFECYCLE fields the
reducer itself owns (presence transitions, the tick-driven countdown,
pause/resume) and a patch is the wrong channel for them — dismiss/tick/
pause/resume already exist and a caller patching remainingMs directly
would race the reducer's own countdown math. duration stays patchable
(it IS presentation — the toast.promise flow moves a sticky loading
toast to a finite success/error duration), so update re-seeds
remainingMs from the new duration whenever duration is part of the
patch (#265 A2) — otherwise a toast created sticky (duration: null,
remainingMs frozen at 0) that is later patched to a finite duration would
inherit that frozen 0 and dismiss on the very next tick instead of lasting
its new duration.
undefined on a REQUIRED field (type, duration, dismissable) means
"not patched", the same as an absent key (#265 G6): { duration: undefined }
leaves the countdown alone rather than re-seeding it to 0 — patch
duration: null to make a toast sticky. On an OPTIONAL field (title,
description, ariaLive) undefined clears it, so a loading -> success
patch can drop a description the success toast should not keep.
export type ToastPatch = Partial<Omit<Toast, 'id' | 'status' | 'remainingMs' | 'pausedBy'>>
ToastPauseReason from @llui/components/toast
Why a toast's countdown is paused. The row's own handlers use 'hover' and
'focus'; a pause/resume message without a reason uses 'manual'.
export type ToastPauseReason = (typeof PAUSE_REASONS)[number]
ToastPlacement from @llui/components/toast
export type ToastPlacement =
| 'top'
| 'top-start'
| 'top-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
ToastPoliteness from @llui/components/toast
aria-live politeness for a toast's announcement region.
export type ToastPoliteness = 'polite' | 'assertive'
ToastType from @llui/components/toast
Toast — ephemeral non-modal notifications rendered in a fixed region. Multiple toasts can be active at once. Each has a duration after which it auto-dismisses (unless paused or sticky).
Architecture (timer-free, tick-driven — same division of labor as timer.ts):
toast.toasterstate manages a collection of toasts. Each toast carries its own countdown in state:duration(null = sticky),remainingMs, andpausedBy(the set of reasons currently holding it paused).- The machine owns NO interval. The consumer drives the countdown with a
tick(id, elapsedMs)message (e.g. via @llui/effectsinterval), subtracting the elapsed wall time since the last tick. A paused toast (any reason held) freezes itsremainingMs(ticks are ignored). - Pause REASONS are independent (#265 G6): the row's hover pauses/resumes
'hover', focus entering/leaving the row pauses/resumes'focus', andpause/resumewith no reason use'manual'. Pointer-leave therefore never resumes a toast whose close button still has keyboard focus. - When
remainingMshits 0 the REDUCER dismisses that toast itself, so there is no consumer/runtime race over who removes it.
Presence (exit animation) — additive, opt-in via init({ animated: true }):
- Each toast carries a presence
status(closed/opening/open/closing). A freshly created toast is born'open'(no enter-animation gate — toasts appear immediately). - Dismissing a toast (manually or when its countdown reaches 0) moves it to
'closing'and KEEPS IT MOUNTED so it can play an exit animation; ananimationEnd(id)message then removes it from the queue. - When the toaster is NOT animated, dismiss removes the toast SYNCHRONOUSLY (today's behavior) — never waiting for an animationend that won't fire.
export type ToastType = 'info' | 'success' | 'warning' | 'error' | 'loading' | 'custom'
Interfaces
ConnectOptions from @llui/components/toast
export interface ConnectOptions {
regionLabel?: string
closeLabel?: string
}
Toast from @llui/components/toast
export interface Toast {
id: string
type: ToastType
title?: string
description?: string
/** ms until auto-dismiss. `null` = sticky (never auto-dismisses). */
duration: number | null
/** ms left before auto-dismiss. Counts down via `tick`. */
remainingMs: number
/** Whether the USER can dismiss the toast: `false` hides the close trigger
* (`closeTrigger.hidden`) and ignores its click. A `dismiss` message from
* code still removes it. Patchable. */
dismissable: boolean
/** The reasons currently holding the countdown paused, as a canonical set
* (unique, in {@link ToastPauseReason} order). Empty = running. Owned by the
* reducer: change it with `pause`/`resume`, read it with {@link isPaused}. */
pausedBy: ToastPauseReason[]
/** Optional per-toast politeness override; otherwise derived from `type`. */
ariaLive?: ToastPoliteness
/**
* Presence lifecycle for this toast (closed/opening/open/closing). Born
* `'open'`; a dismiss moves it to `'closing'` (when the toaster is animated)
* so it can play an exit animation before `animationEnd` removes it.
*/
status: PresenceStatus
}
ToasterInit from @llui/components/toast
export interface ToasterInit {
max?: number
placement?: ToastPlacement
/** Play an exit animation on dismiss (toasts go to `'closing'` and stay
* mounted until `animationEnd`). Default false — instant removal. */
animated?: boolean
}
ToasterParts from @llui/components/toast
export interface ToasterParts {
region: {
role: 'region'
'aria-label': string
tabindex: -1
'data-scope': 'toast'
'data-part': 'region'
'data-placement': ReadSignal<ToastPlacement>
}
/**
* Build the per-row part descriptors for one toast. Takes the row's
* `ReadSignal<Toast>` (e.g. the `item` from `each`) rather than a snapshot, so
* consumers don't `.peek()` in a reactive slot (which the signal compiler
* rejects). Only `id` is immutable for a toast's lifetime — created then
* dismissed, never structurally replaced — so this reads `id` once
* internally to build id-derived wiring (the keyed `each` rebuilds the row
* if `id` changes); every other field (`type`, `ariaLive`, `status`, …) is
* bound reactively so an `update` patch renders wherever it appears.
*/
toast: (toast: ReadSignal<Toast>) => ToastItemParts
/**
* Reactive fraction (in [0,1]) of the countdown remaining for the toast with
* `id` — for a countdown progress bar. Sticky toasts report 1; a dismissed /
* missing toast reports 0.
*/
progress: (id: string) => ReadSignal<number>
/**
* Reactive presence: whether the toast with `id` is still in the queue (i.e.
* should be mounted). Stays true through `'closing'` so the exit animation can
* play; flips false once `animationEnd` removes it. The keyed `each` over
* `toasts` already handles the actual mount/unmount — this is for consumers
* coordinating other elements off a single toast's lifecycle.
*/
isPresent: (id: string) => ReadSignal<boolean>
}
ToasterState from @llui/components/toast
export interface ToasterState {
toasts: Toast[]
max: number
placement: ToastPlacement
/**
* Whether dismissed toasts play an exit animation. When true a dismiss moves
* the toast to `'closing'` (kept mounted) until `animationEnd` removes it;
* when false (default) dismiss removes synchronously — today's behavior, no
* wait for an animationend that won't fire.
*/
animated: boolean
}
ToastItemParts from @llui/components/toast
export interface ToastItemParts {
root: {
/**
* Reactive: derived from the toast's current `type`/`ariaLive` (see
* {@link politeness}), never frozen at mount — an `update` patching
* either is visible here.
*/
role: ReadSignal<'status' | 'alert'>
'aria-atomic': 'true'
/** Reactive — see `role` above. */
'aria-live': ReadSignal<ToastPoliteness>
id: string
'data-scope': 'toast'
'data-part': 'root'
/** Reactive: an `update` patching `type` (e.g. a promise toast moving
* loading → success) is visible here, not frozen at mount. */
'data-type': ReadSignal<ToastType>
'data-id': string
/** Reactive presence status (closed/opening/open/closing) for CSS-driven
* enter/exit animations. */
'data-state': ReadSignal<PresenceStatus>
onPointerEnter: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
/** Bubbling — a plain (non-bubbling) `onFocus`/`onBlur` here would never
* fire for the only naturally focusable descendant, the close button,
* so pause-on-focus would be dead: nothing but the row itself receives
* `focus`/`blur` directly, and it carries no `tabindex`. */
onFocusIn: (e: FocusEvent) => void
onFocusOut: (e: FocusEvent) => void
/** Advance past the exit animation: a `'closing'` toast is removed from the
* queue once its animation/transition ends. */
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
title: {
id: string
'data-scope': 'toast'
'data-part': 'title'
}
description: {
id: string
'data-scope': 'toast'
'data-part': 'description'
}
closeTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'toast'
'data-part': 'close-trigger'
/** Reactive: true while the toast is not `dismissable` — the button leaves
* the accessibility tree and the tab order. Hiding it while it has focus
* is safe for the `'focus'` pause reason: the browser's focus fixup fires
* `blur`/`focusout` on the row (verified in Chromium), which releases it. */
hidden: ReadSignal<boolean>
/** Dismisses the toast; ignored while it is not `dismissable`. */
onClick: (e: MouseEvent) => void
}
}
Constants
toast from @llui/components/toast
const toast
@llui/components/listbox
Functions
connect() from @llui/components/listbox
function connect(state: ReadSignal<ListboxState>, send: Send<ListboxMsg>, opts: ConnectOptions): ListboxParts
init() from @llui/components/listbox
function init(opts: ListboxInit = {}): ListboxState
update() from @llui/components/listbox
function update(state: ListboxState, msg: ListboxMsg): [ListboxState, never[]]
Types
ListboxMsg from @llui/components/listbox
export type ListboxMsg =
/** @intent("Pick the option with the given value (toggles in multi-select)") */
| { type: 'select'; value: string }
/** @intent("Replace the selected values with the provided list") */
| { type: 'setValue'; value: string[] }
/** @intent("Clear all selected values") */
| { type: 'clear' }
/** @humanOnly */
| { type: 'highlight'; index: number | null }
/** @humanOnly */
| { type: 'highlightNext' }
/** @humanOnly */
| { type: 'highlightPrev' }
/** @humanOnly */
| { type: 'highlightFirst' }
/** @humanOnly */
| { type: 'highlightLast' }
/** @intent("Pick the currently-highlighted option") */
| { type: 'selectHighlighted' }
/** @humanOnly */
| { type: 'setItems'; items: string[]; disabled?: string[] }
/** @humanOnly */
| { type: 'typeahead'; char: string; now: number }
SelectionMode from @llui/components/listbox
Listbox — a list of selectable options. Supports single and multiple
selection, keyboard navigation (arrows, Home, End), typeahead, and
disabled items. Renders as role="listbox" with role="option" items.
export type SelectionMode = 'single' | 'multiple'
Interfaces
ConnectOptions from @llui/components/listbox
export interface ConnectOptions {
id: string
}
ListboxInit from @llui/components/listbox
export interface ListboxInit {
value?: string[]
items?: string[]
disabledItems?: string[]
disabled?: boolean
selectionMode?: SelectionMode
}
ListboxItemParts from @llui/components/listbox
export interface ListboxItemParts {
root: {
role: 'option'
id: string
'aria-selected': ReadSignal<boolean>
'aria-disabled': ReadSignal<'true' | undefined>
'data-state': ReadSignal<'selected' | undefined>
'data-highlighted': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'listbox'
'data-part': 'item'
'data-value': string
'data-index': string
onClick: (e: MouseEvent) => void
onPointerMove: (e: PointerEvent) => void
}
}
ListboxParts from @llui/components/listbox
export interface ListboxParts {
root: {
role: 'listbox'
'aria-multiselectable': ReadSignal<'true' | undefined>
'aria-disabled': ReadSignal<'true' | undefined>
'aria-activedescendant': ReadSignal<string | undefined>
tabindex: ReadSignal<number>
id: string
'data-scope': 'listbox'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
onKeyDown: (e: KeyboardEvent) => void
}
item: (value: string, index: number) => ListboxItemParts
}
ListboxState from @llui/components/listbox
export interface ListboxState {
value: string[]
items: string[]
disabledItems: string[]
disabled: boolean
selectionMode: SelectionMode
highlightedIndex: number | null
typeahead: string
typeaheadExpiresAt: number
}
Constants
listbox from @llui/components/listbox
const listbox
@llui/components/select
Functions
connect() from @llui/components/select
function connect(state: ReadSignal<SelectState>, send: Send<SelectMsg>, opts: ConnectOptions): SelectParts
init() from @llui/components/select
function init(opts: SelectInit = {}): SelectState
overlay() from @llui/components/select
function overlay(opts: OverlayOptions): Mountable
update() from @llui/components/select
function update(state: SelectState, msg: SelectMsg): [SelectState, never[]]
Types
SelectionMode from @llui/components/select
Select — a trigger button that opens a listbox dropdown. Value(s) are
visible on the trigger. Supports single or multiple selection.
Positioned relative to the trigger via @floating-ui/dom.
export type SelectionMode = 'single' | 'multiple'
SelectMsg from @llui/components/select
export type SelectMsg =
/** @intent("Open the select dropdown") */
| { type: 'open' }
/** @intent("Close the select dropdown") */
| { type: 'close' }
/** @intent("Toggle the select dropdown open/closed") */
| { type: 'toggle' }
/** @intent("Pick the option with the given value (toggles in multi-select)") */
| { type: 'selectOption'; value: string }
/** @intent("Replace the selected values with the provided list") */
| { type: 'setValue'; value: string[] }
/** @intent("Clear all selected values") */
| { type: 'clear' }
/** @humanOnly */
| { type: 'highlight'; value: string | null }
/** @humanOnly */
| { type: 'highlightNext' }
/** @humanOnly */
| { type: 'highlightPrev' }
/** @humanOnly */
| { type: 'highlightFirst' }
/** @humanOnly */
| { type: 'highlightLast' }
/** @intent("Pick the currently-highlighted option") */
| { type: 'selectHighlighted' }
/** @humanOnly */
| { type: 'setItems'; items: string[]; disabled?: string[] }
/** @humanOnly */
| { type: 'typeahead'; char: string; now: number }
Interfaces
ConnectOptions from @llui/components/select
export interface ConnectOptions {
id: string
/** Text to show in trigger when empty. */
placeholder?: string
/** Join multi-value labels with this separator. */
separator?: string
/**
* Native form-field name. When set, the `hiddenSelect` part becomes a real
* form control that submits the current selection under this name — render
* `<select {...parts.hiddenSelect}>` with one `<option {...parts.hiddenOption(v)}>`
* per item. Without a `name`, the hidden select carries no value into a form.
*/
name?: string
}
OverlayOptions from @llui/components/select
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
state: ReadSignal<SelectState>
send: Send<SelectMsg>
parts: SelectParts
content: () => Renderable
/**
* Optional enter/leave transition for the select listbox (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, giving the raw-`open` select an exit
* animation for free. Omitted ⇒ the listbox closes synchronously as before.
*
* @example select.overlay({ state, send, parts, content, transition: fade({ duration: 120 }) })
*/
transition?: TransitionOptions
placement?: Placement
offset?: number
flip?: boolean
shift?: boolean
/** Match content width to trigger width (default: true). */
sameWidth?: boolean
target?: string | HTMLElement
}
SelectGroup from @llui/components/select
A labelled section of options (rendered like <optgroup>). items are the
option VALUES belonging to the group, in visual order. Groups are an
additive, parallel structure: the flat items list always remains the
source of truth for navigation/highlight indices and item ids — when
groups is provided without an explicit items list, init derives the
flat list by concatenating each group's items in order. A plain flat
string[] (no groups) keeps working unchanged. Group LABELS are never
options, so highlight/typeahead/arrow navigation skips over them for free.
export interface SelectGroup {
id: string
label: string
items: string[]
}
SelectGroupParts from @llui/components/select
export interface SelectGroupParts {
group: {
role: 'group'
/** The group label's id, or absent when `hasLabel: false`. */
'aria-labelledby': string | undefined
'data-scope': 'select'
'data-part': 'group'
'data-group': string
}
groupLabel: {
id: string
'aria-hidden': 'true'
'data-scope': 'select'
'data-part': 'group-label'
'data-group': string
}
}
SelectInit from @llui/components/select
export interface SelectInit {
value?: string[]
items?: string[]
/** Optional labelled sections. When provided without `items`, the flat
* `items` list is derived by concatenating each group's `items` in order. */
groups?: SelectGroup[]
disabledItems?: string[]
selectionMode?: SelectionMode
disabled?: boolean
required?: boolean
}
SelectItemParts from @llui/components/select
export interface SelectItemParts {
item: {
role: 'option'
id: string
'aria-selected': ReadSignal<boolean>
'aria-disabled': ReadSignal<'true' | undefined>
'data-state': ReadSignal<'selected' | undefined>
'data-highlighted': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'select'
'data-part': 'item'
'data-value': string
/** The option's live position in the flat item list (reactive — reused rows
* never report a stale index). */
'data-index': ReadSignal<string>
onClick: (e: MouseEvent) => void
onPointerMove: (e: PointerEvent) => void
}
}
SelectParts from @llui/components/select
export interface SelectParts {
trigger: {
type: 'button'
role: 'combobox'
'aria-haspopup': 'listbox'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
'aria-activedescendant': ReadSignal<string | undefined>
'aria-disabled': ReadSignal<'true' | undefined>
'aria-required': ReadSignal<'true' | undefined>
id: string
disabled: ReadSignal<boolean>
'data-state': ReadSignal<'open' | 'closed'>
/** Present while the trigger is showing the PLACEHOLDER rather than a
* value. `valueText` already falls back to the placeholder string, but a
* string is not something CSS can branch on, so without this the
* placeholder renders at full foreground weight and reads as a real
* selection. This is the attribute every shadcn Select greys it from. */
'data-placeholder': ReadSignal<'' | undefined>
'data-scope': 'select'
'data-part': 'trigger'
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
positioner: {
'data-scope': 'select'
'data-part': 'positioner'
style: string
}
content: {
role: 'listbox'
id: string
'aria-multiselectable': ReadSignal<'true' | undefined>
'aria-labelledby': string
tabindex: -1
'data-state': ReadSignal<'open' | 'closed'>
'data-scope': 'select'
'data-part': 'content'
onKeyDown: (e: KeyboardEvent) => void
}
hiddenSelect: {
'aria-hidden': 'true'
tabindex: -1
style: string
/** Native form field name, or `undefined` when `name` was not supplied. */
name: string | undefined
disabled: ReadSignal<boolean>
multiple: ReadSignal<boolean>
required: ReadSignal<boolean>
'data-scope': 'select'
'data-part': 'hidden-select'
}
/** An `<option>` for the hidden native `<select>`. Render one per item inside
* `hiddenSelect` so the browser submits the selection under the form `name`. */
hiddenOption: (value: string) => {
value: string
selected: ReadSignal<boolean>
'data-scope': 'select'
'data-part': 'hidden-option'
}
/** Build the parts for an option by VALUE. The optional `index` is accepted
* for call-site convenience only — it is NOT used for identity (highlight,
* selection and ids are all value-keyed), so a reused row is never stale. */
item: (value: string, index?: number) => SelectItemParts
/** Parts for a labelled option group (`<optgroup>`-style section). Pass the
* group id; render the section element with `group` and its label element
* (referenced by `aria-labelledby`) with `groupLabel`. Group labels are not
* options, so navigation skips them automatically. */
/**
* A labelled group's parts. The group names its `label` part through
* `aria-labelledby`; a consumer that renders the group WITHOUT that label
* passes `{ hasLabel: false }` so it never names an element that does not
* exist (an unlabelled `role="group"` is valid; a broken idref is not — the
* `dialog` `hasDescription` rule, #268). Default: true.
*/
group: (id: string, options?: { readonly hasLabel?: boolean }) => SelectGroupParts
/** Selected value(s) — use for rendering the trigger label. */
valueText: ReadSignal<string>
}
SelectState from @llui/components/select
export interface SelectState {
open: boolean
value: string[]
items: string[]
groups: SelectGroup[]
disabledItems: string[]
selectionMode: SelectionMode
/** The highlighted option's VALUE (not its index). Value-based identity keeps
* the highlight pinned to the right option when the list is filtered or
* reordered and rows are reused (value-keyed `each`). */
highlightedValue: string | null
disabled: boolean
required: boolean
typeahead: string
typeaheadExpiresAt: number
}
Constants
select from @llui/components/select
const select
@llui/components/combobox
Functions
connect() from @llui/components/combobox
function connect(state: ReadSignal<ComboboxState>, send: Send<ComboboxMsg>, opts: ConnectOptions): ComboboxParts
init() from @llui/components/combobox
function init(opts: ComboboxInit = {}): ComboboxState
isCreateOption() from @llui/components/combobox
function isCreateOption(value: string): boolean
loadProjection() from @llui/components/combobox
function loadProjection(state: Pick<ComboboxState, 'status' | 'items'>): LoadProjection
overlay() from @llui/components/combobox
function overlay(opts: OverlayOptions): Mountable
update() from @llui/components/combobox
function update(state: ComboboxState, msg: ComboboxMsg): [ComboboxState, ComboboxEffect[]]
Types
AsyncStatus from @llui/components/combobox
export type AsyncStatus = 'idle' | 'loading' | 'loaded' | 'error'
ComboboxEffect from @llui/components/combobox
Effects emitted by the combobox machine. Creation is owned by the consumer:
when a create sentinel is selected the machine surfaces the typed text as a
createOption effect rather than mutating its own value.
export type ComboboxEffect =
/** @intent("The user asked to create a brand-new option from the typed text") */
{ type: 'createOption'; value: string }
ComboboxMsg from @llui/components/combobox
export type ComboboxMsg =
/** @intent("Open the combobox dropdown") */
| { type: 'open' }
/** @intent("Close the combobox dropdown") */
| { type: 'close' }
/** @intent("Set the text input contents (re-runs the filter)") */
| { type: 'setInputValue'; value: string }
/** @intent("Pick the option with the given value (toggles in multi-select)") */
| { type: 'selectOption'; value: string }
/** @intent("Replace the selected values with the provided list") */
| { type: 'setValue'; value: string[] }
/** @intent("Clear all selected values and the input text") */
| { type: 'clear' }
/** @humanOnly */
| { type: 'highlightNext' }
/** @humanOnly */
| { type: 'highlightPrev' }
/** @humanOnly */
| { type: 'highlightFirst' }
/** @humanOnly */
| { type: 'highlightLast' }
/** @humanOnly */
| { type: 'highlight'; value: string | null }
/** @intent("Pick the currently-highlighted option in the filtered list") */
| { type: 'selectHighlighted' }
/**
* @humanOnly
*
* Replace `items` (and optionally `disabled`) synchronously — the same
* highlight-resolution policy as `loadSuccess` applies: a highlight that no
* longer survives the fresh list moves to the first enabled match WHILE
* OPEN, or to `null` WHILE CLOSED (#265 G3), since a background refresh
* must not manufacture a highlight before the control is ever opened.
*/
| { type: 'setItems'; items: string[]; disabled?: string[] }
/** @intent("Mark an async option fetch as started; pass the request's id") */
| { type: 'loadStart'; requestId: number }
/**
* @humanOnly
*
* Atomic replacement: `items` is required. `disabled` is an OPTIONAL
* companion that replaces `disabledItems` when present (omitted ⇒
* unchanged). `groups` is different: omitting it RESETS to no groups
* (`[]`), the same as `init()` with no `groups` option — a fresh
* replacement with no `groups` describes a flat result, and carrying a
* PREVIOUS load's groups forward would keep describing options this
* replacement never mentioned as belonging to a group that may no longer
* apply (#265 A3). Every field the fresh `items`/`disabled`/`groups` makes
* inconsistent is reconciled in this SAME reducer step, never in a
* follow-up message. `value` (selection) is dropped when it no longer
* names a value in the new `items` (after the new `disabled` is applied).
* `highlightedValue` is kept only when it is BOTH still in the fresh
* filtered list AND not newly disabled. Otherwise the fallback depends on
* whether the listbox is open: WHILE OPEN it moves to the first enabled
* match (or `null` when none is enabled) — never left dangling for a
* render in between, and never left naming an option that is now
* disabled. WHILE CLOSED it always resolves to `null` instead, even when
* the fresh list has enabled options: the listbox content is unmounted
* while closed, so there is no option `aria-activedescendant` could
* correctly name, and a background load (a prefetch, a poll) must not
* manufacture a highlight before the control is ever opened — re-opening
* always reseeds the highlight itself (#265 G3).
*/
| {
type: 'loadSuccess'
requestId: number
items: string[]
groups?: ComboboxGroup[]
disabled?: string[]
}
/** @humanOnly */
| { type: 'loadError'; requestId: number; error: string }
LoadProjection from @llui/components/combobox
A single, mutually-exclusive summary of the async load lifecycle, derived
from status and whether any items are currently on hand. This exists so a
consumer never has to reconcile independent booleans (isLoading,
isEmpty, hasError) that can read true at the same time — exactly the
defect #265 finding 11 named test-first. There are five states partitioning
every reachable (status, items.length) pair:
'initial-empty'— nothing has ever loaded and none were given synchronously (status === 'idle', no items).'loading'— a fetch is in flight and there is nothing yet to show (a first-ever load).'stale-results'— the STALE-WHILE-REVALIDATE state: a fetch is in flight while a previous list is still on screen.loadStartnever clearsitems, so the previous results keep rendering, filterable and selectable, until the matchingloadSuccess/loadErrorlands.'success'— the current items are the result of a completed load, or were given synchronously and never superseded by a failed fetch.'error'— the most recent fetch failed. Per the same policy, items from an earlier successful load are left mounted and selectable; only the live region / a consumer's own error slot communicate the failure, so'error'is reported the same whether or not stale items remain.
export type LoadProjection = 'initial-empty' | 'loading' | 'stale-results' | 'success' | 'error'
SelectionMode from @llui/components/combobox
Combobox — text input paired with a filtered listbox dropdown. User types to filter items, arrow keys navigate the filtered set, Enter selects. Supports single and multiple selection.
Beyond the sync filtered listbox the machine owns three additive surfaces:
- Async option loading —
status/requestId/errortrack an in-flight fetch. The consumer debounces (e.g.@llui/effectsdebounce) and runs the fetch itself, dispatchingloadStart/loadSuccess/loadErrortagged with a monotonically-increasingrequestId. The reducer DROPS anyloadSuccess/loadErrorwhoserequestIdis not the current one, so a late response from a superseded request can never clobber fresh state. The machine owns no timers. - Option groups —
groupsmirrorselect'sSelectGroupshape exactly. The flatitemslist stays the source of truth for navigation/highlight indices; group LABELS are never options, so arrow navigation skips them. - Creatable — opt-in
allowCreate. WheninputValueis non-empty and matches no item, a synthetic create sentinel is appended tofilteredItems. Selecting it emits acreateOptionEFFECT (carrying the typed text) so the consumer owns creation; the machine never mutatesvaluefor it.
export type SelectionMode = 'single' | 'multiple'
Interfaces
ComboboxGroup from @llui/components/combobox
A labelled section of options (rendered like <optgroup>). items are the
option VALUES belonging to the group, in visual order. Groups are an
additive, parallel structure: the flat items list always remains the
source of truth for navigation/highlight indices and item ids — when
groups is provided without an explicit items list, init derives the
flat list by concatenating each group's items in order. A plain flat
string[] (no groups) keeps working unchanged. Group LABELS are never
options, so highlight/arrow navigation skips over them for free.
Mirrors select's SelectGroup shape exactly.
export interface ComboboxGroup {
id: string
label: string
items: string[]
}
ComboboxGroupParts from @llui/components/combobox
export interface ComboboxGroupParts {
group: {
role: 'group'
/** The group label's id, or absent when `hasLabel: false`. */
'aria-labelledby': string | undefined
'data-scope': 'combobox'
'data-part': 'group'
'data-group': string
}
groupLabel: {
id: string
'aria-hidden': 'true'
'data-scope': 'combobox'
'data-part': 'group-label'
'data-group': string
}
}
ComboboxInit from @llui/components/combobox
export interface ComboboxInit {
value?: string[]
inputValue?: string
items?: string[]
/** Optional labelled sections. When provided without `items`, the flat
* `items` list is derived by concatenating each group's `items` in order. */
groups?: ComboboxGroup[]
disabledItems?: string[]
selectionMode?: SelectionMode
disabled?: boolean
/** Enable creatable mode: a synthetic create option is offered when the
* typed text matches no existing item. */
allowCreate?: boolean
}
ComboboxItemParts from @llui/components/combobox
export interface ComboboxItemParts {
item: {
role: 'option'
id: string
'aria-selected': ReadSignal<boolean>
'aria-disabled': ReadSignal<'true' | undefined>
'data-state': ReadSignal<'selected' | undefined>
'data-highlighted': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
'data-create': '' | undefined
'data-scope': 'combobox'
'data-part': 'item'
'data-value': string
/** The option's live position in the FILTERED list (reactive — reused rows
* never report a stale index). */
'data-index': ReadSignal<string>
onClick: (e: MouseEvent) => void
onPointerMove: (e: PointerEvent) => void
}
}
ComboboxParts from @llui/components/combobox
export interface ComboboxParts {
root: {
'data-scope': 'combobox'
'data-part': 'root'
'data-state': ReadSignal<'open' | 'closed'>
}
input: {
type: 'text'
role: 'combobox'
autocomplete: 'off'
'aria-autocomplete': 'list'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
'aria-activedescendant': ReadSignal<string | undefined>
'aria-disabled': ReadSignal<'true' | undefined>
id: string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'combobox'
'data-part': 'input'
onInput: (e: Event) => void
onKeyDown: (e: KeyboardEvent) => void
onFocus: (e: FocusEvent) => void
}
trigger: {
type: 'button'
'aria-label': string
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
tabindex: -1
'data-scope': 'combobox'
'data-part': 'trigger'
onClick: (e: MouseEvent) => void
}
positioner: {
'data-scope': 'combobox'
'data-part': 'positioner'
style: string
}
content: {
role: 'listbox'
id: string
'aria-labelledby': string
'aria-busy': ReadSignal<'true' | undefined>
tabindex: -1
'data-state': ReadSignal<'open' | 'closed'>
'data-status': ReadSignal<AsyncStatus>
/** The mutually-exclusive load projection (#265 finding 11) — see
* {@link LoadProjection}. Mirrors the top-level `loadState` signal. */
'data-load-state': ReadSignal<LoadProjection>
'data-scope': 'combobox'
'data-part': 'content'
}
/** The mutually-exclusive async load projection: `'initial-empty'` |
* `'loading'` | `'stale-results'` | `'success'` | `'error'`. A single
* signal instead of independent `isLoading`/`isEmpty`/`hasError` booleans,
* so it can never contradict itself. See {@link LoadProjection}. */
loadState: ReadSignal<LoadProjection>
/** Build the parts for an option by VALUE. The optional `index` is accepted
* for call-site convenience only — it is NOT used for identity (highlight,
* selection and ids are all value-keyed), so a reused row is never stale. */
item: (value: string, index?: number) => ComboboxItemParts
/** Parts for a labelled option group (`<optgroup>`-style section). Pass the
* group id; render the section element with `group` and its label element
* (referenced by `aria-labelledby`) with `groupLabel`. Group labels are not
* options, so navigation skips them automatically. Mirrors `select`. */
/**
* A labelled group's parts. The group names its `label` part through
* `aria-labelledby`; a consumer that renders the group WITHOUT that label
* passes `{ hasLabel: false }` so it never names an element that does not
* exist (an unlabelled `role="group"` is valid; a broken idref is not — the
* `dialog` `hasDescription` rule, #268). Default: true.
*/
group: (id: string, options?: { readonly hasLabel?: boolean }) => ComboboxGroupParts
/** Polite live region announcing the result count / error to screen readers
* as the async filter resolves. Render a visually-hidden element with these
* attributes and the `text` signal as its content. */
liveRegion: {
role: 'status'
'aria-live': 'polite'
'aria-atomic': 'true'
'data-scope': 'combobox'
'data-part': 'live-region'
text: ReadSignal<string>
}
empty: {
'data-scope': 'combobox'
'data-part': 'empty'
}
}
ComboboxState from @llui/components/combobox
export interface ComboboxState {
open: boolean
value: string[]
inputValue: string
items: string[]
groups: ComboboxGroup[]
disabledItems: string[]
filteredItems: string[]
/** The highlighted option's VALUE (not its index) in the FILTERED list.
* Value-based identity keeps the highlight pinned to the right option as the
* list is filtered/reordered and value-keyed rows are reused. May hold the
* create sentinel ({@link CREATE_OPTION_VALUE}) in creatable mode. */
highlightedValue: string | null
selectionMode: SelectionMode
disabled: boolean
allowCreate: boolean
status: AsyncStatus
requestId: number
error: string | null
}
ConnectOptions from @llui/components/combobox
export interface ConnectOptions {
id: string
triggerLabel?: string
}
OverlayOptions from @llui/components/combobox
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
state: ReadSignal<ComboboxState>
send: Send<ComboboxMsg>
parts: ComboboxParts
content: () => Renderable
/**
* Optional enter/leave transition for the combobox listbox (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, giving the raw-`open` combobox an exit
* animation for free. Omitted ⇒ the listbox closes synchronously as before.
*
* @example combobox.overlay({ state, send, parts, content, transition: fade({ duration: 120 }) })
*/
transition?: TransitionOptions
placement?: Placement
offset?: number
flip?: boolean
shift?: boolean
sameWidth?: boolean
target?: string | HTMLElement
}
Constants
combobox from @llui/components/combobox
const combobox
CREATE_OPTION_VALUE from @llui/components/combobox
Sentinel value used for the synthetic "create" option appended to
filteredItems in creatable mode. It is intentionally a value that no real
option will ever carry. Render it specially (the data-create part flag),
and treat a selection of it as a create request, not a normal pick.
const CREATE_OPTION_VALUE
@llui/components/hover-card
Functions
connect() from @llui/components/hover-card
function connect(state: ReadSignal<HoverCardState>, send: Send<HoverCardMsg>, opts: ConnectOptions): HoverCardParts
init() from @llui/components/hover-card
function init(opts: HoverCardInit = {}): HoverCardState
isMounted() from @llui/components/hover-card
Whether the hover-card node should be in the DOM — true through the exit animation.
function isMounted(state: HoverCardState): boolean
isPresent() from @llui/components/hover-card
Alias of {@link isMounted} — whether the hover-card is currently present in the DOM.
function isPresent(state: HoverCardState): boolean
overlay() from @llui/components/hover-card
function overlay(opts: OverlayOptions): Mountable
update() from @llui/components/hover-card
function update(state: HoverCardState, msg: HoverCardMsg): [HoverCardState, never[]]
Types
HoverCardMsg from @llui/components/hover-card
export type HoverCardMsg =
| { type: 'show' }
| { type: 'hide' }
| { type: 'setOpen'; open: boolean }
/** @humanOnly */
| { type: 'animationEnd' }
/** @humanOnly */
| { type: 'transitionEnd' }
Interfaces
ConnectOptions from @llui/components/hover-card
export interface ConnectOptions {
id: string
/** ms before showing on hover (default: 700). */
openDelay?: number
/** ms before hiding after pointer leaves (default: 300). */
closeDelay?: number
}
HoverCardInit from @llui/components/hover-card
export interface HoverCardInit {
open?: boolean
/** Skip enter/exit animations — hide unmounts synchronously (default: true). */
skipAnimations?: boolean
}
HoverCardParts from @llui/components/hover-card
export interface HoverCardParts {
trigger: {
id: string
'aria-controls': string
'aria-expanded': ReadSignal<boolean>
'data-state': ReadSignal<'open' | 'closed'>
'data-scope': 'hover-card'
'data-part': 'trigger'
onPointerEnter: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
onFocus: (e: FocusEvent) => void
onBlur: (e: FocusEvent) => void
}
positioner: {
'data-scope': 'hover-card'
'data-part': 'positioner'
style: string
}
content: {
id: string
'data-state': ReadSignal<PresenceStatus>
'data-scope': 'hover-card'
'data-part': 'content'
onPointerEnter: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
arrow: {
'data-scope': 'hover-card'
'data-part': 'arrow'
}
}
HoverCardState from @llui/components/hover-card
Hover card — richer tooltip-like popup triggered by hover or focus.
Unlike tooltip, it uses role="dialog" (not role="tooltip") and
allows interactive content. Content can be hovered without closing.
export interface HoverCardState {
open: boolean
/** Presence lifecycle — drives data-state and keeps the node mounted through exit animations. */
status: PresenceStatus
/** When true, hide transitions go straight to 'closed' (no exit-animation wait). */
skipAnimations: boolean
}
OverlayOptions from @llui/components/hover-card
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
state: ReadSignal<HoverCardState>
send: Send<HoverCardMsg>
parts: HoverCardParts
content: () => Renderable
/**
* Optional enter/leave transition for the hover-card content (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, so the close plays an exit animation.
* Keep `skipAnimations` at its default (true) when driving exits this way.
*
* @example hoverCard.overlay({ state, send, parts, content, transition: fade({ duration: 150 }) })
*/
transition?: TransitionOptions
placement?: Placement
offset?: number
flip?: boolean
shift?: boolean
target?: string | HTMLElement
/** Selector for an arrow rendered inside the content element. When present,
* floating positioning owns its absolute edge geometry transactionally. */
arrowSelector?: string
}
Constants
hoverCard from @llui/components/hover-card
const hoverCard
@llui/components/avatar
Functions
connect() from @llui/components/avatar
function connect(state: ReadSignal<AvatarState>, send: Send<AvatarMsg>, opts: ConnectOptions = {}): AvatarParts
init() from @llui/components/avatar
function init(opts: AvatarInit = {}): AvatarState
update() from @llui/components/avatar
function update(state: AvatarState, msg: AvatarMsg): [AvatarState, never[]]
Types
AvatarDensity from @llui/components/avatar
Optional density of the styled avatar root; behavior remains unchanged.
export type AvatarDensity = 'comfortable' | 'compact'
AvatarMsg from @llui/components/avatar
export type AvatarMsg =
/** @humanOnly */
| { type: 'loadStart' }
/** @humanOnly */
| { type: 'loaded' }
/** @humanOnly */
| { type: 'error' }
/** @intent("Reset the avatar's load status back to idle") */
| { type: 'reset' }
ImageStatus from @llui/components/avatar
Avatar — image with automatic fallback. Tracks image load status so consumers can render the image, a fallback (initials, icon), or a loading placeholder.
export type ImageStatus = 'idle' | 'loading' | 'loaded' | 'error'
Interfaces
AvatarInit from @llui/components/avatar
export interface AvatarInit {
status?: ImageStatus
}
AvatarParts from @llui/components/avatar
export interface AvatarParts {
root: {
'data-scope': 'avatar'
'data-part': 'root'
'data-status': ReadSignal<ImageStatus>
'data-density': AvatarDensity | undefined
}
image: {
'data-scope': 'avatar'
'data-part': 'image'
'data-status': ReadSignal<ImageStatus>
hidden: ReadSignal<boolean>
alt: string
onLoad: (e: Event) => void
onError: (e: Event) => void
onLoadStart: (e: Event) => void
}
fallback: {
'data-scope': 'avatar'
'data-part': 'fallback'
'data-status': ReadSignal<ImageStatus>
hidden: ReadSignal<boolean>
'aria-hidden': ReadSignal<'true' | undefined>
}
}
AvatarState from @llui/components/avatar
export interface AvatarState {
status: ImageStatus
}
ConnectOptions from @llui/components/avatar
export interface ConnectOptions {
alt?: string
/** Presentation density published on the root for either skin to consume. */
density?: AvatarDensity
}
Constants
avatar from @llui/components/avatar
const avatar
@llui/components/clipboard
Functions
connect() from @llui/components/clipboard
function connect(state: ReadSignal<ClipboardState>, send: Send<ClipboardMsg>, opts: ConnectOptions = {}): ClipboardParts
copyToClipboard() from @llui/components/clipboard
Attempt to copy the value to the clipboard. Returns a Promise that RESOLVES
on success and REJECTS when the write is refused. Dispatch copied from the
resolved branch only, and copyFailed from the rejected one — never copied
followed by reset, which would announce and then retract a success that
never happened.
function copyToClipboard(value: string): Promise<void>
init() from @llui/components/clipboard
function init(opts: ClipboardInit = {}): ClipboardState
update() from @llui/components/clipboard
function update(state: ClipboardState, msg: ClipboardMsg): [ClipboardState, never[]]
Types
ClipboardMsg from @llui/components/clipboard
export type ClipboardMsg =
/** @intent("Update the value to be copied") */
| { type: 'setValue'; value: string }
/** @intent("Initiate a clipboard copy of the current value") */
| { type: 'copy' }
/** @humanOnly */
| { type: 'copied' }
/** @humanOnly */
| { type: 'copyFailed' }
/** @intent("Clear the transient \"copied\" / \"failed\" feedback state") */
| { type: 'reset' }
Interfaces
ClipboardInit from @llui/components/clipboard
export interface ClipboardInit {
value?: string
}
ClipboardParts from @llui/components/clipboard
export interface ClipboardParts {
root: {
'data-scope': 'clipboard'
'data-part': 'root'
'data-copied': ReadSignal<'' | undefined>
'data-failed': ReadSignal<'' | undefined>
}
trigger: {
type: 'button'
'aria-label': string
'data-scope': 'clipboard'
'data-part': 'trigger'
'data-copied': ReadSignal<'' | undefined>
'data-failed': ReadSignal<'' | undefined>
onClick: (e: MouseEvent) => void
}
input: {
type: 'text'
readonly: true
value: ReadSignal<string>
'data-scope': 'clipboard'
'data-part': 'input'
onFocus: (e: FocusEvent) => void
}
indicator: {
'data-scope': 'clipboard'
'data-part': 'indicator'
'data-copied': ReadSignal<'' | undefined>
'data-failed': ReadSignal<'' | undefined>
'aria-live': 'polite'
}
}
ClipboardState from @llui/components/clipboard
Clipboard — copy-to-clipboard with transient "copied" feedback. The actual clipboard write is performed by the consumer via an effect (or inline in the trigger's onClick handler). Reducer tracks the success state flag.
copy is a REQUEST and does not set copied: the write can fail
(permission denied, insecure context, browser policy) and indicator
carries aria-live="polite", so a flag set before the promise resolves
ANNOUNCES a success that never happened (#232). Dispatch copied from
the resolved write, copyFailed from the rejected one, and reset to clear
the feedback:
onEffect(effect, send) {
copyToClipboard(effect.value).then(
() => send({ type: 'copied' }),
() => send({ type: 'copyFailed' }), // refused — say so, never "copied"
)
}
copyFailed publishes data-failed (root, trigger, indicator); the skin
tells the user the write was refused and that the value is still selectable
in the read-only input (#266).
export interface ClipboardState {
value: string
/** The last write RESOLVED. Exclusive with `failed`. */
copied: boolean
/**
* The last write was REFUSED (permission denied, insecure context, browser
* policy). Exclusive with `copied`. The read-only `input` is the fallback: the
* user can still select the value and copy it by hand, which is what a skin
* should say when this is set (#266).
*/
failed: boolean
}
ConnectOptions from @llui/components/clipboard
export interface ConnectOptions {
copyLabel?: string
/**
* Called from the trigger with the value to write, alongside the `copy`
* message. This is the seam for performing the write when the consumer does
* not route it through an effect; dispatch `copied` from its resolved
* promise, never from the call itself.
*/
onCopy?: (value: string) => void
}
Constants
clipboard from @llui/components/clipboard
const clipboard
@llui/components/editable
Functions
connect() from @llui/components/editable
function connect(state: ReadSignal<EditableState>, send: Send<EditableMsg>, opts: ConnectOptions = {}): EditableParts
init() from @llui/components/editable
function init(opts: EditableInit = {}): EditableState
update() from @llui/components/editable
function update(state: EditableState, msg: EditableMsg): [EditableState, never[]]
Types
EditableMsg from @llui/components/editable
export type EditableMsg =
/** @intent("Enter edit mode (seeds the draft from the current value)") */
| { type: 'edit' }
/** @intent("Update the in-progress draft as the user types") */
| { type: 'setDraft'; draft: string }
/** @intent("Commit the draft as the new value and exit edit mode") */
| { type: 'submit' }
/** @intent("Discard the draft and exit edit mode without changing the value") */
| { type: 'cancel' }
/** @intent("Set the value directly without going through edit mode") */
| { type: 'setValue'; value: string }
Interfaces
ConnectOptions from @llui/components/editable
export interface ConnectOptions {
/** Activate edit mode on preview focus (default: false — requires click). */
activateOnFocus?: boolean
/** Submit on blur (default: true). False = blur cancels. */
submitOnBlur?: boolean
/** Validate the draft text before committing. Non-empty array blocks submit. */
validate?: (value: string) => string[] | null
}
EditableInit from @llui/components/editable
export interface EditableInit {
value?: string
editing?: boolean
disabled?: boolean
}
EditableParts from @llui/components/editable
export interface EditableParts {
root: {
'data-scope': 'editable'
'data-part': 'root'
'data-editing': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
}
preview: {
tabindex: ReadSignal<number>
'aria-disabled': ReadSignal<'true' | undefined>
'data-scope': 'editable'
'data-part': 'preview'
hidden: ReadSignal<boolean>
onClick: (e: MouseEvent) => void
onFocus: (e: FocusEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
input: {
'data-scope': 'editable'
'data-part': 'input'
hidden: ReadSignal<boolean>
value: ReadSignal<string>
disabled: ReadSignal<boolean>
onInput: (e: Event) => void
onKeyDown: (e: KeyboardEvent) => void
onBlur: (e: FocusEvent) => void
}
submitTrigger: {
type: 'button'
/** Disabled with the editable: a disabled instance ignores the message
* this trigger sends, so an enabled trigger would be a dead control
* (#268 audit). */
disabled: ReadSignal<boolean>
'data-scope': 'editable'
'data-part': 'submit-trigger'
onClick: (e: MouseEvent) => void
}
cancelTrigger: {
type: 'button'
/** Disabled with the editable: a disabled instance ignores the message
* this trigger sends, so an enabled trigger would be a dead control
* (#268 audit). */
disabled: ReadSignal<boolean>
'data-scope': 'editable'
'data-part': 'cancel-trigger'
onClick: (e: MouseEvent) => void
}
editTrigger: {
type: 'button'
/** Disabled with the editable: a disabled instance ignores the message
* this trigger sends, so an enabled trigger would be a dead control
* (#268 audit). */
disabled: ReadSignal<boolean>
'data-scope': 'editable'
'data-part': 'edit-trigger'
onClick: (e: MouseEvent) => void
}
}
EditableState from @llui/components/editable
Editable — inline text editor. Click preview to enter edit mode, Enter
to commit, Escape to cancel. Reports the committed value via onSubmit.
export interface EditableState {
value: string
editing: boolean
draft: string
disabled: boolean
}
Constants
editable from @llui/components/editable
const editable
@llui/components/tags-input
Functions
connect() from @llui/components/tags-input
function connect(state: ReadSignal<TagsInputState>, send: Send<TagsInputMsg>, opts: ConnectOptions = {}): TagsInputParts
init() from @llui/components/tags-input
function init(opts: TagsInputInit = {}): TagsInputState
update() from @llui/components/tags-input
function update(state: TagsInputState, msg: TagsInputMsg): [TagsInputState, never[]]
Types
TagsInputMsg from @llui/components/tags-input
export type TagsInputMsg =
/** @intent("Update the in-progress text in the input field") */
| { type: 'setInput'; value: string }
/** @intent("Commit a new tag (defaults to the current input value)") */
| { type: 'addTag'; value?: string }
/** @intent("Remove the tag at the given index") */
| { type: 'removeTag'; index: number }
/** @intent("Remove the last tag (typically backspace on empty input)") */
| { type: 'removeLast' }
/** @intent("Replace the full tag list with the provided values") */
| { type: 'setValue'; value: string[] }
/** @humanOnly */
| { type: 'focusTag'; index: number | null }
/** @humanOnly */
| { type: 'focusTagNext' }
/** @humanOnly */
| { type: 'focusTagPrev' }
/** @intent("Remove every tag and reset the input") */
| { type: 'clearAll' }
Interfaces
ConnectOptions from @llui/components/tags-input
export interface ConnectOptions {
inputLabel?: string
removeLabel?: string
clearLabel?: string
/** Characters that commit the current input as a tag (default: [',']). */
delimiters?: string[]
/** Commit on blur (default: true). */
commitOnBlur?: boolean
/** Validate a tag value before adding. Non-empty array blocks addTag. */
validate?: (value: string) => string[] | null
}
TagItemParts from @llui/components/tags-input
export interface TagItemParts {
root: {
tabindex: ReadSignal<number>
'data-scope': 'tags-input'
'data-part': 'tag'
'data-value': string
'data-index': string
'data-focused': ReadSignal<'' | undefined>
onKeyDown: (e: KeyboardEvent) => void
onFocus: (e: FocusEvent) => void
}
remove: {
type: 'button'
'aria-label': string
tabindex: -1
'data-scope': 'tags-input'
'data-part': 'tag-remove'
onClick: (e: MouseEvent) => void
}
}
TagsInputInit from @llui/components/tags-input
export interface TagsInputInit {
value?: string[]
inputValue?: string
disabled?: boolean
max?: number
unique?: boolean
}
TagsInputParts from @llui/components/tags-input
export interface TagsInputParts {
root: {
role: 'group'
'aria-disabled': ReadSignal<'true' | undefined>
'data-scope': 'tags-input'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
}
input: {
type: 'text'
autocomplete: 'off'
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'tags-input'
'data-part': 'input'
onInput: (e: Event) => void
onKeyDown: (e: KeyboardEvent) => void
onBlur: (e: FocusEvent) => void
}
tag: (value: string, index: number) => TagItemParts
clearTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'tags-input'
'data-part': 'clear-trigger'
onClick: (e: MouseEvent) => void
}
}
TagsInputState from @llui/components/tags-input
Tags input — text input that creates chips (tags) on commit keys (Enter, comma, blur). Backspace on empty input removes the last tag. Each tag is focusable via arrow keys.
export interface TagsInputState {
value: string[]
inputValue: string
disabled: boolean
/** Maximum tag count. 0 = unlimited. */
max: number
/** Only allow unique values. */
unique: boolean
/** Currently-focused tag index, or null. */
focusedIndex: number | null
}
Constants
tagsInput from @llui/components/tags-input
const tagsInput
@llui/components/splitter
Functions
connect() from @llui/components/splitter
function connect(state: ReadSignal<SplitterState>, send: Send<SplitterMsg>): SplitterParts
init() from @llui/components/splitter
function init(opts: SplitterInit = {}): SplitterState
positionFromPoint() from @llui/components/splitter
Compute position percentage from a pointer event within a container rect.
function positionFromPoint(state: SplitterState, rect: DOMRect, clientX: number, clientY: number): number
update() from @llui/components/splitter
function update(state: SplitterState, msg: SplitterMsg): [SplitterState, never[]]
Types
Orientation from @llui/components/splitter
Splitter — resizable panes with a draggable handle. The handle's position is expressed as a percentage of the container, stored as a number 0..100. Supports keyboard arrow resize with a configurable step.
export type Orientation = 'horizontal' | 'vertical'
SplitterMsg from @llui/components/splitter
export type SplitterMsg =
/** @intent("Set the splitter handle position (0–100, clamped to min/max)") */
| { type: 'setPosition'; position: number }
/** @intent("Move the handle by step (or step × multiplier) toward max") */
| { type: 'increment'; multiplier?: number }
/** @intent("Move the handle by step (or step × multiplier) toward min") */
| { type: 'decrement'; multiplier?: number }
/** @intent("Snap the handle to its minimum position") */
| { type: 'toMin' }
/** @intent("Snap the handle to its maximum position") */
| { type: 'toMax' }
/** @humanOnly */
| { type: 'startDrag' }
/** @humanOnly */
| { type: 'endDrag' }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
Interfaces
SplitterInit from @llui/components/splitter
export interface SplitterInit {
position?: number
min?: number
max?: number
step?: number
orientation?: Orientation
disabled?: boolean
dir?: 'ltr' | 'rtl'
}
SplitterParts from @llui/components/splitter
export interface SplitterParts {
/**
* A layout container: its panels hold the CONSUMER's content. It carries no
* disabled state — the resize trigger is the control and publishes its own
* (`aria-disabled`, out of the tab order). A root `data-disabled` made every
* disabled-state stylesheet dim the panels with it, taking ordinary text to
* 3.69:1 (#268 audit).
*/
root: {
'data-scope': 'splitter'
'data-part': 'root'
'data-orientation': ReadSignal<Orientation>
'data-dragging': ReadSignal<'' | undefined>
}
primaryPanel: {
'data-scope': 'splitter'
'data-part': 'primary-panel'
style: ReadSignal<string>
}
secondaryPanel: {
'data-scope': 'splitter'
'data-part': 'secondary-panel'
style: ReadSignal<string>
}
resizeTrigger: {
role: 'separator'
'aria-orientation': ReadSignal<Orientation>
'aria-valuemin': ReadSignal<number>
'aria-valuemax': ReadSignal<number>
'aria-valuenow': ReadSignal<number>
'aria-disabled': ReadSignal<'true' | undefined>
'data-scope': 'splitter'
'data-part': 'resize-trigger'
'data-orientation': ReadSignal<Orientation>
tabindex: ReadSignal<number>
onKeyDown: (e: KeyboardEvent) => void
onPointerDown: (e: PointerEvent) => void
}
}
SplitterState from @llui/components/splitter
export interface SplitterState {
position: number
min: number
max: number
step: number
orientation: Orientation
disabled: boolean
dragging: boolean
/** Reading direction. Under 'rtl' horizontal arrow keys are flipped. */
dir: 'ltr' | 'rtl'
}
Constants
splitter from @llui/components/splitter
const splitter
@llui/components/file-upload
Functions
acceptToString() from @llui/components/file-upload
Serialize an AcceptValue into a comma-joined string suitable for the
HTML accept attribute. Both MIME types and extensions are emitted.
function acceptToString(accept: AcceptValue): string
connect() from @llui/components/file-upload
function connect(state: ReadSignal<FileUploadState>, send: Send<FileUploadMsg>, opts: ConnectOptions): FileUploadParts
effectiveMaxFiles() from @llui/components/file-upload
The count limit actually in force: a single-select uploader accepts exactly
one file whatever maxFiles says. Without this, dropping three files on a
multiple: false zone accepted all three with zero rejections (#119).
function effectiveMaxFiles(state: FileUploadState): number
fileMatchesAccept() from @llui/components/file-upload
Check whether a file matches the accept configuration. Raw-string accept is passed through to the browser picker so we always return true here; MIME-object accept is validated by checking MIME type (with wildcards) and extension membership.
function fileMatchesAccept(file: FileLike, accept: AcceptValue): boolean
getFile() from @llui/components/file-upload
The live handle for a tracked file, or undefined once released.
function getFile(ref: FileMeta | string): File | undefined
init() from @llui/components/file-upload
function init(opts: FileUploadInit = {}): FileUploadState
preventDocumentDrop() from @llui/components/file-upload
Install a document-level dragover/drop blocker. Without this, dragging a file outside the dropzone causes the browser to navigate away from the page. Call from onMount and invoke the returned disposer on unmount.
function preventDocumentDrop(): () => void
releaseAllFiles() from @llui/components/file-upload
Release every handle state references. What a component owes at unmount.
function releaseAllFiles(state: FileUploadState): void
releaseDropped() from @llui/components/file-upload
Release the handles prev referenced and next does not. Pure bookkeeping
over two states, so it covers every transition that drops a file without
naming it (single-select replacement, a rejected list overwritten by the next
selection).
It CANNOT cover a message whose handles never reached state at all: it only
sees ids referenced by prev, and a gated addFiles was tracked before the
send. Those are releaseUnlanded's job — read it before assuming this one
function is enough (#138 review).
function releaseDropped(prev: FileUploadState, next: FileUploadState): void
releaseFile() from @llui/components/file-upload
function releaseFile(ref: FileMeta | string): void
releaseFiles() from @llui/components/file-upload
function releaseFiles(refs: readonly (FileMeta | string)[]): void
releaseUnlanded() from @llui/components/file-upload
Release the handles msg carried that next does not reference. A disabled
or readonly gate returns the state unchanged, so the files a drop tracked
are unreachable from any state the moment update returns — releaseDropped
cannot see them, and they leaked live File objects (and the blobs behind
them) for the lifetime of the page (#138 review, blocking 1).
function releaseUnlanded(msg: FileUploadMsg, next: FileUploadState): void
totalSize() from @llui/components/file-upload
function totalSize(state: FileUploadState): number
trackedFileCount() from @llui/components/file-upload
How many live handles the registry currently holds. A leak probe: a handle no State references any more is invisible from every other angle, which is exactly the shape a leak takes here.
function trackedFileCount(): number
trackFile() from @llui/components/file-upload
Register a live File and return the serializable record that goes in State.
function trackFile(file: File): FileMeta
trackFiles() from @llui/components/file-upload
function trackFiles(files: readonly File[]): FileMeta[]
update() from @llui/components/file-upload
function update(state: FileUploadState, msg: FileUploadMsg): [FileUploadState, never[]]
uploadOf() from @llui/components/file-upload
The upload entry for a file id, or undefined when it has not started.
function uploadOf(state: FileUploadState, id: string): FileUploadProgress | undefined
validateFiles() from @llui/components/file-upload
Partition incoming files into accepted and rejected based on state's accept/size/count constraints. The current accepted-file count is used to enforce the count limit — the caller is responsible for passing the post-combine accepted total when appending.
function validateFiles(incoming: FileMeta[], state: FileUploadState, existingAcceptedCount: number): { accepted: FileMeta[]; rejected: RejectedFile[] }
Types
AcceptValue from @llui/components/file-upload
File upload — input element + drag-and-drop zone. Tracks selected files, drag state, accept filters, validation errors. Multiple or single selection.
accept can be either a raw HTML-accept string ("image/*,.pdf") or a
MIME-object ({ 'image/*': ['.png', '.jpg'], 'application/pdf': [] }).
The object form is validated client-side per file; the raw string form
only drives the browser's native picker filter.
Files that fail validation (too large, too small, wrong type, over the
count limit) flow into rejectedFiles with a list of FileError codes
attached. The view can render them alongside accepted files.
State holds FileMeta records — plain JSON — never the live File
objects: State must be JSON-serializable (CLAUDE.md), and a File came
back from a round-trip as {}, wiping every name and turning totalSize
into NaN (#119). The handles live in a module-scoped registry keyed by
FileMeta.id; see trackFile/getFile/releaseDropped.
A restored State has no handles. Serializability is exactly what makes
that so: SSR hydration, replayTrace and an agent state snapshot carry the
FileMeta records and nothing else, so after a restore getFile() returns
undefined for every one of them. That is correct and unavoidable — a File
cannot cross the wire — but it is invisible unless you are told, so a view
must treat a missing handle as normal (render the metadata, skip the object-URL
preview) and a re-upload needs a fresh selection from the user.
export type AcceptValue = string | Record<string, string[]>
FileError from @llui/components/file-upload
export type FileError =
| { code: 'TOO_LARGE'; max: number }
| { code: 'TOO_SMALL'; min: number }
| { code: 'INVALID_TYPE' }
| { code: 'TOO_MANY'; max: number }
| { code: 'CUSTOM'; message: string }
FileUploadMsg from @llui/components/file-upload
export type FileUploadMsg =
/** @humanOnly */
| { type: 'setFiles'; files: FileMeta[]; customRejected?: RejectedFile[] }
/** @humanOnly */
| { type: 'addFiles'; files: FileMeta[]; customRejected?: RejectedFile[] }
/** @intent("Remove the accepted file at the given index") */
| { type: 'removeFile'; index: number }
/** @intent("Remove the rejected file at the given index") */
| { type: 'removeRejected'; index: number }
/** @intent("Clear all accepted files") */
| { type: 'clear' }
/** @intent("Clear the rejected-files list") */
| { type: 'clearRejected' }
/** @humanOnly */
| { type: 'dragEnter' }
/** @humanOnly */
| { type: 'dragLeave' }
/** @humanOnly */
| { type: 'drop' }
/** @humanOnly */
| { type: 'setInvalid'; invalid: boolean }
/** @humanOnly */
| { type: 'uploadProgress'; id: string; progress: number }
/** @humanOnly */
| { type: 'uploadSucceeded'; id: string }
/** @humanOnly */
| { type: 'uploadFailed'; id: string; error: string }
/** @intent("Retry the failed upload of the file with the given id") */
| { type: 'retryUpload'; id: string }
FileUploadStatus from @llui/components/file-upload
export type FileUploadStatus = 'uploading' | 'done' | 'error'
Interfaces
ConnectOptions from @llui/components/file-upload
export interface ConnectOptions {
id: string
removeLabel?: string
retryLabel?: string
progressLabel?: string
clearLabel?: string
/**
* Hints the browser to use the device camera/microphone for capture. Only
* applies to mobile. Pass `'user'` for the front camera, `'environment'`
* for the back, or `true` to accept either.
*/
capture?: 'user' | 'environment' | boolean
/** Show a directory-picker instead of a file-picker (webkit only). */
directory?: boolean
/**
* Per-file synchronous validator. Return a non-empty array of `FileError`
* codes to reject the file, or null/empty to accept. Runs in addition to
* the state-driven accept/size/count checks — its errors accumulate into
* `rejectedFiles` alongside the built-in errors.
*/
validate?: (file: File) => FileError[] | null
/**
* Optional transform pipeline. Runs before validation. Can return a
* Promise; onChange awaits it before dispatching. Use for image resizing,
* format conversion, etc.
*/
transformFiles?: (files: File[]) => File[] | Promise<File[]>
}
FileLike from @llui/components/file-upload
Everything fileMatchesAccept needs — satisfied by both File and FileMeta.
export interface FileLike {
name: string
type: string
}
FileMeta from @llui/components/file-upload
The serializable half of a selected file. id is the registry key for the
live handle; the rest mirrors the File fields a view needs.
export interface FileMeta {
id: string
name: string
size: number
type: string
lastModified: number
}
FileUploadInit from @llui/components/file-upload
export interface FileUploadInit {
files?: FileMeta[]
disabled?: boolean
multiple?: boolean
accept?: AcceptValue
maxFiles?: number
maxSize?: number
minFileSize?: number
required?: boolean
readonly?: boolean
invalid?: boolean
}
FileUploadItemParts from @llui/components/file-upload
export interface FileUploadItemParts {
item: {
'data-scope': 'file-upload'
'data-part': 'item'
'data-index': string
/** The file's upload status; absent until an upload is reported (#266). */
'data-upload-status': ReadSignal<FileUploadStatus | undefined>
}
/** A labelled progressbar, shown only while the file is uploading (#266). */
itemProgress: {
role: 'progressbar'
'aria-label': string
'aria-valuemin': 0
'aria-valuemax': 100
'aria-valuenow': ReadSignal<number | undefined>
hidden: ReadSignal<boolean>
'data-scope': 'file-upload'
'data-part': 'item-progress'
}
/** The progress fill; its width is written inline as a percentage. */
itemProgressRange: {
'data-scope': 'file-upload'
'data-part': 'item-progress-range'
style: ReadSignal<string>
}
/**
* Live region for the file's failure message, shown only on error. Its text
* is `uploadOf(state, id)?.error` — the view renders it as a child.
*/
itemErrorText: {
role: 'alert'
hidden: ReadSignal<boolean>
'data-scope': 'file-upload'
'data-part': 'item-error-text'
}
/** Shown only for a failed upload; dispatches `retryUpload` for this file. */
itemRetryTrigger: {
type: 'button'
'aria-label': string
hidden: ReadSignal<boolean>
'data-scope': 'file-upload'
'data-part': 'item-retry-trigger'
onClick: (e: MouseEvent) => void
}
itemName: {
'data-scope': 'file-upload'
'data-part': 'item-name'
}
itemSizeText: {
'data-scope': 'file-upload'
'data-part': 'item-size-text'
}
itemPreview: {
'data-scope': 'file-upload'
'data-part': 'item-preview'
}
removeTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'file-upload'
'data-part': 'item-remove'
onClick: (e: MouseEvent) => void
}
/** Zag-aligned alias for removeTrigger. Same wiring. */
itemDeleteTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'file-upload'
'data-part': 'item-delete-trigger'
onClick: (e: MouseEvent) => void
}
}
FileUploadParts from @llui/components/file-upload
export interface FileUploadParts {
root: {
'data-scope': 'file-upload'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
'aria-disabled': ReadSignal<'true' | undefined>
'data-dragging': ReadSignal<'' | undefined>
'data-invalid': ReadSignal<'' | undefined>
'data-readonly': ReadSignal<'' | undefined>
/** Present while any accepted file is uploading (#266). */
'data-uploading': ReadSignal<'' | undefined>
}
dropzone: {
'data-scope': 'file-upload'
'data-part': 'dropzone'
'data-dragging': ReadSignal<'' | undefined>
onClick: (e: MouseEvent) => void
onDragEnter: (e: DragEvent) => void
onDragOver: (e: DragEvent) => void
onDragLeave: (e: DragEvent) => void
onDrop: (e: DragEvent) => void
}
trigger: {
type: 'button'
'data-scope': 'file-upload'
'data-part': 'trigger'
disabled: ReadSignal<boolean>
onClick: (e: MouseEvent) => void
}
hiddenInput: {
type: 'file'
tabindex: -1
'aria-hidden': 'true'
style: string
disabled: ReadSignal<boolean>
multiple: ReadSignal<boolean>
accept: ReadSignal<string>
required: ReadSignal<boolean>
'aria-invalid': ReadSignal<'true' | undefined>
capture?: string | boolean
webkitdirectory?: '' | undefined
'data-scope': 'file-upload'
'data-part': 'hidden-input'
id: string
onChange: (e: Event) => void
}
label: {
for: string
'data-scope': 'file-upload'
'data-part': 'label'
}
clearTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'file-upload'
'data-part': 'clear-trigger'
onClick: (e: MouseEvent) => void
}
itemGroup: {
'data-scope': 'file-upload'
'data-part': 'item-group'
}
item: (index: number) => FileUploadItemParts
}
FileUploadProgress from @llui/components/file-upload
export interface FileUploadProgress {
status: FileUploadStatus
/** 0..1 */
progress: number
/** The failure message while `status === 'error'`, else `null`. */
error: string | null
}
FileUploadState from @llui/components/file-upload
export interface FileUploadState {
files: FileMeta[]
rejectedFiles: RejectedFile[]
disabled: boolean
multiple: boolean
accept: AcceptValue
maxFiles: number
maxSize: number
minFileSize: number
required: boolean
readonly: boolean
invalid: boolean
/** `dragDepth > 0`, materialized so views bind one boolean. */
dragging: boolean
/**
* Nesting depth of the in-flight drag. `dragenter`/`dragleave` both bubble
* and fire in the order enter@child → leave@parent, so a plain boolean flips
* off while the pointer is still inside the dropzone (#119).
*/
dragDepth: number
/**
* Upload lifecycle per accepted file, keyed by `FileMeta.id` — never by
* index, which shifts when an earlier file is removed. A file with no entry
* has not started uploading. The machine does not upload: the consumer's
* effect does, and reports back with `uploadProgress` / `uploadSucceeded` /
* `uploadFailed`; `retryUpload` is the request to try again (#266).
*/
uploads: Record<string, FileUploadProgress>
}
RejectedFile from @llui/components/file-upload
export interface RejectedFile {
file: FileMeta
errors: FileError[]
}
Constants
fileUpload from @llui/components/file-upload
const fileUpload
@llui/components/tree-view
Functions
connect() from @llui/components/tree-view
function connect(state: ReadSignal<TreeViewState>, send: Send<TreeViewMsg>, opts: ConnectOptions): TreeViewParts
init() from @llui/components/tree-view
function init(opts: TreeViewInit = {}): TreeViewState
isChecked() from @llui/components/tree-view
function isChecked(state: TreeViewState, id: string): boolean
isExpanded() from @llui/components/tree-view
function isExpanded(state: TreeViewState, id: string): boolean
isIndeterminate() from @llui/components/tree-view
function isIndeterminate(state: TreeViewState, id: string): boolean
isLoaded() from @llui/components/tree-view
function isLoaded(state: TreeViewState, id: string): boolean
isLoadFailed() from @llui/components/tree-view
function isLoadFailed(state: TreeViewState, id: string): boolean
isLoading() from @llui/components/tree-view
function isLoading(state: TreeViewState, id: string): boolean
isRenaming() from @llui/components/tree-view
function isRenaming(state: TreeViewState, id: string): boolean
isSelected() from @llui/components/tree-view
function isSelected(state: TreeViewState, id: string): boolean
update() from @llui/components/tree-view
function update(state: TreeViewState, msg: TreeViewMsg): [TreeViewState, TreeViewEffect[]]
Types
SelectionMode from @llui/components/tree-view
Tree view — hierarchical list with expand/collapse. Items are identified by opaque string ids; the tree structure (children relationship) is provided externally. The machine tracks which branches are expanded, which items are selected, and which item has keyboard focus.
export type SelectionMode = 'single' | 'multiple' | 'checkbox'
TreeViewEffect from @llui/components/tree-view
Effects emitted by the tree-view machine for the consumer's onEffect.
export type TreeViewEffect =
/** Fetch the children of `id` lazily, then reply with `childrenLoaded`/`childrenLoadFailed`. */
{ type: 'loadChildren'; id: string }
TreeViewMsg from @llui/components/tree-view
export type TreeViewMsg =
/** @intent("Toggle the branch with the given id expanded/collapsed") */
| { type: 'toggleBranch'; id: string }
/** @intent("Expand the branch with the given id") */
| { type: 'expand'; id: string }
/** @intent("Collapse the branch with the given id") */
| { type: 'collapse'; id: string }
/** @intent("Expand every branch in the provided id list") */
| { type: 'expandAll'; ids: string[] }
/** @intent("Collapse every expanded branch") */
| { type: 'collapseAll' }
/** @intent("Select the item with the given id (additive=true extends multi-selection)") */
| { type: 'select'; id: string; additive?: boolean }
/** @intent("Replace the selected-id set with the provided list") */
| { type: 'setSelected'; ids: string[] }
/** @humanOnly */
| { type: 'focus'; id: string | null }
/** @humanOnly */
| { type: 'focusNext' }
/** @humanOnly */
| { type: 'focusPrev' }
/** @humanOnly */
| { type: 'focusFirst' }
/** @humanOnly */
| { type: 'focusLast' }
/** @humanOnly */
| { type: 'setVisibleItems'; ids: string[]; labels?: string[] }
/** @humanOnly */
| { type: 'typeahead'; char: string; now: number }
/** @humanOnly */
| { type: 'arrowLeftFrom'; id: string; isBranch: boolean; parentId: string | null }
/** @humanOnly */
| { type: 'arrowRightFrom'; id: string }
/** @intent("Toggle the checkbox on the item with the given id (descendantIds drives recursive check)") */
| { type: 'toggleChecked'; id: string; descendantIds?: string[] }
/** @intent("Replace the checked-id set with the provided list") */
| { type: 'setChecked'; ids: string[] }
/** @humanOnly */
| { type: 'setIndeterminate'; ids: string[] }
/** @intent("Begin renaming the item with the given id (seeds the rename input with `initial`)") */
| { type: 'renameStart'; id: string; initial: string }
/** @intent("Update the rename draft as the user types") */
| { type: 'renameChange'; value: string }
/** @intent("Commit the in-progress rename (clears the rename state)") */
| { type: 'renameCommit' }
/** @intent("Cancel the in-progress rename without applying changes") */
| { type: 'renameCancel' }
/** @intent("Mark the branch with the given id as loading children (typically before an async fetch)") */
| { type: 'loadingStart'; id: string }
/** @intent("Clear the loading state for the given branch id (after async fetch completes)") */
| { type: 'loadingEnd'; id: string }
/** @intent("Replace the whole tree structure (adjacency record + root ids)") */
| { type: 'setNodes'; nodes: Record<string, TreeNodeMeta>; roots: string[] }
/** @intent("Supply the lazily-loaded children of branch `id` (clears loading, marks loaded)") */
| { type: 'childrenLoaded'; id: string; items: TreeNodeInput[] }
/** @intent("Report that the lazy load of branch `id` failed (allows retry on re-expand)") */
| { type: 'childrenLoadFailed'; id: string }
Interfaces
ConnectOptions from @llui/components/tree-view
export interface ConnectOptions {
id: string
/**
* If true, clicking anywhere on a branch item (not just the disclosure
* caret) toggles its expanded state. Default: false — clicks on the row
* select it without toggling, consistent with most file-tree UIs.
*/
expandOnClick?: boolean
}
TreeItemParts from @llui/components/tree-view
export interface TreeItemParts {
item: {
role: 'treeitem'
id: string
'aria-expanded': ReadSignal<boolean | undefined>
'aria-selected': ReadSignal<boolean | undefined>
'aria-level': number
'aria-busy': ReadSignal<'true' | undefined>
tabindex: ReadSignal<number>
'data-scope': 'tree-view'
'data-part': 'item'
'data-value': string
'data-depth': string
'data-selected': ReadSignal<'' | undefined>
'data-focused': ReadSignal<'' | undefined>
'data-loading': ReadSignal<'' | undefined>
'data-load-failed': ReadSignal<'' | undefined>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
onFocus: (e: FocusEvent) => void
}
/** For branch items — expand/collapse disclosure trigger. */
branchTrigger: {
'data-scope': 'tree-view'
'data-part': 'branch-trigger'
'data-state': ReadSignal<'open' | 'closed'>
onClick: (e: MouseEvent) => void
}
/**
* Checkbox element (only meaningful when `selectionMode === 'checkbox'`).
* `aria-checked` is the tri-state string ('true' | 'false' | 'mixed').
* The consumer must render a checkbox input or a visual proxy and
* dispatch `toggleChecked` via the `onClick` binding. For branches,
* pass the branch's descendant ids via `descendantIds` on the message
* so children are propagated in a single reducer step.
*/
checkbox: {
role: 'checkbox'
'aria-checked': ReadSignal<'true' | 'false' | 'mixed'>
'data-scope': 'tree-view'
'data-part': 'checkbox'
'data-state': ReadSignal<'checked' | 'unchecked' | 'indeterminate'>
}
}
TreeNodeInput from @llui/components/tree-view
Shape of a lazily-loaded child handed back via childrenLoaded.
export interface TreeNodeInput {
id: string
/** Eagerly-known children of this freshly-loaded node, if any. */
children?: string[]
disabled?: boolean
/** Mark this freshly-loaded node as itself lazily-loadable. */
hasChildren?: boolean
}
TreeNodeMeta from @llui/components/tree-view
JSON-serializable adjacency entry for one tree node. The reducer owns the
tree structure (as a flat record) so it can traverse descendants/ancestors
for automatic indeterminate derivation and lazy-load bookkeeping without
any external collection. Build the record from a {@link TreeCollection} or
by hand; seed it via init({ nodes, roots }) or the setNodes message.
export interface TreeNodeMeta {
/** Ordered ids of this node's loaded children (empty until loaded). */
children: string[]
/** Parent id, or null for a root. */
parentId: string | null
/** When true, descendant cascade and checked-derivation skip this node. */
disabled?: boolean
/**
* Declares the node as a branch whose children are loaded lazily. Expanding
* a `hasChildren` node that has not yet been loaded emits a `loadChildren`
* effect; the consumer fetches and replies with `childrenLoaded`.
*/
hasChildren?: boolean
}
TreeViewInit from @llui/components/tree-view
export interface TreeViewInit {
expanded?: string[]
selected?: string[]
checked?: string[]
indeterminate?: string[]
selectionMode?: SelectionMode
disabled?: boolean
visibleItems?: string[]
visibleLabels?: string[]
nodes?: Record<string, TreeNodeMeta>
roots?: string[]
loaded?: string[]
loadFailed?: string[]
}
TreeViewParts from @llui/components/tree-view
export interface TreeViewParts {
root: {
role: 'tree'
'aria-multiselectable': ReadSignal<'true' | undefined>
'aria-disabled': ReadSignal<'true' | undefined>
'data-scope': 'tree-view'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
}
item: (id: string, depth: number, isBranch: boolean, parentId?: string | null) => TreeItemParts
}
TreeViewState from @llui/components/tree-view
export interface TreeViewState {
/** Ids of expanded branches. */
expanded: string[]
/** Ids of selected items. */
selected: string[]
/** Ids of checked items (checkbox selection mode). */
checked: string[]
/** Ids known to be in the indeterminate tri-state (some-but-not-all
* descendants checked). Consumer-computed via propagation logic or the
* `toggleChecked` message's `descendantIds` parameter. */
indeterminate: string[]
/** Currently focused item id. */
focused: string | null
selectionMode: SelectionMode
/** Ordered list of currently-visible item ids (updated by consumer via setVisible). */
visibleItems: string[]
/** Parallel array of visible-item labels for typeahead. If empty, typeahead
* matches against ids directly. Updated alongside visibleItems via the
* optional `labels` field on `setVisibleItems`. */
visibleLabels: string[]
disabled: boolean
/** Typeahead accumulator buffer. */
typeahead: string
typeaheadExpiresAt: number
/** Id of item currently being renamed, or null. */
renaming: string | null
/** Draft value during rename. */
renameDraft: string
/**
* Ids of branches currently loading their children asynchronously. Item
* parts expose `aria-busy` while loading so assistive tech announces the
* in-progress state. This is now driven by the machine itself: expanding a
* `hasChildren` node sets `loading` and emits a `loadChildren` effect; the
* `childrenLoaded` / `childrenLoadFailed` replies clear it. The legacy
* `loadingStart` / `loadingEnd` messages remain for manual control.
*/
loading: string[]
/**
* Flat tree structure (adjacency record). Owned by the reducer for
* descendant/ancestor traversal. JSON-serializable.
*/
nodes: Record<string, TreeNodeMeta>
/** Ids of the top-level (root) nodes, in order. */
roots: string[]
/**
* Ids of branches whose children have been loaded (distinguishes a
* loaded-but-empty branch from a not-yet-fetched one so we never refetch).
*/
loaded: string[]
/**
* Ids of branches whose last lazy load failed. Re-expanding such a branch
* retries the load (clears the flag and re-emits `loadChildren`).
*/
loadFailed: string[]
}
Constants
treeView from @llui/components/tree-view
const treeView
@llui/components/context-menu
Functions
connect() from @llui/components/context-menu
function connect(state: ReadSignal<ContextMenuState>, send: Send<ContextMenuMsg>, opts: ConnectOptions): ContextMenuParts
init() from @llui/components/context-menu
function init(opts: ContextMenuInit = {}): ContextMenuState
isPresent() from @llui/components/context-menu
Whether the root content should be in the DOM. True for every status except
'closed' — so the content stays mounted through the exit animation. Falls back
to open when a consumer drives open directly without advancing status
(backward-compatible with open-driven callers that predate the presence
lifecycle): an open menu is present even if status still reads 'closed'.
function isPresent(state: ContextMenuState): boolean
overlay() from @llui/components/context-menu
function overlay(opts: OverlayOptions): Mountable
subOverlay() from @llui/components/context-menu
Engine-owned floating overlay for one submenu level (#265 A4), positioned
beside its subTrigger. See
menu-machine.ts:subOverlay's doc comment for the full contract.
function subOverlay(opts: SubOverlayOptions): Mountable
update() from @llui/components/context-menu
function update(state: ContextMenuState, msg: ContextMenuMsg): [ContextMenuState, never[]]
Types
ContextMenuCheckItemParts from @llui/components/context-menu
export type ContextMenuCheckItemParts = MenuCheckItemPartsOf<'context-menu'>
ContextMenuGroupParts from @llui/components/context-menu
export type ContextMenuGroupParts = MenuGroupPartsOf<'context-menu'>
ContextMenuItem from @llui/components/context-menu
A single node in the context-menu item tree (JSON-serializable). Shared with
menu via the {@link MenuNode} machine type.
export type ContextMenuItem = MenuNode
ContextMenuItemKind from @llui/components/context-menu
Kind of a context-menu item.
export type ContextMenuItemKind = MenuNodeKind
ContextMenuItemParts from @llui/components/context-menu
export type ContextMenuItemParts = MenuItemPartsOf<'context-menu'>
ContextMenuMsg from @llui/components/context-menu
export type ContextMenuMsg =
/** @humanOnly */
| { type: 'openAt'; x: number; y: number }
/** @intent("Close the context menu") */
| { type: 'close' }
/** @humanOnly */
| { type: 'highlight'; level: string; value: string | null }
/** @humanOnly */
| { type: 'highlightNext'; level: string }
/** @humanOnly */
| { type: 'highlightPrev'; level: string }
/** @humanOnly */
| { type: 'highlightFirst'; level: string }
/** @humanOnly */
| { type: 'highlightLast'; level: string }
/** @intent("Activate the currently-highlighted item at the given level") */
| { type: 'selectHighlighted'; level: string }
/** @intent("Activate the menu item with the given value") */
| { type: 'select'; value: string }
/** @intent("Open the submenu for the given parent item") */
| { type: 'openSub'; value: string }
/** @intent("Close the deepest open submenu") */
| { type: 'closeSub' }
/** @humanOnly */
| { type: 'setItems'; items: ContextMenuItem[] }
/** @humanOnly */
| { type: 'typeahead'; level: string; char: string; now: number }
/** @intent("Set the reading direction — 'ltr' or 'rtl'") */
| { type: 'setDir'; dir: TextDirection }
/** @humanOnly — synchronized from the mounted root's live ancestor direction. */
| { type: 'syncDomDir'; dir: TextDirection }
/** @humanOnly */
| { type: 'animationEnd' }
ContextMenuSeparatorParts from @llui/components/context-menu
export type ContextMenuSeparatorParts = MenuSeparatorPartsOf<'context-menu'>
ContextMenuSubContentParts from @llui/components/context-menu
export type ContextMenuSubContentParts = MenuSubContentPartsOf<'context-menu'>
ContextMenuSubPositionerParts from @llui/components/context-menu
export type ContextMenuSubPositionerParts = MenuSubPositionerPartsOf<'context-menu'>
ContextMenuSubTriggerParts from @llui/components/context-menu
export type ContextMenuSubTriggerParts = MenuSubTriggerPartsOf<'context-menu'>
Interfaces
ConnectOptions from @llui/components/context-menu
export interface ConnectOptions {
id: string
onSelect?: (value: string) => void
/** ms to wait before opening a submenu on hover (default: 200). */
hoverDelay?: number
/** ms to wait before closing a submenu after the pointer leaves (default: 300). */
hoverCloseDelay?: number
}
ContextMenuInit from @llui/components/context-menu
export interface ContextMenuInit {
items?: ContextMenuItem[]
checked?: string[]
closeOnSelect?: boolean
/** Omit to follow the page's own direction (see {@link ContextMenuState}'s
* `dir`/`dirSource`, resolved from the mounted trigger by `directionSync`). */
dir?: TextDirection
/** When false, opening and closing play enter/exit animations and the content
* stays mounted (status 'opening' or 'closing') until an `animationEnd`.
* Default true: instant. */
skipAnimations?: boolean
}
ContextMenuParts from @llui/components/context-menu
export interface ContextMenuParts {
/** The element users right-click to open the menu. `id` is REQUIRED — it is
* the scope `directionSync` (below) observes for live ancestor `dir`
* changes, since it (unlike `content`) is always mounted (#265 finding 6). */
trigger: {
id: string
'data-scope': 'context-menu'
'data-part': 'trigger'
onContextMenu: (e: MouseEvent) => void
}
positioner: {
'data-scope': 'context-menu'
'data-part': 'positioner'
style: ReadSignal<string>
}
content: {
role: 'menu'
id: string
/** Virtually-focused (highlighted) item id at the root level. */
'aria-activedescendant': ReadSignal<string | undefined>
tabindex: -1
/** Reflects the presence lifecycle: 'opening' | 'open' | 'closing' | 'closed'.
* Stays mounted while 'closing' so the exit animation can run. */
'data-state': ReadSignal<PresenceStatus>
'data-scope': 'context-menu'
'data-part': 'content'
onKeyDown: (e: KeyboardEvent) => void
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
item: (value: string) => ContextMenuItemParts
checkboxItem: (value: string) => ContextMenuCheckItemParts
radioItem: (value: string) => ContextMenuCheckItemParts
/**
* A labelled group's parts. The group names its `label` part through
* `aria-labelledby`; a consumer that renders the group WITHOUT that label
* passes `{ hasLabel: false }` so it never names an element that does not
* exist (an unlabelled `role="group"` is valid; a broken idref is not — the
* `dialog` `hasDescription` rule, #268). Default: true.
*/
group: (id: string, options?: { readonly hasLabel?: boolean }) => ContextMenuGroupParts
separator: () => ContextMenuSeparatorParts
subTrigger: (value: string) => ContextMenuSubTriggerParts
subPositioner: (value: string) => ContextMenuSubPositionerParts
subContent: (value: string) => ContextMenuSubContentParts
/** Place once anywhere in the same build to keep automatic direction live —
* the shared `@llui/interactions` direction-sync seam (#265 finding 6),
* observing the TRIGGER (always mounted, unlike the content) as this
* instance's root. A discarded `Mountable` is inert, so this must be placed
* in the view. */
directionSync: Mountable
}
ContextMenuState from @llui/components/context-menu
Context-menu state — the shared menu-tree state plus the pointer (x, y) the root content is positioned at.
export interface ContextMenuState extends MenuTreeState {
x: number
y: number
}
OverlayOptions from @llui/components/context-menu
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
state: ReadSignal<ContextMenuState>
send: Send<ContextMenuMsg>
parts: ContextMenuParts
content: () => Renderable
/**
* Optional enter/leave transition for the context-menu content (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, so the close plays an exit animation.
* Keep `skipAnimations` at its default (true) when driving exits this way.
*
* @example contextMenu.overlay({ state, send, parts, content, transition: fade({ duration: 120 }) })
*/
transition?: TransitionOptions
target?: string | HTMLElement
}
SubOverlayOptions from @llui/components/context-menu
export interface SubOverlayOptions {
/** The subTrigger value this level opens under. */
value: string
state: ReadSignal<ContextMenuState>
parts: Pick<ContextMenuParts, 'subTrigger' | 'subPositioner' | 'subContent'>
content: () => Renderable
target?: string | HTMLElement
positionerClass?: string
align?: 'start' | 'end'
offset?: number
flip?: boolean
shift?: boolean
}
Constants
contextMenu from @llui/components/context-menu
const contextMenu
isMounted from @llui/components/context-menu
Alias of {@link isPresent} for parity with the presence-convention naming.
const isMounted
@llui/components/password-input
Functions
connect() from @llui/components/password-input
function connect(state: ReadSignal<PasswordInputState>, send: Send<PasswordInputMsg>, opts: ConnectOptions = {}): PasswordInputParts
init() from @llui/components/password-input
function init(opts: PasswordInputInit = {}): PasswordInputState
update() from @llui/components/password-input
function update(state: PasswordInputState, msg: PasswordInputMsg): [PasswordInputState, never[]]
Types
PasswordInputMsg from @llui/components/password-input
export type PasswordInputMsg =
/** @intent("Update the password value as the user types") */
| { type: 'setValue'; value: string }
/** @intent("Toggle the show/hide-password state") */
| { type: 'toggleVisibility' }
/** @intent("Set the show/hide-password state to a specific value") */
| { type: 'setVisible'; visible: boolean }
Interfaces
ConnectOptions from @llui/components/password-input
export interface ConnectOptions {
autocomplete?: string
showLabel?: string
hideLabel?: string
}
PasswordInputInit from @llui/components/password-input
export interface PasswordInputInit {
value?: string
visible?: boolean
disabled?: boolean
}
PasswordInputParts from @llui/components/password-input
export interface PasswordInputParts {
root: {
'data-scope': 'password-input'
'data-part': 'root'
'data-visible': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
}
input: {
type: ReadSignal<'text' | 'password'>
autocomplete: string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'password-input'
'data-part': 'input'
onInput: (e: Event) => void
}
visibilityTrigger: {
type: 'button'
'aria-label': ReadSignal<string>
'aria-pressed': ReadSignal<boolean>
disabled: ReadSignal<boolean>
tabindex: ReadSignal<number>
'data-scope': 'password-input'
'data-part': 'visibility-trigger'
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
}
PasswordInputState from @llui/components/password-input
Password input — text input with show/hide visibility toggle.
export interface PasswordInputState {
value: string
visible: boolean
disabled: boolean
}
Constants
passwordInput from @llui/components/password-input
const passwordInput
@llui/components/steps
Functions
connect() from @llui/components/steps
function connect(state: ReadSignal<StepsState>, send: Send<StepsMsg>, opts: ConnectOptions = {}): StepsParts
init() from @llui/components/steps
function init(opts: StepsInit = {}): StepsState
stepStatus() from @llui/components/steps
function stepStatus(state: StepsState, step: number): StepStatus
update() from @llui/components/steps
function update(state: StepsState, msg: StepsMsg): [StepsState, never[]]
Types
StepsMsg from @llui/components/steps
export type StepsMsg =
/** @intent("Jump to a specific step by zero-based index") */
| { type: 'goTo'; step: number }
/** @intent("Advance to the next step") */
| { type: 'next' }
/** @intent("Go back to the previous step") */
| { type: 'prev' }
/** @intent("Mark the given step as completed") */
| { type: 'complete'; step: number }
/** @intent("Mark the given step as having an error") */
| { type: 'markError'; step: number }
/** @intent("Clear the error flag on the given step") */
| { type: 'clearError'; step: number }
/** @intent("Reset progress back to the first step (clears completed and errors)") */
| { type: 'reset' }
StepStatus from @llui/components/steps
Steps — progress indicator for multi-step flows (wizards, checkouts). Tracks current step and completed steps; supports linear and non-linear navigation.
export type StepStatus = 'pending' | 'current' | 'completed' | 'error'
Interfaces
ConnectOptions from @llui/components/steps
export interface ConnectOptions {
label?: string
}
StepsInit from @llui/components/steps
export interface StepsInit {
current?: number
completed?: number[]
steps?: string[]
linear?: boolean
disabled?: boolean
}
StepsItemParts from @llui/components/steps
export interface StepsItemParts {
item: {
'data-scope': 'steps'
'data-part': 'item'
'data-status': ReadSignal<StepStatus>
'data-index': string
'aria-current': ReadSignal<'step' | undefined>
}
trigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'steps'
'data-part': 'trigger'
'data-status': ReadSignal<StepStatus>
onClick: (e: MouseEvent) => void
}
separator: {
'data-scope': 'steps'
'data-part': 'separator'
'data-status': ReadSignal<StepStatus>
'aria-hidden': 'true'
}
}
StepsParts from @llui/components/steps
export interface StepsParts {
root: {
role: 'group'
'aria-label': string
'data-scope': 'steps'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
}
nextTrigger: {
type: 'button'
disabled: ReadSignal<boolean>
'data-scope': 'steps'
'data-part': 'next-trigger'
onClick: (e: MouseEvent) => void
}
prevTrigger: {
type: 'button'
disabled: ReadSignal<boolean>
'data-scope': 'steps'
'data-part': 'prev-trigger'
onClick: (e: MouseEvent) => void
}
item: (index: number) => StepsItemParts
}
StepsState from @llui/components/steps
export interface StepsState {
current: number
completed: number[]
errors: number[]
steps: string[]
/** If linear, users cannot skip steps. */
linear: boolean
disabled: boolean
}
Constants
steps from @llui/components/steps
const steps
@llui/components/time-picker
Functions
connect() from @llui/components/time-picker
function connect(state: ReadSignal<TimePickerState>, send: Send<TimePickerMsg>, opts: ConnectOptions = {}): TimePickerParts
displayHours() from @llui/components/time-picker
Hours formatted for display (12-hr: 1..12, 24-hr: 0..23).
function displayHours(state: TimePickerState): number
formatTime() from @llui/components/time-picker
Format the full time string (HH:MM or HH:MM:SS).
function formatTime(state: TimePickerState): string
hoursFromDisplay() from @llui/components/time-picker
Inverse of displayHours: turn a value from the hours FIELD into the stored
24-hour value. In 12-hour format the field reads 1..12, so it only says which
hour of the half-day — the meridiem has to come from the current value. The
single mutation path used mod(hours, 24) instead, so typing "3" at 15:00
stored 03:00 and silently flipped PM to AM (#125).
function hoursFromDisplay(hours: number, state: TimePickerState): number
init() from @llui/components/time-picker
function init(opts: TimePickerInit = {}): TimePickerState
period() from @llui/components/time-picker
AM or PM for 12-hour format.
function period(state: TimePickerState): 'AM' | 'PM'
update() from @llui/components/time-picker
function update(state: TimePickerState, msg: TimePickerMsg): [TimePickerState, never[]]
Types
TimeFormat from @llui/components/time-picker
Time picker — hours and minutes input with increment/decrement buttons. 12 or 24-hour format; optional seconds; step for minutes/seconds.
export type TimeFormat = '12' | '24'
TimePickerMsg from @llui/components/time-picker
export type TimePickerMsg =
/** @intent("Set the full time value (hours/minutes/seconds)") */
| { type: 'setValue'; value: TimeValue }
/** @intent("Set the hours field directly — 0-23 in 24-hour format, 1-12 in 12-hour format where the current AM/PM is kept") */
| { type: 'setHours'; hours: number }
/** @intent("Set the minutes field directly") */
| { type: 'setMinutes'; minutes: number }
/** @intent("Set the seconds field directly") */
| { type: 'setSeconds'; seconds: number }
/** @intent("Bump hours up by 1 (wraps at 24/12). Ignored while disabled") */
| { type: 'incrementHours' }
/** @intent("Bump hours down by 1. Ignored while disabled") */
| { type: 'decrementHours' }
/** @intent("Bump minutes up by minuteStep. Ignored while disabled") */
| { type: 'incrementMinutes' }
/** @intent("Bump minutes down by minuteStep. Ignored while disabled") */
| { type: 'decrementMinutes' }
/** @intent("Flip between AM and PM (12-hour format only). Ignored while disabled") */
| { type: 'toggleAmPm' }
/** @intent("Enable or disable the time-picker — a host/agent write, never gated") */
| { type: 'setDisabled'; disabled: boolean }
Interfaces
ConnectOptions from @llui/components/time-picker
export interface ConnectOptions {
label?: string
hoursLabel?: string
minutesLabel?: string
periodLabel?: string
}
TimePickerInit from @llui/components/time-picker
export interface TimePickerInit {
value?: TimeValue
format?: TimeFormat
minuteStep?: number
secondStep?: number
showSeconds?: boolean
disabled?: boolean
}
TimePickerParts from @llui/components/time-picker
export interface TimePickerParts {
root: {
role: 'group'
'aria-label': string
'data-scope': 'time-picker'
'data-part': 'root'
'data-format': ReadSignal<TimeFormat>
'data-disabled': ReadSignal<'' | undefined>
}
hoursInput: {
type: 'number'
role: 'spinbutton'
'aria-label': string
'aria-valuemin': ReadSignal<number>
'aria-valuemax': ReadSignal<number>
'aria-valuenow': ReadSignal<number>
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'time-picker'
'data-part': 'hours-input'
onInput: (e: Event) => void
onKeyDown: (e: KeyboardEvent) => void
}
minutesInput: {
type: 'number'
role: 'spinbutton'
'aria-label': string
'aria-valuemin': 0
'aria-valuemax': 59
'aria-valuenow': ReadSignal<number>
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'time-picker'
'data-part': 'minutes-input'
onInput: (e: Event) => void
onKeyDown: (e: KeyboardEvent) => void
}
periodTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'time-picker'
'data-part': 'period-trigger'
'data-period': ReadSignal<'AM' | 'PM'>
onClick: (e: MouseEvent) => void
hidden: ReadSignal<boolean>
}
}
TimePickerState from @llui/components/time-picker
export interface TimePickerState {
value: TimeValue
format: TimeFormat
minuteStep: number
secondStep: number
showSeconds: boolean
disabled: boolean
}
TimeValue from @llui/components/time-picker
export interface TimeValue {
hours: number
minutes: number
seconds: number
}
Constants
timePicker from @llui/components/time-picker
const timePicker
@llui/components/date-picker
Functions
connect() from @llui/components/date-picker
function connect(state: ReadSignal<DatePickerState>, send: Send<DatePickerMsg>, opts: ConnectOptions = {}): DatePickerParts
init() from @llui/components/date-picker
function init(opts: DatePickerInit = {}): DatePickerState
monthGrid() from @llui/components/date-picker
Compute the grid of days for a visible month. Always returns full weeks:
leading days from the previous month and trailing from the next month fill
the grid. offset shifts the rendered month forward by N months from the
state's visibleMonth/visibleYear (used by the multi-month view).
function monthGrid(state: DatePickerState, offset = 0): DayCell[]
monthLabel() from @llui/components/date-picker
Localized "Month YYYY" label for a calendar header, backed by
Intl.DateTimeFormat via the package's formatDate wrapper.
function monthLabel(year: number, month: number, locale?: string): string
todayInTimeZone() from @llui/components/date-picker
The calendar date (YYYY-MM-DD) of an instant in an IANA time zone. "Today"
is a property of a PLACE, not of an instant: at 23:30 UTC it is still the
14th in Los Angeles and already the 15th in Auckland. Pin the result as
today to show the calendar in a zone other than the runtime's (#266).
function todayInTimeZone(timeZone: string, now: number = Date.now()): string
update() from @llui/components/date-picker
function update(state: DatePickerState, msg: DatePickerMsg): [DatePickerState, never[]]
weekdayLabels() from @llui/components/date-picker
Localized weekday header labels (length 7), rotated so the array begins on
weekStartsOn (0=Sunday, 1=Monday). Uses a known reference week so the Intl
formatter yields the correct day names.
function weekdayLabels(weekStartsOn: 0 | 1, locale?: string): string[]
weekRows() from @llui/components/date-picker
Group a flat DayCell[] (from monthGrid) into rows of 7 — one row
per week — so the view can wrap each in a role="row" element as
required by the WAI-ARIA grid pattern.
function weekRows(cells: DayCell[]): DayCell[][]
Types
DatePickerMode from @llui/components/date-picker
Selection mode: a single date or a start/end range.
export type DatePickerMode = 'single' | 'range'
DatePickerMsg from @llui/components/date-picker
export type DatePickerMsg =
/** @intent("Set the selected date (YYYY-MM-DD), or null to clear") */
| { type: 'setValue'; value: string | null }
/** @intent("Set the selected date range (YYYY-MM-DD start/end); endpoints are normalized so start <= end") */
| { type: 'setRange'; start: string | null; end: string | null }
/** @humanOnly */
| { type: 'setFocused'; date: string }
/** @humanOnly */
| { type: 'setHover'; date: string }
/** @humanOnly */
| { type: 'clearHover' }
/** @intent("Show the previous month in the calendar") */
| { type: 'prevMonth' }
/** @intent("Show the next month in the calendar") */
| { type: 'nextMonth' }
/** @intent("Show the previous year (same month)") */
| { type: 'prevYear' }
/** @intent("Show the next year (same month)") */
| { type: 'nextYear' }
/** @intent("Select the currently-focused date (anchors or completes a range in range mode)") */
| { type: 'selectFocused' }
/** @humanOnly */
| { type: 'moveFocus'; days: number }
/** @humanOnly */
| { type: 'focusStartOfWeek' }
/** @humanOnly */
| { type: 'focusEndOfWeek' }
/** @humanOnly */
| { type: 'focusToday' }
/** @humanOnly */
| { type: 'moveFocusMonths'; months: number }
/** @intent("Clear the current selection") */
| { type: 'clear' }
/** @humanOnly */
| { type: 'setToday'; today: string | null }
/** @intent("Replace the set of individually unavailable dates (YYYY-MM-DD)") */
| { type: 'setUnavailable'; dates: string[] }
Interfaces
ConnectOptions from @llui/components/date-picker
export interface ConnectOptions {
/** Selection mode — affects pointer-hover preview wiring. Defaults to 'single'. */
mode?: DatePickerMode
/** BCP-47 locale tag for month/grid labels. Defaults to the runtime default. */
locale?: string
prevLabel?: string
nextLabel?: string
gridLabel?: (year: number, month: number) => string
}
DatePickerInit from @llui/components/date-picker
export interface DatePickerInit {
mode?: DatePickerMode
value?: string | null
start?: string | null
end?: string | null
visibleMonth?: number
visibleYear?: number
months?: number
min?: string | null
max?: string | null
weekStartsOn?: 0 | 1
disabled?: boolean
/** Pin "today" (YYYY-MM-DD); omit or `null` to read the runtime clock. */
today?: string | null
/** Individually unavailable dates (YYYY-MM-DD). */
unavailable?: string[]
}
DatePickerParts from @llui/components/date-picker
export interface DatePickerParts {
root: {
'data-scope': 'date-picker'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
}
/**
* Grid part factory. `offset` (default 0) selects which month this grid
* renders in a multi-month view — the `aria-label` is the localized
* "Month YYYY" of `visibleMonth + offset`.
*/
grid: (offset?: number) => {
role: 'grid'
'aria-label': ReadSignal<string>
'data-scope': 'date-picker'
'data-part': 'grid'
'data-month-offset': number
}
row: {
role: 'row'
'data-scope': 'date-picker'
'data-part': 'row'
}
prevMonthTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'date-picker'
'data-part': 'prev-month-trigger'
onClick: (e: MouseEvent) => void
}
nextMonthTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'date-picker'
'data-part': 'next-month-trigger'
onClick: (e: MouseEvent) => void
}
dayCell: (cell: DayCell) => DayCellParts
/** Preset part factory — clicking dispatches a single `setRange`. */
preset: (range: PresetRange) => PresetParts
}
DatePickerState from @llui/components/date-picker
export interface DatePickerState {
/** Selection mode. Defaults to 'single'. */
mode: DatePickerMode
/** Selected date as YYYY-MM-DD, or null. Used in 'single' mode. */
value: string | null
/** Range start as YYYY-MM-DD, or null. Used in 'range' mode. */
start: string | null
/** Range end as YYYY-MM-DD, or null. Used in 'range' mode. */
end: string | null
/** Date currently hovered/previewed while a range is being completed. */
hoverDate: string | null
/** The month currently visible (1-indexed, 1-12) — the first/leftmost month. */
visibleMonth: number
/** The year currently visible. */
visibleYear: number
/** Number of months rendered side-by-side. Defaults to 1. */
months: number
/** The date currently focused by the keyboard (YYYY-MM-DD). */
focused: string
/** Minimum selectable date, inclusive. */
min: string | null
/** Maximum selectable date, inclusive. */
max: string | null
/** 0=Sunday, 1=Monday. */
weekStartsOn: 0 | 1
disabled: boolean
/**
* A pinned "today" (YYYY-MM-DD), or `null` to read the runtime clock. Pin it
* for deterministic rendering (SSR + hydration, galleries, tests) or to show
* today in a specific time zone — see `todayInTimeZone` (#266).
*/
today: string | null
/**
* Individually unavailable dates (YYYY-MM-DD) — bookings, holidays. Disabled
* like an out-of-bounds date, published separately as `data-unavailable`, and
* a range may not be completed across one (#266).
*/
unavailable: string[]
}
DayCell from @llui/components/date-picker
export interface DayCell {
iso: string
day: number
inMonth: boolean
isToday: boolean
isSelected: boolean
isFocused: boolean
isDisabled: boolean
/** True for the start endpoint of a (committed or previewed) range. */
isRangeStart: boolean
/** True for the end endpoint of a (committed or previewed) range. */
isRangeEnd: boolean
/** True for dates strictly between the range endpoints. */
isInRange: boolean
/** True for a date listed in `unavailable` (also `isDisabled`). */
isUnavailable: boolean
}
DayCellParts from @llui/components/date-picker
export interface DayCellParts {
cell: {
role: 'gridcell'
// Signals, not plain values: `view()` runs once, so a snapshot here freezes
// every flag at build time and no selection, focus move or range preview
// ever reaches the DOM. See `live` in `connect`.
'aria-selected': ReadSignal<boolean>
'aria-disabled': ReadSignal<'true' | undefined>
tabindex: ReadSignal<number>
'data-scope': 'date-picker'
'data-part': 'day-cell'
/** The cell's identity — the one genuinely static attribute. */
'data-date': string
'data-in-month': ReadSignal<'' | undefined>
'data-today': ReadSignal<'' | undefined>
'data-selected': ReadSignal<'' | undefined>
'data-focused': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
'data-range-start': ReadSignal<'' | undefined>
'data-range-end': ReadSignal<'' | undefined>
'data-in-range': ReadSignal<'' | undefined>
/** An individually unavailable date (also disabled) — distinct from out-of-bounds. */
'data-unavailable': ReadSignal<'' | undefined>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
onFocus: (e: FocusEvent) => void
onPointerEnter: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
}
}
PresetParts from @llui/components/date-picker
export interface PresetParts {
type: 'button'
'data-scope': 'date-picker'
'data-part': 'preset'
onClick: (e: MouseEvent) => void
}
PresetRange from @llui/components/date-picker
A named preset range a consumer can render as a quick-select button.
export interface PresetRange {
start: string | null
end: string | null
}
Constants
datePicker from @llui/components/date-picker
const datePicker
@llui/components/color-picker
Functions
areaCanvasBinding() from @llui/components/color-picker
The component-owned repaint seam for the OKLCH area's <canvas> (finding
E: a consumer writes ZERO registerBinding/isSignalHandle/currentDoc
of its own). Renders a canvas element with id={canvasId} — carrying
areaCanvas's spread props — and PLACE this function's result anywhere in
the same view; it paints once on mount and again whenever the hue or
maxChroma changes, via {@link paintOklchPlane}.
Mirrors icon.ts's nameBinding + onMount pair: registerBinding's
commit can fire before the canvas element exists in the DOM (binding
commits run before runMounts), so the FIRST paint happens from
onMount (which hands back the real node), and only LATER hue/maxChroma
changes repaint directly through the cached reference.
function areaCanvasBinding(state: ReadSignal<ColorPickerState>, canvasId: string): Renderable
colorFromPoint() from @llui/components/color-picker
Map a pointer position over the 2D saturation/value area to HSV S/V (0..100). X axis is saturation (left 0 → right 100); Y axis is value (top 100 → bottom 0). The point is clamped to the rect, so out-of-bounds drags saturate cleanly.
function colorFromPoint(rect: DOMRect, x: number, y: number): { s: number; v: number }
connect() from @llui/components/color-picker
function connect(state: ReadSignal<ColorPickerState>, send: Send<ColorPickerMsg>, opts: ConnectOptions = {}): ColorPickerParts
cssColorToPickerColor() from @llui/components/color-picker
Convert an already-parsed {@link CssColor} into the canonical store for a
given active model, exactly the way setColor/setHex project a typed
color string — but for a caller (gradient-picker) that already has a
CssColor, never a string, and must not round-trip through one.
function cssColorToPickerColor(parsed: CssColor, model: ColorModel, maxChroma: number): { color: PickerColor; alpha: number }
eyeDropperSupportMount() from @llui/components/color-picker
Detect EyeDropper support ONCE, client-side, after mount, and dispatch the
answer into state — the ONLY piece of environment detection a consumer
wires up; place the returned Mountable anywhere in the view (it renders
nothing) and eyeDropperTrigger's hidden/disabled/data-unsupported
follow automatically. Never runs during a render (SSR or the client's
first paint), which is what keeps eyeDropperSupported's init() default
of false identical on both — a check made INLINE during render would
make SSR and a supporting browser's client render disagree.
function eyeDropperSupportMount(send: Send<ColorPickerMsg>): Mountable
hsvToOklchPreserving() from @llui/components/color-picker
HSV -> OKLCH, keeping the HSV hue verbatim when the color is achromatic (S or V is 0) instead of the arbitrary hue an achromatic OKLab conversion would otherwise produce.
function hsvToOklchPreserving(hsv: Hsv): Oklch
init() from @llui/components/color-picker
function init(opts: ColorPickerInit = {}): ColorPickerState
isOutOfGamut() from @llui/components/color-picker
Whether the OKLCH color falls outside the sRGB gamut (always false in
HSV mode — an HSV color is an sRGB parameterization by construction).
function isOutOfGamut(state: ColorPickerState): boolean
lcFromPoint() from @llui/components/color-picker
Map a pointer position over the 2D chroma/lightness area to OKLCH C/L.
X axis is chroma (left 0 → right maxChroma); Y axis is lightness
(top 1 → bottom 0), mirroring {@link colorFromPoint}'s value axis.
function lcFromPoint(rect: DOMRect, x: number, y: number, maxChroma: number = DEFAULT_MAX_CHROMA): { c: number; l: number }
oklchHueRampGradient() from @llui/components/color-picker
A full-circle OKLCH hue ramp at a fixed lightness/chroma, using CSS
Color 4's in oklch longer hue interpolation method — the ONLY way to
express a hue sweep as a plain gradient, since hue 0 and hue 360 are the
SAME point in oklab/srgb (the default interpolation space), so a
naive linear-gradient(oklch(L C 0), oklch(L C 360)) would render as a
solid color instead of a rainbow.
function oklchHueRampGradient(l = 0.75, c = 0.15): string
oklchPlanePixels() from @llui/components/color-picker
Render one OKLCH hue's chroma/lightness plane as RGBA pixels — chroma
increasing left to right (0..maxChroma), lightness increasing bottom to
top (0..1), matching {@link lcFromPoint}'s axes. Out-of-gamut pixels are
fully transparent (alpha 0) rather than gamut-mapped, so a consumer can
paint them hatched/checkered underneath if it wants a visible "no color
here" cue instead of a silently-wrong one.
PER-ROW BINARY SEARCH, not a full per-pixel gamut-mapping search: for a
fixed lightness/hue, {@link inSrgbGamut} is true on a PREFIX of chroma
values and false beyond it (moving out from the achromatic point along one
ray only ever crosses the gamut boundary once — color.test.ts's
"gamut boundary is monotone per row" test sweeps a grid of hues/lightnesses
and fails loudly if that stops holding for some hue this file didn't
anticipate). Finding the edge costs O(log width) inSrgbGamut calls
instead of running the full chroma-reduction search for every pixel;
pixels before the edge need only the CHEAP unmapped conversion (already
proven in-gamut), and pixels at/after it are skipped entirely (transparent,
r/g/b left at the buffer's zero-fill). Measured on a 256x256 plane
(median of 15 runs, warmed up) against the OLD full-gamut-map-every-pixel
approach: hue 145 (the review's own reproduction case) went from 45.1ms to
1.8ms (24.5x); hue 30 from 49.2ms to 1.3ms (37.9x); hue 265 from 49.2ms to
2.0ms (24.8x) — see the mutation table / final report for the exact
reproduction script.
function oklchPlanePixels(hue: number, width: number, height: number, maxChroma: number = DEFAULT_MAX_CHROMA): Uint8ClampedArray<ArrayBuffer>
oklchToHsvPreserving() from @llui/components/color-picker
OKLCH -> HSV (gamut-mapped), keeping the OKLCH hue verbatim when the color is achromatic (chroma ~0).
function oklchToHsvPreserving(ok: Oklch): Hsv
openEyeDropper() from @llui/components/color-picker
Open the browser's native EyeDropper UI and resolve to the picked color as
#rrggbb. Resolves null — never rejects — when the API is unsupported
or the user cancels (AbortError, including a deliberate
signal.abort()). Rejects only for a genuine unexpected failure.
function openEyeDropper(signal?: AbortSignal): Promise<string | null>
paintOklchPlane() from @llui/components/color-picker
Paint {@link oklchPlanePixels} onto a canvas sized to its current
width/height, using the hue/maxChroma from state (not separate
arguments — the ONE source of truth for both, matching how connect()
itself reads them). A no-op on a zero-sized canvas or a context-less
environment (jsdom without a 2D context polyfill).
function paintOklchPlane(canvas: HTMLCanvasElement, state: ColorPickerState): void
pickerColorToCss() from @llui/components/color-picker
toCss's rule (hex/hex8 for hsv, exact oklch() for oklch — see
{@link toCss}'s own doc comment) for just a color + alpha, with none of
ColorPickerState's other fields needed. The seam a caller with a bare
PickerColor (gradient-picker's per-stop color, never wrapped in a full
picker state) reaches for, instead of building a throwaway state shim just
to call toCss.
function pickerColorToCss(color: PickerColor, alpha: number): string
pickerColorToCssColor() from @llui/components/color-picker
The inverse of {@link cssColorToPickerColor}: a canonical stored color plus
alpha, as the shared {@link CssColor} model interpolateColor operates on.
OKLCH stays OKLCH (exact, not gamut-mapped); HSV becomes srgb.
function pickerColorToCssColor(color: PickerColor, alpha: number): CssColor
sanitizeHsv() from @llui/components/color-picker
Exported for gradient-picker, which sanitizes a stop's color the same
way init/setColor do when projecting a parsed CSS color onto HSV.
function sanitizeHsv(raw: Hsv): Hsv
sanitizeOklch() from @llui/components/color-picker
Exported for gradient-picker, same reason as {@link sanitizeHsv}.
function sanitizeOklch(raw: Oklch, maxChroma: number): Oklch
stateHsl() from @llui/components/color-picker
Project the current color onto HSL (via {@link stateHsv}).
function stateHsl(state: ColorPickerState): Hsl
stateHsv() from @llui/components/color-picker
Project the current color onto HSV (gamut-mapped if the active model is OKLCH and out of gamut).
function stateHsv(state: ColorPickerState): Hsv
stateOklch() from @llui/components/color-picker
Project the current color onto OKLCH (exact — not gamut-mapped — if the active model already IS OKLCH; derived from HSV otherwise, which is always in-gamut by construction).
function stateOklch(state: ColorPickerState): Oklch
supportsEyeDropper() from @llui/components/color-picker
Whether the EyeDropper API is available. SSR-safe (false when there is
no window). Exported as a plain, synchronously-callable function for
whatever else might want it, but connect()'s own eyeDropperTrigger
does NOT call this directly — it reads state.eyeDropperSupported, which
only {@link eyeDropperSupportMount} ever sets (see that function's doc
comment for why: calling this INLINE during a render would make SSR and a
supporting browser's client render disagree).
function supportsEyeDropper(): boolean
toCss() from @llui/components/color-picker
The current color as a CSS string. HSV model -> hex (#rrggbb/#rrggbbaa
depending on alpha) — cheap, familiar, and what the hex input already
shows. OKLCH model -> oklch(), EXACT rather than gamut-mapped, because
an OKLCH color may legitimately sit outside sRGB and a hex round trip
would silently clip it; the browser gamut-maps oklch() for display on
its own terms, same as it would for a value typed directly into CSS.
function toCss(state: ColorPickerState): string
toHex() from @llui/components/color-picker
#rrggbb, gamut-mapped from OKLCH when the active model needs it.
function toHex(state: ColorPickerState): string
toHex8() from @llui/components/color-picker
#rrggbbaa.
function toHex8(state: ColorPickerState): string
update() from @llui/components/color-picker
function update(state: ColorPickerState, msg: ColorPickerMsg): [ColorPickerState, never[]]
Types
ColorModel from @llui/components/color-picker
Color picker — HSV and OKLCH color selection.
The canonical color lives in whichever model is ACTIVE (state.color.model)
and is lossless within that model. Every message that edits the color goes
through a PROJECTION into the model it naturally operates in (HSV, HSL, or
OKLCH), applies the edit there, then converts the result into the active
model — so setSaturation (an HSL concept) and setChroma (an OKLCH
concept) both work regardless of which model is currently active, and
setHue sets the hue of whichever model IS active (HSV hue and OKLCH hue
are numerically different rulers for the same wheel).
HUE STABILITY ON ACHROMATIC COLORS is the reason HSV was chosen as the
original canonical store (S or V at 0 loses no information about hue,
unlike HSL's black/white axis) and it is preserved across the HSV<->OKLCH
boundary too: {@link hsvToOklchPreserving}/{@link oklchToHsvPreserving}
carry the stored hue straight across whenever the color is achromatic in
the SOURCE model, rather than recomputing an arbitrary hue-of-zero-chroma.
setModel and every cross-model projection route through these two.
export type ColorModel = 'hsv' | 'oklch'
ColorPickerMsg from @llui/components/color-picker
export type ColorPickerMsg =
/** @intent("Switch the active color model, converting the stored color and keeping its hue stable if it is achromatic") */
| { type: 'setModel'; model: ColorModel }
/** @intent("Set the full HSL color at once") */
| { type: 'setHsl'; hsl: Hsl }
/** @intent("Set the hue of the ACTIVE model (0–360)") */
| { type: 'setHue'; h: number }
/** @intent("Set the HSL saturation channel (0–100)") */
| { type: 'setSaturation'; s: number }
/** @intent("Set the HSL lightness channel (0–100)") */
| { type: 'setLightness'; l: number }
/** @intent("Set the alpha channel (0–1)") */
| { type: 'setAlpha'; alpha: number }
/** @intent("Set the color from any CSS color string (hex, rgb(), hsl(), oklch(), oklab(), a named color)") */
| { type: 'setHex'; hex: string }
/** @intent("Set saturation and value (HSV, 0–100 each) from the 2D area") */
| { type: 'setSv'; s: number; v: number }
/** @intent("Nudge saturation/value (HSV) by signed deltas — used by area arrow keys") */
| { type: 'nudgeSv'; ds: number; dv: number }
/** @intent("Set the color from a swatch, an eyedropper pick, or any CSS color string") */
| { type: 'setColor'; color: string }
/** @intent("Set the full OKLCH color at once (L 0–1, C >= 0, H degrees)") */
| { type: 'setOklch'; l: number; c: number; h: number }
/** @intent("Set the OKLCH chroma channel") */
| { type: 'setChroma'; c: number }
/** @intent("Set the OKLCH lightness channel (0–1)") */
| { type: 'setOklchLightness'; l: number }
/** @intent("Set chroma and lightness (OKLCH, x/y of the 2D area) at once") */
| { type: 'setLc'; c: number; l: number }
/** @intent("Nudge chroma/lightness (OKLCH) by signed deltas — used by area arrow keys") */
| { type: 'nudgeLc'; dc: number; dl: number }
/** @humanOnly */
| { type: 'setEyeDropperSupported'; supported: boolean }
/** @humanOnly */
| { type: 'eyeDropperFailed'; message: string }
PickerColor from @llui/components/color-picker
The canonical stored color union — HSV or OKLCH, tagged by model.
Exported as a named type for gradient-picker, whose stops each store one
of these (never a hex string) so a gray stop keeps its hue and an
out-of-gamut OKLCH stop stays exact.
export type PickerColor = ColorPickerState['color']
Interfaces
ColorPickerInit from @llui/components/color-picker
export interface ColorPickerInit {
/** Which model the canonical color is stored in. Default `'hsv'`. */
model?: ColorModel
/** Initial color as any CSS color string the shared parser accepts (hex,
* `rgb()`, `hsl()`, `oklch()`, `oklab()`, a named color). Takes precedence
* over `hsl`/`hsv`/`oklch` below, and over `alpha` for the color's own
* alpha component (an explicit `alpha` option still wins). */
color?: string
/** Initial color as HSL (converted into the canonical store). */
hsl?: Hsl
/** Initial color as HSV. Takes precedence over `hsl`. */
hsv?: Hsv
/** Initial color as OKLCH. Takes precedence over `hsl`/`hsv`. */
oklch?: Oklch
alpha?: number
disabled?: boolean
/** Upper bound for OKLCH chroma. Must be finite and > 0 — anything else
* (including omission) falls back to {@link DEFAULT_MAX_CHROMA}. */
maxChroma?: number
}
ColorPickerParts from @llui/components/color-picker
export interface ColorPickerParts {
root: {
'data-scope': 'color-picker'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
'data-model': ReadSignal<ColorModel>
/** Bare boolean (package convention): present when the current OKLCH
* color falls outside sRGB. Always absent in HSV mode (an HSV color is
* an sRGB parameterization by construction). */
'data-out-of-gamut': ReadSignal<'' | undefined>
}
/** Cycles the active model between `'hsv'` and `'oklch'`. */
modelToggle: {
type: 'button'
'aria-label': ReadSignal<string>
disabled: ReadSignal<boolean>
'data-scope': 'color-picker'
'data-part': 'model-toggle'
'data-model': ReadSignal<ColorModel>
onClick: (e: MouseEvent) => void
}
hueSlider: {
type: 'range'
min: 0
max: 360
step: 1
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'color-picker'
'data-part': 'hue-slider'
onInput: (e: Event) => void
}
saturationSlider: {
type: 'range'
min: 0
max: 100
step: 1
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
style: ReadSignal<string>
'data-scope': 'color-picker'
'data-part': 'saturation-slider'
onInput: (e: Event) => void
}
lightnessSlider: {
type: 'range'
min: 0
max: 100
step: 1
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
style: ReadSignal<string>
'data-scope': 'color-picker'
'data-part': 'lightness-slider'
onInput: (e: Event) => void
}
/** OKLCH chroma, `0..state.maxChroma`. */
chromaSlider: {
type: 'range'
min: 0
max: ReadSignal<number>
step: ReadSignal<number>
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
style: ReadSignal<string>
'data-scope': 'color-picker'
'data-part': 'chroma-slider'
onInput: (e: Event) => void
}
/** OKLCH perceptual lightness, `0..1`. */
oklchLightnessSlider: {
type: 'range'
min: 0
max: 1
step: number
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
style: ReadSignal<string>
'data-scope': 'color-picker'
'data-part': 'oklch-lightness-slider'
onInput: (e: Event) => void
}
hexInput: {
type: 'text'
autocomplete: 'off'
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'color-picker'
'data-part': 'hex-input'
onInput: (e: Event) => void
}
/** Static preview swatch showing the currently-selected color. */
preview: {
'data-scope': 'color-picker'
'data-part': 'preview'
'aria-hidden': 'true'
style: ReadSignal<string>
}
/** The 2D area track. The machine owns the pointer-drag lifecycle
* (capture on down, released on up/cancel, primary button only, ignored
* while disabled): it computes from `currentTarget.getBoundingClientRect()`
* and dispatches `setSv` (HSV mode, via `colorFromPoint`) or `setLc`
* (OKLCH mode, via `lcFromPoint`) — see `utils/pointer-drag.ts`. Keyboard
* continues from wherever a drag left off because `pointerdown` focuses
* `areaThumb`. */
area: {
'data-scope': 'color-picker'
'data-part': 'area'
'data-model': ReadSignal<ColorModel>
// HSV-mode hue backdrop only; empty in OKLCH mode, where `areaCanvas`
// paints the plane instead (the sRGB gamut boundary is not expressible
// as a CSS gradient).
style: ReadSignal<string>
onPointerDown: (e: PointerEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerUp: (e: PointerEvent) => void
onPointerCancel: (e: PointerEvent) => void
onLostPointerCapture: (e: PointerEvent) => void
}
/** Decorative `<canvas>` seam for the OKLCH area — headless, so the
* consumer owns creating and sizing the real `<canvas>` element (give it
* an id and spread these props), but never its own repaint wiring: place
* {@link areaCanvasBinding}`(state, thatSameId)` anywhere in the view and
* it stays painted, on mount and on every hue/`maxChroma` change. Absent/
* inert in HSV mode. */
areaCanvas: {
'data-scope': 'color-picker'
'data-part': 'area-canvas'
'aria-hidden': 'true'
}
/** The draggable thumb inside the 2D area. Keyboard-operable (arrows move
* S/V or C/L depending on the active model; Shift = coarse) with
* role="slider" and a 2D aria-valuetext. `aria-label` follows the ACTIVE
* model ("Saturation / Value" in HSV mode, "Chroma / Lightness" in OKLCH),
* not a fixed string — the axes it labels are literally different. */
areaThumb: {
role: 'slider'
'aria-label': ReadSignal<string>
'aria-valuemin': ReadSignal<number>
'aria-valuemax': ReadSignal<number>
'aria-valuenow': ReadSignal<number>
'aria-valuetext': ReadSignal<string>
'aria-disabled': ReadSignal<'true' | undefined>
tabindex: ReadSignal<number>
'data-scope': 'color-picker'
'data-part': 'area-thumb'
style: ReadSignal<string>
onKeyDown: (e: KeyboardEvent) => void
}
/** Alpha (opacity) range input, 0..1. Wired to the existing alpha state. */
alphaSlider: {
type: 'range'
min: 0
max: 1
step: number
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
style: ReadSignal<string>
'data-scope': 'color-picker'
'data-part': 'alpha-slider'
onInput: (e: Event) => void
}
/** EyeDropper API trigger. Always PLACED, but `hidden`/`disabled`/
* `data-unsupported` all derive from `state.eyeDropperSupported` — a
* consumer writes no feature-detect code of its own; see
* {@link eyeDropperSupportMount}. */
eyeDropperTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
hidden: ReadSignal<boolean>
/** Bare boolean (package convention). */
'data-unsupported': ReadSignal<'' | undefined>
'data-scope': 'color-picker'
'data-part': 'eyedropper-trigger'
onClick: (e: MouseEvent) => void
}
/** Container for the preset swatch buttons. */
swatchGroup: {
role: 'group'
'aria-label': string
'data-scope': 'color-picker'
'data-part': 'swatch-group'
}
/** Factory for a preset swatch button dispatching a single `setColor`. */
swatch: (color: string) => SwatchParts
}
ColorPickerState from @llui/components/color-picker
export interface ColorPickerState {
color:
| { model: 'hsv'; h: number; s: number; v: number }
| { model: 'oklch'; l: number; c: number; h: number }
/** Alpha channel 0..1. */
alpha: number
disabled: boolean
/** Upper bound for OKLCH chroma — the ONE source of truth for the reducer's
* clamp (`setOklch`/`setChroma`/`setLc`/`nudgeLc`) AND for `connect()`'s
* chroma slider/area rendering range, so the two can no longer disagree
* the way a `ConnectOptions.maxChroma` rendering-only override could.
* Always finite and > 0 (`init` validates it; there is no setter — it is
* fixed for the component's lifetime, like `slider`'s `min`/`max`). */
maxChroma: number
/** Whether the browser's EyeDropper API is available. ALWAYS `false` at
* `init()` — this is what keeps SSR and the client's first paint identical
* (see the module doc on {@link eyeDropperSupportMount}, the ONLY thing
* that ever flips it, from a real mount-time feature check). A consumer
* never feature-detects on its own: placing that one Mountable is the
* whole contract. */
eyeDropperSupported: boolean
}
ConnectOptions from @llui/components/color-picker
export interface ConnectOptions {
hueLabel?: string
saturationLabel?: string
lightnessLabel?: string
chromaLabel?: string
oklchLightnessLabel?: string
hexLabel?: string
eyeDropperLabel?: string
/** aria-label for the 2D area thumb. */
areaLabel?: string
/** aria-label for the alpha slider. */
alphaLabel?: string
/** aria-label for the swatch group container. */
swatchGroupLabel?: string
/** Fine keyboard step for the area thumb (S/V units, or the OKLCH
* equivalent scaled by `state.maxChroma`). Default 1. */
step?: number
/** Coarse keyboard step for the area thumb when Shift is held. Default 10. */
coarseStep?: number
}
EyeDropperOpenOptions from @llui/components/color-picker
export interface EyeDropperOpenOptions {
signal?: AbortSignal
}
EyeDropperResult from @llui/components/color-picker
export interface EyeDropperResult {
sRGBHex: string
}
Hsl from @llui/components/color-picker
HSL color. h 0–360 degrees, s/l 0–100. Float — see the module doc.
export interface Hsl {
h: number
s: number
l: number
}
Hsv from @llui/components/color-picker
HSV color. h 0–360 degrees, s/v 0–100. Float — see the module doc.
export interface Hsv {
h: number
s: number
v: number
}
Oklch from @llui/components/color-picker
OKLCH. l 0–1, c >= 0 (unbounded — maxChroma in the color picker is a
UI choice, not a property of the color space), h in degrees.
export interface Oklch {
l: number
c: number
h: number
}
SwatchParts from @llui/components/color-picker
export interface SwatchParts {
type: 'button'
'aria-label': string
'aria-pressed': ReadSignal<boolean>
'data-scope': 'color-picker'
'data-part': 'swatch'
'data-value': string
'data-state': ReadSignal<'selected' | undefined>
style: string
onClick: (e: MouseEvent) => void
}
Constants
colorPicker from @llui/components/color-picker
const colorPicker
DEFAULT_MAX_CHROMA from @llui/components/color-picker
0.37 covers the whole of sRGB and most of Display P3 while leaving the
2D area / chroma slider a sensible fixed range (an unbounded chroma axis
has no natural "full scale" to show) — the default for
{@link ColorPickerState.maxChroma} when init() gets no maxChroma
option.
const DEFAULT_MAX_CHROMA
@llui/components/gradient-picker
Functions
colorAt() from @llui/components/gradient-picker
The color the gradient renders at position (0–100, one cycle — see the
module doc comment), computed the same way the browser would: when
repeating is on, position is first WRAPPED by the stop range's own
period [first.position, last.position) — exactly how a real
repeating-linear-gradient paints past its last stop — before anything
else runs. An exact stop match then returns that stop's own color;
otherwise the two bracketing stops are interpolated in
state.interpolation. addStop stores EXACTLY this string
(round-tripped through parseCssColor), so a new stop is
indistinguishable from the gradient it was picked off.
function colorAt(state: GradientPickerState, position: number): string
connect() from @llui/components/gradient-picker
function connect(state: ReadSignal<GradientPickerState>, send: Send<GradientPickerMsg>, opts: ConnectOptions): GradientPickerParts
init() from @llui/components/gradient-picker
function init(opts: GradientPickerInit = {}): GradientPickerState
parseGradient() from @llui/components/gradient-picker
Parse a CSS <gradient> (linear-gradient()/radial-gradient()/
conic-gradient(), repeating- included) into this component's shape.
Accepts: angles in deg/grad/rad/turn, to <side-or-corner>
(stored as a keyword direction — see GradientDirection), at <position> (percentages + left/center/right/top/bottom),
radial shape/size keywords, in <space> [<hue> hue] (omitted resolves to
srgb when every stop is a legacy color, oklab otherwise — CSS Images
4), 0/1/2 stop positions (a 2-position stop expands to two stops) with
missing positions filled per the CSS auto-positioning algorithm, and any
color parseCssColor accepts.
Explicitly REJECTS (with a reason, never silently dropping/clamping
data): length-based stop positions (only %), a stop position outside
0%-100%, an explicit radial size LENGTH (circle 40px — only the four
keyword extents), a length-based at position, a color hint (a bare
percentage in the stop list with no color), and any color with a none
component (PickerColor cannot store one — see noneComponentName).
Chroma beyond a live instance's maxChroma is NOT rejected here (this
function has no live instance to compare against) — init/setGradient
raise their own maxChroma to fit instead.
parseGradient(toCss(s)) reproduces s modulo stop ids — see the
property test.
function parseGradient(css: string): ParseGradientResult
pickerStateOf() from @llui/components/gradient-picker
Build the ColorPickerState the embedded picker edits: the SELECTED
stop's OWN color+alpha, verbatim — no HSV<->OKLCH projection. This is what
keeps a picker edit from destroying an out-of-gamut OKLCH stop's other
channels (review finding #1: oklch(0.7 0.3 150) + setAlpha 0.5 used to
come back hsv #00c24780 because the OLD version re-projected onto a
shared "active model" on every edit). update()'s 'picker' case is what
writes the result back onto the stop; a stop's model changes ONLY via an
explicit setModel picker message, which is color-picker's own reducer
— nothing here decides it.
function pickerStateOf(state: GradientPickerState): ColorPickerState
toCss() from @llui/components/gradient-picker
Serialize to a real CSS <gradient> — always with an explicit
in <space> (plus the hue method, only for a polar space and only when it
is not the shorter default) so the string never depends on a browser's
own default. Stop colors go through color-picker's own toCss rule
({@link formatStopColor}), so an out-of-gamut OKLCH stop stays exact
instead of being silently gamut-mapped through a hex round trip.
function toCss(state: GradientPickerState): string
update() from @llui/components/gradient-picker
function update(state: GradientPickerState, msg: GradientPickerMsg): [GradientPickerState, never[]]
Types
GradientDirection from @llui/components/gradient-picker
A linear gradient's direction, exactly as CSS Images 4 lets it be spelled:
an explicit angle, or to <side-or-corner>. Parsing a to right gradient
and immediately serializing it back must reproduce to right, not a
numerically-equal 90deg — only an EXPLICIT angle edit (the setAngle
message, e.g. from angleInput or the angle-slider composition) converts
a keyword direction into an angle. x/y are independently optional so a
single-axis direction (to right, to top) round-trips without a
fabricated opposite axis; both set means a corner.
export type GradientDirection =
| { type: 'angle'; deg: number }
| { type: 'to'; x?: 'left' | 'right'; y?: 'top' | 'bottom' }
GradientKind from @llui/components/gradient-picker
Gradient picker — CSS linear-gradient()/radial-gradient()/
conic-gradient() (and their repeating- forms) authoring: a horizontal
stop ramp plus per-kind controls, with a full color-picker embedded for
whichever stop is selected.
The embedded picker edits the SELECTED STOP'S OWN model
There is exactly one color store per stop (GradientStop.color, the same
PickerColor union color-picker itself uses) and exactly one place that
projects it into a live ColorPickerState: {@link pickerStateOf}. It hands
the stop's color through UNCHANGED — no HSV<->OKLCH re-projection — so a
picker message that only touches one channel (setAlpha, setHue, …)
leaves every OTHER channel of an out-of-gamut OKLCH stop bit-identical.
update()'s 'picker' case runs colorPicker.update on that DERIVED
state and writes color/alpha straight back onto the SELECTED stop; a
stop's MODEL only ever changes via an explicit picker: { msg: { type: 'setModel' } } }, which is itself just color-picker's own reducer
producing a same-shape next.color in the new model — there is no
separate "gradient-level model" to keep in sync. defaultModel is a
DIFFERENT, narrower thing: which model a freshly-added stop with no other
way to pick one starts in (see addStop/buildStopsFromOptions).
The stop-position domain is always one cycle, 0–100
repeating only changes the CSS repeating- prefix (how the SAME 0–100
ramp tiles across the painted box) — it never changes what a stop's
position number means. colorAt/addStop always read position as a
percentage of one cycle; colorAt WRAPS a query outside [first, last]
by the stop range's own period when repeating is true (matching a real
repeating-linear-gradient's paint), and clamps to the end stops
otherwise. toCss's stop list is always the one un-repeated cycle,
matching what {@link parseGradient} reads back.
export type GradientKind = 'linear' | 'radial' | 'conic'
GradientPickerMsg from @llui/components/gradient-picker
@intent group
export type GradientPickerMsg =
/** @intent("Add a stop at `position` (0-100), colored EXACTLY what the gradient already renders there") */
| { type: 'addStop'; position: number }
/** @intent("Remove the stop with the given id. Refused below `minStops`; a removed selection reselects a neighbour") */
| { type: 'removeStop'; id: string }
/** @humanOnly */
| { type: 'selectStop'; id: string }
/** @intent("Move the stop with the given id to `position` (0-100, clamped), keeping sort order and selection") */
| { type: 'moveStop'; id: string; position: number }
/** @intent("Move the stop with the given id by a signed `delta` (0-100 units)") */
| { type: 'nudgeStop'; id: string; delta: number }
/** @intent("Switch the gradient kind (linear/radial/conic)") */
| { type: 'setKind'; kind: GradientKind }
/** @intent("Toggle the `repeating-` form of the current gradient kind") */
| { type: 'setRepeating'; repeating: boolean }
/** @intent("Set an EXPLICIT angle in degrees — linear's direction (converting away from a to-keyword direction) or conic's `from` angle. No-op for radial. Normalized to [0,360)") */
| { type: 'setAngle'; angle: number }
/** @intent("Set the center position (percent 0-100 each axis) — radial and conic only") */
| { type: 'setCenter'; x: number; y: number }
/** @intent("Set the radial gradient's shape (circle/ellipse)") */
| { type: 'setShape'; shape: RadialShape }
/** @intent("Set the radial gradient's extent keyword") */
| { type: 'setSize'; size: RadialSize }
/** @intent("Set the color-interpolation-method: the space, and (polar spaces only) the hue method") */
| { type: 'setInterpolation'; space: InterpolationSpace; hue?: HueInterpolationMethod }
/** @intent("Reverse the stop order (mirrors every stop's position about the middle)") */
| { type: 'reverse' }
/** @intent("Space every stop evenly across 0-100, preserving relative order") */
| { type: 'distribute' }
/** @humanOnly */
| { type: 'setGradientDraft'; value: string }
/** @intent("Parse `css` and, if valid AND within min/maxStops, replace the whole gradient. Invalid input (or a stop count outside min/maxStops) sets a describable error and leaves the gradient unchanged") */
| { type: 'setGradient'; css: string }
/** @intent("Edit the color/alpha of the SELECTED stop through the embedded color-picker's own message set") */
| { type: 'picker'; msg: ColorPickerMsg }
/** @humanOnly */
| { type: 'selectNextStop' }
/** @humanOnly */
| { type: 'selectPrevStop' }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
ParseGradientResult from @llui/components/gradient-picker
export type ParseGradientResult =
| { ok: true; value: ParsedGradient }
| { ok: false; reason: string }
RadialShape from @llui/components/gradient-picker
export type RadialShape = 'circle' | 'ellipse'
RadialSize from @llui/components/gradient-picker
export type RadialSize = 'closest-side' | 'closest-corner' | 'farthest-side' | 'farthest-corner'
ToggleItemPart from @llui/components/gradient-picker
export type ToggleItemPart = 'kind-toggle' | 'shape-option' | 'size-option'
Interfaces
ConnectOptions from @llui/components/gradient-picker
export interface ConnectOptions {
id: string
trackLabel?: string
addStopLabel?: string
removeStopLabel?: string
kindLabels?: Partial<Record<GradientKind, string>>
repeatingLabel?: string
angleLabel?: string
centerLabel?: string
shapeLabels?: Partial<Record<RadialShape, string>>
sizeLabels?: Partial<Record<RadialSize, string>>
interpolationSpaceLabel?: string
interpolationHueLabel?: string
reverseLabel?: string
distributeLabel?: string
cssLabel?: string
/** Fine keyboard step for a stop / the center thumb (percent units).
* Default 1. */
step?: number
/** Coarse keyboard step when Shift is held. Default 10. */
coarseStep?: number
/** Forwarded to the embedded `color-picker`'s own `connect()` (labels,
* area-thumb step/coarseStep). */
pickerOptions?: ColorPickerConnectOptions
}
GradientCenter from @llui/components/gradient-picker
export interface GradientCenter {
x: number
y: number
}
GradientInterpolation from @llui/components/gradient-picker
export interface GradientInterpolation {
space: InterpolationSpace
hue: HueInterpolationMethod
}
GradientPickerInit from @llui/components/gradient-picker
export interface GradientPickerInit {
/** Parse an initial gradient from a CSS string (via {@link parseGradient}).
* Takes precedence over `stops`/`kind`/… below. Invalid CSS (or a stop
* count outside `minStops`/`maxStops`) falls back to the `stops`/defaults
* path below, same as `color-picker`'s `color` option falling back on an
* unparsable string. */
css?: string
/** Explicit initial stops (ignored if `css` parses). An EMPTY array is
* treated the same as omitting the option (never an empty gradient — see
* finding #7); fewer than `minStops` are PADDED. Sorted by `position` on
* init; ids are assigned sequentially in the SORTED order. */
stops?: GradientStopInit[]
kind?: GradientKind
repeating?: boolean
/** Sets the initial direction as an explicit angle (linear) or `from`
* angle (conic). For a `to <side>` initial direction, use `css` instead. */
angle?: number
center?: Partial<GradientCenter>
shape?: RadialShape
size?: RadialSize
interpolation?: Partial<GradientInterpolation>
/** Which model a stop with no explicit `PickerColor`/parseable string
* starts in. Default `'hsv'`. */
defaultModel?: ColorModel
maxChroma?: number
minStops?: number
maxStops?: number
disabled?: boolean
dir?: 'ltr' | 'rtl'
}
GradientPickerParts from @llui/components/gradient-picker
export interface GradientPickerParts {
root: {
'data-scope': 'gradient-picker'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
'data-kind': ReadSignal<GradientKind>
'data-repeating': ReadSignal<'' | undefined>
}
preview: {
'data-scope': 'gradient-picker'
'data-part': 'preview'
'aria-hidden': 'true'
style: ReadSignal<string>
}
/** The horizontal stop ramp: a plain `in <space>` linear ramp of the SAME
* stop list `toCss` serializes (finding #3 — one shared builder, never two
* strings that can drift), including the hue method and the
* `repeating-`/rtl-mirrored angle so what the track PAINTS matches
* `colorAt` at every position, regardless of `state.kind`. Pointerdown on
* the bare track (not on an existing stop, which calls
* `stopPropagation()`) adds a new stop at that position and starts
* dragging it immediately. */
track: {
'data-scope': 'gradient-picker'
'data-part': 'track'
style: ReadSignal<string>
onPointerDown: (e: PointerEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerUp: (e: PointerEvent) => void
onPointerCancel: (e: PointerEvent) => void
onLostPointerCapture: (e: PointerEvent) => void
}
stop: (id: string) => GradientStopParts
addStopButton: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'gradient-picker'
'data-part': 'add-stop-button'
onClick: (e: MouseEvent) => void
}
removeStopButton: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'gradient-picker'
'data-part': 'remove-stop-button'
onClick: (e: MouseEvent) => void
}
kindToggle: (kind: GradientKind) => ToggleItemParts
repeatingToggle: {
type: 'button'
'aria-label': string
'aria-pressed': ReadSignal<boolean>
disabled: ReadSignal<boolean>
'data-scope': 'gradient-picker'
'data-part': 'repeating-toggle'
'data-state': ReadSignal<'on' | 'off'>
onClick: (e: MouseEvent) => void
}
angleInput: {
type: 'range'
min: 0
max: 360
step: 1
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'gradient-picker'
'data-part': 'angle-input'
onInput: (e: Event) => void
}
centerArea: {
'data-scope': 'gradient-picker'
'data-part': 'center-area'
onPointerDown: (e: PointerEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerUp: (e: PointerEvent) => void
onPointerCancel: (e: PointerEvent) => void
onLostPointerCapture: (e: PointerEvent) => void
}
centerThumb: {
role: 'slider'
'aria-label': string
'aria-valuemin': 0
'aria-valuemax': 100
'aria-valuenow': ReadSignal<number>
'aria-valuetext': ReadSignal<string>
'aria-disabled': ReadSignal<'true' | undefined>
tabindex: ReadSignal<number>
'data-scope': 'gradient-picker'
'data-part': 'center-thumb'
style: ReadSignal<string>
onKeyDown: (e: KeyboardEvent) => void
}
shapeOption: (shape: RadialShape) => ToggleItemParts
sizeOption: (size: RadialSize) => ToggleItemParts
interpolationSpaceSelect: {
'aria-label': string
disabled: ReadSignal<boolean>
value: ReadSignal<InterpolationSpace>
'data-scope': 'gradient-picker'
'data-part': 'interpolation-space-select'
onInput: (e: Event) => void
}
interpolationHueSelect: {
'aria-label': string
/** Disabled when the whole picker is disabled, OR the current space has
* no hue (only `hsl`/`oklch` do). */
disabled: ReadSignal<boolean>
value: ReadSignal<HueInterpolationMethod>
'data-scope': 'gradient-picker'
'data-part': 'interpolation-hue-select'
onInput: (e: Event) => void
}
reverseButton: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'gradient-picker'
'data-part': 'reverse-button'
onClick: (e: MouseEvent) => void
}
distributeButton: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'gradient-picker'
'data-part': 'distribute-button'
onClick: (e: MouseEvent) => void
}
/** The draft, NOT the committed value, while editing — see
* `GradientPickerState.cssDraft`. Commits on `change`/Enter, never on
* every keystroke, so the caret never jumps mid-edit; `aria-invalid` +
* `aria-describedby` (pointing at `cssError`'s `id`) publish validity. */
cssInput: {
type: 'text'
autocomplete: 'off'
spellcheck: 'false'
'aria-label': string
'aria-invalid': ReadSignal<'true' | undefined>
'aria-describedby': ReadSignal<string | undefined>
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'gradient-picker'
'data-part': 'css-input'
onInput: (e: Event) => void
onChange: (e: Event) => void
onKeyDown: (e: KeyboardEvent) => void
}
/** The describable error region `cssInput`'s `aria-describedby` points at
* — empty/hidden text when there is no error, matching `form-field`'s
* `errorText` convention (attributes + a sibling `visible`/`message`
* rather than folding non-attribute content into the same bag). */
cssError: {
id: string
role: 'alert'
'data-scope': 'gradient-picker'
'data-part': 'css-error'
visible: ReadSignal<boolean>
message: ReadSignal<string>
}
/**
* The embedded `color-picker`'s FULL part bag, connected over the DERIVED
* `pickerStateOf(state)` — eyedropper, OKLCH canvas, model toggle, every
* slider, all included with zero glue. Messages are wrapped as
* `{ type: 'picker'; msg }` via `wrapChildSend` (which also tags the
* dispatcher `__lluiVariants: ['picker']`, so every handler `color-picker`
* builds from it reports the truthful PARENT-visible type) and unwrapped
* by this component's `update()` — see the module doc comment.
*/
picker: ColorPickerParts
}
GradientPickerState from @llui/components/gradient-picker
export interface GradientPickerState {
kind: GradientKind
repeating: boolean
/** Linear only. Ignored (but still stored — the machine always publishes
* both per-kind clusters; the view branches on `kind`) for radial/conic. */
direction: GradientDirection
/** Degrees, conic's `from` angle only. Conic has no `to <side>` form in
* CSS at all, so — unlike linear — it never needs the `GradientDirection`
* union; it is always a plain angle. */
conicAngle: number
/** Percent 0–100. Radial + conic only. */
center: GradientCenter
/** Radial only. */
shape: RadialShape
/** Radial only. */
size: RadialSize
interpolation: GradientInterpolation
/** Kept sorted by `position` ascending; ties keep insertion order. */
stops: GradientStop[]
selectedId: string
/** The next deterministic stop id counter (`` `s${nextId}` ``) — the reducer
* stays pure, so ids can never come from `Math.random()`/`crypto`. MONOTONE
* across a `setGradient`: a successful replace continues the counter
* rather than restarting at `s1`, so an id a consumer captured earlier
* (a DOM id, a test assertion, an undo/redo log) is never reissued to a
* DIFFERENT stop. */
nextId: number
/** Which model a freshly-added stop starts in when nothing else determines
* one (`buildStopsFromOptions`'s fallback color). Distinct from any
* EXISTING stop's own stored model, which `picker`/`setModel` edits
* in-place — see the module doc comment. */
defaultModel: ColorModel
/** Upper bound for OKLCH chroma — shared with the embedded picker. NEVER
* clamps an author-specified chroma down (CLAUDE.md's "never clamp/drop"
* rule, review finding #2c): `init`/`setGradient` RAISE this to fit
* whatever chroma the input actually contains instead. */
maxChroma: number
/** `removeStop` refuses to go below this many stops; `init` PADS up to it
* if given fewer; `setGradient` REJECTS a parse that would drop below it
* (never truncates/pads a user-authored CSS gradient — see the module doc
* on `setGradient`). Default 2. Always a positive integer. */
minStops: number
/**
* Upper bound on stop count, or ABSENT for unbounded. Follows the
* package's UNBOUNDED-CAPABLE idiom (CLAUDE.md #177, `breadcrumbs.
* maxVisible`): the key is OMITTED rather than holding `null`, so a
* `JSON.parse(JSON.stringify(state))` round trip is a key-for-key
* identity. Always a positive integer `>= minStops` when present — `init`
* normalizes a non-integer or too-low value rather than producing a
* config that could never be satisfied.
*/
maxStops?: number
disabled: boolean
/** Reading direction. Under `rtl`, the track's physical left/right (both
* the stop ramp's own background angle and each stop's `left%`) mirrors —
* see `mirrorForDir`'s doc comment on why this differs from
* `color-picker`'s (native-input) hue slider. */
dir: 'ltr' | 'rtl'
/** Whether the browser's EyeDropper API is available — mirrors
* `color-picker`'s own `eyeDropperSupported` field (never `true` at
* `init()`; only `eyeDropperSupportMount`, wired through the `picker`
* wrapper in `connect()`, ever flips it). Lives at the GRADIENT level, not
* per-stop — it is a mount-time browser capability, not a color. */
eyeDropperSupported: boolean
/** The uncommitted `cssInput` text while the user is actively editing, or
* `null` when not editing (display falls back to the live `toCss(state)`
* value). Kept OUT of the committed gradient so the input's own caret
* never jumps mid-keystroke — see `setGradientDraft`/`setGradient`. */
cssDraft: string | null
/** The reason the last `setGradient` attempt failed, or `null`. Cleared by
* the NEXT successful `setGradient`; typing (`setGradientDraft`) does NOT
* clear it, so the error stays visible while the user is mid-correction. */
cssError: string | null
}
GradientStop from @llui/components/gradient-picker
export interface GradientStop {
id: string
color: PickerColor
alpha: number
/** 0–100, one cycle — see the module doc comment. */
position: number
}
GradientStopInit from @llui/components/gradient-picker
export interface GradientStopInit {
color?: PickerColor | string
alpha?: number
position: number
}
GradientStopParts from @llui/components/gradient-picker
export interface GradientStopParts {
id: string
role: 'slider'
'aria-label': string
'aria-valuemin': 0
'aria-valuemax': 100
'aria-valuenow': ReadSignal<number>
'aria-valuetext': ReadSignal<string>
'aria-disabled': ReadSignal<'true' | undefined>
/** Every stop is independently tabbable (never -1 while enabled) — the
* package's existing multi-thumb precedent (`slider.ts`'s `SliderThumbParts`)
* rather than a roving single-tab-stop composite: APG's roving-tabindex
* pattern is for ONE composite control with ONE conceptual focus (a
* toolbar, a listbox); a multi-stop gradient ramp is closer to several
* independent sliders, which is exactly the case `slider.ts` already
* chose this way for. Tab therefore visits stops in DOM order, which
* tracks position order since `each()` renders the reducer's own
* position-sorted `stops` array. */
tabindex: ReadSignal<number>
'data-scope': 'gradient-picker'
'data-part': 'stop'
'data-value': string
'data-selected': ReadSignal<'' | undefined>
style: ReadSignal<string>
onPointerDown: (e: PointerEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerUp: (e: PointerEvent) => void
onPointerCancel: (e: PointerEvent) => void
onLostPointerCapture: (e: PointerEvent) => void
onFocus: (e: FocusEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
ParsedGradient from @llui/components/gradient-picker
export interface ParsedGradient {
kind: GradientKind
repeating: boolean
direction: GradientDirection
conicAngle: number
center: GradientCenter
shape: RadialShape
size: RadialSize
interpolation: GradientInterpolation
stops: Array<{ color: PickerColor; alpha: number; position: number }>
}
ToggleItemParts from @llui/components/gradient-picker
export interface ToggleItemParts {
type: 'button'
'aria-label': string
'aria-pressed': ReadSignal<boolean>
'data-scope': 'gradient-picker'
'data-part': ToggleItemPart
'data-value': string
'data-state': ReadSignal<'on' | 'off'>
disabled: ReadSignal<boolean>
onClick: (e: MouseEvent) => void
}
Constants
gradientPicker from @llui/components/gradient-picker
const gradientPicker
@llui/components/timer
Functions
connect() from @llui/components/timer
function connect(state: ReadSignal<TimerState>, send: Send<TimerMsg>, opts: ConnectOptions = {}): TimerParts
display() from @llui/components/timer
Returns the display value in ms (elapsed for count-up, remaining for count-down).
function display(state: TimerState): number
formatMs() from @llui/components/timer
Format a ms value using a simple template. Supported tokens: HH / H — hours (2-digit / unpadded) mm / m — minutes ss / s — seconds SSS / S — milliseconds (3-digit / unpadded)
Example: formatMs(125_500, 'mm:ss.SSS') → "02:05.500"
function formatMs(ms: number, template: string): string
init() from @llui/components/timer
function init(opts: TimerInit = {}): TimerState
isComplete() from @llui/components/timer
function isComplete(state: TimerState): boolean
parts() from @llui/components/timer
Breaks a ms value into { hours, minutes, seconds, ms } parts for rendering.
function parts(ms: number): { hours: number; minutes: number; seconds: number; ms: number }
update() from @llui/components/timer
function update(state: TimerState, msg: TimerMsg): [TimerState, never[]]
Types
Direction from @llui/components/timer
Timer — counts elapsed time up from zero, or down from a configured
target. The machine is pure: it doesn't own the ticking interval.
The consumer runs setInterval(() => send({type:'tick', now: Date.now()}), 100)
(or whatever granularity) while the timer is running, and dispatches
start / pause / reset in response to user input.
// `div`, `button`, `text`, `mapSend` are imports from '@llui/dom'.
view: ({ state, send }) => {
const timerState = state.at('timer')
const timerSend = mapSend<Msg, timer.TimerMsg>(send, (msg) => ({
type: 'timer',
msg,
}))
const t = timer.connect(timerState, timerSend)
return [
div({ ...t.root }, [
div({ ...t.display }, [
text(timerState.map((s) => timer.formatMs(timer.display(s), 'mm:ss'))),
]),
button({ ...t.startTrigger }, [text('Start')]),
button({ ...t.pauseTrigger }, [text('Pause')]),
button({ ...t.resetTrigger }, [text('Reset')]),
]),
]
}
export type Direction = 'up' | 'down'
TimerMsg from @llui/components/timer
export type TimerMsg =
/** @intent("Start (or resume) the timer running") */
| { type: 'start'; now: number }
/** @intent("Pause the timer (preserves accumulated elapsed time)") */
| { type: 'pause'; now: number }
/** @intent("Reset the timer back to zero elapsed and pause it") */
| { type: 'reset' }
/** @humanOnly */
| { type: 'tick'; now: number }
/** @intent("Set the countdown target (in milliseconds; 0 disables countdown)") */
| { type: 'setTarget'; targetMs: number }
Interfaces
ConnectOptions from @llui/components/timer
export interface ConnectOptions {
startLabel?: string
pauseLabel?: string
resetLabel?: string
/**
* aria-live politeness for the display element. `'polite'` announces
* updates to assistive tech; `'off'` (default) keeps it silent — use
* 'polite' sparingly to avoid spamming screen reader users with
* every tick.
*/
ariaLive?: 'off' | 'polite'
}
TimerInit from @llui/components/timer
export interface TimerInit {
direction?: Direction
targetMs?: number
elapsedMs?: number
}
TimerParts from @llui/components/timer
export interface TimerParts {
root: {
'data-scope': 'timer'
'data-part': 'root'
'data-running': ReadSignal<'' | undefined>
'data-direction': ReadSignal<Direction>
/** Present once a countdown has reached its target (see `isComplete`). */
'data-complete': ReadSignal<'' | undefined>
}
display: {
role: 'timer'
'aria-live': 'off' | 'polite'
'data-scope': 'timer'
'data-part': 'display'
}
startTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'timer'
'data-part': 'start-trigger'
disabled: ReadSignal<boolean>
onClick: (e: MouseEvent) => void
}
pauseTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'timer'
'data-part': 'pause-trigger'
disabled: ReadSignal<boolean>
onClick: (e: MouseEvent) => void
}
resetTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'timer'
'data-part': 'reset-trigger'
onClick: (e: MouseEvent) => void
}
}
TimerState from @llui/components/timer
export interface TimerState {
running: boolean
direction: Direction
/** Target in milliseconds for countdown (0 = no target, runs indefinitely). */
targetMs: number
/** Accumulated elapsed time, excluding the current running interval. */
elapsedMs: number
/** Timestamp when the current running interval started (null when paused). */
startedAt: number | null
}
Constants
timer from @llui/components/timer
const timer
@llui/components/angle-slider
Functions
angleFromPoint() from @llui/components/angle-slider
Compute the angle in degrees from the center of a rect to a point. 0° = up (12 o'clock), increases clockwise. Result is in 0..360.
Useful inside a pointermove handler: const rect = control.getBoundingClientRect() const angle = angleFromPoint(rect, e.clientX, e.clientY) send({ type: 'setValue', value: angle })
function angleFromPoint(rect: DOMRect, x: number, y: number): number
connect() from @llui/components/angle-slider
function connect(state: ReadSignal<AngleSliderState>, send: Send<AngleSliderMsg>, opts: ConnectOptions = {}): AngleSliderParts
init() from @llui/components/angle-slider
function init(opts: AngleSliderInit = {}): AngleSliderState
pointFromAngle() from @llui/components/angle-slider
Convert an angle to (x, y) on a unit circle (radius 1 at origin).
function pointFromAngle(angleDeg: number): { x: number; y: number }
update() from @llui/components/angle-slider
function update(state: AngleSliderState, msg: AngleSliderMsg): [AngleSliderState, never[]]
Types
AngleSliderMsg from @llui/components/angle-slider
export type AngleSliderMsg =
/** @intent("Set the angle in degrees (clamped to min/max, snapped to step)") */
| { type: 'setValue'; value: number }
/** @intent("Increase the angle by `steps` × step (default: 1 step)") */
| { type: 'increment'; steps?: number }
/** @intent("Decrease the angle by `steps` × step (default: 1 step)") */
| { type: 'decrement'; steps?: number }
/** @humanOnly */
| { type: 'setMin'; min: number }
/** @humanOnly */
| { type: 'setMax'; max: number }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
Interfaces
AngleSliderInit from @llui/components/angle-slider
export interface AngleSliderInit {
value?: number
min?: number
max?: number
step?: number
disabled?: boolean
readonly?: boolean
dir?: 'ltr' | 'rtl'
}
AngleSliderParts from @llui/components/angle-slider
export interface AngleSliderParts {
root: {
role: 'slider'
'aria-valuemin': ReadSignal<number>
'aria-valuemax': ReadSignal<number>
'aria-valuenow': ReadSignal<number>
'aria-valuetext': ReadSignal<string>
'aria-orientation': 'horizontal'
'aria-disabled': ReadSignal<'true' | undefined>
'aria-readonly': ReadSignal<'true' | undefined>
tabindex: ReadSignal<number>
'data-scope': 'angle-slider'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
onKeyDown: (e: KeyboardEvent) => void
}
control: {
'data-scope': 'angle-slider'
'data-part': 'control'
}
/**
* The draggable thumb element. Its position is typically computed via
* CSS custom properties `--angle` (0..360) that the consumer sets from
* `state.value` using pointFromAngle() or a CSS `transform: rotate()`.
*/
thumb: {
'data-scope': 'angle-slider'
'data-part': 'thumb'
'data-value': ReadSignal<string>
}
valueText: {
'data-scope': 'angle-slider'
'data-part': 'value-text'
}
/** A hidden input for form participation. */
hiddenInput: {
type: 'hidden'
value: ReadSignal<string>
name?: string
'data-scope': 'angle-slider'
'data-part': 'hidden-input'
}
}
AngleSliderState from @llui/components/angle-slider
Angle slider — a circular input that selects a value in 0..360 degrees by dragging a thumb around a control. The state machine tracks the current angle; the view layer computes angles from pointer positions (helpers exported for that purpose).
Typical view wiring: on pointerdown/pointermove, read the control
element's bounding rect, compute the angle from (pointerX, pointerY)
to the rect center via angleFromPoint(), and dispatch setValue.
Keyboard: Arrow keys adjust by step; Home/End jump to min/max;
PageUp/PageDown adjust by step * 10.
export interface AngleSliderState {
value: number
min: number
max: number
step: number
disabled: boolean
readonly: boolean
/** Reading direction. Under 'rtl' horizontal arrow keys are flipped. */
dir: 'ltr' | 'rtl'
}
ConnectOptions from @llui/components/angle-slider
export interface ConnectOptions {
/** Name for the hidden input (form integration). */
name?: string
/** Formatter for aria-valuetext (default: "{value}°"). */
format?: (value: number) => string
}
Constants
angleSlider from @llui/components/angle-slider
const angleSlider
@llui/components/marquee
Functions
axis() from @llui/components/marquee
Returns 'horizontal' or 'vertical' based on direction.
function axis(direction: MarqueeDirection): 'horizontal' | 'vertical'
connect() from @llui/components/marquee
function connect(state: ReadSignal<MarqueeState>, send: Send<MarqueeMsg>): MarqueeParts
cssAnimationDirection() from @llui/components/marquee
Returns 'normal' (left/up) or 'reverse' (right/down) for CSS animation-direction.
function cssAnimationDirection(direction: MarqueeDirection): 'normal' | 'reverse'
init() from @llui/components/marquee
function init(opts: MarqueeInit = {}): MarqueeState
isRunning() from @llui/components/marquee
Derived: whether the marquee is currently animating.
function isRunning(state: MarqueeState): boolean
update() from @llui/components/marquee
function update(state: MarqueeState, msg: MarqueeMsg): [MarqueeState, never[]]
Types
MarqueeDirection from @llui/components/marquee
Marquee — continuously-scrolling content. The state machine tracks play/pause + direction + speed; the scrolling itself is driven by CSS animations or JS requestAnimationFrame (the consumer owns that).
Expose the active state via CSS custom properties the consumer reads in their stylesheet: --marquee-duration: {N}s --marquee-direction: 'normal' | 'reverse' --marquee-playstate: 'running' | 'paused'
export type MarqueeDirection = 'left' | 'right' | 'up' | 'down'
MarqueeMsg from @llui/components/marquee
export type MarqueeMsg =
/** @intent("Resume the marquee scrolling") */
| { type: 'play' }
/** @intent("Pause the marquee scrolling") */
| { type: 'pause' }
/** @intent("Toggle the marquee between playing and paused") */
| { type: 'toggle' }
/** @humanOnly */
| { type: 'hoverPause' }
/** @humanOnly */
| { type: 'hoverResume' }
/** @intent("Change the scroll direction (left/right/up/down)") */
| { type: 'setDirection'; direction: MarqueeDirection }
/** @intent("Change the loop duration in seconds (larger = slower)") */
| { type: 'setDuration'; durationSec: number }
Interfaces
MarqueeInit from @llui/components/marquee
export interface MarqueeInit {
running?: boolean
direction?: MarqueeDirection
durationSec?: number
pauseOnHover?: boolean
disabled?: boolean
}
MarqueeParts from @llui/components/marquee
export interface MarqueeParts {
root: {
'data-scope': 'marquee'
'data-part': 'root'
'data-running': ReadSignal<'' | undefined>
'data-direction': ReadSignal<MarqueeDirection>
'data-axis': ReadSignal<'horizontal' | 'vertical'>
'data-disabled': ReadSignal<'' | undefined>
'aria-disabled': ReadSignal<'true' | undefined>
style: ReadSignal<string>
onMouseEnter: (e: MouseEvent) => void
onMouseLeave: (e: MouseEvent) => void
}
content: {
'data-scope': 'marquee'
'data-part': 'content'
}
}
MarqueeState from @llui/components/marquee
export interface MarqueeState {
/** User-intended running state (what play/pause/toggle set). The actual
* effective state is derived via `isRunning()` — it combines this with
* `hovered` + `pauseOnHover`. */
running: boolean
direction: MarqueeDirection
/** Duration of one full loop in seconds. Larger = slower. */
durationSec: number
pauseOnHover: boolean
hovered: boolean
disabled: boolean
}
Constants
marquee from @llui/components/marquee
const marquee
@llui/components/presence
Functions
connect() from @llui/components/presence
Signal-surface connect: takes the component's presence state slice as a
Signal and returns reactive (handle-based) props for spreading into a view.
function connect(state: ReadSignal<PresenceState>, send: Send<PresenceMsg>): PresenceParts
init() from @llui/components/presence
function init(opts: PresenceInit = {}): PresenceState
isAnimating() from @llui/components/presence
function isAnimating(state: PresenceState): boolean
isMounted() from @llui/components/presence
Whether the element should be in the DOM (mounted).
function isMounted(state: PresenceState): boolean
isVisible() from @llui/components/presence
Whether the element is visible (not running an exit animation).
function isVisible(state: PresenceState): boolean
presenceClose() from @llui/components/presence
Move toward closed — the mirror of {@link presenceOpen}.
function presenceClose<S extends PresenceOverlay>(state: S, skipAnimations: boolean): S
presenceEnd() from @llui/components/presence
Advance past the enter/exit animation. Only 'opening'/'closing' move; any other status is returned unchanged, so a stray end event cannot reopen or unmount anything.
SEMANTIC ADDITION over the four private copies this collapsed (#126): the
closing->closed transition also writes open: false. It is unreachable
today, but the reason is narrower than "nothing else writes 'closing'":
other code does — update's own 'close' case above and toast.ts's
dismiss both write it — and neither carries an open field or reduces
through this function. The claim is scoped to {@link PresenceOverlay}: among
the states that DO reach here, 'closing' is only ever written together
with open: false, by {@link presenceClose}. So it changes no behaviour,
and it is kept because "finished closing" implying "not open" is the
invariant a caller reducing through this function is entitled to, and a
future writer of 'closing' on an overlay should not be able to leave the
pair inconsistent.
function presenceEnd<S extends PresenceOverlay>(state: S): S
presenceOpen() from @llui/components/presence
Move toward open. skipAnimations lands on 'open' immediately; otherwise the
overlay sits in 'opening' until an end event calls {@link presenceEnd}.
Already-open state comes back by REFERENCE so a redundant open is a no-op for
the reference-equality reconciler.
function presenceOpen<S extends PresenceOverlay>(state: S, skipAnimations: boolean): S
update() from @llui/components/presence
function update(state: PresenceState, msg: PresenceMsg): [PresenceState, never[]]
Types
PresenceMsg from @llui/components/presence
export type PresenceMsg =
/** @intent("Begin opening the element (closed → opening, plays enter animation)") */
| { type: 'open' }
/** @intent("Begin closing the element (open → closing, plays exit animation)") */
| { type: 'close' }
/** @intent("Toggle between open and closed states") */
| { type: 'toggle' }
/** @humanOnly */
| { type: 'animationEnd' }
/** @intent("Set the desired presence directly (true = open, false = closed)") */
| { type: 'setPresent'; present: boolean }
PresenceStatus from @llui/components/presence
Presence — track mount/unmount lifecycle with exit-delay support.
In many components (dialogs, tooltips, menus) the consumer wants to:
- close the overlay (fire exit animation)
- keep it mounted long enough for the animation to finish
- unmount it
LLui already provides @llui/transitions for most of this, but a
presence machine is useful when you want to coordinate multiple
elements or expose state outside the transition primitive.
State flow: closed → (open) → opening → open open → (close) → closing → closed
The consumer fires animationEnd to advance past opening/closing.
If unmountOnExit is true, closed means "safe to remove from DOM";
otherwise the element stays mounted even when closed (display:none).
export type PresenceStatus = 'closed' | 'opening' | 'open' | 'closing'
Interfaces
PresenceInit from @llui/components/presence
export interface PresenceInit {
/** Initial presence — true starts in 'open', false starts in 'closed'. */
present?: boolean
/** Whether 'closed' means "unmount" (true) or "hidden but mounted" (false). Default: true. */
unmountOnExit?: boolean
}
PresenceOverlay from @llui/components/presence
The presence slice an OVERLAY carries alongside its own state: the logical
open flag plus the animation phase layered over it.
Dialog, drawer, popover, hover-card and tooltip each had a byte-identical private copy of the three transitions below (#126). The machines agreed — their HANDLERS did not — but five copies is five chances to drift, so the transitions live here and every overlay reduces through them.
export interface PresenceOverlay {
open: boolean
/** Optional because a partial `{ open }` bridge slice may omit it; an absent
* status is neither opening nor closing, so it only ever gets WRITTEN. */
status?: PresenceStatus
}
PresenceParts from @llui/components/presence
export interface PresenceParts {
root: {
'data-scope': 'presence'
'data-part': 'root'
'data-state': ReadSignal<PresenceStatus>
hidden: ReadSignal<boolean>
onAnimationEnd: (e: AnimationEvent) => void
onTransitionEnd: (e: TransitionEvent) => void
}
}
PresenceState from @llui/components/presence
export interface PresenceState {
status: PresenceStatus
unmountOnExit: boolean
}
Constants
presence from @llui/components/presence
const presence
@llui/components/signature-pad
Functions
canUndo() from @llui/components/signature-pad
True when undo would change something: a stroke to remove or a clear to restore.
function canUndo(state: SignaturePadState): boolean
connect() from @llui/components/signature-pad
function connect(state: ReadSignal<SignaturePadState>, send: Send<SignaturePadMsg>, opts: ConnectOptions = {}): SignaturePadParts
getBounds() from @llui/components/signature-pad
Compute the axis-aligned bounding box of all strokes, or null if empty. Useful for cropping the exported signature tightly.
function getBounds(state: SignaturePadState): { x: number; y: number; width: number; height: number } | null
init() from @llui/components/signature-pad
function init(opts: SignaturePadInit = {}): SignaturePadState
isEmpty() from @llui/components/signature-pad
function isEmpty(state: SignaturePadState): boolean
pointCount() from @llui/components/signature-pad
Total number of points across all strokes + current.
function pointCount(state: SignaturePadState): number
update() from @llui/components/signature-pad
function update(state: SignaturePadState, msg: SignaturePadMsg): [SignaturePadState, never[]]
Types
SignaturePadMsg from @llui/components/signature-pad
export type SignaturePadMsg =
/** @humanOnly */
| { type: 'strokeStart'; x: number; y: number; pressure?: number }
/** @humanOnly */
| { type: 'strokePoint'; x: number; y: number; pressure?: number }
/** @humanOnly */
| { type: 'strokeEnd' }
/** @humanOnly */
| { type: 'strokeCancel' }
/** @intent("Undo the last completed stroke") */
| { type: 'undo' }
/** @humanOnly */
| { type: 'redo'; stroke: Stroke }
/** @intent("Erase the entire signature") */
| { type: 'clear' }
/** @humanOnly */
| { type: 'setStrokes'; strokes: Stroke[] }
Stroke from @llui/components/signature-pad
export type Stroke = Point[]
Interfaces
ConnectOptions from @llui/components/signature-pad
export interface ConnectOptions {
label?: string
clearLabel?: string
undoLabel?: string
name?: string
}
Point from @llui/components/signature-pad
Signature pad — capture free-form strokes on a canvas. The state machine tracks strokes as arrays of points; the view renders them onto a
Pointer event wiring in the view layer:
onPointerDown: (e) => { canvas.setPointerCapture(e.pointerId) send({ type: 'strokeStart', x: e.offsetX, y: e.offsetY }) } onPointerMove: (e) => { if (state.drawing) send({ type: 'strokePoint', x: e.offsetX, y: e.offsetY }) } onPointerUp: () => send({ type: 'strokeEnd' })
export interface Point {
x: number
y: number
/** Pressure 0..1 (optional; from PointerEvent.pressure). */
pressure?: number
}
SignaturePadInit from @llui/components/signature-pad
export interface SignaturePadInit {
strokes?: Stroke[]
disabled?: boolean
readonly?: boolean
}
SignaturePadParts from @llui/components/signature-pad
export interface SignaturePadParts {
root: {
role: 'application'
'aria-label': string
'data-scope': 'signature-pad'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
'data-readonly': ReadSignal<'' | undefined>
'data-drawing': ReadSignal<'' | undefined>
/** Present while nothing has been drawn — the placeholder hook. */
'data-empty': ReadSignal<'' | undefined>
}
control: {
'data-scope': 'signature-pad'
'data-part': 'control'
}
clearTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'signature-pad'
'data-part': 'clear-trigger'
onClick: (e: MouseEvent) => void
}
undoTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'signature-pad'
'data-part': 'undo-trigger'
onClick: (e: MouseEvent) => void
}
guide: {
'data-scope': 'signature-pad'
'data-part': 'guide'
'aria-hidden': 'true'
}
hiddenInput: {
type: 'hidden'
value: ReadSignal<string>
name?: string
'data-scope': 'signature-pad'
'data-part': 'hidden-input'
}
}
SignaturePadState from @llui/components/signature-pad
export interface SignaturePadState {
strokes: Stroke[]
/** Stroke currently being drawn, or null. */
current: Stroke | null
drawing: boolean
disabled: boolean
readonly: boolean
/**
* The strokes the last `clear` removed, until the next edit — what makes a
* destructive clear undoable (`undo` right after `clear` restores them).
* `null` when there is nothing to restore (#266).
*/
cleared: Stroke[] | null
}
Constants
signaturePad from @llui/components/signature-pad
const signaturePad
@llui/components/toc
Functions
connect() from @llui/components/toc
function connect(state: ReadSignal<TocState>, send: Send<TocMsg>, opts: ConnectOptions = {}): TocParts
init() from @llui/components/toc
function init(opts: TocInit = {}): TocState
isActive() from @llui/components/toc
function isActive(state: TocState, id: string): boolean
isExpanded() from @llui/components/toc
function isExpanded(state: TocState, id: string): boolean
update() from @llui/components/toc
function update(state: TocState, msg: TocMsg): [TocState, never[]]
watchActiveHeading() from @llui/components/toc
Install an IntersectionObserver that watches heading elements and
dispatches setActive as the user scrolls. Call from onMount and
invoke the returned function on unmount.
rootMargin defaults to '0px 0px -80% 0px' — a heading is considered
active once its top edge enters the top 20% of the viewport.
function watchActiveHeading(send: Send<TocMsg>, selector: string = '[id][data-toc]', rootMargin: string = '0px 0px -80% 0px'): () => void
Types
TocMsg from @llui/components/toc
export type TocMsg =
/** @humanOnly */
| { type: 'setItems'; items: TocEntry[] }
/** @humanOnly */
| { type: 'setActive'; id: string | null }
/** @intent("Toggle the expanded state of the entry with the given id") */
| { type: 'toggleExpanded'; id: string }
/** @intent("Expand every collapsible entry") */
| { type: 'expandAll' }
/** @intent("Collapse every expanded entry") */
| { type: 'collapseAll' }
Interfaces
ConnectOptions from @llui/components/toc
export interface ConnectOptions {
label?: string
/** Prefix for href targets (default: '#'). */
hrefPrefix?: string
expandLabel?: string
}
TocEntry from @llui/components/toc
Table of contents — a navigation list that tracks which heading is
currently visible in the main scroll area and highlights it. The
state machine tracks the flat list of heading ids and the currently
active one; the view layer installs an IntersectionObserver in
onMount to detect which heading is on screen and dispatches
setActive.
Typical setup in onMount:
const headings = document.querySelectorAll('h2[id], h3[id]') const io = new IntersectionObserver((entries) => { for (const e of entries) { if (e.isIntersecting) send({ type: 'setActive', id: e.target.id }) } }, { rootMargin: '0px 0px -80% 0px' }) headings.forEach((h) => io.observe(h)) return () => io.disconnect()
export interface TocEntry {
id: string
label: string
/** Nesting level (1 = top-level). */
level: number
}
TocInit from @llui/components/toc
export interface TocInit {
items?: TocEntry[]
activeId?: string | null
expanded?: string[]
}
TocItemParts from @llui/components/toc
export interface TocItemParts {
item: {
'data-scope': 'toc'
'data-part': 'item'
'data-level': string
'data-active': ReadSignal<'' | undefined>
'data-value': string
}
link: {
href: string
'aria-current': ReadSignal<'location' | undefined>
'data-scope': 'toc'
'data-part': 'link'
'data-active': ReadSignal<'' | undefined>
}
expandTrigger: {
type: 'button'
'aria-expanded': ReadSignal<boolean>
'aria-label': string
'data-scope': 'toc'
'data-part': 'expand-trigger'
'data-state': ReadSignal<'open' | 'closed'>
onClick: (e: MouseEvent) => void
}
}
TocParts from @llui/components/toc
export interface TocParts {
root: {
role: 'navigation'
'aria-label': string
'data-scope': 'toc'
'data-part': 'root'
}
list: {
role: 'list'
'data-scope': 'toc'
'data-part': 'list'
}
item: (entry: TocEntry) => TocItemParts
}
TocState from @llui/components/toc
export interface TocState {
items: TocEntry[]
activeId: string | null
/** Ids of entries the user has manually expanded (for collapsible sub-levels). */
expanded: string[]
}
Constants
toc from @llui/components/toc
const toc
@llui/components/tour
Functions
connect() from @llui/components/tour
function connect(state: ReadSignal<TourState>, send: Send<TourMsg>, opts: ConnectOptions): TourParts
currentStep() from @llui/components/tour
function currentStep(state: TourState): TourStep | null
init() from @llui/components/tour
function init(opts: TourInit = {}): TourState
isFirst() from @llui/components/tour
function isFirst(state: TourState): boolean
isLast() from @llui/components/tour
function isLast(state: TourState): boolean
progress() from @llui/components/tour
function progress(state: TourState): { current: number; total: number }
update() from @llui/components/tour
function update(state: TourState, msg: TourMsg): [TourState, never[]]
Types
TourMsg from @llui/components/tour
export type TourMsg =
/** @intent("Begin the tour at the first step (or current index if resuming)") */
| { type: 'start' }
/** @intent("Close the tour without finishing (does not reset progress)") */
| { type: 'stop' }
/** @intent("Advance to the next step (closes the tour after the last step)") */
| { type: 'next' }
/** @intent("Go back to the previous step") */
| { type: 'prev' }
/** @intent("Jump to a specific step by zero-based index") */
| { type: 'goto'; index: number }
/** @humanOnly */
| { type: 'setSteps'; steps: TourStep[] }
Interfaces
ConnectOptions from @llui/components/tour
export interface ConnectOptions {
id: string
closeLabel?: string
/** Whether clicking the backdrop stops the tour. Default: false — tours
* typically require an explicit dismiss. */
closeOnBackdropClick?: boolean
/**
* Whether the consumer renders the `description` part (default: true — a
* `TourStep` always carries one). When false, the root omits
* `aria-describedby`, which would otherwise name an element that does not
* exist (the `dialog` `hasDescription` rule, #268). The title names the
* dialog and stays mandatory.
*/
hasDescription?: boolean
}
TourInit from @llui/components/tour
export interface TourInit {
steps?: TourStep[]
open?: boolean
index?: number
}
TourParts from @llui/components/tour
export interface TourParts {
root: {
role: 'dialog'
'aria-modal': 'false'
'aria-labelledby': string
/** The description part's id, or absent when `hasDescription: false`. */
'aria-describedby': string | undefined
'data-scope': 'tour'
'data-part': 'root'
hidden: ReadSignal<boolean>
}
backdrop: {
'data-scope': 'tour'
'data-part': 'backdrop'
'aria-hidden': 'true'
onClick: (e: MouseEvent) => void
}
spotlight: {
'data-scope': 'tour'
'data-part': 'spotlight'
'aria-hidden': 'true'
}
title: {
id: string
'data-scope': 'tour'
'data-part': 'title'
}
description: {
id: string
'data-scope': 'tour'
'data-part': 'description'
}
progressText: {
'data-scope': 'tour'
'data-part': 'progress-text'
}
prevTrigger: {
type: 'button'
disabled: ReadSignal<boolean>
'data-scope': 'tour'
'data-part': 'prev-trigger'
onClick: (e: MouseEvent) => void
}
nextTrigger: {
type: 'button'
'data-scope': 'tour'
'data-part': 'next-trigger'
'data-last': ReadSignal<'' | undefined>
onClick: (e: MouseEvent) => void
}
closeTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'tour'
'data-part': 'close-trigger'
onClick: (e: MouseEvent) => void
}
}
TourState from @llui/components/tour
export interface TourState {
steps: TourStep[]
open: boolean
index: number
/** Ids of steps already visited. */
visited: string[]
}
TourStep from @llui/components/tour
Tour — guided walkthrough over a sequence of steps, each targeting an element on the page with a pop-up explanation. The state machine tracks the current step index and open/closed; positioning of the pop-up relative to the target selector is done in the view layer (typically via onMount + attachFloating).
There is no overlay() helper: connect() returns the part bags and you
render/position the pop-up yourself (typically onMount + attachFloating
against the current step's target).
view: ({ state, send }) => {
const t = tour.connect(state.at('tour'), send, { id: 'tour' })
const step = tour.currentStep(state.peek().tour)
return [
div({ ...t.root }, [
h3({ ...t.title }, [text(step.title)]),
p({ ...t.description }, [text(step.description)]),
button({ ...t.prevTrigger }, [text('Back')]),
button({ ...t.nextTrigger }, [text('Next')]),
]),
]
}
export interface TourStep {
id: string
title: string
description: string
/** CSS selector or element ref for the tour target. */
target: string
/** Placement hint for the pop-up. */
placement?: 'top' | 'bottom' | 'left' | 'right'
/** Whether to show the highlight ring around the target. */
spotlight?: boolean
}
Constants
tour from @llui/components/tour
const tour
@llui/components/date-input
Functions
connect() from @llui/components/date-input
function connect(state: ReadSignal<DateInputState>, send: Send<DateInputMsg>, opts: ConnectOptions = {}): DateInputParts
formatDate() from @llui/components/date-input
Format a Date as 'YYYY-MM-DD'.
function formatDate(d: Date): string
init() from @llui/components/date-input
function init(opts: DateInputInit = {}): DateInputState
parseDate() from @llui/components/date-input
Parse an ISO-ish date string. Accepts:
- YYYY-MM-DD
- YYYY/MM/DD
- MM/DD/YYYY (US)
- DD/MM/YYYY (EU) Returns null for anything else.
function parseDate(input: string, format: 'iso' | 'us' | 'eu' = 'iso'): Date | null
toIsoDate() from @llui/components/date-input
Parse into the canonical YYYY-MM-DD form, or null when unparseable.
Everything that reaches state goes through here, so the comparisons in
validate can rely on zero-padded lexicographic order.
function toIsoDate(input: string, format: 'iso' | 'us' | 'eu' = 'iso'): IsoDate | null
update() from @llui/components/date-input
function update(state: DateInputState, msg: DateInputMsg, format: 'iso' | 'us' | 'eu' = 'iso'): [DateInputState, never[]]
Types
DateError from @llui/components/date-input
Date input — keyboard-only date field with masked parsing. Unlike date-picker, this is a plain that parses ISO-ish date strings as the user types. Separate from date-picker to keep each focused.
The machine holds the raw input string + the parsed date as an ISO
YYYY-MM-DD string (null until a complete/valid value is entered) —
never a Date, so the state stays JSON-serializable like date-picker's
(#119). Min/max bounds are validated on every change, populating error
when out of range, and an unparseable value — from init or setValue
alike — sets error: 'invalid' rather than vanishing.
export type DateError = 'invalid' | 'before-min' | 'after-max' | null
DateInputMsg from @llui/components/date-input
export type DateInputMsg =
/** @intent("Update the raw text the user has typed (re-parses to a date)") */
| { type: 'setInput'; value: string }
/** @intent("Set the parsed date directly as YYYY-MM-DD (also updates the displayed text)") */
| { type: 'setValue'; value: IsoDate | null }
/** @intent("Clear the input and the parsed date") */
| { type: 'clear' }
/** @humanOnly */
| { type: 'setMin'; min: IsoDate | null }
/** @humanOnly */
| { type: 'setMax'; max: IsoDate | null }
/** @humanOnly */
| { type: 'setDisabled'; disabled: boolean }
IsoDate from @llui/components/date-input
A calendar date as YYYY-MM-DD. Zero-padded, so plain </> order it.
export type IsoDate = string
Interfaces
ConnectOptions from @llui/components/date-input
export interface ConnectOptions {
placeholder?: string
clearLabel?: string
/**
* Base id. With it, the error text gets `<id>:error` and the input's
* `aria-describedby` names it while the value is invalid; without it the
* error is announced once (`role="alert"`) but not associated with the field.
*/
id?: string
}
DateInputInit from @llui/components/date-input
export interface DateInputInit {
input?: string
value?: IsoDate | null
min?: IsoDate | null
max?: IsoDate | null
disabled?: boolean
readonly?: boolean
required?: boolean
}
DateInputParts from @llui/components/date-input
export interface DateInputParts {
root: {
'data-scope': 'date-input'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
'data-invalid': ReadSignal<'' | undefined>
}
input: {
type: 'text'
inputmode: 'numeric'
autocomplete: 'off'
spellcheck: false
value: ReadSignal<string>
disabled: ReadSignal<boolean>
readonly: ReadSignal<boolean>
required: ReadSignal<boolean>
'aria-invalid': ReadSignal<'true' | undefined>
/**
* The error text's id while the value is invalid — so the field names its
* own error rather than relying on the one-shot `role="alert"`
* announcement (#268). Absent without a `ConnectOptions.id`.
*/
'aria-describedby': ReadSignal<string | undefined>
placeholder?: string
'data-scope': 'date-input'
'data-part': 'input'
onInput: (e: Event) => void
onBlur: (e: FocusEvent) => void
}
clearTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'date-input'
'data-part': 'clear-trigger'
onClick: (e: MouseEvent) => void
}
errorText: {
/** `<id>:error` when `ConnectOptions.id` is given. */
id: string | undefined
role: 'alert'
'aria-live': 'polite'
'data-scope': 'date-input'
'data-part': 'error-text'
hidden: ReadSignal<boolean>
}
}
DateInputState from @llui/components/date-input
export interface DateInputState {
/** Raw string as typed by the user. */
input: string
/** Parsed date as `YYYY-MM-DD`, or null if empty/invalid. */
value: IsoDate | null
/** Optional lower bound (inclusive), `YYYY-MM-DD`. */
min: IsoDate | null
/** Optional upper bound (inclusive), `YYYY-MM-DD`. */
max: IsoDate | null
error: DateError
disabled: boolean
readonly: boolean
required: boolean
}
Constants
dateInput from @llui/components/date-input
const dateInput
@llui/components/async-list
Functions
connect() from @llui/components/async-list
function connect<T>(state: ReadSignal<AsyncListState<T>>, send: Send<AsyncListMsg<T>>): AsyncListParts
init() from @llui/components/async-list
function init<T = unknown>(opts: AsyncListInit<T> = {}): AsyncListState<T>
isEmpty() from @llui/components/async-list
function isEmpty<T>(state: AsyncListState<T>): boolean
isError() from @llui/components/async-list
function isError<T>(state: AsyncListState<T>): boolean
isLoading() from @llui/components/async-list
function isLoading<T>(state: AsyncListState<T>): boolean
update() from @llui/components/async-list
function update<T>(state: AsyncListState<T>, msg: AsyncListMsg<T>): [AsyncListState<T>, never[]]
watchSentinel() from @llui/components/async-list
Install an IntersectionObserver on the sentinel element that auto-dispatches
loadMore whenever the sentinel scrolls into view. Call from onMount.
function watchSentinel<T>(send: Send<AsyncListMsg<T>>, sentinel: Element, rootMargin: string = '200px'): () => void
Types
AsyncListMsg from @llui/components/async-list
export type AsyncListMsg<T = unknown> =
/** @intent("Request the next page of items") */
| { type: 'loadMore' }
/** @humanOnly */
| { type: 'pageLoaded'; items: T[]; hasMore: boolean }
/** @humanOnly */
| { type: 'pageFailed'; error: string }
/** @intent("Discard the loaded items and reset back to page 0") */
| { type: 'reset' }
/** @humanOnly */
| { type: 'setItems'; items: T[]; hasMore?: boolean }
/** @intent("Retry the last failed page request") */
| { type: 'retry' }
AsyncStatus from @llui/components/async-list
Async list — paginated/infinite-scroll list that accumulates pages.
The machine is generic over the item type; the consumer runs the
actual fetch in response to loadMore (via a custom handler or
effect) and dispatches pageLoaded/pageFailed when the request
completes.
Typical flow in consumer's update handler:
(state, msg) => {
if (msg.type === 'loadMore') {
fetch(/api/items?page=${state.list.page + 1})
.then(r => r.json())
.then(items => send({type: 'pageLoaded', items, hasMore: items.length === PAGE_SIZE}))
.catch(e => send({type: 'pageFailed', error: String(e)}))
}
}
export type AsyncStatus = 'idle' | 'loading' | 'loaded' | 'error'
Interfaces
AsyncListInit from @llui/components/async-list
export interface AsyncListInit<T = unknown> {
items?: T[]
page?: number
hasMore?: boolean
}
AsyncListParts from @llui/components/async-list
export interface AsyncListParts {
root: {
'data-scope': 'async-list'
'data-part': 'root'
'data-status': ReadSignal<AsyncStatus>
/** `'true'` while a page request is in flight — the list's content is changing. */
'aria-busy': ReadSignal<'true' | 'false'>
/** Present once a load has SETTLED with zero items: the empty-state hook. */
'data-empty': ReadSignal<'' | undefined>
/** Present when the source reports no further pages. */
'data-exhausted': ReadSignal<'' | undefined>
}
sentinel: {
'data-scope': 'async-list'
'data-part': 'sentinel'
'aria-hidden': 'true'
}
loadMoreTrigger: {
type: 'button'
disabled: ReadSignal<boolean>
'data-scope': 'async-list'
'data-part': 'load-more-trigger'
onClick: (e: MouseEvent) => void
}
retryTrigger: {
type: 'button'
'data-scope': 'async-list'
'data-part': 'retry-trigger'
hidden: ReadSignal<boolean>
onClick: (e: MouseEvent) => void
}
errorText: {
role: 'alert'
'aria-live': 'polite'
'data-scope': 'async-list'
'data-part': 'error-text'
hidden: ReadSignal<boolean>
}
}
AsyncListState from @llui/components/async-list
export interface AsyncListState<T = unknown> {
items: T[]
page: number
hasMore: boolean
status: AsyncStatus
error: string | null
}
Constants
asyncList from @llui/components/async-list
const asyncList
@llui/components/cascade-select
Functions
completeValues() from @llui/components/cascade-select
function completeValues(state: CascadeSelectState): string[] | null
connect() from @llui/components/cascade-select
function connect(state: ReadSignal<CascadeSelectState>, send: Send<CascadeSelectMsg>, opts: ConnectOptions): CascadeSelectParts
init() from @llui/components/cascade-select
function init(opts: CascadeSelectInit = {}): CascadeSelectState
isComplete() from @llui/components/cascade-select
function isComplete(state: CascadeSelectState): boolean
isLevelReady() from @llui/components/cascade-select
function isLevelReady(state: CascadeSelectState, levelIndex: number): boolean
update() from @llui/components/cascade-select
function update(state: CascadeSelectState, msg: CascadeSelectMsg): [CascadeSelectState, never[]]
Types
CascadeSelectMsg from @llui/components/cascade-select
export type CascadeSelectMsg =
/** @humanOnly */
| { type: 'setLevels'; levels: CascadeLevel[] }
/** @intent("Pick a value at the given level (clears selections at deeper levels)") */
| { type: 'setValue'; levelIndex: number; value: string | null }
/** @intent("Clear every level's selection") */
| { type: 'clear' }
Interfaces
CascadeLevel from @llui/components/cascade-select
Cascade select — a series of dependent selects where each level's choice filters the options of the next. Classic example: country → state → city. The machine stores a flat list of selections (one per level, or null) and the options at each level; filtering logic is left to the view/consumer.
Level shape: the consumer passes an array of Level descriptors on setLevels, each with its own options. Selecting at level N clears selections at levels > N.
export interface CascadeLevel {
id: string
label: string
options: Array<{ value: string; label: string; disabled?: boolean }>
}
CascadeLevelParts from @llui/components/cascade-select
export interface CascadeLevelParts {
label: {
for: string
'data-scope': 'cascade-select'
'data-part': 'level-label'
}
select: {
id: string
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'cascade-select'
'data-part': 'level-select'
'data-level': string
'data-ready': ReadSignal<'' | undefined>
onChange: (e: Event) => void
}
}
CascadeSelectInit from @llui/components/cascade-select
export interface CascadeSelectInit {
levels?: CascadeLevel[]
values?: (string | null)[]
disabled?: boolean
}
CascadeSelectParts from @llui/components/cascade-select
export interface CascadeSelectParts {
root: {
'data-scope': 'cascade-select'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
'data-complete': ReadSignal<'' | undefined>
}
clearTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'cascade-select'
'data-part': 'clear-trigger'
onClick: (e: MouseEvent) => void
}
level: (index: number) => CascadeLevelParts
}
CascadeSelectState from @llui/components/cascade-select
export interface CascadeSelectState {
levels: CascadeLevel[]
/** Parallel to levels: one value per level, or null. */
values: (string | null)[]
disabled: boolean
}
ConnectOptions from @llui/components/cascade-select
export interface ConnectOptions {
id: string
clearLabel?: string
}
Constants
cascadeSelect from @llui/components/cascade-select
const cascadeSelect
@llui/components/scroll-area
Functions
connect() from @llui/components/scroll-area
function connect(state: ReadSignal<ScrollAreaState>, send: Send<ScrollAreaMsg>): ScrollAreaParts
init() from @llui/components/scroll-area
function init(opts: ScrollAreaInit = {}): ScrollAreaState
showScrollbars() from @llui/components/scroll-area
Whether the scrollbars should be visible given the state.
function showScrollbars(state: ScrollAreaState, axis: 'x' | 'y'): boolean
thumbPosition() from @llui/components/scroll-area
Thumb position as a proportion (0..1) along the track.
function thumbPosition(state: ScrollAreaState, axis: 'x' | 'y'): number
thumbSize() from @llui/components/scroll-area
Thumb size as a proportion (0..1) of the track.
function thumbSize(state: ScrollAreaState, axis: 'x' | 'y'): number
update() from @llui/components/scroll-area
function update(state: ScrollAreaState, msg: ScrollAreaMsg): [ScrollAreaState, never[]]
Types
ScrollAreaMsg from @llui/components/scroll-area
export type ScrollAreaMsg =
/** @humanOnly */
| {
type: 'setScroll'
scrollTop: number
scrollLeft: number
scrollWidth: number
scrollHeight: number
clientWidth: number
clientHeight: number
}
/** @humanOnly */
| { type: 'setScrolling'; scrolling: boolean }
/** @humanOnly */
| { type: 'setHovered'; hovered: boolean }
ScrollbarVisibility from @llui/components/scroll-area
Scroll area — custom-styled scroll container with scrollbars that
can be hidden/shown based on scroll activity or hover. The state
machine tracks scroll position + overflow flags (whether content
actually overflows each axis); the view layer installs listeners
that populate these via setScroll / setOverflow.
This component is primarily a structural shell — the real scrolling is done by the browser (overflow: auto) on the viewport element. The machine just tracks position so the view can render custom thumbs positioned proportionally.
Typical onMount wiring:
const viewport = root.querySelector('[data-part="viewport"]') const sync = () => send({type:'setScroll', ...dimsOf(viewport)}) viewport.addEventListener('scroll', sync) const ro = new ResizeObserver(sync); ro.observe(viewport); ro.observe(content) sync() // initial
export type ScrollbarVisibility = 'auto' | 'always' | 'hover' | 'scroll'
Interfaces
ScrollAreaInit from @llui/components/scroll-area
export interface ScrollAreaInit {
visibility?: ScrollbarVisibility
}
ScrollAreaParts from @llui/components/scroll-area
export interface ScrollAreaParts {
root: {
'data-scope': 'scroll-area'
'data-part': 'root'
'data-scrolling': ReadSignal<'' | undefined>
'data-hovered': ReadSignal<'' | undefined>
onMouseEnter: (e: MouseEvent) => void
onMouseLeave: (e: MouseEvent) => void
}
viewport: {
tabindex: 0
'data-scope': 'scroll-area'
'data-part': 'viewport'
onScroll: (e: Event) => void
}
content: {
'data-scope': 'scroll-area'
'data-part': 'content'
}
scrollbarX: {
'data-scope': 'scroll-area'
'data-part': 'scrollbar'
'data-axis': 'x'
'data-visible': ReadSignal<'' | undefined>
}
scrollbarY: {
'data-scope': 'scroll-area'
'data-part': 'scrollbar'
'data-axis': 'y'
'data-visible': ReadSignal<'' | undefined>
}
thumbX: {
'data-scope': 'scroll-area'
'data-part': 'thumb'
'data-axis': 'x'
style: ReadSignal<string>
}
thumbY: {
'data-scope': 'scroll-area'
'data-part': 'thumb'
'data-axis': 'y'
style: ReadSignal<string>
}
corner: {
'data-scope': 'scroll-area'
'data-part': 'corner'
'data-visible': ReadSignal<'' | undefined>
}
}
ScrollAreaState from @llui/components/scroll-area
export interface ScrollAreaState extends ScrollDims {
overflowX: boolean
overflowY: boolean
/** Whether the user is currently scrolling (set/cleared by the consumer
* via a debounced scroll handler). */
scrolling: boolean
/** Whether the pointer is over the scroll area. */
hovered: boolean
visibility: ScrollbarVisibility
}
ScrollDims from @llui/components/scroll-area
export interface ScrollDims {
scrollTop: number
scrollLeft: number
scrollWidth: number
scrollHeight: number
clientWidth: number
clientHeight: number
}
Constants
scrollArea from @llui/components/scroll-area
const scrollArea
@llui/components/floating-panel
Functions
connect() from @llui/components/floating-panel
function connect(state: ReadSignal<FloatingPanelState>, send: Send<FloatingPanelMsg>, opts: ConnectOptions = {}): FloatingPanelParts
init() from @llui/components/floating-panel
function init(opts: FloatingPanelInit = {}): FloatingPanelState
update() from @llui/components/floating-panel
function update(state: FloatingPanelState, msg: FloatingPanelMsg): [FloatingPanelState, never[]]
Types
FloatingPanelMsg from @llui/components/floating-panel
export type FloatingPanelMsg =
/** @intent("Open the floating panel") */
| { type: 'open' }
/** @intent("Close the floating panel") */
| { type: 'close' }
/** @intent("Minimize the panel (collapses to title bar)") */
| { type: 'minimize' }
/** @intent("Restore the panel from its minimized state") */
| { type: 'restoreFromMinimized' }
/** @intent("Maximize the panel (fills the viewport)") */
| { type: 'maximize' }
/** @intent("Restore the panel to its pre-maximize geometry") */
| { type: 'restoreFromMaximized' }
/** @intent("Toggle between minimized and normal") */
| { type: 'toggleMinimize' }
/** @intent("Toggle between maximized and normal") */
| { type: 'toggleMaximize' }
/** @humanOnly */
| { type: 'dragStart' }
/** @humanOnly */
| { type: 'dragMove'; dx: number; dy: number }
/** @humanOnly */
| { type: 'dragEnd' }
/** @humanOnly */
| { type: 'resizeStart'; handle: ResizeHandle }
/** @humanOnly */
| { type: 'resizeMove'; dx: number; dy: number }
/** @humanOnly */
| { type: 'resizeEnd' }
/** @intent("Set the panel's top-left position in pixels") */
| { type: 'setPosition'; x: number; y: number }
/** @intent("Set the panel's size in pixels (clamped to min/max)") */
| { type: 'setSize'; width: number; height: number }
/** @intent("Move the panel by a pixel offset (x right, y down)") */
| { type: 'moveBy'; dx: number; dy: number }
/** @intent("Resize the panel from one edge or corner by a pixel offset (clamped to min/max)") */
| { type: 'resizeBy'; handle: ResizeHandle; dx: number; dy: number }
ResizeHandle from @llui/components/floating-panel
Floating panel — a draggable + resizable window-like surface, useful for dev tools overlays, pop-out inspectors, preview panels, etc. The state machine tracks position and size; the view layer wires pointer events on the drag handle and resize grips and dispatches the corresponding messages.
Coordinates are in pixels relative to the positioning container
(typically position: fixed relative to the viewport).
export type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
Interfaces
ConnectOptions from @llui/components/floating-panel
export interface ConnectOptions {
label?: string
minimizeLabel?: string
maximizeLabel?: string
closeLabel?: string
moveLabel?: string
resizeLabel?: string
}
FloatingPanelInit from @llui/components/floating-panel
export interface FloatingPanelInit {
position?: { x: number; y: number }
size?: { width: number; height: number }
minSize?: { width?: number; height?: number }
maxSize?: { width?: number; height?: number } | null
open?: boolean
disabled?: boolean
}
FloatingPanelParts from @llui/components/floating-panel
export interface FloatingPanelParts {
root: {
role: 'dialog'
'aria-label': string
'data-scope': 'floating-panel'
'data-part': 'root'
'data-dragging': ReadSignal<'' | undefined>
'data-resizing': ReadSignal<'' | undefined>
'data-minimized': ReadSignal<'' | undefined>
'data-maximized': ReadSignal<'' | undefined>
hidden: ReadSignal<boolean>
style: ReadSignal<string>
}
/**
* Pointer drag starts here, and it is also a keyboard stop (#266): arrows
* move the panel 10px (50px with Shift). Physical under RTL — the panel is
* positioned with physical `left`/`top`.
*/
dragHandle: {
/**
* A focusable, NAMED group: without a role an `aria-label` is prohibited
* on a generic element and assistive tech announced nothing (#268 audit).
* `group`, not `button`: the handle is the title bar and CONTAINS the
* minimize/maximize/close buttons (a button may not), and its action is
* the arrow keys `aria-keyshortcuts` names, not an activation.
*/
role: 'group'
tabindex: 0
'aria-label': string
'aria-keyshortcuts': string
'data-scope': 'floating-panel'
'data-part': 'drag-handle'
onPointerDown: (e: PointerEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
content: {
'data-scope': 'floating-panel'
'data-part': 'content'
hidden: ReadSignal<boolean>
}
minimizeTrigger: {
type: 'button'
'aria-label': string
/** A toggle: `'true'` while minimized. */
'aria-pressed': ReadSignal<'true' | 'false'>
'data-scope': 'floating-panel'
'data-part': 'minimize-trigger'
onClick: (e: MouseEvent) => void
}
maximizeTrigger: {
type: 'button'
'aria-label': string
/** A toggle: `'true'` while maximized. */
'aria-pressed': ReadSignal<'true' | 'false'>
'data-scope': 'floating-panel'
'data-part': 'maximize-trigger'
onClick: (e: MouseEvent) => void
}
closeTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'floating-panel'
'data-part': 'close-trigger'
onClick: (e: MouseEvent) => void
}
/** A resize grip; also a keyboard stop whose arrows resize from this grip (#266). */
resizeHandle: (handle: ResizeHandle) => {
/** A focusable, named group (see `dragHandle`): its action is the arrow
* keys, and a thin edge grip is a window-chrome affordance, not a
* pointer button. */
role: 'group'
tabindex: 0
'aria-label': string
'aria-keyshortcuts': string
'data-scope': 'floating-panel'
'data-part': 'resize-handle'
'data-handle': ResizeHandle
onPointerDown: (e: PointerEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
}
FloatingPanelState from @llui/components/floating-panel
export interface FloatingPanelState {
position: { x: number; y: number }
size: { width: number; height: number }
minSize: { width: number; height: number }
/**
* The upper size bound. `null` — or an absent dimension — is unbounded on
* that axis, which is what `clampSize` already spelled `?? Infinity` at the
* point of use. A bound in state is finite or absent, never an infinity
* (`JSON.stringify` writes `null` for one) and never `NaN` (which is not
* nullish, so it survives the `??` and switches that axis's clamp off) —
* #177.
*/
maxSize: { width?: number; height?: number } | null
open: boolean
minimized: boolean
maximized: boolean
dragging: boolean
resizing: ResizeHandle | null
/** Snapshot of the pre-maximize geometry (for restore). */
restoreBounds: { x: number; y: number; width: number; height: number } | null
disabled: boolean
}
Constants
floatingPanel from @llui/components/floating-panel
const floatingPanel
@llui/components/image-cropper
Functions
centerFill() from @llui/components/image-cropper
Compute the largest centered crop that fits image while respecting the
aspect ratio (if any).
function centerFill(image: { width: number; height: number }, aspectRatio: number | null): CropRect
connect() from @llui/components/image-cropper
function connect(state: ReadSignal<ImageCropperState>, send: Send<ImageCropperMsg>, opts: ConnectOptions = {}): ImageCropperParts
init() from @llui/components/image-cropper
function init(opts: ImageCropperInit = {}): ImageCropperState
update() from @llui/components/image-cropper
function update(state: ImageCropperState, msg: ImageCropperMsg): [ImageCropperState, never[]]
Types
ImageCropperMsg from @llui/components/image-cropper
export type ImageCropperMsg =
/** @humanOnly */
| { type: 'setImage'; width: number; height: number }
/** @intent("Set the crop rectangle (x/y/width/height in image-native pixels)") */
| { type: 'setCrop'; crop: CropRect }
/** @intent("Lock the crop to a specific aspect ratio (width/height), or null for free-form") */
| { type: 'setAspectRatio'; ratio: number | null }
/** @humanOnly */
| { type: 'dragStart' }
/** @humanOnly */
| { type: 'dragMove'; dx: number; dy: number }
/** @humanOnly */
| { type: 'dragEnd' }
/** @humanOnly */
| { type: 'resizeStart'; handle: ResizeHandle }
/** @humanOnly */
| { type: 'resizeMove'; dx: number; dy: number }
/** @humanOnly */
| { type: 'resizeEnd' }
/** @intent("Reset the crop to a default selection (full image or aspect-fit)") */
| { type: 'reset' }
/** @intent("Set the crop to a maximum-area centered selection") */
| { type: 'centerFill' }
/** @intent("Move the crop area by a percentage of the image size (x right, y down)") */
| { type: 'nudge'; x: number; y: number }
/** @intent("Zoom the crop area about its centre: factor > 1 zooms in, < 1 zooms out") */
| { type: 'zoom'; factor: number }
ResizeHandle from @llui/components/image-cropper
Image cropper — select a rectangular crop region over an image, optionally constrained to an aspect ratio. The machine tracks the image's natural dimensions, the crop rectangle, and in-progress drag/resize state. The view layer wires pointer events on the crop box and its resize handles.
Coordinates are in image-native pixels (0..naturalWidth, 0..naturalHeight). The consumer converts to display pixels using the image's rendered size.
export type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
Interfaces
ConnectOptions from @llui/components/image-cropper
export interface ConnectOptions {
resetLabel?: string
/** Name of the crop area; its live geometry is appended. */
cropAreaLabel?: string
}
CropRect from @llui/components/image-cropper
export interface CropRect {
x: number
y: number
width: number
height: number
}
ImageCropperInit from @llui/components/image-cropper
export interface ImageCropperInit {
image?: { width: number; height: number }
crop?: CropRect
aspectRatio?: number | null
minSize?: number
disabled?: boolean
}
ImageCropperParts from @llui/components/image-cropper
export interface ImageCropperParts {
root: {
'data-scope': 'image-cropper'
'data-part': 'root'
'data-dragging': ReadSignal<'' | undefined>
'data-resizing': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
}
image: {
'data-scope': 'image-cropper'
'data-part': 'image'
onLoad: (e: Event) => void
draggable: false
}
/**
* The crop area: a focusable, named `group` (#266). Arrow keys move it 1% of
* the image (10% with Shift), `+`/`-` zoom it. Keys stay PHYSICAL under
* `dir="rtl"`: the image is never mirrored, so neither is the box on it.
* Geometry is written as an inline `style` in PERCENT of the image — a skin
* must not set position or size.
*/
cropBox: {
role: 'group'
tabindex: 0
'aria-label': ReadSignal<string>
'aria-keyshortcuts': string
'data-scope': 'image-cropper'
'data-part': 'crop-box'
style: ReadSignal<string>
onPointerDown: (e: PointerEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
resizeHandle: (handle: ResizeHandle) => {
'data-scope': 'image-cropper'
'data-part': 'resize-handle'
'data-handle': ResizeHandle
onPointerDown: (e: PointerEvent) => void
}
resetTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'image-cropper'
'data-part': 'reset-trigger'
onClick: (e: MouseEvent) => void
}
}
ImageCropperState from @llui/components/image-cropper
export interface ImageCropperState {
/** Natural dimensions of the source image. */
image: { width: number; height: number }
crop: CropRect
/** Constrain the crop to this aspect ratio (width / height), or null to free-form. */
aspectRatio: number | null
minSize: number
dragging: boolean
resizing: ResizeHandle | null
disabled: boolean
}
Constants
imageCropper from @llui/components/image-cropper
const imageCropper
@llui/components/navigation-menu
Functions
connect() from @llui/components/navigation-menu
function connect(state: ReadSignal<NavMenuState>, send: Send<NavMenuMsg>, opts: ConnectOptions): NavMenuParts
init() from @llui/components/navigation-menu
function init(opts: NavMenuInit = {}): NavMenuState
isOpen() from @llui/components/navigation-menu
function isOpen(state: NavMenuState, id: string): boolean
update() from @llui/components/navigation-menu
function update(state: NavMenuState, msg: NavMenuMsg): [NavMenuState, never[]]
watchNavMenuIndicator() from @llui/components/navigation-menu
Track the open top-level trigger and write CSS custom properties onto the
indicator part so the arrow can be animated into position. Call from
onMount with the navigation-menu ROOT element; the returned function
removes the observers.
PASS THE NAV, NOT WHATEVER onMount HANDED YOU. onMount receives the
BUILD's root container — the component's mount container, or an arm's or a
row's — not the element the call sits inside. Forwarding that resolves the
lookups below in document order across everything in the build, so a page
with two navigation menus has both of them tracking the first one's arrow.
That is #123's shape exactly, and it is invisible until there are two.
Scope it yourself: give the nav an id and root.querySelector('#…').
Sets --indicator-left, --indicator-top, --indicator-width and
--indicator-height on the indicator element every time a trigger's
data-state flips or the root resizes. Style the indicator with:
left: 0;
width: var(--indicator-width);
transform: translateX(var(--indicator-left));
This is the same state/layout split tabs uses (watchTabIndicator), and
for the same reason: NavMenuState is JSON-serializable and holds no pixels,
so a measured position cannot live in it without breaking time-travel,
@llui/test replay and SSR. The machine answers data-state; the DOM
answers where.
TWO deliberate differences from watchTabIndicator, both forced by this
component's DOM:
- It resolves the position against the INDICATOR'S OWN
offsetParentrather than readingtrigger.offsetLeft. A navigation menu wraps each trigger in a positionedli(upstream's own markup does — the inline panel is absolutely positioned against it), which makes the trigger its own offset parent andoffsetLeft0 for every item.tabscan useoffsetLeftbecause its trigger list is flat. - It takes the FIRST open trigger in document order, not the only one.
Nested branches publish
data-state="open"too, and a child trigger lives inside its parent's panel — so document order puts the outermost open branch first, which is exactly the one the arrow tracks.
function watchNavMenuIndicator(root: HTMLElement): () => void
Types
NavMenuMsg from @llui/components/navigation-menu
export type NavMenuMsg =
/** @intent("Open the submenu identified by id, closing any open siblings") */
| { type: 'openBranch'; id: string; ancestorIds: string[] }
/** @intent("Close the submenu identified by id (also closes its descendants)") */
| { type: 'closeBranch'; id: string }
/** @intent("Toggle the submenu identified by id open/closed") */
| { type: 'toggleBranch'; id: string; ancestorIds: string[] }
/** @intent("Close every open submenu") */
| { type: 'closeAll' }
/** @humanOnly */
| { type: 'focus'; id: string | null }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
/** @humanOnly — synchronized from the mounted root's live ancestor direction. */
| { type: 'syncDomDir'; dir: 'ltr' | 'rtl' }
/** @intent("Replace the list of ids eligible for the roving tab stop, in document order") */
| { type: 'setItems'; items: string[] }
Interfaces
ConnectOptions from @llui/components/navigation-menu
export interface ConnectOptions {
id: string
label?: string
/**
* Whether pointer-leaving the whole menu closes everything. Default: true.
* The consumer can inject their own close delay by intercepting
* onPointerLeave + calling setTimeout + dispatching closeAll.
*/
closeOnLeave?: boolean
}
NavItemParts from @llui/components/navigation-menu
export interface NavItemParts {
trigger: {
type: 'button'
id: string
/** For a branch item this is the disclosure button controlling its panel;
* `undefined` for a plain link trigger. */
'aria-controls': string | undefined
'aria-expanded': ReadSignal<boolean | undefined>
'data-scope': 'navigation-menu'
'data-part': 'trigger'
'data-state': ReadSignal<'open' | 'closed'>
'data-value': string
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onPointerEnter: (e: PointerEvent) => void
onFocus: (e: FocusEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
content: {
id: string
'aria-labelledby': string
'data-scope': 'navigation-menu'
'data-part': 'content'
'data-state': ReadSignal<'open' | 'closed'>
hidden: ReadSignal<boolean>
onPointerEnter: (e: PointerEvent) => void
}
}
NavMenuInit from @llui/components/navigation-menu
export interface NavMenuInit {
open?: string[]
focused?: string | null
/** Ids eligible for the roving tab stop, in document order — see
* `NavMenuState.items`. */
items?: string[]
disabled?: boolean
dir?: 'ltr' | 'rtl'
}
NavMenuParts from @llui/components/navigation-menu
export interface NavMenuParts {
root: {
// `id` is REQUIRED — it is the scope `directionSyncMount` (below) looks
// the live root up by, the same contract `tabs`/`carousel`/`pagination`'s
// own `root.id` already honours. A consumer that overrides it with a
// DIFFERENT id breaks the direction sync silently (#265 finding 6): the
// watcher would observe nothing, since `getElementByIdInScope` would
// never find this element under the id it was given.
id: string
// Site navigation is NOT an application menu: it uses a `nav` landmark with
// disclosure buttons, not menubar/menu/menuitem roles. Render the root as a
// `<nav>` element; `aria-label` names the landmark.
'aria-label': string
'data-scope': 'navigation-menu'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
onPointerLeave: (e: PointerEvent) => void
onPointerEnter: (e: PointerEvent) => void
}
/**
* The arrow that tracks the open top-level trigger — Radix's
* `NavigationMenuIndicator`, whose `data-state="visible" | "hidden"` drives
* the enter/exit animation. Render it as a sibling of the trigger list,
* inside the positioned root.
*
* It stays MOUNTED in both states: the recipe animates it out with
* `data-[state=hidden]:animate-out fade-out`, and a `show(...)` would unmount
* the node the exit animation is running on. Same reason `formField`'s live
* region is hidden rather than gated.
*
* `data-state` is all the MACHINE can answer — where the arrow sits is a
* layout fact, not state (`NavMenuState` is JSON-serializable and holds no
* pixels), so the position arrives through `watchNavMenuIndicator`, which
* writes `--indicator-left`/`--indicator-width` onto this element. That is
* the same split `tabs` uses for its own indicator.
*/
indicator: {
// Pure decoration: it duplicates no information a screen reader cannot get
// from the trigger's own `aria-expanded`, and it is always in the tree.
'aria-hidden': 'true'
'data-scope': 'navigation-menu'
'data-part': 'indicator'
'data-state': ReadSignal<'visible' | 'hidden'>
}
/** Place once anywhere in the same build to keep automatic direction live —
* the shared `@llui/interactions` direction-sync seam (#265 finding 6),
* same as `tabs`/`carousel`/`pagination`'s own `directionSync` part. A
* discarded `Mountable` is inert, so this must be placed in the view. */
directionSync: Mountable
/**
* Parts for one trigger (+ its panel when it is a branch).
*
* `ancestorIds` is the open-path this item lives under, root-first. It drives
* sibling-closing in `openBranch` AND the roving tab stop: an item whose
* ancestors are not all open is inside a `hidden` panel and is skipped when
* the stop is resolved.
*
* REQUIRED on every NESTED item, leaf ones included. It used to be read only
* inside `isBranch` guards, so passing it on a leaf was optional in practice;
* since #145 it is what makes a leaf's tabbability knowable. Omitting it
* reads as "top level", which lets the tab stop sit inside a closed submenu
* where no Tab press can reach it.
*/
item: (id: string, options: { isBranch: boolean; ancestorIds?: string[] }) => NavItemParts
}
NavMenuState from @llui/components/navigation-menu
Navigation menu — multi-level menu bar with hover/focus-triggered
submenus. Unlike menu (a single dropdown), navigation-menu supports
nested submenus arbitrarily deep and is typically used for primary
site navigation.
State tracks the currently focused item id and the ids of all currently-open branches. The consumer provides the tree structure (items with optional children); the machine doesn't index the hierarchy itself — it just maintains open-paths and lets the view handle traversal.
Typical interaction model (delay-based):
- Pointer enter on a branch → openBranch after openDelay
- Pointer leave of the whole tree → closeAll after closeDelay
- Click/keyboard activation → toggleBranch immediately
The consumer is responsible for debouncing via setTimeout; the machine just responds to the dispatched messages.
export interface NavMenuState {
/** Ids of open branches, in open order (root-first). Closing an
* ancestor automatically closes its descendants. */
open: string[]
focused: string | null
/**
* Ids of the items ELIGIBLE for the roving tab stop, in document order, when
* the consumer renders a DYNAMIC list — the top-level items in the usual
* case; add deeper ids if a submenu item should be able to own the stop.
*
* It is both the fallback and the membership list: while nothing is focused
* the first entry owns the nav's single tab stop, and a `focused` id that is
* not one of these entries has been removed, so the stop falls back rather
* than vanishing (#145).
*
* Leave it empty for a static menu — `connect` then uses the ids handed to
* `item()`, in call order, as the membership list instead, which is document
* order for any depth-first view. Same escape hatch as `radio-group`/`tabs`,
* which keep their `items` list in state for exactly this reason.
*
* Either way the candidates are filtered to the ones currently TABBABLE: an
* id whose `ancestorIds` are not all in `open` sits inside a `hidden`
* submenu panel and cannot carry the stop.
*/
items: string[]
disabled: boolean
/** Reading direction. Under 'rtl', ArrowLeft/ArrowRight swap meaning.
* Routed through the shared `@llui/interactions` direction-sync seam
* (`../utils/direction.js`) rather than a second resolver — `dirSource`
* tracks whether `dir` came from explicit config/`setDir` or from the
* mounted root's live ancestor `dir` attribute (#265 finding 6). */
dir: 'ltr' | 'rtl'
dirSource: DirectionSource
}
Constants
navigationMenu from @llui/components/navigation-menu
const navigationMenu
@llui/components/qr-code
Functions
connect() from @llui/components/qr-code
function connect(state: ReadSignal<QrCodeState>, send: Send<QrCodeMsg>, opts: ConnectOptions = {}): QrCodeParts
init() from @llui/components/qr-code
function init(opts: QrCodeInit = {}): QrCodeState
size() from @llui/components/qr-code
Matrix side length (in modules). Returns 0 for empty matrix.
function size(state: QrCodeState): number
toDataUrl() from @llui/components/qr-code
Encode the matrix as a monochrome 1-bit-per-pixel PNG-ish URL. This
is a helper for consumption — it generates a
data:image/svg+xml
URL (SVG is simpler and scales losslessly).
function toDataUrl(matrix: boolean[][], foreground: string = '#000', background: string = '#fff'): string
toSvgPath() from @llui/components/qr-code
Compute an SVG path string that fills every dark module. Each dark
module becomes a unit-sized square at (col, row) coordinates in module
space; the caller scales via viewBox or CSS. Using a single path
is vastly more performant than rendering N² individual
function toSvgPath(matrix: boolean[][]): string
update() from @llui/components/qr-code
function update(state: QrCodeState, msg: QrCodeMsg): [QrCodeState, never[]]
Types
ErrorCorrectionLevel from @llui/components/qr-code
QR code — renders a QR matrix as SVG. llui does not bundle a QR
encoder (encoders are sizable and consumer apps typically already
have one); instead, the consumer provides the encoded matrix via
setMatrix (or through the optional encode callback on
ConnectOptions, invoked when the value changes).
Minimum usage with a BYOE (bring-your-own-encoder) library — the
consumer dispatches setMatrix with the encoded bits from their
update handler:
import QRCode from 'qrcode-generator'
update: (state, msg) => { if (msg.type === 'updateQr') { const q = QRCode(0, state.qr.errorCorrection) q.addData(msg.value); q.make() const n = q.getModuleCount() const matrix: boolean[][] = [] for (let y = 0; y < n; y++) { const row: boolean[] = [] for (let x = 0; x < n; x++) row.push(q.isDark(y, x)) matrix.push(row) } return [{ ...state, qr: { ...state.qr, value: msg.value, matrix } }, []] } }
export type ErrorCorrectionLevel = 'L' | 'M' | 'Q' | 'H'
QrCodeMsg from @llui/components/qr-code
export type QrCodeMsg =
/** @intent("Set the encoded value (consumer encodes externally and dispatches setMatrix)") */
| { type: 'setValue'; value: string }
/** @humanOnly */
| { type: 'setMatrix'; matrix: boolean[][] }
/** @intent("Change the QR error-correction level (L/M/Q/H)") */
| { type: 'setErrorCorrection'; level: ErrorCorrectionLevel }
Interfaces
ConnectOptions from @llui/components/qr-code
export interface ConnectOptions {
label?: string
downloadLabel?: string
/** Filename for the downloaded SVG. */
downloadFilename?: string
}
QrCodeInit from @llui/components/qr-code
export interface QrCodeInit {
value?: string
matrix?: boolean[][]
errorCorrection?: ErrorCorrectionLevel
}
QrCodeParts from @llui/components/qr-code
export interface QrCodeParts {
/**
* A labelled `group` around the code and its download action. `aria-label` on
* a bare `<div>` (role `generic`) is prohibited ARIA and ignored by most
* assistive technology, which is why the role is stated.
*/
root: {
role: 'group'
'data-scope': 'qr-code'
'data-part': 'root'
'aria-label': string
/** Present while no matrix has been supplied — the empty state hook. */
'data-empty': ReadSignal<'' | undefined>
}
/**
* The `role="img"` graphic carries its OWN accessible name. A screen-reader
* user cannot scan the modules, so the name includes the encoded value.
*/
svg: {
'data-scope': 'qr-code'
'data-part': 'svg'
role: 'img'
'aria-label': ReadSignal<string>
viewBox: ReadSignal<string>
'shape-rendering': 'crispEdges'
}
/** Spread onto a `<rect>`: sized to the module grid so it covers the quiet background. */
background: {
'data-scope': 'qr-code'
'data-part': 'background'
width: ReadSignal<string>
height: ReadSignal<string>
}
/** Spread onto a `<path>`: one sub-path per dark module. */
foreground: {
'data-scope': 'qr-code'
'data-part': 'foreground'
d: ReadSignal<string>
}
downloadTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'qr-code'
'data-part': 'download-trigger'
/** Nothing to download until a matrix exists. */
disabled: ReadSignal<boolean>
onClick: (e: MouseEvent) => void
}
}
QrCodeState from @llui/components/qr-code
export interface QrCodeState {
value: string
/** NxN boolean matrix — true means dark (filled) module. */
matrix: boolean[][]
errorCorrection: ErrorCorrectionLevel
}
Constants
qrCode from @llui/components/qr-code
const qrCode
@llui/components/carousel
Functions
autoplayEffects() from @llui/components/carousel
The effects that bring the timer in line with state from a cold start.
init() returns state only, so a consumer seeds the timer with this from
its own init().
function autoplayEffects(state: CarouselState): CarouselEffect[]
canGoNext() from @llui/components/carousel
function canGoNext(state: CarouselState): boolean
canGoPrev() from @llui/components/carousel
function canGoPrev(state: CarouselState): boolean
connect() from @llui/components/carousel
function connect(state: ReadSignal<CarouselState>, send: Send<CarouselMsg>, opts: ConnectOptions): CarouselParts
init() from @llui/components/carousel
function init(opts: CarouselInit = {}): CarouselState
isAutoplayRunning() from @llui/components/carousel
Whether the autoplay timer should be running. A single slide has nowhere to
advance to, and a swipe in flight suspends autoplay so the slide doesn't move
out from under the user's finger — the same condition data-paused exposes.
function isAutoplayRunning(state: CarouselState): boolean
swipeDecision() from @llui/components/carousel
Pure swipe resolver. Given a state with an active drag, decide whether the gesture commits to the previous/next slide or snaps back to the current one.
- A leftward swipe (deltaX < 0) that crosses
swipeThresholdtargets the NEXT slide; a rightward swipe (deltaX > 0) targets the PREVIOUS. - Below the threshold, or with no active drag, it snaps back.
- At a non-loop boundary the target direction is unavailable, so it snaps back. With loop enabled the move always commits (wraps).
function swipeDecision(state: CarouselState): 'prev' | 'next' | 'snap'
update() from @llui/components/carousel
function update(state: CarouselState, msg: CarouselMsg): [CarouselState, CarouselEffect[]]
Types
CarouselEffect from @llui/components/carousel
Effects emitted by the carousel machine. Running the timer is the consumer's job — the machine only says when it should run (see the module header).
export type CarouselEffect =
/**
* Run the autoplay timer: dispatch `autoplayTick` every `interval` ms.
* Re-emitted to RESTART a timer already running (manual navigation, an
* `interval` change), so the handler must replace rather than add.
*/
| { type: 'startAutoplay'; interval: number }
/** Retire the autoplay timer. */
| { type: 'stopAutoplay' }
CarouselMsg from @llui/components/carousel
export type CarouselMsg =
/** @intent("Jump to a specific slide by zero-based index") */
| { type: 'goTo'; index: number }
/** @intent("Advance to the next slide (wraps if loop is enabled)") */
| { type: 'next' }
/** @intent("Go back to the previous slide (wraps if loop is enabled)") */
| { type: 'prev' }
/** @humanOnly */
| { type: 'setCount'; count: number }
/** @intent("Pause autoplay (typically while user hovers or focuses the carousel)") */
| { type: 'pause' }
/** @intent("Resume autoplay after a pause") */
| { type: 'resume' }
/** @humanOnly */
| { type: 'setHovered'; hovered: boolean }
/** @humanOnly */
| { type: 'setFocusWithin'; focusWithin: boolean }
/** @intent("Turn autoplay on or off") */
| { type: 'setAutoplay'; autoplay: boolean }
/**
* The autoplay timer fired. Advances exactly like `next`, but does NOT
* restart the timer — it IS the timer. Manual navigation restarts it; a tick
* must not, or the period would be re-armed on every fire.
*
* `@humanOnly` because it is the TIMER's message, not a user intent: with no
* tag it defaulted to dispatchMode `'shared'` and an agent could fire the
* timer directly, advancing the carousel without re-arming the period
* (#138 review, item 8). An agent that wants the next slide sends `next`.
*
* @humanOnly
*/
| { type: 'autoplayTick' }
/** @humanOnly */
| { type: 'dragStart'; x: number }
/** @humanOnly */
| { type: 'dragMove'; x: number }
/** @humanOnly */
| { type: 'dragEnd' }
/** @intent("Set the reading direction (ltr/rtl)") */
| { type: 'setDir'; dir: 'ltr' | 'rtl' }
/** @humanOnly — synchronized from the mounted root's live ancestor direction. */
| { type: 'syncDomDir'; dir: 'ltr' | 'rtl' }
Interfaces
CarouselDrag from @llui/components/carousel
Live pointer-swipe state. JSON-serializable: just the start X and the accumulated horizontal delta (positive = dragged right, negative = left). The view supplies pointer coordinates; the machine does pure math.
export interface CarouselDrag {
startX: number
deltaX: number
}
CarouselInit from @llui/components/carousel
export interface CarouselInit {
current?: number
count?: number
loop?: boolean
autoplay?: boolean
interval?: number
swipeThreshold?: number
dir?: 'ltr' | 'rtl'
}
CarouselParts from @llui/components/carousel
export interface CarouselParts {
root: {
id: string
role: 'region'
'aria-roledescription': 'carousel'
'aria-label': string
'data-scope': 'carousel'
'data-part': 'root'
'data-paused': ReadSignal<'' | undefined>
onPointerEnter: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
onFocusIn: (e: FocusEvent) => void
onFocusOut: (e: FocusEvent) => void
}
viewport: {
'data-scope': 'carousel'
'data-part': 'viewport'
/**
* Set while a pointer swipe is in flight — consumers gate the slide-track
* transition off (`[data-dragging] { transition: none }`) so the track
* follows the finger 1:1 instead of easing.
*/
'data-dragging': ReadSignal<'' | undefined>
/** Live track offset (px) to follow the finger: `translateX(var)`. */
'data-drag-offset': ReadSignal<string | undefined>
/** Physical pointer delta consumed by either skin's track transform. */
'style.--carousel-drag-offset': ReadSignal<string | undefined>
onPointerDown: (e: PointerEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerUp: (e: PointerEvent) => void
onPointerCancel: (e: PointerEvent) => void
}
/** Place all slides directly inside this transform-bearing track. */
track: {
'data-scope': 'carousel'
'data-part': 'track'
}
indicatorGroup: {
role: 'tablist'
'aria-label': string
'data-scope': 'carousel'
'data-part': 'indicator-group'
}
nextTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'carousel'
'data-part': 'next-trigger'
onClick: (e: MouseEvent) => void
}
prevTrigger: {
type: 'button'
'aria-label': string
disabled: ReadSignal<boolean>
'data-scope': 'carousel'
'data-part': 'prev-trigger'
onClick: (e: MouseEvent) => void
}
slide: (index: number) => CarouselSlideParts
/** Place once anywhere in the same build to keep automatic direction live. */
directionSync: Mountable
}
CarouselSlideParts from @llui/components/carousel
export interface CarouselSlideParts {
slide: {
role: 'tabpanel'
id: string
'aria-roledescription': 'slide'
'aria-label': string
'data-scope': 'carousel'
'data-part': 'slide'
'data-index': string
'data-active': ReadSignal<'' | undefined>
hidden: ReadSignal<boolean>
}
indicator: {
type: 'button'
role: 'tab'
/** APG roving tab stop: only the selected indicator participates in Tab. */
tabindex: ReadSignal<0 | -1>
'aria-label': string
'aria-selected': ReadSignal<boolean>
'aria-controls': string
'data-scope': 'carousel'
'data-part': 'indicator'
'data-index': string
'data-active': ReadSignal<'' | undefined>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
}
CarouselState from @llui/components/carousel
export interface CarouselState {
current: number
count: number
loop: boolean
autoplay: boolean
interval: number
/** Explicit application pause, independent from hover/focus interaction. */
paused: boolean
/** Pointer is currently over the carousel root. */
hovered: boolean
/** DOM focus is currently contained by the carousel root. */
focusWithin: boolean
/** Direction of the last transition — useful for entry animations. */
direction: 'forward' | 'backward'
/**
* Minimum absolute horizontal distance (px) a swipe must cross to commit
* to the previous/next slide. Below this the drag snaps back.
*/
swipeThreshold: number
/** Active pointer swipe, or null when idle. */
dragging: CarouselDrag | null
/** Reading direction. Under 'rtl' indicator horizontal arrow keys are flipped. */
dir: 'ltr' | 'rtl'
/** Whether direction follows the mounted DOM or explicit init/setDir configuration. */
dirSource: DirectionSource
}
ConnectOptions from @llui/components/carousel
export interface ConnectOptions {
id: string
label?: string
indicatorLabel?: string
nextLabel?: string
prevLabel?: string
/** Builder for each slide's aria-label. Receives index + known count. */
slideLabel?: (index: number, count: number) => string
}
Constants
carousel from @llui/components/carousel
const carousel
@llui/components/field
Functions
connect() from @llui/components/field
function connect(state: ReadSignal<FieldState>, send: Send<FieldMsg>, opts: FieldConnectOptions = {}): FieldParts
init() from @llui/components/field
function init(opts: FieldInit): FieldState
update() from @llui/components/field
function update(state: FieldState, msg: FieldMsg): [FieldState, never[]]
Types
FieldMsg from @llui/components/field
export type FieldMsg =
/** @intent("Mark the field as valid or invalid (drives aria-invalid + the error region)") */
| { type: 'setInvalid'; invalid: boolean }
/** @intent("Mark the field as required or optional") */
| { type: 'setRequired'; required: boolean }
/** @intent("Enable or disable the field's control") */
| { type: 'setDisabled'; disabled: boolean }
/** @intent("Make the field's control read-only or editable") */
| { type: 'setReadonly'; readonly: boolean }
/** @intent("Mark the field as touched (typically after first blur)") */
| { type: 'setTouched'; touched: boolean }
Interfaces
FieldConnectOptions from @llui/components/field
export interface FieldConnectOptions {
/** Base id; if omitted, falls back to the id stored in state. */
id?: string
/**
* Whether a description element is rendered. When true, the description id is
* always present in `aria-describedby`; when false it is omitted entirely.
*/
hasDescription?: boolean
}
FieldInit from @llui/components/field
export interface FieldInit {
id: string
invalid?: boolean
required?: boolean
disabled?: boolean
readonly?: boolean
touched?: boolean
}
FieldParts from @llui/components/field
export interface FieldParts {
/** The field wrapper. */
root: {
'data-scope': 'field'
'data-part': 'root'
'data-invalid': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
'aria-disabled': ReadSignal<'true' | undefined>
}
/** The `<label>`. `htmlFor` focuses the control on click. */
label: {
id: string
htmlFor: string
'data-scope': 'field'
'data-part': 'label'
}
/** Spread onto the input/select/textarea (or a custom control via aria-labelledby). */
control: {
id: string
'aria-labelledby': string
'aria-describedby': ReadSignal<string | undefined>
'aria-invalid': ReadSignal<'true' | undefined>
'aria-required': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
readOnly: ReadSignal<boolean>
'data-scope': 'field'
'data-part': 'control'
onBlur: (e: FocusEvent) => void
}
/** The description / hint text. Render it only when there is a description to show. */
description: {
id: string
'data-scope': 'field'
'data-part': 'description'
}
/** The error message — a polite live region, intended to be rendered only while invalid. */
errorText: {
id: string
role: 'alert'
'aria-live': 'polite'
'data-scope': 'field'
'data-part': 'error'
}
}
FieldState from @llui/components/field
Field — label / description / error ARIA wiring for a single form control.
Generates a stable family of ids from one base id and wires them together
so the consumer never hand-writes for / aria-describedby / aria-invalid:
label.htmlFor→ the control id (clicking the label focuses the control natively)label.id→ exposed ascontrol['aria-labelledby']for CUSTOM controls (combobox, listbox, etc.) that aren't a native labellable elementcontrol['aria-describedby']references the description id whenever a description is rendered, and ADDS the error id only whileinvaliderrorTextis a polite live region intended to be rendered only while invalid
const f = field.connect(state.at('field'), send, { id: 'email', hasDescription: true })
el('div', f.root, [
el('label', f.label, [text('Email')]),
el('input', { ...f.control, type: 'email' }),
el('p', f.description, [text('We never share it.')]),
show(state.map((s) => s.field.invalid),
() => el('p', f.errorText, [text('Enter a valid email.')])),
])
export interface FieldState {
/** Base id from which the control / label / description / error ids derive. */
id: string
invalid: boolean
required: boolean
disabled: boolean
readonly: boolean
touched: boolean
}
Constants
field from @llui/components/field
const field
@llui/components/fieldset
Functions
connect() from @llui/components/fieldset
function connect(state: ReadSignal<FieldsetState>, _send: Send<FieldsetMsg>, opts: FieldsetConnectOptions = {}): FieldsetParts
init() from @llui/components/fieldset
function init(opts: FieldsetInit): FieldsetState
update() from @llui/components/fieldset
function update(state: FieldsetState, msg: FieldsetMsg): [FieldsetState, never[]]
Types
FieldsetMsg from @llui/components/fieldset
export type FieldsetMsg =
/** @intent("Enable or disable the whole group (propagates to every contained control)") */
| { type: 'setDisabled'; disabled: boolean }
/** @intent("Mark the group as valid or invalid (drives the group-level error region)") */
| { type: 'setInvalid'; invalid: boolean }
Interfaces
FieldsetConnectOptions from @llui/components/fieldset
export interface FieldsetConnectOptions {
/** Base id; if omitted, falls back to the id stored in state. */
id?: string
/**
* Whether the consumer renders the `legend` part (default: true). When
* false the group omits `aria-labelledby` rather than naming an element
* that does not exist (the `dialog` `hasDescription` rule, #268). A
* `<fieldset>` without a legend is valid HTML; a broken idref is not.
*/
hasLegend?: boolean
}
FieldsetInit from @llui/components/fieldset
export interface FieldsetInit {
id: string
disabled?: boolean
invalid?: boolean
}
FieldsetParts from @llui/components/fieldset
export interface FieldsetParts {
/** Spread onto a native `<fieldset>` element (role `group`). */
root: {
role: 'group'
/** The legend's id, or absent when `hasLegend: false`. */
'aria-labelledby': string | undefined
'aria-disabled': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
'data-scope': 'fieldset'
'data-part': 'root'
'data-invalid': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
}
/** The `<legend>` naming the group. */
legend: {
id: string
'data-scope': 'fieldset'
'data-part': 'legend'
}
/** Group-level error message — a polite live region, rendered only while invalid. */
errorText: {
id: string
role: 'alert'
'aria-live': 'polite'
'data-scope': 'fieldset'
'data-part': 'error'
}
}
FieldsetState from @llui/components/fieldset
Fieldset — group wiring for a set of related controls (e.g. an address block).
The root is a native <fieldset> (role group) labelled by a <legend>.
Setting the group disabled disables every contained control natively (the
native disabled attribute on <fieldset> propagates to descendants), and is
mirrored to aria-disabled for assistive tech. An optional group-level error
region is exposed for cross-field validation messages.
const g = fieldset.connect(state.at('billing'), send, { id: 'billing' })
el('fieldset', g.root, [
el('legend', g.legend, [text('Billing address')]),
// ...fields...
show(state.map((s) => s.billing.invalid),
() => el('p', g.errorText, [text('Address is incomplete.')])),
])
export interface FieldsetState {
/** Base id from which the legend / error ids derive. */
id: string
disabled: boolean
invalid: boolean
}
Constants
fieldset from @llui/components/fieldset
const fieldset
@llui/components/toolbar
Functions
connect() from @llui/components/toolbar
function connect(state: ReadSignal<ToolbarState>, send: Send<ToolbarMsg>, opts: ConnectOptions): ToolbarParts
init() from @llui/components/toolbar
function init(opts: ToolbarInit = {}): ToolbarState
update() from @llui/components/toolbar
function update(state: ToolbarState, msg: ToolbarMsg): [ToolbarState, never[]]
Types
Orientation from @llui/components/toolbar
Toolbar — a roving-tabindex container for a set of controls (buttons, toggles, menu triggers). The toolbar is a single tab stop: Tab moves focus into the active item and a subsequent Tab leaves the toolbar entirely. Arrow keys rove focus between enabled items, skipping separators and disabled items; Home/End jump to the first/last enabled item.
Toolbar is interaction-agnostic: an item may itself be a toggle or a menu trigger. The toolbar only manages which item holds the single tab stop.
export type Orientation = 'horizontal' | 'vertical'
ToolbarMsg from @llui/components/toolbar
export type ToolbarMsg =
/** @humanOnly */
| { type: 'setItems'; items: string[]; disabled?: string[] }
/** @humanOnly */
| { type: 'setFocused'; value: string }
/** @humanOnly */
| { type: 'focusNext'; from: string }
/** @humanOnly */
| { type: 'focusPrev'; from: string }
/** @humanOnly */
| { type: 'focusFirst' }
/** @humanOnly */
| { type: 'focusLast' }
Interfaces
ConnectOptions from @llui/components/toolbar
export interface ConnectOptions {
id: string
label?: string
}
ToolbarGroupParts from @llui/components/toolbar
export interface ToolbarGroupParts {
root: {
role: 'group'
'data-scope': 'toolbar'
'data-part': 'group'
/** The label part's id, or absent when `hasLabel: false`. */
'aria-labelledby': string | undefined
}
label: {
id: string
'data-scope': 'toolbar'
'data-part': 'group-label'
}
}
ToolbarInit from @llui/components/toolbar
export interface ToolbarInit {
items?: string[]
disabledItems?: string[]
focused?: string | null
orientation?: Orientation
loopFocus?: boolean
disabled?: boolean
}
ToolbarItemParts from @llui/components/toolbar
export interface ToolbarItemParts {
root: {
'data-scope': 'toolbar'
'data-part': 'item'
'data-value': string
'data-disabled': ReadSignal<'' | undefined>
'aria-disabled': ReadSignal<'true' | undefined>
tabindex: ReadSignal<number>
onKeyDown: (e: KeyboardEvent) => void
onFocus: () => void
}
}
ToolbarParts from @llui/components/toolbar
export interface ToolbarParts {
root: {
role: 'toolbar'
'aria-orientation': ReadSignal<Orientation>
'aria-label': string | undefined
'aria-disabled': ReadSignal<'true' | undefined>
'data-scope': 'toolbar'
'data-part': 'root'
'data-orientation': ReadSignal<Orientation>
'data-disabled': ReadSignal<'' | undefined>
}
separator: {
role: 'separator'
'aria-orientation': ReadSignal<Orientation>
'data-scope': 'toolbar'
'data-part': 'separator'
}
item: (value: string) => ToolbarItemParts
/**
* A labelled group's parts. The group names its `label` part through
* `aria-labelledby`; a consumer that renders the group WITHOUT that label
* passes `{ hasLabel: false }` so it never names an element that does not
* exist (an unlabelled `role="group"` is valid; a broken idref is not — the
* `dialog` `hasDescription` rule, #268). Default: true.
*/
group: (label: string, options?: { readonly hasLabel?: boolean }) => ToolbarGroupParts
}
ToolbarState from @llui/components/toolbar
export interface ToolbarState {
items: string[]
disabledItems: string[]
focused: string | null
orientation: Orientation
loopFocus: boolean
disabled: boolean
}
Constants
toolbar from @llui/components/toolbar
const toolbar
@llui/components/meter
Functions
bandAt() from @llui/components/meter
The band the reading falls in, or null. See {@link matchIndex} for the rule.
function bandAt(state: MeterState): MeterBand | null
connect() from @llui/components/meter
function connect(state: ReadSignal<MeterState>, _send: Send<MeterMsg>, opts: ConnectOptions = {}): MeterParts
init() from @llui/components/meter
function init(opts: MeterInit = {}): MeterState
percent() from @llui/components/meter
function percent(state: MeterState): number
toneAt() from @llui/components/meter
The current band's tone — the data-state every part carries.
function toneAt(state: MeterState): MeterTone
update() from @llui/components/meter
function update(state: MeterState, msg: MeterMsg): [MeterState, never[]]
Types
MeterMsg from @llui/components/meter
export type MeterMsg =
/** @humanOnly */
| { type: 'setValue'; value: number }
/** @humanOnly */
| { type: 'setMax'; max: number }
/** @humanOnly */
| { type: 'setBands'; bands: readonly MeterBandInit[] }
MeterTone from @llui/components/meter
A band's quality, and the whole data-state vocabulary. neutral is the
absence of an opinion — a plain gauge, or a band the author gave no tone —
and is deliberately not a synonym for optimal: a disk-usage meter with no
thresholds declared is not claiming the reading is good.
export type MeterTone = 'optimal' | 'suboptimal' | 'critical' | 'neutral'
Interfaces
ConnectOptions from @llui/components/meter
export interface ConnectOptions {
label?: string
/** Custom formatter for the numeric half of the value text. */
format?: (value: number, max: number) => string
}
MeterBand from @llui/components/meter
A band as STATE holds it.
tone is always present — init/setBands fill the default, so every read
is total. The EDGES follow #177's UNBOUNDED-CAPABLE idiom: an absent key
means the band is open on that side, and the key is OMITTED rather than set
to undefined, so the JSON round trip is an identity key for key.
label is what aria-valuetext announces ("2.1 mIU/L, optimal") — the
accessibility win the band model exists for, since a bare aria-valuenow of
47 says nothing about whether the reading is in range. An author-written band
defaults its label to its id; write label: '' to announce nothing.
export interface MeterBand {
id: string
tone: MeterTone
/** Lower edge, INCLUSIVE. Absent → open below. */
from?: number
/** Upper edge, EXCLUSIVE (inclusive only where it reaches `max`). Absent → open above. */
to?: number
label?: string
}
MeterBandGeometry from @llui/components/meter
One band, laid out. Derived from state and stored nowhere: from/to are
the resolved VALUE edges (an open side takes the track bound), start/size
are the drawn box in percent, clamped into the track so a band reaching past
min/max paints inside it.
export interface MeterBandGeometry {
id: string
tone: MeterTone
label: string | undefined
from: number
to: number
start: number
size: number
current: boolean
}
MeterBandInit from @llui/components/meter
A band as a consumer WRITES it; init/setBands normalise it into a {@link MeterBand}.
export interface MeterBandInit {
id: string
from?: number
to?: number
tone?: MeterTone
label?: string
}
MeterInit from @llui/components/meter
export interface MeterInit {
value?: number
min?: number
max?: number
/**
* Native `<meter>` spelling for the three-segment case, compiled into
* {@link MeterState.bands} as `low` / `middle` / `high`. Ignored when
* `bands` is given and yields at least one usable band — which is what makes
* `bands: []` keep the single-optimum behaviour.
*/
low?: number
high?: number
optimum?: number
bands?: readonly MeterBandInit[]
}
MeterParts from @llui/components/meter
export interface MeterParts {
root: {
role: 'meter'
'aria-valuemin': ReadSignal<number>
'aria-valuemax': ReadSignal<number>
'aria-valuenow': ReadSignal<number>
'aria-valuetext': ReadSignal<string>
'aria-label': string | undefined
// Spelled out rather than aliased: `scripts/test/registry-attrs.test.ts`
// reads part-bag VALUES syntactically and gives an ALIAS no verdict, which
// is how the shipped skin came to style `data-[state=critical]` against a
// machine emitting `low`/`high` — two rules of dead CSS, for a release.
'data-state': ReadSignal<'optimal' | 'suboptimal' | 'critical' | 'neutral'>
/** The current band's id — absent when the reading is in no band. */
'data-band': ReadSignal<string | undefined>
'data-scope': 'meter'
'data-part': 'root'
}
track: {
'data-state': ReadSignal<'optimal' | 'suboptimal' | 'critical' | 'neutral'>
'data-scope': 'meter'
'data-part': 'track'
}
/** The filled bar of a classic gauge: `inline-size` up to the reading. */
range: {
'data-state': ReadSignal<'optimal' | 'suboptimal' | 'critical' | 'neutral'>
'data-scope': 'meter'
'data-part': 'range'
style: ReadSignal<string>
}
/** The reading itself, for a BANDED track: positioned, not filled. */
marker: {
'data-state': ReadSignal<'optimal' | 'suboptimal' | 'critical' | 'neutral'>
'data-band': ReadSignal<string | undefined>
'data-scope': 'meter'
'data-part': 'marker'
style: ReadSignal<string>
}
label: {
'data-scope': 'meter'
'data-part': 'label'
}
/** The laid-out bands, for `each` — key rows on `band.id`. */
bands: ReadSignal<MeterBandGeometry[]>
/**
* Attributes for one laid-out band, taking the ROW HANDLE `each` hands the
* render function. It returns signals rather than plain values so the bag is
* spreadable AS IS — `chart`'s `markProps` takes a snapshot instead and every
* call site has to `peek()` the row and then re-bind the fields that move.
*/
bandProps: (band: Signal<MeterBandGeometry>) => {
'data-scope': 'meter'
'data-part': 'band'
'data-band': ReadSignal<string>
'data-state': ReadSignal<'optimal' | 'suboptimal' | 'critical' | 'neutral'>
'data-current': ReadSignal<'' | undefined>
style: ReadSignal<string>
}
/**
* One band by id, for a view that names its bands statically. An id no band
* carries yields an INERT bag — `hidden`, zero-width, `neutral` — rather than
* throwing: `connect` runs while the view is being built, so a typo would
* take the whole page down instead of leaving one stripe undrawn.
*/
band: (id: string) => {
'data-scope': 'meter'
'data-part': 'band'
'data-band': string
'data-state': ReadSignal<'optimal' | 'suboptimal' | 'critical' | 'neutral'>
'data-current': ReadSignal<'' | undefined>
hidden: ReadSignal<boolean>
style: ReadSignal<string>
}
/** The formatted reading, WITHOUT the band name. */
valueText: ReadSignal<string>
/** The current band's announced name, or `''`. */
bandLabel: ReadSignal<string>
}
MeterState from @llui/components/meter
export interface MeterState {
value: number
min: number
max: number
/**
* The reference range, in AUTHOR ORDER. Nothing is sorted or merged: order
* decides which band wins an overlap (the first match) and the order the
* view draws them in, and re-ordering the author's ids would silently change
* both.
*/
bands: MeterBand[]
}
Constants
meter from @llui/components/meter
const meter
@llui/components/breadcrumbs
Functions
connect() from @llui/components/breadcrumbs
function connect(state: ReadSignal<BreadcrumbsState>, send: Send<BreadcrumbsMsg>, opts: ConnectOptions = {}): BreadcrumbsParts
init() from @llui/components/breadcrumbs
function init(opts: BreadcrumbsInit = {}): BreadcrumbsState
update() from @llui/components/breadcrumbs
function update(state: BreadcrumbsState, msg: BreadcrumbsMsg): [BreadcrumbsState, never[]]
visibleItems() from @llui/components/breadcrumbs
Compute the visible breadcrumb trail. When maxVisible is set and exceeded
and the trail is not expanded, collapse the middle to:
[first] … [last (maxVisible - 1) items]. The final item is always current.
function visibleItems(state: BreadcrumbsState): VisibleBreadcrumb[]
Types
BreadcrumbsMsg from @llui/components/breadcrumbs
export type BreadcrumbsMsg =
/** @intent("Replace the breadcrumb trail with a new list of items") */
| { type: 'setItems'; items: BreadcrumbItem[] }
/** @intent("Expand the collapsed middle of the trail to reveal all items") */
| { type: 'expand' }
/** @intent("Collapse the trail back to its truncated form") */
| { type: 'collapse' }
VisibleBreadcrumb from @llui/components/breadcrumbs
export type VisibleBreadcrumb =
| { type: 'item'; id: string; label: string; current: boolean }
| { type: 'ellipsis' }
Interfaces
BreadcrumbItem from @llui/components/breadcrumbs
Breadcrumbs — a hierarchical trail of links to ancestor pages.
The last item is the current page. When maxVisible is set and the trail
is longer, the middle collapses to: first item + ellipsis + last N items,
until the user expands it.
export interface BreadcrumbItem {
id: string
label: string
}
BreadcrumbsInit from @llui/components/breadcrumbs
export interface BreadcrumbsInit {
items?: BreadcrumbItem[]
maxVisible?: number | null
expanded?: boolean
}
BreadcrumbsParts from @llui/components/breadcrumbs
export interface BreadcrumbsParts {
root: {
'aria-label': string
'data-scope': 'breadcrumbs'
'data-part': 'root'
}
list: {
'data-scope': 'breadcrumbs'
'data-part': 'list'
}
item: (id: string) => {
'data-scope': 'breadcrumbs'
'data-part': 'item'
'data-value': string
}
link: (id: string) => {
'aria-current': ReadSignal<'page' | undefined>
'data-scope': 'breadcrumbs'
'data-part': 'link'
'data-value': string
'data-current': ReadSignal<'' | undefined>
}
separator: {
'aria-hidden': 'true'
'data-scope': 'breadcrumbs'
'data-part': 'separator'
}
ellipsisTrigger: {
type: 'button'
'aria-label': string
'data-scope': 'breadcrumbs'
'data-part': 'ellipsis-trigger'
onClick: (e: MouseEvent) => void
}
}
BreadcrumbsState from @llui/components/breadcrumbs
export interface BreadcrumbsState {
items: BreadcrumbItem[]
maxVisible: number | null
expanded: boolean
}
ConnectOptions from @llui/components/breadcrumbs
export interface ConnectOptions {
label?: string
expandLabel?: string
}
Constants
breadcrumbs from @llui/components/breadcrumbs
const breadcrumbs
@llui/components/search-field
Functions
connect() from @llui/components/search-field
function connect(state: ReadSignal<SearchFieldState>, send: Send<SearchFieldMsg>, opts: ConnectOptions = {}): SearchFieldParts
init() from @llui/components/search-field
function init(opts: SearchFieldInit = {}): SearchFieldState
update() from @llui/components/search-field
function update(state: SearchFieldState, msg: SearchFieldMsg): [SearchFieldState, never[]]
Types
SearchFieldMsg from @llui/components/search-field
export type SearchFieldMsg =
/** @humanOnly */
| { type: 'setValue'; value: string }
/** @intent("Clear the search field") */
| { type: 'clear' }
/** @intent("Submit the current search query") */
| { type: 'submit'; value: string }
Interfaces
ConnectOptions from @llui/components/search-field
export interface ConnectOptions {
/** Accessible label for the clear button. */
clearLabel?: string
}
SearchFieldInit from @llui/components/search-field
export interface SearchFieldInit {
value?: string
disabled?: boolean
}
SearchFieldParts from @llui/components/search-field
export interface SearchFieldParts {
root: {
role: 'search'
'data-scope': 'search-field'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
}
label: {
'data-scope': 'search-field'
'data-part': 'label'
}
input: {
type: 'search'
disabled: ReadSignal<boolean>
value: ReadSignal<string>
'data-scope': 'search-field'
'data-part': 'input'
onInput: (e: Event) => void
onKeyDown: (e: KeyboardEvent) => void
}
clearTrigger: {
type: 'button'
'aria-label': string
hidden: ReadSignal<boolean>
tabindex: -1
'data-scope': 'search-field'
'data-part': 'clear-trigger'
onClick: (e: MouseEvent) => void
}
}
SearchFieldState from @llui/components/search-field
Search field — a role="search" landmark wrapping a type="search" input
with a clear button. Escape clears the field (when non-empty), Enter submits
the current value.
Debounced live search is intentionally NOT built into this machine. Keep it
consumer-side: debounce the setValue message (or a derived "search" effect)
with debounce from @llui/effects so the search trigger fires once the user
pauses typing, rather than on every keystroke.
export interface SearchFieldState {
value: string
disabled: boolean
}
Constants
searchField from @llui/components/search-field
const searchField
@llui/components/table
Functions
connect() from @llui/components/table
function connect(state: ReadSignal<TableState>, send: Send<TableMsg>, opts: ConnectOptions): TableParts
init() from @llui/components/table
function init(opts: TableInit = {}): TableState
isAllSelected() from @llui/components/table
function isAllSelected(state: TableState): boolean
isRowSelected() from @llui/components/table
function isRowSelected(state: TableState, id: string): boolean
isSomeSelected() from @llui/components/table
function isSomeSelected(state: TableState): boolean
sortDirectionFor() from @llui/components/table
function sortDirectionFor(state: TableState, columnId: string): SortDirection | null
update() from @llui/components/table
function update(state: TableState, msg: TableMsg): [TableState, never[]]
Types
SortDirection from @llui/components/table
Table / data grid — a headless machine for sortable columns, row
selection, and APG grid keyboard navigation. It is NOT a rendering
engine: row DATA stays in the consumer; the machine tracks only row
IDs (in display order), sort state, the selected-id set, and the
focused cell coordinate. The consumer renders the grid (via each or
virtualEach) and performs the actual data sort — so server-side sort
works by feeding pre-sorted rows back in. focusedCell is addressed
by index, robust to virtualization.
export type SortDirection = 'asc' | 'desc'
TableDensity from @llui/components/table
Optional density of the styled grid root; table behavior remains unchanged.
export type TableDensity = 'comfortable' | 'compact'
TableMsg from @llui/components/table
export type TableMsg =
/** @intent("Cycle the sort on the given column (asc → desc → none, or desc → asc → none when descFirst)") */
| { type: 'toggleSort'; columnId: string }
/** @intent("Set an explicit sort, or null to clear sorting") */
| { type: 'setSort'; sort: TableSort | null }
/** @intent("Toggle selection of the row with the given id at the given display index") */
| { type: 'toggleRow'; id: string; index: number }
/** @intent("Select every row (multiple mode only)") */
| { type: 'selectAll' }
/** @intent("Clear the entire selection") */
| { type: 'clearSelection' }
/** @intent("Toggle between select-all and clear, based on whether every row is selected") */
| { type: 'toggleAll' }
/** @intent("Replace the selected-id set with the provided list") */
| { type: 'setSelection'; ids: string[] }
/** @intent("Select the inclusive range from the current anchor to the given index (Shift+click)") */
| { type: 'selectRange'; index: number }
/** @intent("Activate (open/confirm) the row with the given id at the given index") */
| { type: 'activateRow'; id: string; index: number }
/** @intent("Replace the row-id list (display order); drops selection for ids no longer present") */
| { type: 'setRows'; rows: string[] }
/** @intent("Replace the column descriptors") */
| { type: 'setColumns'; columns: TableColumn[] }
/** @humanOnly */
| { type: 'focusCell'; rowIndex: number; colIndex: number }
/** @humanOnly */
| { type: 'moveCell'; dRow: number; dCol: number }
/** @humanOnly */
| { type: 'rowStart' }
/** @humanOnly */
| { type: 'rowEnd' }
/** @humanOnly */
| { type: 'gridStart' }
/** @humanOnly */
| { type: 'gridEnd' }
/** @humanOnly */
| { type: 'pageDown' }
/** @humanOnly */
| { type: 'pageUp' }
TableSelectionMode from @llui/components/table
export type TableSelectionMode = 'none' | 'single' | 'multiple'
Interfaces
ConnectOptions from @llui/components/table
export interface ConnectOptions {
id: string
/** Presentation density published on the grid root for either skin to consume. */
density?: TableDensity
}
TableCellCoord from @llui/components/table
export interface TableCellCoord {
/** Row index into `rows`, or {@link HEADER_ROW_INDEX} for the header row. */
rowIndex: number
colIndex: number
}
TableCellParts from @llui/components/table
export interface TableCellParts {
role: 'gridcell'
'aria-colindex': number
tabindex: ReadSignal<number>
'data-scope': 'table'
'data-part': 'cell'
/** 0-based row index — addresses the cell for roving DOM focus. Reactive
* for the same reason `TableRowParts`'s `aria-rowindex` is. */
'data-row-index': ReadSignal<number>
/** 0-based column index — addresses the cell for roving DOM focus. Columns
* do not reorder, so this stays a plain number. */
'data-col-index': number
'data-focused': ReadSignal<'' | undefined>
onFocus: (e: FocusEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
TableCheckboxParts from @llui/components/table
export interface TableCheckboxParts {
role: 'checkbox'
/**
* The checkbox's accessible name (localized; `Locale['table']`). A
* `role="checkbox"` span has no content to be named by, so without it
* assistive tech announced an unnamed checkbox in every row (#268).
*/
'aria-label': string
'aria-checked': ReadSignal<'true' | 'false' | 'mixed'>
'data-scope': 'table'
'data-part': 'select-all' | 'row-checkbox'
'data-state': ReadSignal<'checked' | 'unchecked' | 'indeterminate'>
/** Always `-1`: a `role="grid"` has exactly ONE tab stop, the roving cell. */
tabindex: -1
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
TableColumn from @llui/components/table
export interface TableColumn {
/** Opaque column id. */
id: string
/** Whether this column participates in sorting. Defaults to false. */
sortable?: boolean
}
TableColumnHeaderParts from @llui/components/table
export interface TableColumnHeaderParts {
role: 'columnheader'
id: string
'aria-sort': ReadSignal<'ascending' | 'descending' | 'none' | undefined>
/**
* Roving tab stop. The header row participates in the grid's single-tab-stop
* sequence, because it hosts controls — the sort toggle on every sortable
* column, and the select-all checkbox — that are otherwise unreachable by
* keyboard.
*/
tabindex: ReadSignal<number>
'data-scope': 'table'
'data-part': 'column-header'
'data-column': string
/** Always {@link HEADER_ROW_INDEX} — addresses the header for roving DOM focus. */
'data-row-index': typeof HEADER_ROW_INDEX
/** 0-based column index (`-1` for a column not in `columns`). */
'data-col-index': ReadSignal<number>
'data-focused': ReadSignal<'' | undefined>
'data-sortable': ReadSignal<'' | undefined>
'data-sort': ReadSignal<SortDirection | undefined>
onFocus: (e: FocusEvent) => void
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
TableInit from @llui/components/table
export interface TableInit {
columns?: TableColumn[]
rows?: string[]
sort?: TableSort | null
selection?: string[]
selectionMode?: TableSelectionMode
focusedCell?: TableCellCoord | null
pageSize?: number
descFirst?: boolean
disabled?: boolean
}
TableParts from @llui/components/table
export interface TableParts {
/**
* Place the native table carrying {@link root} directly inside this owned
* scroll viewport. Wide grids then scroll locally without changing the
* table's native layout/ARIA semantics or overflowing the page.
*/
viewport: {
'data-scope': 'table'
'data-part': 'viewport'
}
root: {
role: 'grid'
id: string
'aria-multiselectable': ReadSignal<'true' | undefined>
'aria-rowcount': ReadSignal<number>
'aria-colcount': ReadSignal<number>
'aria-disabled': ReadSignal<'true' | undefined>
'data-scope': 'table'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
'data-density': TableDensity | undefined
}
columnHeader: (columnId: string) => TableColumnHeaderParts
/**
* `index` accepts a plain `number` OR a `ReadSignal<number>` (the row handle
* `each`/`virtualEach` passes its render callback) — a keyed row is REUSED
* (moved, not rebuilt) on reorder, so a plain number captured at build time
* would freeze `aria-rowindex` and the row's own `toggleRow`/`selectRange`
* dispatch at the row's ORIGINAL position forever. Pass the row's reactive
* index handle whenever rows can reorder (sorting, filtering); a plain
* number is still accepted for a table that never reorders.
*/
row: (id: string, index: Reactive<number>) => TableRowParts
/** `rowIndex` has the same `Reactive<number>` contract as {@link row}'s
* `index` — the column index does not reorder and stays a plain `number`. */
cell: (rowIndex: Reactive<number>, colIndex: number) => TableCellParts
/**
* The select-all checkbox, for the `columnheader` of `columnId`.
*
* The column id is a PARAMETER rather than a `connect()` option because the
* checkbox is unreachable by keyboard without it: it has no gridcell of its
* own, and every part inside a `role="grid"` except the one roving stop is
* `tabindex="-1"`, so its only keyboard route is Enter/Space on the roving
* header that hosts it — and the machine can only route that key if it knows
* which header. As an option it was forgettable, and forgetting it failed
* SILENTLY (no warning, no error; the key sorted the column or did nothing).
* As a required argument you cannot render the checkbox without answering the
* question, so the failure mode is gone at compile time.
*
* `columnId` must be a column in `state.columns` — that is what gives the
* header a colIndex to rove to. A column not in the list can never take the
* roving stop, and its header will not send `toggleAll` either.
*/
selectAllCheckbox: (columnId: string) => TableCheckboxParts
/** Same `Reactive<number>` contract as {@link row}'s `index`. */
rowCheckbox: (id: string, index: Reactive<number>) => TableCheckboxParts
}
TableRowParts from @llui/components/table
export interface TableRowParts {
role: 'row'
'aria-selected': ReadSignal<boolean | undefined>
/** Reactive: a row's DISPLAY position can change after sort/reorder without
* this row being rebuilt (`each` reuses rows by key), so the index this
* addresses must follow the row's live position rather than freeze at
* whatever it was when the row was first built. */
'aria-rowindex': ReadSignal<number>
'data-scope': 'table'
'data-part': 'row'
'data-row': string
'data-selected': ReadSignal<'' | undefined>
onClick: (e: MouseEvent) => void
}
TableSort from @llui/components/table
export interface TableSort {
columnId: string
direction: SortDirection
}
TableState from @llui/components/table
export interface TableState {
/** Column descriptors in display order. */
columns: TableColumn[]
/** Row IDs in display order. Row DATA stays in the consumer. */
rows: string[]
/** Active sort, or null when unsorted. */
sort: TableSort | null
/** Selected row IDs. */
selection: string[]
selectionMode: TableSelectionMode
/** Focused cell coordinate (header row excluded; rowIndex addresses `rows`). */
focusedCell: TableCellCoord | null
/** Index of the last row toggled — the anchor for shift-range selection. */
rangeAnchor: number | null
/** Rows moved per PageUp/PageDown. */
pageSize: number
/** When true, the sort cycle starts at desc instead of asc. */
descFirst: boolean
disabled: boolean
}
Constants
HEADER_ROW_INDEX from @llui/components/table
The row index of the HEADER row. The header is part of the grid's roving
sequence — APG's data-grid examples make column headers focusable exactly
because they carry controls (sort here, plus the select-all checkbox) — so it
needs a coordinate. -1 is the natural one: aria-rowindex already models
the header as row 1 with data row i at i + 2, so the header sits one row
above data row 0.
const HEADER_ROW_INDEX
table from @llui/components/table
The namespace object. {@link HEADER_ROW_INDEX} is a member again now that
issue #151 is fixed — site/src/generate-api.ts classifies namespace members
by kind instead of rendering every one with a hard-coded (). It remains a
module-level export too, re-exported from the barrel as
TABLE_HEADER_ROW_INDEX.
const table
@llui/components/menubar
Functions
connect() from @llui/components/menubar
function connect(state: ReadSignal<MenubarState>, send: Send<MenubarMsg>, opts: ConnectOptions): MenubarParts
init() from @llui/components/menubar
function init(opts: MenubarInit): MenubarState
overlay() from @llui/components/menubar
Render one top-level menu's dropdown. Mirrors menu.overlay but is gated on
state.open === menuId and dismisses by closing the menubar (returning
focus to the top-level trigger). Submenu unwinding goes through the same
dismissable stack the menu machine uses.
function overlay(opts: MenubarOverlayOptions): Mountable
subOverlay() from @llui/components/menubar
Engine-owned floating overlay for one submenu level of an embedded menu
(#265 A4). Unlike menu/context-menu's own subOverlay, this one reads the ROOT
MenubarState and reaches into the one open menu's embedded MenuState
(s.menuStates[menuId]) for both open-membership and direction, because a
menubar's overlay() is likewise keyed on root state (s.open === menuId)
rather than on the embedded menu's own state. See
menu-machine.ts:subOverlay's doc comment for the shared contract.
function subOverlay(opts: SubOverlayOptions): Mountable
update() from @llui/components/menubar
function update(state: MenubarState, msg: MenubarMsg): [MenubarState, never[]]
Types
MenubarMsg from @llui/components/menubar
export type MenubarMsg =
/** @intent("Open the menu with the given id and focus its first item") */
| { type: 'openMenu'; id: string }
/** @intent("Close the currently-open menu") */
| { type: 'closeMenu' }
/** @intent("Move roving focus to the menu with the given id (switches the open menu in open mode)") */
| { type: 'focusMenu'; id: string }
/** @humanOnly */
| { type: 'syncTriggerFocus'; id: string }
/** @humanOnly */
| { type: 'focusNext' }
/** @humanOnly */
| { type: 'focusPrev' }
/** @humanOnly */
| { type: 'menuMsg'; id: string; msg: MenuMsg }
/** @intent("Set the reading direction") */
| { type: 'setDir'; dir: TextDirection }
/** @humanOnly — synchronized from the mounted root's live ancestor direction. */
| { type: 'syncDomDir'; dir: TextDirection }
Interfaces
ConnectOptions from @llui/components/menubar
export interface ConnectOptions {
id: string
label?: string
/** Called when an item in any menu is activated (Enter/Space/click). */
onSelect?: (menuId: string, value: string) => void
}
MenubarInit from @llui/components/menubar
export interface MenubarInit {
menus: MenubarMenu[]
/** Initially-focused menu id (defaults to the first enabled menu). */
focused?: string | null
/** Reading direction for horizontal keys and delegated menus. Omit to
* follow the page's own direction (see {@link MenubarState}'s `dir`/
* `dirSource`, resolved from the mounted root by `directionSync`). */
dir?: TextDirection
}
MenubarMenu from @llui/components/menubar
Declarative description of one top-level menu in the bar.
export interface MenubarMenu {
id: string
items: MenuItem[]
disabled?: boolean
/** When true, selecting a checkbox/radio also closes this menu. */
closeOnSelect?: boolean
}
MenubarOverlayOptions from @llui/components/menubar
export interface MenubarOverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*/
positionerClass?: string
state: ReadSignal<MenubarState>
send: Send<MenubarMsg>
/** The menu id this overlay renders. */
menuId: string
/** The delegated per-menu bag — `connect(...).menu(menuId)`. Its
* `trigger.id` is the id the bar's `menuTrigger(menuId)` renders, so it
* doubles as the overlay's anchor. */
parts: MenuParts
content: () => Renderable
/**
* Optional enter/leave transition for the menubar menu content (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, so the close plays an exit animation.
* Keep `skipAnimations` at its default (true) when driving exits this way.
*
* @example menubar.overlay({ state, send, parts, content, transition: fade({ duration: 120 }) })
*/
transition?: TransitionOptions
placement?: Placement
offset?: number
flip?: boolean
shift?: boolean
target?: string | HTMLElement
}
MenubarParts from @llui/components/menubar
export interface MenubarParts {
root: {
// `id` is REQUIRED — it is the scope `directionSync` (below) looks the
// live root up by, the same contract `navigation-menu`'s own `root.id`
// already honours (#265 finding 6). A consumer that overrides it with a
// DIFFERENT id breaks the direction sync silently.
id: string
role: 'menubar'
'aria-label': string
'data-scope': 'menubar'
'data-part': 'root'
}
menuTrigger: (id: string) => MenubarTriggerParts
/** Delegated per-menu part bag (content/item/checkboxItem/submenu/…). */
menu: (id: string) => MenuParts
/** Place once anywhere in the same build to keep automatic direction live —
* the shared `@llui/interactions` direction-sync seam (#265 finding 6). A
* discarded `Mountable` is inert, so this must be placed in the view. */
directionSync: Mountable
}
MenubarState from @llui/components/menubar
export interface MenubarState {
/** Top-level menu ids, in bar order. */
menus: string[]
/** The id of the currently-open menu, or null. */
open: string | null
/** The id of the top-level trigger that holds roving focus. */
focused: string | null
/** Ids of disabled menus (cannot be opened/focused). */
disabledMenus: string[]
/** Embedded per-menu machine states, keyed by menu id. */
menuStates: Record<string, MenuState>
/** Reading direction for both the bar and its delegated menu trees. Routed
* through the shared `@llui/interactions` direction-sync seam
* (`../utils/direction.js`) rather than a second resolver — `dirSource`
* tracks whether `dir` came from explicit config/`setDir` or from the
* mounted root's live ancestor `dir` attribute (#265 finding 6). Every
* embedded `MenuState`'s own `dir` is kept explicitly in sync with this one
* (see `syncEmbeddedDir` below), so a delegated menu never disagrees with
* the bar that owns it. */
dir: TextDirection
dirSource: DirectionSource
}
MenubarTriggerParts from @llui/components/menubar
export interface MenubarTriggerParts {
role: 'menuitem'
id: string
'aria-haspopup': 'menu'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
'aria-disabled': ReadSignal<'true' | undefined>
'data-scope': 'menubar'
'data-part': 'trigger'
'data-state': ReadSignal<'open' | 'closed'>
'data-value': string
tabindex: ReadSignal<number>
onClick: (e: MouseEvent) => void
onPointerEnter: (e: PointerEvent) => void
onFocus: (e: FocusEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
SubOverlayOptions from @llui/components/menubar
export interface SubOverlayOptions {
/** The open menu's id (`opts.menuId` of the enclosing `overlay()`). */
menuId: string
/** The subTrigger value this level opens under. */
value: string
/** The ROOT `ReadSignal<MenubarState>` — the same one passed to `connect()`. */
state: ReadSignal<MenubarState>
parts: Pick<MenuParts, 'subTrigger' | 'subPositioner' | 'subContent'>
content: () => Renderable
target?: string | HTMLElement
positionerClass?: string
align?: 'start' | 'end'
offset?: number
flip?: boolean
shift?: boolean
}
Constants
menubar from @llui/components/menubar
const menubar
@llui/components/in-view
Functions
connect() from @llui/components/in-view
function connect(state: ReadSignal<InViewState>, _send: Send<InViewMsg>, _opts: ConnectOptions): InViewParts
createObserver() from @llui/components/in-view
Create an IntersectionObserver for the given element. Returns a cleanup function that disconnects the observer.
Call this inside onMount:
onMount((el) => inView.createObserver(el, send, { once: true }))
function createObserver(el: Element, send: Send<InViewMsg>, opts: ObserverOptions = {}): () => void
init() from @llui/components/in-view
function init(): InViewState
update() from @llui/components/in-view
function update(state: InViewState, msg: InViewMsg): [InViewState, never[]]
Types
InViewMsg from @llui/components/in-view
export type InViewMsg = { type: 'enter' } | { type: 'leave' }
Interfaces
ConnectOptions from @llui/components/in-view
export interface ConnectOptions {
id: string
}
InViewParts from @llui/components/in-view
export interface InViewParts {
root: {
'data-scope': 'in-view'
'data-part': 'root'
'data-state': ReadSignal<'visible' | 'hidden'>
}
}
InViewState from @llui/components/in-view
In View — tracks whether an element is visible in the viewport using IntersectionObserver.
State machine: { visible: false } → enter → { visible: true } → leave → …
With once: true, the observer disconnects after the first enter,
keeping visible: true permanently. Useful for lazy-load and
scroll-triggered animations.
view: ({ state, send }) => {
const inViewState = state.at('inView')
const inViewSend = mapSend<Msg, inView.InViewMsg>(send, (msg) => ({
type: 'inView',
msg,
}))
const parts = inView.connect(inViewState, inViewSend, { id: 'hero' })
return [
div({ ...parts.root, class: inViewState.at('visible').map((v) => (v ? 'fade-in' : '')) }, [
onMount((el) =>
inView.createObserver(el, inViewSend, { threshold: 0.5, once: true }),
),
]),
]
}
export interface InViewState {
visible: boolean
}
ObserverOptions from @llui/components/in-view
export interface ObserverOptions {
threshold?: number
rootMargin?: string
once?: boolean
}
Constants
inView from @llui/components/in-view
const inView
@llui/components/theme-switch
Functions
applyTheme() from @llui/components/theme-switch
Publish the user's PREFERENCE on <html>: data-theme="light" / "dark"
for an explicit choice, and the attribute REMOVED for 'system'.
Removing it is not "doing nothing" — it is how 'system' is spelled, and
tokens-dark.css answers all three states from that one attribute:
@media (prefers-color-scheme: dark) {
:root:where(:not([data-theme='light'])):where(:not(.light)) { … } // absent -> OS
}
.dark,
[data-theme='dark'] { … } // "dark" -> dark
with :root in tokens.css as the light default. So an absent attribute
follows the OS in CSS, and data-theme="light" opts a subtree back out of
the media query.
This takes a {@link Theme}, not a {@link ResolvedTheme}, deliberately (#233).
Three costs to resolving 'system' in JS and pinning the answer, none of
which the CSS route pays:
watchSystemThemebecomes MANDATORY to track OS changes the media query would have followed for free;- SSR has no correct attribute to render, since the server cannot know the
client's
prefers-color-scheme; - the attribute stops naming the PREFERENCE.
data-theme="dark"then means either "the user chose dark" or "the user chose system and the OS is dark", and nothing on the page can tell them apart.
It does NOT cost a first-paint flash — a claim worth not repeating, because it
is false and easy to check: resolveTheme('system') reads the same media
query the first paint used, so the attribute it writes agrees with what is
already on screen.
Use resolveTheme() only when you genuinely need the resolved value IN JS —
canvas theming, a <meta name="theme-color"> — never merely to honour the OS
setting.
THIS FUNCTION DELIBERATELY WRITES NO .dark CLASS (#242), and that is the
decision, not an omission. tokens-dark.css still ships a .dark selector for
consumers whose own tooling writes it (next-themes, an SSR-rendered class), but
maintaining it HERE was rejected on measurement: .dark names a RESOLVED
theme, so a 'system' preference would have to resolve prefers-color-scheme
in JS, and the class then goes stale the moment the OS flips. A stale class
does not merely fail to help — it SUPPRESSES the media query that is correct
with no JS at all. Measured in Chromium against the shipped stylesheets, with
the preference on 'system' throughout:
os=light class=(none) -> LIGHT ok os=dark class=(none) -> DARK ok
os=light class=dark -> DARK !!WRONG!! os=dark class=dark -> DARK ok
os=light class=light -> LIGHT ok os=dark class=light -> LIGHT !!WRONG!!
So the class buys one cell (a consumer's .dark-scoped override under
'system' on a dark OS) at the price of making watchSystemTheme mandatory,
leaving SSR a class it cannot render, and turning a zero-JS-correct cell into
one that is wrong until JS runs. The supported answer for that cell is a CSS
twin block under the media guard — see site/content/styling.md.
The media guard quoted above is :where()-wrapped so it is specificity
(0,1,0), not (0,3,0) (#241). At (0,3,0) it outranked EVERY consumer override of
a base token on a dark OS, including [data-theme='dark'] under an explicit
'dark' preference, where the consumer's selector matches and still lost. Do
not "simplify" the :where()s away, and do not wrap :root itself — that is
(0,0,0) and loses to tokens.css's own :root, breaking dark mode outright.
function applyTheme(theme: Theme): void
connect() from @llui/components/theme-switch
function connect(state: ReadSignal<ThemeSwitchState>, send: Send<ThemeSwitchMsg>, opts: ConnectOptions): ThemeSwitchParts
init() from @llui/components/theme-switch
function init(theme: Theme = 'system'): ThemeSwitchState
resolveTheme() from @llui/components/theme-switch
Resolve a theme preference to the actual theme to apply. Returns 'dark' or
'light' based on the user's setting, consulting prefers-color-scheme for
'system'.
function resolveTheme(theme: Theme): ResolvedTheme
update() from @llui/components/theme-switch
function update(state: ThemeSwitchState, msg: ThemeSwitchMsg): [ThemeSwitchState, never[]]
watchSystemTheme() from @llui/components/theme-switch
Listen for system theme changes (when user has selected 'system'). Returns
a cleanup function. Call this in onMount and dispatch setTheme on
change if you want the UI to auto-follow OS settings.
function watchSystemTheme(callback: (theme: ResolvedTheme) => void): () => void
Types
ResolvedTheme from @llui/components/theme-switch
export type ResolvedTheme = 'light' | 'dark'
Theme from @llui/components/theme-switch
Theme Switch — light/dark/system theme toggle.
State machine tracks the user's explicit preference (light, dark, or
system). applyTheme() publishes that PREFERENCE on <html> — writing
data-theme for an explicit choice and REMOVING it for 'system', which is
the state tokens-dark.css's prefers-color-scheme block answers in pure
CSS. Do not resolve 'system' in JS just to honour the OS setting: that
makes watchSystemTheme mandatory, gives SSR nothing correct to render, and
overwrites the preference with its answer.
Typically wired via onMount or in app init:
onMount(() => {
applyTheme(state.theme.theme)
})
resolveTheme() and watchSystemTheme() remain for consumers who need the
resolved value IN JS — canvas theming, a <meta name="theme-color"> — but
they are no longer on the critical path for merely following the OS.
For persistence, the app reducer reads/writes localStorage.theme in its
init/update — the state machine itself is storage-agnostic.
export type Theme = 'light' | 'dark' | 'system'
ThemeSwitchMsg from @llui/components/theme-switch
export type ThemeSwitchMsg = { type: 'setTheme'; theme: Theme } | { type: 'toggle' }
Interfaces
ConnectOptions from @llui/components/theme-switch
export interface ConnectOptions {
id: string
/** Accessible label for the theme group (default: 'Theme'). */
label?: string
}
ThemeSwitchParts from @llui/components/theme-switch
export interface ThemeSwitchParts {
root: {
'data-scope': 'theme-switch'
'data-part': 'root'
role: 'group'
'aria-label': string
}
option: (theme: Theme) => {
type: 'button'
'data-scope': 'theme-switch'
'data-part': 'option'
'data-theme': Theme
'aria-pressed': ReadSignal<boolean>
'aria-label': string
onClick: (e: MouseEvent) => void
}
toggle: {
type: 'button'
'data-scope': 'theme-switch'
'data-part': 'toggle'
'data-theme': ReadSignal<Theme>
'aria-label': string
onClick: (e: MouseEvent) => void
}
}
ThemeSwitchState from @llui/components/theme-switch
export interface ThemeSwitchState {
theme: Theme
}
Constants
themeSwitch from @llui/components/theme-switch
const themeSwitch
@llui/components/sortable
Functions
connect() from @llui/components/sortable
function connect(state: ReadSignal<SortableState>, send: Send<SortableMsg>, opts: ConnectOptions): SortableParts
droppedMove() from @llui/components/sortable
The reorder a message COMPLETES, or null. A pointer drop and a keyboard
toggleGrab while an item is grabbed both end a drag at currentIndex;
cancel (Escape, pointercancel) ends one WITHOUT a move. Only a drop inside
the container the drag started from is a reorder of that list — a drop onto
another container is the consumer's transfer to handle.
Call it with the state BEFORE update runs.
function droppedMove(prev: SortableState, msg: SortableMsg): { from: number; to: number } | null
init() from @llui/components/sortable
function init(): SortableState
reorder() from @llui/components/sortable
Move an item in an array from one index to another, returning a new array. Out-of-range indices are clamped to array bounds.
function reorder<T>(arr: readonly T[], from: number, to: number): T[]
update() from @llui/components/sortable
function update(state: SortableState, msg: SortableMsg): [SortableState, never[]]
Types
SortableAnnouncement from @llui/components/sortable
What the live region says about the latest drag event, as DATA (the text is
rendered by connect through the locale). Positions are 0-based here;
container is the origin container, the only one whose region speaks.
export type SortableAnnouncement =
| { kind: 'grabbed'; container: string; id: string; position: number; count: number }
| { kind: 'moved'; container: string; id: string; position: number; count: number }
| { kind: 'dropped'; container: string; id: string; from: number; to: number; count: number }
| { kind: 'cancelled'; container: string; id: string; position: number; count: number }
SortableMsg from @llui/components/sortable
export type SortableMsg =
/** @humanOnly */
| {
type: 'start'
id: string
index: number
/** Items in the origin container (for "item X of N"). */
count: number
container: string
x: number
y: number
}
/** @humanOnly */
| { type: 'move'; index: number; container: string; x: number; y: number }
/** @humanOnly */
| { type: 'drop' }
/** @humanOnly */
| { type: 'cancel' }
/** @humanOnly */
| { type: 'toggleGrab'; id: string; index: number; count: number; container: string }
/** @humanOnly */
| { type: 'moveBy'; delta: number }
Interfaces
ConnectOptions from @llui/components/sortable
export interface ConnectOptions {
id: string
/**
* Drag-target selection + render strategy.
*
* - `'1d'` (default) — single-axis, Y-only. `findTargetAt` picks
* by vertical distance; `style.transform` on the dragged item
* is `translateY(deltaY)`; non-dragged items between source
* and target emit `data-shift: 'up' | 'down'` so CSS can
* animate them via `translateY(±var(--sortable-shift))`.
* Correct for vertical lists; fails for 2D layouts (flex-wrap,
* grid) because same-row items collapse to the same midpoint
* distance.
*
* - `'2d'` — Euclidean target selection against 2D midpoints;
* dragged item follows both X and Y (`translate(dx, dy)`);
* non-dragged items between source and target get a per-item
* `style.transform = translate(deltaFromSnapshot)` that opens
* the correct gap regardless of row boundaries. `data-shift`
* is always `undefined` in 2D so CSS `translateY(var(--...))`
* rules don't conflict with the per-item transform.
*
* Keyboard navigation (`moveBy`) stays linear-array in both modes —
* arrow keys step through the array indices regardless of visual
* row, because that's what screen readers announce and what the
* underlying data order actually is.
*/
layout?: '1d' | '2d'
/**
* The name a listener hears for an item, from its `id` ("Picked up Apple,
* item 2 of 5."; "Drag handle for Apple"). Without it the announcements say
* "item" and every handle has the same label.
*/
itemLabel?: (id: string) => string
/**
* Whether the consumer renders the `instructions` part (default: true).
* When false, no handle carries `aria-describedby` — it would otherwise name
* an element that does not exist, a broken reference assistive technology
* reports as a missing description (the `dialog` `hasDescription` rule,
* #268). The default is ON, as for `dialog`, because the instructions are
* the ONLY place the keyboard model is described (the handle's name is just
* "Drag handle"): leaving them out must be a deliberate, visible choice, not
* the quiet default. `command-menu` defaults its description OFF only
* because its own view renders none; here the consumer renders the part.
*/
hasInstructions?: boolean
}
DragState from @llui/components/sortable
Sortable — pointer-based reorderable list.
State machine tracks the currently-dragged item and where it's hovering.
The app owns the actual array: droppedMove(prev, msg) names the move a
message COMPLETES — a pointer drop, or a keyboard toggleGrab while an item
is grabbed — and reorder(arr, from, to) applies it. Watch currentIndex
during a drag for a live preview.
type State = { items: string[]; sort: SortableState }
update: (state, msg) => {
switch (msg.type) {
case 'sort': {
const moved = sortable.droppedMove(state.sort, msg.msg)
const [sort] = sortable.update(state.sort, msg.msg)
const items = moved ? reorder(state.items, moved.from, moved.to) : state.items
return [{ items, sort }, []]
}
}
}
// `each`, `ul`, `li`, `div`, `text`, `mapSend` are imports from '@llui/dom';
// the view bag provides only `state` (a Signal) and `send`.
view: ({ state, send }) => {
const sortableState = state.at('sort')
const sortableSend = mapSend<Msg, sortable.SortableMsg>(send, (msg) => ({
type: 'sort',
msg,
}))
const s = sortable.connect(sortableState, sortableSend, { id: 'list' })
return [
ul({ ...s.root, class: 'list' }, [
...each({
items: (st) => st.items,
key: (x) => x,
render: ({ item, index }) => [
li({ ...s.item(item(), index()), class: 'item' }, [
div({ ...s.handle(item(), index()), class: 'handle' }, [text('⋮⋮')]),
text(item),
]),
],
}),
]),
]
}
Hook up pointermove/pointerup at the root (attachPointerHandlers) — or
wire them directly via onPointerMove / onPointerUp on the root part.
Screen readers. A keyboard user who grabs, moves, drops or cancels needs to HEAR it — nothing on screen tells them where the item is. The machine therefore owns two more parts, and both must be rendered:
liveRegion— a polite, atomicrole="status"region whosetext(a Signal, rendered as the region's CHILD, never spread as an attribute) announces "Picked up Apple, item 2 of 5.", "Apple moved to position 3 of 5.", the drop and a cancel. Render it visually hidden (sr-only), neverdisplay: none— a hidden live region is never announced. PassitemLabelto name the item; without it the announcements say "item".instructions— the how-to text,hidden, which every handle references througharia-describedby(a directly referenced hidden element still provides a description, and stays out of the reading order). A consumer that supplies its own keyboard help and does not render this part passeshasInstructions: false, so no handle names an element that does not exist (thedialoghasDescriptionprecedent, #268).
const { text: live, ...liveAttrs } = s.liveRegion
const { text: howTo, ...howToAttrs } = s.instructions
div({ ...liveAttrs, class: 'sr-only' }, [text(live)])
div({ ...howToAttrs }, [text(howTo)])
The grab is aria-pressed on the handle (a toggle button: pressed while
the item is carried). aria-grabbed is NOT used: ARIA 1.1 deprecated it and
aria-dropeffect with no replacement, and screen readers never broadly
exposed either — the live region is how every mature implementation (dnd-kit,
React Aria) conveys a drag. aria-roledescription is deliberately not set:
it would replace "toggle button", the one cue that Space operates the handle.
All text comes from LocaleContext (Locale['sortable']).
export interface DragState {
id: string
startIndex: number
currentIndex: number
/**
* Container the drag originated from. Defaults to the connect's `id` for
* single-container sortables. Set when multiple sortables share state.
*/
fromContainer: string
/**
* Container the pointer is currently over. Same as `fromContainer` for
* single-container sortables. Differs when dragging across containers.
*/
toContainer: string
/**
* Pointer X at drag start (viewport coordinates). Used by 2D layouts
* to compute `deltaX = currentX - startX` alongside the Y axis. In 1D
* layouts X is tracked but ignored by the renderer.
*/
startX: number
/**
* Pointer Y at drag start (viewport coordinates). Used by CSS / the
* library's `style.transform` binding to make the dragged item follow
* the pointer.
*/
startY: number
/**
* Current pointer X (viewport coordinates). `deltaX = currentX - startX`.
*/
currentX: number
/**
* Current pointer Y (viewport coordinates). `deltaY = currentY - startY`.
*/
currentY: number
/**
* How many items the origin container held when the drag started — the "N"
* in "item 2 of N", and the last slot a keyboard `moveBy` may reach.
*/
count: number
}
SortableParts from @llui/components/sortable
export interface SortableParts {
root: {
'data-scope': 'sortable'
'data-part': 'root'
'data-container-id': string
'data-dragging': ReadSignal<'' | undefined>
onPointerMove: (e: PointerEvent) => void
onPointerUp: (e: PointerEvent) => void
onPointerCancel: (e: PointerEvent) => void
}
item: (
id: string,
index: number,
) => {
'data-scope': 'sortable'
'data-part': 'item'
'data-index': string
'data-id': string
'data-dragging': ReadSignal<'' | undefined>
'data-over': ReadSignal<'' | undefined>
'data-shift': ReadSignal<'up' | 'down' | undefined>
'style.transform': ReadSignal<string | undefined>
'style.zIndex': ReadSignal<string | undefined>
}
handle: (
id: string,
index: number,
) => {
'data-scope': 'sortable'
'data-part': 'handle'
role: 'button'
tabindex: 0
/** A toggle button: pressed while this handle's item is carried. */
'aria-pressed': ReadSignal<boolean>
'aria-label': string
/** The `instructions` part's id, or absent when `hasInstructions: false`. */
'aria-describedby': string | undefined
onPointerDown: (e: PointerEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
/**
* The polite live region announcing grab / move / drop / cancel. `text` is
* the region's CHILD (a Signal), not an attribute: spread the rest and render
* `text(text)` inside. Keep it visually hidden but rendered (`sr-only`) — a
* `display: none` region is never announced. Only the ORIGIN container's
* region speaks, so connects that share one state never announce twice.
*/
liveRegion: {
role: 'status'
'aria-live': 'polite'
'aria-atomic': 'true'
'data-scope': 'sortable'
'data-part': 'live-region'
text: ReadSignal<string>
}
/**
* The keyboard instructions every handle's `aria-describedby` points at.
* `hidden`: a directly referenced hidden element still supplies the
* description, and stays out of the reading order. `text` is its CHILD.
*/
instructions: {
id: string
hidden: true
'data-scope': 'sortable'
'data-part': 'instructions'
text: string
}
}
SortableState from @llui/components/sortable
export interface SortableState {
dragging: DragState | null
/** The latest announcement; `null` before any drag and after a cross-container drop. */
announcement: SortableAnnouncement | null
}
Constants
sortable from @llui/components/sortable
const sortable
@llui/components/sparkline
Functions
connect() from @llui/components/sparkline
function connect(state: ReadSignal<SparklineState>, send: Send<SparklineMsg>, opts: SparklineConnectOptions): SparklineParts
createSparklineGeometry() from @llui/components/sparkline
A memoized {@link sparklineGeometry}, one cell per instance.
Hold it in a closure, never at module scope: a table of fifty sparklines
sharing one cell would evict on every call and the memo would never hit —
the same rule utils/derive.ts states for per-item derivations.
function createSparklineGeometry(): (state: SparklineState) => SparklineGeometry
geometry() from @llui/components/sparkline
Derived geometry for a state. Exported so a consumer can measure or test a sparkline without mounting one.
function geometry(state: SparklineState): SparklineGeometry
init() from @llui/components/sparkline
function init(opts: SparklineInit = {}): SparklineState
isoDay() from @llui/components/sparkline
YYYY-MM-DD for an instant, in the given fixed offset's calendar.
function isoDay(at: number, opts: CalendarOptions = {}): string
locateIndex() from @llui/components/sparkline
The drawn point nearest a position in user units — the pointer hit test. Nearest-x, because dots are a run along the axis with no extents to contain a pointer.
A TIE goes to the EARLIER point (<, not <=). Ties are not hypothetical —
two readings can share an instant, which is why a dot's key carries its index
— and either rule is defensible; what is not defensible is leaving it
unstated, since a silent flip changes which reading a tooltip reports.
function locateIndex(geo: SparklineGeometry, x: number): number | null
sparklineGeometry() from @llui/components/sparkline
The whole picture, as data. A PURE function: same inputs, same output, and the same output on a server as in a browser.
function sparklineGeometry(points: readonly SparklinePoint[], opts: SparklineGeometryOptions = {}): SparklineGeometry
sparklineLabel() from @llui/components/sparkline
Phrasing for a {@link SparklineSummary}. The strings are injected so the
locale can supply them in connect and the pure path can default to
English without a second template.
function sparklineLabel(summary: SparklineSummary, strings: { empty: string; range: (count: number, from: string, to: string) => string }): string
trimLeadingOutliers() from @llui/components/sparkline
Drop leading points separated from the rest by a disproportionate gap. See {@link SparklineTrim} for the rule and for why this is opt-in.
function trimLeadingOutliers(points: readonly SparklinePoint[], trim: SparklineTrim | null): { points: SparklinePoint[]; trimmed: number }
update() from @llui/components/sparkline
function update(state: SparklineState, msg: SparklineMsg): [SparklineState, never[]]
Types
SparklineBandKind from @llui/components/sparkline
export type SparklineBandKind = 'between' | 'below' | 'above'
SparklineMsg from @llui/components/sparkline
export type SparklineMsg =
/** @intent("Set the point under the cursor, or clear it with null") */
| { type: 'setActive'; index: number | null }
/** @intent("Move the cursor along the points by delta, clamping at the ends") */
| { type: 'moveActive'; delta: number }
/** @intent("Move the cursor to the first point") */
| { type: 'firstActive' }
/** @intent("Move the cursor to the last point") */
| { type: 'lastActive' }
/** @intent("Replace the plotted points") */
| { type: 'setPoints'; points: SparklinePoint[] }
/** @intent("Set the instant the right edge denotes, or derive it with null") */
| { type: 'setNow'; at: number | null }
/** @intent("Set the reference band; null on a side leaves it unbounded") */
| { type: 'setBand'; low: number | null; high: number | null }
/** @intent("Set the viewBox size in user units") */
| { type: 'setSize'; width: number; height: number }
SparklineTone from @llui/components/sparkline
export type SparklineTone = 'below' | 'in' | 'above' | 'none'
Interfaces
SparklineBand from @llui/components/sparkline
A reference band. Both bounds are UNBOUNDED-CAPABLE in the sense of
finiteBound (#177): "no bound on this side" is spelled by OMITTING the key,
never by undefined, null or ±Infinity — only the omission survives a
JSON round trip key-for-key, and this lives in state.
The three readings are not a convenience, they are the whole point: both bounds shade BETWEEN them, a high alone shades everything BELOW it, and a low alone shades everything ABOVE it. "Acceptable is under 140" and "acceptable is between 90 and 120" are different statements and must not both come out as a stripe.
export interface SparklineBand {
low?: number
high?: number
}
SparklineBandGeometry from @llui/components/sparkline
export interface SparklineBandGeometry {
kind: SparklineBandKind
low: number | null
high: number | null
d: string
}
SparklineCalendar from @llui/components/sparkline
The serializable subset of {@link CalendarStepOptions} this state carries.
export interface SparklineCalendar {
offsetMinutes: number
weekStartsOn: number
maxTicks: number
/** Force a rung instead of choosing one from the span. */
unit?: CalendarUnit
}
SparklineConnectOptions from @llui/components/sparkline
export interface SparklineConnectOptions {
/** Base id; the title and description ids derive from it. */
id: string
/** Override the composed accessible name entirely. */
label?: string
/** Longer description, announced with the name. Defaults to empty. */
description?: string
/**
* Whether the consumer renders the `desc` part (default: true). When false,
* the svg's `aria-labelledby` names only the title, never an element that
* does not exist (the `dialog` `hasDescription` rule, #268). The TITLE is
* the image's name — required for `role="img"` — and is always referenced.
*/
hasDescription?: boolean
}
SparklineDot from @llui/components/sparkline
export interface SparklineDot {
index: number
at: number
value: number
x: number
y: number
tone: SparklineTone
/** True for the most recent drawn point — the reading the row is about. */
last: boolean
/** True for the point under the cursor. Follows `chart`: the cursor is part
* of the derived picture, so one signal per row carries everything a dot
* renders from and nothing has to combine two signals at the call site. */
active: boolean
key: string
}
SparklineGeometry from @llui/components/sparkline
export interface SparklineGeometry {
frame: Frame
time: Domain
value: Domain
/** The points actually drawn: finite, sorted, and after trimming. */
points: SparklinePoint[]
trimmed: number
/** The trend line. Empty string when there is nothing to draw. */
path: string
band: SparklineBandGeometry | null
ticks: SparklineTick[]
dots: SparklineDot[]
spans: SparklineSpan[]
now: SparklineNow
summary: SparklineSummary
/** Built-in English composition of {@link summary}. `connect` replaces it
* with the locale's phrasing. */
label: string
rows: SparklineRow[]
}
SparklineGeometryOptions from @llui/components/sparkline
export interface SparklineGeometryOptions {
band?: SparklineBand
now?: number
min?: number
max?: number
width?: number
height?: number
padding?: Partial<SparklinePadding>
curve?: Curve
trim?: SparklineTrim | null
track?: SparklineTrack | null
calendar?: Partial<SparklineCalendar>
/** Which drawn point the cursor is on. Indexes the DRAWN list — after
* non-finite samples are dropped and after trimming — so it is the same
* index `locateIndex` returns. */
activeIndex?: number | null
}
SparklineInit from @llui/components/sparkline
export interface SparklineInit {
points?: readonly SparklinePoint[]
band?: SparklineBand
now?: number
min?: number
max?: number
width?: number
height?: number
padding?: Partial<SparklinePadding>
curve?: Curve
/** `true` for the documented defaults, an object to tune them, absent for
* OFF — trimming discards data, so it is never on by accident. */
trim?: boolean | Partial<SparklineTrim>
track?: SparklineTrack | null
calendar?: Partial<SparklineCalendar>
}
SparklineNow from @llui/components/sparkline
export interface SparklineNow {
at: number
x: number
d: string
/** True when the right edge is later than the last reading — the series
* trails off. */
stale: boolean
}
SparklinePadding from @llui/components/sparkline
export interface SparklinePadding {
top: number
right: number
bottom: number
left: number
}
SparklineParts from @llui/components/sparkline
export interface SparklineParts {
root: {
'data-scope': 'sparkline'
'data-part': 'root'
'data-stale': ReadSignal<'' | undefined>
'data-active': ReadSignal<'' | undefined>
}
/**
* The `<svg>`. `role="img"` named through its own `<title>`/`<desc>`; the
* numbers live in {@link SparklineParts.table}, because an SVG polyline is
* otherwise silent and the WAI-ARIA graphics roles are not carried well
* enough to be the only route to the data.
*/
svg: {
'data-scope': 'sparkline'
'data-part': 'svg'
role: 'img'
'aria-labelledby': string
viewBox: ReadSignal<string>
tabindex: 0
onKeyDown: (e: KeyboardEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
onBlur: (e: FocusEvent) => void
}
title: { id: string; 'data-scope': 'sparkline'; 'data-part': 'title' }
desc: { id: string; 'data-scope': 'sparkline'; 'data-part': 'desc' }
/** The visually-hidden `<table>` fallback. Render it with {@link rows}. */
table: {
'data-scope': 'sparkline'
'data-part': 'table'
'aria-label': ReadSignal<string>
}
/** The reference band. Stays MOUNTED and hides itself, so a band appearing or
* disappearing does not rebuild the layer. */
band: {
'data-scope': 'sparkline'
'data-part': 'band'
'data-band': ReadSignal<'between' | 'below' | 'above' | undefined>
d: ReadSignal<string>
hidden: ReadSignal<boolean>
}
line: { 'data-scope': 'sparkline'; 'data-part': 'line'; d: ReadSignal<string> }
/** The right edge. `data-stale` is set when it is later than the last
* reading. */
now: {
'data-scope': 'sparkline'
'data-part': 'now'
'data-stale': ReadSignal<'' | undefined>
d: ReadSignal<string>
}
/** A `<g>` stacking layer. Static — spread it on each layer group. */
layer: { 'data-scope': 'sparkline'; 'data-part': 'layer' }
/**
* Per-row attribute bags. Each takes the ROW SIGNAL an `each` hands its
* render function, not a snapshot value, and every geometry-derived
* attribute comes back as a Signal.
*
* That is not decoration. A keyed `each` REUSES a row whose key is unchanged,
* so a dot whose key is `index:instant` survives a change of time domain —
* and a static `cx` read at build time would then be frozen at the position
* the point had under the OLD domain, with the key giving no hint that
* anything moved. Taking the row signal is what makes the reuse safe.
*/
tickProps: (tick: Signal<SparklineTick>) => {
'data-scope': 'sparkline'
'data-part': 'grid'
'data-unit': ReadSignal<'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'>
d: ReadSignal<string>
}
dotProps: (dot: Signal<SparklineDot>) => {
'data-scope': 'sparkline'
'data-part': 'dot'
'data-tone': ReadSignal<'below' | 'in' | 'above' | 'none'>
'data-last': ReadSignal<'' | undefined>
'data-active': ReadSignal<'' | undefined>
cx: ReadSignal<number>
cy: ReadSignal<number>
}
spanProps: (span: Signal<SparklineSpan>) => {
'data-scope': 'sparkline'
'data-part': 'span'
'data-grain': ReadSignal<string>
d: ReadSignal<string>
}
/** Tooltip ATTRIBUTES — spreadable, with its own reactive `hidden`. */
tooltip: {
'data-scope': 'sparkline'
'data-part': 'tooltip'
role: 'status'
'aria-live': 'polite'
hidden: ReadSignal<boolean>
style: ReadSignal<string>
}
ticks: ReadSignal<SparklineTick[]>
dots: ReadSignal<SparklineDot[]>
spans: ReadSignal<SparklineSpan[]>
rows: ReadSignal<SparklineRow[]>
/** The dot under the cursor, or `null`. */
activeDot: ReadSignal<SparklineDot | null>
/** The composed accessible name — the locale's phrasing of
* {@link SparklineGeometry.summary}, or `opts.label` when given. */
label: ReadSignal<string>
summary: ReadSignal<SparklineSummary>
}
SparklinePoint from @llui/components/sparkline
Sparkline — "this value, over time, against a reference band", at the size of a table cell.
Why this is not chart
chart is a plotting machine: ChartState, a projection seam, a legend, a
keyboard cursor over CATEGORIES, and numeric-nice value ticks. A sparkline is
a pure function of its points — dozens appear in one table, and the only
interaction any of them has is a hover readout. So the load-bearing export
here is {@link sparklineGeometry}, which takes points and options and returns
path strings; it needs no signal, no send, no mount and no state.
init/update/connect exist BESIDE it, not instead of it, and add exactly
one fact: which point the cursor is on. A consumer rendering fifty trends in
a table calls the pure function fifty times and pays for nothing else; one
that wants a tooltip opts into the machine for that column. The stateless
middle ground works too — connect(constant(state), noSend, { id }) gives
the full part bag with the cursor pinned wherever the state says.
What it reuses
The geometry is utils/scale.ts + utils/path.ts + utils/projection.ts,
the same three files chart is built from, so every coordinate goes through
fmt and a path recomputed from unchanged data is BYTE-IDENTICAL — the
reconciler commits on output-equality, and a sparkline in a table is exactly
the place where a spuriously-changing path string costs the most.
Two things it does NOT reuse, each for a stated reason:
valueDomain, because it always includes zero. That is right for a bar, whose LENGTH states the magnitude and therefore lies if the baseline is not zero; it is wrong for a trend line, where the shape is the message. A series reading 118–142 against a 90–120 band has to fill the box, not sit as a flat smear across the top of a 0–142 axis.ticks/niceDomain, because they are NUMERIC. Seeutils/calendar-ticks.ts: rounding an epoch to a nice number never lands on the 1st of March.
There is no coord and no polar projection. A sparkline is inline text
furniture; a round one is a different picture, and chart already draws it.
export interface SparklinePoint {
/** Instant, in epoch milliseconds. */
at: number
value: number
/**
* Sampling resolution of this reading — "spot", "session", "daily", whatever
* the domain calls it. Free-form and never interpreted: consecutive points
* sharing a tag become one segment of the granularity track, so a line whose
* sampling changes SAYS so instead of implying uniform data.
*/
grain?: string
}
SparklineRow from @llui/components/sparkline
One row of the visually-hidden <table> fallback.
export interface SparklineRow {
at: number
/** `YYYY-MM-DD` in the configured offset's calendar. Locale-neutral by
* design: the table is a data fallback, not a formatted report. */
day: string
value: number
tone: SparklineTone
grain: string | null
}
SparklineSpan from @llui/components/sparkline
export interface SparklineSpan {
grain: string
/** First and last instant the segment covers. */
from: number
to: number
x0: number
x1: number
d: string
key: string
}
SparklineState from @llui/components/sparkline
export interface SparklineState {
points: SparklinePoint[]
/** Omit a side to leave it unbounded — see {@link SparklineBand}. */
band: SparklineBand
/**
* The instant the RIGHT EDGE denotes. A stale series then visibly trails off
* instead of looking current, which is the difference between "the last
* reading was fine" and "the last reading was fine, eight months ago".
*
* UNBOUNDED-CAPABLE: omit it and the edge is the last reading's own instant.
* It is deliberately NOT defaulted to `Date.now()` anywhere in this module —
* geometry has to be a pure function of state or an SSR render and the first
* client render disagree, and `Date.now()` would additionally make the path
* string change on every recomputation, which is the one thing `fmt` exists
* to prevent. A consumer that wants "now" puts a clock in its own state.
*/
now?: number
/** Value-axis bounds. UNBOUNDED-CAPABLE (#177): omit to derive. */
min?: number
max?: number
width: number
height: number
padding: SparklinePadding
curve: Curve
/** `null` = no trimming. See {@link SparklineTrim}. */
trim: SparklineTrim | null
/** `null` = no granularity track. */
track: SparklineTrack | null
/** Fixed offset from UTC that calendar boundaries are measured in, and the
* ladder's ceiling. See `utils/calendar-ticks.ts` on why it is an offset and
* not a zone. */
calendar: SparklineCalendar
/** Index of the point under the pointer or keyboard cursor. */
activeIndex: number | null
}
SparklineSummary from @llui/components/sparkline
The facts a label is composed from. Separated from the label itself so the
same numbers can be phrased by the locale in connect and by the built-in
English template in the pure path.
export interface SparklineSummary {
count: number
/** `YYYY-MM-DD` of the first and last drawn reading; empty strings when there
* is no data. */
from: string
to: string
trimmed: number
stale: boolean
}
SparklineTick from @llui/components/sparkline
export interface SparklineTick {
at: number
unit: CalendarUnit
x: number
/** The vertical gridline, in user units. */
d: string
/** Stable key for a keyed `each`. */
key: string
}
SparklineTrack from @llui/components/sparkline
The granularity track: a thin bar under the plot, in the bottom padding.
export interface SparklineTrack {
height: number
/** Distance between the bottom of the plot frame and the top of the track. */
gap: number
}
SparklineTrim from @llui/components/sparkline
Leading-outlier trimming. A HEURISTIC that DISCARDS REAL DATA, so it is off
unless asked for: trim: null is the default, and a chart that silently
drops a reading is worse than a squashed one.
The rule: while more than floor points remain, compare the FIRST gap
against the MEDIAN of the gaps after it, and drop the leading point when the
first gap is more than factor times that median. Median rather than mean so
a second ancient reading cannot inflate the yardstick it is being measured
against; factor > 1 is required, and a median of zero (every remaining
reading at the same instant) stops the walk rather than eating the series.
trimmed in the geometry reports how many points went, so a consumer can say
so on the page.
export interface SparklineTrim {
/** How many times the median gap the leading gap must exceed. Default 4. */
factor: number
/** Never trim below this many points. Default 3, floored at 2 — one point is
* not a trend and cannot be drawn as a line. */
floor: number
}
Constants
sparkline from @llui/components/sparkline
const sparkline
@llui/components/form
Functions
connect() from @llui/components/form
function connect(state: ReadSignal<FormState>, send: Send<FormMsg>, _opts: ConnectOptions): FormParts
init() from @llui/components/form
function init(): FormState
update() from @llui/components/form
function update(state: FormState, msg: FormMsg): [FormState, never[]]
validateSchema() from @llui/components/form
Run a Standard Schema synchronously against a values object. Throws if the schema returns a Promise — use sync validation only for form submit.
Works with any library implementing the Standard Schema spec: Zod (v3.24+), Valibot (v1+), ArkType, etc.
function validateSchema<T>(schema: StandardSchemaV1<T>, values: unknown): ValidateResult<T>
validateSchemaAsync() from @llui/components/form
Async variant — returns a Promise. Use when the schema performs async validation (e.g. uniqueness checks against a backend).
function validateSchemaAsync<T>(schema: StandardSchemaV1<T>, values: unknown): Promise<ValidateResult<T>>
Types
FormMsg from @llui/components/form
export type FormMsg =
/** @intent("Mark a single field as touched (typically on blur)") */
| { type: 'touch'; field: string }
/** @intent("Mark every named field as touched (typically on submit attempt)") */
| { type: 'touchAll'; fields: string[] }
/** @intent("Begin form submission — validate and dispatch the save effect") */
| { type: 'submit' }
/** @intent("Mark the in-flight submission as successful") */
| { type: 'submitSuccess' }
/** @intent("Mark the in-flight submission as failed with the given error message") */
| { type: 'submitError'; error: string }
/** @intent("Reset the form to its initial state (clears touched flags and submit status)") */
| { type: 'reset' }
FormStatus from @llui/components/form
Form — submit lifecycle + touched tracking + Standard Schema validation.
Values live in the parent component's state; form tracks submit status
and which fields have been interacted with (blur), so errors are shown
only after touch instead of immediately.
Bring your own validation library — any Standard Schema-compatible schema works (Zod, Valibot, ArkType, etc.). See https://standardschema.dev.
import { z } from 'zod'
import { form, validateSchema } from '@llui/components/form'
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
type Values = z.infer<typeof schema>
type State = { values: Values; form: FormState }
update: (state, msg) => {
switch (msg.type) {
case 'submit': {
const result = validateSchema(schema, state.values)
if (!result.isValid) {
return [{ ...state, form: { ...state.form, touched: { email: true, password: true } } }, []]
}
return [{ ...state, form: { ...state.form, status: 'submitting' } }, [saveUserEffect]]
}
}
}
export type FormStatus = 'idle' | 'submitting' | 'submitted' | 'error'
Interfaces
ConnectOptions from @llui/components/form
export interface ConnectOptions {
id: string
}
FormParts from @llui/components/form
export interface FormParts {
root: {
'data-scope': 'form'
'data-part': 'root'
'data-state': ReadSignal<FormStatus>
'aria-busy': ReadSignal<'true' | undefined>
}
field: (name: string) => {
'data-scope': 'form'
'data-part': 'field'
'data-touched': ReadSignal<'' | undefined>
touched: ReadSignal<boolean>
onBlur: (e: FocusEvent) => void
}
submit: {
type: 'submit'
'data-scope': 'form'
'data-part': 'submit'
'data-state': ReadSignal<FormStatus>
disabled: ReadSignal<boolean>
}
}
FormState from @llui/components/form
export interface FormState {
status: FormStatus
touched: Record<string, boolean>
submitError: string | null
}
ValidateResult from @llui/components/form
export interface ValidateResult<T> {
isValid: boolean
/** Field name → first error message. Field name is derived from the issue's path. */
errors: Partial<Record<keyof T, string>>
/** All issues from the schema validator, unaltered. */
issues: readonly StandardSchemaV1.Issue[]
}
Constants
form from @llui/components/form
const form
@llui/components/patterns
Functions
commandMenuConnect() from @llui/components/patterns
Project the composed slice into dialog + combobox part bags plus a
shortcutHint accessor and an empty-state part. The dialog/combobox sends
are adapted to command-menu messages: the consumer spreads these onto
elements exactly like the base components.
function commandMenuConnect(state: ReadSignal<CommandMenuState>, send: Send<CommandMenuMsg>, opts: ConnectOptions): CommandMenuParts
commandMenuInit() from @llui/components/patterns
function commandMenuInit(opts: CommandMenuInit = {}): CommandMenuState
commandMenuUpdate() from @llui/components/patterns
function commandMenuUpdate(state: CommandMenuState, msg: CommandMenuMsg): [CommandMenuState, CommandMenuEffect[]]
commandMenuView() from @llui/components/patterns
Default palette view: a combobox (search input + grouped command list) inside
the dialog overlay. The listbox owns one option per FILTERED command,
bucketed into labelled groups (an ungrouped command sits directly in the
listbox, with no group and so no dangling label reference). The combobox
input's own key handling drives the machine's highlight — arrows/Home/End
move it, Enter runs it, Escape clears the query then closes — and a pointer
click runs the clicked command. Consumers wanting a custom row template
should drive the part bags from connect() directly.
function commandMenuView(opts: CommandMenuViewOptions): Mountable
dataTableConnect() from @llui/components/patterns
function dataTableConnect(state: ReadSignal<DataTableState>, send: Send<DataTableMsg>, opts: ConnectOptions): DataTableParts
dataTableInit() from @llui/components/patterns
function dataTableInit(opts: DataTableInit = {}): DataTableState
dataTableUpdate() from @llui/components/patterns
function dataTableUpdate(state: DataTableState, msg: DataTableMsg): [DataTableState, DataTableEffect[]]
isAllSelected() from @llui/components/patterns
function isAllSelected(state: DataTableState): boolean
isEmpty() from @llui/components/patterns
Empty == a settled (not-loading) request that yielded zero rows.
function isEmpty(state: DataTableState): boolean
isError() from @llui/components/patterns
function isError(state: DataTableState): boolean
isLoading() from @llui/components/patterns
function isLoading(state: DataTableState): boolean
pathToFieldName() from @llui/components/patterns
Map a Standard Schema issue path to a flat field name. Object keys join with
. and array indices append as .<n> — so ['address', 'street'] becomes
address.street and ['tags', 0] becomes tags.0. These match the keys a
consumer passes in fields.
function pathToFieldName(path: StandardSchemaV1.Issue['path']): string
searchableSelectConnect() from @llui/components/patterns
function searchableSelectConnect(state: ReadSignal<SearchableSelectState>, send: Send<SearchableSelectMsg>, opts: ConnectOptions): SearchableSelectParts
searchableSelectInit() from @llui/components/patterns
function searchableSelectInit(opts: SearchableSelectInit = {}): SearchableSelectState
searchableSelectOverlay() from @llui/components/patterns
function searchableSelectOverlay(opts: OverlayOptions): Mountable
searchableSelectUpdate() from @llui/components/patterns
function searchableSelectUpdate(state: SearchableSelectState, msg: SearchableSelectMsg): [SearchableSelectState, never[]]
stepStatus() from @llui/components/patterns
function stepStatus(state: WizardState, step: number): StepStatus
totalPages() from @llui/components/patterns
function totalPages(state: DataTableState): number
watchHotkey() from @llui/components/patterns
Listen for the global command-palette hotkey. Returns a cleanup function.
Call from onMount; the DOM listener never lives inside the machine.
combo is a +-joined chord, e.g. 'mod+k' (default). mod matches the
platform-conventional accelerator (⌘ on macOS, Ctrl elsewhere); since both
map to either metaKey or ctrlKey here, mod accepts either modifier.
function watchHotkey(send: Send<CommandMenuMsg>, combo: string = 'mod+k'): () => void
Types
CommandMenuEffect from @llui/components/patterns
Effects emitted by the command-menu machine. execute is the single
agent-resolvable surface: the consumer's onEffect runs the side effect for
the picked command id (the machine never performs IO).
export type CommandMenuEffect =
/** @intent("Run the side effect for the executed command id") */
{ type: 'execute'; commandId: string }
CommandMenuMsg from @llui/components/patterns
export type CommandMenuMsg =
/** @intent("Open the command palette") */
| { type: 'open' }
/** @intent("Close the command palette") */
| { type: 'close' }
/** @intent("Set the search query (re-runs the filter)") */
| { type: 'setQuery'; query: string }
/** @intent("Run the command with the given id, then close the palette") */
| { type: 'execute'; commandId: string }
/** @humanOnly */
| { type: 'escape' }
/** @humanOnly */
| { type: 'highlight'; commandId: string }
/** @humanOnly */
| { type: 'highlightNext' }
/** @humanOnly */
| { type: 'highlightPrev' }
/** @humanOnly */
| { type: 'highlightFirst' }
/** @humanOnly */
| { type: 'highlightLast' }
/** @humanOnly */
| { type: 'executeHighlighted' }
/** @humanOnly */
| { type: 'setCommands'; commands: Command[] }
ConfirmDialogMsg from @llui/components/patterns
export type ConfirmDialogMsg =
| {
type: 'openWith'
tag: string
title: string
description?: string
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
}
| { type: 'confirm' }
| { type: 'cancel' }
| { type: 'setOpen'; open: boolean }
DataTableEffect from @llui/components/patterns
export type DataTableEffect = LoadPageEffect
DataTableMsg from @llui/components/patterns
export type DataTableMsg =
/** @intent("Cycle the sort on the given column; resets to page 1 and reloads") */
| { type: 'toggleSort'; columnId: string }
/** @intent("Set an explicit sort (or null to clear); resets to page 1 and reloads") */
| { type: 'setSort'; sort: TableSort | null }
/** @intent("Jump to a specific 1-based page and reload") */
| { type: 'setPage'; page: number }
/** @intent("Advance to the next page and reload") */
| { type: 'nextPage' }
/** @intent("Go back to the previous page and reload") */
| { type: 'prevPage' }
/** @intent("Change the page size; preserves the first visible item and reloads") */
| { type: 'setPageSize'; pageSize: number }
/** @intent("Re-request the current page (e.g. after a failure)") */
| { type: 'reload' }
/** @humanOnly */
| { type: 'pageLoaded'; queryId: number; rows: string[]; total: number }
/** @humanOnly */
| { type: 'pageFailed'; queryId: number; error: string }
/** @intent("Toggle selection of the row with the given id at the given display index") */
| { type: 'toggleRow'; id: string; index: number }
/** @intent("Toggle between select-all and clear for the current scope") */
| { type: 'toggleAll' }
/** @intent("Clear the entire selection") */
| { type: 'clearSelection' }
/** @intent("Select the inclusive range from the current anchor to the given index (Shift+click)") */
| { type: 'selectRange'; index: number }
/** @intent("Activate (open/confirm) the row with the given id at the given index") */
| { type: 'activateRow'; id: string; index: number }
/** @humanOnly */
| { type: 'focusCell'; rowIndex: number; colIndex: number }
/** @humanOnly */
| { type: 'tableKey'; msg: TableMsg }
FormFieldMsg from @llui/components/patterns
export type FormFieldMsg =
/** @intent("Validate the given values against a Standard Schema synchronously and update field validity") */
| { type: 'validate'; schema: StandardSchemaV1<unknown>; values: unknown }
/** @intent("Begin an async validation — marks every field pending until validateResult arrives") */
| { type: 'validateAsync'; schema: StandardSchemaV1<unknown>; values: unknown; requestId: number }
/** @intent("Apply the issues from a resolved async validation, clearing the pending state") */
| { type: 'validateResult'; issues: StandardSchemaV1.Issue[]; requestId: number }
/** @intent("Mark a single field as touched (typically on blur)") */
| { type: 'touch'; field: string }
/** @intent("Mark every field as touched (typically on a failed submit attempt)") */
| { type: 'touchAll' }
/** @intent("Begin form submission — transitions status to submitting") */
| { type: 'submit' }
/** @intent("Mark the in-flight submission as successful") */
| { type: 'submitSuccess' }
/** @intent("Mark the in-flight submission as failed with the given error message") */
| { type: 'submitError'; error: string }
/** @intent("Reset the form and every field slice to their initial state") */
| { type: 'reset' }
SearchableSelectAsyncStatus from @llui/components/patterns
export type AsyncStatus = 'idle' | 'loading' | 'loaded' | 'error'
SearchableSelectMsg from @llui/components/patterns
export type SearchableSelectMsg =
/** @intent("Open the searchable select popup") */
| { type: 'open' }
/** @intent("Close the popup (resets the filter)") */
| { type: 'close' }
/** @intent("Set the filter text (re-runs the item filter; never commits a value)") */
| { type: 'setFilter'; value: string }
/** @intent("Select the option with the given value (toggles in multi-select)") */
| { type: 'selectValue'; value: string }
/** @intent("Replace the selected values with the provided list") */
| { type: 'setValue'; value: string[] }
/** @intent("Clear the current selection") */
| { type: 'clear' }
/** @humanOnly */
| { type: 'highlightNext' }
/** @humanOnly */
| { type: 'highlightPrev' }
/** @humanOnly */
| { type: 'highlightFirst' }
/** @humanOnly */
| { type: 'highlightLast' }
/** @humanOnly */
| { type: 'highlight'; value: string | null }
/** @intent("Select the currently-highlighted option (the only commit path from the keyboard)") */
| { type: 'selectHighlighted' }
/** @humanOnly */
| { type: 'triggerType'; char: string }
/** @humanOnly */
| { type: 'setItems'; items: string[]; disabled?: string[] }
/** @intent("Mark an async option fetch as started; pass the request's id") */
| { type: 'loadStart'; requestId: number }
/** @humanOnly — atomic replacement passthrough to `combobox`; see its own
* `loadSuccess` doc for the reconciliation this performs in one step. */
| {
type: 'loadSuccess'
requestId: number
items: string[]
groups?: ComboboxGroup[]
disabled?: string[]
}
/** @humanOnly */
| { type: 'loadError'; requestId: number; error: string }
SearchableSelectSelectionMode from @llui/components/patterns
Combobox — text input paired with a filtered listbox dropdown. User types to filter items, arrow keys navigate the filtered set, Enter selects. Supports single and multiple selection.
Beyond the sync filtered listbox the machine owns three additive surfaces:
- Async option loading —
status/requestId/errortrack an in-flight fetch. The consumer debounces (e.g.@llui/effectsdebounce) and runs the fetch itself, dispatchingloadStart/loadSuccess/loadErrortagged with a monotonically-increasingrequestId. The reducer DROPS anyloadSuccess/loadErrorwhoserequestIdis not the current one, so a late response from a superseded request can never clobber fresh state. The machine owns no timers. - Option groups —
groupsmirrorselect'sSelectGroupshape exactly. The flatitemslist stays the source of truth for navigation/highlight indices; group LABELS are never options, so arrow navigation skips them. - Creatable — opt-in
allowCreate. WheninputValueis non-empty and matches no item, a synthetic create sentinel is appended tofilteredItems. Selecting it emits acreateOptionEFFECT (carrying the typed text) so the consumer owns creation; the machine never mutatesvaluefor it.
export type SelectionMode = 'single' | 'multiple'
StepValidator from @llui/components/patterns
A step validator: a predicate or a Standard Schema, sync or async.
export type StepValidator = ((values?: unknown) => boolean | Promise<boolean>) | StandardSchemaV1
WizardEffect from @llui/components/patterns
export type WizardEffect =
/** Run the async validator for `step`; dispatch stepValid/stepInvalid. */
{ type: 'validateStep'; step: number }
WizardInit from @llui/components/patterns
export type WizardInit = StepsInit
WizardMsg from @llui/components/patterns
export type WizardMsg =
/** @intent("Validate the current step and, if it passes, advance to the next step") */
| { type: 'next' }
/** @intent("Go back to the previous step (never gated by validation)") */
| { type: 'prev' }
/** @intent("Jump to a specific step by zero-based index (respects linear + completion gating)") */
| { type: 'goTo'; step: number }
/** @humanOnly */
| { type: 'stepValid'; step: number }
/** @humanOnly */
| { type: 'stepInvalid'; step: number }
/** @intent("Reset the wizard back to the first step (clears completed, errors and pending validation)") */
| { type: 'reset' }
WizardValidators from @llui/components/patterns
Map of zero-based step index → validator. Steps without an entry pass freely.
export type WizardValidators = Record<number, StepValidator>
Interfaces
Command from @llui/components/patterns
A single palette command. JSON-serializable (no functions): execution is
surfaced as an execute effect keyed by id, never a callback in state.
export interface Command {
id: string
label: string
/** Optional group/section label. Commands without a group fall into ''. */
group?: string
/** Extra terms matched by the filter in addition to `label`. */
keywords?: string[]
/** Pre-rendered keybinding hint, e.g. 'mod+s'. Surfaced via `shortcutHint`. */
shortcut?: string
disabled?: boolean
}
CommandGroup from @llui/components/patterns
A labelled section of filtered commands, in visual order.
export interface CommandGroup {
label: string
commands: Command[]
}
CommandMenuConnectOptions from @llui/components/patterns
export interface ConnectOptions {
/** Unique id per palette instance (used for ARIA wiring). */
id: string
/**
* Whether the consumer renders `dialog.description` (default: false — the
* palette's own `view()` renders a title and no description). Forwarded to
* the dialog so `aria-describedby` never names a missing element (#268).
*/
hasDescription?: boolean
}
CommandMenuInit from @llui/components/patterns
export interface CommandMenuInit {
commands?: Command[]
recents?: string[]
maxRecents?: number
open?: boolean
}
CommandMenuParts from @llui/components/patterns
export interface CommandMenuParts {
/** Dialog parts (content/title/positioner/backdrop) for the modal shell. */
dialog: DialogParts
/** Combobox parts (root/input/content/item/group/...) for the search + list. */
combobox: ComboboxParts
/** Accessor for a command's keybinding hint (empty string when none). */
shortcutHint: (commandId: string) => ReadSignal<string>
/** Empty-state part: `data-empty` is set when the filtered list is empty. */
empty: {
'data-scope': 'command-menu'
'data-part': 'empty'
role: 'status'
'data-empty': ReadSignal<'' | undefined>
}
}
CommandMenuState from @llui/components/patterns
export interface CommandMenuState {
open: boolean
query: string
commands: Command[]
/** Filtered (and recents-ranked) flat command list. */
filtered: Command[]
/** Filtered commands bucketed into groups (group order preserved). */
filteredGroups: CommandGroup[]
/** Most-recently-executed command ids, most-recent first (deduped). */
recents: string[]
/** Max recents retained for ranking. */
maxRecents: number
/**
* The keyboard-highlighted command id (the combobox's active descendant).
* Always an ENABLED member of `filtered` while open; `null` when closed or
* when nothing enabled matches.
*/
highlighted: string | null
}
CommandMenuViewOptions from @llui/components/patterns
export interface CommandMenuViewOptions {
/** A PATH signal: the view slices `filteredGroups` with `.at()` so the list is
* gated on that path alone. */
state: Signal<CommandMenuState>
send: Send<CommandMenuMsg>
id: string
inputLabel?: string
/** Custom class for the content root. */
contentClass?: string
/** Accessible title for the palette dialog (default: 'Command palette'). */
title?: string
/** Empty-state text (default: 'No matching commands'). */
emptyText?: string
}
ConfirmDialogInit from @llui/components/patterns
export interface ConfirmDialogInit {
tag?: string
title?: string
description?: string
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
}
ConfirmDialogState from @llui/components/patterns
ConfirmDialog — a pre-wired dialog pattern for confirmations.
Composes dialog with conventional content: title, description, cancel,
confirm. Carries an opaque tag so the consumer's update handler can
recognize which confirmation resolved.
The MACHINE (init/update/openWith) is styling-agnostic and is what most
consumers want. The bundled view() is a convenience for baseline-stylesheet
users only — see the note on ConfirmDialogViewOptions.
Usage in consumer's update:
case 'confirm': {
const [s, fx] = confirmDialog.update(state.confirm, msg.msg)
// When the user clicks confirm, branch on the tag:
if (msg.msg.type === 'confirm') {
switch (state.confirm.tag) {
case 'delete-user': return [{ ...state, confirm: s, users: ... }, fx]
case 'logout': return [{ ...state, confirm: s }, [...fx, logoutEffect]]
}
}
return [{ ...state, confirm: s }, fx]
}
export interface ConfirmDialogState {
open: boolean
tag: string
title: string
description: string
confirmLabel: string
cancelLabel: string
destructive: boolean
}
ConfirmDialogViewOptions from @llui/components/patterns
Options for the convenience view.
view() targets the BASELINE STYLESHEET, not the component registry. It
hardcodes btn btn-secondary, btn btn-danger and confirm-dialog__actions
— class names that only exist in @llui/components/styles/theme.css — and
contentClass / destructiveClass reach only two of them. A consumer
styling with utilities (the registry path) imports tokens.css and NOT
theme.css, so calling this renders unstyled buttons.
That path should wire the machine directly instead: dialogConnect +
dialogOverlay with its own parts, translating the dialog's close into
cancel, which is all this function does minus the class names.
examples/registry-demo's patterns section is the worked example.
export interface ConfirmDialogViewOptions {
state: ReadSignal<ConfirmDialogState>
send: Send<ConfirmDialogMsg>
id: string
/** Custom class for content root. */
contentClass?: string
/** Custom class for destructive confirm button. */
destructiveClass?: string
}
DataTableConnectOptions from @llui/components/patterns
export interface ConnectOptions {
/** Element id base for the table (`grid`) root. */
id: string
/** Presentation density forwarded to the composed table root. */
density?: TableDensity
/** Accessible label for the pagination nav. */
paginationLabel?: string
}
DataTableInit from @llui/components/patterns
export interface DataTableInit {
columns?: TableColumn[]
selectionMode?: TableSelectionMode
sort?: TableSort | null
page?: number
pageSize?: number
total?: number
siblings?: number
boundaries?: number
descFirst?: boolean
clearOnPageChange?: boolean
}
DataTableParts from @llui/components/patterns
export interface DataTableParts extends DataTableStatusParts {
table: TableParts
pagination: PaginationParts
}
DataTableState from @llui/components/patterns
DataTable — a pre-wired pattern for server-paginated, sortable, selectable
lists. It COMPOSES the headless table machine (sort / selection /
grid-keyboard), pagination (page / pageSize / total), and the async-list
status vocabulary (idle | loading | loaded | error) into a single slice.
The value of the pattern is the GLUE:
- changing the sort resets to page 1 and reloads,
- changing the page or page size reloads,
- every reload bumps a
queryIdversion counter and emits aloadPageeffect carrying{ page, pageSize, sort, queryId }; the consumer fetches and repliespageLoaded { queryId, rows, total }. The reducer DROPS anypageLoadedwhosequeryIdis stale (an older in-flight request), so a slow response can never clobber a newer one.
Row DATA stays in the consumer (the table machine only tracks row IDs in
display order). Server-side sort works for free: feed the pre-sorted IDs back
in via pageLoaded.
Usage in the consumer's onEffect:
onEffect: (effect, send) => {
if (effect.type === 'data-table:loadPage') {
const { page, pageSize, sort, queryId } = effect
fetchRows({ page, pageSize, sort })
.then(({ ids, total, data }) => {
setRowData(data)
send({ type: 'dt', msg: { type: 'pageLoaded', queryId, rows: ids, total } })
})
.catch((e) => send({ type: 'dt', msg: { type: 'pageFailed', queryId, error: String(e) } }))
}
}
export interface DataTableState {
table: TableState
pagination: PaginationState
/** Async status of the current page request. */
status: AsyncStatus
/** Error message from the last failed request, or null. */
error: string | null
/**
* Version counter for the in-flight request. Bumped on every reload; a
* `pageLoaded`/`pageFailed` whose `queryId` differs from this is stale and
* dropped.
*/
queryId: number
/**
* Selection policy. When true (default), the selection is cleared on every
* page change and select-all scopes to the current page. When false, the
* selection persists across pages (cross-page selection).
*/
clearOnPageChange: boolean
}
DataTableStatusParts from @llui/components/patterns
ARIA live-region overlay parts derived from async-list conventions.
export interface DataTableStatusParts {
/** Spinner / overlay shown while loading. `aria-busy` mirrors loading. */
loadingOverlay: {
'data-scope': 'data-table'
'data-part': 'loading-overlay'
'aria-busy': ReadSignal<'true' | undefined>
'aria-live': 'polite'
hidden: ReadSignal<boolean>
}
/** Empty-state region — shown when a settled request has zero rows. */
emptyState: {
role: 'status'
'aria-live': 'polite'
'data-scope': 'data-table'
'data-part': 'empty-state'
hidden: ReadSignal<boolean>
}
/** Error-state region — shown when the last request failed. */
errorState: {
role: 'alert'
'aria-live': 'polite'
'data-scope': 'data-table'
'data-part': 'error-state'
hidden: ReadSignal<boolean>
}
}
FormFieldConnectOptions from @llui/components/patterns
export interface FormFieldConnectOptions {
/** Base id; field slice ids derive as `${id}:${name}`. */
id: string
/** The field names this form manages. */
fields: readonly string[]
}
FormFieldFieldParts from @llui/components/patterns
The composed part bag for a single field.
export interface FormFieldFieldParts {
root: {
'data-scope': 'form-field'
'data-part': 'field'
'data-invalid': ReadSignal<'' | undefined>
'data-touched': ReadSignal<'' | undefined>
}
label: {
id: string
htmlFor: string
'data-scope': 'form-field'
'data-part': 'label'
}
control: {
id: string
'aria-labelledby': string
'aria-describedby': ReadSignal<string | undefined>
'aria-invalid': ReadSignal<'true' | undefined>
'aria-required': ReadSignal<'true' | undefined>
'aria-busy': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
readOnly: ReadSignal<boolean>
'data-scope': 'form-field'
'data-part': 'control'
onBlur: (e: FocusEvent) => void
}
description: {
id: string
'data-scope': 'form-field'
'data-part': 'description'
}
/**
* The error live region's ATTRIBUTES — spreadable as-is, like every other
* `errorText` bag in the package (`field`, `fieldset`, `async-list`,
* `date-input`). It carries its own reactive `hidden`, so the element stays
* MOUNTED and `role="alert"` announces when it is revealed; unmounting it
* with `show(...)` would register no live region and announce nothing.
*
* The message and the issues are DATA, not attributes, and live on
* {@link FormFieldFieldParts.error}. They used to sit in this bag, which made
* it the one part bag in the package a consumer could not spread — spreading
* it bound `message` and `issues` as junk DOM attributes, so every call site
* had to destructure them back out first.
*/
errorText: {
id: string
role: 'alert'
'aria-live': 'polite'
'data-scope': 'form-field'
'data-part': 'error'
hidden: ReadSignal<boolean>
}
/** The error CONTENT, for rendering — never spread onto an element. */
error: {
/** First visible issue message for this field, or '' when no error is shown. */
message: ReadSignal<string>
/** Every issue mapped to this field (for custom rendering). */
issues: ReadSignal<StandardSchemaV1.Issue[]>
/** True only when the field is invalid AND its error should be visible
* (`touched || status === 'submitted'`). */
visible: ReadSignal<boolean>
}
}
FormFieldInit from @llui/components/patterns
export interface FormFieldInit {
/** Base id; field slice ids derive as `${id}:${name}`. */
id: string
/** The field names this form manages. */
fields: readonly string[]
}
FormFieldParts from @llui/components/patterns
export interface FormFieldParts {
root: {
'data-scope': 'form-field'
'data-part': 'root'
'data-state': ReadSignal<FormStatus>
'aria-busy': ReadSignal<'true' | undefined>
}
submit: {
type: 'submit'
'data-scope': 'form-field'
'data-part': 'submit'
'data-state': ReadSignal<FormStatus>
disabled: ReadSignal<boolean>
}
/** Build the full part bag for the named field, with the form blur-to-touch
* handler already merged into `control`. */
formField: (name: string, opts?: FieldConnectOptions) => FormFieldFieldParts
}
FormFieldSlice from @llui/components/patterns
Per-field slice: the field component's state plus an async-validation flag.
export interface FormFieldSlice extends FieldState {
/** True while an async validation for this field is in flight. */
pending: boolean
}
FormFieldState from @llui/components/patterns
export interface FormFieldState {
/** The composed `form` lifecycle slice (status, touched, submitError). */
form: FormState
/** Per-field slices keyed by field name. */
fields: Record<string, FormFieldSlice>
/** The issues from the last validation, unaltered. */
issues: StandardSchemaV1.Issue[]
/**
* Id of the most recent async validation. `validateAsync` stamps its
* `requestId` here; a `validateResult` whose `requestId` no longer matches is
* DROPPED (stale-response protection — a slow earlier validation must never
* overwrite a newer result). `reset` bumps it so any in-flight validation is
* invalidated.
*/
validationId: number
}
LoadPageEffect from @llui/components/patterns
The loadPage effect — carried out of update, fulfilled by the consumer.
export interface LoadPageEffect {
type: 'data-table:loadPage'
page: number
pageSize: number
sort: TableSort | null
queryId: number
}
SearchableSelectComboboxGroup from @llui/components/patterns
A labelled section of options (rendered like <optgroup>). items are the
option VALUES belonging to the group, in visual order. Groups are an
additive, parallel structure: the flat items list always remains the
source of truth for navigation/highlight indices and item ids — when
groups is provided without an explicit items list, init derives the
flat list by concatenating each group's items in order. A plain flat
string[] (no groups) keeps working unchanged. Group LABELS are never
options, so highlight/arrow navigation skips over them for free.
Mirrors select's SelectGroup shape exactly.
export interface ComboboxGroup {
id: string
label: string
items: string[]
}
SearchableSelectConnectOptions from @llui/components/patterns
export interface ConnectOptions {
id: string
/** aria-label for the clear button. */
clearLabel?: string
/** Text shown in the empty-state live region when no items match. */
emptyText?: string
}
SearchableSelectGroupParts from @llui/components/patterns
export interface SearchableSelectGroupParts {
group: {
role: 'group'
/** The group label's id, or absent when `hasLabel: false`. */
'aria-labelledby': string | undefined
'data-scope': 'searchable-select'
'data-part': 'group'
'data-group': string
}
groupLabel: {
id: string
'aria-hidden': 'true'
'data-scope': 'searchable-select'
'data-part': 'group-label'
'data-group': string
}
}
SearchableSelectInit from @llui/components/patterns
export interface SearchableSelectInit {
value?: string[]
items?: string[]
/** Optional labelled sections (passthrough to `combobox`). */
groups?: ComboboxGroup[]
disabledItems?: string[]
selectionMode?: SelectionMode
disabled?: boolean
/** Seed the filter with the selected label on open (default: false → empty). */
prefillFilter?: boolean
/** Trigger text when nothing is selected. */
placeholder?: string
/** Join multiple selected labels with this separator in the trigger. */
separator?: string
}
SearchableSelectItemParts from @llui/components/patterns
export interface SearchableSelectItemParts {
item: {
role: 'option'
id: string
'aria-selected': ReadSignal<boolean>
'aria-disabled': ReadSignal<'true' | undefined>
'data-state': ReadSignal<'selected' | undefined>
'data-highlighted': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'searchable-select'
'data-part': 'item'
'data-value': string
/** The option's live position in the filtered list (reactive — reused rows
* never report a stale index). */
'data-index': ReadSignal<string>
onClick: (e: MouseEvent) => void
onPointerMove: (e: PointerEvent) => void
}
}
SearchableSelectOverlayOptions from @llui/components/patterns
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*
* Every `overlay()` in `components/` takes this; the pattern overlays were
* missed when it was added, so a utility-styled consumer had no way to give
* this popup a stacking context at all.
*/
positionerClass?: string
state: ReadSignal<SearchableSelectState>
send: Send<SearchableSelectMsg>
parts: SearchableSelectParts
/** Renders the popup body (filter input + listbox). */
content: () => Renderable
/**
* Optional enter/leave transition for the searchable-select popup (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, giving the raw-`open` popup an exit
* animation for free. Omitted ⇒ the popup closes synchronously as before.
*
* @example searchableSelect.overlay({ state, send, parts, content, transition: fade({ duration: 120 }) })
*/
transition?: TransitionOptions
placement?: Placement
offset?: number
flip?: boolean
shift?: boolean
sameWidth?: boolean
target?: string | HTMLElement
}
SearchableSelectParts from @llui/components/patterns
export interface SearchableSelectParts {
root: {
'data-scope': 'searchable-select'
'data-part': 'root'
'data-state': ReadSignal<'open' | 'closed'>
}
/** The closed-state trigger button. Displays the selection (via
* `triggerLabel`) and opens the popup. Handles closed-trigger typeahead. */
trigger: {
type: 'button'
role: 'combobox'
'aria-haspopup': 'listbox'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
'aria-disabled': ReadSignal<'true' | undefined>
id: string
disabled: ReadSignal<boolean>
'data-scope': 'searchable-select'
'data-part': 'trigger'
'data-state': ReadSignal<'open' | 'closed'>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
/** Text to render inside the trigger: placeholder, single label, or a joined
* multi-select summary. */
triggerLabel: ReadSignal<string>
/** Whether a selection exists (drive showing/hiding the clear button). */
hasValue: ReadSignal<boolean>
/** The filter input rendered inside the popup, above the listbox. */
input: {
type: 'text'
role: 'combobox'
autocomplete: 'off'
'aria-autocomplete': 'list'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
'aria-activedescendant': ReadSignal<string | undefined>
id: string
value: ReadSignal<string>
'data-scope': 'searchable-select'
'data-part': 'input'
onInput: (e: Event) => void
onKeyDown: (e: KeyboardEvent) => void
}
positioner: {
'data-scope': 'searchable-select'
'data-part': 'positioner'
style: string
}
content: {
role: 'listbox'
id: string
'aria-labelledby': string
'aria-busy': ReadSignal<'true' | undefined>
'aria-multiselectable': ReadSignal<'true' | undefined>
tabindex: -1
'data-state': ReadSignal<'open' | 'closed'>
'data-status': ReadSignal<AsyncStatus>
/** The mutually-exclusive load projection (#265 finding 11); mirrors the
* top-level `loadState` signal. See `combobox`'s `LoadProjection`. */
'data-load-state': ReadSignal<LoadProjection>
'data-scope': 'searchable-select'
'data-part': 'content'
}
/** The mutually-exclusive async load projection: `'initial-empty'` |
* `'loading'` | `'stale-results'` | `'success'` | `'error'` — never
* independent booleans that can contradict each other. Documents the
* stale-while-revalidate policy: `'stale-results'` means a fetch is in
* flight while the previous items are still mounted and selectable;
* `'error'` is reported the same whether or not stale items remain
* mounted underneath it. */
loadState: ReadSignal<LoadProjection>
/** Build the parts for an option by VALUE. The optional `index` is accepted
* for call-site convenience only — identity is value-keyed, so a reused row is
* never stale. */
item: (value: string, index?: number) => SearchableSelectItemParts
/**
* A labelled group's parts. The group names its `label` part through
* `aria-labelledby`; a consumer that renders the group WITHOUT that label
* passes `{ hasLabel: false }` so it never names an element that does not
* exist (an unlabelled `role="group"` is valid; a broken idref is not — the
* `dialog` `hasDescription` rule, #268). Default: true.
*/
group: (id: string, options?: { readonly hasLabel?: boolean }) => SearchableSelectGroupParts
/** Clear-selection trigger. Render only when `hasValue` is true. */
clear: {
type: 'button'
'aria-label': string
tabindex: -1
'data-scope': 'searchable-select'
'data-part': 'clear'
onClick: (e: MouseEvent) => void
}
/** Polite live region announcing the no-results / result count once the
* list is settled (`loadState` is `'success'` or `'initial-empty'`), the
* error text on `'error'`, and nothing while a fetch is in flight
* (`'loading'` / `'stale-results'`). */
liveRegion: {
role: 'status'
'aria-live': 'polite'
'aria-atomic': 'true'
'data-scope': 'searchable-select'
'data-part': 'live-region'
text: ReadSignal<string>
}
/** Empty-state container. `hidden` is false only when the SETTLED filtered
* list is empty — never while loading, revalidating, or after a failed
* fetch (the live region / an error slot own those). */
empty: {
'data-scope': 'searchable-select'
'data-part': 'empty'
hidden: ReadSignal<boolean>
}
}
SearchableSelectState from @llui/components/patterns
export interface SearchableSelectState {
/** Whether the popup is open. Mirrors `combobox.open`; kept at the top level
* so consumers can read it without reaching into the nested machine. */
open: boolean
/** The underlying combobox machine state. `value` is the source of truth for
* the selection; `inputValue` is the filter (never the committed value). */
combobox: ComboboxState
/** When opening, seed the filter with the selected label (and the consumer
* should select-all). When false (default) the filter opens empty. */
prefillFilter: boolean
/** Trigger placeholder shown when nothing is selected. */
placeholder: string
/** Separator used to join multiple selected labels in the trigger. */
separator: string
}
WizardConnectOptions from @llui/components/patterns
export interface WizardConnectOptions {
label?: string
}
WizardParts from @llui/components/patterns
export interface WizardParts {
root: {
role: 'group'
'aria-label': string
'data-scope': 'steps'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
}
nextTrigger: {
type: 'button'
disabled: ReadSignal<boolean>
'aria-busy': ReadSignal<'true' | undefined>
'data-scope': 'steps'
'data-part': 'next-trigger'
onClick: (e: MouseEvent) => void
}
prevTrigger: {
type: 'button'
disabled: ReadSignal<boolean>
'data-scope': 'steps'
'data-part': 'prev-trigger'
onClick: (e: MouseEvent) => void
}
/** Per-step trigger parts (item / trigger / separator), gated like raw steps. */
item: (index: number) => StepsItemParts
/** The trigger sub-part for a step index — for keyboard-complete step lists. */
stepTrigger: (index: number) => StepsItemParts['trigger']
}
WizardState from @llui/components/patterns
export interface WizardState {
/** Underlying steps machine state. */
steps: StepsState
/** Index of the step whose async validation is pending, or null when idle. */
validating: number | null
}
Constants
commandMenu from @llui/components/patterns
const commandMenu: typeof import('./command-menu.js')
commandMenuPattern from @llui/components/patterns
const commandMenuPattern
confirmDialog from @llui/components/patterns
const confirmDialog: typeof import('./confirm-dialog.js')
dataTable from @llui/components/patterns
const dataTable: typeof import('./data-table.js')
dataTablePattern from @llui/components/patterns
const dataTablePattern
formField from @llui/components/patterns
const formField: typeof import('./form-field.js')
searchableSelect from @llui/components/patterns
const searchableSelect: typeof import('./searchable-select.js')
searchableSelectPattern from @llui/components/patterns
const searchableSelectPattern
wizard from @llui/components/patterns
const wizard: typeof import('./wizard.js')
wizardFlow from @llui/components/patterns
const wizardFlow
@llui/components/patterns/confirm-dialog
Functions
dialogInit() from @llui/components/patterns/confirm-dialog
function dialogInit(opts: DialogInit = {}): DialogState
dialogUpdate() from @llui/components/patterns/confirm-dialog
function dialogUpdate(state: DialogState, msg: DialogMsg): [DialogState, never[]]
init() from @llui/components/patterns/confirm-dialog
function init(opts: ConfirmDialogInit = {}): ConfirmDialogState
openWith() from @llui/components/patterns/confirm-dialog
Helper to create an openWith message builder.
function openWith(tag: string, opts: {
title: string
description?: string
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
}): ConfirmDialogMsg
update() from @llui/components/patterns/confirm-dialog
function update(state: ConfirmDialogState, msg: ConfirmDialogMsg): [ConfirmDialogState, never[]]
view() from @llui/components/patterns/confirm-dialog
function view(opts: ConfirmDialogViewOptions): Mountable
Types
ConfirmDialogMsg from @llui/components/patterns/confirm-dialog
export type ConfirmDialogMsg =
| {
type: 'openWith'
tag: string
title: string
description?: string
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
}
| { type: 'confirm' }
| { type: 'cancel' }
| { type: 'setOpen'; open: boolean }
Interfaces
ConfirmDialogInit from @llui/components/patterns/confirm-dialog
export interface ConfirmDialogInit {
tag?: string
title?: string
description?: string
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
}
ConfirmDialogState from @llui/components/patterns/confirm-dialog
ConfirmDialog — a pre-wired dialog pattern for confirmations.
Composes dialog with conventional content: title, description, cancel,
confirm. Carries an opaque tag so the consumer's update handler can
recognize which confirmation resolved.
The MACHINE (init/update/openWith) is styling-agnostic and is what most
consumers want. The bundled view() is a convenience for baseline-stylesheet
users only — see the note on ConfirmDialogViewOptions.
Usage in consumer's update:
case 'confirm': {
const [s, fx] = confirmDialog.update(state.confirm, msg.msg)
// When the user clicks confirm, branch on the tag:
if (msg.msg.type === 'confirm') {
switch (state.confirm.tag) {
case 'delete-user': return [{ ...state, confirm: s, users: ... }, fx]
case 'logout': return [{ ...state, confirm: s }, [...fx, logoutEffect]]
}
}
return [{ ...state, confirm: s }, fx]
}
export interface ConfirmDialogState {
open: boolean
tag: string
title: string
description: string
confirmLabel: string
cancelLabel: string
destructive: boolean
}
ConfirmDialogViewOptions from @llui/components/patterns/confirm-dialog
Options for the convenience view.
view() targets the BASELINE STYLESHEET, not the component registry. It
hardcodes btn btn-secondary, btn btn-danger and confirm-dialog__actions
— class names that only exist in @llui/components/styles/theme.css — and
contentClass / destructiveClass reach only two of them. A consumer
styling with utilities (the registry path) imports tokens.css and NOT
theme.css, so calling this renders unstyled buttons.
That path should wire the machine directly instead: dialogConnect +
dialogOverlay with its own parts, translating the dialog's close into
cancel, which is all this function does minus the class names.
examples/registry-demo's patterns section is the worked example.
export interface ConfirmDialogViewOptions {
state: ReadSignal<ConfirmDialogState>
send: Send<ConfirmDialogMsg>
id: string
/** Custom class for content root. */
contentClass?: string
/** Custom class for destructive confirm button. */
destructiveClass?: string
}
Constants
confirmDialog from @llui/components/patterns/confirm-dialog
const confirmDialog
@llui/components/patterns/form-field
Functions
connect() from @llui/components/patterns/form-field
function connect(state: ReadSignal<FormFieldState>, send: Send<FormFieldMsg>, opts: FormFieldConnectOptions): FormFieldParts
init() from @llui/components/patterns/form-field
function init(opts: FormFieldInit): FormFieldState
pathToFieldName() from @llui/components/patterns/form-field
Map a Standard Schema issue path to a flat field name. Object keys join with
. and array indices append as .<n> — so ['address', 'street'] becomes
address.street and ['tags', 0] becomes tags.0. These match the keys a
consumer passes in fields.
function pathToFieldName(path: StandardSchemaV1.Issue['path']): string
update() from @llui/components/patterns/form-field
function update(state: FormFieldState, msg: FormFieldMsg): [FormFieldState, never[]]
Types
FormFieldMsg from @llui/components/patterns/form-field
export type FormFieldMsg =
/** @intent("Validate the given values against a Standard Schema synchronously and update field validity") */
| { type: 'validate'; schema: StandardSchemaV1<unknown>; values: unknown }
/** @intent("Begin an async validation — marks every field pending until validateResult arrives") */
| { type: 'validateAsync'; schema: StandardSchemaV1<unknown>; values: unknown; requestId: number }
/** @intent("Apply the issues from a resolved async validation, clearing the pending state") */
| { type: 'validateResult'; issues: StandardSchemaV1.Issue[]; requestId: number }
/** @intent("Mark a single field as touched (typically on blur)") */
| { type: 'touch'; field: string }
/** @intent("Mark every field as touched (typically on a failed submit attempt)") */
| { type: 'touchAll' }
/** @intent("Begin form submission — transitions status to submitting") */
| { type: 'submit' }
/** @intent("Mark the in-flight submission as successful") */
| { type: 'submitSuccess' }
/** @intent("Mark the in-flight submission as failed with the given error message") */
| { type: 'submitError'; error: string }
/** @intent("Reset the form and every field slice to their initial state") */
| { type: 'reset' }
Interfaces
FormFieldConnectOptions from @llui/components/patterns/form-field
export interface FormFieldConnectOptions {
/** Base id; field slice ids derive as `${id}:${name}`. */
id: string
/** The field names this form manages. */
fields: readonly string[]
}
FormFieldFieldParts from @llui/components/patterns/form-field
The composed part bag for a single field.
export interface FormFieldFieldParts {
root: {
'data-scope': 'form-field'
'data-part': 'field'
'data-invalid': ReadSignal<'' | undefined>
'data-touched': ReadSignal<'' | undefined>
}
label: {
id: string
htmlFor: string
'data-scope': 'form-field'
'data-part': 'label'
}
control: {
id: string
'aria-labelledby': string
'aria-describedby': ReadSignal<string | undefined>
'aria-invalid': ReadSignal<'true' | undefined>
'aria-required': ReadSignal<'true' | undefined>
'aria-busy': ReadSignal<'true' | undefined>
disabled: ReadSignal<boolean>
readOnly: ReadSignal<boolean>
'data-scope': 'form-field'
'data-part': 'control'
onBlur: (e: FocusEvent) => void
}
description: {
id: string
'data-scope': 'form-field'
'data-part': 'description'
}
/**
* The error live region's ATTRIBUTES — spreadable as-is, like every other
* `errorText` bag in the package (`field`, `fieldset`, `async-list`,
* `date-input`). It carries its own reactive `hidden`, so the element stays
* MOUNTED and `role="alert"` announces when it is revealed; unmounting it
* with `show(...)` would register no live region and announce nothing.
*
* The message and the issues are DATA, not attributes, and live on
* {@link FormFieldFieldParts.error}. They used to sit in this bag, which made
* it the one part bag in the package a consumer could not spread — spreading
* it bound `message` and `issues` as junk DOM attributes, so every call site
* had to destructure them back out first.
*/
errorText: {
id: string
role: 'alert'
'aria-live': 'polite'
'data-scope': 'form-field'
'data-part': 'error'
hidden: ReadSignal<boolean>
}
/** The error CONTENT, for rendering — never spread onto an element. */
error: {
/** First visible issue message for this field, or '' when no error is shown. */
message: ReadSignal<string>
/** Every issue mapped to this field (for custom rendering). */
issues: ReadSignal<StandardSchemaV1.Issue[]>
/** True only when the field is invalid AND its error should be visible
* (`touched || status === 'submitted'`). */
visible: ReadSignal<boolean>
}
}
FormFieldInit from @llui/components/patterns/form-field
export interface FormFieldInit {
/** Base id; field slice ids derive as `${id}:${name}`. */
id: string
/** The field names this form manages. */
fields: readonly string[]
}
FormFieldParts from @llui/components/patterns/form-field
export interface FormFieldParts {
root: {
'data-scope': 'form-field'
'data-part': 'root'
'data-state': ReadSignal<FormStatus>
'aria-busy': ReadSignal<'true' | undefined>
}
submit: {
type: 'submit'
'data-scope': 'form-field'
'data-part': 'submit'
'data-state': ReadSignal<FormStatus>
disabled: ReadSignal<boolean>
}
/** Build the full part bag for the named field, with the form blur-to-touch
* handler already merged into `control`. */
formField: (name: string, opts?: FieldConnectOptions) => FormFieldFieldParts
}
FormFieldSlice from @llui/components/patterns/form-field
Per-field slice: the field component's state plus an async-validation flag.
export interface FormFieldSlice extends FieldState {
/** True while an async validation for this field is in flight. */
pending: boolean
}
FormFieldState from @llui/components/patterns/form-field
export interface FormFieldState {
/** The composed `form` lifecycle slice (status, touched, submitError). */
form: FormState
/** Per-field slices keyed by field name. */
fields: Record<string, FormFieldSlice>
/** The issues from the last validation, unaltered. */
issues: StandardSchemaV1.Issue[]
/**
* Id of the most recent async validation. `validateAsync` stamps its
* `requestId` here; a `validateResult` whose `requestId` no longer matches is
* DROPPED (stale-response protection — a slow earlier validation must never
* overwrite a newer result). `reset` bumps it so any in-flight validation is
* invalidated.
*/
validationId: number
}
Constants
formField from @llui/components/patterns/form-field
const formField
@llui/components/patterns/wizard
Functions
connect() from @llui/components/patterns/wizard
function connect(state: ReadSignal<WizardState>, send: Send<WizardMsg>, opts: WizardConnectOptions = {}): WizardParts
init() from @llui/components/patterns/wizard
function init(opts: WizardInit = {}): WizardState
stepStatus() from @llui/components/patterns/wizard
function stepStatus(state: WizardState, step: number): StepStatus
update() from @llui/components/patterns/wizard
function update(state: WizardState, msg: WizardMsg, validators: WizardValidators = {}): [WizardState, WizardEffect[]]
Types
StepValidator from @llui/components/patterns/wizard
A step validator: a predicate or a Standard Schema, sync or async.
export type StepValidator = ((values?: unknown) => boolean | Promise<boolean>) | StandardSchemaV1
WizardEffect from @llui/components/patterns/wizard
export type WizardEffect =
/** Run the async validator for `step`; dispatch stepValid/stepInvalid. */
{ type: 'validateStep'; step: number }
WizardInit from @llui/components/patterns/wizard
export type WizardInit = StepsInit
WizardMsg from @llui/components/patterns/wizard
export type WizardMsg =
/** @intent("Validate the current step and, if it passes, advance to the next step") */
| { type: 'next' }
/** @intent("Go back to the previous step (never gated by validation)") */
| { type: 'prev' }
/** @intent("Jump to a specific step by zero-based index (respects linear + completion gating)") */
| { type: 'goTo'; step: number }
/** @humanOnly */
| { type: 'stepValid'; step: number }
/** @humanOnly */
| { type: 'stepInvalid'; step: number }
/** @intent("Reset the wizard back to the first step (clears completed, errors and pending validation)") */
| { type: 'reset' }
WizardValidators from @llui/components/patterns/wizard
Map of zero-based step index → validator. Steps without an entry pass freely.
export type WizardValidators = Record<number, StepValidator>
Interfaces
WizardConnectOptions from @llui/components/patterns/wizard
export interface WizardConnectOptions {
label?: string
}
WizardParts from @llui/components/patterns/wizard
export interface WizardParts {
root: {
role: 'group'
'aria-label': string
'data-scope': 'steps'
'data-part': 'root'
'data-disabled': ReadSignal<'' | undefined>
}
nextTrigger: {
type: 'button'
disabled: ReadSignal<boolean>
'aria-busy': ReadSignal<'true' | undefined>
'data-scope': 'steps'
'data-part': 'next-trigger'
onClick: (e: MouseEvent) => void
}
prevTrigger: {
type: 'button'
disabled: ReadSignal<boolean>
'data-scope': 'steps'
'data-part': 'prev-trigger'
onClick: (e: MouseEvent) => void
}
/** Per-step trigger parts (item / trigger / separator), gated like raw steps. */
item: (index: number) => StepsItemParts
/** The trigger sub-part for a step index — for keyboard-complete step lists. */
stepTrigger: (index: number) => StepsItemParts['trigger']
}
WizardState from @llui/components/patterns/wizard
export interface WizardState {
/** Underlying steps machine state. */
steps: StepsState
/** Index of the step whose async validation is pending, or null when idle. */
validating: number | null
}
Constants
wizard from @llui/components/patterns/wizard
const wizard
@llui/components/patterns/command-menu
Functions
connect() from @llui/components/patterns/command-menu
Project the composed slice into dialog + combobox part bags plus a
shortcutHint accessor and an empty-state part. The dialog/combobox sends
are adapted to command-menu messages: the consumer spreads these onto
elements exactly like the base components.
function connect(state: ReadSignal<CommandMenuState>, send: Send<CommandMenuMsg>, opts: ConnectOptions): CommandMenuParts
init() from @llui/components/patterns/command-menu
function init(opts: CommandMenuInit = {}): CommandMenuState
update() from @llui/components/patterns/command-menu
function update(state: CommandMenuState, msg: CommandMenuMsg): [CommandMenuState, CommandMenuEffect[]]
view() from @llui/components/patterns/command-menu
Default palette view: a combobox (search input + grouped command list) inside
the dialog overlay. The listbox owns one option per FILTERED command,
bucketed into labelled groups (an ungrouped command sits directly in the
listbox, with no group and so no dangling label reference). The combobox
input's own key handling drives the machine's highlight — arrows/Home/End
move it, Enter runs it, Escape clears the query then closes — and a pointer
click runs the clicked command. Consumers wanting a custom row template
should drive the part bags from connect() directly.
function view(opts: CommandMenuViewOptions): Mountable
watchHotkey() from @llui/components/patterns/command-menu
Listen for the global command-palette hotkey. Returns a cleanup function.
Call from onMount; the DOM listener never lives inside the machine.
combo is a +-joined chord, e.g. 'mod+k' (default). mod matches the
platform-conventional accelerator (⌘ on macOS, Ctrl elsewhere); since both
map to either metaKey or ctrlKey here, mod accepts either modifier.
function watchHotkey(send: Send<CommandMenuMsg>, combo: string = 'mod+k'): () => void
Types
CommandMenuEffect from @llui/components/patterns/command-menu
Effects emitted by the command-menu machine. execute is the single
agent-resolvable surface: the consumer's onEffect runs the side effect for
the picked command id (the machine never performs IO).
export type CommandMenuEffect =
/** @intent("Run the side effect for the executed command id") */
{ type: 'execute'; commandId: string }
CommandMenuMsg from @llui/components/patterns/command-menu
export type CommandMenuMsg =
/** @intent("Open the command palette") */
| { type: 'open' }
/** @intent("Close the command palette") */
| { type: 'close' }
/** @intent("Set the search query (re-runs the filter)") */
| { type: 'setQuery'; query: string }
/** @intent("Run the command with the given id, then close the palette") */
| { type: 'execute'; commandId: string }
/** @humanOnly */
| { type: 'escape' }
/** @humanOnly */
| { type: 'highlight'; commandId: string }
/** @humanOnly */
| { type: 'highlightNext' }
/** @humanOnly */
| { type: 'highlightPrev' }
/** @humanOnly */
| { type: 'highlightFirst' }
/** @humanOnly */
| { type: 'highlightLast' }
/** @humanOnly */
| { type: 'executeHighlighted' }
/** @humanOnly */
| { type: 'setCommands'; commands: Command[] }
Interfaces
Command from @llui/components/patterns/command-menu
A single palette command. JSON-serializable (no functions): execution is
surfaced as an execute effect keyed by id, never a callback in state.
export interface Command {
id: string
label: string
/** Optional group/section label. Commands without a group fall into ''. */
group?: string
/** Extra terms matched by the filter in addition to `label`. */
keywords?: string[]
/** Pre-rendered keybinding hint, e.g. 'mod+s'. Surfaced via `shortcutHint`. */
shortcut?: string
disabled?: boolean
}
CommandGroup from @llui/components/patterns/command-menu
A labelled section of filtered commands, in visual order.
export interface CommandGroup {
label: string
commands: Command[]
}
CommandMenuInit from @llui/components/patterns/command-menu
export interface CommandMenuInit {
commands?: Command[]
recents?: string[]
maxRecents?: number
open?: boolean
}
CommandMenuParts from @llui/components/patterns/command-menu
export interface CommandMenuParts {
/** Dialog parts (content/title/positioner/backdrop) for the modal shell. */
dialog: DialogParts
/** Combobox parts (root/input/content/item/group/...) for the search + list. */
combobox: ComboboxParts
/** Accessor for a command's keybinding hint (empty string when none). */
shortcutHint: (commandId: string) => ReadSignal<string>
/** Empty-state part: `data-empty` is set when the filtered list is empty. */
empty: {
'data-scope': 'command-menu'
'data-part': 'empty'
role: 'status'
'data-empty': ReadSignal<'' | undefined>
}
}
CommandMenuState from @llui/components/patterns/command-menu
export interface CommandMenuState {
open: boolean
query: string
commands: Command[]
/** Filtered (and recents-ranked) flat command list. */
filtered: Command[]
/** Filtered commands bucketed into groups (group order preserved). */
filteredGroups: CommandGroup[]
/** Most-recently-executed command ids, most-recent first (deduped). */
recents: string[]
/** Max recents retained for ranking. */
maxRecents: number
/**
* The keyboard-highlighted command id (the combobox's active descendant).
* Always an ENABLED member of `filtered` while open; `null` when closed or
* when nothing enabled matches.
*/
highlighted: string | null
}
CommandMenuViewOptions from @llui/components/patterns/command-menu
export interface CommandMenuViewOptions {
/** A PATH signal: the view slices `filteredGroups` with `.at()` so the list is
* gated on that path alone. */
state: Signal<CommandMenuState>
send: Send<CommandMenuMsg>
id: string
inputLabel?: string
/** Custom class for the content root. */
contentClass?: string
/** Accessible title for the palette dialog (default: 'Command palette'). */
title?: string
/** Empty-state text (default: 'No matching commands'). */
emptyText?: string
}
ConnectOptions from @llui/components/patterns/command-menu
export interface ConnectOptions {
/** Unique id per palette instance (used for ARIA wiring). */
id: string
/**
* Whether the consumer renders `dialog.description` (default: false — the
* palette's own `view()` renders a title and no description). Forwarded to
* the dialog so `aria-describedby` never names a missing element (#268).
*/
hasDescription?: boolean
}
Constants
commandMenu from @llui/components/patterns/command-menu
const commandMenu
@llui/components/patterns/data-table
Functions
connect() from @llui/components/patterns/data-table
function connect(state: ReadSignal<DataTableState>, send: Send<DataTableMsg>, opts: ConnectOptions): DataTableParts
init() from @llui/components/patterns/data-table
function init(opts: DataTableInit = {}): DataTableState
isAllSelected() from @llui/components/patterns/data-table
function isAllSelected(state: DataTableState): boolean
isEmpty() from @llui/components/patterns/data-table
Empty == a settled (not-loading) request that yielded zero rows.
function isEmpty(state: DataTableState): boolean
isError() from @llui/components/patterns/data-table
function isError(state: DataTableState): boolean
isLoading() from @llui/components/patterns/data-table
function isLoading(state: DataTableState): boolean
totalPages() from @llui/components/patterns/data-table
function totalPages(state: DataTableState): number
update() from @llui/components/patterns/data-table
function update(state: DataTableState, msg: DataTableMsg): [DataTableState, DataTableEffect[]]
Types
DataTableEffect from @llui/components/patterns/data-table
export type DataTableEffect = LoadPageEffect
DataTableMsg from @llui/components/patterns/data-table
export type DataTableMsg =
/** @intent("Cycle the sort on the given column; resets to page 1 and reloads") */
| { type: 'toggleSort'; columnId: string }
/** @intent("Set an explicit sort (or null to clear); resets to page 1 and reloads") */
| { type: 'setSort'; sort: TableSort | null }
/** @intent("Jump to a specific 1-based page and reload") */
| { type: 'setPage'; page: number }
/** @intent("Advance to the next page and reload") */
| { type: 'nextPage' }
/** @intent("Go back to the previous page and reload") */
| { type: 'prevPage' }
/** @intent("Change the page size; preserves the first visible item and reloads") */
| { type: 'setPageSize'; pageSize: number }
/** @intent("Re-request the current page (e.g. after a failure)") */
| { type: 'reload' }
/** @humanOnly */
| { type: 'pageLoaded'; queryId: number; rows: string[]; total: number }
/** @humanOnly */
| { type: 'pageFailed'; queryId: number; error: string }
/** @intent("Toggle selection of the row with the given id at the given display index") */
| { type: 'toggleRow'; id: string; index: number }
/** @intent("Toggle between select-all and clear for the current scope") */
| { type: 'toggleAll' }
/** @intent("Clear the entire selection") */
| { type: 'clearSelection' }
/** @intent("Select the inclusive range from the current anchor to the given index (Shift+click)") */
| { type: 'selectRange'; index: number }
/** @intent("Activate (open/confirm) the row with the given id at the given index") */
| { type: 'activateRow'; id: string; index: number }
/** @humanOnly */
| { type: 'focusCell'; rowIndex: number; colIndex: number }
/** @humanOnly */
| { type: 'tableKey'; msg: TableMsg }
Interfaces
ConnectOptions from @llui/components/patterns/data-table
export interface ConnectOptions {
/** Element id base for the table (`grid`) root. */
id: string
/** Presentation density forwarded to the composed table root. */
density?: TableDensity
/** Accessible label for the pagination nav. */
paginationLabel?: string
}
DataTableInit from @llui/components/patterns/data-table
export interface DataTableInit {
columns?: TableColumn[]
selectionMode?: TableSelectionMode
sort?: TableSort | null
page?: number
pageSize?: number
total?: number
siblings?: number
boundaries?: number
descFirst?: boolean
clearOnPageChange?: boolean
}
DataTableParts from @llui/components/patterns/data-table
export interface DataTableParts extends DataTableStatusParts {
table: TableParts
pagination: PaginationParts
}
DataTableState from @llui/components/patterns/data-table
DataTable — a pre-wired pattern for server-paginated, sortable, selectable
lists. It COMPOSES the headless table machine (sort / selection /
grid-keyboard), pagination (page / pageSize / total), and the async-list
status vocabulary (idle | loading | loaded | error) into a single slice.
The value of the pattern is the GLUE:
- changing the sort resets to page 1 and reloads,
- changing the page or page size reloads,
- every reload bumps a
queryIdversion counter and emits aloadPageeffect carrying{ page, pageSize, sort, queryId }; the consumer fetches and repliespageLoaded { queryId, rows, total }. The reducer DROPS anypageLoadedwhosequeryIdis stale (an older in-flight request), so a slow response can never clobber a newer one.
Row DATA stays in the consumer (the table machine only tracks row IDs in
display order). Server-side sort works for free: feed the pre-sorted IDs back
in via pageLoaded.
Usage in the consumer's onEffect:
onEffect: (effect, send) => {
if (effect.type === 'data-table:loadPage') {
const { page, pageSize, sort, queryId } = effect
fetchRows({ page, pageSize, sort })
.then(({ ids, total, data }) => {
setRowData(data)
send({ type: 'dt', msg: { type: 'pageLoaded', queryId, rows: ids, total } })
})
.catch((e) => send({ type: 'dt', msg: { type: 'pageFailed', queryId, error: String(e) } }))
}
}
export interface DataTableState {
table: TableState
pagination: PaginationState
/** Async status of the current page request. */
status: AsyncStatus
/** Error message from the last failed request, or null. */
error: string | null
/**
* Version counter for the in-flight request. Bumped on every reload; a
* `pageLoaded`/`pageFailed` whose `queryId` differs from this is stale and
* dropped.
*/
queryId: number
/**
* Selection policy. When true (default), the selection is cleared on every
* page change and select-all scopes to the current page. When false, the
* selection persists across pages (cross-page selection).
*/
clearOnPageChange: boolean
}
DataTableStatusParts from @llui/components/patterns/data-table
ARIA live-region overlay parts derived from async-list conventions.
export interface DataTableStatusParts {
/** Spinner / overlay shown while loading. `aria-busy` mirrors loading. */
loadingOverlay: {
'data-scope': 'data-table'
'data-part': 'loading-overlay'
'aria-busy': ReadSignal<'true' | undefined>
'aria-live': 'polite'
hidden: ReadSignal<boolean>
}
/** Empty-state region — shown when a settled request has zero rows. */
emptyState: {
role: 'status'
'aria-live': 'polite'
'data-scope': 'data-table'
'data-part': 'empty-state'
hidden: ReadSignal<boolean>
}
/** Error-state region — shown when the last request failed. */
errorState: {
role: 'alert'
'aria-live': 'polite'
'data-scope': 'data-table'
'data-part': 'error-state'
hidden: ReadSignal<boolean>
}
}
LoadPageEffect from @llui/components/patterns/data-table
The loadPage effect — carried out of update, fulfilled by the consumer.
export interface LoadPageEffect {
type: 'data-table:loadPage'
page: number
pageSize: number
sort: TableSort | null
queryId: number
}
Constants
dataTable from @llui/components/patterns/data-table
const dataTable
@llui/components/patterns/searchable-select
Functions
connect() from @llui/components/patterns/searchable-select
function connect(state: ReadSignal<SearchableSelectState>, send: Send<SearchableSelectMsg>, opts: ConnectOptions): SearchableSelectParts
init() from @llui/components/patterns/searchable-select
function init(opts: SearchableSelectInit = {}): SearchableSelectState
overlay() from @llui/components/patterns/searchable-select
function overlay(opts: OverlayOptions): Mountable
update() from @llui/components/patterns/searchable-select
function update(state: SearchableSelectState, msg: SearchableSelectMsg): [SearchableSelectState, never[]]
Types
AsyncStatus from @llui/components/patterns/searchable-select
export type AsyncStatus = 'idle' | 'loading' | 'loaded' | 'error'
LoadProjection from @llui/components/patterns/searchable-select
A single, mutually-exclusive summary of the async load lifecycle, derived
from status and whether any items are currently on hand. This exists so a
consumer never has to reconcile independent booleans (isLoading,
isEmpty, hasError) that can read true at the same time — exactly the
defect #265 finding 11 named test-first. There are five states partitioning
every reachable (status, items.length) pair:
'initial-empty'— nothing has ever loaded and none were given synchronously (status === 'idle', no items).'loading'— a fetch is in flight and there is nothing yet to show (a first-ever load).'stale-results'— the STALE-WHILE-REVALIDATE state: a fetch is in flight while a previous list is still on screen.loadStartnever clearsitems, so the previous results keep rendering, filterable and selectable, until the matchingloadSuccess/loadErrorlands.'success'— the current items are the result of a completed load, or were given synchronously and never superseded by a failed fetch.'error'— the most recent fetch failed. Per the same policy, items from an earlier successful load are left mounted and selectable; only the live region / a consumer's own error slot communicate the failure, so'error'is reported the same whether or not stale items remain.
export type LoadProjection = 'initial-empty' | 'loading' | 'stale-results' | 'success' | 'error'
SearchableSelectMsg from @llui/components/patterns/searchable-select
export type SearchableSelectMsg =
/** @intent("Open the searchable select popup") */
| { type: 'open' }
/** @intent("Close the popup (resets the filter)") */
| { type: 'close' }
/** @intent("Set the filter text (re-runs the item filter; never commits a value)") */
| { type: 'setFilter'; value: string }
/** @intent("Select the option with the given value (toggles in multi-select)") */
| { type: 'selectValue'; value: string }
/** @intent("Replace the selected values with the provided list") */
| { type: 'setValue'; value: string[] }
/** @intent("Clear the current selection") */
| { type: 'clear' }
/** @humanOnly */
| { type: 'highlightNext' }
/** @humanOnly */
| { type: 'highlightPrev' }
/** @humanOnly */
| { type: 'highlightFirst' }
/** @humanOnly */
| { type: 'highlightLast' }
/** @humanOnly */
| { type: 'highlight'; value: string | null }
/** @intent("Select the currently-highlighted option (the only commit path from the keyboard)") */
| { type: 'selectHighlighted' }
/** @humanOnly */
| { type: 'triggerType'; char: string }
/** @humanOnly */
| { type: 'setItems'; items: string[]; disabled?: string[] }
/** @intent("Mark an async option fetch as started; pass the request's id") */
| { type: 'loadStart'; requestId: number }
/** @humanOnly — atomic replacement passthrough to `combobox`; see its own
* `loadSuccess` doc for the reconciliation this performs in one step. */
| {
type: 'loadSuccess'
requestId: number
items: string[]
groups?: ComboboxGroup[]
disabled?: string[]
}
/** @humanOnly */
| { type: 'loadError'; requestId: number; error: string }
SelectionMode from @llui/components/patterns/searchable-select
Combobox — text input paired with a filtered listbox dropdown. User types to filter items, arrow keys navigate the filtered set, Enter selects. Supports single and multiple selection.
Beyond the sync filtered listbox the machine owns three additive surfaces:
- Async option loading —
status/requestId/errortrack an in-flight fetch. The consumer debounces (e.g.@llui/effectsdebounce) and runs the fetch itself, dispatchingloadStart/loadSuccess/loadErrortagged with a monotonically-increasingrequestId. The reducer DROPS anyloadSuccess/loadErrorwhoserequestIdis not the current one, so a late response from a superseded request can never clobber fresh state. The machine owns no timers. - Option groups —
groupsmirrorselect'sSelectGroupshape exactly. The flatitemslist stays the source of truth for navigation/highlight indices; group LABELS are never options, so arrow navigation skips them. - Creatable — opt-in
allowCreate. WheninputValueis non-empty and matches no item, a synthetic create sentinel is appended tofilteredItems. Selecting it emits acreateOptionEFFECT (carrying the typed text) so the consumer owns creation; the machine never mutatesvaluefor it.
export type SelectionMode = 'single' | 'multiple'
Interfaces
ComboboxGroup from @llui/components/patterns/searchable-select
A labelled section of options (rendered like <optgroup>). items are the
option VALUES belonging to the group, in visual order. Groups are an
additive, parallel structure: the flat items list always remains the
source of truth for navigation/highlight indices and item ids — when
groups is provided without an explicit items list, init derives the
flat list by concatenating each group's items in order. A plain flat
string[] (no groups) keeps working unchanged. Group LABELS are never
options, so highlight/arrow navigation skips over them for free.
Mirrors select's SelectGroup shape exactly.
export interface ComboboxGroup {
id: string
label: string
items: string[]
}
ConnectOptions from @llui/components/patterns/searchable-select
export interface ConnectOptions {
id: string
/** aria-label for the clear button. */
clearLabel?: string
/** Text shown in the empty-state live region when no items match. */
emptyText?: string
}
OverlayOptions from @llui/components/patterns/searchable-select
export interface OverlayOptions {
/**
* Class applied to the positioner — the floating wrapper `div` this helper
* builds around the content. Needed when styling with utilities rather than
* the opt-in baseline stylesheet: it is the element that carries the
* `z-index` for the floating layer.
*
* Every `overlay()` in `components/` takes this; the pattern overlays were
* missed when it was added, so a utility-styled consumer had no way to give
* this popup a stacking context at all.
*/
positionerClass?: string
state: ReadSignal<SearchableSelectState>
send: Send<SearchableSelectMsg>
parts: SearchableSelectParts
/** Renders the popup body (filter input + listbox). */
content: () => Renderable
/**
* Optional enter/leave transition for the searchable-select popup (from
* `@llui/transitions`). `enter` animates it in on open; `leave` defers the
* unmount until its promise resolves, giving the raw-`open` popup an exit
* animation for free. Omitted ⇒ the popup closes synchronously as before.
*
* @example searchableSelect.overlay({ state, send, parts, content, transition: fade({ duration: 120 }) })
*/
transition?: TransitionOptions
placement?: Placement
offset?: number
flip?: boolean
shift?: boolean
sameWidth?: boolean
target?: string | HTMLElement
}
SearchableSelectGroupParts from @llui/components/patterns/searchable-select
export interface SearchableSelectGroupParts {
group: {
role: 'group'
/** The group label's id, or absent when `hasLabel: false`. */
'aria-labelledby': string | undefined
'data-scope': 'searchable-select'
'data-part': 'group'
'data-group': string
}
groupLabel: {
id: string
'aria-hidden': 'true'
'data-scope': 'searchable-select'
'data-part': 'group-label'
'data-group': string
}
}
SearchableSelectInit from @llui/components/patterns/searchable-select
export interface SearchableSelectInit {
value?: string[]
items?: string[]
/** Optional labelled sections (passthrough to `combobox`). */
groups?: ComboboxGroup[]
disabledItems?: string[]
selectionMode?: SelectionMode
disabled?: boolean
/** Seed the filter with the selected label on open (default: false → empty). */
prefillFilter?: boolean
/** Trigger text when nothing is selected. */
placeholder?: string
/** Join multiple selected labels with this separator in the trigger. */
separator?: string
}
SearchableSelectItemParts from @llui/components/patterns/searchable-select
export interface SearchableSelectItemParts {
item: {
role: 'option'
id: string
'aria-selected': ReadSignal<boolean>
'aria-disabled': ReadSignal<'true' | undefined>
'data-state': ReadSignal<'selected' | undefined>
'data-highlighted': ReadSignal<'' | undefined>
'data-disabled': ReadSignal<'' | undefined>
'data-scope': 'searchable-select'
'data-part': 'item'
'data-value': string
/** The option's live position in the filtered list (reactive — reused rows
* never report a stale index). */
'data-index': ReadSignal<string>
onClick: (e: MouseEvent) => void
onPointerMove: (e: PointerEvent) => void
}
}
SearchableSelectParts from @llui/components/patterns/searchable-select
export interface SearchableSelectParts {
root: {
'data-scope': 'searchable-select'
'data-part': 'root'
'data-state': ReadSignal<'open' | 'closed'>
}
/** The closed-state trigger button. Displays the selection (via
* `triggerLabel`) and opens the popup. Handles closed-trigger typeahead. */
trigger: {
type: 'button'
role: 'combobox'
'aria-haspopup': 'listbox'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
'aria-disabled': ReadSignal<'true' | undefined>
id: string
disabled: ReadSignal<boolean>
'data-scope': 'searchable-select'
'data-part': 'trigger'
'data-state': ReadSignal<'open' | 'closed'>
onClick: (e: MouseEvent) => void
onKeyDown: (e: KeyboardEvent) => void
}
/** Text to render inside the trigger: placeholder, single label, or a joined
* multi-select summary. */
triggerLabel: ReadSignal<string>
/** Whether a selection exists (drive showing/hiding the clear button). */
hasValue: ReadSignal<boolean>
/** The filter input rendered inside the popup, above the listbox. */
input: {
type: 'text'
role: 'combobox'
autocomplete: 'off'
'aria-autocomplete': 'list'
'aria-expanded': ReadSignal<boolean>
'aria-controls': string
'aria-activedescendant': ReadSignal<string | undefined>
id: string
value: ReadSignal<string>
'data-scope': 'searchable-select'
'data-part': 'input'
onInput: (e: Event) => void
onKeyDown: (e: KeyboardEvent) => void
}
positioner: {
'data-scope': 'searchable-select'
'data-part': 'positioner'
style: string
}
content: {
role: 'listbox'
id: string
'aria-labelledby': string
'aria-busy': ReadSignal<'true' | undefined>
'aria-multiselectable': ReadSignal<'true' | undefined>
tabindex: -1
'data-state': ReadSignal<'open' | 'closed'>
'data-status': ReadSignal<AsyncStatus>
/** The mutually-exclusive load projection (#265 finding 11); mirrors the
* top-level `loadState` signal. See `combobox`'s `LoadProjection`. */
'data-load-state': ReadSignal<LoadProjection>
'data-scope': 'searchable-select'
'data-part': 'content'
}
/** The mutually-exclusive async load projection: `'initial-empty'` |
* `'loading'` | `'stale-results'` | `'success'` | `'error'` — never
* independent booleans that can contradict each other. Documents the
* stale-while-revalidate policy: `'stale-results'` means a fetch is in
* flight while the previous items are still mounted and selectable;
* `'error'` is reported the same whether or not stale items remain
* mounted underneath it. */
loadState: ReadSignal<LoadProjection>
/** Build the parts for an option by VALUE. The optional `index` is accepted
* for call-site convenience only — identity is value-keyed, so a reused row is
* never stale. */
item: (value: string, index?: number) => SearchableSelectItemParts
/**
* A labelled group's parts. The group names its `label` part through
* `aria-labelledby`; a consumer that renders the group WITHOUT that label
* passes `{ hasLabel: false }` so it never names an element that does not
* exist (an unlabelled `role="group"` is valid; a broken idref is not — the
* `dialog` `hasDescription` rule, #268). Default: true.
*/
group: (id: string, options?: { readonly hasLabel?: boolean }) => SearchableSelectGroupParts
/** Clear-selection trigger. Render only when `hasValue` is true. */
clear: {
type: 'button'
'aria-label': string
tabindex: -1
'data-scope': 'searchable-select'
'data-part': 'clear'
onClick: (e: MouseEvent) => void
}
/** Polite live region announcing the no-results / result count once the
* list is settled (`loadState` is `'success'` or `'initial-empty'`), the
* error text on `'error'`, and nothing while a fetch is in flight
* (`'loading'` / `'stale-results'`). */
liveRegion: {
role: 'status'
'aria-live': 'polite'
'aria-atomic': 'true'
'data-scope': 'searchable-select'
'data-part': 'live-region'
text: ReadSignal<string>
}
/** Empty-state container. `hidden` is false only when the SETTLED filtered
* list is empty — never while loading, revalidating, or after a failed
* fetch (the live region / an error slot own those). */
empty: {
'data-scope': 'searchable-select'
'data-part': 'empty'
hidden: ReadSignal<boolean>
}
}
SearchableSelectState from @llui/components/patterns/searchable-select
export interface SearchableSelectState {
/** Whether the popup is open. Mirrors `combobox.open`; kept at the top level
* so consumers can read it without reaching into the nested machine. */
open: boolean
/** The underlying combobox machine state. `value` is the source of truth for
* the selection; `inputValue` is the filter (never the committed value). */
combobox: ComboboxState
/** When opening, seed the filter with the selected label (and the consumer
* should select-all). When false (default) the filter opens empty. */
prefillFilter: boolean
/** Trigger placeholder shown when nothing is selected. */
placeholder: string
/** Separator used to join multiple selected labels in the trigger. */
separator: string
}
Constants
searchableSelect from @llui/components/patterns/searchable-select
const searchableSelect
@llui/components/styles
Functions
chipHue() from @llui/components/styles
The hue for a category value — stable across sessions, machines and releases, never inside a {@link RESERVED_HUE_ARCS} band, and always one of {@link CHIP_HUES}.
Feed it to --chip-hue; the chip's fill and ink are derived from that one
number by the recipe.
function chipHue(value: string): number
chipHueAt() from @llui/components/styles
The hue for the index-th member of an ORDERED set, walked by a golden-angle
stride so that neighbours in the sequence are far apart on the wheel.
This is where --chart-1..5 and the chip scale agree: a chart with more
series than the five chart tokens define needs exactly this — an unbounded,
reserved-hue-respecting categorical scale allocated by position rather than by
name. Wraps (and repeats) past {@link CHIP_HUE_SLOT_COUNT}; a negative or
fractional index is normalised rather than rejected, because the caller is
usually an array index and a throw there would be a worse failure than a
repeat.
function chipHueAt(index: number): number
createVariants() from @llui/components/styles
function createVariants<V extends VariantRecord>(config: VariantConfig<V>): (props?: VariantProps<V>) => string
cx() from @llui/components/styles
Concatenate class strings, filtering falsy values.
function cx(...classes: ClassValue[]): string
isReservedHue() from @llui/components/styles
Whether hue falls inside a {@link RESERVED_HUE_ARCS} band.
function isReservedHue(hue: number): boolean
Types
ClassValue from @llui/components/styles
export type ClassValue = string | false | null | undefined
ThemeToken from @llui/components/styles
export type ThemeToken = keyof ThemeTokens
VariantProps from @llui/components/styles
export type VariantProps<V extends VariantRecord> = {
[K in keyof V]?: keyof V[K]
}
VariantRecord from @llui/components/styles
export type VariantRecord = Record<string, Record<string, string>>
Interfaces
ReservedHueArc from @llui/components/styles
A hue arc withheld from the categorical scale because a reader assigns it STATUS meaning. Half-widths are categorical: the band spans the hues a viewer would name as that colour family, which is a coarser and more robust boundary than a perceptual-difference threshold at the chip's low chroma.
export interface ReservedHueArc {
/** What the band protects. */
readonly name: 'crit' | 'warn' | 'ok'
/** Centre hue, degrees. */
readonly center: number
/** Degrees either side of {@link center} that are excluded. */
readonly halfWidth: number
}
ThemeTokens from @llui/components/styles
export interface ThemeTokens
extends ThemeBaseTokens, ThemeDerivedTokens, ThemeScaleTokens, ThemeChipTokens {}
VariantConfig from @llui/components/styles
export interface VariantConfig<V extends VariantRecord> {
base: string
variants: V
defaultVariants?: { [K in keyof V]?: keyof V[K] }
compoundVariants?: Array<{ [K in keyof V]?: keyof V[K] } & { class: string }>
}
Constants
CHIP_HUE_SLOT_COUNT from @llui/components/styles
How many distinct chip colours the scale has.
Twelve is the largest count whose slots stay at least 21 degrees apart on the 252 degrees the reserved arcs leave, and 21 degrees is roughly where two chip FILLS stop being reliably distinguishable (their chroma after the mix is only ~0.062–0.081, so a hue step buys little OKLab distance — the more saturated ink carries the rest of the signal). Raising it makes two categories that "have different colours" look the same, which is worse than an honest collision: at twelve slots, values that collide are IDENTICAL rather than subtly-different-but-not-really.
const CHIP_HUE_SLOT_COUNT
CHIP_HUES from @llui/components/styles
The scale itself: {@link CHIP_HUE_SLOT_COUNT} hues, evenly spaced by ARC LENGTH over the unreserved hues and centred in their slots. Even spacing by arc length is what makes the minimum separation a guarantee — skipping a reserved band only ever ADDS degrees between two neighbours.
"Centred in their slots" is centring in the concatenated ARC space and carries
no promise about distance to a reserved edge: 131.5 lands 0.5 degrees off
ok's edge. See the reserved-hues note at the top of this file.
const CHIP_HUES: readonly number[]
RESERVED_HUE_ARCS from @llui/components/styles
The three status families, in ascending hue. Non-overlapping by construction
and asserted to be so by packages/components/test/styles/chip-hue.test.ts —
{@link CHIP_HUES} derives the allowed arcs by walking the gaps between them,
which is only correct while they are disjoint.
crit is centred on --destructive (27.325 light, 22.216 dark; the band
contains both). warn and ok are the amber and green a traffic light
trains readers to expect.
const RESERVED_HUE_ARCS: readonly ReservedHueArc[]
@llui/components/chart
Functions
chartForcedColorPatterns() from @llui/components/chart
Five SVG <pattern> fills, one per {@link ChartSeriesCue} name — the SAME
cue vocabulary data-series-cue already carries on every mark. A skin's
forced-colors rule reads fill: var(--llui-chart-fill-dot) (say), which
connect() below sets to THIS chart's own url('#<id>:pattern-dot') CanvasText, so a bar/area mark gets a REAL redundant cue: fill: CanvasText alone makes every bar/area series under forced-colors: active
paint identically, since forced colors flattens author colors uniformly
(#264) — a dash pattern (already used for LINE marks) does nothing for a
filled shape's fill.
Pure, static, stateless markup — not part of connect()'s REACTIVE parts
(it never varies with data or state), but keyed by the SAME id connect()
takes, and it MUST be. A fixed, globally-shared id (id="llui-chart-pattern- dot" on every chart instance) resolves a url(#...) reference to
WHICHEVER same-named element the browser's id table happens to return —
measured in real Chromium: when the first such element in the document sits
inside a display:none ancestor (one hidden chart earlier on the page),
every OTHER, visible chart's pattern-filled marks paint nothing, because a
referenced paint server inside a non-rendered subtree does not paint even
for a consumer outside it (#264 review item 3). Per-instance ids close the
whole bug class rather than depending on document order: each chart only
ever references its OWN copy. Place it once as the first child of
parts.svg in either skin, passing the SAME id given to connect().
function chartForcedColorPatterns(id: string): Mountable
connect() from @llui/components/chart
function connect(state: ReadSignal<ChartState>, send: Send<ChartMsg>, opts: ChartConnectOptions): ChartParts
geometry() from @llui/components/chart
The derived geometry for a state. Exported so a consumer can measure or test a chart without mounting one.
function geometry(state: ChartState): ChartGeometry
init() from @llui/components/chart
function init(opts: ChartInit): ChartState
update() from @llui/components/chart
function update(state: ChartState, msg: ChartMsg): [ChartState, never[]]
Types
ChartCoord from @llui/components/chart
export type ChartCoord = 'cartesian' | 'polar'
ChartDomain from @llui/components/chart
How the INDEPENDENT axis is allocated — and therefore which of a chart's two normalized coordinates carries the magnitude.
value gives every category an equal slot and reads magnitude off v: a
column, a line, a radar spoke. share allocates each category a slot in
PROPORTION to its value and lets v span the whole depth, so the magnitude
has moved onto u.
That one move is the whole of a pie chart, which is why neither a 'pie'
mark type nor a second projection exists here. Under coord: 'polar' a
share-allocated bar IS a pie or donut wedge; under coord: 'cartesian' the
SAME state is a single full-width 100%-share bar, which is the honest
cartesian reading of the same numbers. Switching coord still re-projects
one dataset rather than swapping charts — see
docs/adr/0003-charts-project-rather-than-branch.md.
export type ChartDomain = 'value' | 'share'
ChartMsg from @llui/components/chart
export type ChartMsg =
/** @intent("Switch between cartesian and polar projection") */
| { type: 'setCoord'; coord: ChartCoord }
/** @intent("Allocate the independent axis by equal slots or by share of the total") */
| { type: 'setDomain'; domain: ChartDomain }
/** @intent("Set the row under the cursor, or clear it with null") */
| { type: 'setActive'; index: number | null }
/** @intent("Move the keyboard cursor along the rows by delta, wrapping") */
| { type: 'moveActive'; delta: number }
/** @intent("Move the keyboard cursor to the first row") */
| { type: 'firstActive' }
/** @intent("Move the keyboard cursor to the last row") */
| { type: 'lastActive' }
/** @intent("Isolate one series, or show them all again with null") */
| { type: 'setActiveSeries'; key: string | null }
/** @intent("Replace the plotted rows") */
| { type: 'setRows'; rows: ChartRow[] }
/** @intent("Pin the low end of the value axis, or derive it again with null") */
| { type: 'setMin'; value: number | null }
/** @intent("Pin the high end of the value axis, or derive it again with null") */
| { type: 'setMax'; value: number | null }
/** @intent("Stack the series instead of overlaying them") */
| { type: 'setStacked'; stacked: boolean }
/** @intent("Set the viewBox size in user units") */
| { type: 'setSize'; width: number; height: number }
/** @intent("Set the polar inner radius as a fraction of the outer") */
| { type: 'setInnerRadius'; value: number }
/** @intent("Swap the cartesian axes for a horizontal bar chart") */
| { type: 'setHorizontal'; horizontal: boolean }
ChartSeriesCue from @llui/components/chart
export type ChartSeriesCue =
| 'solid'
| 'short-dash'
| 'dot'
| 'long-dash'
| 'dash-dot'
| 'grid'
| 'cross-hatch'
MarkType from @llui/components/chart
export type MarkType = 'line' | 'area' | 'bar'
Interfaces
ChartCategoryTick from @llui/components/chart
export interface ChartCategoryTick {
index: number
label: string
x: number
y: number
anchor: 'start' | 'middle' | 'end'
baseline: 'auto' | 'middle' | 'hanging'
active: boolean
}
ChartConnectOptions from @llui/components/chart
export interface ChartConnectOptions {
/** Base id; the title and description ids derive from it. */
id: string
/**
* Whether the consumer renders the `desc` part (default: true). When false,
* the svg's `aria-labelledby` names only the title, never an element that
* does not exist (the `dialog` `hasDescription` rule, #268). The TITLE is
* the image's name — required for `role="img"` — and is always referenced.
*/
hasDescription?: boolean
}
ChartGeometry from @llui/components/chart
export interface ChartGeometry {
frame: Frame
domain: Domain
band: Band
/**
* The proportional allocation of the independent axis under
* `ChartState.domain === 'share'`, and `null` otherwise. It is the axis
* itself, not a decoration: the pointer hit test reads it instead of `band`.
*
* With more than one bar series each ring gets its OWN allocation from its
* own values — that is what makes a nested donut's rings each proportional —
* so a single `u` names different rows on different rings and the pointer
* has to follow one of them. It follows the FIRST bar series, which is the
* whole story for the single-series pie that is the common case.
*/
slices: ShareSlice[] | null
projection: Projection
marks: ChartMark[]
vertices: ChartVertex[]
gridLines: ChartGridLine[]
categoryTicks: ChartCategoryTick[]
/** Anchor for the tooltip, in user units. `null` when nothing is active. */
tooltipAt: { x: number; y: number } | null
tooltipRows: ChartTooltipRow[]
}
ChartGridLine from @llui/components/chart
export interface ChartGridLine {
value: number
label: string
d: string
x: number
y: number
anchor: 'start' | 'middle' | 'end'
baseline: 'auto' | 'middle' | 'hanging'
}
ChartInit from @llui/components/chart
export interface ChartInit {
series: readonly ChartSeries[]
rows?: readonly ChartRow[]
coord?: ChartCoord
domain?: ChartDomain
label?: string
description?: string
width?: number
height?: number
padding?: Partial<ChartPadding>
min?: number
max?: number
tickCount?: number
stacked?: boolean
innerRadius?: number
horizontal?: boolean
}
ChartMark from @llui/components/chart
A drawn mark: one series, one path.
export interface ChartMark {
seriesKey: string
seriesCue: ChartSeriesCue
label: string
mark: MarkType
/** The SVG path `d`. */
d: string
/** Row index, for a `bar`/wedge — `null` for a whole-series line or area. */
index: number | null
active: boolean
dimmed: boolean
}
ChartPadding from @llui/components/chart
export interface ChartPadding {
top: number
right: number
bottom: number
left: number
}
ChartParts from @llui/components/chart
export interface ChartParts {
root: {
'data-scope': 'chart'
'data-part': 'root'
// Spelled out rather than aliased: `scripts/test/registry-attrs.test.ts`
// reads part-bag VALUES syntactically, and an imported alias reads as an
// open type it declines to give a verdict on.
'data-coord': ReadSignal<'cartesian' | 'polar'>
'data-domain': ReadSignal<'value' | 'share'>
'data-active': ReadSignal<'' | undefined>
/** Per-instance forced-colors fill custom properties — see `connect()`'s
* own doc for why these must be per-chart rather than a shared global. */
style: string
}
/**
* The `<svg>`. `role="img"` with a name and description is what a screen
* reader announces; the real data path is {@link ChartParts.table}, because
* AT support for the WAI-ARIA graphics roles is still thin enough that a
* chart relying on them alone is unreadable.
*
* `tabindex="0"` makes the plot itself the keyboard target: arrows walk the
* rows, Home/End jump to the ends, Escape clears. There is no
* `aria-activedescendant` — it is not valid on `role="img"`, and the tooltip
* live region is what actually announces the cursor.
*/
svg: {
'data-scope': 'chart'
'data-part': 'svg'
role: 'img'
'aria-labelledby': string
viewBox: ReadSignal<string>
tabindex: 0
onKeyDown: (e: KeyboardEvent) => void
onPointerMove: (e: PointerEvent) => void
onPointerLeave: (e: PointerEvent) => void
onBlur: (e: FocusEvent) => void
}
title: { id: string; 'data-scope': 'chart'; 'data-part': 'title' }
desc: { id: string; 'data-scope': 'chart'; 'data-part': 'desc' }
/** The visually-hidden `<table>` fallback. Render it with the rows below. */
table: {
'data-scope': 'chart'
'data-part': 'table'
'aria-label': ReadSignal<string>
}
/** Tooltip ATTRIBUTES — spreadable, with its own reactive `hidden`. */
tooltip: {
'data-scope': 'chart'
'data-part': 'tooltip'
role: 'status'
'aria-live': 'polite'
hidden: ReadSignal<boolean>
style: ReadSignal<string>
}
/** A `<g>` stacking layer. Static — spread it on each layer group. */
layer: { 'data-scope': 'chart'; 'data-part': 'layer' }
/** A value gridline. Pass `d` from the {@link ChartGridLine}. */
grid: { 'data-scope': 'chart'; 'data-part': 'grid' }
/** An axis label — value ticks and category names. */
axisLabel: { 'data-scope': 'chart'; 'data-part': 'axis-label' }
/** Attributes for one vertex dot on a line or area series. */
dotProps: (vertex: ChartVertex) => {
'data-scope': 'chart'
'data-part': 'dot'
'data-series': string
'data-series-cue': ChartSeriesCue
'data-active': '' | undefined
cx: number
cy: number
}
legendItem: (key: string) => {
type: 'button'
'data-scope': 'chart'
'data-part': 'legend-item'
'data-series': string
'data-series-cue': ReadSignal<ChartSeriesCue>
'data-dimmed': ReadSignal<'' | undefined>
'aria-pressed': ReadSignal<boolean>
onClick: (e: MouseEvent) => void
}
/** The legend's colour chip. Spreadable onto its own element (a `<span>` in
* both skins) so a forced-colors rule can key off `data-series-cue` the
* SAME way a mark does — a legend swatch that only carries `--mark-color`
* paints identically for every series once forced colors flattens author
* colour, which is the accessibility gap #264 review item 7 names. */
legendSwatch: (key: string) => {
'data-scope': 'chart'
'data-part': 'legend-swatch'
'data-series-cue': ReadSignal<ChartSeriesCue>
}
/** Attributes for one drawn mark. Spread onto a `<path>` and pass `d`. */
markProps: (mark: ChartMark) => {
'data-scope': 'chart'
'data-part': 'mark'
'data-mark': 'line' | 'area' | 'bar'
'data-series': string
'data-series-cue': ChartSeriesCue
'data-active': '' | undefined
'data-dimmed': '' | undefined
d: string
onPointerEnter: (e: PointerEvent) => void
}
// Derived geometry, as signals the view renders with `each`.
marks: ReadSignal<ChartMark[]>
vertices: ReadSignal<ChartVertex[]>
gridLines: ReadSignal<ChartGridLine[]>
categoryTicks: ReadSignal<ChartCategoryTick[]>
tooltipRows: ReadSignal<ChartTooltipRow[]>
activeLabel: ReadSignal<string>
rows: ReadSignal<ChartRow[]>
series: ReadSignal<ChartSeries[]>
}
ChartRow from @llui/components/chart
One row of the independent axis: a category and its value per series.
export interface ChartRow {
label: string
values: Record<string, number>
}
ChartSeries from @llui/components/chart
export interface ChartSeries {
/** Stable key. Also the `--color-<key>` variable a skin reads. */
key: string
/** Human label, used by the legend, the tooltip and the a11y table. */
label: string
mark: MarkType
/** Ignored by a polar projection, which supports `linear` only. */
curve?: Curve
}
ChartState from @llui/components/chart
export interface ChartState {
coord: ChartCoord
/**
* Equal slots per category (`value`), or slots proportional to the value
* (`share`). See {@link ChartDomain}; `share` + `coord: 'polar'` is a pie.
*
* Under `share` only `bar` series are drawn: a line or an area along an axis
* whose spacing already encodes the magnitude would plot each point at a
* position that means something other than where it sits, so they are
* DECLINED rather than approximated — the same call `polarProjection` makes
* about `monotone`. `stacked` is ignored for the same reason, each category
* being its own slice already.
*/
domain: ChartDomain
series: ChartSeries[]
rows: ChartRow[]
/** Accessible name for the whole chart. */
label: string
/** Longer description, announced with the name. */
description: string
width: number
height: number
padding: ChartPadding
/**
* Explicit value-axis bounds. UNBOUNDED-CAPABLE in the sense of `finiteBound`
* (#177): "derive it" is spelled by OMITTING the key, never by `undefined`,
* `null` or `±Infinity` — only the omission survives a JSON round trip
* unchanged.
*/
min?: number
max?: number
tickCount: number
/** Stack series instead of overlaying them. */
stacked: boolean
/** Row index under the pointer or the keyboard cursor. */
activeIndex: number | null
/** Series key the legend has isolated, or `null` for all. */
activeSeries: string | null
/** Polar only: inner radius as a fraction of the outer (0 = pie, .5 = donut). */
innerRadius: number
/** Cartesian only: swap the axes for a horizontal bar chart. */
horizontal: boolean
}
ChartTooltipRow from @llui/components/chart
export interface ChartTooltipRow {
seriesKey: string
label: string
value: number
/**
* The row's fraction of its series' total under a share domain — what a pie
* tooltip shows as a percentage — and `null` under a value domain, where a
* share of an axis that may cross zero is not defined.
*/
share: number | null
}
ChartVertex from @llui/components/chart
A vertex on a line or area series, for the dot layer and hit feedback.
export interface ChartVertex {
seriesKey: string
seriesCue: ChartSeriesCue
index: number
x: number
y: number
active: boolean
}
Constants
chart from @llui/components/chart
const chart
@llui/components/icon
Functions
icon() from @llui/components/icon
Render one Iconify glyph as a real <svg>.
name is prefix:name ('lucide:star', 'simple-icons:github'; a bare
name is a Lucide glyph) or a ReadSignal<string> of one, in which case the glyph
follows the signal. props spread onto the <svg>; class is passed through
as given, so a caller sizes and colours the glyph from there.
function icon(name: string | ReadSignal<string>, props?: ElProps): Mountable
loadIcon() from @llui/components/icon
Resolve one glyph, batched: one HTTP request per PREFIX per tick, not one per icon. A page rendering a dozen chevrons asks Iconify once; the batch is flushed on a macrotask so every icon mounted during the same view build joins it. Exposed so a consumer can warm the cache ahead of a mount.
function loadIcon(name: string): Promise<IconData | null>
Interfaces
IconData from @llui/components/icon
One resolved glyph: its body markup and the box it was drawn in.
export interface IconData {
body: string
width: number
height: number
}
Constants
iconConfig from @llui/components/icon
Where glyphs are fetched from. Point this at a self-hosted Iconify to drop the third-party CDN; the path shape is identical.
const iconConfig