Planning for everything after v1 ships. ROADMAP.md stays the source of truth until Phase 8 (Hardening & Release) is complete — nothing here starts before v1 is out the door, and nothing here renegotiates a v1 locked decision without an explicit entry in the decisions discussion.
v2 start (2026-07-07, explicit user call): implementation began with Phase 8's user-side release steps (real-AT pass,
npm publish) still pending — the "nothing before Phase 8 completes" gate was lifted deliberately, not silently. First workstream: the migration-critical API set (accessorAbortSignal,invalidateChildren+collapseBehavior,defaultFocusedKey, focus retention across data replacement,keyin the template context).
Conventions are inherited from ROADMAP.md: locked decisions are not silently renegotiated, settled discussions get written here immediately, public API changes go through the v1 Public API Sketch process, all scaffolding via ng generate, STYLE.md applies.
| Priority | Rationale |
|---|---|
| Fix known v1 paper cuts first | Cheapest wins; several are one-liners against existing architecture |
| SSR before new features | It was a v1 cross-cutting concern that never landed — closest thing to debt |
| Performance work stays invisible | Same rule as v1: internals may change radically, the public API may not |
| Every new capability keeps the "tree ships no UI it doesn't own" rule | Adapters and handles, never built-in widgets |
Small items discovered during v1 implementation. All shipped 2026-07-07 (139 specs total: 116 lib + 5 app + 18 e2e):
Shift+checkbox range selection✅ —TreeNodeHandle.toggleSelection(range?: boolean);treeNodeCheckboxpassesshiftKey. Additive range from the anchor over visible order; the anchor survives so further shift-clicks re-range from the same spot✅ —expandAll()over lazy subtreesexpandAll({ loadLazy: true })(settled: opt-in, decision 4): batchedensureChildrenfrontier waves, each wave expands and is re-scanned; stops on frontier exhaustion or a wave with zero progress (all errors —retryChildrenstays the recovery path). Default unchanged: skipEscape cancels an in-flight pointer drag✅ — document-level keydown during drag: drop flagged dead, CDK sequence ended via synthetic mouseup (no public cancel exists);stopPropagationso a hosting dialog doesn't close on the same press. Mouse drags only — a fabricated TouchEvent can't carry the coordinates DragRef reads; touch cancels by lifting. e2e-verified incl. the trailing physical mouseupWheel-scroll mid-drag✅ — the existingelementScrolledsubscription re-runs drop targeting from the last pointer position while a drag is liveTouch drag via opt-in handle✅ —treeNodeDragHandle(hosts CDK'sCdkDragHandle): row drags only from the handle, start delay drops to 0 including touch — a dedicated handle makes long-press unambiguous, so the context-menu conflict the delay guarded is gone✅ — resolved as a documented pattern, not an adapter (decision 5, amends the v1 JSDoc promise): bindmat-checkboxadapter[checked]/[indeterminate]fromcheckState, drive selection from(click)forshiftKeyaccess — docs/RECIPES.md; zero Material coupling✅ — CDKaria-liveannouncementsLiveAnnouncer(no DOM shipped): moves/copies, lazy-load outcomes (named viatypeaheadTextwhen present), search result counts (true matches via newsearchMatchCount, not ancestor chains).announcementsinput: partial overrides merge with English defaults,nullsilencesPageUp/PageDown✅ — viewport-height jumps (getViewportSize() / itemSize, clamped to ≥1 row for layoutless environments)✅ 2026-07-07 — optional second argument, opt-in by declaring it (AbortSignalinTreeChildrenAccessorFunction.length >= 2; single-arg accessors skip theAbortControllerallocation entirely — zero cost across a 100k flatten). Aborts on destroy (abortAll), invalidate-while-in-flight, and collapse undercollapseBehavior: 'invalidate'; plain collapse lets the resolve finish (v1 unmount-survival precedent). A generation guard makes stale resolves (consumer ignored the abort) write nothingFocus retention across data replacement✅ 2026-07-07 — an effect overvisibleNodessnapshots the visible order; when the tree owns focus (focusin/focusout/document-pointerdown bookkeeping — clicking a non-focusable outside area fires no focus event, only the pointer-down marks departure) and the focused row's DOM dies, focus re-attaches by key; a vanished key falls back to the nearest survivor in the previous visible order (following first, then preceding — ends at the parent naturally). Never steals focus the user moved elsewhere. Dialog round-trips repair for free✅ 2026-07-07 — seedsdefaultFocusedKeyinputfocusedId(linkedSignal) until the first focus write; unknown/hidden keys fall back to the first visible row so the Tab target is never lost (also fixes the pre-existing collapsed-away-focus gap)Interactive elements inside rows — a11y contract✅ 2026-07-07 (decision 6) — tree-shipped row directives (treeNodeToggle,treeNodeCheckbox,treeNodeDragHandle) leave the tab order via hosttabindex="-1"; arbitrary consumer elements follow the same documented rule (docs/ACCESSIBILITY.md) with the context menu as the keyboard path; the rename input stays the deliberate exception. No template-walking enforcement — free-form templates, one rule
The v1 cross-cutting concern that never landed. Design sketch from ROADMAP.md still applies:
- Server render: viewport has no size → render the first
ssrRowCountrows statically (input, default ~20) - Hydration: reconcile static rows with the virtual viewport without flicker; no
afterNextRenderon the server - Zoneless + incremental hydration verification (
@deferinteraction with the viewport) - Exit criteria: SSR demo route, hydration mismatch-free under
ngSkipHydration-free rendering, CI check
v1 is O(n)-per-change over 100k nodes with per-row reactive reads — already fast. v2 targets 1M nodes and mutation-heavy workloads:
- Incremental re-flatten: today any
dataSource/overlay change rebuilds the full flat model; keyed subtree memoization can rebuild only changed branches (node-object identity already memoizes accessor calls — extend the same idea to flatten results) - In-place
Setmutation + version bump forexpandedIds/selectedIds(the v1 escape hatch explicitly kept open by private state) checkStatesdelta pass: fold only the ancestor chain of changed keys instead of full reverse pass (O(depth·changes) vs O(n))- Dynamic row heights, first-class: v1 documents the experimental autosize escape hatch; evaluate owning a measured-heights strategy (prefix-sum index) so
scrollTo/aria positions stay exact with variable heights - 1M-node benchmark added to the perf suite with budget gates in CI
- Rule unchanged: all of this is invisible at the API boundary
Each was an explicit v1 non-goal with a contract designed not to preclude it:
- Sparse selection over unloaded subtrees — "selected-roots + exclusions" model so checking a lazy parent means everything under it, loaded or not;
checkboxSelectionsemantics were chosen to allow this. Needs: wire-format for persistence,SelectEventextension (additive), cascade rules on later load - Cross-tree & external drag and drop — drag between two
angular-treeinstances (shared drag registry service) and OS file drops (DataTransfer→ a newexternalDroppedintent).MoveEventcontract already plural and doesn't preclude it - Flat input data (
levelAccessor) — accept pre-flattened arrays (the other modernCdkTreepattern); internal model is already flat, so this is mostly an ingestion adapter + expansion semantics for unknown parents Lazy invalidation✅ 2026-07-07 —TreeApi.invalidateChildren(node?): drops the keyed overlay + accessor memo, aborts in flight, bumps the stale-result generation; expanded nodes re-run the accessor immediately (per-rowisLoading), collapsed ones on next expand; no argument = tree-wide (single overlay write for the batch).collapseBehavior: 'keep' | 'invalidate'input shipped (defaultkeep). The headless line held: the tree never fetches or caches — refresh policy, batching, and stale-while-revalidate stay consumer-side behind the accessor (TanStack Query's invalidate-vs-fetch split). Overlays survivedataSourcereplacement (settled 2026-07-06; immutable-update consumers replace array identity constantly — clearing would refetch the world per rename); nothing re-fires implicitly- Server-driven search & reveal (design open) — v1 search (
searchMatch/searchTerm) is client-side over the loaded flat model; a lazy tree can't match what isn't loaded. Headless split: the consumer resolves the term server-side into matched keys + ancestor paths (the tree never queries); the tree owns reveal — load/expand ancestor chains viaensureChildren, highlight matches, next/prev navigation,scrollTofirst hit — and announces result counts through the Phase 9aria-liveregion. Candidate surface:reveal(paths): Promise<void>+ a match-set input composing with clientsearchMatch. No settled design yet — open question below, spike before committing
The concrete driver for this library: replace two engines in iusta-core-frontend — jsTree (core-dnd-tree, the CRUD/drag tree) and PrimeNG p-tree (document-tree). Audit 2026-07-06 (against the real usage, not generic parity) found most of the surface already covered — multi-select + meta/ctrl/shift, per-type node templates (their category/trash/document pTemplates → treeNodeDef when), context menu, DnD move + per-target drop guards, lazy-load-on-expand, freed double-click, expand/collapse persistence, inline rename, icons/colors, thread-line, search — plus virtualization, which neither engine has. What's left:
Library gaps (real code):
Copy-on-drag✅ implemented 2026-07-07 (settled 2026-07-06) —MoveEvent.dropEffect: 'move' | 'copy'(native DnD vocabulary, extensible — not a closed boolean); modifier sampled continuously mid-drag and read at drop time, platform-native: ⌥ on macOS, Ctrl elsewhere (touch has no modifiers → always move). Keyboard path:Ctrl/Cmd+Carms copy-paste vsCtrl/Cmd+Xmove-paste, same guards and validation. Demo shipsapplyCopy(fresh ids per clone); pointer path e2e-verified with a real held modifier (jsdom can't produce one)✅ 2026-07-07 —keyin the template contextkey: stringonTreeNodeContext, template parity with PrimeNG/jsTree node templates- Empty & loading templates — already promoted to v1 Phase 8 (
treeEmptyDef/treeLoadingDef); listed here only as the migration dependency it is - General capabilities this migration depends on, tracked in their own phases — lazy invalidation (Phase 12); accessor
AbortSignal, focus retention across data replacement,defaultFocusedKey(Phase 9).document-treeis the motivating example (sync-refresh menu item, per-nodeAbortControllers, dialog-driven immutable rebuilds that drop focus), not the design target — each lands as a general, decoupled capability or not at all
Interaction decision (v1 lock explicitly reopened and settled 2026-07-06 — see decisions table):
- ✅
clickAction: 'activate' | 'select'input, default'activate'— implemented 2026-07-07. v1 behavior unchanged unless opted in;'select'= plain click replace-selects (respectsisSelectable, sets the range anchor), double-click activates (the tree's dblclick handler is inert under'activate', so v1's double-click-stays-consumer decision holds there). Ctrl/Shift shortcuts identical in both modes.document-treemigrates behavior-preserving viaclickAction="select"
Migration ergonomics ✅ all delivered 2026-07-07 — docs/MIGRATION.md (accessor adapter tables for PrimeNG TreeNode/jsTree/wrapper shapes, full p-tree input/event mapping, jsTree CRUD → intents table, create-node recipe, drop-on-trash = delete + trash-to-category = restore patterns, synthetic grouping nodes incl. the category-null "Uncategorized" case, typed-node-actions union guide) and docs/RECIPES.md (loading mask over existing content, dialog round-trip refocus — largely automatic since Phase 9 focus retention, mat-checkbox binding pattern). README links all of it; ships in the npm package
Exit criteria: document-tree and core-dnd-tree reimplemented on angular-tree in a branch, feature-matched (incl. the trash/category/document drop rules and lazy document loading), passing their existing specs.
First real-consumer feedback: the Phase 14 driver migrated document-tree off PrimeNG p-tree and filed six requests, ranked. Common theme of 1–3: they are the only reasons the consumer still carries effect() + viewChild plumbing — a signals-first library should own that sync. All six accepted 2026-07-12 (user call) with two design corrections (#5, #6 below); everything is additive, every default preserves current behavior. Implementation order = the consumer's priority order.
- Controlled selection —
selectedKeysmodel input (decision 7) ✅ implemented 2026-07-12, amended same day (user call): theSelectionModelinput is REMOVED, not kept for back-compat — pre-release with zero published consumers, back-compat protected nobody, and keeping it meant dual write paths in every selection funnel plus a bridge effect for a model that isn't signal-reactive (and emitschangedtwice per replace-select, and duplicatesmultiin its constructor flag). Final shape:model<readonly string[] | undefined>(), defaultundefined= tree-owned state;selectedIdsin the controller is alinkedSignalover the input — external writes are synchronous, echoes (the consumer writing our emission back) return the previous Set identity so the controlled round-trip is churn-free by construction; interaction writes go.update()+ model sync →(selectedKeysChange).selectionChange(ids + nodes) unchanged. Consumers who want aSelectionModelbridge it consumer-side (docs/RECIPES.md). ROADMAP.md v1 settled entry annotated. All five SelectionModel spec hosts migrated; 132 lib + 41 app specs green - Controlled expansion — live
expandedKeysmodel input (decision 8 — explicitly reopens the v1 lock "expansion state internal + methods") ✅ implemented 2026-07-12. The lock's perf rationale (private state permits Phase 11's O(1) in-place Set mutation; a public model contract forces O(expanded) copies per toggle) is preserved for unbound consumers — only consumers who bind pay the copies, knowingly. Covers server-side-search auto-expand and state restore, whose write half was imperative-only (setExpanded). Implementation notes:expandedIdsis alinkedSignalover the input — while UNBOUND it derives fromdefaultExpandedKeys(read only in that branch, so a later default change still resets an unbound tree — the v1 behavior the demo's scale switch relies on — while a bound tree ignores it entirely); echoes are identity-preserved (same asselectedIds). TheexpandedKeys()snapshot METHOD is removed (input/method name collision; binding the model IS the reactive snapshot);setExpanded(keys)stays as the unbound bulk write. Every mutating TreeApi path (toggle, expandAll incl. lazy frontier waves, collapseAll, expandDescendants, setExpanded) syncs the model →(expandedKeysChange);(toggled)unchanged. All-In-1 demo: consumer-sidelinkedSignalbound[(expandedKeys)]replacesdefaultExpandedKeys(scale switch re-derives, toggles write back). 138 lib + 41 app specs green - Declarative children-cache invalidation —
childrenDepsinput (decision 9) ✅ implemented 2026-07-12.input<unknown>(); a reference-unequal change behaves exactly likeinvalidateChildren()tree-wide (abort in-flight, expanded reload now, collapsed on next expand). Mirrorsresource({ params }). Accessor signal auto-tracking rejected: the accessor is probed inside theflat()computed behind the WeakMap memo — consumer signal reads there would entangle the flatten graph. Headless line holds: the tree still never fetches, it only re-asks. Implementation notes: an effect (invalidation is a process, not a derivation) that skips its first run — the initial value is not a change;invalidateChildren()runs untracked so the effect's dep set stayschildrenDepsalone. Spec caveat pinned: with a PROMISE accessor, invalidation's memo eviction means the next flatten probe re-fetches even collapsed branches — the cold-Observable contract (existing accessor JSDoc) is what makes "collapsed reloads on next expand, not eagerly" true, and the spec models it withdefer. Demo (Lazy Load Only): anodejs/node@{main,v22.x,v20.x}ref switcher —[childrenDeps]="githubRef()"handles the tree half;switchGithubRefhandles the CONSUMER half its write-back architecture requires (drop the grafted GitHub subtree fromroots, deregister in-flight GitHub tasks so a late old-ref resolve can't graft stale children — write-backs are now guarded by task registration). Browser-verified: an expanded root refetches pinned to the new ref, the other four sources untouched. 141 lib + 41 app specs green SelectEvent.trigger+added/removed(decision 10, additive) ✅ implemented 2026-07-12. The issue's "re-click emits nothing" is factually wrong (underclickAction='select'a re-click emits unconditionally) but the real gap stands: a set-shaped event can't identify the interacted row, so "active row / preview pane" consumers guess withnodes.at(-1). Both write funnels know their row —trigger?: Tthreaded through (present for row-addressed gestures incl.'follow'-mode focus moves and right-click reconciliation; ranges report the row the gesture ended on; absent for Ctrl/Cmd+A and Escape / outside-click clears), andadded/removedderived against the pre-write set — shipped as REQUIRED fields (sketch's optionals upgraded: the funnels always know the previous set, and always-present is the simpler consumer contract). Re-click of the selected row = event with unchanged set,triggerpresent, empty deltas — the documented preview-pane refocus contract. Demo: Static example inspector strip renders the last raw event (#count · panel · trigger · +added −removed) — browser-verified for click, re-click (counter advances, empty deltas), and outside-click clear (no trigger). Follow-up candidate (not settled): a publicfocusedNodeoutput — preview-follows-focus would also cover keyboard navigation, whichtriggeralone doesn't. 146 lib + 41 app specs green. Amended 2026-07-21 (issue #2): requiredcause: 'pointer' | 'keyboard' | 'contextmenu'—triggersaid which row, never why; right-click reconciliation was indistinguishable from a genuine selection, so preview panes jumped on plain context-menu open.causedescribes why the write occurred (not the physical device): every#prepareContextpath reports'contextmenu'(right-click, Shift+F10 / ContextMenu key,openContextMenu()). Consumer collapses toif (event.trigger && event.cause !== 'contextmenu'). Suppressing select-on-right-click rejected (fights Phase 7 OS convention). External[(selectedKeys)]writes still never emit. Demo proof (settled same day): VS Code example owns a file-preview pane plus a one-itemtreeContextMenu("Preview"). Normal file selection updates the pane; right-click reconciliation changes selection but leaves the pane alone; choosing Preview explicitly opens the context file. The Static example remains the raw trigger/delta inspector — the cause behavior belongs where a real preview consumer exists. Browser flow pinned ine2e/vscode-preview.spec.ts; 157 lib + 43 app specs and the focused e2e proof green- Key-addressed API —
tree.byKeyfacade (decision 11 — the proposedT | stringunion rejected:Tmay itself bestring, and the union weakens every signature) ✅ implemented 2026-07-12.tree.byKey.expand/collapse/toggle/expandDescendants/isExpanded/edit/focus/scrollTo/openContextMenu/retryChildren/invalidateChildren(key?); unknown or not-yet-loaded key = no-op, resolved through the internal flat model (#withNode), and every call delegates to the node-addressed method so guards (disableEdit, …) apply identically. One deliberate nuance:byKey.isExpandedreports the raw expansion SET (which legitimately holds keys of unloaded lazy branches — restore-before-load), not flat-model membership. Keys are the tree's identity currency (consumers naturally storeparentKey); they no longer rebuild a key→node map the controller already owns. Demo: the lazy example's ref switch ends withtree.byKey.scrollTo('github')— the component holds the KEY constant, no node lookup. 4 specs - Per-node row styling —
rowClass/rowStyleaccessors (decision 12 — mechanism corrected) ✅ implemented 2026-07-12. Accessor-shaped likedisableDrag; applied to the tree-owned row element where the--tree-*chains resolve at point of use. Correction: the consumer's headline use case (per-node guide tint) is unreachable by row-applied variables — guides are per-group overlay divs, siblings of the rows, so nothing set on a row inherits into them. Therefore the group parent'srowStyleresult is additionally applied to that group's guide overlay: one accessor, both surfaces. Refinement at implementation:rowClassstays ROW-ONLY (a row-designed class applied to an overlay div would wreck its geometry; custom properties are inert unless the guide consumes them, classes aren't). Tree-owned bindings (height,top,--tree-level) are property-specific and always win over the consumer's style map — the fixed-row contract is untouchable. Ships-no-UI preserved. Demo (Static): Figma panel gives EVERY group's guide its ownoklchhue viarowStyle(hashed from the node id — deterministic, so stable across virtual-scroll recycles;light-dark()wrapping two full oklch colors for both themes) — a live proof that the guide really is row-styled, ten distinct colours browser-verified; Framer panel tags instances viarowClass+ a stylesheet inset bar. 3 specs (row classes/styles + the guide inheriting the group parent's tint)
Demo example mapping (each lands where it reads as a real-world pattern):
| Fix | Example | Showcase |
|---|---|---|
1 selectedKeys |
All-In-1 (/) |
Front page teaches the signal-first [(selectedKeys)] pattern; SelectionModel stays spec- and docs-covered |
2 expandedKeys |
All-In-1 | Search auto-expands folders with matches; clearing restores the previous expansion |
3 childrenDeps |
Lazy Load Only (/lazy) |
A real fetch parameter (e.g. GitHub ref switcher) bound to [childrenDeps] — flip it, caches drop, expanded branches reload |
4 trigger |
Static (/static) |
Layer-panel inspector driven by the last-clicked layer, incl. re-click of the already-selected row |
5 byKey |
Lazy Load Only | Post-move refresh via tree.byKey.invalidateChildren(parentKey) — the stored-parent-key scenario from the feedback |
6 rowStyle |
Static | Figma-faithful: component subtrees tinted (row + that group's guide) via one accessor |
| 14 reconciler | Lazy Load Only | Save expansion → Cold restore: tree reset to page-load state, saved keys alone re-fetch every open branch wave by wave |
Status: all six shipped 2026-07-12 (incl. the same-day SelectionModel removal and the expandedKeys() method removal). Demo coverage: All-In-1 = both controlled bindings; Static = live [(expandedKeys)] status bars, the SelectEvent inspector strip, rowStyle component-guide tint + rowClass instance bars; Lazy Load Only = [childrenDeps] ref switcher + byKey.scrollTo. 153 lib + 41 app specs green.
✅ implemented 2026-07-31 same day (explicit user call — pulled forward pre-release under the lifted v2 gate): ships in the MAIN entry (a standalone directive tree-shakes; no secondary entry point needed once the dependency question died), selectormiddleEllipsislabel directive (settled 2026-07-31: zero-dep — decision 13)[middleEllipsis]with the full text bound on the selector attribute (ngTemplateOutlet pattern) +middleEllipsisTail: 'balanced' | 'extension'. The directive OWNStextContent(element stays empty — an interpolation binding would fight the write); width 0 (SSR/jsdom/display:none) renders the full text rather than collapsing to…; ~1px margin absorbs canvas-vs-DOM drift; the shared canvas context re-applies font +letterSpacingper call. Ship checklist all landed:title/aria-labelcarry the full name, type-ahead-reads-data pinned by a DOM-scramble spec, bidi isolates (FSI/PDI) wrap the halves only when strong RTL is present (invisible chars would otherwise ride along into copied LTR text), grapheme integrity + ladder + both tail policies spec'd against a deterministic fake measurer (10 unit specs + 2 directive-contract specs). Demo (re-homed same day, user call): the VS Code Explorer carries middleEllipsis — file labels runmiddleEllipsisTail="extension"(a file tree is where Finder's never-truncate-the-extension rule reads as itself; folders take the balanced default; a deliberately long…component.spec.tsseed added;.vsc-namegainsflex: 1 1 auto— the content-INDEPENDENT box the contract requires, or re-truncation chases its own output) +labelOverflow: 'ellipsis'on the tree; the Media library shows the OTHER tier — plain consumer CSS end-ellipsis overlabelOverflowalone (its long Agent 327 title stays as that seed), so the demo teaches both truncation tiers side by side.e2e/middle-ellipsis.spec.tspins real-renderer geometry on /vscode: head is a true prefix, the tail keeps the.tsextension, the composed string fits its box, and narrowing the explorer column re-truncates live through the ResizeObserver path with the extension still intact.e2e/label-overflow.spec.tspins the organic CSS-clipping case on /media (wrapper on its min-width floor, no horizontal scrollbar, label genuinely clipped). Amended same day (user screenshot:virtualized-explorer-panel-with-inline-r….ts) — the first 'extension' cut anchored the tail at the BARE extension, so the head absorbed all loss and the result read as end-ellipsis with the extension stapled on, not a middle cut. Corrected: 'extension' mode cuts the STEM balanced and appends the whole extension (virtualized-expl…ering.component.spec.ts— both ends of the name survive, Finder for real); ladder unchanged (stem gives way around the held extension → extension gives way → bare…). And the VS Code workbench gained a real draggable sash (pointer-capture drag + separator-pattern keyboard resize — arrows step 16px, Home/End jump, clamped 160–520px; hidden in the stacked mobile layout) so the live re-truncation is visible by hand; the e2e narrow test now drives the actual sash with a mouse drag instead of injecting styles, and asserts the tail carries real stem characters beyond.ts. Original design notes: macOS-Finder-style middle truncation for consumer labels (<span middleEllipsis [text]="node.name">): a puremiddleEllipsis(text, maxWidth, measure)core (canvasmeasureText+Intl.Segmentergrapheme-safe binary search — STYLE.md Feature-Engines shape) plus a thin directive owning the canvas context,ResizeObserver, anddocument.fonts.readyre-runs; rows recycle under virtualization, so the directive re-derives on text/width/font changes only. RequireslabelOverflow: 'ellipsis'(v1) for a real edge to truncate against — without the capped rows the measured container grows with the text and the math concludes everything fits. Ship checklist: full text stays intitle/aria-label; spec pinning that type-ahead readstypeaheadTextfrom data, never from the truncated DOM; documented bidi caveat (splitting mixed-direction text mid-string can visually reorder — fall back to end-ellipsis when RTL chars sit mid-run);ctx.letterSpacinghonored + ~1px safety margin against canvas/DOM kerning drift. chenglou/pretext rejected: it's a multiline paragraph-layout engine (line breaking, rich inline flow) with no middle-truncation feature — the one primitive needed is themeasureTextcall pretext itself delegates to, and even an optional peer dep (secondary entry point +peerDependenciesMeta) would dilute "CDK as the only runtime dependency" for ~40 vendorable lines. Revisit only if multiline label layout ever becomes a feature- Documentation site (analog/ng-doc) with live StackBlitz examples per feature: lazy loading, DnD, checkbox trees, menus, theming, SSR
- Generated API reference from source JSDoc (already written in that style)
ng add angular-treeschematic: peer deps + starter template- Harness improvements beyond v1
TreeHarness:dragTogesture simulation, menu-open helpers - Recipes: "migrate from
mat-tree", "migrate fromcdk-tree", "migrate from PrimeNGp-tree", "migrate from jsTree" (Phase 14 adapter guide, generalized), react-arborist comparison table - Versioning/release automation: changelogs, canary channel, Angular major-version support policy (peer range currently
^21.2 || ^22)
Satisfies the sketch-before-code rule for the settled v2 surface. Everything is additive; every default preserves v1 behavior. Server-search (reveal) is deliberately absent until the open-question-5 spike.
// Inputs:
clickAction: 'activate' | 'select'; // default 'activate' (v1 lock intact); 'select' = file-manager
// semantics: plain click selects (single), dblclick activates,
// Ctrl/Shift selection shortcuts unchanged in both modes
collapseBehavior: 'keep' | 'invalidate'; // default 'keep'; 'invalidate' drops the node's lazy overlay on
// collapse → next expand re-runs the accessor
defaultFocusedKey: string; // initial roving-tabindex target (parallel to defaultExpandedKeys);
// unknown key falls back to first row
// Accessor contract — additive second parameter, existing accessors stay valid:
childrenAccessor: (node: T, signal?: AbortSignal) => T[] | Promise<T[]> | Observable<T[]>;
// Aborted on: destroy, invalidate-while-in-flight, and collapse only under
// collapseBehavior: 'invalidate'. Plain collapse ('keep') lets the resolve finish
// (v1 unmount-survival precedent). Observables: abort = unsubscribe.
// TreeApi additions (CdkTree-compatible naming, nodes not keys):
interface TreeApi<T> {
// …v1 surface unchanged
/** Drop the keyed children overlay and re-enter loading. Expanded node → accessor
* re-runs immediately (spinner via loadStates); collapsed → cleared, re-runs on next
* expand. No argument = tree-wide. The tree still never fetches — it only re-asks. */
invalidateChildren(node?: T): void;
}
// MoveEvent extension (additive):
interface MoveEvent<T> {
dragIds: string[];
parentId: string | null;
index: number;
dropEffect: 'move' | 'copy'; // 'copy' when the platform modifier is held at drop time
// (⌥ macOS, Ctrl elsewhere); keyboard path: Ctrl/Cmd+C
// arms copy-paste, Ctrl/Cmd+X arms move-paste
}
// Behavior, no API: focus retention across data replacement — the focused key re-attaches
// to its fresh DOM element after re-flatten; if the key vanished, fallback is nearest
// following sibling → preceding sibling → parent. Makes consumer-dialog round-trips
// refocus correctly with zero consumer code.
// --- Phase 9 sweep additions (sketched 2026-07-07) ---
// TreeApi:
expandAll(options?: { loadLazy?: boolean }): void;
// default: skip unloaded lazy subtrees (v1 behavior). loadLazy: batched
// ensureChildren over lazy nodes as they surface; each resolved batch expands
// and is scanned for further lazy nodes until the frontier is exhausted.
// TreeNodeHandle:
toggleSelection(range?: boolean): void;
// range = true → additive range from the selection anchor over visible order
// (Shift+checkbox, settled in v1 brainstorming). treeNodeCheckbox reads
// shiftKey from its click and passes it — existing callers unchanged.
// New opt-in directive (touch drag without long-press conflicts):
// treeNodeDragHandle — wraps CDK's drag handle: the row drags only from the
// handle, start delay drops to 0 (incl. touch — the handle IS the intent),
// long-press elsewhere stays the context menu's.
// New input (polite live region via CDK LiveAnnouncer — no DOM shipped):
announcements: TreeAnnouncements<T> | null; // null = silent; omitted = English defaults
interface TreeAnnouncements<T> {
moved?: (event: MoveEvent<T>) => string;
childrenLoaded?: (event: LoadChildrenEvent<T>) => string;
searchResults?: (count: number, term: string) => string;
}
// Keyboard map: PageUp / PageDown — viewport-height jumps (APG optional keys).
// Escape during a pointer drag cancels it: drag state resets, no `moved`.
// --- Phase 15 additions (sketched 2026-07-12, all additive) ---
// Controlled state — model() two-way; undefined = unbound (tree-owned state).
// [(selectedKeys)] / [(expandedKeys)] for shared state; one-way [x] + (xChange)
// write-back is the strictly controlled shape. The [selection] SelectionModel
// input is REMOVED (decision 7 amendment) — consumer-side bridge in RECIPES.md.
selectedKeys: model<readonly string[] | undefined>(); // #1 ✅ 2026-07-12
expandedKeys: model<readonly string[] | undefined>(); // #2 ✅ 2026-07-12 (decision 8; replaces the
// expandedKeys() snapshot method — name collision;
// defaultExpandedKeys stays, inert while bound)
// Declarative lazy invalidation, mirroring resource({ params }) — a
// reference-unequal change ≙ invalidateChildren() tree-wide:
childrenDeps: input<unknown>(); // #3 ✅ 2026-07-12
// SelectEvent — additive; `trigger` identifies the interacted row even when
// the resulting set is unchanged (re-click of the selected row):
// ✅ 2026-07-12 — added/removed shipped REQUIRED (funnels always know the
// previous set; always-present is the simpler contract), trigger optional.
// ✅ 2026-07-21 — `cause: 'pointer' | 'keyboard' | 'contextmenu'` REQUIRED
// (why the write occurred, not the physical device). Every path through
// `#prepareContext` reports `'contextmenu'` (right-click, Shift+F10 /
// ContextMenu key, `openContextMenu()`), so preview-pane consumers can ignore
// menu reconciliation: `if (event.trigger && event.cause !== 'contextmenu')`.
// External `[(selectedKeys)]` writes still never emit. Runtime additive for
// event listeners; TypeScript-breaking only for manual SelectEvent literals.
interface SelectEvent<T> {
ids: readonly string[];
nodes: readonly T[];
trigger?: T; // the row whose interaction caused this write
cause: 'pointer' | 'keyboard' | 'contextmenu';
added: readonly string[]; // vs the previous set (empty on a no-op re-click)
removed: readonly string[];
}
// Key-addressed facade (decision 11) — unknown key = no-op:
// ✅ 2026-07-12 — plus expandDescendants(key); isExpanded reports the raw
// expansion set (may hold keys of unloaded lazy branches — restore-before-load).
interface TreeApi<T> {
// …surface unchanged
readonly byKey: {
expand(key: string): void; collapse(key: string): void;
toggle(key: string): void; isExpanded(key: string): boolean;
expandDescendants(key: string): void;
edit(key: string): void; focus(key: string): void;
scrollTo(key: string): void; openContextMenu(key: string): void;
retryChildren(key: string): void;
invalidateChildren(key?: string): void;
};
}
// Per-node row styling (decision 12) — accessor-shaped like disableDrag;
// applied to the row element AND (the group parent's result) to that group's
// indent-guide overlay, since overlays are siblings of rows, not children:
// ✅ 2026-07-12 — rowStyle reaches guides; rowClass is deliberately ROW-ONLY
// (row-designed classes would wreck overlay geometry); tree-owned height/top/
// --tree-level bindings always win over the consumer map.
rowClass: input<((node: T) => string | readonly string[] | undefined) | undefined>();
rowStyle: input<((node: T) => Record<string, string> | undefined) | undefined>();
// --- Sticky scroll (decision 16) — sketched 2026-09-29, all additive ---
// VS Code StickyScrollController parity, opt-in. Tree-owned band over the
// viewport top rendering the consumer's own treeNodeDef for the ancestors of
// the top row; aria-hidden (keyboard: ArrowLeft-to-parent).
stickyScroll: input(false);
stickyScrollMaxRows: input(7); // VS Code stickyScrollMaxItemCount; clamped ≥ 1.
// The band is ALSO capped at 40 % of the viewport
// height; overflow drops the innermost rows.
// TreeNodeContext gains:
isSticky: boolean; // true only in the band — e.g. suppress a label click-to-toggle
// there (VS Code: only the twistie collapses a sticky row)
// Tokens: --tree-sticky-bg (→ --tree-bg → --mat-sys-surface; must be opaque),
// --tree-sticky-shadow (3px inset shadow under the band, VS Code's).
// Behavior: scrollTo()/byKey.scrollTo() land the node BELOW its own sticky
// ancestors; drops over a sticky row target it as drop-inside (disableDrop gates).
// --- Phase 13: middleEllipsis directive (decision 13) ✅ 2026-07-31 ---
// (Sketch recorded at implementation — the pull-forward outran the paperwork.
// Also mirrored into ROADMAP.md's v1 sketch: it ships in the v1 package.)
// Standalone directive, MAIN entry (tree-shakes; not tree-coupled — no
// TREE_NODE DI). It OWNS the element's textContent: leave the element empty.
// Prereqs: labelOverflow: 'ellipsis' on the tree + a content-independent
// label box (flex: 1 1 auto; min-inline-size: 0 — or a fixed width).
// <span class="name" [middleEllipsis]="node.name"
// middleEllipsisTail="extension"></span>
middleEllipsis: input.required<string>(); // full text; also title/aria-label
middleEllipsisTail: input<'balanced' | 'extension'>(); // default 'balanced';
// 'extension' = balanced STEM
// cut + whole extension kept
// Pure core exported alongside (testable without TestBed):
// middleEllipsis(text, maxWidth, measure, { tail }): string
// graphemesOf(text): readonly string[]
// cssTextMeasure(element): TextMeasure | null| # | Decision | Outcome | Date |
|---|---|---|---|
| 1 | Plain-click selection (reopened the v1 rowClickSelects lock, explicitly) |
clickAction: 'activate' | 'select' input, default 'activate' — v1 unchanged; click-select is the file-manager norm and belongs in a general-purpose tree as an opt-in |
2026-07-06 |
| 2 | Copy-on-drag surface & modifier | MoveEvent.dropEffect: 'move' | 'copy' (native DnD vocabulary, extensible); modifier platform-native: ⌥ macOS / Ctrl elsewhere; keyboard via Ctrl/Cmd+C/X + paste |
2026-07-06 |
| 3 | Lazy overlays across dataSource replacement |
Overlays survive, keyed (like expansion/selection); nothing re-fires implicitly — refresh only via explicit invalidateChildren(key?) or collapseBehavior: 'invalidate' |
2026-07-06 |
| 4 | expandAll over lazy subtrees |
Opt-in expandAll({ loadLazy: true }): batched ensureChildren, expanding batches as they resolve; default stays skip — a 100k lazy tree must never fetch-storm by accident |
2026-07-07 |
| 5 | mat-checkbox with treeNodeCheckbox |
Documented binding pattern ([checked]/[indeterminate] from checkState, toggleSelection() on change) — no Material coupling in the lib; the JSDoc "Phase 5 adapter" promise is amended |
2026-07-07 |
| 6 | Row-internal interactive elements (a11y) | Tree-shipped row directives leave the tab order (tabindex="-1" — keyboard equivalents exist: arrows, Space, F2, menu); arbitrary consumer buttons follow the same documented rule. No template-walking enforcement |
2026-07-07 |
| 7 | Controlled selection surface (Phase 15) — amended same day | selectedKeys as model<readonly string[] | undefined>; unbound = tree-owned state. [selection] (SelectionModel) removed outright (pre-release, zero consumers): one write path, no bridge effect — selectedIds is a linkedSignal over the input (echoes identity-preserved). Consumer-side bridge recipe in docs/RECIPES.md |
2026-07-12 |
| 8 | Live expandedKeys (reopens the v1 "expansion internal + methods" lock) |
Opt-in model input; unbound consumers keep private state (and Phase 11's O(1) in-place-mutation option); bound consumers pay O(expanded) copies per toggle knowingly. Amended on implementation: the expandedKeys() snapshot METHOD is removed (name collision; the bound model is the snapshot); defaultExpandedKeys inert while bound |
2026-07-12 |
| 9 | Declarative lazy invalidation | childrenDeps: input<unknown>() — reference change = invalidateChildren() tree-wide; accessor signal auto-tracking rejected (flatten-time probe must not track consumer signals) |
2026-07-12 |
| 10 | SelectEvent identifies the interaction |
Additive trigger?: T + added/removed threaded through the two existing write funnels; event shape otherwise unchanged. Amended 2026-07-21: required cause: 'pointer' | 'keyboard' | 'contextmenu' — why the write occurred (not the physical device); every #prepareContext path is 'contextmenu' so preview panes can ignore menu reconciliation without suppressing OS right-click-selects-first |
2026-07-12 |
| 11 | Key-addressed imperative API | tree.byKey.* facade, unknown key = no-op; T | string unions rejected (T may be string; weakens signatures) |
2026-07-12 |
| 12 | Per-node row styling | rowClass/rowStyle accessors on the row element and the group parent's result on that group's guide overlay (row-applied variables can't reach the sibling overlays) |
2026-07-12 |
| 13 | Middle-ellipsis truncation dependency | Zero-dep: vendored measureText + Intl.Segmenter binary-search core behind a thin directive (Phase 13). chenglou/pretext rejected — no middle-truncation feature (it's a multiline layout engine), the needed primitive is the canvas call it delegates to anyway, and optional-peer plumbing outweighs ~40 vendored lines while denting the "CDK as the only runtime dependency" claim |
2026-07-31 |
| 14 | Expanded state ⇒ load intent, reconciled | Consumer report (custom-field picker refresh): a key flagged expanded whose lazy node is unloaded was a dead state — the toggle funnel is the only fetch trigger, so controlled expandedKeys writes naming lazy nodes, defaultExpandedKeys over lazy roots, and dataSource replacements re-minting objects under still-open keys (resource-backed refresh) all rendered aria-expanded over nothing, forcing consumers to collapse-before-invalidate. An effect over flat × expandedIds × loadStates now runs ensureChildren for every expandable, unloaded, expanded key with no load state. Driven by expansion STATE, never rendering (search force-expansion bypasses expandedIds; virtualization can't start or cancel loads — v1 line intact); resolved overlays never re-fetch (decision 3 intact); error keys wait for retryChildren; no fetch-storm by accident — only keys the consumer explicitly flagged open load (decision 4's spirit). Controller hardening: a nullish accessor resolve coerces to [] in the overlay — a nullish entry reads as never-loaded and would loop the reconciler. angular-tree-expanded-reconcile.spec.ts pins: collapsed lazy nodes stay unfetched, controlled write loads, toggle still emits childrenLoaded exactly once (loading-state guard makes double-ensure race-free), reload across invalidation + re-minted dataSource without collapsing, error parked until retry. Demo: Lazy Load Only "Save expansion" / "Cold restore" — [(expandedKeys)] bound; restore resets the page-load state (graft dropped, in-flight write-backs deregistered, tree overlays invalidated via invalidateChildren() — they survive dataSource replacement by design; stale-kept since decision 15) then writes the saved keys back; browser-verified: one click = exactly two GitHub requests, root via the invalidate + the deeper wave via the reconciler, expansion never collapsed |
2026-07-31 |
| 15 | Invalidation is stale-while-revalidate | Consumer report (same picker/demo refresh flow): invalidation dropped the resolved overlay, so an open branch blanked to a spinner until the refetch landed — a visible flash on every refresh. invalidateChildren/childrenDeps/collapseBehavior:'invalidate' now MARK the overlay stale instead of dropping it: the old children stay rendered (per-row isLoading alongside them), ensureChildren re-runs despite loaded while the mark stands, and the async resolve swaps + clears. A failed revalidation keeps the stale subtree behind the Retry affordance — stale beats blank. Reconciler (decision 14) treats stale-as-unloaded so an expanded stale node revalidates even across a dataSource re-mint. Pinned trap: only an ASYNC resolve clears the mark — a sync/leaf noop read may be running against a flat model the next change detection is about to replace (invalidate-then-swap consumers like the demo's write-back roots), and clearing there killed the revalidation entirely; a mark lingering on a genuinely sync node is inert (the noop touches no signal, the reconciler settles). angular-tree-stale-revalidate.spec.ts (3 specs) pins mid-flight stale rendering, error-keeps-stale + retry, and stale-shown-instantly on re-expand under collapseBehavior:'invalidate'; the controller invalidation spec re-pinned from "overlay gone" to "overlay kept + stale mark". Browser-verified on /lazy: Cold restore over an expanded GBIF Animalia keeps every taxon on screen with the row spinner until the (throttled) refetch swaps them — no blank frame |
2026-07-31 |
| 16 | Sticky scroll (opt-in) — VS Code parity | User call: behavior must match VS Code's StickyScrollController exactly (verified against src/vs/base/browser/ui/tree/abstractTree.ts, main, 2026-09-29), shipped opt-in (VS Code defaults it on). Cap: max item count default 7, minimum 1, AND band height ≤ 40 % of the viewport height (maxWidgetViewRatio = 0.4) — both always apply, the tighter wins; overflow is cut from the INNER end (slice(0, i)), so the OUTERMOST ancestors stay. Stack rule: nothing while scrollTop is 0; a node becomes sticky only once its own row has started scrolling under the band; the row under the band joins only if it is an expanded parent with rendered children. Push: EVERY sticky node (not just the last) sits at min(slot, bottomOfLastDescendant − height), and the cap is checked against the pushed positions. Drag & drop: hovering a sticky row targets that node as a drop-INSIDE (VS Code resolves the sticky row to its source node via data-index), gated by disableDrop exactly like the real row (VS Code's onDragOver rules still run). Interaction/a11y (open question 6, settled same day): band aria-hidden, no second tab stop; a sticky click = VS Code reveal (scrollTop = nodeTop − min(level, max)·itemSize) + focus, then the row's own plain-click funnel (clickAction decides); the toggle inside a sticky row collapses (VS Code twistie); modifier clicks skip the reveal and go straight to selection (VS Code hands them to the list). Keyboard focus moves reveal with the same ancestor padding as VS Code's reveal() (scroll only when the row is under the band or below the viewport), so the focused row never hides under the band. Sticky rows render the consumer's own def with isSticky: true and isEditing: false (never a second rename input); edit() reveals first. ✅ Implemented same day: pure core tree-sticky.ts (line-for-line port of findStickyState/getNextStickyNode/calculateStickyNodePosition/constrainStickyNodes/nodePositionTopBelowWidget/List.reveal onto index math; structural index per visibility change, O(depth) stack per scroll, equal-guarded so unchanged frames don't re-render); band = aria-hidden sibling of the viewport (no ids/data-node-id/role — harness and row lookups only see real rows), per-row contexts + injectors WeakMap-memoized on the FlatRow; scroll/size mirrored via elementScrolled + ResizeObserver; focus engine takes a revealTop input (undefined = sticky off → old render-range path); drag session hit-tests the band before the list. Demo: VS Code Explorer runs [stickyScroll] over a collapsed eight-deep src/app/content/…/pipeline seed (one past the cap), chevron is now a real treeNodeToggle twistie (VS Code: twistie clicks don't select), label click-to-toggle skips when isSticky. Specs: tree-sticky.spec.ts (11, hand-computed VS Code outcomes), angular-tree-sticky.spec.ts (12, wiring), e2e/sticky-scroll.spec.ts (8, real geometry: cap keeps outermost 7, pixel-flush push, band clear of the scrollbar, click reveal+focus+select, focus never under the band, sticky twistie collapses, drop-inside indicator on a pinned row) |
2026-09-29 |
- Dynamic heights: own a measured strategy (Phase 11) or double down on fixed-height + document limitations? Decides Phase 11 scope
- Sparse selection wire format: expose
{ roots, exclusions }publicly or keep it internal behindexpandedKeys()-style snapshots? - External drops: one generic
externalDroppedintent vs typed adapters (files, text, custom)? - Docs site framework: analog, ng-doc, or hand-rolled? (Phase 13)
- Server-side search surface (Phase 12): what's the minimal tree-side API — imperative
reveal(paths)+ a match-set input, or a pluggable async search resolver the tree drives? Does reveal auto-ensureChildrendown ancestor paths (and what does progress/cancel look like)? How do matches in unloaded subtrees interact with sparse selection andaria-liveresult counts? Unsolved — design spike required before anything lands Sticky scroll (decision 16) vs earlier settlements✅ settled 2026-09-29 (user call) — our contract wins on both points: (a) the band isaria-hiddenpointer sugar, NOT a second tab stop (APG single-tab-stop tree from the 2026-07-09 audit stands; ArrowLeft-to-parent is the keyboard path, same rationale as indent guides); (b) a sticky-row click reveals the node under its ancestors and focuses it (VS Code), then runs the SAME plain-click funnel as the real row, soclickActiondecides —'select'replace-selects exactly like VS Code,'activate'activates (the Gmail lock stands). Recorded in decision 16
- Built-in menu/editor/checkbox UI — permanent non-goal, inherited from v1
- Zone.js support guarantees — still zoneless-first, untested under Zone.js
- Tree-shaking the tree into standalone sub-features — one entry point stays
- Row grouping / table-tree hybrid columns — that's a data grid; out of scope