feat: useMenu hook — active branch, disclosure state, breadcrumb trail - #4
Merged
Conversation
dennation
force-pushed
the
claude/use-menu-hook
branch
from
July 21, 2026 14:15
e406cd4 to
598f9ae
Compare
- 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
force-pushed
the
claude/use-menu-hook
branch
from
July 21, 2026 15:34
0df7fc9 to
744a368
Compare
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/menugetMenuTrail(menu, id)— the ancestor chain down toid(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/reactopen/onOpenChangeto control disclosure from outside;defaultOpento seed the uncontrolled case.Record<id, boolean>of overrides, not a list of open sections — soitem.defaultOpenstill wins by default and the common case stays empty. (This is the one place I stepped off the earlieropenIds: string[]sketch — a list would be near-complete under "open by default"; a map of overrides is smaller and gives oneisOpenin both modes.)useMenu—@dennation/menu/reactAuto-expands the active branch on
activeIdchange, exposestrail/activeItemfor breadcrumbs, andopen/close/toggle/isOpenfor 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.useMenu— the hook owns state; a fully-controlledopenIdsinput can come later if needed.Test plan
pnpm test— 35 passed (was 24): trail/find, controlled<Menu>,useMenuauto-expand + imperative togglespnpm typecheck— cleanpnpm build—useMenu/getMenuTrail/findMenuItemin declarationsbiome check src— cleanBranched from
main; independent of #3 (touches no adapter files).