@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,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
  /**
@@ -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>
@@ -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;