forty-cdk 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/accordion/README.md +122 -0
- package/aspect-ratio/README.md +76 -0
- package/avatar/README.md +100 -0
- package/breadcrumbs/README.md +49 -0
- package/breakpoints/README.md +81 -0
- package/button/README.md +49 -0
- package/calendar/README.md +458 -0
- package/carousel/README.md +358 -0
- package/checkbox/README.md +146 -0
- package/combobox/README.md +535 -0
- package/context-menu/README.md +139 -0
- package/date-field/README.md +184 -0
- package/date-picker/README.md +338 -0
- package/dialog/README.md +388 -0
- package/disclosure/README.md +114 -0
- package/drag-drop/README.md +359 -0
- package/drawer/README.md +560 -0
- package/dropdown-menu/README.md +176 -0
- package/fesm2022/forty-cdk-accordion.mjs +348 -0
- package/fesm2022/forty-cdk-accordion.mjs.map +1 -0
- package/fesm2022/forty-cdk-aspect-ratio.mjs +74 -0
- package/fesm2022/forty-cdk-aspect-ratio.mjs.map +1 -0
- package/fesm2022/forty-cdk-avatar.mjs +308 -0
- package/fesm2022/forty-cdk-avatar.mjs.map +1 -0
- package/fesm2022/forty-cdk-breadcrumbs.mjs +125 -0
- package/fesm2022/forty-cdk-breadcrumbs.mjs.map +1 -0
- package/fesm2022/forty-cdk-breakpoints.mjs +117 -0
- package/fesm2022/forty-cdk-breakpoints.mjs.map +1 -0
- package/fesm2022/forty-cdk-button.mjs +134 -0
- package/fesm2022/forty-cdk-button.mjs.map +1 -0
- package/fesm2022/forty-cdk-calendar.mjs +2034 -0
- package/fesm2022/forty-cdk-calendar.mjs.map +1 -0
- package/fesm2022/forty-cdk-carousel.mjs +968 -0
- package/fesm2022/forty-cdk-carousel.mjs.map +1 -0
- package/fesm2022/forty-cdk-checkbox.mjs +226 -0
- package/fesm2022/forty-cdk-checkbox.mjs.map +1 -0
- package/fesm2022/forty-cdk-combobox.mjs +2596 -0
- package/fesm2022/forty-cdk-combobox.mjs.map +1 -0
- package/fesm2022/forty-cdk-context-menu.mjs +413 -0
- package/fesm2022/forty-cdk-context-menu.mjs.map +1 -0
- package/fesm2022/forty-cdk-core.mjs +9022 -0
- package/fesm2022/forty-cdk-core.mjs.map +1 -0
- package/fesm2022/forty-cdk-date-field.mjs +744 -0
- package/fesm2022/forty-cdk-date-field.mjs.map +1 -0
- package/fesm2022/forty-cdk-date-picker.mjs +1011 -0
- package/fesm2022/forty-cdk-date-picker.mjs.map +1 -0
- package/fesm2022/forty-cdk-dialog.mjs +707 -0
- package/fesm2022/forty-cdk-dialog.mjs.map +1 -0
- package/fesm2022/forty-cdk-disclosure.mjs +190 -0
- package/fesm2022/forty-cdk-disclosure.mjs.map +1 -0
- package/fesm2022/forty-cdk-drag-drop.mjs +1180 -0
- package/fesm2022/forty-cdk-drag-drop.mjs.map +1 -0
- package/fesm2022/forty-cdk-drawer.mjs +1641 -0
- package/fesm2022/forty-cdk-drawer.mjs.map +1 -0
- package/fesm2022/forty-cdk-dropdown-menu.mjs +350 -0
- package/fesm2022/forty-cdk-dropdown-menu.mjs.map +1 -0
- package/fesm2022/forty-cdk-field.mjs +425 -0
- package/fesm2022/forty-cdk-field.mjs.map +1 -0
- package/fesm2022/forty-cdk-fieldset.mjs +164 -0
- package/fesm2022/forty-cdk-fieldset.mjs.map +1 -0
- package/fesm2022/forty-cdk-file-upload.mjs +221 -0
- package/fesm2022/forty-cdk-file-upload.mjs.map +1 -0
- package/fesm2022/forty-cdk-hover-card.mjs +496 -0
- package/fesm2022/forty-cdk-hover-card.mjs.map +1 -0
- package/fesm2022/forty-cdk-input.mjs +274 -0
- package/fesm2022/forty-cdk-input.mjs.map +1 -0
- package/fesm2022/forty-cdk-internationalized-date.mjs +1 -1
- package/fesm2022/forty-cdk-internationalized-date.mjs.map +1 -1
- package/fesm2022/forty-cdk-listbox.mjs +1279 -0
- package/fesm2022/forty-cdk-listbox.mjs.map +1 -0
- package/fesm2022/forty-cdk-menu.mjs +1439 -0
- package/fesm2022/forty-cdk-menu.mjs.map +1 -0
- package/fesm2022/forty-cdk-menubar.mjs +787 -0
- package/fesm2022/forty-cdk-menubar.mjs.map +1 -0
- package/fesm2022/forty-cdk-meter.mjs +211 -0
- package/fesm2022/forty-cdk-meter.mjs.map +1 -0
- package/fesm2022/forty-cdk-navigation-menu.mjs +1145 -0
- package/fesm2022/forty-cdk-navigation-menu.mjs.map +1 -0
- package/fesm2022/forty-cdk-number-input.mjs +559 -0
- package/fesm2022/forty-cdk-number-input.mjs.map +1 -0
- package/fesm2022/forty-cdk-otp-input.mjs +527 -0
- package/fesm2022/forty-cdk-otp-input.mjs.map +1 -0
- package/fesm2022/forty-cdk-pagination.mjs +323 -0
- package/fesm2022/forty-cdk-pagination.mjs.map +1 -0
- package/fesm2022/forty-cdk-pane-resizer.mjs +297 -0
- package/fesm2022/forty-cdk-pane-resizer.mjs.map +1 -0
- package/fesm2022/forty-cdk-popover.mjs +698 -0
- package/fesm2022/forty-cdk-popover.mjs.map +1 -0
- package/fesm2022/forty-cdk-progress.mjs +226 -0
- package/fesm2022/forty-cdk-progress.mjs.map +1 -0
- package/fesm2022/forty-cdk-radio-group.mjs +378 -0
- package/fesm2022/forty-cdk-radio-group.mjs.map +1 -0
- package/fesm2022/forty-cdk-scroll-area.mjs +640 -0
- package/fesm2022/forty-cdk-scroll-area.mjs.map +1 -0
- package/fesm2022/forty-cdk-search.mjs +205 -0
- package/fesm2022/forty-cdk-search.mjs.map +1 -0
- package/fesm2022/forty-cdk-select.mjs +1661 -0
- package/fesm2022/forty-cdk-select.mjs.map +1 -0
- package/fesm2022/forty-cdk-separator.mjs +82 -0
- package/fesm2022/forty-cdk-separator.mjs.map +1 -0
- package/fesm2022/forty-cdk-signal-forms.mjs +97 -0
- package/fesm2022/forty-cdk-signal-forms.mjs.map +1 -0
- package/fesm2022/forty-cdk-slider.mjs +803 -0
- package/fesm2022/forty-cdk-slider.mjs.map +1 -0
- package/fesm2022/forty-cdk-stepper.mjs +886 -0
- package/fesm2022/forty-cdk-stepper.mjs.map +1 -0
- package/fesm2022/forty-cdk-switch.mjs +137 -0
- package/fesm2022/forty-cdk-switch.mjs.map +1 -0
- package/fesm2022/forty-cdk-table.mjs +1518 -0
- package/fesm2022/forty-cdk-table.mjs.map +1 -0
- package/fesm2022/forty-cdk-tabs.mjs +400 -0
- package/fesm2022/forty-cdk-tabs.mjs.map +1 -0
- package/fesm2022/forty-cdk-time-field.mjs +593 -0
- package/fesm2022/forty-cdk-time-field.mjs.map +1 -0
- package/fesm2022/forty-cdk-time-picker.mjs +1013 -0
- package/fesm2022/forty-cdk-time-picker.mjs.map +1 -0
- package/fesm2022/forty-cdk-toast.mjs +1153 -0
- package/fesm2022/forty-cdk-toast.mjs.map +1 -0
- package/fesm2022/forty-cdk-toggle.mjs +516 -0
- package/fesm2022/forty-cdk-toggle.mjs.map +1 -0
- package/fesm2022/forty-cdk-toolbar.mjs +374 -0
- package/fesm2022/forty-cdk-toolbar.mjs.map +1 -0
- package/fesm2022/forty-cdk-tooltip.mjs +672 -0
- package/fesm2022/forty-cdk-tooltip.mjs.map +1 -0
- package/fesm2022/forty-cdk-tree.mjs +2007 -0
- package/fesm2022/forty-cdk-tree.mjs.map +1 -0
- package/fesm2022/forty-cdk-virtualization.mjs +1 -1
- package/fesm2022/forty-cdk-virtualization.mjs.map +1 -1
- package/fesm2022/forty-cdk.mjs +0 -43310
- package/fesm2022/forty-cdk.mjs.map +1 -1
- package/field/README.md +97 -0
- package/fieldset/README.md +86 -0
- package/file-upload/README.md +73 -0
- package/hover-card/README.md +171 -0
- package/input/README.md +156 -0
- package/listbox/README.md +424 -0
- package/menu/README.md +181 -0
- package/menubar/README.md +140 -0
- package/meter/README.md +128 -0
- package/navigation-menu/README.md +253 -0
- package/number-input/README.md +171 -0
- package/otp-input/README.md +198 -0
- package/package.json +213 -1
- package/pagination/README.md +61 -0
- package/pane-resizer/README.md +136 -0
- package/popover/README.md +262 -0
- package/progress/README.md +115 -0
- package/radio-group/README.md +129 -0
- package/scroll-area/README.md +184 -0
- package/search/README.md +42 -0
- package/select/README.md +488 -0
- package/separator/README.md +84 -0
- package/signal-forms/README.md +72 -0
- package/slider/README.md +152 -0
- package/stepper/README.md +292 -0
- package/switch/README.md +116 -0
- package/table/README.md +769 -0
- package/tabs/README.md +130 -0
- package/time-field/README.md +157 -0
- package/time-picker/README.md +172 -0
- package/toast/README.md +398 -0
- package/toggle/README.md +224 -0
- package/toolbar/README.md +109 -0
- package/tooltip/README.md +274 -0
- package/tree/README.md +708 -0
- package/types/forty-cdk-accordion.d.ts +242 -0
- package/types/forty-cdk-aspect-ratio.d.ts +59 -0
- package/types/forty-cdk-avatar.d.ts +133 -0
- package/types/forty-cdk-breadcrumbs.d.ts +92 -0
- package/types/forty-cdk-breakpoints.d.ts +141 -0
- package/types/forty-cdk-button.d.ts +80 -0
- package/types/forty-cdk-calendar.d.ts +914 -0
- package/types/forty-cdk-carousel.d.ts +530 -0
- package/types/forty-cdk-checkbox.d.ts +141 -0
- package/types/forty-cdk-combobox.d.ts +1259 -0
- package/types/forty-cdk-context-menu.d.ts +313 -0
- package/types/forty-cdk-core.d.ts +5774 -0
- package/types/forty-cdk-date-field.d.ts +307 -0
- package/types/forty-cdk-date-picker.d.ts +622 -0
- package/types/forty-cdk-dialog.d.ts +546 -0
- package/types/forty-cdk-disclosure.d.ts +127 -0
- package/types/forty-cdk-drag-drop.d.ts +456 -0
- package/types/forty-cdk-drawer.d.ts +871 -0
- package/types/forty-cdk-dropdown-menu.d.ts +242 -0
- package/types/forty-cdk-field.d.ts +236 -0
- package/types/forty-cdk-fieldset.d.ts +119 -0
- package/types/forty-cdk-file-upload.d.ts +124 -0
- package/types/forty-cdk-hover-card.d.ts +320 -0
- package/types/forty-cdk-input.d.ts +169 -0
- package/types/forty-cdk-internationalized-date.d.ts +1 -1
- package/types/forty-cdk-listbox.d.ts +513 -0
- package/types/forty-cdk-menu.d.ts +629 -0
- package/types/forty-cdk-menubar.d.ts +451 -0
- package/types/forty-cdk-meter.d.ts +122 -0
- package/types/forty-cdk-navigation-menu.d.ts +514 -0
- package/types/forty-cdk-number-input.d.ts +319 -0
- package/types/forty-cdk-otp-input.d.ts +248 -0
- package/types/forty-cdk-pagination.d.ts +214 -0
- package/types/forty-cdk-pane-resizer.d.ts +145 -0
- package/types/forty-cdk-popover.d.ts +509 -0
- package/types/forty-cdk-progress.d.ts +143 -0
- package/types/forty-cdk-radio-group.d.ts +222 -0
- package/types/forty-cdk-scroll-area.d.ts +258 -0
- package/types/forty-cdk-search.d.ts +142 -0
- package/types/forty-cdk-select.d.ts +899 -0
- package/types/forty-cdk-separator.d.ts +59 -0
- package/types/forty-cdk-signal-forms.d.ts +58 -0
- package/types/forty-cdk-slider.d.ts +379 -0
- package/types/forty-cdk-stepper.d.ts +650 -0
- package/types/forty-cdk-switch.d.ts +87 -0
- package/types/forty-cdk-table.d.ts +723 -0
- package/types/forty-cdk-tabs.d.ts +235 -0
- package/types/forty-cdk-time-field.d.ts +307 -0
- package/types/forty-cdk-time-picker.d.ts +578 -0
- package/types/forty-cdk-toast.d.ts +598 -0
- package/types/forty-cdk-toggle.d.ts +310 -0
- package/types/forty-cdk-toolbar.d.ts +217 -0
- package/types/forty-cdk-tooltip.d.ts +436 -0
- package/types/forty-cdk-tree.d.ts +688 -0
- package/types/forty-cdk.d.ts +1 -19743
package/tree/README.md
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
# Tree
|
|
2
|
+
|
|
3
|
+
Headless implementation of the [WAI-ARIA Tree View pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/) — a nested `role="tree"` → `treeitem` → `group` → `treeitem` widget for hierarchical data (file explorers, nav trees, category pickers). Roving-tabindex focus management (APG Approach A — DOM focus rides the `treeitem`), single / multi select, full keyboard interaction, typeahead, RTL arrow mirroring, and `aria-level` / `aria-setsize` / `aria-posinset` wiring.
|
|
4
|
+
|
|
5
|
+
Selection and expansion are two independent models: `value` (selected nodes) and `expanded` (open nodes). Expansion is always multi; only `value` honours `multiple`.
|
|
6
|
+
|
|
7
|
+
## Pieces
|
|
8
|
+
|
|
9
|
+
| Class | Selector | Role |
|
|
10
|
+
| ------------------------------ | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
11
|
+
| `ForTree` | `[forTree]` | Root container (`role="tree"`). Owns selection + expansion, navigation, and the shared context. |
|
|
12
|
+
| `ForTreeItem` | `[forTreeItem]` | One node (`role="treeitem"`). Carries ARIA state, the roving tab stop, and the keyboard interaction. |
|
|
13
|
+
| `ForTreeItemLabel` | `[forTreeItemLabel]` | Pointer target inside an item (click selects + focuses) and the default typeahead text source. |
|
|
14
|
+
| `ForTreeItemToggle` | `[forTreeItemToggle]` | Optional expand / collapse control. Its presence marks the item a **parent** (emits `aria-expanded`). |
|
|
15
|
+
| `ForTreeGroup` | `[forTreeGroup]` | Nested container (`role="group"`) holding a parent's child items. Rendered behind `@if`. |
|
|
16
|
+
| `ForTreeItemCheckbox` | `[forTreeItemCheckbox]` | Visible checkbox surface inside `[forTreeItemLabel]`, used in `selectionMode="checkbox"`. Decorative (`aria-hidden`); the `treeitem` owns `aria-checked`. |
|
|
17
|
+
| `ForTreeItemCheckboxIndicator` | `[forTreeItemCheckboxIndicator]` | Optional glyph slot inside `[forTreeItemCheckbox]`. Self-hides while the node is unchecked. |
|
|
18
|
+
|
|
19
|
+
## Inputs / models
|
|
20
|
+
|
|
21
|
+
### `ForTree`
|
|
22
|
+
|
|
23
|
+
| API | Type | Description |
|
|
24
|
+
| ----------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
25
|
+
| `value` | `model<readonly string[]>` | Two-way bindable. Selected node values. Single mode keeps 0 or 1; multi any number. Default `[]`. |
|
|
26
|
+
| `expanded` | `model<readonly string[]>` | Two-way bindable. Open (expanded) parent node values. Always multi. Default `[]`. |
|
|
27
|
+
| `selected` | `Signal<string \| null>` | Read-only single-select convenience view of `value`: the sole selected value, or `null` when none / many are selected. |
|
|
28
|
+
| `multiple` | `input<boolean>` | When true, multiple nodes can be selected. Default `false`. |
|
|
29
|
+
| `disabled` | `input<boolean>` | Disables the whole tree. Reflected as `aria-disabled` / `data-disabled`. |
|
|
30
|
+
| `orientation` | `input<'vertical' \| 'horizontal'>` | Navigation axis. Default `'vertical'` (ArrowUp/Down move; ArrowLeft/Right expand/collapse). Reflected as `aria-orientation` / `data-orientation`. |
|
|
31
|
+
| `ariaLabel` | `input<string \| null>` | Reactive accessible name, reflected as `aria-label`. Default `null` (and empty) emits no attribute. Prefer native `aria-labelledby` when a visible label exists. |
|
|
32
|
+
| `dir` | `input<'ltr' \| 'rtl' \| null>` | Writing direction. Default `null` resolves the inherited ambient direction; an explicit value wins. Reflected to the host `dir` attribute and mirrors the expand/collapse arrows in RTL. |
|
|
33
|
+
| `selectionFollowsFocus` | `input<boolean>` | Single-mode only. When true, arrow navigation also selects the focused node. Default from `provideForTreeDefaults` (library default `false`). |
|
|
34
|
+
| `selectionMode` | `input<'highlight' \| 'checkbox'>` | Selection presentation. `'highlight'` (default) uses `aria-selected`; `'checkbox'` uses `aria-checked` and renders the checkbox anatomy (inherently multi-select). Default `'highlight'`. |
|
|
35
|
+
| `cascade` | `input<boolean>` | Enables cascade selection in `selectionMode="checkbox"`: checking / unchecking a node propagates to all descendants, and a parent reports `aria-checked="mixed"` when only some descendants are checked. Requires `descendantsOf`. Default `false`. |
|
|
36
|
+
| `descendantsOf` | `input<(value: string) => readonly string[]>` | Returns the selectable descendant values of a node (excluding the node itself). Required when `cascade` is `true`; the tree throws a `[forty-cdk/tree]` error otherwise. |
|
|
37
|
+
|
|
38
|
+
### `ForTreeItem`
|
|
39
|
+
|
|
40
|
+
| API | Type | Description |
|
|
41
|
+
| ----------- | ------------------------ | ---------------------------------------------------------------------------------------- |
|
|
42
|
+
| `value` | `input.required<string>` | The node's value. Must be unique within the tree. |
|
|
43
|
+
| `disabled` | `input<boolean>` | Disables this node: not selectable, skipped by keyboard navigation. |
|
|
44
|
+
| `textValue` | `input<string>` | Typeahead text override. Falls back to the `[forTreeItemLabel]` text content when empty. |
|
|
45
|
+
|
|
46
|
+
## Usage (recursive component)
|
|
47
|
+
|
|
48
|
+
Trees are recursive, and the idiomatic Angular shape is a small **recursive component** for the node. This keeps dependency injection correct at every depth: each node component nests its element injector under its enclosing `[forTreeGroup]`, so `[forTreeItem]` resolves the right level / container automatically.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core';
|
|
52
|
+
import {
|
|
53
|
+
ForTree,
|
|
54
|
+
ForTreeGroup,
|
|
55
|
+
ForTreeItem,
|
|
56
|
+
ForTreeItemLabel,
|
|
57
|
+
ForTreeItemToggle,
|
|
58
|
+
} from 'forty-cdk/tree';
|
|
59
|
+
|
|
60
|
+
interface Node {
|
|
61
|
+
id: string;
|
|
62
|
+
name: string;
|
|
63
|
+
children?: Node[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@Component({
|
|
67
|
+
selector: 'app-tree-node',
|
|
68
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
69
|
+
imports: [ForTreeItem, ForTreeItemLabel, ForTreeItemToggle, ForTreeGroup, TreeNode],
|
|
70
|
+
// `display: contents` keeps this wrapper out of layout and the a11y tree, so
|
|
71
|
+
// the <li role="treeitem"> stays a direct child of <ul role="group">.
|
|
72
|
+
host: { style: 'display: contents' },
|
|
73
|
+
template: `
|
|
74
|
+
<li forTreeItem class="tree-item" [value]="node().id">
|
|
75
|
+
<div forTreeItemLabel>
|
|
76
|
+
@if (node().children?.length) {
|
|
77
|
+
<span forTreeItemToggle class="tree-toggle">▸</span>
|
|
78
|
+
}
|
|
79
|
+
{{ node().name }}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
@if (node().children?.length && expanded().includes(node().id)) {
|
|
83
|
+
<ul forTreeGroup>
|
|
84
|
+
@for (child of node().children ?? []; track child.id) {
|
|
85
|
+
<app-tree-node [node]="child" [expanded]="expanded()" />
|
|
86
|
+
}
|
|
87
|
+
</ul>
|
|
88
|
+
}
|
|
89
|
+
</li>
|
|
90
|
+
`,
|
|
91
|
+
})
|
|
92
|
+
export class TreeNode {
|
|
93
|
+
readonly node = input.required<Node>();
|
|
94
|
+
readonly expanded = input.required<readonly string[]>();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@Component({
|
|
98
|
+
selector: 'app-files',
|
|
99
|
+
imports: [ForTree, TreeNode],
|
|
100
|
+
template: `
|
|
101
|
+
<ul forTree [(value)]="selected" [(expanded)]="expanded" aria-label="File system">
|
|
102
|
+
@for (n of roots; track n.id) {
|
|
103
|
+
<app-tree-node [node]="n" [expanded]="expanded()" />
|
|
104
|
+
}
|
|
105
|
+
</ul>
|
|
106
|
+
`,
|
|
107
|
+
})
|
|
108
|
+
export class Files {
|
|
109
|
+
readonly selected = signal<readonly string[]>([]);
|
|
110
|
+
readonly expanded = signal<readonly string[]>([]);
|
|
111
|
+
readonly roots: Node[] = [
|
|
112
|
+
{
|
|
113
|
+
id: 'documents',
|
|
114
|
+
name: 'Documents',
|
|
115
|
+
children: [
|
|
116
|
+
{ id: 'resume', name: 'Resume' },
|
|
117
|
+
{ id: 'projects', name: 'Projects', children: [{ id: 'alpha', name: 'Alpha' }] },
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
{ id: 'readme', name: 'Readme' },
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
> **Why not `ngTemplateOutlet`?** A single recursive `<ng-template>` instantiated with `[ngTemplateOutlet]` resolves dependency injection from where the template is **declared**, not where it is inserted — so a nested `[forTreeItem]` would inject the root tree as its container instead of its enclosing `[forTreeGroup]`, breaking `aria-level` and visible-order navigation. The recursive component above avoids this. If you must use `ngTemplateOutlet`, pass an explicit `[ngTemplateOutletInjector]` captured at each insertion point.
|
|
126
|
+
|
|
127
|
+
Mounting is the consumer's responsibility: wrap `[forTreeGroup]` in `@if (expanded().includes(node.id))` so a collapsed parent drops its subtree. A node is treated as a **parent** (and emits `aria-expanded` / `data-state`) only when a `[forTreeItemToggle]` is registered inside it — leaves render no toggle and emit neither, matching the APG "end nodes lack `aria-expanded`" rule.
|
|
128
|
+
|
|
129
|
+
## Multi select
|
|
130
|
+
|
|
131
|
+
```html
|
|
132
|
+
<ul forTree multiple [(value)]="selected" [(expanded)]="expanded" aria-label="Files">
|
|
133
|
+
...
|
|
134
|
+
</ul>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
In multi mode `Space` toggles the focused node; `Shift+ArrowUp/Down` extends; `Shift+Space` selects the contiguous range from the anchor; `Ctrl/Cmd+A` selects every visible enabled node (or clears when all are already selected).
|
|
138
|
+
|
|
139
|
+
## Checkbox selection
|
|
140
|
+
|
|
141
|
+
`selectionMode="checkbox"` switches each `treeitem` to `aria-checked` (instead of `aria-selected`) and makes every node toggle independently — `multiple` is not required. Place `[forTreeItemCheckbox]` and `[forTreeItemCheckboxIndicator]` inside the label for a visible checkbox surface.
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import {
|
|
145
|
+
ForTree,
|
|
146
|
+
ForTreeGroup,
|
|
147
|
+
ForTreeItem,
|
|
148
|
+
ForTreeItemCheckbox,
|
|
149
|
+
ForTreeItemCheckboxIndicator,
|
|
150
|
+
ForTreeItemLabel,
|
|
151
|
+
ForTreeItemToggle,
|
|
152
|
+
} from 'forty-cdk/tree';
|
|
153
|
+
|
|
154
|
+
@Component({
|
|
155
|
+
selector: 'app-tree-node',
|
|
156
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
157
|
+
imports: [
|
|
158
|
+
ForTreeItem,
|
|
159
|
+
ForTreeItemLabel,
|
|
160
|
+
ForTreeItemToggle,
|
|
161
|
+
ForTreeGroup,
|
|
162
|
+
ForTreeItemCheckbox,
|
|
163
|
+
ForTreeItemCheckboxIndicator,
|
|
164
|
+
TreeNode,
|
|
165
|
+
],
|
|
166
|
+
host: { style: 'display: contents' },
|
|
167
|
+
template: `
|
|
168
|
+
<li forTreeItem [value]="node().id">
|
|
169
|
+
<div forTreeItemLabel>
|
|
170
|
+
@if (node().children?.length) {
|
|
171
|
+
<span forTreeItemToggle>▸</span>
|
|
172
|
+
}
|
|
173
|
+
<span forTreeItemCheckbox>
|
|
174
|
+
<span forTreeItemCheckboxIndicator>✓</span>
|
|
175
|
+
</span>
|
|
176
|
+
{{ node().name }}
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
@if (node().children?.length && expanded().includes(node().id)) {
|
|
180
|
+
<ul forTreeGroup>
|
|
181
|
+
@for (child of node().children ?? []; track child.id) {
|
|
182
|
+
<app-tree-node [node]="child" [expanded]="expanded()" />
|
|
183
|
+
}
|
|
184
|
+
</ul>
|
|
185
|
+
}
|
|
186
|
+
</li>
|
|
187
|
+
`,
|
|
188
|
+
})
|
|
189
|
+
export class TreeNode {
|
|
190
|
+
readonly node = input.required<Node>();
|
|
191
|
+
readonly expanded = input.required<readonly string[]>();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
@Component({
|
|
195
|
+
selector: 'app-categories',
|
|
196
|
+
imports: [ForTree, TreeNode],
|
|
197
|
+
template: `
|
|
198
|
+
<ul
|
|
199
|
+
forTree
|
|
200
|
+
selectionMode="checkbox"
|
|
201
|
+
[(value)]="selected"
|
|
202
|
+
[(expanded)]="expanded"
|
|
203
|
+
aria-label="Categories"
|
|
204
|
+
>
|
|
205
|
+
@for (n of roots; track n.id) {
|
|
206
|
+
<app-tree-node [node]="n" [expanded]="expanded()" />
|
|
207
|
+
}
|
|
208
|
+
</ul>
|
|
209
|
+
`,
|
|
210
|
+
})
|
|
211
|
+
export class Categories {
|
|
212
|
+
readonly selected = signal<readonly string[]>([]);
|
|
213
|
+
readonly expanded = signal<readonly string[]>([]);
|
|
214
|
+
readonly roots: Node[] = [
|
|
215
|
+
{ id: 'a', name: 'Alpha' },
|
|
216
|
+
{ id: 'b', name: 'Beta', children: [{ id: 'b1', name: 'Beta 1' }] },
|
|
217
|
+
];
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Cascade selection
|
|
222
|
+
|
|
223
|
+
Add `cascade` and `[descendantsOf]` to enable tri-state propagation. Checking a parent selects it and all its descendants atomically (including collapsed / unmounted ones), and a parent derives `aria-checked="mixed"` / `data-checked="mixed"` when only some descendants are checked. The `descendantsOf` function must return every selectable descendant id of the given node (not just direct children).
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
@Component({
|
|
227
|
+
selector: 'app-categories',
|
|
228
|
+
imports: [ForTree, TreeNode],
|
|
229
|
+
template: `
|
|
230
|
+
<ul
|
|
231
|
+
forTree
|
|
232
|
+
selectionMode="checkbox"
|
|
233
|
+
cascade
|
|
234
|
+
[descendantsOf]="descendantsFn"
|
|
235
|
+
[(value)]="selected"
|
|
236
|
+
[(expanded)]="expanded"
|
|
237
|
+
aria-label="Categories"
|
|
238
|
+
>
|
|
239
|
+
@for (n of roots; track n.id) {
|
|
240
|
+
<app-tree-node [node]="n" [expanded]="expanded()" />
|
|
241
|
+
}
|
|
242
|
+
</ul>
|
|
243
|
+
`,
|
|
244
|
+
})
|
|
245
|
+
export class Categories {
|
|
246
|
+
readonly selected = signal<readonly string[]>([]);
|
|
247
|
+
readonly expanded = signal<readonly string[]>([]);
|
|
248
|
+
readonly roots: Node[] = [
|
|
249
|
+
{
|
|
250
|
+
id: 'fruits',
|
|
251
|
+
name: 'Fruits',
|
|
252
|
+
children: [
|
|
253
|
+
{ id: 'apple', name: 'Apple' },
|
|
254
|
+
{ id: 'pear', name: 'Pear' },
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
readonly descendantsFn = (id: string): readonly string[] => {
|
|
260
|
+
const flatten = (nodes: Node[]): string[] =>
|
|
261
|
+
nodes.flatMap((n) => [n.id, ...flatten(n.children ?? [])]);
|
|
262
|
+
const find = (nodes: Node[]): Node | undefined =>
|
|
263
|
+
nodes.find((n) => n.id === id) ?? nodes.flatMap((n) => find(n.children ?? [])).find(Boolean);
|
|
264
|
+
const node = find(this.roots);
|
|
265
|
+
return node?.children ? flatten(node.children) : [];
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Filtering
|
|
271
|
+
|
|
272
|
+
forty-cdk ships no filtering machinery — matching stays consumer-owned. The library exports one pure helper, `expandToReveal`, that translates the matched set into the ancestor values you need to expand so every match becomes visible.
|
|
273
|
+
|
|
274
|
+
**Three-step recipe:**
|
|
275
|
+
|
|
276
|
+
1. **Filter your own data and re-render.** Derive a filtered node list with `computed()` and drive the tree's `@for` off that signal. The library adds no filtering engine, empty-state pieces, or snapshot logic.
|
|
277
|
+
2. **Expand ancestors with `expandToReveal`.** Call `expandToReveal(matches, ancestorsOf)` to get the unique ancestor values to merge into `[(expanded)]`. The helper is pure — it has no Angular reactivity, no DOM, and no side effects.
|
|
278
|
+
3. **Highlight matched text with consumer CSS.** Wrap matched text in a `<mark>` element or apply a `.match` class while rendering filtered labels. No new data attribute is emitted by the library.
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
import { expandToReveal } from 'forty-cdk/tree';
|
|
282
|
+
|
|
283
|
+
readonly query = signal('');
|
|
284
|
+
readonly filtered = computed(() => filterNodes(this.roots, this.query()));
|
|
285
|
+
|
|
286
|
+
constructor() {
|
|
287
|
+
// Consumer-owned: re-reveal matches whenever the query changes.
|
|
288
|
+
effect(() => {
|
|
289
|
+
const matches = collectIds(this.filtered());
|
|
290
|
+
this.expanded.update((open) => [
|
|
291
|
+
...new Set([...open, ...expandToReveal(matches, this.ancestorsOf)]),
|
|
292
|
+
]);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Returns a node's ancestor ids from the consumer's own hierarchy.
|
|
297
|
+
ancestorsOf = (id: string): readonly string[] => { /* walk roots, return the path */ };
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
`expandToReveal` accepts any `Iterable<string>` (array, `Set`, generator). Root-level matches contribute nothing — a root has no ancestors to expand.
|
|
301
|
+
|
|
302
|
+
## Keyboard
|
|
303
|
+
|
|
304
|
+
Vertical, LTR (mirrored for `dir="rtl"`):
|
|
305
|
+
|
|
306
|
+
| Key | Behavior |
|
|
307
|
+
| ----------------------- | --------------------------------------------------------------------------------------------- |
|
|
308
|
+
| **ArrowDown / ArrowUp** | Move focus to the next / previous visible node (no wrap; collapsed subtrees are skipped). |
|
|
309
|
+
| **ArrowRight** | Closed parent → expand (focus stays); open parent → focus first child; leaf → no-op. |
|
|
310
|
+
| **ArrowLeft** | Open parent → collapse (focus stays); otherwise → focus the parent node; closed root → no-op. |
|
|
311
|
+
| **Home / End** | First / last visible node. |
|
|
312
|
+
| **Enter** | Select / activate the focused node. |
|
|
313
|
+
| **Space** | Single: select. Multi: toggle the focused node's selection. |
|
|
314
|
+
| **\*** | Expand every sibling parent at the focused node's level. |
|
|
315
|
+
| **type a character** | Typeahead: focus the next visible node whose label starts with the buffer. |
|
|
316
|
+
| **Shift+ArrowUp/Down** | Multi: move focus and toggle the new node's selection. |
|
|
317
|
+
| **Shift+Space** | Multi: select the contiguous range from the anchor to the focused node. |
|
|
318
|
+
| **Ctrl/Cmd+A** | Multi: select every visible enabled node (toggles off when all are already selected). |
|
|
319
|
+
|
|
320
|
+
Under `dir="rtl"` the expand / collapse arrows swap: **ArrowLeft** expands and **ArrowRight** collapses.
|
|
321
|
+
|
|
322
|
+
## Scope defaults
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
import { provideForTreeDefaults } from 'forty-cdk/tree';
|
|
326
|
+
|
|
327
|
+
// app config or a component's providers
|
|
328
|
+
providers: [provideForTreeDefaults({ selectionFollowsFocus: true })];
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
## Styling
|
|
332
|
+
|
|
333
|
+
forty-cdk ships no styles. Add your own class to each piece — the for\* selectors are the behavior API, not a styling contract (see [Styling forty-cdk](../../../../../docs/styling.md)). Key your CSS off the reflected data-\* attributes below.
|
|
334
|
+
|
|
335
|
+
### Data attributes
|
|
336
|
+
|
|
337
|
+
| Piece | Attribute | Values |
|
|
338
|
+
| -------------------------------- | ------------------ | ------------------------------------------------------- |
|
|
339
|
+
| `[forTree]` | `data-orientation` | `vertical` \| `horizontal` |
|
|
340
|
+
| `[forTree]` | `data-disabled` | present \| absent |
|
|
341
|
+
| `[forTreeItem]` | `data-state` | `open` \| `closed` (parent items only) |
|
|
342
|
+
| `[forTreeItem]` | `data-selected` | present \| absent |
|
|
343
|
+
| `[forTreeItem]` | `data-highlighted` | present \| absent |
|
|
344
|
+
| `[forTreeItem]` | `data-disabled` | present \| absent |
|
|
345
|
+
| `[forTreeItem]` | `data-checked` | `"true"` \| `"false"` \| `"mixed"` (checkbox mode only) |
|
|
346
|
+
| `[forTreeItemToggle]` | `data-state` | `open` \| `closed` |
|
|
347
|
+
| `[forTreeItemCheckbox]` | `data-state` | `checked` \| `unchecked` \| `indeterminate` |
|
|
348
|
+
| `[forTreeItemCheckboxIndicator]` | `data-state` | `checked` \| `unchecked` \| `indeterminate` |
|
|
349
|
+
|
|
350
|
+
A `[forTreeItem]` emits `data-state` only when it is a parent (a `[forTreeItemToggle]` is registered inside it); leaves carry neither `data-state` nor `aria-expanded`. Expansion (`data-state`) and selection (`data-selected`) are independent hooks because a node can be both expandable and selected at once.
|
|
351
|
+
|
|
352
|
+
```css
|
|
353
|
+
.tree-toggle {
|
|
354
|
+
display: inline-block;
|
|
355
|
+
transition: transform 150ms;
|
|
356
|
+
}
|
|
357
|
+
.tree-toggle[data-state='open'] {
|
|
358
|
+
transform: rotate(90deg);
|
|
359
|
+
}
|
|
360
|
+
.tree-item[data-highlighted] {
|
|
361
|
+
outline: 2px solid Highlight;
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
## Accessibility notes
|
|
366
|
+
|
|
367
|
+
- **Label the tree** via the reactive `[ariaLabel]` input or a native `aria-labelledby` pointing at a visible heading.
|
|
368
|
+
- **`data-state="open" | "closed"`** is reflected on parent nodes only (and on the toggle); leaves carry neither, matching `aria-expanded`.
|
|
369
|
+
- **`data-selected`** (present / absent) reflects selection on every node — a node is simultaneously expandable and selectable, so expansion (`data-state`) and selection (`data-selected`) get separate hooks.
|
|
370
|
+
- **`data-highlighted=""`** marks the current roving-tabindex node, the same hook used across the listbox / menu / select primitives.
|
|
371
|
+
- **Exactly one node is tabbable** at a time (the selected node, or the first enabled node). `Tab` enters and leaves the whole tree in one stop.
|
|
372
|
+
- **In `selectionMode="checkbox"`** each `treeitem` emits `aria-checked` (`"true"` / `"false"`) and no `aria-selected`; the `[forTreeItemCheckbox]` and `[forTreeItemCheckboxIndicator]` are `aria-hidden` / decorative — the `treeitem` itself is the accessible checkbox. With `cascade`, a parent reports `aria-checked="mixed"` (and `data-checked="mixed"`) when only some of its descendants are checked; the cascade reaches collapsed / unmounted descendants through the `descendantsOf` descriptor, so the tri-state is always correct even when children are not yet mounted.
|
|
373
|
+
|
|
374
|
+
## Virtualization
|
|
375
|
+
|
|
376
|
+
For very large trees (thousands of nodes) bind `[totalCount]` to switch to an **activedescendant focus model** over a consumer-owned virtualized window.
|
|
377
|
+
|
|
378
|
+
### Opt-in API
|
|
379
|
+
|
|
380
|
+
#### `ForTree` additions
|
|
381
|
+
|
|
382
|
+
| API | Type | Description |
|
|
383
|
+
| --------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
384
|
+
| `totalCount` | `input<number \| undefined>` | Total flattened node count. Setting this switches the tree to the activedescendant focus model. Leave unset for roving-tabindex. |
|
|
385
|
+
| `visibleRange` | `input<readonly [number, number] \| undefined>` | Inclusive-exclusive `[start, end)` index range of the currently rendered nodes. Provided by `injectVirtualizer`. |
|
|
386
|
+
| `scrollToIndex` | `output<number>` | Emitted when keyboard navigation reaches a node outside the rendered window. Forward to `injectVirtualizer`'s `scrollToIndex`. |
|
|
387
|
+
|
|
388
|
+
#### `ForTreeItem` additions (virtualized path only)
|
|
389
|
+
|
|
390
|
+
| API | Type | Description |
|
|
391
|
+
| ----------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
392
|
+
| `itemIndex` | `input<number \| null>` | Zero-based absolute index in the flattened node list. **Required** in the virtualized path. Leave unset (default `null`) outside the virtualized path. |
|
|
393
|
+
| `level` | `input<number \| null>` | Tree depth of this node (1-based). Overrides the container-derived `aria-level` in the virtualized path. |
|
|
394
|
+
| `setSize` | `input<number \| null>` | Total siblings at this node's level. Overrides the container-derived `aria-setsize` in the virtualized path. |
|
|
395
|
+
| `posInSet` | `input<number \| null>` | 1-based position among siblings (matches `aria-posinset`). Overrides the container-derived value in the virtualized path. |
|
|
396
|
+
|
|
397
|
+
**Naming note:** `[posInSet]` is the per-level `aria-posinset` (position among siblings at this level, 1-based). It is **not** the absolute flat index — that is `[itemIndex]`. This matches the ARIA attribute name and is intentionally different from how some other APIs name it.
|
|
398
|
+
|
|
399
|
+
### Focus-model switch
|
|
400
|
+
|
|
401
|
+
| Mode | Tree host tabindex | Item tabindex | Focus mechanism |
|
|
402
|
+
| --------------------------------- | ------------------ | --------------- | ----------------------------------- |
|
|
403
|
+
| Standard (no `totalCount`) | none | `0` on one item | DOM focus rides the item (roving) |
|
|
404
|
+
| Virtualized (`totalCount` is set) | `0` | `-1` always | `aria-activedescendant` on the host |
|
|
405
|
+
|
|
406
|
+
### Navigation flow
|
|
407
|
+
|
|
408
|
+
1. Consumer flattens their visible tree into a flat list, computing `level`, `setSize`, `posInSet`, and `itemIndex` for each node (using the true sibling totals — off-window siblings contribute their real counts because the consumer knows them).
|
|
409
|
+
2. `injectVirtualizer({ count: flatCount, estimateSize, scrollElement })` drives the render window.
|
|
410
|
+
3. The tree host receives `(scrollToIndex)` when keyboard navigation needs a node outside the window; the consumer forwards the index to `v.scrollToIndex(idx, { align: 'auto' })`.
|
|
411
|
+
4. Once the target node mounts (carrying the requested `[itemIndex]`), the bridge effect resolves the pending activedescendant.
|
|
412
|
+
|
|
413
|
+
### Consumer example
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
import {
|
|
417
|
+
ChangeDetectionStrategy,
|
|
418
|
+
Component,
|
|
419
|
+
ElementRef,
|
|
420
|
+
computed,
|
|
421
|
+
signal,
|
|
422
|
+
viewChild,
|
|
423
|
+
} from '@angular/core';
|
|
424
|
+
import { ForTree, ForTreeItem, ForTreeItemLabel, ForTreeItemToggle } from 'forty-cdk/tree';
|
|
425
|
+
import { injectVirtualizer } from 'forty-cdk/virtualization';
|
|
426
|
+
|
|
427
|
+
interface TreeNode {
|
|
428
|
+
value: string;
|
|
429
|
+
label: string;
|
|
430
|
+
children?: TreeNode[];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
interface FlatNode {
|
|
434
|
+
value: string;
|
|
435
|
+
label: string;
|
|
436
|
+
level: number;
|
|
437
|
+
setSize: number;
|
|
438
|
+
posInSet: number;
|
|
439
|
+
itemIndex: number;
|
|
440
|
+
expandable: boolean;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function flatten(nodes: TreeNode[], expanded: ReadonlySet<string>, level = 1): FlatNode[] {
|
|
444
|
+
const result: FlatNode[] = [];
|
|
445
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
446
|
+
const node = nodes[i]!;
|
|
447
|
+
result.push({
|
|
448
|
+
value: node.value,
|
|
449
|
+
label: node.label,
|
|
450
|
+
level,
|
|
451
|
+
setSize: nodes.length,
|
|
452
|
+
posInSet: i + 1,
|
|
453
|
+
itemIndex: result.length,
|
|
454
|
+
expandable: !!node.children?.length,
|
|
455
|
+
});
|
|
456
|
+
if (node.children?.length && expanded.has(node.value)) {
|
|
457
|
+
result.push(...flatten(node.children, expanded, level + 1));
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return result;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
@Component({
|
|
464
|
+
selector: 'app-virtual-tree',
|
|
465
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
466
|
+
imports: [ForTree, ForTreeItem, ForTreeItemLabel, ForTreeItemToggle],
|
|
467
|
+
template: `
|
|
468
|
+
<ul
|
|
469
|
+
forTree
|
|
470
|
+
#scroll
|
|
471
|
+
aria-label="Files"
|
|
472
|
+
[(value)]="selected"
|
|
473
|
+
[(expanded)]="expanded"
|
|
474
|
+
[totalCount]="flat().length"
|
|
475
|
+
[visibleRange]="v.range()"
|
|
476
|
+
(scrollToIndex)="v.scrollToIndex($event, { align: 'auto' })"
|
|
477
|
+
style="overflow: auto; max-height: 400px; position: relative;"
|
|
478
|
+
>
|
|
479
|
+
<div [style.height.px]="v.totalSize()" style="position: relative">
|
|
480
|
+
@for (vi of v.virtualItems(); track vi.key) {
|
|
481
|
+
<li
|
|
482
|
+
forTreeItem
|
|
483
|
+
[value]="flat()[vi.index]!.value"
|
|
484
|
+
[level]="flat()[vi.index]!.level"
|
|
485
|
+
[setSize]="flat()[vi.index]!.setSize"
|
|
486
|
+
[posInSet]="flat()[vi.index]!.posInSet"
|
|
487
|
+
[itemIndex]="vi.index"
|
|
488
|
+
[style.transform]="'translateY(' + vi.start + 'px)'"
|
|
489
|
+
style="position: absolute; left: 0; right: 0;"
|
|
490
|
+
>
|
|
491
|
+
@if (flat()[vi.index]!.expandable) {
|
|
492
|
+
<span forTreeItemToggle>▸</span>
|
|
493
|
+
}
|
|
494
|
+
<div forTreeItemLabel>{{ flat()[vi.index]!.label }}</div>
|
|
495
|
+
</li>
|
|
496
|
+
}
|
|
497
|
+
</div>
|
|
498
|
+
</ul>
|
|
499
|
+
`,
|
|
500
|
+
})
|
|
501
|
+
export class VirtualTree {
|
|
502
|
+
readonly selected = signal<readonly string[]>([]);
|
|
503
|
+
readonly expanded = signal<readonly string[]>([]);
|
|
504
|
+
|
|
505
|
+
readonly roots: TreeNode[] = [
|
|
506
|
+
/* large tree data */
|
|
507
|
+
];
|
|
508
|
+
|
|
509
|
+
readonly flat = computed(() => flatten(this.roots, new Set(this.expanded())));
|
|
510
|
+
|
|
511
|
+
private readonly scrollRef = viewChild<ElementRef<HTMLElement>>('scroll');
|
|
512
|
+
private readonly scrollElement = computed(() => this.scrollRef()?.nativeElement ?? null);
|
|
513
|
+
|
|
514
|
+
readonly v = injectVirtualizer({
|
|
515
|
+
count: computed(() => this.flat().length),
|
|
516
|
+
estimateSize: () => 32,
|
|
517
|
+
scrollElement: this.scrollElement,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
### Intentional limitations
|
|
523
|
+
|
|
524
|
+
The following behaviors are unavailable in the virtualized path and are documented intentional limitations (same as listbox/select virtualization):
|
|
525
|
+
|
|
526
|
+
- **Multi-select range modifiers** (Shift+ArrowUp/Down, Shift+Space, Ctrl/Cmd+A) are dropped. Range selection requires knowing the full list of enabled nodes in the range, which is not available when the list is partially unmounted. Provide a custom selection UI (checkboxes with `selectionMode="checkbox"`) for multi-select over large trees.
|
|
527
|
+
- **Cross-window typeahead** only matches within the currently rendered window. Typeahead over unmounted nodes is not supported.
|
|
528
|
+
- **`*` (expand-all-siblings)** is dropped. It requires knowing all siblings at the focused node's level, including those outside the window.
|
|
529
|
+
|
|
530
|
+
## Drag & drop
|
|
531
|
+
|
|
532
|
+
Add `[forTreeNodeDrag]` on the same element as `[forTree]` to enable pointer and keyboard drag reordering and re-parenting.
|
|
533
|
+
|
|
534
|
+
### Pieces
|
|
535
|
+
|
|
536
|
+
| Class | Selector | Description |
|
|
537
|
+
| ----------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
538
|
+
| `ForTreeNodeDrag` | `[forTreeNodeDrag]` | Root coordinator. Apply on the same element as `[forTree]`. |
|
|
539
|
+
| `ForTreeNodeDragHandle` | `[forTreeNodeDragHandle]` | Optional grab-area constraint inside an item. When present, pointer drags start only from within it. |
|
|
540
|
+
|
|
541
|
+
### Inputs / outputs
|
|
542
|
+
|
|
543
|
+
| API | Type | Description |
|
|
544
|
+
| ---------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
545
|
+
| `disabled` | `input<boolean>` | Disables all drag interactions. Default `false`. |
|
|
546
|
+
| `canDrop` | `input<(event: ForTreeDragDropEvent) => boolean>` | Optional veto callback. Return `false` to reject a specific move. When omitted, all drops are accepted. |
|
|
547
|
+
| `nodeDrop` | `output<ForTreeDragDropEvent>` | Emitted once per committed move. Apply `moveTreeNode` in the handler to update your data. |
|
|
548
|
+
|
|
549
|
+
### Keyboard interaction
|
|
550
|
+
|
|
551
|
+
| Key | Behavior while **not** lifted | Behavior while **lifted** |
|
|
552
|
+
| ----------------- | ----------------------------- | ---------------------------------------------------------------- |
|
|
553
|
+
| `Ctrl/Cmd+Space` | Lifts the focused node. | — |
|
|
554
|
+
| `ArrowDown` | Normal tree navigation. | Moves the insertion point one row down. |
|
|
555
|
+
| `ArrowUp` | Normal tree navigation. | Moves the insertion point one row up. |
|
|
556
|
+
| `ArrowRight` | Normal expand / enter. | Deepens the target level by 1 (LTR; reversed under RTL). |
|
|
557
|
+
| `ArrowLeft` | Normal collapse / leave. | Shallows the target level by 1 (LTR; reversed under RTL). |
|
|
558
|
+
| `Space` / `Enter` | Normal select / activate. | Drops the node at the current resolved position. |
|
|
559
|
+
| `Escape` | — | Cancels the drag; the node is returned to its original position. |
|
|
560
|
+
| `Tab` | Normal focus leave. | Cancels the drag. |
|
|
561
|
+
|
|
562
|
+
### Minimal example
|
|
563
|
+
|
|
564
|
+
```ts
|
|
565
|
+
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
|
566
|
+
import {
|
|
567
|
+
ForTree,
|
|
568
|
+
type ForTreeDragDropEvent,
|
|
569
|
+
ForTreeGroup,
|
|
570
|
+
ForTreeItem,
|
|
571
|
+
ForTreeItemLabel,
|
|
572
|
+
ForTreeItemToggle,
|
|
573
|
+
ForTreeNodeDrag,
|
|
574
|
+
ForTreeNodeDragHandle,
|
|
575
|
+
moveTreeNode,
|
|
576
|
+
} from 'forty-cdk/tree';
|
|
577
|
+
|
|
578
|
+
interface Node {
|
|
579
|
+
id: string;
|
|
580
|
+
name: string;
|
|
581
|
+
children?: Node[];
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
@Component({
|
|
585
|
+
selector: 'app-tree-node',
|
|
586
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
587
|
+
imports: [
|
|
588
|
+
ForTreeItem,
|
|
589
|
+
ForTreeItemLabel,
|
|
590
|
+
ForTreeItemToggle,
|
|
591
|
+
ForTreeGroup,
|
|
592
|
+
ForTreeNodeDragHandle,
|
|
593
|
+
TreeNode,
|
|
594
|
+
],
|
|
595
|
+
host: { style: 'display: contents' },
|
|
596
|
+
template: `
|
|
597
|
+
<li forTreeItem [value]="node().id">
|
|
598
|
+
<div forTreeItemLabel>
|
|
599
|
+
<span forTreeNodeDragHandle aria-hidden="true">⠿</span>
|
|
600
|
+
@if (node().children?.length) {
|
|
601
|
+
<span forTreeItemToggle>▸</span>
|
|
602
|
+
}
|
|
603
|
+
{{ node().name }}
|
|
604
|
+
</div>
|
|
605
|
+
@if (node().children?.length && expanded().includes(node().id)) {
|
|
606
|
+
<ul forTreeGroup>
|
|
607
|
+
@for (child of node().children ?? []; track child.id) {
|
|
608
|
+
<app-tree-node [node]="child" [expanded]="expanded()" />
|
|
609
|
+
}
|
|
610
|
+
</ul>
|
|
611
|
+
}
|
|
612
|
+
</li>
|
|
613
|
+
`,
|
|
614
|
+
})
|
|
615
|
+
export class TreeNode {
|
|
616
|
+
readonly node = input.required<Node>();
|
|
617
|
+
readonly expanded = input.required<readonly string[]>();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
@Component({
|
|
621
|
+
selector: 'app-files',
|
|
622
|
+
imports: [ForTree, ForTreeNodeDrag, TreeNode],
|
|
623
|
+
template: `
|
|
624
|
+
<ul
|
|
625
|
+
forTree
|
|
626
|
+
forTreeNodeDrag
|
|
627
|
+
[(value)]="selected"
|
|
628
|
+
[(expanded)]="expanded"
|
|
629
|
+
[canDrop]="canDrop"
|
|
630
|
+
(nodeDrop)="onDrop($event)"
|
|
631
|
+
aria-label="File system"
|
|
632
|
+
>
|
|
633
|
+
@for (n of roots(); track n.id) {
|
|
634
|
+
<app-tree-node [node]="n" [expanded]="expanded()" />
|
|
635
|
+
}
|
|
636
|
+
</ul>
|
|
637
|
+
`,
|
|
638
|
+
})
|
|
639
|
+
export class Files {
|
|
640
|
+
readonly selected = signal<readonly string[]>([]);
|
|
641
|
+
readonly expanded = signal<readonly string[]>([]);
|
|
642
|
+
readonly roots = signal<Node[]>([
|
|
643
|
+
{
|
|
644
|
+
id: 'documents',
|
|
645
|
+
name: 'Documents',
|
|
646
|
+
children: [
|
|
647
|
+
{ id: 'resume', name: 'Resume' },
|
|
648
|
+
{ id: 'projects', name: 'Projects', children: [{ id: 'alpha', name: 'Alpha' }] },
|
|
649
|
+
],
|
|
650
|
+
},
|
|
651
|
+
{ id: 'readme', name: 'Readme' },
|
|
652
|
+
]);
|
|
653
|
+
|
|
654
|
+
readonly canDrop = (event: ForTreeDragDropEvent): boolean => {
|
|
655
|
+
return event.newParent !== event.node;
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
onDrop(event: ForTreeDragDropEvent): void {
|
|
659
|
+
this.roots.update((r) =>
|
|
660
|
+
moveTreeNode(r, {
|
|
661
|
+
event,
|
|
662
|
+
trackBy: (n) => n.id,
|
|
663
|
+
children: (n) => n.children,
|
|
664
|
+
withChildren: (n, children) => ({ ...n, children: children as Node[] }),
|
|
665
|
+
}),
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
### Data attributes on `[forTreeNodeDrag]`
|
|
672
|
+
|
|
673
|
+
| Attribute | Values | When present |
|
|
674
|
+
| ----------------------- | ------------ | --------------------------------------------- |
|
|
675
|
+
| `data-dragging` | `""` (empty) | A drag session is live (pointer or keyboard). |
|
|
676
|
+
| `data-drop-target` | `""` (empty) | A valid drop target has been resolved. |
|
|
677
|
+
| `--for-tree-drop-level` | integer 1–N | The resolved depth of the current target. |
|
|
678
|
+
|
|
679
|
+
### Drop indicator
|
|
680
|
+
|
|
681
|
+
While a drag is live, the single `[forTreeItem]` the lifted node would land beside reflects **`data-drop-position`** so you can draw an indented insertion line. Exactly one visible item carries it at a time; it is absent on every other row and whenever the tree is idle. It tracks every pointer move and every Arrow step (in both LTR and RTL) and clears on drop / cancel / Escape / Tab.
|
|
682
|
+
|
|
683
|
+
| Piece | Attribute | Values |
|
|
684
|
+
| --------------- | -------------------- | ------------------------------------------------------------- |
|
|
685
|
+
| `[forTreeItem]` | `data-drop-position` | `"before"` \| `"after"` (the line sits above / below the row) |
|
|
686
|
+
|
|
687
|
+
Pair it with the root's `--for-tree-drop-level` (the resolved depth) to indent the line to the target level:
|
|
688
|
+
|
|
689
|
+
```css
|
|
690
|
+
[forTreeItem][data-drop-position]::after {
|
|
691
|
+
content: '';
|
|
692
|
+
position: absolute;
|
|
693
|
+
left: calc(var(--for-tree-drop-level, 1) * 1rem);
|
|
694
|
+
right: 0;
|
|
695
|
+
height: 2px;
|
|
696
|
+
background: var(--accent);
|
|
697
|
+
}
|
|
698
|
+
[forTreeItem][data-drop-position='before']::after {
|
|
699
|
+
top: 0;
|
|
700
|
+
}
|
|
701
|
+
[forTreeItem][data-drop-position='after']::after {
|
|
702
|
+
bottom: 0;
|
|
703
|
+
}
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
Advanced consumers can read the same resolved position programmatically: `[forTreeNodeDrag]` exposes a read-only `dropIndicator: Signal<ForTreeDropIndicator | null>` on `ForTreeNodeDragContext` (`{ anchor, position, level }`, `null` when idle).
|
|
707
|
+
|
|
708
|
+
On lift the dragged node's subtree is collapsed (and restored on drop / cancel). This keeps the drop geometry tractable and structurally prevents dropping a node into its own descendant; `[canDrop]` adds consumer-defined vetoes on top.
|