@jxsuite/ui 0.0.0 → 0.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.
Files changed (105) hide show
  1. package/components/jx-accordion-item.json +281 -0
  2. package/components/jx-accordion.json +33 -0
  3. package/components/jx-action-button.json +268 -34
  4. package/components/jx-action-group.json +131 -0
  5. package/components/jx-button.json +141 -26
  6. package/components/jx-checkbox.json +222 -0
  7. package/components/jx-color-area.json +263 -0
  8. package/components/jx-color-field.json +565 -0
  9. package/components/jx-color-slider.json +216 -0
  10. package/components/jx-combobox.json +551 -0
  11. package/components/jx-dialog.json +416 -0
  12. package/components/jx-divider.json +52 -0
  13. package/components/jx-dot.json +38 -0
  14. package/components/jx-field.json +162 -0
  15. package/components/jx-kbd.json +24 -0
  16. package/components/jx-listbox.json +76 -0
  17. package/components/jx-menu-item.json +6 -13
  18. package/components/jx-menu.json +45 -6
  19. package/components/jx-number-field.json +441 -0
  20. package/components/jx-option.json +350 -0
  21. package/components/jx-popover.json +227 -0
  22. package/components/jx-select.json +637 -0
  23. package/components/jx-spinner.json +129 -0
  24. package/components/jx-split.json +221 -0
  25. package/components/jx-swatch-group.json +107 -0
  26. package/components/jx-swatch.json +262 -0
  27. package/components/jx-switch.json +331 -0
  28. package/components/jx-tab-panel.json +58 -0
  29. package/components/jx-tab.json +296 -0
  30. package/components/jx-tabs.json +124 -0
  31. package/components/jx-textfield.json +645 -0
  32. package/components/jx-toast-host.json +121 -0
  33. package/components/jx-toast.json +255 -0
  34. package/components/jx-toolbar.json +81 -0
  35. package/components/jx-tooltip.json +207 -0
  36. package/components/jx-tree-item.json +325 -0
  37. package/components/jx-tree.json +202 -0
  38. package/icons/list.json +4 -0
  39. package/icons/manifest.json +12 -0
  40. package/package.json +27 -5
  41. package/project.json +164 -153
  42. package/src/behaviors/action-group.ts +255 -0
  43. package/src/behaviors/color-area.ts +303 -0
  44. package/src/behaviors/color-field.ts +465 -0
  45. package/src/behaviors/color-slider.ts +154 -0
  46. package/src/behaviors/combobox.ts +474 -0
  47. package/src/behaviors/dialog.ts +137 -0
  48. package/src/behaviors/listbox.ts +118 -0
  49. package/src/behaviors/menu.ts +80 -48
  50. package/src/behaviors/number-field.ts +161 -0
  51. package/src/behaviors/popover.ts +399 -0
  52. package/src/behaviors/select.ts +154 -0
  53. package/src/behaviors/split.ts +478 -0
  54. package/src/behaviors/swatch-group.ts +297 -0
  55. package/src/behaviors/tabs.ts +319 -0
  56. package/src/behaviors/textfield.ts +164 -0
  57. package/src/behaviors/toast-host.ts +241 -0
  58. package/src/behaviors/toast.ts +261 -0
  59. package/src/behaviors/toolbar.ts +413 -0
  60. package/src/behaviors/tooltip.ts +719 -0
  61. package/src/behaviors/tree.ts +830 -0
  62. package/src/color.ts +433 -0
  63. package/src/documents.ts +91 -1
  64. package/src/index.ts +41 -1
  65. package/src/loaders.ts +47 -0
  66. package/src/testing/anchor-support.ts +28 -0
  67. package/src/testing/popover-shim.ts +39 -6
  68. package/src/theme.ts +30 -6
  69. package/stylebook/jx-accordion-item.json +195 -0
  70. package/stylebook/jx-accordion.json +220 -0
  71. package/stylebook/jx-action-button.json +191 -23
  72. package/stylebook/jx-action-group.json +335 -0
  73. package/stylebook/jx-button.json +129 -26
  74. package/stylebook/jx-checkbox.json +245 -0
  75. package/stylebook/jx-color-area.json +272 -0
  76. package/stylebook/jx-color-field.json +261 -0
  77. package/stylebook/jx-color-slider.json +220 -0
  78. package/stylebook/jx-combobox.json +332 -0
  79. package/stylebook/jx-dialog.json +186 -0
  80. package/stylebook/jx-divider.json +125 -0
  81. package/stylebook/jx-dot.json +92 -0
  82. package/stylebook/jx-field.json +313 -0
  83. package/stylebook/jx-icon.json +4 -0
  84. package/stylebook/jx-kbd.json +90 -0
  85. package/stylebook/jx-listbox.json +337 -0
  86. package/stylebook/jx-menu.json +0 -1
  87. package/stylebook/jx-number-field.json +203 -0
  88. package/stylebook/jx-option.json +285 -0
  89. package/stylebook/jx-popover.json +123 -0
  90. package/stylebook/jx-select.json +407 -0
  91. package/stylebook/jx-spinner.json +111 -0
  92. package/stylebook/jx-split.json +170 -0
  93. package/stylebook/jx-swatch-group.json +175 -0
  94. package/stylebook/jx-swatch.json +249 -0
  95. package/stylebook/jx-switch.json +240 -0
  96. package/stylebook/jx-tab-panel.json +147 -0
  97. package/stylebook/jx-tab.json +201 -0
  98. package/stylebook/jx-tabs.json +278 -0
  99. package/stylebook/jx-textfield.json +309 -0
  100. package/stylebook/jx-toast-host.json +157 -0
  101. package/stylebook/jx-toast.json +266 -0
  102. package/stylebook/jx-toolbar.json +435 -0
  103. package/stylebook/jx-tooltip.json +162 -0
  104. package/stylebook/jx-tree-item.json +258 -0
  105. package/stylebook/jx-tree.json +429 -0
@@ -0,0 +1,830 @@
1
+ /**
2
+ * `jx-tree`'s behaviour sidecar: the roving caret, the ARIA tree keyboard, typeahead, the
3
+ * multi-select anchor, and the two things a WINDOWED tree cannot do for itself.
4
+ *
5
+ * A `<jx-tree>` IS the `role="tree"` and its `<jx-tree-item>` children are its items, so every part
6
+ * of the APG's tree pattern a native control would supply is this module's. What it does beyond
7
+ * reading state is exactly the allowed set (specs/ui.md §2): move focus, call an element's own
8
+ * `scrollIntoView()`, and write a child's own declared props through the accessors that child
9
+ * installs. It measures nothing, listens on no document, and never reaches for a foreign
10
+ * attribute.
11
+ *
12
+ * **The tree draws a SLICE, and it knows it.** `jx-tree` takes a FLAT list of already-projected
13
+ * rows — each item carrying its own `level`, `posinset` and `setsize` — because a windowed tree's
14
+ * DOM holds only the rows the viewport can show. Two consequences run through everything below. The
15
+ * counts a reader hears are the host's and are never derived from `items.length`, which would
16
+ * announce "4 items" over a four-thousand-row directory. And a move that runs off either end of the
17
+ * drawn slice has no element to land on, so it is not performed here: `padtop` and `padbottom` are
18
+ * the element's only knowledge of what it cannot see, and a move past them dispatches `move` and
19
+ * stops, leaving the host to scroll and write `current`. A tree with no pads is not windowed, the
20
+ * ends of the slice are the ends of the model, and the same code clamps there instead — which is
21
+ * the APG's answer for a tree, where the arrows deliberately do NOT wrap.
22
+ *
23
+ * **What it hands over is the ROW, and never the KEYBOARD.** A caret move is one act, and it does
24
+ * not change owner just because the row is off-slice: `jx-tree` focuses the row the caret lands on
25
+ * whether it walked there itself ({@link moveTo}) or asked a host to reveal it ({@link land}). The
26
+ * host cannot do that half. It writes `current` while the row it names still has no element, and
27
+ * the element the repaint then draws is not focusable until the roving `tabindex` has reached it —
28
+ * a moment only {@link syncTree} is in a position to know, because the write it answers is this
29
+ * module's own. Both Studio trees tried anyway and both missed it, in opposite directions.
30
+ *
31
+ * **Typeahead is the second of those, and it does not look like the first.** A move announces its
32
+ * own failure; a letter never does, because the search over a slice always answers and the answer
33
+ * is simply the wrong row. A windowed tree therefore dispatches `typeahead` for EVERY printable
34
+ * character rather than only when the slice holds no match — the pad is consulted before the rows,
35
+ * the same order Home and End already use, and for the same reason.
36
+ *
37
+ * {@link syncTree} is the single writer of every drawn item's `caret`, and it is reached three ways
38
+ * that are all the same call: from `onTreeMount`, because `defineElement` runs `applyAttributes`
39
+ * BEFORE `distributeSlots` and no item exists when the root's bindings first evaluate; from the
40
+ * `sync` computed the host reads through `data-current`, which is what makes a HOST write of
41
+ * `.current` re-sync with no method call; and from the observer the mount installs, because the ROW
42
+ * SET moves too — a scroll repaints the whole slice, and a tree whose caret-holder was scrolled
43
+ * away has no `tabindex="0"` left at all.
44
+ *
45
+ * **Every key the tree does not own reaches the host untouched.** That is not tidiness: cut and
46
+ * paste are the WCAG 2.5.7 alternative to the drag island (studio-ui-guidelines.md §8.2), they are
47
+ * host commands with chords, and a tree that swallowed `Ctrl`/`Cmd`+`X` would take the only
48
+ * pointer-free way to move a node with it.
49
+ *
50
+ * @docs extending/ui-kit
51
+ */
52
+
53
+ /** The tag of the tree. */
54
+ const TREE = "jx-tree";
55
+ /** The tag of one row. */
56
+ const ITEM = "jx-tree-item";
57
+
58
+ /** What the reader meant by putting the caret somewhere. */
59
+ export type SelectMode = "replace" | "toggle" | "range";
60
+
61
+ /** The detail of the `select` event: an INTENT, never a resolved set. */
62
+ export interface TreeSelectDetail {
63
+ /** The row the reader acted on. */
64
+ value: string;
65
+ /** Replace the selection, toggle this row into it, or extend from `anchor` to here. */
66
+ mode: SelectMode;
67
+ /** Where a range extends from. Equal to `value` for `replace` and `toggle`. */
68
+ anchor: string;
69
+ }
70
+
71
+ /** The detail of the `move` event: a caret move the drawn slice cannot satisfy. */
72
+ export interface TreeMoveDetail {
73
+ /** The row the caret is on now. */
74
+ from: string;
75
+ /** The key that asked for the move, so the host knows which way and how far. */
76
+ key: string;
77
+ }
78
+
79
+ /** The detail of the `typeahead` event: a letter the drawn slice cannot answer FOR THE MODEL. */
80
+ export interface TreeTypeaheadDetail {
81
+ /** The row the caret is on now — where the search starts, and where it wraps back to. */
82
+ from: string;
83
+ /** The character the reader typed, lowercased: exactly what the element matches labels on. */
84
+ char: string;
85
+ }
86
+
87
+ /** The detail of the `expand` event: the expansion a row should be given. */
88
+ export interface TreeExpandDetail {
89
+ /** The row whose twisty was operated. */
90
+ value: string;
91
+ /** The state it should be put into, not the state it is in. */
92
+ expanded: boolean;
93
+ }
94
+
95
+ /** The reactive scope a `jx-tree` document hands its handlers. */
96
+ export interface TreeState {
97
+ label?: string;
98
+ multiple?: boolean;
99
+ current?: string;
100
+ anchor?: string;
101
+ padtop?: number;
102
+ padbottom?: number;
103
+ [key: string]: unknown;
104
+ }
105
+
106
+ /** A row element with the property accessors its own document installs. */
107
+ type ItemElement = HTMLElement & {
108
+ value?: string;
109
+ label?: string;
110
+ level?: number;
111
+ expanded?: string;
112
+ selected?: boolean;
113
+ disabled?: boolean;
114
+ caret?: boolean;
115
+ };
116
+
117
+ /** A tree element with the accessors its own document installs. */
118
+ type TreeElement = HTMLElement & {
119
+ current?: string;
120
+ anchor?: string;
121
+ multiple?: boolean;
122
+ padtop?: number;
123
+ padbottom?: number;
124
+ };
125
+
126
+ /**
127
+ * The element each scope belongs to, learned once at mount.
128
+ *
129
+ * A WeakMap rather than a key on the scope: the scope is a deep reactive proxy, and an element
130
+ * stored in one is an element every effect that reads it subscribes to.
131
+ */
132
+ const hosts = new WeakMap<object, HTMLElement>();
133
+
134
+ /** The trees already watching their own row set, so a second mount never observes twice. */
135
+ const watched = new WeakMap<HTMLElement, MutationObserver>();
136
+
137
+ /**
138
+ * A caret move the element could not perform and handed to its host, waiting to be finished.
139
+ *
140
+ * The element gives up the ROW and keeps the KEYBOARD, because those are two different questions
141
+ * and only the first one is the host's. See {@link land}.
142
+ */
143
+ interface Handoff {
144
+ /** `current` when the element gave up — what the host's answer has to differ from. */
145
+ from: string;
146
+ /** The row that held the keyboard at that moment, so a repaint that takes it away is legible. */
147
+ held: Element | null;
148
+ }
149
+
150
+ /** The move each tree is waiting to see drawn. At most one: a second key supersedes the first. */
151
+ const handoffs = new WeakMap<HTMLElement, Handoff>();
152
+
153
+ /** The tree an event's `currentTarget` is. */
154
+ function hostOf(event: Event): TreeElement | null {
155
+ const target = event.currentTarget;
156
+ return target instanceof HTMLElement && target.localName === TREE
157
+ ? (target as TreeElement)
158
+ : null;
159
+ }
160
+
161
+ /** Whether `tree` is the nearest tree around `node` — a key inside a nested tree is its own. */
162
+ function ownsNode(tree: HTMLElement, node: EventTarget | null): boolean {
163
+ return node instanceof Element && node.closest(TREE) === tree;
164
+ }
165
+
166
+ /**
167
+ * Read a prop from a row, from the PROPERTY once the child has upgraded and from the attribute
168
+ * before it has.
169
+ *
170
+ * The caret is placed from a lifecycle event and a slotted child's own `connectedCallback` is
171
+ * async, so a tree can legitimately be asked to rove either side of that moment.
172
+ */
173
+ function propOf(item: ItemElement, key: keyof ItemElement, attribute: string): string {
174
+ const value = item[key];
175
+ return value === undefined ? (item.getAttribute(attribute) ?? "") : String(value);
176
+ }
177
+
178
+ /** What a row stands for: the detail of the events it provokes, and what `current` is matched on. */
179
+ function valueOf(item: ItemElement): string {
180
+ return propOf(item, "value", "value");
181
+ }
182
+
183
+ /** How deep a row sits. One-based, as `aria-level` is; anything unreadable is the top level. */
184
+ function levelOf(item: ItemElement): number {
185
+ const level = Number(propOf(item, "level", "aria-level"));
186
+ return Number.isFinite(level) && level >= 1 ? Math.trunc(level) : 1;
187
+ }
188
+
189
+ /** A row's expansion: `"true"`, `"false"`, or `""` for a leaf, which owns no `aria-expanded`. */
190
+ function expansionOf(item: ItemElement): string {
191
+ const expanded = propOf(item, "expanded", "aria-expanded");
192
+ return expanded === "true" || expanded === "false" ? expanded : "";
193
+ }
194
+
195
+ /** Whether a row cannot be acted on. */
196
+ function isDisabled(item: ItemElement): boolean {
197
+ return item.disabled === undefined
198
+ ? item.hasAttribute("disabled") || item.getAttribute("aria-disabled") === "true"
199
+ : item.disabled === true;
200
+ }
201
+
202
+ /**
203
+ * What typeahead matches: the row's `label`, and where it has none, all of its text.
204
+ *
205
+ * Those two are not a guess and a fallback — they are exactly the two things accname computes for
206
+ * this row. A row with a `label` writes `aria-label`, which wins over name-from-content, and a row
207
+ * without one is named by its content. Matching anything else would let a reader type a letter the
208
+ * row never announced and watch the caret move somewhere they were not told about.
209
+ */
210
+ function labelOf(item: ItemElement): string {
211
+ const named = propOf(item, "label", "label");
212
+ return named === "" ? (item.textContent ?? "") : named;
213
+ }
214
+
215
+ /**
216
+ * The rows of one tree, in document order, excluding the rows of any tree nested inside it and any
217
+ * row that cannot be acted on.
218
+ *
219
+ * A row is the tree's own child — a `<slot>` leaves no node, so a distributed row stands where the
220
+ * slot stood — but a host that windows wraps its `$map` output in a `role="none"` container, so
221
+ * this walks descendants and keeps the ones whose nearest tree is this one.
222
+ *
223
+ * @param tree The `jx-tree` element.
224
+ * @param options `includeDisabled` keeps rows a reader cannot act on, for the writers that must
225
+ * still address every drawn row.
226
+ * @returns Its own rows, in document order.
227
+ */
228
+ export function itemsOf(
229
+ tree: HTMLElement,
230
+ options: { includeDisabled?: boolean } = {},
231
+ ): ItemElement[] {
232
+ const items = [...tree.querySelectorAll<ItemElement>(ITEM)].filter(
233
+ (item) => item.closest(TREE) === tree,
234
+ );
235
+ return options.includeDisabled ? items : items.filter((item) => !isDisabled(item));
236
+ }
237
+
238
+ /**
239
+ * Write the tree's caret onto its rows: exactly one `caret`, so Tab enters and leaves the whole
240
+ * tree in one step.
241
+ *
242
+ * Idempotent by construction — everything it writes is derived from the host's own `current`, and
243
+ * an equal write into a reactive proxy triggers nothing. That matters because it runs inside a
244
+ * render effect.
245
+ *
246
+ * **The caret falls back to the first drawn row, and the fallback is the point.** Under windowing
247
+ * the row `current` names is routinely NOT drawn: it has been scrolled past. A tree that insisted
248
+ * on it would have no `tabindex="0"` anywhere and would drop out of the tab order entirely, which
249
+ * is the failure `jx-tabs` records for a closed tab and is worse here, because scrolling causes it.
250
+ * The fallback moves the tab STOP and never `current` itself, so the host's model is untouched and
251
+ * a reader who tabs in lands on a row they can see.
252
+ *
253
+ * It is also the one moment at which a handed-off caret move can be FINISHED, which is why
254
+ * {@link land} is called from here and from nowhere else — see its own note.
255
+ *
256
+ * @param tree The `jx-tree` element.
257
+ */
258
+ export function syncTree(tree: HTMLElement): void {
259
+ const items = itemsOf(tree, { includeDisabled: true });
260
+ if (items.length === 0) {
261
+ return;
262
+ }
263
+ const current = String((tree as TreeElement).current ?? "");
264
+ const enabled = items.filter((item) => !isDisabled(item));
265
+ const caret = enabled.find((item) => valueOf(item) === current) ?? enabled[0] ?? null;
266
+ for (const item of items) {
267
+ item.caret = item === caret;
268
+ }
269
+ land(tree, current, caret);
270
+ }
271
+
272
+ /**
273
+ * Whether the keyboard is still this tree's to move.
274
+ *
275
+ * Two states qualify and the second is the one a windowed tree actually lands in. Focus is still
276
+ * somewhere inside the tree — the row the reader left is often still drawn, one screen up. Or focus
277
+ * is nowhere at all, because the repaint that answered the move took the focused row out of the
278
+ * document and the browser dropped focus to `<body>`; the row this tree remembers is the evidence,
279
+ * and asking whether it is still connected is a fact about a node this module was handed rather
280
+ * than a measurement.
281
+ *
282
+ * Anything else means the reader went somewhere while the host was scrolling, and a tree that took
283
+ * the keyboard back then would be yanking it out of whatever they moved to.
284
+ */
285
+ function keptFocus(tree: HTMLElement, pending: Handoff): boolean {
286
+ const owner = tree.ownerDocument;
287
+ const active = owner.activeElement;
288
+ if (active instanceof Node && tree.contains(active)) {
289
+ return true;
290
+ }
291
+ const adrift = active === null || active === owner.body;
292
+ return adrift && pending.held !== null && !pending.held.isConnected;
293
+ }
294
+
295
+ /**
296
+ * Finish a handed-off caret move: take the keyboard to the row the host revealed.
297
+ *
298
+ * **The element gives up the ROW and keeps the KEYBOARD.** A host answering `move` or `typeahead`
299
+ * scrolls, repaints and writes `current`, and none of those is a moment it can focus at: the row it
300
+ * names has no element until the repaint draws one, and the element it does draw is not focusable
301
+ * until the roving `tabindex` has reached it. Only {@link syncTree} knows when that has happened,
302
+ * so the handoff is finished HERE rather than through a "now" the two hosts would each have to hook
303
+ * correctly — which is what they were doing, by two different mechanisms, and neither of them
304
+ * worked: the Files tree spent its one focus attempt on a row announced through `onNodeCreated`,
305
+ * one reconcile step before any attribute including `tabindex` was on it, and the Outline queued a
306
+ * microtask that ran before the projection it was waiting for had been drawn.
307
+ *
308
+ * Three conditions, each closing a different way to focus the wrong thing. `current` must have
309
+ * MOVED, so a host that decided to answer with the row the caret was already on is not overridden.
310
+ * The caret must be the row `current` names rather than {@link syncTree}'s first-drawn fallback,
311
+ * which is what makes an early sync — the one that runs when `current` changes but before the rows
312
+ * do — a no-op that leaves the handoff standing for the sync that follows the repaint. And the
313
+ * keyboard must still be this tree's to move ({@link keptFocus}).
314
+ *
315
+ * `""` is deliberately NOT one of them, tempting as "no caret" reads. It is a row value like any
316
+ * other and the Outline's is the document root, whose `pathKey([])` is the empty string — a tree
317
+ * that refused to land on it would answer Home in that panel by moving the caret and abandoning the
318
+ * keyboard, which is the defect rather than a guard against it. The `current !== from` test already
319
+ * says a host answered, and `valueOf(caret) === current` already says the row it answered with is
320
+ * the one drawn.
321
+ *
322
+ * **The handoff is spent by the keyboard MOVING, never by having asked.** Writing `caret` puts the
323
+ * roving tab stop on the row in this module's own terms; the `tabindex` that makes the row
324
+ * focusable is written by the row's document, and a row the repaint has only just created is in the
325
+ * page, answering to its `value`, with no `tabindex` on it yet — `focus()` there does nothing at
326
+ * all and reports nothing. That is the exact shape of the defect this replaced, one layer down, so
327
+ * the answer cannot be a better-chosen moment: the request stands until the keyboard actually
328
+ * moved, and {@link watchTree} is what guarantees another sync once the row is focusable.
329
+ */
330
+ function land(tree: HTMLElement, current: string, caret: ItemElement | null): void {
331
+ const pending = handoffs.get(tree);
332
+ if (!pending) {
333
+ return;
334
+ }
335
+ if (!keptFocus(tree, pending)) {
336
+ handoffs.delete(tree);
337
+ return;
338
+ }
339
+ if (current === pending.from || caret === null || valueOf(caret) !== current) {
340
+ return;
341
+ }
342
+ caret.focus();
343
+ if (tree.ownerDocument.activeElement === caret) {
344
+ handoffs.delete(tree);
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Keep the one-tab-stop invariant as the drawn slice comes and goes.
350
+ *
351
+ * The ROW SET is the obvious half: a scroll repaints the whole slice, and a tree whose caret-holder
352
+ * was scrolled away has no `tabindex="0"` left at all.
353
+ *
354
+ * `tabindex` itself is the other half, and it is what makes a handed-off caret move land. A row the
355
+ * repaint has just created reaches the page before its own document has written its attributes, so
356
+ * the sync that the new children provoke sees a row that answers to the right `value` and cannot
357
+ * take focus. Watching the attribute is not a poll and not a guess about timing: it is the row
358
+ * saying, in the only terms it has, that the tab stop this module asked for is now on it — and
359
+ * {@link land} is waiting for exactly that.
360
+ */
361
+ function watchTree(tree: HTMLElement): void {
362
+ if (watched.has(tree)) {
363
+ return;
364
+ }
365
+ /* This DOES feed back — writing `caret` is what makes a row write `tabindex` — and it converges in
366
+ one round rather than looping, because everything `syncTree` writes is derived from `current`
367
+ and an equal write into a reactive proxy changes no attribute and so produces no record. */
368
+ const observer = new MutationObserver(() => {
369
+ syncTree(tree);
370
+ });
371
+ observer.observe(tree, {
372
+ attributeFilter: ["tabindex"],
373
+ attributes: true,
374
+ childList: true,
375
+ subtree: true,
376
+ });
377
+ watched.set(tree, observer);
378
+ }
379
+
380
+ /**
381
+ * The `sync` computed's body: read the caret, and put it on the rows.
382
+ *
383
+ * The HOST reads it as `data-current`, so it is an effect of `current` and re-runs whenever
384
+ * anything writes that — a click, a key, or a host assigning `el.current` from the outside. It
385
+ * takes the SCOPE rather than the value, because the runtime hands a `$src` function only its
386
+ * declared positional arguments and the element is reachable only through the scope it was learned
387
+ * against.
388
+ *
389
+ * @param scope The `jx-tree` reactive scope.
390
+ * @returns The current caret value, for the attribute that reads it.
391
+ */
392
+ export function applyCurrent(scope: TreeState): string {
393
+ const current = String(scope.current ?? "");
394
+ const host = hosts.get(scope);
395
+ if (host) {
396
+ syncTree(host);
397
+ }
398
+ return current;
399
+ }
400
+
401
+ /**
402
+ * Learn the element, sync once the rows exist, and keep watching them.
403
+ *
404
+ * Reached through the `jx-ready` the root dispatches from `onMount`, which is the kit's way of
405
+ * giving an element its first sight of itself: a mount handler is called with the scope alone, and
406
+ * an event handler's `currentTarget` is the host.
407
+ *
408
+ * @param {TreeState} scope
409
+ * @param {Event} event
410
+ */
411
+ export function onTreeMount(scope: TreeState, event: Event): void {
412
+ const host = hostOf(event);
413
+ if (!host) {
414
+ return;
415
+ }
416
+ hosts.set(scope, host);
417
+ syncTree(host);
418
+ watchTree(host);
419
+ }
420
+
421
+ /** Where the caret is now, as an index into `items`; -1 when it is nowhere. */
422
+ function activeIndex(items: ItemElement[], target: EventTarget | null): number {
423
+ return items.findIndex(
424
+ (item) => item === target || (target instanceof Node && item.contains(target)),
425
+ );
426
+ }
427
+
428
+ /** Say a public thing, from the tree, so `e.target` is the element a host wrote. */
429
+ function announce(host: HTMLElement, name: string, detail: unknown): void {
430
+ host.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
431
+ }
432
+
433
+ /**
434
+ * Put the caret on a drawn row: write `current`, move the roving tab stop, take focus, reveal the
435
+ * row, and say the caret moved.
436
+ *
437
+ * `current` is written BEFORE `change` is dispatched, so a listener reading `e.target.current` sees
438
+ * the new value — the same order `jx-tabs` keeps for `selected`.
439
+ */
440
+ function moveTo(host: HTMLElement, scope: TreeState, item: ItemElement): void {
441
+ scope.current = valueOf(item);
442
+ syncTree(host);
443
+ item.focus();
444
+ if (typeof item.scrollIntoView === "function") {
445
+ item.scrollIntoView({ block: "nearest" });
446
+ }
447
+ announce(host, "change", valueOf(item));
448
+ }
449
+
450
+ /** Rows exist above the drawn slice. */
451
+ function hasAbove(scope: TreeState): boolean {
452
+ return Number(scope.padtop ?? 0) > 0;
453
+ }
454
+
455
+ /** Rows exist below the drawn slice. */
456
+ function hasBelow(scope: TreeState): boolean {
457
+ return Number(scope.padbottom ?? 0) > 0;
458
+ }
459
+
460
+ /**
461
+ * Remember that a caret move is in the host's hands, so {@link land} can finish it.
462
+ *
463
+ * Armed BEFORE the event is dispatched, because a host answers one synchronously: it writes
464
+ * `current` inside the dispatch, and the sync that write provokes is already a chance to land.
465
+ *
466
+ * It is armed only from the two dispatches a KEY provokes, never from a host writing `current` for
467
+ * its own reasons — a canvas selection moving the Outline's caret must not pull the keyboard out of
468
+ * the canvas.
469
+ */
470
+ function handOff(host: HTMLElement, scope: TreeState): void {
471
+ handoffs.set(host, {
472
+ from: String(scope.current ?? ""),
473
+ held: host.ownerDocument.activeElement,
474
+ });
475
+ }
476
+
477
+ /**
478
+ * Ask the host for a row this tree cannot reach.
479
+ *
480
+ * The one thing a windowed composite cannot do for itself, and the reason it is an event rather
481
+ * than a scroll: the element knows a row exists past the pad, and nothing else about it — not where
482
+ * it is in the model, not how tall the rows above it are, not whether the host would rather expand
483
+ * something first.
484
+ */
485
+ function beyond(host: HTMLElement, scope: TreeState, key: string): void {
486
+ handOff(host, scope);
487
+ announce(host, "move", { from: String(scope.current ?? ""), key } satisfies TreeMoveDetail);
488
+ }
489
+
490
+ /**
491
+ * Whether the drawn rows are only PART of the model. Both pads at zero is the only way they are
492
+ * not.
493
+ */
494
+ function windowed(scope: TreeState): boolean {
495
+ return hasAbove(scope) || hasBelow(scope);
496
+ }
497
+
498
+ /**
499
+ * Ask the host to resolve a letter against the model.
500
+ *
501
+ * The same shape as {@link beyond} and for the same reason, but it is reached differently, and the
502
+ * difference is the whole point. A move off the end of the slice ANNOUNCES ITSELF: there is no
503
+ * element to land on, so the element discovers it has run out and asks. Typeahead never discovers
504
+ * anything — a drawn row starting with `f` either exists or does not, and whether it is the model's
505
+ * next `f` after the caret is a question the slice cannot even pose. Left to itself the search
506
+ * wraps inside the slice, so in a four-thousand-row directory `f` reached the twenty rows the
507
+ * viewport happened to hold and called that the tree.
508
+ *
509
+ * So the pad is consulted BEFORE the rows, exactly as Home and End consult it — and a windowed tree
510
+ * asks the host every time rather than only when it finds nothing, because "found nothing" and
511
+ * "found the wrong row" are the same state from in here.
512
+ */
513
+ function seek(host: HTMLElement, scope: TreeState, char: string): void {
514
+ handOff(host, scope);
515
+ announce(host, "typeahead", {
516
+ char,
517
+ from: String(scope.current ?? ""),
518
+ } satisfies TreeTypeaheadDetail);
519
+ }
520
+
521
+ /**
522
+ * Say what the reader meant by landing here, and keep the anchor.
523
+ *
524
+ * The tree owns the ANCHOR and dispatches an INTENT; the HOST owns the selection set. That split is
525
+ * forced by windowing rather than chosen: resolving a Shift range means naming every row between
526
+ * two of them, and the rows between are exactly the ones a drawn slice does not have.
527
+ *
528
+ * With `multiple` off every intent is `replace`, so a host that never asked for multi-select cannot
529
+ * be handed a `toggle` by a reader holding a modifier down.
530
+ */
531
+ function intend(
532
+ host: HTMLElement,
533
+ scope: TreeState,
534
+ value: string,
535
+ mode: SelectMode,
536
+ from: string,
537
+ ): void {
538
+ const kind: SelectMode = scope.multiple === true ? mode : "replace";
539
+ const held = String(scope.anchor ?? "");
540
+ const anchor = kind === "range" ? (held === "" ? from || value : held) : value;
541
+ scope.anchor = anchor;
542
+ announce(host, "select", { anchor, mode: kind, value } satisfies TreeSelectDetail);
543
+ }
544
+
545
+ /** Which selection a modifier state means. */
546
+ function modeOf(event: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }): SelectMode {
547
+ if (event.shiftKey) {
548
+ return "range";
549
+ }
550
+ return event.ctrlKey || event.metaKey ? "toggle" : "replace";
551
+ }
552
+
553
+ /** A single printable character, for typeahead; `null` for anything else. */
554
+ function typeaheadChar(event: KeyboardEvent): string | null {
555
+ return event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey
556
+ ? event.key.toLowerCase()
557
+ : null;
558
+ }
559
+
560
+ /** The next row after `active` whose label starts with `char`, wrapping; -1 when none does. */
561
+ function typeaheadHit(items: ItemElement[], active: number, char: string): number {
562
+ const labels = items.map((item) => labelOf(item).trim().toLowerCase());
563
+ for (let step = 1; step <= labels.length; step++) {
564
+ const at = (active + step) % labels.length;
565
+ if (labels[at]!.startsWith(char)) {
566
+ return at;
567
+ }
568
+ }
569
+ return -1;
570
+ }
571
+
572
+ /** The parent of the row at `active`: the nearest drawn row above it at a shallower level. */
573
+ function parentIndex(items: ItemElement[], active: number): number {
574
+ const level = levelOf(items[active]!);
575
+ for (let at = active - 1; at >= 0; at--) {
576
+ if (levelOf(items[at]!) < level) {
577
+ return at;
578
+ }
579
+ }
580
+ return -1;
581
+ }
582
+
583
+ /** Everything one claimed key may ask for, so the switch below states intent and nothing else. */
584
+ interface Step {
585
+ /** Where the caret should land, as an index into the drawn rows. */
586
+ to: number;
587
+ /** The key to report when `to` is outside the drawn slice. */
588
+ key: string;
589
+ /** Whether landing there is also a selection. */
590
+ select: boolean;
591
+ }
592
+
593
+ /**
594
+ * The ARIA tree keyboard contract, on the tree whose row holds the caret.
595
+ *
596
+ * The arrows walk the drawn rows and do NOT wrap, which is the APG's rule for a tree and a
597
+ * deliberate difference from the kit's menu and tablist: in a list of actions wrapping is a
598
+ * convenience, and in a document outline it is a reader losing their place. Home and End go to the
599
+ * ends. `ArrowRight` opens a collapsed row and steps into an open one; `ArrowLeft` closes an open
600
+ * row and steps out of a closed one. Enter activates. Space is a selection verb and never an
601
+ * activation, so a multi-select tree has one key that adds a row without opening it. A printable
602
+ * character is typeahead — over the drawn rows when the slice IS the model, and dispatched as
603
+ * `typeahead` for the host to resolve when a pad says it is not (see {@link seek}).
604
+ *
605
+ * **Selection follows the caret unless a modifier says otherwise.** Shift extends from the anchor,
606
+ * `Ctrl`/`Cmd` moves the caret alone, and a plain move replaces — the three the APG names for a
607
+ * multi-select tree, and the first is the only one a single-select tree ever sees.
608
+ *
609
+ * Every other key returns before anything is prevented or stopped, which is what keeps the host's
610
+ * chords — cut, paste, delete, rename, select-all — reachable from a focused row.
611
+ *
612
+ * @param {TreeState} scope
613
+ * @param {KeyboardEvent} event
614
+ */
615
+ export function onTreeKeydown(scope: TreeState, event: KeyboardEvent): void {
616
+ const host = hostOf(event);
617
+ if (!host || !ownsNode(host, event.target)) {
618
+ return;
619
+ }
620
+ const items = itemsOf(host);
621
+ if (items.length === 0) {
622
+ return;
623
+ }
624
+ const active = activeIndex(items, event.target);
625
+ const from = String(scope.current ?? "");
626
+ const accel = event.ctrlKey || event.metaKey;
627
+ const item = items[active];
628
+ let step: Step | null = null;
629
+
630
+ switch (event.key) {
631
+ case "ArrowDown": {
632
+ step = { key: event.key, select: !accel, to: active === -1 ? 0 : active + 1 };
633
+ break;
634
+ }
635
+ case "ArrowUp": {
636
+ step = { key: event.key, select: !accel, to: active === -1 ? items.length - 1 : active - 1 };
637
+ break;
638
+ }
639
+ case "Home":
640
+ case "End": {
641
+ /* The ends of the MODEL, not the ends of the slice. A drawn row exists at both indices
642
+ whenever the tree has any rows at all, so unlike an arrow this cannot discover that it has
643
+ run out — the pad has to be consulted first, or End in a windowed tree lands on the last row
644
+ the viewport happens to show and calls it the bottom. */
645
+ const outward = event.key === "Home" ? hasAbove(scope) : hasBelow(scope);
646
+ if (outward) {
647
+ event.preventDefault();
648
+ event.stopPropagation();
649
+ beyond(host, scope, event.key);
650
+ return;
651
+ }
652
+ step = { key: event.key, select: true, to: event.key === "Home" ? 0 : items.length - 1 };
653
+ break;
654
+ }
655
+ case "ArrowRight": {
656
+ if (!item) {
657
+ return;
658
+ }
659
+ const expansion = expansionOf(item);
660
+ if (expansion === "") {
661
+ return;
662
+ }
663
+ if (expansion === "false") {
664
+ event.preventDefault();
665
+ event.stopPropagation();
666
+ announce(host, "expand", {
667
+ expanded: true,
668
+ value: valueOf(item),
669
+ } satisfies TreeExpandDetail);
670
+ return;
671
+ }
672
+ // Open: the first child is the next drawn row, and only when it is genuinely deeper.
673
+ const next = items[active + 1];
674
+ if (next && levelOf(next) <= levelOf(item)) {
675
+ return;
676
+ }
677
+ step = { key: event.key, select: true, to: active + 1 };
678
+ break;
679
+ }
680
+ case "ArrowLeft": {
681
+ if (!item) {
682
+ return;
683
+ }
684
+ if (expansionOf(item) === "true") {
685
+ event.preventDefault();
686
+ event.stopPropagation();
687
+ announce(host, "expand", {
688
+ expanded: false,
689
+ value: valueOf(item),
690
+ } satisfies TreeExpandDetail);
691
+ return;
692
+ }
693
+ const parent = parentIndex(items, active);
694
+ // A top-level row has no parent to step out to, and neither does one whose parent is above
695
+ // The drawn slice — but those two are not the same, and only the second is answerable.
696
+ if (parent === -1 && levelOf(item) === 1) {
697
+ return;
698
+ }
699
+ step = { key: event.key, select: true, to: parent };
700
+ break;
701
+ }
702
+ case "Enter": {
703
+ if (!item) {
704
+ return;
705
+ }
706
+ event.preventDefault();
707
+ event.stopPropagation();
708
+ announce(host, "activate", valueOf(item));
709
+ return;
710
+ }
711
+ case " ": {
712
+ if (!item) {
713
+ return;
714
+ }
715
+ event.preventDefault();
716
+ event.stopPropagation();
717
+ intend(host, scope, valueOf(item), scope.multiple === true ? "toggle" : "replace", from);
718
+ return;
719
+ }
720
+ default: {
721
+ const char = typeaheadChar(event);
722
+ if (char === null) {
723
+ return;
724
+ }
725
+ if (windowed(scope)) {
726
+ event.preventDefault();
727
+ event.stopPropagation();
728
+ seek(host, scope, char);
729
+ return;
730
+ }
731
+ const hit = typeaheadHit(items, active, char);
732
+ if (hit === -1) {
733
+ return;
734
+ }
735
+ step = { key: event.key, select: true, to: hit };
736
+ }
737
+ }
738
+
739
+ event.preventDefault();
740
+ event.stopPropagation();
741
+ const landing = items[step.to];
742
+ if (!landing) {
743
+ // Off the drawn slice. A pad on that side means the model has more; nothing there means the
744
+ // Slice ends where the model does, and a tree clamps rather than wrapping.
745
+ if (step.to < 0 ? hasAbove(scope) : hasBelow(scope)) {
746
+ beyond(host, scope, step.key);
747
+ }
748
+ return;
749
+ }
750
+ moveTo(host, scope, landing);
751
+ if (step.select) {
752
+ intend(host, scope, valueOf(landing), modeOf(event), from);
753
+ }
754
+ }
755
+
756
+ /**
757
+ * A pointer landed on a row: the caret follows it, and the modifiers say what it meant.
758
+ *
759
+ * Delegated on the TREE rather than dispatched by each row, which is the one place the kit departs
760
+ * from its own child-dispatches-`select` idiom (`jx-tab`, `jx-menu-item`, `jx-swatch`). The reason
761
+ * is the modifiers: a selection intent is `Shift` or `Ctrl`/`Cmd` plus a row, and a row that
762
+ * dispatched its own custom event would have to copy three booleans out of the mouse event into a
763
+ * detail for the tree to read them back. One listener that reads the real `MouseEvent` is one
764
+ * answer for the pointer and the keyboard both, and it is why a row needs no click handler at all
765
+ * beyond the two that STOP one.
766
+ *
767
+ * @param {TreeState} scope
768
+ * @param {MouseEvent} event
769
+ */
770
+ export function onTreeClick(scope: TreeState, event: MouseEvent): void {
771
+ const host = hostOf(event);
772
+ if (!host || !(event.target instanceof Element)) {
773
+ return;
774
+ }
775
+ const item = event.target.closest<ItemElement>(ITEM);
776
+ if (!item || item.closest(TREE) !== host || isDisabled(item)) {
777
+ return;
778
+ }
779
+ const from = String(scope.current ?? "");
780
+ moveTo(host, scope, item);
781
+ intend(host, scope, valueOf(item), modeOf(event), from);
782
+ }
783
+
784
+ /**
785
+ * A double click means "do the thing this row is for", exactly as Enter does.
786
+ *
787
+ * One name for both, because a pointer-only reader and a keyboard-only reader must be able to reach
788
+ * the same verb, and a tree whose only activation was Enter would be an SC 2.1.1 failure read
789
+ * backwards.
790
+ *
791
+ * @param {TreeState} _scope
792
+ * @param {MouseEvent} event
793
+ */
794
+ export function onTreeDblClick(_scope: TreeState, event: MouseEvent): void {
795
+ const host = hostOf(event);
796
+ if (!host || !(event.target instanceof Element)) {
797
+ return;
798
+ }
799
+ const item = event.target.closest<ItemElement>(ITEM);
800
+ if (!item || item.closest(TREE) !== host || isDisabled(item)) {
801
+ return;
802
+ }
803
+ announce(host, "activate", valueOf(item));
804
+ }
805
+
806
+ /**
807
+ * A row's twisty was pressed: turn its internal `toggle` into the tree's own `expand`.
808
+ *
809
+ * The row says only which row it was; the tree says what should HAPPEN to it, and it says it from
810
+ * itself, so `expand` has one dispatcher and `e.target` is the element the host wrote — whether the
811
+ * reader used the twisty or `ArrowLeft`.
812
+ *
813
+ * @param {TreeState} _scope
814
+ * @param {Event} event
815
+ */
816
+ export function onTreeToggle(_scope: TreeState, event: Event): void {
817
+ const host = hostOf(event);
818
+ if (!host || !ownsNode(host, event.target) || !(event.target instanceof Element)) {
819
+ return;
820
+ }
821
+ event.stopPropagation();
822
+ const item = event.target.closest<ItemElement>(ITEM);
823
+ if (!item || isDisabled(item)) {
824
+ return;
825
+ }
826
+ announce(host, "expand", {
827
+ expanded: expansionOf(item) !== "true",
828
+ value: valueOf(item),
829
+ } satisfies TreeExpandDetail);
830
+ }