@threadlabs/looma 0.15.6 → 0.16.0

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.
@@ -1,10 +1,17 @@
1
1
  <template component="ui-dialog" controller="./ui-dialog.js">
2
2
  <defs>
3
3
  <prop name="open" type="boolean" default="false">Whether the dialog is open initially or when set externally.</prop>
4
- <prop name="modeless" type="boolean" default="false">Open without a backdrop or making the rest of the page inert; dialogs are modal by default.</prop>
5
- <prop name="closedby" type="any | closerequest | none" default="closerequest"
6
- >Which user close requests work: any includes outside presses, closerequest includes Escape, and none requires an action.</prop>
7
- <prop name="alert" type="boolean" default="false">Use alert dialog semantics and require an explicit action instead of the header close button.</prop>
4
+ <prop name="modal" type="boolean" default="false"
5
+ >Open in the top layer with a backdrop and make the rest of the page inert until it closes, like native showModal(). Without it the dialog is non-modal,
6
+ like native show().</prop>
7
+ <prop name="modeless" type="boolean" default="false"
8
+ >Deprecated: does nothing, since dialogs are non-modal unless modal is set. Accepted so markup written for 0.15 keeps working; modal wins when both are
9
+ set.</prop>
10
+ <prop name="closedby" type="any | closerequest | none"
11
+ >Which user close requests work: any includes outside presses, closerequest includes Escape, and none requires an action. Unset follows native dialog:
12
+ closerequest when modal, none when not.</prop>
13
+ <prop name="alert" type="boolean" default="false"
14
+ >Use alert dialog semantics and require an explicit action instead of the header close button. An alert dialog is always modal.</prop>
8
15
  <prop name="size" type="sm | md | lg | fullscreen" default="md">Choose a compact, standard, wide, or viewport-filling dialog.</prop>
9
16
  <prop name="label" type="string">Visible title in the dialog header; also its accessible name.</prop>
10
17
  <prop name="for" type="string" default="">ID of the control that opens the dialog.</prop>
@@ -58,8 +58,10 @@ export default function controller(host) {
58
58
  else dialog?.removeAttribute("role");
59
59
  syncTrigger();
60
60
  const open = Boolean(host.state.internalOpen);
61
- const modal = !host.state.modeless;
62
- const requestedClosedBy = String(host.state.closedby ?? "closerequest");
61
+ // `modeless` is a deprecated no-op: non-modal is the default. An alert dialog is always modal (APG alertdialog).
62
+ const modal = Boolean(host.state.modal || host.state.alert);
63
+ // An unset or unknown closedby follows native <dialog>'s auto state: closerequest when modal, none otherwise.
64
+ const requestedClosedBy = ["any", "closerequest", "none"].includes(host.state.closedby) ? host.state.closedby : modal ? "closerequest" : "none";
63
65
  const closedBy = host.state.alert && requestedClosedBy === "any" ? "closerequest" : requestedClosedBy;
64
66
  trigger?.setAttribute("aria-expanded", String(open));
65
67
  if (!dialog) return;
@@ -13,8 +13,8 @@
13
13
  >The deepest level a drag may produce, counting the top level as 1. A drop that would place the dragged item, or any item beneath it, deeper than this is
14
14
  refused and reported as reorder-rejected. 0 means no limit.</prop>
15
15
  <prop name="marquee" type="boolean" default="false"
16
- >Scrolls a name too long for its row while that row is hovered or focused, then puts it back. Off by default: a tree of names that fit should not move. An
17
- item may set its own marquee to opt in or out on its own.</prop>
16
+ >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
17
+ that fit should not move. An item may set its own marquee to opt in or out on its own.</prop>
18
18
  <event
19
19
  name="reorder"
20
20
  type="object({ sourceId: string, targetId: string, position: before | inside | after, sourceType: string, targetType: string, sourceScope: string, targetScope: string, trigger: keyboard | pointer | programmatic })"
@@ -35,8 +35,9 @@
35
35
  >Marks the item selected: it sets aria-selected and highlights the row. This is controlled state; respond to the parent Tree's select request by updating
36
36
  it.</prop>
37
37
  <prop name="marquee" type="boolean" default="false"
38
- >Scrolls a name too long for its row while the row is hovered or focused, then puts it back. Off by default: a tree of short names should not move, and a
39
- product decides whether reading the whole name matters more than a still list.</prop>
38
+ >Scrolls a name too long for its row while a pointer hovers the row or keyboard focus is in it, rests at the end, then scrolls it again from the start. A
39
+ touch does not start it. Off by default: a tree of short names should not move, and a product decides whether reading the whole name matters more than a
40
+ still list.</prop>
40
41
  <prop name="disabled" type="boolean" default="false"
41
42
  >Makes the item unavailable: faded, skipped by keyboard navigation, unable to expand or be dragged, and closed to drops inside it.</prop>
42
43
  <state name="internalExpanded" :value="false"></state>
@@ -364,27 +365,27 @@
364
365
  );
365
366
  }
366
367
 
367
- /* A hovered row reads its whole name: the label slides far enough to show the end, waits
368
- there, and comes back. Only when it actually overflows — the controller measures by how
368
+ /* A hovered row reads its whole name: the label slides far enough to show the end, rests
369
+ there, and starts again. Only when it actually overflows — the controller measures by how
369
370
  much — after the controls have taken their place over its end — and hands over the distance
370
371
  and a duration from it, so every name moves at the same speed. */
371
372
  .row[data-ui-marquee] > .label > .label-text {
372
- /* Once, and it stays at the end while the pointer is there: a name that keeps sliding back
373
- and forth under the pointer is harder to read than one that arrives and holds. */
373
+ /* Once per pass: the controller restarts it after a rest at the end, so the name holds still
374
+ long enough to read rather than sliding back and forth. */
374
375
  animation: ui-tree-item-marquee var(--_marquee-duration) linear 0.5s 1 normal forwards;
375
376
  will-change: transform;
376
377
  }
377
378
 
378
379
  /* This comes after the hover fade, which it replaces at the same specificity. While the name
379
380
  slides, the label reaches back under the icon (the controller measures how far) with its text
380
- where it was, and fades out across that stretch, gone by the icon's middle: the name slips
381
- under the icon rather than stopping at a hard edge beside it. */
381
+ where it was, and fades out across the gap before the icon, fully gone where the icon begins
382
+ to cover it: the name slips under the icon rather than stopping at a hard edge beside it. */
382
383
  .row[data-ui-marquee] > .label {
383
384
  margin-inline-start: calc(-1 * var(--_marquee-lead, 0px));
384
385
  padding-inline-start: calc(var(--_marquee-lead, 0px) + var(--_ui-tree-item-label-padding-inline, var(--ui-space-2)));
385
386
  mask-image: linear-gradient(
386
387
  to right,
387
- transparent calc(var(--_marquee-lead, 0px) / 2),
388
+ transparent var(--_marquee-icon-width, 0px),
388
389
  #000 calc(var(--_marquee-lead, 0px) + var(--_ui-tree-item-label-padding-inline, var(--ui-space-2))),
389
390
  #000 calc(100% - var(--_tree-actions-width, 1.75rem) - var(--ui-space-3)),
390
391
  transparent calc(100% - var(--_tree-actions-width, 1.75rem))
@@ -394,7 +395,7 @@
394
395
  .row[data-ui-marquee] > .label:dir(rtl) {
395
396
  mask-image: linear-gradient(
396
397
  to left,
397
- transparent calc(var(--_marquee-lead, 0px) / 2),
398
+ transparent var(--_marquee-icon-width, 0px),
398
399
  #000 calc(var(--_marquee-lead, 0px) + var(--_ui-tree-item-label-padding-inline, var(--ui-space-2))),
399
400
  #000 calc(100% - var(--_tree-actions-width, 1.75rem) - var(--ui-space-3)),
400
401
  transparent calc(100% - var(--_tree-actions-width, 1.75rem))
@@ -105,6 +105,10 @@ export default function controller(host) {
105
105
  * overlay the label's end rather than taking width from it.
106
106
  */
107
107
  const MARQUEE_SPEED = 36; // CSS pixels per second, for every name: a clamped duration made short slides crawl.
108
+ const MARQUEE_REST = 1800; // Milliseconds the name holds at its end before the next pass.
109
+ let marqueeRestart;
110
+ let pointerHover = false;
111
+ let keyboardFocus = false;
108
112
  // The tree sets this for its items; an item's own prop overrides it either way.
109
113
  const marqueeWanted = () => host.state.marquee
110
114
  || getComputedStyle(element).getPropertyValue("--_ui-default-tree-item-marquee").trim() === "1";
@@ -122,21 +126,60 @@ export default function controller(host) {
122
126
  if (distance <= 0) return;
123
127
  const lead = icon?.width ? (rightToLeft ? icon.right - cell.right : cell.left - icon.left) : 0;
124
128
  row.style.setProperty("--_marquee-lead", `${Math.max(0, lead)}px`);
129
+ row.style.setProperty("--_marquee-icon-width", `${icon?.width ?? 0}px`);
125
130
  row.style.setProperty("--_marquee-distance", `${rightToLeft ? distance : -distance}px`);
126
131
  row.style.setProperty("--_marquee-duration", `${(distance / MARQUEE_SPEED).toFixed(2)}s`);
127
132
  row.dataset.uiMarquee = "";
128
133
  };
129
134
  const stopMarquee = () => {
135
+ clearTimeout(marqueeRestart);
130
136
  delete row.dataset.uiMarquee;
131
137
  row.style.removeProperty("--_marquee-lead");
138
+ row.style.removeProperty("--_marquee-icon-width");
132
139
  row.style.removeProperty("--_marquee-distance");
133
140
  row.style.removeProperty("--_marquee-duration");
134
141
  };
135
- // Pointer and focus both count: a keyboard walk through a tree reads the same names.
136
- row.addEventListener("pointerenter", startMarquee);
137
- row.addEventListener("pointerleave", stopMarquee);
138
- row.addEventListener("focusin", startMarquee);
139
- row.addEventListener("focusout", stopMarquee);
142
+ // A hovering pointer and keyboard focus both count: a keyboard walk through a tree reads the same
143
+ // names. A touch does not, so a swipe down the list, or the focus a tap leaves, never starts it.
144
+ const onPointerEnter = (event) => {
145
+ if (event.pointerType === "touch") return;
146
+ pointerHover = true;
147
+ startMarquee();
148
+ };
149
+ const onPointerLeave = (event) => {
150
+ if (event.pointerType === "touch") return;
151
+ pointerHover = false;
152
+ if (!keyboardFocus) stopMarquee();
153
+ };
154
+ // Keyboard focus lands on the item itself as the tree roves, or on a control in its row; a nested
155
+ // item's focus is that item's own.
156
+ const ownFocus = (event) => event.target === element || row.contains(event.target);
157
+ const onFocusIn = (event) => {
158
+ if (!ownFocus(event) || !event.target.matches?.(":focus-visible")) return;
159
+ keyboardFocus = true;
160
+ startMarquee();
161
+ };
162
+ const onFocusOut = (event) => {
163
+ if (!ownFocus(event)) return;
164
+ keyboardFocus = false;
165
+ if (!pointerHover) stopMarquee();
166
+ };
167
+ // After a rest at the end, the name goes again from the start for as long as it is being read.
168
+ const onMarqueeEnd = (event) => {
169
+ if (event.target !== labelText || !("uiMarquee" in row.dataset)) return;
170
+ clearTimeout(marqueeRestart);
171
+ marqueeRestart = setTimeout(() => {
172
+ if (!pointerHover && !keyboardFocus) return;
173
+ delete row.dataset.uiMarquee;
174
+ void labelText.offsetWidth; // Flushes the finished animation, so the next one starts over.
175
+ row.dataset.uiMarquee = "";
176
+ }, MARQUEE_REST);
177
+ };
178
+ row.addEventListener("pointerenter", onPointerEnter);
179
+ row.addEventListener("pointerleave", onPointerLeave);
180
+ element.addEventListener("focusin", onFocusIn);
181
+ element.addEventListener("focusout", onFocusOut);
182
+ labelText?.addEventListener("animationend", onMarqueeEnd);
140
183
 
141
184
  // The label's fade has to end where the controls begin, and only the controls know their width.
142
185
  const actionsSize = new ResizeObserver(([entry]) => {
@@ -146,13 +189,15 @@ export default function controller(host) {
146
189
  if (actions) actionsSize.observe(actions);
147
190
 
148
191
  return () => {
192
+ clearTimeout(marqueeRestart);
149
193
  stop?.();
150
194
  observer.disconnect();
151
195
  actionsSize.disconnect();
152
- row.removeEventListener("pointerenter", startMarquee);
153
- row.removeEventListener("pointerleave", stopMarquee);
154
- row.removeEventListener("focusin", startMarquee);
155
- row.removeEventListener("focusout", stopMarquee);
196
+ row.removeEventListener("pointerenter", onPointerEnter);
197
+ row.removeEventListener("pointerleave", onPointerLeave);
198
+ element.removeEventListener("focusin", onFocusIn);
199
+ element.removeEventListener("focusout", onFocusOut);
200
+ labelText?.removeEventListener("animationend", onMarqueeEnd);
156
201
  element.removeEventListener("ui-tree-auto-expand", onAutoExpand);
157
202
  element.removeEventListener("ui-tree-structure-sync", onStructure);
158
203
  element.removeEventListener("ui-tree-roving-tab-stop", onRoving);