@lyeve-labs/ui-kit 0.18.0 → 0.19.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.
Files changed (50) hide show
  1. package/README.md +11 -4
  2. package/dist/components/AccordionItem.svelte +1 -1
  3. package/dist/components/AppShell.svelte +3 -3
  4. package/dist/components/Autocomplete.svelte +4 -4
  5. package/dist/components/AvatarGroup.svelte +2 -2
  6. package/dist/components/Breadcrumb.svelte +1 -1
  7. package/dist/components/Button.svelte +34 -9
  8. package/dist/components/ButtonGroup.svelte +3 -3
  9. package/dist/components/Card.svelte +1 -1
  10. package/dist/components/Checkbox.svelte +1 -1
  11. package/dist/components/CheckboxGroup.svelte +1 -1
  12. package/dist/components/Collapsible.svelte +3 -3
  13. package/dist/components/CopyButton.svelte +1 -1
  14. package/dist/components/DatePicker.svelte +2 -2
  15. package/dist/components/DateTimePicker.svelte +1 -1
  16. package/dist/components/Drawer.svelte +1 -1
  17. package/dist/components/Dropdown.svelte +1 -1
  18. package/dist/components/Field.svelte +1 -1
  19. package/dist/components/Input.svelte +1 -1
  20. package/dist/components/Label.svelte +1 -1
  21. package/dist/components/MultiSelect.svelte +4 -4
  22. package/dist/components/NumberInput.svelte +3 -3
  23. package/dist/components/Pagination.svelte +164 -65
  24. package/dist/components/Pagination.svelte.d.ts +39 -2
  25. package/dist/components/PasswordInput.svelte +4 -4
  26. package/dist/components/Radio.svelte +1 -1
  27. package/dist/components/RadioGroup.svelte +1 -1
  28. package/dist/components/SearchInput.svelte +4 -4
  29. package/dist/components/SegmentedControl.svelte +3 -1
  30. package/dist/components/Select.svelte +6 -6
  31. package/dist/components/SidebarNav.svelte +20 -5
  32. package/dist/components/Table.svelte +114 -1
  33. package/dist/components/Table.svelte.d.ts +37 -0
  34. package/dist/components/Tabs.svelte +1 -1
  35. package/dist/components/Tag.svelte +1 -1
  36. package/dist/components/Textarea.svelte +1 -1
  37. package/dist/components/ThemeToggle.svelte +1 -1
  38. package/dist/components/TimePicker.svelte +2 -2
  39. package/dist/components/Toaster.svelte +2 -2
  40. package/dist/components/Toggle.svelte +10 -4
  41. package/dist/components/TreeView.svelte +4 -4
  42. package/dist/components/dialog/Dialog.svelte +1 -1
  43. package/dist/index.d.ts +2 -1
  44. package/dist/index.js +1 -1
  45. package/dist/internal/href.d.ts +13 -0
  46. package/dist/internal/href.js +27 -0
  47. package/dist/internal/panel.js +1 -1
  48. package/dist/styles/theme.css +53 -0
  49. package/package.json +3 -4
  50. package/src/lib/styles/theme.css +53 -0
@@ -1,24 +1,73 @@
1
1
  <script lang="ts">
2
+ import { safeHref } from '../internal/href.js';
3
+
4
+ interface Base {
5
+ page: number;
6
+ /**
7
+ * Collection size, when the endpoint states one.
8
+ *
9
+ * Leave it out for a list whose endpoint reports no count and say `hasNext`
10
+ * instead. Absent is not zero: a caller who does state a total, even a
11
+ * nonsense one, gets the counted pager with that total clamped.
12
+ */
13
+ total?: number;
14
+ perPage?: number;
15
+ /**
16
+ * Whether a page after this one exists.
17
+ *
18
+ * Read only when `total` is absent, because a counted list already knows
19
+ * where it ends. It is the answer an endpoint can give without counting the
20
+ * collection: ask for one row more than the page holds and see if it comes
21
+ * back.
22
+ */
23
+ hasNext?: boolean;
24
+ class?: string;
25
+ }
26
+
27
+ /**
28
+ * How the pager moves, and why it takes both spellings.
29
+ *
30
+ * `onchange` cannot run before the page hydrates, so a pager that has only a
31
+ * callback is inert on the first paint and an early click lands on nothing.
32
+ * That is not a rare state: it is every server-rendered document, for as long
33
+ * as the bundle takes to arrive. A caller that has to page from that document
34
+ * supplies `href` instead and gets links, which the browser follows with no
35
+ * script at all.
36
+ *
37
+ * Exclusive rather than both together: an anchor whose click also ran the
38
+ * callback would page twice, once through the handler and again through the
39
+ * navigation it did not cancel.
40
+ */
41
+ type Nav =
42
+ | { onchange: (page: number) => void; href?: undefined }
43
+ | { href: (page: number) => string; onchange?: undefined };
44
+
2
45
  let {
3
46
  page,
4
- total,
47
+ total = undefined,
5
48
  perPage = 20,
49
+ hasNext = false,
6
50
  class: cls = '',
7
51
  onchange,
8
- }: {
9
- page: number;
10
- total: number;
11
- perPage?: number;
12
- class?: string;
13
- onchange: (page: number) => void;
14
- } = $props();
52
+ href,
53
+ }: Base & Nav = $props();
15
54
 
16
- let safeTotal = $derived(isFinite(total) && total >= 0 ? total : 0);
55
+ /**
56
+ * A stated total is what makes the numbered pages knowable. Without one the
57
+ * pager can offer the step either side of where it stands and nothing more,
58
+ * because the last page is not a number anybody here has.
59
+ */
60
+ let counted = $derived(total !== undefined);
61
+ let safeTotal = $derived(typeof total === 'number' && isFinite(total) && total >= 0 ? total : 0);
17
62
  let safePage = $derived(isFinite(page) && page >= 1 ? page : 1);
18
63
  let totalPages = $derived(Math.max(1, Math.ceil(safeTotal / perPage)));
19
64
  let from = $derived(Math.min((safePage - 1) * perPage + 1, safeTotal));
20
65
  let to = $derived(Math.min(safePage * perPage, safeTotal));
21
66
 
67
+ let canPrev = $derived(safePage > 1);
68
+ let canNext = $derived(counted ? safePage < totalPages : hasNext);
69
+ let hasControls = $derived(counted ? totalPages > 1 : canPrev || canNext);
70
+
22
71
  /**
23
72
  * The elided run is the sentinel string 'gap', never the horizontal-ellipsis
24
73
  * character.
@@ -51,46 +100,112 @@
51
100
  return slot === 'gap' ? `gap-${index}` : `page-${slot}`;
52
101
  }
53
102
 
54
- let nums = $derived(pageNumbers(safePage, totalPages));
103
+ let nums = $derived(counted ? pageNumbers(safePage, totalPages) : []);
104
+
105
+ /**
106
+ * The count, when there is one to state.
107
+ *
108
+ * An uncounted list says which page it is on and nothing else. It cannot say
109
+ * "1 to 20 of 400" without the total, and it cannot say "1 to 20" either: the
110
+ * last page is short and this component is never told how short.
111
+ */
112
+ let summary = $derived(
113
+ counted
114
+ ? safeTotal === 0
115
+ ? 'No results'
116
+ : `${from} to ${to} of ${safeTotal}`
117
+ : `Page ${safePage}`,
118
+ );
119
+
120
+ /** The target of a control, sanitized, or nothing when the step is unavailable. */
121
+ function linkTo(target: number, enabled: boolean): string | undefined {
122
+ return href && enabled ? safeHref(href(target)) : undefined;
123
+ }
55
124
 
56
125
  const btnBase =
57
126
  'inline-flex items-center justify-center w-7 h-7 rounded text-xs font-medium transition-colors duration-150';
127
+
128
+ /**
129
+ * A page control that is not the page you are on, in every state a pointer
130
+ * can put it in.
131
+ *
132
+ * Held as well as hovered, because `hover:` never matches on a touch screen
133
+ * and paging is a thumb gesture: without the pressed step a tap on a phone
134
+ * changed the page with nothing on the control to say it had been hit.
135
+ *
136
+ * Stated once rather than spelled out on the previous button, the next button
137
+ * and every numbered one. It was written three times, and a state added to
138
+ * one copy is a state missing from two.
139
+ */
140
+ const btnRest = 'text-muted hover:text-fg hover:bg-surface-2 active:bg-line active:text-fg';
141
+
142
+ const btnCurrent = 'bg-brand text-ink';
143
+
144
+ /**
145
+ * A link with nowhere to go.
146
+ *
147
+ * An anchor has no `disabled`, and `disabled:` never matches one, so the two
148
+ * states have to be painted rather than declared. Dropping the href is what
149
+ * takes it out of the tab order and stops activation; the class is only the
150
+ * part a reader can see, at the same 30% the disabled buttons use.
151
+ */
152
+ const linkInert = 'pointer-events-none opacity-30';
58
153
  </script>
59
154
 
155
+ {#snippet arrow(d: string)}
156
+ <svg
157
+ width="14"
158
+ height="14"
159
+ viewBox="0 0 24 24"
160
+ fill="none"
161
+ stroke="currentColor"
162
+ stroke-width="2"
163
+ stroke-linecap="round"
164
+ stroke-linejoin="round"
165
+ aria-hidden="true"
166
+ class="rtl:rotate-180"
167
+ >
168
+ <path {d} />
169
+ </svg>
170
+ {/snippet}
171
+
172
+ <!-- Previous and next, as a link when the caller builds hrefs and as a button
173
+ when it hands over a callback. The two branches carry the same classes and
174
+ the same label, so the pager reads and sounds the same either way. -->
175
+ {#snippet step(target: number, label: string, enabled: boolean, d: string)}
176
+ {#if href}
177
+ <a
178
+ href={linkTo(target, enabled)}
179
+ aria-label={label}
180
+ aria-disabled={enabled ? undefined : 'true'}
181
+ class="{btnBase} {btnRest} {enabled ? '' : linkInert}"
182
+ >
183
+ {@render arrow(d)}
184
+ </a>
185
+ {:else}
186
+ <button
187
+ type="button"
188
+ disabled={!enabled}
189
+ onclick={() => onchange?.(target)}
190
+ aria-label={label}
191
+ class="{btnBase} {btnRest} disabled:cursor-not-allowed disabled:opacity-30"
192
+ >
193
+ {@render arrow(d)}
194
+ </button>
195
+ {/if}
196
+ {/snippet}
197
+
60
198
  <!-- The summary renders whenever there is a count to state, and the page
61
199
  buttons only when there is more than one page. The whole component used to
62
200
  be behind `totalPages > 1`, and an empty list has one page, so the 'No
63
201
  results' line below could never appear and a single page of results showed
64
202
  no count at all. -->
65
203
  <div class="flex flex-wrap items-center gap-x-3 gap-y-2 {cls}">
66
- <span class="text-xs text-faint shrink-0">
67
- {safeTotal === 0 ? 'No results' : `${from} to ${to} of ${safeTotal}`}
68
- </span>
69
-
70
- {#if totalPages > 1}
71
- <div class="flex flex-wrap items-center gap-0.5 ml-auto">
72
- <button
73
- type="button"
74
- disabled={safePage <= 1}
75
- onclick={() => onchange(safePage - 1)}
76
- aria-label="Previous page"
77
- class="{btnBase} text-muted hover:text-fg hover:bg-surface-2
78
- disabled:opacity-30 disabled:cursor-not-allowed"
79
- >
80
- <svg
81
- width="14"
82
- height="14"
83
- viewBox="0 0 24 24"
84
- fill="none"
85
- stroke="currentColor"
86
- stroke-width="2"
87
- stroke-linecap="round"
88
- stroke-linejoin="round"
89
- aria-hidden="true"
90
- >
91
- <path d="M15 18l-6-6 6-6" />
92
- </svg>
93
- </button>
204
+ <span class="shrink-0 text-xs text-faint">{summary}</span>
205
+
206
+ {#if hasControls}
207
+ <div class="ms-auto flex flex-wrap items-center gap-0.5">
208
+ {@render step(safePage - 1, 'Previous page', canPrev, 'M15 18l-6-6 6-6')}
94
209
 
95
210
  {#each nums as n, i (slotKey(n, i))}
96
211
  {#if n === 'gap'}
@@ -111,43 +226,27 @@
111
226
  <path d="M5 12h.01M12 12h.01M19 12h.01" />
112
227
  </svg>
113
228
  </span>
229
+ {:else if href}
230
+ <a
231
+ href={linkTo(n, true)}
232
+ aria-current={safePage === n ? 'page' : undefined}
233
+ class="{btnBase} {safePage === n ? btnCurrent : btnRest}"
234
+ >
235
+ {n}
236
+ </a>
114
237
  {:else}
115
238
  <button
116
239
  type="button"
117
- onclick={() => onchange(n as number)}
240
+ onclick={() => onchange?.(n as number)}
118
241
  aria-current={safePage === n ? 'page' : undefined}
119
- class="{btnBase}
120
- {safePage === n
121
- ? 'bg-brand text-ink'
122
- : 'text-muted hover:text-fg hover:bg-surface-2'}"
242
+ class="{btnBase} {safePage === n ? btnCurrent : btnRest}"
123
243
  >
124
244
  {n}
125
245
  </button>
126
246
  {/if}
127
247
  {/each}
128
248
 
129
- <button
130
- type="button"
131
- disabled={safePage >= totalPages}
132
- onclick={() => onchange(safePage + 1)}
133
- aria-label="Next page"
134
- class="{btnBase} text-muted hover:text-fg hover:bg-surface-2
135
- disabled:opacity-30 disabled:cursor-not-allowed"
136
- >
137
- <svg
138
- width="14"
139
- height="14"
140
- viewBox="0 0 24 24"
141
- fill="none"
142
- stroke="currentColor"
143
- stroke-width="2"
144
- stroke-linecap="round"
145
- stroke-linejoin="round"
146
- aria-hidden="true"
147
- >
148
- <path d="M9 18l6-6-6-6" />
149
- </svg>
150
- </button>
249
+ {@render step(safePage + 1, 'Next page', canNext, 'M9 18l6-6-6-6')}
151
250
  </div>
152
251
  {/if}
153
252
  </div>
@@ -1,10 +1,47 @@
1
- type $$ComponentProps = {
1
+ interface Base {
2
2
  page: number;
3
- total: number;
3
+ /**
4
+ * Collection size, when the endpoint states one.
5
+ *
6
+ * Leave it out for a list whose endpoint reports no count and say `hasNext`
7
+ * instead. Absent is not zero: a caller who does state a total, even a
8
+ * nonsense one, gets the counted pager with that total clamped.
9
+ */
10
+ total?: number;
4
11
  perPage?: number;
12
+ /**
13
+ * Whether a page after this one exists.
14
+ *
15
+ * Read only when `total` is absent, because a counted list already knows
16
+ * where it ends. It is the answer an endpoint can give without counting the
17
+ * collection: ask for one row more than the page holds and see if it comes
18
+ * back.
19
+ */
20
+ hasNext?: boolean;
5
21
  class?: string;
22
+ }
23
+ /**
24
+ * How the pager moves, and why it takes both spellings.
25
+ *
26
+ * `onchange` cannot run before the page hydrates, so a pager that has only a
27
+ * callback is inert on the first paint and an early click lands on nothing.
28
+ * That is not a rare state: it is every server-rendered document, for as long
29
+ * as the bundle takes to arrive. A caller that has to page from that document
30
+ * supplies `href` instead and gets links, which the browser follows with no
31
+ * script at all.
32
+ *
33
+ * Exclusive rather than both together: an anchor whose click also ran the
34
+ * callback would page twice, once through the handler and again through the
35
+ * navigation it did not cancel.
36
+ */
37
+ type Nav = {
6
38
  onchange: (page: number) => void;
39
+ href?: undefined;
40
+ } | {
41
+ href: (page: number) => string;
42
+ onchange?: undefined;
7
43
  };
44
+ type $$ComponentProps = Base & Nav;
8
45
  declare const Pagination: import("svelte").Component<$$ComponentProps, {}, "">;
9
46
  type Pagination = ReturnType<typeof Pagination>;
10
47
  export default Pagination;
@@ -85,12 +85,12 @@
85
85
  <div class="{FIELD_WRAP} {klass}">
86
86
  {#if label}
87
87
  <label for={fieldId} class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
88
- {label}{#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
88
+ {label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
89
89
  </label>
90
90
  {/if}
91
91
 
92
92
  <div class="relative">
93
- <!-- pr-10 overrides the px-3 CONTROL_BASE states, because `pr` is emitted
93
+ <!-- pe-10 overrides the px-3 CONTROL_BASE states, because `pe` is emitted
94
94
  after `px` and so wins. Without it a long key ran under the button and
95
95
  the operator could not read the end of what they had pasted. -->
96
96
  <input
@@ -107,7 +107,7 @@
107
107
  {onchange}
108
108
  aria-invalid={error ? 'true' : undefined}
109
109
  aria-describedby={describedBy(fieldId, error, hint)}
110
- class="{CONTROL_BASE} {controlBorder(!!error)} {showToggle ? 'pr-10' : ''} {controlClass}"
110
+ class="{CONTROL_BASE} {controlBorder(!!error)} {showToggle ? 'pe-10' : ''} {controlClass}"
111
111
  />
112
112
 
113
113
  {#if showToggle}
@@ -120,7 +120,7 @@
120
120
  aria-pressed={revealed}
121
121
  aria-controls={fieldId}
122
122
  aria-label={revealed ? 'Hide password' : 'Show password'}
123
- class="absolute top-1/2 right-1 flex -translate-y-1/2 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors duration-150 hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
123
+ class="absolute top-1/2 end-1 flex -translate-y-1/2 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors duration-150 hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
124
124
  >
125
125
  {#if revealed}
126
126
  <EyeOff size={15} aria-hidden="true" />
@@ -145,7 +145,7 @@
145
145
  own text it became part of the name, and the reader heard the
146
146
  whole paragraph before it reached the selected state. -->
147
147
  <span id="{fieldId}-label" class="{choiceLabel(size)} {labelHidden ? 'sr-only' : ''}">
148
- {label}{#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
148
+ {label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
149
149
  </span>
150
150
  {/if}
151
151
  </span>
@@ -109,7 +109,7 @@
109
109
  ahead of them saying what the set is for.
110
110
  -->
111
111
  <legend class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
112
- {label}{#if required}<span class="text-danger ml-0.5" aria-hidden="true">*</span>{/if}
112
+ {label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
113
113
  </legend>
114
114
 
115
115
  <div class={choiceGroupList(orientation)}>
@@ -51,7 +51,7 @@
51
51
  stroke-width="2"
52
52
  stroke-linecap="round"
53
53
  stroke-linejoin="round"
54
- class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-faint"
54
+ class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-faint"
55
55
  aria-hidden="true"
56
56
  >
57
57
  <circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
@@ -65,9 +65,9 @@
65
65
  {disabled}
66
66
  {value}
67
67
  oninput={handleInput}
68
- class="{CONTROL_BASE} {controlBorder(false)} pl-9
68
+ class="{CONTROL_BASE} {controlBorder(false)} ps-9
69
69
  [&::-webkit-search-cancel-button]:appearance-none
70
- {value ? 'pr-8' : 'pr-3'}"
70
+ {value ? 'pe-8' : 'pe-3'}"
71
71
  />
72
72
 
73
73
  {#if value}
@@ -84,7 +84,7 @@
84
84
  type="button"
85
85
  onclick={clear}
86
86
  aria-label="Clear search"
87
- class="absolute right-2.5 top-1/2 -mx-1.5 -translate-y-1/2 p-1.5 text-faint
87
+ class="absolute end-2.5 top-1/2 -mx-1.5 -translate-y-1/2 p-1.5 text-faint
88
88
  transition-colors duration-150 hover:text-fg"
89
89
  >
90
90
  <svg
@@ -164,7 +164,9 @@
164
164
  onkeydown={(e) => onSegmentKeydown(e, i)}
165
165
  class="inline-flex items-center justify-center rounded-md outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand disabled:cursor-not-allowed {SIZES[
166
166
  size
167
- ].segment} {selected ? 'bg-surface text-fg shadow-sm' : 'text-muted hover:text-fg'}"
167
+ ].segment} {selected
168
+ ? 'bg-surface text-fg shadow-sm'
169
+ : 'text-muted hover:text-fg active:bg-surface active:text-fg'}"
168
170
  >
169
171
  {#if Icon}
170
172
  <Icon size={SIZES[size].icon} class="shrink-0" />
@@ -274,7 +274,7 @@
274
274
 
275
275
  {#snippet chevron(open: boolean)}
276
276
  <span
277
- class="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-faint"
277
+ class="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-faint"
278
278
  aria-hidden="true"
279
279
  >
280
280
  <svg
@@ -328,7 +328,7 @@
328
328
  height="12"
329
329
  viewBox="0 0 12 12"
330
330
  fill="none"
331
- class="ml-auto shrink-0"
331
+ class="ms-auto shrink-0"
332
332
  aria-hidden="true"
333
333
  >
334
334
  <path
@@ -346,7 +346,7 @@
346
346
  <div class="{FIELD_WRAP} {cls}">
347
347
  {#if label}
348
348
  <label for={fieldId} class={FIELD_LABEL}>
349
- {label}{#if required}<span class="text-danger ml-0.5" aria-hidden="true">*</span>{/if}
349
+ {label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
350
350
  </label>
351
351
  {/if}
352
352
 
@@ -381,8 +381,8 @@
381
381
  aria-required={required ? 'true' : undefined}
382
382
  aria-describedby={describedBy(fieldId, error, hint)}
383
383
  {...box.triggerAttrs}
384
- class="{CONTROL_BASE} {controlBorder(!!error)} flex cursor-pointer items-center gap-2 pr-8
385
- text-left"
384
+ class="{CONTROL_BASE} {controlBorder(!!error)} flex cursor-pointer items-center gap-2 pe-8
385
+ text-start"
386
386
  >
387
387
  {#if trigger}
388
388
  {@render trigger({ selected, open: box.open })}
@@ -472,7 +472,7 @@
472
472
  onchange={nativeChange}
473
473
  aria-invalid={error ? 'true' : undefined}
474
474
  aria-describedby={describedBy(fieldId, error, hint)}
475
- class="{CONTROL_BASE} {controlBorder(!!error)} cursor-pointer appearance-none pr-8"
475
+ class="{CONTROL_BASE} {controlBorder(!!error)} cursor-pointer appearance-none pe-8"
476
476
  >
477
477
  {#if children}
478
478
  {@render children()}
@@ -69,11 +69,25 @@
69
69
  'transition-colors duration-150 outline-none ' +
70
70
  'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand';
71
71
 
72
- /** Exactly one border colour, one background and one text colour per state. */
72
+ /**
73
+ * Exactly one border colour, one background and one text colour per state.
74
+ *
75
+ * Every state answers the pointer, including the current one. It did not: the
76
+ * current row was the only paint with no hover step, and the current row is
77
+ * the tail of the active trail, so on any nav deep enough to have sections it
78
+ * is a nested entry. Beside its own siblings, which all lit up, the row for
79
+ * the page you were on read as the one dead entry in the list.
80
+ *
81
+ * The held step is separate from the hovered one because `hover:` compiles
82
+ * inside `@media (hover: hover)` and never matches a finger. A sidebar is
83
+ * mostly used on a phone as a drawer, where the tap that closed it was the
84
+ * one gesture in the component with nothing on screen behind it.
85
+ */
73
86
  function rowPaint(state: 'current' | 'ancestor' | 'rest'): string {
74
- if (state === 'current') return 'border-brand bg-surface-2 font-medium text-brand';
75
- if (state === 'ancestor') return 'border-brand text-fg hover:bg-surface-2';
76
- return 'border-transparent text-muted hover:bg-surface-2 hover:text-fg';
87
+ if (state === 'current')
88
+ return 'border-brand bg-surface-2 font-medium text-brand hover:bg-line active:bg-line';
89
+ if (state === 'ancestor') return 'border-brand text-fg hover:bg-surface-2 active:bg-line';
90
+ return 'border-transparent text-muted hover:bg-surface-2 hover:text-fg active:bg-line active:text-fg';
77
91
  }
78
92
 
79
93
  const ROW_DISABLED = 'cursor-not-allowed border-transparent text-muted opacity-50';
@@ -87,6 +101,7 @@
87
101
  const DISCLOSURE =
88
102
  'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted ' +
89
103
  'transition-colors duration-150 outline-none hover:bg-surface-2 hover:text-fg ' +
104
+ 'active:bg-line active:text-fg ' +
90
105
  'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand';
91
106
 
92
107
  const BADGE_TONE: Record<AccentTone, string> = {
@@ -257,7 +272,7 @@
257
272
  stroke-linecap="round"
258
273
  stroke-linejoin="round"
259
274
  aria-hidden="true"
260
- class="shrink-0 transition-transform duration-150 {open ? 'rotate-90' : ''}"
275
+ class="shrink-0 transition-transform duration-150 {open ? 'rotate-90' : 'rtl:rotate-180'}"
261
276
  >
262
277
  <path d="M9 18l6-6-6-6" />
263
278
  </svg>
@@ -1,3 +1,14 @@
1
+ <script lang="ts" module>
2
+ /**
3
+ * How a body cell treats content wider than its column.
4
+ *
5
+ * `wrap` breaks anywhere it has to. `truncate` keeps the cell to one line and
6
+ * ends it with an ellipsis. `auto` is the browser's own table algorithm, with
7
+ * no cap of any kind.
8
+ */
9
+ export type CellFit = 'auto' | 'wrap' | 'truncate';
10
+ </script>
11
+
1
12
  <script lang="ts">
2
13
  import type { Snippet } from 'svelte';
3
14
 
@@ -12,6 +23,35 @@
12
23
  * else, which is a landmark the reader has to enter to identify.
13
24
  */
14
25
  label?: string;
26
+ /**
27
+ * What a body cell does with content too wide for its column.
28
+ *
29
+ * `wrap` by default, and the default is not `auto`. A table constrained
30
+ * nothing: one cell holding an unbroken token - an API key, a signed URL,
31
+ * a base64 payload - has no break opportunity in it, so the auto table
32
+ * algorithm sizes the column to the whole string and the table grows to
33
+ * whatever that comes to. One measured page took its container from no
34
+ * overflow at all to 3,086px of it, and the audit log overran its own
35
+ * container by 419px at 390px wide.
36
+ *
37
+ * `wrap` fixes exactly that and nothing else. It is `overflow-wrap:
38
+ * anywhere`, which is the one value that shrinks the column's min-content
39
+ * width, so a paragraph still breaks at its spaces and only a token with
40
+ * no spaces in it is broken mid-string. Prose in a cell renders as it
41
+ * always did.
42
+ *
43
+ * The default wraps rather than truncates because wrapping is the choice
44
+ * that cannot lose anything. Truncation hides, and a table that hides is
45
+ * worse than one that scrolls, so it is opted into.
46
+ */
47
+ cell?: CellFit;
48
+ /**
49
+ * The width a truncating cell stops at. Any CSS length.
50
+ *
51
+ * Read through a custom property, so a single column can disagree with the
52
+ * table by setting `--cell-truncate` in its own style attribute.
53
+ */
54
+ truncateAt?: string;
15
55
  class?: string;
16
56
  children: Snippet;
17
57
  }
@@ -21,6 +61,8 @@
21
61
  hoverable = true,
22
62
  fixed = false,
23
63
  label = undefined,
64
+ cell = 'wrap',
65
+ truncateAt = '20rem',
24
66
  class: cls = '',
25
67
  children,
26
68
  }: Props = $props();
@@ -99,6 +141,73 @@
99
141
  * for the same reason and it reads in both themes.
100
142
  */
101
143
  const EDGE = 'pointer-events-none absolute inset-y-px w-6';
144
+
145
+ /*
146
+ * The table-wide treatment, applied only to cells that did not ask for their
147
+ * own.
148
+ *
149
+ * `:not([data-cell])` is what keeps the two apart. Without it a column that
150
+ * opted out still matched the table's rule for the same property, and which
151
+ * of the two won came down to the order Tailwind happened to emit them in.
152
+ */
153
+ const CELL_FIT: Record<CellFit, string> = {
154
+ auto: '',
155
+ wrap: '[&_tbody_td:not([data-cell])]:[overflow-wrap:anywhere]',
156
+ truncate:
157
+ '[&_tbody_td:not([data-cell])]:max-w-[var(--cell-truncate)] [&_tbody_td:not([data-cell])]:truncate',
158
+ };
159
+
160
+ /*
161
+ * The per-cell opt-in, always present so a caller can mix treatments down one
162
+ * row: `data-cell="nowrap"` on a timestamp, `data-cell="truncate"` on a
163
+ * description, nothing at all on the rest.
164
+ */
165
+ const CELL_NAMED =
166
+ '[&_tbody_td[data-cell=wrap]]:[overflow-wrap:anywhere] ' +
167
+ '[&_tbody_td[data-cell=nowrap]]:whitespace-nowrap ' +
168
+ '[&_tbody_td[data-cell=truncate]]:max-w-[var(--cell-truncate)] ' +
169
+ '[&_tbody_td[data-cell=truncate]]:truncate';
170
+
171
+ /** Marks a title this component put there, so it can take it back again. */
172
+ const OWNED = 'data-cell-title';
173
+
174
+ /**
175
+ * Gives a clipped cell its full value back.
176
+ *
177
+ * The text is never removed from the document, so a screen reader already
178
+ * reads the whole of it and it is the sighted pointer user who loses the tail
179
+ * to the ellipsis. The title that returns it is attached when a pointer or
180
+ * the keyboard actually reaches the cell, from one listener on the scroll
181
+ * box, rather than measured for every cell as the table renders: a tenant
182
+ * with 10,000 rows would pay for 10,000 measurements and 10,000 title
183
+ * attributes to answer a question asked of about three of them.
184
+ */
185
+ function reveal(event: Event) {
186
+ const from = event.target;
187
+ if (!(from instanceof Element)) return;
188
+ const td = from.closest('td');
189
+ if (!(td instanceof HTMLElement)) return;
190
+
191
+ const named = td.dataset.cell;
192
+ const clipping = named === 'truncate' || (named === undefined && cell === 'truncate');
193
+ if (!clipping) return;
194
+
195
+ const ours = td.hasAttribute(OWNED);
196
+ // A title the caller wrote is theirs. It may well say more than the cell
197
+ // holds, and replacing it with the visible text would be a downgrade.
198
+ if (td.hasAttribute('title') && !ours) return;
199
+
200
+ const full = (td.textContent ?? '').trim();
201
+ if (full && td.scrollWidth - td.clientWidth > 1) {
202
+ td.setAttribute('title', full);
203
+ td.setAttribute(OWNED, '');
204
+ } else if (ours) {
205
+ // The column widened, or the row now holds something shorter. A tooltip
206
+ // repeating text the reader can already see is noise.
207
+ td.removeAttribute('title');
208
+ td.removeAttribute(OWNED);
209
+ }
210
+ }
102
211
  </script>
103
212
 
104
213
  <div data-testid="table-frame" class="relative w-full rounded-xl border border-line {cls}">
@@ -109,6 +218,8 @@
109
218
  <div
110
219
  bind:this={scroller}
111
220
  onscroll={measure}
221
+ onpointerover={reveal}
222
+ onfocusin={reveal}
112
223
  data-print="unclip"
113
224
  data-testid="table-scroll"
114
225
  class="w-full overflow-x-auto rounded-xl"
@@ -126,8 +237,10 @@
126
237
  same place.
127
238
  -->
128
239
  <table
129
- class="w-full border-separate border-spacing-0 text-sm text-left
240
+ style="--cell-truncate: {truncateAt}"
241
+ class="w-full border-separate border-spacing-0 text-sm text-start
130
242
  {fixed ? 'table-fixed' : ''}
243
+ {CELL_FIT[cell]} {CELL_NAMED}
131
244
  [&_thead_th]:border-b [&_thead_th]:border-line [&_thead]:bg-surface-2/40
132
245
  [&_thead_th]:px-4 [&_thead_th]:py-3 [&_thead_th]:text-xs
133
246
  [&_thead_th]:font-medium [&_thead_th]:text-faint