@spaethtech/svelte-ui 0.4.1-dev.13.042fac9 → 0.4.1-dev.16.e97ba4d
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/.claude/skills/svelte-ui/SKILL.md +6 -4
- package/dist/components/ConfirmDialog.svelte +8 -1
- package/dist/components/ConfirmDialog.svelte.d.ts +5 -1
- package/dist/components/Dialog.svelte +44 -5
- package/dist/components/Dialog.svelte.d.ts +25 -0
- package/dist/components/ThemeSelector.svelte +7 -42
- package/dist/components/ThemeToggle.svelte +57 -0
- package/dist/components/ThemeToggle.svelte.d.ts +10 -0
- package/dist/components/theme.d.ts +23 -0
- package/dist/components/theme.js +49 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/docs/components.md +21 -4
- package/docs/usage.md +21 -2
- package/package.json +1 -1
|
@@ -101,13 +101,15 @@ examples):
|
|
|
101
101
|
- **Date / time:** `DatePicker` `Calendar` `TimePicker` `TimeSpinner` `TimeRangeInput` `DateTimeInput`
|
|
102
102
|
- **Data:** `DataTable` `Query` — driven by the headless layer at **`@spaethtech/svelte-ui/data`**
|
|
103
103
|
(query-language parser/AST, `createGrid`, `DataGrid<T>`, `DataSet`).
|
|
104
|
-
- **Overlays:** `Dialog` (pure modal shell — BYO content; `width`/`height`
|
|
105
|
-
|
|
106
|
-
`
|
|
104
|
+
- **Overlays:** `Dialog` (pure modal shell — BYO content; `width`/`height` take any CSS value;
|
|
105
|
+
`onShow(ctx)`/`onHide(ctx)` control focus — set `ctx.autoFocus` to a selector/element/`false`,
|
|
106
|
+
`ctx.restoreFocus` likewise) · `ConfirmDialog` (title + message + Confirm/Cancel, built on Dialog)
|
|
107
|
+
· `Popup` `Menu` · `tooltip` (a Svelte `use:` action) · `anchored` (positioning engine)
|
|
107
108
|
- **Navigation:** `TabStrip` `SideBarMenu`
|
|
108
109
|
- **Feedback:** `Alert` `Banner` `Badge` `Toaster` + `toast`
|
|
109
110
|
- **Layout:** `Card` `CardHeader` `CardBody` `CardFooter`
|
|
110
|
-
- **Display / theme:** `NotesEditor` `ThemeSelector`
|
|
111
|
+
- **Display / theme:** `NotesEditor` · `ThemeSelector` (Auto/Light/Dark Select) · `ThemeToggle`
|
|
112
|
+
(compact ☀/☾ icon button, same persisted state)
|
|
111
113
|
- **Types / utils:** `Variant` `Size` `TabDefinition` (types), `responsiveClasses` / `resolveScalar`,
|
|
112
114
|
`BREAKPOINT_PX`.
|
|
113
115
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import CardHeader from "./CardHeader.svelte";
|
|
5
5
|
import CardFooter from "./CardFooter.svelte";
|
|
6
6
|
import Button from "./Button.svelte";
|
|
7
|
-
import type { Snippet } from "svelte";
|
|
7
|
+
import type { ComponentProps, Snippet } from "svelte";
|
|
8
8
|
import type { Size } from "../types/sizes.js";
|
|
9
9
|
import type { Responsive } from "../types/responsive.js";
|
|
10
10
|
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
/** Forwarded to the Dialog shell — any CSS value (default: content-sized, capped at 32rem). */
|
|
33
33
|
width?: string;
|
|
34
34
|
height?: string;
|
|
35
|
+
/** Forwarded to the Dialog shell — open/close hooks with the `autoFocus`/`restoreFocus` knobs. */
|
|
36
|
+
onShow?: ComponentProps<typeof Dialog>["onShow"];
|
|
37
|
+
onHide?: ComponentProps<typeof Dialog>["onHide"];
|
|
35
38
|
/** Custom body between header and footer (replaces `message`). */
|
|
36
39
|
children?: Snippet;
|
|
37
40
|
}
|
|
@@ -51,6 +54,8 @@
|
|
|
51
54
|
dismissible = true,
|
|
52
55
|
width,
|
|
53
56
|
height,
|
|
57
|
+
onShow,
|
|
58
|
+
onHide,
|
|
54
59
|
children,
|
|
55
60
|
}: Props = $props();
|
|
56
61
|
|
|
@@ -69,6 +74,8 @@
|
|
|
69
74
|
{dismissible}
|
|
70
75
|
{width}
|
|
71
76
|
{height}
|
|
77
|
+
{onShow}
|
|
78
|
+
{onHide}
|
|
72
79
|
onClose={onCancel}
|
|
73
80
|
aria-label={title}
|
|
74
81
|
>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import Dialog from "./Dialog.svelte";
|
|
2
|
+
import type { ComponentProps, Snippet } from "svelte";
|
|
2
3
|
import type { Size } from "../types/sizes.js";
|
|
3
4
|
import type { Responsive } from "../types/responsive.js";
|
|
4
5
|
/**
|
|
@@ -25,6 +26,9 @@ interface Props {
|
|
|
25
26
|
/** Forwarded to the Dialog shell — any CSS value (default: content-sized, capped at 32rem). */
|
|
26
27
|
width?: string;
|
|
27
28
|
height?: string;
|
|
29
|
+
/** Forwarded to the Dialog shell — open/close hooks with the `autoFocus`/`restoreFocus` knobs. */
|
|
30
|
+
onShow?: ComponentProps<typeof Dialog>["onShow"];
|
|
31
|
+
onHide?: ComponentProps<typeof Dialog>["onHide"];
|
|
28
32
|
/** Custom body between header and footer (replaces `message`). */
|
|
29
33
|
children?: Snippet;
|
|
30
34
|
}
|
|
@@ -11,7 +11,27 @@
|
|
|
11
11
|
*
|
|
12
12
|
* `width` / `height` accept any CSS length: `"80%"`, `"80vw"`, `"60vh"`, `"400px"`, … (both default
|
|
13
13
|
* to content size, capped to the viewport minus a 1rem inset so the panel never runs off-screen).
|
|
14
|
+
*
|
|
15
|
+
* `onShow` / `onHide` fire on open / close. Each hands you a mutable focus knob preset to the
|
|
16
|
+
* default, so a handler that ignores it keeps built-in behavior:
|
|
17
|
+
* onShow(ctx): ctx.autoFocus is the default focusable selector — set a selector/element to focus
|
|
18
|
+
* something specific, or `false` to skip (focus stays on the panel).
|
|
19
|
+
* onHide(ctx): ctx.restoreFocus is the element focused before open — set `false` to skip
|
|
20
|
+
* restoration, or another element to focus on close.
|
|
14
21
|
*/
|
|
22
|
+
interface DialogShowContext {
|
|
23
|
+
/** The panel element (already mounted). */
|
|
24
|
+
panel: HTMLElement;
|
|
25
|
+
/** What to focus on open — preset to the default focusable selector. Set a CSS selector or an
|
|
26
|
+
* element to focus something specific, or `false` to skip autofocus (focus stays on the panel). */
|
|
27
|
+
autoFocus: string | HTMLElement | false;
|
|
28
|
+
}
|
|
29
|
+
interface DialogHideContext {
|
|
30
|
+
/** Where focus returns on close — preset to the element focused before the dialog opened. Set
|
|
31
|
+
* `false` to skip restoration, or another element to focus instead. */
|
|
32
|
+
restoreFocus: HTMLElement | null | false;
|
|
33
|
+
}
|
|
34
|
+
|
|
15
35
|
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "class"> {
|
|
16
36
|
/** Whether the dialog is shown (bindable). */
|
|
17
37
|
isOpen: boolean;
|
|
@@ -23,6 +43,12 @@
|
|
|
23
43
|
dismissible?: boolean;
|
|
24
44
|
/** Called when dismissed via backdrop / Escape (isOpen is also set false). */
|
|
25
45
|
onClose?: () => void;
|
|
46
|
+
/** Fires on open (after mount). `ctx.autoFocus` (default = the focusable selector) controls what
|
|
47
|
+
* gets focused; mutate it to retarget or set `false` to skip. */
|
|
48
|
+
onShow?: (ctx: DialogShowContext) => void;
|
|
49
|
+
/** Fires on close. `ctx.restoreFocus` (default = the pre-open element) controls focus return;
|
|
50
|
+
* mutate it to retarget or set `false` to skip. */
|
|
51
|
+
onHide?: (ctx: DialogHideContext) => void;
|
|
26
52
|
/** Extra classes on the panel wrapper. */
|
|
27
53
|
class?: string;
|
|
28
54
|
children?: Snippet;
|
|
@@ -34,6 +60,8 @@
|
|
|
34
60
|
height,
|
|
35
61
|
dismissible = true,
|
|
36
62
|
onClose,
|
|
63
|
+
onShow,
|
|
64
|
+
onHide,
|
|
37
65
|
class: cls = "",
|
|
38
66
|
children,
|
|
39
67
|
...rest
|
|
@@ -66,16 +94,27 @@
|
|
|
66
94
|
};
|
|
67
95
|
});
|
|
68
96
|
|
|
69
|
-
// Focus management
|
|
70
|
-
//
|
|
97
|
+
// Focus management. On open: remember what was focused, fire `onShow`, then focus per
|
|
98
|
+
// `ctx.autoFocus` (default = first focusable). On close: fire `onHide`, then restore per
|
|
99
|
+
// `ctx.restoreFocus` (default = the pre-open element). (Tab-wrapping is handled in onKeydown.)
|
|
71
100
|
$effect(() => {
|
|
72
101
|
if (!isOpen) return;
|
|
73
102
|
previouslyFocused = document.activeElement as HTMLElement | null;
|
|
74
103
|
queueMicrotask(() => {
|
|
75
|
-
|
|
76
|
-
|
|
104
|
+
if (!panelEl) return;
|
|
105
|
+
const ctx: DialogShowContext = { panel: panelEl, autoFocus: FOCUSABLE };
|
|
106
|
+
onShow?.(ctx);
|
|
107
|
+
let target: HTMLElement | null;
|
|
108
|
+
if (ctx.autoFocus === false) target = panelEl;
|
|
109
|
+
else if (ctx.autoFocus instanceof HTMLElement) target = ctx.autoFocus;
|
|
110
|
+
else target = panelEl.querySelector<HTMLElement>(ctx.autoFocus) ?? panelEl;
|
|
111
|
+
target?.focus();
|
|
77
112
|
});
|
|
78
|
-
return () =>
|
|
113
|
+
return () => {
|
|
114
|
+
const ctx: DialogHideContext = { restoreFocus: previouslyFocused };
|
|
115
|
+
onHide?.(ctx);
|
|
116
|
+
if (ctx.restoreFocus) ctx.restoreFocus.focus?.();
|
|
117
|
+
};
|
|
79
118
|
});
|
|
80
119
|
|
|
81
120
|
const panelStyle = $derived(
|
|
@@ -9,7 +9,26 @@ import type { HTMLAttributes } from "svelte/elements";
|
|
|
9
9
|
*
|
|
10
10
|
* `width` / `height` accept any CSS length: `"80%"`, `"80vw"`, `"60vh"`, `"400px"`, … (both default
|
|
11
11
|
* to content size, capped to the viewport minus a 1rem inset so the panel never runs off-screen).
|
|
12
|
+
*
|
|
13
|
+
* `onShow` / `onHide` fire on open / close. Each hands you a mutable focus knob preset to the
|
|
14
|
+
* default, so a handler that ignores it keeps built-in behavior:
|
|
15
|
+
* onShow(ctx): ctx.autoFocus is the default focusable selector — set a selector/element to focus
|
|
16
|
+
* something specific, or `false` to skip (focus stays on the panel).
|
|
17
|
+
* onHide(ctx): ctx.restoreFocus is the element focused before open — set `false` to skip
|
|
18
|
+
* restoration, or another element to focus on close.
|
|
12
19
|
*/
|
|
20
|
+
interface DialogShowContext {
|
|
21
|
+
/** The panel element (already mounted). */
|
|
22
|
+
panel: HTMLElement;
|
|
23
|
+
/** What to focus on open — preset to the default focusable selector. Set a CSS selector or an
|
|
24
|
+
* element to focus something specific, or `false` to skip autofocus (focus stays on the panel). */
|
|
25
|
+
autoFocus: string | HTMLElement | false;
|
|
26
|
+
}
|
|
27
|
+
interface DialogHideContext {
|
|
28
|
+
/** Where focus returns on close — preset to the element focused before the dialog opened. Set
|
|
29
|
+
* `false` to skip restoration, or another element to focus instead. */
|
|
30
|
+
restoreFocus: HTMLElement | null | false;
|
|
31
|
+
}
|
|
13
32
|
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "class"> {
|
|
14
33
|
/** Whether the dialog is shown (bindable). */
|
|
15
34
|
isOpen: boolean;
|
|
@@ -21,6 +40,12 @@ interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "class"> {
|
|
|
21
40
|
dismissible?: boolean;
|
|
22
41
|
/** Called when dismissed via backdrop / Escape (isOpen is also set false). */
|
|
23
42
|
onClose?: () => void;
|
|
43
|
+
/** Fires on open (after mount). `ctx.autoFocus` (default = the focusable selector) controls what
|
|
44
|
+
* gets focused; mutate it to retarget or set `false` to skip. */
|
|
45
|
+
onShow?: (ctx: DialogShowContext) => void;
|
|
46
|
+
/** Fires on close. `ctx.restoreFocus` (default = the pre-open element) controls focus return;
|
|
47
|
+
* mutate it to retarget or set `false` to skip. */
|
|
48
|
+
onHide?: (ctx: DialogHideContext) => void;
|
|
24
49
|
/** Extra classes on the panel wrapper. */
|
|
25
50
|
class?: string;
|
|
26
51
|
children?: Snippet;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { Size } from "../types/sizes.js";
|
|
4
4
|
import { responsiveClasses, type Responsive } from "../types/responsive.js";
|
|
5
5
|
import type { Variant } from "../types/variants.js";
|
|
6
|
+
import { applyTheme, readStoredTheme, type ThemeValue } from "./theme.js";
|
|
6
7
|
|
|
7
8
|
// Shared axes, forwarded to the underlying Select (and the label text tracks size).
|
|
8
9
|
let { size = "md", variant }: { size?: Responsive<Size>; variant?: Variant } = $props();
|
|
@@ -15,54 +16,18 @@
|
|
|
15
16
|
{ label: "Dark", value: "dark" },
|
|
16
17
|
];
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
dark: "theme-dark",
|
|
21
|
-
};
|
|
22
|
-
|
|
19
|
+
// Shared theme state (localStorage-backed, applied to <html>) — see ./theme.ts. Kept as a plain
|
|
20
|
+
// string for the Select `bind:value`; the values are constrained to the options above.
|
|
23
21
|
let selectedTheme = $state("auto");
|
|
24
22
|
|
|
25
|
-
// Load saved theme on mount
|
|
23
|
+
// Load the saved theme on mount (defaults to "auto" when nothing is stored).
|
|
26
24
|
$effect(() => {
|
|
27
|
-
|
|
28
|
-
const savedTheme = localStorage.getItem("svelte-ui-theme");
|
|
29
|
-
if (savedTheme && options.some((opt) => opt.value === savedTheme)) {
|
|
30
|
-
selectedTheme = savedTheme;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
25
|
+
selectedTheme = readStoredTheme();
|
|
33
26
|
});
|
|
34
27
|
|
|
35
|
-
// Apply
|
|
36
|
-
// The theme tokens (incl. --ui-color-hover/active) are defined at :root, and app.html's pre-hydration
|
|
37
|
-
// script forces the class onto <html>. If we applied it to <body> instead, <html> would stay on the
|
|
38
|
-
// system @media(prefers-color-scheme) path, so tokens that reference other tokens (--ui-color-hover =
|
|
39
|
-
// color-mix(var(--ui-color-text)…)) would resolve against the WRONG theme at :root and inherit down
|
|
40
|
-
// stale (e.g. a white hover-tint on a light page → invisible hover). Keep the class on :root.
|
|
41
|
-
function applyTheme(themeValue: string) {
|
|
42
|
-
const body = document.body;
|
|
43
|
-
const html = document.documentElement;
|
|
44
|
-
|
|
45
|
-
// Remove all existing theme classes from both body and html (clear any stray body class too)
|
|
46
|
-
Object.values(themeClasses).forEach((className) => {
|
|
47
|
-
body.classList.remove(className);
|
|
48
|
-
html.classList.remove(className);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
// Add the selected theme class to <html>
|
|
52
|
-
const themeClass = themeClasses[themeValue as keyof typeof themeClasses];
|
|
53
|
-
if (themeClass) {
|
|
54
|
-
html.classList.add(themeClass);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Save theme to localStorage
|
|
58
|
-
if (typeof window !== "undefined") {
|
|
59
|
-
localStorage.setItem("svelte-ui-theme", themeValue);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Apply theme when selection changes
|
|
28
|
+
// Apply (and persist) whenever the selection changes.
|
|
64
29
|
$effect(() => {
|
|
65
|
-
applyTheme(selectedTheme);
|
|
30
|
+
applyTheme(selectedTheme as ThemeValue);
|
|
66
31
|
});
|
|
67
32
|
</script>
|
|
68
33
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ThemeToggle — an icon button that flips between light and dark, sibling to `ThemeSelector`.
|
|
4
|
+
* Shares the same persisted state (`svelte-ui-theme` in localStorage, applied to `<html>`), so the
|
|
5
|
+
* two stay in sync. When nothing is stored it follows the system preference; the icon reflects the
|
|
6
|
+
* effective theme (☀ light / ☾ dark), and a click sets the explicit opposite.
|
|
7
|
+
*/
|
|
8
|
+
import Button from "./Button.svelte";
|
|
9
|
+
import type { Size } from "../types/sizes.js";
|
|
10
|
+
import type { Variant } from "../types/variants.js";
|
|
11
|
+
import type { Responsive } from "../types/responsive.js";
|
|
12
|
+
import { applyTheme, readStoredTheme, resolveTheme, type ThemeValue } from "./theme.js";
|
|
13
|
+
import IconSun from "~icons/mdi/weather-sunny";
|
|
14
|
+
import IconMoon from "~icons/mdi/weather-night";
|
|
15
|
+
|
|
16
|
+
// Shared axes, forwarded to the underlying Button. Icon-only, so it defaults to the chromeless ghost.
|
|
17
|
+
let { size = "md", variant = "ghost" }: { size?: Responsive<Size>; variant?: Variant } = $props();
|
|
18
|
+
|
|
19
|
+
// The stored value ("auto" until the user picks) and the tracked system preference.
|
|
20
|
+
let theme = $state<ThemeValue>("auto");
|
|
21
|
+
let system = $state<"light" | "dark">("light");
|
|
22
|
+
|
|
23
|
+
// The theme actually showing — "auto" resolved against the system preference.
|
|
24
|
+
const effective = $derived(theme === "auto" ? system : theme);
|
|
25
|
+
|
|
26
|
+
// Load stored theme + track the system preference (so the icon stays right in "auto" mode).
|
|
27
|
+
$effect(() => {
|
|
28
|
+
theme = readStoredTheme();
|
|
29
|
+
system = resolveTheme("auto");
|
|
30
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
31
|
+
const onChange = (e: MediaQueryListEvent) => (system = e.matches ? "dark" : "light");
|
|
32
|
+
mq.addEventListener("change", onChange);
|
|
33
|
+
return () => mq.removeEventListener("change", onChange);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Apply (and persist) whenever the theme changes.
|
|
37
|
+
$effect(() => {
|
|
38
|
+
applyTheme(theme);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Flip to the explicit opposite of whatever is currently in effect.
|
|
42
|
+
function toggle() {
|
|
43
|
+
theme = effective === "dark" ? "light" : "dark";
|
|
44
|
+
}
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<Button
|
|
48
|
+
{size}
|
|
49
|
+
{variant}
|
|
50
|
+
onclick={toggle}
|
|
51
|
+
aria-pressed={effective === "dark"}
|
|
52
|
+
title={effective === "dark" ? "Switch to light theme" : "Switch to dark theme"}
|
|
53
|
+
>
|
|
54
|
+
{#snippet icon()}
|
|
55
|
+
{#if effective === "dark"}<IconMoon />{:else}<IconSun />{/if}
|
|
56
|
+
{/snippet}
|
|
57
|
+
</Button>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Size } from "../types/sizes.js";
|
|
2
|
+
import type { Variant } from "../types/variants.js";
|
|
3
|
+
import type { Responsive } from "../types/responsive.js";
|
|
4
|
+
type $$ComponentProps = {
|
|
5
|
+
size?: Responsive<Size>;
|
|
6
|
+
variant?: Variant;
|
|
7
|
+
};
|
|
8
|
+
declare const ThemeToggle: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
9
|
+
type ThemeToggle = ReturnType<typeof ThemeToggle>;
|
|
10
|
+
export default ThemeToggle;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared theme state — single source for the persisted theme, used by `ThemeSelector` and
|
|
3
|
+
* `ThemeToggle` so the localStorage key, the `<html>` class application, and system detection can't
|
|
4
|
+
* drift between them.
|
|
5
|
+
*/
|
|
6
|
+
export declare const THEME_STORAGE_KEY = "svelte-ui-theme";
|
|
7
|
+
export type ThemeValue = "auto" | "light" | "dark";
|
|
8
|
+
/** The saved theme, or `"auto"` when nothing valid is stored (or on the server). */
|
|
9
|
+
export declare function readStoredTheme(): ThemeValue;
|
|
10
|
+
/** The OS/browser preference, used to resolve `"auto"` to a concrete light/dark. */
|
|
11
|
+
export declare function systemTheme(): "light" | "dark";
|
|
12
|
+
/** The concrete theme in effect — `"auto"` resolved against the system preference. */
|
|
13
|
+
export declare function resolveTheme(value: ThemeValue): "light" | "dark";
|
|
14
|
+
/**
|
|
15
|
+
* Apply a theme to `<html>` (documentElement) — NOT `<body>` — and persist it.
|
|
16
|
+
* The tokens (incl. --ui-color-hover/active, which color-mix from other tokens) are defined at
|
|
17
|
+
* `:root`; app.html's pre-hydration script forces the class onto `<html>`. Applying it to `<body>`
|
|
18
|
+
* instead would leave `<html>` on the system `@media(prefers-color-scheme)` path, so token-references
|
|
19
|
+
* would resolve against the WRONG theme at `:root` and inherit down stale (e.g. a white hover-tint on
|
|
20
|
+
* a light page → invisible hover). Keep the class on `:root`. `"auto"` clears both classes so the
|
|
21
|
+
* `@media` path takes over.
|
|
22
|
+
*/
|
|
23
|
+
export declare function applyTheme(value: ThemeValue): void;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared theme state — single source for the persisted theme, used by `ThemeSelector` and
|
|
3
|
+
* `ThemeToggle` so the localStorage key, the `<html>` class application, and system detection can't
|
|
4
|
+
* drift between them.
|
|
5
|
+
*/
|
|
6
|
+
export const THEME_STORAGE_KEY = "svelte-ui-theme";
|
|
7
|
+
const THEME_CLASSES = {
|
|
8
|
+
light: "theme-light",
|
|
9
|
+
dark: "theme-dark",
|
|
10
|
+
};
|
|
11
|
+
/** The saved theme, or `"auto"` when nothing valid is stored (or on the server). */
|
|
12
|
+
export function readStoredTheme() {
|
|
13
|
+
if (typeof window === "undefined")
|
|
14
|
+
return "auto";
|
|
15
|
+
const v = localStorage.getItem(THEME_STORAGE_KEY);
|
|
16
|
+
return v === "light" || v === "dark" || v === "auto" ? v : "auto";
|
|
17
|
+
}
|
|
18
|
+
/** The OS/browser preference, used to resolve `"auto"` to a concrete light/dark. */
|
|
19
|
+
export function systemTheme() {
|
|
20
|
+
if (typeof window === "undefined")
|
|
21
|
+
return "light";
|
|
22
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
23
|
+
}
|
|
24
|
+
/** The concrete theme in effect — `"auto"` resolved against the system preference. */
|
|
25
|
+
export function resolveTheme(value) {
|
|
26
|
+
return value === "auto" ? systemTheme() : value;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Apply a theme to `<html>` (documentElement) — NOT `<body>` — and persist it.
|
|
30
|
+
* The tokens (incl. --ui-color-hover/active, which color-mix from other tokens) are defined at
|
|
31
|
+
* `:root`; app.html's pre-hydration script forces the class onto `<html>`. Applying it to `<body>`
|
|
32
|
+
* instead would leave `<html>` on the system `@media(prefers-color-scheme)` path, so token-references
|
|
33
|
+
* would resolve against the WRONG theme at `:root` and inherit down stale (e.g. a white hover-tint on
|
|
34
|
+
* a light page → invisible hover). Keep the class on `:root`. `"auto"` clears both classes so the
|
|
35
|
+
* `@media` path takes over.
|
|
36
|
+
*/
|
|
37
|
+
export function applyTheme(value) {
|
|
38
|
+
if (typeof document === "undefined")
|
|
39
|
+
return;
|
|
40
|
+
const { body, documentElement: html } = document;
|
|
41
|
+
Object.values(THEME_CLASSES).forEach((className) => {
|
|
42
|
+
body.classList.remove(className);
|
|
43
|
+
html.classList.remove(className);
|
|
44
|
+
});
|
|
45
|
+
if (value === "light" || value === "dark")
|
|
46
|
+
html.classList.add(THEME_CLASSES[value]);
|
|
47
|
+
if (typeof window !== "undefined")
|
|
48
|
+
localStorage.setItem(THEME_STORAGE_KEY, value);
|
|
49
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ export { default as TimeSpinner } from "./components/TimeSpinner.svelte";
|
|
|
28
28
|
export { default as TimePicker } from "./components/TimePicker.svelte";
|
|
29
29
|
export { default as TimeRangeInput } from "./components/TimeRangeInput.svelte";
|
|
30
30
|
export { default as ThemeSelector } from "./components/ThemeSelector.svelte";
|
|
31
|
+
export { default as ThemeToggle } from "./components/ThemeToggle.svelte";
|
|
31
32
|
export { default as Popup } from "./components/Popup.svelte";
|
|
32
33
|
export { default as Menu } from "./components/Menu.svelte";
|
|
33
34
|
export { default as Checkbox } from "./components/Checkbox.svelte";
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,7 @@ export { default as TimePicker } from "./components/TimePicker.svelte";
|
|
|
32
32
|
export { default as TimeRangeInput } from "./components/TimeRangeInput.svelte";
|
|
33
33
|
// Theme Components
|
|
34
34
|
export { default as ThemeSelector } from "./components/ThemeSelector.svelte";
|
|
35
|
+
export { default as ThemeToggle } from "./components/ThemeToggle.svelte";
|
|
35
36
|
// Utility Components
|
|
36
37
|
export { default as Popup } from "./components/Popup.svelte";
|
|
37
38
|
export { default as Menu } from "./components/Menu.svelte";
|
package/docs/components.md
CHANGED
|
@@ -148,8 +148,13 @@ dialog.
|
|
|
148
148
|
- **Location**: `src/lib/components/Dialog.svelte`
|
|
149
149
|
- **Props**: `isOpen` (bindable), `width` (any CSS value — `'80%'`, `'80vw'`, `'400px'`; default
|
|
150
150
|
auto, capped at `min(32rem, …)`), `height` (any CSS value; default auto), `dismissible` (default
|
|
151
|
-
`true`), `onClose`, `class`, `children` (snippet). Extra attributes (e.g.
|
|
152
|
-
the panel.
|
|
151
|
+
`true`), `onClose`, `onShow`, `onHide`, `class`, `children` (snippet). Extra attributes (e.g.
|
|
152
|
+
`aria-label`) spread onto the panel.
|
|
153
|
+
- **Focus control**: `onShow(ctx)` fires on open — `ctx.autoFocus` is preset to the default focusable
|
|
154
|
+
selector; set it to a **CSS selector / element** to focus something specific, or **`false`** to
|
|
155
|
+
skip (focus stays on the panel). `onHide(ctx)` mirrors it with `ctx.restoreFocus` (preset to the
|
|
156
|
+
pre-open element; set `false` to skip restoring focus, or another element). A handler that ignores
|
|
157
|
+
the knob keeps the built-in behavior.
|
|
153
158
|
- **Features**: portals to `<body>`, background scroll-lock (scrollbar-width compensated), backdrop
|
|
154
159
|
+ Escape dismiss, **focus trap** (initial focus in, Tab wraps, focus restored on close),
|
|
155
160
|
`role="dialog"` + `aria-modal`.
|
|
@@ -162,7 +167,8 @@ shell.
|
|
|
162
167
|
- **Location**: `src/lib/components/ConfirmDialog.svelte`
|
|
163
168
|
- **Props**: `isOpen` (bindable), `title`, `message`, `confirmText`, `cancelText`, `onConfirm`,
|
|
164
169
|
`onCancel`, `variant` (`'normal' | 'danger'`), `size`, `showConfirm`, `showCancel`, `dismissible`,
|
|
165
|
-
`width`/`height` (forwarded to the shell), `children` (snippet — replaces
|
|
170
|
+
`width`/`height`/`onShow`/`onHide` (forwarded to the shell), `children` (snippet — replaces
|
|
171
|
+
`message` as the body).
|
|
166
172
|
|
|
167
173
|
### Popup
|
|
168
174
|
|
|
@@ -225,9 +231,20 @@ Status indicator / label.
|
|
|
225
231
|
Theme switcher (Auto / Light / Dark), built on `Select`.
|
|
226
232
|
|
|
227
233
|
- **Location**: `src/lib/components/ThemeSelector.svelte`
|
|
228
|
-
- **
|
|
234
|
+
- **Axes**: `size`, `variant` (forwarded to the Select)
|
|
235
|
+
- **Features**: applies `.theme-light` / `.theme-dark` to `<html>`, `auto` follows the
|
|
229
236
|
system preference, persists to `localStorage` (`svelte-ui-theme`)
|
|
230
237
|
|
|
238
|
+
### ThemeToggle
|
|
239
|
+
|
|
240
|
+
Compact icon-button sibling of `ThemeSelector` — flips light ⇄ dark (☀ / ☾), built on `Button`.
|
|
241
|
+
|
|
242
|
+
- **Location**: `src/lib/components/ThemeToggle.svelte`
|
|
243
|
+
- **Axes**: `size`, `variant` (default `ghost`)
|
|
244
|
+
- **Features**: shares `ThemeSelector`'s state (same `localStorage` key + `<html>` application, via
|
|
245
|
+
`src/lib/components/theme.ts`), follows the system preference until first toggled, icon reflects the
|
|
246
|
+
effective theme
|
|
247
|
+
|
|
231
248
|
## Import Pattern
|
|
232
249
|
|
|
233
250
|
```typescript
|
package/docs/usage.md
CHANGED
|
@@ -273,6 +273,21 @@ with `width` / `height` (any CSS value).
|
|
|
273
273
|
</Dialog>
|
|
274
274
|
```
|
|
275
275
|
|
|
276
|
+
**Controlling focus.** The shell focuses the first focusable element on open and restores focus on
|
|
277
|
+
close. Override via `onShow` / `onHide` — each hands you a mutable knob (default preserved if you
|
|
278
|
+
ignore it):
|
|
279
|
+
|
|
280
|
+
```svelte
|
|
281
|
+
<Dialog
|
|
282
|
+
bind:isOpen={open}
|
|
283
|
+
onShow={(ctx) => (ctx.autoFocus = "[data-focus-me]")}
|
|
284
|
+
onHide={(ctx) => (ctx.restoreFocus = false)}
|
|
285
|
+
>
|
|
286
|
+
<!-- ctx.autoFocus: CSS selector | element | false (skip). Default = first focusable. -->
|
|
287
|
+
<!-- ctx.restoreFocus: element | false (skip). Default = the element focused before open. -->
|
|
288
|
+
</Dialog>
|
|
289
|
+
```
|
|
290
|
+
|
|
276
291
|
### Badge
|
|
277
292
|
|
|
278
293
|
```svelte
|
|
@@ -439,12 +454,16 @@ See the live demo for the full DataTable + Query walkthrough — http://localhos
|
|
|
439
454
|
</style>
|
|
440
455
|
```
|
|
441
456
|
|
|
442
|
-
### Theme Selector
|
|
457
|
+
### Theme Selector / Toggle
|
|
443
458
|
|
|
444
459
|
```svelte
|
|
445
460
|
<script>
|
|
446
|
-
import { ThemeSelector } from "@spaethtech/svelte-ui";
|
|
461
|
+
import { ThemeSelector, ThemeToggle } from "@spaethtech/svelte-ui";
|
|
447
462
|
</script>
|
|
448
463
|
|
|
464
|
+
<!-- Full Auto/Light/Dark dropdown -->
|
|
449
465
|
<ThemeSelector />
|
|
466
|
+
|
|
467
|
+
<!-- Compact ☀/☾ icon toggle (light ⇄ dark) — same persisted state, follows system until toggled -->
|
|
468
|
+
<ThemeToggle />
|
|
450
469
|
```
|