feat(OneDataCollection): add programmatic nested-table expansion control#4611
feat(OneDataCollection): add programmatic nested-table expansion control#4611AndresRojoFactorial wants to merge 10 commits into
Conversation
✅ No New Circular DependenciesNo new circular dependencies detected. Current count: 0 |
📦 Alpha Package Version PublishedUse Use |
🔍 Visual review for your branch is published 🔍Here are the links to: |
7d58402 to
2a95bff
Compare
Adds a `nested` option to the table visualization with a declarative
auto-expansion policy (defaultExpanded as boolean/depth/predicate), an
imperative controller via useNestedTable() (expand/collapse/toggle by id
or predicate, expandAll, collapseAll, expandTo id-path, isExpanded,
getExpandedItems), eager children loading ("all" vs "paginated" show
more) with runaway-adapter guards, expand animations (fade/stagger/
slide/pop, reduced-motion aware) and an onExpandedChange callback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fetchChildren now receives the debounced search term (like the main fetch), the children cache resets when the search changes, and expansion criteria receive hasActiveFilters in their context so policies can auto-expand matching branches while the user searches or filters and collapse back when cleared. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dren refetch
- Normalize empty search ("" ≡ undefined) so an undefined→"" transition
(e.g. setSearch("") on mount) no longer collapses expanded rows or
clears the children cache; fetchChildren now receives undefined instead
of "" like the main collection fetch
- hasActiveFilters uses the debounced normalized search exclusively so it
stays in sync with the children cache invalidation
- Restore refetch when re-expanding a row whose cached children are empty
(collapsed→expanded transition only, so an empty result can't fetch-loop)
- Create the motion-wrapped Row lazily (skip it for expandAnimation: none)
and register nested rows keyed by rowId instead of item identity
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…xpanded Declarative, controlled expansion criteria for the nested table (mirrors the Graph's revealNodeId/focusOnEntry pattern): while defined, the provider re-applies the criteria whenever its reference changes, taking over from user clicks or imperative controller calls. Precedence: expanded (controlled) > control (imperative) > defaultExpanded (mount-only). Includes story (TableNestedControlledExpansion), MDX section and tests for the controlled mode plus the search-normalization and empty-children refetch fixes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…en loading Remove the hardcoded MAX_EAGER_CHILDREN_PAGES backstop: eager loading (children: "all") now stops solely on the adapter-reported signals — hasMore: false, reaching total, or a fetch error. The consumer's adapter already fully controls termination, so a baked-in page limit was redundant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ions Review findings from an 8-angle adversarially-verified pass over the branch: - hasActiveFilters now delegates to getActiveFilterKeys (per-filter-type isEmpty semantics), so a filter cleared to its empty value (e.g. a search filter left as "") no longer counts as active and no longer keeps hasActiveFilters-driven expansion policies engaged. - The nested-row registry re-registers when the item identity changes, so controller predicates, getExpandedItems and onExpandedChange see current field values after a refetch instead of the first-render snapshot. - A row that remounts already-open with cached empty children now retries the fetch once, matching the behavior of toggling the row itself. - The NestedDataContext value is memoized; all its callbacks were already stable, so unrelated parent re-renders no longer re-render every row. - The motion-wrapped Row is a lazy module-scope singleton (motion.create does not cache custom components) and the expand-animation variants are precomputed instead of rebuilt per row render. - table.mdx no longer claims a page cap on eager children loading; the adapter's hasMore/total/error signals drive termination. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
abfb9d1 to
31ef70e
Compare
|
…ControllableState Align the controlled expansion criteria with the F0 convention (AGENTS.md: "use the useControllable hook — do not manually sync state via useEffect"). The auto-expansion policy is now held by useControllableState: while nested.expanded is defined, the derived controlled policy is the single source of truth (standard controlled-prop semantics — defaultExpanded is ignored and the policy side of expandAll/collapseAll is a no-op), and targeted controller calls keep layering per-row overrides on top. The only remaining effect handles the reset side effect: overrides are cleared when the controlled criteria changes by reference. NestedExpansionState no longer bundles the policy — overrides/eager stay in provider state, and all resolution paths receive the policy explicitly. JSDoc and MDX updated to describe the strict controlled precedence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… manual toggles
A row eagerly expanded by a policy (defaultExpandedChildren: "all" or
expandAll({children: "all"})) used to permanently fall back to "show more"
pagination after the user collapsed and re-expanded it: the collapse stored
an eager=false marker that was never cleared, and the explicit override
suppressed the policy fallback entirely.
The eager fallback now evaluates the policy criteria independently of
explicit overrides — a user click expresses expand/collapse, never a
pagination preference — and expanding (via chevron or a targeted controller
call without options) drops the stale collapse marker, while an explicit
eager opt-in (children: "all") still persists. Regression test covers the
policy-eager → manual collapse → re-expand sequence with deferred fetches.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…indings Addresses findings 1, 2, 4, 5, 6, 7 and 9 of the PR review artifact: - expandAll/collapseAll are now complete no-ops while nested.expanded is controlled: they no longer clear manual overrides, so a row the user collapsed cannot snap back open (finding 1). - rowId is namespaced with the parent row's id (or the group for roots), so equal depth/index positions in different branches or groups can no longer collide and share expansion state (finding 2). - A filters/search reset now unsubscribes any children fetch still in flight (and re-arms the auto-load guard when a first fetch is cancelled), so a slow response can no longer repopulate the cache with children of the previous search (finding 4). - A resolved page that adds no records is treated as the end of pagination even if the adapter reports hasMore, breaking the infinite request loop a misbehaving adapter could cause under eager loading (finding 5). - Dev-only warning when a controlled expanded predicate changes identity repeatedly (the inline-function footgun), plus a memoized example in the MDX (finding 6). - Controller targets and expandTo paths normalize ids with String(), so string targets (e.g. URL params) match numeric item ids (finding 7). - MDX now shows the import path for useNestedTable (finding 9). 7 new regression tests (128 in the file, 628 in OneDataCollection). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rce.idProvider Connect the nested table to the collection's existing identity convention instead of the inherited positional key (depth-id-index, which predates this branch): a row's identity is now source.idProvider(item) when the source defines one — the same mechanism Kanban and item navigation already use — falling back to item.id, and to the position only for id-less items. The parent row's id still namespaces the key, so branches cannot collide. Consequences: - Expansion survives sibling reorders from a refetch (artifact finding 3): the positional index is gone from the key whenever the item has identity. - Controller targets and expandTo paths match against the same resolved identity, so a consumer can designate any univocal column via idProvider without new API surface. - Documented requirement: identities must be unique among sibling rows. Two new regression tests: sibling reorder keeps expansion, and targeting by an idProvider-designated column on id-less items (130 in the file, 630 in OneDataCollection). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Description
Nested tables in the Data Collection pattern could only be expanded by clicking each chevron, with no way to control expansion from code. This PR adds a programmatic expansion system to the table visualization: a declarative auto-expansion policy, an imperative controller hook (
useNestedTable), control over how children pages load (keep the "show more" row vs eager-load every page), targeted deep-node reveal, expand animations, and anonExpandedChangecallback.Storybook
📖 Docs — Programmatic Expansion Control (
Tabledocs page, new section — link resolves once this branch's Storybook is deployed; locally:http://localhost:6006/?path=/docs/patterns-data-collection-visualizations-table--documentation#programmatic-expansion-control)Interactive stories:
TableNestedProgrammaticControl— full playground: bulk expansion, targeted operations (expand/toggle/expandTo), children load mode, animation switcher and resetTableNestedAutoExpandFirstLevel— declarativedefaultExpanded: 1(with play assertion)TableNestedAutoExpandCriteria— predicate-based policy + eager children loading, with resetTableNestedExpandAnimation— animation modes with expand/collapse/reset controlsTableNestedControlledExpansion— controllednested.expandedreasserting itself over imperative controller calls (with play assertion)Implementation details
feat: add
nestedoption toTableVisualizationOptions—defaultExpanded(boolean | depth number |({ item, depth }) => boolean),defaultExpandedChildren("paginated"|"all"),control,expandAnimation,onExpandedChangefeat: add
useNestedTable()controller hook —expand/collapse/toggleby item id or predicate,expandAll({ depth, where, children }),collapseAll,expandTo(idPath),isExpanded,getExpandedItems; operations invoked before the table mounts are queued and applied on mountHow expansion is resolved
Each rendered expandable row resolves its state as explicit override → active policy → collapsed. Policies (
defaultExpanded,expandAll) are re-evaluated for rows revealed later by lazy loading, so auto-expansion cascades naturally through a tree that loads on demand, while a manual collapse always wins over an active policy.expandTokeeps not-yet-rendered ids pending and consumes them as each level registers, so a single call reveals a node buried under collapsed, unloaded ancestors.feat: children fetching is now driven by the resolved open state (effect-based) instead of the click handler, so policy/controller-opened rows lazy-load their children too; eager mode (
children: "all") chains pages until the adapter reports exhaustion (hasMore: false, reachingtotal, or a fetch error) — no artificial page limit — and a resolved page that adds no records is treated as end-of-pagination, so a misbehaving adapter cannot trap it in a request loopfeat: add expand animations (
fade,stagger,slide,pop) — GPU-composited (opacity/transformonly), staggered by sibling index and depth so cached subtrees still cascade level by level, disabled underprefers-reduced-motionfix: filters/sortings changes now reset expansion in the provider only (overrides cleared, policy preserved) — the previous per-row collapse callback left a residual explicit-collapse override on an arbitrary row and emitted spurious
onExpandedChangeeventsdocs: new "Programmatic Expansion Control" section in the Table MDX + 4 interactive stories
feat: nested rows aligned with search/filters —⚠️ intentional behavior change: expanded rows collapse and cached children are refetched when the search term changes, matching what filters already did), and expansion criteria receive
fetchChildrenreceives the debouncedsearchterm (passed asundefinedwhen empty, like the main collection fetch), the children cache resets on search changes (hasActiveFiltersin their context (defaultExpanded: (ctx) => ctx.hasActiveFiltersauto-expands while searching and collapses back when cleared) — new storyTableNestedSearchAlignedExpansionfeat: controlled declarative expansion via
nested.expanded— mirrors the Graph'srevealNodeId/focusOnEntrypattern and is driven byuseControllableState(the repo's controlled-prop convention, same as F0Slider/F0Accordion): while defined it is the single source of truth —defaultExpandedis ignored andexpandAll/collapseAllare complete no-ops, while targetedexpand/collapse/toggle/expandTolayer per-row overrides that are cleared whenever the criteria changes by reference; precedenceexpanded(controlled) >control(targeted overrides) >defaultExpanded(mount-only); a dev-only warning fires when a non-memoized predicate changes identity repeatedly — new storyTableNestedControlledExpansionfeat: nested rows are keyed by data identity, not position —
source.idProvider(item)when the source defines one (the same convention Kanban and item navigation already use), falling back toitem.id, and to the position only for id-less items, always namespaced by the parent row's key: expansion survives sibling reorders from a refetch, equal positions in different branches/groups cannot collide, and controller targets /expandTopaths match the same identity withString()normalization (expand("42")findsid: 42); identities must be unique among siblings (documented)fix: a filters/sortings/search reset now cancels any children fetch still in flight (and re-arms the auto-load guard), so a slow response can no longer repopulate the cache with children of the previous filters
fix: an
undefined → ""search transition (e.g.setSearch("")on mount) is normalized and no longer collapses expanded rows or clears the children cache;hasActiveFiltersuses the debounced normalized search exclusively (in sync with cache invalidation) and delegates the filters side togetActiveFilterKeys— the collection's canonical per-filter-typeisEmptysemantics — so a filter cleared to its empty value no longer counts as activefix: re-expanding a row whose
fetchChildrenreturned an empty list refetches again (restores the pre-refactor behavior; guarded to the collapsed→expanded transition so an empty result cannot cause a fetch loop)perf: the motion-wrapped
Rowis a lazy module-scope singleton (motion does not cache custom components), the expand-animation variants are precomputed, and the nested context value is memoized so unrelated parent re-renders no longer re-render every row; the row registry re-registers on item identity change so controller predicates,getExpandedItemsandonExpandedChangealways see post-refetch field valuestest: 40 new unit tests (
NestedExpansionControl.test.tsx) covering policies, controller operations, predicate targets,expandTopath resolution, pre-mount queueing and controller unbind/rebind, filters reset, eager-loading guards (lying adapters included), animations, search normalization, empty-children refetch, the controlledexpandedmode (incl. no-opexpandAll/collapseAll), identity keying (id-less branches, sibling reorders, numeric ids,idProvidertargeting) and stale in-flight fetch cancellationTesting
pnpm vitestonsrc/patterns/OneDataCollection/visualizations/collection/Table: 130 tests passing (9 files), 630 across the wholeOneDataCollectionsuite — no regressions inNestedSelection/Table/EditableTablepnpm tsc,pnpm lint,pnpm format:checkall greendiv[onClick]withoutrole/aria-expandedinOneTable/NestedCell) predates this PR and is left for a follow-upScreen.Recording.2026-07-10.at.11.56.23.mov
🤖 Generated with Claude Code