NavigationMenu component
Horizontal nav with dropdown submenus, hover-intent delay, keyboard-accessible trigger/content parts, and full Zod v4 schema + ComponentMeta.
This commit is contained in:
parent
80f7af401a
commit
56f06c961d
16
packages/core/src/components/navigation-menu/index.ts
Normal file
16
packages/core/src/components/navigation-menu/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { NavigationMenuRoot } from "./navigation-menu-root";
|
||||
import { NavigationMenuList } from "./navigation-menu-list";
|
||||
import { NavigationMenuItem } from "./navigation-menu-item";
|
||||
import { NavigationMenuTrigger } from "./navigation-menu-trigger";
|
||||
import { NavigationMenuContent } from "./navigation-menu-content";
|
||||
import { NavigationMenuLink } from "./navigation-menu-link";
|
||||
import { NavigationMenuViewport } from "./navigation-menu-viewport";
|
||||
export const NavigationMenu = Object.assign(NavigationMenuRoot, { List: NavigationMenuList, Item: NavigationMenuItem, Trigger: NavigationMenuTrigger, Content: NavigationMenuContent, Link: NavigationMenuLink, Viewport: NavigationMenuViewport });
|
||||
export type { NavigationMenuRootProps } from "./navigation-menu.props";
|
||||
export type { NavigationMenuListProps } from "./navigation-menu.props";
|
||||
export type { NavigationMenuItemProps } from "./navigation-menu.props";
|
||||
export type { NavigationMenuTriggerProps } from "./navigation-menu.props";
|
||||
export type { NavigationMenuContentProps } from "./navigation-menu.props";
|
||||
export type { NavigationMenuLinkProps } from "./navigation-menu.props";
|
||||
export type { NavigationMenuViewportProps } from "./navigation-menu.props";
|
||||
export { NavigationMenuRootPropsSchema, NavigationMenuMeta } from "./navigation-menu.props";
|
||||
@ -0,0 +1,26 @@
|
||||
import type { JSX } from "solid-js";
|
||||
import { Show, splitProps } from "solid-js";
|
||||
import { useNavigationMenuItemContext } from "./navigation-menu-context";
|
||||
import type { NavigationMenuContentProps } from "./navigation-menu.props";
|
||||
|
||||
/**
|
||||
* Dropdown content panel for a NavigationMenu item. Renders only when the
|
||||
* parent item is active, unless forceMount is true.
|
||||
*/
|
||||
export function NavigationMenuContent(props: NavigationMenuContentProps): JSX.Element {
|
||||
const [local, rest] = splitProps(props, ["forceMount", "children"]);
|
||||
const itemCtx = useNavigationMenuItemContext();
|
||||
|
||||
return (
|
||||
<Show when={local.forceMount || itemCtx.isActive()}>
|
||||
<div
|
||||
data-scope="navigation-menu"
|
||||
data-part="content"
|
||||
data-active={itemCtx.isActive() ? "" : undefined}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
import { createContext, useContext } from "solid-js";
|
||||
import type { Accessor } from "solid-js";
|
||||
|
||||
interface NavigationMenuContextValue {
|
||||
value: Accessor<string>;
|
||||
setValue: (value: string) => void;
|
||||
orientation: Accessor<"horizontal" | "vertical">;
|
||||
scheduleOpen: (itemValue: string) => void;
|
||||
cancelAndClose: () => void;
|
||||
}
|
||||
|
||||
const NavigationMenuContext = createContext<NavigationMenuContextValue>();
|
||||
|
||||
/** Returns the NavigationMenu root context. Throws if used outside <NavigationMenu>. */
|
||||
export function useNavigationMenuContext(): NavigationMenuContextValue {
|
||||
const ctx = useContext(NavigationMenuContext);
|
||||
if (!ctx) throw new Error("[PettyUI] NavigationMenu parts must be used within <NavigationMenu>.");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export { NavigationMenuContext };
|
||||
|
||||
interface NavigationMenuItemContextValue {
|
||||
value: string;
|
||||
isActive: Accessor<boolean>;
|
||||
}
|
||||
|
||||
const NavigationMenuItemContext = createContext<NavigationMenuItemContextValue>();
|
||||
|
||||
/** Returns the NavigationMenu item context. Throws if used outside <NavigationMenu.Item>. */
|
||||
export function useNavigationMenuItemContext(): NavigationMenuItemContextValue {
|
||||
const ctx = useContext(NavigationMenuItemContext);
|
||||
if (!ctx) throw new Error("[PettyUI] NavigationMenu.Trigger/Content must be inside <NavigationMenu.Item>.");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export { NavigationMenuItemContext };
|
||||
@ -0,0 +1,31 @@
|
||||
import type { JSX } from "solid-js";
|
||||
import { createUniqueId, splitProps } from "solid-js";
|
||||
import { NavigationMenuItemContext, useNavigationMenuContext } from "./navigation-menu-context";
|
||||
import type { NavigationMenuItemProps } from "./navigation-menu.props";
|
||||
|
||||
/**
|
||||
* A single item within the NavigationMenu list. Provides per-item context
|
||||
* (value and active state) to Trigger and Content children.
|
||||
*/
|
||||
export function NavigationMenuItem(props: NavigationMenuItemProps): JSX.Element {
|
||||
const [local, rest] = splitProps(props, ["value", "children"]);
|
||||
const ctx = useNavigationMenuContext();
|
||||
|
||||
const itemValue = local.value ?? createUniqueId();
|
||||
const isActive = () => ctx.value() === itemValue;
|
||||
|
||||
const itemCtx = { value: itemValue, isActive };
|
||||
|
||||
return (
|
||||
<NavigationMenuItemContext.Provider value={itemCtx}>
|
||||
<li
|
||||
data-scope="navigation-menu"
|
||||
data-part="item"
|
||||
data-active={isActive() ? "" : undefined}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
</li>
|
||||
</NavigationMenuItemContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import type { JSX } from "solid-js";
|
||||
import { splitProps } from "solid-js";
|
||||
import type { NavigationMenuLinkProps } from "./navigation-menu.props";
|
||||
|
||||
/**
|
||||
* An anchor link for use inside NavigationMenu. Marks itself active via
|
||||
* data-active when the active prop is true.
|
||||
*/
|
||||
export function NavigationMenuLink(props: NavigationMenuLinkProps): JSX.Element {
|
||||
const [local, rest] = splitProps(props, ["active", "children"]);
|
||||
|
||||
return (
|
||||
<a
|
||||
data-scope="navigation-menu"
|
||||
data-part="link"
|
||||
role="menuitem"
|
||||
data-active={local.active ? "" : undefined}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import type { JSX } from "solid-js";
|
||||
import { splitProps } from "solid-js";
|
||||
import { useNavigationMenuContext } from "./navigation-menu-context";
|
||||
import type { NavigationMenuListProps } from "./navigation-menu.props";
|
||||
|
||||
/**
|
||||
* Ordered list container for NavigationMenu items. Renders as a <ul> with
|
||||
* role="menubar" and the correct orientation data attribute.
|
||||
*/
|
||||
export function NavigationMenuList(props: NavigationMenuListProps): JSX.Element {
|
||||
const [local, rest] = splitProps(props, ["children"]);
|
||||
const ctx = useNavigationMenuContext();
|
||||
|
||||
return (
|
||||
<ul
|
||||
data-scope="navigation-menu"
|
||||
data-part="list"
|
||||
role="menubar"
|
||||
data-orientation={ctx.orientation()}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
import type { JSX } from "solid-js";
|
||||
import { onCleanup, splitProps } from "solid-js";
|
||||
import { createControllableSignal } from "../../primitives/create-controllable-signal";
|
||||
import { NavigationMenuContext } from "./navigation-menu-context";
|
||||
import type { NavigationMenuRootProps } from "./navigation-menu.props";
|
||||
|
||||
/**
|
||||
* Root container for NavigationMenu. Manages which item is active and
|
||||
* provides context to all sub-components.
|
||||
*/
|
||||
export function NavigationMenuRoot(props: NavigationMenuRootProps): JSX.Element {
|
||||
const [local, rest] = splitProps(props, [
|
||||
"value",
|
||||
"defaultValue",
|
||||
"onValueChange",
|
||||
"orientation",
|
||||
"delayDuration",
|
||||
"children",
|
||||
]);
|
||||
|
||||
const [value, setValue] = createControllableSignal<string>({
|
||||
value: () => local.value,
|
||||
defaultValue: () => local.defaultValue ?? "",
|
||||
onChange: (v) => local.onValueChange?.(v),
|
||||
});
|
||||
|
||||
let openTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const delay = () => local.delayDuration ?? 200;
|
||||
|
||||
/** Schedule opening a menu item after the hover delay. */
|
||||
const scheduleOpen = (itemValue: string) => {
|
||||
if (openTimer !== undefined) {
|
||||
clearTimeout(openTimer);
|
||||
openTimer = undefined;
|
||||
}
|
||||
openTimer = setTimeout(() => {
|
||||
setValue(itemValue);
|
||||
openTimer = undefined;
|
||||
}, delay());
|
||||
};
|
||||
|
||||
/** Cancel any pending open timer and close the active item. */
|
||||
const cancelAndClose = () => {
|
||||
if (openTimer !== undefined) {
|
||||
clearTimeout(openTimer);
|
||||
openTimer = undefined;
|
||||
}
|
||||
setValue("");
|
||||
};
|
||||
|
||||
onCleanup(() => {
|
||||
if (openTimer !== undefined) clearTimeout(openTimer);
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
value: value as () => string,
|
||||
setValue,
|
||||
orientation: () => local.orientation ?? "horizontal",
|
||||
scheduleOpen,
|
||||
cancelAndClose,
|
||||
};
|
||||
|
||||
return (
|
||||
<NavigationMenuContext.Provider value={ctx}>
|
||||
<nav
|
||||
data-scope="navigation-menu"
|
||||
data-part="root"
|
||||
data-orientation={local.orientation ?? "horizontal"}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
</nav>
|
||||
</NavigationMenuContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
import type { JSX } from "solid-js";
|
||||
import { splitProps } from "solid-js";
|
||||
import { useNavigationMenuContext, useNavigationMenuItemContext } from "./navigation-menu-context";
|
||||
import type { NavigationMenuTriggerProps } from "./navigation-menu.props";
|
||||
|
||||
/**
|
||||
* Button that opens the associated NavigationMenu.Content on click or hover.
|
||||
* Manages hover-intent via the root context's scheduleOpen/cancelAndClose.
|
||||
*/
|
||||
export function NavigationMenuTrigger(props: NavigationMenuTriggerProps): JSX.Element {
|
||||
const [local, rest] = splitProps(props, [
|
||||
"children",
|
||||
"onClick",
|
||||
"onPointerEnter",
|
||||
"onPointerLeave",
|
||||
]);
|
||||
|
||||
const ctx = useNavigationMenuContext();
|
||||
const itemCtx = useNavigationMenuItemContext();
|
||||
|
||||
const handleClick: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (e) => {
|
||||
if (typeof local.onClick === "function") local.onClick(e);
|
||||
const next = ctx.value() === itemCtx.value ? "" : itemCtx.value;
|
||||
ctx.setValue(next);
|
||||
};
|
||||
|
||||
const handlePointerEnter: JSX.EventHandler<HTMLButtonElement, PointerEvent> = (e) => {
|
||||
if (typeof local.onPointerEnter === "function") local.onPointerEnter(e);
|
||||
ctx.scheduleOpen(itemCtx.value);
|
||||
};
|
||||
|
||||
const handlePointerLeave: JSX.EventHandler<HTMLButtonElement, PointerEvent> = (e) => {
|
||||
if (typeof local.onPointerLeave === "function") local.onPointerLeave(e);
|
||||
ctx.cancelAndClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-scope="navigation-menu"
|
||||
data-part="trigger"
|
||||
aria-expanded={itemCtx.isActive()}
|
||||
data-active={itemCtx.isActive() ? "" : undefined}
|
||||
onClick={handleClick}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
import type { JSX } from "solid-js";
|
||||
import { splitProps } from "solid-js";
|
||||
import { useNavigationMenuContext } from "./navigation-menu-context";
|
||||
import type { NavigationMenuViewportProps } from "./navigation-menu.props";
|
||||
|
||||
/**
|
||||
* Viewport container that can host active NavigationMenu content outside
|
||||
* the list DOM structure, enabling animated panel transitions.
|
||||
*/
|
||||
export function NavigationMenuViewport(props: NavigationMenuViewportProps): JSX.Element {
|
||||
const [local, rest] = splitProps(props, ["forceMount"]);
|
||||
const ctx = useNavigationMenuContext();
|
||||
const isOpen = () => ctx.value() !== "";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-scope="navigation-menu"
|
||||
data-part="viewport"
|
||||
data-state={isOpen() ? "open" : "closed"}
|
||||
data-orientation={ctx.orientation()}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import { z } from "zod/v4";
|
||||
import type { JSX } from "solid-js";
|
||||
import type { ComponentMeta } from "../../meta";
|
||||
|
||||
export const NavigationMenuRootPropsSchema = z.object({
|
||||
value: z.string().optional().describe("Controlled active item value (the currently open dropdown)"),
|
||||
defaultValue: z.string().optional().describe("Initial active item (uncontrolled)"),
|
||||
orientation: z.enum(["horizontal", "vertical"]).optional().describe("Menu orientation. Defaults to 'horizontal'"),
|
||||
delayDuration: z.number().optional().describe("Delay in ms before dropdown opens on hover. Defaults to 200"),
|
||||
});
|
||||
|
||||
export interface NavigationMenuRootProps extends z.infer<typeof NavigationMenuRootPropsSchema>, Omit<JSX.HTMLAttributes<HTMLElement>, keyof z.infer<typeof NavigationMenuRootPropsSchema>> {
|
||||
onValueChange?: (value: string) => void;
|
||||
children: JSX.Element;
|
||||
}
|
||||
export interface NavigationMenuListProps extends JSX.HTMLAttributes<HTMLUListElement> { children: JSX.Element; }
|
||||
export interface NavigationMenuItemProps extends JSX.HTMLAttributes<HTMLLIElement> { value?: string; children: JSX.Element; }
|
||||
export interface NavigationMenuTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> { children?: JSX.Element; }
|
||||
export interface NavigationMenuContentProps extends JSX.HTMLAttributes<HTMLDivElement> { forceMount?: boolean; children?: JSX.Element; }
|
||||
export interface NavigationMenuLinkProps extends JSX.AnchorHTMLAttributes<HTMLAnchorElement> { active?: boolean; children?: JSX.Element; }
|
||||
export interface NavigationMenuViewportProps extends JSX.HTMLAttributes<HTMLDivElement> { forceMount?: boolean; }
|
||||
|
||||
export const NavigationMenuMeta: ComponentMeta = {
|
||||
name: "NavigationMenu",
|
||||
description: "Horizontal navigation bar with dropdown submenus, hover intent, and keyboard support",
|
||||
parts: ["Root", "List", "Item", "Trigger", "Content", "Link", "Viewport", "Indicator"] as const,
|
||||
requiredParts: ["Root", "List", "Item"] as const,
|
||||
} as const;
|
||||
@ -0,0 +1,58 @@
|
||||
import { render, screen } from "@solidjs/testing-library";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NavigationMenu } from "../../../src/components/navigation-menu/index";
|
||||
import { NavigationMenuRootPropsSchema, NavigationMenuMeta } from "../../../src/components/navigation-menu/navigation-menu.props";
|
||||
|
||||
describe("NavigationMenu", () => {
|
||||
it("renders menu with link items", () => {
|
||||
render(() => (
|
||||
<NavigationMenu>
|
||||
<NavigationMenu.List>
|
||||
<NavigationMenu.Item>
|
||||
<NavigationMenu.Link href="/about">About</NavigationMenu.Link>
|
||||
</NavigationMenu.Item>
|
||||
</NavigationMenu.List>
|
||||
</NavigationMenu>
|
||||
));
|
||||
expect(screen.getByText("About")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders trigger with content", () => {
|
||||
render(() => (
|
||||
<NavigationMenu>
|
||||
<NavigationMenu.List>
|
||||
<NavigationMenu.Item>
|
||||
<NavigationMenu.Trigger>Products</NavigationMenu.Trigger>
|
||||
<NavigationMenu.Content><div>Product A</div></NavigationMenu.Content>
|
||||
</NavigationMenu.Item>
|
||||
</NavigationMenu.List>
|
||||
</NavigationMenu>
|
||||
));
|
||||
expect(screen.getByText("Products")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders as nav element", () => {
|
||||
render(() => (
|
||||
<NavigationMenu data-testid="nav">
|
||||
<NavigationMenu.List>
|
||||
<NavigationMenu.Item><NavigationMenu.Link href="/">Home</NavigationMenu.Link></NavigationMenu.Item>
|
||||
</NavigationMenu.List>
|
||||
</NavigationMenu>
|
||||
));
|
||||
expect(screen.getByTestId("nav").tagName).toBe("NAV");
|
||||
});
|
||||
|
||||
it("schema validates orientation", () => {
|
||||
expect(NavigationMenuRootPropsSchema.safeParse({ orientation: "horizontal" }).success).toBe(true);
|
||||
expect(NavigationMenuRootPropsSchema.safeParse({ orientation: "invalid" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("meta has required fields", () => {
|
||||
expect(NavigationMenuMeta.name).toBe("NavigationMenu");
|
||||
expect(NavigationMenuMeta.parts).toContain("Root");
|
||||
expect(NavigationMenuMeta.parts).toContain("List");
|
||||
expect(NavigationMenuMeta.parts).toContain("Trigger");
|
||||
expect(NavigationMenuMeta.parts).toContain("Content");
|
||||
expect(NavigationMenuMeta.parts).toContain("Link");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user