@loidolt/theme-svelte 0.7.0 → 0.8.1
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/dist/components/PresetPicker.svelte +62 -0
- package/dist/components/PresetPicker.svelte.d.ts +40 -0
- package/dist/components/ToggleGroup.svelte +10 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/theme.svelte.d.ts +24 -4
- package/dist/theme.svelte.js +70 -16
- package/package.json +3 -3
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script lang="ts" generics="P extends string = string">
|
|
2
|
+
import { builtInPresets } from '@loidolt/theme-tokens';
|
|
3
|
+
import type { ControlSize, Option } from '../types.js';
|
|
4
|
+
import type { Theme } from '../theme.svelte.js';
|
|
5
|
+
import Select from './Select.svelte';
|
|
6
|
+
import ToggleGroup from './ToggleGroup.svelte';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
/** The store from `createTheme({ presets })`. */
|
|
10
|
+
theme: Theme<P>;
|
|
11
|
+
/**
|
|
12
|
+
* Label per preset. Defaults to the built-in presets' own labels, and to the capitalised
|
|
13
|
+
* name for any preset of your own.
|
|
14
|
+
*/
|
|
15
|
+
options?: Option<P>[];
|
|
16
|
+
/** Accessible name of the control. */
|
|
17
|
+
label?: string;
|
|
18
|
+
/** `segmented` keeps every preset in view; `select` folds a longer list into a dropdown. */
|
|
19
|
+
variant?: 'segmented' | 'select';
|
|
20
|
+
size?: ControlSize;
|
|
21
|
+
class?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let {
|
|
25
|
+
theme,
|
|
26
|
+
options,
|
|
27
|
+
label = 'Style',
|
|
28
|
+
variant = 'segmented',
|
|
29
|
+
size = 'sm',
|
|
30
|
+
class: className,
|
|
31
|
+
}: Props = $props();
|
|
32
|
+
|
|
33
|
+
const builtInLabels = new Map<string, string>(
|
|
34
|
+
builtInPresets.map((preset) => [preset.name, preset.label])
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const items = $derived(
|
|
38
|
+
options ??
|
|
39
|
+
theme.presets.map((name) => ({
|
|
40
|
+
value: name,
|
|
41
|
+
label: builtInLabels.get(name) ?? name.charAt(0).toUpperCase() + name.slice(1),
|
|
42
|
+
}))
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// Bound through the theme rather than passed down: a segmented control lets the pressed item
|
|
46
|
+
// be pressed again to clear it, and there is no "no preset". The theme ignores the empty
|
|
47
|
+
// value, and the getter hands the control the preset that is still in force.
|
|
48
|
+
const getPreset = () => theme.preset;
|
|
49
|
+
const setPreset = (next: P | P[] | '' | undefined) => {
|
|
50
|
+
if (typeof next === 'string' && next !== '') theme.preset = next;
|
|
51
|
+
};
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<!--
|
|
55
|
+
The preset half of a theme switcher; ThemeToggle is the scheme half. Both drive the same
|
|
56
|
+
`createTheme()` store, so they can sit side by side in a topbar.
|
|
57
|
+
-->
|
|
58
|
+
{#if variant === 'select'}
|
|
59
|
+
<Select options={items} bind:value={getPreset, setPreset} {label} boxed class={className} />
|
|
60
|
+
{:else}
|
|
61
|
+
<ToggleGroup options={items} bind:value={getPreset, setPreset} {label} {size} class={className} />
|
|
62
|
+
{/if}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ControlSize, Option } from '../types.js';
|
|
2
|
+
import type { Theme } from '../theme.svelte.js';
|
|
3
|
+
declare function $$render<P extends string = string>(): {
|
|
4
|
+
props: {
|
|
5
|
+
/** The store from `createTheme({ presets })`. */
|
|
6
|
+
theme: Theme<P>;
|
|
7
|
+
/**
|
|
8
|
+
* Label per preset. Defaults to the built-in presets' own labels, and to the capitalised
|
|
9
|
+
* name for any preset of your own.
|
|
10
|
+
*/
|
|
11
|
+
options?: Option<P>[];
|
|
12
|
+
/** Accessible name of the control. */
|
|
13
|
+
label?: string;
|
|
14
|
+
/** `segmented` keeps every preset in view; `select` folds a longer list into a dropdown. */
|
|
15
|
+
variant?: "segmented" | "select";
|
|
16
|
+
size?: ControlSize;
|
|
17
|
+
class?: string;
|
|
18
|
+
};
|
|
19
|
+
exports: {};
|
|
20
|
+
bindings: "";
|
|
21
|
+
slots: {};
|
|
22
|
+
events: {};
|
|
23
|
+
};
|
|
24
|
+
declare class __sveltets_Render<P extends string = string> {
|
|
25
|
+
props(): ReturnType<typeof $$render<P>>['props'];
|
|
26
|
+
events(): ReturnType<typeof $$render<P>>['events'];
|
|
27
|
+
slots(): ReturnType<typeof $$render<P>>['slots'];
|
|
28
|
+
bindings(): "";
|
|
29
|
+
exports(): {};
|
|
30
|
+
}
|
|
31
|
+
interface $$IsomorphicComponent {
|
|
32
|
+
new <P extends string = string>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<P>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<P>['props']>, ReturnType<__sveltets_Render<P>['events']>, ReturnType<__sveltets_Render<P>['slots']>> & {
|
|
33
|
+
$$bindings?: ReturnType<__sveltets_Render<P>['bindings']>;
|
|
34
|
+
} & ReturnType<__sveltets_Render<P>['exports']>;
|
|
35
|
+
<P extends string = string>(internal: unknown, props: ReturnType<__sveltets_Render<P>['props']> & {}): ReturnType<__sveltets_Render<P>['exports']>;
|
|
36
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
37
|
+
}
|
|
38
|
+
declare const PresetPicker: $$IsomorphicComponent;
|
|
39
|
+
type PresetPicker<P extends string = string> = InstanceType<typeof PresetPicker<P>>;
|
|
40
|
+
export default PresetPicker;
|
|
@@ -51,6 +51,14 @@
|
|
|
51
51
|
value = next as T | T[];
|
|
52
52
|
onValueChange?.(next as T | T[]);
|
|
53
53
|
};
|
|
54
|
+
|
|
55
|
+
/*
|
|
56
|
+
* Bound rather than passed, so Bits holds no copy of its own: whatever `value` reads after a
|
|
57
|
+
* press is what shows as pressed. A parent that refuses a change — PresetPicker refusing the
|
|
58
|
+
* empty value a second press on the pressed item produces — then keeps its item pressed.
|
|
59
|
+
*/
|
|
60
|
+
const single = () => (value ?? '') as string;
|
|
61
|
+
const many = () => (value ?? []) as string[];
|
|
54
62
|
</script>
|
|
55
63
|
|
|
56
64
|
<!--
|
|
@@ -73,8 +81,7 @@
|
|
|
73
81
|
<ToggleGroupPrimitive.Root
|
|
74
82
|
bind:ref
|
|
75
83
|
type="multiple"
|
|
76
|
-
value={
|
|
77
|
-
onValueChange={change}
|
|
84
|
+
bind:value={many, change}
|
|
78
85
|
{orientation}
|
|
79
86
|
{disabled}
|
|
80
87
|
class={classes}
|
|
@@ -92,8 +99,7 @@
|
|
|
92
99
|
<ToggleGroupPrimitive.Root
|
|
93
100
|
bind:ref
|
|
94
101
|
type="single"
|
|
95
|
-
value={
|
|
96
|
-
onValueChange={change}
|
|
102
|
+
bind:value={single, change}
|
|
97
103
|
{orientation}
|
|
98
104
|
{disabled}
|
|
99
105
|
class={classes}
|
package/dist/index.d.ts
CHANGED
|
@@ -50,6 +50,7 @@ export { default as Pagination } from './components/Pagination.svelte';
|
|
|
50
50
|
export { default as Panel } from './components/Panel.svelte';
|
|
51
51
|
export { default as PasswordInput } from './components/PasswordInput.svelte';
|
|
52
52
|
export { default as Popover } from './components/Popover.svelte';
|
|
53
|
+
export { default as PresetPicker } from './components/PresetPicker.svelte';
|
|
53
54
|
export { default as Progress } from './components/Progress.svelte';
|
|
54
55
|
export { default as RadioGroup } from './components/RadioGroup.svelte';
|
|
55
56
|
export { default as RecordStepper } from './components/RecordStepper.svelte';
|
package/dist/index.js
CHANGED
|
@@ -50,6 +50,7 @@ export { default as Pagination } from './components/Pagination.svelte';
|
|
|
50
50
|
export { default as Panel } from './components/Panel.svelte';
|
|
51
51
|
export { default as PasswordInput } from './components/PasswordInput.svelte';
|
|
52
52
|
export { default as Popover } from './components/Popover.svelte';
|
|
53
|
+
export { default as PresetPicker } from './components/PresetPicker.svelte';
|
|
53
54
|
export { default as Progress } from './components/Progress.svelte';
|
|
54
55
|
export { default as RadioGroup } from './components/RadioGroup.svelte';
|
|
55
56
|
export { default as RecordStepper } from './components/RecordStepper.svelte';
|
package/dist/theme.svelte.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export type ThemePreference = 'light' | 'dark' | 'system';
|
|
3
3
|
/** What that choice resolves to right now — the value written to the DOM. */
|
|
4
4
|
export type ColorScheme = 'light' | 'dark';
|
|
5
|
-
export interface ThemeOptions {
|
|
5
|
+
export interface ThemeOptions<P extends string = string> {
|
|
6
6
|
/** `localStorage` key holding the preference. Set to `null` to keep the choice in memory. */
|
|
7
7
|
storageKey?: string | null;
|
|
8
8
|
/** Attribute the resolved scheme is written to. Must match the stylesheet's selector. */
|
|
@@ -18,14 +18,34 @@ export interface ThemeOptions {
|
|
|
18
18
|
* if separate windows should be able to hold different themes.
|
|
19
19
|
*/
|
|
20
20
|
sync?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The style presets the app ships — the names of the preset stylesheets it imports. Setting
|
|
23
|
+
* this turns on the preset axis: the chosen preset is stored and written to `presetAttribute`
|
|
24
|
+
* alongside the colour scheme. Leave it out and the preset attribute is never touched, so a
|
|
25
|
+
* static `<html data-preset="soft">` keeps working.
|
|
26
|
+
*/
|
|
27
|
+
presets?: readonly P[];
|
|
28
|
+
/** Preset before anything is stored. Defaults to the first of `presets`. */
|
|
29
|
+
defaultPreset?: NoInfer<P>;
|
|
30
|
+
/** `localStorage` key holding the preset. Set to `null` to keep the choice in memory. */
|
|
31
|
+
presetStorageKey?: string | null;
|
|
32
|
+
/** Attribute the preset is written to. Must match the preset stylesheets' selector. */
|
|
33
|
+
presetAttribute?: string;
|
|
21
34
|
}
|
|
22
|
-
export interface Theme {
|
|
35
|
+
export interface Theme<P extends string = string> {
|
|
23
36
|
/** The stored choice. Assignable: `theme.preference = 'dark'`. */
|
|
24
37
|
preference: ThemePreference;
|
|
25
38
|
/** The scheme in force — `preference`, or the system scheme when that is `system`. */
|
|
26
39
|
readonly resolved: ColorScheme;
|
|
27
40
|
/** What the system currently prefers, regardless of the choice. */
|
|
28
41
|
readonly system: ColorScheme;
|
|
42
|
+
/**
|
|
43
|
+
* The style preset in force, or `undefined` when the theme was created without `presets`.
|
|
44
|
+
* Assignable: `theme.preset = 'soft'`; a name outside `presets` is ignored.
|
|
45
|
+
*/
|
|
46
|
+
preset: P | undefined;
|
|
47
|
+
/** The presets this theme switches between, as given. */
|
|
48
|
+
readonly presets: readonly P[];
|
|
29
49
|
/** Flips between explicit light and dark, starting from whatever is showing. */
|
|
30
50
|
toggle(): void;
|
|
31
51
|
/** Removes the media and storage listeners. Automatic for a component-scoped instance. */
|
|
@@ -52,8 +72,8 @@ export interface Theme {
|
|
|
52
72
|
*
|
|
53
73
|
* Pair it with {@link themeScript} in `app.html`, or the first paint of a reload is light.
|
|
54
74
|
*/
|
|
55
|
-
export declare function createTheme(options?: ThemeOptions): Theme
|
|
56
|
-
export interface ThemeScriptOptions extends Pick<ThemeOptions, 'storageKey' | 'attribute' | 'defaultPreference' | 'defaultScheme'> {
|
|
75
|
+
export declare function createTheme<const P extends string = string>(options?: ThemeOptions<P>): Theme<P>;
|
|
76
|
+
export interface ThemeScriptOptions extends Pick<ThemeOptions, 'storageKey' | 'attribute' | 'defaultPreference' | 'defaultScheme' | 'presets' | 'defaultPreset' | 'presetStorageKey' | 'presetAttribute'> {
|
|
57
77
|
/** Wrap the source in a `<script>` tag. Off by default, so it can go inside your own tag. */
|
|
58
78
|
tag?: boolean;
|
|
59
79
|
}
|
package/dist/theme.svelte.js
CHANGED
|
@@ -23,7 +23,11 @@ const isPreference = (value) => typeof value === 'string' && SCHEMES.includes(va
|
|
|
23
23
|
* Pair it with {@link themeScript} in `app.html`, or the first paint of a reload is light.
|
|
24
24
|
*/
|
|
25
25
|
export function createTheme(options = {}) {
|
|
26
|
-
const { storageKey = 'loidolt-theme', attribute = 'data-theme', element, defaultPreference = 'system', defaultScheme = 'light', sync = true, } = options;
|
|
26
|
+
const { storageKey = 'loidolt-theme', attribute = 'data-theme', element, defaultPreference = 'system', defaultScheme = 'light', sync = true, presets = [], presetStorageKey = 'loidolt-preset', presetAttribute = 'data-preset', } = options;
|
|
27
|
+
const defaultPreset = options.defaultPreset ?? presets[0];
|
|
28
|
+
if (defaultPreset !== undefined && !presets.includes(defaultPreset)) {
|
|
29
|
+
throw new Error(`defaultPreset "${defaultPreset}" is not one of presets`);
|
|
30
|
+
}
|
|
27
31
|
const browser = typeof document !== 'undefined';
|
|
28
32
|
const target = element ?? (browser ? document.documentElement : null);
|
|
29
33
|
const read = () => {
|
|
@@ -39,7 +43,22 @@ export function createTheme(options = {}) {
|
|
|
39
43
|
return defaultPreference;
|
|
40
44
|
}
|
|
41
45
|
};
|
|
46
|
+
const isPreset = (value) => typeof value === 'string' && presets.includes(value);
|
|
47
|
+
const readPreset = () => {
|
|
48
|
+
if (defaultPreset === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
if (!browser || presetStorageKey === null)
|
|
51
|
+
return defaultPreset;
|
|
52
|
+
try {
|
|
53
|
+
const stored = localStorage.getItem(presetStorageKey);
|
|
54
|
+
return isPreset(stored) ? stored : defaultPreset;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return defaultPreset; // see `read`
|
|
58
|
+
}
|
|
59
|
+
};
|
|
42
60
|
let preference = $state(read());
|
|
61
|
+
let preset = $state(readPreset());
|
|
43
62
|
let system = $state(defaultScheme);
|
|
44
63
|
let media;
|
|
45
64
|
const onSystemChange = (event) => {
|
|
@@ -54,35 +73,57 @@ export function createTheme(options = {}) {
|
|
|
54
73
|
const resolve = () => (preference === 'system' ? system : preference);
|
|
55
74
|
function apply() {
|
|
56
75
|
target?.setAttribute(attribute, resolve());
|
|
76
|
+
if (preset !== undefined)
|
|
77
|
+
target?.setAttribute(presetAttribute, preset);
|
|
57
78
|
}
|
|
58
79
|
const onStorage = (event) => {
|
|
59
|
-
if (event.key
|
|
80
|
+
if (storageKey !== null && event.key === storageKey) {
|
|
81
|
+
// A cleared key means another tab reset to the default rather than picking a scheme.
|
|
82
|
+
preference = isPreference(event.newValue) ? event.newValue : defaultPreference;
|
|
83
|
+
}
|
|
84
|
+
else if (defaultPreset !== undefined &&
|
|
85
|
+
presetStorageKey !== null &&
|
|
86
|
+
event.key === presetStorageKey) {
|
|
87
|
+
preset = isPreset(event.newValue) ? event.newValue : defaultPreset;
|
|
88
|
+
}
|
|
89
|
+
else
|
|
60
90
|
return;
|
|
61
|
-
// A cleared key means another tab reset to the default rather than picking a scheme.
|
|
62
|
-
preference = isPreference(event.newValue) ? event.newValue : defaultPreference;
|
|
63
91
|
apply();
|
|
64
92
|
};
|
|
65
|
-
|
|
93
|
+
const listens = browser &&
|
|
94
|
+
sync &&
|
|
95
|
+
(storageKey !== null || (defaultPreset !== undefined && presetStorageKey !== null));
|
|
96
|
+
if (listens)
|
|
66
97
|
window.addEventListener('storage', onStorage);
|
|
67
98
|
// Written on creation, not in an effect: `createTheme()` is meant to be callable at module
|
|
68
99
|
// scope, where there is no reactive context to own an effect.
|
|
69
100
|
apply();
|
|
101
|
+
const persist = (key, value) => {
|
|
102
|
+
if (!browser || key === null)
|
|
103
|
+
return;
|
|
104
|
+
try {
|
|
105
|
+
localStorage.setItem(key, value);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
/* see `read`: unavailable storage degrades to a memory-only choice */
|
|
109
|
+
}
|
|
110
|
+
};
|
|
70
111
|
function set(next) {
|
|
71
112
|
preference = next;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
113
|
+
persist(storageKey, next);
|
|
114
|
+
apply();
|
|
115
|
+
}
|
|
116
|
+
function setPreset(next) {
|
|
117
|
+
if (defaultPreset === undefined || !isPreset(next))
|
|
118
|
+
return;
|
|
119
|
+
preset = next;
|
|
120
|
+
persist(presetStorageKey, next);
|
|
80
121
|
apply();
|
|
81
122
|
}
|
|
82
123
|
const destroy = () => {
|
|
83
124
|
media?.removeEventListener('change', onSystemChange);
|
|
84
125
|
media = undefined;
|
|
85
|
-
if (
|
|
126
|
+
if (listens)
|
|
86
127
|
window.removeEventListener('storage', onStorage);
|
|
87
128
|
};
|
|
88
129
|
try {
|
|
@@ -104,6 +145,13 @@ export function createTheme(options = {}) {
|
|
|
104
145
|
get system() {
|
|
105
146
|
return system;
|
|
106
147
|
},
|
|
148
|
+
get preset() {
|
|
149
|
+
return preset;
|
|
150
|
+
},
|
|
151
|
+
set preset(next) {
|
|
152
|
+
setPreset(next);
|
|
153
|
+
},
|
|
154
|
+
presets,
|
|
107
155
|
toggle() {
|
|
108
156
|
set(resolve() === 'dark' ? 'light' : 'dark');
|
|
109
157
|
},
|
|
@@ -137,7 +185,7 @@ export function createTheme(options = {}) {
|
|
|
137
185
|
* inline in an HTML `<script>` element without further escaping.
|
|
138
186
|
*/
|
|
139
187
|
export function themeScript(options = {}) {
|
|
140
|
-
const { storageKey = 'loidolt-theme', attribute = 'data-theme', defaultPreference = 'system', defaultScheme = 'light', tag = false, } = options;
|
|
188
|
+
const { storageKey = 'loidolt-theme', attribute = 'data-theme', defaultPreference = 'system', defaultScheme = 'light', presets = [], presetStorageKey = 'loidolt-preset', presetAttribute = 'data-preset', tag = false, } = options;
|
|
141
189
|
// JSON string escaping alone leaves `<` intact, including a literal `</script>` that would end
|
|
142
190
|
// an inline script element. Escaping HTML-significant characters and the two JavaScript line
|
|
143
191
|
// separators keeps every option inside its string literal in both source-only and tagged forms.
|
|
@@ -151,6 +199,12 @@ export function themeScript(options = {}) {
|
|
|
151
199
|
const attr = serialize(attribute);
|
|
152
200
|
const fallback = serialize(defaultPreference);
|
|
153
201
|
const scheme = serialize(defaultScheme);
|
|
202
|
+
// Only emitted when the preset axis is on, so the scheme-only script stays byte-identical.
|
|
203
|
+
const preset = presets.length
|
|
204
|
+
? `try{var q=${serialize(options.defaultPreset ?? presets[0])},l=${serialize(presets)},k=${serialize(presetStorageKey)};` +
|
|
205
|
+
`try{var w=k===null?null:localStorage.getItem(k);if(l.indexOf(w)>-1)q=w;}catch(e){}` +
|
|
206
|
+
`document.documentElement.setAttribute(${serialize(presetAttribute)},q);}catch(e){}`
|
|
207
|
+
: '';
|
|
154
208
|
const source = `(function(){try{` +
|
|
155
209
|
`var p=${fallback};` +
|
|
156
210
|
// Storage failure must not skip system resolution and attribute application.
|
|
@@ -161,6 +215,6 @@ export function themeScript(options = {}) {
|
|
|
161
215
|
`document.documentElement.setAttribute(${attr},p==="system"?s:p);` +
|
|
162
216
|
// A theme is cosmetic; a throw here would abort a blocking head script and take the page
|
|
163
217
|
// down with it. Swallow and let the stylesheet's own default stand.
|
|
164
|
-
`}catch(e){}})()`;
|
|
218
|
+
`}catch(e){}${preset}})()`;
|
|
165
219
|
return tag ? `<script>${source}</script>` : source;
|
|
166
220
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loidolt/theme-svelte",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Accessible Svelte 5 components for Loidolt applications",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"clean": "rimraf dist"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@loidolt/theme-styles": "^0.
|
|
35
|
-
"@loidolt/theme-tokens": "^0.
|
|
34
|
+
"@loidolt/theme-styles": "^0.8.1",
|
|
35
|
+
"@loidolt/theme-tokens": "^0.8.1",
|
|
36
36
|
"bits-ui": "^2.19.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|