@dorsk/tsumikit 0.20.0 → 0.22.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 +29 -3
- package/dist/components/atoms/Badge.svelte +97 -41
- package/dist/components/atoms/Badge.svelte.d.ts +10 -2
- package/dist/components/atoms/Dot.svelte +14 -3
- package/dist/components/atoms/Dot.svelte.d.ts +1 -0
- package/dist/components/atoms/Link.svelte +45 -3
- package/dist/components/atoms/Link.svelte.d.ts +3 -0
- package/dist/components/layouts/AppShell.svelte +47 -5
- package/dist/components/layouts/AppShell.svelte.d.ts +8 -0
- package/dist/components/layouts/Container.svelte +54 -13
- package/dist/components/layouts/Container.svelte.d.ts +11 -3
- package/dist/components/molecules/Toaster.svelte +86 -30
- package/dist/components/molecules/Toaster.svelte.d.ts +2 -17
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/stores/toast.svelte.d.ts +25 -7
- package/dist/stores/toast.svelte.js +42 -8
- package/dist/styles/app.css +1 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -69,8 +69,12 @@ import { Button, Field, Input, Modal, ThemePicker } from '@dorsk/tsumikit';
|
|
|
69
69
|
## Components
|
|
70
70
|
|
|
71
71
|
**Atoms:** Text, Heading, Button, Input, Textarea, Select, Switch, Checkbox,
|
|
72
|
-
Slider, Progress, Card (`tone` tints the surface for inline banners), Badge
|
|
73
|
-
`
|
|
72
|
+
Slider, Progress, Card (`tone` tints the surface for inline banners), Badge
|
|
73
|
+
(`tone` semantic palette or `color` for any CSS colour, `size` xs/sm/md, `dot`,
|
|
74
|
+
`icon`, `numeric`, `truncate`, `variant="text"`; all tints derive from
|
|
75
|
+
`--badge-tone`), Dot (`ring` dark halo over artwork), Link (`tone`, `underline`
|
|
76
|
+
always/hover/none, `align`), Icon (open registry — pass a `children` snippet for
|
|
77
|
+
any custom SVG).
|
|
74
78
|
|
|
75
79
|
**Molecules:** Field, IconButton, SelectButton, Toggle, OptionButton, Modal,
|
|
76
80
|
Popover, Menu, Tabs, RadioGroup, Tooltip, Accordion, CopyButton, FileButton,
|
|
@@ -79,11 +83,26 @@ Dropzone, CodeBlock, Callout, EmptyState, Toaster, ThemePicker, FontScalePicker.
|
|
|
79
83
|
**Organisms:** DataTable (generic `<T>`, typed columns + cell snippets).
|
|
80
84
|
|
|
81
85
|
**Layouts:** AppShell (responsive header/sidebar/main/footer — persistent
|
|
82
|
-
sidebar on desktop, overlay drawer on mobile, optionally resizable
|
|
86
|
+
sidebar on desktop, overlay drawer on mobile, optionally resizable;
|
|
87
|
+
`layout="sidebar-full"` runs the sidebar the full height with the header over
|
|
88
|
+
the content column only, `stickySidebar` pins it to the viewport, and
|
|
89
|
+
`sidebarPadding="none" | "sm" | "md"` sets the aside gutter — the header and its
|
|
90
|
+
children are `min-width: 0` so a wide title/actions row can't widen the grid on
|
|
91
|
+
mobile), NavItem
|
|
83
92
|
(collapses to an icon rail when the sidebar is narrow), Container, Stack
|
|
84
93
|
(vertical), Cluster (wrapping row), AutoGrid (intrinsically responsive columns —
|
|
85
94
|
no media/container query needed).
|
|
86
95
|
|
|
96
|
+
### Container
|
|
97
|
+
|
|
98
|
+
`size` caps the column (`--content-max` by default; `size="none"` fills the parent),
|
|
99
|
+
`gutter` overrides the `--sp-4` inline padding (safe-area insets still win),
|
|
100
|
+
`align="start"` drops the centering. `fullWidth` is a *viewport* breakout for
|
|
101
|
+
edge-to-edge sections — inside AppShell's main column use `size="none"` instead;
|
|
102
|
+
`inset="<left> <right>"` keeps a `fullWidth` container clear of docked panels.
|
|
103
|
+
Children can bleed to the column edge with
|
|
104
|
+
`margin-inline: calc(-1 * var(--container-gutter))`.
|
|
105
|
+
|
|
87
106
|
### Stacked distribution + legend
|
|
88
107
|
|
|
89
108
|
`SegmentedProgress mode="stacked"` turns the bar into one shared track whose slice
|
|
@@ -135,6 +154,13 @@ Use the `.cq-*` utilities (`.cq-hide`, `.cq-stack`, `.cq-truncate`,
|
|
|
135
154
|
you drag the sidebar down to that icon rail (width persisted).
|
|
136
155
|
|
|
137
156
|
**Stores:** `theme`, `toasts`, `fontScale` (opt-in). **Actions:** `autoresize`.
|
|
157
|
+
|
|
158
|
+
`toasts.show(message, { tone, duration, action })` plus the `ok`/`error`/`info`
|
|
159
|
+
shorthands (tones `neutral|ok|error|info`; `danger` aliases `error`). An `action`
|
|
160
|
+
(`{ label, run }`) renders a button; the toast shows a loading state while `run`
|
|
161
|
+
settles, then dismisses, and action toasts stay 7s instead of 4s (`duration: 0` is
|
|
162
|
+
sticky). `Toaster` renders in the browser top layer (`popover="manual"`), so toasts
|
|
163
|
+
paint above an open `Modal`; `--toast-max-width` (28rem) caps the stack.
|
|
138
164
|
**Helpers:** `copyToClipboard(text)` — async Clipboard API with an
|
|
139
165
|
insecure-context fallback; returns whether it succeeded.
|
|
140
166
|
|
|
@@ -1,23 +1,43 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
// Inline label primitive — the project's single pill. Covers three jobs via
|
|
3
3
|
// props rather than separate components:
|
|
4
|
-
// • state → `tone`
|
|
4
|
+
// • state → `tone` semantic palette, or `color` for any CSS colour
|
|
5
5
|
// • info → `mono` for paths/ids/code-ish metadata
|
|
6
6
|
// • tag → `removable` renders a dismiss button + fires `onremove`
|
|
7
7
|
// Polymorphic via `as` so it can be a static <span> or an interactive
|
|
8
|
-
// <button>.
|
|
8
|
+
// <button>. Every tinted style derives from the `--badge-tone` custom
|
|
9
|
+
// property, which is the public hook for consumers who theme by CSS.
|
|
9
10
|
import type { Snippet } from 'svelte';
|
|
11
|
+
import Dot from './Dot.svelte';
|
|
12
|
+
import Icon, { type IconName } from './Icon.svelte';
|
|
10
13
|
|
|
11
|
-
type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
|
|
14
|
+
type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info' | 'accent' | 'muted' | 'violet';
|
|
15
|
+
|
|
16
|
+
const TONE_COLOR: Record<Exclude<Tone, 'neutral'>, string> = {
|
|
17
|
+
ok: 'var(--ok)',
|
|
18
|
+
warn: 'var(--warn)',
|
|
19
|
+
danger: 'var(--danger)',
|
|
20
|
+
info: 'var(--info)',
|
|
21
|
+
accent: 'var(--accent)',
|
|
22
|
+
muted: 'var(--text-muted)',
|
|
23
|
+
violet: 'var(--c-violet)',
|
|
24
|
+
};
|
|
12
25
|
|
|
13
26
|
let {
|
|
14
27
|
tone = 'neutral',
|
|
28
|
+
color,
|
|
15
29
|
as = 'span',
|
|
16
30
|
size = 'md',
|
|
31
|
+
variant = 'chip',
|
|
17
32
|
mono = false,
|
|
18
33
|
uppercase = false,
|
|
34
|
+
numeric = false,
|
|
35
|
+
truncate = false,
|
|
36
|
+
maxWidth,
|
|
19
37
|
border = true,
|
|
20
38
|
active = false,
|
|
39
|
+
dot = false,
|
|
40
|
+
icon,
|
|
21
41
|
removable = false,
|
|
22
42
|
onremove,
|
|
23
43
|
class: klass = '',
|
|
@@ -25,23 +45,39 @@
|
|
|
25
45
|
...rest
|
|
26
46
|
}: {
|
|
27
47
|
tone?: Tone;
|
|
48
|
+
// Any CSS colour (or var()) for a one-off tint; overrides `tone`.
|
|
49
|
+
color?: string;
|
|
28
50
|
as?: 'span' | 'button';
|
|
29
|
-
|
|
51
|
+
// `xs` is the densest form for counters in tight rows.
|
|
52
|
+
size?: 'xs' | 'sm' | 'md';
|
|
53
|
+
// `text` drops the chip (no fill, ring or padding) but keeps badge
|
|
54
|
+
// typography and tint — for mono metadata that must not read as a pill.
|
|
55
|
+
variant?: 'chip' | 'text';
|
|
30
56
|
mono?: boolean;
|
|
31
57
|
// Uppercase, letter-spaced label — for status tags/eyebrows.
|
|
32
58
|
uppercase?: boolean;
|
|
59
|
+
// Tabular digits with a minimum width so counters don't jitter.
|
|
60
|
+
numeric?: boolean;
|
|
61
|
+
// Clip long content with an ellipsis instead of overflowing.
|
|
62
|
+
truncate?: boolean;
|
|
63
|
+
maxWidth?: string;
|
|
33
64
|
// Borderless "soft" variant: tone-tinted fill pill with no ring. The
|
|
34
65
|
// default (`true`) keeps the outlined look for backwards compatibility.
|
|
35
66
|
border?: boolean;
|
|
36
67
|
// Interactive "on" state for a toggle/count badge (`as="button"`): fills the
|
|
37
68
|
// pill with its tone instead of just tinting the border.
|
|
38
69
|
active?: boolean;
|
|
70
|
+
// Leading status dot in the badge colour.
|
|
71
|
+
dot?: boolean;
|
|
72
|
+
icon?: IconName;
|
|
39
73
|
removable?: boolean;
|
|
40
74
|
onremove?: (e: MouseEvent) => void;
|
|
41
75
|
class?: string;
|
|
42
76
|
children?: Snippet;
|
|
43
77
|
[key: string]: unknown;
|
|
44
78
|
} = $props();
|
|
79
|
+
|
|
80
|
+
const toneColor = $derived(color ?? (tone === 'neutral' ? undefined : TONE_COLOR[tone]));
|
|
45
81
|
</script>
|
|
46
82
|
|
|
47
83
|
<svelte:element
|
|
@@ -52,15 +88,32 @@
|
|
|
52
88
|
class:badge-warn={tone === 'warn'}
|
|
53
89
|
class:badge-danger={tone === 'danger'}
|
|
54
90
|
class:badge-info={tone === 'info'}
|
|
91
|
+
class:toned={toneColor !== undefined}
|
|
55
92
|
class:badge-sm={size === 'sm'}
|
|
93
|
+
class:badge-xs={size === 'xs'}
|
|
94
|
+
class:text={variant === 'text'}
|
|
56
95
|
class:mono
|
|
57
96
|
class:uppercase
|
|
97
|
+
class:numeric
|
|
98
|
+
class:truncate
|
|
58
99
|
class:borderless={!border}
|
|
59
100
|
class:active
|
|
60
101
|
class:interactive={as === 'button'}
|
|
102
|
+
style:--badge-tone={toneColor}
|
|
103
|
+
style:--badge-max-width={maxWidth}
|
|
61
104
|
{...rest}
|
|
62
105
|
>
|
|
63
|
-
{
|
|
106
|
+
{#if dot}
|
|
107
|
+
<Dot color="var(--badge-tone, currentColor)" />
|
|
108
|
+
{/if}
|
|
109
|
+
{#if icon}
|
|
110
|
+
<Icon name={icon} />
|
|
111
|
+
{/if}
|
|
112
|
+
{#if truncate}
|
|
113
|
+
<span class="clip">{@render children?.()}</span>
|
|
114
|
+
{:else}
|
|
115
|
+
{@render children?.()}
|
|
116
|
+
{/if}
|
|
64
117
|
{#if removable}
|
|
65
118
|
<button
|
|
66
119
|
type="button"
|
|
@@ -87,32 +140,29 @@
|
|
|
87
140
|
color: var(--text-muted);
|
|
88
141
|
border: 1px solid var(--border);
|
|
89
142
|
white-space: nowrap;
|
|
90
|
-
max-width: 100
|
|
143
|
+
max-width: var(--badge-max-width, 100%);
|
|
91
144
|
}
|
|
92
|
-
.badge-sm
|
|
93
|
-
|
|
94
|
-
font-size: 0.
|
|
145
|
+
.badge-sm,
|
|
146
|
+
.badge-xs {
|
|
147
|
+
font-size: calc(var(--fs-xs) * 0.92);
|
|
95
148
|
gap: 0.15rem;
|
|
96
149
|
}
|
|
97
|
-
.badge-
|
|
98
|
-
|
|
99
|
-
border-color: color-mix(in srgb, var(--ok) 40%, transparent);
|
|
100
|
-
background: color-mix(in srgb, var(--ok) 12%, transparent);
|
|
150
|
+
.badge-sm {
|
|
151
|
+
padding: 0 0.4rem;
|
|
101
152
|
}
|
|
102
|
-
.badge-
|
|
103
|
-
|
|
104
|
-
border-color: color-mix(in srgb, var(--warn) 40%, transparent);
|
|
105
|
-
background: color-mix(in srgb, var(--warn) 12%, transparent);
|
|
153
|
+
.badge-xs {
|
|
154
|
+
padding: 0.05rem var(--sp-2);
|
|
106
155
|
}
|
|
107
|
-
.
|
|
108
|
-
color: var(--
|
|
109
|
-
border-color: color-mix(in srgb, var(--
|
|
110
|
-
background: color-mix(in srgb, var(--
|
|
156
|
+
.toned {
|
|
157
|
+
color: var(--badge-tone);
|
|
158
|
+
border-color: color-mix(in srgb, var(--badge-tone) 40%, transparent);
|
|
159
|
+
background: color-mix(in srgb, var(--badge-tone) 12%, transparent);
|
|
111
160
|
}
|
|
112
|
-
.
|
|
113
|
-
|
|
114
|
-
border-color:
|
|
115
|
-
background:
|
|
161
|
+
.text {
|
|
162
|
+
padding: 0;
|
|
163
|
+
border-color: transparent;
|
|
164
|
+
background: none;
|
|
165
|
+
border-radius: 0;
|
|
116
166
|
}
|
|
117
167
|
/* Soft variant: drop the ring, keep the tinted fill. Transparent (not
|
|
118
168
|
`border: 0`) so layout/baseline matches the outlined default exactly. */
|
|
@@ -128,24 +178,26 @@
|
|
|
128
178
|
letter-spacing: 0.04em;
|
|
129
179
|
font-weight: var(--fw-semibold);
|
|
130
180
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
background: var(--badge-fill, var(--accent));
|
|
136
|
-
border-color: var(--badge-fill, var(--accent));
|
|
137
|
-
}
|
|
138
|
-
.badge-ok.active {
|
|
139
|
-
--badge-fill: var(--ok);
|
|
181
|
+
.numeric {
|
|
182
|
+
font-variant-numeric: tabular-nums;
|
|
183
|
+
min-width: 1.5em;
|
|
184
|
+
justify-content: center;
|
|
140
185
|
}
|
|
141
|
-
.
|
|
142
|
-
|
|
186
|
+
.truncate {
|
|
187
|
+
min-width: 0;
|
|
143
188
|
}
|
|
144
|
-
.
|
|
145
|
-
|
|
189
|
+
.clip {
|
|
190
|
+
min-width: 0;
|
|
191
|
+
overflow: hidden;
|
|
192
|
+
text-overflow: ellipsis;
|
|
193
|
+
white-space: nowrap;
|
|
146
194
|
}
|
|
147
|
-
.
|
|
148
|
-
|
|
195
|
+
/* Interactive "on" state: fill the pill with its tone. Falls back to the
|
|
196
|
+
neutral accent when no tone or colour is set. */
|
|
197
|
+
.active {
|
|
198
|
+
color: var(--text-on-accent);
|
|
199
|
+
background: var(--badge-tone, var(--accent));
|
|
200
|
+
border-color: var(--badge-tone, var(--accent));
|
|
149
201
|
}
|
|
150
202
|
.interactive {
|
|
151
203
|
cursor: pointer;
|
|
@@ -159,9 +211,13 @@
|
|
|
159
211
|
}
|
|
160
212
|
.interactive.active:hover {
|
|
161
213
|
color: var(--text-on-accent);
|
|
162
|
-
border-color: var(--badge-
|
|
214
|
+
border-color: var(--badge-tone, var(--accent));
|
|
163
215
|
filter: brightness(1.08);
|
|
164
216
|
}
|
|
217
|
+
.interactive:focus-visible {
|
|
218
|
+
outline: 2px solid var(--badge-tone, var(--accent));
|
|
219
|
+
outline-offset: 2px;
|
|
220
|
+
}
|
|
165
221
|
.remove {
|
|
166
222
|
display: inline-flex;
|
|
167
223
|
align-items: center;
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
-
|
|
2
|
+
import { type IconName } from './Icon.svelte';
|
|
3
|
+
type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info' | 'accent' | 'muted' | 'violet';
|
|
3
4
|
type $$ComponentProps = {
|
|
4
5
|
tone?: Tone;
|
|
6
|
+
color?: string;
|
|
5
7
|
as?: 'span' | 'button';
|
|
6
|
-
size?: 'sm' | 'md';
|
|
8
|
+
size?: 'xs' | 'sm' | 'md';
|
|
9
|
+
variant?: 'chip' | 'text';
|
|
7
10
|
mono?: boolean;
|
|
8
11
|
uppercase?: boolean;
|
|
12
|
+
numeric?: boolean;
|
|
13
|
+
truncate?: boolean;
|
|
14
|
+
maxWidth?: string;
|
|
9
15
|
border?: boolean;
|
|
10
16
|
active?: boolean;
|
|
17
|
+
dot?: boolean;
|
|
18
|
+
icon?: IconName;
|
|
11
19
|
removable?: boolean;
|
|
12
20
|
onremove?: (e: MouseEvent) => void;
|
|
13
21
|
class?: string;
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// • status → one of the semantic presets (active/stale/dead/hibernated),
|
|
6
6
|
// each mapped to a token.
|
|
7
7
|
// • color → any CSS colour (or var()) for a one-off; overrides `status`.
|
|
8
|
-
// `glow` adds a soft halo in the dot's own colour
|
|
8
|
+
// `glow` adds a soft halo in the dot's own colour; `ring` adds a dark halo so
|
|
9
|
+
// the dot stays legible over artwork. With a `label` the whole
|
|
9
10
|
// thing renders as an inline-flex row (dot + caption); without one it's a bare
|
|
10
11
|
// inline dot, so it can sit inline next to other text.
|
|
11
12
|
import Text from './Text.svelte';
|
|
@@ -24,6 +25,7 @@
|
|
|
24
25
|
color,
|
|
25
26
|
label,
|
|
26
27
|
glow = false,
|
|
28
|
+
ring = false,
|
|
27
29
|
class: klass = '',
|
|
28
30
|
...rest
|
|
29
31
|
}: {
|
|
@@ -31,6 +33,7 @@
|
|
|
31
33
|
color?: string;
|
|
32
34
|
label?: string;
|
|
33
35
|
glow?: boolean;
|
|
36
|
+
ring?: boolean;
|
|
34
37
|
class?: string;
|
|
35
38
|
[key: string]: unknown;
|
|
36
39
|
} = $props();
|
|
@@ -40,11 +43,11 @@
|
|
|
40
43
|
|
|
41
44
|
{#if label}
|
|
42
45
|
<span class="dot-row {klass}" data-tsu="Dot" {...rest}>
|
|
43
|
-
<span class="dot" class:glow style="--dot-color:{resolved}"></span>
|
|
46
|
+
<span class="dot" class:glow class:ring style="--dot-color:{resolved}"></span>
|
|
44
47
|
<Text variant="caption">{label}</Text>
|
|
45
48
|
</span>
|
|
46
49
|
{:else}
|
|
47
|
-
<span class="dot {klass}" class:glow style="--dot-color:{resolved}" data-tsu="Dot" {...rest}></span>
|
|
50
|
+
<span class="dot {klass}" class:glow class:ring style="--dot-color:{resolved}" data-tsu="Dot" {...rest}></span>
|
|
48
51
|
{/if}
|
|
49
52
|
|
|
50
53
|
<style>
|
|
@@ -64,4 +67,12 @@
|
|
|
64
67
|
.glow {
|
|
65
68
|
box-shadow: 0 0 6px var(--dot-color);
|
|
66
69
|
}
|
|
70
|
+
.ring {
|
|
71
|
+
box-shadow: 0 0 0 3px rgb(0 0 0 / 0.35);
|
|
72
|
+
}
|
|
73
|
+
.ring.glow {
|
|
74
|
+
box-shadow:
|
|
75
|
+
0 0 0 3px rgb(0 0 0 / 0.35),
|
|
76
|
+
0 0 6px var(--dot-color);
|
|
77
|
+
}
|
|
67
78
|
</style>
|
|
@@ -6,16 +6,38 @@
|
|
|
6
6
|
|
|
7
7
|
let {
|
|
8
8
|
href,
|
|
9
|
+
tone = 'accent',
|
|
10
|
+
underline = 'always',
|
|
11
|
+
align = 'start',
|
|
9
12
|
class: klass = '',
|
|
10
13
|
children,
|
|
11
14
|
...rest
|
|
12
|
-
}: {
|
|
15
|
+
}: {
|
|
16
|
+
href?: string;
|
|
17
|
+
tone?: 'accent' | 'info' | 'muted' | 'inherit';
|
|
18
|
+
underline?: 'always' | 'hover' | 'none';
|
|
19
|
+
// Text alignment of the <button> form; multi-line titles want `start`.
|
|
20
|
+
align?: 'start' | 'center';
|
|
21
|
+
class?: string;
|
|
22
|
+
children?: Snippet;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
} = $props();
|
|
13
25
|
</script>
|
|
14
26
|
|
|
15
27
|
{#if href}
|
|
16
|
-
<a
|
|
28
|
+
<a
|
|
29
|
+
{href}
|
|
30
|
+
class="link tone-{tone} underline-{underline} align-{align} {klass}"
|
|
31
|
+
data-tsu="Link"
|
|
32
|
+
{...rest}>{@render children?.()}</a
|
|
33
|
+
>
|
|
17
34
|
{:else}
|
|
18
|
-
<button
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
class="link tone-{tone} underline-{underline} align-{align} {klass}"
|
|
38
|
+
data-tsu="Link"
|
|
39
|
+
{...rest}>{@render children?.()}</button
|
|
40
|
+
>
|
|
19
41
|
{/if}
|
|
20
42
|
|
|
21
43
|
<style>
|
|
@@ -27,5 +49,25 @@
|
|
|
27
49
|
cursor: pointer;
|
|
28
50
|
text-decoration: underline;
|
|
29
51
|
font: inherit;
|
|
52
|
+
text-align: start;
|
|
53
|
+
}
|
|
54
|
+
.tone-info {
|
|
55
|
+
color: var(--info);
|
|
56
|
+
}
|
|
57
|
+
.tone-muted {
|
|
58
|
+
color: var(--text-muted);
|
|
59
|
+
}
|
|
60
|
+
.tone-inherit {
|
|
61
|
+
color: inherit;
|
|
62
|
+
}
|
|
63
|
+
.underline-hover,
|
|
64
|
+
.underline-none {
|
|
65
|
+
text-decoration: none;
|
|
66
|
+
}
|
|
67
|
+
.underline-hover:hover {
|
|
68
|
+
text-decoration: underline;
|
|
69
|
+
}
|
|
70
|
+
.align-center {
|
|
71
|
+
text-align: center;
|
|
30
72
|
}
|
|
31
73
|
</style>
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
type $$ComponentProps = {
|
|
3
3
|
href?: string;
|
|
4
|
+
tone?: 'accent' | 'info' | 'muted' | 'inherit';
|
|
5
|
+
underline?: 'always' | 'hover' | 'none';
|
|
6
|
+
align?: 'start' | 'center';
|
|
4
7
|
class?: string;
|
|
5
8
|
children?: Snippet;
|
|
6
9
|
[key: string]: unknown;
|
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
resizableSidebar = false,
|
|
25
25
|
minSidebar = 64,
|
|
26
26
|
maxSidebar = 360,
|
|
27
|
-
sidebarWidthKey
|
|
27
|
+
sidebarWidthKey,
|
|
28
|
+
layout = 'header-top',
|
|
29
|
+
stickySidebar = false,
|
|
30
|
+
sidebarPadding = 'md'
|
|
28
31
|
}: {
|
|
29
32
|
header?: Snippet;
|
|
30
33
|
sidebar?: Snippet;
|
|
@@ -41,6 +44,14 @@
|
|
|
41
44
|
maxSidebar?: number;
|
|
42
45
|
/** localStorage key to persist the resized width. */
|
|
43
46
|
sidebarWidthKey?: string;
|
|
47
|
+
/** `'sidebar-full'`: the sidebar spans the whole height on desktop/tablet
|
|
48
|
+
* and the header only covers the content column (brand sits top-left). */
|
|
49
|
+
layout?: 'header-top' | 'sidebar-full';
|
|
50
|
+
/** Pin the desktop sidebar to the viewport (100dvh, own scroll) so its
|
|
51
|
+
* footer stays visible on long pages. */
|
|
52
|
+
stickySidebar?: boolean;
|
|
53
|
+
/** Inner padding of the sidebar; `'none'` when the nav owns its gutters. */
|
|
54
|
+
sidebarPadding?: 'none' | 'sm' | 'md';
|
|
44
55
|
} = $props();
|
|
45
56
|
|
|
46
57
|
let open = $state(false);
|
|
@@ -115,7 +126,14 @@
|
|
|
115
126
|
|
|
116
127
|
<svelte:window onkeydown={(e) => e.key === 'Escape' && (open = false)} />
|
|
117
128
|
|
|
118
|
-
<div
|
|
129
|
+
<div
|
|
130
|
+
class="shell"
|
|
131
|
+
class:dragging
|
|
132
|
+
class:sidebar-full={layout === 'sidebar-full'}
|
|
133
|
+
class:sticky-sidebar={stickySidebar}
|
|
134
|
+
style="--shell-sidebar-w: {widthCss}"
|
|
135
|
+
data-tsu="AppShell"
|
|
136
|
+
>
|
|
119
137
|
<header class="shell-header">
|
|
120
138
|
{#if sidebar}
|
|
121
139
|
<!-- Wrapper owned here so the responsive hide is a scoped rule on our own
|
|
@@ -143,6 +161,7 @@
|
|
|
143
161
|
<aside
|
|
144
162
|
class="shell-sidebar"
|
|
145
163
|
class:open
|
|
164
|
+
data-padding={sidebarPadding}
|
|
146
165
|
aria-label={navLabel}
|
|
147
166
|
inert={isMobile && !open ? true : undefined}
|
|
148
167
|
>
|
|
@@ -190,6 +209,7 @@
|
|
|
190
209
|
display: flex;
|
|
191
210
|
align-items: center;
|
|
192
211
|
gap: var(--sp-3);
|
|
212
|
+
min-width: 0;
|
|
193
213
|
height: var(--header-h);
|
|
194
214
|
padding-inline: max(var(--sp-4), var(--safe-left)) max(var(--sp-4), var(--safe-right));
|
|
195
215
|
padding-top: var(--safe-top);
|
|
@@ -197,6 +217,9 @@
|
|
|
197
217
|
backdrop-filter: blur(8px);
|
|
198
218
|
border-bottom: 1px solid var(--border);
|
|
199
219
|
}
|
|
220
|
+
.shell-header > :global(*) {
|
|
221
|
+
min-width: 0;
|
|
222
|
+
}
|
|
200
223
|
.shell-menu-btn {
|
|
201
224
|
display: inline-flex;
|
|
202
225
|
align-items: center;
|
|
@@ -229,15 +252,22 @@
|
|
|
229
252
|
-webkit-overflow-scrolling: touch;
|
|
230
253
|
background: var(--bg-elevated);
|
|
231
254
|
border-right: 1px solid var(--border);
|
|
232
|
-
|
|
233
|
-
padding
|
|
234
|
-
padding-
|
|
255
|
+
--shell-sidebar-pad: var(--sp-3);
|
|
256
|
+
padding: var(--shell-sidebar-pad);
|
|
257
|
+
padding-top: max(var(--shell-sidebar-pad), var(--safe-top));
|
|
258
|
+
padding-bottom: max(var(--shell-sidebar-pad), var(--safe-bottom));
|
|
235
259
|
/* A query container named `sidebar` so nav items collapse to an icon rail
|
|
236
260
|
based on the sidebar's own width (see NavItem). */
|
|
237
261
|
container: sidebar / inline-size;
|
|
238
262
|
transform: translateX(-100%);
|
|
239
263
|
transition: transform 0.2s var(--ease);
|
|
240
264
|
}
|
|
265
|
+
.shell-sidebar[data-padding='sm'] {
|
|
266
|
+
--shell-sidebar-pad: var(--sp-2);
|
|
267
|
+
}
|
|
268
|
+
.shell-sidebar[data-padding='none'] {
|
|
269
|
+
--shell-sidebar-pad: 0px;
|
|
270
|
+
}
|
|
241
271
|
.shell-sidebar.open {
|
|
242
272
|
transform: translateX(0);
|
|
243
273
|
box-shadow: var(--shadow-lg);
|
|
@@ -273,6 +303,12 @@
|
|
|
273
303
|
'sidebar main'
|
|
274
304
|
'footer footer';
|
|
275
305
|
}
|
|
306
|
+
.shell.sidebar-full {
|
|
307
|
+
grid-template-areas:
|
|
308
|
+
'sidebar header'
|
|
309
|
+
'sidebar main'
|
|
310
|
+
'sidebar footer';
|
|
311
|
+
}
|
|
276
312
|
.shell-sidebar {
|
|
277
313
|
position: relative; /* anchor the absolute resize handle */
|
|
278
314
|
grid-area: sidebar;
|
|
@@ -281,6 +317,12 @@
|
|
|
281
317
|
box-shadow: none;
|
|
282
318
|
border-right: 1px solid var(--border);
|
|
283
319
|
}
|
|
320
|
+
.shell.sticky-sidebar .shell-sidebar {
|
|
321
|
+
position: sticky;
|
|
322
|
+
top: 0;
|
|
323
|
+
height: 100dvh;
|
|
324
|
+
align-self: start;
|
|
325
|
+
}
|
|
284
326
|
.shell-scrim,
|
|
285
327
|
.shell-menu-btn {
|
|
286
328
|
display: none !important;
|
|
@@ -15,6 +15,14 @@ type $$ComponentProps = {
|
|
|
15
15
|
maxSidebar?: number;
|
|
16
16
|
/** localStorage key to persist the resized width. */
|
|
17
17
|
sidebarWidthKey?: string;
|
|
18
|
+
/** `'sidebar-full'`: the sidebar spans the whole height on desktop/tablet
|
|
19
|
+
* and the header only covers the content column (brand sits top-left). */
|
|
20
|
+
layout?: 'header-top' | 'sidebar-full';
|
|
21
|
+
/** Pin the desktop sidebar to the viewport (100dvh, own scroll) so its
|
|
22
|
+
* footer stays visible on long pages. */
|
|
23
|
+
stickySidebar?: boolean;
|
|
24
|
+
/** Inner padding of the sidebar; `'none'` when the nav owns its gutters. */
|
|
25
|
+
sidebarPadding?: 'none' | 'sm' | 'md';
|
|
18
26
|
};
|
|
19
27
|
declare const AppShell: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
20
28
|
type AppShell = ReturnType<typeof AppShell>;
|
|
@@ -1,31 +1,51 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// (the `margin-inline: calc(50% - 50vw)` trick), for edge-to-edge sections.
|
|
7
|
-
// Polymorphic via `as` so it can be a <main>, <section>, etc.
|
|
2
|
+
// Max-width content column with token gutters that respect safe-area insets.
|
|
3
|
+
// Self-contained: does not depend on the global `.container` utility. Exposes
|
|
4
|
+
// `--container-gutter` so children can bleed to the column edge with
|
|
5
|
+
// `margin-inline: calc(-1 * var(--container-gutter))`.
|
|
8
6
|
import type { Snippet } from 'svelte';
|
|
9
7
|
|
|
10
8
|
let {
|
|
11
9
|
as = 'div',
|
|
12
10
|
size,
|
|
11
|
+
gutter,
|
|
12
|
+
align = 'center',
|
|
13
13
|
pad = false,
|
|
14
14
|
fullWidth = false,
|
|
15
|
+
inset,
|
|
15
16
|
class: klass = '',
|
|
16
17
|
children,
|
|
17
18
|
...rest
|
|
18
19
|
}: {
|
|
19
20
|
as?: 'div' | 'main' | 'section' | 'article';
|
|
20
|
-
/** Max width (any CSS length). Defaults to --content-max. Ignored when `fullWidth`. */
|
|
21
|
-
size?: string;
|
|
21
|
+
/** Max width (any CSS length), or `'none'` to fill the parent. Defaults to --content-max. Ignored when `fullWidth`. */
|
|
22
|
+
size?: string | 'none';
|
|
23
|
+
/** Inline gutter (any CSS length). Defaults to --sp-4; safe-area insets still win when larger. */
|
|
24
|
+
gutter?: string;
|
|
25
|
+
/** `'center'` (margin-inline auto) or `'start'` (flush with the parent's start edge). */
|
|
26
|
+
align?: 'center' | 'start';
|
|
22
27
|
pad?: boolean;
|
|
23
|
-
/** Break out to the full viewport width, ignoring `size`/--content-max
|
|
28
|
+
/** Break out to the full *viewport* width, ignoring `size`/--content-max and any
|
|
29
|
+
* centered ancestor. Inside a sidebar layout prefer `size="none"` (fills the column). */
|
|
24
30
|
fullWidth?: boolean;
|
|
31
|
+
/** Space reserved at the viewport edges when `fullWidth` (`'left right'` or one value
|
|
32
|
+
* for both), e.g. `"var(--dock-left-w) var(--dock-right-w)"` for docked panels. */
|
|
33
|
+
inset?: string;
|
|
25
34
|
class?: string;
|
|
26
35
|
children?: Snippet;
|
|
27
36
|
[key: string]: unknown;
|
|
28
37
|
} = $props();
|
|
38
|
+
|
|
39
|
+
const style = $derived.by(() => {
|
|
40
|
+
const vars: string[] = [];
|
|
41
|
+
if (!fullWidth && size && size !== 'none') vars.push(`--ct-max: ${size}`);
|
|
42
|
+
if (gutter) vars.push(`--container-gutter: ${gutter}`);
|
|
43
|
+
if (fullWidth && inset) {
|
|
44
|
+
const [left, right = left] = inset.trim().split(/\s+/);
|
|
45
|
+
vars.push(`--ct-inset-l: ${left}`, `--ct-inset-r: ${right}`);
|
|
46
|
+
}
|
|
47
|
+
return vars.length ? vars.join('; ') : undefined;
|
|
48
|
+
});
|
|
29
49
|
</script>
|
|
30
50
|
|
|
31
51
|
<svelte:element
|
|
@@ -33,25 +53,46 @@
|
|
|
33
53
|
data-tsu="Container"
|
|
34
54
|
class="container ct {klass}"
|
|
35
55
|
class:pad
|
|
56
|
+
class:none={size === 'none'}
|
|
57
|
+
class:start={align === 'start'}
|
|
36
58
|
class:full={fullWidth}
|
|
37
|
-
style
|
|
59
|
+
{style}
|
|
38
60
|
{...rest}
|
|
39
61
|
>
|
|
40
62
|
{@render children?.()}
|
|
41
63
|
</svelte:element>
|
|
42
64
|
|
|
43
65
|
<style>
|
|
66
|
+
.ct {
|
|
67
|
+
--container-gutter: var(--sp-4);
|
|
68
|
+
width: 100%;
|
|
69
|
+
max-width: var(--ct-max, var(--content-max));
|
|
70
|
+
margin-inline: auto;
|
|
71
|
+
padding-inline: max(var(--container-gutter), var(--safe-left))
|
|
72
|
+
max(var(--container-gutter), var(--safe-right));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.ct.none {
|
|
76
|
+
max-width: none;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.ct.start {
|
|
80
|
+
margin-inline: 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
44
83
|
.ct.pad {
|
|
45
84
|
padding-top: var(--sp-6);
|
|
46
85
|
padding-bottom: var(--sp-12);
|
|
47
86
|
}
|
|
48
87
|
|
|
49
|
-
/* Break out of any centered ancestor to span the
|
|
88
|
+
/* Break out of any centered ancestor to span the viewport (minus `inset`).
|
|
50
89
|
`margin-inline: calc(50% - 50vw)` pulls each edge out to the viewport,
|
|
51
90
|
keeping the element in normal flow (no transform/overflow side-effects). */
|
|
52
91
|
.ct.full {
|
|
92
|
+
--ct-inset-l: 0px;
|
|
93
|
+
--ct-inset-r: 0px;
|
|
53
94
|
max-width: none;
|
|
54
|
-
width: 100vw;
|
|
55
|
-
margin-inline: calc(50% - 50vw);
|
|
95
|
+
width: calc(100vw - var(--ct-inset-l) - var(--ct-inset-r));
|
|
96
|
+
margin-inline: calc(50% - 50vw + var(--ct-inset-l)) calc(50% - 50vw + var(--ct-inset-r));
|
|
56
97
|
}
|
|
57
98
|
</style>
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
type $$ComponentProps = {
|
|
3
3
|
as?: 'div' | 'main' | 'section' | 'article';
|
|
4
|
-
/** Max width (any CSS length). Defaults to --content-max. Ignored when `fullWidth`. */
|
|
5
|
-
size?: string;
|
|
4
|
+
/** Max width (any CSS length), or `'none'` to fill the parent. Defaults to --content-max. Ignored when `fullWidth`. */
|
|
5
|
+
size?: string | 'none';
|
|
6
|
+
/** Inline gutter (any CSS length). Defaults to --sp-4; safe-area insets still win when larger. */
|
|
7
|
+
gutter?: string;
|
|
8
|
+
/** `'center'` (margin-inline auto) or `'start'` (flush with the parent's start edge). */
|
|
9
|
+
align?: 'center' | 'start';
|
|
6
10
|
pad?: boolean;
|
|
7
|
-
/** Break out to the full viewport width, ignoring `size`/--content-max
|
|
11
|
+
/** Break out to the full *viewport* width, ignoring `size`/--content-max and any
|
|
12
|
+
* centered ancestor. Inside a sidebar layout prefer `size="none"` (fills the column). */
|
|
8
13
|
fullWidth?: boolean;
|
|
14
|
+
/** Space reserved at the viewport edges when `fullWidth` (`'left right'` or one value
|
|
15
|
+
* for both), e.g. `"var(--dock-left-w) var(--dock-right-w)"` for docked panels. */
|
|
16
|
+
inset?: string;
|
|
9
17
|
class?: string;
|
|
10
18
|
children?: Snippet;
|
|
11
19
|
[key: string]: unknown;
|
|
@@ -1,31 +1,70 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
// Renders the toast queue in a bottom-centered, polite live region. Mount once
|
|
3
|
-
// near the app root.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// near the app root. The stack lives in a manual popover so it paints in the
|
|
4
|
+
// top layer above an open Modal <dialog>; without the Popover API the
|
|
5
|
+
// attribute is ignored and the fixed/z-index positioning applies instead.
|
|
6
6
|
import { toasts } from '../../stores/toast.svelte';
|
|
7
|
+
import Button from '../atoms/Button.svelte';
|
|
8
|
+
import Card from '../atoms/Card.svelte';
|
|
7
9
|
import Icon from '../atoms/Icon.svelte';
|
|
10
|
+
|
|
11
|
+
let el: HTMLDivElement | undefined = $state();
|
|
12
|
+
|
|
13
|
+
$effect(() => {
|
|
14
|
+
if (!el || typeof el.showPopover !== 'function') return;
|
|
15
|
+
try {
|
|
16
|
+
if (toasts.items.length) el.showPopover();
|
|
17
|
+
else el.hidePopover();
|
|
18
|
+
} catch {}
|
|
19
|
+
});
|
|
8
20
|
</script>
|
|
9
21
|
|
|
10
|
-
<div
|
|
22
|
+
<div
|
|
23
|
+
class="toaster"
|
|
24
|
+
bind:this={el}
|
|
25
|
+
popover="manual"
|
|
26
|
+
role="status"
|
|
27
|
+
aria-live="polite"
|
|
28
|
+
aria-relevant="additions"
|
|
29
|
+
data-tsu="Toaster"
|
|
30
|
+
>
|
|
11
31
|
{#each toasts.items as t (t.id)}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
32
|
+
{#if t.action}
|
|
33
|
+
<Card
|
|
34
|
+
as="div"
|
|
35
|
+
surface="raised"
|
|
36
|
+
class="toast"
|
|
37
|
+
tone={t.tone === 'ok' ? 'ok' : t.tone === 'error' ? 'danger' : t.tone === 'info' ? 'info' : undefined}
|
|
38
|
+
>
|
|
39
|
+
<button type="button" class="toast-text" onclick={() => toasts.dismiss(t.id)}>
|
|
40
|
+
{#if t.tone === 'ok'}<Icon name="check" />{:else if t.tone === 'error'}<Icon name="warning" />{:else if t.tone === 'info'}<Icon name="info" />{/if}
|
|
41
|
+
<span class="msg">{t.message}</span>
|
|
42
|
+
</button>
|
|
43
|
+
<Button size="sm" variant="ghost" loading={t.pending} onclick={() => toasts.act(t.id)}>
|
|
44
|
+
{t.action.label}
|
|
45
|
+
</Button>
|
|
46
|
+
</Card>
|
|
47
|
+
{:else}
|
|
48
|
+
<Card
|
|
49
|
+
as="button"
|
|
50
|
+
surface="raised"
|
|
51
|
+
class="toast"
|
|
52
|
+
tone={t.tone === 'ok' ? 'ok' : t.tone === 'error' ? 'danger' : t.tone === 'info' ? 'info' : undefined}
|
|
53
|
+
type="button"
|
|
54
|
+
onclick={() => toasts.dismiss(t.id)}
|
|
55
|
+
>
|
|
56
|
+
{#if t.tone === 'ok'}<Icon name="check" />{:else if t.tone === 'error'}<Icon name="warning" />{:else if t.tone === 'info'}<Icon name="info" />{/if}
|
|
57
|
+
<span class="msg">{t.message}</span>
|
|
58
|
+
<Icon name="x" />
|
|
59
|
+
</Card>
|
|
60
|
+
{/if}
|
|
23
61
|
{/each}
|
|
24
62
|
</div>
|
|
25
63
|
|
|
26
64
|
<style>
|
|
27
65
|
.toaster {
|
|
28
66
|
position: fixed;
|
|
67
|
+
inset: auto;
|
|
29
68
|
left: 50%;
|
|
30
69
|
transform: translateX(-50%);
|
|
31
70
|
bottom: calc(var(--safe-bottom) + var(--sp-4));
|
|
@@ -34,36 +73,53 @@
|
|
|
34
73
|
flex-direction: column;
|
|
35
74
|
gap: var(--sp-2);
|
|
36
75
|
width: calc(100% - var(--sp-8));
|
|
37
|
-
max-width:
|
|
76
|
+
max-width: var(--toast-max-width, 28rem);
|
|
77
|
+
margin: 0;
|
|
78
|
+
padding: 0;
|
|
79
|
+
border: 0;
|
|
80
|
+
background: transparent;
|
|
81
|
+
overflow: visible;
|
|
38
82
|
pointer-events: none;
|
|
39
83
|
}
|
|
40
|
-
.
|
|
84
|
+
.toaster:not(:popover-open) {
|
|
85
|
+
display: none;
|
|
86
|
+
}
|
|
87
|
+
.toaster :global(.toast) {
|
|
41
88
|
pointer-events: auto;
|
|
42
89
|
display: flex;
|
|
43
90
|
align-items: center;
|
|
44
91
|
gap: var(--sp-2);
|
|
45
92
|
width: 100%;
|
|
46
93
|
text-align: left;
|
|
47
|
-
color: var(--text);
|
|
48
|
-
background: var(--bg-elevated-2);
|
|
49
|
-
border: 1px solid var(--border-strong);
|
|
50
|
-
border-radius: var(--r-md);
|
|
51
|
-
padding: var(--sp-3) var(--sp-4);
|
|
52
|
-
box-shadow: var(--shadow-md);
|
|
53
94
|
font-size: var(--fs-sm);
|
|
54
95
|
animation: toast-in 0.18s var(--ease);
|
|
55
96
|
}
|
|
56
|
-
.
|
|
57
|
-
flex: 1;
|
|
58
|
-
}
|
|
59
|
-
.toast.ok {
|
|
60
|
-
border-color: var(--ok);
|
|
97
|
+
.toaster :global(.toast.card-ok) {
|
|
61
98
|
color: var(--ok);
|
|
62
99
|
}
|
|
63
|
-
.toast.
|
|
64
|
-
border-color: var(--danger);
|
|
100
|
+
.toaster :global(.toast.card-danger) {
|
|
65
101
|
color: var(--danger);
|
|
66
102
|
}
|
|
103
|
+
.toaster :global(.toast.card-info) {
|
|
104
|
+
color: var(--info);
|
|
105
|
+
}
|
|
106
|
+
.toast-text {
|
|
107
|
+
flex: 1;
|
|
108
|
+
display: flex;
|
|
109
|
+
align-items: center;
|
|
110
|
+
gap: var(--sp-2);
|
|
111
|
+
min-width: 0;
|
|
112
|
+
padding: 0;
|
|
113
|
+
border: 0;
|
|
114
|
+
background: none;
|
|
115
|
+
color: inherit;
|
|
116
|
+
font: inherit;
|
|
117
|
+
text-align: left;
|
|
118
|
+
cursor: pointer;
|
|
119
|
+
}
|
|
120
|
+
.msg {
|
|
121
|
+
flex: 1;
|
|
122
|
+
}
|
|
67
123
|
@keyframes toast-in {
|
|
68
124
|
from {
|
|
69
125
|
transform: translateY(8px);
|
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
$$bindings?: Bindings;
|
|
4
|
-
} & Exports;
|
|
5
|
-
(internal: unknown, props: {
|
|
6
|
-
$$events?: Events;
|
|
7
|
-
$$slots?: Slots;
|
|
8
|
-
}): Exports & {
|
|
9
|
-
$set?: any;
|
|
10
|
-
$on?: any;
|
|
11
|
-
};
|
|
12
|
-
z_$$bindings?: Bindings;
|
|
13
|
-
}
|
|
14
|
-
declare const Toaster: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
|
|
15
|
-
[evt: string]: CustomEvent<any>;
|
|
16
|
-
}, {}, {}, string>;
|
|
17
|
-
type Toaster = InstanceType<typeof Toaster>;
|
|
1
|
+
declare const Toaster: import("svelte").Component<Record<string, never>, {}, "">;
|
|
2
|
+
type Toaster = ReturnType<typeof Toaster>;
|
|
18
3
|
export default Toaster;
|
package/dist/index.d.ts
CHANGED
|
@@ -58,6 +58,6 @@ export { default as FilterSearchBar } from './components/organisms/FilterSearchB
|
|
|
58
58
|
export { type AndNode, activeToken, compilePredicate, defaultOperator, type ExprNode, type FieldDef, type FieldType, type FilterNode, filters, findField, freeText, type LeafNode, type NotNode, OPERATORS, type Operator, type OperatorId, type OrNode, operatorByCode, operatorById, operatorsFor, parse, type Query, type QueryNode, resolveValues, type Schema, type Suggestion, type SuggestKind, type SuggestState, serialize, serializeFilter, suggest, type TextNode, toSql, type ValueContext, type ValueOption, type ValueProvider, walk, } from './query';
|
|
59
59
|
export { fontScale, SCALE_LEVELS, type ScaleLevel } from './stores/fontscale.svelte';
|
|
60
60
|
export { type Mode, THEMES, theme } from './stores/theme.svelte';
|
|
61
|
-
export { type Toast, type ToastTone, toasts } from './stores/toast.svelte';
|
|
61
|
+
export { type Toast, type ToastAction, type ToastOptions, type ToastTone, type ToastToneInput, toasts, } from './stores/toast.svelte';
|
|
62
62
|
export { formatTimestamp, localTimeZone, relativeTime, type TimeInput, type TimestampMode, } from './timestamp';
|
|
63
63
|
export { type TruncateMode, type TruncateOptions, truncate } from './truncate';
|
package/dist/index.js
CHANGED
|
@@ -71,6 +71,6 @@ export { activeToken, compilePredicate, defaultOperator, filters, findField, fre
|
|
|
71
71
|
export { fontScale, SCALE_LEVELS } from './stores/fontscale.svelte';
|
|
72
72
|
// ---- stores / actions ----
|
|
73
73
|
export { THEMES, theme } from './stores/theme.svelte';
|
|
74
|
-
export { toasts } from './stores/toast.svelte';
|
|
74
|
+
export { toasts, } from './stores/toast.svelte';
|
|
75
75
|
export { formatTimestamp, localTimeZone, relativeTime, } from './timestamp';
|
|
76
76
|
export { truncate } from './truncate';
|
|
@@ -1,19 +1,37 @@
|
|
|
1
|
-
export type ToastTone = 'neutral' | 'ok' | 'error';
|
|
1
|
+
export type ToastTone = 'neutral' | 'ok' | 'error' | 'info';
|
|
2
|
+
/** Accepted by show(); 'danger' is normalised to 'error'. */
|
|
3
|
+
export type ToastToneInput = ToastTone | 'danger';
|
|
4
|
+
export interface ToastAction {
|
|
5
|
+
label: string;
|
|
6
|
+
run: () => void | Promise<void>;
|
|
7
|
+
}
|
|
2
8
|
export interface Toast {
|
|
3
9
|
id: number;
|
|
4
10
|
message: string;
|
|
5
11
|
tone: ToastTone;
|
|
6
12
|
duration: number;
|
|
13
|
+
action?: ToastAction;
|
|
14
|
+
/** True while `action.run` is in flight; the Toaster shows the button loading. */
|
|
15
|
+
pending: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface ToastOptions {
|
|
18
|
+
tone?: ToastToneInput;
|
|
19
|
+
duration?: number;
|
|
20
|
+
action?: ToastAction;
|
|
7
21
|
}
|
|
22
|
+
export declare const TOAST_MS = 4000;
|
|
23
|
+
/** Toasts with an action linger longer: the action is only reachable while visible. */
|
|
24
|
+
export declare const ACTION_TOAST_MS = 7000;
|
|
8
25
|
declare class Toasts {
|
|
26
|
+
#private;
|
|
9
27
|
items: Toast[];
|
|
10
|
-
show(message: string, opts?:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
ok(message: string, duration?: number): number;
|
|
15
|
-
error(message: string, duration?: number): number;
|
|
28
|
+
show(message: string, opts?: ToastOptions): number;
|
|
29
|
+
ok(message: string, duration?: number, action?: ToastAction): number;
|
|
30
|
+
error(message: string, duration?: number, action?: ToastAction): number;
|
|
31
|
+
info(message: string, duration?: number, action?: ToastAction): number;
|
|
16
32
|
dismiss(id: number): void;
|
|
33
|
+
/** Run a toast's action once: the toast stays (button loading) until run settles, then dismisses. */
|
|
34
|
+
act(id: number): Promise<void>;
|
|
17
35
|
}
|
|
18
36
|
export declare const toasts: Toasts;
|
|
19
37
|
export {};
|
|
@@ -1,26 +1,60 @@
|
|
|
1
1
|
// Toast manager. A tiny reactive queue; mount one <Toaster /> at the app root
|
|
2
|
-
// and call toasts.show()/ok()/error() from anywhere. Auto-dismisses after
|
|
2
|
+
// and call toasts.show()/ok()/error()/info() from anywhere. Auto-dismisses after
|
|
3
3
|
// `duration` ms (0 = sticky). The Toaster renders an aria-live region so screen
|
|
4
4
|
// readers announce messages.
|
|
5
|
+
export const TOAST_MS = 4000;
|
|
6
|
+
/** Toasts with an action linger longer: the action is only reachable while visible. */
|
|
7
|
+
export const ACTION_TOAST_MS = 7000;
|
|
5
8
|
let seq = 0;
|
|
6
9
|
class Toasts {
|
|
7
10
|
items = $state([]);
|
|
11
|
+
#timers = new Map();
|
|
8
12
|
show(message, opts = {}) {
|
|
9
13
|
const id = ++seq;
|
|
10
|
-
const duration = opts.duration ??
|
|
11
|
-
|
|
14
|
+
const duration = opts.duration ?? (opts.action ? ACTION_TOAST_MS : TOAST_MS);
|
|
15
|
+
const tone = opts.tone === 'danger' ? 'error' : (opts.tone ?? 'neutral');
|
|
16
|
+
this.items = [
|
|
17
|
+
...this.items,
|
|
18
|
+
{ id, message, tone, duration, action: opts.action, pending: false },
|
|
19
|
+
];
|
|
12
20
|
if (duration > 0)
|
|
13
|
-
setTimeout(() => this.dismiss(id), duration);
|
|
21
|
+
this.#timers.set(id, setTimeout(() => this.dismiss(id), duration));
|
|
14
22
|
return id;
|
|
15
23
|
}
|
|
16
|
-
ok(message, duration) {
|
|
17
|
-
return this.show(message, { tone: 'ok', duration });
|
|
24
|
+
ok(message, duration, action) {
|
|
25
|
+
return this.show(message, { tone: 'ok', duration, action });
|
|
18
26
|
}
|
|
19
|
-
error(message, duration) {
|
|
20
|
-
return this.show(message, { tone: 'error', duration });
|
|
27
|
+
error(message, duration, action) {
|
|
28
|
+
return this.show(message, { tone: 'error', duration, action });
|
|
29
|
+
}
|
|
30
|
+
info(message, duration, action) {
|
|
31
|
+
return this.show(message, { tone: 'info', duration, action });
|
|
21
32
|
}
|
|
22
33
|
dismiss(id) {
|
|
34
|
+
const timer = this.#timers.get(id);
|
|
35
|
+
if (timer)
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
this.#timers.delete(id);
|
|
23
38
|
this.items = this.items.filter((t) => t.id !== id);
|
|
24
39
|
}
|
|
40
|
+
/** Run a toast's action once: the toast stays (button loading) until run settles, then dismisses. */
|
|
41
|
+
async act(id) {
|
|
42
|
+
const t = this.items.find((x) => x.id === id);
|
|
43
|
+
if (!t?.action || t.pending)
|
|
44
|
+
return;
|
|
45
|
+
const timer = this.#timers.get(id);
|
|
46
|
+
if (timer)
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
this.items = this.items.map((x) => (x.id === id ? { ...x, pending: true } : x));
|
|
49
|
+
try {
|
|
50
|
+
await t.action.run();
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
this.error(e instanceof Error ? e.message : String(e));
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
this.dismiss(id);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
25
59
|
}
|
|
26
60
|
export const toasts = new Toasts();
|
package/dist/styles/app.css
CHANGED
|
@@ -106,9 +106,7 @@ pre {
|
|
|
106
106
|
width: 100%;
|
|
107
107
|
max-width: var(--content-max);
|
|
108
108
|
margin-inline: auto;
|
|
109
|
-
padding-inline: var(--sp-4);
|
|
110
|
-
padding-left: max(var(--sp-4), var(--safe-left));
|
|
111
|
-
padding-right: max(var(--sp-4), var(--safe-right));
|
|
109
|
+
padding-inline: max(var(--sp-4), var(--safe-left)) max(var(--sp-4), var(--safe-right));
|
|
112
110
|
}
|
|
113
111
|
.stack {
|
|
114
112
|
display: flex;
|
package/package.json
CHANGED