@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,217 @@
1
+ <script lang="ts">
2
+ // Phone bottom tab bar: fixed to the bottom edge with safe-area padding.
3
+ // Items without `href` form a `tablist` of `tab`s with roving focus; with
4
+ // `href` the bar is a `<nav>` of `<a aria-current="page">`. While mounted as
5
+ // a bar it publishes its measured height as `--tabbar-h` on `<html>` so
6
+ // content can clear it (`padding-bottom: var(--tabbar-h)`).
7
+ import Badge from '../atoms/Badge.svelte';
8
+ import Icon from '../atoms/Icon.svelte';
9
+ import type { IconName } from '../atoms/Icon.svelte';
10
+ import { formatCount, hasCount } from '../../count';
11
+
12
+ export interface TabBarItem {
13
+ id: string;
14
+ label: string;
15
+ icon?: IconName;
16
+ /** Renders an `<a>`; without it the item is a `<button role="tab">`. */
17
+ href?: string;
18
+ /** Count (capped at `99+`) or short text pinned to the glyph. 0 / '' render nothing. */
19
+ badge?: number | string;
20
+ /** Id of the panel a tab shows (`aria-controls`). */
21
+ controls?: string;
22
+ }
23
+
24
+ let {
25
+ items,
26
+ value = $bindable(),
27
+ onselect,
28
+ label = 'Primary',
29
+ placement = 'bar',
30
+ class: klass = '',
31
+ ...rest
32
+ }: {
33
+ items: TabBarItem[];
34
+ /** `id` of the selected item. */
35
+ value?: string;
36
+ onselect?: (id: string, item: TabBarItem) => void;
37
+ /** Accessible name of the bar. */
38
+ label?: string;
39
+ /** 'bar' = fixed to the bottom edge; 'inline' = flows with its parent. */
40
+ placement?: 'bar' | 'inline';
41
+ class?: string;
42
+ [key: string]: unknown;
43
+ } = $props();
44
+
45
+ const links = $derived(items.some((item) => item.href));
46
+ const isCurrent = (item: TabBarItem) => item.id === value;
47
+ const focusIndex = $derived(Math.max(0, items.findIndex(isCurrent)));
48
+ const badgeText = (badge: TabBarItem['badge']) =>
49
+ typeof badge === 'number' ? (hasCount(badge) ? formatCount(badge) : '') : (badge ?? '');
50
+
51
+ let height = $state(0);
52
+
53
+ $effect(() => {
54
+ if (placement !== 'bar' || typeof document === 'undefined') return;
55
+ const root = document.documentElement;
56
+ root.style.setProperty('--tabbar-h', `${height}px`);
57
+ return () => root.style.removeProperty('--tabbar-h');
58
+ });
59
+
60
+ function select(item: TabBarItem) {
61
+ if (item.id === value) return;
62
+ value = item.id;
63
+ onselect?.(item.id, item);
64
+ }
65
+
66
+ function onkeydown(e: KeyboardEvent) {
67
+ const all = [...(e.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('.tabbar-item')];
68
+ const i = all.indexOf(e.target as HTMLElement);
69
+ if (i < 0) return;
70
+ let next: number;
71
+ if (e.key === 'ArrowRight') next = (i + 1) % all.length;
72
+ else if (e.key === 'ArrowLeft') next = (i - 1 + all.length) % all.length;
73
+ else if (e.key === 'Home') next = 0;
74
+ else if (e.key === 'End') next = all.length - 1;
75
+ else return;
76
+ e.preventDefault();
77
+ all[next].focus();
78
+ if (!links) select(items[next]);
79
+ }
80
+ </script>
81
+
82
+ {#snippet glyph(item: TabBarItem)}
83
+ <span class="tabbar-glyph">
84
+ {#if item.icon}
85
+ <Icon name={item.icon} size={22} />
86
+ {/if}
87
+ {#if badgeText(item.badge)}
88
+ <Badge class="tabbar-badge" tone="accent" size="xs" border={false} active numeric={typeof item.badge === 'number'}>
89
+ {badgeText(item.badge)}
90
+ </Badge>
91
+ {/if}
92
+ </span>
93
+ <span class="tabbar-label">{item.label}</span>
94
+ {/snippet}
95
+
96
+ <nav
97
+ bind:offsetHeight={height}
98
+ data-tsu="TabBar"
99
+ aria-label={label}
100
+ class="tabbar {klass}"
101
+ class:bar={placement === 'bar'}
102
+ {...rest}
103
+ >
104
+ <div
105
+ class="tabbar-row"
106
+ role={links ? undefined : 'tablist'}
107
+ aria-label={links ? undefined : label}
108
+ {onkeydown}
109
+ >
110
+ {#each items as item, i (item.id)}
111
+ {#if item.href}
112
+ <a
113
+ href={item.href}
114
+ class="tabbar-item"
115
+ class:current={isCurrent(item)}
116
+ aria-current={isCurrent(item) ? 'page' : undefined}
117
+ onclick={() => select(item)}
118
+ >
119
+ {@render glyph(item)}
120
+ </a>
121
+ {:else}
122
+ <button
123
+ type="button"
124
+ role="tab"
125
+ class="tabbar-item"
126
+ class:current={isCurrent(item)}
127
+ aria-selected={isCurrent(item)}
128
+ aria-controls={item.controls}
129
+ tabindex={i === focusIndex ? 0 : -1}
130
+ onclick={() => select(item)}
131
+ >
132
+ {@render glyph(item)}
133
+ </button>
134
+ {/if}
135
+ {/each}
136
+ </div>
137
+ </nav>
138
+
139
+ <style>
140
+ .tabbar {
141
+ min-width: 0;
142
+ background: var(--bg-elevated);
143
+ border-top: 1px solid var(--border);
144
+ }
145
+ .bar {
146
+ position: fixed;
147
+ inset-inline: 0;
148
+ bottom: 0;
149
+ z-index: var(--z-nav);
150
+ padding-bottom: env(safe-area-inset-bottom, 0px);
151
+ padding-inline: env(safe-area-inset-left, 0px) env(safe-area-inset-right, 0px);
152
+ background: color-mix(in srgb, var(--bg-elevated) 92%, transparent);
153
+ backdrop-filter: blur(12px);
154
+ }
155
+ .tabbar-row {
156
+ display: flex;
157
+ align-items: stretch;
158
+ min-width: 0;
159
+ min-height: var(--nav-h);
160
+ }
161
+ .tabbar-item {
162
+ position: relative;
163
+ display: flex;
164
+ flex: 1 1 0;
165
+ min-width: 0;
166
+ min-height: max(48px, var(--touch-target));
167
+ flex-direction: column;
168
+ align-items: center;
169
+ justify-content: center;
170
+ gap: 2px;
171
+ padding: var(--sp-1) var(--sp-2);
172
+ border: none;
173
+ border-radius: 0;
174
+ background: none;
175
+ color: var(--text-muted);
176
+ font: inherit;
177
+ font-size: var(--fs-xs);
178
+ font-weight: var(--fw-medium);
179
+ text-decoration: none;
180
+ cursor: pointer;
181
+ -webkit-tap-highlight-color: transparent;
182
+ transition: color 0.12s var(--ease);
183
+ }
184
+ .tabbar-item:hover {
185
+ color: var(--text);
186
+ text-decoration: none;
187
+ }
188
+ .tabbar-item:focus-visible {
189
+ outline: 2px solid var(--accent);
190
+ outline-offset: -2px;
191
+ }
192
+ .tabbar-item.current {
193
+ color: var(--accent);
194
+ }
195
+ .tabbar-glyph {
196
+ position: relative;
197
+ display: inline-flex;
198
+ flex: none;
199
+ align-items: center;
200
+ }
201
+ .tabbar-label {
202
+ max-width: 100%;
203
+ overflow: hidden;
204
+ text-overflow: ellipsis;
205
+ white-space: nowrap;
206
+ }
207
+ .tabbar :global(.tabbar-badge) {
208
+ position: absolute;
209
+ top: -0.4rem;
210
+ left: calc(100% - 0.5rem);
211
+ }
212
+ @media (prefers-reduced-motion: reduce) {
213
+ .tabbar-item {
214
+ transition: none;
215
+ }
216
+ }
217
+ </style>
@@ -0,0 +1,27 @@
1
+ import type { IconName } from '../atoms/Icon.svelte';
2
+ export interface TabBarItem {
3
+ id: string;
4
+ label: string;
5
+ icon?: IconName;
6
+ /** Renders an `<a>`; without it the item is a `<button role="tab">`. */
7
+ href?: string;
8
+ /** Count (capped at `99+`) or short text pinned to the glyph. 0 / '' render nothing. */
9
+ badge?: number | string;
10
+ /** Id of the panel a tab shows (`aria-controls`). */
11
+ controls?: string;
12
+ }
13
+ type $$ComponentProps = {
14
+ items: TabBarItem[];
15
+ /** `id` of the selected item. */
16
+ value?: string;
17
+ onselect?: (id: string, item: TabBarItem) => void;
18
+ /** Accessible name of the bar. */
19
+ label?: string;
20
+ /** 'bar' = fixed to the bottom edge; 'inline' = flows with its parent. */
21
+ placement?: 'bar' | 'inline';
22
+ class?: string;
23
+ [key: string]: unknown;
24
+ };
25
+ declare const TabBar: import("svelte").Component<$$ComponentProps, {}, "value">;
26
+ type TabBar = ReturnType<typeof TabBar>;
27
+ export default TabBar;
@@ -16,6 +16,7 @@
16
16
  consumesArrowKeys,
17
17
  isToolbarNavKey,
18
18
  nextToolbarStop,
19
+ radioGroupPeers,
19
20
  TOOLBAR_STOP_ATTR,
20
21
  toolbarStops
21
22
  } from './toolbar-roving.js';
@@ -93,6 +94,10 @@
93
94
  list.forEach((node, i) => {
94
95
  node.setAttribute(TOOLBAR_STOP_ATTR, '');
95
96
  node.tabIndex = i === index ? 0 : -1;
97
+ for (const peer of radioGroupPeers(node, el)) {
98
+ peer.setAttribute(TOOLBAR_STOP_ATTR, '');
99
+ peer.tabIndex = -1;
100
+ }
96
101
  });
97
102
  }
98
103
 
@@ -38,6 +38,22 @@ export function acceptsRovingStop(el: {
38
38
  * @returns {HTMLElement[]}
39
39
  */
40
40
  export function toolbarStops(container: Element | null, isHidden: (node: HTMLElement) => boolean): HTMLElement[];
41
+ /**
42
+ * A named radio group is a single stop, like a native one: its checked radio,
43
+ * else its first. The arrows inside it stay the browser's.
44
+ *
45
+ * @param {HTMLElement[]} stops
46
+ * @returns {HTMLElement[]}
47
+ */
48
+ export function oneStopPerRadioGroup(stops: HTMLElement[]): HTMLElement[];
49
+ /**
50
+ * The other radios sharing `node`'s group inside `container`.
51
+ *
52
+ * @param {HTMLElement} node
53
+ * @param {Element | null} container
54
+ * @returns {HTMLElement[]}
55
+ */
56
+ export function radioGroupPeers(node: HTMLElement, container: Element | null): HTMLElement[];
41
57
  /**
42
58
  * Arrows wrap; Home/End jump to the ends. `undefined` means "not ours" — the
43
59
  * caller must leave the event alone.
@@ -20,6 +20,7 @@ const ARROW_CONSUMING_INPUTS = new Set([
20
20
  'week',
21
21
  'time',
22
22
  'range',
23
+ 'radio',
23
24
  ]);
24
25
 
25
26
  /** @param {string} key */
@@ -67,7 +68,7 @@ export function acceptsRovingStop(el) {
67
68
  */
68
69
  export function toolbarStops(container, isHidden) {
69
70
  if (!container) return [];
70
- return [
71
+ const stops = [
71
72
  .../** @type {NodeListOf<HTMLElement>} */ (container.querySelectorAll(TOOLBAR_STOP_SELECTOR)),
72
73
  ].filter((node) =>
73
74
  acceptsRovingStop({
@@ -79,6 +80,51 @@ export function toolbarStops(container, isHidden) {
79
80
  claimed: node.hasAttribute(TOOLBAR_STOP_ATTR),
80
81
  }),
81
82
  );
83
+ return oneStopPerRadioGroup(stops);
84
+ }
85
+
86
+ /** @param {HTMLElement} node */
87
+ function radioName(node) {
88
+ const input = /** @type {HTMLInputElement} */ (node);
89
+ return node.tagName === 'INPUT' && input.type === 'radio' && input.name ? input.name : null;
90
+ }
91
+
92
+ /**
93
+ * A named radio group is a single stop, like a native one: its checked radio,
94
+ * else its first. The arrows inside it stay the browser's.
95
+ *
96
+ * @param {HTMLElement[]} stops
97
+ * @returns {HTMLElement[]}
98
+ */
99
+ export function oneStopPerRadioGroup(stops) {
100
+ /** @type {Map<string, HTMLElement>} */
101
+ const picked = new Map();
102
+ for (const node of stops) {
103
+ const name = radioName(node);
104
+ if (name === null) continue;
105
+ const current = /** @type {HTMLInputElement | undefined} */ (picked.get(name));
106
+ if (!current || (!current.checked && /** @type {HTMLInputElement} */ (node).checked))
107
+ picked.set(name, node);
108
+ }
109
+ return stops.filter((node) => {
110
+ const name = radioName(node);
111
+ return name === null || picked.get(name) === node;
112
+ });
113
+ }
114
+
115
+ /**
116
+ * The other radios sharing `node`'s group inside `container`.
117
+ *
118
+ * @param {HTMLElement} node
119
+ * @param {Element | null} container
120
+ * @returns {HTMLElement[]}
121
+ */
122
+ export function radioGroupPeers(node, container) {
123
+ const name = radioName(node);
124
+ if (name === null || !container) return [];
125
+ return [
126
+ .../** @type {NodeListOf<HTMLElement>} */ (container.querySelectorAll('input[type="radio"]')),
127
+ ].filter((peer) => peer !== node && radioName(peer) === name);
82
128
  }
83
129
 
84
130
  /**
@@ -19,6 +19,9 @@
19
19
  char,
20
20
  separator,
21
21
  maxItems = 0,
22
+ size = 'sm',
23
+ wrap = true,
24
+ collapseCurrent = false,
22
25
  class: klass = '',
23
26
  style: styleProp = '',
24
27
  }: {
@@ -29,6 +32,12 @@
29
32
  /** Collapse the middle to an ellipsis when the trail is longer than this
30
33
  * (0 = never collapse). Keeps the first item and the last `maxItems - 1`. */
31
34
  maxItems?: number;
35
+ size?: 'sm' | 'md';
36
+ /** `false` keeps the trail on one line: ancestors keep their width and the
37
+ * current crumb shrinks with an ellipsis. */
38
+ wrap?: boolean;
39
+ /** Hide the current crumb and its separator, keeping only the ancestors. */
40
+ collapseCurrent?: boolean;
32
41
  class?: string;
33
42
  style?: string;
34
43
  } = $props();
@@ -43,7 +52,14 @@
43
52
  </script>
44
53
 
45
54
  <nav aria-label="breadcrumb" data-tsu="Breadcrumb" class={klass} style={styleProp}>
46
- <ol class="crumbs">
55
+ <ol
56
+ class={[
57
+ 'crumbs',
58
+ size === 'md' && 'crumbs--md',
59
+ !wrap && 'crumbs--nowrap',
60
+ collapseCurrent && 'crumbs--collapse',
61
+ ]}
62
+ >
47
63
  {#each shown as item, i (i)}
48
64
  <li class="crumb">
49
65
  {#if 'ellipsis' in item}
@@ -57,7 +73,7 @@
57
73
  <span class="sep" aria-hidden="true">
58
74
  {#if separator}{@render separator()}{:else if char}{char}{:else}<Icon
59
75
  name="chevron-right"
60
- size={14}
76
+ size={size === 'md' ? 16 : 14}
61
77
  />{/if}
62
78
  </span>
63
79
  {/if}
@@ -77,6 +93,26 @@
77
93
  list-style: none;
78
94
  font-size: var(--fs-sm);
79
95
  }
96
+ .crumbs--md {
97
+ font-size: var(--fs-md);
98
+ }
99
+ .crumbs--nowrap {
100
+ flex-wrap: nowrap;
101
+ min-width: 0;
102
+ }
103
+ .crumbs--nowrap .crumb {
104
+ flex-shrink: 0;
105
+ }
106
+ .crumbs--nowrap .crumb:last-child {
107
+ flex-shrink: 1;
108
+ }
109
+ .crumbs--nowrap .current {
110
+ max-width: none;
111
+ }
112
+ .crumbs--collapse .crumb:last-child:not(:first-child),
113
+ .crumbs--collapse .crumb:nth-last-child(2) .sep {
114
+ display: none;
115
+ }
80
116
  .crumb {
81
117
  display: inline-flex;
82
118
  align-items: center;
@@ -104,6 +140,12 @@
104
140
  text-overflow: ellipsis;
105
141
  max-width: 24ch;
106
142
  }
143
+ @media (hover: none) and (pointer: coarse) {
144
+ .link {
145
+ min-height: 40px;
146
+ align-content: center;
147
+ }
148
+ }
107
149
  .ellipsis {
108
150
  color: var(--text-muted);
109
151
  }
@@ -11,6 +11,12 @@ type $$ComponentProps = {
11
11
  /** Collapse the middle to an ellipsis when the trail is longer than this
12
12
  * (0 = never collapse). Keeps the first item and the last `maxItems - 1`. */
13
13
  maxItems?: number;
14
+ size?: 'sm' | 'md';
15
+ /** `false` keeps the trail on one line: ancestors keep their width and the
16
+ * current crumb shrinks with an ellipsis. */
17
+ wrap?: boolean;
18
+ /** Hide the current crumb and its separator, keeping only the ancestors. */
19
+ collapseCurrent?: boolean;
14
20
  class?: string;
15
21
  style?: string;
16
22
  };
@@ -60,6 +60,7 @@
60
60
  size = 'md',
61
61
  shape = 'square',
62
62
  surface = 'base',
63
+ font = 'mono',
63
64
  hotkey,
64
65
  showHotkey = false,
65
66
  grow = false,
@@ -106,6 +107,8 @@
106
107
  shape?: 'square' | 'pill';
107
108
  /** Theme-aware surface shade of the bar (mirrors Card). */
108
109
  surface?: 'base' | 'raised' | 'sunken';
110
+ /** Typeface of the typed text and placeholder; `sans` for plain search boxes. */
111
+ font?: 'mono' | 'sans';
109
112
  /** Key (e.g. `'/'`) that focuses the input when pressed outside an editable
110
113
  * element and without modifiers. */
111
114
  hotkey?: string;
@@ -360,6 +363,7 @@
360
363
  class:surface-raised={surface === 'raised'}
361
364
  class:surface-sunken={surface === 'sunken'}
362
365
  class:fi--grow={grow}
366
+ class:fi--sans={font === 'sans'}
363
367
  data-tsu="FilterInput"
364
368
  >
365
369
  <div class="fi__bar">
@@ -532,6 +536,9 @@
532
536
  font-size: max(16px, var(--fs-sm));
533
537
  }
534
538
  }
539
+ .fi--sans .fi__input {
540
+ font-family: var(--font-sans);
541
+ }
535
542
  .fi__input::placeholder {
536
543
  color: var(--text-faint);
537
544
  }
@@ -54,6 +54,8 @@ type Own = {
54
54
  shape?: 'square' | 'pill';
55
55
  /** Theme-aware surface shade of the bar (mirrors Card). */
56
56
  surface?: 'base' | 'raised' | 'sunken';
57
+ /** Typeface of the typed text and placeholder; `sans` for plain search boxes. */
58
+ font?: 'mono' | 'sans';
57
59
  /** Key (e.g. `'/'`) that focuses the input when pressed outside an editable
58
60
  * element and without modifiers. */
59
61
  hotkey?: string;
@@ -0,0 +1,205 @@
1
+ <script lang="ts">
2
+ // Pull-down-to-refresh scroll container. The pull only starts from
3
+ // `scrollTop === 0` with a touch or pen pointer, the distance is damped so
4
+ // the indicator asymptotically approaches twice the threshold, and a release
5
+ // past the threshold awaits `onrefresh` while a Spinner holds the slot.
6
+ // Mouse and keyboard users refresh through the page's own control.
7
+ import type { Snippet } from 'svelte';
8
+ import Icon from '../atoms/Icon.svelte';
9
+ import Spinner from '../atoms/Spinner.svelte';
10
+
11
+ let {
12
+ children,
13
+ onrefresh,
14
+ threshold = 72,
15
+ label = 'Refreshing…',
16
+ pullLabel = 'Pull to refresh',
17
+ releaseLabel = 'Release to refresh',
18
+ disabled = false,
19
+ class: klass = '',
20
+ ...rest
21
+ }: {
22
+ children?: Snippet;
23
+ /** Awaited after a release past the threshold; the spinner shows until it settles. */
24
+ onrefresh?: () => Promise<void> | void;
25
+ /** Damped pull distance (px) that arms the refresh. */
26
+ threshold?: number;
27
+ /** Announced through the live region while `onrefresh` is pending. */
28
+ label?: string;
29
+ pullLabel?: string;
30
+ releaseLabel?: string;
31
+ disabled?: boolean;
32
+ class?: string;
33
+ [key: string]: unknown;
34
+ } = $props();
35
+
36
+ const SLOP = 6;
37
+
38
+ let scroller: HTMLElement | undefined;
39
+ let offset = $state(0);
40
+ let pending = $state(false);
41
+ let pulling = $state(false);
42
+ let press: { id: number; y: number } | null = null;
43
+
44
+ const ready = $derived(pulling && offset >= threshold);
45
+ const phase = $derived(pending ? 'refreshing' : ready ? 'ready' : pulling ? 'pulling' : 'idle');
46
+ const progress = $derived(Math.min(1, offset / Math.max(1, threshold)));
47
+
48
+ function damp(dy: number) {
49
+ const max = threshold * 2;
50
+ return max * (1 - Math.exp(-dy / max));
51
+ }
52
+
53
+ function onpointerdown(e: PointerEvent) {
54
+ if (disabled || pending || press) return;
55
+ if (e.pointerType !== 'touch' && e.pointerType !== 'pen') return;
56
+ if (!scroller || scroller.scrollTop > 0) return;
57
+ press = { id: e.pointerId, y: e.clientY };
58
+ addEventListener('pointermove', onpointermove);
59
+ addEventListener('pointerup', onpointerup);
60
+ addEventListener('pointercancel', onpointercancel);
61
+ }
62
+
63
+ function onpointermove(e: PointerEvent) {
64
+ if (!press || e.pointerId !== press.id) return;
65
+ const dy = e.clientY - press.y;
66
+ if (!pulling) {
67
+ if (dy < -SLOP || (scroller && scroller.scrollTop > 0)) return release();
68
+ if (dy < SLOP) return;
69
+ pulling = true;
70
+ }
71
+ offset = dy > 0 ? damp(dy) : 0;
72
+ }
73
+
74
+ function onpointerup(e: PointerEvent) {
75
+ if (!press || e.pointerId !== press.id) return;
76
+ const fire = ready;
77
+ release();
78
+ if (fire) void refresh();
79
+ }
80
+
81
+ function onpointercancel(e: PointerEvent) {
82
+ if (!press || e.pointerId !== press.id) return;
83
+ release();
84
+ }
85
+
86
+ function release() {
87
+ press = null;
88
+ pulling = false;
89
+ if (!pending) offset = 0;
90
+ removeEventListener('pointermove', onpointermove);
91
+ removeEventListener('pointerup', onpointerup);
92
+ removeEventListener('pointercancel', onpointercancel);
93
+ }
94
+
95
+ async function refresh() {
96
+ if (pending) return;
97
+ pending = true;
98
+ offset = threshold;
99
+ try {
100
+ await onrefresh?.();
101
+ } finally {
102
+ pending = false;
103
+ offset = 0;
104
+ }
105
+ }
106
+
107
+ function scrollRoot(el: HTMLElement) {
108
+ scroller = el;
109
+ const block = (e: TouchEvent) => {
110
+ if (pulling) e.preventDefault();
111
+ };
112
+ el.addEventListener('touchmove', block, { passive: false });
113
+ return () => {
114
+ el.removeEventListener('touchmove', block);
115
+ release();
116
+ scroller = undefined;
117
+ };
118
+ }
119
+ </script>
120
+
121
+ <div
122
+ data-tsu="PullToRefresh"
123
+ data-state={phase}
124
+ class="ptr {klass}"
125
+ class:pulling
126
+ class:pending
127
+ style:--ptr-offset="{offset}px"
128
+ style:--ptr-progress={progress}
129
+ aria-busy={pending || undefined}
130
+ {onpointerdown}
131
+ {@attach scrollRoot}
132
+ {...rest}
133
+ >
134
+ <div class="ptr-indicator" aria-hidden="true">
135
+ {#if pending}
136
+ <Spinner label={undefined} />
137
+ {:else}
138
+ <Icon name="arrow-down" class="ptr-arrow" />
139
+ {/if}
140
+ </div>
141
+ <div class="ptr-status sr-only" role="status" aria-live="polite">
142
+ {#if pending}{label}{:else if ready}{releaseLabel}{:else if pulling}{pullLabel}{/if}
143
+ </div>
144
+ <div class="ptr-content">
145
+ {@render children?.()}
146
+ </div>
147
+ </div>
148
+
149
+ <style>
150
+ .ptr {
151
+ position: relative;
152
+ overflow-y: auto;
153
+ overscroll-behavior-y: contain;
154
+ -webkit-overflow-scrolling: touch;
155
+ touch-action: pan-y;
156
+ }
157
+ .ptr-content {
158
+ translate: 0 var(--ptr-offset, 0px);
159
+ transition: translate 200ms var(--ease);
160
+ }
161
+ .pulling .ptr-content {
162
+ transition: none;
163
+ }
164
+ .ptr-indicator {
165
+ position: absolute;
166
+ inset: 0 0 auto;
167
+ display: flex;
168
+ align-items: center;
169
+ justify-content: center;
170
+ height: var(--ptr-offset, 0px);
171
+ overflow: hidden;
172
+ color: var(--text-muted);
173
+ opacity: var(--ptr-progress, 0);
174
+ transition: height 200ms var(--ease);
175
+ pointer-events: none;
176
+ }
177
+ .pulling .ptr-indicator {
178
+ transition: none;
179
+ }
180
+ .ptr-indicator :global(.ptr-arrow) {
181
+ rotate: calc(180deg * var(--ptr-progress, 0));
182
+ transition: rotate 150ms var(--ease);
183
+ }
184
+ .pending .ptr-indicator {
185
+ color: var(--accent);
186
+ }
187
+ .sr-only {
188
+ position: absolute;
189
+ width: 1px;
190
+ height: 1px;
191
+ overflow: hidden;
192
+ clip: rect(0 0 0 0);
193
+ white-space: nowrap;
194
+ }
195
+ @media (prefers-reduced-motion: reduce) {
196
+ .ptr-content,
197
+ .ptr-indicator,
198
+ .ptr-indicator :global(.ptr-arrow) {
199
+ transition: none;
200
+ }
201
+ .ptr-indicator :global(.ptr-arrow) {
202
+ rotate: none;
203
+ }
204
+ }
205
+ </style>