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