@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,474 @@
1
+ /**
2
+ * `jx-combobox`'s sidecar: the keyboard a field owns over a list it does not contain, and the two
3
+ * measurements that place the panel.
4
+ *
5
+ * The element is the composed half of the pair `jx-listbox` and `jx-option` are the primitive half
6
+ * of. It brings one native `<input role="combobox">`, a `jx-popover` holding a `jx-listbox` of its
7
+ * own rows, and the APG's editable-combobox contract over the two — reaching the list only through
8
+ * `aria-controls` and `aria-activedescendant`, which are id references and therefore only resolve
9
+ * because the kit is light DOM (specs/ui.md §2 principle 2).
10
+ *
11
+ * Four decisions here are decisions rather than defaults, and each is a thing a second answer would
12
+ * be worse at:
13
+ *
14
+ * 1. **The element filters NOTHING.** `options` is the list that will be drawn, not a corpus to match
15
+ * against. Studio's palettes rank with a fuzzy scorer that knows about modes, recents and
16
+ * chords; `ai-credentials-form` shows a catalogue fetched from a provider. An element with its
17
+ * own opinion about matching would be a second ranking every one of them had to defeat, so the
18
+ * host re-answers `options` on `input` and this module never reads the text to decide a row.
19
+ * 2. **Typing highlights nothing.** After a keystroke `activeIndex` is `-1`, so Enter commits what the
20
+ * READER typed until they arrow onto a row. That is what makes `allowsCustomValue` mean
21
+ * something — the reported gap is a model id field whose catalogue is "a set of suggestions,
22
+ * never a whitelist" — and it is equally right for a closed list, which refuses the text on
23
+ * commit rather than silently answering a different question.
24
+ * 3. **Home and End are left alone.** They belong to the text caret in the field, which is where the
25
+ * reader's cursor actually is. The APG gives them to the list only in the variant where the
26
+ * LISTBOX has focus, and this one never does.
27
+ * 4. **A refused value is refused ON THE COMMIT, and the native `change` never escapes.** Reverting
28
+ * after letting the input's own `change` bubble would deliver two committed edits for one
29
+ * gesture — the same defect `jx-textfield`'s clear button cancels a `pointerdown` to avoid. So
30
+ * the refusal stops the inner event at the input and dispatches one `change` from the HOST, and
31
+ * a host reading `e.target.value` reads the value that stands either way.
32
+ *
33
+ * Two more are about a list that arrives LATE, which is what a fetched catalogue is:
34
+ *
35
+ * 5. **A list opened by a gesture lands on the row the field already holds.** The chevron and
36
+ * Alt+ArrowDown put the caret on the row whose value the field holds, so a catalogue that
37
+ * arrived after the reader typed `gpt-4o` shows it as THEIR row when they open the list, rather
38
+ * than a list with nothing chosen. A plain arrow still enters at an end, and a keystroke still
39
+ * lands on nothing (decision 2).
40
+ * 6. **An empty list is not shown.** With no rows there is nothing to open: the chevron is not drawn
41
+ * (`data-empty`), and neither typing nor an arrow shows the panel, so a field whose catalogue
42
+ * has not arrived is a text field until it does — and Escape reaches the dialog around it
43
+ * instead of closing a panel nobody could see. The cost is stated: a host that re-answers
44
+ * `options` on `input` and whose PREVIOUS answer was empty sees the list on the keystroke after
45
+ * the one that produced rows, because the sidecar's own `input` handler runs before the host's;
46
+ * a host that wants it sooner calls {@link openList} itself once it has answered.
47
+ *
48
+ * @docs extending/ui-kit
49
+ */
50
+
51
+ import { close, openAt } from "./popover.ts";
52
+
53
+ /** A row as the element draws it. */
54
+ export interface ComboboxRow {
55
+ /** What the row commits; also what it draws, unless it carries a `label`. */
56
+ value: string;
57
+ /** What the row draws. */
58
+ label?: string;
59
+ /** A muted note at the end of the row. */
60
+ description?: string;
61
+ /** A row that cannot be picked. */
62
+ disabled?: boolean;
63
+ /** The face the row's label draws in. */
64
+ face?: string;
65
+ /** A colour chip before the label. */
66
+ swatch?: string;
67
+ /** A border-style rule before the label. */
68
+ line?: string;
69
+ weight?: string;
70
+ slant?: string;
71
+ variant?: string;
72
+ transform?: string;
73
+ decoration?: string;
74
+ }
75
+
76
+ /** The reactive scope a `jx-combobox` document hands its handlers. */
77
+ export interface ComboboxState {
78
+ value?: string;
79
+ committed?: string;
80
+ options?: ComboboxRow[];
81
+ open?: boolean;
82
+ activeIndex?: number;
83
+ allowsCustomValue?: boolean;
84
+ disabled?: boolean;
85
+ readonly?: boolean;
86
+ uid?: string;
87
+ [key: string]: unknown;
88
+ }
89
+
90
+ const HOST = "jx-combobox";
91
+
92
+ /** How many stems have been minted, so no two instances share one. */
93
+ let minted = 0;
94
+
95
+ /** The `jx-combobox` an event is being handled inside, whichever node bound the handler. */
96
+ function comboboxOf(event: Event): HTMLElement | null {
97
+ const target = event.currentTarget;
98
+ return target instanceof Element ? target.closest<HTMLElement>(HOST) : null;
99
+ }
100
+
101
+ /** The element's own text control. */
102
+ export function inputOf(host: HTMLElement): HTMLInputElement | null {
103
+ return host.querySelector<HTMLInputElement>('input[part="input"]');
104
+ }
105
+
106
+ /** The element's own panel. */
107
+ function panelOf(host: HTMLElement): HTMLElement | null {
108
+ return host.querySelector<HTMLElement>('[part="popup"]');
109
+ }
110
+
111
+ /** The rows the element was handed, whatever it was handed. */
112
+ function rowsOf(state: ComboboxState): ComboboxRow[] {
113
+ return Array.isArray(state.options) ? state.options : [];
114
+ }
115
+
116
+ /** Dispatch the element's own events, so `e.target` is the element and `e.target.value` its value. */
117
+ function announce(host: HTMLElement, ...names: string[]): void {
118
+ for (const name of names) {
119
+ host.dispatchEvent(new Event(name, { bubbles: true }));
120
+ }
121
+ }
122
+
123
+ /** Whether the element may open its list at all. */
124
+ function operable(state: ComboboxState): boolean {
125
+ return state.disabled !== true && state.readonly !== true;
126
+ }
127
+
128
+ /**
129
+ * Show the panel under the field.
130
+ *
131
+ * The coordinates are written before the panel is shown, which is what `jx-popover` asks of whoever
132
+ * opens it: its own toggle handler then clamps a real position into the viewport rather than the
133
+ * origin.
134
+ *
135
+ * The source is the HOST rather than the inner input, and both halves of that matter. `matchWidth`
136
+ * measures whatever the toggle names as its source, and the list has to be as wide as the FIELD —
137
+ * the text box plus the chevron beside it — rather than as wide as the text box alone. And light
138
+ * dismissal treats a popover's invoker subtree as part of the panel, so naming the host is what
139
+ * keeps a press on the input, or on the chevron, from dismissing the list it just opened.
140
+ *
141
+ * @param host The `jx-combobox` element.
142
+ */
143
+ export function openList(host: HTMLElement): void {
144
+ const panel = panelOf(host);
145
+ if (!panel) {
146
+ return;
147
+ }
148
+ const box = host.getBoundingClientRect();
149
+ const placed = panel as HTMLElement & { x: number; y: number };
150
+ placed.x = Math.round(box.left);
151
+ placed.y = Math.round(box.bottom);
152
+ openAt(panel, host);
153
+ }
154
+
155
+ /** Hide the panel. `open` follows the platform's own toggle, never a write. */
156
+ export function closeList(host: HTMLElement): void {
157
+ const panel = panelOf(host);
158
+ if (panel) {
159
+ close(panel);
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Show the list, if there is anything to list.
165
+ *
166
+ * Every opener the element owns goes through here rather than {@link openList}: a list with no rows
167
+ * is a bordered box with nothing in it, and an `aria-expanded` field over an empty listbox is a
168
+ * panel a reader would have to Escape out of without ever seeing it.
169
+ *
170
+ * @param state The element's reactive scope.
171
+ * @param host The `jx-combobox` element.
172
+ * @returns Whether the list was shown.
173
+ */
174
+ function showList(state: ComboboxState, host: HTMLElement): boolean {
175
+ if (!operable(state) || rowsOf(state).length === 0) {
176
+ return false;
177
+ }
178
+ openList(host);
179
+ return true;
180
+ }
181
+
182
+ /**
183
+ * The row a list opened by a GESTURE lands on: the one the field's value already names, or none.
184
+ *
185
+ * Compared through {@link rowFor}, so the row is found in whatever case the reader typed it, and a
186
+ * disabled row is not a landing — the caret could not have arrowed onto it either.
187
+ *
188
+ * @param state The element's reactive scope.
189
+ * @returns The index of that row, or -1.
190
+ */
191
+ export function landingIndex(state: ComboboxState): number {
192
+ const rows = rowsOf(state);
193
+ const row = rowFor(rows, String(state.value ?? ""));
194
+ return row && row.disabled !== true ? rows.indexOf(row) : -1;
195
+ }
196
+
197
+ /**
198
+ * The next row a caret may land on, stepping over disabled rows and wrapping at both ends.
199
+ *
200
+ * `-1` in means "nothing is highlighted", and the first step from there lands on an end rather than
201
+ * beside one: down goes to the first row, up to the last. `-1` out means there is nowhere to go.
202
+ *
203
+ * @param rows The rows as they are drawn.
204
+ * @param from The index the caret is on, or -1.
205
+ * @param step +1 or -1.
206
+ * @returns The index to move to, or -1 when no row can take the caret.
207
+ */
208
+ export function nextIndex(rows: ComboboxRow[], from: number, step: number): number {
209
+ const start = from < 0 ? (step > 0 ? -1 : 0) : from;
210
+ for (const offset of rows.keys()) {
211
+ const at = (((start + step * (offset + 1)) % rows.length) + rows.length) % rows.length;
212
+ if (rows[at]?.disabled !== true) {
213
+ return at;
214
+ }
215
+ }
216
+ return -1;
217
+ }
218
+
219
+ /**
220
+ * The row a piece of text names, compared without case.
221
+ *
222
+ * A picker normalises what the reader typed to the row's own spelling — `GPT-4O` commits as the
223
+ * catalogue's `gpt-4o` — because the row is what the value means and the casing is not the reader's
224
+ * to decide. Only `value` is compared: `label` is what a row DRAWS, and a list whose labels were
225
+ * also accepted would make two strings commit the same row and neither of them what is stored.
226
+ *
227
+ * @param rows The rows as they are drawn.
228
+ * @param text The text in the field.
229
+ * @returns The row, or null when no row holds that value.
230
+ */
231
+ export function rowFor(rows: ComboboxRow[], text: string): ComboboxRow | null {
232
+ const wanted = text.toLowerCase();
233
+ return rows.find((row) => String(row.value).toLowerCase() === wanted) ?? null;
234
+ }
235
+
236
+ /**
237
+ * Take a row: its value into the field, the list closed, the caret back where the reader was, and
238
+ * one `input`/`change` pair from the element.
239
+ *
240
+ * @param state The element's reactive scope.
241
+ * @param host The `jx-combobox` element.
242
+ * @param row The row to commit.
243
+ */
244
+ function commitRow(state: ComboboxState, host: HTMLElement, row: ComboboxRow): void {
245
+ const value = String(row.value);
246
+ state.value = value;
247
+ state.committed = value;
248
+ state.activeIndex = -1;
249
+ closeList(host);
250
+ inputOf(host)?.focus();
251
+ announce(host, "input", "change");
252
+ }
253
+
254
+ /**
255
+ * Mint the element's id stem and record the value it starts out holding.
256
+ *
257
+ * The stem is what every id reference in the document is built from — the panel's, the listbox's
258
+ * and one per row — so a consumer writes none of them and two comboboxes on one surface cannot
259
+ * collide. A document cannot mint it: the closed operator set has no counter and no identity, which
260
+ * is the sidecar case specs/ui.md §3.2 sanctions.
261
+ *
262
+ * @param state The element's reactive scope.
263
+ * @param _host The `jx-combobox` element.
264
+ */
265
+ export function mountCombobox(state: ComboboxState, _host: HTMLElement): void {
266
+ minted += 1;
267
+ state.uid = `jx-combobox-${minted}`;
268
+ state.committed = String(state.value ?? "");
269
+ }
270
+
271
+ /**
272
+ * The reader typing: the text becomes the value, nothing is highlighted, and the list opens.
273
+ *
274
+ * @param state The element's reactive scope.
275
+ * @param event The control's own `input`, which goes on to bubble out of the element unchanged.
276
+ */
277
+ export function onComboboxInput(state: ComboboxState, event: Event): void {
278
+ const host = comboboxOf(event);
279
+ const control = event.target;
280
+ if (!host || !(control instanceof HTMLInputElement)) {
281
+ return;
282
+ }
283
+ state.value = control.value;
284
+ state.activeIndex = -1;
285
+ showList(state, host);
286
+ }
287
+
288
+ /**
289
+ * The panel's own toggle: the one source of truth for `open`.
290
+ *
291
+ * A list light-dismissed by a click outside, or closed with Escape, moves this exactly as a call to
292
+ * {@link closeList} does — which is why `open` is mirrored rather than written.
293
+ *
294
+ * @param state The element's reactive scope.
295
+ * @param event The platform's ToggleEvent, from the panel.
296
+ */
297
+ export function onComboboxToggle(state: ComboboxState, event: Event): void {
298
+ const opening = (event as { newState?: string }).newState === "open";
299
+ state.open = opening;
300
+ if (!opening) {
301
+ state.activeIndex = -1;
302
+ }
303
+ }
304
+
305
+ /**
306
+ * The chevron: open the list on the row the field already holds, or close it when it is already
307
+ * open.
308
+ *
309
+ * @param state The element's reactive scope.
310
+ * @param event The click.
311
+ */
312
+ export function onComboboxButton(state: ComboboxState, event: Event): void {
313
+ const host = comboboxOf(event);
314
+ if (!host || !operable(state)) {
315
+ return;
316
+ }
317
+ if (state.open === true) {
318
+ closeList(host);
319
+ return;
320
+ }
321
+ if (showList(state, host)) {
322
+ state.activeIndex = landingIndex(state);
323
+ }
324
+ inputOf(host)?.focus();
325
+ }
326
+
327
+ /**
328
+ * A row was activated — by a click, or by Enter through {@link onComboboxKeydown}.
329
+ *
330
+ * @param state The element's reactive scope.
331
+ * @param event The row's own bubbling `select`, whose detail is its value.
332
+ */
333
+ export function onComboboxPick(state: ComboboxState, event: Event): void {
334
+ const host = comboboxOf(event);
335
+ const picked = String((event as CustomEvent<unknown>).detail ?? "");
336
+ const row = rowFor(rowsOf(state), picked);
337
+ if (!host || !row) {
338
+ return;
339
+ }
340
+ commitRow(state, host, row);
341
+ }
342
+
343
+ /**
344
+ * The commit: what the reader typed either stands or is refused, and a closed list is what refuses
345
+ * it.
346
+ *
347
+ * Bound to the CONTROL's own `change`, which the platform fires on blur and on Enter. When the
348
+ * value is accepted the event bubbles out of the element as itself, exactly as `jx-textfield`'s
349
+ * does, so `e.target` is the inner input and its `validity` and `form` are live. When it is refused
350
+ * the event stops at the control and the element dispatches one of its own instead — never both,
351
+ * because two `change` events for one edit are two undo entries for one gesture.
352
+ *
353
+ * @param state The element's reactive scope.
354
+ * @param event The control's `change`.
355
+ */
356
+ export function onComboboxCommit(state: ComboboxState, event: Event): void {
357
+ const host = comboboxOf(event);
358
+ if (!host) {
359
+ return;
360
+ }
361
+ /* The CONTROL's text, not the scope's: they agree whenever `input` preceded this `change`, which
362
+ is every keystroke and every paste, but a `change` that arrives on its own — an engine or a
363
+ test that set the control and reported only the commit — is still the reader's text, and the
364
+ scope catching up here is what keeps the element's `value` from contradicting its own box. */
365
+ const control = event.target instanceof HTMLInputElement ? event.target : null;
366
+ const text = control ? control.value : String(state.value ?? "");
367
+ const row = rowFor(rowsOf(state), text);
368
+ if (state.allowsCustomValue === true || text === "" || row) {
369
+ /* A listed value commits in the ROW's spelling, so a picker normalises the case the reader
370
+ typed; a custom value commits verbatim. The list closes with the commit: a value that stands
371
+ has nothing left to suggest, and a `change` that arrived without Enter — a blur onto
372
+ something light dismissal does not count as outside — would otherwise leave it open. */
373
+ state.value = row ? String(row.value) : text;
374
+ state.committed = String(state.value);
375
+ closeList(host);
376
+ return;
377
+ }
378
+ event.stopPropagation();
379
+ state.value = String(state.committed ?? "");
380
+ announce(host, "change");
381
+ }
382
+
383
+ /**
384
+ * The combobox keyboard, on the field that owns it.
385
+ *
386
+ * @param {ComboboxState} state
387
+ * @param {KeyboardEvent} event
388
+ */
389
+ export function onComboboxKeydown(state: ComboboxState, event: KeyboardEvent): void {
390
+ const host = comboboxOf(event);
391
+ if (!host || event.ctrlKey || event.metaKey) {
392
+ return;
393
+ }
394
+ const rows = rowsOf(state);
395
+ const at = Number(state.activeIndex ?? -1);
396
+ switch (event.key) {
397
+ case "ArrowDown": {
398
+ if (!operable(state)) {
399
+ return;
400
+ }
401
+ if (state.open !== true) {
402
+ /* Nothing to list is the platform's own key, not a cancelled one. */
403
+ if (!showList(state, host)) {
404
+ return;
405
+ }
406
+ state.activeIndex = event.altKey ? landingIndex(state) : nextIndex(rows, -1, 1);
407
+ } else if (!event.altKey) {
408
+ state.activeIndex = nextIndex(rows, at, 1);
409
+ }
410
+ break;
411
+ }
412
+ case "ArrowUp": {
413
+ if (!operable(state)) {
414
+ return;
415
+ }
416
+ if (event.altKey) {
417
+ closeList(host);
418
+ break;
419
+ }
420
+ if (state.open !== true) {
421
+ if (!showList(state, host)) {
422
+ return;
423
+ }
424
+ state.activeIndex = nextIndex(rows, -1, -1);
425
+ } else {
426
+ state.activeIndex = nextIndex(rows, at, -1);
427
+ }
428
+ break;
429
+ }
430
+ case "Enter": {
431
+ const row = state.open === true && at >= 0 ? rows[at] : undefined;
432
+ if (!row) {
433
+ /* Nothing highlighted: the platform's Enter, uncancelled, so the control's own `change`
434
+ follows and commits what the reader typed. The list closes here all the same — a
435
+ browser found it standing open over the rows below after the reader had committed with
436
+ Enter, because the commit is `change`'s and `change` does not fire when the text is what
437
+ it already was. */
438
+ if (state.open === true) {
439
+ closeList(host);
440
+ }
441
+ return;
442
+ }
443
+ commitRow(state, host, row);
444
+ break;
445
+ }
446
+ case "Tab": {
447
+ /* The one key that commits WITHOUT being cancelled: the reader is leaving, and taking the row
448
+ they had highlighted with them is the APG's answer for a list-autocomplete combobox. The
449
+ list closes either way — a browser found it still standing over the fields below once
450
+ focus had moved on, because light dismissal answers a press outside and not a Tab. */
451
+ const row = state.open === true && at >= 0 ? rows[at] : undefined;
452
+ if (row) {
453
+ commitRow(state, host, row);
454
+ } else {
455
+ closeList(host);
456
+ }
457
+ return;
458
+ }
459
+ case "Escape": {
460
+ if (state.open !== true) {
461
+ return;
462
+ }
463
+ closeList(host);
464
+ /* Stopped as well as cancelled: a combobox inside a dialog must not close the dialog with the
465
+ same press that closed its own list. */
466
+ event.stopPropagation();
467
+ break;
468
+ }
469
+ default: {
470
+ return;
471
+ }
472
+ }
473
+ event.preventDefault();
474
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * `jx-dialog`'s behaviour sidecar: the one place the element calls the platform's own `showModal()`
3
+ * and `close()`, and the handlers that mirror the platform's events back onto the element's state
4
+ * and out to the host.
5
+ *
6
+ * Nothing here traps focus, listens on the document or decides what Escape means. A modal
7
+ * `<dialog>` makes the rest of the page inert, restores focus when it closes, and answers Escape
8
+ * and light dismissal through `closedby`; the sidecar's job is only to say so in the element's own
9
+ * vocabulary — `open` on the state, `confirm`, `secondary`, `cancel` and `close` on the host.
10
+ *
11
+ * @docs extending/ui-kit
12
+ */
13
+
14
+ export interface DialogState {
15
+ open: boolean;
16
+ dismissible: boolean;
17
+ }
18
+
19
+ /** The `jx-dialog` an event belongs to. */
20
+ function hostOf(event: Event): HTMLElement | null {
21
+ const target = event.currentTarget;
22
+ return target instanceof Element ? target.closest<HTMLElement>("jx-dialog") : null;
23
+ }
24
+
25
+ /** The element's own native dialog. */
26
+ function innerOf(host: HTMLElement): HTMLDialogElement | null {
27
+ return host.querySelector<HTMLDialogElement>('dialog[part="dialog"]');
28
+ }
29
+
30
+ function emit(host: HTMLElement | null, name: string): void {
31
+ host?.dispatchEvent(new CustomEvent(name, { bubbles: true }));
32
+ }
33
+
34
+ /**
35
+ * A `toggle` event, dispatched by hand where the platform does not fire one for dialogs.
36
+ *
37
+ * Chrome fires `beforetoggle`/`toggle` on a dialog since 2024; the test DOM does not, and the
38
+ * element's `open` state is read from that event. Dispatched only when the platform's own is not
39
+ * coming, so a browser never sees two.
40
+ */
41
+ function syntheticToggle(dialog: HTMLDialogElement, newState: "open" | "closed"): void {
42
+ if (typeof ToggleEvent !== "undefined") {
43
+ return;
44
+ }
45
+ const event = new Event("toggle", { bubbles: false });
46
+ Object.assign(event, { newState, oldState: newState === "open" ? "closed" : "open" });
47
+ dialog.dispatchEvent(event);
48
+ }
49
+
50
+ /**
51
+ * Open the dialog modally. The host's door in: Studio's dialog surface calls it once the element is
52
+ * ready, and a `--show` invoker command reaches it through {@link onCommand}.
53
+ *
54
+ * @param host The `jx-dialog` element.
55
+ */
56
+ export function showModal(host: HTMLElement): void {
57
+ const dialog = innerOf(host);
58
+ if (!dialog || dialog.open) {
59
+ return;
60
+ }
61
+ dialog.showModal();
62
+ syntheticToggle(dialog, "open");
63
+ }
64
+
65
+ /**
66
+ * Close the dialog, with an optional return value the platform records on the native element.
67
+ *
68
+ * @param host The `jx-dialog` element.
69
+ * @param returnValue What closed it, for `dialog.returnValue`.
70
+ */
71
+ export function close(host: HTMLElement, returnValue?: string): void {
72
+ const dialog = innerOf(host);
73
+ if (!dialog?.open) {
74
+ return;
75
+ }
76
+ dialog.close(returnValue);
77
+ syntheticToggle(dialog, "closed");
78
+ }
79
+
80
+ /** The platform's `toggle` on the inner dialog: the one source of truth for `open`. */
81
+ export function onToggle(state: DialogState, event: Event): void {
82
+ state.open = (event as { newState?: string }).newState === "open";
83
+ }
84
+
85
+ /**
86
+ * The platform's `cancel` on the inner dialog — Escape, or a light dismissal `closedby` allows. The
87
+ * platform closes the dialog itself after this; the element only says so.
88
+ */
89
+ export function onNativeCancel(state: DialogState, event: Event): void {
90
+ state.open = false;
91
+ emit(hostOf(event), "cancel");
92
+ }
93
+
94
+ /** The platform's `close` on the inner dialog, whatever closed it. */
95
+ export function onNativeClose(state: DialogState, event: Event): void {
96
+ state.open = false;
97
+ emit(hostOf(event), "close");
98
+ }
99
+
100
+ /**
101
+ * An invoker command aimed at the host: `--show` opens the dialog modally, `--close` closes it.
102
+ *
103
+ * Custom commands are the only ones the platform delivers to an element that is not itself a
104
+ * `<dialog>`, which the host of an inner one is not; so `<button command="--show"
105
+ * commandfor="my-dialog">` is how a page opens a `jx-dialog` with no script.
106
+ */
107
+ export function onCommand(_state: DialogState, event: Event): void {
108
+ const host = hostOf(event);
109
+ const command = (event as { command?: string }).command ?? "";
110
+ if (!host) {
111
+ return;
112
+ }
113
+ if (command === "--show") {
114
+ showModal(host);
115
+ } else if (command === "--close") {
116
+ close(host);
117
+ }
118
+ }
119
+
120
+ /** The confirm button. The host decides whether the dialog closes: a refused value keeps it open. */
121
+ export function onConfirm(_state: DialogState, event: Event): void {
122
+ emit(hostOf(event), "confirm");
123
+ }
124
+
125
+ /** The secondary button, the dialog's third answer (Save · Discard · Cancel). */
126
+ export function onSecondary(_state: DialogState, event: Event): void {
127
+ emit(hostOf(event), "secondary");
128
+ }
129
+
130
+ /** The cancel button: says so, then closes, so the platform's `close` follows. */
131
+ export function onCancelClick(_state: DialogState, event: Event): void {
132
+ const host = hostOf(event);
133
+ emit(host, "cancel");
134
+ if (host) {
135
+ close(host, "cancel");
136
+ }
137
+ }