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:
parent
5bc9ac7b61
commit
6382a59eaf
52
packages/core/src/components/combobox/combobox-content.tsx
Normal file
52
packages/core/src/components/combobox/combobox-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
packages/core/src/components/combobox/combobox-context.ts
Normal file
78
packages/core/src/components/combobox/combobox-context.ts
Normal 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;
|
||||
21
packages/core/src/components/combobox/combobox-empty.tsx
Normal file
21
packages/core/src/components/combobox/combobox-empty.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
16
packages/core/src/components/combobox/combobox-group.tsx
Normal file
16
packages/core/src/components/combobox/combobox-group.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
packages/core/src/components/combobox/combobox-input.tsx
Normal file
98
packages/core/src/components/combobox/combobox-input.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
29
packages/core/src/components/combobox/combobox-item.tsx
Normal file
29
packages/core/src/components/combobox/combobox-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
packages/core/src/components/combobox/combobox-root.tsx
Normal file
156
packages/core/src/components/combobox/combobox-root.tsx
Normal 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,
|
||||
});
|
||||
34
packages/core/src/components/combobox/combobox-trigger.tsx
Normal file
34
packages/core/src/components/combobox/combobox-trigger.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
packages/core/src/components/combobox/index.ts
Normal file
9
packages/core/src/components/combobox/index.ts
Normal 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";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
158
packages/core/src/components/context-menu/context-menu-root.tsx
Normal file
158
packages/core/src/components/context-menu/context-menu-root.tsx
Normal 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,
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
8
packages/core/src/components/context-menu/index.ts
Normal file
8
packages/core/src/components/context-menu/index.ts
Normal 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";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
11
packages/core/src/components/dropdown-menu/index.ts
Normal file
11
packages/core/src/components/dropdown-menu/index.ts
Normal 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";
|
||||
12
packages/core/src/components/toast/index.ts
Normal file
12
packages/core/src/components/toast/index.ts
Normal 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";
|
||||
52
packages/core/src/components/toast/toast-api.ts
Normal file
52
packages/core/src/components/toast/toast-api.ts
Normal 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);
|
||||
39
packages/core/src/components/toast/toast-region.tsx
Normal file
39
packages/core/src/components/toast/toast-region.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
packages/core/src/components/toast/toast-store.ts
Normal file
49
packages/core/src/components/toast/toast-store.ts
Normal 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 };
|
||||
138
packages/core/tests/components/combobox/combobox.test.tsx
Normal file
138
packages/core/tests/components/combobox/combobox.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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("");
|
||||
});
|
||||
});
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
65
packages/core/tests/components/toast/toast.test.tsx
Normal file
65
packages/core/tests/components/toast/toast.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user