Skip to content

feat: useMenu hook — active branch, disclosure state, breadcrumb trail - #4

Merged
dennation merged 9 commits into
mainfrom
claude/use-menu-hook
Jul 21, 2026
Merged

feat: useMenu hook — active branch, disclosure state, breadcrumb trail#4
dennation merged 9 commits into
mainfrom
claude/use-menu-hook

Conversation

@dennation

Copy link
Copy Markdown
Owner

The top layer of the active-menu work: a hook that owns disclosure state and derives the active branch, plus the two things it stands on.

Three layers, each reusable on its own:

Core (React-free) — @dennation/menu

  • getMenuTrail(menu, id) — the ancestor chain down to id (breadcrumbs and the branch to expand). Usable on the server / in loaders, no React.
  • findMenuItem(menu, id) — the node, sharing that traversal.

<Menu> learns controlled state — @dennation/menu/react

  • open / onOpenChange to control disclosure from outside; defaultOpen to seed the uncontrolled case.
  • State is a sparse Record<id, boolean> of overrides, not a list of open sections — so item.defaultOpen still wins by default and the common case stays empty. (This is the one place I stepped off the earlier openIds: string[] sketch — a list would be near-complete under "open by default"; a map of overrides is smaller and gives one isOpen in both modes.)

useMenu@dennation/menu/react

const { menuProps, trail, activeItem, open, close, toggle, isOpen } =
  useMenu(menu, { activeId: pathname });
<Menu {...menuProps} components={components} />;

Auto-expands the active branch on activeId change, exposes trail/activeItem for breadcrumbs, and open/close/toggle/isOpen for imperative control — RHF-style bind-props (Variant A), not a returned component.

Scope I deliberately trimmed

  • filterMenu / flattenMenu (rights, ⌘K, prev/next) — left out; no consumer in this PR. Add when one lands.
  • External-store controlled useMenu — the hook owns state; a fully-controlled openIds input can come later if needed.

Test plan

  • pnpm test — 35 passed (was 24): trail/find, controlled <Menu>, useMenu auto-expand + imperative toggles
  • pnpm typecheck — clean
  • pnpm builduseMenu/getMenuTrail/findMenuItem in declarations
  • biome check src — clean

Branched from main; independent of #3 (touches no adapter files).

@dennation
dennation force-pushed the claude/use-menu-hook branch from e406cd4 to 598f9ae Compare July 21, 2026 14:15
- getMenuTrail/findMenuItem with …By predicate variants (share one traversal)
- defineMenu infers the meta type M from the input's meta fields (MetaOf<T>),
  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
- <Menu> 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<typeof menu> infers meta for a standalone Item
- replaces the components={{Container,Item}} + createMenuComponent API
The consumer augments StaticDataRouteOption with RouteMenuEntry<Meta> once, which
types staticData.menu (including meta) at every route. menuInputFromRouteTree
defaults M to that registered meta (RegisteredMeta), so
defineMenu(menuInputFromRouteTree(tree)) is Menu<Meta> with no explicit type
argument. Drops the adapter's own declare module and the icon field.
Headless positioning, one end-to-end Usage example (data -> Item -> AppMenu ->
useMenu), per-surface API tables (<Menu> props, useMenu options/returns, item
types), an Adapters section, and a Guides section for edge cases.
@dennation
dennation force-pushed the claude/use-menu-hook branch from 0df7fc9 to 744a368 Compare July 21, 2026 15:34
version 0.0.0; headless/typesafe/router-agnostic description; keywords, homepage,
bugs, author, publishConfig (public); prepublishOnly builds dist. README tagline
gains 'typesafe'.
Single-package changesets (same tool as the other projects): config with
access=public and baseBranch=main, and changeset/version/release scripts. Add
the initial-release changeset (minor: 0.0.0 -> 0.1.0).
test stage (typecheck, test, build) on every pipeline; publish stage on the
default branch runs `changeset publish` (needs NPM_TOKEN). Publish after
merging a `changeset version` bump; no-op when nothing is unreleased.
The repo is on GitHub, not GitLab. ci.yml runs typecheck/test/build on PRs and
main. release.yml uses changesets/action: opens a Version Packages PR from
pending changesets, and publishes to npm when it's merged (needs NPM_TOKEN).
@dennation
dennation merged commit 0e18770 into main Jul 21, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant