@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
@@ -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
  }
@@ -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 {
@@ -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
 
@@ -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;
@@ -1,27 +1,171 @@
1
- // Keep externally supplied selections in sync when options arrive after the listbox upgrades.
2
- // Without value or values, the native select owns selection, keyboard behavior, and form reset.
1
+ let nextId = 0;
2
+
3
+ const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
4
+
5
+ // Authored options remain the source of labels, order, disabled state, and default selections.
6
+ function readOptions(container) {
7
+ return Array.from(container.querySelectorAll("option"), (option, index) => ({
8
+ value: option.value,
9
+ label: option.label || option.textContent?.trim() || option.value,
10
+ disabled: option.disabled || Boolean(option.closest("optgroup[disabled]")),
11
+ selected: option.hasAttribute("selected"),
12
+ index,
13
+ }));
14
+ }
15
+
3
16
  export default function controller(host) {
4
- const select = host.element;
5
- const apply = () => {
17
+ const element = host.element;
18
+ const { options: authored, validity } = host.refs;
19
+ const form = element.closest("form");
20
+ host.state.uid = `ui-listbox-${++nextId}`;
21
+ if (element.id && !element.hasAttribute("aria-label") && !element.hasAttribute("aria-labelledby")) {
22
+ const label = Array.from(element.ownerDocument.querySelectorAll("label[for]")).find((candidate) => candidate.htmlFor === element.id);
23
+ if (label) {
24
+ label.id ||= `${host.state.uid}-label`;
25
+ element.setAttribute("aria-labelledby", label.id);
26
+ }
27
+ }
28
+ let options = [];
29
+ let initial = [];
30
+ let initialized = false;
31
+ let external = "";
32
+ let typeahead = "";
33
+ let typeaheadTimer;
34
+
35
+ const selected = () => Array.isArray(host.state.selection) ? host.state.selection : [];
36
+ const externallySelected = () => host.state.multiple
37
+ ? (Array.isArray(host.state.values) ? Array.from(new Set(host.state.values.map(String))) : null)
38
+ : (host.state.value === undefined || host.state.value === null ? null : [String(host.state.value)]);
39
+ const defaultSelection = () => externallySelected()
40
+ ?? options.filter((option) => option.selected).map((option) => option.value).slice(0, host.state.multiple ? undefined : 1);
41
+ const enabledIndices = () => options.filter((option) => !option.disabled).map((option) => option.index);
42
+ const render = () => {
43
+ const set = new Set(selected());
44
+ const rows = options.map((option) => ({
45
+ value: option.value,
46
+ label: option.label,
47
+ disabled: option.disabled,
48
+ selected: set.has(option.value),
49
+ index: option.index,
50
+ }));
51
+ if (!same(host.state.choices, rows)) host.state.choices = rows;
52
+ const active = host.state.active;
53
+ if (active < 0 || !options[active] || options[active].disabled) element.removeAttribute("aria-activedescendant");
54
+ else element.setAttribute("aria-activedescendant", `${host.state.uid}-option-${active}`);
55
+ };
56
+ const setSelection = (values, trigger) => {
57
+ const next = Array.from(new Set(values.map(String)));
58
+ if (same(selected(), next)) return;
59
+ host.state.selection = next;
60
+ host.state.hasSelection = next.some((value) => value !== "");
61
+ host.state.validationError = false;
62
+ render();
63
+ if (trigger) host.dispatch("change", { value: next[0] ?? null, values: next, trigger });
64
+ };
65
+ const syncOptions = () => {
66
+ const next = readOptions(authored);
67
+ if (same(options, next) && initialized) return;
68
+ const hadOptions = options.length > 0;
69
+ options = next;
70
+ if (!initialized || (!hadOptions && options.length && !selected().length)) {
71
+ initial = defaultSelection();
72
+ host.state.selection = initial;
73
+ host.state.hasSelection = initial.some((value) => value !== "");
74
+ initialized = true;
75
+ }
76
+ render();
77
+ };
78
+ const activate = (index) => {
79
+ if (index < 0 || options[index]?.disabled || !options[index]) return;
80
+ host.state.active = index;
81
+ render();
82
+ element.querySelector(`[data-index="${index}"]`)?.scrollIntoView({ block: "nearest" });
83
+ };
84
+ const choose = (index, trigger) => {
85
+ const option = options[index];
86
+ if (!option || option.disabled || host.state.disabled) return;
87
+ activate(index);
6
88
  if (host.state.multiple) {
7
- if (host.state.values === undefined || host.state.values === null) return;
8
- const values = new Set(host.state.values.map(String));
9
- for (const option of select.options) {
10
- option.defaultSelected = values.has(option.value);
11
- option.selected = values.has(option.value);
89
+ const current = selected();
90
+ setSelection(current.includes(option.value)
91
+ ? current.filter((value) => value !== option.value)
92
+ : options.filter((candidate) => current.includes(candidate.value) || candidate.value === option.value).map((candidate) => candidate.value), trigger);
93
+ } else setSelection([option.value], trigger);
94
+ };
95
+ const move = (key) => {
96
+ const indices = enabledIndices();
97
+ if (!indices.length) return;
98
+ const current = indices.indexOf(host.state.active);
99
+ const next = key === "Home" ? indices[0]
100
+ : key === "End" ? indices[indices.length - 1]
101
+ : key === "ArrowDown" ? indices[(current + 1) % indices.length]
102
+ : indices[(current <= 0 ? indices.length : current) - 1];
103
+ activate(next);
104
+ if (!host.state.multiple) choose(next, "keyboard");
105
+ };
106
+ const onClick = (event) => {
107
+ if (host.state.disabled) return;
108
+ const row = event.target.closest("[data-index]");
109
+ if (!row || !element.contains(row)) return;
110
+ element.focus();
111
+ choose(Number(row.dataset.index), "pointer");
112
+ };
113
+ const onKeydown = (event) => {
114
+ if (host.state.disabled || event.altKey || event.metaKey || event.ctrlKey) return;
115
+ if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
116
+ event.preventDefault();
117
+ move(event.key);
118
+ } else if (event.key === " " || event.key === "Enter") {
119
+ event.preventDefault();
120
+ choose(host.state.active >= 0 ? host.state.active : enabledIndices()[0], "keyboard");
121
+ } else if (event.key.length === 1 && !event.shiftKey) {
122
+ typeahead += event.key.toLocaleLowerCase();
123
+ clearTimeout(typeaheadTimer);
124
+ typeaheadTimer = setTimeout(() => { typeahead = ""; }, 700);
125
+ const match = options.find((option) => !option.disabled && option.label.toLocaleLowerCase().startsWith(typeahead));
126
+ if (match) {
127
+ activate(match.index);
128
+ if (!host.state.multiple) choose(match.index, "keyboard");
12
129
  }
13
- return;
14
130
  }
15
- if (host.state.value === undefined || host.state.value === null) return;
16
- const value = String(host.state.value);
17
- for (const option of select.options) option.defaultSelected = option.value === value;
18
- if (select.value !== value) select.value = value;
19
- };
20
- const observer = new MutationObserver(apply);
21
- observer.observe(select, { childList: true, subtree: true });
22
- const stop = host.effect(apply);
131
+ };
132
+ const onReset = () => queueMicrotask(() => {
133
+ setSelection(initial);
134
+ host.state.validationError = false;
135
+ });
136
+ const onInvalid = (event) => {
137
+ event.preventDefault();
138
+ host.state.validationError = true;
139
+ requestAnimationFrame(() => element.focus());
140
+ };
141
+
142
+ const observer = new MutationObserver(syncOptions);
143
+ observer.observe(authored, { childList: true, subtree: true, attributes: true, attributeFilter: ["selected", "disabled", "value", "label"] });
144
+ syncOptions();
145
+ authored.disabled = true;
146
+ element.setAttribute("data-enhanced", "");
147
+ const stop = host.effect(() => {
148
+ const signature = JSON.stringify([host.state.multiple, host.state.value, host.state.values]);
149
+ if (signature !== external) {
150
+ external = signature;
151
+ initial = defaultSelection();
152
+ setSelection(initial);
153
+ }
154
+ render();
155
+ });
156
+ element.addEventListener("click", onClick);
157
+ element.addEventListener("keydown", onKeydown);
158
+ form?.addEventListener("reset", onReset);
159
+ validity.addEventListener("invalid", onInvalid);
23
160
  return () => {
24
161
  stop();
25
162
  observer.disconnect();
163
+ authored.disabled = host.state.disabled;
164
+ element.removeAttribute("data-enhanced");
165
+ clearTimeout(typeaheadTimer);
166
+ element.removeEventListener("click", onClick);
167
+ element.removeEventListener("keydown", onKeydown);
168
+ form?.removeEventListener("reset", onReset);
169
+ validity.removeEventListener("invalid", onInvalid);
26
170
  };
27
171
  }
@@ -92,7 +92,7 @@
92
92
  background-size: 1.125rem 1.125rem;
93
93
  }
94
94
 
95
- :host:hover:not(:focus):not(:disabled) {
95
+ :host:hover:not(:focus):not(:disabled):not([aria-invalid="true"]):not(:user-invalid) {
96
96
  border-color: var(--ui-select-border-hover, var(--ui-control-border-hover, var(--ui-text-secondary)));
97
97
  }
98
98