@zeyos/zx 4.3.0 → 4.4.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 +266 -1
- package/README.md +7 -3
- package/dist/types/components/activity-item/activity-item.d.ts +351 -0
- package/dist/types/components/activity-list/activity-list.d.ts +195 -0
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +28 -0
- package/dist/types/components/button/button.d.ts +8 -0
- package/dist/types/components/calendar/calendar-model.d.ts +22 -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 +12 -4
- package/dist/types/components/card-view/record-card.d.ts +53 -5
- package/dist/types/components/code-editor/code-editor.d.ts +160 -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/dialog/dialog.d.ts +29 -0
- package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
- package/dist/types/components/file-item/file-item.d.ts +234 -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/kanban-view/kanban-card.d.ts +138 -0
- package/dist/types/components/kanban-view/kanban-policy.d.ts +268 -0
- package/dist/types/components/kanban-view/kanban-view.d.ts +837 -26
- package/dist/types/components/launcher/launcher.d.ts +19 -12
- package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
- package/dist/types/components/menu-button/menu-button.d.ts +4 -37
- package/dist/types/components/message/message.d.ts +7 -0
- package/dist/types/components/modal/modal.d.ts +48 -0
- 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/select.d.ts +4 -0
- package/dist/types/components/sort-control/sort-control.d.ts +105 -0
- package/dist/types/components/stat-tile/stat-tile.d.ts +277 -0
- package/dist/types/components/tabbox/tabbox.d.ts +7 -0
- package/dist/types/components/table/table.d.ts +144 -0
- package/dist/types/components/table-view/table-view.d.ts +11 -5
- package/dist/types/components/timebox/timebox.d.ts +7 -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/component.d.ts +6 -1
- package/dist/types/core/icons.d.ts +17 -0
- package/dist/types/index.d.ts +13 -1
- package/dist/types/internal/app-rail.d.ts +10 -0
- package/dist/types/internal/menu-items.d.ts +134 -0
- package/dist/zx-zeyos.esm.js +651 -88
- package/dist/zx-zeyos.esm.min.js +8 -1
- package/dist/zx.css +6266 -3900
- package/dist/zx.esm.js +8487 -1805
- package/dist/zx.esm.min.js +10 -3
- package/dist/zx.global.js +8487 -1805
- package/dist/zx.global.min.js +10 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +1811 -247
- package/docs/llms.md +345 -40
- package/docs/llms.txt +11 -7
- package/package.json +1 -2
- package/src/components/activity-item/activity-item.css +137 -0
- package/src/components/activity-item/activity-item.js +599 -0
- package/src/components/activity-list/activity-list.css +64 -0
- package/src/components/activity-list/activity-list.js +375 -0
- package/src/components/app-rail/app-rail.css +227 -19
- package/src/components/app-sidebar/app-sidebar.css +414 -24
- package/src/components/app-sidebar/app-sidebar.js +65 -5
- package/src/components/button/button.css +8 -0
- package/src/components/button/button.js +6 -1
- package/src/components/calendar/calendar-model.js +45 -0
- package/src/components/calendar/calendar.css +30 -10
- package/src/components/calendar/calendar.js +41 -13
- package/src/components/card/card.js +12 -1
- package/src/components/card-view/card-view.css +212 -9
- package/src/components/card-view/card-view.js +32 -16
- package/src/components/card-view/record-card.js +98 -21
- package/src/components/code-editor/code-editor.css +67 -0
- package/src/components/code-editor/code-editor.js +288 -0
- package/src/components/code-editor/field-adapter.js +34 -0
- package/src/components/context-menu/context-menu.css +80 -3
- package/src/components/context-menu/context-menu.js +22 -30
- package/src/components/date-picker/date-picker.js +22 -8
- package/src/components/date-picker/month-picker.js +3 -3
- package/src/components/date-picker/time-picker.js +20 -8
- package/src/components/dialog/dialog.js +88 -66
- package/src/components/dropdown/dropdown.js +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/file-item/file-item.css +88 -0
- package/src/components/file-item/file-item.js +371 -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 +199 -45
- package/src/components/filter-panel/filter-panel.css +104 -0
- package/src/components/filter-panel/filter-panel.js +964 -0
- package/src/components/kanban-view/kanban-card.js +239 -0
- package/src/components/kanban-view/kanban-policy.js +412 -0
- package/src/components/kanban-view/kanban-view.css +674 -36
- package/src/components/kanban-view/kanban-view.js +1762 -240
- package/src/components/launcher/launcher.js +59 -27
- package/src/components/list-toolbar/list-toolbar.css +189 -0
- package/src/components/list-toolbar/list-toolbar.js +851 -0
- package/src/components/menu-button/menu-button.css +80 -3
- package/src/components/menu-button/menu-button.js +21 -37
- package/src/components/message/message.css +14 -12
- package/src/components/message/message.js +13 -2
- package/src/components/modal/modal.css +15 -0
- package/src/components/modal/modal.js +141 -0
- package/src/components/navigation-bar/navigation-bar.css +77 -2
- package/src/components/navigation-bar/navigation-bar.js +171 -4
- package/src/components/panel/panel.js +13 -2
- package/src/components/search/search.js +17 -5
- package/src/components/select/field-adapter.js +4 -1
- package/src/components/select/select.js +3 -0
- package/src/components/sort-control/sort-control.css +17 -0
- package/src/components/sort-control/sort-control.js +140 -0
- package/src/components/stat-tile/stat-tile.css +213 -0
- package/src/components/stat-tile/stat-tile.js +590 -0
- package/src/components/tabbox/tabbox.js +12 -1
- package/src/components/table/table.css +51 -2
- package/src/components/table/table.js +298 -19
- 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 +3 -1
- package/src/components/timebox/timebox.js +18 -5
- 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/component.js +8 -3
- package/src/core/icons.js +74 -5
- package/src/index.js +33 -3
- package/src/internal/app-rail.js +19 -1
- package/src/internal/menu-items.js +142 -0
- package/styles/print.css +192 -0
- package/styles/zx.css +11 -0
- package/DESIGN-SYSTEM.md +0 -182
|
@@ -11,15 +11,19 @@
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
.zx-menu-button__item {
|
|
14
|
-
display:
|
|
14
|
+
display: grid;
|
|
15
|
+
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
15
16
|
align-items: center;
|
|
16
17
|
gap: var(--zx-space-2);
|
|
17
18
|
inline-size: 100%;
|
|
18
19
|
min-block-size: var(--zx-control-height);
|
|
19
|
-
border
|
|
20
|
+
border: 1px solid transparent;
|
|
20
21
|
background: transparent;
|
|
21
22
|
padding-inline: var(--zx-control-pad-x);
|
|
23
|
+
color: inherit;
|
|
24
|
+
font: inherit;
|
|
22
25
|
text-align: start;
|
|
26
|
+
text-decoration: none;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
.zx-menu-button__item:hover,
|
|
@@ -37,11 +41,84 @@
|
|
|
37
41
|
opacity: .65;
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
.zx-menu-
|
|
44
|
+
.zx-menu-button__leading,
|
|
45
|
+
.zx-menu-button__meta {
|
|
41
46
|
display: inline-flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
gap: var(--zx-space-2);
|
|
42
49
|
flex: none;
|
|
43
50
|
}
|
|
44
51
|
|
|
52
|
+
.zx-menu-button__meta {
|
|
53
|
+
justify-content: flex-end;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.zx-menu-button__leading {
|
|
57
|
+
justify-content: center;
|
|
58
|
+
inline-size: var(--zx-space-4);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.zx-menu-button__body {
|
|
62
|
+
display: flex;
|
|
63
|
+
min-inline-size: 0;
|
|
64
|
+
flex-direction: column;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.zx-menu-button__label,
|
|
68
|
+
.zx-menu-button__description {
|
|
69
|
+
overflow: hidden;
|
|
70
|
+
text-overflow: ellipsis;
|
|
71
|
+
white-space: nowrap;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.zx-menu-button__description,
|
|
75
|
+
.zx-menu-button__shortcut,
|
|
76
|
+
.zx-menu-button__adornment {
|
|
77
|
+
color: var(--zx-color-text-muted);
|
|
78
|
+
font-size: var(--zx-text-xs);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.zx-menu-button__badge {
|
|
82
|
+
min-inline-size: var(--zx-space-4);
|
|
83
|
+
border-radius: var(--zx-radius-full);
|
|
84
|
+
background: var(--zx-color-bg-muted);
|
|
85
|
+
padding-inline: var(--zx-space-1);
|
|
86
|
+
color: var(--zx-color-text-muted);
|
|
87
|
+
font-size: var(--zx-text-xs);
|
|
88
|
+
text-align: center;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.zx-menu-button__shortcut {
|
|
92
|
+
border: 0;
|
|
93
|
+
background: transparent;
|
|
94
|
+
padding: 0;
|
|
95
|
+
font-family: var(--zx-font-mono);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.zx-menu-button__check {
|
|
99
|
+
inline-size: var(--zx-space-3);
|
|
100
|
+
block-size: var(--zx-space-3);
|
|
101
|
+
border: 1px solid var(--zx-color-border-strong);
|
|
102
|
+
border-radius: var(--zx-radius-sm);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.zx-menu-button__check[data-check-kind="radio"] {
|
|
106
|
+
border-radius: var(--zx-radius-full);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.zx-menu-button__item[aria-checked="true"] .zx-menu-button__check {
|
|
110
|
+
border-color: var(--zx-color-accent);
|
|
111
|
+
background: var(--zx-color-accent);
|
|
112
|
+
box-shadow: inset 0 0 0 2px var(--zx-color-bg-raised);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.zx-menu-button__heading {
|
|
116
|
+
padding: var(--zx-space-2) var(--zx-control-pad-x) var(--zx-space-1);
|
|
117
|
+
color: var(--zx-color-text-muted);
|
|
118
|
+
font-size: var(--zx-text-xs);
|
|
119
|
+
font-weight: 600;
|
|
120
|
+
}
|
|
121
|
+
|
|
45
122
|
.zx-menu-button__separator {
|
|
46
123
|
block-size: 1px;
|
|
47
124
|
margin: var(--zx-space-1) var(--zx-space-2);
|
|
@@ -2,20 +2,15 @@ import { Component } from '../../core/component.js';
|
|
|
2
2
|
import { h, resolveElement } from '../../core/dom.js';
|
|
3
3
|
import { icon } from '../../core/icons.js';
|
|
4
4
|
import { rovingTabindex, typeahead } from '../../core/keyboard.js';
|
|
5
|
+
import {
|
|
6
|
+
isMenuItem,
|
|
7
|
+
MENU_ITEM_SELECTOR,
|
|
8
|
+
renderMenuEntries
|
|
9
|
+
} from '../../internal/menu-items.js';
|
|
5
10
|
import { Dropdown } from '../dropdown/dropdown.js';
|
|
6
11
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* @typedef {Object} MenuItem
|
|
11
|
-
* @property {string} label Item label.
|
|
12
|
-
* @property {string} [icon] Kernel icon name.
|
|
13
|
-
* @property {unknown} [value] Value emitted on selection.
|
|
14
|
-
* @property {boolean} [disabled=false] Whether selection is disabled.
|
|
15
|
-
* @property {boolean} [danger=false] Whether the action is dangerous.
|
|
16
|
-
* @property {(value: unknown, item: MenuItem, menuButton: MenuButton) => void} [onselect] Selection callback.
|
|
17
|
-
*/
|
|
18
|
-
/** @typedef {MenuItem|'-'} MenuButtonItem */
|
|
12
|
+
/** @typedef {import('../../internal/menu-items.js').MenuItem} MenuItem */
|
|
13
|
+
/** @typedef {import('../../internal/menu-items.js').MenuEntry} MenuButtonItem */
|
|
19
14
|
/**
|
|
20
15
|
* @typedef {Object} MenuButtonOptions
|
|
21
16
|
* @property {string} [label=''] Trigger label.
|
|
@@ -116,7 +111,7 @@ export class MenuButton extends Component {
|
|
|
116
111
|
this.listen(this.el, 'keydown', (event) => this.#onTriggerKeydown(event));
|
|
117
112
|
this.listen(this.#panel, 'click', (event) => {
|
|
118
113
|
const item = event.target.closest?.(MENU_ITEM_SELECTOR);
|
|
119
|
-
if (item && this.#panel.contains(item)) this.#selectElement(item);
|
|
114
|
+
if (item && this.#panel.contains(item)) this.#selectElement(item, event);
|
|
120
115
|
});
|
|
121
116
|
this.listen(this.#panel, 'keydown', (event) => this.#onMenuKeydown(event));
|
|
122
117
|
}
|
|
@@ -142,25 +137,7 @@ export class MenuButton extends Component {
|
|
|
142
137
|
*/
|
|
143
138
|
setItems(items) {
|
|
144
139
|
this.#items = Array.isArray(items) ? items.slice() : [];
|
|
145
|
-
this.#panel.replaceChildren();
|
|
146
|
-
this.#items.forEach((item, index) => {
|
|
147
|
-
if (item === '-') {
|
|
148
|
-
this.#panel.append(h('div', { class: 'zx-menu-button__separator', role: 'separator' }));
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
const children = [];
|
|
152
|
-
if (item.icon) children.push(h('span', { class: 'zx-menu-button__icon' }, icon(item.icon)));
|
|
153
|
-
children.push(h('span', { class: 'zx-menu-button__label' }, item.label));
|
|
154
|
-
this.#panel.append(h('button', {
|
|
155
|
-
class: 'zx-menu-button__item',
|
|
156
|
-
type: 'button',
|
|
157
|
-
role: 'menuitem',
|
|
158
|
-
tabindex: '-1',
|
|
159
|
-
'data-menu-item': String(index),
|
|
160
|
-
'data-danger': item.danger ? 'true' : null,
|
|
161
|
-
ariaDisabled: item.disabled ? 'true' : null
|
|
162
|
-
}, children));
|
|
163
|
-
});
|
|
140
|
+
this.#panel.replaceChildren(...renderMenuEntries(this.#items, 'menu-button'));
|
|
164
141
|
return this;
|
|
165
142
|
}
|
|
166
143
|
|
|
@@ -265,20 +242,27 @@ export class MenuButton extends Component {
|
|
|
265
242
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
266
243
|
const item = event.target.closest?.(MENU_ITEM_SELECTOR);
|
|
267
244
|
if (!item || !this.#panel.contains(item)) return;
|
|
245
|
+
if (event.key === 'Enter' && item.tagName === 'A') return;
|
|
268
246
|
event.preventDefault();
|
|
269
|
-
|
|
247
|
+
/** @type {HTMLElement} */ (item).click();
|
|
270
248
|
return;
|
|
271
249
|
}
|
|
272
250
|
this.#typeahead(event);
|
|
273
251
|
}
|
|
274
252
|
|
|
275
|
-
/** @param {Element} element @returns {void} */
|
|
276
|
-
#selectElement(element) {
|
|
253
|
+
/** @param {Element} element @param {Event|null} [event=null] @returns {void} */
|
|
254
|
+
#selectElement(element, event = null) {
|
|
277
255
|
const index = Number(element.getAttribute('data-menu-item'));
|
|
278
256
|
const item = this.#items[index];
|
|
279
|
-
if (!item || item
|
|
257
|
+
if (!isMenuItem(item) || item.disabled) {
|
|
258
|
+
event?.preventDefault();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
280
261
|
const selected = this.emit('select', { value: item.value, item });
|
|
281
|
-
if (selected.defaultPrevented)
|
|
262
|
+
if (selected.defaultPrevented) {
|
|
263
|
+
event?.preventDefault();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
282
266
|
try {
|
|
283
267
|
item.onselect?.(item.value, item, this);
|
|
284
268
|
} finally {
|
|
@@ -27,14 +27,15 @@
|
|
|
27
27
|
|
|
28
28
|
.zx-message__toast {
|
|
29
29
|
--zx-message-kind: var(--zx-color-info);
|
|
30
|
+
--zx-message-fallback-bg: var(--zx-color-info-bg);
|
|
30
31
|
position: relative;
|
|
31
32
|
display: grid;
|
|
32
33
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
33
34
|
align-items: start;
|
|
34
35
|
gap: var(--zx-space-3);
|
|
35
36
|
border: 1px solid var(--zx-color-overlay-border);
|
|
36
|
-
border-radius: var(--zx-radius-
|
|
37
|
-
background: var(--zx-
|
|
37
|
+
border-radius: var(--zx-radius-lg);
|
|
38
|
+
background: var(--zx-message-fallback-bg);
|
|
38
39
|
box-shadow: inset 0 0 0 1px var(--zx-message-kind), var(--zx-glass-raised-shadow);
|
|
39
40
|
padding: var(--zx-space-3);
|
|
40
41
|
color: var(--zx-color-text);
|
|
@@ -42,17 +43,17 @@
|
|
|
42
43
|
|
|
43
44
|
.zx-message__toast[data-kind="success"] {
|
|
44
45
|
--zx-message-kind: var(--zx-color-success);
|
|
45
|
-
|
|
46
|
+
--zx-message-fallback-bg: var(--zx-color-success-bg);
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
.zx-message__toast[data-kind="warning"] {
|
|
49
50
|
--zx-message-kind: var(--zx-color-warning);
|
|
50
|
-
|
|
51
|
+
--zx-message-fallback-bg: var(--zx-color-warning-bg);
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
.zx-message__toast[data-kind="error"] {
|
|
54
55
|
--zx-message-kind: var(--zx-color-danger);
|
|
55
|
-
|
|
56
|
+
--zx-message-fallback-bg: var(--zx-color-danger-bg);
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
|
@@ -63,21 +64,19 @@
|
|
|
63
64
|
background-clip: padding-box;
|
|
64
65
|
-webkit-backdrop-filter: var(--zx-glass-filter-overlay);
|
|
65
66
|
backdrop-filter: var(--zx-glass-filter-overlay);
|
|
66
|
-
box-shadow: var(--zx-
|
|
67
|
+
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--zx-message-kind) 72%,
|
|
68
|
+
var(--zx-color-glass-highlight-strong)), var(--zx-glass-overlay-shadow);
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
@media (prefers-reduced-transparency: reduce) {
|
|
71
73
|
.zx-message__toast {
|
|
72
74
|
border-color: var(--zx-color-border);
|
|
75
|
+
background: var(--zx-message-fallback-bg);
|
|
73
76
|
-webkit-backdrop-filter: none;
|
|
74
77
|
backdrop-filter: none;
|
|
75
78
|
box-shadow: var(--zx-overlay-shadow);
|
|
76
79
|
}
|
|
77
|
-
.zx-message__toast[data-kind="info"] { background: var(--zx-color-info-bg); }
|
|
78
|
-
.zx-message__toast[data-kind="success"] { background: var(--zx-color-success-bg); }
|
|
79
|
-
.zx-message__toast[data-kind="warning"] { background: var(--zx-color-warning-bg); }
|
|
80
|
-
.zx-message__toast[data-kind="error"] { background: var(--zx-color-danger-bg); }
|
|
81
80
|
}
|
|
82
81
|
|
|
83
82
|
.zx-message__icon {
|
|
@@ -92,9 +91,12 @@
|
|
|
92
91
|
}
|
|
93
92
|
|
|
94
93
|
/* Visual chrome comes from the shared .zx-icon-btn utility; only alignment is set here so
|
|
95
|
-
the close control sits flush with the top
|
|
94
|
+
the close control sits flush with the trailing top corner without stretching the toast.
|
|
95
|
+
Trailing, not right: in an RTL document the close control belongs on the left, and a
|
|
96
|
+
four-value `margin` shorthand is the one place a logical property cannot be spelled. */
|
|
96
97
|
.zx-message__close {
|
|
97
|
-
margin: calc(-1 * var(--zx-space-1))
|
|
98
|
+
margin-block: calc(-1 * var(--zx-space-1)) 0;
|
|
99
|
+
margin-inline: 0 calc(-1 * var(--zx-space-1));
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
.zx-message__progress {
|
|
@@ -340,8 +340,8 @@ export class Message extends Component {
|
|
|
340
340
|
const close = h('button', {
|
|
341
341
|
class: 'zx-icon-btn zx-message__close',
|
|
342
342
|
type: 'button',
|
|
343
|
-
ariaLabel: 'Close message',
|
|
344
|
-
title: 'Close'
|
|
343
|
+
ariaLabel: this._message('message.close', 'Close message'),
|
|
344
|
+
title: this._message('message.closeTitle', 'Close')
|
|
345
345
|
}, icon('x', { size: 14 }));
|
|
346
346
|
node.append(close);
|
|
347
347
|
this.listen(close, 'click', () => this._close(entry));
|
|
@@ -417,6 +417,17 @@ export class Message extends Component {
|
|
|
417
417
|
this._queue.remove(entry);
|
|
418
418
|
}
|
|
419
419
|
|
|
420
|
+
/**
|
|
421
|
+
* Resolves a message through the host translator, falling back to the built-in English text.
|
|
422
|
+
* @param {string} key Message key.
|
|
423
|
+
* @param {string} fallback English default.
|
|
424
|
+
* @returns {string}
|
|
425
|
+
*/
|
|
426
|
+
_message(key, fallback) {
|
|
427
|
+
const message = this.msg(key);
|
|
428
|
+
return message === key ? fallback : message;
|
|
429
|
+
}
|
|
430
|
+
|
|
420
431
|
/** @param {Element|string|null|undefined} scope @returns {Message} */
|
|
421
432
|
static #floatingRegion(scope) {
|
|
422
433
|
const host = overlayHost(scope ?? null);
|
|
@@ -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.
|
|
@@ -2,6 +2,15 @@ import { Component } from '../../core/component.js';
|
|
|
2
2
|
import { h } from '../../core/dom.js';
|
|
3
3
|
import { overlayHost } from '../../core/overlay-host.js';
|
|
4
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(',');
|
|
13
|
+
|
|
5
14
|
/**
|
|
6
15
|
* @typedef {Object} ModalOptions
|
|
7
16
|
* @property {Node|string|number|{toElement: () => Node|null}|null} [content=null] Modal content.
|
|
@@ -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}
|
|
@@ -188,6 +277,12 @@ export class Modal extends Component {
|
|
|
188
277
|
*/
|
|
189
278
|
open() {
|
|
190
279
|
if (this.isOpen()) return this;
|
|
280
|
+
/*
|
|
281
|
+
* Captured before anything is presented, so it is the element the reader was actually on
|
|
282
|
+
* rather than whatever the overlay is about to focus. A subclass that traps focus before
|
|
283
|
+
* delegating here has already moved it and restores its own capture afterwards.
|
|
284
|
+
*/
|
|
285
|
+
this.#opener = document.activeElement instanceof Element ? document.activeElement : null;
|
|
191
286
|
const host = this.mountTarget();
|
|
192
287
|
if (this.el.parentElement !== host) host.append(this.el);
|
|
193
288
|
this.el.dataset.zxOverlayOrder = String(nextOverlayOrder());
|
|
@@ -196,10 +291,15 @@ export class Modal extends Component {
|
|
|
196
291
|
this._present();
|
|
197
292
|
} catch (error) {
|
|
198
293
|
delete this.el.dataset.zxOverlayOrder;
|
|
294
|
+
// Nothing was presented, so nothing will close; a capture left behind would outlive its open.
|
|
295
|
+
this.#opener = null;
|
|
199
296
|
throw error;
|
|
200
297
|
}
|
|
201
298
|
this.el.dataset.state = 'open';
|
|
202
299
|
this.emit('open');
|
|
300
|
+
// Deferred by one microtask: the platform's own focusing step runs as part of presenting, and
|
|
301
|
+
// this has to be the one that settles, not the one it overwrites.
|
|
302
|
+
queueMicrotask(() => this._focusInitial());
|
|
203
303
|
return this;
|
|
204
304
|
}
|
|
205
305
|
|
|
@@ -278,6 +378,47 @@ export class Modal extends Component {
|
|
|
278
378
|
* @type {CustomEvent<Record<string, never>>}
|
|
279
379
|
*/
|
|
280
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Picks the control initial focus belongs on inside an overlay panel: an `autofocus` control when
|
|
383
|
+
* the content named one, the first focusable descendant otherwise, and null when the panel holds
|
|
384
|
+
* neither and must take focus itself.
|
|
385
|
+
*
|
|
386
|
+
* Candidates in a hidden, `aria-hidden` or `inert` subtree are skipped, because `focus()` on one
|
|
387
|
+
* is a silent no-op: a `Dialog` whose header close button is hidden by `closable: false`, or whose
|
|
388
|
+
* other registered views sit hidden in the same body, would otherwise "focus" nothing at all and
|
|
389
|
+
* leave the reader with no way into the overlay.
|
|
390
|
+
* @param {Element} root Overlay panel.
|
|
391
|
+
* @returns {HTMLElement|null}
|
|
392
|
+
*/
|
|
393
|
+
export function initialFocusTarget(root) {
|
|
394
|
+
const declared = root.querySelector('[autofocus]:not([disabled])');
|
|
395
|
+
if (declared && isFocusable(declared, root)) return /** @type {HTMLElement} */ (declared);
|
|
396
|
+
for (const candidate of root.querySelectorAll(FOCUSABLE_SELECTOR)) {
|
|
397
|
+
if (isFocusable(candidate, root)) return /** @type {HTMLElement} */ (candidate);
|
|
398
|
+
}
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Whether a candidate can take focus from where it sits inside the panel.
|
|
404
|
+
*
|
|
405
|
+
* Duck-typed on `focus` rather than tested against `HTMLElement`, so a focusable SVG or MathML
|
|
406
|
+
* element inside the panel is a candidate rather than a silently skipped one.
|
|
407
|
+
* @param {Element} element Candidate found by the focusable selector.
|
|
408
|
+
* @param {Element} root Panel the search is bounded by.
|
|
409
|
+
* @returns {boolean}
|
|
410
|
+
*/
|
|
411
|
+
function isFocusable(element, root) {
|
|
412
|
+
if (typeof (/** @type {HTMLElement} */ (element).focus) !== 'function') return false;
|
|
413
|
+
for (let node = /** @type {Element|null} */ (element); node; node = node.parentElement) {
|
|
414
|
+
if (/** @type {HTMLElement} */ (node).hidden) return false;
|
|
415
|
+
if (node.getAttribute('aria-hidden') === 'true') return false;
|
|
416
|
+
if (node.hasAttribute('inert')) return false;
|
|
417
|
+
if (node === root) break;
|
|
418
|
+
}
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
|
|
281
422
|
/**
|
|
282
423
|
* @param {Element} target
|
|
283
424
|
* @param {Node|string|number|{toElement: () => Node|null}|null|undefined} content
|
|
@@ -138,12 +138,87 @@
|
|
|
138
138
|
box-shadow: inset var(--zx-focus-ring);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/*
|
|
142
|
+
* Overflow.
|
|
143
|
+
*
|
|
144
|
+
* Three modes, written by the component onto the root as `data-overflow`:
|
|
145
|
+
*
|
|
146
|
+
* all — every item collapses (the default, `minVisible: 0`): the row is taken out and the
|
|
147
|
+
* More button replaces it, which is what a top app bar wants.
|
|
148
|
+
* partial — `minVisible` items stay in the row and the rest move into the menu. The row and
|
|
149
|
+
* the button are then on screen together, which is the one case the navigation has
|
|
150
|
+
* to lay them out side by side.
|
|
151
|
+
* off — `overflow: false` or `overflowBelow: false`: nothing ever collapses, and the
|
|
152
|
+
* More button never appears at any width.
|
|
153
|
+
*
|
|
154
|
+
* The threshold is the awkward part, because a container query cannot read a custom property. So
|
|
155
|
+
* the default threshold lives here, in the query below, and any other one is measured by the
|
|
156
|
+
* component, which writes `data-overflow-below` (stand down, this bar has its own threshold) and
|
|
157
|
+
* `data-narrow`. The two blocks carry the same three declarations for that reason — a change to
|
|
158
|
+
* one belongs in the other.
|
|
159
|
+
*/
|
|
160
|
+
.zx-navigation-bar[data-overflow="partial"] .zx-navigation-bar__navigation {
|
|
161
|
+
display: flex;
|
|
162
|
+
align-items: center;
|
|
163
|
+
gap: var(--zx-space-1);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/*
|
|
167
|
+
* `partial` and `off` are the two modes that keep items in the row at a width the row was never
|
|
168
|
+
* going to fit: `minVisible` is a promise that those items stay, not that they fit, and
|
|
169
|
+
* `overflow: false` is the same promise for all of them. They give way by truncating their own
|
|
170
|
+
* labels rather than by spilling out of the bar. The badge and the More button keep their size —
|
|
171
|
+
* a count that says "4" and a button that says "More" have nothing to give.
|
|
172
|
+
*
|
|
173
|
+
* The default `all` mode is deliberately not in this list: it takes the whole row out instead, and
|
|
174
|
+
* it never has both on screen at once.
|
|
175
|
+
*/
|
|
176
|
+
.zx-navigation-bar[data-overflow="partial"] .zx-navigation-bar__item,
|
|
177
|
+
.zx-navigation-bar[data-overflow="partial"] .zx-navigation-bar__label,
|
|
178
|
+
.zx-navigation-bar[data-overflow="off"] .zx-navigation-bar__item,
|
|
179
|
+
.zx-navigation-bar[data-overflow="off"] .zx-navigation-bar__label {
|
|
180
|
+
min-inline-size: 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.zx-navigation-bar[data-overflow="partial"] .zx-navigation-bar__label,
|
|
184
|
+
.zx-navigation-bar[data-overflow="off"] .zx-navigation-bar__label {
|
|
185
|
+
overflow: hidden;
|
|
186
|
+
text-overflow: ellipsis;
|
|
187
|
+
white-space: nowrap;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.zx-navigation-bar[data-overflow="partial"] .zx-navigation-bar__badge,
|
|
191
|
+
.zx-navigation-bar[data-overflow="partial"] .zx-navigation-bar__more,
|
|
192
|
+
.zx-navigation-bar[data-overflow="off"] .zx-navigation-bar__badge {
|
|
193
|
+
flex: none;
|
|
194
|
+
}
|
|
195
|
+
|
|
141
196
|
@container (max-width: 44rem) {
|
|
142
|
-
.zx-navigation-bar__items {
|
|
197
|
+
.zx-navigation-bar[data-overflow="all"]:not([data-overflow-below]) .zx-navigation-bar__items {
|
|
143
198
|
display: none;
|
|
144
199
|
}
|
|
145
200
|
|
|
146
|
-
.zx-navigation-
|
|
201
|
+
.zx-navigation-bar[data-overflow="partial"]:not([data-overflow-below])
|
|
202
|
+
.zx-navigation-bar__item[data-collapsed="true"] {
|
|
203
|
+
display: none;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.zx-navigation-bar:not([data-overflow="off"]):not([data-overflow-below])
|
|
207
|
+
.zx-navigation-bar__more:not([data-empty="true"]) {
|
|
147
208
|
display: inline-flex;
|
|
148
209
|
}
|
|
149
210
|
}
|
|
211
|
+
|
|
212
|
+
.zx-navigation-bar[data-overflow="all"][data-narrow="true"] .zx-navigation-bar__items {
|
|
213
|
+
display: none;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.zx-navigation-bar[data-overflow="partial"][data-narrow="true"]
|
|
217
|
+
.zx-navigation-bar__item[data-collapsed="true"] {
|
|
218
|
+
display: none;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.zx-navigation-bar[data-narrow="true"]:not([data-overflow="off"])
|
|
222
|
+
.zx-navigation-bar__more:not([data-empty="true"]) {
|
|
223
|
+
display: inline-flex;
|
|
224
|
+
}
|