diff --git a/packages/core/src/components/combobox/combobox-content.tsx b/packages/core/src/components/combobox/combobox-content.tsx new file mode 100644 index 0000000..cf22ed9 --- /dev/null +++ b/packages/core/src/components/combobox/combobox-content.tsx @@ -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 { + /** 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 ( + +
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} +
+
+ ); +} diff --git a/packages/core/src/components/combobox/combobox-context.ts b/packages/core/src/components/combobox/combobox-context.ts new file mode 100644 index 0000000..1a567d4 --- /dev/null +++ b/packages/core/src/components/combobox/combobox-context.ts @@ -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; + open: () => void; + close: () => void; + toggle: () => void; + navigation: ListNavigationState; + inputRef: Accessor; + setInputRef: (el: HTMLInputElement | null) => void; + contentRef: Accessor; + setContentRef: (el: HTMLElement | null) => void; + selectedValue: Accessor; + onSelect: (value: string) => void; + disabled: Accessor; + required: Accessor; + name: Accessor; + contentId: string; + inputId: string; + items: Accessor; + inputValue: Accessor; + setInputValue: (value: string) => void; + onInputChange: ((value: string) => void) | undefined; + allowCustomValue: Accessor; + onCustomValue: (value: string) => void; + floatingStyle: Accessor; +} + +const InternalComboboxContext = createContext(); + +/** + * 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 .\n" + + " Fix: \n" + + " \n" + + " \n" + + ' A\n' + + " \n" + + " ", + ); + } + return ctx; +} + +export const InternalComboboxContextProvider = InternalComboboxContext.Provider; + +/** Public context exposed via Combobox.useContext(). */ +export interface ComboboxContextValue { + /** Currently selected value. */ + value: Accessor; + /** Whether the combobox dropdown is open. */ + open: Accessor; + /** Current input text. */ + inputValue: Accessor; +} + +const ComboboxContext = createContext(); + +/** + * 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 ."); + } + return ctx; +} + +export const ComboboxContextProvider = ComboboxContext.Provider; diff --git a/packages/core/src/components/combobox/combobox-empty.tsx b/packages/core/src/components/combobox/combobox-empty.tsx new file mode 100644 index 0000000..cab3b9e --- /dev/null +++ b/packages/core/src/components/combobox/combobox-empty.tsx @@ -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 { + 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 ( + +
+ {local.children} +
+
+ ); +} diff --git a/packages/core/src/components/combobox/combobox-group-label.tsx b/packages/core/src/components/combobox/combobox-group-label.tsx new file mode 100644 index 0000000..ab705e5 --- /dev/null +++ b/packages/core/src/components/combobox/combobox-group-label.tsx @@ -0,0 +1,17 @@ +import type { JSX } from "solid-js"; +import { createUniqueId, splitProps } from "solid-js"; + +export interface ComboboxGroupLabelProps extends JSX.HTMLAttributes { + 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 ( + + ); +} diff --git a/packages/core/src/components/combobox/combobox-group.tsx b/packages/core/src/components/combobox/combobox-group.tsx new file mode 100644 index 0000000..1842733 --- /dev/null +++ b/packages/core/src/components/combobox/combobox-group.tsx @@ -0,0 +1,16 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; + +export interface ComboboxGroupProps extends JSX.HTMLAttributes { + children?: JSX.Element | undefined; +} + +/** Groups related Combobox items together. */ +export function ComboboxGroup(props: ComboboxGroupProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + return ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/combobox/combobox-input.tsx b/packages/core/src/components/combobox/combobox-input.tsx new file mode 100644 index 0000000..df2d827 --- /dev/null +++ b/packages/core/src/components/combobox/combobox-input.tsx @@ -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 { + 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 = (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 = (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 ( + 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} + /> + ); +} diff --git a/packages/core/src/components/combobox/combobox-item.tsx b/packages/core/src/components/combobox/combobox-item.tsx new file mode 100644 index 0000000..9f7ae96 --- /dev/null +++ b/packages/core/src/components/combobox/combobox-item.tsx @@ -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 { + /** 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 ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/combobox/combobox-root.tsx b/packages/core/src/components/combobox/combobox-root.tsx new file mode 100644 index 0000000..b1c0898 --- /dev/null +++ b/packages/core/src/components/combobox/combobox-root.tsx @@ -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, + contentRef: Accessor, + 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, + middleware: (() => [offset(8), flip(), shift({ padding: 8 })]) as Accessor, + 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(null); + const [contentRef, setContentRef] = createSignal(null); + const [internalInputValue, setInternalInputValue] = createSignal(""); + const resolveInputValue = () => local.inputValue ?? internalInputValue(); + + const { disclosure, navigation, floating } = createComboboxPrimitives( + local, inputRef as Accessor, 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 ( + + + {local.children} + + + ); +} + +/** 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, +}); diff --git a/packages/core/src/components/combobox/combobox-trigger.tsx b/packages/core/src/components/combobox/combobox-trigger.tsx new file mode 100644 index 0000000..3802355 --- /dev/null +++ b/packages/core/src/components/combobox/combobox-trigger.tsx @@ -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 { + 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 = (e) => { + if (typeof local.onClick === "function") local.onClick(e); + if (!ctx.disabled()) ctx.toggle(); + }; + + return ( + + ); +} diff --git a/packages/core/src/components/combobox/index.ts b/packages/core/src/components/combobox/index.ts new file mode 100644 index 0000000..7ff1bde --- /dev/null +++ b/packages/core/src/components/combobox/index.ts @@ -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"; diff --git a/packages/core/src/components/context-menu/context-menu-content.tsx b/packages/core/src/components/context-menu/context-menu-content.tsx new file mode 100644 index 0000000..e56042c --- /dev/null +++ b/packages/core/src/components/context-menu/context-menu-content.tsx @@ -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 { + /** 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 = (e) => { + if (e.key === "Escape") { + e.preventDefault(); + ctx.close(); + return; + } + if (e.key === "Tab") { + ctx.close(); + return; + } + ctx.navigation.containerProps.onKeyDown(e); + }; + + return ( + +
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} +
+
+ ); +} diff --git a/packages/core/src/components/context-menu/context-menu-context.ts b/packages/core/src/components/context-menu/context-menu-context.ts new file mode 100644 index 0000000..e380afe --- /dev/null +++ b/packages/core/src/components/context-menu/context-menu-context.ts @@ -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; + open: () => void; + close: () => void; + navigation: ListNavigationState; + contentRef: Accessor; + setContentRef: (el: HTMLElement | null) => void; + contentId: string; + floatingStyle: Accessor; + /** 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(); + +/** + * 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 .\n" + + " Fix: \n" + + " ...\n" + + " \n" + + ' A\n' + + " \n" + + " ", + ); + } + return ctx; +} + +export const InternalContextMenuContextProvider = InternalContextMenuContext.Provider; + +/** Public context exposed via ContextMenu.useContext(). */ +export interface ContextMenuContextValue { + /** Whether the context menu is open. */ + open: Accessor; +} + +const ContextMenuPublicContext = createContext(); + +/** + * 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 ."); + } + return ctx; +} + +export const ContextMenuPublicContextProvider = ContextMenuPublicContext.Provider; diff --git a/packages/core/src/components/context-menu/context-menu-group-label.tsx b/packages/core/src/components/context-menu/context-menu-group-label.tsx new file mode 100644 index 0000000..06be218 --- /dev/null +++ b/packages/core/src/components/context-menu/context-menu-group-label.tsx @@ -0,0 +1,17 @@ +import type { JSX } from "solid-js"; +import { createUniqueId, splitProps } from "solid-js"; + +export interface ContextMenuGroupLabelProps extends JSX.HTMLAttributes { + 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 ( + + ); +} diff --git a/packages/core/src/components/context-menu/context-menu-group.tsx b/packages/core/src/components/context-menu/context-menu-group.tsx new file mode 100644 index 0000000..931493c --- /dev/null +++ b/packages/core/src/components/context-menu/context-menu-group.tsx @@ -0,0 +1,16 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; + +export interface ContextMenuGroupProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +/** Groups related ContextMenu items together. */ +export function ContextMenuGroup(props: ContextMenuGroupProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + return ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/context-menu/context-menu-item.tsx b/packages/core/src/components/context-menu/context-menu-item.tsx new file mode 100644 index 0000000..1a461ec --- /dev/null +++ b/packages/core/src/components/context-menu/context-menu-item.tsx @@ -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 { + /** 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 = () => { + if (local.disabled) return; + local.onSelect?.(); + ctx.close(); + }; + + return ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/context-menu/context-menu-root.tsx b/packages/core/src/components/context-menu/context-menu-root.tsx new file mode 100644 index 0000000..0616210 --- /dev/null +++ b/packages/core/src/components/context-menu/context-menu-root.tsx @@ -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(null); + const [contentRef, setContentRef] = createSignal(null); + + const itemHandlers = new Map 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, + middleware: (() => [offset(2), flip(), shift({ padding: 8 })]) as Accessor, + 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 ( + + + {local.children} + + + ); +} + +/** 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, +}); diff --git a/packages/core/src/components/context-menu/context-menu-separator.tsx b/packages/core/src/components/context-menu/context-menu-separator.tsx new file mode 100644 index 0000000..ec4db7b --- /dev/null +++ b/packages/core/src/components/context-menu/context-menu-separator.tsx @@ -0,0 +1,16 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; + +export interface ContextMenuSeparatorProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +/** A visual separator between ContextMenu items. */ +export function ContextMenuSeparator(props: ContextMenuSeparatorProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + return ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/context-menu/context-menu-trigger.tsx b/packages/core/src/components/context-menu/context-menu-trigger.tsx new file mode 100644 index 0000000..171f549 --- /dev/null +++ b/packages/core/src/components/context-menu/context-menu-trigger.tsx @@ -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 { + 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 = (e) => { + e.preventDefault(); + if (typeof local.onContextMenu === "function") local.onContextMenu(e); + ctx.setAnchorPosition(e.clientX, e.clientY); + ctx.open(); + }; + + return ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/context-menu/index.ts b/packages/core/src/components/context-menu/index.ts new file mode 100644 index 0000000..93efdd3 --- /dev/null +++ b/packages/core/src/components/context-menu/index.ts @@ -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"; diff --git a/packages/core/src/components/dropdown-menu/dropdown-menu-checkbox-item.tsx b/packages/core/src/components/dropdown-menu/dropdown-menu-checkbox-item.tsx new file mode 100644 index 0000000..b095b83 --- /dev/null +++ b/packages/core/src/components/dropdown-menu/dropdown-menu-checkbox-item.tsx @@ -0,0 +1,41 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; + +export interface DropdownMenuCheckboxItemProps extends JSX.HTMLAttributes { + /** 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 = () => { + if (local.disabled) return; + local.onCheckedChange?.(!local.checked); + }; + + return ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/dropdown-menu/dropdown-menu-content.tsx b/packages/core/src/components/dropdown-menu/dropdown-menu-content.tsx new file mode 100644 index 0000000..b3b7adb --- /dev/null +++ b/packages/core/src/components/dropdown-menu/dropdown-menu-content.tsx @@ -0,0 +1,69 @@ +import type { JSX } from "solid-js"; +import { Show, createEffect, onCleanup, splitProps } from "solid-js"; +import { createDismiss } from "../../utilities/dismiss/create-dismiss"; +import { useInternalDropdownMenuContext } from "./dropdown-menu-context"; + +export interface DropdownMenuContentProps extends JSX.HTMLAttributes { + /** 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 = (e) => { + if (e.key === "Escape") { + e.preventDefault(); + ctx.close(); + ctx.triggerRef()?.focus(); + return; + } + if (e.key === "Tab") { + ctx.close(); + return; + } + ctx.navigation.containerProps.onKeyDown(e); + }; + + return ( + +
ctx.setContentRef(el)} + id={ctx.contentId} + role="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} +
+
+ ); +} diff --git a/packages/core/src/components/dropdown-menu/dropdown-menu-context.ts b/packages/core/src/components/dropdown-menu/dropdown-menu-context.ts new file mode 100644 index 0000000..e1ce9fb --- /dev/null +++ b/packages/core/src/components/dropdown-menu/dropdown-menu-context.ts @@ -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; + open: () => void; + close: () => void; + toggle: () => void; + navigation: ListNavigationState; + triggerRef: Accessor; + setTriggerRef: (el: HTMLElement | null) => void; + contentRef: Accessor; + setContentRef: (el: HTMLElement | null) => void; + contentId: string; + triggerId: string; + floatingStyle: Accessor; + /** 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(); + +/** + * 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 .\n" + + " Fix: \n" + + " ...\n" + + " \n" + + ' A\n' + + " \n" + + " ", + ); + } + return ctx; +} + +export const InternalDropdownMenuContextProvider = InternalDropdownMenuContext.Provider; + +/** Context for DropdownMenu RadioGroup. */ +export interface DropdownMenuRadioGroupContextValue { + value: Accessor; + onValueChange: ((value: string) => void) | undefined; +} + +const DropdownMenuRadioGroupContext = createContext(); + +/** + * 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 .", + ); + } + return ctx; +} + +export const DropdownMenuRadioGroupContextProvider = DropdownMenuRadioGroupContext.Provider; + +/** Public context exposed via DropdownMenu.useContext(). */ +export interface DropdownMenuContextValue { + /** Whether the dropdown menu is open. */ + open: Accessor; +} + +const DropdownMenuPublicContext = createContext(); + +/** + * 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 ."); + } + return ctx; +} + +export const DropdownMenuPublicContextProvider = DropdownMenuPublicContext.Provider; diff --git a/packages/core/src/components/dropdown-menu/dropdown-menu-group-label.tsx b/packages/core/src/components/dropdown-menu/dropdown-menu-group-label.tsx new file mode 100644 index 0000000..66b43f5 --- /dev/null +++ b/packages/core/src/components/dropdown-menu/dropdown-menu-group-label.tsx @@ -0,0 +1,17 @@ +import type { JSX } from "solid-js"; +import { createUniqueId, splitProps } from "solid-js"; + +export interface DropdownMenuGroupLabelProps extends JSX.HTMLAttributes { + 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 ( + + ); +} diff --git a/packages/core/src/components/dropdown-menu/dropdown-menu-group.tsx b/packages/core/src/components/dropdown-menu/dropdown-menu-group.tsx new file mode 100644 index 0000000..86ea79e --- /dev/null +++ b/packages/core/src/components/dropdown-menu/dropdown-menu-group.tsx @@ -0,0 +1,16 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; + +export interface DropdownMenuGroupProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +/** Groups related DropdownMenu items together. */ +export function DropdownMenuGroup(props: DropdownMenuGroupProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + return ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/dropdown-menu/dropdown-menu-item.tsx b/packages/core/src/components/dropdown-menu/dropdown-menu-item.tsx new file mode 100644 index 0000000..3ae776e --- /dev/null +++ b/packages/core/src/components/dropdown-menu/dropdown-menu-item.tsx @@ -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 { + /** 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 = () => { + if (local.disabled) return; + local.onSelect?.(); + ctx.close(); + }; + + return ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/dropdown-menu/dropdown-menu-radio-group.tsx b/packages/core/src/components/dropdown-menu/dropdown-menu-radio-group.tsx new file mode 100644 index 0000000..1ed4966 --- /dev/null +++ b/packages/core/src/components/dropdown-menu/dropdown-menu-radio-group.tsx @@ -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 { + /** 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 ( + local.value, + onValueChange: local.onValueChange, + }} + > +
+ {local.children} +
+
+ ); +} diff --git a/packages/core/src/components/dropdown-menu/dropdown-menu-radio-item.tsx b/packages/core/src/components/dropdown-menu/dropdown-menu-radio-item.tsx new file mode 100644 index 0000000..d64af91 --- /dev/null +++ b/packages/core/src/components/dropdown-menu/dropdown-menu-radio-item.tsx @@ -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 { + /** 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 = () => { + if (local.disabled) return; + radioCtx.onValueChange?.(local.value); + }; + + return ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/dropdown-menu/dropdown-menu-root.tsx b/packages/core/src/components/dropdown-menu/dropdown-menu-root.tsx new file mode 100644 index 0000000..6a35eba --- /dev/null +++ b/packages/core/src/components/dropdown-menu/dropdown-menu-root.tsx @@ -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(null); + const [contentRef, setContentRef] = createSignal(null); + const [registeredItems, setRegisteredItems] = createSignal([]); + + const itemHandlers = new Map 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, + middleware: (() => [offset(8), flip(), shift({ padding: 8 })]) as Accessor, + 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 ( + + + {local.children} + + + ); +} + +/** 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, +}); diff --git a/packages/core/src/components/dropdown-menu/dropdown-menu-separator.tsx b/packages/core/src/components/dropdown-menu/dropdown-menu-separator.tsx new file mode 100644 index 0000000..c88738f --- /dev/null +++ b/packages/core/src/components/dropdown-menu/dropdown-menu-separator.tsx @@ -0,0 +1,16 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; + +export interface DropdownMenuSeparatorProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +/** A visual separator between DropdownMenu items. */ +export function DropdownMenuSeparator(props: DropdownMenuSeparatorProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + return ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/dropdown-menu/dropdown-menu-trigger.tsx b/packages/core/src/components/dropdown-menu/dropdown-menu-trigger.tsx new file mode 100644 index 0000000..2c8b35c --- /dev/null +++ b/packages/core/src/components/dropdown-menu/dropdown-menu-trigger.tsx @@ -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 { + 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 = (e) => { + if (typeof local.onClick === "function") local.onClick(e); + ctx.toggle(); + }; + + const handleKeyDown: JSX.EventHandler = (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 ( + + ); +} diff --git a/packages/core/src/components/dropdown-menu/index.ts b/packages/core/src/components/dropdown-menu/index.ts new file mode 100644 index 0000000..ce35fb9 --- /dev/null +++ b/packages/core/src/components/dropdown-menu/index.ts @@ -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"; diff --git a/packages/core/src/components/toast/index.ts b/packages/core/src/components/toast/index.ts new file mode 100644 index 0000000..614b00e --- /dev/null +++ b/packages/core/src/components/toast/index.ts @@ -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"; diff --git a/packages/core/src/components/toast/toast-api.ts b/packages/core/src/components/toast/toast-api.ts new file mode 100644 index 0000000..7c1a840 --- /dev/null +++ b/packages/core/src/components/toast/toast-api.ts @@ -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); diff --git a/packages/core/src/components/toast/toast-region.tsx b/packages/core/src/components/toast/toast-region.tsx new file mode 100644 index 0000000..80572cb --- /dev/null +++ b/packages/core/src/components/toast/toast-region.tsx @@ -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 { + /** 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 ( +
+
    + + {(t) => ( +
  1. +
    {t.title}
    + {t.description &&
    {t.description}
    } +
  2. + )} +
    +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/toast/toast-store.ts b/packages/core/src/components/toast/toast-store.ts new file mode 100644 index 0000000..b1977ef --- /dev/null +++ b/packages/core/src/components/toast/toast-store.ts @@ -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([]); +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 & { 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>): void { + setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, ...data } : t))); +} + +/** Returns the reactive toast list accessor. */ +export function useToastStore(): Accessor { + return toasts; +} + +export const toastActions = { addToast, dismissToast, clearToasts, updateToast }; diff --git a/packages/core/tests/components/combobox/combobox.test.tsx b/packages/core/tests/components/combobox/combobox.test.tsx new file mode 100644 index 0000000..2c92343 --- /dev/null +++ b/packages/core/tests/components/combobox/combobox.test.tsx @@ -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(() => ( + + + + A + B + + + )); + expect(screen.getByRole("combobox")).toBeTruthy(); + }); + + it("content has role=listbox when open", () => { + render(() => ( + + + + A + B + + + )); + expect(screen.getByRole("listbox")).toBeTruthy(); + }); +}); + +describe("Combobox – input", () => { + it("typing opens content", () => { + const onInput = vi.fn(); + render(() => ( + + + + A + B + + + )); + fireEvent.input(screen.getByRole("combobox"), { target: { value: "a" } }); + expect(onInput).toHaveBeenCalled(); + }); + + it("controlled value works", () => { + render(() => ( + {}}> + + + A + B + + + )); + expect(screen.getByRole("combobox")).toBeTruthy(); + }); +}); + +describe("Combobox – keyboard", () => { + it("ArrowDown highlights first item", () => { + render(() => ( + + + + A + B + + + )); + 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(() => ( + + + + A + B + + + )); + fireEvent.keyDown(screen.getByRole("combobox"), { key: "ArrowDown" }); + fireEvent.keyDown(screen.getByRole("combobox"), { key: "Enter" }); + expect(onChange).toHaveBeenCalledWith("a"); + }); + + it("Escape closes", () => { + render(() => ( + + + + A + + + )); + 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(() => ( + + + + No results + + + )); + expect(screen.getByText("No results")).toBeTruthy(); + }); + + it("allowCustomValue works", () => { + const onChange = vi.fn(); + render(() => ( + + + + No results + + + )); + const input = screen.getByRole("combobox") as HTMLInputElement; + fireEvent.input(input, { target: { value: "custom" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onChange).toHaveBeenCalledWith("custom"); + }); +}); diff --git a/packages/core/tests/components/context-menu/context-menu.test.tsx b/packages/core/tests/components/context-menu/context-menu.test.tsx new file mode 100644 index 0000000..4964b0e --- /dev/null +++ b/packages/core/tests/components/context-menu/context-menu.test.tsx @@ -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(() => ( + + +
Right click me
+
+ + Edit + Delete + +
+ )); + expect(screen.queryByRole("menu")).toBeNull(); + fireEvent.contextMenu(screen.getByTestId("area")); + expect(screen.getByRole("menu")).toBeTruthy(); + }); + + it("content has role=menu", () => { + render(() => ( + +
Area
+ + Edit + +
+ )); + expect(screen.getByRole("menu")).toBeTruthy(); + }); + + it("items have role=menuitem", () => { + render(() => ( + +
Area
+ + Edit + Delete + +
+ )); + expect(screen.getAllByRole("menuitem")).toHaveLength(2); + }); + + it("trigger area does NOT have aria-haspopup", () => { + render(() => ( + + +
Area
+
+ + Edit + +
+ )); + expect(screen.getByTestId("area").getAttribute("aria-haspopup")).toBeNull(); + }); +}); + +describe("ContextMenu — keyboard navigation", () => { + it("Enter activates item", () => { + const onSelect = vi.fn(); + render(() => ( + +
Area
+ + Edit + +
+ )); + const menu = screen.getByRole("menu"); + fireEvent.keyDown(menu, { key: "ArrowDown" }); + fireEvent.keyDown(menu, { key: "Enter" }); + expect(onSelect).toHaveBeenCalled(); + }); + + it("Escape closes", () => { + render(() => ( + +
Area
+ + Edit + +
+ )); + fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" }); + expect(screen.queryByRole("menu")).toBeNull(); + }); + + it("ArrowDown navigates items", () => { + render(() => ( + +
Area
+ + Edit + Delete + +
+ )); + const menu = screen.getByRole("menu"); + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(screen.getAllByRole("menuitem")[0].getAttribute("data-highlighted")).toBe(""); + }); +}); diff --git a/packages/core/tests/components/dropdown-menu/dropdown-menu.test.tsx b/packages/core/tests/components/dropdown-menu/dropdown-menu.test.tsx new file mode 100644 index 0000000..36d276d --- /dev/null +++ b/packages/core/tests/components/dropdown-menu/dropdown-menu.test.tsx @@ -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(() => ( + + Actions + + Edit + + + )); + expect(screen.getByRole("menu")).toBeTruthy(); + }); + + it("items have role=menuitem", () => { + render(() => ( + + Actions + + Edit + Delete + + + )); + expect(screen.getAllByRole("menuitem")).toHaveLength(2); + }); + + it("trigger has correct ARIA", () => { + render(() => ( + + Actions + + Edit + + + )); + 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(() => ( + + Actions + + Edit + + + )); + expect(screen.queryByRole("menu")).toBeNull(); + fireEvent.click(screen.getByText("Actions")); + expect(screen.getByRole("menu")).toBeTruthy(); + }); + + it("Escape closes", () => { + render(() => ( + + Actions + + Edit + + + )); + fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" }); + expect(screen.queryByRole("menu")).toBeNull(); + }); + + it("item click activates and closes", () => { + const onSelect = vi.fn(); + render(() => ( + + Actions + + Edit + + + )); + 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(() => ( + + Actions + + Edit + + + )); + const menu = screen.getByRole("menu"); + fireEvent.keyDown(menu, { key: "ArrowDown" }); + fireEvent.keyDown(menu, { key: "Enter" }); + expect(onSelect).toHaveBeenCalled(); + }); + + it("ArrowDown navigates", () => { + render(() => ( + + Actions + + Edit + Delete + + + )); + 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(() => ( + + Actions + + + Bookmark + + + + )); + fireEvent.click(screen.getByRole("menuitemcheckbox")); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it("RadioItem selects", () => { + const onChange = vi.fn(); + render(() => ( + + Actions + + + List + Grid + + + + )); + fireEvent.click(screen.getAllByRole("menuitemradio")[1]); + expect(onChange).toHaveBeenCalledWith("grid"); + }); +}); diff --git a/packages/core/tests/components/toast/toast.test.tsx b/packages/core/tests/components/toast/toast.test.tsx new file mode 100644 index 0000000..38eabc2 --- /dev/null +++ b/packages/core/tests/components/toast/toast.test.tsx @@ -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(() => ); + expect(screen.getByRole("region")).toBeTruthy(); + }); + + it("region has aria-label", () => { + render(() => ); + expect(screen.getByRole("region").getAttribute("aria-label")).toBeTruthy(); + }); + + it("toast() adds a toast to the region", () => { + render(() => ); + toast("Hello world"); + expect(screen.getByText("Hello world")).toBeTruthy(); + }); + + it("toast.success() creates a success toast", () => { + render(() => ); + toast.success("Saved!"); + expect(screen.getByText("Saved!")).toBeTruthy(); + }); + + it("toast.error() creates an error toast", () => { + render(() => ); + toast.error("Failed"); + expect(screen.getByText("Failed")).toBeTruthy(); + }); + + it("toast.dismiss() removes a toast", async () => { + render(() => ); + 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("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(() => ); + const id = toast("Test"); + expect(typeof id).toBe("string"); + expect(id.length).toBeGreaterThan(0); + }); +});