diff --git a/packages/core/src/components/navigation-menu/index.ts b/packages/core/src/components/navigation-menu/index.ts new file mode 100644 index 0000000..0363e9a --- /dev/null +++ b/packages/core/src/components/navigation-menu/index.ts @@ -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"; diff --git a/packages/core/src/components/navigation-menu/navigation-menu-content.tsx b/packages/core/src/components/navigation-menu/navigation-menu-content.tsx new file mode 100644 index 0000000..6f313a3 --- /dev/null +++ b/packages/core/src/components/navigation-menu/navigation-menu-content.tsx @@ -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 ( + +
+ {local.children} +
+
+ ); +} diff --git a/packages/core/src/components/navigation-menu/navigation-menu-context.ts b/packages/core/src/components/navigation-menu/navigation-menu-context.ts new file mode 100644 index 0000000..d6f5599 --- /dev/null +++ b/packages/core/src/components/navigation-menu/navigation-menu-context.ts @@ -0,0 +1,37 @@ +import { createContext, useContext } from "solid-js"; +import type { Accessor } from "solid-js"; + +interface NavigationMenuContextValue { + value: Accessor; + setValue: (value: string) => void; + orientation: Accessor<"horizontal" | "vertical">; + scheduleOpen: (itemValue: string) => void; + cancelAndClose: () => void; +} + +const NavigationMenuContext = createContext(); + +/** Returns the NavigationMenu root context. Throws if used outside . */ +export function useNavigationMenuContext(): NavigationMenuContextValue { + const ctx = useContext(NavigationMenuContext); + if (!ctx) throw new Error("[PettyUI] NavigationMenu parts must be used within ."); + return ctx; +} + +export { NavigationMenuContext }; + +interface NavigationMenuItemContextValue { + value: string; + isActive: Accessor; +} + +const NavigationMenuItemContext = createContext(); + +/** Returns the NavigationMenu item context. Throws if used outside . */ +export function useNavigationMenuItemContext(): NavigationMenuItemContextValue { + const ctx = useContext(NavigationMenuItemContext); + if (!ctx) throw new Error("[PettyUI] NavigationMenu.Trigger/Content must be inside ."); + return ctx; +} + +export { NavigationMenuItemContext }; diff --git a/packages/core/src/components/navigation-menu/navigation-menu-item.tsx b/packages/core/src/components/navigation-menu/navigation-menu-item.tsx new file mode 100644 index 0000000..6b51286 --- /dev/null +++ b/packages/core/src/components/navigation-menu/navigation-menu-item.tsx @@ -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 ( + +
  • + {local.children} +
  • +
    + ); +} diff --git a/packages/core/src/components/navigation-menu/navigation-menu-link.tsx b/packages/core/src/components/navigation-menu/navigation-menu-link.tsx new file mode 100644 index 0000000..4be09fc --- /dev/null +++ b/packages/core/src/components/navigation-menu/navigation-menu-link.tsx @@ -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 ( + + {local.children} + + ); +} diff --git a/packages/core/src/components/navigation-menu/navigation-menu-list.tsx b/packages/core/src/components/navigation-menu/navigation-menu-list.tsx new file mode 100644 index 0000000..18d2ed2 --- /dev/null +++ b/packages/core/src/components/navigation-menu/navigation-menu-list.tsx @@ -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
      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 ( +
        + {local.children} +
      + ); +} diff --git a/packages/core/src/components/navigation-menu/navigation-menu-root.tsx b/packages/core/src/components/navigation-menu/navigation-menu-root.tsx new file mode 100644 index 0000000..28d34b2 --- /dev/null +++ b/packages/core/src/components/navigation-menu/navigation-menu-root.tsx @@ -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({ + value: () => local.value, + defaultValue: () => local.defaultValue ?? "", + onChange: (v) => local.onValueChange?.(v), + }); + + let openTimer: ReturnType | 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 ( + + + + ); +} diff --git a/packages/core/src/components/navigation-menu/navigation-menu-trigger.tsx b/packages/core/src/components/navigation-menu/navigation-menu-trigger.tsx new file mode 100644 index 0000000..6672ac4 --- /dev/null +++ b/packages/core/src/components/navigation-menu/navigation-menu-trigger.tsx @@ -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 = (e) => { + if (typeof local.onClick === "function") local.onClick(e); + const next = ctx.value() === itemCtx.value ? "" : itemCtx.value; + ctx.setValue(next); + }; + + const handlePointerEnter: JSX.EventHandler = (e) => { + if (typeof local.onPointerEnter === "function") local.onPointerEnter(e); + ctx.scheduleOpen(itemCtx.value); + }; + + const handlePointerLeave: JSX.EventHandler = (e) => { + if (typeof local.onPointerLeave === "function") local.onPointerLeave(e); + ctx.cancelAndClose(); + }; + + return ( + + ); +} diff --git a/packages/core/src/components/navigation-menu/navigation-menu-viewport.tsx b/packages/core/src/components/navigation-menu/navigation-menu-viewport.tsx new file mode 100644 index 0000000..39b6174 --- /dev/null +++ b/packages/core/src/components/navigation-menu/navigation-menu-viewport.tsx @@ -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 ( +
      + ); +} diff --git a/packages/core/src/components/navigation-menu/navigation-menu.props.ts b/packages/core/src/components/navigation-menu/navigation-menu.props.ts new file mode 100644 index 0000000..defae48 --- /dev/null +++ b/packages/core/src/components/navigation-menu/navigation-menu.props.ts @@ -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, Omit, keyof z.infer> { + onValueChange?: (value: string) => void; + children: JSX.Element; +} +export interface NavigationMenuListProps extends JSX.HTMLAttributes { children: JSX.Element; } +export interface NavigationMenuItemProps extends JSX.HTMLAttributes { value?: string; children: JSX.Element; } +export interface NavigationMenuTriggerProps extends JSX.ButtonHTMLAttributes { children?: JSX.Element; } +export interface NavigationMenuContentProps extends JSX.HTMLAttributes { forceMount?: boolean; children?: JSX.Element; } +export interface NavigationMenuLinkProps extends JSX.AnchorHTMLAttributes { active?: boolean; children?: JSX.Element; } +export interface NavigationMenuViewportProps extends JSX.HTMLAttributes { 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; diff --git a/packages/core/tests/components/navigation-menu/navigation-menu.test.tsx b/packages/core/tests/components/navigation-menu/navigation-menu.test.tsx new file mode 100644 index 0000000..6346f6a --- /dev/null +++ b/packages/core/tests/components/navigation-menu/navigation-menu.test.tsx @@ -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(() => ( + + + + About + + + + )); + expect(screen.getByText("About")).toBeTruthy(); + }); + + it("renders trigger with content", () => { + render(() => ( + + + + Products +
      Product A
      +
      +
      +
      + )); + expect(screen.getByText("Products")).toBeTruthy(); + }); + + it("renders as nav element", () => { + render(() => ( + + + Home + + + )); + 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"); + }); +});