@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
@@ -1,20 +1,44 @@
1
1
  import { Component } from '../../core/component.js';
2
2
  import { h } from '../../core/dom.js';
3
3
 
4
+ /**
5
+ * A categorical tone from the design system's tone palette (`--zx-color-tone-*`).
6
+ * @typedef {'blue'|'teal'|'green'|'amber'|'orange'|'red'|'pink'|'violet'|'slate'} AvatarTone
7
+ */
8
+
4
9
  /**
5
10
  * @typedef {Object} AvatarOptions
6
11
  * @property {string|null} [src=null] Image URL.
7
- * @property {string} [name=''] Name used to derive fallback initials.
12
+ * @property {string} [name=''] Name used to derive the fallback initials and tone.
8
13
  * @property {string|null} [initials=null] Explicit fallback initials.
9
14
  * @property {string|null} [label=null] Accessible label; null makes the avatar decorative.
10
- * @property {string|number} [size='md'] Preset (`sm`, `md`, `lg`) or pixel size.
15
+ * @property {string|number} [size='md'] Preset — `sm` 24px, `md` 32px, `lg` 40px at the default
16
+ * spacing unit — or a pixel size. Below 24px the fallback shows a single initial.
11
17
  * @property {'circle'|'rounded'|'square'} [shape='circle'] Avatar shape.
18
+ * @property {AvatarTone|null} [tone=null] Fallback colour from the tone palette. `null` derives a
19
+ * stable tone from the name (or the initials when there is no name), so the same person gets the
20
+ * same colour on every screen; see {@link avatarTone}.
12
21
  * @property {'online'|'away'|'busy'|'offline'|null} [status=null] Optional presence state.
13
22
  * @property {string|null} [statusLabel=null] Text alternative for the presence state.
14
23
  */
15
24
 
16
25
  /**
17
- * Stable-size user image with deterministic initials fallback and optional presence state.
26
+ * The tone palette, in hash order. Every entry is a valid `tone`; the hash picks among the
27
+ * chromatic ones and keeps `slate`, the neutral, for a fallback with no name behind it. Order is
28
+ * part of the contract: changing it moves every derived avatar to a different colour.
29
+ * @type {ReadonlyArray<AvatarTone>}
30
+ */
31
+ export const AVATAR_TONES = Object.freeze(['blue', 'teal', 'green', 'amber', 'orange', 'red', 'pink', 'violet', 'slate']);
32
+
33
+ /** The tones a name can hash to: the palette without its neutral. */
34
+ const HASHED_TONES = Object.freeze(AVATAR_TONES.filter((tone) => tone !== 'slate'));
35
+
36
+ /** Below this rendered size the fallback has room for one initial, not two. */
37
+ const SINGLE_INITIAL_BELOW = 24;
38
+
39
+ /**
40
+ * Stable-size user image with a deterministic fallback — initials on a tone picked from the name —
41
+ * and an optional presence state.
18
42
  * @extends {Component<AvatarOptions>}
19
43
  */
20
44
  export class Avatar extends Component {
@@ -28,6 +52,7 @@ export class Avatar extends Component {
28
52
  label: null,
29
53
  size: 'md',
30
54
  shape: 'circle',
55
+ tone: null,
31
56
  status: null,
32
57
  statusLabel: null
33
58
  };
@@ -48,6 +73,7 @@ export class Avatar extends Component {
48
73
  label: this.options.label,
49
74
  size: this.options.size,
50
75
  shape: this.options.shape,
76
+ tone: this.options.tone,
51
77
  status: this.options.status,
52
78
  statusLabel: this.options.statusLabel
53
79
  };
@@ -87,7 +113,7 @@ export class Avatar extends Component {
87
113
  */
88
114
  set(values = {}) {
89
115
  if (!values || typeof values !== 'object') return this;
90
- for (const key of ['src', 'name', 'initials', 'label', 'size', 'shape', 'status', 'statusLabel']) {
116
+ for (const key of ['src', 'name', 'initials', 'label', 'size', 'shape', 'tone', 'status', 'statusLabel']) {
91
117
  if (Object.hasOwn(values, key)) this._state[key] = values[key];
92
118
  }
93
119
  this._sync();
@@ -130,7 +156,15 @@ export class Avatar extends Component {
130
156
  this.el.style.removeProperty('--zx-avatar-size');
131
157
  }
132
158
  this._syncLabel();
133
- this.refs.fallback.textContent = String(this._state.initials || avatarInitials(this._state.name));
159
+ const name = String(this._state.name ?? '');
160
+ const limit = typeof size === 'number' && size < SINGLE_INITIAL_BELOW ? 1 : 2;
161
+ this.refs.fallback.textContent = this._state.initials
162
+ ? clipGraphemes(String(this._state.initials), limit)
163
+ : avatarInitials(name, limit);
164
+ const tone = AVATAR_TONES.includes(this._state.tone)
165
+ ? this._state.tone
166
+ : avatarTone(name.trim() ? name : this._state.initials);
167
+ this.el.dataset.tone = tone;
134
168
  const src = this._state.src == null ? '' : String(this._state.src).trim();
135
169
  if (src) {
136
170
  this.refs.image.setAttribute('src', src);
@@ -195,18 +229,48 @@ export class Avatar extends Component {
195
229
  }
196
230
 
197
231
  /**
198
- * Derives at most two uppercase initials from the first and last words of a name.
232
+ * Derives uppercase initials from the first and last words of a name.
199
233
  * @param {unknown} name Name-like value.
234
+ * @param {1|2} [limit=2] How many initials to return: two by default, one for an avatar too small
235
+ * to hold two legibly.
200
236
  * @returns {string}
201
237
  */
202
- export function avatarInitials(name) {
238
+ export function avatarInitials(name, limit = 2) {
203
239
  const words = String(name ?? '').trim().split(/\s+/u).filter(Boolean);
204
240
  if (words.length === 0) return '?';
205
241
  const first = uppercaseInitial(words[0]);
206
- const last = words.length > 1 ? uppercaseInitial(words[words.length - 1]) : '';
242
+ const last = limit > 1 && words.length > 1 ? uppercaseInitial(words[words.length - 1]) : '';
207
243
  return `${first}${last}`;
208
244
  }
209
245
 
246
+ /**
247
+ * Picks a stable fallback tone for a name.
248
+ *
249
+ * The name is trimmed, NFC-normalised and lower-cased, then hashed with 32-bit FNV-1a over its
250
+ * UTF-16 code units, and the hash picks one of the eight chromatic tones of {@link AVATAR_TONES}.
251
+ * The result depends on nothing but the name — not the locale, the page, or the order avatars
252
+ * render in — so a person keeps their colour everywhere. An empty name is `slate`, the neutral
253
+ * tone, which no name hashes to.
254
+ * @param {unknown} name Name-like value.
255
+ * @returns {AvatarTone}
256
+ */
257
+ export function avatarTone(name) {
258
+ const key = String(name ?? '').trim().normalize('NFC').toLowerCase();
259
+ if (!key) return 'slate';
260
+ let hash = 0x811c9dc5;
261
+ for (let index = 0; index < key.length; index += 1) {
262
+ hash ^= key.charCodeAt(index);
263
+ hash = Math.imul(hash, 0x01000193);
264
+ }
265
+ return HASHED_TONES[(hash >>> 0) % HASHED_TONES.length];
266
+ }
267
+
268
+ /** Keeps the first `limit` graphemes of explicit initials. @param {string} value @param {number} limit @returns {string} */
269
+ function clipGraphemes(value, limit) {
270
+ if (limit > 1) return value;
271
+ return firstGrapheme(value.trim()) || value;
272
+ }
273
+
210
274
  /** Returns one uppercased grapheme even when Unicode case expansion produces multiple letters. @param {string} word @returns {string} */
211
275
  function uppercaseInitial(word) {
212
276
  return firstGrapheme(firstGrapheme(word).toUpperCase());
@@ -1,3 +1,8 @@
1
+ /*
2
+ * The neutral soft fill is a wash of the text colour rather than an opaque step of the surface
3
+ * scale: it darkens whatever it sits on by the same amount. In the dark theme the muted step is
4
+ * the raised one, so an opaque muted badge vanished on a raised card, a menu, or a dialog.
5
+ */
1
6
  .zx-badge {
2
7
  display: inline-flex;
3
8
  align-items: center;
@@ -5,12 +10,12 @@
5
10
  max-inline-size: 100%;
6
11
  border: 1px solid transparent;
7
12
  border-radius: var(--zx-radius-full);
8
- background: var(--zx-color-bg-muted);
13
+ background: color-mix(in srgb, var(--zx-color-text) 7%, transparent);
9
14
  color: var(--zx-color-text-muted);
10
15
  padding-block: 2px;
11
16
  padding-inline: var(--zx-space-2);
12
17
  font-size: var(--zx-text-xs);
13
- font-weight: 600;
18
+ font-weight: var(--zx-weight-medium);
14
19
  font-variant-numeric: tabular-nums;
15
20
  line-height: 1.35;
16
21
  white-space: nowrap;
@@ -127,6 +132,39 @@
127
132
  color: var(--zx-color-info);
128
133
  }
129
134
 
135
+ /*
136
+ * Tone — categorical colour from the tone palette, for what a badge *is* (a project, a label, a
137
+ * module) rather than how it is doing. Declared after every `kind` rule so a tone wins at equal
138
+ * specificity: a badge has one colour, and a tone is the more specific request. Soft by default,
139
+ * borderless; solid fills with the tone's foreground; outline draws it.
140
+ */
141
+ .zx-badge[data-tone] {
142
+ border-color: transparent;
143
+ background: var(--zx-badge-tone-bg);
144
+ color: var(--zx-badge-tone-fg);
145
+ }
146
+
147
+ .zx-badge[data-tone="blue"] { --zx-badge-tone-bg: var(--zx-color-tone-blue-bg); --zx-badge-tone-fg: var(--zx-color-tone-blue-fg); }
148
+ .zx-badge[data-tone="teal"] { --zx-badge-tone-bg: var(--zx-color-tone-teal-bg); --zx-badge-tone-fg: var(--zx-color-tone-teal-fg); }
149
+ .zx-badge[data-tone="green"] { --zx-badge-tone-bg: var(--zx-color-tone-green-bg); --zx-badge-tone-fg: var(--zx-color-tone-green-fg); }
150
+ .zx-badge[data-tone="amber"] { --zx-badge-tone-bg: var(--zx-color-tone-amber-bg); --zx-badge-tone-fg: var(--zx-color-tone-amber-fg); }
151
+ .zx-badge[data-tone="orange"] { --zx-badge-tone-bg: var(--zx-color-tone-orange-bg); --zx-badge-tone-fg: var(--zx-color-tone-orange-fg); }
152
+ .zx-badge[data-tone="red"] { --zx-badge-tone-bg: var(--zx-color-tone-red-bg); --zx-badge-tone-fg: var(--zx-color-tone-red-fg); }
153
+ .zx-badge[data-tone="pink"] { --zx-badge-tone-bg: var(--zx-color-tone-pink-bg); --zx-badge-tone-fg: var(--zx-color-tone-pink-fg); }
154
+ .zx-badge[data-tone="violet"] { --zx-badge-tone-bg: var(--zx-color-tone-violet-bg); --zx-badge-tone-fg: var(--zx-color-tone-violet-fg); }
155
+ .zx-badge[data-tone="slate"] { --zx-badge-tone-bg: var(--zx-color-tone-slate-bg); --zx-badge-tone-fg: var(--zx-color-tone-slate-fg); }
156
+
157
+ .zx-badge[data-variant="solid"][data-tone] {
158
+ background: var(--zx-badge-tone-fg);
159
+ color: var(--zx-color-bg-surface);
160
+ }
161
+
162
+ .zx-badge[data-variant="outline"][data-tone] {
163
+ border-color: currentColor;
164
+ background: transparent;
165
+ color: var(--zx-badge-tone-fg);
166
+ }
167
+
130
168
  .zx-badge[data-size="sm"] {
131
169
  gap: calc(var(--zx-space-1) / 2);
132
170
  padding-block: 1px;
@@ -139,6 +177,20 @@
139
177
  block-size: 5px;
140
178
  }
141
179
 
180
+ /* A small badge with a dot keeps the medium badge's text size and a full step between dot and
181
+ label: at 11px with a 2px gap the dot ran into the first letter. The label's end gets a little
182
+ more room than the dot's start, which the pill's round end would otherwise crowd. */
183
+ .zx-badge[data-size="sm"]:has(> .zx-badge__dot) {
184
+ gap: var(--zx-space-1);
185
+ padding-inline: var(--zx-space-1) calc(var(--zx-space-1) * 1.5);
186
+ font-size: var(--zx-text-xs);
187
+ }
188
+
189
+ .zx-badge[data-size="sm"]:has(> .zx-badge__dot) .zx-badge__dot {
190
+ inline-size: 6px;
191
+ block-size: 6px;
192
+ }
193
+
142
194
  .zx-badge-group {
143
195
  display: inline-flex;
144
196
  flex-wrap: wrap;
@@ -1,11 +1,20 @@
1
1
  import { h } from '../../core/dom.js';
2
2
  import { icon as createIcon } from '../../core/icons.js';
3
3
 
4
+ /**
5
+ * A categorical tone from the design system's tone palette (`--zx-color-tone-*`).
6
+ * @typedef {'blue'|'teal'|'green'|'amber'|'orange'|'red'|'pink'|'violet'|'slate'} BadgeTone
7
+ */
8
+
4
9
  /**
5
10
  * @typedef {Object} BadgeOptions
6
11
  * @property {string} [label=''] Visible badge text.
7
12
  * @property {string|null} [icon=null] Icon name from `icons.js`, rendered before the label.
8
13
  * @property {'neutral'|'accent'|'success'|'warning'|'danger'|'info'} [kind='neutral'] Semantic intent.
14
+ * @property {BadgeTone|null} [tone=null] Categorical colour — a project, a label, a module — from
15
+ * the tone palette rather than the status set, so a tag never reads as a warning. When set it
16
+ * decides the colour and `kind` is ignored for it; `variant` still applies. Unknown names are
17
+ * ignored.
9
18
  * @property {'soft'|'solid'|'outline'} [variant='soft'] Fill treatment.
10
19
  * @property {'md'|'sm'} [size='md'] Badge size.
11
20
  * @property {boolean} [dot=false] Renders a small leading status dot instead of an icon.
@@ -18,18 +27,26 @@ const KINDS = Object.freeze(['neutral', 'accent', 'success', 'warning', 'danger'
18
27
  /** Fill treatments. */
19
28
  const VARIANTS = Object.freeze(['soft', 'solid', 'outline']);
20
29
 
30
+ /**
31
+ * Categorical tones, in the order they are documented.
32
+ * @type {ReadonlyArray<BadgeTone>}
33
+ */
34
+ export const BADGE_TONES = Object.freeze(['blue', 'teal', 'green', 'amber', 'orange', 'red', 'pink', 'violet', 'slate']);
35
+
21
36
  /** @type {Readonly<BadgeOptions>} */
22
37
  const defaults = Object.freeze({
23
38
  label: '',
24
39
  icon: null,
25
40
  kind: 'neutral',
41
+ tone: null,
26
42
  variant: 'soft',
27
43
  size: 'md',
28
44
  dot: false
29
45
  });
30
46
 
31
47
  /**
32
- * Creates a status pill: a small, non-interactive label carrying a semantic colour.
48
+ * Creates a status pill: a small, non-interactive label carrying a semantic colour — or, with
49
+ * `tone`, a categorical one.
33
50
  *
34
51
  * The badge is plain text content, so it needs no ARIA of its own. A badge without a label but
35
52
  * with a `title` is named for assistive technology through `role="img"`.
@@ -45,7 +62,8 @@ export function badge(opts = {}) {
45
62
  dataset: {
46
63
  kind: normalizeKind(options.kind),
47
64
  variant: normalizeVariant(options.variant),
48
- size: normalizeSize(options.size)
65
+ size: normalizeSize(options.size),
66
+ tone: normalizeBadgeTone(options.tone) ?? undefined
49
67
  }
50
68
  }));
51
69
 
@@ -86,6 +104,15 @@ function normalizeVariant(value) {
86
104
  /** @type {'soft'|'solid'|'outline'} */ (value) : 'soft';
87
105
  }
88
106
 
107
+ /**
108
+ * Resolves a categorical tone name, or null for anything outside the palette.
109
+ * @param {unknown} value Requested tone.
110
+ * @returns {BadgeTone|null}
111
+ */
112
+ export function normalizeBadgeTone(value) {
113
+ return BADGE_TONES.includes(/** @type {BadgeTone} */ (value)) ? /** @type {BadgeTone} */ (value) : null;
114
+ }
115
+
89
116
  /** @param {unknown} value @returns {'md'|'sm'} */
90
117
  function normalizeSize(value) {
91
118
  return value === 'sm' ? 'sm' : 'md';
@@ -40,20 +40,22 @@
40
40
  cursor: not-allowed;
41
41
  }
42
42
 
43
+ /* The screen's one solid accent. It reads the solid-accent roles, which keep the deep stop with
44
+ white ink in both themes — the dark theme's bright text accent would make it a glowing slab. */
43
45
  .zx-btn[data-kind="primary"] {
44
- border-color: var(--zx-color-accent);
45
- background: var(--zx-color-accent);
46
- color: var(--zx-color-on-accent);
46
+ border-color: var(--zx-color-accent-solid);
47
+ background: var(--zx-color-accent-solid);
48
+ color: var(--zx-color-on-accent-solid);
47
49
  }
48
50
 
49
51
  .zx-btn[data-kind="primary"]:hover:not(:disabled) {
50
- border-color: var(--zx-color-accent-hover);
51
- background: var(--zx-color-accent-hover);
52
+ border-color: var(--zx-color-accent-solid-hover);
53
+ background: var(--zx-color-accent-solid-hover);
52
54
  }
53
55
 
54
56
  .zx-btn[data-kind="primary"]:active:not(:disabled) {
55
- border-color: var(--zx-color-accent-active);
56
- background: var(--zx-color-accent-active);
57
+ border-color: var(--zx-color-accent-solid-active);
58
+ background: var(--zx-color-accent-solid-active);
57
59
  }
58
60
 
59
61
  .zx-btn[data-kind="danger"] {
@@ -66,6 +68,8 @@
66
68
  background: color-mix(in srgb, var(--zx-color-danger) 88%, var(--zx-color-text));
67
69
  }
68
70
 
71
+ /* Ghost: no chrome until hovered, and its glyph in the subtle text colour so a row of them reads
72
+ as quiet affordances rather than a second toolbar of buttons. */
69
73
  .zx-btn[data-kind="ghost"] {
70
74
  border-color: transparent;
71
75
  background: none;
@@ -74,11 +78,43 @@
74
78
  box-shadow: none;
75
79
  }
76
80
 
81
+ .zx-btn[data-kind="ghost"]:focus-visible {
82
+ box-shadow: var(--zx-focus-ring);
83
+ }
84
+
85
+ .zx-btn[data-kind="ghost"] > .zx-icon {
86
+ color: var(--zx-color-text-subtle);
87
+ }
88
+
77
89
  .zx-btn[data-kind="ghost"]:hover:not(:disabled) {
78
90
  border-color: transparent;
79
91
  background: var(--zx-color-bg-hover);
80
92
  }
81
93
 
94
+ .zx-btn[data-kind="ghost"]:hover:not(:disabled) > .zx-icon {
95
+ color: var(--zx-color-text);
96
+ }
97
+
98
+ /* A pressed toggle (`aria-pressed="true"`, e.g. a Toolbar item with `active`) is a mode you are
99
+ in, not a pick: the neutral current fill and the full text colour, never the accent. */
100
+ .zx-btn[aria-pressed="true"] {
101
+ border-color: var(--zx-color-border-control);
102
+ background: var(--zx-color-bg-current);
103
+ color: var(--zx-color-text);
104
+ }
105
+
106
+ .zx-btn[data-kind="ghost"][aria-pressed="true"] {
107
+ border-color: transparent;
108
+ }
109
+
110
+ .zx-btn[aria-pressed="true"] > .zx-icon {
111
+ color: var(--zx-color-text);
112
+ }
113
+
114
+ .zx-btn[aria-pressed="true"]:hover:not(:disabled) {
115
+ background: var(--zx-color-bg-current);
116
+ }
117
+
82
118
  .zx-btn[data-size="sm"] {
83
119
  min-block-size: calc(var(--zx-control-height) - var(--zx-space-1));
84
120
  padding-inline: var(--zx-space-2);
@@ -93,14 +129,35 @@
93
129
  display: inline-flex;
94
130
  }
95
131
 
132
+ /* Busy (setButtonBusy): the action is running. It keeps its look and its focus; a spinner leads
133
+ the label, and an icon it had steps aside for the spinner so the width holds. */
134
+ .zx-btn[aria-busy="true"] {
135
+ cursor: progress;
136
+ }
137
+
138
+ .zx-btn[aria-busy="true"] > .zx-icon {
139
+ display: none;
140
+ }
141
+
142
+ .zx-btn__spinner {
143
+ flex: none;
144
+ }
145
+
146
+ /* The key hint is quieter than the label by colour and weight, not by opacity: faded white on a
147
+ solid fill falls below 4.5:1. On a solid button it keeps the button's ink at a lighter weight. */
96
148
  .zx-btn__shortcut {
97
149
  font: inherit;
98
150
  font-size: var(--zx-text-xs);
99
- opacity: .7;
151
+ font-weight: 400;
152
+ color: var(--zx-color-text-muted);
100
153
  margin-inline-start: var(--zx-space-1);
101
154
  white-space: nowrap;
102
155
  }
103
156
 
157
+ .zx-btn:is([data-kind="primary"], [data-kind="danger"]) .zx-btn__shortcut {
158
+ color: inherit;
159
+ }
160
+
104
161
  .zx-btn-group > .zx-btn:not(:first-child) {
105
162
  margin-inline-start: -1px;
106
163
  border-start-start-radius: 0;
@@ -135,7 +192,8 @@
135
192
  backdrop-filter: none;
136
193
  }
137
194
 
138
- .zx-btn[data-kind="primary"] {
195
+ .zx-btn[data-kind="primary"],
196
+ .zx-btn[aria-pressed="true"] {
139
197
  border-color: Highlight;
140
198
  background: Highlight;
141
199
  color: HighlightText;
@@ -1,5 +1,6 @@
1
1
  import { h } from '../../core/dom.js';
2
2
  import { icon as createIcon } from '../../core/icons.js';
3
+ import { spinner } from '../loading/spinner.js';
3
4
 
4
5
  /** @typedef {{label:string,keys:string}} ButtonShortcut Visible hint and ARIA key combination; the host binds the action. */
5
6
 
@@ -10,6 +11,8 @@ import { icon as createIcon } from '../../core/icons.js';
10
11
  * @property {'default'|'primary'|'danger'|'ghost'} [kind='default'] Visual intent.
11
12
  * @property {'md'|'sm'} [size='md'] Control size.
12
13
  * @property {boolean} [disabled=false] Whether the button is disabled.
14
+ * @property {boolean} [busy=false] Whether the button is working on its action — see
15
+ * `setButtonBusy()`.
13
16
  * @property {string} [title] Native title text.
14
17
  * @property {ButtonShortcut} [shortcut] Keyboard hint; does not register a keyboard handler.
15
18
  * @property {(event: MouseEvent) => void} [onclick] Click callback.
@@ -52,6 +55,7 @@ export function button(opts = {}) {
52
55
  if (options.shortcut) element.append(h('kbd', { class: 'zx-btn__shortcut', ariaHidden: 'true' }, options.shortcut.label));
53
56
  if (!options.label && options.title) element.setAttribute('aria-label', options.title);
54
57
  if (typeof options.onclick === 'function') element.onclick = options.onclick;
58
+ if (options.busy) setButtonBusy(element, true);
55
59
  return element;
56
60
  }
57
61
 
@@ -69,6 +73,40 @@ export function buttonGroup(buttons) {
69
73
  return group;
70
74
  }
71
75
 
76
+ /**
77
+ * Marks a button as working on its action, or done. A busy button shows a spinner before its
78
+ * label, announces `aria-busy`, and ignores activation — but stays focusable, unlike a disabled
79
+ * one, so focus does not fall to the page while the work runs and the reader hears it finish.
80
+ * Works on any `.zx-btn`, including one the application built itself.
81
+ * @param {HTMLButtonElement} element
82
+ * @param {boolean} busy
83
+ * @returns {HTMLButtonElement} The same button.
84
+ */
85
+ export function setButtonBusy(element, busy) {
86
+ // One guard per button, installed on first use: a busy button swallows clicks — keyboard
87
+ // activation included — before any of the application's own listeners see them.
88
+ if (!element.hasAttribute('data-busy-guard')) {
89
+ element.setAttribute('data-busy-guard', '');
90
+ element.addEventListener('click', (event) => {
91
+ if (element.getAttribute('aria-busy') !== 'true') return;
92
+ event.preventDefault();
93
+ event.stopImmediatePropagation();
94
+ }, { capture: true });
95
+ }
96
+ element.querySelector(':scope > .zx-btn__spinner')?.remove();
97
+ if (busy) {
98
+ element.setAttribute('aria-busy', 'true');
99
+ element.setAttribute('aria-disabled', 'true');
100
+ const ring = spinner({ size: 'sm', kind: 'current' });
101
+ ring.classList.add('zx-btn__spinner');
102
+ element.prepend(ring);
103
+ } else {
104
+ element.removeAttribute('aria-busy');
105
+ element.removeAttribute('aria-disabled');
106
+ }
107
+ return element;
108
+ }
109
+
72
110
  /** @param {unknown} value @returns {'default'|'primary'|'danger'|'ghost'} */
73
111
  function normalizeKind(value) {
74
112
  return ['primary', 'danger', 'ghost'].includes(String(value)) ?