Listbox, Select, and list navigation

createListNavigation is the core primitive for all collection components
(Listbox, Select, Combobox, Menu). It provides value-based keyboard
navigation, selection/activation modes, typeahead, and aria-activedescendant
virtual focus.
Listbox: standalone selectable list with single/multiple selection.
Select: floating dropdown with trigger, keyboard navigation, form integration.
Also fixes exactOptionalPropertyTypes compatibility in createDisclosureState
and createListNavigation interfaces.
This commit is contained in:
Mats Bosson 2026-03-29 19:12:05 +07:00
parent b249509cd7
commit 5bc9ac7b61
22 changed files with 1561 additions and 3 deletions

View File

@ -0,0 +1,18 @@
import { useListboxContext } from "./listbox-context";
import { ListboxGroup } from "./listbox-group";
import { ListboxGroupLabel } from "./listbox-group-label";
import { ListboxItem } from "./listbox-item";
import { ListboxRoot } from "./listbox-root";
export const Listbox = Object.assign(ListboxRoot, {
Item: ListboxItem,
Group: ListboxGroup,
GroupLabel: ListboxGroupLabel,
useContext: useListboxContext,
});
export type { ListboxRootProps } from "./listbox-root";
export type { ListboxItemProps } from "./listbox-item";
export type { ListboxGroupProps } from "./listbox-group";
export type { ListboxGroupLabelProps } from "./listbox-group-label";
export type { ListboxContextValue } from "./listbox-context";

View File

@ -0,0 +1,26 @@
import { createContext, useContext } from "solid-js";
import type { ListNavigationState } from "../../primitives/create-list-navigation";
export interface ListboxContextValue {
navigation: ListNavigationState;
}
const ListboxContext = createContext<ListboxContextValue>();
/**
* Returns the Listbox context. Throws if used outside <Listbox>.
*/
export function useListboxContext(): ListboxContextValue {
const ctx = useContext(ListboxContext);
if (!ctx) {
throw new Error(
"[PettyUI] Listbox.Item must be used inside <Listbox>.\n" +
" Fix: <Listbox items={[...]}>\n" +
' <Listbox.Item value="a">A</Listbox.Item>\n' +
" </Listbox>",
);
}
return ctx;
}
export const ListboxContextProvider = ListboxContext.Provider;

View File

@ -0,0 +1,17 @@
import type { JSX } from "solid-js";
import { createUniqueId, splitProps } from "solid-js";
export interface ListboxGroupLabelProps extends JSX.HTMLAttributes<HTMLDivElement> {
children?: JSX.Element;
}
/** Label for a Listbox group. Linked via aria-labelledby. */
export function ListboxGroupLabel(props: ListboxGroupLabelProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const id = createUniqueId();
return (
<div id={id} role="presentation" {...rest}>
{local.children}
</div>
);
}

View File

@ -0,0 +1,16 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
export interface ListboxGroupProps extends JSX.HTMLAttributes<HTMLDivElement> {
children?: JSX.Element;
}
/** Groups related Listbox items together. */
export function ListboxGroup(props: ListboxGroupProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
return (
<div role="group" {...rest}>
{local.children}
</div>
);
}

View File

@ -0,0 +1,29 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useListboxContext } from "./listbox-context";
export interface ListboxItemProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** The value this item represents. */
value: string;
/** Whether this item is disabled. */
disabled?: boolean;
children?: JSX.Element;
}
/** A single selectable option within a Listbox. */
export function ListboxItem(props: ListboxItemProps): JSX.Element {
const [local, rest] = splitProps(props, ["value", "disabled", "children"]);
const ctx = useListboxContext();
const itemProps = () => ctx.navigation.getItemProps(local.value);
return (
<div
aria-disabled={local.disabled || undefined}
data-disabled={local.disabled || undefined}
{...itemProps()}
{...rest}
>
{local.children}
</div>
);
}

View File

@ -0,0 +1,88 @@
import type { JSX } from "solid-js";
import { createUniqueId, splitProps } from "solid-js";
import { createListNavigation } from "../../primitives/create-list-navigation";
import { ListboxContextProvider } from "./listbox-context";
export interface ListboxRootProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Ordered list of active (non-disabled) item values. */
items: string[];
/** Controlled selected value (single selection). */
value?: string;
/** Initial selected value (uncontrolled). */
defaultValue?: string;
/** Called when selection changes (single). */
onValueChange?: (value: string) => void;
/** Selection mode. @default "single" */
selectionMode?: "single" | "multiple";
/** Controlled selected values (multiple selection). */
values?: string[];
/** Initial selected values (uncontrolled, multiple). */
defaultValues?: string[];
/** Called when selection changes (multiple). */
onValuesChange?: (values: string[]) => void;
/** @default "vertical" */
orientation?: "vertical" | "horizontal";
/** Wrap at boundaries. @default true */
loop?: boolean;
/** Resolve value to display label for typeahead. */
getLabel?: (value: string) => string;
/** Whether the listbox is disabled. */
disabled?: boolean;
children: JSX.Element;
}
/**
* A list of selectable options. Supports single and multiple selection,
* keyboard navigation, and typeahead search.
*/
export function ListboxRoot(props: ListboxRootProps): JSX.Element {
const [local, rest] = splitProps(props, [
"items",
"value",
"defaultValue",
"onValueChange",
"selectionMode",
"values",
"defaultValues",
"onValuesChange",
"orientation",
"loop",
"getLabel",
"disabled",
"children",
]);
const baseId = createUniqueId();
const isMultiple = () => local.selectionMode === "multiple";
const navigation = createListNavigation({
items: () => local.items,
mode: "selection",
orientation: local.orientation,
loop: local.loop,
value: local.value !== undefined ? () => local.value : undefined,
defaultValue: local.defaultValue,
onValueChange: local.onValueChange,
multiple: isMultiple(),
values: local.values !== undefined ? () => local.values ?? [] : undefined,
defaultValues: local.defaultValues,
onValuesChange: local.onValuesChange,
getLabel: local.getLabel,
baseId,
});
return (
<ListboxContextProvider value={{ navigation }}>
<div
tabIndex={local.disabled ? -1 : 0}
aria-disabled={local.disabled || undefined}
aria-multiselectable={isMultiple() ? "true" : undefined}
data-disabled={local.disabled || undefined}
{...navigation.containerProps}
{...rest}
>
{local.children}
</div>
</ListboxContextProvider>
);
}

View File

@ -0,0 +1,9 @@
export { Select, SelectRoot } from "./select-root";
export { SelectTrigger } from "./select-trigger";
export { SelectValue } from "./select-value";
export { SelectContent } from "./select-content";
export { SelectItem } from "./select-item";
export { SelectGroup } from "./select-group";
export { SelectGroupLabel } from "./select-group-label";
export { SelectHiddenSelect } from "./select-hidden-select";
export { useSelectContext } from "./select-context";

View File

@ -0,0 +1,69 @@
import type { JSX } from "solid-js";
import { Show, createEffect, onCleanup, splitProps } from "solid-js";
import { createDismiss } from "../../utilities/dismiss/create-dismiss";
import { useInternalSelectContext } from "./select-context";
export interface SelectContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Keep mounted when closed. @default false */
forceMount?: boolean;
children?: JSX.Element;
}
/**
* Floating dropdown content for Select. Contains the listbox with items.
* Handles dismiss (outside click) and keyboard navigation.
*/
export function SelectContent(props: SelectContentProps): JSX.Element {
const [local, rest] = splitProps(props, ["children", "forceMount"]);
const ctx = useInternalSelectContext();
const dismiss = createDismiss({
getContainer: () => ctx.contentRef(),
onDismiss: () => ctx.close(),
dismissOnEscape: false,
});
createEffect(() => {
if (ctx.isOpen()) {
dismiss.attach();
} else {
dismiss.detach();
}
onCleanup(() => dismiss.detach());
});
const handleKeyDown: JSX.EventHandler<HTMLDivElement, KeyboardEvent> = (e) => {
if (e.key === "Escape") {
e.preventDefault();
ctx.close();
ctx.triggerRef()?.focus();
return;
}
if (e.key === "Tab") {
ctx.close();
return;
}
ctx.navigation.containerProps.onKeyDown(e);
};
return (
<Show when={local.forceMount || ctx.isOpen()}>
<div
ref={(el) => ctx.setContentRef(el)}
id={ctx.contentId}
role="listbox"
aria-orientation="vertical"
aria-activedescendant={ctx.navigation.containerProps["aria-activedescendant"]}
aria-labelledby={ctx.triggerId}
data-state={ctx.isOpen() ? "open" : "closed"}
style={ctx.floatingStyle()}
tabIndex={-1}
onKeyDown={handleKeyDown}
onPointerLeave={() => ctx.navigation.clearHighlight()}
{...rest}
>
{local.children}
</div>
</Show>
);
}

View File

@ -0,0 +1,70 @@
import type { Accessor, JSX } from "solid-js";
import { createContext, useContext } from "solid-js";
import type { ListNavigationState } from "../../primitives/create-list-navigation";
/** Internal context shared between all Select sub-components. */
export interface InternalSelectContextValue {
isOpen: Accessor<boolean>;
open: () => void;
close: () => void;
toggle: () => void;
navigation: ListNavigationState;
triggerRef: Accessor<HTMLElement | null>;
setTriggerRef: (el: HTMLElement | null) => void;
contentRef: Accessor<HTMLElement | null>;
setContentRef: (el: HTMLElement | null) => void;
selectedValue: Accessor<string | undefined>;
onSelect: (value: string) => void;
disabled: Accessor<boolean>;
required: Accessor<boolean>;
name: Accessor<string | undefined>;
contentId: string;
triggerId: string;
floatingStyle: Accessor<JSX.CSSProperties>;
}
const InternalSelectContext = createContext<InternalSelectContextValue>();
/**
* Returns the internal Select context. Throws if used outside Select.
*/
export function useInternalSelectContext(): InternalSelectContextValue {
const ctx = useContext(InternalSelectContext);
if (!ctx) {
throw new Error(
"[PettyUI] Select parts must be used inside <Select>.\n" +
" Fix: <Select items={[...]}>\n" +
" <Select.Trigger>...</Select.Trigger>\n" +
" <Select.Content>\n" +
' <Select.Item value="a">A</Select.Item>\n' +
" </Select.Content>\n" +
" </Select>",
);
}
return ctx;
}
export const InternalSelectContextProvider = InternalSelectContext.Provider;
/** Public context exposed via Select.useContext(). */
export interface SelectContextValue {
/** Currently selected value. */
value: Accessor<string | undefined>;
/** Whether the select dropdown is open. */
open: Accessor<boolean>;
}
const SelectContext = createContext<SelectContextValue>();
/**
* Returns the public Select context. Throws if used outside Select.
*/
export function useSelectContext(): SelectContextValue {
const ctx = useContext(SelectContext);
if (!ctx) {
throw new Error("[PettyUI] Select.useContext() called outside of <Select>.");
}
return ctx;
}
export const SelectContextProvider = SelectContext.Provider;

View File

@ -0,0 +1,17 @@
import type { JSX } from "solid-js";
import { createUniqueId, splitProps } from "solid-js";
export interface SelectGroupLabelProps extends JSX.HTMLAttributes<HTMLDivElement> {
children?: JSX.Element;
}
/** Label for a Select group. */
export function SelectGroupLabel(props: SelectGroupLabelProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const id = createUniqueId();
return (
<div id={id} role="presentation" {...rest}>
{local.children}
</div>
);
}

View File

@ -0,0 +1,16 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
export interface SelectGroupProps extends JSX.HTMLAttributes<HTMLDivElement> {
children?: JSX.Element;
}
/** Groups related Select items together. */
export function SelectGroup(props: SelectGroupProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
return (
<div role="group" {...rest}>
{local.children}
</div>
);
}

View File

@ -0,0 +1,17 @@
import type { JSX } from "solid-js";
import { useInternalSelectContext } from "./select-context";
/** Hidden native input for form submission. */
export function SelectHiddenSelect(): JSX.Element {
const ctx = useInternalSelectContext();
return (
<input
type="hidden"
name={ctx.name() ?? ""}
value={ctx.selectedValue() ?? ""}
aria-hidden="true"
tabIndex={-1}
/>
);
}

View File

@ -0,0 +1,29 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useInternalSelectContext } from "./select-context";
export interface SelectItemProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** The value this item represents. */
value: string;
/** Whether this item is disabled. */
disabled?: boolean;
children?: JSX.Element;
}
/** A single selectable option within a Select dropdown. */
export function SelectItem(props: SelectItemProps): JSX.Element {
const [local, rest] = splitProps(props, ["value", "disabled", "children"]);
const ctx = useInternalSelectContext();
const itemProps = () => ctx.navigation.getItemProps(local.value);
return (
<div
aria-disabled={local.disabled || undefined}
data-disabled={local.disabled || undefined}
{...itemProps()}
{...rest}
>
{local.children}
</div>
);
}

View File

@ -0,0 +1,144 @@
import type { Middleware, Placement } from "@floating-ui/dom";
import { flip, offset, shift } from "@floating-ui/dom";
import type { Accessor, JSX } from "solid-js";
import { createSignal, createUniqueId, splitProps } from "solid-js";
import { createDisclosureState } from "../../primitives/create-disclosure-state";
import { createFloating } from "../../primitives/create-floating";
import { createListNavigation } from "../../primitives/create-list-navigation";
import { SelectContent } from "./select-content";
import { useSelectContext } from "./select-context";
import { InternalSelectContextProvider, SelectContextProvider } from "./select-context";
import type { InternalSelectContextValue } from "./select-context";
import { SelectGroup } from "./select-group";
import { SelectGroupLabel } from "./select-group-label";
import { SelectHiddenSelect } from "./select-hidden-select";
import { SelectItem } from "./select-item";
import { SelectTrigger } from "./select-trigger";
import { SelectValue } from "./select-value";
export interface SelectRootProps {
/** Ordered list of active item values. */
items: string[];
/** Controlled selected value. */
value?: string;
/** Initial selected value (uncontrolled). */
defaultValue?: string;
/** Called when value changes. */
onValueChange?: (value: string) => void;
/** Controlled open state. */
open?: boolean;
/** Initial open state (uncontrolled). */
defaultOpen?: boolean;
/** Called when open state changes. */
onOpenChange?: (open: boolean) => void;
/** Resolve value to display label. */
getLabel?: (value: string) => string;
/** Whether the select is disabled. */
disabled?: boolean;
/** Whether selection is required. */
required?: boolean;
/** Form field name for hidden input. */
name?: string;
children: JSX.Element;
}
/**
* Root component for Select. Manages open state, selection, floating
* positioning, and keyboard navigation via context.
*/
export function SelectRoot(props: SelectRootProps): JSX.Element {
const [local] = splitProps(props, [
"items",
"value",
"defaultValue",
"onValueChange",
"open",
"defaultOpen",
"onOpenChange",
"getLabel",
"disabled",
"required",
"name",
"children",
]);
const triggerId = createUniqueId();
const contentId = createUniqueId();
const baseId = createUniqueId();
const [triggerRef, setTriggerRef] = createSignal<HTMLElement | null>(null);
const [contentRef, setContentRef] = createSignal<HTMLElement | null>(null);
const disclosure = createDisclosureState({
get open() {
return local.open;
},
get defaultOpen() {
return local.defaultOpen;
},
get onOpenChange() {
return local.onOpenChange;
},
});
const navigation = createListNavigation({
items: () => local.items,
mode: "selection",
value: local.value !== undefined ? () => local.value : undefined,
defaultValue: local.defaultValue,
onValueChange: (v) => {
local.onValueChange?.(v);
disclosure.close();
},
getLabel: local.getLabel,
baseId,
});
const floating = createFloating({
anchor: triggerRef,
floating: contentRef,
placement: (() => "bottom-start") as Accessor<Placement>,
middleware: (() => [offset(8), flip(), shift({ padding: 8 })]) as Accessor<Middleware[]>,
open: disclosure.isOpen,
});
const ctx: InternalSelectContextValue = {
isOpen: disclosure.isOpen,
open: disclosure.open,
close: disclosure.close,
toggle: disclosure.toggle,
navigation,
triggerRef,
setTriggerRef,
contentRef,
setContentRef,
selectedValue: navigation.selectedValue,
onSelect: (value: string) => navigation.getItemProps(value).onClick(),
disabled: () => local.disabled ?? false,
required: () => local.required ?? false,
name: () => local.name,
contentId,
triggerId,
floatingStyle: floating.style,
};
return (
<InternalSelectContextProvider value={ctx}>
<SelectContextProvider value={{ value: navigation.selectedValue, open: disclosure.isOpen }}>
{local.children}
</SelectContextProvider>
</InternalSelectContextProvider>
);
}
/** Compound Select component with all sub-components as static properties. */
export const Select = Object.assign(SelectRoot, {
Trigger: SelectTrigger,
Value: SelectValue,
Content: SelectContent,
Item: SelectItem,
Group: SelectGroup,
GroupLabel: SelectGroupLabel,
HiddenSelect: SelectHiddenSelect,
useContext: useSelectContext,
});

View File

@ -0,0 +1,52 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useInternalSelectContext } from "./select-context";
export interface SelectTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
children?: JSX.Element;
}
/** Button that opens the Select dropdown. */
export function SelectTrigger(props: SelectTriggerProps): JSX.Element {
const [local, rest] = splitProps(props, ["children", "onClick", "onKeyDown"]);
const ctx = useInternalSelectContext();
const handleClick: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (e) => {
if (typeof local.onClick === "function") local.onClick(e);
if (!ctx.disabled()) ctx.toggle();
};
const handleKeyDown: JSX.EventHandler<HTMLButtonElement, KeyboardEvent> = (e) => {
if (typeof local.onKeyDown === "function") local.onKeyDown(e);
if (ctx.disabled()) return;
if (e.key === "ArrowDown") {
e.preventDefault();
ctx.open();
ctx.navigation.highlightFirst();
} else if (e.key === "ArrowUp") {
e.preventDefault();
ctx.open();
ctx.navigation.highlightLast();
}
};
return (
<button
ref={(el) => ctx.setTriggerRef(el)}
type="button"
role="combobox"
id={ctx.triggerId}
aria-expanded={ctx.isOpen()}
aria-haspopup="listbox"
aria-controls={ctx.isOpen() ? ctx.contentId : undefined}
data-state={ctx.isOpen() ? "open" : "closed"}
disabled={ctx.disabled()}
onClick={handleClick}
onKeyDown={handleKeyDown}
{...rest}
>
{local.children}
</button>
);
}

View File

@ -0,0 +1,20 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useInternalSelectContext } from "./select-context";
export interface SelectValueProps extends JSX.HTMLAttributes<HTMLSpanElement> {
/** Text to display when no value is selected. */
placeholder?: string;
}
/** Displays the selected value or a placeholder. */
export function SelectValue(props: SelectValueProps): JSX.Element {
const [local, rest] = splitProps(props, ["placeholder"]);
const ctx = useInternalSelectContext();
return (
<span data-placeholder={!ctx.selectedValue() ? "" : undefined} {...rest}>
{ctx.selectedValue() ?? local.placeholder ?? ""}
</span>
);
}

View File

@ -2,9 +2,9 @@ import type { Accessor } from "solid-js";
import { createControllableSignal } from "./create-controllable-signal";
export interface CreateDisclosureStateOptions {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
open?: boolean | undefined;
defaultOpen?: boolean | undefined;
onOpenChange?: ((open: boolean) => void) | undefined;
}
export interface DisclosureState {

View File

@ -0,0 +1,280 @@
import { type Accessor, createComputed, createSignal, createUniqueId, on } from "solid-js";
/** Options for createListNavigation. */
export interface CreateListNavigationOptions {
/** Ordered list of valid item values. Reactive -- can change (e.g., filtering). */
items: Accessor<string[]>;
/** "selection" for Listbox/Select/Combobox. "activation" for Menu. */
mode: "selection" | "activation";
/** @default "vertical" */
orientation?: "vertical" | "horizontal" | undefined;
/** Wrap at list boundaries. @default true */
loop?: boolean | undefined;
/** Controlled selected value (single selection mode). */
value?: Accessor<string | undefined> | undefined;
/** Initial uncontrolled value. */
defaultValue?: string | undefined;
/** Called when selection changes (single). */
onValueChange?: ((value: string) => void) | undefined;
/** Allow multiple selection. @default false */
multiple?: boolean | undefined;
/** For multiple mode: controlled values. */
values?: Accessor<string[]> | undefined;
/** For multiple mode: initial uncontrolled values. */
defaultValues?: string[] | undefined;
/** For multiple mode: called when selection changes. */
onValuesChange?: ((values: string[]) => void) | undefined;
/** Called when an item is activated (activation mode). */
onActivate?: ((value: string) => void) | undefined;
/** Enable typeahead. @default true */
typeahead?: boolean | undefined;
/** Return the display label for a value. Used for typeahead matching. */
getLabel?: ((value: string) => string) | undefined;
/** Base ID for generating item IDs. Uses createUniqueId() if not provided. */
baseId?: string | undefined;
}
/** State returned by createListNavigation. */
export interface ListNavigationState {
/** Currently highlighted item value (virtual focus). */
highlightedValue: Accessor<string | undefined>;
/** Currently selected value (selection mode, single). */
selectedValue: Accessor<string | undefined>;
/** Currently selected values (selection mode, multiple). */
selectedValues: Accessor<string[]>;
/** Props to spread on the list container. */
containerProps: {
role: "listbox" | "menu";
"aria-orientation": "horizontal" | "vertical";
"aria-activedescendant": string | undefined;
onKeyDown: (e: KeyboardEvent) => void;
onPointerLeave: () => void;
};
/** Get props for a specific item by value. */
getItemProps: (value: string) => {
id: string;
role: "option" | "menuitem";
"aria-selected": "true" | "false" | undefined;
"data-highlighted": "" | undefined;
"data-value": string;
onPointerEnter: () => void;
onPointerMove: () => void;
onClick: () => void;
};
/** Imperatively set highlighted value. */
highlight: (value: string | undefined) => void;
/** Highlight the first item. */
highlightFirst: () => void;
/** Highlight the last item. */
highlightLast: () => void;
/** Clear highlight. */
clearHighlight: () => void;
}
/** Default label resolver that returns the value as-is. */
function defaultGetLabel(v: string): string {
return v;
}
/**
* Value-based keyboard navigation, selection/activation, and typeahead for list-like components.
* Uses aria-activedescendant (virtual focus) so the container/input retains DOM focus.
*/
export function createListNavigation(options: CreateListNavigationOptions): ListNavigationState {
const baseId = options.baseId ?? createUniqueId();
const orientation: "horizontal" | "vertical" = options.orientation ?? "vertical";
const loop = options.loop ?? true;
const isTypeaheadEnabled = options.typeahead ?? true;
const getLabel = options.getLabel ?? defaultGetLabel;
const [highlighted, setHighlighted] = createSignal<string | undefined>(undefined);
const [internalValue, setInternalValue] = createSignal<string | undefined>(options.defaultValue);
const resolveValue = (): string | undefined => {
if (options.value) return options.value();
return internalValue();
};
const [internalValues, setInternalValues] = createSignal<string[]>(options.defaultValues ?? []);
const resolveValues = (): string[] => {
if (options.values) return options.values();
return internalValues();
};
createComputed(
on(
() => options.items(),
(items) => {
const h = highlighted();
if (h !== undefined && !items.includes(h)) {
setHighlighted(undefined);
}
},
),
);
let typeaheadBuffer = "";
let typeaheadTimer: ReturnType<typeof setTimeout> | undefined;
function handleTypeahead(key: string): void {
if (!isTypeaheadEnabled || key.length !== 1) return;
clearTimeout(typeaheadTimer);
typeaheadBuffer += key;
typeaheadTimer = setTimeout(() => {
typeaheadBuffer = "";
}, 500);
const items = options.items();
const search = typeaheadBuffer.toLowerCase();
const match = items.find((v) => getLabel(v).toLowerCase().startsWith(search));
if (match) setHighlighted(match);
}
function getNextValue(current: string | undefined, direction: 1 | -1): string | undefined {
const items = options.items();
if (items.length === 0) return undefined;
if (current === undefined) {
return direction === 1 ? items[0] : items[items.length - 1];
}
const index = items.indexOf(current);
if (index === -1) return items[0];
const nextIndex = index + direction;
if (loop) {
return items[(nextIndex + items.length) % items.length];
}
if (nextIndex < 0 || nextIndex >= items.length) return current;
return items[nextIndex];
}
function selectItem(value: string): void {
if (options.mode === "activation") {
options.onActivate?.(value);
return;
}
if (options.multiple) {
const current = resolveValues();
const next = current.includes(value)
? current.filter((v) => v !== value)
: [...current, value];
setInternalValues(next);
options.onValuesChange?.(next);
} else {
setInternalValue(value);
options.onValueChange?.(value);
}
}
const nextKey = orientation === "horizontal" ? "ArrowRight" : "ArrowDown";
const prevKey = orientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
function handleKeyDown(e: KeyboardEvent): void {
switch (e.key) {
case nextKey: {
e.preventDefault();
setHighlighted(getNextValue(highlighted(), 1));
break;
}
case prevKey: {
e.preventDefault();
setHighlighted(getNextValue(highlighted(), -1));
break;
}
case "Home": {
e.preventDefault();
const items = options.items();
if (items.length > 0) setHighlighted(items[0]);
break;
}
case "End": {
e.preventDefault();
const items = options.items();
if (items.length > 0) setHighlighted(items[items.length - 1]);
break;
}
case "Enter":
case " ": {
e.preventDefault();
const h = highlighted();
if (h !== undefined) selectItem(h);
break;
}
case "Escape": {
setHighlighted(undefined);
break;
}
default: {
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
handleTypeahead(e.key);
}
}
}
}
const itemRole: "option" | "menuitem" = options.mode === "selection" ? "option" : "menuitem";
const containerProps = {
get role(): "listbox" | "menu" {
return options.mode === "selection" ? "listbox" : "menu";
},
get "aria-orientation"(): "horizontal" | "vertical" {
return orientation;
},
get "aria-activedescendant"() {
const h = highlighted();
return h !== undefined ? `${baseId}-${itemRole}-${h}` : undefined;
},
onKeyDown: handleKeyDown,
onPointerLeave: () => setHighlighted(undefined),
};
/** Get props for a specific item by value. */
function getItemProps(value: string) {
return {
get id() {
return `${baseId}-${itemRole}-${value}`;
},
role: itemRole,
get "aria-selected"(): "true" | "false" | undefined {
if (options.mode !== "selection") return undefined;
if (options.multiple) {
return resolveValues().includes(value) ? "true" : "false";
}
return resolveValue() === value ? "true" : "false";
},
get "data-highlighted"() {
return highlighted() === value ? ("" as const) : undefined;
},
get "data-value"() {
return value;
},
onPointerEnter: () => { setHighlighted(value); },
onPointerMove: () => {
if (highlighted() !== value) setHighlighted(value);
},
onClick: () => {
setHighlighted(value);
selectItem(value);
},
};
}
return {
highlightedValue: highlighted,
selectedValue: () => (options.mode === "selection" ? resolveValue() : undefined),
selectedValues: () => (options.mode === "selection" ? resolveValues() : []),
containerProps,
getItemProps,
highlight: setHighlighted,
highlightFirst: () => {
const items = options.items();
if (items.length > 0) setHighlighted(items[0]);
},
highlightLast: () => {
const items = options.items();
if (items.length > 0) setHighlighted(items[items.length - 1]);
},
clearHighlight: () => setHighlighted(undefined),
};
}

View File

@ -4,4 +4,9 @@ export { createDisclosureState } from "./create-disclosure-state";
export type { CreateDisclosureStateOptions, DisclosureState } from "./create-disclosure-state";
export { createFloating } from "./create-floating";
export type { CreateFloatingOptions, FloatingState } from "./create-floating";
export { createListNavigation } from "./create-list-navigation";
export type {
CreateListNavigationOptions,
ListNavigationState,
} from "./create-list-navigation";
export { createRegisterId } from "./create-register-id";

View File

@ -0,0 +1,114 @@
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it, vi } from "vitest";
import { Listbox } from "../../../src/components/listbox/index";
describe("Listbox — roles and selection", () => {
it("root has role=listbox", () => {
render(() => (
<Listbox items={["a", "b", "c"]}>
<Listbox.Item value="a">A</Listbox.Item>
<Listbox.Item value="b">B</Listbox.Item>
<Listbox.Item value="c">C</Listbox.Item>
</Listbox>
));
expect(screen.getByRole("listbox")).toBeTruthy();
});
it("items have role=option", () => {
render(() => (
<Listbox items={["a", "b"]}>
<Listbox.Item value="a">A</Listbox.Item>
<Listbox.Item value="b">B</Listbox.Item>
</Listbox>
));
expect(screen.getAllByRole("option")).toHaveLength(2);
});
it("aria-selected reflects selection", () => {
render(() => (
<Listbox items={["a", "b"]} defaultValue="b">
<Listbox.Item value="a">A</Listbox.Item>
<Listbox.Item value="b">B</Listbox.Item>
</Listbox>
));
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("false");
expect(options[1].getAttribute("aria-selected")).toBe("true");
});
it("click selects item", () => {
const onChange = vi.fn();
render(() => (
<Listbox items={["a", "b"]} onValueChange={onChange}>
<Listbox.Item value="a">A</Listbox.Item>
<Listbox.Item value="b">B</Listbox.Item>
</Listbox>
));
fireEvent.click(screen.getAllByRole("option")[1]);
expect(onChange).toHaveBeenCalledWith("b");
});
it("multiple selection mode works", () => {
const onChange = vi.fn();
render(() => (
<Listbox items={["a", "b", "c"]} selectionMode="multiple" onValuesChange={onChange}>
<Listbox.Item value="a">A</Listbox.Item>
<Listbox.Item value="b">B</Listbox.Item>
<Listbox.Item value="c">C</Listbox.Item>
</Listbox>
));
const listbox = screen.getByRole("listbox");
expect(listbox.getAttribute("aria-multiselectable")).toBe("true");
fireEvent.click(screen.getAllByRole("option")[0]);
expect(onChange).toHaveBeenCalledWith(["a"]);
});
});
describe("Listbox — keyboard navigation", () => {
it("ArrowDown highlights next item", () => {
render(() => (
<Listbox items={["a", "b", "c"]}>
<Listbox.Item value="a">A</Listbox.Item>
<Listbox.Item value="b">B</Listbox.Item>
<Listbox.Item value="c">C</Listbox.Item>
</Listbox>
));
const listbox = screen.getByRole("listbox");
listbox.focus();
fireEvent.keyDown(listbox, { key: "ArrowDown" });
fireEvent.keyDown(listbox, { key: "ArrowDown" });
const highlighted = listbox.getAttribute("aria-activedescendant");
expect(highlighted).toBeTruthy();
});
it("Enter selects highlighted item", () => {
const onChange = vi.fn();
render(() => (
<Listbox items={["a", "b", "c"]} onValueChange={onChange}>
<Listbox.Item value="a">A</Listbox.Item>
<Listbox.Item value="b">B</Listbox.Item>
<Listbox.Item value="c">C</Listbox.Item>
</Listbox>
));
const listbox = screen.getByRole("listbox");
listbox.focus();
fireEvent.keyDown(listbox, { key: "ArrowDown" });
fireEvent.keyDown(listbox, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith("a");
});
it("Home/End navigate to first/last", () => {
render(() => (
<Listbox items={["a", "b", "c"]}>
<Listbox.Item value="a">A</Listbox.Item>
<Listbox.Item value="b">B</Listbox.Item>
<Listbox.Item value="c">C</Listbox.Item>
</Listbox>
));
const listbox = screen.getByRole("listbox");
listbox.focus();
fireEvent.keyDown(listbox, { key: "End" });
const options = screen.getAllByRole("option");
expect(options[2].getAttribute("data-highlighted")).toBe("");
});
});

View File

@ -0,0 +1,145 @@
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it, vi } from "vitest";
import { Select } from "../../../src/components/select/index";
describe("Select — roles", () => {
it("trigger has role=combobox", () => {
render(() => (
<Select items={["a", "b"]}>
<Select.Trigger>Pick one</Select.Trigger>
<Select.Content>
<Select.Item value="a">A</Select.Item>
<Select.Item value="b">B</Select.Item>
</Select.Content>
</Select>
));
expect(screen.getByRole("combobox")).toBeTruthy();
});
it("content has role=listbox when open", () => {
render(() => (
<Select items={["a", "b"]} defaultOpen>
<Select.Trigger>Pick one</Select.Trigger>
<Select.Content>
<Select.Item value="a">A</Select.Item>
<Select.Item value="b">B</Select.Item>
</Select.Content>
</Select>
));
expect(screen.getByRole("listbox")).toBeTruthy();
});
it("aria-selected on selected item", () => {
render(() => (
<Select items={["a", "b"]} defaultValue="b" defaultOpen>
<Select.Trigger>Pick one</Select.Trigger>
<Select.Content>
<Select.Item value="a">A</Select.Item>
<Select.Item value="b">B</Select.Item>
</Select.Content>
</Select>
));
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("false");
expect(options[1].getAttribute("aria-selected")).toBe("true");
});
});
describe("Select — open and close", () => {
it("click trigger opens content", () => {
render(() => (
<Select items={["a", "b"]}>
<Select.Trigger>Pick one</Select.Trigger>
<Select.Content>
<Select.Item value="a">A</Select.Item>
<Select.Item value="b">B</Select.Item>
</Select.Content>
</Select>
));
expect(screen.queryByRole("listbox")).toBeNull();
fireEvent.click(screen.getByRole("combobox"));
expect(screen.getByRole("listbox")).toBeTruthy();
});
it("Escape closes without selecting", () => {
render(() => (
<Select items={["a", "b"]} defaultOpen>
<Select.Trigger>Pick one</Select.Trigger>
<Select.Content>
<Select.Item value="a">A</Select.Item>
<Select.Item value="b">B</Select.Item>
</Select.Content>
</Select>
));
fireEvent.keyDown(screen.getByRole("listbox"), { key: "Escape" });
expect(screen.queryByRole("listbox")).toBeNull();
});
});
describe("Select — keyboard navigation", () => {
it("ArrowDown on trigger opens and highlights first", () => {
render(() => (
<Select items={["a", "b"]}>
<Select.Trigger>Pick one</Select.Trigger>
<Select.Content>
<Select.Item value="a">A</Select.Item>
<Select.Item value="b">B</Select.Item>
</Select.Content>
</Select>
));
fireEvent.keyDown(screen.getByRole("combobox"), { key: "ArrowDown" });
expect(screen.getByRole("listbox")).toBeTruthy();
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("data-highlighted")).toBe("");
});
it("Enter selects highlighted item and closes", () => {
const onChange = vi.fn();
render(() => (
<Select items={["a", "b"]} onValueChange={onChange} defaultOpen>
<Select.Trigger>Pick one</Select.Trigger>
<Select.Content>
<Select.Item value="a">A</Select.Item>
<Select.Item value="b">B</Select.Item>
</Select.Content>
</Select>
));
const listbox = screen.getByRole("listbox");
fireEvent.keyDown(listbox, { key: "ArrowDown" });
fireEvent.keyDown(listbox, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith("a");
expect(screen.queryByRole("listbox")).toBeNull();
});
});
describe("Select — controlled and form", () => {
it("controlled mode", () => {
render(() => (
<Select items={["a", "b"]} value="a" onValueChange={() => {}}>
<Select.Trigger>Pick one</Select.Trigger>
<Select.Content>
<Select.Item value="a">A</Select.Item>
<Select.Item value="b">B</Select.Item>
</Select.Content>
</Select>
));
expect(screen.getByRole("combobox")).toBeTruthy();
});
it("hidden input rendered when name provided", () => {
render(() => (
<Select items={["a", "b"]} name="fruit" defaultValue="a">
<Select.Trigger>Pick one</Select.Trigger>
<Select.Content>
<Select.Item value="a">A</Select.Item>
</Select.Content>
<Select.HiddenSelect />
</Select>
));
const hidden = document.querySelector(
"input[name='fruit']",
) as HTMLInputElement;
expect(hidden).toBeTruthy();
expect(hidden.value).toBe("a");
});
});

View File

@ -0,0 +1,377 @@
import { createRoot, createSignal } from "solid-js";
import { describe, expect, it, vi } from "vitest";
import { createListNavigation } from "../../src/primitives/create-list-navigation";
describe("createListNavigation — highlighting", () => {
it("highlights first item via highlightFirst", () => {
createRoot((dispose) => {
const nav = createListNavigation({
items: () => ["a", "b", "c"],
mode: "selection",
baseId: "test",
});
expect(nav.highlightedValue()).toBeUndefined();
nav.highlightFirst();
expect(nav.highlightedValue()).toBe("a");
dispose();
});
});
it("highlights last item via highlightLast", () => {
createRoot((dispose) => {
const nav = createListNavigation({
items: () => ["a", "b", "c"],
mode: "selection",
baseId: "test",
});
nav.highlightLast();
expect(nav.highlightedValue()).toBe("c");
dispose();
});
});
it("clears highlight via clearHighlight", () => {
createRoot((dispose) => {
const nav = createListNavigation({
items: () => ["a", "b", "c"],
mode: "selection",
baseId: "test",
});
nav.highlightFirst();
nav.clearHighlight();
expect(nav.highlightedValue()).toBeUndefined();
dispose();
});
});
});
describe("createListNavigation — arrow keys", () => {
it("ArrowDown highlights next item", () => {
createRoot((dispose) => {
const nav = createListNavigation({
items: () => ["a", "b", "c"],
mode: "selection",
baseId: "test",
});
nav.highlightFirst();
nav.containerProps.onKeyDown(
new KeyboardEvent("keydown", { key: "ArrowDown" }),
);
expect(nav.highlightedValue()).toBe("b");
dispose();
});
});
it("ArrowUp highlights previous item", () => {
createRoot((dispose) => {
const nav = createListNavigation({
items: () => ["a", "b", "c"],
mode: "selection",
baseId: "test",
});
nav.highlight("c");
nav.containerProps.onKeyDown(
new KeyboardEvent("keydown", { key: "ArrowUp" }),
);
expect(nav.highlightedValue()).toBe("b");
dispose();
});
});
});
describe("createListNavigation — loop behavior", () => {
it("wraps around when loop is true (default)", () => {
createRoot((dispose) => {
const nav = createListNavigation({
items: () => ["a", "b", "c"],
mode: "selection",
baseId: "test",
});
nav.highlight("c");
nav.containerProps.onKeyDown(
new KeyboardEvent("keydown", { key: "ArrowDown" }),
);
expect(nav.highlightedValue()).toBe("a");
dispose();
});
});
it("does not wrap when loop is false", () => {
createRoot((dispose) => {
const nav = createListNavigation({
items: () => ["a", "b", "c"],
mode: "selection",
baseId: "test",
loop: false,
});
nav.highlight("c");
nav.containerProps.onKeyDown(
new KeyboardEvent("keydown", { key: "ArrowDown" }),
);
expect(nav.highlightedValue()).toBe("c");
dispose();
});
});
});
describe("createListNavigation — Home/End keys", () => {
it("Home highlights first, End highlights last", () => {
createRoot((dispose) => {
const nav = createListNavigation({
items: () => ["a", "b", "c"],
mode: "selection",
baseId: "test",
});
nav.highlight("b");
nav.containerProps.onKeyDown(
new KeyboardEvent("keydown", { key: "End" }),
);
expect(nav.highlightedValue()).toBe("c");
nav.containerProps.onKeyDown(
new KeyboardEvent("keydown", { key: "Home" }),
);
expect(nav.highlightedValue()).toBe("a");
dispose();
});
});
});
describe("createListNavigation — selection mode", () => {
it("Enter selects highlighted item", () => {
createRoot((dispose) => {
const onChange = vi.fn();
const nav = createListNavigation({
items: () => ["a", "b", "c"],
mode: "selection",
baseId: "test",
onValueChange: onChange,
});
nav.highlight("b");
nav.containerProps.onKeyDown(
new KeyboardEvent("keydown", { key: "Enter" }),
);
expect(onChange).toHaveBeenCalledWith("b");
dispose();
});
});
it("Space selects highlighted item", () => {
createRoot((dispose) => {
const onChange = vi.fn();
const nav = createListNavigation({
items: () => ["a", "b", "c"],
mode: "selection",
baseId: "test",
onValueChange: onChange,
});
nav.highlight("a");
nav.containerProps.onKeyDown(
new KeyboardEvent("keydown", { key: " " }),
);
expect(onChange).toHaveBeenCalledWith("a");
dispose();
});
});
it("selectedValue reflects controlled value", () => {
createRoot((dispose) => {
const [value, setValue] = createSignal<string | undefined>("b");
const nav = createListNavigation({
items: () => ["a", "b", "c"],
mode: "selection",
baseId: "test",
value,
onValueChange: setValue,
});
expect(nav.selectedValue()).toBe("b");
dispose();
});
});
});
describe("createListNavigation — activation mode", () => {
it("Enter calls onActivate", () => {
createRoot((dispose) => {
const onActivate = vi.fn();
const nav = createListNavigation({
items: () => ["a", "b", "c"],
mode: "activation",
baseId: "test",
onActivate,
});
nav.highlight("b");
nav.containerProps.onKeyDown(
new KeyboardEvent("keydown", { key: "Enter" }),
);
expect(onActivate).toHaveBeenCalledWith("b");
dispose();
});
});
});
describe("createListNavigation — item props", () => {
it("returns correct id and role for selection mode", () => {
createRoot((dispose) => {
const nav = createListNavigation({
items: () => ["a", "b"],
mode: "selection",
baseId: "test",
});
const props = nav.getItemProps("a");
expect(props.id).toBe("test-option-a");
expect(props.role).toBe("option");
dispose();
});
});
it("returns menuitem role for activation mode", () => {
createRoot((dispose) => {
const nav = createListNavigation({
items: () => ["a"],
mode: "activation",
baseId: "test",
});
const props = nav.getItemProps("a");
expect(props.role).toBe("menuitem");
dispose();
});
});
it("marks highlighted item", () => {
createRoot((dispose) => {
const nav = createListNavigation({
items: () => ["a", "b"],
mode: "selection",
baseId: "test",
});
nav.highlight("a");
expect(nav.getItemProps("a")["data-highlighted"]).toBe("");
expect(nav.getItemProps("b")["data-highlighted"]).toBeUndefined();
dispose();
});
});
});
describe("createListNavigation — container props", () => {
it("has correct role", () => {
createRoot((dispose) => {
const selNav = createListNavigation({
items: () => ["a"],
mode: "selection",
baseId: "test",
});
expect(selNav.containerProps.role).toBe("listbox");
const actNav = createListNavigation({
items: () => ["a"],
mode: "activation",
baseId: "test2",
});
expect(actNav.containerProps.role).toBe("menu");
dispose();
});
});
it("aria-activedescendant tracks highlighted item", () => {
createRoot((dispose) => {
const nav = createListNavigation({
items: () => ["a", "b"],
mode: "selection",
baseId: "test",
});
expect(nav.containerProps["aria-activedescendant"]).toBeUndefined();
nav.highlight("b");
expect(nav.containerProps["aria-activedescendant"]).toBe(
"test-option-b",
);
dispose();
});
});
});
describe("createListNavigation — typeahead", () => {
it("typing a character highlights matching item", () => {
createRoot((dispose) => {
const nav = createListNavigation({
items: () => ["apple", "banana", "cherry"],
mode: "selection",
baseId: "test",
getLabel: (v) => v,
});
nav.containerProps.onKeyDown(
new KeyboardEvent("keydown", { key: "b" }),
);
expect(nav.highlightedValue()).toBe("banana");
dispose();
});
});
});
describe("createListNavigation — reactive items", () => {
it("highlight resets when highlighted item is removed", () => {
createRoot((dispose) => {
const [items, setItems] = createSignal(["a", "b", "c"]);
const nav = createListNavigation({
items,
mode: "selection",
baseId: "test",
});
nav.highlight("b");
expect(nav.highlightedValue()).toBe("b");
setItems(["a", "c"]);
expect(nav.highlightedValue()).toBeUndefined();
dispose();
});
});
});
describe("createListNavigation — horizontal orientation", () => {
it("ArrowRight navigates in horizontal mode", () => {
createRoot((dispose) => {
const nav = createListNavigation({
items: () => ["a", "b", "c"],
mode: "selection",
baseId: "test",
orientation: "horizontal",
});
nav.highlightFirst();
nav.containerProps.onKeyDown(
new KeyboardEvent("keydown", { key: "ArrowRight" }),
);
expect(nav.highlightedValue()).toBe("b");
dispose();
});
});
});
describe("createListNavigation — multiple selection", () => {
it("toggles items on and off", () => {
createRoot((dispose) => {
const onChange = vi.fn();
const nav = createListNavigation({
items: () => ["a", "b", "c"],
mode: "selection",
baseId: "test",
multiple: true,
onValuesChange: onChange,
});
nav.highlight("a");
nav.containerProps.onKeyDown(
new KeyboardEvent("keydown", { key: "Enter" }),
);
expect(onChange).toHaveBeenCalledWith(["a"]);
nav.highlight("b");
nav.containerProps.onKeyDown(
new KeyboardEvent("keydown", { key: "Enter" }),
);
expect(onChange).toHaveBeenCalledWith(["a", "b"]);
nav.highlight("a");
nav.containerProps.onKeyDown(
new KeyboardEvent("keydown", { key: "Enter" }),
);
expect(onChange).toHaveBeenCalledWith(["b"]);
dispose();
});
});
});