@threadlabs/looma 0.17.4 → 0.18.1
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.
- package/components/ui-avatar/ui-avatar.html +8 -4
- package/components/ui-badge/ui-badge.html +28 -34
- package/components/ui-button/ui-button.html +1 -1
- package/components/ui-combobox/ui-combobox.html +4 -48
- package/components/ui-icon-button/ui-icon-button.html +10 -0
- package/components/ui-input/ui-input.html +1 -1
- package/components/ui-input-group/ui-input-group.html +1 -1
- package/components/ui-listbox/ui-listbox.html +88 -12
- package/components/ui-listbox/ui-listbox.js +162 -18
- package/components/ui-menu/ui-menu.js +14 -2
- package/components/ui-menu-item/ui-menu-item.html +3 -0
- package/components/ui-popover/ui-popover.js +13 -2
- package/components/ui-select/ui-select.html +1 -1
- package/components/ui-spinner/ui-spinner.html +47 -14
- package/components/ui-textarea/ui-textarea.html +1 -1
- package/dist/index.js +1 -1
- package/editor/chunks/extensions.js +5 -0
- package/package.json +1 -1
- package/styles/ui-avatar.css +6 -2
- package/styles/ui-badge.css +30 -40
- package/styles/ui-button.css +1 -1
- package/styles/ui-combobox.css +0 -52
- package/styles/ui-icon-button.css +10 -0
- package/styles/ui-input-group.css +1 -1
- package/styles/ui-input.css +1 -1
- package/styles/ui-listbox.css +33 -8
- package/styles/ui-menu-item.css +3 -0
- package/styles/ui-select.css +1 -1
- package/styles/ui-spinner.css +40 -15
- package/styles/ui-textarea.css +1 -1
- package/tokens.css +67 -2
- package/vanilla/UiCombobox.js +9 -1
- package/vanilla/UiListbox.d.ts +3 -1
- package/vanilla/UiListbox.js +65 -18
- package/vanilla/UiSpinner.js +19 -1
- package/vue/UiAvatar.vue +11 -2
- package/vue/UiBadge.vue +30 -36
- package/vue/UiButton.vue +1 -1
- package/vue/UiCombobox.vue +4 -49
- package/vue/UiIconButton.vue +13 -0
- package/vue/UiInput.vue +1 -1
- package/vue/UiInputGroup.vue +1 -1
- package/vue/UiListbox.d.ts +18 -5
- package/vue/UiListbox.vue +142 -25
- package/vue/UiMenuItem.vue +3 -0
- package/vue/UiSelect.vue +1 -1
- package/vue/UiSpinner.vue +47 -14
- package/vue/UiTextarea.vue +1 -1
- package/vue/chunks/UiAvatar.js +2 -1
- package/vue/chunks/UiBadge.js +1 -1
- package/vue/chunks/UiButton.js +1 -1
- package/vue/chunks/UiCombobox.js +11 -2
- package/vue/chunks/UiIconButton.js +1 -1
- package/vue/chunks/UiInput.js +1 -1
- package/vue/chunks/UiInputGroup.js +1 -1
- package/vue/chunks/UiListbox.js +134 -20
- package/vue/chunks/UiMenuItem.js +1 -1
- package/vue/chunks/UiSelect.js +1 -1
- package/vue/chunks/UiSpinner.js +29 -3
- package/vue/chunks/UiTextarea.js +1 -1
- package/vue/components.css +354 -342
|
@@ -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
|
|
22
|
-
|
|
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-
|
|
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:
|
|
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)
|
|
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)
|
|
139
|
-
--_badge-border: color-mix(in srgb, var(--ui-accent-solid)
|
|
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)
|
|
145
|
-
--_badge-border: color-mix(in srgb, var(--ui-danger-solid)
|
|
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)
|
|
151
|
-
--_badge-border: color-mix(in srgb, var(--ui-success-solid)
|
|
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)
|
|
157
|
-
--_badge-border: color-mix(in srgb, var(--ui-warning-solid)
|
|
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)
|
|
163
|
-
--_badge-border: color-mix(in srgb, var(--ui-info-solid)
|
|
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
|
-
/*
|
|
210
|
-
|
|
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"])
|
|
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:
|
|
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([
|
|
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([
|
|
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([
|
|
250
|
+
:host-state([shape="tag"])::before {
|
|
257
251
|
background: Canvas;
|
|
258
252
|
}
|
|
259
253
|
}
|
|
@@ -307,7 +307,7 @@
|
|
|
307
307
|
border-color: transparent;
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
:host-state([variant="ghost"]):active:not(:disabled, [aria-disabled="true"]) {
|
|
310
|
+
:host-state([variant="ghost"]):is(:active, [aria-expanded="true"]):not(:disabled, [aria-disabled="true"]) {
|
|
311
311
|
background-color: var(--ui-button-ghost-active-surface, oklch(from var(--_tone) l c h / 16%));
|
|
312
312
|
border-color: transparent;
|
|
313
313
|
box-shadow: var(--ui-pressed);
|
|
@@ -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 {
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
syntax: "*";
|
|
36
36
|
inherits: false;
|
|
37
37
|
}
|
|
38
|
+
@property --ui-icon-button-active-surface {
|
|
39
|
+
syntax: "*";
|
|
40
|
+
inherits: false;
|
|
41
|
+
}
|
|
38
42
|
@property --ui-icon-button-border {
|
|
39
43
|
syntax: "*";
|
|
40
44
|
inherits: false;
|
|
@@ -277,6 +281,12 @@
|
|
|
277
281
|
background-color: var(--ui-icon-button-solid-hover-surface, var(--ui-accent-hover));
|
|
278
282
|
}
|
|
279
283
|
|
|
284
|
+
:host-state([variant="ghost"])[aria-expanded="true"]:enabled:not([aria-disabled="true"]) {
|
|
285
|
+
background-color: var(--ui-icon-button-active-surface, color-mix(in srgb, currentColor 16%, transparent));
|
|
286
|
+
color: var(--ui-icon-button-hover-text, var(--ui-text-primary));
|
|
287
|
+
box-shadow: var(--ui-pressed);
|
|
288
|
+
}
|
|
289
|
+
|
|
280
290
|
:host:active:enabled:not([aria-disabled="true"]) {
|
|
281
291
|
transform: scale(var(--ui-icon-button-active-scale, 0.96));
|
|
282
292
|
}
|
|
@@ -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
|
|
4
|
-
<prop name="multiple" type="boolean" default="false">Allows several
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
-
|
|
2
|
-
|
|
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
|
|
5
|
-
const
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
};
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
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
|
}
|