Combobox, DropdownMenu, ContextMenu, Toast

Combobox: text input + floating listbox with consumer-driven filtering,
  allowCustomValue, and aria-autocomplete support.
DropdownMenu: activation-mode menu with menuitem, menuitemcheckbox,
  menuitemradio roles, keyboard navigation, and typeahead.
ContextMenu: right-click triggered menu with virtual anchor positioning
  at pointer coordinates.
Toast: imperative API (toast(), toast.success/error/dismiss/clear) with
  reactive signal-based store and Toast.Region declarative renderer.
This commit is contained in:
Mats Bosson 2026-03-29 19:23:33 +07:00
parent 5bc9ac7b61
commit 6382a59eaf
39 changed files with 2119 additions and 0 deletions

View File

@ -0,0 +1,52 @@
import type { JSX } from "solid-js";
import { Show, createEffect, onCleanup, splitProps } from "solid-js";
import { createDismiss } from "../../utilities/dismiss/create-dismiss";
import { useInternalComboboxContext } from "./combobox-context";
export interface ComboboxContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Keep mounted when closed. @default false */
forceMount?: boolean | undefined;
children?: JSX.Element | undefined;
}
/**
* Floating dropdown content for Combobox. Contains the listbox with items.
* Handles dismiss on outside click; Escape is handled by the input.
*/
export function ComboboxContent(props: ComboboxContentProps): JSX.Element {
const [local, rest] = splitProps(props, ["children", "forceMount"]);
const ctx = useInternalComboboxContext();
const dismiss = createDismiss({
getContainer: () => ctx.contentRef(),
onDismiss: () => ctx.close(),
dismissOnEscape: false,
});
createEffect(() => {
if (ctx.isOpen()) {
dismiss.attach();
} else {
dismiss.detach();
}
onCleanup(() => dismiss.detach());
});
return (
<Show when={local.forceMount || ctx.isOpen()}>
<div
ref={(el) => ctx.setContentRef(el)}
id={ctx.contentId}
role="listbox"
aria-orientation="vertical"
aria-labelledby={ctx.inputId}
data-state={ctx.isOpen() ? "open" : "closed"}
style={ctx.floatingStyle()}
onPointerLeave={() => ctx.navigation.clearHighlight()}
{...rest}
>
{local.children}
</div>
</Show>
);
}

View File

@ -0,0 +1,78 @@
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 Combobox sub-components. */
export interface InternalComboboxContextValue {
isOpen: Accessor<boolean>;
open: () => void;
close: () => void;
toggle: () => void;
navigation: ListNavigationState;
inputRef: Accessor<HTMLInputElement | null>;
setInputRef: (el: HTMLInputElement | 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;
inputId: string;
items: Accessor<string[]>;
inputValue: Accessor<string>;
setInputValue: (value: string) => void;
onInputChange: ((value: string) => void) | undefined;
allowCustomValue: Accessor<boolean>;
onCustomValue: (value: string) => void;
floatingStyle: Accessor<JSX.CSSProperties>;
}
const InternalComboboxContext = createContext<InternalComboboxContextValue>();
/**
* Returns the internal Combobox context. Throws if used outside Combobox.
*/
export function useInternalComboboxContext(): InternalComboboxContextValue {
const ctx = useContext(InternalComboboxContext);
if (!ctx) {
throw new Error(
"[PettyUI] Combobox parts must be used inside <Combobox>.\n" +
" Fix: <Combobox items={[...]}>\n" +
" <Combobox.Input />\n" +
" <Combobox.Content>\n" +
' <Combobox.Item value="a">A</Combobox.Item>\n' +
" </Combobox.Content>\n" +
" </Combobox>",
);
}
return ctx;
}
export const InternalComboboxContextProvider = InternalComboboxContext.Provider;
/** Public context exposed via Combobox.useContext(). */
export interface ComboboxContextValue {
/** Currently selected value. */
value: Accessor<string | undefined>;
/** Whether the combobox dropdown is open. */
open: Accessor<boolean>;
/** Current input text. */
inputValue: Accessor<string>;
}
const ComboboxContext = createContext<ComboboxContextValue>();
/**
* Returns the public Combobox context. Throws if used outside Combobox.
*/
export function useComboboxContext(): ComboboxContextValue {
const ctx = useContext(ComboboxContext);
if (!ctx) {
throw new Error("[PettyUI] Combobox.useContext() called outside of <Combobox>.");
}
return ctx;
}
export const ComboboxContextProvider = ComboboxContext.Provider;

View File

@ -0,0 +1,21 @@
import type { JSX } from "solid-js";
import { Show, splitProps } from "solid-js";
import { useInternalComboboxContext } from "./combobox-context";
export interface ComboboxEmptyProps extends JSX.HTMLAttributes<HTMLDivElement> {
children?: JSX.Element | undefined;
}
/** Shown when the Combobox items list is empty and the dropdown is open. */
export function ComboboxEmpty(props: ComboboxEmptyProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const ctx = useInternalComboboxContext();
return (
<Show when={ctx.items().length === 0 && ctx.isOpen()}>
<div role="status" {...rest}>
{local.children}
</div>
</Show>
);
}

View File

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

View File

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

View File

@ -0,0 +1,98 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useInternalComboboxContext } from "./combobox-context";
export interface ComboboxInputProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
children?: JSX.Element | undefined;
}
/**
* Input element for Combobox. Has role="combobox" and handles typing,
* arrow-key navigation, Enter selection, and Escape to close.
*/
export function ComboboxInput(props: ComboboxInputProps): JSX.Element {
const [local, rest] = splitProps(props, ["onInput", "onKeyDown", "children"]);
const ctx = useInternalComboboxContext();
const handleInput: JSX.EventHandler<HTMLInputElement, InputEvent> = (e) => {
if (typeof local.onInput === "function") {
(local.onInput as (e: InputEvent & { currentTarget: HTMLInputElement }) => void)(e);
}
const value = e.currentTarget.value;
ctx.setInputValue(value);
ctx.onInputChange?.(value);
if (!ctx.isOpen()) ctx.open();
};
/** Handles Enter key: selects highlighted item or commits custom value. */
function handleEnter(): void {
const highlighted = ctx.navigation.highlightedValue();
if (highlighted !== undefined) {
ctx.onSelect(highlighted);
return;
}
if (ctx.allowCustomValue() && ctx.inputValue() !== "") {
ctx.navigation.clearHighlight();
ctx.close();
ctx.onCustomValue(ctx.inputValue());
}
}
const handleKeyDown: JSX.EventHandler<HTMLInputElement, KeyboardEvent> = (e) => {
if (typeof local.onKeyDown === "function") local.onKeyDown(e);
if (ctx.disabled()) return;
switch (e.key) {
case "ArrowDown": {
e.preventDefault();
if (!ctx.isOpen()) ctx.open();
ctx.navigation.containerProps.onKeyDown(e);
break;
}
case "ArrowUp": {
e.preventDefault();
ctx.navigation.containerProps.onKeyDown(e);
break;
}
case "Enter": {
e.preventDefault();
handleEnter();
break;
}
case "Escape": {
e.preventDefault();
if (ctx.isOpen()) {
ctx.close();
} else {
ctx.setInputValue("");
ctx.onInputChange?.("");
}
break;
}
default:
break;
}
};
return (
<input
ref={(el) => ctx.setInputRef(el)}
type="text"
role="combobox"
id={ctx.inputId}
value={ctx.inputValue()}
aria-expanded={ctx.isOpen()}
aria-haspopup="listbox"
aria-autocomplete="list"
aria-controls={ctx.isOpen() ? ctx.contentId : undefined}
aria-activedescendant={
ctx.isOpen() ? ctx.navigation.containerProps["aria-activedescendant"] : undefined
}
data-state={ctx.isOpen() ? "open" : "closed"}
disabled={ctx.disabled()}
onInput={handleInput}
onKeyDown={handleKeyDown}
{...rest}
/>
);
}

View File

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

View File

@ -0,0 +1,156 @@
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 { ComboboxContent } from "./combobox-content";
import {
ComboboxContextProvider,
InternalComboboxContextProvider,
useComboboxContext,
} from "./combobox-context";
import type { InternalComboboxContextValue } from "./combobox-context";
import { ComboboxEmpty } from "./combobox-empty";
import { ComboboxGroup } from "./combobox-group";
import { ComboboxGroupLabel } from "./combobox-group-label";
import { ComboboxInput } from "./combobox-input";
import { ComboboxItem } from "./combobox-item";
import { ComboboxTrigger } from "./combobox-trigger";
export interface ComboboxRootProps {
/** Ordered list of active (already filtered) item values. */
items: string[];
/** Controlled selected value. */
value?: string | undefined;
/** Initial selected value (uncontrolled). */
defaultValue?: string | undefined;
/** Called when value changes. */
onValueChange?: ((value: string) => void) | undefined;
/** Controlled open state. */
open?: boolean | undefined;
/** Initial open state (uncontrolled). */
defaultOpen?: boolean | undefined;
/** Called when open state changes. */
onOpenChange?: ((open: boolean) => void) | undefined;
/** Controlled input text value. */
inputValue?: string | undefined;
/** Called when the input text changes. */
onInputChange?: ((value: string) => void) | undefined;
/** Allow selecting a value not in the items list. */
allowCustomValue?: boolean | undefined;
/** Resolve value to display label. */
getLabel?: ((value: string) => string) | undefined;
/** Whether the combobox is disabled. */
disabled?: boolean | undefined;
/** Whether selection is required. */
required?: boolean | undefined;
/** Form field name. */
name?: string | undefined;
children: JSX.Element;
}
/** Creates the three core primitives (disclosure, navigation, floating). */
function createComboboxPrimitives(
local: ComboboxRootProps,
inputRef: Accessor<HTMLElement | null>,
contentRef: Accessor<HTMLElement | null>,
setInternalInputValue: (v: string) => void,
baseId: string,
) {
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,
typeahead: false,
onValueChange: (v) => {
local.onValueChange?.(v);
setInternalInputValue(local.getLabel ? local.getLabel(v) : v);
disclosure.close();
},
getLabel: local.getLabel,
baseId,
});
const floating = createFloating({
anchor: inputRef,
floating: contentRef,
placement: (() => "bottom-start") as Accessor<Placement>,
middleware: (() => [offset(8), flip(), shift({ padding: 8 })]) as Accessor<Middleware[]>,
open: disclosure.isOpen,
});
return { disclosure, navigation, floating };
}
/**
* Root component for Combobox. Manages open state, selection, input value,
* floating positioning, and keyboard navigation via context.
*/
export function ComboboxRoot(props: ComboboxRootProps): JSX.Element {
const [local] = splitProps(props, [
"items", "value", "defaultValue", "onValueChange",
"open", "defaultOpen", "onOpenChange",
"inputValue", "onInputChange", "allowCustomValue",
"getLabel", "disabled", "required", "name", "children",
]);
const inputId = createUniqueId();
const contentId = createUniqueId();
const baseId = createUniqueId();
const [inputRef, setInputRef] = createSignal<HTMLInputElement | null>(null);
const [contentRef, setContentRef] = createSignal<HTMLElement | null>(null);
const [internalInputValue, setInternalInputValue] = createSignal("");
const resolveInputValue = () => local.inputValue ?? internalInputValue();
const { disclosure, navigation, floating } = createComboboxPrimitives(
local, inputRef as Accessor<HTMLElement | null>, contentRef, setInternalInputValue, baseId,
);
const ctx: InternalComboboxContextValue = {
isOpen: disclosure.isOpen, open: disclosure.open,
close: disclosure.close, toggle: disclosure.toggle,
navigation, inputRef, setInputRef, contentRef, setContentRef,
selectedValue: navigation.selectedValue,
onSelect: (value: string) => navigation.getItemProps(value).onClick(),
disabled: () => local.disabled ?? false,
required: () => local.required ?? false,
name: () => local.name, contentId, inputId,
items: () => local.items, inputValue: resolveInputValue,
setInputValue: setInternalInputValue, onInputChange: local.onInputChange,
allowCustomValue: () => local.allowCustomValue ?? false,
onCustomValue: (v: string) => { local.onValueChange?.(v); disclosure.close(); },
floatingStyle: floating.style,
};
return (
<InternalComboboxContextProvider value={ctx}>
<ComboboxContextProvider
value={{ value: navigation.selectedValue, open: disclosure.isOpen, inputValue: resolveInputValue }}
>
{local.children}
</ComboboxContextProvider>
</InternalComboboxContextProvider>
);
}
/** Compound Combobox component with all sub-components as static properties. */
export const Combobox = Object.assign(ComboboxRoot, {
Input: ComboboxInput,
Trigger: ComboboxTrigger,
Content: ComboboxContent,
Item: ComboboxItem,
Empty: ComboboxEmpty,
Group: ComboboxGroup,
GroupLabel: ComboboxGroupLabel,
useContext: useComboboxContext,
});

View File

@ -0,0 +1,34 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useInternalComboboxContext } from "./combobox-context";
export interface ComboboxTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
children?: JSX.Element | undefined;
}
/** Optional toggle button for Combobox. Not in tab order; input is primary. */
export function ComboboxTrigger(props: ComboboxTriggerProps): JSX.Element {
const [local, rest] = splitProps(props, ["children", "onClick"]);
const ctx = useInternalComboboxContext();
const handleClick: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (e) => {
if (typeof local.onClick === "function") local.onClick(e);
if (!ctx.disabled()) ctx.toggle();
};
return (
<button
type="button"
tabIndex={-1}
aria-label="Toggle"
aria-expanded={ctx.isOpen()}
aria-controls={ctx.isOpen() ? ctx.contentId : undefined}
data-state={ctx.isOpen() ? "open" : "closed"}
disabled={ctx.disabled()}
onClick={handleClick}
{...rest}
>
{local.children}
</button>
);
}

View File

@ -0,0 +1,9 @@
export { Combobox, ComboboxRoot } from "./combobox-root";
export { ComboboxInput } from "./combobox-input";
export { ComboboxTrigger } from "./combobox-trigger";
export { ComboboxContent } from "./combobox-content";
export { ComboboxItem } from "./combobox-item";
export { ComboboxEmpty } from "./combobox-empty";
export { ComboboxGroup } from "./combobox-group";
export { ComboboxGroupLabel } from "./combobox-group-label";
export { useComboboxContext } from "./combobox-context";

View File

@ -0,0 +1,68 @@
import type { JSX } from "solid-js";
import { Show, createEffect, onCleanup, splitProps } from "solid-js";
import { createDismiss } from "../../utilities/dismiss/create-dismiss";
import { useInternalContextMenuContext } from "./context-menu-context";
export interface ContextMenuContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Keep mounted when closed. @default false */
forceMount?: boolean | undefined;
children?: JSX.Element;
}
/**
* Floating content panel for ContextMenu. Contains the menu items.
* Handles dismiss (outside click) and keyboard navigation.
*/
export function ContextMenuContent(props: ContextMenuContentProps): JSX.Element {
const [local, rest] = splitProps(props, ["children", "forceMount"]);
const ctx = useInternalContextMenuContext();
const dismiss = createDismiss({
getContainer: () => ctx.contentRef(),
onDismiss: () => ctx.close(),
dismissOnEscape: false,
});
createEffect(() => {
if (ctx.isOpen()) {
dismiss.attach();
queueMicrotask(() => ctx.contentRef()?.focus());
} else {
dismiss.detach();
}
onCleanup(() => dismiss.detach());
});
const handleKeyDown: JSX.EventHandler<HTMLDivElement, KeyboardEvent> = (e) => {
if (e.key === "Escape") {
e.preventDefault();
ctx.close();
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="menu"
aria-orientation="vertical"
aria-activedescendant={ctx.navigation.containerProps["aria-activedescendant"]}
data-state={ctx.isOpen() ? "open" : "closed"}
style={ctx.floatingStyle()}
tabIndex={-1}
onKeyDown={handleKeyDown}
onPointerLeave={() => ctx.navigation.clearHighlight()}
{...rest}
>
{local.children}
</div>
</Show>
);
}

View File

@ -0,0 +1,67 @@
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 ContextMenu sub-components. */
export interface InternalContextMenuContextValue {
isOpen: Accessor<boolean>;
open: () => void;
close: () => void;
navigation: ListNavigationState;
contentRef: Accessor<HTMLElement | null>;
setContentRef: (el: HTMLElement | null) => void;
contentId: string;
floatingStyle: Accessor<JSX.CSSProperties>;
/** Update the virtual anchor position from pointer coordinates. */
setAnchorPosition: (x: number, y: number) => void;
/** Called when a regular menu item is activated. Closes the menu. */
onItemActivate: (value: string) => void;
/** Register an item's onSelect callback. */
registerItemHandler: (value: string, handler: () => void) => void;
/** Unregister an item's onSelect callback. */
unregisterItemHandler: (value: string) => void;
}
const InternalContextMenuContext = createContext<InternalContextMenuContextValue>();
/**
* Returns the internal ContextMenu context. Throws if used outside ContextMenu.
*/
export function useInternalContextMenuContext(): InternalContextMenuContextValue {
const ctx = useContext(InternalContextMenuContext);
if (!ctx) {
throw new Error(
"[PettyUI] ContextMenu parts must be used inside <ContextMenu>.\n" +
" Fix: <ContextMenu items={[...]}>\n" +
" <ContextMenu.Trigger>...</ContextMenu.Trigger>\n" +
" <ContextMenu.Content>\n" +
' <ContextMenu.Item value="a">A</ContextMenu.Item>\n' +
" </ContextMenu.Content>\n" +
" </ContextMenu>",
);
}
return ctx;
}
export const InternalContextMenuContextProvider = InternalContextMenuContext.Provider;
/** Public context exposed via ContextMenu.useContext(). */
export interface ContextMenuContextValue {
/** Whether the context menu is open. */
open: Accessor<boolean>;
}
const ContextMenuPublicContext = createContext<ContextMenuContextValue>();
/**
* Returns the public ContextMenu context. Throws if used outside ContextMenu.
*/
export function useContextMenuContext(): ContextMenuContextValue {
const ctx = useContext(ContextMenuPublicContext);
if (!ctx) {
throw new Error("[PettyUI] ContextMenu.useContext() called outside of <ContextMenu>.");
}
return ctx;
}
export const ContextMenuPublicContextProvider = ContextMenuPublicContext.Provider;

View File

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

View File

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

View File

@ -0,0 +1,46 @@
import type { JSX } from "solid-js";
import { onCleanup, onMount, splitProps } from "solid-js";
import { useInternalContextMenuContext } from "./context-menu-context";
export interface ContextMenuItemProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** The value this item represents. */
value: string;
/** Whether this item is disabled. */
disabled?: boolean | undefined;
/** Called when the item is activated (click or Enter/Space). */
onSelect?: (() => void) | undefined;
children?: JSX.Element;
}
/** A single activatable option within a ContextMenu. */
export function ContextMenuItem(props: ContextMenuItemProps): JSX.Element {
const [local, rest] = splitProps(props, ["value", "disabled", "onSelect", "children"]);
const ctx = useInternalContextMenuContext();
const itemProps = () => ctx.navigation.getItemProps(local.value);
onMount(() => {
ctx.registerItemHandler(local.value, () => local.onSelect?.());
});
onCleanup(() => {
ctx.unregisterItemHandler(local.value);
});
const handleClick: JSX.EventHandler<HTMLDivElement, MouseEvent> = () => {
if (local.disabled) return;
local.onSelect?.();
ctx.close();
};
return (
<div
aria-disabled={local.disabled || undefined}
data-disabled={local.disabled || undefined}
{...itemProps()}
onClick={handleClick}
{...rest}
>
{local.children}
</div>
);
}

View File

@ -0,0 +1,158 @@
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 { ContextMenuContent } from "./context-menu-content";
import {
ContextMenuPublicContextProvider,
InternalContextMenuContextProvider,
useContextMenuContext,
} from "./context-menu-context";
import type { InternalContextMenuContextValue } from "./context-menu-context";
import { ContextMenuGroup } from "./context-menu-group";
import { ContextMenuGroupLabel } from "./context-menu-group-label";
import { ContextMenuItem } from "./context-menu-item";
import { ContextMenuSeparator } from "./context-menu-separator";
import { ContextMenuTrigger } from "./context-menu-trigger";
export interface ContextMenuRootProps {
/** Ordered list of active item values for keyboard navigation. */
items?: string[] | undefined;
/** Controlled open state. */
open?: boolean | undefined;
/** Initial open state (uncontrolled). */
defaultOpen?: boolean | undefined;
/** Called when open state changes. */
onOpenChange?: ((open: boolean) => void) | undefined;
children: JSX.Element;
}
/** Virtual anchor element that positions the floating menu at pointer coordinates. */
interface VirtualAnchor {
getBoundingClientRect: () => {
x: number;
y: number;
width: number;
height: number;
top: number;
left: number;
right: number;
bottom: number;
};
}
/** Creates a virtual anchor element from pointer coordinates. */
function createVirtualAnchorElement(x: number, y: number): VirtualAnchor {
return {
getBoundingClientRect: () => ({
x,
y,
width: 0,
height: 0,
top: y,
left: x,
right: x,
bottom: y,
}),
};
}
/**
* Root component for ContextMenu. Manages open state, virtual anchor
* positioning at pointer coordinates, and keyboard navigation via context.
*/
export function ContextMenuRoot(props: ContextMenuRootProps): JSX.Element {
const [local] = splitProps(props, [
"items",
"open",
"defaultOpen",
"onOpenChange",
"children",
]);
const contentId = createUniqueId();
const baseId = createUniqueId();
const [virtualAnchor, setVirtualAnchor] = createSignal<VirtualAnchor | null>(null);
const [contentRef, setContentRef] = createSignal<HTMLElement | null>(null);
const itemHandlers = new Map<string, () => void>();
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: "activation",
onActivate: (value: string) => {
const handler = itemHandlers.get(value);
if (handler) handler();
disclosure.close();
},
baseId,
});
const floating = createFloating({
anchor: virtualAnchor,
floating: contentRef,
placement: (() => "bottom-start") as Accessor<Placement>,
middleware: (() => [offset(2), flip(), shift({ padding: 8 })]) as Accessor<Middleware[]>,
open: disclosure.isOpen,
});
const ctx: InternalContextMenuContextValue = {
isOpen: disclosure.isOpen,
open: disclosure.open,
close: disclosure.close,
navigation,
contentRef,
setContentRef,
contentId,
floatingStyle: floating.style,
setAnchorPosition: (x: number, y: number) => {
setVirtualAnchor(createVirtualAnchorElement(x, y));
},
onItemActivate: (value: string) => {
const handler = itemHandlers.get(value);
if (handler) handler();
disclosure.close();
},
registerItemHandler: (value: string, handler: () => void) => {
itemHandlers.set(value, handler);
},
unregisterItemHandler: (value: string) => {
itemHandlers.delete(value);
},
};
return (
<InternalContextMenuContextProvider value={ctx}>
<ContextMenuPublicContextProvider value={{ open: disclosure.isOpen }}>
{local.children}
</ContextMenuPublicContextProvider>
</InternalContextMenuContextProvider>
);
}
/** Compound ContextMenu component with all sub-components as static properties. */
export const ContextMenu = Object.assign(ContextMenuRoot, {
Trigger: ContextMenuTrigger,
Content: ContextMenuContent,
Item: ContextMenuItem,
Group: ContextMenuGroup,
GroupLabel: ContextMenuGroupLabel,
Separator: ContextMenuSeparator,
useContext: useContextMenuContext,
});

View File

@ -0,0 +1,16 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
export interface ContextMenuSeparatorProps extends JSX.HTMLAttributes<HTMLDivElement> {
children?: JSX.Element;
}
/** A visual separator between ContextMenu items. */
export function ContextMenuSeparator(props: ContextMenuSeparatorProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
return (
<div role="separator" {...rest}>
{local.children}
</div>
);
}

View File

@ -0,0 +1,33 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useInternalContextMenuContext } from "./context-menu-context";
export interface ContextMenuTriggerProps extends JSX.HTMLAttributes<HTMLDivElement> {
children?: JSX.Element;
}
/**
* Area that responds to right-click (contextmenu) to open the ContextMenu.
* Does NOT render aria-haspopup since context menus are discoverable by convention.
*/
export function ContextMenuTrigger(props: ContextMenuTriggerProps): JSX.Element {
const [local, rest] = splitProps(props, ["children", "onContextMenu"]);
const ctx = useInternalContextMenuContext();
const handleContextMenu: JSX.EventHandler<HTMLDivElement, PointerEvent> = (e) => {
e.preventDefault();
if (typeof local.onContextMenu === "function") local.onContextMenu(e);
ctx.setAnchorPosition(e.clientX, e.clientY);
ctx.open();
};
return (
<div
data-state={ctx.isOpen() ? "open" : "closed"}
onContextMenu={handleContextMenu}
{...rest}
>
{local.children}
</div>
);
}

View File

@ -0,0 +1,8 @@
export { ContextMenu, ContextMenuRoot } from "./context-menu-root";
export { ContextMenuTrigger } from "./context-menu-trigger";
export { ContextMenuContent } from "./context-menu-content";
export { ContextMenuItem } from "./context-menu-item";
export { ContextMenuGroup } from "./context-menu-group";
export { ContextMenuGroupLabel } from "./context-menu-group-label";
export { ContextMenuSeparator } from "./context-menu-separator";
export { useContextMenuContext } from "./context-menu-context";

View File

@ -0,0 +1,41 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
export interface DropdownMenuCheckboxItemProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Whether the checkbox is checked. */
checked: boolean;
/** Called when checked state changes. */
onCheckedChange?: ((checked: boolean) => void) | undefined;
/** Whether this item is disabled. */
disabled?: boolean | undefined;
children?: JSX.Element;
}
/** A menu item that toggles a boolean checked state. */
export function DropdownMenuCheckboxItem(props: DropdownMenuCheckboxItemProps): JSX.Element {
const [local, rest] = splitProps(props, [
"checked",
"onCheckedChange",
"disabled",
"children",
]);
const handleClick: JSX.EventHandler<HTMLDivElement, MouseEvent> = () => {
if (local.disabled) return;
local.onCheckedChange?.(!local.checked);
};
return (
<div
role="menuitemcheckbox"
aria-checked={local.checked}
aria-disabled={local.disabled || undefined}
data-disabled={local.disabled || undefined}
data-state={local.checked ? "checked" : "unchecked"}
onClick={handleClick}
{...rest}
>
{local.children}
</div>
);
}

View File

@ -0,0 +1,69 @@
import type { JSX } from "solid-js";
import { Show, createEffect, onCleanup, splitProps } from "solid-js";
import { createDismiss } from "../../utilities/dismiss/create-dismiss";
import { useInternalDropdownMenuContext } from "./dropdown-menu-context";
export interface DropdownMenuContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Keep mounted when closed. @default false */
forceMount?: boolean | undefined;
children?: JSX.Element;
}
/**
* Floating dropdown content for DropdownMenu. Contains the menu with items.
* Handles dismiss (outside click) and keyboard navigation.
*/
export function DropdownMenuContent(props: DropdownMenuContentProps): JSX.Element {
const [local, rest] = splitProps(props, ["children", "forceMount"]);
const ctx = useInternalDropdownMenuContext();
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="menu"
aria-orientation="vertical"
aria-activedescendant={ctx.navigation.containerProps["aria-activedescendant"]}
aria-labelledby={ctx.triggerId}
data-state={ctx.isOpen() ? "open" : "closed"}
style={ctx.floatingStyle()}
tabIndex={-1}
onKeyDown={handleKeyDown}
onPointerLeave={() => ctx.navigation.clearHighlight()}
{...rest}
>
{local.children}
</div>
</Show>
);
}

View File

@ -0,0 +1,92 @@
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 DropdownMenu sub-components. */
export interface InternalDropdownMenuContextValue {
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;
contentId: string;
triggerId: string;
floatingStyle: Accessor<JSX.CSSProperties>;
/** Called when a regular menu item is activated. Closes the menu. */
onItemActivate: (value: string) => void;
/** Register a menu item value and optional onSelect handler. */
registerItem: (value: string, handler?: (() => void) | undefined) => void;
/** Unregister a menu item value. */
unregisterItem: (value: string) => void;
}
const InternalDropdownMenuContext = createContext<InternalDropdownMenuContextValue>();
/**
* Returns the internal DropdownMenu context. Throws if used outside DropdownMenu.
*/
export function useInternalDropdownMenuContext(): InternalDropdownMenuContextValue {
const ctx = useContext(InternalDropdownMenuContext);
if (!ctx) {
throw new Error(
"[PettyUI] DropdownMenu parts must be used inside <DropdownMenu>.\n" +
" Fix: <DropdownMenu>\n" +
" <DropdownMenu.Trigger>...</DropdownMenu.Trigger>\n" +
" <DropdownMenu.Content>\n" +
' <DropdownMenu.Item value="a">A</DropdownMenu.Item>\n' +
" </DropdownMenu.Content>\n" +
" </DropdownMenu>",
);
}
return ctx;
}
export const InternalDropdownMenuContextProvider = InternalDropdownMenuContext.Provider;
/** Context for DropdownMenu RadioGroup. */
export interface DropdownMenuRadioGroupContextValue {
value: Accessor<string | undefined>;
onValueChange: ((value: string) => void) | undefined;
}
const DropdownMenuRadioGroupContext = createContext<DropdownMenuRadioGroupContextValue>();
/**
* Returns the DropdownMenu RadioGroup context. Throws if used outside RadioGroup.
*/
export function useDropdownMenuRadioGroupContext(): DropdownMenuRadioGroupContextValue {
const ctx = useContext(DropdownMenuRadioGroupContext);
if (!ctx) {
throw new Error(
"[PettyUI] DropdownMenu.RadioItem must be used inside <DropdownMenu.RadioGroup>.",
);
}
return ctx;
}
export const DropdownMenuRadioGroupContextProvider = DropdownMenuRadioGroupContext.Provider;
/** Public context exposed via DropdownMenu.useContext(). */
export interface DropdownMenuContextValue {
/** Whether the dropdown menu is open. */
open: Accessor<boolean>;
}
const DropdownMenuPublicContext = createContext<DropdownMenuContextValue>();
/**
* Returns the public DropdownMenu context. Throws if used outside DropdownMenu.
*/
export function useDropdownMenuContext(): DropdownMenuContextValue {
const ctx = useContext(DropdownMenuPublicContext);
if (!ctx) {
throw new Error("[PettyUI] DropdownMenu.useContext() called outside of <DropdownMenu>.");
}
return ctx;
}
export const DropdownMenuPublicContextProvider = DropdownMenuPublicContext.Provider;

View File

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

View File

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

View File

@ -0,0 +1,46 @@
import type { JSX } from "solid-js";
import { onCleanup, onMount, splitProps } from "solid-js";
import { useInternalDropdownMenuContext } from "./dropdown-menu-context";
export interface DropdownMenuItemProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** The value this item represents. */
value: string;
/** Whether this item is disabled. */
disabled?: boolean | undefined;
/** Called when the item is activated (click or Enter/Space). */
onSelect?: (() => void) | undefined;
children?: JSX.Element;
}
/** A single activatable option within a DropdownMenu. */
export function DropdownMenuItem(props: DropdownMenuItemProps): JSX.Element {
const [local, rest] = splitProps(props, ["value", "disabled", "onSelect", "children"]);
const ctx = useInternalDropdownMenuContext();
const itemProps = () => ctx.navigation.getItemProps(local.value);
onMount(() => {
ctx.registerItem(local.value, () => local.onSelect?.());
});
onCleanup(() => {
ctx.unregisterItem(local.value);
});
const handleClick: JSX.EventHandler<HTMLDivElement, MouseEvent> = () => {
if (local.disabled) return;
local.onSelect?.();
ctx.close();
};
return (
<div
aria-disabled={local.disabled || undefined}
data-disabled={local.disabled || undefined}
{...itemProps()}
onClick={handleClick}
{...rest}
>
{local.children}
</div>
);
}

View File

@ -0,0 +1,29 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { DropdownMenuRadioGroupContextProvider } from "./dropdown-menu-context";
export interface DropdownMenuRadioGroupProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Currently selected radio value. */
value: string;
/** Called when the selected value changes. */
onValueChange?: ((value: string) => void) | undefined;
children?: JSX.Element;
}
/** Groups radio items within a DropdownMenu, managing single-select state. */
export function DropdownMenuRadioGroup(props: DropdownMenuRadioGroupProps): JSX.Element {
const [local, rest] = splitProps(props, ["value", "onValueChange", "children"]);
return (
<DropdownMenuRadioGroupContextProvider
value={{
value: () => local.value,
onValueChange: local.onValueChange,
}}
>
<div role="group" {...rest}>
{local.children}
</div>
</DropdownMenuRadioGroupContextProvider>
);
}

View File

@ -0,0 +1,38 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useDropdownMenuRadioGroupContext } from "./dropdown-menu-context";
export interface DropdownMenuRadioItemProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** The value this radio item represents. */
value: string;
/** Whether this item is disabled. */
disabled?: boolean | undefined;
children?: JSX.Element;
}
/** A radio-selectable menu item within a DropdownMenu.RadioGroup. */
export function DropdownMenuRadioItem(props: DropdownMenuRadioItemProps): JSX.Element {
const [local, rest] = splitProps(props, ["value", "disabled", "children"]);
const radioCtx = useDropdownMenuRadioGroupContext();
const isChecked = () => radioCtx.value() === local.value;
const handleClick: JSX.EventHandler<HTMLDivElement, MouseEvent> = () => {
if (local.disabled) return;
radioCtx.onValueChange?.(local.value);
};
return (
<div
role="menuitemradio"
aria-checked={isChecked()}
aria-disabled={local.disabled || undefined}
data-disabled={local.disabled || undefined}
data-state={isChecked() ? "checked" : "unchecked"}
onClick={handleClick}
{...rest}
>
{local.children}
</div>
);
}

View File

@ -0,0 +1,137 @@
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 { DropdownMenuCheckboxItem } from "./dropdown-menu-checkbox-item";
import { DropdownMenuContent } from "./dropdown-menu-content";
import {
DropdownMenuPublicContextProvider,
InternalDropdownMenuContextProvider,
useDropdownMenuContext,
} from "./dropdown-menu-context";
import type { InternalDropdownMenuContextValue } from "./dropdown-menu-context";
import { DropdownMenuGroup } from "./dropdown-menu-group";
import { DropdownMenuGroupLabel } from "./dropdown-menu-group-label";
import { DropdownMenuItem } from "./dropdown-menu-item";
import { DropdownMenuRadioGroup } from "./dropdown-menu-radio-group";
import { DropdownMenuRadioItem } from "./dropdown-menu-radio-item";
import { DropdownMenuSeparator } from "./dropdown-menu-separator";
import { DropdownMenuTrigger } from "./dropdown-menu-trigger";
export interface DropdownMenuRootProps {
/** Controlled open state. */
open?: boolean | undefined;
/** Initial open state (uncontrolled). */
defaultOpen?: boolean | undefined;
/** Called when open state changes. */
onOpenChange?: ((open: boolean) => void) | undefined;
children: JSX.Element;
}
/**
* Root component for DropdownMenu. Manages open state, floating
* positioning, and keyboard navigation via context.
* Items self-register their values on mount.
*/
export function DropdownMenuRoot(props: DropdownMenuRootProps): JSX.Element {
const [local] = splitProps(props, [
"open",
"defaultOpen",
"onOpenChange",
"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 [registeredItems, setRegisteredItems] = createSignal<string[]>([]);
const itemHandlers = new Map<string, () => void>();
const disclosure = createDisclosureState({
get open() {
return local.open;
},
get defaultOpen() {
return local.defaultOpen;
},
get onOpenChange() {
return local.onOpenChange;
},
});
const navigation = createListNavigation({
items: registeredItems,
mode: "activation",
onActivate: (value: string) => {
const handler = itemHandlers.get(value);
if (handler) handler();
disclosure.close();
},
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: InternalDropdownMenuContextValue = {
isOpen: disclosure.isOpen,
open: disclosure.open,
close: disclosure.close,
toggle: disclosure.toggle,
navigation,
triggerRef,
setTriggerRef,
contentRef,
setContentRef,
contentId,
triggerId,
floatingStyle: floating.style,
onItemActivate: (value: string) => {
const handler = itemHandlers.get(value);
if (handler) handler();
disclosure.close();
},
registerItem: (value: string, handler?: (() => void) | undefined) => {
if (handler) itemHandlers.set(value, handler);
setRegisteredItems((prev) => (prev.includes(value) ? prev : [...prev, value]));
},
unregisterItem: (value: string) => {
itemHandlers.delete(value);
setRegisteredItems((prev) => prev.filter((v) => v !== value));
},
};
return (
<InternalDropdownMenuContextProvider value={ctx}>
<DropdownMenuPublicContextProvider value={{ open: disclosure.isOpen }}>
{local.children}
</DropdownMenuPublicContextProvider>
</InternalDropdownMenuContextProvider>
);
}
/** Compound DropdownMenu component with all sub-components as static properties. */
export const DropdownMenu = Object.assign(DropdownMenuRoot, {
Trigger: DropdownMenuTrigger,
Content: DropdownMenuContent,
Item: DropdownMenuItem,
Group: DropdownMenuGroup,
GroupLabel: DropdownMenuGroupLabel,
Separator: DropdownMenuSeparator,
CheckboxItem: DropdownMenuCheckboxItem,
RadioGroup: DropdownMenuRadioGroup,
RadioItem: DropdownMenuRadioItem,
useContext: useDropdownMenuContext,
});

View File

@ -0,0 +1,16 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
export interface DropdownMenuSeparatorProps extends JSX.HTMLAttributes<HTMLDivElement> {
children?: JSX.Element;
}
/** A visual separator between DropdownMenu items. */
export function DropdownMenuSeparator(props: DropdownMenuSeparatorProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
return (
<div role="separator" {...rest}>
{local.children}
</div>
);
}

View File

@ -0,0 +1,49 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useInternalDropdownMenuContext } from "./dropdown-menu-context";
export interface DropdownMenuTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
children?: JSX.Element;
}
/** Button that opens the DropdownMenu. */
export function DropdownMenuTrigger(props: DropdownMenuTriggerProps): JSX.Element {
const [local, rest] = splitProps(props, ["children", "onClick", "onKeyDown"]);
const ctx = useInternalDropdownMenuContext();
const handleClick: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (e) => {
if (typeof local.onClick === "function") local.onClick(e);
ctx.toggle();
};
const handleKeyDown: JSX.EventHandler<HTMLButtonElement, KeyboardEvent> = (e) => {
if (typeof local.onKeyDown === "function") local.onKeyDown(e);
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"
id={ctx.triggerId}
aria-expanded={ctx.isOpen()}
aria-haspopup="menu"
aria-controls={ctx.isOpen() ? ctx.contentId : undefined}
data-state={ctx.isOpen() ? "open" : "closed"}
onClick={handleClick}
onKeyDown={handleKeyDown}
{...rest}
>
{local.children}
</button>
);
}

View File

@ -0,0 +1,11 @@
export { DropdownMenu, DropdownMenuRoot } from "./dropdown-menu-root";
export { DropdownMenuTrigger } from "./dropdown-menu-trigger";
export { DropdownMenuContent } from "./dropdown-menu-content";
export { DropdownMenuItem } from "./dropdown-menu-item";
export { DropdownMenuGroup } from "./dropdown-menu-group";
export { DropdownMenuGroupLabel } from "./dropdown-menu-group-label";
export { DropdownMenuSeparator } from "./dropdown-menu-separator";
export { DropdownMenuCheckboxItem } from "./dropdown-menu-checkbox-item";
export { DropdownMenuRadioGroup } from "./dropdown-menu-radio-group";
export { DropdownMenuRadioItem } from "./dropdown-menu-radio-item";
export { useDropdownMenuContext } from "./dropdown-menu-context";

View File

@ -0,0 +1,12 @@
// packages/core/src/components/toast/index.ts
import { ToastRegion } from "./toast-region";
export { toast } from "./toast-api";
/** Toast compound component with Region sub-component. */
export const Toast = {
Region: ToastRegion,
};
export type { ToastRegionProps } from "./toast-region";
export type { ToastData, ToastType } from "./toast-store";

View File

@ -0,0 +1,52 @@
// packages/core/src/components/toast/toast-api.ts
import { type ToastType, toastActions } from "./toast-store";
interface ToastOptions {
description?: string | undefined;
duration?: number | undefined;
id?: string | undefined;
}
/** Creates a toast with the given type. Returns the toast ID. */
function createToast(
title: string,
type: ToastType = "default",
options?: ToastOptions,
): string {
return toastActions.addToast({
title,
type,
description: options?.description,
duration: options?.duration,
id: options?.id,
});
}
/** Imperative toast API. Call toast("message") or toast.success/error/loading. */
export function toast(title: string, options?: ToastOptions): string {
return createToast(title, "default", options);
}
/** Creates a success toast. */
toast.success = (title: string, options?: ToastOptions): string =>
createToast(title, "success", options);
/** Creates an error toast. */
toast.error = (title: string, options?: ToastOptions): string =>
createToast(title, "error", options);
/** Creates a loading toast. */
toast.loading = (title: string, options?: ToastOptions): string =>
createToast(title, "loading", options);
/** Dismisses a toast by ID. */
toast.dismiss = (id: string): void => toastActions.dismissToast(id);
/** Clears all toasts. */
toast.clear = (): void => toastActions.clearToasts();
/** Updates an existing toast by ID. */
toast.update = (
id: string,
data: Partial<{ title: string; description: string; type: ToastType }>,
): void => toastActions.updateToast(id, data);

View File

@ -0,0 +1,39 @@
import type { JSX } from "solid-js";
import { For, splitProps } from "solid-js";
import { useToastStore } from "./toast-store";
export interface ToastRegionProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Maximum visible toasts. @default 5 */
limit?: number | undefined;
/** ARIA label for the region. @default "Notifications" */
label?: string | undefined;
children?: JSX.Element | undefined;
}
/** Region where toasts are rendered. Place once in your app root. */
export function ToastRegion(props: ToastRegionProps): JSX.Element {
const [local, rest] = splitProps(props, ["limit", "label", "children"]);
const toasts = useToastStore();
const visible = () => toasts().slice(0, local.limit ?? 5);
return (
<div
role="region"
aria-label={local.label ?? "Notifications"}
tabIndex={-1}
{...rest}
>
<ol>
<For each={visible()}>
{(t) => (
<li role="status" data-type={t.type}>
<div>{t.title}</div>
{t.description && <div>{t.description}</div>}
</li>
)}
</For>
</ol>
{local.children}
</div>
);
}

View File

@ -0,0 +1,49 @@
import { type Accessor, createSignal } from "solid-js";
export type ToastType = "default" | "success" | "error" | "loading";
export interface ToastData {
id: string;
title: string;
description?: string | undefined;
type: ToastType;
duration?: number | undefined;
}
const [toasts, setToasts] = createSignal<ToastData[]>([]);
let nextId = 0;
/** Generates a unique toast ID. */
function generateId(): string {
nextId += 1;
return `toast-${nextId}`;
}
/** Adds a toast to the store. Returns the toast ID. */
function addToast(data: Omit<ToastData, "id"> & { id?: string | undefined }): string {
const id = data.id ?? generateId();
setToasts((prev) => [...prev, { ...data, id }]);
return id;
}
/** Removes a toast by ID. */
function dismissToast(id: string): void {
setToasts((prev) => prev.filter((t) => t.id !== id));
}
/** Removes all toasts. */
function clearToasts(): void {
setToasts([]);
}
/** Updates an existing toast by ID. */
function updateToast(id: string, data: Partial<Omit<ToastData, "id">>): void {
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, ...data } : t)));
}
/** Returns the reactive toast list accessor. */
export function useToastStore(): Accessor<ToastData[]> {
return toasts;
}
export const toastActions = { addToast, dismissToast, clearToasts, updateToast };

View File

@ -0,0 +1,138 @@
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { createSignal } from "solid-js";
import { describe, expect, it, vi } from "vitest";
import { Combobox } from "../../../src/components/combobox/index";
describe("Combobox roles", () => {
it("input has role=combobox", () => {
render(() => (
<Combobox items={["a", "b"]}>
<Combobox.Input />
<Combobox.Content>
<Combobox.Item value="a">A</Combobox.Item>
<Combobox.Item value="b">B</Combobox.Item>
</Combobox.Content>
</Combobox>
));
expect(screen.getByRole("combobox")).toBeTruthy();
});
it("content has role=listbox when open", () => {
render(() => (
<Combobox items={["a", "b"]} defaultOpen>
<Combobox.Input />
<Combobox.Content>
<Combobox.Item value="a">A</Combobox.Item>
<Combobox.Item value="b">B</Combobox.Item>
</Combobox.Content>
</Combobox>
));
expect(screen.getByRole("listbox")).toBeTruthy();
});
});
describe("Combobox input", () => {
it("typing opens content", () => {
const onInput = vi.fn();
render(() => (
<Combobox items={["a", "b"]} onInputChange={onInput}>
<Combobox.Input />
<Combobox.Content>
<Combobox.Item value="a">A</Combobox.Item>
<Combobox.Item value="b">B</Combobox.Item>
</Combobox.Content>
</Combobox>
));
fireEvent.input(screen.getByRole("combobox"), { target: { value: "a" } });
expect(onInput).toHaveBeenCalled();
});
it("controlled value works", () => {
render(() => (
<Combobox items={["a", "b"]} value="a" onValueChange={() => {}}>
<Combobox.Input />
<Combobox.Content>
<Combobox.Item value="a">A</Combobox.Item>
<Combobox.Item value="b">B</Combobox.Item>
</Combobox.Content>
</Combobox>
));
expect(screen.getByRole("combobox")).toBeTruthy();
});
});
describe("Combobox keyboard", () => {
it("ArrowDown highlights first item", () => {
render(() => (
<Combobox items={["a", "b"]} defaultOpen>
<Combobox.Input />
<Combobox.Content>
<Combobox.Item value="a">A</Combobox.Item>
<Combobox.Item value="b">B</Combobox.Item>
</Combobox.Content>
</Combobox>
));
fireEvent.keyDown(screen.getByRole("combobox"), { key: "ArrowDown" });
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("data-highlighted")).toBe("");
});
it("Enter selects highlighted item", () => {
const onChange = vi.fn();
render(() => (
<Combobox items={["a", "b"]} defaultOpen onValueChange={onChange}>
<Combobox.Input />
<Combobox.Content>
<Combobox.Item value="a">A</Combobox.Item>
<Combobox.Item value="b">B</Combobox.Item>
</Combobox.Content>
</Combobox>
));
fireEvent.keyDown(screen.getByRole("combobox"), { key: "ArrowDown" });
fireEvent.keyDown(screen.getByRole("combobox"), { key: "Enter" });
expect(onChange).toHaveBeenCalledWith("a");
});
it("Escape closes", () => {
render(() => (
<Combobox items={["a", "b"]} defaultOpen>
<Combobox.Input />
<Combobox.Content>
<Combobox.Item value="a">A</Combobox.Item>
</Combobox.Content>
</Combobox>
));
fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" });
expect(screen.queryByRole("listbox")).toBeNull();
});
});
describe("Combobox empty and custom", () => {
it("Empty message shown when no items", () => {
render(() => (
<Combobox items={[]} defaultOpen>
<Combobox.Input />
<Combobox.Content>
<Combobox.Empty>No results</Combobox.Empty>
</Combobox.Content>
</Combobox>
));
expect(screen.getByText("No results")).toBeTruthy();
});
it("allowCustomValue works", () => {
const onChange = vi.fn();
render(() => (
<Combobox items={[]} defaultOpen allowCustomValue onValueChange={onChange}>
<Combobox.Input />
<Combobox.Content>
<Combobox.Empty>No results</Combobox.Empty>
</Combobox.Content>
</Combobox>
));
const input = screen.getByRole("combobox") as HTMLInputElement;
fireEvent.input(input, { target: { value: "custom" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith("custom");
});
});

View File

@ -0,0 +1,107 @@
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it, vi } from "vitest";
import { ContextMenu } from "../../../src/components/context-menu/index";
describe("ContextMenu — open/close and roles", () => {
it("right-click opens menu", () => {
render(() => (
<ContextMenu items={["edit", "delete"]}>
<ContextMenu.Trigger>
<div data-testid="area">Right click me</div>
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item value="edit">Edit</ContextMenu.Item>
<ContextMenu.Item value="delete">Delete</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu>
));
expect(screen.queryByRole("menu")).toBeNull();
fireEvent.contextMenu(screen.getByTestId("area"));
expect(screen.getByRole("menu")).toBeTruthy();
});
it("content has role=menu", () => {
render(() => (
<ContextMenu items={["edit"]} defaultOpen>
<ContextMenu.Trigger><div>Area</div></ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item value="edit">Edit</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu>
));
expect(screen.getByRole("menu")).toBeTruthy();
});
it("items have role=menuitem", () => {
render(() => (
<ContextMenu items={["edit", "delete"]} defaultOpen>
<ContextMenu.Trigger><div>Area</div></ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item value="edit">Edit</ContextMenu.Item>
<ContextMenu.Item value="delete">Delete</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu>
));
expect(screen.getAllByRole("menuitem")).toHaveLength(2);
});
it("trigger area does NOT have aria-haspopup", () => {
render(() => (
<ContextMenu items={["edit"]}>
<ContextMenu.Trigger>
<div data-testid="area">Area</div>
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item value="edit">Edit</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu>
));
expect(screen.getByTestId("area").getAttribute("aria-haspopup")).toBeNull();
});
});
describe("ContextMenu — keyboard navigation", () => {
it("Enter activates item", () => {
const onSelect = vi.fn();
render(() => (
<ContextMenu items={["edit"]} defaultOpen>
<ContextMenu.Trigger><div>Area</div></ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item value="edit" onSelect={onSelect}>Edit</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu>
));
const menu = screen.getByRole("menu");
fireEvent.keyDown(menu, { key: "ArrowDown" });
fireEvent.keyDown(menu, { key: "Enter" });
expect(onSelect).toHaveBeenCalled();
});
it("Escape closes", () => {
render(() => (
<ContextMenu items={["edit"]} defaultOpen>
<ContextMenu.Trigger><div>Area</div></ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item value="edit">Edit</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu>
));
fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
expect(screen.queryByRole("menu")).toBeNull();
});
it("ArrowDown navigates items", () => {
render(() => (
<ContextMenu items={["edit", "delete"]} defaultOpen>
<ContextMenu.Trigger><div>Area</div></ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item value="edit">Edit</ContextMenu.Item>
<ContextMenu.Item value="delete">Delete</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu>
));
const menu = screen.getByRole("menu");
fireEvent.keyDown(menu, { key: "ArrowDown" });
expect(screen.getAllByRole("menuitem")[0].getAttribute("data-highlighted")).toBe("");
});
});

View File

@ -0,0 +1,157 @@
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it, vi } from "vitest";
import { DropdownMenu } from "../../../src/components/dropdown-menu/index";
describe("DropdownMenu — rendering", () => {
it("content has role=menu when open", () => {
render(() => (
<DropdownMenu defaultOpen>
<DropdownMenu.Trigger>Actions</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item value="edit">Edit</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
));
expect(screen.getByRole("menu")).toBeTruthy();
});
it("items have role=menuitem", () => {
render(() => (
<DropdownMenu defaultOpen>
<DropdownMenu.Trigger>Actions</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item value="edit">Edit</DropdownMenu.Item>
<DropdownMenu.Item value="delete">Delete</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
));
expect(screen.getAllByRole("menuitem")).toHaveLength(2);
});
it("trigger has correct ARIA", () => {
render(() => (
<DropdownMenu>
<DropdownMenu.Trigger>Actions</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item value="edit">Edit</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
));
const trigger = screen.getByText("Actions");
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
});
});
describe("DropdownMenu — interactions", () => {
it("click trigger opens", () => {
render(() => (
<DropdownMenu>
<DropdownMenu.Trigger>Actions</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item value="edit">Edit</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
));
expect(screen.queryByRole("menu")).toBeNull();
fireEvent.click(screen.getByText("Actions"));
expect(screen.getByRole("menu")).toBeTruthy();
});
it("Escape closes", () => {
render(() => (
<DropdownMenu defaultOpen>
<DropdownMenu.Trigger>Actions</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item value="edit">Edit</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
));
fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
expect(screen.queryByRole("menu")).toBeNull();
});
it("item click activates and closes", () => {
const onSelect = vi.fn();
render(() => (
<DropdownMenu defaultOpen>
<DropdownMenu.Trigger>Actions</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item value="edit" onSelect={onSelect}>Edit</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
));
fireEvent.click(screen.getByRole("menuitem"));
expect(onSelect).toHaveBeenCalled();
expect(screen.queryByRole("menu")).toBeNull();
});
});
describe("DropdownMenu — keyboard navigation", () => {
it("Enter activates item", () => {
const onSelect = vi.fn();
render(() => (
<DropdownMenu defaultOpen>
<DropdownMenu.Trigger>Actions</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item value="edit" onSelect={onSelect}>Edit</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
));
const menu = screen.getByRole("menu");
fireEvent.keyDown(menu, { key: "ArrowDown" });
fireEvent.keyDown(menu, { key: "Enter" });
expect(onSelect).toHaveBeenCalled();
});
it("ArrowDown navigates", () => {
render(() => (
<DropdownMenu defaultOpen>
<DropdownMenu.Trigger>Actions</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item value="edit">Edit</DropdownMenu.Item>
<DropdownMenu.Item value="delete">Delete</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
));
const menu = screen.getByRole("menu");
fireEvent.keyDown(menu, { key: "ArrowDown" });
const items = screen.getAllByRole("menuitem");
expect(items[0].getAttribute("data-highlighted")).toBe("");
});
});
describe("DropdownMenu — checkbox and radio items", () => {
it("CheckboxItem toggles", () => {
const onChange = vi.fn();
render(() => (
<DropdownMenu defaultOpen>
<DropdownMenu.Trigger>Actions</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.CheckboxItem checked={false} onCheckedChange={onChange}>
Bookmark
</DropdownMenu.CheckboxItem>
</DropdownMenu.Content>
</DropdownMenu>
));
fireEvent.click(screen.getByRole("menuitemcheckbox"));
expect(onChange).toHaveBeenCalledWith(true);
});
it("RadioItem selects", () => {
const onChange = vi.fn();
render(() => (
<DropdownMenu defaultOpen>
<DropdownMenu.Trigger>Actions</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup value="list" onValueChange={onChange}>
<DropdownMenu.RadioItem value="list">List</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="grid">Grid</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu>
));
fireEvent.click(screen.getAllByRole("menuitemradio")[1]);
expect(onChange).toHaveBeenCalledWith("grid");
});
});

View File

@ -0,0 +1,65 @@
import { render, screen, waitFor } from "@solidjs/testing-library";
import { afterEach, describe, expect, it } from "vitest";
import { Toast, toast } from "../../../src/components/toast/index";
afterEach(() => {
toast.clear();
});
describe("Toast", () => {
it("region has role=region", () => {
render(() => <Toast.Region />);
expect(screen.getByRole("region")).toBeTruthy();
});
it("region has aria-label", () => {
render(() => <Toast.Region />);
expect(screen.getByRole("region").getAttribute("aria-label")).toBeTruthy();
});
it("toast() adds a toast to the region", () => {
render(() => <Toast.Region />);
toast("Hello world");
expect(screen.getByText("Hello world")).toBeTruthy();
});
it("toast.success() creates a success toast", () => {
render(() => <Toast.Region />);
toast.success("Saved!");
expect(screen.getByText("Saved!")).toBeTruthy();
});
it("toast.error() creates an error toast", () => {
render(() => <Toast.Region />);
toast.error("Failed");
expect(screen.getByText("Failed")).toBeTruthy();
});
it("toast.dismiss() removes a toast", async () => {
render(() => <Toast.Region />);
const id = toast("Dismissable");
expect(screen.getByText("Dismissable")).toBeTruthy();
toast.dismiss(id);
await waitFor(() => expect(screen.queryByText("Dismissable")).toBeNull());
});
it("toast.clear() removes all toasts", async () => {
render(() => <Toast.Region />);
toast("One");
toast("Two");
expect(screen.getByText("One")).toBeTruthy();
expect(screen.getByText("Two")).toBeTruthy();
toast.clear();
await waitFor(() => {
expect(screen.queryByText("One")).toBeNull();
expect(screen.queryByText("Two")).toBeNull();
});
});
it("toast returns an ID", () => {
render(() => <Toast.Region />);
const id = toast("Test");
expect(typeof id).toBe("string");
expect(id.length).toBeGreaterThan(0);
});
});