@neuravision/construct 1.1.6 → 1.2.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.
@@ -50,3 +50,4 @@
50
50
  @import './app-shell-v2.css';
51
51
  @import './_shell-shared.css';
52
52
  @import './list.css';
53
+ @import './tree.css';
@@ -0,0 +1,328 @@
1
+ /* ── Tree (WAI-ARIA Tree View) ─────────────────────── */
2
+
3
+ .ct-tree {
4
+ --ct-tree-indent: 1.5rem;
5
+ --ct-tree-row-height: 2rem;
6
+ --ct-tree-row-padding-y: var(--space-2);
7
+ --ct-tree-row-padding-x: var(--space-4);
8
+ --ct-tree-row-gap: var(--space-3);
9
+ --ct-tree-row-radius: var(--radius-sm);
10
+ --ct-tree-row-bg: transparent;
11
+ --ct-tree-row-bg-hover: var(--color-bg-muted);
12
+ --ct-tree-row-bg-selected: color-mix(in srgb, var(--color-brand-primary) 14%, transparent);
13
+ --ct-tree-row-color: var(--color-text-primary);
14
+ --ct-tree-row-color-selected: var(--color-brand-primary);
15
+
16
+ --ct-tree-guide-color: var(--color-border-subtle);
17
+ --ct-tree-guide-width: 1px;
18
+
19
+ --ct-tree-toggle-size: 1rem;
20
+ --ct-tree-toggle-color: var(--color-text-muted);
21
+ --ct-tree-toggle-color-hover: var(--color-text-primary);
22
+ --ct-tree-toggle-radius: var(--radius-xs);
23
+
24
+ --ct-tree-orphan-color: var(--color-state-warning-text);
25
+ --ct-tree-orphan-bg: var(--color-state-warning-surface);
26
+ --ct-tree-orphan-border: var(--color-state-warning-border);
27
+
28
+ --ct-tree-actions-opacity: 0;
29
+
30
+ display: block;
31
+ margin: 0;
32
+ padding: 0;
33
+ list-style: none;
34
+ font-size: var(--font-size-sm);
35
+ font-family: var(--font-family-body);
36
+ color: var(--ct-tree-row-color);
37
+ }
38
+
39
+ .ct-tree__group {
40
+ display: block;
41
+ margin: 0;
42
+ padding: 0;
43
+ list-style: none;
44
+ position: relative;
45
+ }
46
+
47
+ .ct-tree__node {
48
+ display: block;
49
+ position: relative;
50
+ margin: 0;
51
+ padding: 0;
52
+ }
53
+
54
+ .ct-tree__node[aria-expanded='false'] > .ct-tree__group {
55
+ display: none;
56
+ }
57
+
58
+ /* ── Row (focus + click target) ── */
59
+
60
+ .ct-tree__row {
61
+ display: flex;
62
+ align-items: center;
63
+ gap: var(--ct-tree-row-gap);
64
+ min-height: var(--ct-tree-row-height);
65
+ padding-block: var(--ct-tree-row-padding-y);
66
+ padding-inline-end: var(--ct-tree-row-padding-x);
67
+ /* Indent: --ct-level is 1-based and set inline by consumers */
68
+ padding-inline-start: calc(
69
+ var(--ct-tree-row-padding-x) +
70
+ var(--ct-tree-indent) * (var(--ct-level, 1) - 1)
71
+ );
72
+ border-radius: var(--ct-tree-row-radius);
73
+ background: var(--ct-tree-row-bg);
74
+ color: inherit;
75
+ cursor: pointer;
76
+ user-select: none;
77
+ transition: background var(--duration-fast) var(--easing-standard),
78
+ color var(--duration-fast) var(--easing-standard);
79
+ }
80
+
81
+ @media (hover: hover) {
82
+ .ct-tree__row:hover {
83
+ background: var(--ct-tree-row-bg-hover);
84
+ }
85
+
86
+ .ct-tree__row:hover .ct-tree__actions {
87
+ --ct-tree-actions-opacity: 1;
88
+ }
89
+ }
90
+
91
+ /* Focus lives on the `<li role="treeitem">`; outline is rendered on the row */
92
+ .ct-tree__node:focus {
93
+ outline: none;
94
+ }
95
+
96
+ .ct-tree__node:focus-visible > .ct-tree__row {
97
+ outline: 2px solid var(--color-focus-ring);
98
+ outline-offset: -2px;
99
+ }
100
+
101
+ /* ── Toggle (chevron) ── */
102
+
103
+ .ct-tree__toggle,
104
+ .ct-tree__spacer {
105
+ flex-shrink: 0;
106
+ inline-size: var(--ct-tree-toggle-size);
107
+ block-size: var(--ct-tree-toggle-size);
108
+ display: inline-flex;
109
+ align-items: center;
110
+ justify-content: center;
111
+ }
112
+
113
+ .ct-tree__toggle {
114
+ padding: 0;
115
+ margin: 0;
116
+ background: transparent;
117
+ border: none;
118
+ border-radius: var(--ct-tree-toggle-radius);
119
+ color: var(--ct-tree-toggle-color);
120
+ cursor: pointer;
121
+ transition: color var(--duration-fast) var(--easing-standard),
122
+ background var(--duration-fast) var(--easing-standard);
123
+ }
124
+
125
+ @media (hover: hover) {
126
+ .ct-tree__toggle:hover {
127
+ color: var(--ct-tree-toggle-color-hover);
128
+ }
129
+ }
130
+
131
+ .ct-tree__chevron {
132
+ inline-size: 0.75rem;
133
+ block-size: 0.75rem;
134
+ transition: transform var(--duration-fast) var(--easing-standard);
135
+ }
136
+
137
+ .ct-tree__node[aria-expanded='true'] > .ct-tree__row .ct-tree__chevron {
138
+ transform: rotate(90deg);
139
+ }
140
+
141
+ [dir='rtl'] .ct-tree__chevron {
142
+ transform: rotate(180deg);
143
+ }
144
+
145
+ [dir='rtl'] .ct-tree__node[aria-expanded='true'] > .ct-tree__row .ct-tree__chevron {
146
+ transform: rotate(90deg);
147
+ }
148
+
149
+ /* ── Content / actions slots ── */
150
+
151
+ .ct-tree__content {
152
+ display: flex;
153
+ align-items: center;
154
+ gap: var(--space-3);
155
+ min-inline-size: 0;
156
+ flex: 1;
157
+ overflow: hidden;
158
+ text-overflow: ellipsis;
159
+ white-space: nowrap;
160
+ }
161
+
162
+ .ct-tree__content > svg {
163
+ flex-shrink: 0;
164
+ inline-size: var(--icon-sm);
165
+ block-size: var(--icon-sm);
166
+ color: var(--color-text-secondary);
167
+ }
168
+
169
+ .ct-tree__label {
170
+ overflow: hidden;
171
+ text-overflow: ellipsis;
172
+ white-space: nowrap;
173
+ }
174
+
175
+ .ct-tree__label mark {
176
+ background: color-mix(in srgb, var(--color-brand-primary) 22%, transparent);
177
+ color: inherit;
178
+ border-radius: 2px;
179
+ padding-inline: 1px;
180
+ }
181
+
182
+ .ct-tree__actions {
183
+ display: inline-flex;
184
+ align-items: center;
185
+ gap: var(--space-2);
186
+ flex-shrink: 0;
187
+ margin-inline-start: auto;
188
+ opacity: var(--ct-tree-actions-opacity);
189
+ transition: opacity var(--duration-fast) var(--easing-standard);
190
+ }
191
+
192
+ .ct-tree__node:focus-visible > .ct-tree__row .ct-tree__actions,
193
+ .ct-tree__row:focus-within .ct-tree__actions {
194
+ --ct-tree-actions-opacity: 1;
195
+ }
196
+
197
+ /* ── Selection ── */
198
+
199
+ .ct-tree__node[aria-selected='true'] > .ct-tree__row,
200
+ .ct-tree__node--selected > .ct-tree__row {
201
+ background: var(--ct-tree-row-bg-selected);
202
+ color: var(--ct-tree-row-color-selected);
203
+ font-weight: var(--font-weight-semibold);
204
+ }
205
+
206
+ .ct-tree__node[aria-selected='true'] > .ct-tree__row .ct-tree__content > svg,
207
+ .ct-tree__node--selected > .ct-tree__row .ct-tree__content > svg {
208
+ color: var(--ct-tree-row-color-selected);
209
+ }
210
+
211
+ /* ── Disabled ── */
212
+
213
+ .ct-tree__node[aria-disabled='true'] > .ct-tree__row {
214
+ opacity: var(--opacity-disabled);
215
+ cursor: not-allowed;
216
+ }
217
+
218
+ .ct-tree__node[aria-disabled='true'] > .ct-tree__row:hover {
219
+ background: var(--ct-tree-row-bg);
220
+ }
221
+
222
+ /* ── Async loading (aria-busy on the `<li role="treeitem">`) ── */
223
+
224
+ .ct-tree__node[aria-busy='true'] > .ct-tree__row .ct-tree__chevron {
225
+ animation: ct-spin var(--duration-xslow) linear infinite;
226
+ transform-origin: center;
227
+ }
228
+
229
+ .ct-tree__node[aria-busy='true'] > .ct-tree__row .ct-tree__toggle {
230
+ pointer-events: none;
231
+ }
232
+
233
+ /* ── Orphan state ── */
234
+
235
+ .ct-tree__node--orphan > .ct-tree__row {
236
+ --ct-tree-row-bg: var(--ct-tree-orphan-bg);
237
+ border: 1px dashed var(--ct-tree-orphan-border);
238
+ color: var(--ct-tree-orphan-color);
239
+ }
240
+
241
+ .ct-tree__node--orphan > .ct-tree__row .ct-tree__content > svg {
242
+ color: var(--ct-tree-orphan-color);
243
+ }
244
+
245
+ /* ── Modifier: indent guides ── */
246
+
247
+ .ct-tree--guides .ct-tree__group::before {
248
+ content: '';
249
+ position: absolute;
250
+ inset-block: 0;
251
+ /* Anchor guide under parent's chevron centre */
252
+ inset-inline-start: calc(
253
+ var(--ct-tree-row-padding-x) +
254
+ var(--ct-tree-indent) * (var(--ct-parent-level, 0)) +
255
+ var(--ct-tree-toggle-size) / 2
256
+ );
257
+ inline-size: var(--ct-tree-guide-width);
258
+ background: var(--ct-tree-guide-color);
259
+ pointer-events: none;
260
+ }
261
+
262
+ /* ── Modifier: dense ── */
263
+
264
+ .ct-tree--dense {
265
+ --ct-tree-row-height: 1.5rem;
266
+ --ct-tree-row-padding-y: var(--space-1);
267
+ --ct-tree-row-gap: var(--space-2);
268
+ font-size: var(--font-size-xs);
269
+ }
270
+
271
+ /* ── Modifier: bordered (optional surface variant) ── */
272
+
273
+ .ct-tree--bordered {
274
+ border: var(--border-thin) solid var(--color-border-subtle);
275
+ border-radius: var(--radius-md);
276
+ padding: var(--space-2);
277
+ background: var(--color-bg-elevated);
278
+ }
279
+
280
+ /* ── Reduced motion ── */
281
+
282
+ @media (prefers-reduced-motion: reduce) {
283
+ .ct-tree__row,
284
+ .ct-tree__chevron,
285
+ .ct-tree__toggle,
286
+ .ct-tree__actions {
287
+ transition: none;
288
+ }
289
+
290
+ .ct-tree__node[aria-busy='true'] > .ct-tree__row .ct-tree__chevron {
291
+ animation: none;
292
+ }
293
+ }
294
+
295
+ /* ── Forced colors / High-contrast ── */
296
+
297
+ @media (forced-colors: active) {
298
+ .ct-tree__row {
299
+ border: 1px solid transparent;
300
+ }
301
+
302
+ .ct-tree__node:focus-visible > .ct-tree__row {
303
+ outline: 2px solid Highlight;
304
+ outline-offset: -2px;
305
+ }
306
+
307
+ .ct-tree__node[aria-selected='true'] > .ct-tree__row,
308
+ .ct-tree__node--selected > .ct-tree__row {
309
+ border: 2px solid Highlight;
310
+ background: Canvas;
311
+ color: HighlightText;
312
+ forced-color-adjust: none;
313
+ }
314
+
315
+ .ct-tree--guides .ct-tree__group::before {
316
+ background: CanvasText;
317
+ }
318
+
319
+ .ct-tree__node--orphan > .ct-tree__row {
320
+ border: 1px dashed Mark;
321
+ color: Mark;
322
+ }
323
+
324
+ .ct-tree__chevron,
325
+ .ct-tree__content > svg {
326
+ forced-color-adjust: auto;
327
+ }
328
+ }
@@ -82,6 +82,65 @@ A consistent, accessible, professional design system for modern web applications
82
82
  - **Close**: On blur and Esc
83
83
  - **Role**: `role="tooltip"` with `aria-describedby`
84
84
 
85
+ ### Tree (`ct-tree`)
86
+
87
+ Hierarchical disclosure following the [WAI-ARIA Tree View pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/). Use it for n-level nested data such as file explorers, organisation hierarchies, or category trees.
88
+
89
+ **Roles**
90
+ - Container: `<ul role="tree">` with an `aria-label` or `aria-labelledby` reference
91
+ - Node: `<li role="treeitem">` with `aria-level` (1-based), `aria-setsize`, `aria-posinset`
92
+ - Children container: `<ul role="group">`
93
+ - Expandable nodes carry `aria-expanded="true|false"`. Leaf nodes omit the attribute.
94
+
95
+ **Indent**
96
+ Set `--ct-level` inline on each `.ct-tree__row` (matches the node's `aria-level`). The bundled JS controller used in Storybook fills it in automatically; framework wrappers should generate it during render.
97
+
98
+ **Keyboard**
99
+ - `↑` / `↓`: focus previous / next visible row
100
+ - `→`: if collapsed, expand. If expanded, focus first child. Leaf: nothing.
101
+ - `←`: if expanded, collapse. Otherwise, focus parent row.
102
+ - `Home` / `End`: focus first / last visible row
103
+ - `Enter`: activate (consumer-defined; emit `ct-tree:activate`)
104
+ - `Space`: toggle selection (multi/single) or activate (no selection)
105
+ - `*`: expand all siblings on the same level
106
+ - Type-ahead (`A`–`Z`): focus next row whose label starts with the typed prefix; buffer resets after 500 ms
107
+
108
+ **Roving tabindex**
109
+ Exactly one `<li role="treeitem">` carries `tabindex="0"`. All others carry `tabindex="-1"`. Arrow keys move focus — and the `tabindex` — along with them. The `tabindex` lives on the element with the `treeitem` role, never on the inner `.ct-tree__row` `<div>`: a focused `<div>` without a role would orphan the screen-reader announcement of level, posinset, expanded and selected state.
110
+
111
+ **Toggle / chevron**
112
+ The `.ct-tree__toggle` is a non-focusable `<span aria-hidden="true">`. Expand/collapse is reachable via keyboard through `←`/`→` on the row and via mouse through clicking the chevron. We deliberately avoid making the toggle a `<button>`: a focusable button inside a focusable row violates `aria-hidden-focus` / `nested-interactive`, and the row already provides the keyboard affordance.
113
+
114
+ **Row actions**
115
+ Buttons in the `.ct-tree__actions` slot live inside the focusable treeitem. Give them `tabindex="-1"` so the tree exposes a single Tab stop, as required by the WAI-ARIA Tree View pattern. Reach them via mouse, or expose a row-level action hotkey from the consuming framework.
116
+
117
+ **Selection**
118
+ - `aria-selected="true|false"` on the `<li role="treeitem">` (only when selection is active).
119
+ - For multi-selection, the container needs `aria-multiselectable="true"`. The Storybook controller (`attachTree`) sets and tears this down automatically when invoked with `selection: 'multi'`.
120
+ - Construct only styles selection — the consumer decides whether to clear other rows (`single`) or keep them (`multi`).
121
+
122
+ **Async children**
123
+ Set `aria-busy="true"` on the `<li role="treeitem">` while its children are loading. The chevron switches to a spinner via the existing `ct-spin` keyframe and the toggle becomes non-interactive while busy.
124
+
125
+ **Disabled nodes**
126
+ Use `aria-disabled="true"` on the `<li>`. Do **not** use the HTML `disabled` attribute — `treeitem` is not a form control. The Storybook controller skips activation, selection and toggle for disabled nodes; arrow-key navigation still passes through them so screen readers can announce them.
127
+
128
+ **Orphan state**
129
+ For sub-nodes whose parent reference is missing in the data set, render them on the root level with the `.ct-tree__node--orphan` modifier. They get a warning-tinted surface and a dashed border so the data inconsistency is visible without breaking the tree.
130
+
131
+ **Custom events**
132
+ The Storybook controller emits four bubbling `CustomEvent`s on the focused treeitem so consumers can wire up application logic without re-implementing the keyboard model:
133
+
134
+ | Event | Detail | Fires on |
135
+ |---|---|---|
136
+ | `ct-tree:expand` | `{ node }` | Node opened (click on toggle, `→`, `*`) |
137
+ | `ct-tree:collapse` | `{ node }` | Node closed (click on toggle, `←`) |
138
+ | `ct-tree:select` | `{ node, mode: 'click'\|'enter'\|'space' }` | Selection changed (only when `selection !== 'none'`) |
139
+ | `ct-tree:activate` | `{ node }` | Row primary-action (click, `Enter`, or `Space` when no selection) |
140
+
141
+ **What's not in Phase 1**
142
+ Drag & drop reparenting, virtual scrolling, and tristate parent-derived checkboxes are out of scope and tracked separately.
143
+
85
144
  ## Component States
86
145
 
87
146
  Use these attributes for state management:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neuravision/construct",
3
- "version": "1.1.6",
3
+ "version": "1.2.0",
4
4
  "description": "Construct Design System - Accessible, token-based design system for modern web applications",
5
5
  "license": "MIT",
6
6
  "repository": {