@threadlabs/looma 0.6.0 → 0.6.2

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 (71) hide show
  1. package/components/ui-avatar/ui-avatar.html +13 -0
  2. package/components/ui-avatar-group/ui-avatar-group.html +7 -2
  3. package/components/ui-avatar-group/ui-avatar-group.js +3 -3
  4. package/components/ui-badge/ui-badge.html +14 -0
  5. package/components/ui-button/ui-button.html +2 -2
  6. package/components/ui-cluster/ui-cluster.html +1 -1
  7. package/components/ui-combobox/ui-combobox.html +112 -46
  8. package/components/ui-combobox/ui-combobox.js +14 -2
  9. package/components/ui-editor-insert-table-grid/ui-editor-insert-table-grid.html +3 -3
  10. package/components/ui-editor-table-context-menu/ui-editor-table-context-menu.html +2 -2
  11. package/components/ui-editor-table-toolbar/ui-editor-table-toolbar.html +2 -2
  12. package/components/ui-editor-toolbar/ui-editor-toolbar.html +5 -3
  13. package/components/ui-menu-item/ui-menu-item.html +13 -0
  14. package/components/ui-stack/ui-stack.html +1 -1
  15. package/components/ui-tabs/ui-tabs.html +13 -0
  16. package/components/ui-toast-region/ui-toast-region.html +2 -2
  17. package/components/ui-tooltip/ui-tooltip.html +3 -0
  18. package/components/ui-tooltip/ui-tooltip.js +24 -3
  19. package/dist/index.js +1 -1
  20. package/package.json +1 -1
  21. package/styles/ui-avatar-group.css +7 -2
  22. package/styles/ui-avatar.css +19 -0
  23. package/styles/ui-badge.css +21 -1
  24. package/styles/ui-cluster.css +1 -1
  25. package/styles/ui-combobox.css +52 -1
  26. package/styles/ui-editor-insert-table-grid.css +2 -2
  27. package/styles/ui-editor-table-context-menu.css +1 -1
  28. package/styles/ui-editor-table-toolbar.css +1 -1
  29. package/styles/ui-editor-toolbar.css +5 -3
  30. package/styles/ui-menu-item.css +20 -1
  31. package/styles/ui-stack.css +1 -1
  32. package/styles/ui-tabs.css +19 -0
  33. package/styles/ui-toast-region.css +2 -2
  34. package/tokens.css +4 -0
  35. package/vanilla/UiCombobox.js +141 -119
  36. package/vanilla/UiEditorInsertTableGrid.js +1 -1
  37. package/vanilla/UiEditorTableContextMenu.js +1 -1
  38. package/vanilla/UiEditorTableToolbar.js +1 -1
  39. package/vanilla/UiTooltip.d.ts +1 -0
  40. package/vanilla/UiTooltip.js +1 -1
  41. package/vue/UiAvatar.vue +13 -0
  42. package/vue/UiAvatarGroup.vue +7 -2
  43. package/vue/UiBadge.vue +14 -0
  44. package/vue/UiCluster.vue +1 -1
  45. package/vue/UiCombobox.vue +135 -53
  46. package/vue/UiEditorInsertTableGrid.vue +3 -3
  47. package/vue/UiEditorTableContextMenu.vue +2 -2
  48. package/vue/UiEditorTableToolbar.vue +2 -2
  49. package/vue/UiEditorToolbar.vue +5 -3
  50. package/vue/UiMenuItem.vue +13 -0
  51. package/vue/UiStack.vue +1 -1
  52. package/vue/UiTabs.vue +13 -0
  53. package/vue/UiToastRegion.vue +2 -2
  54. package/vue/UiTooltip.d.ts +4 -0
  55. package/vue/UiTooltip.vue +6 -0
  56. package/vue/chunks/UiAvatar.js +1 -1
  57. package/vue/chunks/UiAvatarGroup.js +1 -1
  58. package/vue/chunks/UiBadge.js +1 -1
  59. package/vue/chunks/UiCluster.js +1 -1
  60. package/vue/chunks/UiCombobox.js +66 -41
  61. package/vue/chunks/UiEditorInsertTableGrid.js +3 -3
  62. package/vue/chunks/UiEditorTableContextMenu.js +2 -2
  63. package/vue/chunks/UiEditorTableToolbar.js +2 -2
  64. package/vue/chunks/UiEditorToolbar.js +1 -1
  65. package/vue/chunks/UiMenuItem.js +1 -1
  66. package/vue/chunks/UiStack.js +1 -1
  67. package/vue/chunks/UiTabs.js +1 -1
  68. package/vue/chunks/UiToastRegion.js +1 -1
  69. package/vue/chunks/UiTooltip.js +5 -2
  70. package/vue/components.css +322 -211
  71. package/vue/editor/index.js +2 -1
@@ -47,6 +47,19 @@
47
47
  block-size: 100%;
48
48
  }
49
49
 
50
+ /* Centre the glyphs, not the line box: a label without descenders otherwise reads high. */
51
+ /* Centre the glyphs, not the line box: a label without descenders otherwise reads high.
52
+ * Trimming applies to block containers, so the box is a block with centred content. */
53
+ @supports (text-box-trim: trim-both) {
54
+ .fallback {
55
+ display: block;
56
+ text-align: center;
57
+ align-content: center;
58
+ text-box-trim: trim-both;
59
+ text-box-edge: cap alphabetic;
60
+ }
61
+ }
62
+
50
63
  [hidden] {
51
64
  display: none;
52
65
  }
@@ -12,6 +12,11 @@
12
12
  </div>
13
13
  <style>
14
14
  :host {
15
+ /* Avatars separate with a tight, crisp edge, matching Looma's 1px borders rather than the
16
+ * diffuse elevation overlays use. */
17
+ --_overlap-shadow: -2px 0 2px -1px rgba(0, 0, 0, 0.22);
18
+ --_edge-ring: 0 0 0 1px rgba(0, 0, 0, 0.08);
19
+
15
20
  display: flex;
16
21
  align-items: center;
17
22
  flex-direction: row;
@@ -42,8 +47,8 @@
42
47
  background: var(--ui-surface-elevated);
43
48
  color: var(--ui-text-primary);
44
49
  box-shadow:
45
- var(--ui-avatar-group-overlap-shadow, -2px 0 5px -1px rgb(0 0 0 / 0.28)),
46
- 0 0 0 1px rgb(0 0 0 / 0.06);
50
+ var(--ui-avatar-group-overlap-shadow, var(--_overlap-shadow)),
51
+ var(--ui-avatar-group-edge-ring, var(--_edge-ring));
47
52
  font: inherit;
48
53
  font-size: 0.75rem;
49
54
  font-weight: 600;
@@ -10,10 +10,10 @@ export default function controller(host) {
10
10
  avatar.style.display = index >= visible ? "none" : "";
11
11
  avatar.style.marginInlineStart = index === 0 ? "0px" : "-0.625rem";
12
12
  avatar.style.borderRadius = "999px";
13
- // Later avatars cast a soft shadow back onto the one they overlap; the first overlaps nothing.
13
+ // Later avatars cast a crisp shadow back onto the one they overlap; the first overlaps nothing.
14
14
  avatar.style.boxShadow = index === 0
15
- ? "0 0 0 1px rgb(0 0 0 / 0.06)"
16
- : "var(--ui-avatar-group-overlap-shadow, -2px 0 5px -1px rgb(0 0 0 / 0.28)), 0 0 0 1px rgb(0 0 0 / 0.06)";
15
+ ? "var(--ui-avatar-group-edge-ring, var(--_edge-ring))"
16
+ : "var(--ui-avatar-group-overlap-shadow, var(--_overlap-shadow)), var(--ui-avatar-group-edge-ring, var(--_edge-ring))";
17
17
  });
18
18
  host.state.overflowCount = Math.max(0, avatars.length - visible);
19
19
  };
@@ -26,6 +26,20 @@
26
26
  white-space: nowrap;
27
27
  }
28
28
 
29
+ /* Centre the glyphs, not the line box: a label without descenders otherwise reads high. */
30
+ /* Centre the glyphs, not the line box: a label without descenders otherwise reads high.
31
+ * Trimming applies to block containers, so the box is a block with centred content. */
32
+ @supports (text-box-trim: trim-both) {
33
+ :host {
34
+ display: block;
35
+ min-block-size: var(--ui-badge-min-block-size, 1.5rem);
36
+ text-align: center;
37
+ align-content: center;
38
+ text-box-trim: trim-both;
39
+ text-box-edge: cap alphabetic;
40
+ }
41
+ }
42
+
29
43
  :host-state([tone="accent"]) {
30
44
  --_badge-surface: var(--ui-accent-strong-surface, var(--ui-accent-solid));
31
45
  --_badge-border: var(--ui-accent-strong-surface, var(--ui-accent-solid));
@@ -5,8 +5,8 @@
5
5
  <prop name="size" type="sm | md | lg" default="md">Control size.</prop>
6
6
  <prop name="tone" type="neutral | accent" default="neutral"
7
7
  >Intent of an outline, ghost, or link button. accent tints it with the accent colour, for a secondary action that still reads as the primary path. For a
8
- product whose buttons are accent-tinted by default, set --ui-button-surface, --ui-button-border, and --ui-button-text once in your theme rather than
9
- annotating every call site.</prop>
8
+ product whose buttons are accent-tinted by default, point --ui-button-surface, --ui-button-border, and --ui-button-text at the accent values in your theme
9
+ rather than annotating every call site, and keep them as var() references so they follow a later change of accent.</prop>
10
10
  <prop name="disabled" type="boolean" default="false">disabled token.</prop>
11
11
  <prop name="align" type="center | start" default="center"
12
12
  >Content alignment. start lays the content out like a list option: from the start edge, with start-aligned text.</prop>
@@ -10,7 +10,7 @@
10
10
  display: flex;
11
11
  flex-wrap: wrap;
12
12
  align-items: center;
13
- gap: var(--ui-layout-gap, var(--_layout-gap));
13
+ gap: var(--ui-cluster-gap, var(--_layout-gap));
14
14
  min-inline-size: 0;
15
15
  max-inline-size: 100%;
16
16
  margin: 0;
@@ -21,6 +21,7 @@
21
21
  <prop name="disclosure" type="boolean" default="false">disclosure token.</prop>
22
22
  <prop name="clearable" type="boolean" default="false">clearable token.</prop>
23
23
  <prop name="help" type="string" default="">help token.</prop>
24
+ <state name="internalItems" type="list(object({ id: string, value: string, label: string, group?: string, disabled?: boolean }))" :value="[]"></state>
24
25
  <state name="raw" :value="''"></state>
25
26
  <state name="display" :value="''"></state>
26
27
  <state name="selected" type="string | null" :value="null"></state>
@@ -79,68 +80,85 @@
79
80
  <div>
80
81
  <label :for="format('%s-input', uid)" class:sr-only="labelVisibility = 'sr-only'"><template $value="label"></template><template $if="required">
81
82
  *</template></label>
82
- <div class="field" $ref="field">
83
- <slot name="start"></slot>
84
- <template $if="multiple">
83
+ <div class="control">
84
+ <div class="field" $ref="field">
85
+ <slot name="start"></slot>
86
+ <template $if="multiple">
87
+ <button
88
+ $each="item, index of internalItems"
89
+ $key="item.id"
90
+ class="item"
91
+ type="button"
92
+ tabindex="-1"
93
+ :data-index="index"
94
+ :disabled="disabled or readonly or item.disabled"
95
+ :aria-label="format('%s, press Delete or Backspace to remove', item.label)"
96
+ >
97
+ <slot :name="format('item-%s', item.id)"><ui-badge $value="item.label"></ui-badge></slot>
98
+ </button>
99
+ </template>
100
+ <input
101
+ $ref="input"
102
+ :id="format('%s-input', uid)"
103
+ role="combobox"
104
+ aria-autocomplete="list"
105
+ autocomplete="off"
106
+ :aria-controls="format('%s-listbox', uid)"
107
+ :aria-expanded="expanded"
108
+ :aria-invalid="validationStatus = 'error'"
109
+ :aria-busy="validationStatus = 'pending'"
110
+ .value="display"
111
+ :placeholder="placeholder"
112
+ :name="name"
113
+ :disabled="disabled"
114
+ :readonly="readonly"
115
+ :required="required"
116
+ >
117
+ <button $if="clearable" type="button" data-combobox-action="clear" aria-label="Clear">
118
+ <svg class="affordance-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
119
+ <path d="M7 7l10 10M17 7 7 17"></path>
120
+ </svg>
121
+ </button>
85
122
  <button
86
- $each="item, index of items"
87
- $key="item.id"
88
- class="item"
123
+ $if="disclosure"
89
124
  type="button"
125
+ data-combobox-action="disclosure"
90
126
  tabindex="-1"
91
- :data-index="index"
92
- :disabled="disabled or readonly or item.disabled"
93
- :aria-label="format('%s, press Delete or Backspace to remove', item.label)"
127
+ aria-label="Show suggestions"
128
+ :aria-controls="format('%s-listbox', uid)"
94
129
  >
95
- <slot :name="format('item-%s', item.id)"><ui-badge $value="item.label"></ui-badge></slot>
130
+ <svg
131
+ class="affordance-icon"
132
+ aria-hidden="true"
133
+ viewBox="0 0 24 24"
134
+ fill="none"
135
+ stroke="currentColor"
136
+ stroke-width="2.25"
137
+ stroke-linecap="round"
138
+ stroke-linejoin="round"
139
+ >
140
+ <path d="m6 9 6 6 6-6"></path>
141
+ </svg>
96
142
  </button>
97
- </template>
98
- <input
99
- $ref="input"
100
- :id="format('%s-input', uid)"
101
- role="combobox"
102
- aria-autocomplete="list"
103
- autocomplete="off"
104
- :aria-controls="format('%s-listbox', uid)"
105
- :aria-expanded="expanded"
106
- :aria-invalid="validationStatus = 'error'"
107
- :aria-busy="validationStatus = 'pending'"
108
- .value="display"
109
- :placeholder="placeholder"
110
- :name="name"
111
- :disabled="disabled"
112
- :readonly="readonly"
113
- :required="required"
114
- >
115
- <button $if="clearable" type="button" data-combobox-action="clear" aria-label="Clear">
116
- <svg class="affordance-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
117
- <path d="M7 7l10 10M17 7 7 17"></path>
118
- </svg>
119
- </button>
120
- <button $if="help" :id="format('%s-help', uid)" type="button" aria-label="Help" :disabled="disabled"><span aria-hidden="true">?</span></button>
121
- <button
122
- $if="disclosure"
123
- type="button"
124
- data-combobox-action="disclosure"
125
- tabindex="-1"
126
- aria-label="Show suggestions"
127
- :aria-controls="format('%s-listbox', uid)"
128
- >
143
+ </div>
144
+ <button $if="help" class="help" :id="format('%s-help', uid)" type="button" aria-label="Help" :disabled="disabled">
129
145
  <svg
130
- class="affordance-icon"
146
+ class="help-icon"
131
147
  aria-hidden="true"
132
148
  viewBox="0 0 24 24"
133
149
  fill="none"
134
150
  stroke="currentColor"
135
- stroke-width="2.25"
151
+ stroke-width="2"
136
152
  stroke-linecap="round"
137
153
  stroke-linejoin="round"
138
154
  >
139
- <path d="m6 9 6 6 6-6"></path>
155
+ <circle cx="12" cy="12" r="10"></circle>
156
+ <path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3"></path>
157
+ <path d="M12 17h.01"></path>
140
158
  </svg>
141
159
  </button>
142
160
  </div>
143
- <ui-tooltip $if="help" :id="format('%s-help-text', uid)" :for="format('%s-help', uid)"><template $value="help"></template></ui-tooltip>
161
+ <ui-tooltip $if="help" trigger="click" :id="format('%s-help-text', uid)" :for="format('%s-help', uid)"><template $value="help"></template></ui-tooltip>
144
162
  <div class="popup" $ref="popup" :hidden="not expanded">
145
163
  <div role="listbox" $ref="listbox" :id="format('%s-listbox', uid)" :aria-label="format('%s suggestions', label)" :aria-busy="loading">
146
164
  <div $each="group of groups" $key="group.name" :role="group.role" :aria-label="group.label">
@@ -193,7 +211,17 @@
193
211
  font-size: var(--ui-combobox-label-font-size, 0.875rem);
194
212
  font-weight: 500;
195
213
  }
214
+ /* The field and anything that annotates it sit on one row; the help button is beside the box,
215
+ not inside it, because a control's box holds its value. */
216
+ .control {
217
+ display: flex;
218
+ align-items: center;
219
+ gap: var(--ui-space-2);
220
+ min-inline-size: 0;
221
+ }
222
+
196
223
  .field {
224
+ flex: 1 1 auto;
197
225
  display: flex;
198
226
  min-inline-size: 0;
199
227
  border: 1px solid var(--ui-control-border, var(--ui-border-default));
@@ -208,6 +236,44 @@
208
236
  var(--ui-control-inset),
209
237
  var(--ui-control-focus-halo-shadow);
210
238
  }
239
+ /* A ghost icon button: the affordance is the mark, not a box around it. */
240
+ .help {
241
+ flex: none;
242
+ display: inline-flex;
243
+ align-items: center;
244
+ justify-content: center;
245
+ inline-size: var(--ui-control-size-sm);
246
+ block-size: var(--ui-control-size-sm);
247
+ padding: 0;
248
+ border: 0;
249
+ border-radius: var(--ui-radius-round);
250
+ background: transparent;
251
+ color: var(--ui-text-secondary);
252
+ cursor: pointer;
253
+ transition: background-color var(--ui-motion-fast) var(--ui-motion-ease), color var(--ui-motion-fast) var(--ui-motion-ease);
254
+ }
255
+
256
+ .help:hover:enabled,
257
+ .help[aria-expanded="true"] {
258
+ background: color-mix(in srgb, currentColor 8%, transparent);
259
+ color: var(--ui-text-primary);
260
+ }
261
+
262
+ .help:focus-visible {
263
+ outline: none;
264
+ box-shadow: var(--ui-focus-halo);
265
+ }
266
+
267
+ .help:disabled {
268
+ color: var(--ui-disabled-text);
269
+ cursor: not-allowed;
270
+ }
271
+
272
+ .help-icon {
273
+ inline-size: var(--ui-icon-size-sm);
274
+ block-size: var(--ui-icon-size-sm);
275
+ }
276
+
211
277
  input::selection {
212
278
  background: color-mix(in srgb, var(--ui-accent-solid) 20%, transparent);
213
279
  color: inherit;
@@ -48,7 +48,9 @@ export default function controller(host) {
48
48
  allowFreeText: Boolean(host.state.allowFreeText),
49
49
  allowCreate: Boolean(host.state.allowCreate),
50
50
  });
51
- const items = () => host.state.multiple && Array.isArray(host.state.items) ? host.state.items : [];
51
+ // Selected items work uncontrolled: the component keeps them and reports every change. A consumer
52
+ // that owns `items` stays in charge, because the prop resyncs whatever it sets.
53
+ const items = () => host.state.multiple && Array.isArray(host.state.internalItems) ? host.state.internalItems : [];
52
54
  const selectedValues = () => new Set(items().map((item) => item.value));
53
55
  const setValidation = (result, touched = host.state.validation?.touched ?? false) => {
54
56
  const issues = result.issues ?? [];
@@ -168,7 +170,10 @@ export default function controller(host) {
168
170
  proposedChange = undefined;
169
171
  });
170
172
  };
171
- const emitItems = (next) => host.dispatch("value-change", next);
173
+ const emitItems = (next) => {
174
+ host.state.internalItems = next;
175
+ host.dispatch("value-change", next);
176
+ };
172
177
  const setMultiQuery = (query, trigger) => {
173
178
  if (host.state.query === undefined) {
174
179
  host.state.raw = query;
@@ -412,7 +417,14 @@ export default function controller(host) {
412
417
  };
413
418
  const observer = new MutationObserver(relabel);
414
419
  observer.observe(authored, { childList: true, subtree: true, characterData: true, attributes: true });
420
+ let lastItems = host.state.items;
421
+ if (Array.isArray(lastItems)) host.state.internalItems = lastItems;
415
422
  const stop = host.effect(() => {
423
+ // A consumer that sets `items` owns them; otherwise the component keeps its own.
424
+ if (host.state.items !== lastItems) {
425
+ lastItems = host.state.items;
426
+ if (Array.isArray(lastItems)) host.state.internalItems = lastItems;
427
+ }
416
428
  if (host.state.value !== lastValue) { lastValue = host.state.value; syncValue(); }
417
429
  if (host.state.query !== lastQuery) { lastQuery = host.state.query; syncQuery(); }
418
430
  if (host.state.disabled || host.state.readonly) close();
@@ -13,7 +13,7 @@
13
13
  </defs>
14
14
  <div :hidden="not open">
15
15
  <p class="hint" $value="hint"></p>
16
- <div class="grid" role="group" aria-label="Table dimensions" style:--ui-editor-table-grid-rows="rows" style:--ui-editor-table-grid-cols="cols">
16
+ <div class="grid" role="group" aria-label="Table dimensions" style:--_rows="rows" style:--_cols="cols">
17
17
  <button
18
18
  $each="cell of cells"
19
19
  $key="cell.key"
@@ -47,8 +47,8 @@
47
47
 
48
48
  .grid {
49
49
  display: grid;
50
- grid-template-columns: repeat(var(--ui-editor-table-grid-cols, 8), 1.25rem);
51
- grid-template-rows: repeat(var(--ui-editor-table-grid-rows, 8), 1.25rem);
50
+ grid-template-columns: repeat(var(--_cols, 8), 1.25rem);
51
+ grid-template-rows: repeat(var(--_rows, 8), 1.25rem);
52
52
  gap: 2px;
53
53
  margin-bottom: var(--ui-space-2);
54
54
  }
@@ -32,7 +32,7 @@
32
32
  :aria-checked="swatch.selected"
33
33
  :data-action="swatch.action"
34
34
  >
35
- <span class="swatch" class:default="not swatch.color" style:--ui-editor-table-context-swatch="swatch.color"></span>
35
+ <span class="swatch" class:default="not swatch.color" style:--_swatch="swatch.color"></span>
36
36
  <span class="swatch-label" $value="swatch.label"></span>
37
37
  </button>
38
38
  </div>
@@ -123,7 +123,7 @@
123
123
  height: 1rem;
124
124
  border-radius: 999px;
125
125
  border: 1px solid var(--ui-border);
126
- background: var(--ui-editor-table-context-swatch, #fff);
126
+ background: var(--_swatch, #fff);
127
127
  box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.35);
128
128
  flex-shrink: 0;
129
129
  }
@@ -67,7 +67,7 @@
67
67
  :aria-checked="swatch.selected"
68
68
  :data-action="swatch.action"
69
69
  >
70
- <span class="swatch" class:default="not swatch.color" style:--ui-editor-table-toolbar-swatch="swatch.color"></span>
70
+ <span class="swatch" class:default="not swatch.color" style:--_swatch="swatch.color"></span>
71
71
  <span $value="swatch.label"></span>
72
72
  </button>
73
73
  </div>
@@ -255,7 +255,7 @@
255
255
  flex: 0 0 14px;
256
256
  border: 1px solid color-mix(in srgb, var(--ui-border) 80%, #000);
257
257
  border-radius: 3px;
258
- background: var(--ui-editor-table-toolbar-swatch);
258
+ background: var(--_swatch);
259
259
  }
260
260
 
261
261
  .swatch.default {
@@ -14,9 +14,10 @@
14
14
  gap: var(--ui-editor-toolbar-gap, 2px);
15
15
  padding: var(--ui-editor-toolbar-padding-block, 6px) var(--ui-editor-toolbar-padding-inline, 12px);
16
16
  min-height: var(--ui-editor-toolbar-min-height, 48px);
17
- overflow-x: auto;
18
- scrollbar-width: none;
19
- white-space: nowrap;
17
+ /* More controls than the column is wide is normal at a readable measure, so the row wraps.
18
+ Scrolling hid the tail behind an invisible scrollbar. */
19
+ flex-wrap: wrap;
20
+ row-gap: var(--ui-editor-toolbar-row-gap, var(--ui-editor-toolbar-gap, 2px));
20
21
  background: var(--ui-editor-toolbar-surface, var(--ui-surface));
21
22
  border-bottom: 1px solid var(--ui-editor-toolbar-border, var(--ui-border));
22
23
  }
@@ -38,6 +39,7 @@
38
39
  }
39
40
 
40
41
  :host-state([floating]) {
42
+ flex-wrap: nowrap;
41
43
  position: static;
42
44
  top: auto;
43
45
  width: max-content;
@@ -20,6 +20,19 @@
20
20
  transition: background-color var(--ui-motion-fast) var(--ui-motion-ease), color var(--ui-motion-fast) var(--ui-motion-ease);
21
21
  }
22
22
 
23
+ /* Centre the glyphs, not the line box: a label without descenders otherwise reads high. */
24
+ /* Centre the glyphs, not the line box: a label without descenders otherwise reads high.
25
+ * Trimming applies to block containers, so the box is a block with centred content. */
26
+ @supports (text-box-trim: trim-both) {
27
+ :host {
28
+ display: block;
29
+ min-block-size: var(--ui-menu-item-min-block-size, var(--ui-control-size-md, 2.5rem));
30
+ align-content: center;
31
+ text-box-trim: trim-both;
32
+ text-box-edge: cap alphabetic;
33
+ }
34
+ }
35
+
23
36
  :host:is(:hover, :focus-visible):not([aria-disabled="true"]) {
24
37
  background: var(--ui-menu-item-hover-surface, var(--ui-surface-subtle));
25
38
  }
@@ -11,7 +11,7 @@
11
11
  display: flex;
12
12
  flex-direction: column;
13
13
  align-items: stretch;
14
- gap: var(--ui-layout-gap, var(--_layout-gap));
14
+ gap: var(--ui-stack-gap, var(--_layout-gap));
15
15
  min-inline-size: 0;
16
16
  max-inline-size: 100%;
17
17
  margin: 0;
@@ -81,6 +81,19 @@
81
81
  border-color var(--ui-motion-fast) var(--ui-motion-ease);
82
82
  }
83
83
 
84
+ /* Centre the glyphs, not the line box: a label without descenders otherwise reads high. */
85
+ /* Centre the glyphs, not the line box: a label without descenders otherwise reads high.
86
+ * Trimming applies to block containers, so the box is a block with centred content. */
87
+ @supports (text-box-trim: trim-both) {
88
+ .list button {
89
+ display: block;
90
+ text-align: center;
91
+ align-content: center;
92
+ text-box-trim: trim-both;
93
+ text-box-edge: cap alphabetic;
94
+ }
95
+ }
96
+
84
97
  :host-state([stretch]) .list {
85
98
  grid-auto-columns: minmax(max-content, 1fr);
86
99
  }
@@ -72,12 +72,12 @@
72
72
  background: var(--ui-surface-elevated);
73
73
  color: var(--ui-text-primary);
74
74
  box-shadow: var(--ui-elevation-overlay, var(--ui-shadow-lg));
75
- animation: ui-toast-enter var(--ui-toast-enter-duration, 0.32s) var(--ui-motion-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both;
75
+ animation: ui-toast-enter var(--ui-toast-region-enter-duration, 0.32s) var(--ui-motion-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both;
76
76
  }
77
77
 
78
78
  /* Leaving slides on down and fades; the controller removes the toast when this ends. */
79
79
  .toast.closing {
80
- animation: ui-toast-exit var(--ui-toast-exit-duration, 0.22s) var(--ui-motion-ease) both;
80
+ animation: ui-toast-exit var(--ui-toast-region-exit-duration, 0.22s) var(--ui-motion-ease) both;
81
81
  pointer-events: none;
82
82
  }
83
83
 
@@ -3,6 +3,9 @@
3
3
  <prop name="for" type="string" default="">ID of the control described and visually anchored by the tooltip.</prop>
4
4
  <prop name="open" type="boolean" default="false">Whether the tooltip is open initially or when set externally.</prop>
5
5
  <prop name="placement" type="string" default="top-start">Positions the description relative to its trigger.</prop>
6
+ <prop name="trigger" type="hover | click | focus" default="hover"
7
+ >How the trigger opens it. hover also opens on keyboard focus; click toggles it and suits a help button, where hovering a question mark says nothing; focus
8
+ opens only while the trigger holds focus.</prop>
6
9
  <prop name="showDelay" type="number" default="500">Waits this many milliseconds before showing after pointer entry.</prop>
7
10
  <prop name="hideDelay" type="number" default="100">Waits this many milliseconds before hiding after pointer exit.</prop>
8
11
  <prop name="inverse" type="boolean" default="false">Use the high-contrast inverse surface (dark on light themes, light on dark themes).</prop>
@@ -21,9 +21,15 @@ export default function controller(host) {
21
21
  const setOpen = (open, input) => {
22
22
  if (Boolean(host.state.internalOpen) === open) return;
23
23
  host.state.internalOpen = open;
24
+ if (trigger && host.state.trigger === "click") trigger.setAttribute("aria-expanded", String(open));
24
25
  host.dispatch(open ? "open" : "close", { open, reason: "action", trigger: input });
25
26
  };
26
- const onKeydown = (event) => { if (event.key === "Escape") clearTimers(); };
27
+ const onKeydown = (event) => {
28
+ if (event.key !== "Escape") return;
29
+ clearTimers();
30
+ // A tooltip the reader opened is theirs to close.
31
+ if (host.state.trigger === "click" && host.state.internalOpen) setOpen(false, "keyboard");
32
+ };
27
33
  const onEnter = (event) => {
28
34
  if (event.pointerType === "touch") return;
29
35
  clearHide();
@@ -58,19 +64,34 @@ export default function controller(host) {
58
64
  if (ids.length) trigger.setAttribute("aria-describedby", ids.join(" "));
59
65
  else trigger.removeAttribute("aria-describedby");
60
66
  trigger.removeEventListener("keydown", onKeydown);
67
+ trigger.removeEventListener("click", onClick);
68
+ trigger.removeAttribute("aria-expanded");
61
69
  trigger.removeEventListener("pointerenter", onEnter);
62
70
  trigger.removeEventListener("pointerleave", onLeave);
63
71
  trigger.removeEventListener("focusin", onFocusin);
64
72
  trigger.removeEventListener("focusout", onFocusout);
65
73
  };
74
+ const onClick = () => {
75
+ clearTimers();
76
+ setOpen(!host.state.internalOpen, "pointer");
77
+ };
66
78
  const attach = () => {
67
79
  if (!trigger) return;
68
80
  const ids = new Set((trigger.getAttribute("aria-describedby") ?? "").split(/\s+/).filter(Boolean));
69
81
  ids.add(element.id);
70
82
  trigger.setAttribute("aria-describedby", [...ids].join(" "));
71
83
  trigger.addEventListener("keydown", onKeydown);
72
- trigger.addEventListener("pointerenter", onEnter);
73
- trigger.addEventListener("pointerleave", onLeave);
84
+ const how = host.state.trigger ?? "hover";
85
+ if (how === "click") {
86
+ // A question-mark button says nothing on hover: it opens when pressed, and closes the same way.
87
+ trigger.addEventListener("click", onClick);
88
+ trigger.setAttribute("aria-expanded", String(Boolean(host.state.internalOpen)));
89
+ return;
90
+ }
91
+ if (how === "hover") {
92
+ trigger.addEventListener("pointerenter", onEnter);
93
+ trigger.addEventListener("pointerleave", onLeave);
94
+ }
74
95
  trigger.addEventListener("focusin", onFocusin);
75
96
  trigger.addEventListener("focusout", onFocusout);
76
97
  };