@dorsk/tsumikit 0.23.0 → 0.25.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 (35) hide show
  1. package/README.md +174 -6
  2. package/dist/components/atoms/Button.svelte +1 -1
  3. package/dist/components/atoms/Card.svelte +65 -2
  4. package/dist/components/atoms/Card.svelte.d.ts +6 -0
  5. package/dist/components/atoms/Scrim.svelte +81 -0
  6. package/dist/components/atoms/Scrim.svelte.d.ts +14 -0
  7. package/dist/components/layouts/ResizablePanel.svelte +238 -120
  8. package/dist/components/layouts/ResizablePanel.svelte.d.ts +24 -8
  9. package/dist/components/layouts/resizable-panel-frame.d.ts +70 -0
  10. package/dist/components/layouts/resizable-panel-frame.js +161 -0
  11. package/dist/components/molecules/ConfirmModal.svelte +102 -0
  12. package/dist/components/molecules/ConfirmModal.svelte.d.ts +16 -0
  13. package/dist/components/molecules/KeyValue.svelte +117 -0
  14. package/dist/components/molecules/KeyValue.svelte.d.ts +20 -0
  15. package/dist/components/molecules/LoadMore.svelte +78 -0
  16. package/dist/components/molecules/LoadMore.svelte.d.ts +15 -0
  17. package/dist/components/molecules/Modal.svelte +66 -10
  18. package/dist/components/molecules/Modal.svelte.d.ts +12 -2
  19. package/dist/components/molecules/Pagination.svelte +209 -0
  20. package/dist/components/molecules/Pagination.svelte.d.ts +22 -0
  21. package/dist/components/molecules/SectionHeader.svelte +233 -0
  22. package/dist/components/molecules/SectionHeader.svelte.d.ts +29 -0
  23. package/dist/components/molecules/ThemePicker.svelte +11 -13
  24. package/dist/index.d.ts +7 -0
  25. package/dist/index.js +7 -0
  26. package/dist/stores/theme.svelte.d.ts +21 -5
  27. package/dist/stores/theme.svelte.js +48 -22
  28. package/dist/styles/app.css +7 -297
  29. package/dist/styles/reset.css +97 -0
  30. package/dist/styles/syntax.css +94 -0
  31. package/dist/styles/themes.css +729 -0
  32. package/dist/styles/tokens.css +207 -0
  33. package/dist/styles/utilities.css +111 -0
  34. package/dist/styles/variables.css +5 -926
  35. package/package.json +7 -2
@@ -61,3 +61,164 @@ export function createFrameBatcher(requestFrame, cancelFrame, apply) {
61
61
  },
62
62
  };
63
63
  }
64
+
65
+ /**
66
+ * Resolve a width prop that is either a pixel number or a CSS length.
67
+ * Plain `px` strings parse directly; anything else is handed to `measure`,
68
+ * which lays the length out and returns its pixel size (or `undefined` when
69
+ * there is no DOM to measure in).
70
+ *
71
+ * @param {number | string} value
72
+ * @param {(css: string) => number | undefined} measure
73
+ * @returns {number | undefined}
74
+ */
75
+ export function resolveLength(value, measure) {
76
+ if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
77
+ const px = /^\s*(-?\d*\.?\d+)px\s*$/.exec(value);
78
+ if (px) return Number(px[1]);
79
+ const measured = measure(value);
80
+ return measured !== undefined && Number.isFinite(measured) ? measured : undefined;
81
+ }
82
+
83
+ /**
84
+ * @typedef {object} ResizeHandleParams
85
+ * @property {'left' | 'right'} side Edge the resized box sits on; dragging away from it grows the box.
86
+ * @property {(width: number) => void} onwidth Called once per animation frame while dragging and on every keyboard step.
87
+ * @property {(width: number) => void} [oncommit] Called with the settled width on pointer release and after each keyboard step.
88
+ * @property {() => void} [onreset] Double-click on the handle.
89
+ * @property {(active: boolean) => void} [onactive] Drag start/end, for a `resizing` class.
90
+ * @property {() => number} [measure] Current width in px; defaults to the handle's parent box.
91
+ * @property {number} [min]
92
+ * @property {number} [max]
93
+ * @property {number} [step] Pixels per arrow key press (default 16).
94
+ */
95
+
96
+ /**
97
+ * Svelte action turning any element into a pointer + keyboard width grip.
98
+ * One pointer-capture / rAF-coalesced implementation shared by
99
+ * ResizablePanel and consumer-built grips.
100
+ *
101
+ * Usage: <div role="separator" tabindex="0" use:resizeHandle={{ side, min, max, onwidth }}></div>
102
+ *
103
+ * @param {HTMLElement} node
104
+ * @param {ResizeHandleParams} params
105
+ */
106
+ export function resizeHandle(node, params) {
107
+ let current = params;
108
+ let active = false;
109
+ let startX = 0;
110
+ let startWidth = 0;
111
+
112
+ const frames = createFrameBatcher(
113
+ (callback) => requestAnimationFrame(callback),
114
+ (handle) => cancelAnimationFrame(handle),
115
+ /** @param {number} width */
116
+ (width) => current.onwidth(width),
117
+ );
118
+
119
+ /** @param {number} width */
120
+ function clamp(width) {
121
+ const min = current.min ?? 1;
122
+ const max = current.max ?? Number.POSITIVE_INFINITY;
123
+ return Math.round(Math.max(min, Math.min(width, Math.max(min, max))));
124
+ }
125
+
126
+ function direction() {
127
+ return current.side === 'right' ? -1 : 1;
128
+ }
129
+
130
+ function measure() {
131
+ if (current.measure) return current.measure();
132
+ return node.parentElement?.getBoundingClientRect().width ?? 0;
133
+ }
134
+
135
+ /** @param {number} clientX */
136
+ function widthAt(clientX) {
137
+ return clamp(startWidth + (clientX - startX) * direction());
138
+ }
139
+
140
+ /** @param {PointerEvent} event */
141
+ function down(event) {
142
+ if (event.button !== 0) return;
143
+ active = true;
144
+ startX = event.clientX;
145
+ startWidth = measure();
146
+ node.setPointerCapture(event.pointerId);
147
+ event.preventDefault();
148
+ current.onactive?.(true);
149
+ }
150
+
151
+ /** @param {PointerEvent} event */
152
+ function move(event) {
153
+ if (!active) return;
154
+ frames.schedule(widthAt(event.clientX));
155
+ }
156
+
157
+ /** @param {PointerEvent} event */
158
+ function up(event) {
159
+ if (!active) return;
160
+ active = false;
161
+ const width = widthAt(event.clientX);
162
+ frames.flush(width);
163
+ try {
164
+ node.releasePointerCapture(event.pointerId);
165
+ } catch {
166
+ // Pointer capture may already have been released by the browser.
167
+ }
168
+ current.onactive?.(false);
169
+ current.oncommit?.(width);
170
+ }
171
+
172
+ /** @param {KeyboardEvent} event */
173
+ function keydown(event) {
174
+ const step = current.step ?? 16;
175
+ /** @type {number | undefined} */
176
+ let next;
177
+ switch (event.key) {
178
+ case 'ArrowLeft':
179
+ next = measure() - step * direction();
180
+ break;
181
+ case 'ArrowRight':
182
+ next = measure() + step * direction();
183
+ break;
184
+ case 'Home':
185
+ next = current.min;
186
+ break;
187
+ case 'End':
188
+ next = current.max;
189
+ break;
190
+ }
191
+ if (next === undefined) return;
192
+ event.preventDefault();
193
+ const width = clamp(next);
194
+ current.onwidth(width);
195
+ current.oncommit?.(width);
196
+ }
197
+
198
+ function reset() {
199
+ current.onreset?.();
200
+ }
201
+
202
+ node.addEventListener('pointerdown', down);
203
+ node.addEventListener('pointermove', move);
204
+ node.addEventListener('pointerup', up);
205
+ node.addEventListener('pointercancel', up);
206
+ node.addEventListener('keydown', keydown);
207
+ node.addEventListener('dblclick', reset);
208
+
209
+ return {
210
+ /** @param {ResizeHandleParams} next */
211
+ update(next) {
212
+ current = next;
213
+ },
214
+ destroy() {
215
+ node.removeEventListener('pointerdown', down);
216
+ node.removeEventListener('pointermove', move);
217
+ node.removeEventListener('pointerup', up);
218
+ node.removeEventListener('pointercancel', up);
219
+ node.removeEventListener('keydown', keydown);
220
+ node.removeEventListener('dblclick', reset);
221
+ frames.discard();
222
+ },
223
+ };
224
+ }
@@ -0,0 +1,102 @@
1
+ <script lang="ts">
2
+ // Yes/no dialog on top of Modal. An async `onconfirm` keeps the dialog open
3
+ // and busy until it settles; a rejection surfaces its message and leaves the
4
+ // dialog open so the user can retry or cancel.
5
+ import type { Snippet } from 'svelte';
6
+ import Button from '../atoms/Button.svelte';
7
+ import Text from '../atoms/Text.svelte';
8
+ import Modal from './Modal.svelte';
9
+
10
+ let {
11
+ open = $bindable(false),
12
+ title,
13
+ message,
14
+ children,
15
+ confirmLabel = 'Confirm',
16
+ cancelLabel = 'Cancel',
17
+ tone = 'primary',
18
+ busy = false,
19
+ onconfirm,
20
+ oncancel
21
+ }: {
22
+ open?: boolean;
23
+ title: string;
24
+ message?: string;
25
+ children?: Snippet;
26
+ confirmLabel?: string;
27
+ cancelLabel?: string;
28
+ tone?: 'primary' | 'danger' | 'warn';
29
+ busy?: boolean;
30
+ onconfirm: () => void | Promise<void>;
31
+ oncancel?: () => void;
32
+ } = $props();
33
+
34
+ let pending = $state(false);
35
+ let error = $state<string | null>(null);
36
+ const working = $derived(busy || pending);
37
+
38
+ $effect(() => {
39
+ if (!open) error = null;
40
+ });
41
+
42
+ async function confirm() {
43
+ if (working) return;
44
+ error = null;
45
+ try {
46
+ const result = onconfirm();
47
+ if (result instanceof Promise) {
48
+ pending = true;
49
+ await result;
50
+ }
51
+ open = false;
52
+ } catch (e) {
53
+ error = e instanceof Error ? e.message : String(e);
54
+ } finally {
55
+ pending = false;
56
+ }
57
+ }
58
+
59
+ function cancel() {
60
+ if (working) return;
61
+ open = false;
62
+ oncancel?.();
63
+ }
64
+ </script>
65
+
66
+ <Modal
67
+ bind:open
68
+ {title}
69
+ tone={tone === 'primary' ? 'neutral' : tone}
70
+ busy={working}
71
+ onclose={oncancel}
72
+ size="sm"
73
+ >
74
+ {#snippet body()}
75
+ <div class="confirm-body" data-tsu="ConfirmModal">
76
+ {#if message}<Text variant="body">{message}</Text>{/if}
77
+ {@render children?.()}
78
+ {#if error}
79
+ <Text variant="caption" tone="danger" role="alert" class="confirm-error">{error}</Text>
80
+ {/if}
81
+ </div>
82
+ {/snippet}
83
+ {#snippet footer()}
84
+ <Button onclick={cancel} disabled={working}>{cancelLabel}</Button>
85
+ <Button
86
+ variant={tone === 'danger' ? 'danger' : 'primary'}
87
+ tone={tone === 'warn' ? 'warn' : 'none'}
88
+ loading={working}
89
+ onclick={confirm}
90
+ >
91
+ {confirmLabel}
92
+ </Button>
93
+ {/snippet}
94
+ </Modal>
95
+
96
+ <style>
97
+ .confirm-body {
98
+ display: flex;
99
+ flex-direction: column;
100
+ gap: var(--sp-3);
101
+ }
102
+ </style>
@@ -0,0 +1,16 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ open?: boolean;
4
+ title: string;
5
+ message?: string;
6
+ children?: Snippet;
7
+ confirmLabel?: string;
8
+ cancelLabel?: string;
9
+ tone?: 'primary' | 'danger' | 'warn';
10
+ busy?: boolean;
11
+ onconfirm: () => void | Promise<void>;
12
+ oncancel?: () => void;
13
+ };
14
+ declare const ConfirmModal: import("svelte").Component<$$ComponentProps, {}, "open">;
15
+ type ConfirmModal = ReturnType<typeof ConfirmModal>;
16
+ export default ConfirmModal;
@@ -0,0 +1,117 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+
4
+ export type KeyValueTone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
5
+
6
+ export interface KeyValueRow {
7
+ label: string;
8
+ value: string | number | Snippet;
9
+ mono?: boolean;
10
+ tone?: KeyValueTone;
11
+ hint?: string;
12
+ }
13
+ </script>
14
+
15
+ <script lang="ts">
16
+ // Label/value grid as a semantic <dl>: one or two label+value column pairs,
17
+ // values optionally mono / toned, with a faint hint line under the value.
18
+ import Text from '../atoms/Text.svelte';
19
+
20
+ const TEXT_TONE = {
21
+ neutral: 'default',
22
+ ok: 'success',
23
+ warn: 'warn',
24
+ danger: 'danger',
25
+ info: 'accent',
26
+ } as const;
27
+
28
+ let {
29
+ rows,
30
+ columns = 1,
31
+ dense = false,
32
+ align = 'start',
33
+ class: klass = '',
34
+ ...rest
35
+ }: {
36
+ rows: KeyValueRow[];
37
+ columns?: 1 | 2;
38
+ dense?: boolean;
39
+ align?: 'start' | 'end';
40
+ class?: string;
41
+ [key: string]: unknown;
42
+ } = $props();
43
+ </script>
44
+
45
+ <dl
46
+ data-tsu="KeyValue"
47
+ class="kv {klass}"
48
+ class:kv-cols-2={columns === 2}
49
+ class:kv-dense={dense}
50
+ class:kv-align-end={align === 'end'}
51
+ {...rest}
52
+ >
53
+ {#each rows as row, i (i)}
54
+ <div class="kv-row">
55
+ <dt class="kv-label">{row.label}</dt>
56
+ <dd class="kv-value">
57
+ {#if typeof row.value === 'function'}
58
+ {@render row.value()}
59
+ {:else}
60
+ <Text
61
+ tone={TEXT_TONE[row.tone ?? 'neutral']}
62
+ variant={row.mono ? 'code' : undefined}
63
+ numeric={typeof row.value === 'number'}
64
+ >
65
+ {row.value}
66
+ </Text>
67
+ {/if}
68
+ {#if row.hint}
69
+ <Text as="div" variant="caption" class="kv-hint">{row.hint}</Text>
70
+ {/if}
71
+ </dd>
72
+ </div>
73
+ {/each}
74
+ </dl>
75
+
76
+ <style>
77
+ .kv {
78
+ display: grid;
79
+ grid-template-columns: max-content minmax(0, 1fr);
80
+ column-gap: var(--sp-4);
81
+ row-gap: var(--sp-2);
82
+ margin: 0;
83
+ font-size: var(--fs-sm);
84
+ }
85
+ .kv-cols-2 {
86
+ grid-template-columns: repeat(2, max-content minmax(0, 1fr));
87
+ }
88
+ .kv-dense {
89
+ row-gap: var(--sp-1);
90
+ column-gap: var(--sp-3);
91
+ font-size: var(--fs-xs);
92
+ }
93
+ .kv-row {
94
+ display: contents;
95
+ }
96
+ .kv-label {
97
+ color: var(--text-muted);
98
+ font-weight: var(--fw-medium);
99
+ }
100
+ .kv-value {
101
+ margin: 0;
102
+ min-width: 0;
103
+ color: var(--text);
104
+ overflow-wrap: anywhere;
105
+ }
106
+ .kv-align-end .kv-value {
107
+ text-align: end;
108
+ }
109
+ .kv-value :global(.kv-hint) {
110
+ margin-top: var(--sp-1);
111
+ }
112
+ @container (max-width: 30rem) {
113
+ .kv-cols-2 {
114
+ grid-template-columns: max-content minmax(0, 1fr);
115
+ }
116
+ }
117
+ </style>
@@ -0,0 +1,20 @@
1
+ import type { Snippet } from 'svelte';
2
+ export type KeyValueTone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
3
+ export interface KeyValueRow {
4
+ label: string;
5
+ value: string | number | Snippet;
6
+ mono?: boolean;
7
+ tone?: KeyValueTone;
8
+ hint?: string;
9
+ }
10
+ type $$ComponentProps = {
11
+ rows: KeyValueRow[];
12
+ columns?: 1 | 2;
13
+ dense?: boolean;
14
+ align?: 'start' | 'end';
15
+ class?: string;
16
+ [key: string]: unknown;
17
+ };
18
+ declare const KeyValue: import("svelte").Component<$$ComponentProps, {}, "">;
19
+ type KeyValue = ReturnType<typeof KeyValue>;
20
+ export default KeyValue;
@@ -0,0 +1,78 @@
1
+ <script lang="ts">
2
+ // Tri-state "load more" footer for paginated lists: idle button, loading
3
+ // button (spinner, blocks re-entry), error line with retry, or a faint
4
+ // "done" note. `pill` renders the compact centered chip used to load older
5
+ // items above a feed.
6
+ import Button from '../atoms/Button.svelte';
7
+ import Text from '../atoms/Text.svelte';
8
+
9
+ let {
10
+ state = 'idle',
11
+ onload,
12
+ label = 'Load more',
13
+ loadingLabel = 'Loading…',
14
+ errorLabel = 'Failed to load',
15
+ retryLabel = 'Retry',
16
+ doneLabel = 'No more items',
17
+ pill = false,
18
+ class: klass = '',
19
+ ...rest
20
+ }: {
21
+ state?: 'idle' | 'loading' | 'error' | 'done';
22
+ onload?: () => void;
23
+ label?: string;
24
+ loadingLabel?: string;
25
+ errorLabel?: string;
26
+ retryLabel?: string;
27
+ doneLabel?: string;
28
+ pill?: boolean;
29
+ class?: string;
30
+ [key: string]: unknown;
31
+ } = $props();
32
+ </script>
33
+
34
+ <div
35
+ data-tsu="LoadMore"
36
+ data-state={state}
37
+ class="load-more {klass}"
38
+ class:load-more-pill={pill}
39
+ role="status"
40
+ aria-busy={state === 'loading' || undefined}
41
+ {...rest}
42
+ >
43
+ {#if state === 'done'}
44
+ <Text variant="caption" class="load-more-done">{doneLabel}</Text>
45
+ {:else if state === 'error'}
46
+ <Text tone="danger" size="sm" class="load-more-error">{errorLabel}</Text>
47
+ <Button size="sm" onclick={() => onload?.()}>{retryLabel}</Button>
48
+ {:else}
49
+ <Button
50
+ size="sm"
51
+ variant={pill ? 'default' : 'ghost'}
52
+ loading={state === 'loading'}
53
+ class={pill ? 'load-more-chip' : ''}
54
+ onclick={() => onload?.()}
55
+ >
56
+ {state === 'loading' ? loadingLabel : label}
57
+ </Button>
58
+ {/if}
59
+ </div>
60
+
61
+ <style>
62
+ .load-more {
63
+ display: flex;
64
+ flex-wrap: wrap;
65
+ align-items: center;
66
+ justify-content: center;
67
+ gap: var(--sp-2);
68
+ padding: var(--sp-2);
69
+ min-height: var(--sp-8);
70
+ }
71
+ .load-more-pill {
72
+ padding: var(--sp-1);
73
+ }
74
+ .load-more :global(.load-more-chip) {
75
+ border-radius: var(--r-pill);
76
+ font-size: var(--fs-xs);
77
+ }
78
+ </style>
@@ -0,0 +1,15 @@
1
+ type $$ComponentProps = {
2
+ state?: 'idle' | 'loading' | 'error' | 'done';
3
+ onload?: () => void;
4
+ label?: string;
5
+ loadingLabel?: string;
6
+ errorLabel?: string;
7
+ retryLabel?: string;
8
+ doneLabel?: string;
9
+ pill?: boolean;
10
+ class?: string;
11
+ [key: string]: unknown;
12
+ };
13
+ declare const LoadMore: import("svelte").Component<$$ComponentProps, {}, "">;
14
+ type LoadMore = ReturnType<typeof LoadMore>;
15
+ export default LoadMore;
@@ -3,24 +3,46 @@
3
3
  // the platform gives us the hard parts for free: top-layer rendering above
4
4
  // everything (no z-index races), a real focus trap, inert background, initial
5
5
  // focus, focus restoration to the trigger, Escape-to-close and a styleable
6
- // ::backdrop. We only add: open-on-mount, click-outside, and optional
7
- // desktop resize (width persisted under `resizeKey`).
6
+ // ::backdrop. We only add: open-on-mount (or `open`-driven), click-outside,
7
+ // tone/busy chrome, and optional desktop resize (width persisted under
8
+ // `resizeKey`).
8
9
  import type { Snippet } from 'svelte';
9
10
  import { browser } from '../../env';
11
+ import Icon, { type IconName } from '../atoms/Icon.svelte';
12
+ import Spinner from '../atoms/Spinner.svelte';
10
13
  import IconButton from './IconButton.svelte';
11
14
 
15
+ type Tone = 'neutral' | 'danger' | 'warn' | 'info';
16
+ const TONE_ICON: Record<Exclude<Tone, 'neutral'>, IconName> = {
17
+ danger: 'warning',
18
+ warn: 'warning',
19
+ info: 'info'
20
+ };
21
+
12
22
  let {
13
23
  title,
24
+ open = $bindable(),
14
25
  onclose,
15
26
  body,
16
27
  footer,
17
28
  size = 'md',
29
+ tone = 'neutral',
30
+ busy = false,
18
31
  resizeKey
19
32
  }: {
20
33
  title: string;
21
- onclose: () => void;
34
+ /** Controlled visibility. When provided the `<dialog>` stays mounted and
35
+ * `showModal()`/`close()` follow the value; closing sets it back to false.
36
+ * Omit to open on mount (mount/unmount the component to show/hide). */
37
+ open?: boolean;
38
+ onclose?: () => void;
22
39
  body: Snippet;
23
40
  footer?: Snippet;
41
+ /** Title glyph + 3px top border in the semantic colour. */
42
+ tone?: Tone;
43
+ /** Work in flight: body is inert, a spinner sits by the title and Escape,
44
+ * backdrop and the close button stop closing until it clears. */
45
+ busy?: boolean;
24
46
  /** Desktop width preset (sm 24rem / md 34rem / lg 48rem / xl 72rem). A
25
47
  * `resizeKey` drag still overrides it. */
26
48
  size?: 'sm' | 'md' | 'lg' | 'xl';
@@ -42,16 +64,30 @@
42
64
  let width = $state<number | null>(loadWidth());
43
65
  let dialogEl = $state<HTMLDialogElement | null>(null);
44
66
 
67
+ const controlled = $derived(open !== undefined);
68
+
69
+ // Open as a modal (top layer + trap + inert background + focus mgmt). The
70
+ // native element restores focus to the trigger automatically on close.
45
71
  $effect(() => {
46
- // Open as a modal (top layer + trap + inert background + focus mgmt). The
47
- // native element restores focus to the trigger automatically on close.
48
- dialogEl?.showModal();
72
+ if (!dialogEl) return;
73
+ if (!controlled) {
74
+ dialogEl.showModal();
75
+ return;
76
+ }
77
+ if (open && !dialogEl.open) dialogEl.showModal();
78
+ else if (!open && dialogEl.open) dialogEl.close();
49
79
  });
50
80
 
81
+ function requestClose() {
82
+ if (busy) return;
83
+ if (controlled) open = false;
84
+ onclose?.();
85
+ }
86
+
51
87
  // Click outside: <dialog fills the viewport; clicks on its padding-free self
52
88
  // (not the inner .sheet) are backdrop clicks.
53
89
  function onDialogClick(e: MouseEvent) {
54
- if (e.target === dialogEl) onclose();
90
+ if (e.target === dialogEl) requestClose();
55
91
  }
56
92
 
57
93
  // --- resize ---
@@ -102,10 +138,15 @@
102
138
  class="modal"
103
139
  class:resizing
104
140
  aria-labelledby={titleId}
141
+ aria-busy={busy || undefined}
142
+ data-tone={tone === 'neutral' ? undefined : tone}
105
143
  style={width != null ? `--sheet-w: ${width}px` : undefined}
106
144
  oncancel={(e) => {
107
145
  e.preventDefault(); /* keep parent the source of truth for open state */
108
- onclose();
146
+ requestClose();
147
+ }}
148
+ onclose={() => {
149
+ if (controlled) open = false;
109
150
  }}
110
151
  onclick={onDialogClick}
111
152
  >
@@ -114,13 +155,19 @@
114
155
  class:sheet-sm={size === 'sm'}
115
156
  class:sheet-lg={size === 'lg'}
116
157
  class:sheet-xl={size === 'xl'}
158
+ class:sheet-toned={tone !== 'neutral'}
159
+ style:--modal-tone={tone === 'neutral' ? undefined : `var(--${tone})`}
117
160
  >
118
161
  <div class="sheet-head">
162
+ {#if tone !== 'neutral'}
163
+ <span class="sheet-tone-icon"><Icon name={TONE_ICON[tone]} size={18} /></span>
164
+ {/if}
119
165
  <span id={titleId} class="sheet-title truncate">{title}</span>
166
+ {#if busy}<Spinner label="Working" />{/if}
120
167
  <div class="spacer"></div>
121
- <IconButton icon="x" label="Close dialog" onclick={onclose} />
168
+ <IconButton icon="x" label="Close dialog" disabled={busy} onclick={requestClose} />
122
169
  </div>
123
- <div class="sheet-body">
170
+ <div class="sheet-body" inert={busy}>
124
171
  {@render body()}
125
172
  </div>
126
173
  {#if footer}
@@ -231,6 +278,14 @@
231
278
  padding: var(--sp-4);
232
279
  border-bottom: 1px solid var(--border);
233
280
  }
281
+ .sheet-toned {
282
+ border-top: 3px solid var(--modal-tone);
283
+ }
284
+ .sheet-tone-icon {
285
+ display: inline-flex;
286
+ flex: none;
287
+ color: var(--modal-tone);
288
+ }
234
289
  .sheet-title {
235
290
  font-size: var(--fs-lg);
236
291
  font-weight: var(--fw-semibold);
@@ -242,6 +297,7 @@
242
297
  }
243
298
  .sheet-foot {
244
299
  display: flex;
300
+ justify-content: flex-end;
245
301
  gap: var(--sp-2);
246
302
  padding: var(--sp-4);
247
303
  border-top: 1px solid var(--border);
@@ -1,9 +1,19 @@
1
1
  import type { Snippet } from 'svelte';
2
+ type Tone = 'neutral' | 'danger' | 'warn' | 'info';
2
3
  type $$ComponentProps = {
3
4
  title: string;
4
- onclose: () => void;
5
+ /** Controlled visibility. When provided the `<dialog>` stays mounted and
6
+ * `showModal()`/`close()` follow the value; closing sets it back to false.
7
+ * Omit to open on mount (mount/unmount the component to show/hide). */
8
+ open?: boolean;
9
+ onclose?: () => void;
5
10
  body: Snippet;
6
11
  footer?: Snippet;
12
+ /** Title glyph + 3px top border in the semantic colour. */
13
+ tone?: Tone;
14
+ /** Work in flight: body is inert, a spinner sits by the title and Escape,
15
+ * backdrop and the close button stop closing until it clears. */
16
+ busy?: boolean;
7
17
  /** Desktop width preset (sm 24rem / md 34rem / lg 48rem / xl 72rem). A
8
18
  * `resizeKey` drag still overrides it. */
9
19
  size?: 'sm' | 'md' | 'lg' | 'xl';
@@ -11,6 +21,6 @@ type $$ComponentProps = {
11
21
  * width persists under this localStorage key. */
12
22
  resizeKey?: string;
13
23
  };
14
- declare const Modal: import("svelte").Component<$$ComponentProps, {}, "">;
24
+ declare const Modal: import("svelte").Component<$$ComponentProps, {}, "open">;
15
25
  type Modal = ReturnType<typeof Modal>;
16
26
  export default Modal;