@jxsuite/ui 0.0.0 → 0.1.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/components/jx-accordion-item.json +281 -0
- package/components/jx-accordion.json +33 -0
- package/components/jx-action-button.json +261 -33
- package/components/jx-action-group.json +123 -0
- package/components/jx-button.json +133 -25
- package/components/jx-checkbox.json +215 -0
- package/components/jx-color-area.json +263 -0
- package/components/jx-color-field.json +565 -0
- package/components/jx-color-slider.json +216 -0
- package/components/jx-combobox.json +544 -0
- package/components/jx-dialog.json +416 -0
- package/components/jx-divider.json +52 -0
- package/components/jx-dot.json +38 -0
- package/components/jx-field.json +162 -0
- package/components/jx-kbd.json +24 -0
- package/components/jx-listbox.json +76 -0
- package/components/jx-menu-item.json +5 -12
- package/components/jx-menu.json +45 -6
- package/components/jx-number-field.json +441 -0
- package/components/jx-option.json +350 -0
- package/components/jx-popover.json +227 -0
- package/components/jx-select.json +637 -0
- package/components/jx-spinner.json +129 -0
- package/components/jx-split.json +221 -0
- package/components/jx-swatch-group.json +107 -0
- package/components/jx-swatch.json +262 -0
- package/components/jx-switch.json +253 -0
- package/components/jx-tab-panel.json +58 -0
- package/components/jx-tab.json +296 -0
- package/components/jx-tabs.json +124 -0
- package/components/jx-textfield.json +639 -0
- package/components/jx-toast-host.json +121 -0
- package/components/jx-toast.json +255 -0
- package/components/jx-toolbar.json +81 -0
- package/components/jx-tooltip.json +207 -0
- package/components/jx-tree-item.json +325 -0
- package/components/jx-tree.json +202 -0
- package/icons/list.json +4 -0
- package/icons/manifest.json +12 -0
- package/package.json +27 -5
- package/project.json +164 -153
- package/src/behaviors/action-group.ts +255 -0
- package/src/behaviors/color-area.ts +303 -0
- package/src/behaviors/color-field.ts +465 -0
- package/src/behaviors/color-slider.ts +154 -0
- package/src/behaviors/combobox.ts +474 -0
- package/src/behaviors/dialog.ts +137 -0
- package/src/behaviors/listbox.ts +118 -0
- package/src/behaviors/menu.ts +80 -48
- package/src/behaviors/number-field.ts +161 -0
- package/src/behaviors/popover.ts +399 -0
- package/src/behaviors/select.ts +154 -0
- package/src/behaviors/split.ts +478 -0
- package/src/behaviors/swatch-group.ts +297 -0
- package/src/behaviors/tabs.ts +319 -0
- package/src/behaviors/textfield.ts +164 -0
- package/src/behaviors/toast-host.ts +241 -0
- package/src/behaviors/toast.ts +261 -0
- package/src/behaviors/toolbar.ts +413 -0
- package/src/behaviors/tooltip.ts +715 -0
- package/src/behaviors/tree.ts +830 -0
- package/src/color.ts +433 -0
- package/src/documents.ts +91 -1
- package/src/index.ts +41 -1
- package/src/loaders.ts +47 -0
- package/src/testing/anchor-support.ts +28 -0
- package/src/testing/popover-shim.ts +39 -6
- package/src/theme.ts +30 -6
- package/stylebook/jx-accordion-item.json +195 -0
- package/stylebook/jx-accordion.json +220 -0
- package/stylebook/jx-action-button.json +191 -23
- package/stylebook/jx-action-group.json +335 -0
- package/stylebook/jx-button.json +129 -26
- package/stylebook/jx-checkbox.json +245 -0
- package/stylebook/jx-color-area.json +272 -0
- package/stylebook/jx-color-field.json +261 -0
- package/stylebook/jx-color-slider.json +220 -0
- package/stylebook/jx-combobox.json +332 -0
- package/stylebook/jx-dialog.json +186 -0
- package/stylebook/jx-divider.json +125 -0
- package/stylebook/jx-dot.json +92 -0
- package/stylebook/jx-field.json +313 -0
- package/stylebook/jx-icon.json +4 -0
- package/stylebook/jx-kbd.json +90 -0
- package/stylebook/jx-listbox.json +337 -0
- package/stylebook/jx-menu.json +0 -1
- package/stylebook/jx-number-field.json +203 -0
- package/stylebook/jx-option.json +285 -0
- package/stylebook/jx-popover.json +123 -0
- package/stylebook/jx-select.json +407 -0
- package/stylebook/jx-spinner.json +111 -0
- package/stylebook/jx-split.json +170 -0
- package/stylebook/jx-swatch-group.json +175 -0
- package/stylebook/jx-swatch.json +249 -0
- package/stylebook/jx-switch.json +240 -0
- package/stylebook/jx-tab-panel.json +147 -0
- package/stylebook/jx-tab.json +201 -0
- package/stylebook/jx-tabs.json +278 -0
- package/stylebook/jx-textfield.json +309 -0
- package/stylebook/jx-toast-host.json +157 -0
- package/stylebook/jx-toast.json +266 -0
- package/stylebook/jx-toolbar.json +435 -0
- package/stylebook/jx-tooltip.json +162 -0
- package/stylebook/jx-tree-item.json +258 -0
- 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
|
+
}
|