@dorsk/tsumikit 0.71.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 (46) hide show
  1. package/README.md +33 -5
  2. package/dist/autoresize.js +13 -0
  3. package/dist/clipboard.js +12 -0
  4. package/dist/components/atoms/Artwork.svelte +4 -2
  5. package/dist/components/atoms/Avatar.svelte +4 -3
  6. package/dist/components/atoms/Badge.svelte +13 -0
  7. package/dist/components/atoms/Button.svelte +25 -9
  8. package/dist/components/atoms/Card.svelte +2 -0
  9. package/dist/components/atoms/Dot.svelte +1 -1
  10. package/dist/components/atoms/Icon.svelte +2 -0
  11. package/dist/components/atoms/Icon.svelte.d.ts +2 -0
  12. package/dist/components/atoms/Kbd.svelte +28 -16
  13. package/dist/components/atoms/Link.svelte +6 -4
  14. package/dist/components/atoms/Scrim.svelte +18 -1
  15. package/dist/components/atoms/Slider.svelte +11 -1
  16. package/dist/components/atoms/Swatch.svelte +12 -0
  17. package/dist/components/layouts/AppShell.svelte +70 -39
  18. package/dist/components/layouts/Container.svelte +25 -3
  19. package/dist/components/layouts/MasterDetail.svelte +59 -33
  20. package/dist/components/layouts/MasterDetail.svelte.d.ts +1 -1
  21. package/dist/components/layouts/NavItem.svelte +8 -4
  22. package/dist/components/layouts/NavSection.svelte +8 -2
  23. package/dist/components/layouts/ResizablePanel.svelte +14 -0
  24. package/dist/components/layouts/TabBar.svelte +217 -0
  25. package/dist/components/layouts/TabBar.svelte.d.ts +27 -0
  26. package/dist/components/layouts/Toolbar.svelte +5 -0
  27. package/dist/components/layouts/toolbar-roving.d.ts +16 -0
  28. package/dist/components/layouts/toolbar-roving.js +47 -1
  29. package/dist/components/molecules/FilterInput.svelte +7 -0
  30. package/dist/components/molecules/FilterInput.svelte.d.ts +2 -0
  31. package/dist/components/molecules/PullToRefresh.svelte +205 -0
  32. package/dist/components/molecules/PullToRefresh.svelte.d.ts +18 -0
  33. package/dist/components/molecules/SwipeRow.svelte +349 -0
  34. package/dist/components/molecules/SwipeRow.svelte.d.ts +37 -0
  35. package/dist/components/organisms/ConversationFeed.svelte +22 -8
  36. package/dist/components/organisms/ConversationFeed.svelte.d.ts +2 -0
  37. package/dist/components/organisms/DataTable.svelte +22 -5
  38. package/dist/components/organisms/DataTable.svelte.d.ts +4 -2
  39. package/dist/components/organisms/FilterSearchBar.svelte +4 -0
  40. package/dist/components/organisms/FilterSearchBar.svelte.d.ts +2 -0
  41. package/dist/index.d.ts +3 -0
  42. package/dist/index.js +3 -0
  43. package/dist/markdown.js +15 -7
  44. package/dist/styles/reset.css +30 -0
  45. package/dist/styles/syntax.css +0 -29
  46. package/package.json +1 -1
@@ -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;
@@ -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
  };
@@ -87,9 +87,11 @@
87
87
  /** Replaces a column's header content (e.g. a select-all checkbox). */
88
88
  headerSnippets?: Record<string, Snippet<[Column<T>]>>;
89
89
  empty?: string | Snippet;
90
+ /** Sticks within the table's own scroller, which also scrolls horizontally,
91
+ * so it only sticks when the table has a bounded height
92
+ * (e.g. `style="max-height: 60vh"`), not to the page. */
90
93
  stickyHeader?: boolean;
91
- /** Distance from the scroll container's top for the sticky header, e.g.
92
- * `var(--header-h)` when the page header overlaps. Defaults to 0. */
94
+ /** Distance from the scroller's top for the sticky header. Defaults to 0. */
93
95
  stickyOffset?: string;
94
96
  /** `fixed` makes declared column widths authoritative (needed for truncate). */
95
97
  layout?: 'auto' | 'fixed';
@@ -171,6 +173,13 @@
171
173
 
172
174
  const colCount = $derived(columns.length + (rowActions ? 1 : 0));
173
175
 
176
+ const NESTED_CONTROL = 'a,button,input,select,textarea,label,summary,[role="button"],[contenteditable]';
177
+
178
+ function fromNestedControl(e: Event & { currentTarget: HTMLElement }): boolean {
179
+ const hit = (e.target as Element).closest(NESTED_CONTROL);
180
+ return !!hit && hit !== e.currentTarget && e.currentTarget.contains(hit);
181
+ }
182
+
174
183
  function display(col: Column<T>, row: T): unknown {
175
184
  if (col.get) return col.get(row);
176
185
  return (row as Record<string, unknown>)[col.key];
@@ -245,7 +254,7 @@
245
254
  {:else if col.sortable}
246
255
  <button type="button" class="dt-sort" onclick={() => toggleSort(col)}>
247
256
  <span>{col.label}</span>
248
- <span class="dt-arrow" class:active={sortKey === col.key}>
257
+ <span class="dt-arrow" aria-hidden="true" class:active={sortKey === col.key}>
249
258
  {sortKey === col.key ? (sortDir === 'asc' ? '↑' : '↓') : '↕'}
250
259
  </span>
251
260
  </button>
@@ -280,9 +289,14 @@
280
289
  class:clickable={!!onrowclick}
281
290
  class:toned={!!tone && tone !== 'neutral'}
282
291
  tabindex={onrowclick ? 0 : undefined}
283
- onclick={onrowclick ? () => onrowclick(row) : undefined}
292
+ onclick={onrowclick
293
+ ? (e) => {
294
+ if (!fromNestedControl(e)) onrowclick(row);
295
+ }
296
+ : undefined}
284
297
  onkeydown={onrowclick
285
298
  ? (e) => {
299
+ if (e.target !== e.currentTarget) return;
286
300
  if (e.key === 'Enter' || e.key === ' ') {
287
301
  e.preventDefault();
288
302
  onrowclick(row);
@@ -432,7 +446,10 @@
432
446
  tr.clickable:hover,
433
447
  tr.clickable:focus-visible {
434
448
  background: var(--bg-elevated-2);
435
- outline: none;
449
+ }
450
+ tr.clickable:focus-visible {
451
+ outline: var(--focus-ring);
452
+ outline-offset: -2px;
436
453
  }
437
454
  td.truncate {
438
455
  white-space: nowrap;
@@ -38,9 +38,11 @@ declare function $$render<T>(): {
38
38
  /** Replaces a column's header content (e.g. a select-all checkbox). */
39
39
  headerSnippets?: Record<string, Snippet<[Column<T>]>>;
40
40
  empty?: string | Snippet;
41
+ /** Sticks within the table's own scroller, which also scrolls horizontally,
42
+ * so it only sticks when the table has a bounded height
43
+ * (e.g. `style="max-height: 60vh"`), not to the page. */
41
44
  stickyHeader?: boolean;
42
- /** Distance from the scroll container's top for the sticky header, e.g.
43
- * `var(--header-h)` when the page header overlaps. Defaults to 0. */
45
+ /** Distance from the scroller's top for the sticky header. Defaults to 0. */
44
46
  stickyOffset?: string;
45
47
  /** `fixed` makes declared column widths authoritative (needed for truncate). */
46
48
  layout?: "auto" | "fixed";
@@ -29,6 +29,7 @@
29
29
  size = 'md',
30
30
  shape = 'square',
31
31
  surface = 'base',
32
+ font = 'mono',
32
33
  hotkey,
33
34
  showHotkey = false,
34
35
  grow = false,
@@ -56,6 +57,8 @@
56
57
  size?: ControlSize;
57
58
  shape?: 'square' | 'pill';
58
59
  surface?: 'base' | 'raised' | 'sunken';
60
+ /** `sans` renders the typed text and placeholder in `--font-sans`; chips stay mono. */
61
+ font?: 'mono' | 'sans';
59
62
  hotkey?: string;
60
63
  showHotkey?: boolean;
61
64
  grow?: boolean;
@@ -81,6 +84,7 @@
81
84
  {size}
82
85
  {shape}
83
86
  {surface}
87
+ {font}
84
88
  {hotkey}
85
89
  {showHotkey}
86
90
  {grow}
@@ -19,6 +19,8 @@ type Own = {
19
19
  size?: ControlSize;
20
20
  shape?: 'square' | 'pill';
21
21
  surface?: 'base' | 'raised' | 'sunken';
22
+ /** `sans` renders the typed text and placeholder in `--font-sans`; chips stay mono. */
23
+ font?: 'mono' | 'sans';
22
24
  hotkey?: string;
23
25
  showHotkey?: boolean;
24
26
  grow?: boolean;
package/dist/index.d.ts CHANGED
@@ -41,6 +41,7 @@ export { default as NavSection } from './components/layouts/NavSection.svelte';
41
41
  export { default as ResizablePanel } from './components/layouts/ResizablePanel.svelte';
42
42
  export { type ResizeHandleParams, resizeHandle, } from './components/layouts/resizable-panel-frame.js';
43
43
  export { default as Stack } from './components/layouts/Stack.svelte';
44
+ export { default as TabBar, type TabBarItem } from './components/layouts/TabBar.svelte';
44
45
  export { default as Toolbar } from './components/layouts/Toolbar.svelte';
45
46
  export { type AccordionHeaderContext, type AccordionItem, type AccordionVariant, default as Accordion, } from './components/molecules/Accordion.svelte';
46
47
  export { type Attachment, default as AttachmentList, } from './components/molecules/AttachmentList.svelte';
@@ -79,6 +80,7 @@ export { default as OptionButton } from './components/molecules/OptionButton.sve
79
80
  export { default as Pagination } from './components/molecules/Pagination.svelte';
80
81
  export { default as Popover } from './components/molecules/Popover.svelte';
81
82
  export { default as Prose } from './components/molecules/Prose.svelte';
83
+ export { default as PullToRefresh } from './components/molecules/PullToRefresh.svelte';
82
84
  export { default as RadialMenu, type RadialArc, } from './components/molecules/RadialMenu.svelte';
83
85
  export { default as RadioGroup, type RadioOption, } from './components/molecules/RadioGroup.svelte';
84
86
  export { default as SectionHeader } from './components/molecules/SectionHeader.svelte';
@@ -87,6 +89,7 @@ export { default as SelectButton } from './components/molecules/SelectButton.sve
87
89
  export { default as SortableList, SORTABLE_LIST_MESSAGES, type SortableListMessages, type SortableListMove, type SortableListRowContext, } from './components/molecules/SortableList.svelte';
88
90
  export { default as SplitButton } from './components/molecules/SplitButton.svelte';
89
91
  export { default as StatusBar } from './components/molecules/StatusBar.svelte';
92
+ export { default as SwipeRow, type SwipeAction } from './components/molecules/SwipeRow.svelte';
90
93
  export { default as Tabs, type TabItem } from './components/molecules/Tabs.svelte';
91
94
  export { default as ThemePicker } from './components/molecules/ThemePicker.svelte';
92
95
  export { default as Timestamp } from './components/molecules/Timestamp.svelte';
package/dist/index.js CHANGED
@@ -46,6 +46,7 @@ export { default as NavSection } from './components/layouts/NavSection.svelte';
46
46
  export { default as ResizablePanel } from './components/layouts/ResizablePanel.svelte';
47
47
  export { resizeHandle, } from './components/layouts/resizable-panel-frame.js';
48
48
  export { default as Stack } from './components/layouts/Stack.svelte';
49
+ export { default as TabBar } from './components/layouts/TabBar.svelte';
49
50
  export { default as Toolbar } from './components/layouts/Toolbar.svelte';
50
51
  export { default as Accordion, } from './components/molecules/Accordion.svelte';
51
52
  export { default as AttachmentList, } from './components/molecules/AttachmentList.svelte';
@@ -87,6 +88,7 @@ export { default as OptionButton } from './components/molecules/OptionButton.sve
87
88
  export { default as Pagination } from './components/molecules/Pagination.svelte';
88
89
  export { default as Popover } from './components/molecules/Popover.svelte';
89
90
  export { default as Prose } from './components/molecules/Prose.svelte';
91
+ export { default as PullToRefresh } from './components/molecules/PullToRefresh.svelte';
90
92
  export { default as RadialMenu, } from './components/molecules/RadialMenu.svelte';
91
93
  export { default as RadioGroup, } from './components/molecules/RadioGroup.svelte';
92
94
  export { default as SectionHeader } from './components/molecules/SectionHeader.svelte';
@@ -95,6 +97,7 @@ export { default as SelectButton } from './components/molecules/SelectButton.sve
95
97
  export { default as SortableList, SORTABLE_LIST_MESSAGES, } from './components/molecules/SortableList.svelte';
96
98
  export { default as SplitButton } from './components/molecules/SplitButton.svelte';
97
99
  export { default as StatusBar } from './components/molecules/StatusBar.svelte';
100
+ export { default as SwipeRow } from './components/molecules/SwipeRow.svelte';
98
101
  export { default as Tabs } from './components/molecules/Tabs.svelte';
99
102
  export { default as ThemePicker } from './components/molecules/ThemePicker.svelte';
100
103
  export { default as Timestamp } from './components/molecules/Timestamp.svelte';
package/dist/markdown.js CHANGED
@@ -15,6 +15,7 @@ const BLOCK_RE = new RegExp(`${BLOCK_L}(\\d+)${BLOCK_R}`, 'g');
15
15
  const SPAN_L = '\x10';
16
16
  const SPAN_R = '\x11';
17
17
  const SPAN_RE = new RegExp(`${SPAN_L}(\\d+)${SPAN_R}`, 'g');
18
+ const BARE_URL_RE = new RegExp(`\\bhttps?://(?:&amp;|[^\\s&<>"${SPAN_L}${SPAN_R}])+`, 'g');
18
19
  // Terminal escape sequences (the ansi-regex pattern), then the remaining C0
19
20
  // controls except tab and newline, plus DEL.
20
21
  const ANSI_RE =
@@ -62,20 +63,27 @@ function autolinkUrls(s) {
62
63
  });
63
64
  return s.replace(SPAN_RE, (_m, i) => saved[Number(i)]);
64
65
  }
65
- // Inline passes on already-escaped text.
66
+ // Inline passes on already-escaped text; emphasis never reaches inside code, link tags or URLs.
66
67
  function inline(s, autolink) {
67
- s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
68
- s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
69
- s = s.replace(/(^|[^*\w])\*([^*\n]+)\*/g, '$1<em>$2</em>');
70
- s = s.replace(/(^|[^\w])_([^_\n]+)_(?!\w)/g, '$1<em>$2</em>');
71
- s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>');
68
+ const held = [];
69
+ const hold = (html) => `${SPAN_L}${held.push(html) - 1}${SPAN_R}`;
70
+ s = s.replace(/`([^`]+)`/g, (_m, code) => hold(`<code>${code}</code>`));
72
71
  s = s.replace(/\[([^\]]+)\]\(([^\s)]+)\)/g, (m, text, url) => {
73
72
  const href = safeHref(url);
74
73
  if (!href)
75
74
  return m;
76
75
  const external = /^https?:/i.test(href) ? ' target="_blank" rel="noopener noreferrer"' : '';
77
- return `<a href="${href}"${external}>${text}</a>`;
76
+ return `${hold(`<a href="${href}"${external}>`)}${text}${hold('</a>')}`;
77
+ });
78
+ s = s.replace(BARE_URL_RE, (raw) => {
79
+ const url = raw.replace(/[*_~]+$/, '');
80
+ return hold(url) + raw.slice(url.length);
78
81
  });
82
+ s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
83
+ s = s.replace(/(^|[^*\w])\*([^*\n]+)\*/g, '$1<em>$2</em>');
84
+ s = s.replace(/(^|[^\w])_([^_\n]+)_(?!\w)/g, '$1<em>$2</em>');
85
+ s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>');
86
+ s = s.replace(SPAN_RE, (_m, i) => held[Number(i)]);
79
87
  return autolink ? autolinkUrls(s) : s;
80
88
  }
81
89
  function splitRow(row) {