@threadlabs/looma 0.10.10 → 0.10.12

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,33 @@
1
+ <template component="ui-list">
2
+ <defs>
3
+ <prop name="layout" type="rows | grid" default="rows"
4
+ >How the items are laid out. Rows stack the items one per line, for a list read top to bottom. Grid sets them in as many columns as fit, each at least
5
+ --ui-list-min-item-width (14rem) wide, for card items such as pinned or featured entries.</prop>
6
+ </defs>
7
+ <ul role="list"><slot></slot></ul>
8
+ <style>
9
+ /* A list of items (ui-list-item). It owns the spacing between them, so a view never sets it. The
10
+ explicit list role keeps the list announced as one: some browsers drop it from a list with no
11
+ markers. */
12
+ :host {
13
+ display: flex;
14
+ flex-direction: column;
15
+ gap: var(--ui-list-gap, var(--ui-space-1));
16
+ min-inline-size: 0;
17
+ margin: 0;
18
+ padding: 0;
19
+ list-style: none;
20
+ }
21
+
22
+ :host-state([layout="grid"]) {
23
+ display: grid;
24
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--ui-list-min-item-width, 14rem)), 1fr));
25
+ gap: var(--ui-list-gap, var(--ui-space-3));
26
+ }
27
+
28
+ /* A component that sets its display still honours the hidden attribute on its root. */
29
+ :host[hidden] {
30
+ display: none;
31
+ }
32
+ </style>
33
+ </template>
@@ -0,0 +1,146 @@
1
+ <template component="ui-list-item">
2
+ <defs>
3
+ <prop name="variant" type="row | card" default="row"
4
+ >How the item is drawn. A row is flat and takes a hover surface when it is a link, for items read down a list. A card sits on a raised, bordered surface,
5
+ for items set in a grid (a List with layout="grid"). Both have the same parts: leading, title, description, and trailing.</prop>
6
+ <prop name="current" type="boolean" default="false"
7
+ >Marks the item as the current one, such as the site you are in: it takes the selected surface and aria-current="true", which assistive technology
8
+ announces. Set it on one item at a time.</prop>
9
+ </defs>
10
+ <li :aria-current="current">
11
+ <span class="leading"><slot name="leading"></slot></span>
12
+ <span class="content">
13
+ <span class="title"><slot></slot></span>
14
+ <span class="description"><slot name="description"></slot></span>
15
+ </span>
16
+ <span class="trailing"><slot name="trailing"></slot></span>
17
+ </li>
18
+ <style>
19
+ /* One entry in a list: something to recognise (leading, usually an icon), its name (the default
20
+ slot), one line about it (description), and what it offers (trailing, such as a button or a
21
+ badge). The title and description are one line each and end in an ellipsis, so every item in a
22
+ list has the same height.
23
+
24
+ A link in the title is the whole item: its hit area stretches over the item, so the icon and the
25
+ padding follow it too, and the item takes the link's focus ring. The link stays a real link, so
26
+ a router, a modified click, and "open in new tab" all work. Trailing controls sit above it and
27
+ stay their own. */
28
+ :host {
29
+ position: relative;
30
+ box-sizing: border-box;
31
+ display: flex;
32
+ align-items: center;
33
+ gap: var(--ui-list-item-gap, var(--ui-space-3));
34
+ min-inline-size: 0;
35
+ min-block-size: var(--ui-control-size, 2.5rem);
36
+ padding-block: var(--ui-list-item-padding-block, var(--ui-space-2));
37
+ padding-inline: var(--ui-list-item-padding-inline, var(--ui-space-3));
38
+ border-radius: var(--ui-radius-md);
39
+ color: var(--ui-text-primary);
40
+ transition: background-color var(--ui-motion-fast) var(--ui-motion-ease), border-color var(--ui-motion-fast) var(--ui-motion-ease);
41
+ }
42
+
43
+ :host-state([variant="card"]) {
44
+ padding-block: var(--ui-list-item-padding-block, var(--ui-space-3));
45
+ padding-inline: var(--ui-list-item-padding-inline, var(--ui-space-4));
46
+ border: 1px solid var(--ui-border-default);
47
+ border-radius: var(--ui-radius-lg);
48
+ background: var(--ui-surface-raised);
49
+ }
50
+
51
+ :host:has(.title a[href]:hover) {
52
+ background: var(--ui-surface-hover);
53
+ }
54
+
55
+ :host-state([variant="card"]):has(.title a[href]:hover) {
56
+ border-color: var(--ui-border-strong);
57
+ }
58
+
59
+ :host-state([current]) {
60
+ background: var(--ui-surface-hover);
61
+ }
62
+
63
+ :host:has(.title a[href]:focus-visible) {
64
+ box-shadow: 0 0 0 2px var(--ui-focus-ring);
65
+ }
66
+
67
+ .leading,
68
+ .trailing {
69
+ display: inline-flex;
70
+ flex: none;
71
+ align-items: center;
72
+ gap: var(--ui-space-2);
73
+ }
74
+
75
+ .leading {
76
+ color: var(--ui-text-muted);
77
+ font-size: var(--ui-list-item-icon-size, var(--ui-font-size-lg));
78
+ }
79
+
80
+ /* Trailing controls take their own clicks, above the title link's stretched hit area. */
81
+ .trailing {
82
+ position: relative;
83
+ z-index: 1;
84
+ color: var(--ui-text-muted);
85
+ font-size: var(--ui-font-size-sm);
86
+ }
87
+
88
+ .content {
89
+ display: flex;
90
+ flex: 1;
91
+ flex-direction: column;
92
+ gap: var(--ui-space-0-5, 2px);
93
+ min-inline-size: 0;
94
+ }
95
+
96
+ .title,
97
+ .description {
98
+ display: block;
99
+ min-inline-size: 0;
100
+ overflow: hidden;
101
+ text-overflow: ellipsis;
102
+ white-space: nowrap;
103
+ }
104
+
105
+ .title {
106
+ font-size: var(--ui-font-size-md);
107
+ font-weight: var(--ui-font-medium);
108
+ line-height: var(--ui-line-height-tight);
109
+ }
110
+
111
+ .description {
112
+ color: var(--ui-text-muted);
113
+ font-size: var(--ui-font-size-sm);
114
+ line-height: var(--ui-line-height-tight);
115
+ }
116
+
117
+ .title > :slotted(a) {
118
+ color: inherit;
119
+ text-decoration: none;
120
+ }
121
+
122
+ .title > :slotted(a:focus-visible) {
123
+ outline: none;
124
+ box-shadow: none;
125
+ }
126
+
127
+ .title > :slotted(a[href])::after {
128
+ position: absolute;
129
+ inset: 0;
130
+ border-radius: inherit;
131
+ content: "";
132
+ }
133
+
134
+ /* An unused region takes no space. */
135
+ .leading:empty,
136
+ .description:empty,
137
+ .trailing:empty {
138
+ display: none;
139
+ }
140
+
141
+ /* A component that sets its display still honours the hidden attribute on its root. */
142
+ :host[hidden] {
143
+ display: none;
144
+ }
145
+ </style>
146
+ </template>
@@ -85,8 +85,8 @@
85
85
  <button class="disclosure" $ref="disclosure" type="button" :disabled="disabled" :hidden="not isContainer">
86
86
  <span class="disclosure-icon" aria-hidden="true"></span>
87
87
  </button>
88
- <span class="leading"><slot name="leading"></slot></span>
89
- <span class="label"><span class="label-text" $ref="labelText"><slot name="label"><template $value="label"></template></slot></span></span>
88
+ <span class="leading" $ref="leading"><slot name="leading"></slot></span>
89
+ <span class="label" $ref="label"><span class="label-text" $ref="labelText"><slot name="label"><template $value="label"></template></slot></span></span>
90
90
  <span class="actions" $ref="actions"><slot name="actions"></slot></span>
91
91
  <span class="drop-indicator row-drop-indicator" aria-hidden="true"></span>
92
92
  </div>
@@ -221,6 +221,9 @@
221
221
  block-size: 1em;
222
222
  margin-inline-start: var(--ui-space-1);
223
223
  color: var(--ui-text-muted);
224
+ /* Above the label, so a sliding name passes under the icon. */
225
+ position: relative;
226
+ z-index: 1;
224
227
  }
225
228
 
226
229
  .leading:empty {
@@ -248,6 +251,9 @@
248
251
  /* The cell clips: a name longer than the row slides inside it rather than over its icons. */
249
252
  overflow: hidden;
250
253
  padding: var(--ui-tree-item-label-padding-block, var(--ui-space-1)) var(--ui-tree-item-label-padding-inline, var(--ui-space-2));
254
+ /* The length of the fade before the controls. The label's one flex child lays out no gap; this
255
+ is where the controller reads the length in pixels to stop a sliding name before the fade. */
256
+ column-gap: var(--ui-space-3);
251
257
  }
252
258
 
253
259
  /* Slotted label content fills the cell, so a link is the row's hit area rather than sizing to
@@ -268,6 +274,27 @@
268
274
  min-inline-size: 0;
269
275
  }
270
276
 
277
+ /* The controls sit over the label's end, so the text fades out where they begin. The fade is
278
+ the truncation mark while it shows: an ellipsis under it would read as a second one. */
279
+ .row:hover > .label,
280
+ .row:focus-within > .label {
281
+ text-overflow: clip;
282
+ mask-image: linear-gradient(
283
+ to right,
284
+ #000 calc(100% - var(--_tree-actions-width, 1.75rem) - var(--ui-space-3)),
285
+ transparent calc(100% - var(--_tree-actions-width, 1.75rem))
286
+ );
287
+ }
288
+
289
+ .row:hover > .label:dir(rtl),
290
+ .row:focus-within > .label:dir(rtl) {
291
+ mask-image: linear-gradient(
292
+ to left,
293
+ #000 calc(100% - var(--_tree-actions-width, 1.75rem) - var(--ui-space-3)),
294
+ transparent calc(100% - var(--_tree-actions-width, 1.75rem))
295
+ );
296
+ }
297
+
271
298
  /* A hovered row reads its whole name: the label slides far enough to show the end, waits
272
299
  there, and comes back. Only when it actually overflows — the controller measures by how
273
300
  much — after the controls have taken their place over its end — and hands over the distance
@@ -279,13 +306,17 @@
279
306
  will-change: transform;
280
307
  }
281
308
 
282
- /* While it slides, the start fades too: the name is leaving the cell at both ends, and a hard
283
- edge beside the icon reads as a collision rather than as more text. */
309
+ /* This comes after the hover fade, which it replaces at the same specificity. While the name
310
+ slides, the label reaches back under the icon (the controller measures how far) with its text
311
+ where it was, and fades out across that stretch, gone by the icon's middle: the name slips
312
+ under the icon rather than stopping at a hard edge beside it. */
284
313
  .row[data-ui-marquee] > .label {
314
+ margin-inline-start: calc(-1 * var(--_marquee-lead, 0px));
315
+ padding-inline-start: calc(var(--_marquee-lead, 0px) + var(--ui-tree-item-label-padding-inline, var(--ui-space-2)));
285
316
  mask-image: linear-gradient(
286
317
  to right,
287
- transparent 0,
288
- #000 var(--ui-space-2),
318
+ transparent calc(var(--_marquee-lead, 0px) / 2),
319
+ #000 calc(var(--_marquee-lead, 0px) + var(--ui-tree-item-label-padding-inline, var(--ui-space-2))),
289
320
  #000 calc(100% - var(--_tree-actions-width, 1.75rem) - var(--ui-space-3)),
290
321
  transparent calc(100% - var(--_tree-actions-width, 1.75rem))
291
322
  );
@@ -294,8 +325,8 @@
294
325
  .row[data-ui-marquee] > .label:dir(rtl) {
295
326
  mask-image: linear-gradient(
296
327
  to left,
297
- transparent 0,
298
- #000 var(--ui-space-2),
328
+ transparent calc(var(--_marquee-lead, 0px) / 2),
329
+ #000 calc(var(--_marquee-lead, 0px) + var(--ui-tree-item-label-padding-inline, var(--ui-space-2))),
299
330
  #000 calc(100% - var(--_tree-actions-width, 1.75rem) - var(--ui-space-3)),
300
331
  transparent calc(100% - var(--_tree-actions-width, 1.75rem))
301
332
  );
@@ -318,27 +349,6 @@
318
349
  }
319
350
  }
320
351
 
321
- /* The controls sit over the label's end, so the text fades out where they begin. The fade is
322
- the truncation mark while it shows: an ellipsis under it would read as a second one. */
323
- .row:hover > .label,
324
- .row:focus-within > .label {
325
- text-overflow: clip;
326
- mask-image: linear-gradient(
327
- to right,
328
- #000 calc(100% - var(--_tree-actions-width, 1.75rem) - var(--ui-space-3)),
329
- transparent calc(100% - var(--_tree-actions-width, 1.75rem))
330
- );
331
- }
332
-
333
- .row:hover > .label:dir(rtl),
334
- .row:focus-within > .label:dir(rtl) {
335
- mask-image: linear-gradient(
336
- to left,
337
- #000 calc(100% - var(--_tree-actions-width, 1.75rem) - var(--ui-space-3)),
338
- transparent calc(100% - var(--_tree-actions-width, 1.75rem))
339
- );
340
- }
341
-
342
352
  .row:hover > .label > :slotted(*),
343
353
  .row:focus-within > .label > :slotted(*) {
344
354
  text-overflow: clip;
@@ -19,7 +19,7 @@ const CONTROLS = [
19
19
 
20
20
  export default function controller(host) {
21
21
  const element = host.element;
22
- const { row, disclosure, children, actions, labelText } = host.refs;
22
+ const { row, disclosure, children, leading, label, actions, labelText } = host.refs;
23
23
  // Touch use enlarges rows and hides drag handles (see the template's styles).
24
24
  trackInputModality(element.ownerDocument);
25
25
  const childItems = () => Array.from(children?.children ?? [])
@@ -98,23 +98,28 @@ export default function controller(host) {
98
98
  const stop = host.effect(apply);
99
99
  apply();
100
100
  /**
101
- * A hovered row reads its whole name. The controls overlay the label's end rather than taking
102
- * width from it, so the width the name actually has while hovered is the label minus them:
103
- * measuring against the full label scrolls short and leaves the tail under the controls.
101
+ * A hovered row reads its whole name. The label box always fills the row, so the name is measured
102
+ * at its own width; it has to end where the fade before the controls begins, since the controls
103
+ * overlay the label's end rather than taking width from it.
104
104
  */
105
105
  const MARQUEE_SPEED = 36; // CSS pixels per second.
106
- const MARQUEE_SLACK = 4; // So the tail clears the edge rather than stopping on it.
107
106
  // The tree sets this for its items; an item's own prop overrides it either way.
108
107
  const marqueeWanted = () => host.state.marquee
109
108
  || getComputedStyle(element).getPropertyValue("--ui-tree-item-marquee").trim() === "1";
110
109
  const startMarquee = () => {
111
- if (!labelText || !marqueeWanted()) return;
112
- const covered = actions?.offsetWidth ?? 0;
113
- const visible = labelText.clientWidth - covered;
114
- const overflow = Math.round(labelText.scrollWidth - visible);
115
- if (overflow <= 0) return;
116
- const distance = overflow + MARQUEE_SLACK;
110
+ if (!labelText || !label || !marqueeWanted()) return;
111
+ labelText.style.flex = "none";
112
+ labelText.style.inlineSize = "max-content";
113
+ const text = labelText.getBoundingClientRect();
114
+ labelText.style.flex = labelText.style.inlineSize = "";
115
+ const cell = label.getBoundingClientRect();
116
+ const icon = leading?.getBoundingClientRect();
117
+ const stop = (actions?.offsetWidth ?? 0) + parseFloat(getComputedStyle(label).columnGap);
117
118
  const rightToLeft = getComputedStyle(element).direction === "rtl";
119
+ const distance = Math.ceil(rightToLeft ? cell.left + stop - text.left : text.right - (cell.right - stop));
120
+ if (distance <= 0) return;
121
+ const lead = icon?.width ? (rightToLeft ? icon.right - cell.right : cell.left - icon.left) : 0;
122
+ row.style.setProperty("--_marquee-lead", `${Math.max(0, lead)}px`);
118
123
  row.style.setProperty("--_marquee-distance", `${rightToLeft ? distance : -distance}px`);
119
124
  row.style.setProperty(
120
125
  "--_marquee-duration",
@@ -124,6 +129,7 @@ export default function controller(host) {
124
129
  };
125
130
  const stopMarquee = () => {
126
131
  delete row.dataset.uiMarquee;
132
+ row.style.removeProperty("--_marquee-lead");
127
133
  row.style.removeProperty("--_marquee-distance");
128
134
  row.style.removeProperty("--_marquee-duration");
129
135
  };