@threadlabs/looma 0.17.4 → 0.18.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 (47) hide show
  1. package/components/ui-avatar/ui-avatar.html +8 -4
  2. package/components/ui-badge/ui-badge.html +28 -34
  3. package/components/ui-combobox/ui-combobox.html +4 -48
  4. package/components/ui-input/ui-input.html +1 -1
  5. package/components/ui-input-group/ui-input-group.html +1 -1
  6. package/components/ui-listbox/ui-listbox.html +88 -12
  7. package/components/ui-listbox/ui-listbox.js +162 -18
  8. package/components/ui-select/ui-select.html +1 -1
  9. package/components/ui-spinner/ui-spinner.html +47 -14
  10. package/components/ui-textarea/ui-textarea.html +1 -1
  11. package/dist/index.js +1 -1
  12. package/editor/chunks/extensions.js +5 -0
  13. package/package.json +1 -1
  14. package/styles/ui-avatar.css +6 -2
  15. package/styles/ui-badge.css +30 -40
  16. package/styles/ui-combobox.css +0 -52
  17. package/styles/ui-input-group.css +1 -1
  18. package/styles/ui-input.css +1 -1
  19. package/styles/ui-listbox.css +33 -8
  20. package/styles/ui-select.css +1 -1
  21. package/styles/ui-spinner.css +40 -15
  22. package/styles/ui-textarea.css +1 -1
  23. package/tokens.css +67 -2
  24. package/vanilla/UiCombobox.js +9 -1
  25. package/vanilla/UiListbox.d.ts +3 -1
  26. package/vanilla/UiListbox.js +65 -18
  27. package/vanilla/UiSpinner.js +19 -1
  28. package/vue/UiAvatar.vue +11 -2
  29. package/vue/UiBadge.vue +30 -36
  30. package/vue/UiCombobox.vue +4 -49
  31. package/vue/UiInput.vue +1 -1
  32. package/vue/UiInputGroup.vue +1 -1
  33. package/vue/UiListbox.d.ts +18 -5
  34. package/vue/UiListbox.vue +142 -25
  35. package/vue/UiSelect.vue +1 -1
  36. package/vue/UiSpinner.vue +47 -14
  37. package/vue/UiTextarea.vue +1 -1
  38. package/vue/chunks/UiAvatar.js +2 -1
  39. package/vue/chunks/UiBadge.js +1 -1
  40. package/vue/chunks/UiCombobox.js +11 -2
  41. package/vue/chunks/UiInput.js +1 -1
  42. package/vue/chunks/UiInputGroup.js +1 -1
  43. package/vue/chunks/UiListbox.js +134 -20
  44. package/vue/chunks/UiSelect.js +1 -1
  45. package/vue/chunks/UiSpinner.js +29 -3
  46. package/vue/chunks/UiTextarea.js +1 -1
  47. package/vue/components.css +248 -251
@@ -2,7 +2,7 @@
2
2
  import { manageComponentLifecycle } from "@nextwebwg/html-next/runtime";
3
3
  import "../styles/ui-spinner.css";
4
4
 
5
- const definition = {...{"contract":{"tag":"ui-spinner","props":{"label":{"type":"string","required":false,"target":{"attribute":"label"},"default":""},"size":{"type":{"enum":["sm","md"]},"required":false,"target":{"attribute":"size"},"default":"md"}}},"template":{"kind":"element","name":"span","attributes":[{"kind":"literal","name":"class","value":"ring"},{"kind":"attribute","name":"role","expression":"{ true: 'status', false: null }[format('%s', label != '')]","expressionPlan":{"source":"{ true: 'status', false: null }[format('%s', label != '')]","ast":{"kind":"index","object":{"kind":"object","pairs":[{"key":"true","value":{"kind":"literal","value":"status"}},{"key":"false","value":{"kind":"literal","value":null}}]},"index":{"kind":"call","fn":"format","args":[{"kind":"literal","value":"%s"},{"kind":"binary","op":"!=","left":{"kind":"id","name":"label"},"right":{"kind":"literal","value":""}}]}},"dependencies":["label"]}},{"kind":"attribute","name":"aria-label","expression":"{ true: label, false: null }[format('%s', label != '')]","expressionPlan":{"source":"{ true: label, false: null }[format('%s', label != '')]","ast":{"kind":"index","object":{"kind":"object","pairs":[{"key":"true","value":{"kind":"id","name":"label"}},{"key":"false","value":{"kind":"literal","value":null}}]},"index":{"kind":"call","fn":"format","args":[{"kind":"literal","value":"%s"},{"kind":"binary","op":"!=","left":{"kind":"id","name":"label"},"right":{"kind":"literal","value":""}}]}},"dependencies":["label"]}},{"kind":"attribute","name":"aria-hidden","expression":"{ true: null, false: 'true' }[format('%s', label != '')]","expressionPlan":{"source":"{ true: null, false: 'true' }[format('%s', label != '')]","ast":{"kind":"index","object":{"kind":"object","pairs":[{"key":"true","value":{"kind":"literal","value":null}},{"key":"false","value":{"kind":"literal","value":"true"}}]},"index":{"kind":"call","fn":"format","args":[{"kind":"literal","value":"%s"},{"kind":"binary","op":"!=","left":{"kind":"id","name":"label"},"right":{"kind":"literal","value":""}}]}},"dependencies":["label"]}}],"children":[]},"declarations":[],"root":{"kind":"native","element":"span","choices":["span"]}},source:{file:import.meta.url},css:""};
5
+ const definition = {...{"contract":{"tag":"ui-spinner","props":{"label":{"type":"string","required":false,"target":{"attribute":"label"},"default":""},"size":{"type":{"enum":["sm","md"]},"required":false,"target":{"attribute":"size"},"default":"md"}}},"template":{"kind":"element","name":"span","attributes":[{"kind":"literal","name":"class","value":"ring"},{"kind":"attribute","name":"role","expression":"{ true: 'status', false: null }[format('%s', label != '')]","expressionPlan":{"source":"{ true: 'status', false: null }[format('%s', label != '')]","ast":{"kind":"index","object":{"kind":"object","pairs":[{"key":"true","value":{"kind":"literal","value":"status"}},{"key":"false","value":{"kind":"literal","value":null}}]},"index":{"kind":"call","fn":"format","args":[{"kind":"literal","value":"%s"},{"kind":"binary","op":"!=","left":{"kind":"id","name":"label"},"right":{"kind":"literal","value":""}}]}},"dependencies":["label"]}},{"kind":"attribute","name":"aria-label","expression":"{ true: label, false: null }[format('%s', label != '')]","expressionPlan":{"source":"{ true: label, false: null }[format('%s', label != '')]","ast":{"kind":"index","object":{"kind":"object","pairs":[{"key":"true","value":{"kind":"id","name":"label"}},{"key":"false","value":{"kind":"literal","value":null}}]},"index":{"kind":"call","fn":"format","args":[{"kind":"literal","value":"%s"},{"kind":"binary","op":"!=","left":{"kind":"id","name":"label"},"right":{"kind":"literal","value":""}}]}},"dependencies":["label"]}},{"kind":"attribute","name":"aria-live","expression":"{ true: 'polite', false: null }[format('%s', label != '')]","expressionPlan":{"source":"{ true: 'polite', false: null }[format('%s', label != '')]","ast":{"kind":"index","object":{"kind":"object","pairs":[{"key":"true","value":{"kind":"literal","value":"polite"}},{"key":"false","value":{"kind":"literal","value":null}}]},"index":{"kind":"call","fn":"format","args":[{"kind":"literal","value":"%s"},{"kind":"binary","op":"!=","left":{"kind":"id","name":"label"},"right":{"kind":"literal","value":""}}]}},"dependencies":["label"]}},{"kind":"attribute","name":"aria-hidden","expression":"{ true: null, false: 'true' }[format('%s', label != '')]","expressionPlan":{"source":"{ true: null, false: 'true' }[format('%s', label != '')]","ast":{"kind":"index","object":{"kind":"object","pairs":[{"key":"true","value":{"kind":"literal","value":null}},{"key":"false","value":{"kind":"literal","value":"true"}}]},"index":{"kind":"call","fn":"format","args":[{"kind":"literal","value":"%s"},{"kind":"binary","op":"!=","left":{"kind":"id","name":"label"},"right":{"kind":"literal","value":""}}]}},"dependencies":["label"]}}],"children":[{"kind":"element","name":"svg","attributes":[{"kind":"literal","name":"viewBox","value":"0 0 50 50"},{"kind":"literal","name":"fill","value":"none"},{"kind":"literal","name":"aria-hidden","value":"true"},{"kind":"literal","name":"focusable","value":"false"}],"children":[{"kind":"element","name":"circle","attributes":[{"kind":"literal","name":"class","value":"track"},{"kind":"literal","name":"cx","value":"25"},{"kind":"literal","name":"cy","value":"25"},{"kind":"literal","name":"r","value":"20"}],"children":[]},{"kind":"element","name":"circle","attributes":[{"kind":"literal","name":"class","value":"arc"},{"kind":"literal","name":"cx","value":"25"},{"kind":"literal","name":"cy","value":"25"},{"kind":"literal","name":"r","value":"20"}],"children":[]}]}]},"declarations":[],"root":{"kind":"native","element":"span","choices":["span"]}},source:{file:import.meta.url},css:""};
6
6
 
7
7
  export function createUiSpinner(options = {}) {
8
8
  const { attributes = {}, children = [], slots = {}, ...componentProps } = options;
@@ -14,6 +14,24 @@ export function createUiSpinner(options = {}) {
14
14
  }
15
15
  element.setAttribute("class", ["ring", element.getAttribute("class")].filter(Boolean).join(" "));
16
16
  element.setAttribute("data-component", "ui-spinner");
17
+ const element0 = document.createElementNS("http://www.w3.org/2000/svg", "svg");
18
+ element0.setAttribute("viewBox", "0 0 50 50");
19
+ element0.setAttribute("fill", "none");
20
+ element0.setAttribute("aria-hidden", "true");
21
+ element0.setAttribute("focusable", "false");
22
+ const element1 = document.createElementNS("http://www.w3.org/2000/svg", "circle");
23
+ element1.setAttribute("class", "track");
24
+ element1.setAttribute("cx", "25");
25
+ element1.setAttribute("cy", "25");
26
+ element1.setAttribute("r", "20");
27
+ element0.append(element1);
28
+ const element2 = document.createElementNS("http://www.w3.org/2000/svg", "circle");
29
+ element2.setAttribute("class", "arc");
30
+ element2.setAttribute("cx", "25");
31
+ element2.setAttribute("cy", "25");
32
+ element2.setAttribute("r", "20");
33
+ element0.append(element2);
34
+ element.append(element0);
17
35
  manageComponentLifecycle(element, definition, { props: componentProps, projected,
18
36
  });
19
37
  return element;
package/vue/UiAvatar.vue CHANGED
@@ -40,6 +40,7 @@ const showGlyph = ref(false)
40
40
  const hostState = computed(() =>
41
41
  [
42
42
  props.size && `size size=${props.size}`,
43
+ hasImage.value && 'hasImage',
43
44
  props.shape && `shape shape=${props.shape}`,
44
45
  props.active && 'active',
45
46
  ].filter(Boolean).join(' ')
@@ -108,7 +109,11 @@ useComponentHost(controllerModule.default, {
108
109
  inline-size: 2.5rem;
109
110
  block-size: 2.5rem;
110
111
  border-radius: var(--ui-avatar-radius, var(--ui-radius-round));
111
- border: 1px solid var(--ui-avatar-border, var(--ui-border-strong));
112
+ border: 1px solid
113
+ var(
114
+ --ui-avatar-border,
115
+ color-mix(in srgb, var(--ui-accent-solid) 35%, var(--ui-surface-default))
116
+ );
112
117
  background: var(--ui-avatar-surface, var(--ui-accent-soft));
113
118
  color: var(--ui-avatar-text, var(--ui-accent-subtle-text, var(--ui-accent-solid)));
114
119
  font: inherit;
@@ -124,6 +129,10 @@ useComponentHost(controllerModule.default, {
124
129
  font-size: var(--ui-font-size-xs);
125
130
  }
126
131
 
132
+ [data-component~="ui-avatar"][data-ui-avatar-state~="hasImage"] {
133
+ border-color: var(--ui-avatar-border, var(--ui-border-strong));
134
+ }
135
+
127
136
  [data-component~="ui-avatar"][data-ui-avatar-state~="shape=square"] {
128
137
  border-radius: var(--ui-avatar-radius, var(--ui-radius-md));
129
138
  }
@@ -131,7 +140,7 @@ useComponentHost(controllerModule.default, {
131
140
  /* An outline, not a dot: the avatar clips its content to the circle, and an outline is drawn
132
141
  outside the box, so the ring survives the clip and the overlap in an Avatar Group. */
133
142
  [data-component~="ui-avatar"][data-ui-avatar-state~="active"] {
134
- outline: 2px solid var(--ui-avatar-active-ring, var(--ui-success));
143
+ outline: 1px solid var(--ui-avatar-active-ring, var(--ui-accent-solid));
135
144
  outline-offset: 1px;
136
145
  }
137
146
 
package/vue/UiBadge.vue CHANGED
@@ -83,7 +83,7 @@ const hostState = computed(() =>
83
83
  }
84
84
 
85
85
  [data-component~="ui-badge"] {
86
- --_badge-surface: var(--ui-surface-subtle);
86
+ --_badge-surface: color-mix(in srgb, var(--ui-surface-subtle) 80%, var(--ui-surface-default));
87
87
  --_badge-text: var(--ui-text-secondary);
88
88
  /* Sit between the soft fill and its stronger theme ink. */
89
89
  --_badge-border: color-mix(in srgb, var(--_badge-surface) 75%, var(--_badge-text) 25%);
@@ -91,7 +91,7 @@ const hostState = computed(() =>
91
91
  surface, text, and border hooks. Each value is invalid, so the tone shows, while it is unset. The
92
92
  mixes keep 4.5:1 text contrast for any colour, in a light theme or a dark one. */
93
93
  --_ui-badge-color: var(--ui-badge-color);
94
- --_badge-color-wash: color-mix(in oklab, var(--_ui-badge-color) 15%, var(--ui-surface-default));
94
+ --_badge-color-wash: color-mix(in oklab, var(--_ui-badge-color) 13%, var(--ui-surface-default));
95
95
  --_badge-color-ink: color-mix(in oklab, var(--_ui-badge-color) 35%, var(--ui-text-primary));
96
96
  --_badge-color-surface: var(--_badge-color-wash);
97
97
  --_badge-color-border: color-mix(
@@ -167,32 +167,32 @@ const hostState = computed(() =>
167
167
  }
168
168
 
169
169
  [data-component~="ui-badge"][data-ui-badge-state~="variant=subtle"][data-ui-badge-state~="tone=accent"] {
170
- --_badge-surface: color-mix(in srgb, var(--ui-accent-solid) 22%, var(--ui-surface-default));
171
- --_badge-border: color-mix(in srgb, var(--ui-accent-solid) 28%, var(--ui-surface-default));
170
+ --_badge-surface: color-mix(in srgb, var(--ui-accent-solid) 19%, var(--ui-surface-default));
171
+ --_badge-border: color-mix(in srgb, var(--ui-accent-solid) 25%, var(--ui-surface-default));
172
172
  --_badge-text: var(--ui-accent-subtle-text, var(--ui-accent-solid));
173
173
  }
174
174
 
175
175
  [data-component~="ui-badge"][data-ui-badge-state~="variant=subtle"][data-ui-badge-state~="tone=danger"] {
176
- --_badge-surface: color-mix(in srgb, var(--ui-danger-solid) 22%, var(--ui-surface-default));
177
- --_badge-border: color-mix(in srgb, var(--ui-danger-solid) 28%, var(--ui-surface-default));
176
+ --_badge-surface: color-mix(in srgb, var(--ui-danger-solid) 19%, var(--ui-surface-default));
177
+ --_badge-border: color-mix(in srgb, var(--ui-danger-solid) 25%, var(--ui-surface-default));
178
178
  --_badge-text: var(--ui-danger-subtle-text, var(--ui-danger-solid));
179
179
  }
180
180
 
181
181
  [data-component~="ui-badge"][data-ui-badge-state~="variant=subtle"][data-ui-badge-state~="tone=success"] {
182
- --_badge-surface: color-mix(in srgb, var(--ui-success-solid) 22%, var(--ui-surface-default));
183
- --_badge-border: color-mix(in srgb, var(--ui-success-solid) 28%, var(--ui-surface-default));
182
+ --_badge-surface: color-mix(in srgb, var(--ui-success-solid) 19%, var(--ui-surface-default));
183
+ --_badge-border: color-mix(in srgb, var(--ui-success-solid) 25%, var(--ui-surface-default));
184
184
  --_badge-text: var(--ui-success-subtle-text, var(--ui-success-solid));
185
185
  }
186
186
 
187
187
  [data-component~="ui-badge"][data-ui-badge-state~="variant=subtle"][data-ui-badge-state~="tone=warning"] {
188
- --_badge-surface: color-mix(in srgb, var(--ui-warning-solid) 22%, var(--ui-surface-default));
189
- --_badge-border: color-mix(in srgb, var(--ui-warning-solid) 28%, var(--ui-surface-default));
188
+ --_badge-surface: color-mix(in srgb, var(--ui-warning-solid) 19%, var(--ui-surface-default));
189
+ --_badge-border: color-mix(in srgb, var(--ui-warning-solid) 25%, var(--ui-surface-default));
190
190
  --_badge-text: var(--ui-warning-subtle-text, var(--ui-warning-solid));
191
191
  }
192
192
 
193
193
  [data-component~="ui-badge"][data-ui-badge-state~="variant=subtle"][data-ui-badge-state~="tone=info"] {
194
- --_badge-surface: color-mix(in srgb, var(--ui-info-solid) 22%, var(--ui-surface-default));
195
- --_badge-border: color-mix(in srgb, var(--ui-info-solid) 28%, var(--ui-surface-default));
194
+ --_badge-surface: color-mix(in srgb, var(--ui-info-solid) 19%, var(--ui-surface-default));
195
+ --_badge-border: color-mix(in srgb, var(--ui-info-solid) 25%, var(--ui-surface-default));
196
196
  --_badge-text: var(--ui-info-subtle-text, var(--ui-info-solid));
197
197
  }
198
198
 
@@ -238,14 +238,16 @@ const hostState = computed(() =>
238
238
  --_badge-text: var(--ui-info-subtle-text, var(--ui-info-solid));
239
239
  }
240
240
 
241
- /* A tag is flat at the start and points at the end. The point is cut from the box, so it has no
242
- outline to follow: the edge is the fill. The end padding grows by the point's depth so the
243
- label never runs into it. */
241
+ /* The clipped point needs an inset surface over an outer border-colour shape: a native border
242
+ has no diagonal edge to follow. Keep this edge for every tag variant. */
244
243
  [data-component~="ui-badge"][data-ui-badge-state~="shape=tag"] {
245
244
  --_badge-point: var(--ui-badge-point, var(--ui-space-2));
246
245
 
246
+ position: relative;
247
+ isolation: isolate;
247
248
  border-color: transparent;
248
249
  border-radius: 0;
250
+ background: var(--ui-badge-border, var(--_badge-color-border, var(--_badge-border)));
249
251
  padding-inline-end: calc(var(--ui-badge-padding-inline, var(--ui-space-2)) + var(--_badge-point));
250
252
  clip-path: polygon(
251
253
  0 0,
@@ -256,26 +258,10 @@ const hostState = computed(() =>
256
258
  );
257
259
  }
258
260
 
259
- [data-component~="ui-badge"][data-ui-badge-state~="shape=tag"]:dir(rtl) {
260
- padding-inline-end: var(--ui-badge-padding-inline, var(--ui-space-2));
261
- padding-inline-start: calc(
262
- var(--ui-badge-padding-inline, var(--ui-space-2)) + var(--_badge-point)
263
- );
264
- clip-path: polygon(var(--_badge-point) 0, 100% 0, 100% 100%, var(--_badge-point) 100%, 0 50%);
265
- }
266
-
267
- /* The tag clips its point, including the native border. Draw the outer shape in the border
268
- colour and an inset inner shape in the surface colour to keep a visible diagonal edge. */
269
- [data-component~="ui-badge"][data-ui-badge-state~="variant=outline"][data-ui-badge-state~="shape=tag"] {
270
- position: relative;
271
- isolation: isolate;
272
- background: var(--ui-badge-border, var(--_badge-color-border, var(--_badge-border)));
273
- }
274
-
275
- [data-component~="ui-badge"][data-ui-badge-state~="variant=outline"][data-ui-badge-state~="shape=tag"]::before {
261
+ [data-component~="ui-badge"][data-ui-badge-state~="shape=tag"]::before {
276
262
  content: "";
277
263
  position: absolute;
278
- inset: 1px;
264
+ inset: 0;
279
265
  z-index: -1;
280
266
  background: var(--_badge-outline-surface);
281
267
  clip-path: polygon(
@@ -288,18 +274,26 @@ const hostState = computed(() =>
288
274
  pointer-events: none;
289
275
  }
290
276
 
291
- [data-component~="ui-badge"][data-ui-badge-state~="variant=outline"][data-ui-badge-state~="shape=tag"]:dir(rtl)::before {
277
+ [data-component~="ui-badge"][data-ui-badge-state~="shape=tag"]:dir(rtl) {
278
+ padding-inline-end: var(--ui-badge-padding-inline, var(--ui-space-2));
279
+ padding-inline-start: calc(
280
+ var(--ui-badge-padding-inline, var(--ui-space-2)) + var(--_badge-point)
281
+ );
282
+ clip-path: polygon(var(--_badge-point) 0, 100% 0, 100% 100%, var(--_badge-point) 100%, 0 50%);
283
+ }
284
+
285
+ [data-component~="ui-badge"][data-ui-badge-state~="shape=tag"]:dir(rtl)::before {
292
286
  clip-path: polygon(var(--_badge-point) 0, 100% 0, 100% 100%, var(--_badge-point) 100%, 0 50%);
293
287
  }
294
288
 
295
289
  @media (forced-colors: active) {
296
- [data-component~="ui-badge"][data-ui-badge-state~="variant=outline"][data-ui-badge-state~="shape=tag"] {
290
+ [data-component~="ui-badge"][data-ui-badge-state~="shape=tag"] {
297
291
  forced-color-adjust: none;
298
292
  background: CanvasText;
299
293
  color: CanvasText;
300
294
  }
301
295
 
302
- [data-component~="ui-badge"][data-ui-badge-state~="variant=outline"][data-ui-badge-state~="shape=tag"]::before {
296
+ [data-component~="ui-badge"][data-ui-badge-state~="shape=tag"]::before {
303
297
  background: Canvas;
304
298
  }
305
299
  }
@@ -937,6 +937,8 @@ defineExpose({
937
937
  :key="row.id"
938
938
  class="option"
939
939
  role="option"
940
+ data-ui-choice-option=""
941
+ :data-multiple="multiple ? '' : undefined"
940
942
  :id="`${uid}-option-${row.index}`"
941
943
  :aria-selected="row.selected"
942
944
  :aria-disabled="row.disabled"
@@ -969,7 +971,9 @@ defineExpose({
969
971
  v-if="creatable"
970
972
  class="option"
971
973
  role="option"
974
+ data-ui-choice-option=""
972
975
  aria-selected="false"
976
+ :data-multiple="multiple ? '' : undefined"
973
977
  :id="`${uid}-option-${createIndex}`"
974
978
  :data-index="createIndex"
975
979
  :class="{ active: createIndex === active }"
@@ -1260,21 +1264,6 @@ input:disabled, button:disabled {
1260
1264
  [hidden] {
1261
1265
  display: none;
1262
1266
  }
1263
- .option {
1264
- padding: var(--ui-space-2) var(--ui-space-3);
1265
- border-radius: var(--ui-radius-sm);
1266
- cursor: pointer;
1267
- overflow-wrap: anywhere;
1268
- }
1269
- .option:hover, .option.active {
1270
- background: var(--ui-surface-muted);
1271
- outline: 1px solid var(--ui-focus-ring);
1272
- outline-offset: -1px;
1273
- }
1274
- .option[aria-disabled="true"] {
1275
- color: var(--ui-disabled-text);
1276
- cursor: not-allowed;
1277
- }
1278
1267
  .option[aria-disabled="true"] .primary,
1279
1268
  .option[aria-disabled="true"] .secondary {
1280
1269
  color: var(--ui-disabled-text);
@@ -1342,40 +1331,6 @@ input:disabled, button:disabled {
1342
1331
  }
1343
1332
  }
1344
1333
 
1345
- /* Multiple mode: every option carries a checkbox, so a list that takes several answers says so
1346
- before anything is picked. aria-selected is the state; this draws it.
1347
- Longhands, not shorthands: lowering drops a shorthand that omits a component. */
1348
- [data-component~="ui-combobox"][data-ui-combobox-state~="multiple"] .option {
1349
- display: flex;
1350
- gap: var(--ui-space-2);
1351
- align-items: center;
1352
- }
1353
-
1354
- [data-component~="ui-combobox"][data-ui-combobox-state~="multiple"] .option::before {
1355
- content: "";
1356
- flex: none;
1357
- inline-size: 1.125rem;
1358
- block-size: 1.125rem;
1359
- border-style: solid;
1360
- border-width: 1px;
1361
- border-color: var(--ui-control-border-hover, var(--ui-border-strong));
1362
- border-radius: var(--ui-radius-sm);
1363
- background-color: var(--ui-control-surface, var(--ui-surface-default));
1364
- background-repeat: no-repeat;
1365
- background-position: center;
1366
- background-size: 0.7rem;
1367
- }
1368
-
1369
- [data-component~="ui-combobox"][data-ui-combobox-state~="multiple"]
1370
- .option[aria-selected="true"]::before {
1371
- border-color: var(--ui-accent-solid);
1372
- background-color: var(--ui-accent-solid);
1373
- background-image: var(
1374
- --_ui-combobox-option-check,
1375
- url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12 5 5 9-9'/%3E%3C/svg%3E")
1376
- );
1377
- }
1378
-
1379
1334
  /* Multiple mode keeps one control height. Chips and the input scroll within the entry,
1380
1335
  while the clear and disclosure controls remain fixed at the end of the field. */
1381
1336
  [data-component~="ui-combobox"][data-ui-combobox-state~="multiple"] .field {
package/vue/UiInput.vue CHANGED
@@ -197,7 +197,7 @@ useComponentHost(controllerModule.default, {
197
197
  opacity: 1;
198
198
  }
199
199
 
200
- [data-component~="ui-input"]:hover:not(:focus):not(:disabled) {
200
+ [data-component~="ui-input"]:hover:not(:focus):not(:disabled):not(:read-only):not([aria-invalid="true"]):not(:is(:user-invalid, [data-user-invalid])) {
201
201
  border-color: var(
202
202
  --ui-input-border-hover,
203
203
  var(--_ui-default-input-border-hover, var(--ui-control-border-hover, var(--ui-text-secondary)))
@@ -118,7 +118,7 @@ useComponentHost(controllerModule.default, {
118
118
  }
119
119
  }
120
120
 
121
- [data-component~="ui-input-group"]:hover:not(:has(input:focus, input:disabled)) {
121
+ [data-component~="ui-input-group"]:hover:not(:has(input:focus, input:disabled, input:read-only, [aria-invalid="true"], :is(:user-invalid, [data-user-invalid]))) {
122
122
  border-color: var(--ui-control-border-hover, var(--ui-text-secondary));
123
123
  }
124
124
 
@@ -2,21 +2,34 @@ type __VLS_Props = {
2
2
  disabled?: boolean;
3
3
  invalid?: boolean;
4
4
  multiple?: boolean;
5
+ name?: string;
5
6
  required?: boolean;
6
7
  rows?: number;
7
8
  value?: string;
8
9
  values?: readonly (string)[];
9
- modelValue?: string | null;
10
10
  };
11
- declare var __VLS_1: {};
11
+ declare var __VLS_9: {};
12
12
  type __VLS_Slots = {} & {
13
- default?: (props: typeof __VLS_1) => any;
13
+ default?: (props: typeof __VLS_9) => any;
14
14
  };
15
15
  declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
16
- "update:modelValue": (value: string) => any;
16
+ change: (detail: {
17
+ readonly value: string | null;
18
+ readonly values: readonly (string)[];
19
+ readonly trigger: "keyboard" | "pointer";
20
+ }) => any;
21
+ "update:value": (value: string) => any;
22
+ "update:values": (value: readonly string[]) => any;
17
23
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
18
- "onUpdate:modelValue"?: ((value: string) => any) | undefined;
24
+ onChange?: ((detail: {
25
+ readonly value: string | null;
26
+ readonly values: readonly (string)[];
27
+ readonly trigger: "keyboard" | "pointer";
28
+ }) => any) | undefined;
29
+ "onUpdate:value"?: ((value: string) => any) | undefined;
30
+ "onUpdate:values"?: ((value: readonly string[]) => any) | undefined;
19
31
  }>, {
32
+ name: string;
20
33
  invalid: boolean;
21
34
  disabled: boolean;
22
35
  required: boolean;
package/vue/UiListbox.vue CHANGED
@@ -1,8 +1,9 @@
1
1
  <!-- Generated by HTML Next 1.0.0-alpha.7 for Vue 3.5. Do not edit. -->
2
2
  <script setup lang="ts">
3
- import { computed, ref } from 'vue'
3
+ import { ref, useTemplateRef } from 'vue'
4
4
  import * as controllerModule from '../components/ui-listbox/ui-listbox.js'
5
5
  import { createDispatch, useComponentHost } from './host'
6
+ import UiScrollArea from './UiScrollArea.vue'
6
7
 
7
8
  defineOptions({ inheritAttrs: false })
8
9
 
@@ -11,53 +12,144 @@ const props = withDefaults(
11
12
  disabled?: boolean
12
13
  invalid?: boolean
13
14
  multiple?: boolean
15
+ name?: string
14
16
  required?: boolean
15
17
  rows?: number
16
18
  value?: string
17
19
  values?: readonly (string)[]
18
- modelValue?: string | null
19
20
  }>(),
20
21
  {
21
22
  disabled: false,
22
23
  invalid: false,
23
24
  multiple: false,
25
+ name: '',
24
26
  required: false,
25
27
  rows: 5,
26
28
  },
27
29
  )
28
30
  const emit = defineEmits<{
29
- 'update:modelValue': [value: string]
31
+ 'update:value': [value: string]
32
+ 'update:values': [value: readonly (string)[]]
33
+ change: [
34
+ detail: {
35
+ readonly value: string | null
36
+ readonly values: readonly (string)[]
37
+ readonly trigger: 'keyboard' | 'pointer'
38
+ },
39
+ ]
30
40
  }>()
31
- const model = computed({
32
- get: () => props.modelValue ?? props.value ?? undefined,
33
- set: (value) => emit('update:modelValue', value as string),
34
- })
35
41
 
36
42
  const root = ref<HTMLElement | null>(null)
43
+ const choicesElement = useTemplateRef<HTMLElement>('choices')
44
+ const optionsElement = useTemplateRef<HTMLElement>('options')
45
+ const validityElement = useTemplateRef<HTMLElement>('validity')
46
+ const choices = ref<
47
+ { value: string; label: string; disabled: boolean; selected: boolean; index: number }[]
48
+ >([])
49
+ const selection = ref<string[]>([])
50
+ const hasSelection = ref(false)
51
+ const active = ref(-1)
52
+ const uid = ref('')
53
+ const validationError = ref(false)
37
54
 
38
- const dispatch = createDispatch(root)
55
+ const isChangeDetail = (detail: unknown): boolean =>
56
+ detail !== null && typeof detail === 'object' && !Array.isArray(detail)
57
+ && (typeof (detail as Record<string, unknown>)['value'] === 'string'
58
+ || (detail as Record<string, unknown>)['value'] === null)
59
+ && (Array.isArray((detail as Record<string, unknown>)['values'])
60
+ && ((detail as Record<string, unknown>)['values'] as unknown[]).every((item: unknown) =>
61
+ typeof item === 'string'
62
+ ))
63
+ && ((detail as Record<string, unknown>)['trigger'] === 'keyboard'
64
+ || (detail as Record<string, unknown>)['trigger'] === 'pointer')
65
+ && Object.keys(detail as object).every((key) => ['value', 'values', 'trigger'].includes(key))
66
+ const dispatch = createDispatch(root, emit as (name: string, detail: unknown) => void, {
67
+ checks: {
68
+ change: isChangeDetail,
69
+ },
70
+ modeled: ['value', 'values'],
71
+ })
39
72
 
40
73
  useComponentHost(controllerModule.default, {
41
74
  root,
42
75
  dispatch,
43
76
  props,
77
+ refs: { choices: choicesElement, options: optionsElement, validity: validityElement },
78
+ state: { choices, selection, hasSelection, active, uid, validationError },
44
79
  })
80
+
81
+ function text(value: unknown): string {
82
+ if (Array.isArray(value)) return value.map(text).join(' ')
83
+ return value === null || value === undefined || typeof value === 'object' ? '' : String(value)
84
+ }
85
+
86
+ function attribute(value: unknown): any {
87
+ if (
88
+ value === null || value === undefined || value === false
89
+ || (typeof value === 'object' && !Array.isArray(value))
90
+ ) return undefined
91
+ return value === true ? '' : text(value)
92
+ }
45
93
  </script>
46
94
 
47
95
  <template>
48
- <select
96
+ <div
49
97
  data-component="ui-listbox"
98
+ role="listbox"
50
99
  v-bind="$attrs"
51
- :size="rows"
52
- :multiple="multiple"
53
- :disabled="disabled"
54
- :required="required"
55
- :aria-invalid="invalid"
56
- v-model="model"
100
+ :tabindex="attribute(({ true: '-1', false: '0' })[`${disabled}`])"
101
+ :aria-multiselectable="multiple"
102
+ :aria-disabled="disabled"
103
+ :aria-required="required"
104
+ :aria-invalid="invalid || validationError"
105
+ :style="{ '--_rows': rows }"
57
106
  ref="root"
58
107
  >
59
- <slot />
60
- </select>
108
+ <UiScrollArea class="choices" ref="choices"><div
109
+ v-for="choice in choices"
110
+ :key="choice.index"
111
+ class="option"
112
+ role="option"
113
+ data-ui-choice-option=""
114
+ :data-multiple="multiple ? '' : undefined"
115
+ :id="`${uid}-option-${choice.index}`"
116
+ :data-index="choice.index"
117
+ :aria-selected="choice.selected"
118
+ :aria-disabled="choice.disabled"
119
+ :class="{ active: choice.index === active }"
120
+ >
121
+ <span>{{ choice.label }}</span>
122
+ </div></UiScrollArea>
123
+ <select
124
+ class="fallback"
125
+ aria-label="Choices"
126
+ :size="rows"
127
+ :multiple="multiple"
128
+ :disabled="disabled"
129
+ :required="required"
130
+ :name="name"
131
+ ref="options"
132
+ >
133
+ <slot />
134
+ </select>
135
+ <template v-if="name && !disabled"><input
136
+ v-for="(selected, index) in selection"
137
+ :key="index"
138
+ type="hidden"
139
+ :name="name"
140
+ :value="selected"
141
+ ></template>
142
+ <input
143
+ class="validity"
144
+ type="checkbox"
145
+ aria-hidden="true"
146
+ tabindex="-1"
147
+ :required="required && !hasSelection && !disabled"
148
+ :checked="hasSelection"
149
+ :disabled="disabled"
150
+ ref="validity"
151
+ >
152
+ </div>
61
153
  </template>
62
154
 
63
155
  <style scoped>
@@ -83,29 +175,55 @@ useComponentHost(controllerModule.default, {
83
175
  }
84
176
 
85
177
  [data-component~="ui-listbox"] {
178
+ position: relative;
86
179
  box-sizing: border-box;
87
180
  display: block;
88
181
  inline-size: 100%;
89
182
  min-inline-size: 0;
90
- min-block-size: var(--ui-control-min-block-size);
91
183
  margin: 0;
92
184
  padding: var(--ui-space-1);
93
185
  border: 1px solid var(--ui-listbox-border, var(--ui-control-border));
94
186
  border-radius: var(--ui-listbox-radius, var(--ui-field-radius, var(--ui-radius-md)));
95
187
  background: var(--ui-listbox-surface, var(--ui-control-surface));
96
188
  color: var(--ui-text-primary);
97
- color-scheme: light dark;
98
189
  font: inherit;
99
190
  line-height: var(--ui-line-height-md);
100
191
  box-shadow: var(--ui-control-inset);
192
+ outline: none;
101
193
  }
102
194
 
103
- [data-component~="ui-listbox"] option,
104
- [data-component~="ui-listbox"] optgroup {
105
- padding: var(--ui-space-2);
195
+ .choices {
196
+ max-block-size: calc(max(2, var(--_rows, 5)) * var(--ui-control-size-md));
197
+ }
198
+
199
+ [data-component~="ui-listbox"]:not([data-enhanced]) {
200
+ padding: 0;
201
+ border: 0;
202
+ background: transparent;
203
+ box-shadow: none;
204
+ }
205
+
206
+ [data-component~="ui-listbox"]:not([data-enhanced]) .choices,
207
+ [data-component~="ui-listbox"][data-enhanced] .fallback {
208
+ display: none;
209
+ }
210
+
211
+ .fallback {
212
+ box-sizing: border-box;
213
+ inline-size: 100%;
106
214
  font: inherit;
107
215
  }
108
216
 
217
+ .validity {
218
+ position: absolute;
219
+ inline-size: 1px;
220
+ block-size: 1px;
221
+ overflow: hidden;
222
+ clip-path: inset(50%);
223
+ opacity: 0;
224
+ pointer-events: none;
225
+ }
226
+
109
227
  [data-component~="ui-listbox"]:focus-visible {
110
228
  border-color: var(--ui-control-focus);
111
229
  outline: 2px solid var(--ui-focus-ring);
@@ -113,12 +231,11 @@ useComponentHost(controllerModule.default, {
113
231
  box-shadow: var(--ui-listbox-focus-shadow, var(--ui-focus-halo));
114
232
  }
115
233
 
116
- [data-component~="ui-listbox"][aria-invalid="true"],
117
- [data-component~="ui-listbox"]:is(:user-invalid, [data-user-invalid]) {
234
+ [data-component~="ui-listbox"][aria-invalid="true"] {
118
235
  border-color: var(--ui-listbox-invalid-border, var(--ui-field-danger, var(--ui-danger)));
119
236
  }
120
237
 
121
- [data-component~="ui-listbox"]:disabled {
238
+ [data-component~="ui-listbox"][aria-disabled="true"] {
122
239
  background: var(--ui-control-surface-disabled);
123
240
  color: var(--ui-disabled-text);
124
241
  box-shadow: none;
package/vue/UiSelect.vue CHANGED
@@ -142,7 +142,7 @@ useComponentHost(controllerModule.default, {
142
142
  background-size: 1.125rem 1.125rem;
143
143
  }
144
144
 
145
- [data-component~="ui-select"]:hover:not(:focus):not(:disabled) {
145
+ [data-component~="ui-select"]:hover:not(:focus):not(:disabled):not([aria-invalid="true"]):not(:is(:user-invalid, [data-user-invalid])) {
146
146
  border-color: var(
147
147
  --ui-select-border-hover,
148
148
  var(--ui-control-border-hover, var(--ui-text-secondary))