@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 +67 -1
- package/dist/components/atoms/Button.svelte +1 -1
- package/dist/components/atoms/Card.svelte +65 -2
- package/dist/components/atoms/Card.svelte.d.ts +6 -0
- package/dist/components/layouts/ResizablePanel.svelte.d.ts +1 -1
- package/dist/components/molecules/ConfirmModal.svelte +102 -0
- package/dist/components/molecules/ConfirmModal.svelte.d.ts +16 -0
- package/dist/components/molecules/KeyValue.svelte +117 -0
- package/dist/components/molecules/KeyValue.svelte.d.ts +20 -0
- package/dist/components/molecules/LoadMore.svelte +78 -0
- package/dist/components/molecules/LoadMore.svelte.d.ts +15 -0
- package/dist/components/molecules/Modal.svelte +66 -10
- package/dist/components/molecules/Modal.svelte.d.ts +12 -2
- package/dist/components/molecules/Pagination.svelte +209 -0
- package/dist/components/molecules/Pagination.svelte.d.ts +22 -0
- package/dist/components/molecules/SectionHeader.svelte +233 -0
- package/dist/components/molecules/SectionHeader.svelte.d.ts +29 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/package.json +1 -1
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,
|
|
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
|
-
{
|
|
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(--
|
|
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, {}, "
|
|
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,
|
|
7
|
-
// desktop resize (width persisted under
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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)
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
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