@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
@@ -3,8 +3,12 @@ import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
3
3
  import { icon } from '../../core/icons.js';
4
4
  import { typeahead } from '../../core/keyboard.js';
5
5
  import { position } from '../../core/position.js';
6
- import { isCssColor, uid } from '../../core/util.js';
6
+ import { uid } from '../../core/util.js';
7
+ import { itemColor } from './color.js';
7
8
  import { matchItems } from './filter.js';
9
+ import {
10
+ DEFAULT_STATUSES, STATUS_INDICATORS, STATUS_TONES, normalizePriorityLevel, priorityIcon, statusIcon
11
+ } from './glyphs.js';
8
12
 
9
13
  /** @typedef {string|number|boolean|symbol|bigint|null|undefined} Primitive */
10
14
  /** @typedef {Record<string, any>|Primitive} SelectItem */
@@ -49,7 +53,9 @@ import { matchItems } from './filter.js';
49
53
  * @property {string} [recentLabel='Recent'] Heading above recent choices.
50
54
  * @property {boolean|SelectAction} [create=false] Optional create command at the bottom.
51
55
  * @property {string|((item: SelectItem)=>unknown)} [iconKey='icon'] Item icon reader.
52
- * @property {string|((item: SelectItem)=>unknown)} [colorKey='color'] Optional icon colour reader.
56
+ * @property {string|((item: SelectItem)=>unknown)} [colorKey='color'] Optional icon colour reader: a CSS
57
+ * colour or a token reference such as `var(--zx-color-tone-blue-fg)`. Without one the glyph is a
58
+ * neutral tile in muted ink.
53
59
  * @property {string|((item: SelectItem)=>unknown)} [subtitleKey='description'] Secondary-text reader.
54
60
  * @property {((item: SelectItem, context:{selected:boolean})=>Node|string|null)|null} [renderIcon=null] Entity icon renderer.
55
61
  */
@@ -69,18 +75,6 @@ const PRIORITY_KEYS = [
69
75
  ];
70
76
  const PRIORITY_FALLBACKS = ['Lowest', 'Low', 'Medium', 'High', 'Highest'];
71
77
 
72
- const DEFAULT_STATUSES = Object.freeze([
73
- ['backlog', 'status.backlog', 'Backlog', 'dotted', 'muted'],
74
- ['todo', 'status.todo', 'Todo', 'ring', 'neutral'],
75
- ['in-progress', 'status.inProgress', 'In Progress', 'progress', 'warning'],
76
- ['in-review', 'status.inReview', 'In Review', 'review', 'info'],
77
- ['ready', 'status.ready', 'Ready to Merge', 'ready', 'accent'],
78
- ['waiting', 'status.waiting', 'Waiting for feedback', 'waiting', 'warning'],
79
- ['done', 'status.done', 'Done', 'done', 'info'],
80
- ['canceled', 'status.canceled', 'Canceled', 'canceled', 'muted'],
81
- ['duplicate', 'status.duplicate', 'Duplicate', 'duplicate', 'muted']
82
- ]);
83
-
84
78
  /**
85
79
  * APG editable combobox with optional local or asynchronous filtering.
86
80
  *
@@ -125,7 +119,7 @@ export class Select extends Component {
125
119
  render() {
126
120
  // Instance state initialized here because render() runs inside the base
127
121
  // constructor, before class-field initializers would run (and clobber it).
128
- this._items = []; this._visibleItems = []; this._fixedItems = []; this._visibleFixed = []; this._actions = []; this._selected = null; this._optionEntries = []; this._activeIndex = -1; this._open = false; this._disabled = false; this._loading = false; this._requestSequence = 0; this._queryTimer = null; this._position = null; this._createdRoot = false; this._root = null; this._snapshot = null;
122
+ this._items = []; this._visibleItems = []; this._fixedItems = []; this._visibleFixed = []; this._actions = []; this._selected = null; this._optionEntries = []; this._activeIndex = -1; this._open = false; this._disabled = false; this._loading = false; this._requestSequence = 0; this._queryTimer = null; this._position = null; this._createdRoot = false; this._root = null; this._snapshot = null; this._pointerModality = false;
129
123
  this._createdRoot = this.el === null;
130
124
  const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
131
125
  this.el = root;
@@ -209,7 +203,22 @@ export class Select extends Component {
209
203
 
210
204
  this.listen(input, 'click', () => this.open());
211
205
  this.listen(input, 'input', () => this._onInput());
212
- this.listen(input, 'keydown', (event) => this._onKeydown(/** @type {KeyboardEvent} */ (event)));
206
+ this.listen(input, 'keydown', (event) => {
207
+ if (!isModifierKey(/** @type {KeyboardEvent} */ (event))) root.removeAttribute('data-focus-origin');
208
+ this._onKeydown(/** @type {KeyboardEvent} */ (event));
209
+ });
210
+ // A text input matches :focus-visible however it was focused, read-only or not, so a click on
211
+ // a read-only Select would draw the keyboard ring. Track how the user last interacted and mark
212
+ // a pointer focus; the ring then follows keyboard focus only, and returns on the first key.
213
+ this.listen(input, 'focus', () => {
214
+ if (this._pointerModality && /** @type {HTMLInputElement} */ (input).readOnly) {
215
+ root.setAttribute('data-focus-origin', 'pointer');
216
+ } else root.removeAttribute('data-focus-origin');
217
+ });
218
+ this.listen(input, 'blur', () => root.removeAttribute('data-focus-origin'));
219
+ this.listen(document, 'keydown', (event) => {
220
+ if (!isModifierKey(/** @type {KeyboardEvent} */ (event))) this._pointerModality = false;
221
+ }, { capture: true });
213
222
  this.listen(toggle, 'click', () => {
214
223
  this.focus();
215
224
  if (this._open) this.close();
@@ -222,6 +231,7 @@ export class Select extends Component {
222
231
  this.listen(panel, 'pointerdown', (event) => event.preventDefault());
223
232
  this.listen(panel, 'click', (event) => this._onOptionClick(event));
224
233
  this.listen(document, 'pointerdown', (event) => {
234
+ this._pointerModality = true;
225
235
  if (this._open && !root.contains(/** @type {Node} */ (event.target))) this.close();
226
236
  }, { capture: true });
227
237
 
@@ -381,26 +391,44 @@ export class Select extends Component {
381
391
  }
382
392
 
383
393
  /**
384
- * Creates the five-level priority preset.
394
+ * Creates the five-level priority preset: values 0 (lowest) to 4 (highest), each drawn as grey
395
+ * signal bars filled to its level, except the highest, which is the one coloured level — a
396
+ * danger square with an exclamation mark. A "No priority" row (value null, a row of dashes)
397
+ * heads the list and labels the empty control; `clearable: false` removes it.
385
398
  * @param {Element|string|null} target Component target.
386
- * @param {SelectOptions} [options={}] Select overrides other than fixed items and renderer.
399
+ * @param {SelectOptions} [options={}] Select overrides other than items and renderers.
400
+ * `clearable` defaults to true here; `noneLabel` and `placeholder` default to the
401
+ * `priority.none` message ("No priority").
387
402
  * @returns {Select}
388
403
  */
389
404
  static priority(target, options = {}) {
390
405
  const requestedValue = options.value ?? null;
391
- const select = new Select(target, {
406
+ const clearable = options.clearable ?? true;
407
+ /** @type {Select|null} */
408
+ let select = null;
409
+ // The constructor renders the list once before `select` is assigned; the label is settled by
410
+ // the setItems() below, which renders it again.
411
+ const noneLabel = () => options.noneLabel
412
+ ?? (select ? select._message('priority.none', 'No priority') : 'No priority');
413
+ select = new Select(target, {
392
414
  ...options,
415
+ clearable,
393
416
  value: null,
394
417
  items: [],
395
418
  renderItem(item) {
396
419
  return priorityContent(item, true);
397
420
  },
421
+ renderNone: options.renderNone ?? (() => priorityContent({ level: null, name: noneLabel() }, true)),
398
422
  renderValueAdornment(item) { return priorityIndicator(item.level); }
399
423
  });
424
+ if (clearable && !options.placeholder) {
425
+ /** @type {HTMLInputElement} */ (select.refs.input).placeholder = noneLabel();
426
+ }
427
+ const preset = select;
400
428
  select.setItems(PRIORITY_KEYS.map((key, level) => {
401
- let name = level === 2 ? select.msg('priority.medium') : key;
402
- if (name === 'priority.medium') name = select._message(key, PRIORITY_FALLBACKS[level]);
403
- else if (level !== 2) name = select._message(key, PRIORITY_FALLBACKS[level]);
429
+ let name = level === 2 ? preset.msg('priority.medium') : key;
430
+ if (name === 'priority.medium') name = preset._message(key, PRIORITY_FALLBACKS[level]);
431
+ else if (level !== 2) name = preset._message(key, PRIORITY_FALLBACKS[level]);
404
432
  return { ID: level, name, level };
405
433
  }));
406
434
  select.set(requestedValue, { silent: true });
@@ -453,10 +481,10 @@ export class Select extends Component {
453
481
  const value = readSelectItem(item, iconKey);
454
482
  if (value && typeof value === 'object' && typeof value.nodeType === 'number') return value.cloneNode(true);
455
483
  if (typeof value !== 'string' || !value) return null;
456
- const color = readSelectItem(item, colorKey);
484
+ const color = itemColor(readSelectItem(item, colorKey));
457
485
  return h('span', {
458
486
  class: 'zx-select__entity-glyph',
459
- style: isCssColor(color) ? { '--zx-select-entity-color': String(color) } : null,
487
+ style: color ? { '--zx-select-entity-color': color } : null,
460
488
  ariaHidden: 'true'
461
489
  }, icon(value, { size: 14 }));
462
490
  };
@@ -992,28 +1020,26 @@ function priorityContent(item, label) {
992
1020
  label ? h('span', {}, String(item.name)) : null);
993
1021
  }
994
1022
 
995
- /** @param {unknown} level @returns {Node} */
1023
+ /**
1024
+ * The priority glyph `priorityIcon()` draws, carrying the Select's own class as well so styles
1025
+ * written against it keep applying.
1026
+ * @param {unknown} level Priority level 0–4, or null for none.
1027
+ * @returns {Node}
1028
+ */
996
1029
  function priorityIndicator(level) {
997
- const normalized = Math.max(0, Math.min(4, Math.trunc(Number(level) || 0)));
998
- return h('span', {
999
- class: 'zx-select__priority-icon',
1000
- dataset: { level: normalized },
1001
- ariaHidden: 'true'
1002
- }, Array.from({ length: 5 }, (_, index) => h('span', {
1003
- dataset: { filled: String(index <= normalized) }
1004
- })));
1030
+ const glyph = priorityIcon(/** @type {number|null} */ (normalizePriorityLevel(level)));
1031
+ glyph.classList.add('zx-select__priority-icon');
1032
+ return glyph;
1005
1033
  }
1006
1034
 
1007
1035
  /** @param {unknown} input @returns {Record<string, any>|null} */
1008
1036
  function normalizeStatusItem(input) {
1009
1037
  if (!input || typeof input !== 'object' || input.ID == null || input.name == null) return null;
1010
- const indicators = ['dotted', 'ring', 'progress', 'review', 'ready', 'waiting', 'done', 'canceled', 'duplicate'];
1011
- const tones = ['muted', 'neutral', 'warning', 'info', 'accent', 'success', 'danger'];
1012
1038
  return {
1013
1039
  ...input,
1014
1040
  name: String(input.name),
1015
- indicator: indicators.includes(String(input.indicator)) ? String(input.indicator) : 'ring',
1016
- tone: tones.includes(String(input.tone)) ? String(input.tone) : 'muted',
1041
+ indicator: STATUS_INDICATORS.includes(String(input.indicator)) ? String(input.indicator) : 'ring',
1042
+ tone: STATUS_TONES.includes(String(input.tone)) ? String(input.tone) : 'muted',
1017
1043
  description: input.description == null ? '' : String(input.description),
1018
1044
  shortcut: input.shortcut == null ? '' : String(input.shortcut)
1019
1045
  };
@@ -1029,17 +1055,16 @@ function statusContent(item) {
1029
1055
  item.shortcut ? h('kbd', { class: 'zx-select__status-shortcut' }, item.shortcut) : null);
1030
1056
  }
1031
1057
 
1032
- /** @param {Record<string, any>} item @returns {Node} */
1058
+ /**
1059
+ * The status glyph `statusIcon()` draws for a settled status item, carrying the Select's own class
1060
+ * as well so styles written against it keep applying.
1061
+ * @param {Record<string, any>} item Normalized status item.
1062
+ * @returns {Node}
1063
+ */
1033
1064
  function statusIndicator(item) {
1034
- const indicator = String(item.indicator ?? 'ring');
1035
- const marker = h('span', {
1036
- class: 'zx-select__status-indicator',
1037
- dataset: { indicator, tone: String(item.tone ?? 'muted') },
1038
- ariaHidden: 'true'
1039
- });
1040
- if (indicator === 'done') marker.append(icon('check', { size: 8 }));
1041
- if (indicator === 'canceled') marker.append(icon('x', { size: 7 }));
1042
- return marker;
1065
+ const glyph = statusIcon({ indicator: item.indicator ?? 'ring', tone: item.tone ?? 'muted' });
1066
+ glyph.classList.add('zx-select__status-indicator');
1067
+ return glyph;
1043
1068
  }
1044
1069
 
1045
1070
  /** Change emitted when the selected value changes. @event Select#change @type {CustomEvent<SelectChangeDetail>} */
@@ -1061,6 +1086,17 @@ function readItem(item, reader) {
1061
1086
  return item[reader];
1062
1087
  }
1063
1088
 
1089
+ /**
1090
+ * Reports a key press that says nothing about how the user is moving focus: a lone modifier, or a
1091
+ * shortcut chord the page or the browser handles.
1092
+ * @param {KeyboardEvent} event Key event.
1093
+ * @returns {boolean}
1094
+ */
1095
+ function isModifierKey(event) {
1096
+ return ['Shift', 'Control', 'Alt', 'Meta', 'CapsLock'].includes(event.key)
1097
+ || event.ctrlKey || event.metaKey;
1098
+ }
1099
+
1064
1100
  /** @param {KeyboardEvent} event @returns {boolean} */
1065
1101
  function isPrintable(event) {
1066
1102
  return event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
@@ -57,8 +57,8 @@
57
57
  inset-block-start: 0;
58
58
  inset-block-end: auto;
59
59
  border-block-end: 1px solid var(--zx-color-overlay-border);
60
- border-end-start-radius: var(--zx-sheet-radius, var(--zx-radius-xl));
61
- border-end-end-radius: var(--zx-sheet-radius, var(--zx-radius-xl));
60
+ border-end-start-radius: var(--zx-sheet-radius, var(--zx-radius-lg));
61
+ border-end-end-radius: var(--zx-sheet-radius, var(--zx-radius-lg));
62
62
  --zx-sheet-enter: 0 -100%;
63
63
  }
64
64
 
@@ -66,8 +66,8 @@
66
66
  inset-block-start: auto;
67
67
  inset-block-end: 0;
68
68
  border-block-start: 1px solid var(--zx-color-overlay-border);
69
- border-start-start-radius: var(--zx-sheet-radius, var(--zx-radius-xl));
70
- border-start-end-radius: var(--zx-sheet-radius, var(--zx-radius-xl));
69
+ border-start-start-radius: var(--zx-sheet-radius, var(--zx-radius-lg));
70
+ border-start-end-radius: var(--zx-sheet-radius, var(--zx-radius-lg));
71
71
  --zx-sheet-enter: 0 100%;
72
72
  }
73
73
 
@@ -105,11 +105,14 @@
105
105
  * `display: none` immediately — so `display` and `overlay` transition with `allow-discrete`, which
106
106
  * defers the discrete jump until the rest of the transition has finished. `@starting-style` gives
107
107
  * the entry its from-state. Where either is unsupported the sheet simply appears and disappears.
108
+ *
109
+ * The slide runs on the drawer curve — most of the distance at once, then a settle — while the
110
+ * fade keeps the ordinary ease, so the panel arrives decisively without flashing in.
108
111
  */
109
112
  @media (prefers-reduced-motion: no-preference) {
110
113
  .zx-sheet {
111
114
  transition:
112
- translate var(--zx-dur-base) var(--zx-ease),
115
+ translate var(--zx-dur-base) var(--zx-ease-drawer),
113
116
  opacity var(--zx-dur-base) var(--zx-ease),
114
117
  display var(--zx-dur-base) allow-discrete,
115
118
  overlay var(--zx-dur-base) allow-discrete;
@@ -193,7 +196,7 @@
193
196
  @media (prefers-reduced-motion: no-preference) {
194
197
  .zx-sheet[data-docked] {
195
198
  transition:
196
- flex-basis var(--zx-dur-base) var(--zx-ease),
199
+ flex-basis var(--zx-dur-base) var(--zx-ease-drawer),
197
200
  opacity var(--zx-dur-base) var(--zx-ease),
198
201
  display var(--zx-dur-base) allow-discrete,
199
202
  overlay var(--zx-dur-base) allow-discrete;
@@ -363,8 +366,8 @@
363
366
  @media (prefers-reduced-motion: no-preference) {
364
367
  .zx-sheet[data-stack] {
365
368
  transition:
366
- transform var(--zx-dur-base) var(--zx-ease),
367
- translate var(--zx-dur-base) var(--zx-ease),
369
+ transform var(--zx-dur-base) var(--zx-ease-drawer),
370
+ translate var(--zx-dur-base) var(--zx-ease-drawer),
368
371
  opacity var(--zx-dur-base) var(--zx-ease),
369
372
  display var(--zx-dur-base) allow-discrete,
370
373
  overlay var(--zx-dur-base) allow-discrete;
@@ -147,17 +147,31 @@
147
147
  font-variant-numeric: tabular-nums;
148
148
  }
149
149
 
150
+ /*
151
+ * A mark sits under the thumb's centre at its value — half a thumb in from either end of the rail
152
+ * — and centres its label there, except that a label is never pushed past either end of the
153
+ * track: the outermost ones end flush with it instead of spilling out of the component.
154
+ * `--zx-slider-mark` is the value's position on the scale, from 0 to 1.
155
+ */
150
156
  .zx-slider__marks {
151
157
  position: relative;
158
+ inline-size: 100%;
152
159
  block-size: var(--zx-space-5);
153
160
  margin-block-start: var(--zx-space-1);
161
+ container-type: inline-size;
154
162
  }
155
163
 
156
164
  .zx-slider__mark {
165
+ --zx-slider-mark-at: calc(var(--zx-slider-thumb) / 2
166
+ + (100cqi - var(--zx-slider-thumb)) * var(--zx-slider-mark, 0));
157
167
  position: absolute;
158
168
  inset-block-start: 0;
159
- inset-inline-start: var(--zx-slider-mark, 0%);
160
- transform: translateX(-50%);
169
+ inset-inline-start: var(--zx-slider-mark-at);
170
+ transform: translateX(clamp(
171
+ -1 * var(--zx-slider-mark-at),
172
+ -50%,
173
+ 100cqi - var(--zx-slider-mark-at) - 100%
174
+ ));
161
175
  color: var(--zx-color-text-muted);
162
176
  font-size: var(--zx-text-xs);
163
177
  white-space: nowrap;
@@ -279,7 +279,7 @@ export class Slider extends Component {
279
279
  const label = typeof mark === 'number' ? this._format(value) : String(mark.label ?? this._format(value));
280
280
  element.append(h('span', {
281
281
  class: 'zx-slider__mark',
282
- style: { '--zx-slider-mark': `${this._ratio(value) * 100}%` }
282
+ style: { '--zx-slider-mark': String(this._ratio(value)) }
283
283
  }, label));
284
284
  }
285
285
  return element;
@@ -10,8 +10,11 @@
10
10
  .zx-sort-control__direction {
11
11
  inline-size: var(--zx-control-height);
12
12
  padding: 0;
13
- border-radius: var(--zx-radius-full);
13
+ border-radius: var(--zx-control-radius);
14
14
  }
15
15
 
16
16
  .zx-sort-control__direction:focus-visible { box-shadow: var(--zx-focus-ring); }
17
+ /* The arrow is the current direction, not decoration, so it keeps the text colour a ghost glyph
18
+ would otherwise give up. */
19
+ .zx-sort-control .zx-sort-control__direction > .zx-icon { color: var(--zx-color-text); }
17
20
  .zx-sort-control .zx-select__control { background: var(--zx-color-bg-control); }
@@ -1,14 +1,17 @@
1
1
  /*
2
- * The tile is a size ladder, not a colour: the value is the loudest thing on it, the label is
3
- * small above it, and the delta and sparkline are quieter than both. `kind` tints the icon and
4
- * the sparkline only — never the value, which has to stay at full text contrast whatever intent
5
- * a server assigns the metric.
2
+ * The tile is a size ladder, not a colour: the value is the loudest thing on it, the label is a
3
+ * quiet sentence-case line above it, and the caption, delta and sparkline are quieter than both.
4
+ * The icon is a muted glyph, not a coloured chip. `kind` tints the sparkline only — never the
5
+ * value, which has to stay at full text contrast whatever intent a server assigns the metric.
6
+ *
7
+ * A column, with the sparkline pushed to the foot: tiles in one row are as tall as the tallest,
8
+ * so their labels, values and sparklines line up whether or not each tile has every part.
6
9
  */
7
10
  .zx-stat-tile {
8
11
  --zx-stat-tile-value-size: var(--zx-text-2xl);
9
- display: grid;
10
- gap: var(--zx-space-2);
11
- align-content: start;
12
+ display: flex;
13
+ flex-direction: column;
14
+ gap: var(--zx-space-1);
12
15
  min-inline-size: 0;
13
16
  padding: var(--zx-space-4);
14
17
  border: 1px solid var(--zx-color-border);
@@ -60,25 +63,19 @@ button.zx-stat-tile:focus-visible {
60
63
  .zx-stat-tile__label {
61
64
  min-inline-size: 0;
62
65
  overflow: hidden;
63
- font-size: var(--zx-text-xs);
64
- font-weight: 600;
65
- letter-spacing: 0.04em;
66
+ font-size: var(--zx-text-sm);
67
+ font-weight: var(--zx-weight-medium);
66
68
  line-height: 1.4;
67
- text-transform: uppercase;
68
69
  text-overflow: ellipsis;
69
70
  white-space: nowrap;
70
71
  color: var(--zx-color-text-muted);
71
72
  }
72
73
 
73
74
  .zx-stat-tile__icon {
74
- display: grid;
75
+ display: inline-grid;
75
76
  flex: none;
76
77
  place-items: center;
77
- inline-size: 1.75rem;
78
- block-size: 1.75rem;
79
- border-radius: var(--zx-radius-full);
80
- background: var(--zx-color-bg-muted);
81
- color: var(--zx-color-text-muted);
78
+ color: var(--zx-color-text-subtle);
82
79
  }
83
80
 
84
81
  /*
@@ -87,21 +84,35 @@ button.zx-stat-tile:focus-visible {
87
84
  */
88
85
  .zx-stat-tile__value {
89
86
  min-inline-size: 0;
87
+ margin-block-start: var(--zx-space-1);
90
88
  font-size: var(--zx-stat-tile-value-size);
91
- font-weight: 600;
89
+ font-weight: 650;
92
90
  font-variant-numeric: tabular-nums;
91
+ letter-spacing: var(--zx-tracking-title);
93
92
  line-height: 1.15;
94
93
  overflow-wrap: break-word;
95
94
  color: var(--zx-color-text);
96
95
  }
97
96
 
97
+ /* What the value is out of, or measured in: one muted line, read with the value. */
98
+ .zx-stat-tile__caption {
99
+ min-inline-size: 0;
100
+ overflow: hidden;
101
+ color: var(--zx-color-text-muted);
102
+ font-size: var(--zx-text-sm);
103
+ font-variant-numeric: tabular-nums;
104
+ line-height: 1.4;
105
+ text-overflow: ellipsis;
106
+ white-space: nowrap;
107
+ }
108
+
98
109
  .zx-stat-tile__delta {
99
110
  display: flex;
100
111
  gap: var(--zx-space-1);
101
112
  align-items: baseline;
102
113
  min-inline-size: 0;
103
114
  font-size: var(--zx-text-xs);
104
- font-weight: 600;
115
+ font-weight: var(--zx-weight-medium);
105
116
  font-variant-numeric: tabular-nums;
106
117
  line-height: 1.4;
107
118
  color: var(--zx-color-text-muted);
@@ -124,6 +135,12 @@ button.zx-stat-tile:focus-visible {
124
135
  align-self: center;
125
136
  }
126
137
 
138
+ .zx-stat-tile__delta-value {
139
+ /* A figure never breaks ("+23" over "pcs"); the comparison beside it gives way first. */
140
+ flex: none;
141
+ white-space: nowrap;
142
+ }
143
+
127
144
  .zx-stat-tile__delta-label {
128
145
  min-inline-size: 0;
129
146
  overflow: hidden;
@@ -136,9 +153,12 @@ button.zx-stat-tile:focus-visible {
136
153
 
137
154
  .zx-stat-tile__trend {
138
155
  display: block;
156
+ flex: none;
157
+ box-sizing: content-box;
139
158
  inline-size: 100%;
140
159
  block-size: 2rem;
141
- margin-block-start: var(--zx-space-1);
160
+ margin-block-start: auto;
161
+ padding-block-start: var(--zx-space-2);
142
162
  color: var(--zx-color-text-placeholder);
143
163
  }
144
164
 
@@ -147,32 +167,7 @@ button.zx-stat-tile:focus-visible {
147
167
  gap: var(--zx-space-2);
148
168
  }
149
169
 
150
- /* Intent. The icon carries it as a tint, the sparkline as its stroke. */
151
- .zx-stat-tile[data-kind="accent"] .zx-stat-tile__icon {
152
- background: var(--zx-color-accent-subtle);
153
- color: var(--zx-color-accent);
154
- }
155
-
156
- .zx-stat-tile[data-kind="success"] .zx-stat-tile__icon {
157
- background: var(--zx-color-success-bg);
158
- color: var(--zx-color-success);
159
- }
160
-
161
- .zx-stat-tile[data-kind="warning"] .zx-stat-tile__icon {
162
- background: var(--zx-color-warning-bg);
163
- color: var(--zx-color-warning);
164
- }
165
-
166
- .zx-stat-tile[data-kind="danger"] .zx-stat-tile__icon {
167
- background: var(--zx-color-danger-bg);
168
- color: var(--zx-color-danger);
169
- }
170
-
171
- .zx-stat-tile[data-kind="info"] .zx-stat-tile__icon {
172
- background: var(--zx-color-info-bg);
173
- color: var(--zx-color-info);
174
- }
175
-
170
+ /* Intent. The sparkline carries it as its stroke; the icon stays a muted glyph. */
176
171
  .zx-stat-tile[data-kind="accent"] .zx-stat-tile__trend {
177
172
  color: var(--zx-color-accent);
178
173
  }
@@ -25,6 +25,7 @@ import { skeleton } from '../skeleton/skeleton.js';
25
25
  * @typedef {Object} StatTileNameParts
26
26
  * @property {string} [label] The metric's name.
27
27
  * @property {string} [value] The formatted value.
28
+ * @property {string} [caption] The context line under the value, e.g. `'of 13,184'`.
28
29
  * @property {string} [delta] The change, already worded for speech (`'up 2'`, not `'+2'`).
29
30
  * @property {string} [deltaLabel] What the change is measured against.
30
31
  */
@@ -36,6 +37,8 @@ import { skeleton } from '../skeleton/skeleton.js';
36
37
  * @property {StatTileFormat} [format='number'] How a numeric value is rendered: a named mode, a
37
38
  * callback receiving the raw value, or null to print the value unformatted.
38
39
  * @property {string|null} [currency=null] ISO 4217 code used by `format: 'currency'`.
40
+ * @property {string} [caption=''] A muted context line under the value — what the number is out
41
+ * of or measured in, e.g. `'of 13,184'` or `'open tickets'`. Part of the accessible name.
39
42
  * @property {string} [locale] BCP 47 tag for every formatter; defaults to the active language.
40
43
  * @property {number|string|null} [delta=null] Change against the previous period. Rendered with
41
44
  * its own sign and an arrow, never with colour alone.
@@ -46,9 +49,10 @@ import { skeleton } from '../skeleton/skeleton.js';
46
49
  * @property {string} [deltaLabel=''] What the change is measured against, e.g. `'vs. last week'`.
47
50
  * @property {number[]} [trend=[]] Values for a decorative sparkline. Fewer than two usable numbers
48
51
  * render nothing.
49
- * @property {string|null} [icon=null] Icon name from `icons.js`, shown beside the label.
50
- * @property {StatTileKind} [kind='neutral'] Semantic intent. It tints the icon and the sparkline
51
- * and never the value, which stays at full text contrast whatever intent a record carries.
52
+ * @property {string|null} [icon=null] Icon name from `icons.js`, shown muted beside the label —
53
+ * a glyph, not a coloured chip.
54
+ * @property {StatTileKind} [kind='neutral'] Semantic intent. It tints the sparkline and never the
55
+ * value, which stays at full text contrast whatever intent a record carries.
52
56
  * @property {string|null} [href=null] Destination. A tile with an href is an `<a>`.
53
57
  * @property {boolean} [clickable=false] Renders a `<button>` without an href. Passing `onclick`
54
58
  * sets this implicitly; set it directly to subscribe with `on('click', …)` instead.
@@ -93,6 +97,7 @@ const defaults = Object.freeze({
93
97
  value: null,
94
98
  format: 'number',
95
99
  currency: null,
100
+ caption: '',
96
101
  delta: null,
97
102
  deltaKind: 'neutral',
98
103
  deltaLabel: '',
@@ -306,17 +311,19 @@ export function statTileTag(options = {}) {
306
311
  }
307
312
 
308
313
  /**
309
- * Composes the tile's accessible name: label, then value, then how it changed.
314
+ * Composes the tile's accessible name: label, then value with its caption, then how it changed.
310
315
  *
311
316
  * The sparkline is decoration and contributes nothing, and the delta arrives already worded —
312
- * `'up 2'` rather than `'+2'` — because a screen reader should not have to interpret a glyph.
317
+ * `'up 2'` rather than `'+2'` — because a screen reader should not have to interpret a glyph. The
318
+ * caption is read with the value it qualifies (`'2,184 of 13,184'`), not as a separate clause.
313
319
  *
314
320
  * @param {StatTileNameParts} [parts={}] The pieces, already formatted and translated.
315
321
  * @returns {string} The name, with empty pieces dropped rather than left as stray punctuation.
316
322
  */
317
323
  export function statTileName(parts = {}) {
324
+ const value = [parts.value, parts.caption].map(trimmed).filter(Boolean).join(' ');
318
325
  const change = [parts.delta, parts.deltaLabel].map(trimmed).filter(Boolean).join(' ');
319
- return [trimmed(parts.label), trimmed(parts.value), change].filter(Boolean).join(', ');
326
+ return [trimmed(parts.label), value, change].filter(Boolean).join(', ');
320
327
  }
321
328
 
322
329
  /**
@@ -391,10 +398,12 @@ function fillStatTile(root, options, message) {
391
398
  currency: options.currency
392
399
  });
393
400
  const deltaLabel = asText(options.deltaLabel);
401
+ const caption = asText(options.caption).trim();
394
402
 
395
403
  applyName(root, statTileName({
396
404
  label,
397
405
  value,
406
+ caption,
398
407
  delta: delta === null ? '' : spokenDelta(delta, message),
399
408
  deltaLabel
400
409
  }));
@@ -407,6 +416,7 @@ function fillStatTile(root, options, message) {
407
416
  // `Element`, not `HTMLElement`: the sparkline is an SVG element and belongs in the same list.
408
417
  /** @type {Element[]} */
409
418
  const children = [header, h('div', { class: 'zx-stat-tile__value' }, value)];
419
+ if (caption) children.push(h('div', { class: 'zx-stat-tile__caption' }, caption));
410
420
  if (delta !== null) children.push(deltaBody(delta, deltaLabel, options.deltaKind));
411
421
  const trend = trendBody(options.trend);
412
422
  if (trend) children.push(trend);
@@ -481,6 +491,7 @@ function loadingBody(options) {
481
491
  h('div', { class: 'zx-stat-tile__header' }, skeleton({ width: '55%', height: '0.75rem' })),
482
492
  skeleton({ width: '70%', height: '1.75rem', radius: 'md' })
483
493
  ];
494
+ if (asText(options.caption).trim()) blocks.push(skeleton({ width: '35%', height: '0.75rem' }));
484
495
  if (options.delta !== null && options.delta !== undefined) {
485
496
  blocks.push(skeleton({ width: '40%', height: '0.75rem' }));
486
497
  }