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:
Mats Bosson 2026-03-29 20:53:17 +07:00
parent 80f7af401a
commit 56f06c961d
11 changed files with 396 additions and 0 deletions

View 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";

View File

@ -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>
);
}

View File

@ -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 };

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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}
/>
);
}

View File

@ -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;

View File

@ -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");
});
});