@threadlabs/looma 0.14.5 → 0.14.7

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.
@@ -0,0 +1,46 @@
1
+ // Warnings about authoring mistakes the controllers work around but cannot fix. They run in
2
+ // development only: bundlers replace process.env.NODE_ENV, the convention Vue and React follow, and a
3
+ // production build ("production") skips them. Without a bundler there is no process, and they run.
4
+ export const DEV = (() => {
5
+ try {
6
+ return process.env.NODE_ENV !== "production";
7
+ } catch {
8
+ return true;
9
+ }
10
+ })();
11
+
12
+ // True when another element in the same document or shadow root has this element's id.
13
+ export function duplicateId(element) {
14
+ return Boolean(element.id) && element.getRootNode().querySelectorAll(`[id="${CSS.escape(element.id)}"]`).length > 1;
15
+ }
16
+
17
+ // A Form Field's authoring problems, read before the field links its label and input.
18
+ export function fieldProblems(label, input) {
19
+ const problems = [];
20
+ const target = label?.getAttribute("for");
21
+ if (label && !target && !label.contains(input)) {
22
+ problems.push(
23
+ "ui-form-field: the label has no for, so the field linked it to its input, which works only once JavaScript runs. Give the input an id and the label a matching for, or put the input inside the label, so the label works without JavaScript.",
24
+ );
25
+ }
26
+ if (duplicateId(input)) {
27
+ problems.push(
28
+ `ui-form-field: the input's id "${input.id}" is used by another element in the same document or shadow root, so its label and description can reach the wrong element. Give each input its own id.`,
29
+ );
30
+ }
31
+ if (target && input.getRootNode().querySelector(`#${CSS.escape(target)}`) !== input) {
32
+ problems.push(
33
+ `ui-form-field: the label's for="${target}" does not point at this field's input, so the label names another element or none. Set for to the input's id.`,
34
+ );
35
+ }
36
+ return problems;
37
+ }
38
+
39
+ // Each problem is said once per field, with the field, however often the controller re-links it.
40
+ export function warnOnce(warned, problems, element) {
41
+ for (const problem of problems) {
42
+ if (warned.has(problem)) continue;
43
+ warned.add(problem);
44
+ console.warn(problem, element);
45
+ }
46
+ }
@@ -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
@@ -60,14 +62,18 @@
60
62
  <prop name="help" type="string" default=""
61
63
  >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
64
  it.</prop>
63
- <state name="internalItems" type="list(object({ id: string, value: string, label: string, group?: string, disabled?: boolean }))" :value="[]"></state>
65
+ <state
66
+ name="internalItems"
67
+ 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 }) }))"
68
+ :value="[]"
69
+ ></state>
64
70
  <state name="raw" :value="''"></state>
65
71
  <state name="display" :value="''"></state>
66
72
  <state name="selected" type="string | null" :value="null"></state>
67
73
  <state name="expanded" :value="false"></state>
68
74
  <state
69
75
  name="rows"
70
- type="list(object({ id: string, value: string, label: string, group?: string, disabled: boolean, selected: boolean }))"
76
+ 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
77
  :value="[]"
72
78
  ></state>
73
79
  <state name="active" :value="-1"></state>
@@ -83,7 +89,7 @@
83
89
  <state name="submitted" :value="''"></state>
84
90
  <state
85
91
  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 })) }))"
92
+ 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
93
  :value="[]"
88
94
  ></state>
89
95
  <state name="creatable" :value="false"></state>
@@ -94,28 +100,31 @@
94
100
  <event name="query-change" type="object({ query: string, display: string, trigger: keyboard | pointer | programmatic })"></event>
95
101
  <event
96
102
  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 }))"
103
+ 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
104
  ></event>
99
105
  <event
100
106
  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 })"
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 })"
102
108
  ></event>
103
109
  <event
104
110
  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 })"
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 })"
106
112
  ></event>
107
113
  <event
108
114
  name="validation-change"
109
115
  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
116
  ></event>
111
- <event name="options-change" type="list(object({ id: string, value: string, label: string, group?: string, disabled?: boolean }))"></event>
117
+ <event
118
+ name="options-change"
119
+ 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 }) }))"
120
+ ></event>
112
121
  <event
113
122
  name="add-item"
114
- type="object({ item: object({ id: string, value: string, label: string, group?: string, disabled?: boolean }), index: integer, trigger: keyboard | pointer | programmatic })"
123
+ 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
124
  ></event>
116
125
  <event
117
126
  name="remove-item"
118
- 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 })"
119
128
  ></event>
120
129
  <event name="create-item" type="object({ query: string, trigger: keyboard | pointer | programmatic })"></event>
121
130
  <event name="selected-values-change" type="object({ selectedValues: list(string), trigger: keyboard | pointer | programmatic })"></event>
@@ -222,8 +231,19 @@
222
231
  :aria-selected="row.selected"
223
232
  :aria-disabled="row.disabled"
224
233
  :data-index="row.index"
234
+ :aria-labelledby="row.labelledBy"
235
+ :aria-describedby="row.detail"
225
236
  >
226
- <slot :name="format('option-%s', row.id)"><span class="primary" $value="row.label"></span></slot>
237
+ <slot :name="format('option-%s', row.id)">
238
+ <span $if="row.detail" class="detail">
239
+ <span class="line">
240
+ <span class="primary" :id="format('%s-option-%s-label', uid, row.index)" $value="row.label"></span>
241
+ <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>
242
+ </span>
243
+ <span $if="row.description" class="description" :id="format('%s-option-%s-description', uid, row.index)" $value="row.description"></span>
244
+ </span>
245
+ <span $if="not row.detail" class="primary" $value="row.label"></span>
246
+ </slot>
227
247
  </div>
228
248
  </div>
229
249
  <div
@@ -486,6 +506,23 @@
486
506
  color: var(--_ui-combobox-option-text, var(--ui-text-primary));
487
507
  font-weight: 500;
488
508
  }
509
+ /* A row with a tag or a description: the label and its tag on one line, the description under them. */
510
+ .detail {
511
+ display: grid;
512
+ gap: var(--ui-space-0-5);
513
+ min-inline-size: 0;
514
+ }
515
+ .line {
516
+ display: flex;
517
+ flex-wrap: wrap;
518
+ align-items: center;
519
+ gap: var(--ui-space-2);
520
+ }
521
+ .description {
522
+ color: var(--ui-text-muted);
523
+ font-size: var(--ui-font-size-sm);
524
+ line-height: var(--ui-line-height-md);
525
+ }
489
526
  .message, .group {
490
527
  color: var(--_ui-combobox-message-text, var(--ui-text-secondary));
491
528
  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) {
@@ -218,12 +225,14 @@ export default function controller(host) {
218
225
  host.dispatch("query-change", { query, display: query, trigger });
219
226
  };
220
227
  // 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 }) => ({
228
+ const asItem = ({ id, value, label, group, disabled, description, tag }) => ({
222
229
  id,
223
230
  value,
224
231
  label,
225
232
  ...(group === undefined ? {} : { group }),
226
- ...(disabled === undefined ? {} : { disabled })
233
+ ...(disabled === undefined ? {} : { disabled }),
234
+ ...(description === undefined ? {} : { description }),
235
+ ...(tag === undefined ? {} : { tag })
227
236
  });
228
237
  const addSelectedItem = (row, trigger) => {
229
238
  const option = asItem(row);
@@ -363,7 +372,10 @@ export default function controller(host) {
363
372
  group = { name, role: name ? "group" : "presentation", label: name || null, rows: [] };
364
373
  groups.push(group);
365
374
  }
366
- group.rows.push({ ...row, index });
375
+ // The label names the option and the tag and description describe it, as a listbox option's
376
+ // label and description parts do; a row without either keeps its name from its content.
377
+ const detail = [row.tag ? `${uid}-option-${index}-tag` : "", row.description ? `${uid}-option-${index}-description` : ""].filter(Boolean).join(" ");
378
+ group.rows.push({ ...row, index, labelledBy: detail ? `${uid}-option-${index}-label` : null, detail: detail || null });
367
379
  });
368
380
  host.state.groups = groups;
369
381
  host.state.creatable = canCreate();
@@ -491,7 +503,7 @@ export default function controller(host) {
491
503
  });
492
504
  // Options can change while the list is open, as when a consumer adds the option it just created;
493
505
  // 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]));
506
+ const optionsKey = () => JSON.stringify((config().options ?? []).map((row) => [row.value, row.label, row.disabled, row.description, row.tag]));
495
507
  let lastOptionsKey = optionsKey();
496
508
  const observer = new MutationObserver(() => {
497
509
  relabel();
@@ -1,3 +1,5 @@
1
+ import { DEV, fieldProblems, warnOnce } from "../shared/authoring.js";
2
+
1
3
  let nextFieldId = 0;
2
4
 
3
5
  function id(prefix) {
@@ -14,6 +16,7 @@ export default function controller(host) {
14
16
  let activeInput = null;
15
17
  let owned = new Set();
16
18
  let queued = false;
19
+ const warned = new Set();
17
20
  const removeOwned = (input) => {
18
21
  if (!input || owned.size === 0) return;
19
22
  const remaining = descriptionIds(input).filter((value) => !owned.has(value));
@@ -32,6 +35,7 @@ export default function controller(host) {
32
35
  owned = new Set();
33
36
  }
34
37
  if (!input) return;
38
+ if (DEV) warnOnce(warned, fieldProblems(label, input), element);
35
39
  if (!input.id) input.id = id("form-field-input");
36
40
  if (label && !label.getAttribute("for")) label.setAttribute("for", input.id);
37
41
  const external = descriptionIds(input).filter((value) => !owned.has(value));
@@ -1,3 +1,5 @@
1
+ import { DEV, duplicateId, warnOnce } from "../shared/authoring.js";
2
+
1
3
  let nextAffixId = 0;
2
4
  // An affix holding a control keeps it: its clicks are its own, and it is not hidden or described.
3
5
  const INTERACTIVE = "a[href], button, input, select, textarea, [tabindex], [contenteditable]";
@@ -6,6 +8,9 @@ function descriptionIds(input) {
6
8
  return (input.getAttribute("aria-describedby") ?? "").split(/\s+/).filter(Boolean);
7
9
  }
8
10
 
11
+ const affixProblem = (affix) =>
12
+ `ui-input-group: the affix id "${affix.id}" is used by another element in the same document or shadow root, so the input's description can read the wrong element. Give each affix its own id, or leave it off and the group adds one.`;
13
+
9
14
  // A text affix is hidden from assistive technology and linked to the input as its description
10
15
  // instead, so the input's name stays its label and the affix is read once, with the input. The
11
16
  // consumer's own aria-describedby (or a Form Field's) is kept. A press on an affix, or anywhere in
@@ -14,6 +19,7 @@ export default function controller(host) {
14
19
  const element = host.element;
15
20
  let owned = [];
16
21
  let described = null;
22
+ const warned = new Set();
17
23
  const wire = () => {
18
24
  const input = element.querySelector(".field input");
19
25
  const affixes = Array.from(element.querySelectorAll(":scope > .affix")).filter((affix) => {
@@ -23,6 +29,7 @@ export default function controller(host) {
23
29
  return text;
24
30
  });
25
31
  for (const affix of affixes) affix.id ||= `ui-input-group-affix-${(nextAffixId += 1)}`;
32
+ if (DEV) warnOnce(warned, affixes.filter(duplicateId).map(affixProblem), element);
26
33
  if (described && described !== input) {
27
34
  const rest = descriptionIds(described).filter((id) => !owned.includes(id));
28
35
  if (rest.length) described.setAttribute("aria-describedby", rest.join(" "));