@zeyos/zx 5.0.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +2 -1
  3. package/dist/types/components/account-menu/account-menu.d.ts +8 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +9 -1
  5. package/dist/types/components/app-icon/app-icon.d.ts +14 -2
  6. package/dist/types/components/app-sidebar/app-sidebar.d.ts +288 -14
  7. package/dist/types/components/calendar/calendar.d.ts +67 -5
  8. package/dist/types/components/card-view/card-view.d.ts +7 -4
  9. package/dist/types/components/card-view/record-card.d.ts +11 -0
  10. package/dist/types/components/context-menu/context-menu.d.ts +6 -2
  11. package/dist/types/components/data-filter/data-filter.d.ts +4 -4
  12. package/dist/types/components/dropdown/dropdown.d.ts +22 -7
  13. package/dist/types/components/field/field.d.ts +30 -0
  14. package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
  15. package/dist/types/components/fieldset/fieldset.d.ts +7 -0
  16. package/dist/types/components/file-list/file-list.d.ts +35 -3
  17. package/dist/types/components/filter/filter.d.ts +127 -3
  18. package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
  19. package/dist/types/components/launcher/launcher.d.ts +8 -0
  20. package/dist/types/components/list-toolbar/list-toolbar.d.ts +15 -6
  21. package/dist/types/components/menu-button/menu-button.d.ts +6 -0
  22. package/dist/types/components/message/message.d.ts +14 -1
  23. package/dist/types/components/modal/modal.d.ts +30 -0
  24. package/dist/types/components/module-bar/module-bar.d.ts +395 -0
  25. package/dist/types/components/pagination/pagination.d.ts +17 -3
  26. package/dist/types/components/select/select.d.ts +6 -0
  27. package/dist/types/components/sheet/sheet.d.ts +15 -0
  28. package/dist/types/components/table/table.d.ts +265 -17
  29. package/dist/types/components/view/record-view.d.ts +59 -2
  30. package/dist/types/components/view-settings/view-settings.d.ts +879 -0
  31. package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
  32. package/dist/types/core/component.d.ts +12 -2
  33. package/dist/types/core/i18n.d.ts +27 -3
  34. package/dist/types/core/keyboard.d.ts +98 -1
  35. package/dist/types/core/messages.d.ts +382 -0
  36. package/dist/types/core/overlay-host.d.ts +28 -1
  37. package/dist/types/core/overlay-stack.d.ts +260 -0
  38. package/dist/types/index.d.ts +5 -2
  39. package/dist/types/internal/app-rail.d.ts +38 -0
  40. package/dist/types/internal/menu-items.d.ts +71 -3
  41. package/dist/types/zeyos/index.d.ts +1 -0
  42. package/dist/types/zeyos/launcher.d.ts +9 -0
  43. package/dist/types/zeyos/module-bar.d.ts +176 -0
  44. package/dist/types/zeyos/query.d.ts +31 -5
  45. package/dist/types/zeyos/table.d.ts +245 -8
  46. package/dist/types/zeyos/view.d.ts +16 -2
  47. package/dist/zx-zeyos.esm.js +4422 -430
  48. package/dist/zx-zeyos.esm.min.js +5 -5
  49. package/dist/zx.css +1417 -134
  50. package/dist/zx.esm.js +6986 -830
  51. package/dist/zx.esm.min.js +8 -8
  52. package/dist/zx.global.js +6986 -830
  53. package/dist/zx.global.min.js +8 -8
  54. package/dist/zx.min.css +1 -1
  55. package/docs/api.json +794 -17
  56. package/docs/llms.md +503 -63
  57. package/docs/llms.txt +6 -2
  58. package/package.json +1 -1
  59. package/src/components/account-menu/account-menu.js +12 -0
  60. package/src/components/activity-list/activity-list.js +27 -1
  61. package/src/components/app-icon/app-icon.css +13 -2
  62. package/src/components/app-icon/app-icon.js +11 -1
  63. package/src/components/app-rail/app-rail.css +19 -0
  64. package/src/components/app-sidebar/app-sidebar.css +204 -3
  65. package/src/components/app-sidebar/app-sidebar.js +646 -40
  66. package/src/components/breadcrumb/breadcrumb.css +1 -1
  67. package/src/components/calendar/calendar.css +1 -1
  68. package/src/components/calendar/calendar.js +26 -52
  69. package/src/components/card/card.css +2 -2
  70. package/src/components/card-view/card-view.css +5 -5
  71. package/src/components/card-view/card-view.js +13 -9
  72. package/src/components/card-view/record-card.js +14 -3
  73. package/src/components/code-editor/code-editor.css +1 -1
  74. package/src/components/code-editor/field-adapter.js +9 -2
  75. package/src/components/context-menu/context-menu.js +8 -2
  76. package/src/components/copy/copy-input.css +1 -1
  77. package/src/components/data-filter/data-filter.js +2 -2
  78. package/src/components/date-picker/time-picker.css +2 -2
  79. package/src/components/dock/dock.css +5 -4
  80. package/src/components/dropdown/dropdown.js +157 -72
  81. package/src/components/field/field.js +33 -3
  82. package/src/components/fieldset/external-adapter.js +363 -0
  83. package/src/components/fieldset/fieldset.js +10 -59
  84. package/src/components/file-list/file-list.js +144 -21
  85. package/src/components/filter/filter.css +86 -2
  86. package/src/components/filter/filter.js +300 -8
  87. package/src/components/filter-panel/filter-panel.js +149 -32
  88. package/src/components/finder/finder.css +1 -1
  89. package/src/components/kanban-view/kanban-view.css +3 -3
  90. package/src/components/kanban-view/kanban-view.js +5 -2
  91. package/src/components/launcher/launcher.css +1 -1
  92. package/src/components/launcher/launcher.js +29 -3
  93. package/src/components/list-toolbar/list-toolbar.js +39 -13
  94. package/src/components/master-panel/master-panel.js +0 -1
  95. package/src/components/menu-button/menu-button.css +63 -0
  96. package/src/components/menu-button/menu-button.js +97 -11
  97. package/src/components/message/message.js +60 -20
  98. package/src/components/modal/modal.css +1 -1
  99. package/src/components/modal/modal.js +66 -13
  100. package/src/components/module-bar/module-bar.css +486 -0
  101. package/src/components/module-bar/module-bar.js +1074 -0
  102. package/src/components/multi-value-editor/multi-value-editor.css +1 -1
  103. package/src/components/number-field/field-adapter.js +2 -1
  104. package/src/components/pagination/pagination.css +5 -5
  105. package/src/components/pagination/pagination.js +20 -4
  106. package/src/components/panel/panel.css +3 -2
  107. package/src/components/questionnaire/questionnaire.css +4 -4
  108. package/src/components/rating/field-adapter.js +2 -1
  109. package/src/components/rating/rating.css +1 -1
  110. package/src/components/search/search.css +4 -1
  111. package/src/components/select/select.js +9 -0
  112. package/src/components/sheet/sheet.css +1 -1
  113. package/src/components/sheet/sheet.js +74 -32
  114. package/src/components/slider/field-adapter.js +6 -1
  115. package/src/components/slider/slider.css +2 -2
  116. package/src/components/split-view/split-view.css +1 -1
  117. package/src/components/stat-tile/stat-tile.css +1 -1
  118. package/src/components/stepper/stepper.css +1 -1
  119. package/src/components/tabbox/tabbox.css +9 -5
  120. package/src/components/table/table.css +86 -11
  121. package/src/components/table/table.js +628 -57
  122. package/src/components/tag-picker/field-adapter.js +2 -1
  123. package/src/components/tooltip/tooltip.js +35 -17
  124. package/src/components/tree/tree.css +1 -1
  125. package/src/components/view/record-view.css +1 -1
  126. package/src/components/view/record-view.js +178 -18
  127. package/src/components/view-settings/view-settings.css +285 -0
  128. package/src/components/view-settings/view-settings.js +1830 -0
  129. package/src/components/workspace-switcher/workspace-switcher.css +188 -0
  130. package/src/components/workspace-switcher/workspace-switcher.js +463 -0
  131. package/src/core/component.js +88 -17
  132. package/src/core/fontawesome.js +5 -1
  133. package/src/core/i18n.js +47 -4
  134. package/src/core/icons.js +12 -2
  135. package/src/core/keyboard.js +206 -11
  136. package/src/core/messages.js +427 -0
  137. package/src/core/overlay-host.js +64 -9
  138. package/src/core/overlay-stack.js +487 -0
  139. package/src/core/position.js +22 -3
  140. package/src/index.js +5 -2
  141. package/src/internal/app-rail.js +134 -53
  142. package/src/internal/menu-items.js +61 -3
  143. package/src/zeyos/form.js +5 -3
  144. package/src/zeyos/index.js +3 -0
  145. package/src/zeyos/launcher.js +8 -3
  146. package/src/zeyos/module-bar.js +181 -0
  147. package/src/zeyos/query.js +44 -13
  148. package/src/zeyos/table.js +220 -80
  149. package/src/zeyos/view.js +43 -93
  150. package/styles/base.css +16 -1
  151. package/styles/print.css +1 -1
  152. package/styles/tokens/dark.css +4 -0
  153. package/styles/tokens/semantic.css +56 -28
  154. package/styles/zx.css +3 -0
@@ -1,11 +1,16 @@
1
1
  import { Component } from '../../core/component.js';
2
2
  import { h, resolveElement } from '../../core/dom.js';
3
- import { connectedOverlayHost, overlayHost } from '../../core/overlay-host.js';
3
+ import { connectedOverlayHost, openDialogOf, overlayHost, overlayScope } from '../../core/overlay-host.js';
4
+ import { pushOverlay } from '../../core/overlay-stack.js';
4
5
  import { position } from '../../core/position.js';
5
6
  import { uid } from '../../core/util.js';
6
7
 
7
- const HANDLE_POINTER_DISMISS = Symbol('zxDropdownPointerDismiss');
8
- const HANDLE_ESCAPE_DISMISS = Symbol('zxDropdownEscapeDismiss');
8
+ /** What Tab may land on inside a panel: the controls a reader reaches in sequence. */
9
+ const TABBABLE_SELECTOR = [
10
+ 'a[href]', 'button:not([disabled])', 'input:not([disabled]):not([type="hidden"])',
11
+ 'select:not([disabled])', 'textarea:not([disabled])', 'iframe', '[contenteditable="true"]',
12
+ '[tabindex]'
13
+ ].map((selector) => `${selector}:not([tabindex^="-"])`).join(',');
9
14
 
10
15
  /**
11
16
  * @typedef {Object} DropdownOptions
@@ -22,6 +27,16 @@ const HANDLE_ESCAPE_DISMISS = Symbol('zxDropdownEscapeDismiss');
22
27
 
23
28
  /**
24
29
  * Generic anchored floating panel using the browser popover top layer.
30
+ *
31
+ * Open, the panel is one entry on the overlay stack (`core/overlay-stack.js`): Escape closes it
32
+ * only while it is the topmost layer that takes Escape, a press outside it and every overlay opened
33
+ * from it closes it, and it closes with the overlay its anchor sits in. Inside an open dialog the
34
+ * panel is hosted by that dialog, so `showModal()` does not make it inert.
35
+ *
36
+ * The panel follows its trigger in the Tab order: Tab on the anchor of an open panel moves into
37
+ * the panel's first focusable control (or onto the panel itself when it carries a `tabindex`),
38
+ * Tab past the panel's last control continues after the anchor, and Shift+Tab from the first goes
39
+ * back to the anchor. Closing while focus is inside returns it to the anchor.
25
40
  * @fires Dropdown#open
26
41
  * @fires Dropdown#close
27
42
  * @extends {Component<DropdownOptions>}
@@ -46,6 +61,12 @@ export class Dropdown extends Component {
46
61
  #position = null;
47
62
  /** @type {Map<string, string|null>} */
48
63
  #anchorAttributes = new Map();
64
+ /** @type {import('../../core/overlay-stack.js').OverlayHandle|null} */
65
+ #overlay = null;
66
+ /** @type {((event: KeyboardEvent) => void)|null} Escape behaviour a composing component supplies. */
67
+ #escapeHandler = null;
68
+ /** Whether the panel joins the Tab order after its anchor; menus and rail flyouts opt out. */
69
+ #tabReach = true;
49
70
  #destroyed = false;
50
71
 
51
72
  /**
@@ -59,8 +80,9 @@ export class Dropdown extends Component {
59
80
  super(null, options);
60
81
  this.#anchor = resolved;
61
82
  // An anchor that is not in the document yet has no theme scope to read; the panel then waits
62
- // detached and finds its host on the first open, instead of settling on <body>.
63
- connectedOverlayHost(this.options.scope ?? resolved)?.append(this.el);
83
+ // detached and finds its host on the first open, instead of settling on <body>. A closed panel
84
+ // rests in the theme scope even when the anchor is in a dialog; it moves in only while open.
85
+ connectedOverlayHost(this.options.scope ?? resolved, { dialog: false })?.append(this.el);
64
86
  this.#rememberAnchorAttribute('aria-expanded');
65
87
  this.#rememberAnchorAttribute('aria-controls');
66
88
  this.#anchor.setAttribute('aria-expanded', 'false');
@@ -73,6 +95,8 @@ export class Dropdown extends Component {
73
95
  this.listen(this.el, 'click', () => {
74
96
  if (this.options.closeOnSelect) this.close();
75
97
  });
98
+ this.listen(this.#anchor, 'keydown', (event) => this.#onAnchorKeydown(/** @type {KeyboardEvent} */ (event)));
99
+ this.listen(this.el, 'keydown', (event) => this.#onPanelKeydown(/** @type {KeyboardEvent} */ (event)));
76
100
  }
77
101
 
78
102
  /**
@@ -95,7 +119,7 @@ export class Dropdown extends Component {
95
119
  */
96
120
  open() {
97
121
  if (this.isOpen() || this.#destroyed) return this;
98
- const host = overlayHost(this.options.scope ?? this.#anchor);
122
+ const host = this.#host();
99
123
  if (this.el.parentElement !== host) host.append(this.el);
100
124
  this.#position = position(this.#anchor, this.el, {
101
125
  placement: this.options.placement,
@@ -103,9 +127,18 @@ export class Dropdown extends Component {
103
127
  matchWidth: Boolean(this.options.matchWidth)
104
128
  });
105
129
  this.el.dataset.state = 'open';
106
- this.el.dataset.zxOverlayOrder = String(nextOverlayOrder());
130
+ this.#overlay = pushOverlay({
131
+ kind: 'popup',
132
+ root: this.el,
133
+ owner: this,
134
+ anchor: [this.#anchor, resolveElement(this.options.scope)],
135
+ inside: [this.#anchor],
136
+ onEscape: (event) => this.#onEscape(event),
137
+ onOutsidePointer: () => this.close(),
138
+ onParentClose: () => this.close()
139
+ });
140
+ this.el.dataset.zxOverlayOrder = String(this.#overlay.order);
107
141
  this.#anchor.setAttribute('aria-expanded', 'true');
108
- ensureDismissManager();
109
142
  this.emit('open');
110
143
  return this;
111
144
  }
@@ -117,13 +150,24 @@ export class Dropdown extends Component {
117
150
  */
118
151
  close() {
119
152
  if (!this.isOpen()) return this;
153
+ const hadFocus = this.el.contains(document.activeElement);
120
154
  this.#position?.destroy();
121
155
  this.#position = null;
122
156
  this.el.dataset.state = 'closed';
123
157
  delete this.el.dataset.zxOverlayOrder;
158
+ const overlay = this.#overlay;
159
+ this.#overlay = null;
124
160
  this.#anchor.setAttribute('aria-expanded', 'false');
161
+ // A panel that took the Tab order hands focus back rather than dropping it on <body>. Menus and
162
+ // flyouts decide where their own focus goes.
163
+ if (hadFocus && this.#tabReach && this.#anchor instanceof HTMLElement) this.#anchor.focus();
164
+ // Back to the theme scope: a closed panel left inside a dialog would sit in that dialog's
165
+ // focus search and go with it when the dialog is removed.
166
+ if (openDialogOf(this.el.parentElement) && this.el.parentElement?.isConnected) {
167
+ overlayScope(this.options.scope ?? this.#anchor).append(this.el);
168
+ }
169
+ overlay?.release();
125
170
  this.emit('close');
126
- destroyUnusedDismissManager();
127
171
  return this;
128
172
  }
129
173
 
@@ -174,50 +218,78 @@ export class Dropdown extends Component {
174
218
  super.destroy();
175
219
  }
176
220
 
177
- /** @param {PointerEvent} event @returns {void} */
178
- [HANDLE_POINTER_DISMISS](event) {
179
- if (!this.isOpen()) return;
180
- const path = event.composedPath();
181
- if (path.includes(this.el) || path.includes(this.#anchor)) return;
182
- this.close();
221
+ /**
222
+ * Internal seam for components built on a Dropdown (MenuButton, ContextMenu, the app rail):
223
+ * replaces what Escape does when the overlay stack routes it to this panel, and takes the panel
224
+ * out of the anchor's Tab order — a menu is reached with the arrow keys, not Tab.
225
+ * @param {{onEscape?: ((event: KeyboardEvent) => void)|null, tabReach?: boolean}} settings
226
+ * @returns {this}
227
+ * @internal
228
+ */
229
+ _configure(settings) {
230
+ if ('onEscape' in settings) this.#escapeHandler = settings.onEscape ?? null;
231
+ if ('tabReach' in settings) this.#tabReach = Boolean(settings.tabReach);
232
+ return this;
233
+ }
234
+
235
+ /** @returns {Element} Where the panel lives while open. */
236
+ #host() {
237
+ const dialog = openDialogOf(this.#anchor) ?? openDialogOf(resolveElement(this.options.scope));
238
+ return dialog ?? overlayHost(this.options.scope ?? this.#anchor);
183
239
  }
184
240
 
185
241
  /** @param {KeyboardEvent} event @returns {void} */
186
- [HANDLE_ESCAPE_DISMISS](event) {
187
- if (event.defaultPrevented || event.key !== 'Escape' || !this.isOpen()) return;
188
- event.preventDefault();
242
+ #onEscape(event) {
243
+ if (this.#escapeHandler) {
244
+ this.#escapeHandler(event);
245
+ return;
246
+ }
189
247
  const restoreFocus = this.el.contains(document.activeElement);
190
248
  this.close();
191
249
  if (restoreFocus && this.#anchor instanceof HTMLElement) this.#anchor.focus();
192
250
  }
193
251
 
194
- /** @param {string} name @returns {void} */
195
- #rememberAnchorAttribute(name) {
196
- this.#anchorAttributes.set(name, this.#anchor.getAttribute(name));
252
+ /** @param {KeyboardEvent} event @returns {void} */
253
+ #onAnchorKeydown(event) {
254
+ if (event.key !== 'Tab' || event.shiftKey || event.defaultPrevented || !this.#tabReach || !this.isOpen()) return;
255
+ const target = tabbables(this.el)[0] ?? (this.el.hasAttribute('tabindex') ? this.el : null);
256
+ if (!(target instanceof HTMLElement)) return;
257
+ event.preventDefault();
258
+ target.focus();
197
259
  }
198
- }
199
260
 
200
- /** Shared document-level light-dismiss listener owned only while a dropdown is open. */
201
- class DropdownDismissManager extends Component {
202
- /** Creates the transient listener owner. */
203
- constructor() {
204
- super(null);
205
- this.listen(document, 'pointerdown', (event) => {
206
- const component = topmostOverlayComponent();
207
- if (component instanceof Dropdown) component[HANDLE_POINTER_DISMISS](event);
208
- });
209
- this.listen(document, 'keydown', (event) => {
210
- if (event.defaultPrevented || event.key !== 'Escape') return;
211
- const component = topmostOverlayComponent();
212
- if (component instanceof Dropdown) component[HANDLE_ESCAPE_DISMISS](event);
213
- });
261
+ /** @param {KeyboardEvent} event @returns {void} */
262
+ #onPanelKeydown(event) {
263
+ if (event.key !== 'Tab' || event.defaultPrevented || !this.#tabReach || !this.isOpen()) return;
264
+ const items = tabbables(this.el);
265
+ const active = document.activeElement;
266
+ /*
267
+ * Judged by position, not identity: focus may rest on something Tab itself would never stop
268
+ * at — a composite's `tabindex="-1"` part, a roving item — and what matters is whether any
269
+ * real stop is left in the panel on the side Tab is moving to.
270
+ */
271
+ const ahead = active && active !== this.el
272
+ ? items.filter((item) => !item.contains(active) && (event.shiftKey
273
+ ? Boolean(active.compareDocumentPosition(item) & Node.DOCUMENT_POSITION_PRECEDING)
274
+ : Boolean(active.compareDocumentPosition(item) & Node.DOCUMENT_POSITION_FOLLOWING)
275
+ && !active.contains(item)))
276
+ : (event.shiftKey ? [] : items);
277
+ if (ahead.length > 0) return;
278
+ if (event.shiftKey) {
279
+ if (!(this.#anchor instanceof HTMLElement)) return;
280
+ event.preventDefault();
281
+ this.#anchor.focus();
282
+ return;
283
+ }
284
+ const next = followingTabbable(this.#anchor, this.el);
285
+ if (!next) return;
286
+ event.preventDefault();
287
+ next.focus();
214
288
  }
215
289
 
216
- /** @returns {HTMLElement} */
217
- render() {
218
- const marker = h('span', { hidden: true, 'data-zx-dropdown-dismiss-manager': '' });
219
- document.body.append(marker);
220
- return marker;
290
+ /** @param {string} name @returns {void} */
291
+ #rememberAnchorAttribute(name) {
292
+ this.#anchorAttributes.set(name, this.#anchor.getAttribute(name));
221
293
  }
222
294
  }
223
295
 
@@ -251,41 +323,54 @@ function replaceContent(target, content) {
251
323
  }
252
324
  }
253
325
 
254
- /** @returns {Component|null} */
255
- function topmostOverlayComponent() {
256
- let topmost = null;
257
- let highest = -1;
258
- for (const candidate of document.querySelectorAll('[data-zx-overlay-order]')) {
259
- const order = Number(candidate.getAttribute('data-zx-overlay-order')) || 0;
260
- if (order > highest) {
261
- highest = order;
262
- topmost = candidate;
263
- }
264
- }
265
- return topmost ? Component.from(topmost) : null;
266
- }
267
-
268
- /** @returns {number} */
269
- function nextOverlayOrder() {
270
- const orders = Array.from(document.querySelectorAll('[data-zx-overlay-order]'), (element) =>
271
- Number(element.getAttribute('data-zx-overlay-order')) || 0
272
- );
273
- return Math.max(0, ...orders) + 1;
326
+ /**
327
+ * The controls inside a container Tab would visit, in document order.
328
+ * @param {Element} container
329
+ * @returns {HTMLElement[]}
330
+ */
331
+ function tabbables(container) {
332
+ return /** @type {HTMLElement[]} */ (Array.from(container.querySelectorAll(TABBABLE_SELECTOR))
333
+ .filter((element) => isTabbable(element)));
274
334
  }
275
335
 
276
- /** @returns {DropdownDismissManager} */
277
- function ensureDismissManager() {
278
- const marker = document.querySelector('[data-zx-dropdown-dismiss-manager]');
279
- const existing = marker ? Component.from(marker) : null;
280
- return existing instanceof DropdownDismissManager ? existing : new DropdownDismissManager();
336
+ /**
337
+ * The control Tab reaches after `anchor`, skipping the panel that follows it.
338
+ * @param {Element} anchor
339
+ * @param {Element} panel
340
+ * @returns {HTMLElement|null}
341
+ */
342
+ function followingTabbable(anchor, panel) {
343
+ // Inside a modal dialog nothing beyond it can take focus, so the search stays within it.
344
+ const scope = openDialogOf(anchor) ?? anchor.ownerDocument.body ?? anchor.ownerDocument.documentElement;
345
+ const all = tabbables(scope).filter((element) => !panel.contains(element));
346
+ const index = all.findIndex((element) => element === anchor || anchor.contains(element));
347
+ if (index < 0) return null;
348
+ let next = index + 1;
349
+ while (next < all.length && anchor.contains(all[next])) next += 1;
350
+ return all[next] ?? null;
281
351
  }
282
352
 
283
- /** @returns {void} */
284
- function destroyUnusedDismissManager() {
285
- if (document.querySelector('.zx-dropdown[data-state="open"]')) return;
286
- const marker = document.querySelector('[data-zx-dropdown-dismiss-manager]');
287
- const manager = marker ? Component.from(marker) : null;
288
- if (manager instanceof DropdownDismissManager) manager.destroy();
353
+ /**
354
+ * Whether sequential navigation stops at an element: focusable, a non-negative tab index, not
355
+ * disabled, hidden, inert or `aria-hidden`, rendered, and — for a radio — the one stop its group
356
+ * offers (the checked radio, or the first when none is checked).
357
+ * @param {Element} element
358
+ * @returns {boolean}
359
+ */
360
+ function isTabbable(element) {
361
+ const html = /** @type {HTMLElement} */ (element);
362
+ if (typeof html.focus !== 'function' || html.tabIndex < 0) return false;
363
+ if (/** @type {HTMLButtonElement} */ (html).disabled) return false;
364
+ if (element.closest('[inert], [hidden], [aria-hidden="true"], fieldset[disabled]')) return false;
365
+ if (html instanceof HTMLInputElement && html.type === 'radio' && html.name && !html.checked) {
366
+ const scope = html.form ?? html.getRootNode();
367
+ const group = /** @type {HTMLInputElement[]} */ (Array.from(
368
+ /** @type {ParentNode} */ (scope).querySelectorAll?.('input[type="radio"]') ?? []
369
+ )).filter((radio) => radio.name === html.name && (radio.form ?? radio.getRootNode()) === scope);
370
+ if (group.some((radio) => radio.checked) || group[0] !== html) return false;
371
+ }
372
+ if (typeof html.checkVisibility === 'function') return html.checkVisibility({ visibilityProperty: true });
373
+ return html.getClientRects().length > 0;
289
374
  }
290
375
 
291
376
  /** @param {Element} element @param {string} name @param {string|null} value @returns {void} */
@@ -15,6 +15,9 @@ import { uid } from '../../core/util.js';
15
15
  * @property {string} [placeholder=''] Control placeholder.
16
16
  * @property {boolean} [required=false] Whether a value is required.
17
17
  * @property {boolean} [disabled=false] Whether the control is disabled.
18
+ * @property {boolean} [readonly=false] Whether the value is read-only: shown and focusable, not
19
+ * editable. Types whose control has no read-only state (`checkbox`, `select`, `optionlist`,
20
+ * `zxselect`, `date`, `time`, …) ignore it.
18
21
  * @property {string[]|Record<string, string>|null} [options=null] Select or option-list values.
19
22
  * @property {'stack'|'inline'} [layout='stack'] Label/control layout.
20
23
  * @property {Record<string, unknown>} [props={}] Adapter-specific control properties.
@@ -30,6 +33,8 @@ import { uid } from '../../core/util.js';
30
33
  * @property {(value: unknown, options?: {silent?: boolean}) => void} set Set the current value.
31
34
  * @property {() => void} focus Focus the control.
32
35
  * @property {(disabled: boolean) => void} setDisabled Set disabled state.
36
+ * @property {(readonly: boolean) => void} [setReadonly] Set read-only state; left out when the
37
+ * control has none.
33
38
  * @property {() => void} [destroy] Release adapter-owned resources.
34
39
  * @property {Element} [labelTarget] The element the field's label, description, and validity
35
40
  * belong to when it is not `el` — the text input inside a composite control. Left out, a
@@ -42,6 +47,7 @@ import { uid } from '../../core/util.js';
42
47
  /**
43
48
  * One labelled form control backed by a registered adapter.
44
49
  * @fires Field#change
50
+ * @fires Field#input
45
51
  * @fires Field#invalid
46
52
  * @extends {Component<FieldOptions>}
47
53
  */
@@ -59,6 +65,7 @@ export class Field extends Component {
59
65
  placeholder: '',
60
66
  required: false,
61
67
  disabled: false,
68
+ readonly: false,
62
69
  options: null,
63
70
  layout: 'stack',
64
71
  props: {},
@@ -108,7 +115,7 @@ export class Field extends Component {
108
115
  const layout = this.options.layout === 'inline' ? 'inline' : 'stack';
109
116
  this._ownedComponents = new Set();
110
117
  this._ownedNodes = [];
111
- this._rootAttributes = rememberAttributes(root, ['data-layout', 'data-field-type', 'data-disabled', 'data-state']);
118
+ this._rootAttributes = rememberAttributes(root, ['data-layout', 'data-field-type', 'data-disabled', 'data-readonly', 'data-state']);
112
119
  root.dataset.layout = layout;
113
120
  root.dataset.fieldType = type;
114
121
 
@@ -175,6 +182,8 @@ export class Field extends Component {
175
182
  this.initialValue = initialValue(type, this.options.value);
176
183
  this.adapter.set(this.initialValue, { silent: true });
177
184
  this.setDisabled(Boolean(this.options.disabled));
185
+ if (this.options.readonly) this.setReadonly(true);
186
+ // Adapters whose widget reports its own commits stop the native `change` before this sees it.
178
187
  this.listen(control, 'change', () => {
179
188
  this.emit('change', { value: this.getValue() });
180
189
  });
@@ -233,6 +242,19 @@ export class Field extends Component {
233
242
  return this;
234
243
  }
235
244
 
245
+ /**
246
+ * Makes the value read-only or editable again, where the control has a read-only state (see
247
+ * the `readonly` option); reflected as `data-readonly` on the root either way.
248
+ * @param {boolean} readonly Read-only state.
249
+ * @returns {this}
250
+ */
251
+ setReadonly(readonly) {
252
+ const value = Boolean(readonly);
253
+ this.adapter.setReadonly?.(value);
254
+ this.el.dataset.readonly = String(value);
255
+ return this;
256
+ }
257
+
236
258
  /**
237
259
  * Displays a validation or status message.
238
260
  * @param {string} message Message text.
@@ -470,7 +492,7 @@ function textAdapter(tag, type) {
470
492
  required: options.required,
471
493
  disabled: options.disabled
472
494
  }));
473
- return elementValueAdapter(el);
495
+ return { ...elementValueAdapter(el), setReadonly: (readonly) => { el.readOnly = readonly; } };
474
496
  };
475
497
  }
476
498
 
@@ -507,7 +529,8 @@ function numericAdapter(kind) {
507
529
  });
508
530
  return {
509
531
  ...elementValueAdapter(input),
510
- get: () => coerce(input.value).value
532
+ get: () => coerce(input.value).value,
533
+ setReadonly: (readonly) => { input.readOnly = readonly; }
511
534
  };
512
535
  };
513
536
  }
@@ -642,6 +665,13 @@ Field.register('custom', customAdapter);
642
665
  * @type {CustomEvent<{value: unknown}>}
643
666
  */
644
667
 
668
+ /**
669
+ * Live edit before it is committed, from the types whose widget tells the two apart (`code`,
670
+ * `slider`): every keystroke or drag step, where `change` follows only on commit.
671
+ * @event Field#input
672
+ * @type {CustomEvent<{value: unknown}>}
673
+ */
674
+
645
675
  /**
646
676
  * Field validation failure event.
647
677
  * @event Field#invalid