@dorsk/tsumikit 0.24.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.
package/README.md CHANGED
@@ -154,7 +154,8 @@ any custom SVG).
154
154
 
155
155
  **Molecules:** Field, IconButton, SelectButton, Toggle, OptionButton, Modal,
156
156
  Popover, Menu, Tabs, RadioGroup, Tooltip, Accordion, CopyButton, FileButton,
157
- Dropzone, CodeBlock, Callout, EmptyState, Toaster, ThemePicker, FontScalePicker.
157
+ Dropzone, CodeBlock, Callout, EmptyState, ConfirmModal, Pagination, Toaster,
158
+ ThemePicker, FontScalePicker, SectionHeader, KeyValue, LoadMore.
158
159
 
159
160
  **Organisms:** DataTable (generic `<T>`, typed columns + cell snippets;
160
161
  `layout="fixed"` makes column widths authoritative, `Column.truncate` /
@@ -255,6 +256,71 @@ is a live region — `role="status"`, or `role="alert"` when `tone="danger"`.
255
256
  </Callout>
256
257
  ```
257
258
 
259
+ ### Modal, ConfirmModal & Pagination
260
+
261
+ `Modal` opens on mount by default; pass `bind:open` instead to keep it mounted
262
+ and drive `showModal()`/`close()` from state (closing writes `open = false`).
263
+ `tone="danger" | "warn" | "info"` adds a title glyph and a 3px top border;
264
+ `busy` makes the body inert, shows a spinner by the title and disables Escape,
265
+ backdrop and the close button. The `footer` snippet is a right-aligned flex row
266
+ (`justify-content: flex-end; gap: var(--sp-2)`).
267
+
268
+ `ConfirmModal` wraps it as a yes/no dialog: `title`, `message` (or `children`),
269
+ `confirmLabel`/`cancelLabel`, `tone="primary" | "danger" | "warn"`, `busy`,
270
+ `onconfirm`, `oncancel`. An async `onconfirm` keeps the dialog busy until it
271
+ settles, closes only on success and shows a rejection's message (`role="alert"`)
272
+ under the body. `Button` accepts `tone="danger"` as an alias of
273
+ `variant="danger"` so the tone axis works alone.
274
+
275
+ ```svelte
276
+ <ConfirmModal bind:open title="Delete library?" tone="danger" confirmLabel="Delete"
277
+ onconfirm={() => api.deleteLibrary(id)} />
278
+ ```
279
+
280
+ `Pagination` renders `<nav aria-label>` with prev/next IconButtons, numbered
281
+ pages (`aria-current="page"`), ellipses and an optional `showRange` readout.
282
+ Page mode: `bind:page` + `pageCount`. Offset mode: `bind:offset` + `limit` +
283
+ `total` (page and count are derived, `offset` is written back). `onchange(page)`,
284
+ `siblings` (1), `showEdges` (true), `size="sm" | "md"`, `label`. Under 24rem of
285
+ container width it collapses to prev / "3 / 12" / next.
286
+
287
+ ```svelte
288
+ <Pagination bind:offset limit={20} total={412} showRange onchange={load} />
289
+ ```
290
+
291
+ ### SectionHeader, Card header/footer, KeyValue, LoadMore
292
+
293
+ `SectionHeader` is the one "title + meta + right-aligned actions" row:
294
+ `title` (or `label`), `level` 1–4 (default 2), `size`, `subtitle`, `icon`,
295
+ `count` (faint tabular figure after the title), `tone`, `hue` (0–360 swatch
296
+ chip), `uppercase` (eyebrow group label), `divider`, `sticky` (pins at
297
+ `--sticky-offset` / `--header-h` and publishes `--section-header-h` on its
298
+ parent), `collapsible` + `bind:open` (title becomes a disclosure button with
299
+ `aria-expanded`; `children` render beneath while open), `actions` snippet.
300
+
301
+ `Card` gains `header` / `footer` snippets rendered outside the padded body
302
+ behind a divider, `title` / `subtitle` / `actions` sugar that renders a
303
+ `SectionHeader` in the header slot, and `gap` to stack children as a flex
304
+ column. A Card without any of these renders exactly as before.
305
+
306
+ `KeyValue` renders `rows: { label, value: string | number | Snippet, mono?,
307
+ tone?, hint? }[]` as a semantic `<dl>` grid; `columns` 1 | 2, `dense`, `align`
308
+ start | end. `LoadMore` is the tri-state list footer: `state` idle | loading |
309
+ error | done, `onload`, `label`, `loadingLabel`, `errorLabel`, `retryLabel`,
310
+ `doneLabel`, `pill` for the compact "load older" chip.
311
+
312
+ ```svelte
313
+ <Card title="Recent sessions" subtitle="last 24h">
314
+ {#snippet actions()}<Link href="/sessions">View all</Link>{/snippet}
315
+ <KeyValue rows={[{ label: 'Running', value: 3, tone: 'ok' }, { label: 'Host', value: 'sakura', mono: true }]} />
316
+ {#snippet footer()}<LoadMore state={more} onload={loadMore} />{/snippet}
317
+ </Card>
318
+
319
+ <SectionHeader label="Blocked" count={4} uppercase hue={12} collapsible bind:open>
320
+ …group rows…
321
+ </SectionHeader>
322
+ ```
323
+
258
324
  ## Container queries
259
325
 
260
326
  AppShell's `main` and `sidebar` are query containers (`container-name: main` /
@@ -112,7 +112,7 @@
112
112
  class:btn-grow={grow}
113
113
  class:btn-primary={variant === 'primary'}
114
114
  class:btn-ghost={variant === 'ghost'}
115
- class:btn-danger={variant === 'danger'}
115
+ class:btn-danger={variant === 'danger' || (tone === 'danger' && variant === 'default')}
116
116
  class:btn-sm={size === 'sm'}
117
117
  class:btn-lg={size === 'lg'}
118
118
  class:btn-control={control}
@@ -13,7 +13,12 @@
13
13
  // keeps them on the plain border colour. `stackY`/`stackX` set the per-layer
14
14
  // vertical / horizontal offset in px (vertical spacing stays even across the
15
15
  // 3 borders); horizontal defaults to a tiny 2px peek.
16
+ //
17
+ // `header`/`footer` snippets (or the `title`/`subtitle`/`actions` sugar,
18
+ // which renders a SectionHeader) sit outside the padded body behind a
19
+ // divider; `gap` stacks children as a flex column without a wrapper.
16
20
  import type { Snippet } from 'svelte';
21
+ import SectionHeader from '../molecules/SectionHeader.svelte';
17
22
 
18
23
  type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
19
24
 
@@ -27,8 +32,14 @@
27
32
  stackTone = 'neutral',
28
33
  stackY = 8,
29
34
  stackX = 2,
35
+ title,
36
+ subtitle,
37
+ gap,
30
38
  class: klass = '',
31
39
  style = '',
40
+ header,
41
+ footer,
42
+ actions,
32
43
  children,
33
44
  ...rest
34
45
  }: {
@@ -41,8 +52,14 @@
41
52
  stackTone?: Tone;
42
53
  stackY?: number;
43
54
  stackX?: number;
55
+ title?: string;
56
+ subtitle?: string;
57
+ gap?: string;
44
58
  class?: string;
45
59
  style?: string;
60
+ header?: Snippet;
61
+ footer?: Snippet;
62
+ actions?: Snippet;
46
63
  children?: Snippet;
47
64
  [key: string]: unknown;
48
65
  } = $props();
@@ -50,6 +67,7 @@
50
67
  let stackStyle = $derived(
51
68
  stacked ? `--stack-y:${stackY}px;--stack-x:${stackX}px;` : ''
52
69
  );
70
+ const framed = $derived(!!(header || footer || title));
53
71
  </script>
54
72
 
55
73
  <svelte:element
@@ -71,18 +89,55 @@
71
89
  class:stack-warn={stacked && stackTone === 'warn'}
72
90
  class:stack-danger={stacked && stackTone === 'danger'}
73
91
  class:stack-info={stacked && stackTone === 'info'}
92
+ class:card-framed={framed}
93
+ class:card-gap={!framed && gap !== undefined}
94
+ style:--card-gap={gap}
74
95
  style={`${stackStyle}${style}`}
75
96
  {...rest}
76
97
  >
77
- {@render children?.()}
98
+ {#if framed}
99
+ {#if title}
100
+ <div class="card-head">
101
+ <SectionHeader {title} {subtitle} {actions} level={3} size="md" />
102
+ </div>
103
+ {:else if header}
104
+ <div class="card-head">{@render header()}</div>
105
+ {/if}
106
+ <div class="card-body" class:card-gap={gap !== undefined}>{@render children?.()}</div>
107
+ {#if footer}
108
+ <div class="card-foot">{@render footer()}</div>
109
+ {/if}
110
+ {:else}
111
+ {@render children?.()}
112
+ {/if}
78
113
  </svelte:element>
79
114
 
80
115
  <style>
81
116
  .card {
117
+ --card-pad: var(--sp-4);
82
118
  background: var(--bg-elevated);
83
119
  border: 1px solid var(--border);
84
120
  border-radius: var(--r-lg);
85
- padding: var(--sp-4);
121
+ padding: var(--card-pad);
122
+ }
123
+ .card-gap {
124
+ display: flex;
125
+ flex-direction: column;
126
+ gap: var(--card-gap);
127
+ }
128
+ .card-framed {
129
+ padding: 0;
130
+ }
131
+ .card-head,
132
+ .card-body,
133
+ .card-foot {
134
+ padding: var(--card-pad);
135
+ }
136
+ .card-head {
137
+ border-bottom: 1px solid var(--border);
138
+ }
139
+ .card-foot {
140
+ border-top: 1px solid var(--border);
86
141
  }
87
142
  /* Theme-aware surface shade, selected by prop so it adapts across themes
88
143
  instead of being overridden with app-level :global hacks. `base` is the
@@ -94,14 +149,22 @@
94
149
  background: var(--bg-elevated-2);
95
150
  }
96
151
  .pad-none {
152
+ --card-pad: 0;
97
153
  padding: 0;
98
154
  }
99
155
  .pad-sm {
156
+ --card-pad: var(--sp-2);
100
157
  padding: var(--sp-2);
101
158
  }
102
159
  .pad-lg {
160
+ --card-pad: var(--sp-6);
103
161
  padding: var(--sp-6);
104
162
  }
163
+ .card-framed.pad-none,
164
+ .card-framed.pad-sm,
165
+ .card-framed.pad-lg {
166
+ padding: 0;
167
+ }
105
168
  .card-tap {
106
169
  cursor: pointer;
107
170
  transition:
@@ -10,8 +10,14 @@ type $$ComponentProps = {
10
10
  stackTone?: Tone;
11
11
  stackY?: number;
12
12
  stackX?: number;
13
+ title?: string;
14
+ subtitle?: string;
15
+ gap?: string;
13
16
  class?: string;
14
17
  style?: string;
18
+ header?: Snippet;
19
+ footer?: Snippet;
20
+ actions?: Snippet;
15
21
  children?: Snippet;
16
22
  [key: string]: unknown;
17
23
  };
@@ -42,6 +42,6 @@ type $$ComponentProps = {
42
42
  /** Overlay mode: cap the width at the viewport and re-clamp on window resize. */
43
43
  clampToViewport?: boolean;
44
44
  };
45
- declare const ResizablePanel: import("svelte").Component<$$ComponentProps, {}, "collapsed" | "open">;
45
+ declare const ResizablePanel: import("svelte").Component<$$ComponentProps, {}, "open" | "collapsed">;
46
46
  type ResizablePanel = ReturnType<typeof ResizablePanel>;
47
47
  export default ResizablePanel;
@@ -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;
@@ -0,0 +1,209 @@
1
+ <script lang="ts">
2
+ // Page navigation in two flavours: page mode (`page` + `pageCount`) or
3
+ // offset mode (`offset` + `limit` + `total`, page/pageCount derived and
4
+ // `offset` written back). Numbered buttons collapse to prev / "3 / 12" /
5
+ // next when the container is narrower than 24rem.
6
+ import Text from '../atoms/Text.svelte';
7
+ import IconButton from './IconButton.svelte';
8
+
9
+ let {
10
+ page = $bindable(1),
11
+ pageCount,
12
+ offset = $bindable(),
13
+ limit,
14
+ total,
15
+ onchange,
16
+ siblings = 1,
17
+ showEdges = true,
18
+ showRange = false,
19
+ size = 'md',
20
+ label = 'Pagination',
21
+ class: klass = ''
22
+ }: {
23
+ /** 1-based current page. */
24
+ page?: number;
25
+ pageCount?: number;
26
+ /** Offset mode: 0-based index of the first item; needs `limit` + `total`. */
27
+ offset?: number;
28
+ limit?: number;
29
+ total?: number;
30
+ onchange?: (page: number) => void;
31
+ /** Numbered pages shown on each side of the current one. */
32
+ siblings?: number;
33
+ /** Always show the first and last page. */
34
+ showEdges?: boolean;
35
+ /** Show "1–20 / 412" (offset mode) or "3 / 12" next to the buttons. */
36
+ showRange?: boolean;
37
+ size?: 'sm' | 'md';
38
+ label?: string;
39
+ class?: string;
40
+ } = $props();
41
+
42
+ const offsetMode = $derived(offset !== undefined && limit !== undefined && total !== undefined);
43
+ const count = $derived(
44
+ Math.max(
45
+ 1,
46
+ offsetMode ? Math.ceil((total as number) / Math.max(1, limit as number)) : (pageCount ?? 1)
47
+ )
48
+ );
49
+ const current = $derived(
50
+ Math.min(
51
+ count,
52
+ Math.max(
53
+ 1,
54
+ offsetMode ? Math.floor((offset as number) / Math.max(1, limit as number)) + 1 : page
55
+ )
56
+ )
57
+ );
58
+
59
+ const ELLIPSIS = -1;
60
+ const items = $derived.by((): number[] => {
61
+ const out: number[] = [];
62
+ const lo = Math.max(1, current - siblings);
63
+ const hi = Math.min(count, current + siblings);
64
+ if (showEdges && lo > 1) {
65
+ out.push(1);
66
+ if (lo > 2) out.push(ELLIPSIS);
67
+ }
68
+ for (let p = lo; p <= hi; p++) out.push(p);
69
+ if (showEdges && hi < count) {
70
+ if (hi < count - 1) out.push(ELLIPSIS);
71
+ out.push(count);
72
+ }
73
+ return out;
74
+ });
75
+
76
+ const rangeText = $derived.by(() => {
77
+ if (offsetMode) {
78
+ const t = total as number;
79
+ const from = t === 0 ? 0 : (current - 1) * (limit as number) + 1;
80
+ const to = Math.min(t, current * (limit as number));
81
+ return `${from}–${to} / ${t}`;
82
+ }
83
+ return `${current} / ${count}`;
84
+ });
85
+
86
+ function go(p: number) {
87
+ const next = Math.min(count, Math.max(1, p));
88
+ if (next === current) return;
89
+ page = next;
90
+ if (offsetMode) offset = (next - 1) * (limit as number);
91
+ onchange?.(next);
92
+ }
93
+ </script>
94
+
95
+ <nav
96
+ data-tsu="Pagination"
97
+ class="pagination {klass}"
98
+ class:pagination-sm={size === 'sm'}
99
+ aria-label={label}
100
+ >
101
+ <IconButton
102
+ icon="chevron-left"
103
+ label="Previous page"
104
+ box={size === 'sm' ? 'sm' : 'md'}
105
+ disabled={current <= 1}
106
+ onclick={() => go(current - 1)}
107
+ />
108
+ <ol class="pages">
109
+ {#each items as item, i (item === ELLIPSIS ? `e${i}` : item)}
110
+ <li>
111
+ {#if item === ELLIPSIS}
112
+ <span class="ellipsis" aria-hidden="true">…</span>
113
+ {:else}
114
+ <button
115
+ type="button"
116
+ class="page"
117
+ class:on={item === current}
118
+ aria-current={item === current ? 'page' : undefined}
119
+ aria-label="Page {item}"
120
+ onclick={() => go(item)}
121
+ >
122
+ {item}
123
+ </button>
124
+ {/if}
125
+ </li>
126
+ {/each}
127
+ </ol>
128
+ <Text class="compact" size="sm" numeric tone="muted" aria-live="polite">{current} / {count}</Text>
129
+ <IconButton
130
+ icon="chevron-right"
131
+ label="Next page"
132
+ box={size === 'sm' ? 'sm' : 'md'}
133
+ disabled={current >= count}
134
+ onclick={() => go(current + 1)}
135
+ />
136
+ {#if showRange}
137
+ <Text class="range" size="sm" numeric tone="muted">{rangeText}</Text>
138
+ {/if}
139
+ </nav>
140
+
141
+ <style>
142
+ .pagination {
143
+ container-type: inline-size;
144
+ display: flex;
145
+ align-items: center;
146
+ gap: var(--sp-1);
147
+ flex-wrap: wrap;
148
+ }
149
+ .pages {
150
+ display: flex;
151
+ align-items: center;
152
+ gap: var(--sp-1);
153
+ list-style: none;
154
+ margin: 0;
155
+ padding: 0;
156
+ }
157
+ .page {
158
+ min-width: var(--box-md);
159
+ height: var(--box-md);
160
+ padding: 0 var(--sp-2);
161
+ border: 1px solid transparent;
162
+ border-radius: 999px;
163
+ background: transparent;
164
+ color: var(--text-muted);
165
+ font-size: var(--fs-sm);
166
+ font-variant-numeric: tabular-nums;
167
+ cursor: pointer;
168
+ transition:
169
+ background 0.12s var(--ease),
170
+ color 0.12s var(--ease);
171
+ }
172
+ .page:hover {
173
+ background: var(--bg-elevated-2);
174
+ color: var(--text);
175
+ }
176
+ .page.on {
177
+ background: color-mix(in srgb, var(--accent) 16%, transparent);
178
+ border-color: color-mix(in srgb, var(--accent) 50%, transparent);
179
+ color: var(--accent);
180
+ font-weight: var(--fw-semibold);
181
+ }
182
+ .pagination-sm .page {
183
+ min-width: var(--box-sm);
184
+ height: var(--box-sm);
185
+ font-size: var(--fs-xs);
186
+ }
187
+ .ellipsis {
188
+ display: inline-flex;
189
+ align-items: center;
190
+ justify-content: center;
191
+ min-width: var(--box-md);
192
+ color: var(--text-faint);
193
+ }
194
+ .pagination :global(.compact) {
195
+ display: none;
196
+ padding: 0 var(--sp-2);
197
+ }
198
+ .pagination :global(.range) {
199
+ margin-left: var(--sp-2);
200
+ }
201
+ @container (max-width: 24rem) {
202
+ .pages {
203
+ display: none;
204
+ }
205
+ .pagination :global(.compact) {
206
+ display: inline;
207
+ }
208
+ }
209
+ </style>
@@ -0,0 +1,22 @@
1
+ type $$ComponentProps = {
2
+ /** 1-based current page. */
3
+ page?: number;
4
+ pageCount?: number;
5
+ /** Offset mode: 0-based index of the first item; needs `limit` + `total`. */
6
+ offset?: number;
7
+ limit?: number;
8
+ total?: number;
9
+ onchange?: (page: number) => void;
10
+ /** Numbered pages shown on each side of the current one. */
11
+ siblings?: number;
12
+ /** Always show the first and last page. */
13
+ showEdges?: boolean;
14
+ /** Show "1–20 / 412" (offset mode) or "3 / 12" next to the buttons. */
15
+ showRange?: boolean;
16
+ size?: 'sm' | 'md';
17
+ label?: string;
18
+ class?: string;
19
+ };
20
+ declare const Pagination: import("svelte").Component<$$ComponentProps, {}, "page" | "offset">;
21
+ type Pagination = ReturnType<typeof Pagination>;
22
+ export default Pagination;
@@ -0,0 +1,233 @@
1
+ <script lang="ts">
2
+ // Section / group header: heading + optional subtitle, icon, count, hue
3
+ // swatch and right-aligned actions on one wrapping row. `divider` underlines
4
+ // it, `sticky` pins it under the app header and publishes its height as
5
+ // `--section-header-h` on the parent so sibling sticky content can offset
6
+ // itself. `collapsible` turns the title into a disclosure button that
7
+ // toggles the `children` block rendered beneath.
8
+ import type { Snippet } from 'svelte';
9
+ import Heading from '../atoms/Heading.svelte';
10
+ import Icon, { type IconName } from '../atoms/Icon.svelte';
11
+ import Text from '../atoms/Text.svelte';
12
+
13
+ type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
14
+ type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
15
+
16
+ let {
17
+ title,
18
+ label,
19
+ level = 2,
20
+ size,
21
+ subtitle,
22
+ icon,
23
+ count,
24
+ tone = 'neutral',
25
+ hue,
26
+ uppercase = false,
27
+ divider = false,
28
+ sticky = false,
29
+ collapsible = false,
30
+ open = $bindable(true),
31
+ class: klass = '',
32
+ actions,
33
+ children,
34
+ ...rest
35
+ }: {
36
+ title?: string;
37
+ /** Alias for `title`. */
38
+ label?: string;
39
+ level?: 1 | 2 | 3 | 4;
40
+ size?: Size;
41
+ subtitle?: string;
42
+ icon?: IconName;
43
+ count?: number | string;
44
+ tone?: Tone;
45
+ /** Hue (0–360) rendered as a small swatch chip before the title. */
46
+ hue?: number;
47
+ uppercase?: boolean;
48
+ divider?: boolean;
49
+ sticky?: boolean;
50
+ collapsible?: boolean;
51
+ open?: boolean;
52
+ class?: string;
53
+ actions?: Snippet;
54
+ children?: Snippet;
55
+ [key: string]: unknown;
56
+ } = $props();
57
+
58
+ const text = $derived(title ?? label ?? '');
59
+ const id = $props.id();
60
+ const panelId = `${id}-panel`;
61
+ let el = $state<HTMLElement>();
62
+ let height = $state(0);
63
+
64
+ $effect(() => {
65
+ const parent = el?.parentElement;
66
+ if (!parent) return;
67
+ if (sticky) parent.style.setProperty('--section-header-h', `${height}px`);
68
+ else parent.style.removeProperty('--section-header-h');
69
+ return () => parent.style.removeProperty('--section-header-h');
70
+ });
71
+ </script>
72
+
73
+ <div
74
+ bind:this={el}
75
+ bind:offsetHeight={height}
76
+ data-tsu="SectionHeader"
77
+ class="section-header sh-{tone} {klass}"
78
+ class:sh-divider={divider}
79
+ class:sh-sticky={sticky}
80
+ class:sh-uppercase={uppercase}
81
+ class:sh-open={open}
82
+ {...rest}
83
+ >
84
+ <div class="sh-row">
85
+ {#if collapsible}
86
+ <button
87
+ type="button"
88
+ class="sh-toggle"
89
+ aria-expanded={open}
90
+ aria-controls={panelId}
91
+ onclick={() => (open = !open)}
92
+ >
93
+ <Icon name="chevron-right" class="sh-chevron" />
94
+ {@render head()}
95
+ </button>
96
+ {:else}
97
+ {@render head()}
98
+ {/if}
99
+ {#if subtitle}
100
+ <Text variant="caption" class="sh-subtitle">{subtitle}</Text>
101
+ {/if}
102
+ {#if actions}
103
+ <div class="sh-actions">{@render actions()}</div>
104
+ {/if}
105
+ </div>
106
+ {#if children && (!collapsible || open)}
107
+ <div class="sh-panel" id={panelId}>{@render children()}</div>
108
+ {/if}
109
+ </div>
110
+
111
+ {#snippet head()}
112
+ {#if hue !== undefined}
113
+ <span class="sh-swatch" style:--sh-hue={hue} aria-hidden="true"></span>
114
+ {/if}
115
+ {#if icon}
116
+ <span class="sh-icon" aria-hidden="true"><Icon name={icon} /></span>
117
+ {/if}
118
+ <Heading {level} {size} class="sh-title">{text}</Heading>
119
+ {#if count !== undefined}
120
+ <Text tone="faint" weight="normal" numeric class="sh-count">{count}</Text>
121
+ {/if}
122
+ {/snippet}
123
+
124
+ <style>
125
+ .section-header {
126
+ --sh-tone: var(--text);
127
+ }
128
+ .sh-ok {
129
+ --sh-tone: var(--ok);
130
+ }
131
+ .sh-warn {
132
+ --sh-tone: var(--warn);
133
+ }
134
+ .sh-danger {
135
+ --sh-tone: var(--danger);
136
+ }
137
+ .sh-info {
138
+ --sh-tone: var(--info);
139
+ }
140
+ .sh-row {
141
+ display: flex;
142
+ align-items: center;
143
+ gap: var(--sp-3);
144
+ flex-wrap: wrap;
145
+ min-width: 0;
146
+ }
147
+ .sh-divider > .sh-row {
148
+ padding-bottom: var(--sp-2);
149
+ border-bottom: 1px solid var(--border);
150
+ }
151
+ .sh-sticky {
152
+ position: sticky;
153
+ top: var(--sticky-offset, var(--header-h, 0));
154
+ z-index: 1;
155
+ background: var(--section-header-bg, var(--bg));
156
+ }
157
+ .section-header :global(.sh-title) {
158
+ color: var(--sh-tone);
159
+ min-width: 0;
160
+ overflow-wrap: anywhere;
161
+ }
162
+ .sh-uppercase :global(.sh-title) {
163
+ text-transform: uppercase;
164
+ letter-spacing: 0.06em;
165
+ font-size: var(--fs-xs);
166
+ color: var(--text-muted);
167
+ }
168
+ .sh-ok.sh-uppercase :global(.sh-title),
169
+ .sh-warn.sh-uppercase :global(.sh-title),
170
+ .sh-danger.sh-uppercase :global(.sh-title),
171
+ .sh-info.sh-uppercase :global(.sh-title) {
172
+ color: var(--sh-tone);
173
+ }
174
+ .sh-icon {
175
+ display: inline-flex;
176
+ flex: none;
177
+ color: var(--sh-tone);
178
+ line-height: 1;
179
+ }
180
+ .sh-swatch {
181
+ flex: none;
182
+ width: 0.625rem;
183
+ height: 0.625rem;
184
+ border-radius: var(--r-sm);
185
+ background: hsl(var(--sh-hue) 70% 55%);
186
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 20%, transparent);
187
+ }
188
+ .section-header :global(.sh-subtitle) {
189
+ flex: 1 1 auto;
190
+ min-width: 0;
191
+ }
192
+ .sh-actions {
193
+ display: flex;
194
+ flex-wrap: wrap;
195
+ align-items: center;
196
+ gap: var(--sp-2);
197
+ margin-inline-start: auto;
198
+ }
199
+ .sh-toggle {
200
+ display: inline-flex;
201
+ align-items: center;
202
+ gap: var(--sp-2);
203
+ min-width: 0;
204
+ padding: 0;
205
+ border: 0;
206
+ background: none;
207
+ font: inherit;
208
+ color: inherit;
209
+ text-align: start;
210
+ cursor: pointer;
211
+ border-radius: var(--r-sm);
212
+ }
213
+ .sh-toggle:focus-visible {
214
+ outline: 2px solid var(--accent);
215
+ outline-offset: 2px;
216
+ }
217
+ .section-header :global(.sh-chevron) {
218
+ flex: none;
219
+ color: var(--text-muted);
220
+ transition: transform 0.15s var(--ease);
221
+ }
222
+ .sh-open :global(.sh-chevron) {
223
+ transform: rotate(90deg);
224
+ }
225
+ .sh-panel {
226
+ margin-top: var(--sp-3);
227
+ }
228
+ @media (prefers-reduced-motion: reduce) {
229
+ .section-header :global(.sh-chevron) {
230
+ transition: none;
231
+ }
232
+ }
233
+ </style>
@@ -0,0 +1,29 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type IconName } from '../atoms/Icon.svelte';
3
+ type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
4
+ type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
5
+ type $$ComponentProps = {
6
+ title?: string;
7
+ /** Alias for `title`. */
8
+ label?: string;
9
+ level?: 1 | 2 | 3 | 4;
10
+ size?: Size;
11
+ subtitle?: string;
12
+ icon?: IconName;
13
+ count?: number | string;
14
+ tone?: Tone;
15
+ /** Hue (0–360) rendered as a small swatch chip before the title. */
16
+ hue?: number;
17
+ uppercase?: boolean;
18
+ divider?: boolean;
19
+ sticky?: boolean;
20
+ collapsible?: boolean;
21
+ open?: boolean;
22
+ class?: string;
23
+ actions?: Snippet;
24
+ children?: Snippet;
25
+ [key: string]: unknown;
26
+ };
27
+ declare const SectionHeader: import("svelte").Component<$$ComponentProps, {}, "open">;
28
+ type SectionHeader = ReturnType<typeof SectionHeader>;
29
+ export default SectionHeader;
package/dist/index.d.ts CHANGED
@@ -32,6 +32,7 @@ export { type AccordionItem, default as Accordion, } from './components/molecule
32
32
  export { type BreadcrumbItem, default as Breadcrumb, } from './components/molecules/Breadcrumb.svelte';
33
33
  export { default as Callout } from './components/molecules/Callout.svelte';
34
34
  export { default as CodeBlock } from './components/molecules/CodeBlock.svelte';
35
+ export { default as ConfirmModal } from './components/molecules/ConfirmModal.svelte';
35
36
  export { default as CopyButton } from './components/molecules/CopyButton.svelte';
36
37
  export { default as Dropzone } from './components/molecules/Dropzone.svelte';
37
38
  export { default as EmptyState } from './components/molecules/EmptyState.svelte';
@@ -40,12 +41,16 @@ export { default as FileButton } from './components/molecules/FileButton.svelte'
40
41
  export { default as FilterInput, type FilterInputContext, } from './components/molecules/FilterInput.svelte';
41
42
  export { default as FontScalePicker } from './components/molecules/FontScalePicker.svelte';
42
43
  export { default as IconButton } from './components/molecules/IconButton.svelte';
44
+ export { default as KeyValue, type KeyValueRow, type KeyValueTone, } from './components/molecules/KeyValue.svelte';
45
+ export { default as LoadMore } from './components/molecules/LoadMore.svelte';
43
46
  export { default as Menu, type MenuItem } from './components/molecules/Menu.svelte';
44
47
  export { default as Metric, default as StatTile, } from './components/molecules/Metric.svelte';
45
48
  export { default as Modal } from './components/molecules/Modal.svelte';
46
49
  export { default as OptionButton } from './components/molecules/OptionButton.svelte';
50
+ export { default as Pagination } from './components/molecules/Pagination.svelte';
47
51
  export { default as Popover } from './components/molecules/Popover.svelte';
48
52
  export { default as RadioGroup, type RadioOption, } from './components/molecules/RadioGroup.svelte';
53
+ export { default as SectionHeader } from './components/molecules/SectionHeader.svelte';
49
54
  export { default as SegmentedControl, type SegmentOption, } from './components/molecules/SegmentedControl.svelte';
50
55
  export { default as SelectButton } from './components/molecules/SelectButton.svelte';
51
56
  export { default as Tabs, type TabItem } from './components/molecules/Tabs.svelte';
package/dist/index.js CHANGED
@@ -39,6 +39,7 @@ export { default as Accordion, } from './components/molecules/Accordion.svelte';
39
39
  export { default as Breadcrumb, } from './components/molecules/Breadcrumb.svelte';
40
40
  export { default as Callout } from './components/molecules/Callout.svelte';
41
41
  export { default as CodeBlock } from './components/molecules/CodeBlock.svelte';
42
+ export { default as ConfirmModal } from './components/molecules/ConfirmModal.svelte';
42
43
  export { default as CopyButton } from './components/molecules/CopyButton.svelte';
43
44
  export { default as Dropzone } from './components/molecules/Dropzone.svelte';
44
45
  export { default as EmptyState } from './components/molecules/EmptyState.svelte';
@@ -48,14 +49,18 @@ export { default as FileButton } from './components/molecules/FileButton.svelte'
48
49
  export { default as FilterInput, } from './components/molecules/FilterInput.svelte';
49
50
  export { default as FontScalePicker } from './components/molecules/FontScalePicker.svelte';
50
51
  export { default as IconButton } from './components/molecules/IconButton.svelte';
52
+ export { default as KeyValue, } from './components/molecules/KeyValue.svelte';
53
+ export { default as LoadMore } from './components/molecules/LoadMore.svelte';
51
54
  export { default as Menu } from './components/molecules/Menu.svelte';
52
55
  export { default as Metric,
53
56
  // StatTile is an alias for Metric — same component, dashboard-friendly name.
54
57
  default as StatTile, } from './components/molecules/Metric.svelte';
55
58
  export { default as Modal } from './components/molecules/Modal.svelte';
56
59
  export { default as OptionButton } from './components/molecules/OptionButton.svelte';
60
+ export { default as Pagination } from './components/molecules/Pagination.svelte';
57
61
  export { default as Popover } from './components/molecules/Popover.svelte';
58
62
  export { default as RadioGroup, } from './components/molecules/RadioGroup.svelte';
63
+ export { default as SectionHeader } from './components/molecules/SectionHeader.svelte';
59
64
  export { default as SegmentedControl, } from './components/molecules/SegmentedControl.svelte';
60
65
  export { default as SelectButton } from './components/molecules/SelectButton.svelte';
61
66
  export { default as Tabs } from './components/molecules/Tabs.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",