@spaethtech/svelte-ui 0.3.0 → 0.3.1-dev.10.ff03a8d

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
@@ -159,9 +159,23 @@ CI verifies the tag matches `package.json`, type-checks, then runs `npm publish`
159
159
  script builds `dist/` fresh — never commit `dist/`). Versions are **immutable**: a number can't be
160
160
  republished, so don't tag until the CHANGELOG/version are right.
161
161
 
162
+ ### Dev / canary builds
163
+
164
+ Every push to `main` also publishes a prerelease under the **`dev`** dist-tag (same workflow, no git
165
+ tag needed) — version `<next patch>-dev.<run>.<sha>`. It never touches `latest`, and caret ranges
166
+ (`^0.3.0`) skip prereleases, so it's invisible unless a consumer opts in:
167
+
168
+ ```bash
169
+ npm install @spaethtech/svelte-ui@dev # newest main build; re-run / npm update to refresh
170
+ ```
171
+
172
+ Use it to test unreleased work in a consumer before cutting a real release. (Publishing on every push
173
+ does accumulate prerelease versions on the registry — that's expected.)
174
+
162
175
  **One-time setup (already done once, listed for reference):** on npmjs.com → the package → Settings →
163
176
  add a **Trusted Publisher** for GitHub Actions (org `spaethtech`, repo `svelte-ui`, workflow
164
- `publish.yml`).
177
+ `publish.yml`, "Allow npm publish"). Both the release and dev paths live in that one workflow file, so
178
+ the single trusted-publisher entry covers both.
165
179
 
166
180
  ## Known issues
167
181
 
@@ -7,7 +7,8 @@
7
7
  * Border + radius come from the shared tint tokens (the same scheme Input/DataTable use, so surfaces
8
8
  * match): the OUTER frame is the variant token at `--ui-tint-border` (rest) / `--ui-tint-border-hover`
9
9
  * (hover) — one calc for every variant, no `secondary` special case — while inset Header/Footer
10
- * separators stay soft (`--ui-border-color`). `variant` sets the border colour; `size` the padding.
10
+ * separators stay soft (`--ui-border-color`), matching the library's other internal dividers.
11
+ * `variant` sets the border colour; `size` the padding.
11
12
  *
12
13
  * Background comes from `--ui-color-background` so the card reads as a raised panel over the page
13
14
  * body (matches Input, Dialog, tooltip surface tokens). Themes remap this token to their own
@@ -30,7 +30,9 @@
30
30
  } & Omit<HTMLAttributes<HTMLDivElement>, "class"> = $props();
31
31
 
32
32
  const space: Record<Size, string> = { sm: "pt-2 mt-2", md: "pt-3 mt-3", lg: "pt-4 mt-4" };
33
- // Neutral default (secondary) the shared surface border; any other variant that accent.
33
+ // The separator is a SOFT internal divider, consistent with the other section splits/footers in the
34
+ // library (DatePicker/TimePicker/TimeRangeInput) — deliberately lighter than the Card frame + fields.
35
+ // Default (secondary) → the shared `--ui-border-color`; any other variant → that accent's tint.
34
36
  const sepColor = $derived(
35
37
  variant === "secondary"
36
38
  ? "var(--ui-border-color)"
@@ -30,7 +30,9 @@
30
30
  } & Omit<HTMLAttributes<HTMLDivElement>, "class"> = $props();
31
31
 
32
32
  const space: Record<Size, string> = { sm: "pb-2 mb-2", md: "pb-3 mb-3", lg: "pb-4 mb-4" };
33
- // Neutral default (secondary) the shared surface border; any other variant that accent.
33
+ // The separator is a SOFT internal divider, consistent with the other section splits/footers in the
34
+ // library (DatePicker/TimePicker/TimeRangeInput) — deliberately lighter than the Card frame + fields.
35
+ // Default (secondary) → the shared `--ui-border-color`; any other variant → that accent's tint.
34
36
  const sepColor = $derived(
35
37
  variant === "secondary"
36
38
  ? "var(--ui-border-color)"
@@ -0,0 +1,103 @@
1
+ <script lang="ts">
2
+ import Dialog from "./Dialog.svelte";
3
+ import Card from "./Card.svelte";
4
+ import CardHeader from "./CardHeader.svelte";
5
+ import CardFooter from "./CardFooter.svelte";
6
+ import Button from "./Button.svelte";
7
+ import type { Snippet } from "svelte";
8
+ import type { Size } from "../types/sizes.js";
9
+ import type { Responsive } from "../types/responsive.js";
10
+
11
+ /**
12
+ * ConfirmDialog — the common title + message + Confirm/Cancel modal, built on the `Dialog` shell.
13
+ * It composes a `Card` (`CardHeader` title, body, `CardFooter` actions) inside `Dialog`, so it stays
14
+ * consistent with every other surface. Pass `children` to replace the message body with custom
15
+ * content (the header + footer stay). For a fully custom modal, use `Dialog` directly.
16
+ */
17
+ interface Props {
18
+ isOpen: boolean;
19
+ title?: string;
20
+ message?: string;
21
+ confirmText?: string;
22
+ cancelText?: string;
23
+ onConfirm?: () => void;
24
+ onCancel?: () => void;
25
+ variant?: "danger" | "normal";
26
+ /** Density tier — forwarded to the Card / CardHeader / CardFooter. */
27
+ size?: Responsive<Size>;
28
+ showConfirm?: boolean;
29
+ showCancel?: boolean;
30
+ /** Allow dismissing by backdrop / Escape (default true). */
31
+ dismissible?: boolean;
32
+ /** Forwarded to the Dialog shell — any CSS value (default: content-sized, capped at 32rem). */
33
+ width?: string;
34
+ height?: string;
35
+ /** Custom body between header and footer (replaces `message`). */
36
+ children?: Snippet;
37
+ }
38
+
39
+ let {
40
+ isOpen = $bindable(),
41
+ title = "Dialog",
42
+ message,
43
+ confirmText = "Confirm",
44
+ cancelText = "Cancel",
45
+ onConfirm,
46
+ onCancel,
47
+ variant = "normal",
48
+ size = "md",
49
+ showConfirm = true,
50
+ showCancel = true,
51
+ dismissible = true,
52
+ width,
53
+ height,
54
+ children,
55
+ }: Props = $props();
56
+
57
+ function handleConfirm() {
58
+ onConfirm?.();
59
+ isOpen = false;
60
+ }
61
+ function handleCancel() {
62
+ onCancel?.();
63
+ isOpen = false;
64
+ }
65
+ </script>
66
+
67
+ <Dialog
68
+ bind:isOpen
69
+ {dismissible}
70
+ {width}
71
+ {height}
72
+ onClose={onCancel}
73
+ aria-label={title}
74
+ >
75
+ <Card {size} class="w-full shadow-xl">
76
+ <CardHeader {size}>
77
+ <h3 class="text-lg font-medium [color:var(--ui-color-text)]">{title}</h3>
78
+ </CardHeader>
79
+
80
+ {#if children}
81
+ {@render children()}
82
+ {:else if message}
83
+ <p class="text-sm [color:color-mix(in_srgb,var(--ui-color-text)_70%,transparent)]">
84
+ {message}
85
+ </p>
86
+ {/if}
87
+
88
+ {#if showConfirm || showCancel}
89
+ <CardFooter {size}>
90
+ {#if showCancel}
91
+ <Button text={cancelText} onclick={handleCancel} variant="secondary" />
92
+ {/if}
93
+ {#if showConfirm}
94
+ <Button
95
+ text={confirmText}
96
+ onclick={handleConfirm}
97
+ variant={variant === "danger" ? "danger" : "primary"}
98
+ />
99
+ {/if}
100
+ </CardFooter>
101
+ {/if}
102
+ </Card>
103
+ </Dialog>
@@ -0,0 +1,33 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { Size } from "../types/sizes.js";
3
+ import type { Responsive } from "../types/responsive.js";
4
+ /**
5
+ * ConfirmDialog — the common title + message + Confirm/Cancel modal, built on the `Dialog` shell.
6
+ * It composes a `Card` (`CardHeader` title, body, `CardFooter` actions) inside `Dialog`, so it stays
7
+ * consistent with every other surface. Pass `children` to replace the message body with custom
8
+ * content (the header + footer stay). For a fully custom modal, use `Dialog` directly.
9
+ */
10
+ interface Props {
11
+ isOpen: boolean;
12
+ title?: string;
13
+ message?: string;
14
+ confirmText?: string;
15
+ cancelText?: string;
16
+ onConfirm?: () => void;
17
+ onCancel?: () => void;
18
+ variant?: "danger" | "normal";
19
+ /** Density tier — forwarded to the Card / CardHeader / CardFooter. */
20
+ size?: Responsive<Size>;
21
+ showConfirm?: boolean;
22
+ showCancel?: boolean;
23
+ /** Allow dismissing by backdrop / Escape (default true). */
24
+ dismissible?: boolean;
25
+ /** Forwarded to the Dialog shell — any CSS value (default: content-sized, capped at 32rem). */
26
+ width?: string;
27
+ height?: string;
28
+ /** Custom body between header and footer (replaces `message`). */
29
+ children?: Snippet;
30
+ }
31
+ declare const ConfirmDialog: import("svelte").Component<Props, {}, "isOpen">;
32
+ type ConfirmDialog = ReturnType<typeof ConfirmDialog>;
33
+ export default ConfirmDialog;
@@ -1,57 +1,58 @@
1
1
  <script lang="ts">
2
- import Button from "./Button.svelte";
3
- import Card from "./Card.svelte";
4
- import CardHeader from "./CardHeader.svelte";
5
- import CardFooter from "./CardFooter.svelte";
6
2
  import type { Snippet } from "svelte";
7
- import type { Size } from "../types/sizes.js";
8
- import type { Responsive } from "../types/responsive.js";
3
+ import type { HTMLAttributes } from "svelte/elements";
9
4
 
10
- interface Props {
5
+ /**
6
+ * Dialog — a PURE modal shell (layout container). It owns only the modal mechanics: portals itself
7
+ * to <body>, locks background scroll, renders a dismissable backdrop, traps focus, and exposes the
8
+ * ARIA dialog role. It imposes NO content structure — put a `Card` (or anything) inside via
9
+ * `children`. For the common title + message + Confirm/Cancel pattern, use `ConfirmDialog`, which is
10
+ * built on this.
11
+ *
12
+ * `width` / `height` accept any CSS length: `"80%"`, `"80vw"`, `"60vh"`, `"400px"`, … (both default
13
+ * to content size, capped to the viewport minus a 1rem inset so the panel never runs off-screen).
14
+ */
15
+ interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "class"> {
16
+ /** Whether the dialog is shown (bindable). */
11
17
  isOpen: boolean;
12
- title?: string;
13
- message?: string;
14
- confirmText?: string;
15
- cancelText?: string;
16
- onConfirm?: () => void;
17
- onCancel?: () => void;
18
- variant?: "danger" | "normal";
19
- /** Density tier — forwarded to the underlying Card / CardHeader / CardFooter. */
20
- size?: Responsive<Size>;
21
- showConfirm?: boolean;
22
- showCancel?: boolean;
23
- /** Allow dismissing by clicking the backdrop OR pressing Escape (default true). Set `false` for a
24
- * must-decide dialog (e.g. a destructive confirm) so only the buttons close it. */
18
+ /** Panel width — any CSS value (`80%`, `80vw`, `400px`). Default: auto, capped at `min(32rem, …)`. */
19
+ width?: string;
20
+ /** Panel height — any CSS value (`60%`, `60vh`, `300px`). Default: auto (content height). */
21
+ height?: string;
22
+ /** Allow dismissing via backdrop click / Escape (default true). Set false for a must-decide modal. */
25
23
  dismissible?: boolean;
24
+ /** Called when dismissed via backdrop / Escape (isOpen is also set false). */
25
+ onClose?: () => void;
26
+ /** Extra classes on the panel wrapper. */
27
+ class?: string;
26
28
  children?: Snippet;
27
29
  }
28
30
 
29
31
  let {
30
32
  isOpen = $bindable(),
31
- title = "Dialog",
32
- message,
33
- confirmText = "Confirm",
34
- cancelText = "Cancel",
35
- onConfirm,
36
- onCancel,
37
- variant = "normal",
38
- size = "md",
39
- showConfirm = true,
40
- showCancel = true,
33
+ width,
34
+ height,
41
35
  dismissible = true,
36
+ onClose,
37
+ class: cls = "",
42
38
  children,
39
+ ...rest
43
40
  }: Props = $props();
44
41
 
42
+ const FOCUSABLE =
43
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
44
+
45
+ let panelEl: HTMLElement | undefined = $state();
46
+ let previouslyFocused: HTMLElement | null = null;
47
+
45
48
  // Move the overlay to <body> so it's a real top-level layer — never a sibling of the trigger (which
46
- // would perturb surrounding layout, e.g. flip a space-y `:last-child`) and never clipped by an
47
- // ancestor's overflow/stacking context. This is what makes it an actual modal, not an inline box.
49
+ // would perturb surrounding layout) and never clipped by an ancestor's overflow/stacking context.
48
50
  function portal(node: HTMLElement) {
49
51
  document.body.appendChild(node);
50
52
  return { destroy: () => node.remove() };
51
53
  }
52
54
 
53
- // Lock background scroll while open; pad by the scrollbar width so hiding it doesn't shift the page
54
- // sideways. Restore exactly what was there on close/destroy.
55
+ // Lock background scroll while open; pad by the scrollbar width so hiding it doesn't shift the page.
55
56
  $effect(() => {
56
57
  if (!isOpen) return;
57
58
  const sbw = window.innerWidth - document.documentElement.clientWidth;
@@ -65,68 +66,86 @@
65
66
  };
66
67
  });
67
68
 
68
- function handleConfirm() {
69
- onConfirm?.();
70
- isOpen = false;
71
- }
72
- function handleCancel() {
73
- onCancel?.();
69
+ // Focus management: on open, remember what was focused and move focus into the panel; on close,
70
+ // restore it. (Tab-wrapping is handled in onKeydown.)
71
+ $effect(() => {
72
+ if (!isOpen) return;
73
+ previouslyFocused = document.activeElement as HTMLElement | null;
74
+ queueMicrotask(() => {
75
+ const first = panelEl?.querySelector<HTMLElement>(FOCUSABLE);
76
+ (first ?? panelEl)?.focus();
77
+ });
78
+ return () => previouslyFocused?.focus?.();
79
+ });
80
+
81
+ const panelStyle = $derived(
82
+ [
83
+ width ? `width:${width}` : "width:auto",
84
+ `max-width:${width ? "calc(100vw - 2rem)" : "min(32rem, calc(100vw - 2rem))"}`,
85
+ height ? `height:${height}` : null,
86
+ "max-height:calc(100vh - 2rem)",
87
+ ]
88
+ .filter(Boolean)
89
+ .join(";"),
90
+ );
91
+
92
+ function close() {
93
+ if (!dismissible) return;
94
+ onClose?.();
74
95
  isOpen = false;
75
96
  }
76
- // Backdrop click / Escape only dismiss when allowed.
77
- function dismiss() {
78
- if (dismissible) handleCancel();
79
- }
80
- function handleBackdropClick(event: MouseEvent) {
81
- if (event.target === event.currentTarget) dismiss();
97
+ function onBackdrop(event: MouseEvent) {
98
+ if (event.target === event.currentTarget) close();
82
99
  }
83
- function handleKeydown(event: KeyboardEvent) {
84
- if (isOpen && event.key === "Escape") dismiss();
100
+ function onKeydown(event: KeyboardEvent) {
101
+ if (!isOpen) return;
102
+ if (event.key === "Escape") {
103
+ close();
104
+ return;
105
+ }
106
+ if (event.key === "Tab" && panelEl) {
107
+ const items = [...panelEl.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
108
+ (el) => el.offsetParent !== null,
109
+ );
110
+ if (items.length === 0) {
111
+ event.preventDefault();
112
+ panelEl.focus();
113
+ return;
114
+ }
115
+ const first = items[0];
116
+ const last = items[items.length - 1];
117
+ const active = document.activeElement;
118
+ if (event.shiftKey && (active === first || active === panelEl)) {
119
+ event.preventDefault();
120
+ last.focus();
121
+ } else if (!event.shiftKey && active === last) {
122
+ event.preventDefault();
123
+ first.focus();
124
+ }
125
+ }
85
126
  }
86
127
  </script>
87
128
 
88
- <svelte:window on:keydown={handleKeydown} />
129
+ <svelte:window on:keydown={onKeydown} />
89
130
 
90
131
  {#if isOpen}
132
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
91
133
  <div
92
134
  use:portal
93
- class="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999]"
94
- onclick={handleBackdropClick}
95
- onkeydown={handleKeydown}
96
- role="dialog"
97
- aria-modal="true"
98
- tabindex="-1"
135
+ class="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999] p-4"
136
+ onclick={onBackdrop}
137
+ role="presentation"
99
138
  >
100
- <!-- The panel IS a Card — border + --ui-border-radius + padding + background come from Card, so a
101
- Dialog stays consistent with every other surface; CardHeader/CardFooter give the title + action
102
- rows their (inset) separators and spacing. `size` flows to all three. -->
103
- <Card {size} class="max-w-md w-full mx-4 shadow-xl z-[10000]">
104
- <CardHeader {size}>
105
- <h3 class="text-lg font-medium [color:var(--ui-color-text)]">{title}</h3>
106
- </CardHeader>
107
-
108
- {#if children}
109
- {@render children()}
110
- {:else if message}
111
- <p class="text-sm [color:color-mix(in_srgb,var(--ui-color-text)_70%,transparent)]">
112
- {message}
113
- </p>
114
- {/if}
115
-
116
- {#if showConfirm || showCancel}
117
- <CardFooter {size}>
118
- {#if showCancel}
119
- <Button text={cancelText} onclick={handleCancel} variant="secondary" />
120
- {/if}
121
- {#if showConfirm}
122
- <Button
123
- text={confirmText}
124
- onclick={handleConfirm}
125
- variant={variant === "danger" ? "danger" : "primary"}
126
- />
127
- {/if}
128
- </CardFooter>
129
- {/if}
130
- </Card>
139
+ <div
140
+ bind:this={panelEl}
141
+ class="overflow-auto z-[10000] {cls}"
142
+ style={panelStyle}
143
+ role="dialog"
144
+ aria-modal="true"
145
+ tabindex="-1"
146
+ {...rest}
147
+ >
148
+ {@render children?.()}
149
+ </div>
131
150
  </div>
132
151
  {/if}
@@ -1,22 +1,28 @@
1
1
  import type { Snippet } from "svelte";
2
- import type { Size } from "../types/sizes.js";
3
- import type { Responsive } from "../types/responsive.js";
4
- interface Props {
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ /**
4
+ * Dialog — a PURE modal shell (layout container). It owns only the modal mechanics: portals itself
5
+ * to <body>, locks background scroll, renders a dismissable backdrop, traps focus, and exposes the
6
+ * ARIA dialog role. It imposes NO content structure — put a `Card` (or anything) inside via
7
+ * `children`. For the common title + message + Confirm/Cancel pattern, use `ConfirmDialog`, which is
8
+ * built on this.
9
+ *
10
+ * `width` / `height` accept any CSS length: `"80%"`, `"80vw"`, `"60vh"`, `"400px"`, … (both default
11
+ * to content size, capped to the viewport minus a 1rem inset so the panel never runs off-screen).
12
+ */
13
+ interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "class"> {
14
+ /** Whether the dialog is shown (bindable). */
5
15
  isOpen: boolean;
6
- title?: string;
7
- message?: string;
8
- confirmText?: string;
9
- cancelText?: string;
10
- onConfirm?: () => void;
11
- onCancel?: () => void;
12
- variant?: "danger" | "normal";
13
- /** Density tier — forwarded to the underlying Card / CardHeader / CardFooter. */
14
- size?: Responsive<Size>;
15
- showConfirm?: boolean;
16
- showCancel?: boolean;
17
- /** Allow dismissing by clicking the backdrop OR pressing Escape (default true). Set `false` for a
18
- * must-decide dialog (e.g. a destructive confirm) so only the buttons close it. */
16
+ /** Panel width — any CSS value (`80%`, `80vw`, `400px`). Default: auto, capped at `min(32rem, …)`. */
17
+ width?: string;
18
+ /** Panel height — any CSS value (`60%`, `60vh`, `300px`). Default: auto (content height). */
19
+ height?: string;
20
+ /** Allow dismissing via backdrop click / Escape (default true). Set false for a must-decide modal. */
19
21
  dismissible?: boolean;
22
+ /** Called when dismissed via backdrop / Escape (isOpen is also set false). */
23
+ onClose?: () => void;
24
+ /** Extra classes on the panel wrapper. */
25
+ class?: string;
20
26
  children?: Snippet;
21
27
  }
22
28
  declare const Dialog: import("svelte").Component<Props, {}, "isOpen">;
@@ -14,7 +14,7 @@
14
14
  import Input from "./Input.svelte";
15
15
  import Button from "./Button.svelte";
16
16
  import Popup from "./Popup.svelte";
17
- import Dialog from "./Dialog.svelte";
17
+ import ConfirmDialog from "./ConfirmDialog.svelte";
18
18
  import type { DataSet } from "../data/dataset.js";
19
19
  import type { Size } from "../types/sizes.js";
20
20
  import type { Responsive } from "../types/responsive.js";
@@ -227,7 +227,7 @@
227
227
  </ul>
228
228
  </Popup>
229
229
 
230
- <Dialog bind:isOpen={helpOpen} title="Query syntax" showConfirm={false} showCancel={false}>
230
+ <ConfirmDialog bind:isOpen={helpOpen} title="Query syntax" showConfirm={false} showCancel={false}>
231
231
  <div class="max-h-[70vh] space-y-4 overflow-y-auto text-sm [color:var(--ui-color-text)]">
232
232
  <p>
233
233
  A filter is one or more conditions, <code class="font-mono">$column operator value</code>.
@@ -281,4 +281,4 @@ $ip|inet |= [10.0.0.0|inet..10.255.255.255|inet]
281
281
  values. Applies on <kbd class="rounded border px-1 {bc}">Enter</kbd>.
282
282
  </p>
283
283
  </div>
284
- </Dialog>
284
+ </ConfirmDialog>
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export { default as CardFooter } from "./components/CardFooter.svelte";
10
10
  export { default as Toaster } from "./components/Toast/Toaster.svelte";
11
11
  export { toast, getToasts, dismissToast, type ToastVariant, type ToastItem, } from "./components/Toast/toast.svelte.js";
12
12
  export { default as Dialog } from "./components/Dialog.svelte";
13
+ export { default as ConfirmDialog } from "./components/ConfirmDialog.svelte";
13
14
  export { default as Input } from "./components/Input.svelte";
14
15
  export { default as NotesEditor } from "./components/NotesEditor.svelte";
15
16
  export { default as Rating } from "./components/Rating.svelte";
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ export { default as CardFooter } from "./components/CardFooter.svelte";
12
12
  export { default as Toaster } from "./components/Toast/Toaster.svelte";
13
13
  export { toast, getToasts, dismissToast, } from "./components/Toast/toast.svelte.js";
14
14
  export { default as Dialog } from "./components/Dialog.svelte";
15
+ export { default as ConfirmDialog } from "./components/ConfirmDialog.svelte";
15
16
  export { default as Input } from "./components/Input.svelte";
16
17
  export { default as NotesEditor } from "./components/NotesEditor.svelte";
17
18
  export { default as Rating } from "./components/Rating.svelte";