@threadlabs/looma 0.17.3 → 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 (71) 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-button/ui-button.html +5 -0
  4. package/components/ui-checkbox/ui-checkbox.html +5 -5
  5. package/components/ui-combobox/ui-combobox.html +4 -48
  6. package/components/ui-icon-button/ui-icon-button.html +7 -7
  7. package/components/ui-input/ui-input.html +1 -1
  8. package/components/ui-input-group/ui-input-group.html +2 -2
  9. package/components/ui-listbox/ui-listbox.html +88 -12
  10. package/components/ui-listbox/ui-listbox.js +162 -18
  11. package/components/ui-search-result-row/ui-search-result-row.html +2 -6
  12. package/components/ui-search-shell/ui-search-shell.html +2 -11
  13. package/components/ui-select/ui-select.html +1 -1
  14. package/components/ui-spinner/ui-spinner.html +45 -18
  15. package/components/ui-textarea/ui-textarea.html +1 -1
  16. package/components/ui-tooltip/ui-tooltip.html +2 -2
  17. package/dist/index.js +1 -1
  18. package/editor/chunks/extensions.js +5 -0
  19. package/package.json +1 -1
  20. package/styles/ui-avatar.css +6 -2
  21. package/styles/ui-badge.css +30 -40
  22. package/styles/ui-button.css +7 -0
  23. package/styles/ui-checkbox.css +6 -6
  24. package/styles/ui-combobox.css +0 -52
  25. package/styles/ui-icon-button.css +7 -7
  26. package/styles/ui-input-group.css +2 -2
  27. package/styles/ui-input.css +1 -1
  28. package/styles/ui-listbox.css +33 -8
  29. package/styles/ui-search-result-row.css +2 -6
  30. package/styles/ui-search-shell.css +2 -13
  31. package/styles/ui-select.css +1 -1
  32. package/styles/ui-spinner.css +38 -19
  33. package/styles/ui-textarea.css +1 -1
  34. package/styles/ui-tooltip.css +2 -2
  35. package/tokens.css +67 -2
  36. package/vanilla/UiCombobox.js +9 -1
  37. package/vanilla/UiListbox.d.ts +3 -1
  38. package/vanilla/UiListbox.js +65 -18
  39. package/vanilla/UiSpinner.js +19 -1
  40. package/vue/UiAvatar.vue +11 -2
  41. package/vue/UiBadge.vue +30 -36
  42. package/vue/UiButton.vue +5 -0
  43. package/vue/UiCheckbox.vue +5 -5
  44. package/vue/UiCombobox.vue +4 -49
  45. package/vue/UiIconButton.vue +10 -7
  46. package/vue/UiInput.vue +1 -1
  47. package/vue/UiInputGroup.vue +2 -2
  48. package/vue/UiListbox.d.ts +18 -5
  49. package/vue/UiListbox.vue +142 -25
  50. package/vue/UiSearchResultRow.vue +2 -6
  51. package/vue/UiSearchShell.vue +2 -13
  52. package/vue/UiSelect.vue +1 -1
  53. package/vue/UiSpinner.vue +45 -18
  54. package/vue/UiTextarea.vue +1 -1
  55. package/vue/UiTooltip.vue +2 -2
  56. package/vue/chunks/UiAvatar.js +2 -1
  57. package/vue/chunks/UiBadge.js +1 -1
  58. package/vue/chunks/UiButton.js +1 -1
  59. package/vue/chunks/UiCheckbox.js +1 -1
  60. package/vue/chunks/UiCombobox.js +11 -2
  61. package/vue/chunks/UiIconButton.js +1 -1
  62. package/vue/chunks/UiInput.js +1 -1
  63. package/vue/chunks/UiInputGroup.js +1 -1
  64. package/vue/chunks/UiListbox.js +134 -20
  65. package/vue/chunks/UiSearchResultRow.js +1 -1
  66. package/vue/chunks/UiSearchShell.js +1 -1
  67. package/vue/chunks/UiSelect.js +1 -1
  68. package/vue/chunks/UiSpinner.js +29 -3
  69. package/vue/chunks/UiTextarea.js +1 -1
  70. package/vue/chunks/UiTooltip.js +1 -1
  71. package/vue/components.css +426 -440
@@ -18,8 +18,8 @@
18
18
  <prop name="shape" type="circle | square" default="circle">Circle by default; square uses the theme's medium radius.</prop>
19
19
  <prop name="decorative" type="boolean" default="false">Hides the avatar from assistive technology when adjacent text already identifies the person.</prop>
20
20
  <prop name="active" type="boolean" default="false"
21
- >Marks the person as active now, such as editing the same page: a ring in the success colour around the avatar. The ring is not announced, so say what it
22
- means in alt ("Ada Lovelace, editing").</prop>
21
+ >Marks the person as active now, such as editing the same page: a fine accent ring around the avatar. The ring is not announced, so say what it means in alt
22
+ ("Ada Lovelace, editing").</prop>
23
23
  <state name="hasImage" :value="false"></state>
24
24
  <state name="hasAuthoredImage" :value="false"></state>
25
25
  <state name="initials" :value="'?'"></state>
@@ -64,7 +64,7 @@
64
64
  inline-size: 2.5rem;
65
65
  block-size: 2.5rem;
66
66
  border-radius: var(--ui-avatar-radius, var(--ui-radius-round));
67
- border: 1px solid var(--ui-avatar-border, var(--ui-border-strong));
67
+ border: 1px solid var(--ui-avatar-border, color-mix(in srgb, var(--ui-accent-solid) 35%, var(--ui-surface-default)));
68
68
  background: var(--ui-avatar-surface, var(--ui-accent-soft));
69
69
  color: var(--ui-avatar-text, var(--ui-accent-subtle-text, var(--ui-accent-solid)));
70
70
  font: inherit;
@@ -80,6 +80,10 @@
80
80
  font-size: var(--ui-font-size-xs);
81
81
  }
82
82
 
83
+ :host-state([hasImage]) {
84
+ border-color: var(--ui-avatar-border, var(--ui-border-strong));
85
+ }
86
+
83
87
  :host-state([shape="square"]) {
84
88
  border-radius: var(--ui-avatar-radius, var(--ui-radius-md));
85
89
  }
@@ -87,7 +91,7 @@
87
91
  /* An outline, not a dot: the avatar clips its content to the circle, and an outline is drawn
88
92
  outside the box, so the ring survives the clip and the overlap in an Avatar Group. */
89
93
  :host-state([active]) {
90
- outline: 2px solid var(--ui-avatar-active-ring, var(--ui-success));
94
+ outline: 1px solid var(--ui-avatar-active-ring, var(--ui-accent-solid));
91
95
  outline-offset: 1px;
92
96
  }
93
97
 
@@ -59,7 +59,7 @@
59
59
  }
60
60
 
61
61
  :host {
62
- --_badge-surface: var(--ui-surface-subtle);
62
+ --_badge-surface: color-mix(in srgb, var(--ui-surface-subtle) 80%, var(--ui-surface-default));
63
63
  --_badge-text: var(--ui-text-secondary);
64
64
  /* Sit between the soft fill and its stronger theme ink. */
65
65
  --_badge-border: color-mix(in srgb, var(--_badge-surface) 75%, var(--_badge-text) 25%);
@@ -67,7 +67,7 @@
67
67
  surface, text, and border hooks. Each value is invalid, so the tone shows, while it is unset. The
68
68
  mixes keep 4.5:1 text contrast for any colour, in a light theme or a dark one. */
69
69
  --_ui-badge-color: var(--ui-badge-color);
70
- --_badge-color-wash: color-mix(in oklab, var(--_ui-badge-color) 15%, var(--ui-surface-default));
70
+ --_badge-color-wash: color-mix(in oklab, var(--_ui-badge-color) 13%, var(--ui-surface-default));
71
71
  --_badge-color-ink: color-mix(in oklab, var(--_ui-badge-color) 35%, var(--ui-text-primary));
72
72
  --_badge-color-surface: var(--_badge-color-wash);
73
73
  --_badge-color-border: color-mix(in srgb, var(--_badge-color-wash) 75%, var(--_badge-color-ink) 25%);
@@ -135,32 +135,32 @@
135
135
  }
136
136
 
137
137
  :host-state([variant="subtle"][tone="accent"]) {
138
- --_badge-surface: color-mix(in srgb, var(--ui-accent-solid) 22%, var(--ui-surface-default));
139
- --_badge-border: color-mix(in srgb, var(--ui-accent-solid) 28%, var(--ui-surface-default));
138
+ --_badge-surface: color-mix(in srgb, var(--ui-accent-solid) 19%, var(--ui-surface-default));
139
+ --_badge-border: color-mix(in srgb, var(--ui-accent-solid) 25%, var(--ui-surface-default));
140
140
  --_badge-text: var(--ui-accent-subtle-text, var(--ui-accent-solid));
141
141
  }
142
142
 
143
143
  :host-state([variant="subtle"][tone="danger"]) {
144
- --_badge-surface: color-mix(in srgb, var(--ui-danger-solid) 22%, var(--ui-surface-default));
145
- --_badge-border: color-mix(in srgb, var(--ui-danger-solid) 28%, var(--ui-surface-default));
144
+ --_badge-surface: color-mix(in srgb, var(--ui-danger-solid) 19%, var(--ui-surface-default));
145
+ --_badge-border: color-mix(in srgb, var(--ui-danger-solid) 25%, var(--ui-surface-default));
146
146
  --_badge-text: var(--ui-danger-subtle-text, var(--ui-danger-solid));
147
147
  }
148
148
 
149
149
  :host-state([variant="subtle"][tone="success"]) {
150
- --_badge-surface: color-mix(in srgb, var(--ui-success-solid) 22%, var(--ui-surface-default));
151
- --_badge-border: color-mix(in srgb, var(--ui-success-solid) 28%, var(--ui-surface-default));
150
+ --_badge-surface: color-mix(in srgb, var(--ui-success-solid) 19%, var(--ui-surface-default));
151
+ --_badge-border: color-mix(in srgb, var(--ui-success-solid) 25%, var(--ui-surface-default));
152
152
  --_badge-text: var(--ui-success-subtle-text, var(--ui-success-solid));
153
153
  }
154
154
 
155
155
  :host-state([variant="subtle"][tone="warning"]) {
156
- --_badge-surface: color-mix(in srgb, var(--ui-warning-solid) 22%, var(--ui-surface-default));
157
- --_badge-border: color-mix(in srgb, var(--ui-warning-solid) 28%, var(--ui-surface-default));
156
+ --_badge-surface: color-mix(in srgb, var(--ui-warning-solid) 19%, var(--ui-surface-default));
157
+ --_badge-border: color-mix(in srgb, var(--ui-warning-solid) 25%, var(--ui-surface-default));
158
158
  --_badge-text: var(--ui-warning-subtle-text, var(--ui-warning-solid));
159
159
  }
160
160
 
161
161
  :host-state([variant="subtle"][tone="info"]) {
162
- --_badge-surface: color-mix(in srgb, var(--ui-info-solid) 22%, var(--ui-surface-default));
163
- --_badge-border: color-mix(in srgb, var(--ui-info-solid) 28%, var(--ui-surface-default));
162
+ --_badge-surface: color-mix(in srgb, var(--ui-info-solid) 19%, var(--ui-surface-default));
163
+ --_badge-border: color-mix(in srgb, var(--ui-info-solid) 25%, var(--ui-surface-default));
164
164
  --_badge-text: var(--ui-info-subtle-text, var(--ui-info-solid));
165
165
  }
166
166
 
@@ -206,54 +206,48 @@
206
206
  --_badge-text: var(--ui-info-subtle-text, var(--ui-info-solid));
207
207
  }
208
208
 
209
- /* A tag is flat at the start and points at the end. The point is cut from the box, so it has no
210
- outline to follow: the edge is the fill. The end padding grows by the point's depth so the
211
- label never runs into it. */
209
+ /* The clipped point needs an inset surface over an outer border-colour shape: a native border
210
+ has no diagonal edge to follow. Keep this edge for every tag variant. */
212
211
  :host-state([shape="tag"]) {
213
212
  --_badge-point: var(--ui-badge-point, var(--ui-space-2));
214
213
 
214
+ position: relative;
215
+ isolation: isolate;
215
216
  border-color: transparent;
216
217
  border-radius: 0;
218
+ background: var(--ui-badge-border, var(--_badge-color-border, var(--_badge-border)));
217
219
  padding-inline-end: calc(var(--ui-badge-padding-inline, var(--ui-space-2)) + var(--_badge-point));
218
220
  clip-path: polygon(0 0, calc(100% - var(--_badge-point)) 0, 100% 50%, calc(100% - var(--_badge-point)) 100%, 0 100%);
219
221
  }
220
222
 
221
- :host-state([shape="tag"]):dir(rtl) {
222
- padding-inline-end: var(--ui-badge-padding-inline, var(--ui-space-2));
223
- padding-inline-start: calc(var(--ui-badge-padding-inline, var(--ui-space-2)) + var(--_badge-point));
224
- clip-path: polygon(var(--_badge-point) 0, 100% 0, 100% 100%, var(--_badge-point) 100%, 0 50%);
225
- }
226
-
227
- /* The tag clips its point, including the native border. Draw the outer shape in the border
228
- colour and an inset inner shape in the surface colour to keep a visible diagonal edge. */
229
- :host-state([variant="outline"][shape="tag"]) {
230
- position: relative;
231
- isolation: isolate;
232
- background: var(--ui-badge-border, var(--_badge-color-border, var(--_badge-border)));
233
- }
234
-
235
- :host-state([variant="outline"][shape="tag"])::before {
223
+ :host-state([shape="tag"])::before {
236
224
  content: "";
237
225
  position: absolute;
238
- inset: 1px;
226
+ inset: 0;
239
227
  z-index: -1;
240
228
  background: var(--_badge-outline-surface);
241
229
  clip-path: polygon(0 0, calc(100% - var(--_badge-point)) 0, 100% 50%, calc(100% - var(--_badge-point)) 100%, 0 100%);
242
230
  pointer-events: none;
243
231
  }
244
232
 
245
- :host-state([variant="outline"][shape="tag"]):dir(rtl)::before {
233
+ :host-state([shape="tag"]):dir(rtl) {
234
+ padding-inline-end: var(--ui-badge-padding-inline, var(--ui-space-2));
235
+ padding-inline-start: calc(var(--ui-badge-padding-inline, var(--ui-space-2)) + var(--_badge-point));
236
+ clip-path: polygon(var(--_badge-point) 0, 100% 0, 100% 100%, var(--_badge-point) 100%, 0 50%);
237
+ }
238
+
239
+ :host-state([shape="tag"]):dir(rtl)::before {
246
240
  clip-path: polygon(var(--_badge-point) 0, 100% 0, 100% 100%, var(--_badge-point) 100%, 0 50%);
247
241
  }
248
242
 
249
243
  @media (forced-colors: active) {
250
- :host-state([variant="outline"][shape="tag"]) {
244
+ :host-state([shape="tag"]) {
251
245
  forced-color-adjust: none;
252
246
  background: CanvasText;
253
247
  color: CanvasText;
254
248
  }
255
249
 
256
- :host-state([variant="outline"][shape="tag"])::before {
250
+ :host-state([shape="tag"])::before {
257
251
  background: Canvas;
258
252
  }
259
253
  }
@@ -188,6 +188,11 @@
188
188
  margin: 0;
189
189
  }
190
190
 
191
+ /* An anchor keeps the boxed button look even when an app styles ordinary links. */
192
+ :host:is(a) {
193
+ text-decoration: none;
194
+ }
195
+
191
196
  .spinner-wrap {
192
197
  display: inline-flex;
193
198
  flex: none;
@@ -123,7 +123,7 @@
123
123
  box-shadow: var(--ui-control-inset);
124
124
  }
125
125
 
126
- /* Centre the tick's box, then lift it: a rotated L's ink sits ~0.11rem below its box centre. */
126
+ /* Centre the tick's box and lift it slightly for the rotated check shape. */
127
127
  input:checked::after {
128
128
  inset: 0;
129
129
  margin: auto;
@@ -135,7 +135,7 @@
135
135
  border-color: var(--ui-text-on-accent);
136
136
  border-width: 0 0.125rem 0.125rem 0;
137
137
  opacity: 1;
138
- transform: translate(0.07rem, -0.09rem) rotate(45deg);
138
+ transform: translateY(-0.09rem) rotate(45deg);
139
139
  animation: ui-checkbox-check 180ms var(--ui-motion-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both;
140
140
  }
141
141
 
@@ -151,15 +151,15 @@
151
151
  @keyframes ui-checkbox-check {
152
152
  from {
153
153
  opacity: 0;
154
- transform: translate(0.07rem, -0.09rem) rotate(45deg) scale(0.55);
154
+ transform: translateY(-0.09rem) rotate(45deg) scale(0.55);
155
155
  }
156
156
  65% {
157
157
  opacity: 1;
158
- transform: translate(0.07rem, -0.09rem) rotate(45deg) scale(1.12);
158
+ transform: translateY(-0.09rem) rotate(45deg) scale(1.12);
159
159
  }
160
160
  to {
161
161
  opacity: 1;
162
- transform: translate(0.07rem, -0.09rem) rotate(45deg) scale(1);
162
+ transform: translateY(-0.09rem) rotate(45deg) scale(1);
163
163
  }
164
164
  }
165
165
 
@@ -244,6 +244,8 @@
244
244
  class="option"
245
245
  class:active="row.index = active"
246
246
  role="option"
247
+ data-ui-choice-option
248
+ :data-multiple="multiple"
247
249
  :id="format('%s-option-%s', uid, row.index)"
248
250
  :aria-selected="row.selected"
249
251
  :aria-disabled="row.disabled"
@@ -271,6 +273,8 @@
271
273
  class="option"
272
274
  class:active="createIndex = active"
273
275
  role="option"
276
+ data-ui-choice-option
277
+ :data-multiple="multiple"
274
278
  aria-selected="false"
275
279
  :id="format('%s-option-%s', uid, createIndex)"
276
280
  :data-index="createIndex"
@@ -543,21 +547,6 @@
543
547
  [hidden] {
544
548
  display: none;
545
549
  }
546
- .option {
547
- padding: var(--ui-space-2) var(--ui-space-3);
548
- border-radius: var(--ui-radius-sm);
549
- cursor: pointer;
550
- overflow-wrap: anywhere;
551
- }
552
- .option:hover, .option.active {
553
- background: var(--ui-surface-muted);
554
- outline: 1px solid var(--ui-focus-ring);
555
- outline-offset: -1px;
556
- }
557
- .option[aria-disabled="true"] {
558
- color: var(--ui-disabled-text);
559
- cursor: not-allowed;
560
- }
561
550
  .option[aria-disabled="true"] .primary,
562
551
  .option[aria-disabled="true"] .secondary {
563
552
  color: var(--ui-disabled-text);
@@ -625,39 +614,6 @@
625
614
  }
626
615
  }
627
616
 
628
- /* Multiple mode: every option carries a checkbox, so a list that takes several answers says so
629
- before anything is picked. aria-selected is the state; this draws it.
630
- Longhands, not shorthands: lowering drops a shorthand that omits a component. */
631
- :host-state([multiple]) .option {
632
- display: flex;
633
- gap: var(--ui-space-2);
634
- align-items: center;
635
- }
636
-
637
- :host-state([multiple]) .option::before {
638
- content: "";
639
- flex: none;
640
- inline-size: 1.125rem;
641
- block-size: 1.125rem;
642
- border-style: solid;
643
- border-width: 1px;
644
- border-color: var(--ui-control-border-hover, var(--ui-border-strong));
645
- border-radius: var(--ui-radius-sm);
646
- background-color: var(--ui-control-surface, var(--ui-surface-default));
647
- background-repeat: no-repeat;
648
- background-position: center;
649
- background-size: 0.7rem;
650
- }
651
-
652
- :host-state([multiple]) .option[aria-selected="true"]::before {
653
- border-color: var(--ui-accent-solid);
654
- background-color: var(--ui-accent-solid);
655
- background-image: var(
656
- --_ui-combobox-option-check,
657
- 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")
658
- );
659
- }
660
-
661
617
  /* Multiple mode keeps one control height. Chips and the input scroll within the entry,
662
618
  while the clear and disclosure controls remain fixed at the end of the field. */
663
619
  :host-state([multiple]) .field {
@@ -147,7 +147,7 @@
147
147
  @media (hover: hover) and (pointer: fine) {
148
148
  :host-state([anticipatory]):not(:host-state([variant="solid"]), :host-state([loading]), :host-state([pending]), :disabled, [data-ui-proximity="near"], :hover, :focus-visible) {
149
149
  border-color: transparent;
150
- background: transparent;
150
+ background-color: transparent;
151
151
  box-shadow: none;
152
152
  }
153
153
 
@@ -175,7 +175,7 @@
175
175
  margin: 0;
176
176
  border: 1px solid transparent;
177
177
  border-radius: var(--ui-icon-button-radius, var(--ui-action-radius, var(--ui-radius-md)));
178
- background: var(--ui-icon-button-surface, transparent);
178
+ background-color: var(--ui-icon-button-surface, transparent);
179
179
  color: inherit;
180
180
  display: inline-flex;
181
181
  align-items: center;
@@ -247,14 +247,14 @@
247
247
 
248
248
  :host-state([variant="outline"]) {
249
249
  border-color: var(--ui-icon-button-border, var(--ui-control-border, var(--ui-border-strong)));
250
- background: var(--ui-icon-button-outline-surface, var(--ui-surface-default));
250
+ background-color: var(--ui-icon-button-outline-surface, var(--ui-surface-default));
251
251
  background-image: var(--ui-raised-highlight);
252
252
  color: var(--ui-icon-button-outline-text, var(--ui-text-primary));
253
253
  box-shadow: var(--ui-raised);
254
254
  }
255
255
 
256
256
  :host-state([variant="solid"]) {
257
- background: var(--ui-icon-button-solid-surface, var(--ui-accent-solid));
257
+ background-color: var(--ui-icon-button-solid-surface, var(--ui-accent-solid));
258
258
  background-image: var(--ui-raised-highlight);
259
259
  color: var(--ui-icon-button-solid-text, var(--ui-text-on-accent));
260
260
  box-shadow: var(
@@ -265,7 +265,7 @@
265
265
 
266
266
  :host:hover:enabled:not([aria-disabled="true"]) {
267
267
  /* Derived from the text colour so the ghost hover shows on any background. */
268
- background: var(--ui-icon-button-hover-surface, color-mix(in srgb, currentColor 8%, transparent));
268
+ background-color: var(--ui-icon-button-hover-surface, color-mix(in srgb, currentColor 8%, transparent));
269
269
  color: var(--ui-icon-button-hover-text, var(--ui-text-primary));
270
270
  }
271
271
 
@@ -274,7 +274,7 @@
274
274
  }
275
275
 
276
276
  :host-state([variant="solid"]):hover:enabled:not([aria-disabled="true"]) {
277
- background: var(--ui-icon-button-solid-hover-surface, var(--ui-accent-hover));
277
+ background-color: var(--ui-icon-button-solid-hover-surface, var(--ui-accent-hover));
278
278
  }
279
279
 
280
280
  :host:active:enabled:not([aria-disabled="true"]) {
@@ -309,7 +309,7 @@
309
309
  /* Disabled is a flat neutral control, independent of the active variant's tone. */
310
310
  :host:disabled {
311
311
  border-color: var(--ui-disabled-border);
312
- background: var(--ui-disabled-surface);
312
+ background-color: var(--ui-disabled-surface);
313
313
  color: var(--ui-disabled-text);
314
314
  filter: var(--ui-icon-button-disabled-filter, var(--ui-disabled-filter));
315
315
  opacity: var(--ui-icon-button-disabled-opacity, 1);
@@ -127,7 +127,7 @@
127
127
  opacity: 1;
128
128
  }
129
129
 
130
- :host:hover:not(:focus):not(:disabled) {
130
+ :host:hover:not(:focus):not(:disabled):not(:read-only):not([aria-invalid="true"]):not(:user-invalid) {
131
131
  border-color: var(--ui-input-border-hover, var(--_ui-default-input-border-hover, var(--ui-control-border-hover, var(--ui-text-secondary))));
132
132
  }
133
133
 
@@ -77,7 +77,7 @@
77
77
  }
78
78
  }
79
79
 
80
- :host:hover:not(:has(input:focus, input:disabled)) {
80
+ :host:hover:not(:has(input:focus, input:disabled, input:read-only, [aria-invalid="true"], :user-invalid)) {
81
81
  border-color: var(--ui-control-border-hover, var(--ui-text-secondary));
82
82
  }
83
83
 
@@ -128,7 +128,7 @@
128
128
  flex: none;
129
129
  align-items: center;
130
130
  padding-inline: var(--ui-space-2);
131
- color: var(--ui-text-secondary);
131
+ color: var(--ui-text-muted);
132
132
  font-family: var(--ui-font-family-mono);
133
133
  font-size: var(--ui-font-size-sm);
134
134
  white-space: nowrap;
@@ -1,14 +1,65 @@
1
+ <link rel="component" href="../ui-scroll-area/ui-scroll-area.html">
1
2
  <template component="ui-listbox" controller="./ui-listbox.js">
2
3
  <defs>
3
- <prop name="rows" type="number" default="5">The number of option rows visible at once. Use at least two.</prop>
4
- <prop name="multiple" type="boolean" default="false">Allows several options to be selected with native keyboard and pointer behavior.</prop>
4
+ <prop name="rows" type="number" default="5">The maximum number of choices visible before the list scrolls. Use at least two.</prop>
5
+ <prop name="multiple" type="boolean" default="false">Allows several choices, each shown with a checkmark when selected.</prop>
6
+ <prop name="name" type="string" default="">The form field name used for each selected value.</prop>
5
7
  <prop name="value" type="string">The initial or externally set single selection. Authored selected options work when it is omitted.</prop>
6
8
  <prop name="values" type="list(string)">The initial or externally set selections in multiple mode. Authored selected options work when it is omitted.</prop>
7
9
  <prop name="disabled" type="boolean" default="false">Prevents selection and form submission.</prop>
8
- <prop name="required" type="boolean" default="false">Requires at least one selection for form validation.</prop>
10
+ <prop name="required" type="boolean" default="false">Requires at least one choice before form submission.</prop>
9
11
  <prop name="invalid" type="boolean" default="false">Exposes invalid state to assistive technology and styling.</prop>
12
+ <state name="choices" type="list(object({ value: string, label: string, disabled: boolean, selected: boolean, index: integer }))" :value="[]"></state>
13
+ <state name="selection" type="list(string)" :value="[]"></state>
14
+ <state name="hasSelection" type="boolean" :value="false"></state>
15
+ <state name="active" type="integer" :value="-1"></state>
16
+ <state name="uid" type="string" :value="''"></state>
17
+ <state name="validationError" type="boolean" :value="false"></state>
18
+ <event name="change" type="object({ value: string | null, values: list(string), trigger: keyboard | pointer })"></event>
10
19
  </defs>
11
- <select :size="rows" :multiple="multiple" :disabled="disabled" :required="required" :aria-invalid="invalid"><slot></slot></select>
20
+ <div
21
+ role="listbox"
22
+ :tabindex="{ true: '-1', false: '0' }[format('%s', disabled)]"
23
+ :aria-multiselectable="multiple"
24
+ :aria-disabled="disabled"
25
+ :aria-required="required"
26
+ :aria-invalid="invalid or validationError"
27
+ style:--_rows="rows"
28
+ >
29
+ <ui-scroll-area class="choices" $ref="choices">
30
+ <div
31
+ $each="choice of choices"
32
+ $key="choice.index"
33
+ class="option"
34
+ class:active="choice.index = active"
35
+ role="option"
36
+ data-ui-choice-option
37
+ :data-multiple="multiple"
38
+ :id="format('%s-option-%s', uid, choice.index)"
39
+ :data-index="choice.index"
40
+ :aria-selected="choice.selected"
41
+ :aria-disabled="choice.disabled"
42
+ >
43
+ <span $value="choice.label"></span>
44
+ </div>
45
+ </ui-scroll-area>
46
+ <select class="fallback" $ref="options" :size="rows" :multiple="multiple" :disabled="disabled" :required="required" :name="name" aria-label="Choices">
47
+ <slot></slot>
48
+ </select>
49
+ <template $if="name and not disabled">
50
+ <input $each="selected of selection" type="hidden" :name="name" :value="selected">
51
+ </template>
52
+ <input
53
+ class="validity"
54
+ $ref="validity"
55
+ type="checkbox"
56
+ :required="required and not hasSelection and not disabled"
57
+ :checked="hasSelection"
58
+ :disabled="disabled"
59
+ aria-hidden="true"
60
+ tabindex="-1"
61
+ >
62
+ </div>
12
63
  <style>
13
64
  @property --ui-listbox-radius {
14
65
  syntax: "*";
@@ -32,29 +83,55 @@
32
83
  }
33
84
 
34
85
  :host {
86
+ position: relative;
35
87
  box-sizing: border-box;
36
88
  display: block;
37
89
  inline-size: 100%;
38
90
  min-inline-size: 0;
39
- min-block-size: var(--ui-control-min-block-size);
40
91
  margin: 0;
41
92
  padding: var(--ui-space-1);
42
93
  border: 1px solid var(--ui-listbox-border, var(--ui-control-border));
43
94
  border-radius: var(--ui-listbox-radius, var(--ui-field-radius, var(--ui-radius-md)));
44
95
  background: var(--ui-listbox-surface, var(--ui-control-surface));
45
96
  color: var(--ui-text-primary);
46
- color-scheme: light dark;
47
97
  font: inherit;
48
98
  line-height: var(--ui-line-height-md);
49
99
  box-shadow: var(--ui-control-inset);
100
+ outline: none;
50
101
  }
51
102
 
52
- :host option,
53
- :host optgroup {
54
- padding: var(--ui-space-2);
103
+ .choices {
104
+ max-block-size: calc(max(2, var(--_rows, 5)) * var(--ui-control-size-md));
105
+ }
106
+
107
+ :host:not([data-enhanced]) {
108
+ padding: 0;
109
+ border: 0;
110
+ background: transparent;
111
+ box-shadow: none;
112
+ }
113
+
114
+ :host:not([data-enhanced]) .choices,
115
+ :host[data-enhanced] .fallback {
116
+ display: none;
117
+ }
118
+
119
+ .fallback {
120
+ box-sizing: border-box;
121
+ inline-size: 100%;
55
122
  font: inherit;
56
123
  }
57
124
 
125
+ .validity {
126
+ position: absolute;
127
+ inline-size: 1px;
128
+ block-size: 1px;
129
+ overflow: hidden;
130
+ clip-path: inset(50%);
131
+ opacity: 0;
132
+ pointer-events: none;
133
+ }
134
+
58
135
  :host:focus-visible {
59
136
  border-color: var(--ui-control-focus);
60
137
  outline: 2px solid var(--ui-focus-ring);
@@ -62,12 +139,11 @@
62
139
  box-shadow: var(--ui-listbox-focus-shadow, var(--ui-focus-halo));
63
140
  }
64
141
 
65
- :host[aria-invalid="true"],
66
- :host:user-invalid {
142
+ :host[aria-invalid="true"] {
67
143
  border-color: var(--ui-listbox-invalid-border, var(--ui-field-danger, var(--ui-danger)));
68
144
  }
69
145
 
70
- :host:disabled {
146
+ :host[aria-disabled="true"] {
71
147
  background: var(--ui-control-surface-disabled);
72
148
  color: var(--ui-disabled-text);
73
149
  box-shadow: none;