npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@script-development/ui-inputs

v0.13.0

Published

Headless, themeable Vue 3 UI components — form inputs (field, label, error, text/number/date input, textarea, select, checkbox/radio/switch) plus keyboard-correct interactive controls (pressable, disclosure) — styled entirely through --ui-* CSS custom pro

Readme

@script-development/ui-inputs

Headless, themeable Vue 3 UI components — form inputs plus the interactive controls that carry no value (Pressable, Disclosure) — styled entirely through --ui-* CSS custom properties.

Part of the Armory ui-* family. The components ship no token vocabulary and no colour literal — you map your design tokens onto the --ui-* contract once, and every component follows. Kendo-soft or brutalist, light or dark, from one component set.

Install

npm install @script-development/ui-inputs

Peer dependency: vue@^3.5. Import the stylesheet once (e.g. in your entry):

import '@script-development/ui-inputs/style.css';

Components

| Component | Purpose | | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | FormField | Label + error + required-marker composition wrapper (error-as-prop) | | FormLabel / FormError | The atoms FormField composes | | TextInput | Native text / email / password / search / tel / url input | | NumberInput | Native number input; owns the NaNnull empty-value guard | | DateInput | Native date input | | Textarea | Native textarea with rows | | Checkbox | Native checkbox, visually restyled; non-nullable boolean model, indeterminate as a visual prop | | CheckboxGroup | Fieldset/legend group of checkboxes — models an array of option ids in options order | | Switch | The checkbox chassis with role="switch" — an on/off toggle with a themeable track + thumb | | RadioGroup | Fieldset/legend radio group (role="radiogroup") — models T['id'] \| null; native roving focus and arrow-key selection | | SingleSelect | Accessible button-triggered listbox over @floating-ui/vue, generic over your option type | | Combobox | Accessible searchable/filtering single-select — a text input that filters the listbox as you type; exposes an imperative focus() handle | | MultiSelect | Accessible multi-value select — models an array of option ids; toggle-in-place listbox that stays open on commit, inline chip bar with per-chip remove | | MultiCombobox | Accessible searchable multi-value select — MultiSelect's array model + chips with Combobox's filter-as-you-type input as the trigger | | Pressable | A real <button> for an interactive control that carries no value — the keyboard-correct replacement for <span @click> / <div @click>; optional aria-pressed toggle mode | | Disclosure | Show/hide a region from a real <button> carrying aria-expanded + aria-controls; optionally wrapped in a real heading — the replacement for <h2 @click> |

<FormField id="fruit" label="Fruit" :error="errors.fruit" #default="{controlId, describedby, invalid}">
    <SingleSelect :id="controlId" v-model="fruit" :options="fruits" label="name" :invalid="invalid" :describedby="describedby" />
</FormField>

Horizontal field layout

FormField stacks the label above the control by default. Pass orientation="horizontal" to place the label in a fixed left column instead — control on the right, error beneath the control. An unlabelled field ignores orientation and keeps the vertical default — there is no label to give a column to, so the control uses the full width. Set the column width with --ui-field-label-width (default 12rem) and the label's cross-axis alignment with --ui-field-label-align (default start). The field carries .is-horizontal, and the slot content sits inside a .ui-field__control wrapper (display: contents when vertical, so the default layout is unchanged; a selector like .ui-field > .ui-control now needs .ui-field .ui-control). Below 48rem (768px) a horizontal field renders vertical — the label column has no room on a phone, so the sheet collapses it to the default shape at that fixed breakpoint (a custom property cannot drive a media query, so it is not a --ui-* var):

<FormField
    id="fruit"
    label="Fruit"
    orientation="horizontal"
    :error="errors.fruit"
    #default="{controlId, describedby, invalid}"
>
    <SingleSelect :id="controlId" v-model="fruit" :options="fruits" label="name" :invalid="invalid" :describedby="describedby" />
</FormField>

The select family's shared extras

Per-option content — the #option scoped slot. All four selects render each option's plain display string by default; the option slot replaces that content with your own (colour swatches, icons, rich labels). The payload is {option, index, selected, active} — typed against your option type T. Highlight and selection chrome (.is-active, aria-selected) stay on the option row, outside the slot, so custom content never re-creates them:

<SingleSelect id="label" v-model="labelId" :options="labels" label="name">
    <template #option="{option}">
        <span class="swatch" :style="{background: option.color}" /> {{ option.name }}
    </template>
</SingleSelect>

Muted options. mutedOptions (an array of option ids) renders the matching options visually muted (.is-muted, themed by --ui-option-text-muted). Muted is not disabled: muted options stay committable and stay in the keyboard path — use it for de-emphasis ("already assigned", "archived"), never for gating.

The open menu is promoted to the top layer. The listbox carries the Popover API's popover attribute, so while open the browser paints it in the top layer — above everything, clipped by nothing. An overflow: hidden or stacking-context ancestor of the trigger can no longer cut it off, which is the whole of KD-1136.

Crucially the menu is never moved in the DOM. It stays inside the control, so:

  • your --ui-* map keeps applying, wherever you declared it — an app-shell class or a <style scoped> block reaches the menu exactly as it reaches the trigger;
  • a control inside a shadow root keeps its encapsulated styles on the menu;
  • click-outside stays honest, because the menu really is inside the control.

Requires the Popover API: Chrome 114+, Safari 17+, Firefox 125+ (Baseline since April 2024).

Home/End + the empty-state announcement. While the listbox is open, Home jumps the keyboard highlight to the first option and End to the last — one shared keyboard skeleton, all four selects (on the input-triggered Combobox/MultiCombobox this deliberately trades away caret jumps while the popup is open; both readings are APG-sanctioned). And the empty state ("No options" / emptyText) is announced, not just painted: every select carries a persistent, visually-hidden aria-live="polite" region that speaks the empty text the moment the (filtered) list drains to nothing — which matters most on the filterable components, where typing can drain the list silently.

The committing clear entry (SingleSelect / Combobox). clearLabel renders a committing entry above the options — choosing it commits null and closes, exactly like choosing an option. It lives outside the option index space: its own keyboard slot between "nothing highlighted" and the first option, its own ${id}-clear id for aria-activedescendant, and aria-selected="true" while the model is null. Pair it with emptyDisplayValue — the string the trigger (or the Combobox input) renders as a value when the model is null ("No sprint (backlog)") instead of the muted placeholder / blank input. The entry is danger-toned by default (--ui-clear-text, chains to --ui-danger-text).

<SingleSelect
    id="sprint"
    v-model="sprintId"
    :options="sprints"
    label="name"
    clear-label="No sprint"
    empty-display-value="No sprint (backlog)"
/>

Combobox

Combobox shares SingleSelect's generic contract (:options, label, v-model, alphabeticalSort, optionsLabel, emptyText, invalid, describedby, required) but the trigger is a text <input>. As the user types, the listbox filters to options whose label contains the query (labelOf(o).toLowerCase().includes(query)), then the same optional alphabetical sort applies; an empty query shows everything. Arrow keys and Enter navigate/commit the filtered list. On commit the input shows the chosen label; on Escape, Tab, or a click outside the control the input snaps back to the committed label so a half-typed non-match never lingers.

Browse-to-change. A query equal to the committed rendering (the committed option's label, or emptyDisplayValue on a committed null) does not filter — opening a filled combobox shows the full list, and the committed label sits fully selected so the first keystroke replaces it and starts a fresh filter. The filter engages only once the query diverges from the committed rendering.

<FormField id="city" label="City" :error="errors.city" #default="{controlId, describedby, invalid}">
    <Combobox ref="cityBox" :id="controlId" v-model="city" :options="cities" label="name" :invalid="invalid" :describedby="describedby" />
</FormField>

Imperative focus handle. Combobox exposes focus() via defineExpose, so a parent can move DOM focus onto the input programmatically (cityBox.value?.focus()) — the piece a focus-trap / command-palette integration needs.

MultiSelect

MultiSelect shares the family's generic contract (:options, label, alphabeticalSort, optionsLabel, emptyText, invalid, describedby, required) but models an array of option ids (v-model="tagIds"). Committing an option — Enter or click — toggles its membership and the listbox stays open, so picking several values is one open/close cycle, not five. The committed values render as an inline chip bar inside the control; every chip carries its own remove button (aria-label="${removeLabel} <label>"removeLabel defaults to 'Remove' and is a prop so Dutch territories can localise it, like optionsLabel), and Backspace on the focused trigger pops the last committed value. There is no text input and no filtering — that stays Combobox's job.

<FormField id="tags" label="Tags" :error="errors.tags" #default="{controlId, describedby, invalid}">
    <MultiSelect :id="controlId" v-model="tagIds" :options="tags" label="name" :invalid="invalid" :describedby="describedby" />
</FormField>

The listbox is marked aria-multiselectable="true"; aria-selected marks committed membership (selected options remain listed, toggled in place), while the keyboard pointer stays conveyed by aria-activedescendant — the same position-keyed ${id}-opt-${index} option-id scheme as SingleSelect (see below), so an unusual option.id can never break the IDREF linkage. An id whose option has not loaded yet (async options) stays in the model but renders no chip until it resolves.

Chips theme through --ui-chip-bg / --ui-chip-text / --ui-chip-radius / --ui-chip-pad, each defaulting to an existing resting token (--ui-option-bg-active, --ui-control-text, --ui-option-radius) — neutral out of the box, remap to opt in.

MultiCombobox

MultiCombobox is MultiSelect's searchable sibling: the same T['id'][] model, toggle-in-place commits, chip bar (with removeLabel), and aria-multiselectable listbox — but the trigger is Combobox's filter-as-you-type text <input> (the APG-canonical input-as-trigger combobox; an in-popup search field has an awkward focus story). The list opens on focus, click, or typing.

The multi-specific query choreography differs from Combobox, because there is no single committed label to snap to. The input rests empty + placeholder; on every toggle-commit the popup stays open, the query clears so the full list is re-offered, and DOM focus returns to the input — so picking several values is type, Enter, type, Enter. Escape, Tab, or a click outside closes and clears the query. Backspace with an empty query pops the last chip (with text in the query it stays native editing). Since the input's own value is the query, the committed selection is additionally conveyed to assistive tech through an aria-describedby summary (the input-as-trigger analogue of MultiSelect's hidden value span).

<FormField id="tags" label="Tags" :error="errors.tags" #default="{controlId, describedby, invalid}">
    <MultiCombobox :id="controlId" v-model="tagIds" :options="tags" label="name" :invalid="invalid" :describedby="describedby" />
</FormField>

Like Combobox it exposes an imperative focus() handle. clearLabel / emptyDisplayValue deliberately do not transfer — an empty array is the multi "nothing", so there is no null to commit and no committed label to name.

Checkbox family

Checkbox, CheckboxGroup, Switch, and RadioGroup all sit on a native input chassis — a real <input type="checkbox"> / <input type="radio"> restyled with appearance: none, never a div-with-role — so keyboard and assistive-tech semantics come from the platform.

Checkbox models a non-nullable boolean (v-model="accepted") — a checkbox is never "empty", unchecked IS false. The label renders inline via the label prop (the default slot overrides it for rich content); the whole row is the hit target. indeterminate is a prop mirrored onto the element's DOM property (drawn as a dash) — purely visual, it never touches the model. Native required is never set; aria-required is the conveyance, like the whole family. Undeclared attrs (name, data-*, …) fall through to the input, not the label root.

Switch is the same chassis with role="switch" on the native checkbox — the native checked state maps to aria-checked (HTML-AAM), so the component never sets it by hand. Same non-nullable boolean model; distinct track + thumb surface on --ui-switch-* vars.

CheckboxGroup renders a chrome-less <fieldset> with a <legend> (the label prop) and one checkbox per option (optionLabel is the family's property-name-or-getter display resolver — renamed from the selects' label because label is the legend here). It models T['id'][] kept in options order, not click order; an id whose option has not arrived yet (async options) is preserved at the tail. Error wiring is one story: aria-describedby (and aria-invalid) live on the fieldset only — members mirror the invalid styling but never repeat the IDREF. Because ARIA forbids aria-required on role=group, the required state is conveyed group-level through the legend: the visual * marker plus screen-reader-only text (requiredLabel, default '(required)', localisable).

RadioGroup is the same fieldset shape with native radios sharing one generated name (the group id) — the browser provides the roving tabindex and arrow-key selection, the component hand-rolls neither and only mirrors the model from change. It models T['id'] | null (null = nothing selected, the SingleSelect shape). The fieldset carries role="radiogroup", which — unlike plain group — legitimately carries aria-required, so here the attribute is the group-level conveyance.

<Checkbox id="terms" v-model="accepted" label="Accept the terms" />
<Switch id="notify" v-model="notifications" label="Email notifications" />
<CheckboxGroup id="toppings" v-model="toppingIds" :options="toppings" option-label="name" label="Toppings" />
<RadioGroup id="size" v-model="size" :options="sizes" option-label="name" label="Size" />

The family themes through --ui-check-* (box size, border — shorthand-valued like --ui-control-border-width — checked fill, mark colour, radius, control↔label gap, group item spacing) and --ui-switch-* (track width/height/radius, checked/unchecked track colours, thumb size/colour). Every colour default derives from an existing resting token (--ui-control-bg, --ui-control-border-color, --ui-control-border-open), so an existing --ui-* token map themes the checkbox family with no new mappings — remap to opt in.

Interactive controls that are not form inputs

Pressable and Disclosure carry no value and belong to no field. They exist because the single most common accessibility defect in a Vue codebase is a click handler on an element that cannot receive one: a bare <span @click> is invisible to the keyboard and announces no role (WCAG 2.1.1 Keyboard and 4.1.2 Name, Role, Value, both Level A). No linter and no input component catches it.

Pressable renders a real <button type="button">. That is the whole design: focusability, Enter/Space activation, disabled semantics and forced-colors treatment come from the platform, correctly, for free — hand-rolled ARIA is the fallback, never the default. The chassis is chrome-less (transparent background, no border, no padding, inherited font), so swapping a <span @click> for a Pressable changes the semantics without repainting the control. It lays out as inline-flex — except on the tags whose own display their parent's layout algorithm requires, where the component keeps the tag's own: a clickable <tr> stays a table row.

<Pressable label="Show example" @click="showExample" />
<Pressable v-model:pressed="bold" label="Bold" />

v-model:pressed opts into toggle mode: the control conveys aria-pressed and flips it on activation. Left unbound the attribute is absent — a plain action button must not claim toggle semantics. (This is the one place the package sets an ARIA state by hand, and deliberately: unlike Switch, where role="switch" on a native checkbox lets the native checked state map to aria-checked, a <button> has no native pressed state, so aria-pressed is the only conveyance there is.) Initialise the ref you bind: defineModel cannot tell an unbound model from one bound to undefined, so a ref<boolean>() left uninitialised produces a control that renders no aria-pressed and silently never toggles — development warns when it sees a pressed binding holding undefined. Give an icon-only control an accessible name with aria-label — undeclared attrs fall through to the button, because here the root is the interactive element.

Both controls warn in development when they render with no accessible name. label is optional on Pressable and Disclosure alike and their slots may render empty, so nothing in the types stops a consumer producing a focusable, correctly-roled, unnamed control — a WCAG 4.1.2 (Level A) failure, and on Disclosure a trigger whose only content is the aria-hidden chevron. On mount each control checks that a name arrives by one of four routes — rendered content, aria-label, aria-labelledby or title — and console.warns once, naming all four, when none does. Rendered content is counted the way the accessible name is computed: an aria-hidden="true" subtree contributes nothing, so a control whose only content is a decorative icon still warns. Icon-only usage with aria-label is legitimate and stays silent. It takes aria-labelledby at its word rather than dereferencing the IDREF (the target may legitimately mount later), so a dangling reference passes it — axe is the layer that catches that one.

Every dev-only warning here is gated on process.env.NODE_ENV, which Vite and webpack substitute for you; rollup needs @rollup/plugin-replace. Where nothing substitutes it the package fails silent rather than warning — a library must not console.warn into a host it cannot identify, and a missing process global must not become a ReferenceError at mount.

The as escape hatch is discouraged. For the cases where a button genuinely cannot be used — a clickable <tr>, an element whose parent forbids interactive content — as renders another tag and the component hand-rolls the whole contract together: role="button", tabindex, Enter on keydown, Space on keyup (dispatching a real click, so your own @click still runs), and a disabled emulation (tabindex="-1" + aria-disabled, plus a click stop). Half a contract is worse than none — and the stop is the half that is easy to miss. as is matched case-insensitively, because <component :is> resolves tag names that way: as="BUTTON" renders a genuine native button and takes the native path with it. The native path is not protected by the browser here: Chromium withholds a click on a disabled <button> only for user activation, and a dispatchEvent still runs every listener on one (measured). The stop is therefore what keeps a fall-through @click off a disabled control on both paths, not a nicety the fallback needs and the button does not. It is a real guard in the component — preventDefault() plus stopImmediatePropagation(), in the capture phase — not a pointer-events: none in the stylesheet: that rule takes the control out of hit-testing altogether, so a pointer over it targets whatever sits behind it and an ancestor's @click fires. The capture phase is what makes the whole subtree inert: a bubble-phase stop on the root arrives only after a nested <a href> or <button @click> has already run. And preventDefault() is the half no propagation stop can supply — it withholds the element's own default action, which is what keeps a disabled as="a" from following its href. Never point as at an element the browser already activates (a[href], summary) — the component would hand-roll a role="button" and a second key-to-click translation on top of the ones the element already has, and development warns when you do.

Keys inside an as fallback belong to the child that has focus. Both key handlers check the event's origin and ignore anything that reached the root by bubbling, so a nested <input> keeps its spacebar and a nested <button> keeps its own Enter. Without that check every Space typed into an inline filter field is swallowed and converted into an activation of the row — the field cannot hold a space at all. The check is deliberately not applied to the click handler: a click targets the element under the pointer, so the ordinary <Pressable as="div"><span>Label</span></Pressable> shape legitimately reports a child as the target, and the same guard there would stop the row responding to the mouse. Keys follow focus; clicks follow the pointer. A disabled control is deaf to keys in the full sense — both handlers stop the event rather than returning, so a consumer's fall-through @keydown/@keyup does not run on it either; a disabled fallback keeps tabindex="-1", which is out of the tab order but still mouse-focusable. One consequence to plan for: the child's own click still bubbles, so a row with its own @click counts an activation when a nested button is pressed, by keyboard or mouse alike — put @click.stop on the child where that is not what you want.

Disclosure shows and hides a region from a real button carrying aria-expanded and aria-controls, paired to the panel by the stable ${id}-panel derived id. Pass headingLevel and the trigger is wrapped in a real <h2><h6>: the heading contains the button, it never behaves as one — a heading with a click handler on it is the exact defect this replaces. Omit headingLevel where the disclosure is not a section heading and the wrapper stays a plain div, leaving the document outline untouched.

<Disclosure id="details" label="Details" :heading-level="2">
    <p>Anything at all.</p>
    <template #trigger>Rich <strong>trigger</strong> content</template>
</Disclosure>

Expansion is UI state, not form data, so — unlike the value-carrying components, whose model is required — Disclosure works uncontrolled out of the box; bind v-model:expanded only when the parent needs to drive or observe it. The panel is always mounted and hidden with v-show, never v-if: aria-controls is an IDREF, and one pointing at nothing names no relationship for assistive tech to expose — the reference has to resolve in both states. Wrap genuinely expensive panel content in your own v-if inside the slot.

Both theme through --ui-pressable-* (gap, padding, min-height, background, text, font-size, line-height, border width/colour, radius, disabled text, and the [aria-pressed="true"] pair) and --ui-disclosure-panel-pad / --ui-disclosure-panel-gap. Every default is either neutral or derived from an existing resting token, with one deliberate exception: --ui-pressable-bg-pressed defaults to --ui-option-bg-active rather than to a no-op, because a toggle whose state reaches only a screen reader is invisible to everyone else.

Theming

Every visual rule keys on a --ui-* custom property — colours and structure (--ui-control-border-width, --ui-control-radius, --ui-control-shadow, --ui-label-transform, …). Remap them under any selector to theme the whole set; the shipped defaults render out of the box. Dark/light is orthogonal — pair with @script-development/fs-theme's data-theme switching.

State-variant hooks

The control has a background/text/border hook for each interactive state, so a strong focus or invalid treatment stays a one-line remap instead of a hand-written :focus-visible override block. Every hook defaults to its resting counterpart, so the contract is a no-op until you opt in:

| Var | Fires on | Default | | --------------------------------- | ---------------- | -------------------------------- | | --ui-control-bg-focus | :focus-visible | var(--ui-control-bg) | | --ui-control-text-focus | :focus-visible | var(--ui-control-text) | | --ui-control-border-color-focus | :focus-visible | var(--ui-control-border-color) | | --ui-control-border-width-focus | :focus-visible | var(--ui-control-border-width) | | --ui-control-bg-invalid | .is-invalid | var(--ui-control-bg) | | --ui-control-text-invalid | .is-invalid | var(--ui-control-text) |

The .is-open and .is-invalid state classes follow :focus-visible in source order, so they keep winning their border/background where they did before — the focus hooks only take effect on a plain focused control.

The listbox options carry the same discipline: --ui-option-text-muted (fires on .is-muted, defaults to the resting option text) and the MultiSelect membership pair --ui-option-bg-selected / --ui-option-text-selected (fire on [aria-selected="true"] in the MultiSelect popup, default transparent / resting text — the pointer highlight keeps winning its background). All no-ops until you opt in.

Two media queries the tokens do not reach

The stylesheet ends with two gated blocks that deliberately step outside the --ui-* contract, because the user's own preferences outrank the theme.

@media (forced-colors: active) — a high-contrast theme makes the user agent ignore author colours outright, so anything conveyed by colour alone disappears. Three states would: keyboard focus (painted through box-shadow, which forced-colors strips), the aria-pressed toggle state, and the as fallback's disabled state. The block restores each with a system colourHighlight for the focus outline, Highlight / HighlightText for the pressed surface, GrayText for both disabled paths — rather than opting out with forced-color-adjust, since speaking the user's palette is the point of the mode. Your --ui-pressable-bg-pressed / --ui-pressable-text-disabled overrides do not apply there, and that is correct.

@media (prefers-reduced-motion: reduce) zeroes every transition the sheet declares, scoped to the ui-* surfaces that carry one so your own transitions stay untouched.

Typography escape hatch

--ui-control-font-size (default inherit) sizes control text, and --ui-control-line-height (default inherit) completes the decomposition. The control's font is decomposed into longhands (font-family/font-size/font-style/font-variant/font-weight/font-stretch/line-height, all inheriting except the two var-keyed ones), so both read from their var rather than from a consumer utility class — which would otherwise lose the source-order tie against the package stylesheet. The defaults are byte-identical to the historical font: inherit. The listbox popup has its own hook: --ui-menu-font-size (default inherit — the popup never leaves the control, so it inherits from the component root; set the var to size menu text independently).

Menu width clamps

--ui-menu-min-width (default 100% — of the .ui-menu-anchor, i.e. the trigger width) and --ui-menu-max-width (default none) clamp the listbox popup. A territory caps them without fighting specificity:

:root {
    --ui-menu-min-width: max(100%, 240px);
    --ui-menu-max-width: calc(100vw - 16px);
}

100% means the trigger width: the popup sits inside a .ui-menu-anchor box that floating-ui sizes to the trigger. When the menu outgrows the trigger — a floor like the one above, or a long option — the anchor grows with it, so the popup stays on-screen.

Touch targets

--ui-control-min-height and --ui-option-min-height (both default auto — the measured status quo) put a floor under the control and the listbox options. WCAG 2.5.5's 44px target is the consumer's call: assign them under your own coarse-pointer media query rather than expecting the package to decide for every territory:

@media (hover: none) and (pointer: coarse) {
    :root {
        --ui-control-min-height: 2.75rem;
        --ui-option-min-height: 2.75rem;
    }
}

Nullable values

Every text-like input (TextInput, DateInput, Textarea) models string | null, and NumberInput models number | null. A null from a nullable backend column binds directly — the control renders empty, no ?? '' at the call site. When the user clears the field, the string inputs emit '' (the raw native value); a Laravel backend's ConvertEmptyStringsToNull middleware maps that back to null on submit. NumberInput is the one exception: an empty number input emits null (not NaN, not ''), since a number model can never hold '' honestly — so it round-trips to null without relying on the middleware.

SingleSelect and assistive tech

The listbox keeps DOM focus on the trigger and conveys the keyboard-focused option with aria-activedescendant, so arrow-key navigation is announced rather than silent. The trigger carries aria-controls while open (the IDREF only resolves inside the listbox it owns), and each option gets a stable ${id}-opt-${index} keyed on its position in the rendered list, not on option.id.

aria-selected marks the committed value — not the option under the keyboard pointer or the mouse. Keyboard/hover focus is visual (.is-active) plus aria-activedescendant; selection only moves on Enter or click. Because the IDREF is position-derived, a non-unique or whitespace-containing option.id never breaks the aria-activedescendant linkage.

Errors are a prop, never a service

The components never import an error service. Resolve the message in your app and pass error (to FormField) or invalid + describedby (to the inputs). That keeps the package agnostic to how your territory produces validation errors.