@threadlabs/looma 0.13.3 → 0.13.5

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.
@@ -25,6 +25,7 @@ export const icons = {
25
25
  "heading-1": [["path",{"d":"M4 12h8"}],["path",{"d":"M4 18V6"}],["path",{"d":"M12 18V6"}],["path",{"d":"m17 12 3-2v8"}]],
26
26
  "heading-2": [["path",{"d":"M4 12h8"}],["path",{"d":"M4 18V6"}],["path",{"d":"M12 18V6"}],["path",{"d":"M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"}]],
27
27
  "heading-3": [["path",{"d":"M4 12h8"}],["path",{"d":"M4 18V6"}],["path",{"d":"M12 18V6"}],["path",{"d":"M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2"}],["path",{"d":"M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2"}]],
28
+ "help": [["circle",{"cx":"12","cy":"12","r":"10"}],["path",{"d":"M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"}],["path",{"d":"M12 17h.01"}]],
28
29
  "highlighter": [["path",{"d":"m9 11-6 6v3h9l3-3"}],["path",{"d":"m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"}]],
29
30
  "image": [["rect",{"width":"18","height":"18","x":"3","y":"3","rx":"2","ry":"2"}],["circle",{"cx":"9","cy":"9","r":"2"}],["path",{"d":"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"}]],
30
31
  "info": [["circle",{"cx":"12","cy":"12","r":"10"}],["path",{"d":"M12 16v-4"}],["path",{"d":"M12 8h.01"}]],
@@ -6,6 +6,9 @@
6
6
  <prop name="align" type="start | center | end | stretch"
7
7
  >How items line up within each row: center (the default) aligns their middles, start their tops, end their bottoms, and stretch makes every item as tall as
8
8
  its row.</prop>
9
+ <prop name="justify" type="start | center | end | between"
10
+ >Where items sit along each row: start (the default), center, end, or between, which pushes the first and last items to the ends of the row and spreads the
11
+ spare space between them, as for a title with its actions.</prop>
9
12
  </defs>
10
13
  <div><slot></slot></div>
11
14
  <style>
@@ -50,6 +53,15 @@
50
53
  :host-state([align="stretch"]) {
51
54
  align-items: stretch;
52
55
  }
56
+ :host-state([justify="center"]) {
57
+ justify-content: center;
58
+ }
59
+ :host-state([justify="end"]) {
60
+ justify-content: flex-end;
61
+ }
62
+ :host-state([justify="between"]) {
63
+ justify-content: space-between;
64
+ }
53
65
 
54
66
  /* A component that sets its display still honours the hidden attribute on its root. */
55
67
  :host[hidden] {
@@ -16,9 +16,15 @@
16
16
  this value.</prop>
17
17
  <prop name="items" type="list(object({ id: string, value: string, label: string, group?: string, disabled?: boolean }))"
18
18
  >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
+ <prop name="selectedValues" type="list(string)"
20
+ >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
21
+ authored option's label, adding or removing one reports selected-values-change with the new list, and the badges follow selectedValues, not the user,
22
+ unless it is updated to match. Setting it reports nothing. It takes precedence over items; leave it unset to let the combobox keep its own
23
+ selection.</prop>
19
24
  <prop name="multiple" type="boolean" default="false"
20
25
  >Lets the user choose several options. Each choice becomes a removable badge before the input, the list stays open between picks, and choosing a checked
21
- option removes it. value-change then reports the whole list of items; use items, not value, in this mode.</prop>
26
+ option removes it. value-change then reports the whole list of items, and selected-values-change the list of their values; use selectedValues or items,
27
+ not value, in this mode.</prop>
22
28
  <prop name="tokenSeparators" type="list(string)"
23
29
  >Keys that commit the typed text in multiple mode, as KeyboardEvent key values such as "," or ";". The text is added as the option whose label matches it,
24
30
  ignoring case, or with allowCreate is reported as create-item; otherwise it stays in the input.</prop>
@@ -110,6 +116,7 @@
110
116
  type="object({ item: object({ id: string, value: string, label: string, group?: string, disabled?: boolean }), index: integer, trigger: keyboard | pointer | programmatic })"
111
117
  ></event>
112
118
  <event name="create-item" type="object({ query: string, trigger: keyboard | pointer | programmatic })"></event>
119
+ <event name="selected-values-change" type="object({ selectedValues: list(string), trigger: keyboard | pointer | programmatic })"></event>
113
120
  <method name="validate" export="validate" returns="promise(unknown)"></method>
114
121
  <method name="focusInput" export="focusInput" returns="promise(undefined)"></method>
115
122
  </defs>
@@ -50,9 +50,32 @@ export default function controller(host) {
50
50
  allowCreate: Boolean(host.state.allowCreate),
51
51
  });
52
52
  // Selected items work uncontrolled: the component keeps them and reports every change. A consumer
53
- // that owns `items` stays in charge, because the prop resyncs whatever it sets.
53
+ // that owns `items` stays in charge, because the prop resyncs whatever it sets. A consumer that sets
54
+ // `selectedValues` controls the selection: the chips follow it, and a user's change is only reported.
54
55
  const items = () => host.state.multiple && Array.isArray(host.state.internalItems) ? host.state.internalItems : [];
55
- const selectedValues = () => new Set(items().map((item) => item.value));
56
+ const selectedSet = () => new Set(items().map((item) => item.value));
57
+ const controlledValues = () => Array.isArray(host.state.selectedValues);
58
+ // Each controlled value shows its authored option; a value no option describes yet keeps the chip it
59
+ // had, or shows the value itself until its option arrives. A repeated value shows once: the chips and
60
+ // hidden inputs are keyed by value. Array.from, not a spread: a loose transpile turns [...set] into [set].
61
+ const itemsForValues = () => {
62
+ const options = config().options;
63
+ return Array.from(new Set(host.state.selectedValues)).map((value) => asItem(options.find((option) => option.value === value)
64
+ ?? items().find((item) => item.value === value) ?? { id: value, value, label: value }));
65
+ };
66
+ const markSelectedRows = () => {
67
+ if (!host.state.multiple || !host.state.rows?.length) return;
68
+ const selected = selectedSet();
69
+ host.state.rows = host.state.rows.map((row) => ({ ...row, selected: selected.has(row.value) }));
70
+ };
71
+ const syncValues = () => {
72
+ if (!controlledValues()) return;
73
+ const next = itemsForValues();
74
+ // An equal selection (a re-rendered literal, an unrelated option edit) leaves the chips alone.
75
+ if (JSON.stringify(next) === JSON.stringify(items())) return;
76
+ host.state.internalItems = next;
77
+ markSelectedRows();
78
+ };
56
79
  const setValidation = (result, touched = host.state.validation?.touched ?? false) => {
57
80
  const issues = result.issues ?? [];
58
81
  const blocking = issues.some((issue) => issue.severity !== "warning");
@@ -113,7 +136,7 @@ export default function controller(host) {
113
136
  resetValidation();
114
137
  }
115
138
  const ids = new Set();
116
- const selected = host.state.multiple ? selectedValues() : undefined;
139
+ const selected = host.state.multiple ? selectedSet() : undefined;
117
140
  // Multiple keeps its chosen options in the list, checked. Dropping them hid what was picked
118
141
  // from the list and from assistive technology, which only ever heard aria-selected="false".
119
142
  const filtered = options.filter((option) => {
@@ -179,9 +202,12 @@ export default function controller(host) {
179
202
  proposedChange = undefined;
180
203
  });
181
204
  };
182
- const emitItems = (next) => {
183
- host.state.internalItems = next;
205
+ const emitItems = (next, trigger) => {
206
+ if (!controlledValues()) host.state.internalItems = next;
184
207
  host.dispatch("value-change", next);
208
+ // Not `values`: Vue's adapter reads a modeled prop with `in`, and every list-shaped detail (this
209
+ // value-change, options-change) has Array.prototype.values, so it would emit update:values wrongly.
210
+ host.dispatch("selected-values-change", { selectedValues: next.map((item) => item.value), trigger });
185
211
  };
186
212
  const setMultiQuery = (query, trigger) => {
187
213
  if (host.state.query === undefined) {
@@ -203,7 +229,7 @@ export default function controller(host) {
203
229
  const option = asItem(row);
204
230
  const current = items();
205
231
  host.dispatch("add-item", { item: option, index: current.length, trigger });
206
- emitItems([...current, option]);
232
+ emitItems([...current, option], trigger);
207
233
  };
208
234
  const createSelectedItem = (query, trigger) => { if (query) host.dispatch("create-item", { query, trigger }); };
209
235
  const choose = (index, trigger) => {
@@ -261,7 +287,8 @@ export default function controller(host) {
261
287
  const query = String(host.state.raw).trim();
262
288
  if (!query) return;
263
289
  const option = (host.state.rows ?? []).find((row) => row.label.trim().toLocaleLowerCase() === query.toLocaleLowerCase());
264
- if (option) { setMultiQuery("", trigger); addSelectedItem(option, trigger); }
290
+ // Typing a label already chosen just clears the text; adding it again would duplicate the chip.
291
+ if (option) { setMultiQuery("", trigger); if (!selectedSet().has(option.value)) addSelectedItem(option, trigger); }
265
292
  else if (config().allowCreate) { setMultiQuery("", trigger); createSelectedItem(query, trigger); }
266
293
  };
267
294
  const itemButtons = () => Array.from(field.querySelectorAll(".item"));
@@ -270,7 +297,7 @@ export default function controller(host) {
270
297
  const item = current[index];
271
298
  if (!item || item.disabled || host.state.disabled || host.state.readonly) return;
272
299
  host.dispatch("remove-item", { item, index, trigger });
273
- emitItems(current.filter((_, position) => position !== index));
300
+ emitItems(current.filter((_, position) => position !== index), trigger);
274
301
  requestAnimationFrame(() => itemButtons()[Math.min(index, itemButtons().length - 1)]?.focus?.() ?? input.focus());
275
302
  };
276
303
  const move = (key) => {
@@ -457,7 +484,7 @@ export default function controller(host) {
457
484
  const stopReset = afterFormReset(input, () => {
458
485
  close();
459
486
  lastSelection = null;
460
- host.state.internalItems = Array.isArray(host.state.items) ? host.state.items : [];
487
+ host.state.internalItems = controlledValues() ? itemsForValues() : Array.isArray(host.state.items) ? host.state.items : [];
461
488
  applyDefaults();
462
489
  input.value = host.state.display;
463
490
  resetValidation();
@@ -468,6 +495,8 @@ export default function controller(host) {
468
495
  let lastOptionsKey = optionsKey();
469
496
  const observer = new MutationObserver(() => {
470
497
  relabel();
498
+ // Controlled chips show their options' current labels, including options that arrive late.
499
+ if (host.state.multiple) syncValues();
471
500
  const key = optionsKey();
472
501
  if (key === lastOptionsKey) return;
473
502
  lastOptionsKey = key;
@@ -475,19 +504,19 @@ export default function controller(host) {
475
504
  });
476
505
  observer.observe(authored, { childList: true, subtree: true, characterData: true, attributes: true });
477
506
  let lastItems = host.state.items;
478
- if (Array.isArray(lastItems)) host.state.internalItems = lastItems;
507
+ let lastSelectedValues = host.state.selectedValues;
508
+ if (controlledValues()) host.state.internalItems = itemsForValues();
509
+ else if (Array.isArray(lastItems)) host.state.internalItems = lastItems;
479
510
  const stop = host.effect(() => {
480
- // A consumer that sets `items` owns them; otherwise the component keeps its own.
511
+ // A consumer that sets `items` owns them; otherwise the component keeps its own. selectedValues wins.
481
512
  if (host.state.items !== lastItems) {
482
513
  lastItems = host.state.items;
483
- if (Array.isArray(lastItems)) host.state.internalItems = lastItems;
514
+ if (Array.isArray(lastItems) && !controlledValues()) host.state.internalItems = lastItems;
484
515
  // The list shows the selection; a consumer that adds or removes an item later (after creating
485
516
  // it, say) is reflected at once rather than at the next search.
486
- if (host.state.multiple && host.state.rows?.length) {
487
- const selected = selectedValues();
488
- host.state.rows = host.state.rows.map((row) => ({ ...row, selected: selected.has(row.value) }));
489
- }
517
+ markSelectedRows();
490
518
  }
519
+ if (host.state.selectedValues !== lastSelectedValues) { lastSelectedValues = host.state.selectedValues; syncValues(); }
491
520
  if (host.state.value !== lastValue) { lastValue = host.state.value; syncValue(); }
492
521
  if (host.state.query !== lastQuery) { lastQuery = host.state.query; syncQuery(); }
493
522
  if (host.state.disabled || host.state.readonly) close();
@@ -265,6 +265,10 @@
265
265
 
266
266
  :host {
267
267
  --_icon-button-icon-default: var(--ui-icon-size-md);
268
+ /* A slotted ui-icon is its own component, out of reach of the svg rule below; these are its
269
+ defaults, which its own --ui-icon-* hooks still override. */
270
+ --_ui-default-icon-size: var(--_ui-icon-button-icon-size, var(--_icon-button-icon-default));
271
+ --_ui-default-icon-stroke-width: var(--_ui-icon-button-icon-stroke-width, 1.75);
268
272
  }
269
273
 
270
274
  :host-state([size="sm"]) {