@threadlabs/looma 0.14.6 → 0.14.8
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-combobox/ui-combobox.html +52 -11
- package/components/ui-combobox/ui-combobox.js +29 -14
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/styles/ui-combobox.css +18 -0
- package/vanilla/UiCombobox.d.ts +8 -7
- package/vanilla/UiCombobox.js +43 -27
- package/vue/UiCombobox.d.ts +80 -3
- package/vue/UiCombobox.vue +279 -8
- package/vue/chunks/UiCombobox.js +64 -25
- package/vue/components.css +67 -50
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
<prop name="value" type="string | null"
|
|
15
15
|
>The selected value in single mode, which a named combobox submits with its form. The field shows the matching option's label, and a form reset returns to
|
|
16
16
|
this value.</prop>
|
|
17
|
-
<prop
|
|
17
|
+
<prop
|
|
18
|
+
name="items"
|
|
19
|
+
type="list(object({ id: string, value: string, label: string, group?: string, disabled?: boolean, description?: string, tag?: object({ label: string, tone?: neutral | accent | info | success | warning | danger }) }))"
|
|
18
20
|
>The selected items in multiple mode, as JSON; a named combobox submits each one's value. A form reset returns to these items, or to none when unset.</prop>
|
|
19
21
|
<prop name="selectedValues" type="list(string)"
|
|
20
22
|
>The selected option values in multiple mode, when the consumer controls the selection. Once set, the consumer owns it: each value shows as a badge with its
|
|
@@ -31,6 +33,10 @@
|
|
|
31
33
|
<prop name="query" type="string"
|
|
32
34
|
>The input's text, when the consumer controls it. Once set, the consumer owns the text: typing reports query-change, and the input returns to query unless
|
|
33
35
|
query is updated to match. Leave it unset to let the combobox manage its own text, showing the chosen option's label.</prop>
|
|
36
|
+
<prop name="filter" type="label | none" default="label"
|
|
37
|
+
>How the list narrows as the user types. label lists the options whose label contains the typed text, ignoring case. none lists every authored option, for
|
|
38
|
+
options already narrowed elsewhere, such as a server search, so a result that matched on data other than its label still shows. Leaving the field still
|
|
39
|
+
commits an option whose label matches the text, or the only one listed.</prop>
|
|
34
40
|
<prop name="allowFreeText" type="boolean" default="false">Allows values that do not match an authored option.</prop>
|
|
35
41
|
<prop name="allowCreate" type="boolean" default="false"
|
|
36
42
|
>Offers the current query as a new option, reported as create-item. When nothing else matches, the offer is highlighted, so Enter creates it; with nothing
|
|
@@ -60,14 +66,18 @@
|
|
|
60
66
|
<prop name="help" type="string" default=""
|
|
61
67
|
>Help text for the field. When set, a help button appears beside the field and opens a tooltip with this text; while it is open, the input is described by
|
|
62
68
|
it.</prop>
|
|
63
|
-
<state
|
|
69
|
+
<state
|
|
70
|
+
name="internalItems"
|
|
71
|
+
type="list(object({ id: string, value: string, label: string, group?: string, disabled?: boolean, description?: string, tag?: object({ label: string, tone?: neutral | accent | info | success | warning | danger }) }))"
|
|
72
|
+
:value="[]"
|
|
73
|
+
></state>
|
|
64
74
|
<state name="raw" :value="''"></state>
|
|
65
75
|
<state name="display" :value="''"></state>
|
|
66
76
|
<state name="selected" type="string | null" :value="null"></state>
|
|
67
77
|
<state name="expanded" :value="false"></state>
|
|
68
78
|
<state
|
|
69
79
|
name="rows"
|
|
70
|
-
type="list(object({ id: string, value: string, label: string, group?: string, disabled: boolean, selected: boolean }))"
|
|
80
|
+
type="list(object({ id: string, value: string, label: string, group?: string, disabled: boolean, selected: boolean, description?: string, tag?: object({ label: string, tone?: neutral | accent | info | success | warning | danger }) }))"
|
|
71
81
|
:value="[]"
|
|
72
82
|
></state>
|
|
73
83
|
<state name="active" :value="-1"></state>
|
|
@@ -83,7 +93,7 @@
|
|
|
83
93
|
<state name="submitted" :value="''"></state>
|
|
84
94
|
<state
|
|
85
95
|
name="groups"
|
|
86
|
-
type="list(object({ name: string, role: group | presentation, label: string | null, rows: list(object({ id: string, value: string, label: string, group?: string, disabled: boolean, selected: boolean, index: integer })) }))"
|
|
96
|
+
type="list(object({ name: string, role: group | presentation, label: string | null, rows: list(object({ id: string, value: string, label: string, group?: string, disabled: boolean, selected: boolean, description?: string, tag?: object({ label: string, tone?: neutral | accent | info | success | warning | danger }), index: integer, labelledBy: string | null, detail: string | null })) }))"
|
|
87
97
|
:value="[]"
|
|
88
98
|
></state>
|
|
89
99
|
<state name="creatable" :value="false"></state>
|
|
@@ -94,28 +104,31 @@
|
|
|
94
104
|
<event name="query-change" type="object({ query: string, display: string, trigger: keyboard | pointer | programmatic })"></event>
|
|
95
105
|
<event
|
|
96
106
|
name="value-change"
|
|
97
|
-
type="object({ value: string | null, query: string, option: object({ id: string, value: string, label: string, group?: string, disabled?: boolean }) | null, kind: selection | clear | free-entry | create, trigger: keyboard | pointer | programmatic }) | list(object({ id: string, value: string, label: string, group?: string, disabled?: boolean }))"
|
|
107
|
+
type="object({ value: string | null, query: string, option: object({ id: string, value: string, label: string, group?: string, disabled?: boolean, description?: string, tag?: object({ label: string, tone?: neutral | accent | info | success | warning | danger }) }) | null, kind: selection | clear | free-entry | create, trigger: keyboard | pointer | programmatic }) | list(object({ id: string, value: string, label: string, group?: string, disabled?: boolean, description?: string, tag?: object({ label: string, tone?: neutral | accent | info | success | warning | danger }) }))"
|
|
98
108
|
></event>
|
|
99
109
|
<event
|
|
100
110
|
name="free-entry"
|
|
101
|
-
type="object({ value: string | null, query: string, option: object({ id: string, value: string, label: string, group?: string, disabled?: boolean }) | null, kind: selection | clear | free-entry | create, trigger: keyboard | pointer | programmatic })"
|
|
111
|
+
type="object({ value: string | null, query: string, option: object({ id: string, value: string, label: string, group?: string, disabled?: boolean, description?: string, tag?: object({ label: string, tone?: neutral | accent | info | success | warning | danger }) }) | null, kind: selection | clear | free-entry | create, trigger: keyboard | pointer | programmatic })"
|
|
102
112
|
></event>
|
|
103
113
|
<event
|
|
104
114
|
name="create-entry"
|
|
105
|
-
type="object({ value: string | null, query: string, option: object({ id: string, value: string, label: string, group?: string, disabled?: boolean }) | null, kind: selection | clear | free-entry | create, trigger: keyboard | pointer | programmatic })"
|
|
115
|
+
type="object({ value: string | null, query: string, option: object({ id: string, value: string, label: string, group?: string, disabled?: boolean, description?: string, tag?: object({ label: string, tone?: neutral | accent | info | success | warning | danger }) }) | null, kind: selection | clear | free-entry | create, trigger: keyboard | pointer | programmatic })"
|
|
106
116
|
></event>
|
|
107
117
|
<event
|
|
108
118
|
name="validation-change"
|
|
109
119
|
type="object({ status: pristine | pending | valid | warning | error, touched: boolean, dirty: boolean, issues: list(object({ message: string, path?: list(unknown), severity?: error | warning })), output?: unknown })"
|
|
110
120
|
></event>
|
|
111
|
-
<event
|
|
121
|
+
<event
|
|
122
|
+
name="options-change"
|
|
123
|
+
type="list(object({ id: string, value: string, label: string, group?: string, disabled?: boolean, description?: string, tag?: object({ label: string, tone?: neutral | accent | info | success | warning | danger }) }))"
|
|
124
|
+
></event>
|
|
112
125
|
<event
|
|
113
126
|
name="add-item"
|
|
114
|
-
type="object({ item: object({ id: string, value: string, label: string, group?: string, disabled?: boolean }), index: integer, trigger: keyboard | pointer | programmatic })"
|
|
127
|
+
type="object({ item: object({ id: string, value: string, label: string, group?: string, disabled?: boolean, description?: string, tag?: object({ label: string, tone?: neutral | accent | info | success | warning | danger }) }), index: integer, trigger: keyboard | pointer | programmatic })"
|
|
115
128
|
></event>
|
|
116
129
|
<event
|
|
117
130
|
name="remove-item"
|
|
118
|
-
type="object({ item: object({ id: string, value: string, label: string, group?: string, disabled?: boolean }), index: integer, trigger: keyboard | pointer | programmatic })"
|
|
131
|
+
type="object({ item: object({ id: string, value: string, label: string, group?: string, disabled?: boolean, description?: string, tag?: object({ label: string, tone?: neutral | accent | info | success | warning | danger }) }), index: integer, trigger: keyboard | pointer | programmatic })"
|
|
119
132
|
></event>
|
|
120
133
|
<event name="create-item" type="object({ query: string, trigger: keyboard | pointer | programmatic })"></event>
|
|
121
134
|
<event name="selected-values-change" type="object({ selectedValues: list(string), trigger: keyboard | pointer | programmatic })"></event>
|
|
@@ -222,8 +235,19 @@
|
|
|
222
235
|
:aria-selected="row.selected"
|
|
223
236
|
:aria-disabled="row.disabled"
|
|
224
237
|
:data-index="row.index"
|
|
238
|
+
:aria-labelledby="row.labelledBy"
|
|
239
|
+
:aria-describedby="row.detail"
|
|
225
240
|
>
|
|
226
|
-
<slot :name="format('option-%s', row.id)"
|
|
241
|
+
<slot :name="format('option-%s', row.id)">
|
|
242
|
+
<span $if="row.detail" class="detail">
|
|
243
|
+
<span class="line">
|
|
244
|
+
<span class="primary" :id="format('%s-option-%s-label', uid, row.index)" $value="row.label"></span>
|
|
245
|
+
<ui-badge $if="row.tag" :tone="row.tag.tone"><span :id="format('%s-option-%s-tag', uid, row.index)" $value="row.tag.label"></span></ui-badge>
|
|
246
|
+
</span>
|
|
247
|
+
<span $if="row.description" class="description" :id="format('%s-option-%s-description', uid, row.index)" $value="row.description"></span>
|
|
248
|
+
</span>
|
|
249
|
+
<span $if="not row.detail" class="primary" $value="row.label"></span>
|
|
250
|
+
</slot>
|
|
227
251
|
</div>
|
|
228
252
|
</div>
|
|
229
253
|
<div
|
|
@@ -486,6 +510,23 @@
|
|
|
486
510
|
color: var(--_ui-combobox-option-text, var(--ui-text-primary));
|
|
487
511
|
font-weight: 500;
|
|
488
512
|
}
|
|
513
|
+
/* A row with a tag or a description: the label and its tag on one line, the description under them. */
|
|
514
|
+
.detail {
|
|
515
|
+
display: grid;
|
|
516
|
+
gap: var(--ui-space-0-5);
|
|
517
|
+
min-inline-size: 0;
|
|
518
|
+
}
|
|
519
|
+
.line {
|
|
520
|
+
display: flex;
|
|
521
|
+
flex-wrap: wrap;
|
|
522
|
+
align-items: center;
|
|
523
|
+
gap: var(--ui-space-2);
|
|
524
|
+
}
|
|
525
|
+
.description {
|
|
526
|
+
color: var(--ui-text-muted);
|
|
527
|
+
font-size: var(--ui-font-size-sm);
|
|
528
|
+
line-height: var(--ui-line-height-md);
|
|
529
|
+
}
|
|
489
530
|
.message, .group {
|
|
490
531
|
color: var(--_ui-combobox-message-text, var(--ui-text-secondary));
|
|
491
532
|
font-weight: 400;
|
|
@@ -5,14 +5,21 @@ const instances = new WeakMap();
|
|
|
5
5
|
|
|
6
6
|
let comboboxes = 0;
|
|
7
7
|
|
|
8
|
+
// An option's secondary line and tag ride on data-* attributes: <option> holds only text, so markup inside
|
|
9
|
+
// it would run into its label before JavaScript. A row without them keeps the declared shape exactly.
|
|
8
10
|
function authoredOptions(container) {
|
|
9
|
-
return Array.from(container.querySelectorAll("option")).map((option, index) =>
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
return Array.from(container.querySelectorAll("option")).map((option, index) => {
|
|
12
|
+
const { description, tag, tagTone } = option.dataset;
|
|
13
|
+
return {
|
|
14
|
+
id: option.id || option.value || `option-${index}`,
|
|
15
|
+
value: option.value,
|
|
16
|
+
label: option.label || option.textContent?.trim() || option.value,
|
|
17
|
+
group: option.closest("optgroup")?.label || undefined,
|
|
18
|
+
disabled: option.disabled,
|
|
19
|
+
...(description ? { description } : {}),
|
|
20
|
+
...(tag ? { tag: tagTone ? { label: tag, tone: tagTone } : { label: tag } } : {}),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
export async function validate(host) {
|
|
@@ -48,6 +55,8 @@ export default function controller(host) {
|
|
|
48
55
|
options: authoredOptions(authored),
|
|
49
56
|
allowFreeText: Boolean(host.state.allowFreeText),
|
|
50
57
|
allowCreate: Boolean(host.state.allowCreate),
|
|
58
|
+
// "none": the consumer already narrowed the options (a server search), so every one is listed.
|
|
59
|
+
filterByLabel: host.state.filter !== "none",
|
|
51
60
|
});
|
|
52
61
|
// Selected items work uncontrolled: the component keeps them and reports every change. A consumer
|
|
53
62
|
// that owns `items` stays in charge, because the prop resyncs whatever it sets. A consumer that sets
|
|
@@ -142,7 +151,7 @@ export default function controller(host) {
|
|
|
142
151
|
const filtered = options.filter((option) => {
|
|
143
152
|
if (ids.has(option.id)) return false;
|
|
144
153
|
ids.add(option.id);
|
|
145
|
-
if (fullSet) return true;
|
|
154
|
+
if (fullSet || !current.filterByLabel) return true;
|
|
146
155
|
return option.label.toLocaleLowerCase().includes(query.toLocaleLowerCase());
|
|
147
156
|
}).map((option) => ({
|
|
148
157
|
...option,
|
|
@@ -218,12 +227,14 @@ export default function controller(host) {
|
|
|
218
227
|
host.dispatch("query-change", { query, display: query, trigger });
|
|
219
228
|
};
|
|
220
229
|
// A row carries a view-only `selected` flag; an item is the option itself, in its declared shape.
|
|
221
|
-
const asItem = ({ id, value, label, group, disabled }) => ({
|
|
230
|
+
const asItem = ({ id, value, label, group, disabled, description, tag }) => ({
|
|
222
231
|
id,
|
|
223
232
|
value,
|
|
224
233
|
label,
|
|
225
234
|
...(group === undefined ? {} : { group }),
|
|
226
|
-
...(disabled === undefined ? {} : { disabled })
|
|
235
|
+
...(disabled === undefined ? {} : { disabled }),
|
|
236
|
+
...(description === undefined ? {} : { description }),
|
|
237
|
+
...(tag === undefined ? {} : { tag })
|
|
227
238
|
});
|
|
228
239
|
const addSelectedItem = (row, trigger) => {
|
|
229
240
|
const option = asItem(row);
|
|
@@ -258,7 +269,8 @@ export default function controller(host) {
|
|
|
258
269
|
};
|
|
259
270
|
// A strict combobox (single, no free text, no create) behaves like a select: leaving it resolves the
|
|
260
271
|
// typed text to a valid option (the highlighted one, an exact label, the first label it begins, or the
|
|
261
|
-
// only remaining option); with no match it reverts to the previous selection, or clears.
|
|
272
|
+
// only remaining option); with no match it reverts to the previous selection, or clears. With filter
|
|
273
|
+
// "none" the remaining options are all of them, whatever their labels, so the only one listed counts.
|
|
262
274
|
const resolveTyped = (trigger) => {
|
|
263
275
|
const current = config();
|
|
264
276
|
if (host.state.multiple || current.allowFreeText || current.allowCreate) return;
|
|
@@ -276,7 +288,7 @@ export default function controller(host) {
|
|
|
276
288
|
?? enabled.find((row) => row.label.toLocaleLowerCase() === query)
|
|
277
289
|
?? enabled.find((row) => row.label.toLocaleLowerCase().startsWith(query))
|
|
278
290
|
?? (() => {
|
|
279
|
-
const containing = enabled.filter((option) => option.label.toLocaleLowerCase().includes(query));
|
|
291
|
+
const containing = current.filterByLabel ? enabled.filter((option) => option.label.toLocaleLowerCase().includes(query)) : enabled;
|
|
280
292
|
return containing.length === 1 ? containing[0] : undefined;
|
|
281
293
|
})();
|
|
282
294
|
if (match) commit(match.value, match.label, match, "selection", trigger);
|
|
@@ -363,7 +375,10 @@ export default function controller(host) {
|
|
|
363
375
|
group = { name, role: name ? "group" : "presentation", label: name || null, rows: [] };
|
|
364
376
|
groups.push(group);
|
|
365
377
|
}
|
|
366
|
-
|
|
378
|
+
// The label names the option and the tag and description describe it, as a listbox option's
|
|
379
|
+
// label and description parts do; a row without either keeps its name from its content.
|
|
380
|
+
const detail = [row.tag ? `${uid}-option-${index}-tag` : "", row.description ? `${uid}-option-${index}-description` : ""].filter(Boolean).join(" ");
|
|
381
|
+
group.rows.push({ ...row, index, labelledBy: detail ? `${uid}-option-${index}-label` : null, detail: detail || null });
|
|
367
382
|
});
|
|
368
383
|
host.state.groups = groups;
|
|
369
384
|
host.state.creatable = canCreate();
|
|
@@ -491,7 +506,7 @@ export default function controller(host) {
|
|
|
491
506
|
});
|
|
492
507
|
// Options can change while the list is open, as when a consumer adds the option it just created;
|
|
493
508
|
// the open list shows them at once rather than at the next keystroke.
|
|
494
|
-
const optionsKey = () => JSON.stringify((config().options ?? []).map((row) => [row.value, row.label, row.disabled]));
|
|
509
|
+
const optionsKey = () => JSON.stringify((config().options ?? []).map((row) => [row.value, row.label, row.disabled, row.description, row.tag]));
|
|
495
510
|
let lastOptionsKey = optionsKey();
|
|
496
511
|
const observer = new MutationObserver(() => {
|
|
497
512
|
relabel();
|