@dorsk/tsumikit 0.70.0 → 0.72.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 (56) hide show
  1. package/README.md +49 -8
  2. package/dist/autoresize.js +13 -0
  3. package/dist/clipboard.js +12 -0
  4. package/dist/components/atoms/Artwork.svelte +10 -3
  5. package/dist/components/atoms/Artwork.svelte.d.ts +2 -0
  6. package/dist/components/atoms/Avatar.svelte +4 -3
  7. package/dist/components/atoms/Badge.svelte +23 -8
  8. package/dist/components/atoms/Badge.svelte.d.ts +2 -1
  9. package/dist/components/atoms/Button.svelte +39 -11
  10. package/dist/components/atoms/Card.svelte +2 -0
  11. package/dist/components/atoms/Dot.svelte +1 -1
  12. package/dist/components/atoms/Icon.svelte +2 -0
  13. package/dist/components/atoms/Icon.svelte.d.ts +2 -0
  14. package/dist/components/atoms/Input.svelte +3 -0
  15. package/dist/components/atoms/Kbd.svelte +28 -16
  16. package/dist/components/atoms/Link.svelte +18 -4
  17. package/dist/components/atoms/Scrim.svelte +18 -1
  18. package/dist/components/atoms/Select.svelte +1 -0
  19. package/dist/components/atoms/Slider.svelte +11 -1
  20. package/dist/components/atoms/Swatch.svelte +12 -0
  21. package/dist/components/atoms/Switch.svelte +12 -0
  22. package/dist/components/layouts/AppShell.svelte +70 -39
  23. package/dist/components/layouts/Container.svelte +25 -3
  24. package/dist/components/layouts/MasterDetail.svelte +59 -33
  25. package/dist/components/layouts/MasterDetail.svelte.d.ts +1 -1
  26. package/dist/components/layouts/NavItem.svelte +8 -4
  27. package/dist/components/layouts/NavSection.svelte +8 -2
  28. package/dist/components/layouts/ResizablePanel.svelte +14 -0
  29. package/dist/components/layouts/TabBar.svelte +217 -0
  30. package/dist/components/layouts/TabBar.svelte.d.ts +27 -0
  31. package/dist/components/layouts/Toolbar.svelte +5 -0
  32. package/dist/components/layouts/toolbar-roving.d.ts +16 -0
  33. package/dist/components/layouts/toolbar-roving.js +47 -1
  34. package/dist/components/molecules/Breadcrumb.svelte +44 -2
  35. package/dist/components/molecules/Breadcrumb.svelte.d.ts +6 -0
  36. package/dist/components/molecules/FilterInput.svelte +7 -0
  37. package/dist/components/molecules/FilterInput.svelte.d.ts +2 -0
  38. package/dist/components/molecules/PullToRefresh.svelte +205 -0
  39. package/dist/components/molecules/PullToRefresh.svelte.d.ts +18 -0
  40. package/dist/components/molecules/SegmentedControl.svelte +44 -1
  41. package/dist/components/molecules/SwipeRow.svelte +349 -0
  42. package/dist/components/molecules/SwipeRow.svelte.d.ts +37 -0
  43. package/dist/components/molecules/Toggle.svelte +12 -0
  44. package/dist/components/organisms/ConversationFeed.svelte +22 -8
  45. package/dist/components/organisms/ConversationFeed.svelte.d.ts +2 -0
  46. package/dist/components/organisms/DataTable.svelte +72 -12
  47. package/dist/components/organisms/DataTable.svelte.d.ts +7 -2
  48. package/dist/components/organisms/FilterSearchBar.svelte +4 -0
  49. package/dist/components/organisms/FilterSearchBar.svelte.d.ts +2 -0
  50. package/dist/index.d.ts +3 -0
  51. package/dist/index.js +3 -0
  52. package/dist/markdown.js +15 -7
  53. package/dist/styles/reset.css +30 -0
  54. package/dist/styles/syntax.css +0 -29
  55. package/dist/styles/tokens.css +6 -0
  56. package/package.json +1 -1
@@ -0,0 +1,18 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ children?: Snippet;
4
+ /** Awaited after a release past the threshold; the spinner shows until it settles. */
5
+ onrefresh?: () => Promise<void> | void;
6
+ /** Damped pull distance (px) that arms the refresh. */
7
+ threshold?: number;
8
+ /** Announced through the live region while `onrefresh` is pending. */
9
+ label?: string;
10
+ pullLabel?: string;
11
+ releaseLabel?: string;
12
+ disabled?: boolean;
13
+ class?: string;
14
+ [key: string]: unknown;
15
+ };
16
+ declare const PullToRefresh: import("svelte").Component<$$ComponentProps, {}, "">;
17
+ type PullToRefresh = ReturnType<typeof PullToRefresh>;
18
+ export default PullToRefresh;
@@ -179,6 +179,7 @@
179
179
  flex-wrap: nowrap;
180
180
  max-width: 100%;
181
181
  overflow-x: auto;
182
+ overflow-y: hidden;
182
183
  scrollbar-width: none;
183
184
  -webkit-overflow-scrolling: touch;
184
185
  }
@@ -244,6 +245,37 @@
244
245
  .seg-box .seg-item {
245
246
  height: 100%;
246
247
  }
248
+ /* Wrapped rows size to their content; each item keeps the one-row height. */
249
+ .seg-wrap.seg-sm,
250
+ .seg-wrap.seg-control,
251
+ .seg-wrap.seg-box {
252
+ height: auto;
253
+ }
254
+ .seg-wrap.seg-sm .seg-item {
255
+ height: auto;
256
+ min-height: calc(var(--control-height-compact) - 8px);
257
+ }
258
+ .seg-wrap.seg-control .seg-item {
259
+ height: auto;
260
+ min-height: calc(var(--control-height) - 8px);
261
+ }
262
+ .seg-wrap.seg-box .seg-item {
263
+ height: auto;
264
+ min-height: calc(var(--control-height-default) - 8px);
265
+ }
266
+ /* Capped at half a row: one row stays a pill, extra rows square the corners. */
267
+ .seg-wrap:not(.seg-icon) {
268
+ border-radius: calc((0.6rem + 1.3 * var(--fs-sm)) / 2 + 4px);
269
+ }
270
+ .seg-wrap.seg-sm:not(.seg-icon) {
271
+ border-radius: calc(var(--control-height-compact) / 2);
272
+ }
273
+ .seg-wrap.seg-control:not(.seg-icon) {
274
+ border-radius: calc(var(--control-height) / 2);
275
+ }
276
+ .seg-wrap.seg-box:not(.seg-icon) {
277
+ border-radius: calc(var(--control-height-default) / 2);
278
+ }
247
279
  .seg-icon .seg-item {
248
280
  gap: 0;
249
281
  padding: 0.35rem;
@@ -320,13 +352,24 @@
320
352
  border-radius: var(--r-pill);
321
353
  background: var(--bg-elevated-2);
322
354
  color: var(--text-faint);
323
- font-size: 0.6875rem;
355
+ font-size: var(--fs-xs);
324
356
  font-weight: var(--fw-semibold);
325
357
  font-variant-numeric: tabular-nums;
326
358
  transition:
327
359
  background 0.12s var(--ease),
328
360
  color 0.12s var(--ease);
329
361
  }
362
+ @media (pointer: coarse) {
363
+ .seg-item {
364
+ position: relative;
365
+ }
366
+ .seg-item::before {
367
+ content: '';
368
+ position: absolute;
369
+ inset-block: var(--touch-slab);
370
+ inset-inline: calc(var(--sp-1) / -2);
371
+ }
372
+ }
330
373
  .seg-item.selected .seg-count {
331
374
  background: color-mix(in srgb, var(--accent) 18%, transparent);
332
375
  color: var(--accent);
@@ -0,0 +1,349 @@
1
+ <script lang="ts" module>
2
+ import type { IconName } from '../atoms/Icon.svelte';
3
+ import type { Tone } from '../../tone';
4
+
5
+ export interface SwipeAction {
6
+ label: string;
7
+ icon?: IconName;
8
+ tone?: Tone;
9
+ onaction: () => void;
10
+ }
11
+ </script>
12
+
13
+ <script lang="ts">
14
+ /**
15
+ * List row with a hidden action on each side, revealed by a horizontal swipe.
16
+ * A swipe past `threshold` (fraction of the row width) fires the action on
17
+ * release; a shorter one snaps back. The axis is locked after a few pixels:
18
+ * a drag that is more vertical than horizontal is left to the scroller.
19
+ * The same actions are reachable as visually hidden buttons for keyboard
20
+ * and screen-reader users.
21
+ */
22
+ import type { Snippet } from 'svelte';
23
+ import type { HTMLAttributes } from 'svelte/elements';
24
+ import Icon from '../atoms/Icon.svelte';
25
+ import { canonicalTone } from '../../tone';
26
+
27
+ type Own = {
28
+ children: Snippet;
29
+ /** Revealed on a rightward swipe. */
30
+ leadingAction?: SwipeAction;
31
+ /** Revealed on a leftward swipe. */
32
+ trailingAction?: SwipeAction;
33
+ /** Custom pane content; defaults to the action's icon and label. */
34
+ leading?: Snippet<[SwipeAction, boolean]>;
35
+ trailing?: Snippet<[SwipeAction, boolean]>;
36
+ /** Fraction of the row width the content must travel to fire. */
37
+ threshold?: number;
38
+ disabled?: boolean;
39
+ class?: string;
40
+ style?: string;
41
+ };
42
+
43
+ let {
44
+ children,
45
+ leadingAction,
46
+ trailingAction,
47
+ leading,
48
+ trailing,
49
+ threshold = 0.35,
50
+ disabled = false,
51
+ class: klass = '',
52
+ style: styleProp,
53
+ ...rest
54
+ }: Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own = $props();
55
+
56
+ const SLOP = 8;
57
+ const FALLBACK_WIDTH = 320;
58
+
59
+ let offset = $state(0);
60
+ let dragging = $state(false);
61
+ let settling = $state(false);
62
+
63
+ let pointerId: number | null = null;
64
+ let startX = 0;
65
+ let startY = 0;
66
+ let width = $state(0);
67
+ let locked: 'x' | 'y' | null = null;
68
+ let swallowClick = false;
69
+ let settleTimer: ReturnType<typeof setTimeout> | undefined;
70
+
71
+ const armed = $derived(width > 0 && Math.abs(offset) >= width * threshold);
72
+ const side = $derived(offset > 0 ? 'leading' : offset < 0 ? 'trailing' : null);
73
+ const active = $derived(side === 'leading' ? leadingAction : side === 'trailing' ? trailingAction : undefined);
74
+ const tone = $derived(canonicalTone(active?.tone ?? 'neutral'));
75
+
76
+ function reducedMotion() {
77
+ return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
78
+ }
79
+
80
+ function release(el: HTMLElement) {
81
+ if (pointerId !== null && typeof el.releasePointerCapture === 'function') {
82
+ try {
83
+ el.releasePointerCapture(pointerId);
84
+ } catch {}
85
+ }
86
+ pointerId = null;
87
+ locked = null;
88
+ dragging = false;
89
+ }
90
+
91
+ function settle() {
92
+ clearTimeout(settleTimer);
93
+ if (reducedMotion()) {
94
+ offset = 0;
95
+ return;
96
+ }
97
+ settling = true;
98
+ offset = 0;
99
+ settleTimer = setTimeout(() => (settling = false), 220);
100
+ }
101
+
102
+ function onpointerdown(e: PointerEvent) {
103
+ swallowClick = false;
104
+ if (disabled || e.button !== 0 || pointerId !== null) return;
105
+ if (!leadingAction && !trailingAction) return;
106
+ const el = e.currentTarget as HTMLElement;
107
+ pointerId = e.pointerId;
108
+ startX = e.clientX;
109
+ startY = e.clientY;
110
+ width = el.clientWidth || FALLBACK_WIDTH;
111
+ locked = null;
112
+ clearTimeout(settleTimer);
113
+ settling = false;
114
+ }
115
+
116
+ function onpointermove(e: PointerEvent) {
117
+ if (pointerId !== e.pointerId) return;
118
+ const el = e.currentTarget as HTMLElement;
119
+ const dx = e.clientX - startX;
120
+ const dy = e.clientY - startY;
121
+ if (locked === null) {
122
+ if (Math.hypot(dx, dy) < SLOP) return;
123
+ if (Math.abs(dy) >= Math.abs(dx)) {
124
+ locked = 'y';
125
+ release(el);
126
+ return;
127
+ }
128
+ locked = 'x';
129
+ dragging = true;
130
+ swallowClick = true;
131
+ if (typeof el.setPointerCapture === 'function') {
132
+ try {
133
+ el.setPointerCapture(e.pointerId);
134
+ } catch {}
135
+ }
136
+ }
137
+ if (locked !== 'x') return;
138
+ const max = leadingAction ? width : 0;
139
+ const min = trailingAction ? -width : 0;
140
+ offset = Math.max(min, Math.min(max, dx));
141
+ }
142
+
143
+ function onpointerup(e: PointerEvent) {
144
+ if (pointerId !== e.pointerId) return;
145
+ const el = e.currentTarget as HTMLElement;
146
+ const fire = locked === 'x' && armed ? active : undefined;
147
+ release(el);
148
+ settle();
149
+ fire?.onaction();
150
+ }
151
+
152
+ function onpointercancel(e: PointerEvent) {
153
+ if (pointerId !== e.pointerId) return;
154
+ release(e.currentTarget as HTMLElement);
155
+ settle();
156
+ }
157
+
158
+ function onclickcapture(e: MouseEvent) {
159
+ if (!swallowClick) return;
160
+ swallowClick = false;
161
+ e.stopPropagation();
162
+ e.preventDefault();
163
+ }
164
+
165
+ function onlostpointercapture() {
166
+ if (pointerId === null) return;
167
+ pointerId = null;
168
+ locked = null;
169
+ dragging = false;
170
+ settle();
171
+ }
172
+ </script>
173
+
174
+ <div
175
+ data-tsu="SwipeRow"
176
+ class="swiperow {klass}"
177
+ class:swiperow--dragging={dragging}
178
+ class:swiperow--settling={settling}
179
+ class:swiperow--armed={armed}
180
+ class:swiperow--leading={side === 'leading'}
181
+ class:swiperow--trailing={side === 'trailing'}
182
+ class:swiperow-tone-accent={tone === 'accent'}
183
+ class:swiperow-tone-success={tone === 'ok'}
184
+ class:swiperow-tone-info={tone === 'info'}
185
+ class:swiperow-tone-warn={tone === 'warn'}
186
+ class:swiperow-tone-danger={tone === 'danger'}
187
+ style="--swiperow-x: {offset}px;{styleProp ?? ''}"
188
+ {onpointerdown}
189
+ {onpointermove}
190
+ {onpointerup}
191
+ {onpointercancel}
192
+ {onlostpointercapture}
193
+ {onclickcapture}
194
+ {...rest}
195
+ >
196
+ {#if leadingAction}
197
+ <div class="swiperow-pane swiperow-pane--leading" aria-hidden="true" data-part="leading">
198
+ {#if leading}
199
+ {@render leading(leadingAction, side === 'leading' && armed)}
200
+ {:else}
201
+ {#if leadingAction.icon}<Icon name={leadingAction.icon} />{/if}
202
+ <span class="swiperow-pane-label">{leadingAction.label}</span>
203
+ {/if}
204
+ </div>
205
+ {/if}
206
+ {#if trailingAction}
207
+ <div class="swiperow-pane swiperow-pane--trailing" aria-hidden="true" data-part="trailing">
208
+ {#if trailing}
209
+ {@render trailing(trailingAction, side === 'trailing' && armed)}
210
+ {:else}
211
+ {#if trailingAction.icon}<Icon name={trailingAction.icon} />{/if}
212
+ <span class="swiperow-pane-label">{trailingAction.label}</span>
213
+ {/if}
214
+ </div>
215
+ {/if}
216
+ <div class="swiperow-content" data-part="content">
217
+ {@render children()}
218
+ </div>
219
+ {#if leadingAction || trailingAction}
220
+ <div class="swiperow-actions" data-part="actions">
221
+ {#if leadingAction}
222
+ <button type="button" class="swiperow-sr" data-part="leading-button" {disabled} onclick={() => leadingAction?.onaction()}>
223
+ {leadingAction.label}
224
+ </button>
225
+ {/if}
226
+ {#if trailingAction}
227
+ <button type="button" class="swiperow-sr" data-part="trailing-button" {disabled} onclick={() => trailingAction?.onaction()}>
228
+ {trailingAction.label}
229
+ </button>
230
+ {/if}
231
+ </div>
232
+ {/if}
233
+ </div>
234
+
235
+ <style>
236
+ .swiperow {
237
+ --swiperow-tone: var(--bg-elevated-2);
238
+ --swiperow-ink: var(--text);
239
+ position: relative;
240
+ overflow: hidden;
241
+ touch-action: pan-y;
242
+ user-select: none;
243
+ -webkit-user-select: none;
244
+ }
245
+ .swiperow-tone-accent {
246
+ --swiperow-tone: var(--accent);
247
+ --swiperow-ink: var(--text-on-accent);
248
+ }
249
+ .swiperow-tone-success {
250
+ --swiperow-tone: var(--ok);
251
+ --swiperow-ink: var(--bg);
252
+ }
253
+ .swiperow-tone-info {
254
+ --swiperow-tone: var(--info);
255
+ --swiperow-ink: var(--bg);
256
+ }
257
+ .swiperow-tone-warn {
258
+ --swiperow-tone: var(--warn);
259
+ --swiperow-ink: var(--bg);
260
+ }
261
+ .swiperow-tone-danger {
262
+ --swiperow-tone: var(--danger);
263
+ --swiperow-ink: var(--bg);
264
+ }
265
+ .swiperow-pane {
266
+ position: absolute;
267
+ inset: 0;
268
+ display: flex;
269
+ align-items: center;
270
+ gap: var(--sp-2);
271
+ padding: 0 var(--sp-4);
272
+ background: var(--swiperow-tone);
273
+ color: var(--swiperow-ink);
274
+ font-size: var(--fs-sm);
275
+ font-weight: 600;
276
+ visibility: hidden;
277
+ pointer-events: none;
278
+ }
279
+ .swiperow-pane--leading {
280
+ justify-content: flex-start;
281
+ }
282
+ .swiperow-pane--trailing {
283
+ justify-content: flex-end;
284
+ }
285
+ .swiperow--leading .swiperow-pane--leading,
286
+ .swiperow--trailing .swiperow-pane--trailing {
287
+ visibility: visible;
288
+ }
289
+ .swiperow-pane :global(svg) {
290
+ transition: transform 120ms var(--ease);
291
+ }
292
+ .swiperow--armed .swiperow-pane :global(svg) {
293
+ transform: scale(1.2);
294
+ }
295
+ .swiperow-content {
296
+ position: relative;
297
+ background: var(--bg);
298
+ transform: translate3d(var(--swiperow-x), 0, 0);
299
+ will-change: transform;
300
+ }
301
+ .swiperow--settling .swiperow-content {
302
+ transition: transform 200ms var(--ease);
303
+ }
304
+ .swiperow--dragging .swiperow-content {
305
+ cursor: grabbing;
306
+ }
307
+ .swiperow-actions {
308
+ position: absolute;
309
+ inset: 0 0 0 auto;
310
+ display: flex;
311
+ align-items: center;
312
+ gap: var(--sp-1);
313
+ padding: 0 var(--sp-2);
314
+ pointer-events: none;
315
+ }
316
+ .swiperow-sr {
317
+ position: absolute;
318
+ width: 1px;
319
+ height: 1px;
320
+ padding: 0;
321
+ margin: -1px;
322
+ overflow: hidden;
323
+ clip: rect(0, 0, 0, 0);
324
+ white-space: nowrap;
325
+ border: 0;
326
+ }
327
+ .swiperow-sr:focus-visible {
328
+ position: static;
329
+ width: auto;
330
+ height: auto;
331
+ margin: 0;
332
+ padding: var(--sp-1) var(--sp-2);
333
+ clip: auto;
334
+ pointer-events: auto;
335
+ font: inherit;
336
+ font-size: var(--fs-sm);
337
+ color: var(--swiperow-ink);
338
+ background: var(--swiperow-tone);
339
+ border-radius: var(--r-sm);
340
+ outline: 2px solid var(--accent);
341
+ outline-offset: 1px;
342
+ }
343
+ @media (prefers-reduced-motion: reduce) {
344
+ .swiperow--settling .swiperow-content,
345
+ .swiperow-pane :global(svg) {
346
+ transition: none;
347
+ }
348
+ }
349
+ </style>
@@ -0,0 +1,37 @@
1
+ import type { IconName } from '../atoms/Icon.svelte';
2
+ import type { Tone } from '../../tone';
3
+ export interface SwipeAction {
4
+ label: string;
5
+ icon?: IconName;
6
+ tone?: Tone;
7
+ onaction: () => void;
8
+ }
9
+ /**
10
+ * List row with a hidden action on each side, revealed by a horizontal swipe.
11
+ * A swipe past `threshold` (fraction of the row width) fires the action on
12
+ * release; a shorter one snaps back. The axis is locked after a few pixels:
13
+ * a drag that is more vertical than horizontal is left to the scroller.
14
+ * The same actions are reachable as visually hidden buttons for keyboard
15
+ * and screen-reader users.
16
+ */
17
+ import type { Snippet } from 'svelte';
18
+ import type { HTMLAttributes } from 'svelte/elements';
19
+ type Own = {
20
+ children: Snippet;
21
+ /** Revealed on a rightward swipe. */
22
+ leadingAction?: SwipeAction;
23
+ /** Revealed on a leftward swipe. */
24
+ trailingAction?: SwipeAction;
25
+ /** Custom pane content; defaults to the action's icon and label. */
26
+ leading?: Snippet<[SwipeAction, boolean]>;
27
+ trailing?: Snippet<[SwipeAction, boolean]>;
28
+ /** Fraction of the row width the content must travel to fire. */
29
+ threshold?: number;
30
+ disabled?: boolean;
31
+ class?: string;
32
+ style?: string;
33
+ };
34
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own;
35
+ declare const SwipeRow: import("svelte").Component<$$ComponentProps, {}, "">;
36
+ type SwipeRow = ReturnType<typeof SwipeRow>;
37
+ export default SwipeRow;
@@ -108,4 +108,16 @@
108
108
  .toggle.struck {
109
109
  text-decoration: line-through;
110
110
  }
111
+ @media (pointer: coarse) {
112
+ .toggle {
113
+ position: relative;
114
+ }
115
+ .toggle::before {
116
+ content: '';
117
+ position: absolute;
118
+ inset-block: var(--touch-slab);
119
+ inset-inline-start: var(--touch-slab);
120
+ inset-inline-end: 0;
121
+ }
122
+ }
111
123
  </style>
@@ -17,6 +17,7 @@
17
17
  loadMoreLabel = 'Load older',
18
18
  follow = true,
19
19
  jumpLabel = 'Jump to latest',
20
+ label = 'Messages',
20
21
  class: klass = '',
21
22
  style: styleProp = ''
22
23
  }: {
@@ -32,6 +33,8 @@
32
33
  /** Stick to the bottom while the user is at the bottom. */
33
34
  follow?: boolean;
34
35
  jumpLabel?: string;
36
+ /** Accessible name of the message log. */
37
+ label?: string;
35
38
  class?: string;
36
39
  style?: string;
37
40
  } = $props();
@@ -41,9 +44,10 @@
41
44
  let scroller = $state<HTMLElement | null>(null);
42
45
  let stuck = $state(true);
43
46
  let pending = $state(false);
47
+ // Keeps screen readers from announcing a history load or swap as new messages.
48
+ let busy = $state(false);
44
49
  let prevFirst: string | number | undefined;
45
50
  let prevLast: string | number | undefined;
46
- let prevLength = 0;
47
51
 
48
52
  const atBottom = (el: HTMLElement) => el.scrollHeight - el.scrollTop - el.clientHeight <= THRESHOLD;
49
53
 
@@ -64,17 +68,26 @@
64
68
  const el = scroller;
65
69
  const first = items.length ? key(items[0]) : undefined;
66
70
  const last = items.length ? key(items[items.length - 1]) : undefined;
67
- const length = items.length;
68
71
  if (!el) return;
69
- const prepended = prevFirst !== undefined && first !== prevFirst && length > prevLength;
70
- const appended = last !== undefined && last !== prevLast;
72
+ const keys = prevFirst === undefined ? null : new Set(items.map(key));
73
+ const kept = (k: string | number | undefined) => k !== undefined && keys !== null && keys.has(k);
74
+ const prepended = first !== prevFirst && last === prevLast && kept(prevFirst);
75
+ const reset = prevLast !== undefined && !kept(prevLast) && !kept(prevFirst);
76
+ const appended = !reset && last !== undefined && last !== prevLast;
71
77
  prevFirst = first;
72
78
  prevLast = last;
73
- prevLength = length;
74
79
  const fromBottom = el.scrollHeight - el.scrollTop;
80
+ if (prepended || reset) {
81
+ busy = true;
82
+ requestAnimationFrame(() => requestAnimationFrame(() => (busy = false)));
83
+ }
75
84
  tick().then(() => {
76
85
  if (prepended) {
77
86
  el.scrollTop = el.scrollHeight - fromBottom;
87
+ } else if (reset) {
88
+ pending = false;
89
+ if (untrack(() => follow)) scrollToBottom();
90
+ else el.scrollTop = 0;
78
91
  } else if (appended) {
79
92
  if (untrack(() => follow && stuck)) scrollToBottom();
80
93
  else pending = true;
@@ -100,7 +113,7 @@
100
113
  {#if items.length === 0 && empty}
101
114
  {@render empty()}
102
115
  {/if}
103
- <div class="feed-items" {@attach pin}>
116
+ <div class="feed-items" role="log" aria-label={label} aria-busy={busy} {@attach pin}>
104
117
  {#each items as entry, i (key(entry))}
105
118
  {@render item(entry, i)}
106
119
  {/each}
@@ -138,9 +151,10 @@
138
151
  }
139
152
  .jump {
140
153
  position: absolute;
141
- inset-inline-start: 50%;
154
+ inset-inline: 0;
142
155
  bottom: var(--sp-3);
143
- transform: translateX(-50%);
156
+ width: fit-content;
157
+ margin-inline: auto;
144
158
  z-index: 1;
145
159
  border-radius: var(--r-pill);
146
160
  box-shadow: var(--shadow-md);
@@ -13,6 +13,8 @@ declare function $$render<T>(): {
13
13
  /** Stick to the bottom while the user is at the bottom. */
14
14
  follow?: boolean;
15
15
  jumpLabel?: string;
16
+ /** Accessible name of the message log. */
17
+ label?: string;
16
18
  class?: string;
17
19
  style?: string;
18
20
  };