Twelve issues. Each has a Title line and a Body; paste the body as-is. Paths are relative to projects/angular-tree/src/lib/ unless stated otherwise.
1
Title: hasChildren predicate: decide expandability without calling the children accessor
Body:
Problem
To decide whether a row is expandable, flat() calls the children accessor on every node (tree-controller.ts:384-398). This leads to three problems.
A Promise loader fires its fetch just to answer "has children?" That's why types.ts:78-82 tells consumers to return a cold Observable. A zoneless/signals app without RxJS has to import defer() only to satisfy the tree.
An AbortController is created for every probe. When the accessor takes a signal argument, one is created and stored per key at probe time (tree-controller.ts:206-212). A 10k-node root allocates 10k controllers before anything is expanded. And when the roots are rebuilt while a load is in flight, the new probe overwrites the controller that belongs to that load, so a later invalidation can no longer abort it.
Signals read inside the accessor don't update expandability. Results are cached per node object (:193-222), and invalidation only clears keys that already have load state (:304-326). A consumer with a server-side "does this folder have children?" hint has to rebuild node objects to get it re-read.
Proposal
readonly hasChildren = input<((node: T) => boolean) | undefined>(undefined);
When set, expandable comes from hasChildren(node). It's evaluated reactively and not cached, so signals read inside it work.
The children accessor then runs only from ensureChildren (expand, keyboard expand, drag-hover expand, expandAll). The AbortSignal is created at that moment.
Plain async loaders become safe, and the "use a cold Observable" note turns into "or bind hasChildren".
Without hasChildren, today's behaviour is unchanged.
Acceptance
not done
hasChildren input; the accessor isn't called for expandability when it's bound
not done
AbortController created at load start, not at probe time
not done
Reactive: a signal read inside hasChildren updates the row's expandable / aria-expanded
not done
RECIPES.md: lazy loading with a Promise loader + hasChildren
2
Title: Empty containers: a node that has no children but still accepts drops "inside"
Body:
Problem
A file-manager tree often knows a folder is empty (server-side count), yet the folder must stay a drop target. The tree can't express that:
expandable is raw != null (tree-controller.ts:398), so returning undefined makes the folder a leaf.
A drop "inside" a leaf becomes "after" (tree-controller.ts:684, tree-drag-session.ts:311), so it can no longer receive items.
The only workaround is to return [], but then the tree treats an empty folder as expandable:
aria-expanded is announced on a row with nothing under it (angular-tree.html:35)
ArrowRight "expands" it over nothing (tree-keyboard.ts:122-124)
hovering it during a drag auto-expands it after the delay (tree-drag-session.ts:362)
treeNodeToggle toggles it too, because the directive never checks expandable (tree-node-toggle.ts:26-30). The consumer then hand-rolls its own toggle.
Proposal
isContainer?: (node: T) => boolean. When true, the "inside" drop zone stays "inside" even when the node isn't expandable. expandable and aria-expanded keep describing children only.
treeNodeToggle does nothing when !handle.expandable.
Docs: say explicitly that [] means "expandable, no children yet" and undefined means leaf. It's a common surprise.
Acceptance
not done
isContainer input honoured by pointer drops, keyboard drops and sticky rows
not done
treeNodeToggle ignores rows that can't expand
not done
Docs note on [] vs undefined
3
Title: Type inference for treeNodeDef without when, and for treeContextMenu
Body:
Problem
TreeNodeDef defaults its node type to any (tree-node-def.ts:19-23). A def without a when guard gets let node typed any, so node.label / node.id go unchecked under strictTemplates.
TreeContextMenu has no input that carries T (tree-context-menu.ts:30-43), so node and nodes in the menu template are always any. Consumers with several node kinds end up with one large @switch (node.kind) over untyped values, or copy the node into signals only to get types back.
Proposal
An inference-only of input on both, taking the tree instance:
<angular-tree #tree ...>
<ng-container *treeNodeDef="let node; of: tree">…
<ng-template treeContextMenu [treeContextMenuOf]="tree" let-node let-nodes="nodes">…
The same pattern as cdkVirtualForOf / matRowDefTableDataSource: the input exists so ngTemplateContextGuard can infer T.
treeContextMenuWhen: a type-guard predicate like treeNodeDef's when, allowing several menu defs where the first match wins. Menus per node kind then become typed and small.
Acceptance
not done
of input on treeNodeDef and treeContextMenu; let node is T
not done
when guard on treeContextMenu, several defs, first match wins
not done
Type-check spec (e.g. an expectType in a template test)
4
Title: Buttons inside rows: menu trigger, retry directive, and a tab-stop guard
Body:
Problem
Buttons inside a row (⋮ menu, retry, copy) are a common need. Each consumer rebuilds them, and each gets at least one of these wrong:
Tab stops. ACCESSIBILITY.md:20-35 says row content must not be a tab stop. treeNodeToggle sets a static tabindex="-1" host attribute (tree-node-toggle.ts:20), but a host component binding [attr.tabindex] wipes it: MatIconButton binds undefined, and Angular removes the attribute. An aria menu trigger binds 0. In practice every row then adds 1–2 Tab stops.
Enter or Space on a focused row button is taken by the row. onKeydown only skips the rename input (angular-tree.ts:1016). Focusing a button makes its row the focused row (tree-focus-engine.ts:141-147). Enter then emits activated and calls preventDefault(), so the button's own click never fires.
Click bubbling. A button that doesn't stop propagation also triggers the row click (activate / toggle). A retry button on a failed group, for example, retries and collapses it. The library's own demos have this bug (projects/app/src/app/lazy-load-example/lazy-load-example.html:132-136, tree-example.html:110).
⋮ menus duplicate the context menu. CONTEXT-MENUS.md:29 suggests TreeApi.openContextMenu(node), but the public method drops the position (angular-tree.ts:1437; the private #openContextMenuAt(row, position?) at :1188 already accepts one). The menu then anchors at the row's left/bottom (:1171-1172), not at the button. So consumers build their own overlay menu (~150 lines).
Retry needs the whole tree. TreeNodeHandle (types.ts:50) has no retry, so a row template needs a viewChild to the tree just to call retryChildren(node).
Proposal
openContextMenu(node, anchor?: Element | { x: number; y: number }).
A treeNodeMenuTrigger directive that:
sets tabindex=-1 via a host binding (not a static attribute)
stops click and dblclick from reaching the row
handles its own Enter/Space
sets aria-haspopup / aria-expanded
opens the tree's menu anchored to its host
A menu-open row state: data-menu-open on the row, isMenuOpen in the node context.
TreeNodeHandle.retryChildren(), plus a treeNodeRetry directive with the same behaviour as treeNodeToggle (stops the click, tabindex=-1).
onKeydown ignores keys whose target is an interactive element inside the row (button, a, input, [tabindex]), except the ones the tree needs for navigation, so Enter/Space reach the button.
A dev-mode warning after first render when an element carrying a tree row directive has tabIndex !== -1.
Fix the demo retry buttons.
Acceptance
not done
openContextMenu(node, anchor); treeNodeMenuTrigger; row menu-open state
not done
handle.retryChildren() + treeNodeRetry
not done
Enter/Space on a row button activates the button, not the row
not done
Dev-mode tab-stop warning; ACCESSIBILITY.md notes the [attr.tabindex] override trap
not done
Demo retry buttons fixed
5
Title: Bugs: Escape clears the selection, Ctrl+C ignores disableDrag, toggling during search, right-clicking a row that can't be selected
Body:
Four interaction bugs found while auditing consumers. Each was checked against the source.
5a. Escape always clears the selection
tree-keyboard.ts:88-92 → angular-tree.ts:1068-1071: once no move mark is pending, Escape runs clearSelection. deselectOnOutsideClick (angular-tree.ts:221) has no keyboard counterpart. In a checkbox picker dialog, the first Escape silently wipes every pick, and only the second one closes the dialog.
Fix: apply deselectOnOutsideClick to the Escape step too, or add escapeClearsSelection, defaulting to false under checkboxSelection.
5b. Ctrl/Cmd+C / X marks rows even when dragging is disabled
tree-keyboard.ts:68-71 always produces markMove, and mark() (tree-drag-session.ts:131-138) never consults disableDrag (disableDrag is only read at angular-tree.ts:664 for the pointer path). In a read-only tree:
Cmd+C is swallowed (no browser copy)
the row gets an invisible mark
the next Escape clears that mark instead of doing what the user expected
Fix: mark() skips keys for which disableDrag is true. If nothing is left to mark, the tree doesn't handle the key at all, so the browser gets it.
5c. Expand/collapse during search uses stored state, not what's shown
While searching, an ancestor of a match renders expanded because it has matching children (tree-controller.ts:500-510). But isExpanded() / toggle() / collapse() read the stored expanded set (angular-tree.ts:1326-1340). So:
clicking such a row never collapses it
each click still changes the stored expansion, which breaks the promise that "clearing the term restores it intact" (tree-controller.ts:434-437)
ArrowLeft emits a collapseRow that does nothing
Fix: keep a "collapsed while searching" set that hides a force-expanded row's children and resets when the term changes, and make isExpanded / toggle / collapse use the shown state while a search is active.
5d. Right-clicking a row that can't be selected targets the other selection
#prepareContext (angular-tree.ts:1161-1170) only reconciles the selection when the row is selectable. Otherwise ids falls back to selectedIds(), which is the other rows, not the one that was clicked. That contradicts CONTEXT-MENUS.md:42. With a bulk menu, right-clicking a blocked row offers Delete for rows the user never pointed at.
Fix: when the row isn't in the selection, use ids = [row.key] (whether or not it's selectable).
Acceptance
not done
5a–5d each fixed with a spec
6
Title: Drag & drop switches: turn it off in one input, and refuse copy drops
Body:
Problem
Turning drag & drop off takes three inputs. A read-only tree passes the same () => true to disableDrag, disableDrop and disableEdit. Even then, keyboard cut/copy/paste stays active (see the Ctrl+C bug in the interaction-bugs issue).
Copy drops can't be refused. Alt/Ctrl-drag and Ctrl/Cmd+C then V produce dropEffect: 'copy' (tree-drag-session.ts:216,232, tree-keyboard.ts:68-71). TreeDropContext has no dropEffect field (types.ts:103-108), so disableDrop can't reject a copy. Most trees only move. A consumer that ignores event.dropEffect performs a move while the default announcement says "copied".
Proposal
[dragDrop]="false": turns off cdkDrag, the drop list and keyboard cut/copy/paste, and gives Ctrl+C/X/V back to the browser.
dropEffects: 'move' | 'copy' | 'both', defaulting to 'both' for compatibility. With 'move', copy modifiers are ignored and the gesture stays a move.
Add dropEffect to TreeDropContext for per-target decisions.
Acceptance
not done
dragDrop input
not done
dropEffects input + TreeDropContext.dropEffect
not done
The announcement matches the effect actually applied
7
Title: Checkbox pickers: cascade into lazily loaded children, and a "toggle checkbox" click action
Body:
Problem
Checking a collapsed lazy group checks only the group. The cascade covers loaded nodes only (tree-controller.ts:533-541), and subtreeKeys walks loaded children only (:604-616). Consumers work around this with their own "pending cascade" bookkeeping: tracking loaded group ids, their own child fetch, and parsing parent ids out of keys. That's about 70 lines.
A row click can't toggle the checkbox. clickAction is 'activate' | 'select' (angular-tree.ts:272, :723-744). Consumers write selectedKeys from (activated) themselves, which means:
no selectionChange is emitted
the Shift-range anchor doesn't move
isSelectable is ignored
and it needs the tree ref to toggle groups
Proposal
cascadeOnLoad (with checkboxSelection): in ensureChildren's success branch (tree-controller.ts:267-277), if the parent key is selected, add the new child keys and emit selectionChange with a new SelectCause value 'cascade' (events.ts:46).
clickAction also accepts 'toggle-selection' | 'toggle-expansion', or a per-node function (node) => ClickAction, so folders expand and leaves check in one tree (VS Code / file-picker behaviour). It goes through #toggleSelection (angular-tree.ts:1622-1652), so anchors, isSelectable and events all behave.
Acceptance
not done
cascadeOnLoad + 'cascade' select cause
not done
New clickAction values / per-node function
not done
RECIPES.md: lazy checkbox picker
8
Title: Rename (edit()): show off-screen rows, and keep row gestures out of the input
Body:
Problem
edit() only scrolls when sticky scroll is on (angular-tree.ts:1405-1416). Focus reveals the row (tree-focus-engine.ts:116-121), but edit() doesn't. A new child appended below the fold never shows its rename input. When the user later scrolls to it, the input mounts and grabs focus (tree-node-edit-input.ts:22-27).
byKey.edit(key) for a key not yet in the model does nothing (angular-tree.ts:1489-1493). This bites create-then-rename flows where the insert and the edit call race.
Row gestures reach the rename input. onKeydown (:1016) and onContextMenu (:980) skip it, but onRowClick / onRowDoubleClick (:723-753) don't, so consumers add (click)="$event.stopPropagation()" to the input. Drag isn't turned off while editing either: [cdkDragDisabled]="row.dragDisabled" (angular-tree.html:28). With no drag delay (tree-drag-session.ts:77), selecting text by dragging in the input can probably start a row drag.
Proposal
edit() reveals the row like focusKey does and sets focusedId.
byKey.edit keeps a pending request for a key that appears later, or add an [(editingKey)] model.
The row click and dblclick handlers skip events coming from the rename input. The row's drag is disabled while isEditing.
Acceptance
not done
edit() scrolls the row into view
not done
Pending edit / editingKey model
not done
Click, dblclick and drag guarded while editing
9
Title: Theming tokens: row gap, trailing inset, selected-hover, toggle hit target
Body:
Problem
Consumers still need ::ng-deep or copied values for common row geometry:
Row gap is a private calc(var(--_spacing) * 2) (angular-tree.scss:19,249). Consumers copy the formula so their own inner groups match it.
There's no trailing inset. Only a leading inset exists (:255-257), and the drop indicator's trailing inset is hard-coded (:30). So consumers use ::ng-deep .tree-node { padding-inline-end }.
Selected + hover: the selected rule comes after hover at the same specificity (:360-373), so hovering a selected row gives no feedback. Consumers write ::ng-deep .tree-node[data-selected]:hover.
Small twisty, larger hit area: THEMING.md (:117-155) uses one size for the toggle column and its hit target. Consumers who want a compact chevron invent a token plus negative margins, which moves the indent guide off the chevron's centre.
Proposal
--tree-node-gap
--tree-row-inset-end (the drop indicator uses it too)
--tree-node-selected-hover, defaulting to var(--tree-node-selected)
--tree-toggle-target (hit area), separate from --tree-toggle-size (the column the guides centre on)
Optional: under checkboxSelection, skip the selected fill, since aria-checked and the checkbox already show the state (angular-tree.html:38)
Acceptance
not done
Tokens added and listed in api-reference.ts + THEMING.md
not done
Indent guides stay centred on the chevron with a custom toggle target
10
Title: API conveniences: byKey.node(key) and MoveEvent.indexAfterRemoval
Body:
byKey.node(key)
The tree has no public way to look up a node by key. nodesForKeys exists but is internal (tree-controller.ts:712), and byKey (angular-tree.ts:1495-1517) has no getter. So consumers store node objects next to ids (an "active row" signal, the selection as objects), and those go stale after a rename or reload replaces the nodes. A deep-linked id can't be resolved at all.
Proposal: a reactive byKey.node(key): T | undefined that reads the flat model, so consumers store keys only and derive rows with computed.
MoveEvent.indexAfterRemoval
Every consumer that applies a same-parent move recomputes the insert index after removing the dragged nodes. The library's own demos do it twice (projects/app/src/app/example-data.ts:102, projects/app/src/app/lazy-load-example/lazy-sources.ts:346). The tree already knows the target's children when it resolves the drop.
Proposal: add indexAfterRemoval to MoveEvent, so applyMove becomes "remove, then insert at indexAfterRemoval".
Acceptance
not done
byKey.node
not done
MoveEvent.indexAfterRemoval; demos use it
11
Title: /testing: export the jsdom viewport helper so consumer specs can render rows
Body:
Problem
@h-k-dev/angular-tree/testing ships TreeHarness (README.md:114-116). But under jsdom the virtual viewport measures 0×0 and renders no rows. The sizing trick that fixes this is private to angular-tree-harness.spec.ts:78-107. So consumer specs give up on row assertions and fake events with { ids: [key] } as any instead.
Proposal
Export forceTreeViewportSize(fixture, height) or TreeHarness.setViewportSize(height), and document it in the README's Testing section.
Acceptance
not done
Helper exported from /testing
not done
README example of a consumer spec asserting on rows under jsdom
12
Title: Publish middleEllipsis from its own entry point
Body:
Problem
middleEllipsis is useful outside trees (table cells, file names in lists), but it's only exported from the main entry point, and its JSDoc is tree-specific ("for node labels", "pair with the tree's labelOverflow"). Consumers have copied it into their own code so non-tree call sites don't import from a tree package. That leaves two identical directives with the same selector in one bundle, and upstream fixes don't reach the copy. With "sideEffects": false bundle size isn't the problem: the import path and the docs are.
Proposal
A secondary entry point @h-k-dev/angular-tree/middle-ellipsis, like /testing; keep the main re-export for compatibility.
JSDoc that doesn't assume a tree. Document the table-cell rule: the cell needs inline-size: 0; min-inline-size: 100% so the box doesn't size to its content.
Add it to api-reference.ts, where it's currently missing.
Twelve issues. Each has a Title line and a Body; paste the body as-is. Paths are relative to projects/angular-tree/src/lib/ unless stated otherwise.
1
Title: hasChildren predicate: decide expandability without calling the children accessor
Body:
Problem
To decide whether a row is expandable, flat() calls the children accessor on every node (tree-controller.ts:384-398). This leads to three problems.
A Promise loader fires its fetch just to answer "has children?" That's why types.ts:78-82 tells consumers to return a cold Observable. A zoneless/signals app without RxJS has to import defer() only to satisfy the tree.
An AbortController is created for every probe. When the accessor takes a signal argument, one is created and stored per key at probe time (tree-controller.ts:206-212). A 10k-node root allocates 10k controllers before anything is expanded. And when the roots are rebuilt while a load is in flight, the new probe overwrites the controller that belongs to that load, so a later invalidation can no longer abort it.
Signals read inside the accessor don't update expandability. Results are cached per node object (:193-222), and invalidation only clears keys that already have load state (:304-326). A consumer with a server-side "does this folder have children?" hint has to rebuild node objects to get it re-read.
Proposal
readonly hasChildren = input<((node: T) => boolean) | undefined>(undefined);
When set, expandable comes from hasChildren(node). It's evaluated reactively and not cached, so signals read inside it work.
The children accessor then runs only from ensureChildren (expand, keyboard expand, drag-hover expand, expandAll). The AbortSignal is created at that moment.
Plain async loaders become safe, and the "use a cold Observable" note turns into "or bind hasChildren".
Without hasChildren, today's behaviour is unchanged.
Acceptance
not done
hasChildren input; the accessor isn't called for expandability when it's bound
not done
AbortController created at load start, not at probe time
not done
Reactive: a signal read inside hasChildren updates the row's expandable / aria-expanded
not done
RECIPES.md: lazy loading with a Promise loader + hasChildren
2
Title: Empty containers: a node that has no children but still accepts drops "inside"
Body:
Problem
A file-manager tree often knows a folder is empty (server-side count), yet the folder must stay a drop target. The tree can't express that:
expandable is raw != null (tree-controller.ts:398), so returning undefined makes the folder a leaf.
A drop "inside" a leaf becomes "after" (tree-controller.ts:684, tree-drag-session.ts:311), so it can no longer receive items.
The only workaround is to return [], but then the tree treats an empty folder as expandable:
aria-expanded is announced on a row with nothing under it (angular-tree.html:35)
ArrowRight "expands" it over nothing (tree-keyboard.ts:122-124)
hovering it during a drag auto-expands it after the delay (tree-drag-session.ts:362)
treeNodeToggle toggles it too, because the directive never checks expandable (tree-node-toggle.ts:26-30). The consumer then hand-rolls its own toggle.
Proposal
isContainer?: (node: T) => boolean. When true, the "inside" drop zone stays "inside" even when the node isn't expandable. expandable and aria-expanded keep describing children only.
treeNodeToggle does nothing when !handle.expandable.
Docs: say explicitly that [] means "expandable, no children yet" and undefined means leaf. It's a common surprise.
Acceptance
not done
isContainer input honoured by pointer drops, keyboard drops and sticky rows
not done
treeNodeToggle ignores rows that can't expand
not done
Docs note on [] vs undefined
3
Title: Type inference for treeNodeDef without when, and for treeContextMenu
Body:
Problem
TreeNodeDef defaults its node type to any (tree-node-def.ts:19-23). A def without a when guard gets let node typed any, so node.label / node.id go unchecked under strictTemplates.
TreeContextMenu has no input that carries T (tree-context-menu.ts:30-43), so node and nodes in the menu template are always any. Consumers with several node kinds end up with one large @switch (node.kind) over untyped values, or copy the node into signals only to get types back.
Proposal
An inference-only of input on both, taking the tree instance:
<angular-tree #tree ...>
<ng-container *treeNodeDef="let node; of: tree">…
<ng-template treeContextMenu [treeContextMenuOf]="tree" let-node let-nodes="nodes">…
The same pattern as cdkVirtualForOf / matRowDefTableDataSource: the input exists so ngTemplateContextGuard can infer T.
treeContextMenuWhen: a type-guard predicate like treeNodeDef's when, allowing several menu defs where the first match wins. Menus per node kind then become typed and small.
Acceptance
not done
of input on treeNodeDef and treeContextMenu; let node is T
not done
when guard on treeContextMenu, several defs, first match wins
not done
Type-check spec (e.g. an expectType in a template test)
4
Title: Buttons inside rows: menu trigger, retry directive, and a tab-stop guard
Body:
Problem
Buttons inside a row (⋮ menu, retry, copy) are a common need. Each consumer rebuilds them, and each gets at least one of these wrong:
Tab stops. ACCESSIBILITY.md:20-35 says row content must not be a tab stop. treeNodeToggle sets a static tabindex="-1" host attribute (tree-node-toggle.ts:20), but a host component binding [attr.tabindex] wipes it: MatIconButton binds undefined, and Angular removes the attribute. An aria menu trigger binds 0. In practice every row then adds 1–2 Tab stops.
Enter or Space on a focused row button is taken by the row. onKeydown only skips the rename input (angular-tree.ts:1016). Focusing a button makes its row the focused row (tree-focus-engine.ts:141-147). Enter then emits activated and calls preventDefault(), so the button's own click never fires.
Click bubbling. A button that doesn't stop propagation also triggers the row click (activate / toggle). A retry button on a failed group, for example, retries and collapses it. The library's own demos have this bug (projects/app/src/app/lazy-load-example/lazy-load-example.html:132-136, tree-example.html:110).
⋮ menus duplicate the context menu. CONTEXT-MENUS.md:29 suggests TreeApi.openContextMenu(node), but the public method drops the position (angular-tree.ts:1437; the private #openContextMenuAt(row, position?) at :1188 already accepts one). The menu then anchors at the row's left/bottom (:1171-1172), not at the button. So consumers build their own overlay menu (~150 lines).
Retry needs the whole tree. TreeNodeHandle (types.ts:50) has no retry, so a row template needs a viewChild to the tree just to call retryChildren(node).
Proposal
openContextMenu(node, anchor?: Element | { x: number; y: number }).
A treeNodeMenuTrigger directive that:
sets tabindex=-1 via a host binding (not a static attribute)
stops click and dblclick from reaching the row
handles its own Enter/Space
sets aria-haspopup / aria-expanded
opens the tree's menu anchored to its host
A menu-open row state: data-menu-open on the row, isMenuOpen in the node context.
TreeNodeHandle.retryChildren(), plus a treeNodeRetry directive with the same behaviour as treeNodeToggle (stops the click, tabindex=-1).
onKeydown ignores keys whose target is an interactive element inside the row (button, a, input, [tabindex]), except the ones the tree needs for navigation, so Enter/Space reach the button.
A dev-mode warning after first render when an element carrying a tree row directive has tabIndex !== -1.
Fix the demo retry buttons.
Acceptance
not done
openContextMenu(node, anchor); treeNodeMenuTrigger; row menu-open state
not done
handle.retryChildren() + treeNodeRetry
not done
Enter/Space on a row button activates the button, not the row
not done
Dev-mode tab-stop warning; ACCESSIBILITY.md notes the [attr.tabindex] override trap
not done
Demo retry buttons fixed
5
Title: Bugs: Escape clears the selection, Ctrl+C ignores disableDrag, toggling during search, right-clicking a row that can't be selected
Body:
Four interaction bugs found while auditing consumers. Each was checked against the source.
5a. Escape always clears the selection
tree-keyboard.ts:88-92 → angular-tree.ts:1068-1071: once no move mark is pending, Escape runs clearSelection. deselectOnOutsideClick (angular-tree.ts:221) has no keyboard counterpart. In a checkbox picker dialog, the first Escape silently wipes every pick, and only the second one closes the dialog.
Fix: apply deselectOnOutsideClick to the Escape step too, or add escapeClearsSelection, defaulting to false under checkboxSelection.
5b. Ctrl/Cmd+C / X marks rows even when dragging is disabled
tree-keyboard.ts:68-71 always produces markMove, and mark() (tree-drag-session.ts:131-138) never consults disableDrag (disableDrag is only read at angular-tree.ts:664 for the pointer path). In a read-only tree:
Cmd+C is swallowed (no browser copy)
the row gets an invisible mark
the next Escape clears that mark instead of doing what the user expected
Fix: mark() skips keys for which disableDrag is true. If nothing is left to mark, the tree doesn't handle the key at all, so the browser gets it.
5c. Expand/collapse during search uses stored state, not what's shown
While searching, an ancestor of a match renders expanded because it has matching children (tree-controller.ts:500-510). But isExpanded() / toggle() / collapse() read the stored expanded set (angular-tree.ts:1326-1340). So:
clicking such a row never collapses it
each click still changes the stored expansion, which breaks the promise that "clearing the term restores it intact" (tree-controller.ts:434-437)
ArrowLeft emits a collapseRow that does nothing
Fix: keep a "collapsed while searching" set that hides a force-expanded row's children and resets when the term changes, and make isExpanded / toggle / collapse use the shown state while a search is active.
5d. Right-clicking a row that can't be selected targets the other selection
#prepareContext (angular-tree.ts:1161-1170) only reconciles the selection when the row is selectable. Otherwise ids falls back to selectedIds(), which is the other rows, not the one that was clicked. That contradicts CONTEXT-MENUS.md:42. With a bulk menu, right-clicking a blocked row offers Delete for rows the user never pointed at.
Fix: when the row isn't in the selection, use ids = [row.key] (whether or not it's selectable).
Acceptance
not done
5a–5d each fixed with a spec
6
Title: Drag & drop switches: turn it off in one input, and refuse copy drops
Body:
Problem
Turning drag & drop off takes three inputs. A read-only tree passes the same () => true to disableDrag, disableDrop and disableEdit. Even then, keyboard cut/copy/paste stays active (see the Ctrl+C bug in the interaction-bugs issue).
Copy drops can't be refused. Alt/Ctrl-drag and Ctrl/Cmd+C then V produce dropEffect: 'copy' (tree-drag-session.ts:216,232, tree-keyboard.ts:68-71). TreeDropContext has no dropEffect field (types.ts:103-108), so disableDrop can't reject a copy. Most trees only move. A consumer that ignores event.dropEffect performs a move while the default announcement says "copied".
Proposal
[dragDrop]="false": turns off cdkDrag, the drop list and keyboard cut/copy/paste, and gives Ctrl+C/X/V back to the browser.
dropEffects: 'move' | 'copy' | 'both', defaulting to 'both' for compatibility. With 'move', copy modifiers are ignored and the gesture stays a move.
Add dropEffect to TreeDropContext for per-target decisions.
Acceptance
not done
dragDrop input
not done
dropEffects input + TreeDropContext.dropEffect
not done
The announcement matches the effect actually applied
7
Title: Checkbox pickers: cascade into lazily loaded children, and a "toggle checkbox" click action
Body:
Problem
Checking a collapsed lazy group checks only the group. The cascade covers loaded nodes only (tree-controller.ts:533-541), and subtreeKeys walks loaded children only (:604-616). Consumers work around this with their own "pending cascade" bookkeeping: tracking loaded group ids, their own child fetch, and parsing parent ids out of keys. That's about 70 lines.
A row click can't toggle the checkbox. clickAction is 'activate' | 'select' (angular-tree.ts:272, :723-744). Consumers write selectedKeys from (activated) themselves, which means:
no selectionChange is emitted
the Shift-range anchor doesn't move
isSelectable is ignored
and it needs the tree ref to toggle groups
Proposal
cascadeOnLoad (with checkboxSelection): in ensureChildren's success branch (tree-controller.ts:267-277), if the parent key is selected, add the new child keys and emit selectionChange with a new SelectCause value 'cascade' (events.ts:46).
clickAction also accepts 'toggle-selection' | 'toggle-expansion', or a per-node function (node) => ClickAction, so folders expand and leaves check in one tree (VS Code / file-picker behaviour). It goes through #toggleSelection (angular-tree.ts:1622-1652), so anchors, isSelectable and events all behave.
Acceptance
not done
cascadeOnLoad + 'cascade' select cause
not done
New clickAction values / per-node function
not done
RECIPES.md: lazy checkbox picker
8
Title: Rename (edit()): show off-screen rows, and keep row gestures out of the input
Body:
Problem
edit() only scrolls when sticky scroll is on (angular-tree.ts:1405-1416). Focus reveals the row (tree-focus-engine.ts:116-121), but edit() doesn't. A new child appended below the fold never shows its rename input. When the user later scrolls to it, the input mounts and grabs focus (tree-node-edit-input.ts:22-27).
byKey.edit(key) for a key not yet in the model does nothing (angular-tree.ts:1489-1493). This bites create-then-rename flows where the insert and the edit call race.
Row gestures reach the rename input. onKeydown (:1016) and onContextMenu (:980) skip it, but onRowClick / onRowDoubleClick (:723-753) don't, so consumers add (click)="$event.stopPropagation()" to the input. Drag isn't turned off while editing either: [cdkDragDisabled]="row.dragDisabled" (angular-tree.html:28). With no drag delay (tree-drag-session.ts:77), selecting text by dragging in the input can probably start a row drag.
Proposal
edit() reveals the row like focusKey does and sets focusedId.
byKey.edit keeps a pending request for a key that appears later, or add an [(editingKey)] model.
The row click and dblclick handlers skip events coming from the rename input. The row's drag is disabled while isEditing.
Acceptance
not done
edit() scrolls the row into view
not done
Pending edit / editingKey model
not done
Click, dblclick and drag guarded while editing
9
Title: Theming tokens: row gap, trailing inset, selected-hover, toggle hit target
Body:
Problem
Consumers still need ::ng-deep or copied values for common row geometry:
Row gap is a private calc(var(--_spacing) * 2) (angular-tree.scss:19,249). Consumers copy the formula so their own inner groups match it.
There's no trailing inset. Only a leading inset exists (:255-257), and the drop indicator's trailing inset is hard-coded (:30). So consumers use ::ng-deep .tree-node { padding-inline-end }.
Selected + hover: the selected rule comes after hover at the same specificity (:360-373), so hovering a selected row gives no feedback. Consumers write ::ng-deep .tree-node[data-selected]:hover.
Small twisty, larger hit area: THEMING.md (:117-155) uses one size for the toggle column and its hit target. Consumers who want a compact chevron invent a token plus negative margins, which moves the indent guide off the chevron's centre.
Proposal
--tree-node-gap
--tree-row-inset-end (the drop indicator uses it too)
--tree-node-selected-hover, defaulting to var(--tree-node-selected)
--tree-toggle-target (hit area), separate from --tree-toggle-size (the column the guides centre on)
Optional: under checkboxSelection, skip the selected fill, since aria-checked and the checkbox already show the state (angular-tree.html:38)
Acceptance
not done
Tokens added and listed in api-reference.ts + THEMING.md
not done
Indent guides stay centred on the chevron with a custom toggle target
10
Title: API conveniences: byKey.node(key) and MoveEvent.indexAfterRemoval
Body:
byKey.node(key)
The tree has no public way to look up a node by key. nodesForKeys exists but is internal (tree-controller.ts:712), and byKey (angular-tree.ts:1495-1517) has no getter. So consumers store node objects next to ids (an "active row" signal, the selection as objects), and those go stale after a rename or reload replaces the nodes. A deep-linked id can't be resolved at all.
Proposal: a reactive byKey.node(key): T | undefined that reads the flat model, so consumers store keys only and derive rows with computed.
MoveEvent.indexAfterRemoval
Every consumer that applies a same-parent move recomputes the insert index after removing the dragged nodes. The library's own demos do it twice (projects/app/src/app/example-data.ts:102, projects/app/src/app/lazy-load-example/lazy-sources.ts:346). The tree already knows the target's children when it resolves the drop.
Proposal: add indexAfterRemoval to MoveEvent, so applyMove becomes "remove, then insert at indexAfterRemoval".
Acceptance
not done
byKey.node
not done
MoveEvent.indexAfterRemoval; demos use it
11
Title: /testing: export the jsdom viewport helper so consumer specs can render rows
Body:
Problem
@h-k-dev/angular-tree/testing ships TreeHarness (README.md:114-116). But under jsdom the virtual viewport measures 0×0 and renders no rows. The sizing trick that fixes this is private to angular-tree-harness.spec.ts:78-107. So consumer specs give up on row assertions and fake events with { ids: [key] } as any instead.
Proposal
Export forceTreeViewportSize(fixture, height) or TreeHarness.setViewportSize(height), and document it in the README's Testing section.
Acceptance
not done
Helper exported from /testing
not done
README example of a consumer spec asserting on rows under jsdom
12
Title: Publish middleEllipsis from its own entry point
Body:
Problem
middleEllipsis is useful outside trees (table cells, file names in lists), but it's only exported from the main entry point, and its JSDoc is tree-specific ("for node labels", "pair with the tree's labelOverflow"). Consumers have copied it into their own code so non-tree call sites don't import from a tree package. That leaves two identical directives with the same selector in one bundle, and upstream fixes don't reach the copy. With "sideEffects": false bundle size isn't the problem: the import path and the docs are.
Proposal
A secondary entry point @h-k-dev/angular-tree/middle-ellipsis, like /testing; keep the main re-export for compatibility.
JSDoc that doesn't assume a tree. Document the table-cell rule: the cell needs inline-size: 0; min-inline-size: 100% so the box doesn't size to its content.
Add it to api-reference.ts, where it's currently missing.