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