From 1be30815619859378e04ae8eece4f5008bbb5a9d Mon Sep 17 00:00:00 2001 From: Denis Goncharenko Date: Tue, 21 Jul 2026 20:32:19 +0500 Subject: [PATCH 1/9] feat(core): tree traversal, inferred meta, render-free model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getMenuTrail/findMenuItem with …By predicate variants (share one traversal) - defineMenu infers the meta type M from the input's meta fields (MetaOf), no explicit type argument - drop before/after/icon/MenuSlot from the data model — it's now free of React (types.ts imports nothing from react); rendering lives on the component --- src/__tests__/defineMenu.test.ts | 42 +++++++++++------ src/__tests__/getMenuTrail.test.ts | 54 ++++++++++++++++++++++ src/defineMenu.ts | 74 ++++++++++++++++++------------ src/findMenuItem.ts | 28 +++++++++++ src/getMenuTrail.ts | 34 ++++++++++++++ src/index.ts | 15 +++--- src/types.ts | 45 ++---------------- 7 files changed, 202 insertions(+), 90 deletions(-) create mode 100644 src/__tests__/getMenuTrail.test.ts create mode 100644 src/findMenuItem.ts create mode 100644 src/getMenuTrail.ts diff --git a/src/__tests__/defineMenu.test.ts b/src/__tests__/defineMenu.test.ts index 22594b4..fbf1323 100644 --- a/src/__tests__/defineMenu.test.ts +++ b/src/__tests__/defineMenu.test.ts @@ -39,6 +39,16 @@ describe("defineMenu", () => { expect(menu[0].items?.map((i) => i.href)).toEqual(["/button"]); }); + it("sorts by `order` at nested levels too, not just the top", () => { + // The single stable sort must order children, not only root siblings. + const menu = defineMenu({ + "/s": { title: "S" }, + "/s/b": { title: "B", parent: "/s", order: 2 }, + "/s/a": { title: "A", parent: "/s", order: 1 }, + }); + expect(menu[0].items?.map((i) => i.title)).toEqual(["A", "B"]); + }); + it("supports a non-navigable container via `href: false`, addressed by its key", () => { const menu = defineMenu({ grp: { title: "Group", href: false }, @@ -87,10 +97,13 @@ describe("defineMenu", () => { }; const menu = defineMenu({ ...generated, - "/button": { title: "Button (override)", icon: "icon" }, + "/button": { title: "Button (override)", defaultOpen: false }, }); expect(menu.map((i) => i.href)).toEqual(["/button", "/about"]); - expect(menu[0]).toMatchObject({ title: "Button (override)", icon: "icon" }); + expect(menu[0]).toMatchObject({ + title: "Button (override)", + defaultOpen: false, + }); }); it("lets a later entry attach into an earlier section (custom child)", () => { @@ -102,33 +115,36 @@ describe("defineMenu", () => { expect(menu[0].items?.map((i) => i.href)).toEqual(["/button", "/x"]); }); - it("carries typed `meta` through verbatim (required when the type is plain)", () => { + it("carries typed `meta` through verbatim, inferring the type from the input", () => { interface Meta { badge: string; } - const menu = defineMenu({ - "/components": { title: "Components", meta: { badge: "section" } }, + const menu = defineMenu({ + "/components": { + title: "Components", + meta: { badge: "section" } as Meta, + }, "/search": { title: "Search", parent: "/components", - meta: { badge: "new" }, + meta: { badge: "new" } as Meta, }, }); const [section] = menu; - // `meta` is required, so no optional chaining on `.meta` on the output. + // `meta` inferred as `Meta`, required — no optional chaining on `.meta`. expect(section.meta.badge).toBe("section"); expect(section.items?.[0].meta.badge).toBe("new"); }); - it("makes `meta` optional (and omittable) when the type admits undefined", () => { + it("infers an optional `meta` type; omitted values just aren't present", () => { interface Meta { badge?: string; } - const menu = defineMenu({ - "/a": { title: "A" }, // meta omitted — allowed - "/b": { title: "B", meta: { badge: "new" } }, + const menu = defineMenu({ + "/a": { title: "A", meta: {} as Meta }, // meta type given, value empty + "/b": { title: "B", meta: { badge: "new" } as Meta }, }); - expect(menu[0]).not.toHaveProperty("meta"); - expect(menu[1].meta).toEqual({ badge: "new" }); + expect(menu[0].meta?.badge).toBeUndefined(); + expect(menu[1].meta?.badge).toBe("new"); }); }); diff --git a/src/__tests__/getMenuTrail.test.ts b/src/__tests__/getMenuTrail.test.ts new file mode 100644 index 0000000..6fc07e3 --- /dev/null +++ b/src/__tests__/getMenuTrail.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { defineMenu } from "../defineMenu"; +import { findMenuItem, findMenuItemBy } from "../findMenuItem"; +import { getMenuTrail, getMenuTrailBy } from "../getMenuTrail"; + +const menu = defineMenu({ + "/": { title: "Home" }, + "/components": { title: "Components" }, + "/components/button": { title: "Button", parent: "/components" }, +}); + +describe("getMenuTrail", () => { + it("returns the chain from the top level down to the id", () => { + expect(getMenuTrail(menu, "/components/button").map((i) => i.id)).toEqual([ + "/components", + "/components/button", + ]); + }); + + it("returns a single item for a top-level id", () => { + expect(getMenuTrail(menu, "/").map((i) => i.id)).toEqual(["/"]); + }); + + it("returns empty for an unknown id", () => { + expect(getMenuTrail(menu, "/missing")).toEqual([]); + }); +}); + +describe("findMenuItem", () => { + it("finds a nested item", () => { + expect(findMenuItem(menu, "/components/button")?.title).toBe("Button"); + }); + + it("returns undefined for an unknown id", () => { + expect(findMenuItem(menu, "/missing")).toBeUndefined(); + }); +}); + +describe("by predicate", () => { + it("getMenuTrailBy returns the branch of the first matching item", () => { + const trail = getMenuTrailBy(menu, (i) => i.title === "Button"); + expect(trail.map((i) => i.id)).toEqual([ + "/components", + "/components/button", + ]); + }); + + it("findMenuItemBy returns the first match (pre-order)", () => { + expect(findMenuItemBy(menu, (i) => i.title === "Button")?.id).toBe( + "/components/button", + ); + expect(findMenuItemBy(menu, () => false)).toBeUndefined(); + }); +}); diff --git a/src/defineMenu.ts b/src/defineMenu.ts index 5a455f1..5154dc8 100644 --- a/src/defineMenu.ts +++ b/src/defineMenu.ts @@ -13,6 +13,22 @@ const UNORDERED = Number.MAX_SAFE_INTEGER; */ type MenuKeys = Extract; +/** + * The input `defineMenu` accepts, expressed against itself so `parent` is checked + * against the object's own keys ({@link MenuKeys}). `meta` is left `unknown` here + * and its real type is inferred separately by {@link MetaOf}. + */ +type DefineMenuInput = Record< + string, + MenuItemInput, unknown> | undefined +>; + +/** + * The per-item `meta` type inferred from a menu input — the `meta` field of its + * values (`NonNullable` drops the `Partial`/`undefined` from adapter results). + */ +type MetaOf = NonNullable extends { meta?: infer M } ? M : never; + /** * Meta-opaque views for the internal pipeline: the runtime never inspects `meta` * (it just rides through), so the resolver works at `unknown` meta and the @@ -27,17 +43,14 @@ type LooseNode = MenuItem; * equals), the input-only `parent`/`order` are stripped, and `href` falls back * to the entry key. An unknown `parent` hoists the entry to the top level. * - * `M` types the opaque per-item `meta`. It comes first so it can be given - * explicitly (`defineMenu(…)`) while `T` is still inferred from the - * argument; it passes through verbatim and is never read here. + * The per-item `meta` type is **inferred** from the input's `meta` fields (see + * {@link MetaOf}), so `defineMenu(menuInputFromRouteTree(tree))` yields the menu + * typed with your registered meta — no explicit type argument. `meta` passes + * through verbatim and is never read here. */ -export function defineMenu< - M = never, - const T extends Record< - string, - MenuItemInput, M> | undefined - > = Record>, ->(input: T): Menu { +export function defineMenu>( + input: T, +): Menu> { // One stable sort over the flat list. `Array#sort` is stable, so every // parent's bucket comes out ordered without a second per-level pass. const entries = Object.entries( @@ -46,25 +59,33 @@ export function defineMenu< .filter((entry): entry is [string, LooseInput] => entry[1] != null) .sort(([, a], [, b]) => (a.order ?? UNORDERED) - (b.order ?? UNORDERED)); - const nodeByKey = new Map( - entries.map(([key, item]) => [key, toNode(key, item)]), - ); + // Build every node up front; `nodeByKey` is only for resolving `parent`. + const placed = entries.map(([key, item]) => ({ + key, + item, + node: toNode(key, item), + })); + const nodeByKey = new Map(placed.map(({ key, node }) => [key, node])); const roots: LooseNode[] = []; - for (const [key, item] of entries) { - const node = nodeByKey.get(key) as LooseNode; + for (const { item, node } of placed) { const parent = item.parent == null ? undefined : nodeByKey.get(item.parent); if (parent) { parent.items ??= []; parent.items.push(node); } else { - if (item.parent != null) warnUnknownParent(item); + if (isDev && item.parent != null) warnUnknownParent(item); roots.push(node); } } - if (isDev) warnUnreachable(roots, entries.length); - return roots as unknown as Menu; + // A `parent` cycle links its nodes to each other but off the tree. + if (isDev && countNodes(roots) !== entries.length) + console.warn( + "[menu] cyclic `parent` detected; the cycle's items were dropped", + ); + + return roots as unknown as Menu>; } /** Build the output node: strip the input-only fields and resolve `href`. */ @@ -73,25 +94,20 @@ function toNode( { href, parent, order, ...fields }: LooseInput, ): LooseNode { // `meta`, when present, rides through in `...fields` untouched. - const target = href === false ? undefined : (href ?? key); - return { id: key, ...fields, ...(target != null && { href: target }) }; + const resolvedHref = href === false ? undefined : (href ?? key); + return { + id: key, + ...fields, + ...(resolvedHref != null && { href: resolvedHref }), + }; } function warnUnknownParent({ title, parent }: LooseInput): void { - if (!isDev) return; console.warn( `[menu] item "${title}" has unknown parent "${parent}"; hoisting to top level`, ); } -/** A cyclic `parent` chain links nodes to each other but off the tree. */ -function warnUnreachable(roots: LooseNode[], total: number): void { - if (countNodes(roots) === total) return; - console.warn( - "[menu] cyclic `parent` detected; the items in the cycle were dropped", - ); -} - function countNodes(nodes: LooseNode[]): number { return nodes.reduce( (count, node) => count + 1 + countNodes(node.items ?? []), diff --git a/src/findMenuItem.ts b/src/findMenuItem.ts new file mode 100644 index 0000000..5d25aa5 --- /dev/null +++ b/src/findMenuItem.ts @@ -0,0 +1,28 @@ +import { + getMenuTrail, + getMenuTrailBy, + type MenuItemPredicate, +} from "./getMenuTrail"; +import type { Menu, MenuItem } from "./types"; + +/** + * The first item (pre-order) matching `predicate`, or `undefined` — the tail of + * its trail, so the two share one traversal. Use it to resolve an item by any + * condition (router match, role, flag) and feed its `id` to `useMenu.setActive`. + * "First match" like `Array.find`: for prefix/most-specific resolution, make the + * predicate select exactly one item. + */ +export function findMenuItemBy( + menu: Menu, + predicate: MenuItemPredicate, +): MenuItem | undefined { + return getMenuTrailBy(menu, predicate).at(-1); +} + +/** {@link findMenuItemBy} for the common case of an exact `id` match. */ +export function findMenuItem( + menu: Menu, + id: string, +): MenuItem | undefined { + return getMenuTrail(menu, id).at(-1); +} diff --git a/src/getMenuTrail.ts b/src/getMenuTrail.ts new file mode 100644 index 0000000..1c776bd --- /dev/null +++ b/src/getMenuTrail.ts @@ -0,0 +1,34 @@ +import type { Menu, MenuItem } from "./types"; + +/** A test applied to an item during a tree walk. */ +export type MenuItemPredicate = (item: MenuItem) => boolean; + +/** + * The chain of items from the top level down to the first one matching + * `predicate`, inclusive: for a match on `/components/button` → + * `[Components, Button]`. Empty if nothing matches. + * + * This is both the breadcrumb trail and the set of ancestors to expand for an + * active item — `useMenu` uses it to open the branch of the active item. + */ +export function getMenuTrailBy( + menu: Menu, + predicate: MenuItemPredicate, +): MenuItem[] { + for (const item of menu) { + if (predicate(item)) return [item]; + if (item.items) { + const below = getMenuTrailBy(item.items, predicate); + if (below.length) return [item, ...below]; + } + } + return []; +} + +/** {@link getMenuTrailBy} for the common case of an exact `id` match. */ +export function getMenuTrail( + menu: Menu, + id: string, +): MenuItem[] { + return getMenuTrailBy(menu, (item) => item.id === id); +} diff --git a/src/index.ts b/src/index.ts index fd0642c..d1eccd3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,8 @@ export { defineMenu } from "./defineMenu"; -export type { - Menu, - MenuInput, - MenuItem, - MenuItemInput, - MenuItemState, - MenuSlot, -} from "./types"; +export { findMenuItem, findMenuItemBy } from "./findMenuItem"; +export { + getMenuTrail, + getMenuTrailBy, + type MenuItemPredicate, +} from "./getMenuTrail"; +export type { Menu, MenuInput, MenuItem, MenuItemInput } from "./types"; diff --git a/src/types.ts b/src/types.ts index 02a6e9f..d4b9f2e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,19 +1,3 @@ -import type { ReactNode } from "react"; - -/** State of the item a {@link MenuSlot} is rendering next to. */ -export interface MenuItemState { - /** The item's nested `items` are expanded. */ - open: boolean; - /** Nesting depth: `0` at the top, `+1` per level down. */ - level: number; -} - -/** Render custom JSX before/after a menu item (e.g. a divider or section heading). */ -export type MenuSlot = ( - item: MenuItem, - state: MenuItemState, -) => ReactNode; - /** * The opaque per-item metadata field, used **identically** on input and output. * `[M]` wrappers stop the conditional distributing over a union. @@ -29,16 +13,9 @@ type MetaField = [M] extends [never] ? { meta?: M } : { meta: M }; -/** - * Fields shared by the stored {@link MenuItem} and the input {@link MenuItemInput}. - * - * There is deliberately no `match`/`active`: the renderer is router-agnostic, so - * active-state matching lives in the consumer's `Item`, which talks to its own - * router. - */ +/** Fields shared by the stored {@link MenuItem} and input {@link MenuItemInput}. */ export interface MenuItemBase { title: string; - icon?: ReactNode; /** Initial expanded state when the item has children. Default `true`. */ defaultOpen?: boolean; /** @@ -47,10 +24,6 @@ export interface MenuItemBase { * gets the non-collapsible prop variant. Ignored for leaf links. */ collapsible?: boolean; - /** Custom JSX rendered before the item. */ - before?: MenuSlot; - /** Custom JSX rendered after the item. */ - after?: MenuSlot; } /** @@ -59,11 +32,7 @@ export interface MenuItemBase { */ export type MenuItem = MenuItemBase & MetaField & { - /** - * Stable identity — the input key it was defined under (its `href`, an - * adapter's route `fullPath`, or a container id). Unique across the tree; - * use it for React keys, `aria-controls`, or matching back to your data. - */ + /** Stable identity — the input key (unique across the tree). */ id: string; /** Link target — internal route or external URL. Absent → pure container. */ href?: string; @@ -75,13 +44,9 @@ export type MenuItem = MenuItemBase & export type Menu = MenuItem[]; /** - * Authoring/adapter input entry. The input is **keyed by identity** (the item's - * `href`, or an arbitrary id for a pure container) and hierarchy comes from - * `parent`, not nesting — so a keyed override is plain object spread and - * `parent` can be type-checked via `keyof`. - * - * `Parent` is the union of keys allowed for `parent`; {@link defineMenu} infers - * it from the input, including route paths spread in from an adapter. + * Authoring/adapter input entry, keyed by identity. Hierarchy comes from + * `parent` (a key), not nesting. `Parent` — the union of allowed parent keys, + * inferred by {@link defineMenu} from the input. */ export type MenuItemInput< Parent extends string = string, From 5e75a34cc451a9bdc01af876b802ee1accdaefb5 Mon Sep 17 00:00:00 2001 From: Denis Goncharenko Date: Tue, 21 Jul 2026 20:32:30 +0500 Subject: [PATCH 2/9] feat(react): useMenu hook and headless render-prop renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - renders no markup of its own: renderItem component per entry, a render-prop children for the shell, renderBeforeItem/renderAfterItem slots - useMenu owns active item + disclosure state in a store (menuStateStore), read per-node via useSyncExternalStore — a toggle or a navigation re-renders only the affected nodes, never the caller - Item props: isActive / containsActive, driven by setActive - MenuItemPropsOf infers meta for a standalone Item - replaces the components={{Container,Item}} + createMenuComponent API --- src/react/Menu.tsx | 276 ++++++++++++++++------ src/react/__tests__/Menu.test.tsx | 341 ++++++++++++++++++++++++--- src/react/__tests__/useMenu.test.tsx | 111 +++++++++ src/react/index.ts | 12 +- src/react/menuStateStore.ts | 76 ++++++ src/react/useMenu.ts | 126 ++++++++++ 6 files changed, 832 insertions(+), 110 deletions(-) create mode 100644 src/react/__tests__/useMenu.test.tsx create mode 100644 src/react/menuStateStore.ts create mode 100644 src/react/useMenu.ts diff --git a/src/react/Menu.tsx b/src/react/Menu.tsx index c1b8294..eeb427c 100644 --- a/src/react/Menu.tsx +++ b/src/react/Menu.tsx @@ -1,13 +1,39 @@ import type { ComponentType, ReactNode } from "react"; -import { Fragment, useState } from "react"; -import type { MenuItem, MenuItemState, Menu as MenuModel } from "../types"; +import { + createContext, + memo, + useCallback, + useContext, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import type { MenuItem, Menu as MenuModel } from "../types"; +import { createMenuStateStore, type MenuStateStore } from "./menuStateStore"; /** Expanded state for a section that doesn't set `defaultOpen`. */ const DEFAULT_OPEN = true; -/** The single outer shell wrapping the whole menu (the consumer's `