@zeyos/zx 4.4.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 (252) hide show
  1. package/CHANGELOG.md +447 -0
  2. package/README.md +44 -11
  3. package/dist/types/components/account-menu/account-menu.d.ts +8 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +31 -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 +293 -17
  7. package/dist/types/components/avatar/avatar.d.ts +46 -7
  8. package/dist/types/components/badge/badge.d.ts +21 -1
  9. package/dist/types/components/button/button.d.ts +15 -0
  10. package/dist/types/components/calendar/calendar.d.ts +67 -5
  11. package/dist/types/components/card-view/card-view.d.ts +7 -4
  12. package/dist/types/components/card-view/record-card.d.ts +11 -0
  13. package/dist/types/components/chart/chart.d.ts +10 -0
  14. package/dist/types/components/checklist/checklist.d.ts +9 -0
  15. package/dist/types/components/code-editor/code-editor.d.ts +6 -17
  16. package/dist/types/components/context-menu/context-menu.d.ts +6 -2
  17. package/dist/types/components/data-filter/data-filter.d.ts +4 -4
  18. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  19. package/dist/types/components/datebox/datebox.d.ts +7 -2
  20. package/dist/types/components/dialog/dialog.d.ts +46 -6
  21. package/dist/types/components/dropdown/dropdown.d.ts +24 -9
  22. package/dist/types/components/entity-ref/entity-ref.d.ts +2 -2
  23. package/dist/types/components/field/field.d.ts +54 -2
  24. package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
  25. package/dist/types/components/fieldset/fieldset.d.ts +26 -1
  26. package/dist/types/components/file-item/file-item.d.ts +34 -6
  27. package/dist/types/components/file-list/file-list.d.ts +35 -3
  28. package/dist/types/components/filter/filter.d.ts +127 -3
  29. package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
  30. package/dist/types/components/form/form.d.ts +12 -1
  31. package/dist/types/components/kanban-view/kanban-card.d.ts +8 -0
  32. package/dist/types/components/launcher/launcher.d.ts +268 -15
  33. package/dist/types/components/list-toolbar/list-toolbar.d.ts +16 -7
  34. package/dist/types/components/menu-button/menu-button.d.ts +39 -5
  35. package/dist/types/components/message/message.d.ts +132 -17
  36. package/dist/types/components/modal/modal.d.ts +43 -2
  37. package/dist/types/components/module-bar/module-bar.d.ts +395 -0
  38. package/dist/types/components/pagination/pagination.d.ts +17 -3
  39. package/dist/types/components/select/color.d.ts +12 -0
  40. package/dist/types/components/select/glyphs.d.ts +124 -0
  41. package/dist/types/components/select/select.d.ts +17 -3
  42. package/dist/types/components/sheet/sheet.d.ts +15 -0
  43. package/dist/types/components/stat-tile/stat-tile.d.ts +16 -5
  44. package/dist/types/components/tabbox/tabbox.d.ts +9 -0
  45. package/dist/types/components/table/table.d.ts +311 -18
  46. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  47. package/dist/types/components/tree/tree.d.ts +13 -0
  48. package/dist/types/components/view/record-view.d.ts +59 -2
  49. package/dist/types/components/view-settings/view-settings.d.ts +879 -0
  50. package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
  51. package/dist/types/core/breakpoint.d.ts +1 -1
  52. package/dist/types/core/component.d.ts +12 -2
  53. package/dist/types/core/i18n.d.ts +27 -3
  54. package/dist/types/core/icons.d.ts +23 -25
  55. package/dist/types/core/keyboard.d.ts +98 -1
  56. package/dist/types/core/messages.d.ts +382 -0
  57. package/dist/types/core/motion.d.ts +59 -0
  58. package/dist/types/core/overlay-host.d.ts +42 -1
  59. package/dist/types/core/overlay-stack.d.ts +260 -0
  60. package/dist/types/index.d.ts +10 -5
  61. package/dist/types/internal/app-rail.d.ts +38 -0
  62. package/dist/types/internal/menu-items.d.ts +71 -3
  63. package/dist/types/zeyos/connect.d.ts +6 -0
  64. package/dist/types/zeyos/form.d.ts +50 -9
  65. package/dist/types/zeyos/index.d.ts +2 -1
  66. package/dist/types/zeyos/launcher.d.ts +9 -0
  67. package/dist/types/zeyos/module-bar.d.ts +176 -0
  68. package/dist/types/zeyos/query.d.ts +110 -5
  69. package/dist/types/zeyos/schema.d.ts +24 -1
  70. package/dist/types/zeyos/table.d.ts +345 -22
  71. package/dist/types/zeyos/view.d.ts +26 -2
  72. package/dist/zx-zeyos.esm.js +6178 -1008
  73. package/dist/zx-zeyos.esm.min.js +53 -5
  74. package/dist/zx.css +2861 -827
  75. package/dist/zx.esm.js +8909 -1480
  76. package/dist/zx.esm.min.js +55 -7
  77. package/dist/zx.global.js +8911 -1482
  78. package/dist/zx.global.min.js +55 -7
  79. package/dist/zx.min.css +1 -1
  80. package/docs/api.json +1079 -117
  81. package/docs/llms.md +956 -154
  82. package/docs/llms.txt +9 -5
  83. package/package.json +1 -1
  84. package/src/components/account-menu/account-menu.css +35 -10
  85. package/src/components/account-menu/account-menu.js +12 -0
  86. package/src/components/activity-item/activity-item.css +1 -1
  87. package/src/components/activity-item/activity-item.js +3 -0
  88. package/src/components/activity-list/activity-list.css +38 -3
  89. package/src/components/activity-list/activity-list.js +61 -1
  90. package/src/components/app-icon/app-icon.css +14 -3
  91. package/src/components/app-icon/app-icon.js +11 -1
  92. package/src/components/app-rail/app-rail.css +59 -79
  93. package/src/components/app-sidebar/app-sidebar.css +304 -168
  94. package/src/components/app-sidebar/app-sidebar.js +667 -46
  95. package/src/components/aurora/aurora.css +5 -3
  96. package/src/components/avatar/avatar.css +32 -7
  97. package/src/components/avatar/avatar.js +72 -8
  98. package/src/components/badge/badge.css +54 -2
  99. package/src/components/badge/badge.js +29 -2
  100. package/src/components/breadcrumb/breadcrumb.css +1 -1
  101. package/src/components/button/button.css +67 -9
  102. package/src/components/button/button.js +38 -0
  103. package/src/components/calendar/calendar.css +47 -47
  104. package/src/components/calendar/calendar.js +26 -52
  105. package/src/components/card/card.css +4 -4
  106. package/src/components/card-view/card-view.css +14 -12
  107. package/src/components/card-view/card-view.js +13 -9
  108. package/src/components/card-view/record-card.js +14 -3
  109. package/src/components/chart/chart.css +4 -1
  110. package/src/components/chart/chart.js +12 -0
  111. package/src/components/check-button/check-button.css +10 -2
  112. package/src/components/checklist/checklist.css +9 -2
  113. package/src/components/checklist/checklist.js +11 -5
  114. package/src/components/checklist/field-adapter.js +3 -1
  115. package/src/components/code-editor/code-editor.css +7 -6
  116. package/src/components/code-editor/code-editor.js +19 -1
  117. package/src/components/code-editor/field-adapter.js +9 -2
  118. package/src/components/context-menu/context-menu.css +9 -4
  119. package/src/components/context-menu/context-menu.js +8 -2
  120. package/src/components/copy/copy-button.css +6 -0
  121. package/src/components/copy/copy-input.css +9 -4
  122. package/src/components/data-filter/data-filter.css +5 -4
  123. package/src/components/data-filter/data-filter.js +2 -2
  124. package/src/components/date-picker/date-picker.css +6 -6
  125. package/src/components/date-picker/date-range-picker.css +3 -3
  126. package/src/components/date-picker/time-picker.css +3 -3
  127. package/src/components/datebox/date-range-box.css +79 -2
  128. package/src/components/datebox/date-range-box.js +13 -2
  129. package/src/components/datebox/datebox.css +79 -2
  130. package/src/components/datebox/datebox.js +13 -2
  131. package/src/components/dialog/dialog.css +26 -0
  132. package/src/components/dialog/dialog.js +99 -17
  133. package/src/components/dock/dock.css +5 -4
  134. package/src/components/dropdown/dropdown.css +1 -1
  135. package/src/components/dropdown/dropdown.js +159 -72
  136. package/src/components/empty-state/empty-state.css +2 -2
  137. package/src/components/entity-ref/entity-ref.css +1 -1
  138. package/src/components/entity-ref/entity-ref.js +1 -1
  139. package/src/components/field/field.css +28 -9
  140. package/src/components/field/field.js +101 -29
  141. package/src/components/field-upload/field-upload.css +1 -1
  142. package/src/components/fieldset/external-adapter.js +363 -0
  143. package/src/components/fieldset/fieldset.css +5 -3
  144. package/src/components/fieldset/fieldset.js +38 -64
  145. package/src/components/file-item/file-item.js +144 -10
  146. package/src/components/file-list/file-list.js +144 -21
  147. package/src/components/filter/filter.css +86 -2
  148. package/src/components/filter/filter.js +307 -11
  149. package/src/components/filter-panel/filter-panel.css +1 -1
  150. package/src/components/filter-panel/filter-panel.js +149 -32
  151. package/src/components/finder/finder.css +16 -14
  152. package/src/components/form/form.css +5 -4
  153. package/src/components/form/form.js +11 -2
  154. package/src/components/grid/grid.css +1 -1
  155. package/src/components/groupbox/groupbox.css +1 -1
  156. package/src/components/kanban-view/kanban-card.js +11 -3
  157. package/src/components/kanban-view/kanban-view.css +42 -18
  158. package/src/components/kanban-view/kanban-view.js +9 -6
  159. package/src/components/launcher/launcher.css +108 -26
  160. package/src/components/launcher/launcher.js +530 -42
  161. package/src/components/list-toolbar/list-toolbar.css +13 -8
  162. package/src/components/list-toolbar/list-toolbar.js +40 -14
  163. package/src/components/master-panel/master-panel.css +2 -0
  164. package/src/components/master-panel/master-panel.js +0 -1
  165. package/src/components/menu-button/menu-button.css +72 -4
  166. package/src/components/menu-button/menu-button.js +160 -18
  167. package/src/components/message/message.css +33 -3
  168. package/src/components/message/message.js +264 -22
  169. package/src/components/modal/modal.css +1 -1
  170. package/src/components/modal/modal.js +83 -15
  171. package/src/components/module-bar/module-bar.css +486 -0
  172. package/src/components/module-bar/module-bar.js +1074 -0
  173. package/src/components/multi-value-editor/multi-value-editor.css +1 -1
  174. package/src/components/navigation-bar/navigation-bar.css +18 -9
  175. package/src/components/number-field/field-adapter.js +3 -1
  176. package/src/components/number-field/number-field.css +70 -0
  177. package/src/components/number-field/number-field.js +2 -0
  178. package/src/components/pagination/pagination.css +13 -11
  179. package/src/components/pagination/pagination.js +20 -4
  180. package/src/components/panel/panel.css +4 -3
  181. package/src/components/questionnaire/questionnaire.css +4 -4
  182. package/src/components/rating/field-adapter.js +2 -1
  183. package/src/components/rating/rating.css +8 -1
  184. package/src/components/search/search.css +4 -1
  185. package/src/components/select/color.js +22 -0
  186. package/src/components/select/field-adapter.js +2 -0
  187. package/src/components/select/glyphs.js +165 -0
  188. package/src/components/select/select.css +138 -45
  189. package/src/components/select/select.js +92 -47
  190. package/src/components/sheet/sheet.css +12 -9
  191. package/src/components/sheet/sheet.js +74 -32
  192. package/src/components/slider/field-adapter.js +6 -1
  193. package/src/components/slider/slider.css +18 -4
  194. package/src/components/slider/slider.js +1 -1
  195. package/src/components/sort-control/sort-control.css +4 -1
  196. package/src/components/split-view/split-view.css +1 -1
  197. package/src/components/stat-tile/stat-tile.css +42 -47
  198. package/src/components/stat-tile/stat-tile.js +17 -6
  199. package/src/components/stepper/stepper.css +1 -1
  200. package/src/components/tabbox/tabbox.css +41 -32
  201. package/src/components/tabbox/tabbox.js +5 -1
  202. package/src/components/table/table.css +187 -37
  203. package/src/components/table/table.js +718 -73
  204. package/src/components/tag-picker/field-adapter.js +2 -1
  205. package/src/components/tag-picker/tag-picker.css +83 -5
  206. package/src/components/tag-picker/tag-picker.js +25 -8
  207. package/src/components/timebox/timebox.css +22 -2
  208. package/src/components/toggle/toggle.css +12 -1
  209. package/src/components/toolbar/toolbar.js +2 -2
  210. package/src/components/tooltip/tooltip.css +4 -0
  211. package/src/components/tooltip/tooltip.js +44 -19
  212. package/src/components/tree/tree.css +26 -17
  213. package/src/components/tree/tree.js +31 -0
  214. package/src/components/value-list/value-list.css +3 -3
  215. package/src/components/view/record-view.css +1 -1
  216. package/src/components/view/record-view.js +178 -18
  217. package/src/components/view-settings/view-settings.css +285 -0
  218. package/src/components/view-settings/view-settings.js +1830 -0
  219. package/src/components/workspace-switcher/workspace-switcher.css +188 -0
  220. package/src/components/workspace-switcher/workspace-switcher.js +463 -0
  221. package/src/core/breakpoint.js +1 -1
  222. package/src/core/component.js +88 -17
  223. package/src/core/fontawesome.js +5 -1
  224. package/src/core/i18n.js +47 -4
  225. package/src/core/icons.js +204 -61
  226. package/src/core/keyboard.js +206 -11
  227. package/src/core/messages.js +427 -0
  228. package/src/core/motion.js +147 -0
  229. package/src/core/overlay-host.js +77 -3
  230. package/src/core/overlay-stack.js +487 -0
  231. package/src/core/position.js +22 -3
  232. package/src/index.js +10 -5
  233. package/src/internal/app-rail.js +135 -55
  234. package/src/internal/menu-items.js +61 -3
  235. package/src/zeyos/connect.js +6 -2
  236. package/src/zeyos/form.js +69 -27
  237. package/src/zeyos/index.js +4 -1
  238. package/src/zeyos/launcher.js +8 -3
  239. package/src/zeyos/module-bar.js +181 -0
  240. package/src/zeyos/query.js +197 -13
  241. package/src/zeyos/schema.js +144 -27
  242. package/src/zeyos/table.js +287 -84
  243. package/src/zeyos/view.js +49 -93
  244. package/styles/base.css +72 -1
  245. package/styles/icons.css +11 -1
  246. package/styles/print.css +1 -1
  247. package/styles/tokens/dark.css +117 -37
  248. package/styles/tokens/global.css +121 -27
  249. package/styles/tokens/semantic.css +140 -43
  250. package/styles/tokens/themes.css +7 -6
  251. package/styles/zx.css +3 -1
  252. package/styles/tokens/density.css +0 -9
@@ -68,10 +68,11 @@
68
68
  }
69
69
 
70
70
  /*
71
- * WCAG 2.5.5 Target Size: 44 px, in both densities. Density tightens a layout; it does not make
72
- * a finger smaller, so this floor is deliberately not expressed through --zx-control-height
73
- * (32 px cozy, 28 px compact) the way the rest of the controls in this row are. The chrome is flat
74
- * until the tool is hovered, focused, or pressed, so a target this size does not read as a slab.
71
+ * WCAG 2.5.5 Target Size: 44 px, whatever the theme does to spacing. A tighter theme tightens a
72
+ * layout; it does not make a finger smaller, so this floor is deliberately not expressed through
73
+ * --zx-control-height (30 px by default) the way the rest of the controls in this row are. The
74
+ * chrome is flat until the tool is hovered, focused, or pressed, so a target this size does not
75
+ * read as a slab.
75
76
  */
76
77
  .zx-list-toolbar__tool {
77
78
  position: relative;
@@ -100,11 +101,12 @@
100
101
  box-shadow: var(--zx-focus-ring);
101
102
  }
102
103
 
103
- /* A pressed tool is marked three ways — the state in `aria-pressed`, a filled ground, and a
104
- border where the other states have none — so it never rests on colour alone. */
104
+ /* A pressed tool is marked three ways — the state in `aria-pressed`, the neutral current fill, and
105
+ a border where the other states have none — so it never rests on colour alone. A toggled filter
106
+ is a mode the list is in, not a pick, so the fill is neutral rather than the accent tint. */
105
107
  .zx-list-toolbar__tool[aria-pressed="true"] {
106
108
  border-color: var(--zx-color-border-strong);
107
- background: var(--zx-color-bg-selected);
109
+ background: var(--zx-color-bg-current);
108
110
  }
109
111
 
110
112
  .zx-list-toolbar__tool:disabled {
@@ -150,9 +152,12 @@
150
152
  box-shadow: var(--zx-focus-ring);
151
153
  }
152
154
 
155
+ /* The current view is where you are: the neutral current fill, the full text colour, a heavier
156
+ label. Hover stays a lighter step of the same fill. */
153
157
  .zx-list-toolbar__view[aria-pressed="true"] {
154
- background: var(--zx-color-bg-selected);
158
+ background: var(--zx-color-bg-current);
155
159
  color: var(--zx-color-text);
160
+ font-weight: var(--zx-weight-semibold);
156
161
  }
157
162
 
158
163
  @media (prefers-reduced-motion: no-preference) {
@@ -7,8 +7,10 @@ import { badge } from '../badge/badge.js';
7
7
  import { Search } from '../search/search.js';
8
8
 
9
9
  /**
10
- * Built-in view shapes, so `views: ['auto', 'cards', 'table']` needs no labels of its own. A host
11
- * that wants other words hands descriptors instead.
10
+ * Built-in view shapes, so `views: ['table', 'list', 'cards', 'gallery', 'board']` needs no labels
11
+ * of its own. A host that wants other words hands descriptors instead. Each shape has the glyph
12
+ * the overview tools people already use give it: a ruled grid for the table, lines for the list,
13
+ * tiles for cards, a picture for the gallery, and columns for the board.
12
14
  *
13
15
  * `auto` deliberately carries no icon: no glyph in the set says "whatever fits", and an icon that
14
16
  * has to be explained is worse than the word.
@@ -16,9 +18,11 @@ import { Search } from '../search/search.js';
16
18
  */
17
19
  const VIEW_PRESETS = Object.freeze({
18
20
  auto: { icon: null, key: 'listToolbar.viewAuto', fallback: 'Auto' },
19
- cards: { icon: 'copy', key: 'listToolbar.viewCards', fallback: 'Cards' },
20
- table: { icon: 'list', key: 'listToolbar.viewTable', fallback: 'Table' },
21
- list: { icon: 'list', key: 'listToolbar.viewList', fallback: 'List' }
21
+ table: { icon: 'table', key: 'listToolbar.viewTable', fallback: 'Table' },
22
+ list: { icon: 'list', key: 'listToolbar.viewList', fallback: 'List' },
23
+ cards: { icon: 'grid', key: 'listToolbar.viewCards', fallback: 'Cards' },
24
+ gallery: { icon: 'image', key: 'listToolbar.viewGallery', fallback: 'Gallery' },
25
+ board: { icon: 'columns', key: 'listToolbar.viewBoard', fallback: 'Board' }
22
26
  });
23
27
 
24
28
  /**
@@ -88,7 +92,7 @@ const VIEW_PRESETS = Object.freeze({
88
92
  * control, rather than found separately beside it.
89
93
  * - **The count line is a live region**, and `setCount(null)` clears it. The previous number
90
94
  * describes the previous filters, and a stale count during a load is worse than none.
91
- * - **Tool buttons meet the 44 px target** in both densities. An icon button sized to its glyph is
95
+ * - **Tool buttons meet the 44 px target** at any spacing. An icon button sized to its glyph is
92
96
  * the control people miss twice before hitting once.
93
97
  *
94
98
  * `bind()` is optional sugar; the toolbar is fully functional without it.
@@ -378,8 +382,10 @@ export class ListToolbar extends Component {
378
382
  }
379
383
 
380
384
  /**
381
- * Optional sugar for a `TableView` or `CardView`: it wires the shape switch to the view and
382
- * mounts the view's field chooser into this bar.
385
+ * Optional sugar for a `TableView`, `CardView`, or `KanbanView`: it wires the shape switch to
386
+ * the view and mounts the view's field chooser into this bar — through the view's public
387
+ * `mountFieldControls()` when it has one, which every `RecordView` does. A `TableView` with the
388
+ * default `fieldControls: true` contributes its Table's column chooser.
383
389
  *
384
390
  * Nothing is imported and nothing is required — every capability is feature-detected, so a view
385
391
  * that offers none of them still works and still gets `viewchange`, and the host swaps the view
@@ -409,10 +415,19 @@ export class ListToolbar extends Component {
409
415
  * @returns {this}
410
416
  */
411
417
  unbind() {
418
+ const bound = this._bound;
412
419
  this._bound = null;
413
420
  if (this._chooser) {
414
- restorePosition(this._chooser, this.el);
421
+ const chooser = this._chooser;
415
422
  this._chooser = null;
423
+ const view = bound?.view;
424
+ // The view put it here and knows where it came from; hand it back only while it is still
425
+ // ours, since a destroyed view has already taken it away.
426
+ if (chooser.viaView && view && typeof view.mountFieldControls === 'function') {
427
+ if (this.el.contains(chooser.element)) view.mountFieldControls(null);
428
+ } else {
429
+ restorePosition(chooser, this.el);
430
+ }
416
431
  }
417
432
  return this;
418
433
  }
@@ -597,21 +612,28 @@ export class ListToolbar extends Component {
597
612
  if (view.options?.fieldControls === false) return;
598
613
  const element = this._findFieldControls(view);
599
614
  if (!element || this.el.contains(element)) return;
615
+ if (typeof view.mountFieldControls === 'function') {
616
+ const mounted = view.mountFieldControls(this.refs.tools);
617
+ if (isElement(mounted) && this.el.contains(mounted)) {
618
+ this._chooser = { element: mounted, parent: null, next: null, viaView: true };
619
+ }
620
+ return;
621
+ }
600
622
  this._chooser = capturePosition(element);
601
623
  this.refs.tools.append(element);
602
624
  }
603
625
 
604
626
  /**
605
- * Finds a view's field chooser without importing the view, newest accessor first.
627
+ * Finds a view's field chooser without importing the view: its public `getFieldControls()`,
628
+ * else — for an object that only looks like a record view — the shared chooser's class.
606
629
  * @param {Record<string, any>} view Bound view.
607
630
  * @returns {Element|null}
608
631
  */
609
632
  _findFieldControls(view) {
610
633
  if (typeof view.getFieldControls === 'function') {
611
634
  const element = view.getFieldControls();
612
- if (isElement(element)) return element;
635
+ return isElement(element) ? element : null;
613
636
  }
614
- if (isElement(view._viewFieldControls)) return view._viewFieldControls;
615
637
  const root = typeof view.toElement === 'function' ? view.toElement() : null;
616
638
  return isElement(root) ? root.querySelector('.zx-record-view__field-controls') : null;
617
639
  }
@@ -661,7 +683,10 @@ export class ListToolbar extends Component {
661
683
  * @property {((event: MouseEvent) => void)|null} onclick Descriptor callback.
662
684
  */
663
685
 
664
- /** @typedef {{element: Element, parent: Node|null, next: Node|null}} AdoptedElement */
686
+ /**
687
+ * @typedef {{element: Element, parent: Node|null, next: Node|null, viaView?: boolean}} AdoptedElement
688
+ * `viaView` marks a field chooser the bound view moved here itself and takes back on `unbind()`.
689
+ */
665
690
 
666
691
  /**
667
692
  * @typedef {Object} RecordViewCapabilities
@@ -669,7 +694,8 @@ export class ListToolbar extends Component {
669
694
  * @property {string|null} setShape Method that changes the view shape, if any.
670
695
  * @property {string|null} getShape Method that reads the view shape, if any.
671
696
  * @property {boolean} events Whether the object emits component events.
672
- * @property {boolean} fieldControls Whether the object offers its field chooser through a method.
697
+ * @property {boolean} fieldControls Whether the object offers its field chooser through a method
698
+ * (`getFieldControls()`, which every `RecordView` has).
673
699
  */
674
700
 
675
701
  /**
@@ -26,6 +26,8 @@
26
26
  margin: 0;
27
27
  flex: 1;
28
28
  font-size: var(--zx-text-xl);
29
+ font-weight: 650;
30
+ letter-spacing: var(--zx-tracking-title);
29
31
  }
30
32
 
31
33
  .zx-master-panel__buttons {
@@ -89,7 +89,6 @@ export class MasterPanel extends Component {
89
89
 
90
90
  const moduleName = normalizeModule(this.options.module);
91
91
  root.dataset.module = moduleName;
92
- root.style.setProperty('--zx-master-panel-accent', `var(--zx-module-${moduleName})`);
93
92
  this.setTitle(this.options.title);
94
93
  if (this.options.content !== null) this.setContent(this.options.content);
95
94
  else content.append(...originalContent);
@@ -16,12 +16,16 @@
16
16
  align-items: center;
17
17
  gap: var(--zx-space-2);
18
18
  inline-size: 100%;
19
- min-block-size: var(--zx-control-height);
20
- border: 1px solid transparent;
19
+ /* A menu row sits a little under a control so a list of them reads lighter than a column of
20
+ fields; the radius is the smallest step, inset inside the panel's own. */
21
+ min-block-size: calc(var(--zx-control-height) - 2px);
22
+ border: 0;
23
+ border-radius: var(--zx-radius-sm);
21
24
  background: transparent;
22
- padding-inline: var(--zx-control-pad-x);
25
+ padding: var(--zx-space-1) var(--zx-space-2);
23
26
  color: inherit;
24
27
  font: inherit;
28
+ line-height: 1.35;
25
29
  text-align: start;
26
30
  text-decoration: none;
27
31
  }
@@ -112,8 +116,9 @@
112
116
  box-shadow: inset 0 0 0 2px var(--zx-color-bg-raised);
113
117
  }
114
118
 
119
+ /* A section label is a caption, never taller than the rows it introduces. */
115
120
  .zx-menu-button__heading {
116
- padding: var(--zx-space-2) var(--zx-control-pad-x) var(--zx-space-1);
121
+ padding: var(--zx-space-1) var(--zx-space-2);
117
122
  color: var(--zx-color-text-muted);
118
123
  font-size: var(--zx-text-xs);
119
124
  font-weight: 600;
@@ -124,3 +129,66 @@
124
129
  margin: var(--zx-space-1) var(--zx-space-2);
125
130
  background: var(--zx-color-overlay-divider);
126
131
  }
132
+
133
+ /*
134
+ * A choice: a setting chosen in place — its label, then a small segmented control of options, the
135
+ * checked one lifted out of the track. Neutral, not the accent: it is a state, not an action.
136
+ */
137
+ .zx-menu-button__choice {
138
+ display: flex;
139
+ align-items: center;
140
+ justify-content: space-between;
141
+ gap: var(--zx-space-3);
142
+ min-block-size: var(--zx-control-height);
143
+ padding: var(--zx-space-1) var(--zx-space-2);
144
+ }
145
+
146
+ .zx-menu-button__choice-label {
147
+ color: var(--zx-color-text);
148
+ font-size: var(--zx-text-md);
149
+ }
150
+
151
+ .zx-menu-button__choice-options {
152
+ display: inline-flex;
153
+ flex: none;
154
+ gap: 2px;
155
+ border-radius: var(--zx-radius-md);
156
+ background: var(--zx-color-bg-hover);
157
+ padding: 2px;
158
+ }
159
+
160
+ .zx-menu-button__choice-option {
161
+ display: grid;
162
+ place-items: center;
163
+ inline-size: var(--zx-space-6);
164
+ block-size: var(--zx-space-6);
165
+ border: 0;
166
+ border-radius: var(--zx-radius-sm);
167
+ background: transparent;
168
+ padding: 0;
169
+ color: var(--zx-color-text-muted);
170
+ cursor: pointer;
171
+ }
172
+
173
+ .zx-menu-button__choice-option:hover { color: var(--zx-color-text); }
174
+
175
+ .zx-menu-button__choice-option[aria-checked="true"] {
176
+ background: var(--zx-color-bg-surface);
177
+ box-shadow: 0 0 0 1px var(--zx-color-border-subtle);
178
+ color: var(--zx-color-text);
179
+ }
180
+
181
+ .zx-menu-button__choice-option[aria-disabled="true"] { cursor: not-allowed; opacity: .55; }
182
+
183
+ .zx-menu-button__choice-option:focus-visible {
184
+ outline-color: transparent;
185
+ box-shadow: var(--zx-focus-ring);
186
+ }
187
+
188
+ @media (forced-colors: active) {
189
+ .zx-menu-button__choice-option[aria-checked="true"] {
190
+ background: Highlight;
191
+ color: HighlightText;
192
+ forced-color-adjust: none;
193
+ }
194
+ }
@@ -1,5 +1,6 @@
1
1
  import { Component } from '../../core/component.js';
2
2
  import { h, resolveElement } from '../../core/dom.js';
3
+ import { uid } from '../../core/util.js';
3
4
  import { icon } from '../../core/icons.js';
4
5
  import { rovingTabindex, typeahead } from '../../core/keyboard.js';
5
6
  import {
@@ -23,8 +24,19 @@ import { Dropdown } from '../dropdown/dropdown.js';
23
24
  * @property {(event: CustomEvent<Record<string, never>>) => void} [onclose] Close event listener.
24
25
  */
25
26
 
27
+ /**
28
+ * @typedef {Object} MenuButtonOpenOptions
29
+ * @property {boolean|HTMLElement} [returnFocus=true] Where focus goes when this opening ends with a
30
+ * selection or Escape: `true` is the trigger, an element is that element (the trigger again if
31
+ * it has left the document), and `false` leaves focus alone.
32
+ */
33
+
26
34
  /**
27
35
  * APG menu-button trigger backed by an anchored Dropdown.
36
+ *
37
+ * Choosing an item closes the menu and puts focus back on the trigger before the `select` event
38
+ * and the item's `onselect` run, so whatever they open — a dialog, a sheet — records the trigger
39
+ * as the place to return to, and focus they move themselves stays where they put it.
28
40
  * @fires MenuButton#select
29
41
  * @fires MenuButton#open
30
42
  * @fires MenuButton#close
@@ -54,6 +66,8 @@ export class MenuButton extends Component {
54
66
  #items = [];
55
67
  /** @type {{attributes: Array<[string, string]>, children: Node[]}|null} */
56
68
  #original = null;
69
+ /** @type {boolean|HTMLElement} Focus destination for the current opening. */
70
+ #returnFocus = true;
57
71
  #created = false;
58
72
  #destroyed = false;
59
73
 
@@ -87,8 +101,15 @@ export class MenuButton extends Component {
87
101
  content: null
88
102
  });
89
103
  this.#panel = this.#dropdown.getPanel();
104
+ // Escape reaches the menu through the overlay stack, which offers it only to the topmost layer;
105
+ // the menu decides where focus goes. Items are reached with the arrow keys, not Tab.
106
+ this.#dropdown._configure({ onEscape: () => this.#dismiss(), tabReach: false });
90
107
  this.#panel.classList.add('zx-menu-button__menu');
91
108
  this.#panel.setAttribute('role', 'menu');
109
+ // The menu is named by its trigger, as the APG menu-button pattern has it. An id given here to
110
+ // an enhanced trigger goes again on destroy(), with the rest of the attributes it gained.
111
+ if (!this.el.id) this.el.id = uid('zx-menu-button');
112
+ this.#panel.setAttribute('aria-labelledby', this.el.id);
92
113
  this.el.setAttribute('aria-haspopup', 'menu');
93
114
  if (existing && hasExplicitLabel) this.setLabel(this.options.label);
94
115
 
@@ -100,12 +121,15 @@ export class MenuButton extends Component {
100
121
  );
101
122
 
102
123
  this.#dropdown.on('open', () => this.emit('open'));
103
- this.#dropdown.on('close', () => this.emit('close'));
124
+ this.#dropdown.on('close', () => {
125
+ this.#returnFocus = true;
126
+ this.emit('close');
127
+ });
104
128
  this.listen(this.el, 'click', () => {
105
129
  if (this.isOpen()) this.close();
106
130
  else {
107
131
  this.open();
108
- this.#roving.focusFirst();
132
+ this.#focusFirstItem();
109
133
  }
110
134
  });
111
135
  this.listen(this.el, 'keydown', (event) => this.#onTriggerKeydown(event));
@@ -114,6 +138,9 @@ export class MenuButton extends Component {
114
138
  if (item && this.#panel.contains(item)) this.#selectElement(item, event);
115
139
  });
116
140
  this.listen(this.#panel, 'keydown', (event) => this.#onMenuKeydown(event));
141
+ // A choice row runs across, not down: Left and Right move within it, Up and Down leave it.
142
+ // Capture, so the menu's own roving focus never sees the keys this takes.
143
+ this.listen(this.#panel, 'keydown', (event) => this.#onChoiceKeydown(event), { capture: true });
117
144
  }
118
145
 
119
146
  /**
@@ -137,15 +164,18 @@ export class MenuButton extends Component {
137
164
  */
138
165
  setItems(items) {
139
166
  this.#items = Array.isArray(items) ? items.slice() : [];
140
- this.#panel.replaceChildren(...renderMenuEntries(this.#items, 'menu-button'));
167
+ this.#panel.replaceChildren(...renderMenuEntries(this.#items, 'menu-button', { choices: true }));
141
168
  return this;
142
169
  }
143
170
 
144
171
  /**
145
172
  * Opens the menu.
173
+ * @param {MenuButtonOpenOptions} [options={}] Where focus returns when this opening ends; ignored
174
+ * while the menu is already open.
146
175
  * @returns {this}
147
176
  */
148
- open() {
177
+ open(options = {}) {
178
+ if (!this.isOpen()) this.#returnFocus = options?.returnFocus ?? true;
149
179
  this.#dropdown.open();
150
180
  return this;
151
181
  }
@@ -159,6 +189,15 @@ export class MenuButton extends Component {
159
189
  return this;
160
190
  }
161
191
 
192
+ /**
193
+ * Opens the menu when it is closed, and closes it when it is open. Opening this way moves no
194
+ * focus; `focusFirst()` opens with focus on the first item.
195
+ * @returns {this}
196
+ */
197
+ toggle() {
198
+ return this.isOpen() ? this.close() : this.open();
199
+ }
200
+
162
201
  /**
163
202
  * Reports whether the menu is open.
164
203
  * @returns {boolean}
@@ -177,16 +216,24 @@ export class MenuButton extends Component {
177
216
  return this.#panel;
178
217
  }
179
218
 
180
- /** Opens the menu and focuses its first enabled item. @returns {this} */
181
- focusFirst() {
182
- this.open();
183
- this.#roving.focusFirst();
219
+ /**
220
+ * Opens the menu and focuses its first enabled item.
221
+ * @param {MenuButtonOpenOptions} [options={}] Passed to `open()`.
222
+ * @returns {this}
223
+ */
224
+ focusFirst(options = {}) {
225
+ this.open(options);
226
+ this.#focusFirstItem();
184
227
  return this;
185
228
  }
186
229
 
187
- /** Opens the menu and focuses its last enabled item. @returns {this} */
188
- focusLast() {
189
- this.open();
230
+ /**
231
+ * Opens the menu and focuses its last enabled item.
232
+ * @param {MenuButtonOpenOptions} [options={}] Passed to `open()`.
233
+ * @returns {this}
234
+ */
235
+ focusLast(options = {}) {
236
+ this.open(options);
190
237
  this.#roving.focusLast();
191
238
  return this;
192
239
  }
@@ -224,17 +271,11 @@ export class MenuButton extends Component {
224
271
  event.preventDefault();
225
272
  this.open();
226
273
  if (event.key === 'ArrowUp') this.#roving.focusLast();
227
- else this.#roving.focusFirst();
274
+ else this.#focusFirstItem();
228
275
  }
229
276
 
230
277
  /** @param {KeyboardEvent} event @returns {void} */
231
278
  #onMenuKeydown(event) {
232
- if (event.key === 'Escape') {
233
- event.preventDefault();
234
- this.close();
235
- this.el.focus();
236
- return;
237
- }
238
279
  if (event.key === 'Tab') {
239
280
  this.close();
240
281
  return;
@@ -250,17 +291,107 @@ export class MenuButton extends Component {
250
291
  this.#typeahead(event);
251
292
  }
252
293
 
294
+ /**
295
+ * Escape, as the overlay stack routes it here: closes the menu, and returns focus when the menu
296
+ * held it (or it had already fallen to the body).
297
+ * @returns {void}
298
+ */
299
+ #dismiss() {
300
+ const active = document.activeElement;
301
+ const reclaim = !active || active === document.body || this.#panel.contains(active);
302
+ const target = this.#returnTarget();
303
+ this.close();
304
+ if (reclaim) target?.focus();
305
+ }
306
+
307
+ /** Focuses the first item, or, when that is a choice row, its checked option. @returns {void} */
308
+ #focusFirstItem() {
309
+ this.#roving.focusFirst();
310
+ const first = this.#menuItems()[0];
311
+ const checked = first?.hasAttribute('data-menu-option')
312
+ ? first.parentElement?.querySelector('[aria-checked="true"]') : null;
313
+ if (checked instanceof HTMLElement) checked.focus();
314
+ }
315
+
316
+ /** @param {KeyboardEvent} event @returns {void} */
317
+ #onChoiceKeydown(event) {
318
+ const option = /** @type {Element|null} */ (event.target)?.closest?.('[data-menu-option]');
319
+ if (!(option instanceof HTMLElement) || !this.#panel.contains(option)) {
320
+ // Arriving at a choice row from another item lands on its checked option, not its nearest.
321
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
322
+ const items = this.#menuItems();
323
+ const index = items.indexOf(/** @type {HTMLElement} */ (event.target));
324
+ if (index < 0) return;
325
+ const next = items[(index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length];
326
+ const row = next?.hasAttribute('data-menu-option') ? next.parentElement : null;
327
+ if (!row) return;
328
+ event.preventDefault();
329
+ event.stopPropagation();
330
+ /** @type {HTMLElement} */ (row.querySelector('[aria-checked="true"]') ?? next).focus();
331
+ return;
332
+ }
333
+ const row = /** @type {HTMLElement[]} */ ([...(option.parentElement?.children ?? [])]);
334
+ const items = this.#menuItems();
335
+ let target = null;
336
+ if (event.key === 'ArrowLeft') target = row[(row.indexOf(option) - 1 + row.length) % row.length];
337
+ else if (event.key === 'ArrowRight') target = row[(row.indexOf(option) + 1) % row.length];
338
+ else if (event.key === 'ArrowDown') target = items[(items.indexOf(row.at(-1)) + 1) % items.length];
339
+ else if (event.key === 'ArrowUp') target = items[(items.indexOf(row[0]) - 1 + items.length) % items.length];
340
+ else return;
341
+ event.preventDefault();
342
+ event.stopPropagation();
343
+ // Into another choice row, focus lands on its checked option.
344
+ const checked = target?.closest('[role="group"]')?.querySelector('[data-menu-option][aria-checked="true"]');
345
+ const next = event.key === 'ArrowDown' || event.key === 'ArrowUp' ? /** @type {HTMLElement} */ (checked ?? target) : target;
346
+ next?.focus();
347
+ }
348
+
349
+ /** @param {HTMLElement} element @param {Record<string, any>} entry @param {Event|null} event @returns {void} */
350
+ #selectChoice(element, entry, event) {
351
+ const option = entry.options?.[Number(element.getAttribute('data-menu-option'))];
352
+ if (!option || option.disabled) {
353
+ event?.preventDefault();
354
+ return;
355
+ }
356
+ // A setting changes in place and the menu stays open, so its effect shows.
357
+ for (const sibling of element.parentElement?.children ?? []) {
358
+ sibling.setAttribute('aria-checked', String(sibling === element));
359
+ }
360
+ element.focus();
361
+ entry.onselect?.(option.value, entry, this);
362
+ // A handler that closed the menu leaves focus on a hidden option: it goes where a close sends it.
363
+ if (!this.isOpen() && (element.contains(document.activeElement) || document.activeElement === document.body)) {
364
+ this.#returnTarget()?.focus();
365
+ }
366
+ }
367
+
253
368
  /** @param {Element} element @param {Event|null} [event=null] @returns {void} */
254
369
  #selectElement(element, event = null) {
255
370
  const index = Number(element.getAttribute('data-menu-item'));
256
371
  const item = this.#items[index];
372
+ if (item && typeof item === 'object' && item.type === 'choice') {
373
+ this.#selectChoice(/** @type {HTMLElement} */ (element), item, event);
374
+ return;
375
+ }
257
376
  if (!isMenuItem(item) || item.disabled) {
258
377
  event?.preventDefault();
259
378
  return;
260
379
  }
380
+ /*
381
+ * Focus goes back first, as the APG pattern has it, and only if it is still the menu's to give:
382
+ * inside the panel, or already lost to the body. A dialog opened by a listener below then
383
+ * records the trigger as its opener rather than an item that is about to be hidden, and focus
384
+ * a listener moves on purpose is left where it was put.
385
+ */
386
+ const target = this.#returnTarget();
387
+ const active = document.activeElement;
388
+ const reclaim = Boolean(target) && (!active || active === document.body || this.#panel.contains(active));
389
+ if (reclaim) target?.focus();
261
390
  const selected = this.emit('select', { value: item.value, item });
262
391
  if (selected.defaultPrevented) {
263
392
  event?.preventDefault();
393
+ // The menu stays open, so focus returns to the item, unless a listener has moved it again.
394
+ if (reclaim && document.activeElement === target && element instanceof HTMLElement) element.focus();
264
395
  return;
265
396
  }
266
397
  try {
@@ -270,6 +401,17 @@ export class MenuButton extends Component {
270
401
  }
271
402
  }
272
403
 
404
+ /**
405
+ * The element focus returns to when the current opening ends, or null when it should stay put.
406
+ * @returns {HTMLElement|null}
407
+ */
408
+ #returnTarget() {
409
+ const target = this.#returnFocus;
410
+ if (target === false) return null;
411
+ if (target instanceof HTMLElement && target.isConnected) return target;
412
+ return /** @type {HTMLElement} */ (this.el);
413
+ }
414
+
273
415
  /** @returns {HTMLElement[]} */
274
416
  #menuItems() {
275
417
  return Array.from(this.#panel.querySelectorAll(MENU_ITEM_SELECTOR));
@@ -25,12 +25,30 @@
25
25
  align-content: start;
26
26
  }
27
27
 
28
+ /* On a phone the toasts rise from the foot of the screen, full width, where they cover content
29
+ rather than the header's controls, and sit where a thumb can reach their action. */
30
+ @media (max-width: 640px) {
31
+ .zx-message-region {
32
+ inset-block-start: auto;
33
+ inset-block-end: var(--zx-space-4);
34
+ inset-inline: var(--zx-space-4);
35
+ inline-size: auto;
36
+ }
37
+
38
+ .zx-message-region:popover-open {
39
+ align-content: end;
40
+ }
41
+ }
42
+
28
43
  .zx-message__toast {
29
44
  --zx-message-kind: var(--zx-color-info);
30
45
  --zx-message-fallback-bg: var(--zx-color-info-bg);
31
46
  position: relative;
32
47
  display: grid;
33
- grid-template-columns: auto minmax(0, 1fr) auto;
48
+ /* Icon and text, then whatever follows — the action, the close button — in columns of their own. */
49
+ grid-template-columns: auto minmax(0, 1fr);
50
+ grid-auto-columns: auto;
51
+ grid-auto-flow: column;
34
52
  align-items: start;
35
53
  gap: var(--zx-space-3);
36
54
  border: 1px solid var(--zx-color-overlay-border);
@@ -64,8 +82,8 @@
64
82
  background-clip: padding-box;
65
83
  -webkit-backdrop-filter: var(--zx-glass-filter-overlay);
66
84
  backdrop-filter: var(--zx-glass-filter-overlay);
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);
85
+ /* No bar on the leading edge: the kind shows in the icon and the tinted border. */
86
+ box-shadow: var(--zx-glass-overlay-shadow);
69
87
  }
70
88
  }
71
89
 
@@ -90,6 +108,18 @@
90
108
  overflow-wrap: anywhere;
91
109
  }
92
110
 
111
+ /* A leading visual — an avatar, say — keeps its own size, centred on the first line of text. */
112
+ .zx-message__toast[data-leading] > .zx-message__icon {
113
+ margin-block: calc(-0.5 * var(--zx-space-1)) 0;
114
+ }
115
+
116
+ /* The action sits level with the close button: both are pulled up the same step so their centres
117
+ meet the first line of text rather than hanging below it. */
118
+ .zx-message__action {
119
+ margin-block: calc(-1 * var(--zx-space-1)) 0;
120
+ white-space: nowrap;
121
+ }
122
+
93
123
  /* Visual chrome comes from the shared .zx-icon-btn utility; only alignment is set here so
94
124
  the close control sits flush with the trailing top corner without stretching the toast.
95
125
  Trailing, not right: in an RTL document the close control belongs on the left, and a