@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,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jx-toolbar`'s behaviour sidecar: the roving caret over a row of MIXED controls, and the one
|
|
3
|
+
* question `jx-action-group` never has to ask — does the control the caret is standing on want this
|
|
4
|
+
* key for itself?
|
|
5
|
+
*
|
|
6
|
+
* **Why this is not `jx-action-group`.** That element roves `jx-action-button` and nothing else:
|
|
7
|
+
* its `itemsOf` is a query for one tag, its `compact` seam is a rule about that tag's borders, and
|
|
8
|
+
* its `selects="single"` clicks the child an arrow lands on. Both surfaces this element was built
|
|
9
|
+
* for — Studio's grid toolbar and the Project Styles chrome bar — hold a `jx-textfield` beside
|
|
10
|
+
* their buttons, and a text field is precisely the child that breaks every one of those three: it
|
|
11
|
+
* is not an action button, it has no border to join, and an arrow key inside it is a caret move
|
|
12
|
+
* rather than a selection. So the two elements are not one element under two names.
|
|
13
|
+
* `jx-action-group` is a ROW OF BUTTONS that may be joined into a segmented control or made a
|
|
14
|
+
* radiogroup; `jx-toolbar` is a CONTAINER OF ARBITRARY CONTROLS with one tab stop.
|
|
15
|
+
*
|
|
16
|
+
* What it does beyond reading state is the allowed set (specs/ui.md §2): move focus, write a
|
|
17
|
+
* child's own declared `tabindex` prop (or, for a plain native control, its own `tabIndex`), and
|
|
18
|
+
* read a text control's caret offset, which is a measurement. It measures no box, listens on no
|
|
19
|
+
* document, and calls no platform overlay API. It owns no overflow menu, and §5.5 says why.
|
|
20
|
+
*
|
|
21
|
+
* @docs extending/ui-kit
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The tag of the toolbar. */
|
|
25
|
+
const TOOLBAR = "jx-toolbar";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Anything that could take DOM focus. Deliberately not keyed on `tabindex`, because the roving
|
|
29
|
+
* caret WRITES `tabindex` — a query that read it would stop finding the items it had just parked.
|
|
30
|
+
*/
|
|
31
|
+
const FOCUSABLE =
|
|
32
|
+
"button, input, select, textarea, a[href], [contenteditable=''], [contenteditable='true']";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Containers that run a roving caret of their own.
|
|
36
|
+
*
|
|
37
|
+
* A control inside one of these belongs to IT, not to the toolbar. Two roving carets over one set
|
|
38
|
+
* of buttons is two writers of the same `tabindex`, and they do not agree: the group parks its
|
|
39
|
+
* caret on the child it thinks is current and the toolbar parks the row's on another. So a nested
|
|
40
|
+
* composite is left alone entirely — it keeps its own single tab stop, and the toolbar's arrows do
|
|
41
|
+
* not reach into it. See §5.5 for why that limit is stated rather than engineered around.
|
|
42
|
+
*/
|
|
43
|
+
const COMPOSITE = "jx-action-group, jx-tabs, jx-tree, jx-swatch-group, jx-listbox, jx-menu";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Controls whose own arrow keys are theirs: they step a value or drive a popup, rather than moving
|
|
47
|
+
* a caret through text.
|
|
48
|
+
*
|
|
49
|
+
* These are not toolbar items at all, and the reason is not symmetry with {@link COMPOSITE} but
|
|
50
|
+
* data loss: a reader arrowing along the row past a `<select>` or a spin button would rewrite the
|
|
51
|
+
* value on the way past, once per press, and past a combobox would open and walk its list. An extra
|
|
52
|
+
* tab stop is an inconvenience; silently changing what somebody chose is a defect. A text field is
|
|
53
|
+
* the opposite case and IS an item — walking off the end of its text costs nothing — which is what
|
|
54
|
+
* the boundary rule in {@link cedesKey} is for.
|
|
55
|
+
*/
|
|
56
|
+
const OWN_ARROWS =
|
|
57
|
+
"select, input[type='range'], input[type='number'], [role='spinbutton'], [role='slider'], [role='combobox']";
|
|
58
|
+
|
|
59
|
+
/** `<input>` types that have a text caret a reader moves with the arrow keys. */
|
|
60
|
+
const TEXT_TYPES = new Set(["", "text", "search", "url", "tel", "password", "email"]);
|
|
61
|
+
|
|
62
|
+
/** The reactive scope a `jx-toolbar` document hands its handlers. */
|
|
63
|
+
export interface ToolbarState {
|
|
64
|
+
orientation?: string;
|
|
65
|
+
label?: string;
|
|
66
|
+
[key: string]: unknown;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A toolbar item: a control, or the kit element that wraps one. */
|
|
70
|
+
type ItemElement = HTMLElement & { tabindex?: string; disabled?: boolean };
|
|
71
|
+
|
|
72
|
+
/** The toolbar an event's `currentTarget` is. */
|
|
73
|
+
function barOf(event: Event): HTMLElement | null {
|
|
74
|
+
const target = event.currentTarget;
|
|
75
|
+
return target instanceof HTMLElement && target.localName === TOOLBAR ? target : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether `bar` is the nearest toolbar around `node` — a key inside a nested one is its own. */
|
|
79
|
+
function ownsNode(bar: HTMLElement, node: EventTarget | null): boolean {
|
|
80
|
+
return node instanceof Element && node.closest(TOOLBAR) === bar;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The element a focusable node stands for: the OUTERMOST custom element between it and the toolbar,
|
|
85
|
+
* or the node itself when there is none.
|
|
86
|
+
*
|
|
87
|
+
* This is what makes one item out of the several focusable nodes a kit control owns — a
|
|
88
|
+
* `jx-textfield` is its `<input>` AND its clear `<button>`, and a toolbar that counted both would
|
|
89
|
+
* park a caret on a button that only exists while the field has a value. It is also what sees
|
|
90
|
+
* through the `display: contents` wrappers a `$switch` branch leaves around a single control, which
|
|
91
|
+
* Studio's grid toolbar has four of.
|
|
92
|
+
*
|
|
93
|
+
* @param bar The toolbar.
|
|
94
|
+
* @param node A focusable node inside it.
|
|
95
|
+
* @returns The item that node belongs to.
|
|
96
|
+
*/
|
|
97
|
+
function itemFor(bar: HTMLElement, node: HTMLElement): ItemElement {
|
|
98
|
+
let item: ItemElement = node;
|
|
99
|
+
for (let el = node.parentElement; el && el !== bar; el = el.parentElement) {
|
|
100
|
+
if (el.localName.includes("-")) {
|
|
101
|
+
item = el as ItemElement;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return item;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The node inside an item that actually takes focus.
|
|
109
|
+
*
|
|
110
|
+
* `[part="control"]` and `[part="input"]` are the kit's own two names for "this element's own
|
|
111
|
+
* control" (`jx-button`, `jx-action-button`, `jx-swatch` and `jx-color-field` use the first,
|
|
112
|
+
* `jx-textfield` and `jx-number-field` the second), so they are asked for first — but only if the
|
|
113
|
+
* node wearing the name is FOCUSABLE. A composed control names the one node it wants roved: the
|
|
114
|
+
* colour field is a swatch, a text field and two doors, and the swatch wears the name because it is
|
|
115
|
+
* the opener, so the query finds it ahead of the text field's `[part="input"]`. On `jx-checkbox`
|
|
116
|
+
* and `jx-switch` `[part="control"]` is the `<label>` that wraps the input, so taking the named
|
|
117
|
+
* node on trust focused a label, which focuses nothing, and left the real control where it was. A
|
|
118
|
+
* plain native control is its own answer, and anything else falls back to the first focusable node
|
|
119
|
+
* the item holds.
|
|
120
|
+
*
|
|
121
|
+
* @param item A toolbar item.
|
|
122
|
+
* @returns The node to focus and to ask about a key.
|
|
123
|
+
*/
|
|
124
|
+
export function controlOf(item: ItemElement): HTMLElement {
|
|
125
|
+
if (item.matches(FOCUSABLE)) {
|
|
126
|
+
return item;
|
|
127
|
+
}
|
|
128
|
+
const named = [...item.querySelectorAll<HTMLElement>('[part="control"], [part="input"]')].find(
|
|
129
|
+
(node) => node.matches(FOCUSABLE),
|
|
130
|
+
);
|
|
131
|
+
return named ?? item.querySelector<HTMLElement>(FOCUSABLE) ?? item;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The toolbar's own items, in document order.
|
|
136
|
+
*
|
|
137
|
+
* @param bar The `jx-toolbar` element.
|
|
138
|
+
* @returns The controls it roves; a nested toolbar's, a nested composite's and a control that owns
|
|
139
|
+
* its own arrow keys are not among them.
|
|
140
|
+
*/
|
|
141
|
+
export function itemsOf(bar: HTMLElement): ItemElement[] {
|
|
142
|
+
const items: ItemElement[] = [];
|
|
143
|
+
for (const node of bar.querySelectorAll<HTMLElement>(FOCUSABLE)) {
|
|
144
|
+
if (node.closest(TOOLBAR) !== bar) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const composite = node.closest<HTMLElement>(COMPOSITE);
|
|
148
|
+
if (composite && bar.contains(composite)) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const item = itemFor(bar, node);
|
|
152
|
+
if (!items.includes(item) && !controlOf(item).matches(OWN_ARROWS)) {
|
|
153
|
+
items.push(item);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return items;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Whether an item cannot take the caret.
|
|
161
|
+
*
|
|
162
|
+
* Read from the PROPERTY once the child has upgraded and from the attribute before it has, for the
|
|
163
|
+
* reason `behaviors/action-group.ts` gives: the caret is placed from a lifecycle event and a
|
|
164
|
+
* slotted child's own `connectedCallback` is async, so the toolbar can be asked to rove either side
|
|
165
|
+
* of that moment. `hidden` counts too — a control a branch has hidden rather than removed is one
|
|
166
|
+
* the reader cannot see, and a caret parked on it is a tab stop that goes nowhere.
|
|
167
|
+
*/
|
|
168
|
+
function isOut(item: ItemElement): boolean {
|
|
169
|
+
if (item.hidden) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
return item.disabled === undefined ? item.hasAttribute("disabled") : item.disabled === true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The roving `tabindex` an item is currently carrying, as the string it was written as. */
|
|
176
|
+
function caretOf(item: ItemElement): string {
|
|
177
|
+
return "tabindex" in item ? String(item.tabindex ?? "") : (item.getAttribute("tabindex") ?? "");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Write the roving `tabindex` onto one item.
|
|
182
|
+
*
|
|
183
|
+
* A kit control declares a `tabindex` PROP for exactly this — it forwards the value to the native
|
|
184
|
+
* control inside itself, so the toolbar never reaches into another element's internals. A plain
|
|
185
|
+
* native control in the row is its own control, so its own `tabIndex` is that same write.
|
|
186
|
+
*/
|
|
187
|
+
function setCaret(item: ItemElement, value: string): void {
|
|
188
|
+
if ("tabindex" in item) {
|
|
189
|
+
item.tabindex = value;
|
|
190
|
+
} else if (item.matches(FOCUSABLE)) {
|
|
191
|
+
item.tabIndex = Number(value);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Write `0` on `caret` and `-1` on every other item, the ones that cannot act included. */
|
|
196
|
+
function writeRoving(items: ItemElement[], caret: ItemElement | null): void {
|
|
197
|
+
for (const item of items) {
|
|
198
|
+
setCaret(item, item === caret ? "0" : "-1");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The item that should hold the caret: the one that already does and still can, else the first. */
|
|
203
|
+
function caretFor(items: ItemElement[]): ItemElement | null {
|
|
204
|
+
const live = items.filter((item) => !isOut(item));
|
|
205
|
+
return live.find((item) => caretOf(item) === "0") ?? live[0] ?? null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Move the caret to the reachable item at `index`, wrapping at both ends, and focus its control.
|
|
210
|
+
*
|
|
211
|
+
* @param bar The toolbar whose caret is moving.
|
|
212
|
+
* @param index An index into the REACHABLE items; negative counts from the end.
|
|
213
|
+
* @returns The item that took the caret, or null when the toolbar has none that can.
|
|
214
|
+
*/
|
|
215
|
+
export function focusItem(bar: HTMLElement, index: number): ItemElement | null {
|
|
216
|
+
const items = itemsOf(bar);
|
|
217
|
+
const live = items.filter((item) => !isOut(item));
|
|
218
|
+
if (live.length === 0) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
const at = ((index % live.length) + live.length) % live.length;
|
|
222
|
+
const caret = live[at]!;
|
|
223
|
+
writeRoving(items, caret);
|
|
224
|
+
controlOf(caret).focus();
|
|
225
|
+
return caret;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Whether a text control's caret has nowhere left to go in the direction `forward` names.
|
|
230
|
+
*
|
|
231
|
+
* A selection that cannot be read counts as the edge, and that direction is chosen deliberately: a
|
|
232
|
+
* toolbar that ceded a key it could not reason about would leave the reader inside a control with
|
|
233
|
+
* no arrow key out of it, which is SC 2.1.2. Chrome refuses `selectionStart` on `type="email"`, and
|
|
234
|
+
* happy-dom answers null for the types it does not model, so this is a case that really occurs.
|
|
235
|
+
*/
|
|
236
|
+
function atEdge(node: HTMLInputElement | HTMLTextAreaElement, forward: boolean): boolean {
|
|
237
|
+
let start: number | null = null;
|
|
238
|
+
let end: number | null = null;
|
|
239
|
+
try {
|
|
240
|
+
start = node.selectionStart;
|
|
241
|
+
end = node.selectionEnd;
|
|
242
|
+
} catch {
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
if (start === null || end === null) {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
if (start !== end) {
|
|
249
|
+
// A live selection is a gesture in progress; the arrow that collapses it is the control's.
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
return forward ? start >= String(node.value ?? "").length : start <= 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Whether `node` is a control with a text caret the arrow keys move. */
|
|
256
|
+
function isTextEntry(node: HTMLElement): node is HTMLInputElement | HTMLTextAreaElement {
|
|
257
|
+
if (node.localName === "textarea") {
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
return (
|
|
261
|
+
node.localName === "input" && TEXT_TYPES.has((node.getAttribute("type") ?? "").toLowerCase())
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Whether the focused control answers this key itself, so the toolbar must not.
|
|
267
|
+
*
|
|
268
|
+
* The whole of the mixed-row problem is here. A toolbar owns the arrow pair of its own axis, and
|
|
269
|
+
* inside a text field that same pair moves the caret — the APG's own note says a toolbar holding
|
|
270
|
+
* such a control needs an answer and does not give one. This is the answer: **the field keeps the
|
|
271
|
+
* key while it has text left to walk in that direction, and the toolbar takes it at the edge.** So
|
|
272
|
+
* the reader arrows in, edits, and arrows out with the same key they came in with, and no gesture
|
|
273
|
+
* is lost either way. Home and End are the field's unconditionally, because inside text they mean
|
|
274
|
+
* the ends of the line and a toolbar that stole them would break editing to save a keystroke.
|
|
275
|
+
*
|
|
276
|
+
* @param node The event's target.
|
|
277
|
+
* @param key The key pressed.
|
|
278
|
+
* @param forward The toolbar's own forward key on its own axis.
|
|
279
|
+
* @returns Whether to stand aside.
|
|
280
|
+
*/
|
|
281
|
+
export function cedesKey(node: EventTarget | null, key: string, forward: string): boolean {
|
|
282
|
+
if (!(node instanceof HTMLElement) || !isTextEntry(node)) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
if (key === "Home" || key === "End") {
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
return !atEdge(node, key === forward);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Give the toolbar exactly one tab stop.
|
|
293
|
+
*
|
|
294
|
+
* Without this every control in the row is its own, because a native `<button>` and a native
|
|
295
|
+
* `<input>` are both focusable with no `tabindex` at all — so the collapse to one is a WRITE that
|
|
296
|
+
* has to happen before the reader's first Tab, not something their first arrow key can repair.
|
|
297
|
+
*
|
|
298
|
+
* Idempotent, which is what lets it be called from a lifecycle event that may arrive more than once
|
|
299
|
+
* and from the observer below on every change to the row.
|
|
300
|
+
*
|
|
301
|
+
* @param bar The `jx-toolbar` element.
|
|
302
|
+
*/
|
|
303
|
+
export function syncRoving(bar: HTMLElement): void {
|
|
304
|
+
const items = itemsOf(bar);
|
|
305
|
+
writeRoving(items, caretFor(items));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Whether the row already says what {@link syncRoving} would write. */
|
|
309
|
+
function isSettled(bar: HTMLElement): boolean {
|
|
310
|
+
const items = itemsOf(bar);
|
|
311
|
+
const caret = caretFor(items);
|
|
312
|
+
return items.every((item) => caretOf(item) === (item === caret ? "0" : "-1"));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Toolbars already being watched, so a second `jx-ready` does not observe one twice. */
|
|
316
|
+
const watched = new WeakSet<HTMLElement>();
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Keep the one-tab-stop invariant as the row's controls come and go.
|
|
320
|
+
*
|
|
321
|
+
* A Studio toolbar is not a fixed row: the grid's Add Row and Delete Rows appear with the source's
|
|
322
|
+
* capabilities, its pager appears with a second page, and every button in it enables and disables
|
|
323
|
+
* with the selection. A control appended afterwards arrives with no `tabindex` at all, which is a
|
|
324
|
+
* SECOND tab stop in an element whose whole purpose is to have one; removing or disabling the one
|
|
325
|
+
* holding the caret leaves ZERO, so a row of operable controls cannot be reached by Tab.
|
|
326
|
+
*
|
|
327
|
+
* `hidden` is watched beside `disabled` because {@link isOut} reads it, and the `$switch` branches a
|
|
328
|
+
* surface writes are not the only way a control leaves the row.
|
|
329
|
+
*
|
|
330
|
+
* @param bar The `jx-toolbar` element.
|
|
331
|
+
*/
|
|
332
|
+
function watchRoving(bar: HTMLElement): void {
|
|
333
|
+
if (watched.has(bar)) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
watched.add(bar);
|
|
337
|
+
/* `isSettled` first, so the observer writes only when the invariant is actually broken: a
|
|
338
|
+
re-write is a mutation of its own, and a callback that always writes is a loop. */
|
|
339
|
+
const observer = new MutationObserver(() => {
|
|
340
|
+
if (!isSettled(bar)) {
|
|
341
|
+
syncRoving(bar);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
observer.observe(bar, {
|
|
345
|
+
childList: true,
|
|
346
|
+
subtree: true,
|
|
347
|
+
attributes: true,
|
|
348
|
+
attributeFilter: ["disabled", "hidden"],
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The toolbar keyboard contract: the arrow pair of its own axis moves the caret between controls
|
|
354
|
+
* with wrap, Home and End go to the ends, and a control that answers the key itself keeps it.
|
|
355
|
+
*
|
|
356
|
+
* Enter, Space, the focus ring and `:disabled` are the platform's, on each control.
|
|
357
|
+
*
|
|
358
|
+
* @param {ToolbarState} state
|
|
359
|
+
* @param {KeyboardEvent} event
|
|
360
|
+
*/
|
|
361
|
+
export function onToolbarKeydown(state: ToolbarState, event: KeyboardEvent): void {
|
|
362
|
+
const bar = barOf(event);
|
|
363
|
+
if (!bar || !ownsNode(bar, event.target)) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const vertical = state.orientation === "vertical";
|
|
367
|
+
const forward = vertical ? "ArrowDown" : "ArrowRight";
|
|
368
|
+
const back = vertical ? "ArrowUp" : "ArrowLeft";
|
|
369
|
+
if (event.key !== forward && event.key !== back && event.key !== "Home" && event.key !== "End") {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (cedesKey(event.target, event.key, forward)) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const live = itemsOf(bar).filter((item) => !isOut(item));
|
|
376
|
+
if (live.length === 0) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const active = live.findIndex(
|
|
380
|
+
(item) =>
|
|
381
|
+
item === event.target || (event.target instanceof Node && item.contains(event.target)),
|
|
382
|
+
);
|
|
383
|
+
let index: number;
|
|
384
|
+
if (event.key === forward) {
|
|
385
|
+
index = active + 1;
|
|
386
|
+
} else if (event.key === back) {
|
|
387
|
+
index = active === -1 ? -1 : active - 1;
|
|
388
|
+
} else if (event.key === "Home") {
|
|
389
|
+
index = 0;
|
|
390
|
+
} else {
|
|
391
|
+
index = -1;
|
|
392
|
+
}
|
|
393
|
+
focusItem(bar, index);
|
|
394
|
+
event.preventDefault();
|
|
395
|
+
event.stopPropagation();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Collapse the toolbar to one tab stop once it has rendered, and keep it collapsed to one.
|
|
400
|
+
*
|
|
401
|
+
* `onMount` is handed the reactive scope alone, so the element announces itself with `jx-ready` —
|
|
402
|
+
* the kit's existing readiness event — and this reads the host off that event.
|
|
403
|
+
*
|
|
404
|
+
* @param {ToolbarState} _state
|
|
405
|
+
* @param {Event} event
|
|
406
|
+
*/
|
|
407
|
+
export function onToolbarReady(_state: ToolbarState, event: Event): void {
|
|
408
|
+
const bar = barOf(event);
|
|
409
|
+
if (bar) {
|
|
410
|
+
syncRoving(bar);
|
|
411
|
+
watchRoving(bar);
|
|
412
|
+
}
|
|
413
|
+
}
|