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";
|
import { createControllableSignal } from "./create-controllable-signal";
|
||||||
|
|
||||||
export interface CreateDisclosureStateOptions {
|
export interface CreateDisclosureStateOptions {
|
||||||
open?: boolean;
|
open?: boolean | undefined;
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean | undefined;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: ((open: boolean) => void) | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DisclosureState {
|
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 type { CreateDisclosureStateOptions, DisclosureState } from "./create-disclosure-state";
|
||||||
export { createFloating } from "./create-floating";
|
export { createFloating } from "./create-floating";
|
||||||
export type { CreateFloatingOptions, FloatingState } 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";
|
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