@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.
@@ -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 name="items" type="list(object({ id: string, value: string, label: string, group?: string, disabled?: boolean }))"
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 name="internalItems" type="list(object({ id: string, value: string, label: string, group?: string, disabled?: boolean }))" :value="[]"></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 name="options-change" type="list(object({ id: string, value: string, label: string, group?: string, disabled?: boolean }))"></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)"><span class="primary" $value="row.label"></span></slot>
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
- id: option.id || option.value || `option-${index}`,
11
- value: option.value,
12
- label: option.label || option.textContent?.trim() || option.value,
13
- group: option.closest("optgroup")?.label || undefined,
14
- disabled: option.disabled,
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
- group.rows.push({ ...row, index });
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();