@zeyos/zx 4.4.0 → 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.
Files changed (182) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/README.md +42 -10
  3. package/dist/types/components/activity-list/activity-list.d.ts +22 -0
  4. package/dist/types/components/app-sidebar/app-sidebar.d.ts +5 -3
  5. package/dist/types/components/avatar/avatar.d.ts +46 -7
  6. package/dist/types/components/badge/badge.d.ts +21 -1
  7. package/dist/types/components/button/button.d.ts +15 -0
  8. package/dist/types/components/chart/chart.d.ts +10 -0
  9. package/dist/types/components/checklist/checklist.d.ts +9 -0
  10. package/dist/types/components/code-editor/code-editor.d.ts +6 -17
  11. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  12. package/dist/types/components/datebox/datebox.d.ts +7 -2
  13. package/dist/types/components/dialog/dialog.d.ts +46 -6
  14. package/dist/types/components/dropdown/dropdown.d.ts +2 -2
  15. package/dist/types/components/entity-ref/entity-ref.d.ts +2 -2
  16. package/dist/types/components/field/field.d.ts +24 -2
  17. package/dist/types/components/fieldset/fieldset.d.ts +19 -1
  18. package/dist/types/components/file-item/file-item.d.ts +34 -6
  19. package/dist/types/components/form/form.d.ts +12 -1
  20. package/dist/types/components/kanban-view/kanban-card.d.ts +8 -0
  21. package/dist/types/components/launcher/launcher.d.ts +260 -15
  22. package/dist/types/components/list-toolbar/list-toolbar.d.ts +1 -1
  23. package/dist/types/components/menu-button/menu-button.d.ts +33 -5
  24. package/dist/types/components/message/message.d.ts +119 -17
  25. package/dist/types/components/modal/modal.d.ts +13 -2
  26. package/dist/types/components/select/color.d.ts +12 -0
  27. package/dist/types/components/select/glyphs.d.ts +124 -0
  28. package/dist/types/components/select/select.d.ts +11 -3
  29. package/dist/types/components/stat-tile/stat-tile.d.ts +16 -5
  30. package/dist/types/components/tabbox/tabbox.d.ts +9 -0
  31. package/dist/types/components/table/table.d.ts +46 -1
  32. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  33. package/dist/types/components/tree/tree.d.ts +13 -0
  34. package/dist/types/core/breakpoint.d.ts +1 -1
  35. package/dist/types/core/icons.d.ts +23 -25
  36. package/dist/types/core/motion.d.ts +59 -0
  37. package/dist/types/core/overlay-host.d.ts +15 -1
  38. package/dist/types/index.d.ts +5 -3
  39. package/dist/types/zeyos/connect.d.ts +6 -0
  40. package/dist/types/zeyos/form.d.ts +50 -9
  41. package/dist/types/zeyos/index.d.ts +1 -1
  42. package/dist/types/zeyos/query.d.ts +79 -0
  43. package/dist/types/zeyos/schema.d.ts +24 -1
  44. package/dist/types/zeyos/table.d.ts +100 -14
  45. package/dist/types/zeyos/view.d.ts +10 -0
  46. package/dist/zx-zeyos.esm.js +1621 -443
  47. package/dist/zx-zeyos.esm.min.js +53 -5
  48. package/dist/zx.css +1411 -660
  49. package/dist/zx.esm.js +1976 -703
  50. package/dist/zx.esm.min.js +56 -8
  51. package/dist/zx.global.js +1978 -705
  52. package/dist/zx.global.min.js +56 -8
  53. package/dist/zx.min.css +1 -1
  54. package/docs/api.json +219 -34
  55. package/docs/llms.md +472 -110
  56. package/docs/llms.txt +3 -3
  57. package/package.json +1 -1
  58. package/src/components/account-menu/account-menu.css +35 -10
  59. package/src/components/activity-item/activity-item.css +1 -1
  60. package/src/components/activity-item/activity-item.js +3 -0
  61. package/src/components/activity-list/activity-list.css +38 -3
  62. package/src/components/activity-list/activity-list.js +34 -0
  63. package/src/components/app-icon/app-icon.css +1 -1
  64. package/src/components/app-rail/app-rail.css +40 -79
  65. package/src/components/app-sidebar/app-sidebar.css +109 -174
  66. package/src/components/app-sidebar/app-sidebar.js +21 -6
  67. package/src/components/aurora/aurora.css +5 -3
  68. package/src/components/avatar/avatar.css +32 -7
  69. package/src/components/avatar/avatar.js +72 -8
  70. package/src/components/badge/badge.css +54 -2
  71. package/src/components/badge/badge.js +29 -2
  72. package/src/components/button/button.css +67 -9
  73. package/src/components/button/button.js +38 -0
  74. package/src/components/calendar/calendar.css +46 -46
  75. package/src/components/card/card.css +2 -2
  76. package/src/components/card-view/card-view.css +9 -7
  77. package/src/components/chart/chart.css +4 -1
  78. package/src/components/chart/chart.js +12 -0
  79. package/src/components/check-button/check-button.css +10 -2
  80. package/src/components/checklist/checklist.css +9 -2
  81. package/src/components/checklist/checklist.js +11 -5
  82. package/src/components/checklist/field-adapter.js +3 -1
  83. package/src/components/code-editor/code-editor.css +6 -5
  84. package/src/components/code-editor/code-editor.js +19 -1
  85. package/src/components/context-menu/context-menu.css +9 -4
  86. package/src/components/copy/copy-button.css +6 -0
  87. package/src/components/copy/copy-input.css +8 -3
  88. package/src/components/data-filter/data-filter.css +5 -4
  89. package/src/components/date-picker/date-picker.css +6 -6
  90. package/src/components/date-picker/date-range-picker.css +3 -3
  91. package/src/components/date-picker/time-picker.css +1 -1
  92. package/src/components/datebox/date-range-box.css +79 -2
  93. package/src/components/datebox/date-range-box.js +13 -2
  94. package/src/components/datebox/datebox.css +79 -2
  95. package/src/components/datebox/datebox.js +13 -2
  96. package/src/components/dialog/dialog.css +26 -0
  97. package/src/components/dialog/dialog.js +99 -17
  98. package/src/components/dropdown/dropdown.css +1 -1
  99. package/src/components/dropdown/dropdown.js +5 -3
  100. package/src/components/empty-state/empty-state.css +2 -2
  101. package/src/components/entity-ref/entity-ref.css +1 -1
  102. package/src/components/entity-ref/entity-ref.js +1 -1
  103. package/src/components/field/field.css +28 -9
  104. package/src/components/field/field.js +68 -26
  105. package/src/components/field-upload/field-upload.css +1 -1
  106. package/src/components/fieldset/fieldset.css +5 -3
  107. package/src/components/fieldset/fieldset.js +28 -5
  108. package/src/components/file-item/file-item.js +144 -10
  109. package/src/components/filter/filter.js +7 -3
  110. package/src/components/filter-panel/filter-panel.css +1 -1
  111. package/src/components/finder/finder.css +15 -13
  112. package/src/components/form/form.css +5 -4
  113. package/src/components/form/form.js +11 -2
  114. package/src/components/grid/grid.css +1 -1
  115. package/src/components/groupbox/groupbox.css +1 -1
  116. package/src/components/kanban-view/kanban-card.js +11 -3
  117. package/src/components/kanban-view/kanban-view.css +39 -15
  118. package/src/components/kanban-view/kanban-view.js +4 -4
  119. package/src/components/launcher/launcher.css +107 -25
  120. package/src/components/launcher/launcher.js +503 -41
  121. package/src/components/list-toolbar/list-toolbar.css +13 -8
  122. package/src/components/list-toolbar/list-toolbar.js +1 -1
  123. package/src/components/master-panel/master-panel.css +2 -0
  124. package/src/components/menu-button/menu-button.css +9 -4
  125. package/src/components/menu-button/menu-button.js +65 -9
  126. package/src/components/message/message.css +33 -3
  127. package/src/components/message/message.js +217 -15
  128. package/src/components/modal/modal.js +18 -3
  129. package/src/components/navigation-bar/navigation-bar.css +18 -9
  130. package/src/components/number-field/field-adapter.js +1 -0
  131. package/src/components/number-field/number-field.css +70 -0
  132. package/src/components/number-field/number-field.js +2 -0
  133. package/src/components/pagination/pagination.css +8 -6
  134. package/src/components/panel/panel.css +1 -1
  135. package/src/components/rating/rating.css +7 -0
  136. package/src/components/select/color.js +22 -0
  137. package/src/components/select/field-adapter.js +2 -0
  138. package/src/components/select/glyphs.js +165 -0
  139. package/src/components/select/select.css +138 -45
  140. package/src/components/select/select.js +83 -47
  141. package/src/components/sheet/sheet.css +11 -8
  142. package/src/components/slider/slider.css +16 -2
  143. package/src/components/slider/slider.js +1 -1
  144. package/src/components/sort-control/sort-control.css +4 -1
  145. package/src/components/stat-tile/stat-tile.css +41 -46
  146. package/src/components/stat-tile/stat-tile.js +17 -6
  147. package/src/components/tabbox/tabbox.css +32 -27
  148. package/src/components/tabbox/tabbox.js +5 -1
  149. package/src/components/table/table.css +104 -29
  150. package/src/components/table/table.js +90 -16
  151. package/src/components/tag-picker/tag-picker.css +83 -5
  152. package/src/components/tag-picker/tag-picker.js +25 -8
  153. package/src/components/timebox/timebox.css +22 -2
  154. package/src/components/toggle/toggle.css +12 -1
  155. package/src/components/toolbar/toolbar.js +2 -2
  156. package/src/components/tooltip/tooltip.css +4 -0
  157. package/src/components/tooltip/tooltip.js +10 -3
  158. package/src/components/tree/tree.css +25 -16
  159. package/src/components/tree/tree.js +31 -0
  160. package/src/components/value-list/value-list.css +3 -3
  161. package/src/components/view/record-view.js +1 -1
  162. package/src/core/breakpoint.js +1 -1
  163. package/src/core/icons.js +193 -60
  164. package/src/core/motion.js +147 -0
  165. package/src/core/overlay-host.js +21 -2
  166. package/src/index.js +5 -3
  167. package/src/internal/app-rail.js +1 -2
  168. package/src/zeyos/connect.js +6 -2
  169. package/src/zeyos/form.js +67 -27
  170. package/src/zeyos/index.js +1 -1
  171. package/src/zeyos/query.js +153 -0
  172. package/src/zeyos/schema.js +144 -27
  173. package/src/zeyos/table.js +68 -5
  174. package/src/zeyos/view.js +6 -0
  175. package/styles/base.css +56 -0
  176. package/styles/icons.css +11 -1
  177. package/styles/tokens/dark.css +113 -37
  178. package/styles/tokens/global.css +121 -27
  179. package/styles/tokens/semantic.css +100 -31
  180. package/styles/tokens/themes.css +7 -6
  181. package/styles/zx.css +0 -1
  182. package/styles/tokens/density.css +0 -9
@@ -20,7 +20,7 @@ import { button } from '../button/button.js';
20
20
  * @property {DialogButton[]} [buttons=[]] Footer buttons.
21
21
  * @property {boolean} [closable=true] Whether Escape and the header button may close the dialog.
22
22
  * @property {Node|string|number|{toElement: () => Node|null}|null} [content=null] Body content.
23
- * @property {Element|string|null} [scope=null] Element whose nearest Zx theme scope owns the overlay; defaults to the opener.
23
+ * @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.
24
24
  * @property {(event: CustomEvent<Record<string, never>>) => void} [onopen] Open event listener.
25
25
  * @property {(event: CustomEvent<{result: unknown}>) => void} [onclose] Close event listener.
26
26
  * @property {(event: CustomEvent<Record<string, never>>) => void} [oncancel] Cancel event listener.
@@ -51,6 +51,11 @@ import { button } from '../button/button.js';
51
51
  * @property {string} [okLabel='OK'] Confirmation label.
52
52
  * @property {string} [cancelLabel='Cancel'] Cancellation label.
53
53
  * @property {boolean} [danger=false] Whether the confirmation action is dangerous.
54
+ * @property {string|null} [confirmText=null] Text the reader must type before the confirm button
55
+ * enables — the name of what is about to be deleted, say. The dialog adds an input labelled
56
+ * "Type <confirmText> to confirm" (message `dialog.typeToConfirm`, `%1` standing for the text),
57
+ * focuses it, and enables the confirm button, and Enter, only while its value matches exactly,
58
+ * ignoring surrounding spaces.
54
59
  */
55
60
  /**
56
61
  * @typedef {Object} PromptOptions
@@ -58,6 +63,8 @@ import { button } from '../button/button.js';
58
63
  * @property {Node|string|number|null} [message=''] Prompt message.
59
64
  * @property {string} [value=''] Initial input value.
60
65
  * @property {string} [placeholder=''] Input placeholder.
66
+ * @property {string} [okLabel='OK'] Confirmation label.
67
+ * @property {string} [cancelLabel='Cancel'] Cancellation label.
61
68
  */
62
69
 
63
70
  /**
@@ -138,7 +145,7 @@ export class Dialog extends Modal {
138
145
  h('footer', { class: 'zx-dialog__footer', ref: 'footer' })
139
146
  ));
140
147
  dialog.dataset.state = 'closed';
141
- this.mountTarget().append(dialog);
148
+ this._mountInitial(dialog);
142
149
  return dialog;
143
150
  }
144
151
 
@@ -281,12 +288,13 @@ export class Dialog extends Modal {
281
288
  }
282
289
 
283
290
  /**
284
- * Opens a confirmation dialog.
291
+ * Opens a confirmation dialog. With `confirmText` it becomes a type-to-confirm dialog: the
292
+ * confirm button stays disabled until the reader has typed that text.
285
293
  * @param {ConfirmOptions} [options={}] Confirmation options.
286
- * @returns {Promise<boolean>}
294
+ * @returns {Promise<boolean>} True when confirmed; false on Cancel, Escape, or the close button.
287
295
  */
288
296
  static confirm({
289
- title = '', message = '', okLabel, cancelLabel, danger = false
297
+ title = '', message = '', okLabel, cancelLabel, danger = false, confirmText = null
290
298
  } = {}) {
291
299
  return new Promise((resolve) => {
292
300
  let settled = false;
@@ -296,16 +304,58 @@ export class Dialog extends Modal {
296
304
  dialog.close(value);
297
305
  resolve(value);
298
306
  };
299
- const dialog = new Dialog(null, { title, content: message });
300
- dialog.setButtons([
301
- { label: cancelLabel ?? dialog._message('dialog.cancel', 'Cancel'), action: () => finish(false) },
302
- {
303
- label: okLabel ?? dialog._message('dialog.ok', 'OK'),
304
- kind: danger ? 'danger' : 'primary',
305
- action: () => finish(true),
307
+ const expected = confirmText == null ? '' : String(confirmText).trim();
308
+ const dialog = new Dialog(null, { title, content: expected ? null : message });
309
+ // A destructive confirmation starts on the least destructive choice, as the APG alert dialog
310
+ // has it; type-to-confirm starts in its input.
311
+ const cancel = {
312
+ label: cancelLabel ?? dialog._message('dialog.cancel', 'Cancel'),
313
+ action: () => finish(false),
314
+ autofocus: danger && !expected
315
+ };
316
+ const accept = {
317
+ label: okLabel ?? dialog._message('dialog.ok', 'OK'),
318
+ kind: /** @type {DialogButtonKind} */ (danger ? 'danger' : 'primary'),
319
+ action: () => finish(true),
320
+ autofocus: !danger && !expected
321
+ };
322
+ if (expected) {
323
+ const input = /** @type {HTMLInputElement} */ (h('input', {
324
+ class: 'zx-dialog__confirm-input',
325
+ id: uid('zx-dialog-confirm'),
326
+ type: 'text',
327
+ autocomplete: 'off',
328
+ autocapitalize: 'off',
329
+ spellcheck: false,
306
330
  autofocus: true
307
- }
308
- ]);
331
+ }));
332
+ const [before, after] = typeToConfirmParts(
333
+ dialog._message('dialog.typeToConfirm', 'Type %1 to confirm'), expected);
334
+ const label = h('label', { class: 'zx-dialog__confirm-label', for: input.id },
335
+ before, h('b', {}, expected), after);
336
+ dialog.setContent(h('div', { class: 'zx-dialog__confirm' },
337
+ isEmptyContent(message) ? null : h('div', { class: 'zx-dialog__prompt-message' }, message),
338
+ label,
339
+ input));
340
+ let matched = false;
341
+ const settle = () => dialog.setButtons([cancel, { ...accept, autofocus: false, disabled: !matched }]);
342
+ settle();
343
+ dialog.listen(input, 'input', () => {
344
+ const next = typedConfirmation(input.value, expected);
345
+ if (next === matched) return;
346
+ matched = next;
347
+ settle();
348
+ });
349
+ // Enter confirms only a match; otherwise it does nothing, as the disabled button would.
350
+ dialog.listen(input, 'keydown', (event) => {
351
+ const key = /** @type {KeyboardEvent} */ (event);
352
+ if (key.key !== 'Enter' || key.isComposing) return;
353
+ key.preventDefault();
354
+ if (typedConfirmation(input.value, expected)) finish(true);
355
+ });
356
+ } else {
357
+ dialog.setButtons([cancel, accept]);
358
+ }
309
359
  dialog.once('close', () => {
310
360
  if (!settled) {
311
361
  settled = true;
@@ -322,7 +372,9 @@ export class Dialog extends Modal {
322
372
  * @param {PromptOptions} [options={}] Prompt options.
323
373
  * @returns {Promise<string|null>}
324
374
  */
325
- static prompt({ title = '', message = '', value = '', placeholder = '' } = {}) {
375
+ static prompt({
376
+ title = '', message = '', value = '', placeholder = '', okLabel, cancelLabel
377
+ } = {}) {
326
378
  return new Promise((resolve) => {
327
379
  let settled = false;
328
380
  const input = h('input', {
@@ -344,8 +396,8 @@ export class Dialog extends Modal {
344
396
  const dialog = new Dialog(null, { title, content });
345
397
  input.setAttribute('aria-label', String(title || dialog._message('dialog.promptValue', 'Prompt value')));
346
398
  dialog.setButtons([
347
- { label: dialog._message('dialog.cancel', 'Cancel'), action: () => finish(null) },
348
- { label: dialog._message('dialog.ok', 'OK'), kind: 'primary', action: () => finish(input.value) }
399
+ { label: cancelLabel ?? dialog._message('dialog.cancel', 'Cancel'), action: () => finish(null) },
400
+ { label: okLabel ?? dialog._message('dialog.ok', 'OK'), kind: 'primary', action: () => finish(input.value) }
349
401
  ]);
350
402
  dialog.listen(input, 'keydown', (event) => {
351
403
  if (event.key !== 'Enter') return;
@@ -428,6 +480,36 @@ export class Dialog extends Modal {
428
480
  }
429
481
  }
430
482
 
483
+ /**
484
+ * Reports whether what the reader typed confirms a type-to-confirm dialog: the same text, exactly
485
+ * — case and inner spacing included — once surrounding spaces are ignored on both sides.
486
+ * @param {unknown} typed Input value.
487
+ * @param {unknown} expected Text the dialog asks for.
488
+ * @returns {boolean}
489
+ */
490
+ export function typedConfirmation(typed, expected) {
491
+ const target = expected == null ? '' : String(expected).trim();
492
+ return target !== '' && String(typed ?? '').trim() === target;
493
+ }
494
+
495
+ /**
496
+ * Splits the type-to-confirm message around `%1`, where the text to type goes. A translation that
497
+ * leaves `%1` out gets the text after it, following a space; a second `%1` is filled in as text.
498
+ * @param {string} template Message such as "Type %1 to confirm".
499
+ * @param {string} expected Text to type.
500
+ * @returns {[string, string]} The text before and after the text to type.
501
+ */
502
+ export function typeToConfirmParts(template, expected) {
503
+ const parts = String(template).split('%1');
504
+ if (parts.length === 1) return [`${parts[0]} `, ''];
505
+ return [parts[0], parts.slice(1).join(expected)];
506
+ }
507
+
508
+ /** @param {unknown} content @returns {boolean} */
509
+ function isEmptyContent(content) {
510
+ return content == null || content === '';
511
+ }
512
+
431
513
  /**
432
514
  * Copies footer-button descriptors without mutating the caller's array or objects, and settles
433
515
  * the two booleans the footer renders from.
@@ -3,7 +3,7 @@
3
3
  max-block-size: calc(100vh - (2 * var(--zx-space-3)));
4
4
  overflow: auto;
5
5
  border: 1px solid var(--zx-color-overlay-border);
6
- border-radius: var(--zx-radius-md);
6
+ border-radius: var(--zx-radius-lg);
7
7
  background-color: var(--zx-color-overlay-surface);
8
8
  background-image: var(--zx-overlay-surface-image);
9
9
  background-clip: padding-box;
@@ -1,6 +1,6 @@
1
1
  import { Component } from '../../core/component.js';
2
2
  import { h, resolveElement } from '../../core/dom.js';
3
- import { overlayHost } from '../../core/overlay-host.js';
3
+ import { connectedOverlayHost, overlayHost } from '../../core/overlay-host.js';
4
4
  import { position } from '../../core/position.js';
5
5
  import { uid } from '../../core/util.js';
6
6
 
@@ -15,7 +15,7 @@ const HANDLE_ESCAPE_DISMISS = Symbol('zxDropdownEscapeDismiss');
15
15
  * @property {boolean} [matchWidth=false] Whether the panel matches the anchor width.
16
16
  * @property {'click'|'manual'} [openOn='click'] Trigger behavior.
17
17
  * @property {boolean} [closeOnSelect=false] Whether a click within the panel closes it.
18
- * @property {Element|string|null} [scope=null] Element whose nearest Zx theme scope owns the panel; defaults to the anchor.
18
+ * @property {Element|string|null} [scope=null] Element whose nearest Zx theme scope owns the panel; defaults to the anchor. Resolved on every open, so an anchor connected after construction still gets its scope.
19
19
  * @property {(event: CustomEvent<Record<string, never>>) => void} [onopen] Open event listener.
20
20
  * @property {(event: CustomEvent<Record<string, never>>) => void} [onclose] Close event listener.
21
21
  */
@@ -58,7 +58,9 @@ export class Dropdown extends Component {
58
58
  if (!resolved) throw new TypeError('Dropdown anchor could not be resolved');
59
59
  super(null, options);
60
60
  this.#anchor = resolved;
61
- overlayHost(this.options.scope ?? resolved).append(this.el);
61
+ // 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);
62
64
  this.#rememberAnchorAttribute('aria-expanded');
63
65
  this.#rememberAnchorAttribute('aria-controls');
64
66
  this.#anchor.setAttribute('aria-expanded', 'false');
@@ -11,14 +11,14 @@
11
11
 
12
12
  .zx-empty-state__icon {
13
13
  display: flex;
14
- color: var(--zx-color-text-placeholder);
14
+ color: var(--zx-color-text-subtle);
15
15
  }
16
16
 
17
17
  .zx-empty-state__title {
18
18
  margin: 0;
19
19
  color: var(--zx-color-text);
20
20
  font-size: var(--zx-text-lg);
21
- font-weight: 600;
21
+ font-weight: var(--zx-weight-semibold);
22
22
  line-height: 1.4;
23
23
  }
24
24
 
@@ -52,7 +52,7 @@
52
52
 
53
53
  .zx-entity-ref__title {
54
54
  color: var(--zx-color-text);
55
- font-weight: 600;
55
+ font-weight: var(--zx-weight-medium);
56
56
  line-height: 1.25;
57
57
  }
58
58
 
@@ -52,7 +52,7 @@ import { isElement } from '../../core/util.js';
52
52
  * @property {EntityRefMetadata[]} [metadata=[]] Labelled dense metadata.
53
53
  * @property {EntityRefLinkValue} [link=null] Optional native primary link.
54
54
  * @property {EntityRefActionEntry[]} [actions=[]] Secondary controls; element actions keep their own behavior.
55
- * @property {'sm'|'md'|'lg'} [size='md'] Density-independent identity size.
55
+ * @property {'sm'|'md'|'lg'} [size='md'] Size of the icon and the identity text.
56
56
  * @property {boolean} [wrap=false] Whether long identity text may wrap instead of truncating.
57
57
  * @property {string} [actionsLabel='Entity actions'] Accessible name for secondary actions.
58
58
  * @property {(event: CustomEvent<{id: unknown, entity: Readonly<EntityRefState>, href: string, event: MouseEvent}>) => void} [onactivate]
@@ -1,5 +1,8 @@
1
+ /* Beside a taller field in a multi-column row the field's cell grows, but its label and control
2
+ stay at the top of it rather than spreading over the extra height. */
1
3
  .zx-field {
2
4
  display: grid;
5
+ align-content: start;
3
6
  gap: var(--zx-space-1);
4
7
  min-inline-size: 0;
5
8
  }
@@ -10,15 +13,24 @@
10
13
  align-items: start;
11
14
  }
12
15
 
16
+ /* Beside its control, a label lines up with the control's first line rather than its top edge, so
17
+ "Status" and "In review" share a baseline — and still sits by the first row of a control that
18
+ grows taller. */
19
+ .zx-field[data-layout="inline"] .zx-field__label {
20
+ padding-block-start: max(0px, (var(--zx-control-height) - 1lh) / 2);
21
+ }
22
+
13
23
  .zx-field[data-field-type="hidden"] {
14
24
  display: none;
15
25
  }
16
26
 
27
+ /* Labels are quieter than the values they name: the small size, a medium weight, the muted text
28
+ colour. The value is what a reader came for. */
17
29
  .zx-field__label {
18
30
  min-inline-size: 0;
19
- color: var(--zx-color-text);
31
+ color: var(--zx-color-text-muted);
20
32
  font-size: var(--zx-text-sm);
21
- font-weight: 600;
33
+ font-weight: var(--zx-weight-medium);
22
34
  }
23
35
 
24
36
  .zx-field__required,
@@ -101,17 +113,24 @@
101
113
  border-color: var(--zx-color-danger);
102
114
  }
103
115
 
104
- .zx-field__static,
105
- .zx-field__error-control {
116
+ /* Display content is not an input, so it draws no frame and no fill. It keeps a control's height
117
+ and centres its first line in it, so it sits level with the fields beside it and with an inline
118
+ label; in a quiet form it is inset like the quiet values around it. */
119
+ .zx-field__static {
106
120
  min-block-size: var(--zx-control-height);
107
- padding: var(--zx-space-2) var(--zx-control-pad-x);
108
- border: 1px solid var(--zx-color-border);
109
- border-radius: var(--zx-control-radius);
110
- background: var(--zx-color-bg-surface);
121
+ padding-block: max(0px, (var(--zx-control-height) - 1lh) / 2);
122
+ font-size: var(--zx-control-font-size);
123
+ }
124
+
125
+ [data-zx-fields="quiet"] .zx-field__static {
126
+ padding-inline: calc(var(--zx-control-pad-x) + 1px);
111
127
  }
112
128
 
113
129
  .zx-field__error-control {
114
- border-color: var(--zx-color-danger);
130
+ min-block-size: var(--zx-control-height);
131
+ padding: var(--zx-space-2) var(--zx-control-pad-x);
132
+ border: 1px solid var(--zx-color-danger);
133
+ border-radius: var(--zx-control-radius);
115
134
  background: var(--zx-color-danger-bg);
116
135
  color: var(--zx-color-danger);
117
136
  }
@@ -4,7 +4,10 @@ import { uid } from '../../core/util.js';
4
4
 
5
5
  /**
6
6
  * @typedef {Object} FieldOptions
7
- * @property {string|null} [id=null] Control ID and logical field identifier.
7
+ * @property {string|null} [id=null] DOM id of the control. Generated when omitted; a Fieldset
8
+ * derives it from its `idPrefix` and the field key, so a key never becomes a DOM id by itself.
9
+ * @property {string|null} [name=null] Field name: the `name` of a native control and the key the
10
+ * field is filed under. A Fieldset sets it to the field key; a standalone field falls back to `id`.
8
11
  * @property {string} [type='text'] Registered field type.
9
12
  * @property {string} [label=''] Visible label.
10
13
  * @property {string} [description=''] Supporting description.
@@ -28,6 +31,9 @@ import { uid } from '../../core/util.js';
28
31
  * @property {() => void} focus Focus the control.
29
32
  * @property {(disabled: boolean) => void} setDisabled Set disabled state.
30
33
  * @property {() => void} [destroy] Release adapter-owned resources.
34
+ * @property {Element} [labelTarget] The element the field's label, description, and validity
35
+ * belong to when it is not `el` — the text input inside a composite control. Left out, a
36
+ * wrapper that cannot be labelled hands them to the first unnamed form control inside it.
31
37
  */
32
38
 
33
39
  /** @typedef {(field: Field, options: Readonly<FieldOptions>) => FieldAdapter} FieldAdapterFactory */
@@ -45,6 +51,7 @@ export class Field extends Component {
45
51
  /** @type {FieldOptions & {msg: Record<string, string>}} */
46
52
  static defaults = {
47
53
  id: null,
54
+ name: null,
48
55
  type: 'text',
49
56
  label: '',
50
57
  description: '',
@@ -88,9 +95,10 @@ export class Field extends Component {
88
95
  return Field.#adapters.has(String(type).trim().toLowerCase());
89
96
  }
90
97
 
91
- // `adapter` (FieldAdapter), `initialValue`, and `controlId` are assigned in render();
92
- // declaring them as class fields would re-initialize them to undefined after the base
93
- // constructor (which calls render()) returns — see AGENTS.md.
98
+ // `adapter` (FieldAdapter), `initialValue`, `controlId` (the control's DOM id), and `name` (the
99
+ // field name, '' when there is none) are assigned in render(); declaring them as class fields
100
+ // would re-initialize them to undefined after the base constructor (which calls render())
101
+ // returns — see AGENTS.md.
94
102
 
95
103
  /** @returns {HTMLElement} */
96
104
  render() {
@@ -104,7 +112,10 @@ export class Field extends Component {
104
112
  root.dataset.layout = layout;
105
113
  root.dataset.fieldType = type;
106
114
 
115
+ /** @type {string} The control's DOM id: `options.id`, or a generated one. */
107
116
  this.controlId = this.options.id ? String(this.options.id) : uid('zx-field');
117
+ /** @type {string} The field name (`options.name`, else `options.id`), '' when there is none. */
118
+ this.name = String(this.options.name ?? this.options.id ?? '');
108
119
  const labelId = `${this.controlId}-label`;
109
120
  const descriptionId = `${this.controlId}-description`;
110
121
  const highlightId = `${this.controlId}-highlight`;
@@ -148,9 +159,14 @@ export class Field extends Component {
148
159
 
149
160
  const control = this.adapter.el;
150
161
  if (!control.id) control.id = this.controlId;
151
- label.htmlFor = control.id;
152
- if (!isLabelable(control)) control.setAttribute('aria-labelledby', labelId);
153
- this._baseDescribedBy = control.getAttribute('aria-describedby') ?? '';
162
+ // A wrapper (a `custom` adapter's box) cannot carry a name; the control inside it can.
163
+ const labelled = this.adapter.labelTarget
164
+ ?? (isLabelable(control) ? control : unnamedControlWithin(control)) ?? control;
165
+ this._labelled = labelled;
166
+ if (labelled !== control && isLabelable(labelled) && !labelled.id) labelled.id = `${this.controlId}-input`;
167
+ label.htmlFor = isLabelable(labelled) ? labelled.id : control.id;
168
+ if (!isLabelable(labelled)) labelled.setAttribute('aria-labelledby', labelId);
169
+ this._baseDescribedBy = labelled.getAttribute('aria-describedby') ?? '';
154
170
  controlSlot.append(control);
155
171
  this._descriptionId = String(this.options.description ?? '') ? descriptionId : '';
156
172
  this._highlightId = highlightId;
@@ -231,11 +247,11 @@ export class Field extends Component {
231
247
  this._highlight.hidden = !message;
232
248
  if (normalizedKind === 'danger' && message) {
233
249
  this._highlight.setAttribute('role', 'alert');
234
- this.adapter.el.setAttribute('aria-invalid', 'true');
250
+ this._labelled.setAttribute('aria-invalid', 'true');
235
251
  this.emit('invalid', { message: String(message) });
236
252
  } else {
237
253
  this._highlight.removeAttribute('role');
238
- this.adapter.el.removeAttribute('aria-invalid');
254
+ this._labelled.removeAttribute('aria-invalid');
239
255
  }
240
256
  this._syncDescription(Boolean(message));
241
257
  return this;
@@ -250,7 +266,7 @@ export class Field extends Component {
250
266
  this._highlight.textContent = '';
251
267
  this._highlight.hidden = true;
252
268
  this._highlight.removeAttribute('role');
253
- this.adapter.el.removeAttribute('aria-invalid');
269
+ this._labelled.removeAttribute('aria-invalid');
254
270
  this._syncDescription(false);
255
271
  return this;
256
272
  }
@@ -279,8 +295,8 @@ export class Field extends Component {
279
295
  _syncDescription(highlighted = false) {
280
296
  const ids = [this._baseDescribedBy, this._descriptionId, highlighted ? this._highlightId : '']
281
297
  .flatMap((value) => value.split(/\s+/)).filter(Boolean);
282
- if (ids.length) this.adapter.el.setAttribute('aria-describedby', [...new Set(ids)].join(' '));
283
- else this.adapter.el.removeAttribute('aria-describedby');
298
+ if (ids.length) this._labelled.setAttribute('aria-describedby', [...new Set(ids)].join(' '));
299
+ else this._labelled.removeAttribute('aria-describedby');
284
300
  }
285
301
 
286
302
  /** @returns {void} */
@@ -400,6 +416,22 @@ function isLabelable(element) {
400
416
  return ['BUTTON', 'INPUT', 'METER', 'OUTPUT', 'PROGRESS', 'SELECT', 'TEXTAREA'].includes(element.tagName);
401
417
  }
402
418
 
419
+ /**
420
+ * The first form control inside `root` that nothing names yet — no `aria-label`, no
421
+ * `aria-labelledby`, no `<label>` with any text — or null.
422
+ * @param {Element} root
423
+ * @returns {Element|null}
424
+ */
425
+ function unnamedControlWithin(root) {
426
+ for (const candidate of root.querySelectorAll('input:not([type="hidden"]), select, textarea')) {
427
+ if (candidate.hasAttribute('aria-label') || candidate.hasAttribute('aria-labelledby')) continue;
428
+ const labels = [.../** @type {HTMLInputElement} */ (candidate).labels ?? []];
429
+ if (labels.some((label) => label.textContent?.trim())) continue;
430
+ return candidate;
431
+ }
432
+ return null;
433
+ }
434
+
403
435
  /** @param {HTMLElement} element @param {string[]} names @returns {Map<string, string|null>} */
404
436
  function rememberAttributes(element, names) {
405
437
  return new Map(names.map((name) => [name, element.getAttribute(name)]));
@@ -413,19 +445,26 @@ function restoreAttributes(element, attributes) {
413
445
  }
414
446
  }
415
447
 
416
- /** @param {Readonly<FieldOptions>} options @param {Record<string, unknown>} base @returns {Record<string, unknown>} */
417
- function controlProps(options, base) {
448
+ /**
449
+ * The properties a native control is built with: the adapter's own `base` over the caller's
450
+ * `props`, with the field name as the control's `name` unless `props` gives one.
451
+ * @param {Field} field Field the control belongs to.
452
+ * @param {Readonly<FieldOptions>} options Field options.
453
+ * @param {Record<string, unknown>} base Properties the adapter owns.
454
+ * @returns {Record<string, unknown>}
455
+ */
456
+ function controlProps(field, options, base) {
418
457
  const props = { ...(options.props ?? {}) };
419
458
  for (const key of Object.keys(props)) {
420
459
  if (/^on[a-z]/i.test(key) || key === 'content') delete props[key];
421
460
  }
422
- return { ...props, ...base };
461
+ return { name: field.name || undefined, ...props, ...base };
423
462
  }
424
463
 
425
464
  /** @param {'input'|'textarea'} tag @param {string} type @returns {FieldAdapterFactory} */
426
465
  function textAdapter(tag, type) {
427
- return (_field, options) => {
428
- const el = h(tag, controlProps(options, {
466
+ return (field, options) => {
467
+ const el = h(tag, controlProps(field, options, {
429
468
  type: tag === 'input' ? type : undefined,
430
469
  placeholder: options.placeholder,
431
470
  required: options.required,
@@ -450,7 +489,7 @@ function elementValueAdapter(el) {
450
489
  function numericAdapter(kind) {
451
490
  return (field, options) => {
452
491
  const coerce = kind === 'int' ? coerceInteger : coerceFloat;
453
- const input = h('input', controlProps(options, {
492
+ const input = h('input', controlProps(field, options, {
454
493
  type: 'text',
455
494
  inputMode: kind === 'int' ? 'numeric' : 'decimal',
456
495
  placeholder: options.placeholder,
@@ -474,8 +513,8 @@ function numericAdapter(kind) {
474
513
  }
475
514
 
476
515
  /** @type {FieldAdapterFactory} */
477
- const checkboxAdapter = (_field, options) => {
478
- const input = h('input', controlProps(options, {
516
+ const checkboxAdapter = (field, options) => {
517
+ const input = h('input', controlProps(field, options, {
479
518
  type: 'checkbox',
480
519
  required: options.required,
481
520
  disabled: options.disabled
@@ -490,8 +529,8 @@ const checkboxAdapter = (_field, options) => {
490
529
  };
491
530
 
492
531
  /** @type {FieldAdapterFactory} */
493
- const selectAdapter = (_field, options) => {
494
- const select = h('select', controlProps(options, {
532
+ const selectAdapter = (field, options) => {
533
+ const select = h('select', controlProps(field, options, {
495
534
  required: options.required,
496
535
  disabled: options.disabled
497
536
  }));
@@ -502,12 +541,15 @@ const selectAdapter = (_field, options) => {
502
541
  };
503
542
 
504
543
  /** @type {FieldAdapterFactory} */
505
- const optionListAdapter = (_field, options) => {
544
+ const optionListAdapter = (field, options) => {
506
545
  const root = h('div', {
507
546
  class: 'zx-field__option-list',
508
547
  role: 'radiogroup'
509
548
  });
510
- const name = options.id ? String(options.id) : uid('zx-option-list');
549
+ // The radios are grouped by the control's id, not by the field key: radios outside a <form>
550
+ // share one group per name across the whole document, so the same fieldset mounted twice
551
+ // would otherwise check one copy's option by clicking the other's.
552
+ const name = field.controlId;
511
553
  const radios = normalizeOptions(options.options).map(([value, label], index) => {
512
554
  const input = h('input', {
513
555
  type: 'radio',
@@ -534,8 +576,8 @@ const optionListAdapter = (_field, options) => {
534
576
  };
535
577
 
536
578
  /** @type {FieldAdapterFactory} */
537
- const htmlAdapter = (_field, options) => {
538
- const el = h('div', controlProps(options, { class: 'zx-field__static' }));
579
+ const htmlAdapter = (field, options) => {
580
+ const el = h('div', controlProps(field, options, { class: 'zx-field__static', name: undefined }));
539
581
  let adopted = null;
540
582
  const set = (value) => {
541
583
  restoreAdopted(adopted);
@@ -32,7 +32,7 @@
32
32
 
33
33
  .zx-field-upload__prompt {
34
34
  color: var(--zx-color-text);
35
- font-weight: 600;
35
+ font-weight: var(--zx-weight-medium);
36
36
  }
37
37
 
38
38
  .zx-field-upload__hint,
@@ -15,11 +15,13 @@
15
15
  inline-size: 100%;
16
16
  }
17
17
 
18
+ /* A caption for the group, in sentence case and the same quiet voice as the field labels inside
19
+ it — the fields are the content. */
18
20
  .zx-fieldset__legend {
19
21
  padding-inline: var(--zx-space-2);
20
- color: var(--zx-color-text);
21
- font-size: var(--zx-text-lg);
22
- font-weight: 600;
22
+ color: var(--zx-color-text-muted);
23
+ font-size: var(--zx-text-sm);
24
+ font-weight: var(--zx-weight-medium);
23
25
  }
24
26
 
25
27
  .zx-fieldset__grid {
@@ -1,6 +1,6 @@
1
1
  import { Component } from '../../core/component.js';
2
2
  import { h } from '../../core/dom.js';
3
- import { isElement } from '../../core/util.js';
3
+ import { isElement, uid } from '../../core/util.js';
4
4
  import { Field } from '../field/field.js';
5
5
 
6
6
  /**
@@ -8,6 +8,9 @@ import { Field } from '../field/field.js';
8
8
  * @property {string} [title=''] Legend text.
9
9
  * @property {1|2|3} [columns=1] Grid column count.
10
10
  * @property {Record<string, FieldOptions|Field|Element|Component>} [fields={}] Initial fields.
11
+ * @property {string|null} [idPrefix=null] Prefix of the DOM ids generated for the fields, as
12
+ * `<idPrefix>-<key>`. Null generates one per instance, so the same fieldset mounted twice
13
+ * never repeats an id and a field key never collides with an element id elsewhere on the page.
11
14
  */
12
15
 
13
16
  /** @typedef {import('../field/field.js').FieldOptions} FieldOptions */
@@ -20,7 +23,7 @@ export class Fieldset extends Component {
20
23
  static cssName = 'fieldset';
21
24
 
22
25
  /** @type {FieldsetOptions} */
23
- static defaults = { title: '', columns: 1, fields: {} };
26
+ static defaults = { title: '', columns: 1, fields: {}, idPrefix: null };
24
27
 
25
28
  /** @returns {HTMLElement} */
26
29
  render() {
@@ -30,6 +33,7 @@ export class Fieldset extends Component {
30
33
  this._origins = new Map();
31
34
  this._fieldIdAttributes = new Map();
32
35
  this._ownedNodes = [];
36
+ this._idPrefix = this.options.idPrefix ? String(this.options.idPrefix) : uid('zx-fieldset');
33
37
  this._columnAttribute = root.getAttribute('data-columns');
34
38
  const columns = [1, 2, 3].includes(Number(this.options.columns)) ? Number(this.options.columns) : 1;
35
39
  root.dataset.columns = String(columns);
@@ -49,7 +53,9 @@ export class Fieldset extends Component {
49
53
  }
50
54
 
51
55
  /**
52
- * Adds a field from options, an existing field, an element, or a component.
56
+ * Adds a field from options, an existing field, an element, or a component. The key becomes the
57
+ * field's `name` and its `data-field-id`; the control's DOM id is `<idPrefix>-<key>`, unless the
58
+ * field options give an `id` of their own that differs from the key.
53
59
  * @param {string} id Logical field identifier.
54
60
  * @param {FieldOptions|Field|Element|Component} field Field source.
55
61
  * @returns {Field}
@@ -64,7 +70,8 @@ export class Fieldset extends Component {
64
70
  } else if (isElement(field) || field instanceof Component) {
65
71
  const source = field;
66
72
  instance = new Field(null, {
67
- id: key,
73
+ id: this._controlId(key),
74
+ name: key,
68
75
  type: 'custom',
69
76
  label: key,
70
77
  value: readExternalValue(source),
@@ -74,7 +81,14 @@ export class Fieldset extends Component {
74
81
  owned = true;
75
82
  } else {
76
83
  const options = field && typeof field === 'object' ? field : {};
77
- instance = new Field(null, { ...options, id: options.id ?? key });
84
+ // An `id` equal to the key is the key restated (schema descriptors carry both), not a
85
+ // request for that DOM id.
86
+ const explicit = options.id != null && String(options.id) !== '' && String(options.id) !== key;
87
+ instance = new Field(null, {
88
+ ...options,
89
+ id: explicit ? String(options.id) : this._controlId(key),
90
+ name: options.name ?? key
91
+ });
78
92
  owned = true;
79
93
  }
80
94
 
@@ -218,6 +232,15 @@ export class Fieldset extends Component {
218
232
  return this;
219
233
  }
220
234
 
235
+ /**
236
+ * The DOM id generated for a field key: unique per instance unless `idPrefix` was given.
237
+ * @param {string} key Field key.
238
+ * @returns {string}
239
+ */
240
+ _controlId(key) {
241
+ return `${this._idPrefix}-${key.trim().replace(/\s+/g, '-')}`;
242
+ }
243
+
221
244
  /** @param {Field} field @returns {void} */
222
245
  _releaseField(field) {
223
246
  const fieldId = this._fieldIdAttributes.get(field);