@zeyos/zx 4.3.4 → 5.0.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/CHANGELOG.md +438 -0
- package/README.md +49 -10
- package/dist/types/components/activity-item/activity-item.d.ts +351 -0
- package/dist/types/components/activity-list/activity-list.d.ts +217 -0
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +33 -3
- package/dist/types/components/avatar/avatar.d.ts +46 -7
- package/dist/types/components/badge/badge.d.ts +21 -1
- package/dist/types/components/button/button.d.ts +23 -0
- package/dist/types/components/calendar/calendar.d.ts +19 -0
- package/dist/types/components/card/card.d.ts +7 -0
- package/dist/types/components/card-view/card-view.d.ts +11 -3
- package/dist/types/components/card-view/record-card.d.ts +7 -1
- package/dist/types/components/chart/chart.d.ts +10 -0
- package/dist/types/components/checklist/checklist.d.ts +9 -0
- package/dist/types/components/code-editor/code-editor.d.ts +149 -0
- package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
- package/dist/types/components/context-menu/context-menu.d.ts +18 -2
- package/dist/types/components/date-picker/date-picker.d.ts +8 -0
- package/dist/types/components/date-picker/time-picker.d.ts +7 -0
- package/dist/types/components/datebox/date-range-box.d.ts +5 -1
- package/dist/types/components/datebox/datebox.d.ts +7 -2
- package/dist/types/components/dialog/dialog.d.ts +75 -6
- package/dist/types/components/dropdown/dropdown.d.ts +2 -2
- package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
- package/dist/types/components/field/field.d.ts +24 -2
- package/dist/types/components/fieldset/fieldset.d.ts +19 -1
- package/dist/types/components/file-item/file-item.d.ts +262 -0
- package/dist/types/components/file-list/file-list.d.ts +171 -0
- package/dist/types/components/filter/filter.d.ts +91 -36
- package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
- package/dist/types/components/form/form.d.ts +12 -1
- package/dist/types/components/kanban-view/kanban-card.d.ts +146 -0
- package/dist/types/components/kanban-view/kanban-view.d.ts +170 -4
- package/dist/types/components/launcher/launcher.d.ts +278 -26
- package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
- package/dist/types/components/menu-button/menu-button.d.ts +37 -42
- package/dist/types/components/message/message.d.ts +126 -17
- package/dist/types/components/modal/modal.d.ts +61 -2
- package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
- package/dist/types/components/panel/panel.d.ts +7 -0
- package/dist/types/components/search/search.d.ts +7 -0
- package/dist/types/components/select/color.d.ts +12 -0
- package/dist/types/components/select/glyphs.d.ts +124 -0
- package/dist/types/components/select/select.d.ts +15 -3
- package/dist/types/components/sort-control/sort-control.d.ts +105 -0
- package/dist/types/components/stat-tile/stat-tile.d.ts +288 -0
- package/dist/types/components/tabbox/tabbox.d.ts +16 -0
- package/dist/types/components/table/table.d.ts +190 -1
- package/dist/types/components/table-view/table-view.d.ts +11 -5
- package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
- package/dist/types/components/timebox/timebox.d.ts +7 -0
- package/dist/types/components/tree/tree.d.ts +13 -0
- package/dist/types/components/value-list/value-list.d.ts +8 -0
- package/dist/types/components/view/record-view.d.ts +96 -5
- package/dist/types/core/breakpoint.d.ts +1 -1
- package/dist/types/core/icons.d.ts +37 -22
- package/dist/types/core/motion.d.ts +59 -0
- package/dist/types/core/overlay-host.d.ts +15 -1
- package/dist/types/index.d.ts +16 -3
- package/dist/types/internal/app-rail.d.ts +10 -0
- package/dist/types/internal/menu-items.d.ts +134 -0
- package/dist/types/zeyos/connect.d.ts +6 -0
- package/dist/types/zeyos/form.d.ts +50 -9
- package/dist/types/zeyos/index.d.ts +1 -1
- package/dist/types/zeyos/query.d.ts +79 -0
- package/dist/types/zeyos/schema.d.ts +24 -1
- package/dist/types/zeyos/table.d.ts +100 -14
- package/dist/types/zeyos/view.d.ts +10 -0
- package/dist/zx-zeyos.esm.js +2206 -489
- package/dist/zx-zeyos.esm.min.js +56 -1
- package/dist/zx.css +3322 -724
- package/dist/zx.esm.js +12244 -5809
- package/dist/zx.esm.min.js +58 -3
- package/dist/zx.global.js +12246 -5811
- package/dist/zx.global.min.js +58 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +1683 -146
- package/docs/llms.md +777 -110
- package/docs/llms.txt +14 -8
- package/package.json +1 -1
- package/src/components/account-menu/account-menu.css +35 -10
- package/src/components/activity-item/activity-item.css +137 -0
- package/src/components/activity-item/activity-item.js +602 -0
- package/src/components/activity-list/activity-list.css +99 -0
- package/src/components/activity-list/activity-list.js +409 -0
- package/src/components/app-icon/app-icon.css +1 -1
- package/src/components/app-rail/app-rail.css +191 -22
- package/src/components/app-sidebar/app-sidebar.css +349 -24
- package/src/components/app-sidebar/app-sidebar.js +86 -11
- package/src/components/aurora/aurora.css +5 -3
- package/src/components/avatar/avatar.css +32 -7
- package/src/components/avatar/avatar.js +72 -8
- package/src/components/badge/badge.css +54 -2
- package/src/components/badge/badge.js +29 -2
- package/src/components/button/button.css +74 -8
- package/src/components/button/button.js +44 -1
- package/src/components/calendar/calendar.css +46 -46
- package/src/components/calendar/calendar.js +33 -3
- package/src/components/card/card.css +2 -2
- package/src/components/card/card.js +12 -1
- package/src/components/card-view/card-view.css +10 -7
- package/src/components/card-view/card-view.js +25 -9
- package/src/components/card-view/record-card.js +7 -3
- package/src/components/chart/chart.css +4 -1
- package/src/components/chart/chart.js +12 -0
- package/src/components/check-button/check-button.css +10 -2
- package/src/components/checklist/checklist.css +9 -2
- package/src/components/checklist/checklist.js +11 -5
- package/src/components/checklist/field-adapter.js +3 -1
- package/src/components/code-editor/code-editor.css +68 -0
- package/src/components/code-editor/code-editor.js +306 -0
- package/src/components/code-editor/field-adapter.js +34 -0
- package/src/components/context-menu/context-menu.css +87 -5
- package/src/components/context-menu/context-menu.js +22 -30
- package/src/components/copy/copy-button.css +6 -0
- package/src/components/copy/copy-input.css +8 -3
- package/src/components/data-filter/data-filter.css +5 -4
- package/src/components/date-picker/date-picker.css +6 -6
- package/src/components/date-picker/date-picker.js +22 -8
- package/src/components/date-picker/date-range-picker.css +3 -3
- package/src/components/date-picker/month-picker.js +3 -3
- package/src/components/date-picker/time-picker.css +1 -1
- package/src/components/date-picker/time-picker.js +20 -8
- package/src/components/datebox/date-range-box.css +79 -2
- package/src/components/datebox/date-range-box.js +13 -2
- package/src/components/datebox/datebox.css +79 -2
- package/src/components/datebox/datebox.js +13 -2
- package/src/components/dialog/dialog.css +26 -0
- package/src/components/dialog/dialog.js +174 -70
- package/src/components/dropdown/dropdown.css +1 -1
- package/src/components/dropdown/dropdown.js +7 -5
- package/src/components/empty-state/empty-state.css +2 -2
- package/src/components/entity-ref/entity-ref.css +182 -0
- package/src/components/entity-ref/entity-ref.js +496 -0
- package/src/components/field/field.css +28 -9
- package/src/components/field/field.js +68 -26
- package/src/components/field-upload/field-upload.css +1 -1
- package/src/components/fieldset/fieldset.css +5 -3
- package/src/components/fieldset/fieldset.js +28 -5
- package/src/components/file-item/file-item.css +88 -0
- package/src/components/file-item/file-item.js +505 -0
- package/src/components/file-list/file-list.css +51 -0
- package/src/components/file-list/file-list.js +320 -0
- package/src/components/filter/filter.css +27 -1
- package/src/components/filter/filter.js +205 -47
- package/src/components/filter-panel/filter-panel.css +104 -0
- package/src/components/filter-panel/filter-panel.js +964 -0
- package/src/components/finder/finder.css +15 -13
- package/src/components/form/form.css +5 -4
- package/src/components/form/form.js +11 -2
- package/src/components/grid/grid.css +1 -1
- package/src/components/groupbox/groupbox.css +1 -1
- package/src/components/kanban-view/kanban-card.js +247 -0
- package/src/components/kanban-view/kanban-view.css +226 -8
- package/src/components/kanban-view/kanban-view.js +255 -12
- package/src/components/launcher/launcher.css +107 -25
- package/src/components/launcher/launcher.js +557 -63
- package/src/components/list-toolbar/list-toolbar.css +194 -0
- package/src/components/list-toolbar/list-toolbar.js +851 -0
- package/src/components/master-panel/master-panel.css +2 -0
- package/src/components/menu-button/menu-button.css +87 -5
- package/src/components/menu-button/menu-button.js +86 -46
- package/src/components/message/message.css +38 -5
- package/src/components/message/message.js +230 -17
- package/src/components/modal/modal.css +15 -0
- package/src/components/modal/modal.js +159 -3
- package/src/components/navigation-bar/navigation-bar.css +95 -11
- package/src/components/navigation-bar/navigation-bar.js +171 -4
- package/src/components/number-field/field-adapter.js +1 -0
- package/src/components/number-field/number-field.css +70 -0
- package/src/components/number-field/number-field.js +2 -0
- package/src/components/pagination/pagination.css +8 -6
- package/src/components/panel/panel.css +1 -1
- package/src/components/panel/panel.js +13 -2
- package/src/components/rating/rating.css +7 -0
- package/src/components/search/search.js +17 -5
- package/src/components/select/color.js +22 -0
- package/src/components/select/field-adapter.js +6 -1
- package/src/components/select/glyphs.js +165 -0
- package/src/components/select/select.css +138 -45
- package/src/components/select/select.js +86 -47
- package/src/components/sheet/sheet.css +11 -8
- package/src/components/slider/slider.css +16 -2
- package/src/components/slider/slider.js +1 -1
- package/src/components/sort-control/sort-control.css +20 -0
- package/src/components/sort-control/sort-control.js +140 -0
- package/src/components/stat-tile/stat-tile.css +208 -0
- package/src/components/stat-tile/stat-tile.js +601 -0
- package/src/components/tabbox/tabbox.css +32 -27
- package/src/components/tabbox/tabbox.js +17 -2
- package/src/components/table/table.css +155 -31
- package/src/components/table/table.js +386 -33
- package/src/components/table-view/table-view.css +8 -2
- package/src/components/table-view/table-view.js +21 -6
- package/src/components/tag-picker/tag-picker.css +86 -6
- package/src/components/tag-picker/tag-picker.js +25 -8
- package/src/components/timebox/timebox.css +22 -2
- package/src/components/timebox/timebox.js +18 -5
- package/src/components/toggle/toggle.css +12 -1
- package/src/components/toolbar/toolbar.js +2 -2
- package/src/components/tooltip/tooltip.css +4 -0
- package/src/components/tooltip/tooltip.js +10 -3
- package/src/components/tree/tree.css +25 -16
- package/src/components/tree/tree.js +31 -0
- package/src/components/value-list/value-list.css +3 -3
- package/src/components/value-list/value-list.js +22 -6
- package/src/components/view/record-view.css +5 -0
- package/src/components/view/record-view.js +100 -7
- package/src/core/breakpoint.js +1 -1
- package/src/core/icons.js +259 -57
- package/src/core/motion.js +147 -0
- package/src/core/overlay-host.js +21 -2
- package/src/index.js +30 -3
- package/src/internal/app-rail.js +18 -1
- package/src/internal/menu-items.js +142 -0
- package/src/zeyos/connect.js +6 -2
- package/src/zeyos/form.js +67 -27
- package/src/zeyos/index.js +1 -1
- package/src/zeyos/query.js +153 -0
- package/src/zeyos/schema.js +144 -27
- package/src/zeyos/table.js +68 -5
- package/src/zeyos/view.js +6 -0
- package/styles/base.css +56 -0
- package/styles/icons.css +11 -1
- package/styles/print.css +192 -0
- package/styles/tokens/dark.css +113 -37
- package/styles/tokens/global.css +121 -27
- package/styles/tokens/semantic.css +100 -31
- package/styles/tokens/themes.css +7 -6
- package/styles/zx.css +11 -1
- package/styles/tokens/density.css +0 -9
|
@@ -1,15 +1,32 @@
|
|
|
1
1
|
import { Component } from '../../core/component.js';
|
|
2
|
-
import { h } from '../../core/dom.js';
|
|
2
|
+
import { h, resolveElement } from '../../core/dom.js';
|
|
3
3
|
import { icon } from '../../core/icons.js';
|
|
4
4
|
import { overlayHost } from '../../core/overlay-host.js';
|
|
5
|
+
import { button } from '../button/button.js';
|
|
5
6
|
|
|
6
7
|
/** @typedef {'info'|'success'|'warning'|'error'} MessageKind */
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The one command a message may carry, drawn as a small button beside its text.
|
|
11
|
+
* @typedef {Object} MessageAction
|
|
12
|
+
* @property {string} label Button text: a verb naming what happens ("Undo", "Approve", "Retry").
|
|
13
|
+
* @property {(event: MouseEvent) => void} [onclick] Runs when the button is activated, by pointer
|
|
14
|
+
* or keyboard; the message closes right after it.
|
|
15
|
+
* @property {'default'|'primary'|'danger'|'ghost'} [kind='default'] Button kind; the default is the
|
|
16
|
+
* secondary button.
|
|
17
|
+
*/
|
|
18
|
+
|
|
8
19
|
/**
|
|
9
20
|
* @typedef {Object} MessageShowOptions
|
|
10
21
|
* @property {MessageKind|'danger'} [kind='info'] Message intent (`danger` aliases `error`).
|
|
11
|
-
* @property {number} [timeout=4000] Auto-dismiss delay in milliseconds; zero persists.
|
|
22
|
+
* @property {number} [timeout=4000] Auto-dismiss delay in milliseconds; zero persists. A message
|
|
23
|
+
* with an `action` stays at least 8000ms.
|
|
12
24
|
* @property {boolean} [closable=true] Whether to render a close button.
|
|
25
|
+
* @property {MessageAction|null} [action=null] A command button inside the message. The message
|
|
26
|
+
* never times out while the pointer is over it or focus is inside it.
|
|
27
|
+
* @property {Node|{toElement: () => Node|null}|null} [leading=null] Visual shown in place of the
|
|
28
|
+
* kind icon, such as an Avatar's element. It is decorative — the message text must say what it
|
|
29
|
+
* shows.
|
|
13
30
|
* @property {Element|string|null} [scope=null] Element whose nearest Zx theme scope owns a floating toast.
|
|
14
31
|
*/
|
|
15
32
|
|
|
@@ -21,7 +38,15 @@ import { overlayHost } from '../../core/overlay-host.js';
|
|
|
21
38
|
* @property {number} [maxVisible=5] Maximum number of visible messages.
|
|
22
39
|
*/
|
|
23
40
|
|
|
24
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Returned by every `show()`: closes that message early. Closing one that already closed, or one
|
|
43
|
+
* still waiting in the queue, is safe.
|
|
44
|
+
* @typedef {Object} MessageHandle
|
|
45
|
+
* @property {() => void} close Closes the message.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** The shortest time a message that carries an action stays, in milliseconds. */
|
|
49
|
+
export const MESSAGE_ACTION_MIN_TIMEOUT = 8000;
|
|
25
50
|
|
|
26
51
|
/**
|
|
27
52
|
* @typedef {Object} ProgressHandle
|
|
@@ -135,7 +160,7 @@ export class Message extends Component {
|
|
|
135
160
|
maxVisible: 5
|
|
136
161
|
};
|
|
137
162
|
|
|
138
|
-
/** @type {WeakMap<Element, Message>} */
|
|
163
|
+
/** @type {WeakMap<Element, Message>} Floating region per overlay host. */
|
|
139
164
|
static #floating = new WeakMap();
|
|
140
165
|
|
|
141
166
|
/** @returns {HTMLElement} */
|
|
@@ -145,6 +170,8 @@ export class Message extends Component {
|
|
|
145
170
|
this._createdRoot = created;
|
|
146
171
|
this._original = created ? null : snapshot(root);
|
|
147
172
|
this._entries = new Set();
|
|
173
|
+
/** @type {Element|null} The overlay host this instance is the floating region of; null when inline. */
|
|
174
|
+
this._floatingHost = null;
|
|
148
175
|
this._queue = new MessageQueue(normalizeLimit(this.options.maxVisible), (entry) => {
|
|
149
176
|
this._activate(entry);
|
|
150
177
|
});
|
|
@@ -161,10 +188,13 @@ export class Message extends Component {
|
|
|
161
188
|
* @returns {MessageHandle}
|
|
162
189
|
*/
|
|
163
190
|
show(msg, options = {}) {
|
|
191
|
+
const action = normalizeMessageAction(options.action);
|
|
164
192
|
const settings = {
|
|
165
193
|
kind: normalizeKind(options.kind ?? this.options.kind),
|
|
166
|
-
timeout:
|
|
167
|
-
closable: options.closable ?? Boolean(this.options.closable)
|
|
194
|
+
timeout: messageTimeout(options.timeout ?? this.options.timeout, action !== null),
|
|
195
|
+
closable: options.closable ?? Boolean(this.options.closable),
|
|
196
|
+
action,
|
|
197
|
+
leading: resolveLeading(options.leading)
|
|
168
198
|
};
|
|
169
199
|
const entry = this._createEntry(msg, settings);
|
|
170
200
|
this._entries.add(entry);
|
|
@@ -232,10 +262,33 @@ export class Message extends Component {
|
|
|
232
262
|
return this.#floatingRegion(options.scope)._progress(text);
|
|
233
263
|
}
|
|
234
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Removes a floating region at once, toasts and all, without waiting for them to time out.
|
|
267
|
+
*
|
|
268
|
+
* A region already removes itself when its last toast closes, and one whose host has left the
|
|
269
|
+
* document is replaced on the next toast, so this is only for a host being torn down while
|
|
270
|
+
* toasts are still showing — an application view unmounting its themed shell, say.
|
|
271
|
+
* @param {Element|string} scope Element or selector whose nearest Zx theme scope owns the region,
|
|
272
|
+
* as passed to `show()`.
|
|
273
|
+
* @returns {boolean} Whether there was a region to remove.
|
|
274
|
+
*/
|
|
275
|
+
static disposeRegion(scope) {
|
|
276
|
+
if (scope == null) return false;
|
|
277
|
+
const context = resolveElement(scope);
|
|
278
|
+
if (!context) return false;
|
|
279
|
+
const host = overlayHost(context);
|
|
280
|
+
const region = Message.#floating.get(host);
|
|
281
|
+
if (!region) return false;
|
|
282
|
+
Message.#floating.delete(host);
|
|
283
|
+
region._disposeFloating();
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
235
287
|
/** @returns {void} */
|
|
236
288
|
destroy() {
|
|
237
289
|
if (this._cleaned) return;
|
|
238
290
|
this._cleaned = true;
|
|
291
|
+
this._floatingHost = null;
|
|
239
292
|
for (const entry of this._entries) {
|
|
240
293
|
clearEntryTimers(entry);
|
|
241
294
|
entry.node.remove();
|
|
@@ -288,13 +341,13 @@ export class Message extends Component {
|
|
|
288
341
|
|
|
289
342
|
/**
|
|
290
343
|
* @param {string|Node} msg
|
|
291
|
-
* @param {{kind: MessageKind, timeout: number, closable: boolean}} settings
|
|
344
|
+
* @param {{kind: MessageKind, timeout: number, closable: boolean, action?: MessageAction|null, leading?: Node|null}} settings
|
|
292
345
|
* @param {{pct: number, text: string}|null} [progress=null]
|
|
293
346
|
* @returns {any}
|
|
294
347
|
*/
|
|
295
348
|
_createEntry(msg, settings, progress = null) {
|
|
296
349
|
const iconHost = h('span', { class: 'zx-message__icon', ariaHidden: 'true' },
|
|
297
|
-
icon(settings.kind, { size: 18 })
|
|
350
|
+
settings.leading ?? icon(settings.kind, { size: 18 })
|
|
298
351
|
);
|
|
299
352
|
const content = h('div', { class: 'zx-message__content' });
|
|
300
353
|
if (isNode(msg)) content.append(msg);
|
|
@@ -315,9 +368,13 @@ export class Message extends Component {
|
|
|
315
368
|
timer: null,
|
|
316
369
|
exitTimer: null,
|
|
317
370
|
closed: false,
|
|
371
|
+
hovered: false,
|
|
372
|
+
focused: false,
|
|
373
|
+
returnFocus: null,
|
|
318
374
|
progressBar: null,
|
|
319
375
|
progressFill: null
|
|
320
376
|
};
|
|
377
|
+
if (settings.leading) node.dataset.leading = '';
|
|
321
378
|
|
|
322
379
|
if (progress) {
|
|
323
380
|
node.dataset.progress = '';
|
|
@@ -336,18 +393,56 @@ export class Message extends Component {
|
|
|
336
393
|
syncProgress(entry, progress);
|
|
337
394
|
}
|
|
338
395
|
|
|
396
|
+
if (settings.action) {
|
|
397
|
+
const { label, kind, onclick } = settings.action;
|
|
398
|
+
const control = button({ label, kind, size: 'sm' });
|
|
399
|
+
control.classList.add('zx-message__action');
|
|
400
|
+
node.append(control);
|
|
401
|
+
this.listen(control, 'click', (event) => {
|
|
402
|
+
if (entry.closed) return;
|
|
403
|
+
try {
|
|
404
|
+
onclick?.(/** @type {MouseEvent} */ (event));
|
|
405
|
+
} finally {
|
|
406
|
+
this._close(entry);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
339
411
|
if (settings.closable) {
|
|
340
412
|
const close = h('button', {
|
|
341
413
|
class: 'zx-icon-btn zx-message__close',
|
|
342
414
|
type: 'button',
|
|
343
|
-
ariaLabel: 'Close message',
|
|
344
|
-
title: 'Close'
|
|
415
|
+
ariaLabel: this._message('message.close', 'Close message'),
|
|
416
|
+
title: this._message('message.closeTitle', 'Close')
|
|
345
417
|
}, icon('x', { size: 14 }));
|
|
346
418
|
node.append(close);
|
|
347
419
|
this.listen(close, 'click', () => this._close(entry));
|
|
348
420
|
}
|
|
349
|
-
|
|
350
|
-
|
|
421
|
+
// Hover and focus each hold the timer (WCAG 2.2.1): a reader moving to the action or the close
|
|
422
|
+
// button must not have the message vanish under them, and it counts down again only once
|
|
423
|
+
// neither holds it.
|
|
424
|
+
this.listen(node, 'mouseenter', () => {
|
|
425
|
+
entry.hovered = true;
|
|
426
|
+
this._pause(entry);
|
|
427
|
+
});
|
|
428
|
+
this.listen(node, 'mouseleave', () => {
|
|
429
|
+
entry.hovered = false;
|
|
430
|
+
this._resume(entry);
|
|
431
|
+
});
|
|
432
|
+
this.listen(node, 'focusin', (event) => {
|
|
433
|
+
if (!entry.focused) {
|
|
434
|
+
const from = /** @type {FocusEvent} */ (event).relatedTarget;
|
|
435
|
+
entry.returnFocus = isNode(from) && !node.contains(/** @type {Node} */ (from)) ? from : null;
|
|
436
|
+
}
|
|
437
|
+
entry.focused = true;
|
|
438
|
+
this._pause(entry);
|
|
439
|
+
});
|
|
440
|
+
this.listen(node, 'focusout', (event) => {
|
|
441
|
+
const to = /** @type {FocusEvent} */ (event).relatedTarget;
|
|
442
|
+
if (isNode(to) && node.contains(/** @type {Node} */ (to))) return;
|
|
443
|
+
entry.focused = false;
|
|
444
|
+
this._resume(entry);
|
|
445
|
+
});
|
|
351
446
|
return entry;
|
|
352
447
|
}
|
|
353
448
|
|
|
@@ -361,7 +456,7 @@ export class Message extends Component {
|
|
|
361
456
|
|
|
362
457
|
/** @param {any} entry @returns {void} */
|
|
363
458
|
_schedule(entry) {
|
|
364
|
-
if (entry.closed || entry.remaining <= 0) return;
|
|
459
|
+
if (entry.closed || entry.remaining <= 0 || entry.hovered || entry.focused) return;
|
|
365
460
|
entry.startedAt = Date.now();
|
|
366
461
|
entry.timer = setTimeout(() => this._close(entry), entry.remaining);
|
|
367
462
|
}
|
|
@@ -376,7 +471,7 @@ export class Message extends Component {
|
|
|
376
471
|
|
|
377
472
|
/** @param {any} entry @returns {void} */
|
|
378
473
|
_resume(entry) {
|
|
379
|
-
if (entry.closed || entry.timer !== null) return;
|
|
474
|
+
if (entry.closed || entry.timer !== null || entry.hovered || entry.focused) return;
|
|
380
475
|
if (entry.remaining <= 0) {
|
|
381
476
|
this._close(entry);
|
|
382
477
|
return;
|
|
@@ -389,10 +484,12 @@ export class Message extends Component {
|
|
|
389
484
|
if (entry.closed) return;
|
|
390
485
|
entry.closed = true;
|
|
391
486
|
clearEntryTimers(entry);
|
|
487
|
+
returnFocus(entry);
|
|
392
488
|
if (!this._queue.isActive(entry)) {
|
|
393
489
|
this._queue.remove(entry);
|
|
394
490
|
entry.node.remove();
|
|
395
491
|
this._entries.delete(entry);
|
|
492
|
+
this._releaseIfEmpty();
|
|
396
493
|
return;
|
|
397
494
|
}
|
|
398
495
|
|
|
@@ -415,13 +512,57 @@ export class Message extends Component {
|
|
|
415
512
|
entry.node.remove();
|
|
416
513
|
this._entries.delete(entry);
|
|
417
514
|
this._queue.remove(entry);
|
|
515
|
+
this._releaseIfEmpty();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Takes a floating region out of the document once its last toast has gone, so an emptied host
|
|
520
|
+
* is not left holding a region nobody uses. Inline areas stay: their element is the caller's.
|
|
521
|
+
* @returns {void}
|
|
522
|
+
*/
|
|
523
|
+
_releaseIfEmpty() {
|
|
524
|
+
if (!this._floatingHost || this._entries.size > 0) return;
|
|
525
|
+
Message.#release(this);
|
|
418
526
|
}
|
|
419
527
|
|
|
420
|
-
/**
|
|
528
|
+
/**
|
|
529
|
+
* Destroys a floating region and removes its element.
|
|
530
|
+
* @returns {void}
|
|
531
|
+
*/
|
|
532
|
+
_disposeFloating() {
|
|
533
|
+
const region = this.el;
|
|
534
|
+
this.destroy();
|
|
535
|
+
region.remove();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Resolves a message through the host translator, falling back to the built-in English text.
|
|
540
|
+
* @param {string} key Message key.
|
|
541
|
+
* @param {string} fallback English default.
|
|
542
|
+
* @returns {string}
|
|
543
|
+
*/
|
|
544
|
+
_message(key, fallback) {
|
|
545
|
+
const message = this.msg(key);
|
|
546
|
+
return message === key ? fallback : message;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Finds or creates the floating region for a scope. A cached region is reused only while it is
|
|
551
|
+
* still where it was put: a host emptied by `replaceChildren()` would otherwise keep receiving
|
|
552
|
+
* toasts in a node nobody can see.
|
|
553
|
+
* @param {Element|string|null|undefined} scope
|
|
554
|
+
* @returns {Message}
|
|
555
|
+
*/
|
|
421
556
|
static #floatingRegion(scope) {
|
|
422
|
-
|
|
557
|
+
let host = overlayHost(scope ?? null);
|
|
558
|
+
// A scope that has itself left the document cannot show anything; the toast still must be seen.
|
|
559
|
+
if (!host.isConnected) host = overlayHost(null);
|
|
423
560
|
const existing = this.#floating.get(host);
|
|
424
|
-
if (existing) return existing;
|
|
561
|
+
if (existing && existing.el.isConnected && existing.el.parentElement === host) return existing;
|
|
562
|
+
if (existing) {
|
|
563
|
+
this.#floating.delete(host);
|
|
564
|
+
existing._disposeFloating();
|
|
565
|
+
}
|
|
425
566
|
const region = h('div', {
|
|
426
567
|
class: 'zx-message-region',
|
|
427
568
|
popover: 'manual',
|
|
@@ -430,10 +571,18 @@ export class Message extends Component {
|
|
|
430
571
|
});
|
|
431
572
|
host.append(region);
|
|
432
573
|
const floating = new Message(region);
|
|
574
|
+
floating._floatingHost = host;
|
|
433
575
|
this.#floating.set(host, floating);
|
|
434
576
|
if (typeof region.showPopover === 'function') region.showPopover();
|
|
435
577
|
return floating;
|
|
436
578
|
}
|
|
579
|
+
|
|
580
|
+
/** @param {Message} region Floating region whose last toast closed. @returns {void} */
|
|
581
|
+
static #release(region) {
|
|
582
|
+
const host = region._floatingHost;
|
|
583
|
+
if (host && Message.#floating.get(host) === region) Message.#floating.delete(host);
|
|
584
|
+
region._disposeFloating();
|
|
585
|
+
}
|
|
437
586
|
}
|
|
438
587
|
|
|
439
588
|
/** @param {unknown} value @returns {MessageKind} */
|
|
@@ -449,6 +598,70 @@ function normalizeTimeout(value) {
|
|
|
449
598
|
return Number.isFinite(timeout) ? Math.max(0, timeout) : 4000;
|
|
450
599
|
}
|
|
451
600
|
|
|
601
|
+
/**
|
|
602
|
+
* Settles how long a message stays. A message that carries an action stays at least
|
|
603
|
+
* `MESSAGE_ACTION_MIN_TIMEOUT`, so there is time to reach it; zero still means it stays until
|
|
604
|
+
* closed, and anything that is not a finite number falls back to the 4000ms default.
|
|
605
|
+
* @param {unknown} timeout Requested auto-dismiss delay in milliseconds.
|
|
606
|
+
* @param {boolean} [hasAction=false] Whether the message carries an action.
|
|
607
|
+
* @returns {number}
|
|
608
|
+
*/
|
|
609
|
+
export function messageTimeout(timeout, hasAction = false) {
|
|
610
|
+
const settled = normalizeTimeout(timeout);
|
|
611
|
+
return hasAction && settled > 0 ? Math.max(settled, MESSAGE_ACTION_MIN_TIMEOUT) : settled;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Copies a message action into the shape the message renders, or returns null when there is
|
|
616
|
+
* nothing to render: no object, or no label. An unknown `kind` reads as the default, secondary
|
|
617
|
+
* button, and a missing `onclick` leaves a button that only closes the message.
|
|
618
|
+
* @param {unknown} value Action descriptor.
|
|
619
|
+
* @returns {{label: string, kind: 'default'|'primary'|'danger'|'ghost', onclick: ((event: MouseEvent) => void)|null}|null}
|
|
620
|
+
*/
|
|
621
|
+
export function normalizeMessageAction(value) {
|
|
622
|
+
if (!value || typeof value !== 'object') return null;
|
|
623
|
+
const action = /** @type {Record<string, unknown>} */ (value);
|
|
624
|
+
const label = action.label == null ? '' : String(action.label).trim();
|
|
625
|
+
if (!label) return null;
|
|
626
|
+
const kind = ['primary', 'danger', 'ghost'].includes(String(action.kind))
|
|
627
|
+
? /** @type {'primary'|'danger'|'ghost'} */ (action.kind) : 'default';
|
|
628
|
+
return {
|
|
629
|
+
label,
|
|
630
|
+
kind,
|
|
631
|
+
onclick: typeof action.onclick === 'function'
|
|
632
|
+
? /** @type {(event: MouseEvent) => void} */ (action.onclick) : null
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Accepts a node, or a component such as an Avatar, for the leading slot.
|
|
638
|
+
* @param {any} value Leading content.
|
|
639
|
+
* @returns {Node|null}
|
|
640
|
+
*/
|
|
641
|
+
function resolveLeading(value) {
|
|
642
|
+
if (isNode(value)) return /** @type {Node} */ (value);
|
|
643
|
+
if (value && typeof value === 'object' && typeof value.toElement === 'function') {
|
|
644
|
+
const element = value.toElement();
|
|
645
|
+
return isNode(element) ? element : null;
|
|
646
|
+
}
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Hands focus back to where it came from when a message closes with focus inside it, so a keyboard
|
|
652
|
+
* reader is not dropped on the page body with the element they were on gone.
|
|
653
|
+
* @param {any} entry Message entry.
|
|
654
|
+
* @returns {void}
|
|
655
|
+
*/
|
|
656
|
+
function returnFocus(entry) {
|
|
657
|
+
const root = entry.node.getRootNode?.();
|
|
658
|
+
const active = root?.activeElement ?? globalThis.document?.activeElement;
|
|
659
|
+
if (!active || !entry.node.contains(active)) return;
|
|
660
|
+
const target = entry.returnFocus;
|
|
661
|
+
entry.returnFocus = null;
|
|
662
|
+
if (target?.isConnected && typeof target.focus === 'function') target.focus({ preventScroll: true });
|
|
663
|
+
}
|
|
664
|
+
|
|
452
665
|
/** @param {unknown} value @returns {number} */
|
|
453
666
|
function normalizeLimit(value) {
|
|
454
667
|
const limit = Number(value);
|
|
@@ -32,6 +32,21 @@
|
|
|
32
32
|
padding: var(--zx-space-5);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/*
|
|
36
|
+
* The panel takes focus itself only as a fallback, when it holds nothing focusable at all. It is a
|
|
37
|
+
* surface rather than a control, so that programmatic focus paints no ring; a reader the browser
|
|
38
|
+
* reports as navigating by keyboard still gets one, because otherwise focus would be inside the
|
|
39
|
+
* overlay with nothing on screen saying so.
|
|
40
|
+
*/
|
|
41
|
+
.zx-modal:focus {
|
|
42
|
+
outline: none;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.zx-modal:focus-visible {
|
|
46
|
+
outline: 2px solid var(--zx-color-ring);
|
|
47
|
+
outline-offset: calc(-1 * var(--zx-space-1));
|
|
48
|
+
}
|
|
49
|
+
|
|
35
50
|
/*
|
|
36
51
|
* `:modal` rather than `[open]`: only a dialog opened with `showModal()` blocks the page, and a
|
|
37
52
|
* non-modal one (Sheet's `modal: false`) must leave the page scrollable behind it.
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { Component } from '../../core/component.js';
|
|
2
2
|
import { h } from '../../core/dom.js';
|
|
3
|
-
import { overlayHost } from '../../core/overlay-host.js';
|
|
3
|
+
import { connectedOverlayHost, overlayHost } from '../../core/overlay-host.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Controls initial focus may be placed on: what a reader reaches with Tab, rather than everything
|
|
7
|
+
* the platform will focus programmatically.
|
|
8
|
+
*/
|
|
9
|
+
const FOCUSABLE_SELECTOR = [
|
|
10
|
+
'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])',
|
|
11
|
+
'textarea:not([disabled])', '[contenteditable="true"]', '[tabindex]:not([tabindex="-1"])'
|
|
12
|
+
].join(',');
|
|
4
13
|
|
|
5
14
|
/**
|
|
6
15
|
* @typedef {Object} ModalOptions
|
|
@@ -9,7 +18,7 @@ import { overlayHost } from '../../core/overlay-host.js';
|
|
|
9
18
|
* @property {boolean} [closable=true] Whether Escape may close the modal.
|
|
10
19
|
* @property {boolean} [lightDismiss=false] Whether a backdrop click closes the modal.
|
|
11
20
|
* @property {boolean} [destroyOnClose=false] Whether to destroy the modal after it closes.
|
|
12
|
-
* @property {Element|string|null} [scope=null] Element whose nearest Zx theme scope owns the overlay; defaults to the opener.
|
|
21
|
+
* @property {Element|string|null} [scope=null] Element whose nearest Zx theme scope owns the overlay; defaults to the opener. Resolved on every open, so a scope connected after construction still owns it.
|
|
13
22
|
* @property {(event: CustomEvent<Record<string, never>>) => void} [onopen] Open event listener.
|
|
14
23
|
* @property {(event: CustomEvent<{result: unknown}>) => void} [onclose] Close event listener.
|
|
15
24
|
* @property {(event: CustomEvent<Record<string, never>>) => void} [oncancel] Cancel event listener.
|
|
@@ -18,6 +27,15 @@ import { overlayHost } from '../../core/overlay-host.js';
|
|
|
18
27
|
/**
|
|
19
28
|
* Thin native-dialog overlay with lifecycle-safe content and dismissal behavior.
|
|
20
29
|
* The constructor always creates and appends a new dialog; its target argument is ignored.
|
|
30
|
+
*
|
|
31
|
+
* Accessibility is the platform's wherever the platform has an answer. The root is a real
|
|
32
|
+
* `<dialog>` and `_show()` presents it with `showModal()`, so focus containment, the inertness of
|
|
33
|
+
* everything behind it, Escape, the top layer and the implicit `dialog` role all come from the
|
|
34
|
+
* browser — there is no hand-written focus trap here, and adding one on top of `:modal` would be
|
|
35
|
+
* two traps fighting over the same Tab key. What the platform leaves undone is the rest of this
|
|
36
|
+
* class's accessibility work: it stops initial focus at the panel instead of the first control,
|
|
37
|
+
* and it restores focus to the opener only for its own native presentation, not for the popover
|
|
38
|
+
* one a non-modal subclass uses.
|
|
21
39
|
* @fires Modal#open
|
|
22
40
|
* @fires Modal#close
|
|
23
41
|
* @fires Modal#cancel
|
|
@@ -42,6 +60,8 @@ export class Modal extends Component {
|
|
|
42
60
|
#presentation = 0;
|
|
43
61
|
/** @type {number[]} */
|
|
44
62
|
#nativeClosingPresentations = [];
|
|
63
|
+
/** @type {Element|null} Element that had focus when the overlay was opened. */
|
|
64
|
+
#opener = null;
|
|
45
65
|
|
|
46
66
|
/**
|
|
47
67
|
* Creates a modal in the configured theme scope, or at document level when none is present.
|
|
@@ -64,6 +84,14 @@ export class Modal extends Component {
|
|
|
64
84
|
if (!this.options.lightDismiss || event.target !== this.el || !isBackdropClick(this.el, event)) return;
|
|
65
85
|
this.close();
|
|
66
86
|
});
|
|
87
|
+
/*
|
|
88
|
+
* One listener for every close path, because `close` is the one event all four converge on:
|
|
89
|
+
* a footer button and a programmatic `close()` go through `_dismiss()`, Escape and a native
|
|
90
|
+
* dismissal arrive as the platform's own `close`, and light dismiss calls `close()` itself.
|
|
91
|
+
* A subclass that traps focus registers its own restore after this one and so wins, which is
|
|
92
|
+
* what keeps `Sheet`'s trap authoritative over its own capture.
|
|
93
|
+
*/
|
|
94
|
+
this.on('close', () => this.#restoreFocus());
|
|
67
95
|
}
|
|
68
96
|
|
|
69
97
|
/**
|
|
@@ -112,6 +140,23 @@ export class Modal extends Component {
|
|
|
112
140
|
_present() {
|
|
113
141
|
this._show();
|
|
114
142
|
this.#presentation += 1;
|
|
143
|
+
this.#syncModality();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Mirrors the presentation's real modality onto the panel as `aria-modal`.
|
|
148
|
+
*
|
|
149
|
+
* Asked of the platform (`:modal`) rather than of an option, so it stays true through every
|
|
150
|
+
* presentation a subclass chooses: `showModal()` is modal, a popover or an in-flow `show()` is
|
|
151
|
+
* not, and a sheet that moves between them is re-answered when it is presented again. The
|
|
152
|
+
* `<dialog>` element already carries the implicit `dialog` role and, while `:modal`, the
|
|
153
|
+
* implicit modal semantics — the attribute states them for assistive technology that reads the
|
|
154
|
+
* markup rather than the top-layer state.
|
|
155
|
+
* @returns {void}
|
|
156
|
+
*/
|
|
157
|
+
#syncModality() {
|
|
158
|
+
if (this.el.matches(':modal')) this.el.setAttribute('aria-modal', 'true');
|
|
159
|
+
else if (this.el.getAttribute('aria-modal') === 'true') this.el.removeAttribute('aria-modal');
|
|
115
160
|
}
|
|
116
161
|
|
|
117
162
|
/**
|
|
@@ -158,6 +203,11 @@ export class Modal extends Component {
|
|
|
158
203
|
}
|
|
159
204
|
this.el.dataset.state = 'closed';
|
|
160
205
|
delete this.el.dataset.zxOverlayOrder;
|
|
206
|
+
/*
|
|
207
|
+
* Only the flag this class raised. A subclass presenting non-modally writes
|
|
208
|
+
* `aria-modal="false"` and owns that value across its own presentations.
|
|
209
|
+
*/
|
|
210
|
+
if (this.el.getAttribute('aria-modal') === 'true') this.el.removeAttribute('aria-modal');
|
|
161
211
|
const pending = closingPresentation === undefined
|
|
162
212
|
? undefined
|
|
163
213
|
: this.#pendingResults.get(closingPresentation);
|
|
@@ -168,6 +218,45 @@ export class Modal extends Component {
|
|
|
168
218
|
return true;
|
|
169
219
|
}
|
|
170
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Returns focus to whatever held it when the overlay opened.
|
|
223
|
+
*
|
|
224
|
+
* A closing `<dialog>` restores focus itself, but only for its own native presentation and only
|
|
225
|
+
* while the opener is still where it was; doing it here covers the popover presentation a
|
|
226
|
+
* non-modal subclass uses, and covers `destroyOnClose`, which takes the opener's document
|
|
227
|
+
* position out from under the platform's own restore.
|
|
228
|
+
* @returns {void}
|
|
229
|
+
*/
|
|
230
|
+
#restoreFocus() {
|
|
231
|
+
const opener = /** @type {HTMLElement|null} */ (this.#opener);
|
|
232
|
+
this.#opener = null;
|
|
233
|
+
if (opener?.isConnected && typeof opener.focus === 'function') opener.focus();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Places initial focus inside the panel.
|
|
238
|
+
*
|
|
239
|
+
* `showModal()` focuses an `autofocus` control when the content declares one and otherwise stops
|
|
240
|
+
* at the dialog element, which leaves a reader one Tab short of the first control. This keeps
|
|
241
|
+
* that preference — so a `Dialog` footer button marked `autofocus` still wins — and then falls
|
|
242
|
+
* through to the first focusable descendant, with the panel itself as the last resort so focus
|
|
243
|
+
* is never left outside the overlay.
|
|
244
|
+
* @returns {void}
|
|
245
|
+
*/
|
|
246
|
+
_focusInitial() {
|
|
247
|
+
if (!this.isOpen()) return;
|
|
248
|
+
const target = initialFocusTarget(this.el);
|
|
249
|
+
if (target) {
|
|
250
|
+
target.focus();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// A panel holding nothing focusable has to take focus itself, and is made focusable only for
|
|
254
|
+
// that: `tabindex="-1"` is programmatic focus, so it adds nothing to the Tab order.
|
|
255
|
+
const panel = /** @type {HTMLElement} */ (this.el);
|
|
256
|
+
if (!panel.hasAttribute('tabindex')) panel.setAttribute('tabindex', '-1');
|
|
257
|
+
panel.focus();
|
|
258
|
+
}
|
|
259
|
+
|
|
171
260
|
/**
|
|
172
261
|
* Creates the owned native dialog.
|
|
173
262
|
* @returns {HTMLDialogElement}
|
|
@@ -177,10 +266,25 @@ export class Modal extends Component {
|
|
|
177
266
|
class: 'zx-modal'
|
|
178
267
|
}, h('div', { class: 'zx-modal__content', ref: 'content' })));
|
|
179
268
|
dialog.dataset.state = 'closed';
|
|
180
|
-
this.
|
|
269
|
+
this._mountInitial(dialog);
|
|
181
270
|
return dialog;
|
|
182
271
|
}
|
|
183
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Gives a freshly rendered dialog its first home.
|
|
275
|
+
*
|
|
276
|
+
* A `scope` that is not in the document yet cannot say which theme it belongs to, and the
|
|
277
|
+
* `<body>` that `mountTarget()` would answer is outside it. The dialog then stays detached and
|
|
278
|
+
* `open()` places it, by which time the scope is in place. Called from `render()`, so it may
|
|
279
|
+
* read `this.options` but no instance state.
|
|
280
|
+
* @param {HTMLDialogElement} dialog The rendered dialog.
|
|
281
|
+
* @returns {void}
|
|
282
|
+
*/
|
|
283
|
+
_mountInitial(dialog) {
|
|
284
|
+
if (this.options.scope != null && !connectedOverlayHost(this.options.scope)) return;
|
|
285
|
+
this.mountTarget().append(dialog);
|
|
286
|
+
}
|
|
287
|
+
|
|
184
288
|
/**
|
|
185
289
|
* Opens the modal in the browser top layer.
|
|
186
290
|
* @returns {this}
|
|
@@ -188,6 +292,12 @@ export class Modal extends Component {
|
|
|
188
292
|
*/
|
|
189
293
|
open() {
|
|
190
294
|
if (this.isOpen()) return this;
|
|
295
|
+
/*
|
|
296
|
+
* Captured before anything is presented, so it is the element the reader was actually on
|
|
297
|
+
* rather than whatever the overlay is about to focus. A subclass that traps focus before
|
|
298
|
+
* delegating here has already moved it and restores its own capture afterwards.
|
|
299
|
+
*/
|
|
300
|
+
this.#opener = document.activeElement instanceof Element ? document.activeElement : null;
|
|
191
301
|
const host = this.mountTarget();
|
|
192
302
|
if (this.el.parentElement !== host) host.append(this.el);
|
|
193
303
|
this.el.dataset.zxOverlayOrder = String(nextOverlayOrder());
|
|
@@ -196,10 +306,15 @@ export class Modal extends Component {
|
|
|
196
306
|
this._present();
|
|
197
307
|
} catch (error) {
|
|
198
308
|
delete this.el.dataset.zxOverlayOrder;
|
|
309
|
+
// Nothing was presented, so nothing will close; a capture left behind would outlive its open.
|
|
310
|
+
this.#opener = null;
|
|
199
311
|
throw error;
|
|
200
312
|
}
|
|
201
313
|
this.el.dataset.state = 'open';
|
|
202
314
|
this.emit('open');
|
|
315
|
+
// Deferred by one microtask: the platform's own focusing step runs as part of presenting, and
|
|
316
|
+
// this has to be the one that settles, not the one it overwrites.
|
|
317
|
+
queueMicrotask(() => this._focusInitial());
|
|
203
318
|
return this;
|
|
204
319
|
}
|
|
205
320
|
|
|
@@ -278,6 +393,47 @@ export class Modal extends Component {
|
|
|
278
393
|
* @type {CustomEvent<Record<string, never>>}
|
|
279
394
|
*/
|
|
280
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Picks the control initial focus belongs on inside an overlay panel: an `autofocus` control when
|
|
398
|
+
* the content named one, the first focusable descendant otherwise, and null when the panel holds
|
|
399
|
+
* neither and must take focus itself.
|
|
400
|
+
*
|
|
401
|
+
* Candidates in a hidden, `aria-hidden` or `inert` subtree are skipped, because `focus()` on one
|
|
402
|
+
* is a silent no-op: a `Dialog` whose header close button is hidden by `closable: false`, or whose
|
|
403
|
+
* other registered views sit hidden in the same body, would otherwise "focus" nothing at all and
|
|
404
|
+
* leave the reader with no way into the overlay.
|
|
405
|
+
* @param {Element} root Overlay panel.
|
|
406
|
+
* @returns {HTMLElement|null}
|
|
407
|
+
*/
|
|
408
|
+
export function initialFocusTarget(root) {
|
|
409
|
+
const declared = root.querySelector('[autofocus]:not([disabled])');
|
|
410
|
+
if (declared && isFocusable(declared, root)) return /** @type {HTMLElement} */ (declared);
|
|
411
|
+
for (const candidate of root.querySelectorAll(FOCUSABLE_SELECTOR)) {
|
|
412
|
+
if (isFocusable(candidate, root)) return /** @type {HTMLElement} */ (candidate);
|
|
413
|
+
}
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Whether a candidate can take focus from where it sits inside the panel.
|
|
419
|
+
*
|
|
420
|
+
* Duck-typed on `focus` rather than tested against `HTMLElement`, so a focusable SVG or MathML
|
|
421
|
+
* element inside the panel is a candidate rather than a silently skipped one.
|
|
422
|
+
* @param {Element} element Candidate found by the focusable selector.
|
|
423
|
+
* @param {Element} root Panel the search is bounded by.
|
|
424
|
+
* @returns {boolean}
|
|
425
|
+
*/
|
|
426
|
+
function isFocusable(element, root) {
|
|
427
|
+
if (typeof (/** @type {HTMLElement} */ (element).focus) !== 'function') return false;
|
|
428
|
+
for (let node = /** @type {Element|null} */ (element); node; node = node.parentElement) {
|
|
429
|
+
if (/** @type {HTMLElement} */ (node).hidden) return false;
|
|
430
|
+
if (node.getAttribute('aria-hidden') === 'true') return false;
|
|
431
|
+
if (node.hasAttribute('inert')) return false;
|
|
432
|
+
if (node === root) break;
|
|
433
|
+
}
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
|
|
281
437
|
/**
|
|
282
438
|
* @param {Element} target
|
|
283
439
|
* @param {Node|string|number|{toElement: () => Node|null}|null|undefined} content
|