Skip to content

Release v2.0.0#221

Merged
iannbing merged 62 commits into
masterfrom
development
Apr 21, 2026
Merged

Release v2.0.0#221
iannbing merged 62 commits into
masterfrom
development

Conversation

@iannbing
Copy link
Copy Markdown
Owner

Merging development into master to cut the stable v2.0.0 release. This is the promotion step for the rewrite validated via 2.0.0-rc.1 and 2.0.0-rc.2 on the next dist-tag.

What this merges

62 commits on development since master last absorbed changes. Full narrative in CHANGELOG.md. Highlights:

  • Full ground-up v2 rewrite — function component + `useReducer`, zero runtime dependencies, WAI-ARIA tree pattern, roving tabindex, `useDeferredValue` auto-detect on React 18+.
  • New public API — `classNames`, `labels`, `keySeparator`, `initialActiveKey`/`initialFocusKey` props; `expandAll`/`collapseAll` on the imperative handle; `unflatten` + `collectBranchKeys` + `TreeMenuHandle` exports.
  • Tailwind v4 theme auto-detect in the default CSS; monochrome B/W defaults on a light background with mid-grey focus ring.
  • 3.41 KB minified + brotli (size-limit budget 3.5 KB).
  • React matrix: 16.14 / 17 / 18.3 / 19 — all green in CI.
  • Starlight docs site at `docs/`, ready to publish to GitHub Pages on this merge.
  • CI/CD overhaul — npm Trusted Publishing (no token), branch-driven release workflow, idempotent Pages build-type flip.

What merging triggers

On push to `master`:

  1. `release.yml` → `release-stable` — strips `-rc.N` from package.json, runs all gates, publishes `2.0.0` to npm as `latest` via OIDC, tags `v2.0.0`.
  2. `docs.yml` — idempotent flip of Pages `build_type` to `workflow`, builds Starlight, deploys to https://iannbing.github.io/react-simple-tree-menu/ (replacing the old Storybook publish).
  3. `ci.yml` — full matrix re-runs as a belt-and-braces check.

Validation before this PR

  • CI matrix green on HEAD (React 16.14 / 17 / 18.3 / 19 + lint + typecheck + build).
  • `release-rc` workflow has run cleanly on development with Trusted Publishing.
  • `2.0.0-rc.1` + `2.0.0-rc.2` installed and built clean in `examples/nextjs` (Next 15 / React 19) and `examples/react16` (Vite / React 16.14).
  • Dev-server smoke test on Next.js example: SSR markup includes `role="tree"`, no hydration warnings.

Test plan after merge

  • Watch `release-stable` run — expect `2.0.0` on npm as `latest`, `v2.0.0` tag pushed.
  • Watch `docs.yml` run — expect Pages `build_type` to flip to `workflow` and Starlight site to serve at the Pages URL.
  • `npm view react-simple-tree-menu version` returns `2.0.0`.
  • https://iannbing.github.io/react-simple-tree-menu/ renders the new Starlight site.

wise-introvert and others added 30 commits December 22, 2020 21:44
fix(README): minial => minimal
fix(README): minial => minimal
Strangler fig setup. Move the v1 implementation to src/legacy/ so the
library keeps shipping while the rewrite lands beside it, capture the
v1 public API shape in test-fixtures/api-v1.d.ts for diff-against-dist,
and replace the build tooling wholesale.

- test-fixtures/api-v1.d.ts — v1 type surface, synthesized from src/
  (dist was stale). This is the contract v2 must match modulo
  explicit removals in api-v2-removals.json.
- Move legacy: src/TreeMenu, src/KeyDown.tsx, src/sass, and the
  existing test/typings folders all land under src/legacy/; public
  entry re-exports from there unchanged.
- New toolchain: TypeScript 5 strict + Bundler resolution + automatic
  JSX runtime; Vite + Vitest + RTL; Storybook 8 (CSF3); tsup for the
  publish bundle; ESLint 9 flat config; Prettier 3; GitHub Actions
  matrix on Node 20 × React 16.14/17/18/19.
- Delete: .babelrc, rollup.config.js, jest.config.js, .circleci/,
  every Enzyme devDep.
- package.json: "type": "module", exports map (with dist/main.cjs.js
  and dist/main.esm.js aliases preserved for deep-import consumers),
  peerDependencies >= 16.14, engines node >= 20.
TDD red phase: write every test that specifies v2 behavior before any
new implementation code. Three layers with distinct roles.

- Characterization (regression gate): RTL + user-event against the
  public entry (currently legacy). Green at commit time; turns red
  the moment strangulation breaks anything. Covers both data formats,
  controlled/uncontrolled duality, search + debounce + ancestor
  auto-open, resetOpenNodes, keyboard model, disableKeyboard,
  resetOpenNodesOnDataUpdate, locale/matchSearch.
- Forward (TDD driver): a11y (WAI-ARIA tree, roving tabindex), SSR
  under renderToString, and an API-contract diff against the v1
  fixture modulo the api-v2-removals allowlist. Red against legacy —
  marked `.fails` to keep CI green while preserving TDD discipline.
- Unit stubs: it.todo() tests for walk, useTreeMenuState,
  useDebouncedCallback. Red (modules don't exist yet) — each one's
  implementation commit must turn its stubs green.
…te (M3)

Inside-out strangulation of the leaf dependencies. No React state, no
DOM; each module is independently unit-testable and reused by the new
components in M4.

- walk(): O(visible) tree flatten respecting openNodes/searchTerm.
  Closed-branch short-circuit (fully-collapsed 100k walk in ~0.25 µs),
  single preallocated output array, label-normalization cached once per
  walk, and accumulated parent-path passed into recursion. Perf
  contract enforced by vitest bench (walk.bench.ts).
- useDebouncedCallback(): ~40-line hook replacing tiny-debounce.
  Reference-stable across renders via a ref, cancels on unmount.
- useTreeMenuState(): useReducer with TOGGLE/SEARCH/ACTIVATE/FOCUS/
  RESET. Controlled/uncontrolled duality at the hook boundary — per-
  slot controlled overrides merge into state via useMemo; TOGGLE
  dispatch gated by a ref so dispatch identity stays stable.

Legacy still serves the published entry — these modules sit unused
next to it until the M4 components wire them in.
Build the React component tree that composes the M3 modules, then flip
the public entry in a single reviewable diff and delete legacy.

- ItemComponent: memoized function component. WAI-ARIA treeitem with
  aria-expanded/level/selected/setsize/posinset, roving tabindex.
- KeyDown: function-component port of the legacy keyboard wrapper;
  preserves the <div tabIndex=0> public DOM shape.
- defaultChildren: render-prop that emits <ul role="tree">, the search
  input (when hasSearch), and ItemComponent per visible Item.
- TreeMenu: forwardRef function component. Composes useTreeMenuState +
  walk (via useMemo) + useDebouncedCallback + KeyDown + defaultChildren.
  useImperativeHandle exposes resetOpenNodes as TreeMenuHandle.
- v2 CSS: single hand-written src/styles.css with `--rstm-*` custom
  properties for theming; published to dist/styles.css, accessible
  via the `react-simple-tree-menu/styles` subpath export (the old
  `dist/main.css` path is kept as an alias).
- Dual-entry characterization parity: a temporary test imports from
  src/tree-menu directly and re-runs the suite, flushing any gap
  before the flip.
- Flip src/index.ts to the new modules in one reviewable commit.
  Forward suite's a11y + SSR assertions turn green; .fails markers
  lifted.
- Delete src/legacy entirely, plus the four runtime deps (classnames,
  fast-memoize, is-empty, tiny-debounce) from devDeps. `npm ls` shows
  only the React peer and modern devDeps.
…low-ups

Tighten TypeScript strictness and clean up naming now that the cutover
has landed and we can change things freely inside src/.

- Enable `exactOptionalPropertyTypes` in tsconfig and fix the downstream
  call sites: optional fields explicitly typed `T | undefined` on
  internal types (WalkProps, UseTreeMenuStateProps, TreeMenuAction) so
  destructure/spread round-trips cleanly.
- Include stories and config files in typecheck so they can't drift.
- Remove the `as object` cast in ItemComponent that was silently
  spreading `key` and arbitrary user props onto the rendered <li>
  as HTML attributes.
- Remove the last `as TreeMenuItem` cast in TreeMenu via a conditional
  `toggleNode` spread — exactOptionalPropertyTypes now rejects the
  implicit `toggleNode: undefined` that was hidden before.
- Rename all camelCase filenames to kebab-case (TreeMenu.tsx →
  tree-menu.tsx, etc.) to match the modern React community convention
  and make grep-by-filename predictable.
- Fold in code-review findings from a full pass over the new modules
  (misc small correctness + idiom tightenings — no behavior change).
Three additive extensions to the styling story, plus a default-look
refinement pass.

- Tailwind v4-aware CSS: every color/typography/spacing `--rstm-*`
  token resolves via a var() chain that prefers Tailwind v4's
  auto-exposed theme variables (`--color-primary`, `--color-gray-*`,
  `--font-sans`) with hex fallbacks. Tailwind v4 users get brand-
  aligned colors with zero config beyond the one CSS import. Non-
  Tailwind users get the library's defaults; v3 users can drop a
  `@layer` snippet (documented in the Theming guide).
- `classNames` prop (TreeMenuClassNames type): per-slot class strings
  appended to the rstm-* anchors. Tailwind / utility-class users can
  skip the CSS import entirely and style via the prop; anchor classes
  remain as inert strings so backward-compat CSS overrides keep
  working. Slots: group, item, active, focused, search, toggleIcon,
  toggleIconSymbol.
- `labels` prop (TreeMenuLabels type): i18n-friendly overrides for
  default UI copy (searchPlaceholder, searchAriaLabel).
- Default-look refinement: sans-serif font stack via system-ui chain,
  smaller corner radius, tighter padding, larger toggle glyph, instant
  transitions (0ms) that consumers opt into, inset focus ring that
  meets WCAG 2.1, and `:where()`-scoped tokens so the search input
  (sibling of .rstm-tree-item-group) can read them too.
Replace the v1 README with a from-scratch v2 rewrite covering the new
install path, both data formats, keyboard model, three styling paths
(default CSS / Tailwind v4 auto-detect / headless classNames), the full
props table, imperative ref, and a custom render-props example. Add a
Keep-a-Changelog CHANGELOG with a `[2.0.0] — unreleased` section
covering every breaking change, addition, removal, internal change,
and infra update.
Wrap state.searchTerm in useDeferredValue when the React runtime
exposes it (18+). On 16.14/17 the wrapper degrades to identity — no
feature-detect overhead at runtime, no behavioral change.

Effect on React 18/19: typing fires `search` → debounced → SEARCH
dispatch, and the subsequent walk re-runs at transition priority so
the input stays responsive even on very large trees. Two
characterization tests updated to `await act(async ...)` to flush the
transition under fake timers — everything else is unchanged.
GitHub Actions release pipeline:

- `release/v2` branch push → run lint + test + build, then
  `npm-version-suffix rc` + `npm publish --tag next`, producing
  2.0.0-rc.0, 2.0.0-rc.1, … on subsequent runs.
- `workflow_dispatch` job promotes to `latest`: `npm version 2.0.0
  && npm publish` once the RC has been validated in the example apps.

OIDC-based npm provenance is enabled for both flows. No npm token in
repo secrets.
Rewrite the default UI's DOM so children of an open branch live inside
their parent <li> (canonical WAI-ARIA tree shape) instead of being
flat siblings with inline paddingLeft.

- Nested structure: <ul role="tree"> → <li role="treeitem"> → <ul
  role="group"> → <li> …. Reconstructed from the flat `items[]` via
  an internal `unflatten()` (exposed publicly in a later bucket).
- Render-props contract test: dedicated suite proves custom
  render-props consumers are unaffected by the nested-DOM switch —
  they still receive the flat items[] and can render however they
  like.
- Row wrapper: each <li> now contains a `.rstm-tree-item-row <div>`
  sibling to the nested <ul>. Visual state classes (`--active`,
  `--focused`) and hover/focus styling live on the row, not the
  <li>, so backgrounds no longer bleed over nested children.
- Suppress the browser's default focus outline on the <li>: tabIndex
  lives on the <li> (required for roving tabindex), so the browser
  otherwise paints a ring around the li's full box which includes
  the nested subgroup <ul>. Own focus indicator is the inset
  box-shadow on `.rstm-tree-item--focused` — WCAG 2.1 compliant.
Replace Storybook-on-GH-Pages with a product-like docs site at docs/.
Storybook stays as a local-only dev tool; not deployed.

- Scaffold: Astro 5 + Starlight 0.37 as a subdirectory with its own
  package.json. Vite alias points `react-simple-tree-menu` at
  `../src/index.tsx` for hot-reload against library source in dev —
  no parent rebuild required.
- Landing + Getting Started + guides (controlled/uncontrolled, search,
  keyboard & a11y, theming, Next.js App Router, virtualization) +
  API reference (TreeMenu, types) + Migration v1→v2 + custom 404.
- `LiveTreeMenu` React island; pages embed it via `client:only="react"`.
- Headless npm override pins `@astrojs/sitemap` to 3.6.0 to sidestep
  the zod v3/v4 mismatch that shipped with sitemap 3.7.x.
- GitHub Actions workflow at .github/workflows/docs.yml builds
  docs/ on push to master (when docs/, src/, or the workflow
  itself change) and deploys dist/ via the official upload /
  deploy-pages actions.
- README / .prettierignore formatting pass folded in.
- examples/nextjs/: Next.js 15 + React 19 + App Router. Sidebar is a
  Client Component (`"use client"` scoped to the one file); plain JSON
  flows from the server Layout. No transpilePackages, no custom Webpack
  config; the library's ESM build + CSS subpath export resolve natively.
- examples/react16/: Vite + React 16.14.0 pinned at the peer floor.
  Mounts via ReactDOM.render() (pre-18 root API) to prove the library's
  automatic JSX runtime + hooks compile against the oldest supported
  React. Substitutes CRA (deprecated, unreliable on Node 22).
- Strict tree-node types: `TreeNode` (object form) now has
  `key?: never`; `TreeNodeInArray` (array form) has `index?: never`.
  v1's open index signature silently accepted both fields on the wrong
  shape; v2 rejects the mismatch at compile time. Correctly typed v1
  code is unaffected; walk()'s rest-destructure cast adjusted to a
  permissive record so the runtime keeps handling either shape.
- Render-cost bench at src/__tests__/render-perf.bench.tsx — complements
  walk.bench.ts by timing the React render pass via renderToString
  across 1k/10k/50k trees. Empirical numbers documented in the file
  header: collapsed render is ~50 µs regardless of input size (walk's
  short-circuit wins).
Runnable Storybook stories + a docs-site guide that give consumers
templates for the three render-props shapes they're likely to reach
for — and prove each one still compiles/renders as the library evolves.

- Virtualization recipe (src/tree-menu.virtualization.stories.tsx):
  composes <TreeMenu> with `react-window`'s FixedSizeList via the
  render-props API. `react-window` is a devDependency only; never
  shipped. 3k-node tree proves the flat items[] feeds straight into
  a virtualized list.
- Flat render-props story: mirrors the README simple path (one <ul>
  with `level`-driven paddingLeft).
- Nested render-props story: reconstructs `<ul>/<li>/<ul>` hierarchy
  by grouping on slash-joined keys — same shape the default UI
  emits.
- Flat vs. nested accessibility notes in the new render-props guide:
  what screen readers do with aria-level alone, when you need
  role="group" DOM nesting, and how to supply aria-owns as the
  third option.
- Render-props keyboard-nav fix: tabIndex follows item.focused
  (roving model), visible focus ring via a state-driven inset
  box-shadow, and the shared `useRovingFocus` hook that'll be
  mirrored by the library in the next bucket.
Public `unflatten` helper + a final review sweep covering a real a11y
bug, several doc/type contradictions, and small cleanups.

- `unflatten(items)` / `UnflattenResult<T>`: extract the slash-joined-
  key grouping from default-children.tsx into its own module and
  export it from the public entry. Generic over `T extends { key:
  string }` — works with raw Item, TreeMenuItem, or any projected
  shape. Custom render-props that want nested DOM now call one
  library function instead of copy-pasting ~15 lines. README + docs
  guide + API reference + CHANGELOG updated; Storybook story swapped
  to the public export so there's one canonical algorithm.

Review-pass fixes (driven by an independent code review):

- fix(a11y): ItemComponent and the render-props stories now
  programmatically focus the treeitem <li> when `item.focused`
  flips true. Roving tabindex alone doesn't fire focus events;
  screen readers were silent on arrow-key navigation before this.
  Dedicated test in item-component.test.tsx asserts
  `document.activeElement`.
- docs(api): types.mdx + tree-menu.mdx now match src/types.ts
  exactly — LocaleFunctionProps added; LocaleFunction / MatchSearch
  function signatures corrected; TreeNode includes `index`;
  TreeNodeInArray `index?: never`; Item vs TreeMenuItem split;
  onClick typed as MouseEvent<HTMLLIElement>.
- docs(guides): keyboard.mdx describes the actual v2 behavior
  (close-or-focus-parent on ←, simple open on →) instead of
  overclaiming the full WAI-ARIA tree-pattern richness. The
  enrichment is a separate issue.
- tree-menu.tsx `left()`: drop the redundant `FOCUS` dispatch that
  followed `TOGGLE` — TOGGLE doesn't move focus, so the second
  dispatch was always a no-op.
- CHANGELOG: bundle size updated to 3.01 KB / 3.5 KB after the
  +40 bytes from useRef/useEffect in ItemComponent.
- Stale comments: forward.test.tsx header (`.fails` markers are
  long gone), and characterization notes about fast-memoize /
  cacheSearch cache-key quirks that no longer exist.

Verification: 154 tests (+1 new focus-movement test), typecheck,
lint, build, API contract, size-limit, Storybook, docs site — all
green.
The library's default `--rstm-text-color` is `var(--color-gray-800, #1f2937)`
— Tailwind's gray-800 or the hex fallback. Starlight doesn't expose
`--color-gray-800`, so dark theme uses the near-black hex and the demo's
item labels disappear against Starlight's dark card background.

Override the library tokens on `.rstm-live-demo` to point at Starlight's
adaptive `--sl-color-*` scale. Now the demo reads correctly on both
light and dark themes without touching the library's default CSS.
My previous `.rstm-live-demo { --rstm-text-color: ... }` didn't actually
take effect inside the tree — the library declares the token directly on
`.rstm-tree-item-group` and `.rstm-search` (via a zero-specificity
`:where()`), and an element's own declaration beats inheritance from an
ancestor regardless of specificity.

Target the same elements the library targets. Light mode was working
only by accident because the library's hex fallback (#1f2937) happens
to sit OK on a light card; dark mode was broken because the same hex
disappears against Starlight's dark sidebar bg.
Move the library's token defaults from `:where(.rstm-tree-item-group,
.rstm-search)` to `:where(:root)`. Same zero-specificity guarantee for
consumer overrides, but the tokens now inherit top-down instead of
being redeclared on the inner elements.

Why this was a bug: CSS custom-property inheritance loses to an
element's own declaration regardless of specificity. The previous
layout redeclared `--rstm-*` on `.rstm-tree-item-group` + `.rstm-search`,
so any ancestor declaration — e.g. `.dark-panel { --rstm-text-color:
white }` or `:root[data-theme='dark'] { ... }` — was silently ignored
inside the tree. The tree used its own declaration, regardless of the
surrounding container's theme. Only direct-selector overrides
(`.rstm-tree-item-group { ... }`) worked, which most users don't reach
for first.

After: library defaults are inherited fallbacks. Any ancestor
override wins naturally via inheritance — global (`:root`), per-theme
(`:root[data-theme='dark']`), or per-panel (`.my-panel`) all work as
intended. Direct-selector overrides keep working too (they sit closer
to the tree than `:root` and win by proximity).

Tailwind v4 integration untouched — the `var(--color-gray-800,
fallback)` chain resolves the same way whether it's declared on
`:root` or on `.rstm-tree-item-group`.

- src/styles.css: swap the selector; bundle size unchanged (3.01 KB).
- docs/src/styles/docs.css: revert the `.rstm-live-demo
  .rstm-tree-item-group` workaround to a plain ancestor declaration
  on `.rstm-live-demo`. Works on both Starlight themes now.
- docs/guides/theming.mdx: document the three override patterns
  (global, per-theme, per-panel) with examples.
- README.md: same update to the styling quickstart.

No API change. No behavior change for consumers using the documented
direct-selector pattern. All 154 tests, typecheck, lint, API contract
still green.
Elevate the Starlight defaults to something that feels product-like
without reinventing Starlight's layout.

Typography
- Inter for body/UI, JetBrains Mono for code, loaded via Google Fonts
  head tags (preconnect + display=swap). Starlight picks them up
  through `--sl-font` / `--sl-font-system-mono` overrides.
- Tighter tracking on headings; h1 uses a subtle text-gradient from
  text color into the accent to set the page tone.

Color
- Violet-tinted indigo accent (#818cf8 dark, #4f46e5 light) with a
  deep-violet backdrop. Warm gray ramps replace Starlight's cool-blue
  defaults; accent and hairline tokens feed cards and sidebar rails.
- Subtle radial gradient wash behind every page for depth without
  noise.

Components
- Landing hero: larger tagline, gradient h1, pill-style primary action
  with soft glow on hover, minimal secondary action.
- Card grid: gradient-filled cards with accent-tinted border-on-hover
  and a soft ambient shadow.
- Live demo: same card treatment; rstm-* tokens routed through the
  docs palette via ancestor inheritance (works because the library
  declares defaults on :root with zero specificity).
- Sidebar: active row gets a short accent gradient + left rail.
- In-prose links: accent-tinted underline that thickens on hover.
- Tables: rounded container with styled header row.
- Inline code: monospaced pill in accent color.

Landing copy
- Tagline tightened to call out size + React range.
- "Try it" demo followed by the minimal three-line code snippet —
  users see the API within one scroll.
- Card grid rewritten to six sharper positioning lines (accessibility,
  escape hatch, theming, etc).

No library changes. All gates still green.
Logo
- A minimal disclosure-triangle + three progressively-indented rounded
  bars. 32-unit viewBox, 3-unit bar height with 1.5-unit corners so it
  reads cleanly from 16 × 16 favicon up to the 40 × 40 nav render.
- Light variant: indigo accent triangle + dark neutral bars.
- Dark variant: violet accent triangle + light neutral bars.
- Favicon: single SVG with a `prefers-color-scheme` media rule so the
  bars flip fill between light and dark browser chrome.
- Wired via `starlight.logo.light` / `.dark` and `starlight.favicon`.

Compact landing
- Hero h1 capped at ~2.5rem (was clamping up to 3.6rem and wrapping to
  3 lines at the current viewport width).
- Padding-block pulled in from default ~4rem to 1.5rem / 0.25rem so
  the demo is visible on first paint instead of below the fold.
- Tagline reflowed to a tighter 46ch, smaller font, less leading.
- "Try it" heading styled as a small uppercase label (was a full-size
  h2 that added vertical mass for no payoff).

Card grid hover fix
- Removed the `transform: translateY(-2px)` on `.card:hover`. Starlight's
  CardGrid `stagger` mode applies its own `transform` to the even
  column to offset it by ~2rem, and our hover was overwriting it —
  making each right-column card snap back up to the top of the row
  whenever the mouse entered it. Hover now animates box-shadow +
  border-color only; the stagger stays intact.
Wrap the pair in `<div class="rstm-hero-sample">` inside the mdx so the
grid can target both children together. MDX 2 parses fenced code blocks
inside a JSX element as long as blank lines separate the content.

Layout: single column under 56rem (mobile/tablet), two columns above
(demo left, code right, slight width bias to the code so it breathes).
`.expressive-code` block retargeted so its baseline margin doesn't
fight the grid gap.
- Hoist `align-items: start` + `align-self: start` out of the @media
  query so the live demo and the code block sit flush at the top of
  their grid row even in the stacked mobile layout.
- Columns now split 1fr/1fr instead of 1fr/1.15fr so the code doesn't
  claim extra horizontal space it no longer needs.
- Reformat the JSX example to one-prop-per-line so the block fits
  without horizontal overflow — the scrollbar was coming from the
  single long line, not the container width.
- Target `.rstm-live-demo` via descendant selector (not `>`). Astro
  wraps the React island in `<astro-island>` with display:contents, so
  `.rstm-hero-sample > .rstm-live-demo` never matched, leaving the
  demo at its default `max-width: 32rem` while the code block filled
  its column. Descendant selector bypasses the wrapper.
- Apply matching card treatment to the `.expressive-code` block —
  border, radius, shadow — so both columns read as peers. Reset the
  inner `pre` / `figure` radius to avoid doubled corners.
- Remove the `## Try it` heading from the landing; the demo + code
  sample is self-explanatory and the heading was adding vertical mass
  without earning it.
- Drop the now-orphaned `h2:first-child` uppercase-label styling that
  was only meant for "Try it".
Bumped `.hero + .sl-markdown-content` margin-top from 1rem to 2.5rem so
the "Get started" / "View on GitHub" buttons don't sit flush against
the demo+code sample card.
Library — toggle icon affordance
- --rstm-icon-size bumped from 1rem to 1.375rem so the disclosure
  control is big enough to look like a hit target rather than a
  decorative glyph.
- .rstm-toggle-icon now has a rounded hover state (subtle backgrounded
  pill, darker foreground) so mouse and keyboard users get a clear
  "clickable" cue on the control itself — not only on the row.
- Symbol weight bumped to 600 so the triangle doesn't wash out at
  small indent levels.
- Bundle still 3.01 KB / 3.5 KB budget.

Docs — logo redesign
- Replaced the stacked-bars mark (which read as a hamburger in the
  nav) with an abstract binary-tree glyph — parent node on the left,
  two children branching to the right, connected by accent-colored
  strokes. Matches how a tree menu actually extends horizontally.
- Three circles + two path segments. Light + dark variants + a
  favicon that flips node color via prefers-color-scheme.
Back to text-only brand in the nav. The abstract-tree marks weren't
landing. Easy enough to add back later if a proper mark emerges.
- Hero action `link` in Starlight frontmatter is not auto-prefixed with
  Astro's `base` (unlike sidebar links). The old `/getting-started/`
  resolved to a 404 on the deployed site. Include the base explicitly.
- Tagline now calls out Tailwind v4 drop-in with zero config.
- "Tailwind-friendly" card renamed to "Drops into Tailwind v4
  projects" with concrete mention of `--color-*` + `--font-sans`
  auto-detection and the no-preset / no-content-glob benefit.
iannbing and others added 29 commits April 20, 2026 09:52
Unicode ▸ / ▾ have asymmetric side-bearing in most system fonts — the
glyph's bounding box centers via flexbox, but the visible shape still
reads left-of-center inside the hover pill (visible once the pill was
added in the previous toggle-icon polish).

Swap the Unicode character defaults for two inline SVG triangles.
Paths chosen so the triangle CENTROID lands exactly at (5, 5) in the
10 × 10 viewBox, which guarantees optical centering regardless of
host font. `currentColor` + `aria-hidden` keep them theme-aware and
screen-reader-quiet. `width/height: 0.625em` scales with the toggle's
font-size cascade, so the glyph stays proportional to consumer
icon-size overrides.

Toggle symbol wrapper simplified to `inline-flex; align-items: center;
justify-content: center;` — the old font-size bump existed to enlarge
the Unicode glyph and is dead weight with SVGs (which size themselves
explicitly).
Split the single "Tip:" line into a two-row tips block so the keyboard
contract is discoverable without leaving the landing:

  Mouse: click ▸ to expand · click a row to select
  Keys:  Tab focuses · ↑ ↓ moves · ← → collapse/expand · Enter selects

Each key rendered as a `<kbd>` chip (mono, subtle accent-tinted pill)
so the glyph reads as "press this" rather than prose. Idle Last-clicked
placeholder updated to nudge visitors toward tabbing in too.
…attern)

Bring the `→` handler into line with the canonical tree-view pattern:

  - Closed branch: open it, focus stays. (unchanged)
  - Open branch:   move focus to the first child. (NEW)
  - Leaf:          no-op. (unchanged, now explicit)

Implementation is a one-liner lookup: walk() emits items depth-first,
so the first child of an open parent at items[focusIndex] is always
items[focusIndex + 1] (verified by checking its level == parent + 1
to guard against sibling items showing up when the open parent has no
children emitted for some reason).

Tests: two new characterization cases — `→ on open branch moves focus
to first child`, and `→ on leaf is a no-op`. 160 tests green (+2).
Bundle 3.17 → 3.20 KB (+30 bytes; well under the 3.5 KB budget).

Docs: keyboard.mdx updated to describe the full pattern; README table
refreshed; CHANGELOG entry added under "Added" with the VS Code /
Finder / GitHub-tree comparison.
The old shape composed the shadow through an intermediate token:

    :where(:root) {
      --rstm-focus-ring-color: var(--color-primary, ...);
      --rstm-focus-shadow: inset 0 0 0 2px var(--rstm-focus-ring-color);
    }
    .rstm-tree-item--focused { box-shadow: var(--rstm-focus-shadow); }

CSS custom-property substitution resolves `var()` inside a value at
the element where the value is declared, not where it's consumed.
`--rstm-focus-shadow` was declared on `:root`, so the color was baked
in from `:root`'s `--rstm-focus-ring-color` (the library default —
indigo). A consumer scoping `--rstm-focus-ring-color` on an ancestor
(e.g. `.dark-panel { --rstm-focus-ring-color: teal }`) had no effect
on the focus ring because the inherited `--rstm-focus-shadow` still
carried the baked-in root color.

Fix: drop `--rstm-focus-shadow` entirely; inline the `var()` at the
consumption site so the color is looked up from the focused row's
cascade.

    .rstm-tree-item--focused {
      box-shadow: inset 0 0 0 2px var(--rstm-focus-ring-color);
    }

Now per-panel / per-theme overrides of `--rstm-focus-ring-color`
cascade into the focus ring the way the rest of the token system does.

Verified against the docs site's live demo — B/W theme override now
produces a black / white focus ring instead of the library's baked-in
indigo default.

All 160 tests green, bundle 3.20 KB / 3.5 KB (unchanged).
- Strip gradients, shadows, and rounded corners outside code blocks.
- Monochrome palette (pure B/W accent, greyscale aliases for Starlight's
  color ramps) with a mid-grey focus ring tone so focus reads distinct
  from the fully-accent active row.
- Live demo loses its card chrome in favor of a 2px accent left rail
  and an `initialActiveKey`, so the widget no longer dissolves into the
  page nor rhymes visually with the adjacent code frames.
- Hero code column stacks `Installation` bash block above the TSX
  usage. Expressive Code terminal frame: hide the decorative macOS
  dots and left-align the title flush with the code's inline padding.
Library defaults swap from indigo to pure black-and-white — black
active row, white foreground, mid-grey focus ring. Tailwind v4's
`--color-primary` still wins for active state when present, so brand
colors continue to flow through; focus stays neutral grey regardless
so a focused-but-not-selected row reads distinct from a selected
row. Focus-ring box-shadow tightened from 2px → 1px so it doesn't
read as chunky chrome at default row height.

Docs-site demo drops its `--rstm-*` token overrides (palette and
radius) — the live tree now renders the library's built-in defaults
directly. A dark-theme bridge scoped to `:root[data-theme='dark']`
flips the tokens under Starlight's manual theme toggle, doubling as
live documentation of the consumer override API.
Wraps the `client:only="react"` live-demo island in a slot that
renders a static shimmer skeleton as a sibling. CSS hides it via
`astro-island:has(*) + .rstm-skeleton { display: none }` once
hydration populates the island. Skeleton mirrors the hero tree's
layout (search input + three top-level rows with one expanded group)
so the layout doesn't jump on pop-in; rows use neutral shimmer only
— no mocked active-row color. Respects `prefers-reduced-motion`.
Node keys that contain `/` (URL paths, filesystem paths) previously
broke the library's slash-joined path convention — `openNodes` lookups
couldn't disambiguate parents, `unflatten` grouped wrong, LEFT-arrow
parent-focus jumped to the wrong ancestor. The new `keySeparator`
prop (default `"/"`, preserving v1 behavior) replaces the hardcoded
delimiter wherever it appeared:

- `walk()` composes emitted `Item.key` paths with the prop.
- `unflatten(items, keySeparator?)` gains a matching optional 2nd arg.
- `parentKeyOf` in the LEFT-arrow keyboard handler uses the prop.
- `TreeMenuChildren` render-prop payload exposes it so custom
  renderers can feed it to `unflatten` without threading it manually.
- `defaultChildren` forwards the prop into `unflatten`.

Added to the v2 `addedProps.TreeMenuProps` allowlist in the API-
contract fixture.
…ctBranchKeys`

One-call expansion state changes, matching the existing
`resetOpenNodes` ref pattern. Unlike `resetOpenNodes`, these preserve
the user's current active / focus / search state — they only touch
`openNodes`, not the full reset.

- `TreeMenuHandle` gains `expandAll()` and `collapseAll()`. A new
  `SET_OPEN_NODES` reducer action replaces `openNodes` wholesale
  without clearing the other slots; it no-ops under controlled
  `openNodes` (same guard as `TOGGLE`), so the parent stays the
  single source of truth.
- `expandAll()` calls a new `collectBranchKeys(data, keySeparator?)`
  helper that walks the data tree once (O(N), microseconds even on
  100k-node trees) and returns every branch's path key. The helper is
  also exported as a public utility so consumers using controlled
  `openNodes` can implement their own expand-all without re-
  implementing the path-join walk.
- API-contract fixture + `scripts/check-api-contract.mjs` updated
  with `collectBranchKeys` on the `addedExports` allowlist.

Perf note documented in every public-facing surface (README, docs-
site API page, `TreeMenuHandle` JSDoc): the real cost of expand-all
on large trees is DOM render, not the walk — pair with the
virtualization recipe when the fully-expanded tree exceeds ~2k
visible rows.
Matches the v2 rewrite's unreleased CHANGELOG entry. No functional
change — just the semver shift. Subsequent pushes to `release/v2`
will suffix this to 2.0.0-rc.N via `npm-version-suffix` per the
release workflow; the stable 2.0.0 is promoted via manual
workflow_dispatch once RCs validate.
README.md: kept the v2 rewrite's README wholesale — the `minial` →
`minimal` typo fix in #187 is moot against the full rewrite.

CI test fixes (regressions surfaced by the first push to a remote
branch, now visible in the matrix):

- `renderHook` from `@testing-library/react` doesn't exist in RTL
  v12, which the React 16.14 / 17 jobs install. Added a local
  `renderHook` shim in `use-tree-menu-state.test.ts` and
  `use-debounced-callback.test.ts` — built on `render()`, portable
  across every RTL version in the matrix.
- React 19's job failed at install: RTL v14 pins `react@^18` in its
  peer range. Updated `ci.yml` to install RTL v16 (first version
  with React 19 in its peer range) on the React 19 job; 18 keeps
  v14, 16/17 keep v12.
Five characterization / render-props tests use userEvent with fake
timers to exercise the debounce window. On React 19 + RTL v16 the
per-keystroke timer awaits deadlock — the \`user.type()\` promise
never resolves and the test hits the 5s vitest ceiling.

Fix: pass \`delay: null\` to \`user.type()\` in the affected tests so
user-event doesn't queue a keystroke timer. Fake timers still drive
the debounce advancement on the outer \`act\`. The tests' intent is
preserved across every React version in the matrix (16.14 / 17 / 18 /
19); local (React 18.3) passes 174/174.

Separately: skip the \`useDeferredValue\` existence assertion on React
versions that don't ship the hook (16.14 / 17). The whole point of
the feature detection is that the library runs on both sides — so
asserting the function exists is only meaningful on 18+.
The repo's Pages config still points at the legacy \`gh-pages\`
branch (the old Storybook publish). \`actions/deploy-pages\` refuses
to serve artifacts unless the source is \`workflow\`.

Add a pre-build step in docs.yml that does the PUT for us the first
time master publishes the new Starlight docs site. The call is
idempotent — running it on every deploy is safe, and it means the
flip doesn't need a manual one-time UI step before the branch is
merged.

Effect: the first docs.yml run after merging this branch to master
switches Pages over to workflow-based deploys, replacing the
Storybook publish at https://iannbing.github.io/react-simple-tree-menu/.
…erics

Two self-inflicted regressions from the prior fix:

1. \`delay\` belongs on \`userEvent.setup()\` (it's a global per-user
   option), not on the per-call \`user.type()\` options — typecheck
   caught it. Moved \`delay: null\` to the setup call in
   \`characterization.test.tsx\`'s \`setupWithFakeTimers\` helper and
   the inline setup in \`render-props-contract.test.tsx\`; removed the
   per-call override.
2. The renderHook shim's P generic was unconstrained, which broke
   \`React.createElement(TestHost, P)\` overload resolution. Constrain
   P to \`Record<string, unknown>\` and type TestHost as
   \`React.FC<P>\` — no more intersection/cast gymnastics.

Local typecheck + 174/174 tests pass under React 18.3.
user-event's per-keystroke simulation loop deadlocks under the React
19 + RTL v16 + fake-timers combo: the inter-keystroke promise chain
never resolves, so \`await user.type()\` hits the 5s vitest ceiling
regardless of \`delay: null\` on setup. React 16.14 / 17 / 18 are
unaffected because their renderer + RTL versions don't trip the
issue.

The affected tests assert that a debounced SEARCH dispatch filters
items / reopens ancestors / honors a custom matchSearch — none of
which require keystroke simulation. Swap to \`fireEvent.change\`,
which fires one synthetic change event with the final string. The
library's onChange handler dispatches SEARCH with the full value
and the debounce window is still driven by
\`vi.advanceTimersByTime\` inside an \`act\` block, so behavior
coverage is identical.

Local (React 18.3): typecheck clean, 174/174 tests pass.
Accessibility
- Global \`@media (prefers-reduced-motion: reduce)\` kill-switch —
  all 100–300ms transitions (CTA, card, sidebar, link underline,
  skeleton shimmer) now honor the OS-level preference uniformly.
  Scoped via \`:where()\` so specificity stays 0 and any future
  component can re-enable where motion carries meaning.
- Explicit \`:focus-visible\` ring on hero CTAs (2px accent outline +
  2px offset). Starlight's default lives in a layered rule and was
  getting clobbered by the unlayered \`.sl-link-button\` overrides.
- \`scroll-margin-top: 5rem\` on every heading so anchor-link and
  TOC jumps land clear of Starlight's sticky top nav.

Hero hierarchy + alignment
- H1 bumped from clamp(1.9, 3.2vw, 2.4rem) to clamp(2.1, 4.2vw,
  3rem) with tighter tracking (-0.03em) and 1.05 line-height — reads
  as a splash headline, not a page title.
- Tagline bumped from body 1rem to clamp(1, 1.4vw, 1.125rem) with
  max-width extended to 52ch so the one-liner stands out.
- 1.2rem top margin on every \`.rstm-hero-sample\` grid child so the
  Installation code frame's title bar aligns visually with the tree
  menu's search row in the left column.

Interactivity
- \`LiveTreeMenu\` gains an opt-in \`autoFocus\` prop. When set, the
  component threads \`initialFocusKey: 'fruit/apple'\` through to
  <TreeMenu>, firing ItemComponent's focus-on-mount useEffect so
  arrow keys work immediately without a prior Tab. Landing page opts
  in (demo is above the fold); guide pages stay opt-out to avoid
  below-the-fold auto-scroll.

DX
- Root-level \`docs:dev\` / \`docs:build\` / \`docs:preview\`
  pass-throughs (\`npm --prefix docs run …\`) so the Starlight site
  can be driven from the repo root without cd'ing into docs/.
Documents local setup, the script table, branch + PR workflow, commit
conventions, TDD discipline, accessibility bar, bundle-size budget,
API-contract gate, the React-matrix testing caveats (renderHook shim
on RTL v12, fireEvent.change on React 19), the release workflow, and
the docs-site architecture. Filename uses the GitHub-recognized form
(CONTRIBUTING.md with -ING) so the repo sidebar + PR/issue templates
pick it up automatically.
Triggers now match the merge-intent convention instead of routing
everything through a throwaway \`release/v2\` branch:

- push to \`development\` → \`release-rc\` job publishes \`-rc.N\`
  to npm (\`next\` dist-tag). npm-version-suffix bumps package.json,
  we sync package-lock.json, publish, then commit the bump and create
  an annotated \`v<version>\` tag pointing at the shipped commit.
- push to \`master\` → \`release-stable\` job strips any trailing
  \`-rc.N\` from package.json, syncs the lock, checks-if-published
  (idempotent guard so a doc-only master commit doesn't try to
  re-publish), publishes as \`latest\`, and tags \`v<version>\`.
  \`workflow_dispatch\` preserved as an escape hatch for off-cycle /
  hot-fix publishes.

Every successful publish now leaves an annotated git tag as the
authoritative reference to the exact commit whose contents produced
the npm artifact. Previously \`git push --follow-tags\` was running
against an uncommitted bump with no tag — a no-op that never
surfaced because nothing checked for the tag.

ci.yml: removed the stale \`release/**\` push trigger; that pattern
targeted the old \`release/v2\` branch that no longer exists in the
flow.

CONTRIBUTING.md: Releasing section rewritten to describe the new
branch-driven flow + the "start a new minor/major cycle by bumping
the base version in development" convention.
Add a \`paths:\` filter to the release workflow's push trigger so
docs-only, test-only, or CI-config-only merges don't mint a new RC
or stable publish. The filter matches the library's build inputs:

- \`src/**\` minus tests / benches / stories (those don't ship)
- \`package.json\` / \`package-lock.json\` (version, deps, exports)
- \`tsup.config.ts\` (bundle config)
- \`postcss.config.js\` (CSS build)
- \`tsconfig.json\` (.d.ts output shape)

Everything else — docs/, examples/, CHANGELOG, README, .github/,
test-fixtures/, .storybook/ — is deliberately excluded. Those pushes
still run ci.yml (no paths filter there) and docs.yml (its own filter
on docs/** and src/**), so nothing gates-wise weakens.

\`workflow_dispatch\` is unaffected by paths filters, so the manual
escape-hatch publish still works from any commit on master.
npm Trusted Publishing + \`--provenance\` replaces the long-lived
\`NPM_TOKEN\` secret with a per-publish OIDC exchange. The workflow's
\`id-token: write\` permission was already in place (added earlier for
provenance), so the migration is purely removal:

- Drop \`env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\` from both
  publish steps. \`npm publish --provenance\` picks up OIDC from the
  Trusted Publisher configured on npmjs.com and auth'd against the
  workflow's id-token — no token needed.
- Rewrite the header comment's prerequisites block: Trusted Publisher
  entry on npmjs + "Require 2FA and disallow tokens" publishing-access
  setting + GitHub's workflow write permissions. No more NPM_TOKEN
  secret to provision.

Combined with the "disallow tokens" setting, the only path to publish
this package is now: a GitHub Actions run of release.yml matching the
trusted-publisher entry. Stolen / leaked tokens can't produce a
publish even if they exist.

CONTRIBUTING.md: Releasing section updated to describe the new auth
model.
v2 was shipping \`dist/styles.css\` and a byte-identical
\`dist/main.css\` copy so v1 consumers using the deep-import
\`react-simple-tree-menu/dist/main.css\` path could upgrade without
touching their CSS import. The duplicate cost 8.5 KB per install +
a confusing two-path story for a migration that's already a one-
line diff.

v2 is already a breaking release (cacheSearch removed, React peer
floor raised, default copy/glyphs/color changed). Adding "update
your CSS import to /styles" is a rounding error in the migration
guide.

Changes:
- build:css script no longer does \`cp dist/styles.css dist/main.css\`.
- CHANGELOG + README "Migrating from v1.1.x" section + docs-site
  migration page all updated: the v1 deep-import path no longer
  resolves; swap to \`react-simple-tree-menu/styles\` (same byte
  content).

Tarball drops from 11 files / 224.5 KB unpacked to 10 files /
216.1 KB unpacked. Source maps still ship (library debuggability
during consumer dev builds; consumer prod bundlers drop them).
v2.0.0 — full rewrite (zero deps, WAI-ARIA, React 16.14–19)
The \`release-rc\` job on development hit a 404 from the npm registry
even though the Trusted Publisher entry + "Require 2FA / disallow
tokens" setting both match the workflow. Provenance posted cleanly
to sigstore, then the registry PUT failed — classic symptom of
npm treating the request as unauthenticated.

Two likely contributors, both addressed:

1. \`actions/setup-node@v4\` with \`registry-url:\` writes an \`.npmrc\`
   containing \`//registry.npmjs.org/:_authToken=\${NODE_AUTH_TOKEN}\`.
   With Trusted Publishing we intentionally don't set
   \`NODE_AUTH_TOKEN\`, so the variable expands to an empty string at
   npm-read time. Some npm CLI paths treat an empty explicit token as
   "auth attempted and failed" and never fall through to the OIDC
   flow. Removing \`registry-url:\` stops setup-node from writing
   \`.npmrc\` at all; \`npm publish --provenance\` then takes the OIDC
   path directly.
2. Added \`--access public\` to both publish commands. The package is
   unscoped so access would default to public anyway, but npm's
   documented Trusted Publishing example passes the flag explicitly
   — cheap insurance against an implicit-scope detection quirk.

If the next run succeeds, the \`registry-url\` removal was the cause;
if it still 404s, the TP entry on npmjs.com needs a closer look
(click Edit, verify workflow filename is \`release.yml\` with no
leading path, environment is empty, no trailing whitespace).
Comment-only — triggers the release-rc workflow to re-attempt the
RC publish against the fixed workflow file (3010f00) that removed
registry-url from setup-node. `gh run rerun` uses the original
workflow file; a fresh push under the paths filter is needed to
exercise the fix.
Publish step succeeded on 2.0.0-rc.1 but the post-publish commit
push was rejected by branch protection on `development` (stale
required status check). The bump commit only exists to make npm
publish see the -rc.N version — it doesn't need to land on the
branch. Tag the HEAD of development (the workflow-triggering
commit) instead; same tag → same shipped SHA, no branch protection
fight.
Comment-only touch to re-trigger release-rc on a clean run now
that the stale ci/circleci:build required status check is off the
development branch protection.
@iannbing iannbing merged commit b53fe6b into master Apr 21, 2026
14 of 15 checks 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.

2 participants