From 5bc9ac7b61b44bf392834754bb10b91aa8da71ad Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 19:12:05 +0700 Subject: [PATCH] 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. --- packages/core/src/components/listbox/index.ts | 18 + .../src/components/listbox/listbox-context.ts | 26 ++ .../listbox/listbox-group-label.tsx | 17 + .../src/components/listbox/listbox-group.tsx | 16 + .../src/components/listbox/listbox-item.tsx | 29 ++ .../src/components/listbox/listbox-root.tsx | 88 ++++ packages/core/src/components/select/index.ts | 9 + .../src/components/select/select-content.tsx | 69 ++++ .../src/components/select/select-context.ts | 70 ++++ .../components/select/select-group-label.tsx | 17 + .../src/components/select/select-group.tsx | 16 + .../select/select-hidden-select.tsx | 17 + .../src/components/select/select-item.tsx | 29 ++ .../src/components/select/select-root.tsx | 144 +++++++ .../src/components/select/select-trigger.tsx | 52 +++ .../src/components/select/select-value.tsx | 20 + .../src/primitives/create-disclosure-state.ts | 6 +- .../src/primitives/create-list-navigation.ts | 280 +++++++++++++ packages/core/src/primitives/index.ts | 5 + .../tests/components/listbox/listbox.test.tsx | 114 ++++++ .../tests/components/select/select.test.tsx | 145 +++++++ .../create-list-navigation.test.tsx | 377 ++++++++++++++++++ 22 files changed, 1561 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/components/listbox/index.ts create mode 100644 packages/core/src/components/listbox/listbox-context.ts create mode 100644 packages/core/src/components/listbox/listbox-group-label.tsx create mode 100644 packages/core/src/components/listbox/listbox-group.tsx create mode 100644 packages/core/src/components/listbox/listbox-item.tsx create mode 100644 packages/core/src/components/listbox/listbox-root.tsx create mode 100644 packages/core/src/components/select/index.ts create mode 100644 packages/core/src/components/select/select-content.tsx create mode 100644 packages/core/src/components/select/select-context.ts create mode 100644 packages/core/src/components/select/select-group-label.tsx create mode 100644 packages/core/src/components/select/select-group.tsx create mode 100644 packages/core/src/components/select/select-hidden-select.tsx create mode 100644 packages/core/src/components/select/select-item.tsx create mode 100644 packages/core/src/components/select/select-root.tsx create mode 100644 packages/core/src/components/select/select-trigger.tsx create mode 100644 packages/core/src/components/select/select-value.tsx create mode 100644 packages/core/src/primitives/create-list-navigation.ts create mode 100644 packages/core/tests/components/listbox/listbox.test.tsx create mode 100644 packages/core/tests/components/select/select.test.tsx create mode 100644 packages/core/tests/primitives/create-list-navigation.test.tsx diff --git a/packages/core/src/components/listbox/index.ts b/packages/core/src/components/listbox/index.ts new file mode 100644 index 0000000..e5bd745 --- /dev/null +++ b/packages/core/src/components/listbox/index.ts @@ -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"; diff --git a/packages/core/src/components/listbox/listbox-context.ts b/packages/core/src/components/listbox/listbox-context.ts new file mode 100644 index 0000000..d71e944 --- /dev/null +++ b/packages/core/src/components/listbox/listbox-context.ts @@ -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(); + +/** + * Returns the Listbox context. Throws if used outside . + */ +export function useListboxContext(): ListboxContextValue { + const ctx = useContext(ListboxContext); + if (!ctx) { + throw new Error( + "[PettyUI] Listbox.Item must be used inside .\n" + + " Fix: \n" + + ' A\n' + + " ", + ); + } + return ctx; +} + +export const ListboxContextProvider = ListboxContext.Provider; diff --git a/packages/core/src/components/listbox/listbox-group-label.tsx b/packages/core/src/components/listbox/listbox-group-label.tsx new file mode 100644 index 0000000..dc69f6b --- /dev/null +++ b/packages/core/src/components/listbox/listbox-group-label.tsx @@ -0,0 +1,17 @@ +import type { JSX } from "solid-js"; +import { createUniqueId, splitProps } from "solid-js"; + +export interface ListboxGroupLabelProps extends JSX.HTMLAttributes { + 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 ( + + ); +} diff --git a/packages/core/src/components/listbox/listbox-group.tsx b/packages/core/src/components/listbox/listbox-group.tsx new file mode 100644 index 0000000..c8393e7 --- /dev/null +++ b/packages/core/src/components/listbox/listbox-group.tsx @@ -0,0 +1,16 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; + +export interface ListboxGroupProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +/** Groups related Listbox items together. */ +export function ListboxGroup(props: ListboxGroupProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + return ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/listbox/listbox-item.tsx b/packages/core/src/components/listbox/listbox-item.tsx new file mode 100644 index 0000000..ba50765 --- /dev/null +++ b/packages/core/src/components/listbox/listbox-item.tsx @@ -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 { + /** 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 ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/listbox/listbox-root.tsx b/packages/core/src/components/listbox/listbox-root.tsx new file mode 100644 index 0000000..74e3fd8 --- /dev/null +++ b/packages/core/src/components/listbox/listbox-root.tsx @@ -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 { + /** 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 ( + +
+ {local.children} +
+
+ ); +} diff --git a/packages/core/src/components/select/index.ts b/packages/core/src/components/select/index.ts new file mode 100644 index 0000000..f5d2884 --- /dev/null +++ b/packages/core/src/components/select/index.ts @@ -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"; diff --git a/packages/core/src/components/select/select-content.tsx b/packages/core/src/components/select/select-content.tsx new file mode 100644 index 0000000..6530887 --- /dev/null +++ b/packages/core/src/components/select/select-content.tsx @@ -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 { + /** 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 = (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 ( + +
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} +
+
+ ); +} diff --git a/packages/core/src/components/select/select-context.ts b/packages/core/src/components/select/select-context.ts new file mode 100644 index 0000000..e9092b2 --- /dev/null +++ b/packages/core/src/components/select/select-context.ts @@ -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; + open: () => void; + close: () => void; + toggle: () => void; + navigation: ListNavigationState; + triggerRef: Accessor; + setTriggerRef: (el: HTMLElement | null) => void; + contentRef: Accessor; + setContentRef: (el: HTMLElement | null) => void; + selectedValue: Accessor; + onSelect: (value: string) => void; + disabled: Accessor; + required: Accessor; + name: Accessor; + contentId: string; + triggerId: string; + floatingStyle: Accessor; +} + +const InternalSelectContext = createContext(); + +/** + * 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 \n" + + " ...\n" + + " \n" + + ' A\n' + + " \n" + + " ", + ); + } + return ctx; +} + +export const InternalSelectContextProvider = InternalSelectContext.Provider; + +/** Public context exposed via Select.useContext(). */ +export interface SelectContextValue { + /** Currently selected value. */ + value: Accessor; + /** Whether the select dropdown is open. */ + open: Accessor; +} + +const SelectContext = createContext(); + +/** + * 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 + ); +} diff --git a/packages/core/src/components/select/select-item.tsx b/packages/core/src/components/select/select-item.tsx new file mode 100644 index 0000000..ca5e0f1 --- /dev/null +++ b/packages/core/src/components/select/select-item.tsx @@ -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 { + /** 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 ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/select/select-root.tsx b/packages/core/src/components/select/select-root.tsx new file mode 100644 index 0000000..95acb7e --- /dev/null +++ b/packages/core/src/components/select/select-root.tsx @@ -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(null); + const [contentRef, setContentRef] = createSignal(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, + middleware: (() => [offset(8), flip(), shift({ padding: 8 })]) as Accessor, + 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 ( + + + {local.children} + + + ); +} + +/** 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, +}); diff --git a/packages/core/src/components/select/select-trigger.tsx b/packages/core/src/components/select/select-trigger.tsx new file mode 100644 index 0000000..badc1a5 --- /dev/null +++ b/packages/core/src/components/select/select-trigger.tsx @@ -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 { + 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 = (e) => { + if (typeof local.onClick === "function") local.onClick(e); + if (!ctx.disabled()) ctx.toggle(); + }; + + const handleKeyDown: JSX.EventHandler = (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 ( + + ); +} diff --git a/packages/core/src/components/select/select-value.tsx b/packages/core/src/components/select/select-value.tsx new file mode 100644 index 0000000..134ba7f --- /dev/null +++ b/packages/core/src/components/select/select-value.tsx @@ -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 { + /** 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 ( + + {ctx.selectedValue() ?? local.placeholder ?? ""} + + ); +} diff --git a/packages/core/src/primitives/create-disclosure-state.ts b/packages/core/src/primitives/create-disclosure-state.ts index 67ee140..ef1739c 100644 --- a/packages/core/src/primitives/create-disclosure-state.ts +++ b/packages/core/src/primitives/create-disclosure-state.ts @@ -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 { diff --git a/packages/core/src/primitives/create-list-navigation.ts b/packages/core/src/primitives/create-list-navigation.ts new file mode 100644 index 0000000..36581b6 --- /dev/null +++ b/packages/core/src/primitives/create-list-navigation.ts @@ -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; + /** "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 | 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 | 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; + /** Currently selected value (selection mode, single). */ + selectedValue: Accessor; + /** Currently selected values (selection mode, multiple). */ + selectedValues: Accessor; + /** 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(undefined); + + const [internalValue, setInternalValue] = createSignal(options.defaultValue); + const resolveValue = (): string | undefined => { + if (options.value) return options.value(); + return internalValue(); + }; + + const [internalValues, setInternalValues] = createSignal(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 | 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), + }; +} diff --git a/packages/core/src/primitives/index.ts b/packages/core/src/primitives/index.ts index d20b880..48489e2 100644 --- a/packages/core/src/primitives/index.ts +++ b/packages/core/src/primitives/index.ts @@ -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"; diff --git a/packages/core/tests/components/listbox/listbox.test.tsx b/packages/core/tests/components/listbox/listbox.test.tsx new file mode 100644 index 0000000..5d95fe4 --- /dev/null +++ b/packages/core/tests/components/listbox/listbox.test.tsx @@ -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(() => ( + + A + B + C + + )); + expect(screen.getByRole("listbox")).toBeTruthy(); + }); + + it("items have role=option", () => { + render(() => ( + + A + B + + )); + expect(screen.getAllByRole("option")).toHaveLength(2); + }); + + it("aria-selected reflects selection", () => { + render(() => ( + + A + B + + )); + 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(() => ( + + A + B + + )); + fireEvent.click(screen.getAllByRole("option")[1]); + expect(onChange).toHaveBeenCalledWith("b"); + }); + + it("multiple selection mode works", () => { + const onChange = vi.fn(); + render(() => ( + + A + B + C + + )); + 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(() => ( + + A + B + C + + )); + 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(() => ( + + A + B + C + + )); + 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(() => ( + + A + B + C + + )); + const listbox = screen.getByRole("listbox"); + listbox.focus(); + fireEvent.keyDown(listbox, { key: "End" }); + const options = screen.getAllByRole("option"); + expect(options[2].getAttribute("data-highlighted")).toBe(""); + }); +}); diff --git a/packages/core/tests/components/select/select.test.tsx b/packages/core/tests/components/select/select.test.tsx new file mode 100644 index 0000000..5b679c6 --- /dev/null +++ b/packages/core/tests/components/select/select.test.tsx @@ -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(() => ( + + )); + expect(screen.getByRole("combobox")).toBeTruthy(); + }); + + it("content has role=listbox when open", () => { + render(() => ( + + )); + expect(screen.getByRole("listbox")).toBeTruthy(); + }); + + it("aria-selected on selected item", () => { + render(() => ( + + )); + 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(() => ( + + )); + expect(screen.queryByRole("listbox")).toBeNull(); + fireEvent.click(screen.getByRole("combobox")); + expect(screen.getByRole("listbox")).toBeTruthy(); + }); + + it("Escape closes without selecting", () => { + render(() => ( + + )); + 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(() => ( + + )); + 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(() => ( + + )); + 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(() => ( + + )); + expect(screen.getByRole("combobox")).toBeTruthy(); + }); + + it("hidden input rendered when name provided", () => { + render(() => ( + + )); + const hidden = document.querySelector( + "input[name='fruit']", + ) as HTMLInputElement; + expect(hidden).toBeTruthy(); + expect(hidden.value).toBe("a"); + }); +}); diff --git a/packages/core/tests/primitives/create-list-navigation.test.tsx b/packages/core/tests/primitives/create-list-navigation.test.tsx new file mode 100644 index 0000000..4da4ba1 --- /dev/null +++ b/packages/core/tests/primitives/create-list-navigation.test.tsx @@ -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("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(); + }); + }); +});