@threadlabs/looma 0.23.9 → 0.23.11

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.
@@ -54,6 +54,12 @@
54
54
  >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
55
55
  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
56
56
  commits an option whose label matches the text, or the only one listed.</prop>
57
+ <prop name="autoHighlight" type="keyword" values="none, single" default="none"
58
+ >single highlights the only enabled authored suggestion while typing. Ambiguous results and an empty query stay unhighlighted. none preserves manual
59
+ highlighting with the arrow keys.</prop>
60
+ <prop name="selectOnTab" type="boolean" default="false"
61
+ >Commits a highlighted authored suggestion on forward Tab, then lets focus advance normally. In multiple mode a selected suggestion is retained without
62
+ duplication or removal. Shift+Tab does not commit, and Tab never chooses the create offer.</prop>
57
63
  <prop name="allowFreeText" type="boolean" default="false">Allows values that do not match an authored option.</prop>
58
64
  <prop name="allowCreate" type="boolean" default="false"
59
65
  >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
@@ -174,6 +174,10 @@ export default function controller(host) {
174
174
  // When nothing matches what was typed, the offer to create it is the only choice, so it is
175
175
  // the one Enter makes, and it is highlighted as such.
176
176
  if (query && !host.state.rows.some((row) => !row.disabled) && canCreate()) host.state.active = host.state.rows.length;
177
+ if (query && host.props.autoHighlight.value === "single") {
178
+ const enabled = host.state.rows.flatMap((row, index) => row.disabled ? [] : [index]);
179
+ if (enabled.length === 1) host.state.active = enabled[0];
180
+ }
177
181
  // Options in their declared shape: a row's `selected` flag is the list's own view state.
178
182
  host.dispatch("options-change", host.state.rows.map(asItem));
179
183
  };
@@ -441,7 +445,19 @@ export default function controller(host) {
441
445
  if (!host.state.raw && (input.selectionStart ?? 0) === 0 && event.key === "Backspace" && items().length) { event.preventDefault(); removeItemAt(items().length - 1, "keyboard"); return; }
442
446
  }
443
447
  if (event.key === "Escape" && host.state.expanded) { event.preventDefault(); event.stopPropagation(); close(); }
444
- else if (event.key === "Tab") { resolveTyped("keyboard"); close(); }
448
+ else if (event.key === "Tab") {
449
+ const highlighted = host.state.expanded ? (host.state.rows ?? [])[host.state.active] : undefined;
450
+ if (host.props.selectOnTab.value && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey && highlighted && !highlighted.disabled) {
451
+ if (host.props.multiple.value) {
452
+ setMultiQuery("", "keyboard");
453
+ if (!selectedSet().has(highlighted.value)) addSelectedItem(highlighted, "keyboard");
454
+ } else {
455
+ commit(highlighted.value, highlighted.label, asItem(highlighted), "selection", "keyboard");
456
+ queueMicrotask(() => void validateCurrent());
457
+ }
458
+ } else if (!host.props.selectOnTab.value) resolveTyped("keyboard");
459
+ close();
460
+ }
445
461
  else if (!event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey && ["ArrowDown", "ArrowUp"].includes(event.key)) { event.preventDefault(); if (!host.state.expanded) open("disclosure"); move(event.key); }
446
462
  else if (["Home", "End"].includes(event.key) && host.state.expanded && host.state.active >= 0) { event.preventDefault(); move(event.key); }
447
463
  else if (event.key === "Enter" && host.state.expanded) {
@@ -3,7 +3,8 @@
3
3
  <prop name="density" type="keyword" values="comfortable, compact" default="comfortable"
4
4
  >Compact trades padding and type size for rows that fit more on screen.</prop>
5
5
  <prop name="variant" type="keyword" values="plain, navigation" default="plain"
6
- >Navigation shares icon columns, spacing, type, hover, and corners with Nav Item. Plain retains the standard disclosure treatment.</prop>
6
+ >Navigation shares icon columns, spacing, type, hover, and corners with Nav Item, while keeping expanded sections neutral: opening content is not route
7
+ selection. Plain retains the standard disclosure treatment.</prop>
7
8
  <prop name="requiredOpen" type="boolean" default="false"
8
9
  >In a named exclusive group, keeps at least one member open. Clicking the last open trigger does not close it; opening another member transfers the open
9
10
  state.</prop>
@@ -227,9 +228,6 @@
227
228
  background: var(--ui-nav-hover-surface);
228
229
  color: var(--ui-text-primary);
229
230
  }
230
- :host-state([variant="navigation"][internalOpen]) .trigger {
231
- color: var(--ui-selection-text);
232
- }
233
231
  :host-state([variant="navigation"]) :is(.leading, .indicator) {
234
232
  inline-size: var(--ui-nav-icon-size);
235
233
  font-size: var(--ui-nav-icon-size);
@@ -17,6 +17,10 @@
17
17
  <prop name="marquee" type="boolean" default="false"
18
18
  >Scrolls a name too long for its row while that row is hovered or keyboard focused, rests at the end, then scrolls it again. Off by default: a tree of names
19
19
  that fit should not move. An item may set its own marquee to opt in or out on its own.</prop>
20
+ <prop name="moveActivation" type="keyword" values="click, keyboard-touch" default="click"
21
+ >Which handle activations start guided moving. click preserves two-click moving with any pointer. keyboard-touch leaves ordinary mouse and pen clicks inert
22
+ while retaining keyboard activation and touch taps. Actual pointer dragging works in either mode. Guided moves show Cancel on the source row; Escape or
23
+ reactivating that source handle also cancels without requesting a reorder.</prop>
20
24
  <event
21
25
  name="reorder"
22
26
  type="object"
@@ -32,6 +32,7 @@ export default function controller(host) {
32
32
  let typed = "";
33
33
  let lastTyped = 0;
34
34
  let moveMode = false;
35
+ let handlePointerType = null;
35
36
  const announcer = document.createElement("span");
36
37
  announcer.setAttribute("role", "status");
37
38
  announcer.style.cssText = "position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap";
@@ -103,6 +104,8 @@ export default function controller(host) {
103
104
  moveMode = false;
104
105
  element.removeAttribute("data-move-mode");
105
106
  source?.removeAttribute("data-dragging");
107
+ source?.removeAttribute("data-move-source");
108
+ source?.dispatchEvent(new CustomEvent("ui-tree-move-state", { detail: { moving: false } }));
106
109
  source = null;
107
110
  clearTarget();
108
111
  rejection = null;
@@ -165,8 +168,16 @@ export default function controller(host) {
165
168
  moveMode = true;
166
169
  source = item;
167
170
  source.setAttribute("data-dragging", "true");
171
+ source.setAttribute("data-move-source", "");
172
+ source.dispatchEvent(new CustomEvent("ui-tree-move-state", { detail: { moving: true } }));
168
173
  element.setAttribute("data-move-mode", "");
169
- announce(`Moving ${nameOf(item)}. Choose a destination by click or the arrow keys. Press Enter to place or Escape to cancel.`);
174
+ announce(`Moving ${nameOf(item)}. Choose a destination by click or the arrow keys. Press Enter to place, or use Cancel or Escape to cancel.`);
175
+ };
176
+ const cancelMove = () => {
177
+ const handle = source && rowFor(source)?.querySelector(".drag-handle");
178
+ finish();
179
+ handle?.focus();
180
+ announce("Move cancelled.");
170
181
  };
171
182
  const commitMove = (how) => {
172
183
  if (!source) return;
@@ -182,12 +193,21 @@ export default function controller(host) {
182
193
  } else announce("Choose another destination row.");
183
194
  };
184
195
  const onMoveClick = (event) => {
196
+ if (moveMode && event.composedPath().some((node) => node instanceof HTMLElement && node.hasAttribute("data-tree-cancel-move"))) {
197
+ event.preventDefault();
198
+ event.stopPropagation();
199
+ cancelMove();
200
+ return;
201
+ }
185
202
  const handle = event.composedPath().find((node) => node instanceof HTMLElement && node.classList.contains("drag-handle"));
186
203
  if (handle) {
187
204
  event.preventDefault();
188
205
  event.stopPropagation();
189
206
  const item = itemFromEvent(event);
190
- if (item) beginMove(item);
207
+ if (moveMode && item === source) cancelMove();
208
+ else if (item && (host.props.moveActivation.value !== "keyboard-touch" || event.detail === 0
209
+ || (event.pointerType ?? handlePointerType) === "touch")) beginMove(item);
210
+ handlePointerType = null;
191
211
  return;
192
212
  }
193
213
  if (!moveMode) {
@@ -214,6 +234,7 @@ export default function controller(host) {
214
234
  else if (rejection) commitMove("pointer");
215
235
  else announce("Choose another destination row.");
216
236
  };
237
+ const onHandlePointerDown = (event) => { handlePointerType = event.pointerType; };
217
238
  const onMovePointer = (event) => {
218
239
  if (!moveMode) return;
219
240
  const item = itemFromEvent(event);
@@ -223,13 +244,15 @@ export default function controller(host) {
223
244
  if (!moveMode) return false;
224
245
  if (event.key === "Escape") {
225
246
  event.preventDefault();
226
- finish();
227
- announce("Move cancelled.");
247
+ cancelMove();
228
248
  return true;
229
249
  }
230
250
  if (event.key === "Enter" || event.key === " ") {
231
251
  event.preventDefault();
232
- commitMove("keyboard");
252
+ const cancelling = event.composedPath().some((node) => node instanceof HTMLElement
253
+ && (node.hasAttribute("data-tree-cancel-move") || (node.classList.contains("drag-handle") && itemFromEvent(event) === source)));
254
+ if (cancelling) cancelMove();
255
+ else commitMove("keyboard");
233
256
  return true;
234
257
  }
235
258
  if (event.key === "ArrowRight" && target) {
@@ -254,6 +277,7 @@ export default function controller(host) {
254
277
  const candidate = items[index];
255
278
  const side = step < 0 ? "before" : "after";
256
279
  if (candidate !== source && chooseTarget(candidate, side)) {
280
+ focusItem(candidate);
257
281
  announce(`Move ${nameOf(source)} ${side} ${nameOf(candidate)}. Press Enter to place.`);
258
282
  break;
259
283
  }
@@ -364,6 +388,7 @@ export default function controller(host) {
364
388
  host.dispatch("select", { ids: items.map(itemId).filter((candidate) => candidate && selected.has(candidate)), trigger: how });
365
389
  };
366
390
  const syncStructure = () => {
391
+ if (source && !element.contains(source)) finish();
367
392
  for (const item of allItems()) item.dispatchEvent(new CustomEvent("ui-tree-structure-sync"));
368
393
  syncTabStop();
369
394
  syncSelection();
@@ -416,7 +441,7 @@ export default function controller(host) {
416
441
  if (match) { event.preventDefault(); focusItem(match); }
417
442
  }
418
443
  };
419
- const listeners = { dragstart: onDragStart, drag: onDrag, dragenter: onDragOver, dragover: onDragOver, dragleave: onDragLeave, drop: onDrop, dragend: onDragEnd, keydown: onKeydown, focusin: onFocusin, pointermove: onMovePointer, "ui-tree-expansion-change": onExpansion };
444
+ const listeners = { dragstart: onDragStart, drag: onDrag, dragenter: onDragOver, dragover: onDragOver, dragleave: onDragLeave, drop: onDrop, dragend: onDragEnd, keydown: onKeydown, focusin: onFocusin, pointerdown: onHandlePointerDown, pointermove: onMovePointer, "ui-tree-expansion-change": onExpansion };
420
445
  for (const [name, listener] of Object.entries(listeners)) element.addEventListener(name, listener);
421
446
  element.addEventListener("click", onMoveClick, true);
422
447
  const observer = new MutationObserver((records) => {
@@ -430,6 +455,7 @@ export default function controller(host) {
430
455
  });
431
456
  syncStructure();
432
457
  return () => {
458
+ finish();
433
459
  stop();
434
460
  observer.disconnect();
435
461
  cancelHover();
@@ -1,3 +1,4 @@
1
+ <link rel="component" href="../ui-button/ui-button.html">
1
2
  <template component="ui-tree-item" controller="./ui-tree-item.js">
2
3
  <defs>
3
4
  <prop name="itemId" type="string" default=""
@@ -17,8 +18,8 @@
17
18
  >Keeps an empty item expandable while its children are supplied later. Expanding emits expand; the item is aria-busy until child Tree Items are appended.
18
19
  Clear lazy if loading finishes with no children.</prop>
19
20
  <prop name="sortable" type="boolean" default="false"
20
- >Gives the item a move handle. Drag it, or activate it and choose a target by a second click or the keyboard. The tree reports reorder and leaves moving the
21
- item to you. Needs itemId; disabled items show no handle.</prop>
21
+ >Gives the item a move handle. Drag it, or activate it according to the tree's moveActivation and choose a target by a second click or the keyboard. A
22
+ guided move exposes Cancel on this row. The tree reports reorder and leaves moving the item to you. Needs itemId; disabled items show no handle.</prop>
22
23
  <prop name="dragType" type="string" default="item"
23
24
  >The item's kind, for drag rules: it can be dropped before or after only items of the same type, and inside a branch only if that branch's accepts allows
24
25
  it. Reported as sourceType and targetType.</prop>
@@ -44,6 +45,7 @@
44
45
  <state type="integer" name="structuralLevel" value="1"></state>
45
46
  <state type="boolean" name="tabStop" value="false"></state>
46
47
  <state type="boolean" name="isContainer" value="false"></state>
48
+ <state type="boolean" name="moving" value="false"></state>
47
49
  <event name="expand" bubbles="false" type="object">
48
50
  <prop name="id" required type="string"></prop>
49
51
  <prop name="expanded" required type="boolean"></prop>
@@ -70,7 +72,7 @@
70
72
  $ref="dragHandle"
71
73
  type="button"
72
74
  draggable="true"
73
- from:aria-label="format('Drag %s to reorder', label)"
75
+ from:aria-label="{ true: format('Cancel moving %s', label), false: format('Drag %s to reorder', label) }[format('%s', moving)]"
74
76
  >
75
77
  <svg
76
78
  aria-hidden="true"
@@ -103,7 +105,10 @@
103
105
  ></label>
104
106
  <span class="leading" $ref="leading"><slot name="leading"></slot></span>
105
107
  <span class="label" $ref="label"><span class="label-text" $ref="labelText"><slot name="label"><template $value="label"></template></slot></span></span>
106
- <span class="actions" $ref="actions"><slot name="actions"></slot></span>
108
+ <span class="actions" $ref="actions">
109
+ <ui-button $if="moving" data-tree-cancel-move variant="ghost" tone="neutral" size="sm" aria-label="Cancel move">Cancel</ui-button>
110
+ <span $else><slot name="actions"></slot></span>
111
+ </span>
107
112
  <span class="drop-indicator row-drop-indicator" aria-hidden="true"></span>
108
113
  </div>
109
114
  <div class="children" $ref="children" role="group" from:hidden="not (isContainer and internalExpanded)"><slot></slot></div>
@@ -187,7 +192,12 @@
187
192
  background: var(--ui-disabled-surface);
188
193
  }
189
194
 
190
- :host[data-dragging] .row {
195
+ :host[data-dragging]:not([data-move-source]) .row {
196
+ opacity: 0.42;
197
+ }
198
+
199
+ :host[data-move-source] > .row > .label,
200
+ :host[data-move-source] > .row > .leading {
191
201
  opacity: 0.42;
192
202
  }
193
203
 
@@ -360,7 +370,8 @@
360
370
  /* The controls sit over the label's end, so the text fades out where they begin. The fade is
361
371
  the truncation mark while it shows: an ellipsis under it would read as a second one. */
362
372
  .row:hover > .label,
363
- .row:focus-within > .label {
373
+ .row:focus-within > .label,
374
+ :host[data-move-source] > .row > .label {
364
375
  text-overflow: clip;
365
376
  mask-image: linear-gradient(
366
377
  to right,
@@ -370,7 +381,8 @@
370
381
  }
371
382
 
372
383
  .row:hover > .label:dir(rtl),
373
- .row:focus-within > .label:dir(rtl) {
384
+ .row:focus-within > .label:dir(rtl),
385
+ :host[data-move-source] > .row > .label:dir(rtl) {
374
386
  mask-image: linear-gradient(
375
387
  to left,
376
388
  #000 calc(100% - var(--_tree-actions-width, 1.75rem) - var(--ui-space-1)),
@@ -451,7 +463,8 @@
451
463
  }
452
464
 
453
465
  .row:hover > .actions,
454
- .row:focus-within > .actions {
466
+ .row:focus-within > .actions,
467
+ :host[data-move-source] > .row > .actions {
455
468
  opacity: 1;
456
469
  pointer-events: auto;
457
470
  }
@@ -90,7 +90,9 @@ export default function controller(host) {
90
90
  };
91
91
  const onStructure = () => { updateLevel(); apply(); };
92
92
  const onAutoExpand = () => setExpanded(true, "pointer");
93
+ const onMoveState = (event) => { host.state.moving = Boolean(event.detail?.moving); };
93
94
 
95
+ element.addEventListener("ui-tree-move-state", onMoveState);
94
96
  element.addEventListener("ui-tree-auto-expand", onAutoExpand);
95
97
  element.addEventListener("ui-tree-structure-sync", onStructure);
96
98
  element.addEventListener("ui-tree-roving-tab-stop", onRoving);
@@ -219,6 +221,7 @@ export default function controller(host) {
219
221
  element.removeEventListener("focusout", onFocusOut);
220
222
  labelText?.removeEventListener("animationend", onMarqueeEnd);
221
223
  element.removeEventListener("ui-tree-auto-expand", onAutoExpand);
224
+ element.removeEventListener("ui-tree-move-state", onMoveState);
222
225
  element.removeEventListener("ui-tree-structure-sync", onStructure);
223
226
  element.removeEventListener("ui-tree-roving-tab-stop", onRoving);
224
227
  element.removeEventListener("ui-tree-request-expanded", onExpansionRequest);