v2.0.0 — full rewrite (zero deps, WAI-ARIA, React 16.14–19)#220
Merged
Conversation
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.
The KeyDown wrapper caught ArrowUp/Down/Left/Right/Enter and dispatched the tree's focus actions, but never called `e.preventDefault()`. The browser's default action (page scroll on arrows, newline-in-form on Enter) still ran on top of the handler, so the page scrolled while the tree was also navigating. Fix: - Call `e.preventDefault()` on matched keys so only the tree reacts. - Skip handling entirely when focus is inside an <input>, <textarea>, <select>, or contenteditable element — arrow keys still move the text cursor in the built-in search input and in any consumer-supplied input nested under the wrapper. Tests: - Asserts `defaultPrevented === true` for every matched key. - Asserts `defaultPrevented === false` for unmatched keys (Escape). - Asserts arrow keys and Enter on an inner `<input>` do NOT fire the tree handlers (so text editing is unaffected). Bundle: 3.01 KB → 3.06 KB (+50 bytes, well under the 3.5 KB budget).
First-time visitors were clicking a branch label, seeing no expansion (library default: toggle only fires on the disclosure icon), and concluding the component was broken. Fix the demo UX so the contract is obvious at a glance without changing the library default. - LiveTreeMenu now owns `openNodes` as controlled state and wires `onClickItem` to toggle branches on label click — the file-explorer behavior users expect. Leaves fire `onClickItem` as normal. - Hint strip under the tree: "Tip: click a branch to expand · click a leaf to select" + a live "Last clicked: <key>" readout (aria-live polite). Makes the interaction visibly confirmed. - `fruit` starts open by default so the demo shows nested structure without requiring a click first. No library changes. This stays demo-only — consumers who want the same "click anywhere to toggle" UX in their own app can crib the 4-line onClickItem handler from the component source.
…ight prose styles
Final shape of the landing demo after the review:
- `openNodes` stays UNCONTROLLED — the library owns it, so the
disclosure triangle toggles natively. (My earlier attempt used a
controlled prop and accidentally masked the native toggle.)
- A render-props wrapper decorates each item's `onClick` to also call
`toggleNode()` on branches, giving the file-explorer "click the
label to expand" behavior without changing the library default.
- `defaultChildren` is re-invoked with the decorated items so we don't
reimplement the UI.
- The demo wrapper is marked `.not-content` so Starlight's markdown
prose rules don't apply a `margin-top` between every adjacent tree
row (that was the "big gap between nodes" bug — Starlight's rules
sit at specificity (0,1,2), which beats a naive library reset; the
`.not-content` escape hatch is the idiomatic skip).
- Hint strip + aria-live "Last clicked" readout make the interaction
visible.
No library changes — ran through a revert cycle on a naive
`.rstm-tree-item { margin: 0 }` attempt that lost on specificity, then
dropped it in favor of the Starlight-scoped fix. The library remains
defensive against prose-style margin pollution only to the extent
consumers apply their framework's own escape hatch.
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/.
3618599 to
1edd6f0
Compare
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).
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.
Summary
Full ground-up rewrite of the library for v2.0.0 (see
CHANGELOG.mdfor the full list). Public API preserved byte-for-byte modulo documented removals; consumers who aren't usingcacheSearchand are on React ≥ 16.14 upgrade with a singlenpm installand no code changes.Highlights:
useReducer. Zero runtime dependencies (dependencies: {}).useDeferredValuefeature-detected on React 18+ for search-input smoothness.classNames,labels, andkeySeparator.expandAll(),collapseAll()(alongside existingresetOpenNodes).unflatten,collectBranchKeys,TreeMenuHandle,TreeMenuClassNames,TreeMenuLabels,UnflattenResult.docs/, deployed to GitHub Pages via Actions.Breaking changes:
>=16.6.3to>=16.14(required forreact/jsx-runtime).cacheSearchprop removed (leaky abstraction; internal memoization is now plainuseMemo).onClickItemis now a no-op (wasconsole.log).TreeNode(object-form) forbidskeyat the type level;TreeNodeInArray(array-form) forbidsindex.Related: closes / supersedes the community proposal in #186 (different implementation covering the same
keySeparatorfeature + more).Gates
All passing on HEAD:
npm run lint✓npm run typecheck✓npm test→ 174/174 ✓npm run build✓npm run check:api(diff against v1 fixture modulo allowlist) ✓npm run check:size→ 3.41 KB / 3.5 KB ✓CI matrix covers Node 20 × React 16.14, 17, 18.3.1, 19.
Test plan
examples/nextjs/andexamples/react16/against the built distrelease/v2to trigger the RC release workflow; validate the published-rc.Nin a fresh consumer projectworkflow_dispatchto promote RC → stable2.0.0