@zeyos/zx 4.3.4 → 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 (231) hide show
  1. package/CHANGELOG.md +438 -0
  2. package/README.md +49 -10
  3. package/dist/types/components/activity-item/activity-item.d.ts +351 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +217 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +33 -3
  6. package/dist/types/components/avatar/avatar.d.ts +46 -7
  7. package/dist/types/components/badge/badge.d.ts +21 -1
  8. package/dist/types/components/button/button.d.ts +23 -0
  9. package/dist/types/components/calendar/calendar.d.ts +19 -0
  10. package/dist/types/components/card/card.d.ts +7 -0
  11. package/dist/types/components/card-view/card-view.d.ts +11 -3
  12. package/dist/types/components/card-view/record-card.d.ts +7 -1
  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 +149 -0
  16. package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
  17. package/dist/types/components/context-menu/context-menu.d.ts +18 -2
  18. package/dist/types/components/date-picker/date-picker.d.ts +8 -0
  19. package/dist/types/components/date-picker/time-picker.d.ts +7 -0
  20. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  21. package/dist/types/components/datebox/datebox.d.ts +7 -2
  22. package/dist/types/components/dialog/dialog.d.ts +75 -6
  23. package/dist/types/components/dropdown/dropdown.d.ts +2 -2
  24. package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
  25. package/dist/types/components/field/field.d.ts +24 -2
  26. package/dist/types/components/fieldset/fieldset.d.ts +19 -1
  27. package/dist/types/components/file-item/file-item.d.ts +262 -0
  28. package/dist/types/components/file-list/file-list.d.ts +171 -0
  29. package/dist/types/components/filter/filter.d.ts +91 -36
  30. package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
  31. package/dist/types/components/form/form.d.ts +12 -1
  32. package/dist/types/components/kanban-view/kanban-card.d.ts +146 -0
  33. package/dist/types/components/kanban-view/kanban-view.d.ts +170 -4
  34. package/dist/types/components/launcher/launcher.d.ts +278 -26
  35. package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
  36. package/dist/types/components/menu-button/menu-button.d.ts +37 -42
  37. package/dist/types/components/message/message.d.ts +126 -17
  38. package/dist/types/components/modal/modal.d.ts +61 -2
  39. package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
  40. package/dist/types/components/panel/panel.d.ts +7 -0
  41. package/dist/types/components/search/search.d.ts +7 -0
  42. package/dist/types/components/select/color.d.ts +12 -0
  43. package/dist/types/components/select/glyphs.d.ts +124 -0
  44. package/dist/types/components/select/select.d.ts +15 -3
  45. package/dist/types/components/sort-control/sort-control.d.ts +105 -0
  46. package/dist/types/components/stat-tile/stat-tile.d.ts +288 -0
  47. package/dist/types/components/tabbox/tabbox.d.ts +16 -0
  48. package/dist/types/components/table/table.d.ts +190 -1
  49. package/dist/types/components/table-view/table-view.d.ts +11 -5
  50. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  51. package/dist/types/components/timebox/timebox.d.ts +7 -0
  52. package/dist/types/components/tree/tree.d.ts +13 -0
  53. package/dist/types/components/value-list/value-list.d.ts +8 -0
  54. package/dist/types/components/view/record-view.d.ts +96 -5
  55. package/dist/types/core/breakpoint.d.ts +1 -1
  56. package/dist/types/core/icons.d.ts +37 -22
  57. package/dist/types/core/motion.d.ts +59 -0
  58. package/dist/types/core/overlay-host.d.ts +15 -1
  59. package/dist/types/index.d.ts +16 -3
  60. package/dist/types/internal/app-rail.d.ts +10 -0
  61. package/dist/types/internal/menu-items.d.ts +134 -0
  62. package/dist/types/zeyos/connect.d.ts +6 -0
  63. package/dist/types/zeyos/form.d.ts +50 -9
  64. package/dist/types/zeyos/index.d.ts +1 -1
  65. package/dist/types/zeyos/query.d.ts +79 -0
  66. package/dist/types/zeyos/schema.d.ts +24 -1
  67. package/dist/types/zeyos/table.d.ts +100 -14
  68. package/dist/types/zeyos/view.d.ts +10 -0
  69. package/dist/zx-zeyos.esm.js +2206 -489
  70. package/dist/zx-zeyos.esm.min.js +56 -1
  71. package/dist/zx.css +3322 -724
  72. package/dist/zx.esm.js +12244 -5809
  73. package/dist/zx.esm.min.js +58 -3
  74. package/dist/zx.global.js +12246 -5811
  75. package/dist/zx.global.min.js +58 -3
  76. package/dist/zx.min.css +1 -1
  77. package/docs/api.json +1683 -146
  78. package/docs/llms.md +777 -110
  79. package/docs/llms.txt +14 -8
  80. package/package.json +1 -1
  81. package/src/components/account-menu/account-menu.css +35 -10
  82. package/src/components/activity-item/activity-item.css +137 -0
  83. package/src/components/activity-item/activity-item.js +602 -0
  84. package/src/components/activity-list/activity-list.css +99 -0
  85. package/src/components/activity-list/activity-list.js +409 -0
  86. package/src/components/app-icon/app-icon.css +1 -1
  87. package/src/components/app-rail/app-rail.css +191 -22
  88. package/src/components/app-sidebar/app-sidebar.css +349 -24
  89. package/src/components/app-sidebar/app-sidebar.js +86 -11
  90. package/src/components/aurora/aurora.css +5 -3
  91. package/src/components/avatar/avatar.css +32 -7
  92. package/src/components/avatar/avatar.js +72 -8
  93. package/src/components/badge/badge.css +54 -2
  94. package/src/components/badge/badge.js +29 -2
  95. package/src/components/button/button.css +74 -8
  96. package/src/components/button/button.js +44 -1
  97. package/src/components/calendar/calendar.css +46 -46
  98. package/src/components/calendar/calendar.js +33 -3
  99. package/src/components/card/card.css +2 -2
  100. package/src/components/card/card.js +12 -1
  101. package/src/components/card-view/card-view.css +10 -7
  102. package/src/components/card-view/card-view.js +25 -9
  103. package/src/components/card-view/record-card.js +7 -3
  104. package/src/components/chart/chart.css +4 -1
  105. package/src/components/chart/chart.js +12 -0
  106. package/src/components/check-button/check-button.css +10 -2
  107. package/src/components/checklist/checklist.css +9 -2
  108. package/src/components/checklist/checklist.js +11 -5
  109. package/src/components/checklist/field-adapter.js +3 -1
  110. package/src/components/code-editor/code-editor.css +68 -0
  111. package/src/components/code-editor/code-editor.js +306 -0
  112. package/src/components/code-editor/field-adapter.js +34 -0
  113. package/src/components/context-menu/context-menu.css +87 -5
  114. package/src/components/context-menu/context-menu.js +22 -30
  115. package/src/components/copy/copy-button.css +6 -0
  116. package/src/components/copy/copy-input.css +8 -3
  117. package/src/components/data-filter/data-filter.css +5 -4
  118. package/src/components/date-picker/date-picker.css +6 -6
  119. package/src/components/date-picker/date-picker.js +22 -8
  120. package/src/components/date-picker/date-range-picker.css +3 -3
  121. package/src/components/date-picker/month-picker.js +3 -3
  122. package/src/components/date-picker/time-picker.css +1 -1
  123. package/src/components/date-picker/time-picker.js +20 -8
  124. package/src/components/datebox/date-range-box.css +79 -2
  125. package/src/components/datebox/date-range-box.js +13 -2
  126. package/src/components/datebox/datebox.css +79 -2
  127. package/src/components/datebox/datebox.js +13 -2
  128. package/src/components/dialog/dialog.css +26 -0
  129. package/src/components/dialog/dialog.js +174 -70
  130. package/src/components/dropdown/dropdown.css +1 -1
  131. package/src/components/dropdown/dropdown.js +7 -5
  132. package/src/components/empty-state/empty-state.css +2 -2
  133. package/src/components/entity-ref/entity-ref.css +182 -0
  134. package/src/components/entity-ref/entity-ref.js +496 -0
  135. package/src/components/field/field.css +28 -9
  136. package/src/components/field/field.js +68 -26
  137. package/src/components/field-upload/field-upload.css +1 -1
  138. package/src/components/fieldset/fieldset.css +5 -3
  139. package/src/components/fieldset/fieldset.js +28 -5
  140. package/src/components/file-item/file-item.css +88 -0
  141. package/src/components/file-item/file-item.js +505 -0
  142. package/src/components/file-list/file-list.css +51 -0
  143. package/src/components/file-list/file-list.js +320 -0
  144. package/src/components/filter/filter.css +27 -1
  145. package/src/components/filter/filter.js +205 -47
  146. package/src/components/filter-panel/filter-panel.css +104 -0
  147. package/src/components/filter-panel/filter-panel.js +964 -0
  148. package/src/components/finder/finder.css +15 -13
  149. package/src/components/form/form.css +5 -4
  150. package/src/components/form/form.js +11 -2
  151. package/src/components/grid/grid.css +1 -1
  152. package/src/components/groupbox/groupbox.css +1 -1
  153. package/src/components/kanban-view/kanban-card.js +247 -0
  154. package/src/components/kanban-view/kanban-view.css +226 -8
  155. package/src/components/kanban-view/kanban-view.js +255 -12
  156. package/src/components/launcher/launcher.css +107 -25
  157. package/src/components/launcher/launcher.js +557 -63
  158. package/src/components/list-toolbar/list-toolbar.css +194 -0
  159. package/src/components/list-toolbar/list-toolbar.js +851 -0
  160. package/src/components/master-panel/master-panel.css +2 -0
  161. package/src/components/menu-button/menu-button.css +87 -5
  162. package/src/components/menu-button/menu-button.js +86 -46
  163. package/src/components/message/message.css +38 -5
  164. package/src/components/message/message.js +230 -17
  165. package/src/components/modal/modal.css +15 -0
  166. package/src/components/modal/modal.js +159 -3
  167. package/src/components/navigation-bar/navigation-bar.css +95 -11
  168. package/src/components/navigation-bar/navigation-bar.js +171 -4
  169. package/src/components/number-field/field-adapter.js +1 -0
  170. package/src/components/number-field/number-field.css +70 -0
  171. package/src/components/number-field/number-field.js +2 -0
  172. package/src/components/pagination/pagination.css +8 -6
  173. package/src/components/panel/panel.css +1 -1
  174. package/src/components/panel/panel.js +13 -2
  175. package/src/components/rating/rating.css +7 -0
  176. package/src/components/search/search.js +17 -5
  177. package/src/components/select/color.js +22 -0
  178. package/src/components/select/field-adapter.js +6 -1
  179. package/src/components/select/glyphs.js +165 -0
  180. package/src/components/select/select.css +138 -45
  181. package/src/components/select/select.js +86 -47
  182. package/src/components/sheet/sheet.css +11 -8
  183. package/src/components/slider/slider.css +16 -2
  184. package/src/components/slider/slider.js +1 -1
  185. package/src/components/sort-control/sort-control.css +20 -0
  186. package/src/components/sort-control/sort-control.js +140 -0
  187. package/src/components/stat-tile/stat-tile.css +208 -0
  188. package/src/components/stat-tile/stat-tile.js +601 -0
  189. package/src/components/tabbox/tabbox.css +32 -27
  190. package/src/components/tabbox/tabbox.js +17 -2
  191. package/src/components/table/table.css +155 -31
  192. package/src/components/table/table.js +386 -33
  193. package/src/components/table-view/table-view.css +8 -2
  194. package/src/components/table-view/table-view.js +21 -6
  195. package/src/components/tag-picker/tag-picker.css +86 -6
  196. package/src/components/tag-picker/tag-picker.js +25 -8
  197. package/src/components/timebox/timebox.css +22 -2
  198. package/src/components/timebox/timebox.js +18 -5
  199. package/src/components/toggle/toggle.css +12 -1
  200. package/src/components/toolbar/toolbar.js +2 -2
  201. package/src/components/tooltip/tooltip.css +4 -0
  202. package/src/components/tooltip/tooltip.js +10 -3
  203. package/src/components/tree/tree.css +25 -16
  204. package/src/components/tree/tree.js +31 -0
  205. package/src/components/value-list/value-list.css +3 -3
  206. package/src/components/value-list/value-list.js +22 -6
  207. package/src/components/view/record-view.css +5 -0
  208. package/src/components/view/record-view.js +100 -7
  209. package/src/core/breakpoint.js +1 -1
  210. package/src/core/icons.js +259 -57
  211. package/src/core/motion.js +147 -0
  212. package/src/core/overlay-host.js +21 -2
  213. package/src/index.js +30 -3
  214. package/src/internal/app-rail.js +18 -1
  215. package/src/internal/menu-items.js +142 -0
  216. package/src/zeyos/connect.js +6 -2
  217. package/src/zeyos/form.js +67 -27
  218. package/src/zeyos/index.js +1 -1
  219. package/src/zeyos/query.js +153 -0
  220. package/src/zeyos/schema.js +144 -27
  221. package/src/zeyos/table.js +68 -5
  222. package/src/zeyos/view.js +6 -0
  223. package/styles/base.css +56 -0
  224. package/styles/icons.css +11 -1
  225. package/styles/print.css +192 -0
  226. package/styles/tokens/dark.css +113 -37
  227. package/styles/tokens/global.css +121 -27
  228. package/styles/tokens/semantic.css +100 -31
  229. package/styles/tokens/themes.css +7 -6
  230. package/styles/zx.css +11 -1
  231. package/styles/tokens/density.css +0 -9
@@ -0,0 +1,601 @@
1
+ // @ts-check
2
+ import { Component } from '../../core/component.js';
3
+ import { h, restoreTarget, safeHref, snapshotTarget } from '../../core/dom.js';
4
+ import { formatCurrency, formatFileSize, formatNumber, formatPercent } from '../../core/format.js';
5
+ import { printf } from '../../core/i18n.js';
6
+ import { icon as createIcon } from '../../core/icons.js';
7
+ import { clamp } from '../../core/util.js';
8
+ import { skeleton } from '../skeleton/skeleton.js';
9
+
10
+ /** @typedef {'number'|'currency'|'percent'|'fileSize'|null} StatTileFormatName */
11
+ /** @typedef {StatTileFormatName|((value: number|string|null|undefined) => string)} StatTileFormat */
12
+ /** @typedef {'neutral'|'accent'|'success'|'warning'|'danger'|'info'} StatTileKind */
13
+ /** @typedef {'positive'|'negative'|'neutral'} StatTileDeltaKind */
14
+ /** @typedef {'up'|'down'|'flat'} StatTileDirection */
15
+
16
+ /**
17
+ * @typedef {Object} StatTileDelta
18
+ * @property {StatTileDirection} direction Which way the metric moved, read from the sign alone.
19
+ * @property {string} sign `'+'`, `'−'` (U+2212), or `''` for an unchanged metric.
20
+ * @property {string} magnitude The formatted absolute change, without a sign.
21
+ * @property {string} text The visible change: `sign` followed by `magnitude`.
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} StatTileNameParts
26
+ * @property {string} [label] The metric's name.
27
+ * @property {string} [value] The formatted value.
28
+ * @property {string} [caption] The context line under the value, e.g. `'of 13,184'`.
29
+ * @property {string} [delta] The change, already worded for speech (`'up 2'`, not `'+2'`).
30
+ * @property {string} [deltaLabel] What the change is measured against.
31
+ */
32
+
33
+ /**
34
+ * @typedef {Object} StatTileOptions
35
+ * @property {string} [label=''] The metric's name, set small above the value.
36
+ * @property {number|string|null} [value=null] The metric. A string is rendered as given.
37
+ * @property {StatTileFormat} [format='number'] How a numeric value is rendered: a named mode, a
38
+ * callback receiving the raw value, or null to print the value unformatted.
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.
42
+ * @property {string} [locale] BCP 47 tag for every formatter; defaults to the active language.
43
+ * @property {number|string|null} [delta=null] Change against the previous period. Rendered with
44
+ * its own sign and an arrow, never with colour alone.
45
+ * @property {StatTileDeltaKind} [deltaKind='neutral'] Whether the change is good, bad, or merely a
46
+ * change. This — not the sign's direction — picks the delta colour: a rising ticket count is not
47
+ * a success, so "up is good" is never assumed.
48
+ * @property {StatTileFormat} [deltaFormat] How the delta is rendered; defaults to `format`.
49
+ * @property {string} [deltaLabel=''] What the change is measured against, e.g. `'vs. last week'`.
50
+ * @property {number[]} [trend=[]] Values for a decorative sparkline. Fewer than two usable numbers
51
+ * render nothing.
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.
56
+ * @property {string|null} [href=null] Destination. A tile with an href is an `<a>`.
57
+ * @property {boolean} [clickable=false] Renders a `<button>` without an href. Passing `onclick`
58
+ * sets this implicitly; set it directly to subscribe with `on('click', …)` instead.
59
+ * @property {boolean} [loading=false] Renders a skeleton shaped like the tile.
60
+ * @property {Record<string, unknown>} [msg] Message overrides, keyed as `statTile.*`.
61
+ * @property {(event: CustomEvent<{event: MouseEvent}>) => void} [onclick] Activation listener; its
62
+ * presence is what turns a tile with no href into a button. It is the tile's `click` event, as
63
+ * every Zx component's `on*` option is, so it receives the `CustomEvent` carrying the native
64
+ * event in `detail.event`.
65
+ */
66
+
67
+ /** Semantic intents, in the order they are documented. */
68
+ const KINDS = Object.freeze(['neutral', 'accent', 'success', 'warning', 'danger', 'info']);
69
+
70
+ /** Delta intents. The sign says which way; this says whether that is good news. */
71
+ const DELTA_KINDS = Object.freeze(['positive', 'negative', 'neutral']);
72
+
73
+ /**
74
+ * The built-in English text behind every `statTile.*` key. Kept as one table rather than repeated
75
+ * at each call site so the fallbacks cannot drift from each other, and so a host translating the
76
+ * component has the whole set in front of it.
77
+ */
78
+ const MESSAGES = Object.freeze({
79
+ 'statTile.up': 'up %1',
80
+ 'statTile.down': 'down %1',
81
+ 'statTile.unchanged': 'no change',
82
+ 'statTile.loading': 'Loading'
83
+ });
84
+
85
+ /** Arrow glyph per direction. A delta is never colour alone, so the shape carries it too. */
86
+ const DIRECTION_ICONS = Object.freeze({ up: 'chevron-up', down: 'chevron-down', flat: 'minus' });
87
+
88
+ /** The sparkline's user-unit box. It is stretched to the rendered width by the viewBox. */
89
+ const TREND_WIDTH = 100;
90
+ const TREND_HEIGHT = 30;
91
+ /** Half the stroke plus a little, so the extreme points are not clipped by the box. */
92
+ const TREND_INSET = 2;
93
+
94
+ /** @type {Readonly<StatTileOptions>} */
95
+ const defaults = Object.freeze({
96
+ label: '',
97
+ value: null,
98
+ format: 'number',
99
+ currency: null,
100
+ caption: '',
101
+ delta: null,
102
+ deltaKind: 'neutral',
103
+ deltaLabel: '',
104
+ trend: [],
105
+ icon: null,
106
+ kind: 'neutral',
107
+ href: null,
108
+ clickable: false,
109
+ loading: false
110
+ });
111
+
112
+ const SVG_NS = 'http://www.w3.org/2000/svg';
113
+
114
+ /**
115
+ * Renders a metric: a label, the number it names, and — optionally — how that number moved.
116
+ *
117
+ * The value is the loudest thing on the tile; the label sits small above it and the delta and the
118
+ * sparkline are subordinate to both. Nothing here assumes a fixed set of metrics: the label, the
119
+ * intent colour, the icon and the formatting all arrive per instance, so a tile whose content
120
+ * comes from a server is the normal case rather than the exception.
121
+ *
122
+ * The element shape follows the tile's behaviour, and a tag cannot be rewritten in place, so the
123
+ * component normally builds its own root — pass `null` as the target, or use {@link statTile}. An
124
+ * existing element is adopted only when its tag already is the one the options call for; a mismatch
125
+ * throws rather than silently shipping a `<div>` with a click handler or a link that cannot
126
+ * navigate.
127
+ *
128
+ * @fires StatTile#click
129
+ * @extends {Component<StatTileOptions>}
130
+ */
131
+ export class StatTile extends Component {
132
+ static cssName = 'stat-tile';
133
+
134
+ /** @type {Readonly<StatTileOptions>} */
135
+ static defaults = defaults;
136
+
137
+ /**
138
+ * Creates or adopts one metric tile.
139
+ * @param {Element|string|null} [target=null] Existing element of the required tag, or null.
140
+ * @param {StatTileOptions} [options={}] Tile options.
141
+ */
142
+ constructor(target = null, options = {}) {
143
+ // `Component` consumes every function-valued `on*` option into a component event listener, so
144
+ // `options.onclick` no longer exists by the time `render()` chooses the element's tag. The
145
+ // shape decision is taken here, before the base constructor, and carried across as a flag.
146
+ super(target, {
147
+ ...options,
148
+ clickable: options?.clickable === true || typeof options?.onclick === 'function'
149
+ });
150
+ }
151
+
152
+ /**
153
+ * @returns {HTMLElement}
154
+ * @throws {TypeError} When an adopted target is not the tag the options require.
155
+ */
156
+ render() {
157
+ const tag = statTileTag(this.options);
158
+ this._createdRoot = this.el === null;
159
+ if (!this._createdRoot && this.el.tagName.toLowerCase() !== tag) {
160
+ throw new TypeError(
161
+ `StatTile needs a <${tag}> for these options, but the target is a `
162
+ + `<${this.el.tagName.toLowerCase()}>. Pass null as the target to let it build its own.`);
163
+ }
164
+ const root = /** @type {HTMLElement} */ (this.el ?? h(tag));
165
+ this.el = root;
166
+ this._snapshot = this._createdRoot ? null : snapshotTarget(root);
167
+ this._closed = false;
168
+ this._state = { ...this.options };
169
+
170
+ if (tag === 'button') /** @type {HTMLButtonElement} */ (root).type = 'button';
171
+ fillStatTile(root, this._state, (key, ...args) => this._message(key, ...args));
172
+ if (tag !== 'div') {
173
+ this.listen(root, 'click', (event) => {
174
+ this.emit('click', { event: /** @type {MouseEvent} */ (event) });
175
+ });
176
+ }
177
+ return root;
178
+ }
179
+
180
+ /**
181
+ * Replaces any subset of the tile's content and redraws it. This is how a tile leaves
182
+ * `loading: true` behind once the server answers.
183
+ * @param {StatTileOptions} options Options to merge over the current ones.
184
+ * @returns {this}
185
+ * @throws {TypeError} When the merged options would need a different element tag.
186
+ */
187
+ update(options = {}) {
188
+ const next = { ...this._state, ...options };
189
+ const tag = this.el.tagName.toLowerCase();
190
+ if (statTileTag(next) !== tag) {
191
+ throw new TypeError(
192
+ `StatTile cannot change from <${tag}> to <${statTileTag(next)}> in place: href and `
193
+ + 'clickable decide the element, and an element\'s tag is fixed once it exists.');
194
+ }
195
+ this._state = next;
196
+ fillStatTile(/** @type {HTMLElement} */ (this.el), next, (key, ...args) => this._message(key, ...args));
197
+ return this;
198
+ }
199
+
200
+ /**
201
+ * Reports the tile's own accessible name, which is what assistive technology announces.
202
+ * @returns {string} Label, value, and delta, in that order.
203
+ */
204
+ getAccessibleName() {
205
+ return this.el.getAttribute('aria-label') ?? '';
206
+ }
207
+
208
+ /**
209
+ * Aborts listeners and restores an adopted target to the state it was found in.
210
+ * @returns {void}
211
+ */
212
+ destroy() {
213
+ if (this._closed) return;
214
+ this._closed = true;
215
+ const root = this.el;
216
+ super.destroy();
217
+ if (!this._createdRoot && root) restoreTarget(root, this._snapshot);
218
+ }
219
+
220
+ /**
221
+ * Resolves a message through the host translator, falling back to the built-in English text.
222
+ * @param {string} key Message key.
223
+ * @param {...unknown} args Interpolation values.
224
+ * @returns {string}
225
+ */
226
+ _message(key, ...args) {
227
+ const resolved = this.msg(key, ...args);
228
+ return resolved === key ? printf(MESSAGES[key] ?? key, args) : resolved;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Creates a tile that builds its own root: the convenience form of `new StatTile(null, options)`,
234
+ * the way `tooltip()` stands in for `new Tooltip(…)`.
235
+ *
236
+ * It returns the component, not an element, so there is one construction idiom and one click path.
237
+ * Call `.toElement()` for the node to place.
238
+ *
239
+ * @param {StatTileOptions} [opts={}] Tile options.
240
+ * @returns {StatTile} The component, whose root is an `<a>`, a `<button>`, or a `<div>`.
241
+ */
242
+ export function statTile(opts = {}) {
243
+ return new StatTile(null, opts);
244
+ }
245
+
246
+ /**
247
+ * Formats the metric itself.
248
+ *
249
+ * A string value is the host's own final text and is passed through untouched; an explicit
250
+ * callback outranks that, because a host that supplies one has asked to decide every case.
251
+ *
252
+ * @param {number|string|null|undefined} value The metric.
253
+ * @param {{format?: StatTileFormat, locale?: string, currency?: string|null}} [options={}] Mode,
254
+ * language, and currency.
255
+ * @returns {string} The text to display; empty for a missing value.
256
+ */
257
+ export function formatStatValue(value, options = {}) {
258
+ const { format = 'number', locale, currency = null } = options;
259
+ if (typeof format === 'function') return asText(format(value));
260
+ if (typeof value === 'string') return value;
261
+ if (value === null || value === undefined) return '';
262
+
263
+ switch (format) {
264
+ case 'currency': return formatCurrency(value, currency, { locale });
265
+ case 'percent': return formatPercent(value, { locale });
266
+ case 'fileSize': return formatFileSize(value, { locale });
267
+ case 'number': return formatNumber(value, { locale });
268
+ default: return asText(value);
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Describes a change without judging it.
274
+ *
275
+ * The direction is read from the sign and nothing else; whether that direction is good news is
276
+ * `deltaKind`'s business, not this function's. The magnitude is formatted from the absolute value
277
+ * and the sign is prepended, because no locale prints a leading `+` on its own.
278
+ *
279
+ * @param {number|string|null|undefined} delta The change.
280
+ * @param {{format?: StatTileFormat, locale?: string, currency?: string|null}} [options={}]
281
+ * Formatting, defaulting to the tile's own.
282
+ * @returns {StatTileDelta|null} The change, or null when there is none to show.
283
+ */
284
+ export function statDelta(delta, options = {}) {
285
+ const number = finite(delta);
286
+ if (number === null) return null;
287
+
288
+ const direction = number > 0 ? 'up' : number < 0 ? 'down' : 'flat';
289
+ // U+2212 MINUS SIGN rather than a hyphen: it is the same width as the digits beside it, which
290
+ // keeps a column of tiles aligned. The accessible name never uses it — it says "down" instead.
291
+ const sign = direction === 'up' ? '+' : direction === 'down' ? '−' : '';
292
+ const magnitude = formatStatValue(Math.abs(number), options);
293
+ return { direction, sign, magnitude, text: `${sign}${magnitude}` };
294
+ }
295
+
296
+ /**
297
+ * Chooses the element a tile must be, from what the tile does.
298
+ *
299
+ * A destination is a link, an action is a button, and a tile that does neither is a plain
300
+ * container. A `<div>` never gets a click handler, and an href that could execute script is not a
301
+ * destination at all — it falls through to whichever shape the remaining options justify.
302
+ *
303
+ * @param {{href?: string|null, clickable?: boolean, onclick?: unknown}} [options={}] The options
304
+ * that carry behaviour.
305
+ * @returns {'a'|'button'|'div'} The tag to render.
306
+ */
307
+ export function statTileTag(options = {}) {
308
+ if (safeHref(options.href)) return 'a';
309
+ if (options.clickable === true || typeof options.onclick === 'function') return 'button';
310
+ return 'div';
311
+ }
312
+
313
+ /**
314
+ * Composes the tile's accessible name: label, then value with its caption, then how it changed.
315
+ *
316
+ * The sparkline is decoration and contributes nothing, and the delta arrives already worded —
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.
319
+ *
320
+ * @param {StatTileNameParts} [parts={}] The pieces, already formatted and translated.
321
+ * @returns {string} The name, with empty pieces dropped rather than left as stray punctuation.
322
+ */
323
+ export function statTileName(parts = {}) {
324
+ const value = [parts.value, parts.caption].map(trimmed).filter(Boolean).join(' ');
325
+ const change = [parts.delta, parts.deltaLabel].map(trimmed).filter(Boolean).join(' ');
326
+ return [trimmed(parts.label), value, change].filter(Boolean).join(', ');
327
+ }
328
+
329
+ /**
330
+ * Projects a series onto the sparkline's user-unit box.
331
+ *
332
+ * A series needs two usable numbers to be a line; a flat series is drawn down the middle rather
333
+ * than along the floor, which would read as zero.
334
+ *
335
+ * @param {unknown[]} values The series, in order. Unusable entries are dropped.
336
+ * @param {{width?: number, height?: number, inset?: number}} [box={}] The target box.
337
+ * @returns {string|null} An SVG `points` list, or null when there is no line to draw.
338
+ */
339
+ export function statTrendPoints(values, box = {}) {
340
+ const { width = TREND_WIDTH, height = TREND_HEIGHT, inset = TREND_INSET } = box;
341
+ const series = /** @type {number[]} */ ((Array.isArray(values) ? values : [])
342
+ .map(finite)
343
+ .filter((entry) => entry !== null));
344
+ if (series.length < 2) return null;
345
+
346
+ const low = Math.min(...series);
347
+ const high = Math.max(...series);
348
+ const top = inset;
349
+ const bottom = Math.max(inset, height - inset);
350
+ const span = high - low;
351
+ const step = width / (series.length - 1);
352
+
353
+ return series.map((entry, index) => {
354
+ const x = index === series.length - 1 ? width : index * step;
355
+ const y = span === 0 ? (top + bottom) / 2 : bottom - ((entry - low) / span) * (bottom - top);
356
+ return `${round(x)},${round(y)}`;
357
+ }).join(' ');
358
+ }
359
+
360
+ /**
361
+ * Builds the tile's children into an element that already has the right tag, and (re)applies every
362
+ * attribute the tile owns. Shared by the factory and the component so there is one tile, not two.
363
+ * @param {HTMLElement} root The tile's root.
364
+ * @param {StatTileOptions} options Fully merged options.
365
+ * @param {(key: string, ...args: unknown[]) => string} message Message resolver.
366
+ * @returns {void}
367
+ */
368
+ function fillStatTile(root, options, message) {
369
+ const kind = normalize(options.kind, KINDS, 'neutral');
370
+ const label = asText(options.label);
371
+ const loading = options.loading === true;
372
+
373
+ // `add` rather than an assignment to `className`: an adopted target keeps the classes its host
374
+ // put on it, and a redraw through `update()` does not quietly strip them.
375
+ root.classList.add('zx-stat-tile');
376
+ root.dataset.kind = kind;
377
+ root.dataset.loading = String(loading);
378
+ if (root.tagName.toLowerCase() === 'a') {
379
+ // `setAttribute` rather than the reflected property, so `update()` can also take a destination
380
+ // away again — assigning `''` to `.href` leaves a link pointing at the current page.
381
+ const href = safeHref(options.href);
382
+ if (href) root.setAttribute('href', href);
383
+ else root.removeAttribute('href');
384
+ }
385
+
386
+ if (loading) {
387
+ root.setAttribute('aria-busy', 'true');
388
+ applyName(root, statTileName({ label, value: message('statTile.loading') }));
389
+ root.replaceChildren(loadingBody(options));
390
+ return;
391
+ }
392
+
393
+ root.removeAttribute('aria-busy');
394
+ const value = formatStatValue(options.value, options);
395
+ const delta = statDelta(options.delta, {
396
+ format: options.deltaFormat ?? options.format,
397
+ locale: options.locale,
398
+ currency: options.currency
399
+ });
400
+ const deltaLabel = asText(options.deltaLabel);
401
+ const caption = asText(options.caption).trim();
402
+
403
+ applyName(root, statTileName({
404
+ label,
405
+ value,
406
+ caption,
407
+ delta: delta === null ? '' : spokenDelta(delta, message),
408
+ deltaLabel
409
+ }));
410
+
411
+ const header = h('div', { class: 'zx-stat-tile__header' },
412
+ h('span', { class: 'zx-stat-tile__label' }, label));
413
+ const glyph = tileIcon(options.icon);
414
+ if (glyph) header.append(h('span', { class: 'zx-stat-tile__icon' }, glyph));
415
+
416
+ // `Element`, not `HTMLElement`: the sparkline is an SVG element and belongs in the same list.
417
+ /** @type {Element[]} */
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));
420
+ if (delta !== null) children.push(deltaBody(delta, deltaLabel, options.deltaKind));
421
+ const trend = trendBody(options.trend);
422
+ if (trend) children.push(trend);
423
+ root.replaceChildren(...children);
424
+ }
425
+
426
+ /**
427
+ * Builds the delta row: sign, arrow, and what the change is measured against.
428
+ * @param {StatTileDelta} delta The change.
429
+ * @param {string} deltaLabel Comparison text.
430
+ * @param {StatTileDeltaKind|undefined} deltaKind Whether the change is good news.
431
+ * @returns {HTMLElement}
432
+ */
433
+ function deltaBody(delta, deltaLabel, deltaKind) {
434
+ // The whole row is hidden from assistive technology: the root's own name already carries the
435
+ // change, worded, and reading "+2" after "up 2" would say the same thing twice.
436
+ const row = h('div', {
437
+ class: 'zx-stat-tile__delta',
438
+ ariaHidden: 'true',
439
+ dataset: {
440
+ kind: normalize(deltaKind, DELTA_KINDS, 'neutral'),
441
+ direction: delta.direction
442
+ }
443
+ }, createIcon(DIRECTION_ICONS[delta.direction], { size: 12 }),
444
+ h('span', { class: 'zx-stat-tile__delta-value' }, delta.text));
445
+
446
+ if (deltaLabel) row.append(h('span', { class: 'zx-stat-tile__delta-label' }, deltaLabel));
447
+ return row;
448
+ }
449
+
450
+ /**
451
+ * Builds the sparkline, or nothing when the series cannot make a line.
452
+ *
453
+ * Inline SVG rather than `icon()`, because this is data and not a glyph — and `aria-hidden`,
454
+ * because a line nobody can read is decoration and has to say so.
455
+ * @param {unknown} values The series.
456
+ * @returns {SVGSVGElement|null}
457
+ */
458
+ function trendBody(values) {
459
+ const points = statTrendPoints(/** @type {unknown[]} */ (values));
460
+ if (points === null) return null;
461
+
462
+ const svg = document.createElementNS(SVG_NS, 'svg');
463
+ svg.setAttribute('class', 'zx-stat-tile__trend');
464
+ svg.setAttribute('viewBox', `0 0 ${TREND_WIDTH} ${TREND_HEIGHT}`);
465
+ // The line stretches to whatever width the tile has; `non-scaling-stroke` below keeps that
466
+ // stretch from thinning the stroke with it.
467
+ svg.setAttribute('preserveAspectRatio', 'none');
468
+ svg.setAttribute('aria-hidden', 'true');
469
+ svg.setAttribute('focusable', 'false');
470
+
471
+ const line = document.createElementNS(SVG_NS, 'polyline');
472
+ line.setAttribute('points', points);
473
+ line.setAttribute('fill', 'none');
474
+ line.setAttribute('stroke', 'currentColor');
475
+ line.setAttribute('stroke-width', '1.5');
476
+ line.setAttribute('stroke-linecap', 'round');
477
+ line.setAttribute('stroke-linejoin', 'round');
478
+ line.setAttribute('vector-effect', 'non-scaling-stroke');
479
+ svg.append(line);
480
+ return svg;
481
+ }
482
+
483
+ /**
484
+ * Builds the placeholder: the tile's own shape in grey, not a spinner, so nothing moves when the
485
+ * numbers arrive.
486
+ * @param {StatTileOptions} options Fully merged options.
487
+ * @returns {HTMLElement}
488
+ */
489
+ function loadingBody(options) {
490
+ const blocks = [
491
+ h('div', { class: 'zx-stat-tile__header' }, skeleton({ width: '55%', height: '0.75rem' })),
492
+ skeleton({ width: '70%', height: '1.75rem', radius: 'md' })
493
+ ];
494
+ if (asText(options.caption).trim()) blocks.push(skeleton({ width: '35%', height: '0.75rem' }));
495
+ if (options.delta !== null && options.delta !== undefined) {
496
+ blocks.push(skeleton({ width: '40%', height: '0.75rem' }));
497
+ }
498
+ if (statTrendPoints(/** @type {unknown[]} */ (options.trend)) !== null) {
499
+ blocks.push(skeleton({ width: '100%', height: `${TREND_HEIGHT}px`, radius: 'md' }));
500
+ }
501
+ return h('div', { class: 'zx-stat-tile__loading' }, ...blocks);
502
+ }
503
+
504
+ /**
505
+ * Renders the tile's icon.
506
+ *
507
+ * The icon travels with the metric, which means it comes from a server: a name the built-in set
508
+ * does not carry is bad data on one tile, not a reason for the dashboard around it to stop
509
+ * rendering.
510
+ *
511
+ * This used to wrap `icon()` in a `try`/`catch`, because `icon()` threw `RangeError` on a name it
512
+ * could not resolve. It no longer does — it renders an empty placeholder of the right size and
513
+ * reports the name once — so the guard is gone with it. The difference on screen is that the tile
514
+ * now keeps the icon's 16px box instead of closing the gap, which is the better of the two:
515
+ * a row of tiles stays aligned when one of them has a name nobody configured.
516
+ * @param {unknown} name Icon name.
517
+ * @returns {Element|null}
518
+ */
519
+ function tileIcon(name) {
520
+ return name ? createIcon(String(name), { size: 16 }) : null;
521
+ }
522
+
523
+ /**
524
+ * Words a change for speech, so the name says "up 2" where the tile shows "+2" and an arrow.
525
+ * @param {StatTileDelta} delta The change.
526
+ * @param {(key: string, ...args: unknown[]) => string} message Message resolver.
527
+ * @returns {string}
528
+ */
529
+ function spokenDelta(delta, message) {
530
+ if (delta.direction === 'flat') return message('statTile.unchanged');
531
+ return message(delta.direction === 'up' ? 'statTile.up' : 'statTile.down', delta.magnitude);
532
+ }
533
+
534
+ /**
535
+ * Names the tile.
536
+ *
537
+ * A link and a button take their name from `aria-label` alone. A plain tile is not a widget, so it
538
+ * needs a role before a name means anything: `group` gives it one without hiding the text inside
539
+ * it, which `img` would.
540
+ * @param {HTMLElement} root The tile's root.
541
+ * @param {string} name The composed name.
542
+ * @returns {void}
543
+ */
544
+ function applyName(root, name) {
545
+ const plain = root.tagName.toLowerCase() === 'div';
546
+ if (name) {
547
+ root.setAttribute('aria-label', name);
548
+ if (plain) root.setAttribute('role', 'group');
549
+ return;
550
+ }
551
+ // An empty tile has nothing to be named, and a named-nothing role is worse than no role.
552
+ root.removeAttribute('aria-label');
553
+ if (plain) root.removeAttribute('role');
554
+ }
555
+
556
+ /** @param {unknown} value @param {readonly string[]} allowed @param {string} fallback @returns {any} */
557
+ function normalize(value, allowed, fallback) {
558
+ return allowed.includes(/** @type {string} */ (value)) ? value : fallback;
559
+ }
560
+
561
+ /**
562
+ * Reads a usable number, or nothing.
563
+ *
564
+ * `Number()` alone would turn a gap in a series — `null`, `''`, a stray `false` — into a hard zero,
565
+ * which a sparkline then draws as a crash to the floor and a delta reports as "no change". A gap is
566
+ * absent data, not the number nought, so it is dropped instead.
567
+ * @param {unknown} value Candidate number.
568
+ * @returns {number|null}
569
+ */
570
+ function finite(value) {
571
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
572
+ // Only numbers and numeric strings are numbers. `Number()` says otherwise about `null`, `''`,
573
+ // `false` and `[]` — all of which it reads as 0 — so they never reach it.
574
+ if (typeof value !== 'string') return null;
575
+ const text = value.trim();
576
+ if (text === '') return null;
577
+ const number = Number(text);
578
+ return Number.isFinite(number) ? number : null;
579
+ }
580
+
581
+ /** @param {unknown} value @returns {string} */
582
+ function asText(value) {
583
+ return value === null || value === undefined ? '' : String(value);
584
+ }
585
+
586
+ /** @param {unknown} value @returns {string} */
587
+ function trimmed(value) {
588
+ return asText(value).trim();
589
+ }
590
+
591
+ /** @param {number} value @returns {number} Two decimals, which is finer than one device pixel here. */
592
+ function round(value) {
593
+ return Math.round(clamp(value, -1e6, 1e6) * 100) / 100;
594
+ }
595
+
596
+ /**
597
+ * Fired when a linked or clickable tile is activated. A tile with neither an href nor a click
598
+ * handler is a `<div>` and never emits it.
599
+ * @event StatTile#click
600
+ * @type {CustomEvent<{event: MouseEvent}>}
601
+ */