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:
parent
b249509cd7
commit
5bc9ac7b61
18
packages/core/src/components/listbox/index.ts
Normal file
18
packages/core/src/components/listbox/index.ts
Normal 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";
|
||||
26
packages/core/src/components/listbox/listbox-context.ts
Normal file
26
packages/core/src/components/listbox/listbox-context.ts
Normal 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;
|
||||
17
packages/core/src/components/listbox/listbox-group-label.tsx
Normal file
17
packages/core/src/components/listbox/listbox-group-label.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/core/src/components/listbox/listbox-group.tsx
Normal file
16
packages/core/src/components/listbox/listbox-group.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
packages/core/src/components/listbox/listbox-item.tsx
Normal file
29
packages/core/src/components/listbox/listbox-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
packages/core/src/components/listbox/listbox-root.tsx
Normal file
88
packages/core/src/components/listbox/listbox-root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
packages/core/src/components/select/index.ts
Normal file
9
packages/core/src/components/select/index.ts
Normal 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";
|
||||
69
packages/core/src/components/select/select-content.tsx
Normal file
69
packages/core/src/components/select/select-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
packages/core/src/components/select/select-context.ts
Normal file
70
packages/core/src/components/select/select-context.ts
Normal 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;
|
||||
17
packages/core/src/components/select/select-group-label.tsx
Normal file
17
packages/core/src/components/select/select-group-label.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/core/src/components/select/select-group.tsx
Normal file
16
packages/core/src/components/select/select-group.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
packages/core/src/components/select/select-hidden-select.tsx
Normal file
17
packages/core/src/components/select/select-hidden-select.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
29
packages/core/src/components/select/select-item.tsx
Normal file
29
packages/core/src/components/select/select-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
packages/core/src/components/select/select-root.tsx
Normal file
144
packages/core/src/components/select/select-root.tsx
Normal 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,
|
||||
});
|
||||
52
packages/core/src/components/select/select-trigger.tsx
Normal file
52
packages/core/src/components/select/select-trigger.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
packages/core/src/components/select/select-value.tsx
Normal file
20
packages/core/src/components/select/select-value.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
280
packages/core/src/primitives/create-list-navigation.ts
Normal file
280
packages/core/src/primitives/create-list-navigation.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@ -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";
|
||||
|
||||
114
packages/core/tests/components/listbox/listbox.test.tsx
Normal file
114
packages/core/tests/components/listbox/listbox.test.tsx
Normal 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("");
|
||||
});
|
||||
});
|
||||
145
packages/core/tests/components/select/select.test.tsx
Normal file
145
packages/core/tests/components/select/select.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
377
packages/core/tests/primitives/create-list-navigation.test.tsx
Normal file
377
packages/core/tests/primitives/create-list-navigation.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user