@dorsk/tsumikit 0.19.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,12 +69,16 @@ 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, Badge, Dot, Link, Icon (open registry pass a
73
- `children` snippet for any custom SVG).
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,
77
- Dropzone, CodeBlock, Toaster, ThemePicker, FontScalePicker.
81
+ Dropzone, CodeBlock, Callout, EmptyState, Toaster, ThemePicker, FontScalePicker.
78
82
 
79
83
  **Organisms:** DataTable (generic `<T>`, typed columns + cell snippets).
80
84
 
@@ -84,6 +88,46 @@ sidebar on desktop, overlay drawer on mobile, optionally resizable), NavItem
84
88
  (vertical), Cluster (wrapping row), AutoGrid (intrinsically responsive columns —
85
89
  no media/container query needed).
86
90
 
91
+ ### Stacked distribution + legend
92
+
93
+ `SegmentedProgress mode="stacked"` turns the bar into one shared track whose slice
94
+ widths follow `value` (not `max`), fully filled, no gaps; a zero value collapses to
95
+ nothing. Pass a top-level `max` to show the remainder as empty track. `legend`
96
+ renders a dot + label + count per segment (`true`/`'below'` or `'inline'`), or takes
97
+ a snippet for custom rendering. In this mode the bar is `role="img"`, labelled from
98
+ the segments. `gap` (px or CSS length, default `2`) applies in segments mode, and
99
+ `tone: 'ok'` is an alias of `'success'`.
100
+
101
+ ```svelte
102
+ <SegmentedProgress
103
+ mode="stacked"
104
+ label="Analysis"
105
+ max={analyzedCount}
106
+ legend
107
+ segments={[
108
+ { value: 412, max: 0, tone: 'ok', label: 'conforming' },
109
+ { value: 12, max: 0, tone: 'warn', label: 'nonconforming' },
110
+ { value: 0, max: 0, tone: 'danger', label: 'blocked' }
111
+ ]}
112
+ />
113
+ ```
114
+
115
+ ### Tinted surfaces & Callout
116
+
117
+ `Card` takes `tone="neutral" | "ok" | "warn" | "danger" | "info"` (default
118
+ `neutral`, unchanged look) to tint its border and wash its background with the
119
+ semantic hue. `Callout` builds on it for inline messages: leading glyph (auto
120
+ per tone, or `icon`), optional `title`, body, right-aligned `actions` snippet,
121
+ `dismissible` + `ondismiss`, and `busy` to show a Spinner while work runs. It
122
+ is a live region — `role="status"`, or `role="alert"` when `tone="danger"`.
123
+
124
+ ```svelte
125
+ <Callout tone="danger" title="Search failed" dismissible ondismiss={clear}>
126
+ The provider returned 503.
127
+ {#snippet actions()}<Button size="sm" onclick={retry}>Retry</Button>{/snippet}
128
+ </Callout>
129
+ ```
130
+
87
131
  ## Container queries
88
132
 
89
133
  AppShell's `main` and `sidebar` are query containers (`container-name: main` /
@@ -95,6 +139,13 @@ Use the `.cq-*` utilities (`.cq-hide`, `.cq-stack`, `.cq-truncate`,
95
139
  you drag the sidebar down to that icon rail (width persisted).
96
140
 
97
141
  **Stores:** `theme`, `toasts`, `fontScale` (opt-in). **Actions:** `autoresize`.
142
+
143
+ `toasts.show(message, { tone, duration, action })` plus the `ok`/`error`/`info`
144
+ shorthands (tones `neutral|ok|error|info`; `danger` aliases `error`). An `action`
145
+ (`{ label, run }`) renders a button; the toast shows a loading state while `run`
146
+ settles, then dismisses, and action toasts stay 7s instead of 4s (`duration: 0` is
147
+ sticky). `Toaster` renders in the browser top layer (`popover="manual"`), so toasts
148
+ paint above an open `Modal`; `--toast-max-width` (28rem) caps the stack.
98
149
  **Helpers:** `copyToClipboard(text)` — async Clipboard API with an
99
150
  insecure-context fallback; returns whether it succeeded.
100
151
 
@@ -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` (neutral/ok/warn/danger/info) semantic palette
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>. `size="sm"` is the compact form for counts/dense rows.
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
- size?: 'sm' | 'md';
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
- {@render children?.()}
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
- padding: 0 0.4rem;
94
- font-size: 0.6875rem;
145
+ .badge-sm,
146
+ .badge-xs {
147
+ font-size: calc(var(--fs-xs) * 0.92);
95
148
  gap: 0.15rem;
96
149
  }
97
- .badge-ok {
98
- color: var(--ok);
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-warn {
103
- color: var(--warn);
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
- .badge-danger {
108
- color: var(--danger);
109
- border-color: color-mix(in srgb, var(--danger) 40%, transparent);
110
- background: color-mix(in srgb, var(--danger) 12%, transparent);
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
- .badge-info {
113
- color: var(--info);
114
- border-color: color-mix(in srgb, var(--info) 40%, transparent);
115
- background: color-mix(in srgb, var(--info) 12%, transparent);
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
- /* Interactive "on" state: fill the pill with its tone. Falls back to the
132
- neutral accent when no semantic tone is set. */
133
- .active {
134
- color: var(--text-on-accent);
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
- .badge-warn.active {
142
- --badge-fill: var(--warn);
186
+ .truncate {
187
+ min-width: 0;
143
188
  }
144
- .badge-danger.active {
145
- --badge-fill: var(--danger);
189
+ .clip {
190
+ min-width: 0;
191
+ overflow: hidden;
192
+ text-overflow: ellipsis;
193
+ white-space: nowrap;
146
194
  }
147
- .badge-info.active {
148
- --badge-fill: var(--info);
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-fill, var(--accent));
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
- type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
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;
@@ -4,6 +4,8 @@
4
4
  // interactive hover/active affordance for tappable list items (e.g. session
5
5
  // rows); `as` lets it be a button/anchor when the whole surface is clickable.
6
6
  // `padding` dials the inner spacing (none/sm/md/lg) for denser cards.
7
+ // `tone` tints the surface itself (border + faint background wash) with a
8
+ // semantic hue for inline banners; `neutral` is the plain card.
7
9
  //
8
10
  // `stacked` fakes a pile of cards by drawing two layers peeking out below
9
11
  // (and optionally to the right) via pseudo-elements. `stackTone` tints those
@@ -20,6 +22,7 @@
20
22
  as = 'div',
21
23
  padding = 'md',
22
24
  surface = 'base',
25
+ tone = 'neutral',
23
26
  stacked = false,
24
27
  stackTone = 'neutral',
25
28
  stackY = 8,
@@ -33,6 +36,7 @@
33
36
  as?: 'div' | 'button' | 'a' | 'li' | 'section' | 'form';
34
37
  padding?: 'none' | 'sm' | 'md' | 'lg';
35
38
  surface?: 'base' | 'raised' | 'sunken';
39
+ tone?: Tone;
36
40
  stacked?: boolean;
37
41
  stackTone?: Tone;
38
42
  stackY?: number;
@@ -58,6 +62,10 @@
58
62
  class:surface-raised={surface === 'raised'}
59
63
  class:surface-sunken={surface === 'sunken'}
60
64
  class:card-tap={tap}
65
+ class:card-ok={tone === 'ok'}
66
+ class:card-warn={tone === 'warn'}
67
+ class:card-danger={tone === 'danger'}
68
+ class:card-info={tone === 'info'}
61
69
  class:card-stacked={stacked}
62
70
  class:stack-ok={stacked && stackTone === 'ok'}
63
71
  class:stack-warn={stacked && stackTone === 'warn'}
@@ -107,6 +115,26 @@
107
115
  border-color: var(--border-strong);
108
116
  }
109
117
 
118
+ .card-ok {
119
+ --card-tone: var(--ok);
120
+ }
121
+ .card-warn {
122
+ --card-tone: var(--warn);
123
+ }
124
+ .card-danger {
125
+ --card-tone: var(--danger);
126
+ }
127
+ .card-info {
128
+ --card-tone: var(--info);
129
+ }
130
+ .card-ok,
131
+ .card-warn,
132
+ .card-danger,
133
+ .card-info {
134
+ border-color: color-mix(in srgb, var(--card-tone) 55%, var(--border));
135
+ background: color-mix(in srgb, var(--card-tone) 8%, var(--bg-elevated));
136
+ }
137
+
110
138
  /* Stacked effect — two back layers peeking out bottom-right. The front
111
139
  surface keeps its own background so the layers only show at the edges. */
112
140
  .card-stacked {
@@ -5,6 +5,7 @@ type $$ComponentProps = {
5
5
  as?: 'div' | 'button' | 'a' | 'li' | 'section' | 'form';
6
6
  padding?: 'none' | 'sm' | 'md' | 'lg';
7
7
  surface?: 'base' | 'raised' | 'sunken';
8
+ tone?: Tone;
8
9
  stacked?: boolean;
9
10
  stackTone?: Tone;
10
11
  stackY?: number;
@@ -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. With a `label` the whole
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>
@@ -4,6 +4,7 @@ type $$ComponentProps = {
4
4
  color?: string;
5
5
  label?: string;
6
6
  glow?: boolean;
7
+ ring?: boolean;
7
8
  class?: string;
8
9
  [key: string]: unknown;
9
10
  };
@@ -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
- }: { href?: string; class?: string; children?: Snippet; [key: string]: unknown } = $props();
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 {href} class="link {klass}" data-tsu="Link" {...rest}>{@render children?.()}</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 type="button" class="link {klass}" data-tsu="Link" {...rest}>{@render children?.()}</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;
@@ -1,67 +1,156 @@
1
1
  <script lang="ts" module>
2
- // One bar split into proportional segments (e.g. one per season of a series),
3
- // each filled value/max with its own tone. Token-styled, thin separators.
2
+ // One bar split into segments, each with its own tone. Two modes:
3
+ // segments (default): widths ∝ `max`, each filled value/max, thin gaps.
4
+ // • stacked: one shared track, widths ∝ `value` (Σvalue = full width, or
5
+ // `max` when given), every slice 100 % filled, no gaps; a zero value
6
+ // collapses to 0 width.
4
7
  export type ProgressSegment = {
5
8
  value: number;
9
+ // Ignored in `stacked` mode.
6
10
  max: number;
7
- // Fill colour per segment. `muted` renders a faint fill for empty parts.
8
- tone?: 'accent' | 'success' | 'warn' | 'danger' | 'muted';
9
- // Native tooltip / accessible name for the segment.
11
+ // Fill colour per segment. `ok` is an alias of `success`; `muted` renders a
12
+ // faint fill for empty parts.
13
+ tone?: 'accent' | 'success' | 'ok' | 'warn' | 'danger' | 'muted';
14
+ // Native tooltip / accessible name / legend caption for the segment.
10
15
  label?: string;
11
16
  };
17
+
18
+ export const TONE_FILL: Record<NonNullable<ProgressSegment['tone']>, string> = {
19
+ accent: 'var(--accent)',
20
+ success: 'var(--ok)',
21
+ ok: 'var(--ok)',
22
+ warn: 'var(--warn)',
23
+ danger: 'var(--danger)',
24
+ muted: 'var(--text-faint)',
25
+ };
12
26
  </script>
13
27
 
14
28
  <script lang="ts">
29
+ import type { Snippet } from 'svelte';
30
+ import Dot from './Dot.svelte';
31
+ import Text from './Text.svelte';
32
+
15
33
  let {
16
34
  segments,
17
35
  label,
18
36
  size = 'md',
19
- class: klass = ''
37
+ mode = 'segments',
38
+ gap = 2,
39
+ max,
40
+ legend = false,
41
+ class: klass = '',
20
42
  }: {
21
43
  segments: ProgressSegment[];
22
44
  label?: string;
23
45
  // Track height. `sm` is a thin ~5px track for inline rows.
24
46
  size?: 'sm' | 'md';
47
+ mode?: 'segments' | 'stacked';
48
+ // Space between segments in `segments` mode; a bare number is px.
49
+ gap?: number | string;
50
+ // `stacked` only: total the slices are measured against. When Σvalue < max
51
+ // the remainder shows the empty track.
52
+ max?: number;
53
+ // `true` = 'below'. A snippet receives the segments for custom rendering.
54
+ legend?: boolean | 'inline' | 'below' | Snippet<[ProgressSegment[]]>;
25
55
  class?: string;
26
56
  } = $props();
27
57
 
58
+ const stacked = $derived(mode === 'stacked');
28
59
  const totalMax = $derived(segments.reduce((s, seg) => s + Math.max(0, seg.max), 0));
29
60
  const totalValue = $derived(
30
- segments.reduce((s, seg) => s + Math.max(0, Math.min(seg.value, seg.max)), 0)
61
+ segments.reduce((s, seg) => s + Math.max(0, Math.min(seg.value, seg.max)), 0),
62
+ );
63
+ const stackedTotal = $derived(segments.reduce((s, seg) => s + Math.max(0, seg.value), 0));
64
+ const remainder = $derived(max === undefined ? 0 : Math.max(0, max - stackedTotal));
65
+ const gapCss = $derived(stacked ? '0' : typeof gap === 'number' ? `${gap}px` : gap);
66
+ const legendPlacement = $derived(
67
+ legend === true ? 'below' : legend === 'inline' || legend === 'below' ? legend : null,
68
+ );
69
+ const legendSnippet = $derived(typeof legend === 'function' ? legend : null);
70
+ const hasLegend = $derived(legendPlacement !== null || legendSnippet !== null);
71
+ const stackedLabel = $derived(
72
+ [label, segments.map((seg) => `${seg.label ?? seg.tone ?? 'accent'} ${seg.value}`).join(', ')]
73
+ .filter(Boolean)
74
+ .join(': '),
31
75
  );
32
76
 
33
77
  function pct(seg: ProgressSegment): number {
78
+ if (stacked) return 100;
34
79
  if (seg.max <= 0) return 0;
35
80
  return Math.max(0, Math.min(100, (seg.value / seg.max) * 100));
36
81
  }
82
+
83
+ function grow(seg: ProgressSegment): number {
84
+ return stacked ? Math.max(0, seg.value) : Math.max(seg.max, 1);
85
+ }
37
86
  </script>
38
87
 
39
- <div
40
- data-tsu="SegmentedProgress"
41
- class="segmented-progress size-{size} {klass}"
42
- role="progressbar"
43
- aria-label={label}
44
- aria-valuemin={0}
45
- aria-valuemax={totalMax}
46
- aria-valuenow={totalValue}
47
- >
48
- {#each segments as seg, i (i)}
88
+ {#snippet bar(rootClass: string)}
89
+ {#if stacked}
49
90
  <div
50
- class="segment tone-{seg.tone ?? 'accent'}"
51
- style="flex-grow: {Math.max(seg.max, 1)}"
52
- title={seg.label}
91
+ data-tsu="SegmentedProgress"
92
+ class="segmented-progress stacked size-{size} {rootClass}"
93
+ style="gap: {gapCss}"
94
+ role="img"
95
+ aria-label={stackedLabel}
53
96
  >
54
- <div class="bar" style="width: {pct(seg)}%"></div>
97
+ {#each segments as seg, i (i)}
98
+ <div class="segment tone-{seg.tone ?? 'accent'}" style="flex-grow: {grow(seg)}" title={seg.label}>
99
+ <div class="bar" style="width: {pct(seg)}%"></div>
100
+ </div>
101
+ {/each}
102
+ {#if remainder > 0}
103
+ <div class="segment remainder" style="flex-grow: {remainder}"></div>
104
+ {/if}
55
105
  </div>
56
- {/each}
57
- </div>
106
+ {:else}
107
+ <div
108
+ data-tsu="SegmentedProgress"
109
+ class="segmented-progress size-{size} {rootClass}"
110
+ style="gap: {gapCss}"
111
+ role="progressbar"
112
+ aria-label={label}
113
+ aria-valuemin={0}
114
+ aria-valuemax={totalMax}
115
+ aria-valuenow={totalValue}
116
+ >
117
+ {#each segments as seg, i (i)}
118
+ <div class="segment tone-{seg.tone ?? 'accent'}" style="flex-grow: {grow(seg)}" title={seg.label}>
119
+ <div class="bar" style="width: {pct(seg)}%"></div>
120
+ </div>
121
+ {/each}
122
+ </div>
123
+ {/if}
124
+ {/snippet}
125
+
126
+ {#if hasLegend}
127
+ <div data-tsu="SegmentedProgress" class="segmented-progress-wrap legend-{legendPlacement ?? 'below'} {klass}">
128
+ {@render bar('')}
129
+ {#if legendSnippet}
130
+ {@render legendSnippet(segments)}
131
+ {:else}
132
+ <ul class="legend">
133
+ {#each segments as seg, i (i)}
134
+ <li class="legend-item">
135
+ <Dot color={TONE_FILL[seg.tone ?? 'accent']} />
136
+ {#if seg.label}
137
+ <Text variant="caption">{seg.label}</Text>
138
+ {/if}
139
+ <Text variant="caption" weight="medium" numeric>{seg.value}</Text>
140
+ </li>
141
+ {/each}
142
+ </ul>
143
+ {/if}
144
+ </div>
145
+ {:else}
146
+ {@render bar(klass)}
147
+ {/if}
58
148
 
59
149
  <style>
60
150
  .segmented-progress {
61
151
  display: flex;
62
152
  width: 100%;
63
153
  height: 0.5rem;
64
- gap: 2px;
65
154
  border-radius: var(--r-pill);
66
155
  }
67
156
  .segmented-progress.size-sm {
@@ -87,7 +176,8 @@
87
176
  background: var(--fill, var(--accent));
88
177
  transition: width 0.2s var(--ease);
89
178
  }
90
- .tone-success {
179
+ .tone-success,
180
+ .tone-ok {
91
181
  --fill: var(--ok);
92
182
  }
93
183
  .tone-warn {
@@ -99,4 +189,44 @@
99
189
  .tone-muted {
100
190
  --fill: var(--text-faint);
101
191
  }
192
+
193
+ .stacked {
194
+ overflow: hidden;
195
+ background: var(--bg-elevated-2);
196
+ }
197
+ .stacked .segment {
198
+ min-width: 0;
199
+ border-radius: 0;
200
+ background: transparent;
201
+ transition: flex-grow 0.2s var(--ease);
202
+ }
203
+
204
+ .segmented-progress-wrap {
205
+ display: flex;
206
+ width: 100%;
207
+ gap: var(--sp-2);
208
+ }
209
+ .legend-below {
210
+ flex-direction: column;
211
+ }
212
+ .legend-inline {
213
+ align-items: center;
214
+ }
215
+ .legend-inline .segmented-progress {
216
+ flex: 1 1 0%;
217
+ width: auto;
218
+ }
219
+ .legend {
220
+ display: flex;
221
+ flex-wrap: wrap;
222
+ gap: var(--sp-1) var(--sp-3);
223
+ margin: 0;
224
+ padding: 0;
225
+ list-style: none;
226
+ }
227
+ .legend-item {
228
+ display: inline-flex;
229
+ align-items: center;
230
+ gap: var(--sp-1);
231
+ }
102
232
  </style>
@@ -1,13 +1,19 @@
1
1
  export type ProgressSegment = {
2
2
  value: number;
3
3
  max: number;
4
- tone?: 'accent' | 'success' | 'warn' | 'danger' | 'muted';
4
+ tone?: 'accent' | 'success' | 'ok' | 'warn' | 'danger' | 'muted';
5
5
  label?: string;
6
6
  };
7
+ export declare const TONE_FILL: Record<NonNullable<ProgressSegment['tone']>, string>;
8
+ import type { Snippet } from 'svelte';
7
9
  type $$ComponentProps = {
8
10
  segments: ProgressSegment[];
9
11
  label?: string;
10
12
  size?: 'sm' | 'md';
13
+ mode?: 'segments' | 'stacked';
14
+ gap?: number | string;
15
+ max?: number;
16
+ legend?: boolean | 'inline' | 'below' | Snippet<[ProgressSegment[]]>;
11
17
  class?: string;
12
18
  };
13
19
  declare const SegmentedProgress: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -0,0 +1,144 @@
1
+ <script lang="ts">
2
+ // Inline message banner — the one way to say "heads up" inside a page:
3
+ // tone-tinted Card with a leading glyph, optional title, body, right-aligned
4
+ // actions and an optional dismiss button. `busy` swaps the glyph for a
5
+ // Spinner (auto-search / long-running states). Announced as a live region:
6
+ // `status` normally, `alert` for danger so errors interrupt.
7
+ import type { Snippet } from 'svelte';
8
+ import Card from '../atoms/Card.svelte';
9
+ import Icon, { type IconName } from '../atoms/Icon.svelte';
10
+ import Spinner from '../atoms/Spinner.svelte';
11
+ import IconButton from './IconButton.svelte';
12
+
13
+ type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
14
+
15
+ const DEFAULT_ICON: Record<Tone, IconName> = {
16
+ neutral: 'info',
17
+ info: 'info',
18
+ ok: 'check',
19
+ warn: 'warning',
20
+ danger: 'warning'
21
+ };
22
+
23
+ let {
24
+ tone = 'info',
25
+ icon,
26
+ title,
27
+ dismissible = false,
28
+ dismissLabel = 'Dismiss',
29
+ ondismiss,
30
+ busy = false,
31
+ class: klass = '',
32
+ children,
33
+ actions,
34
+ ...rest
35
+ }: {
36
+ tone?: Tone;
37
+ icon?: IconName;
38
+ title?: string;
39
+ dismissible?: boolean;
40
+ dismissLabel?: string;
41
+ ondismiss?: () => void;
42
+ busy?: boolean;
43
+ class?: string;
44
+ children?: Snippet;
45
+ actions?: Snippet;
46
+ [key: string]: unknown;
47
+ } = $props();
48
+
49
+ let glyph = $derived(icon ?? DEFAULT_ICON[tone]);
50
+ </script>
51
+
52
+ <Card
53
+ {tone}
54
+ padding="none"
55
+ role={tone === 'danger' ? 'alert' : 'status'}
56
+ class="callout callout-{tone} {klass}"
57
+ data-tsu="Callout"
58
+ {...rest}
59
+ >
60
+ <span class="callout-icon" aria-hidden={busy ? undefined : true}>
61
+ {#if busy}
62
+ <Spinner />
63
+ {:else}
64
+ <Icon name={glyph} />
65
+ {/if}
66
+ </span>
67
+ <div class="callout-body">
68
+ {#if title}<p class="callout-title">{title}</p>{/if}
69
+ {#if children}<div class="callout-text">{@render children()}</div>{/if}
70
+ </div>
71
+ {#if actions}
72
+ <div class="callout-actions">{@render actions()}</div>
73
+ {/if}
74
+ {#if dismissible}
75
+ <span class="callout-dismiss">
76
+ <IconButton icon="x" inline label={dismissLabel} onclick={() => ondismiss?.()} />
77
+ </span>
78
+ {/if}
79
+ </Card>
80
+
81
+ <style>
82
+ :global(.card.callout) {
83
+ --callout-tone: var(--text-muted);
84
+ display: flex;
85
+ flex-wrap: wrap;
86
+ align-items: flex-start;
87
+ gap: var(--sp-3);
88
+ padding: var(--sp-3);
89
+ }
90
+ :global(.callout-ok) {
91
+ --callout-tone: var(--ok);
92
+ }
93
+ :global(.callout-warn) {
94
+ --callout-tone: var(--warn);
95
+ }
96
+ :global(.callout-danger) {
97
+ --callout-tone: var(--danger);
98
+ }
99
+ :global(.callout-info) {
100
+ --callout-tone: var(--info);
101
+ }
102
+
103
+ .callout-icon {
104
+ display: inline-flex;
105
+ flex: none;
106
+ align-items: center;
107
+ color: var(--callout-tone);
108
+ font-size: 1.125rem;
109
+ line-height: 1;
110
+ min-height: 1.4em;
111
+ }
112
+ .callout-body {
113
+ flex: 1 1 12rem;
114
+ min-width: 0;
115
+ display: flex;
116
+ flex-direction: column;
117
+ gap: var(--sp-1);
118
+ font-size: var(--fs-sm);
119
+ line-height: 1.5;
120
+ color: var(--text);
121
+ }
122
+ .callout-title {
123
+ margin: 0;
124
+ font-weight: var(--fw-semibold);
125
+ }
126
+ .callout-text {
127
+ overflow-wrap: anywhere;
128
+ }
129
+ .callout-actions {
130
+ display: flex;
131
+ flex-wrap: wrap;
132
+ align-items: center;
133
+ gap: var(--sp-2);
134
+ margin-left: auto;
135
+ }
136
+ .callout-dismiss {
137
+ display: inline-flex;
138
+ flex: none;
139
+ margin-left: auto;
140
+ }
141
+ .callout-actions + .callout-dismiss {
142
+ margin-left: 0;
143
+ }
144
+ </style>
@@ -0,0 +1,19 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type IconName } from '../atoms/Icon.svelte';
3
+ type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
4
+ type $$ComponentProps = {
5
+ tone?: Tone;
6
+ icon?: IconName;
7
+ title?: string;
8
+ dismissible?: boolean;
9
+ dismissLabel?: string;
10
+ ondismiss?: () => void;
11
+ busy?: boolean;
12
+ class?: string;
13
+ children?: Snippet;
14
+ actions?: Snippet;
15
+ [key: string]: unknown;
16
+ };
17
+ declare const Callout: import("svelte").Component<$$ComponentProps, {}, "">;
18
+ type Callout = ReturnType<typeof Callout>;
19
+ export default Callout;
@@ -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. Each toast is tap/click-dismissible; screen readers
4
- // announce additions via aria-live. Sits in its own stacking context above
5
- // page content but below modals' top layer.
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 class="toaster" role="status" aria-live="polite" aria-relevant="additions" data-tsu="Toaster">
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
- <button
13
- type="button"
14
- class="toast"
15
- class:ok={t.tone === 'ok'}
16
- class:err={t.tone === 'error'}
17
- onclick={() => toasts.dismiss(t.id)}
18
- >
19
- {#if t.tone === 'ok'}<Icon name="check" />{:else if t.tone === 'error'}<Icon name="warning" />{/if}
20
- <span class="msg">{t.message}</span>
21
- <Icon name="x" />
22
- </button>
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: 30rem;
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
- .toast {
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
- .toast .msg {
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.err {
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
- interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
- new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
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
@@ -28,6 +28,7 @@ export { default as ResizablePanel } from './components/layouts/ResizablePanel.s
28
28
  export { default as Stack } from './components/layouts/Stack.svelte';
29
29
  export { type AccordionItem, default as Accordion, } from './components/molecules/Accordion.svelte';
30
30
  export { type BreadcrumbItem, default as Breadcrumb, } from './components/molecules/Breadcrumb.svelte';
31
+ export { default as Callout } from './components/molecules/Callout.svelte';
31
32
  export { default as CodeBlock } from './components/molecules/CodeBlock.svelte';
32
33
  export { default as CopyButton } from './components/molecules/CopyButton.svelte';
33
34
  export { default as Dropzone } from './components/molecules/Dropzone.svelte';
@@ -57,6 +58,6 @@ export { default as FilterSearchBar } from './components/organisms/FilterSearchB
57
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';
58
59
  export { fontScale, SCALE_LEVELS, type ScaleLevel } from './stores/fontscale.svelte';
59
60
  export { type Mode, THEMES, theme } from './stores/theme.svelte';
60
- 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';
61
62
  export { formatTimestamp, localTimeZone, relativeTime, type TimeInput, type TimestampMode, } from './timestamp';
62
63
  export { type TruncateMode, type TruncateOptions, truncate } from './truncate';
package/dist/index.js CHANGED
@@ -35,6 +35,7 @@ export { default as ResizablePanel } from './components/layouts/ResizablePanel.s
35
35
  export { default as Stack } from './components/layouts/Stack.svelte';
36
36
  export { default as Accordion, } from './components/molecules/Accordion.svelte';
37
37
  export { default as Breadcrumb, } from './components/molecules/Breadcrumb.svelte';
38
+ export { default as Callout } from './components/molecules/Callout.svelte';
38
39
  export { default as CodeBlock } from './components/molecules/CodeBlock.svelte';
39
40
  export { default as CopyButton } from './components/molecules/CopyButton.svelte';
40
41
  export { default as Dropzone } from './components/molecules/Dropzone.svelte';
@@ -70,6 +71,6 @@ export { activeToken, compilePredicate, defaultOperator, filters, findField, fre
70
71
  export { fontScale, SCALE_LEVELS } from './stores/fontscale.svelte';
71
72
  // ---- stores / actions ----
72
73
  export { THEMES, theme } from './stores/theme.svelte';
73
- export { toasts } from './stores/toast.svelte';
74
+ export { toasts, } from './stores/toast.svelte';
74
75
  export { formatTimestamp, localTimeZone, relativeTime, } from './timestamp';
75
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
- tone?: ToastTone;
12
- duration?: number;
13
- }): number;
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 ?? 4000;
11
- this.items = [...this.items, { id, message, tone: opts.tone ?? 'neutral', duration }];
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.19.1",
3
+ "version": "0.21.0",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",