@dorsk/tsumikit 0.46.0 → 0.48.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 +12 -0
- package/dist/components/molecules/Carousel.svelte +276 -0
- package/dist/components/molecules/Carousel.svelte.d.ts +59 -0
- package/dist/components/molecules/ThemePicker.svelte +129 -10
- package/dist/components/molecules/ThemePicker.svelte.d.ts +7 -0
- package/dist/components/molecules/carousel-keyboard.d.ts +44 -0
- package/dist/components/molecules/carousel-keyboard.js +63 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/stores/theme.svelte.d.ts +23 -0
- package/dist/stores/theme.svelte.js +78 -6
- package/dist/theme-mode.d.ts +33 -0
- package/dist/theme-mode.js +45 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -64,6 +64,18 @@ import { Button, Field, Input, Modal, ThemePicker } from '@dorsk/tsumikit';
|
|
|
64
64
|
- `<ThemePicker />` and `<FontScalePicker />` wire the stores to the UI. Theme
|
|
65
65
|
is persisted to `localStorage` and applied with no flash (head snippet in
|
|
66
66
|
`app.html`) and updates the mobile `<meta name="theme-color">`.
|
|
67
|
+
- The store keeps a **preference**, not just an id: one remembered light theme,
|
|
68
|
+
one remembered dark theme, and which of the two is pinned — or `auto`, which
|
|
69
|
+
follows `prefers-color-scheme` and repaints when the system flips.
|
|
70
|
+
`theme.choose(id | 'auto')`, `theme.mode`, `theme.pref`, `theme.resolved`,
|
|
71
|
+
`theme.hydrate(pref)` (replay a server-side preference) and
|
|
72
|
+
`theme.onchange = (pref) => …` (mirror it back). `theme.set(id)` and
|
|
73
|
+
`theme.current` are unchanged. `<ThemePicker auto />` adds the "Auto" row;
|
|
74
|
+
its labels (`autoLabel`, `autoHelp`, `lightLabel`, `darkLabel`) are props.
|
|
75
|
+
- **Storage format (0.47):** `localStorage['tsumikit-theme']` now holds
|
|
76
|
+
`{"mode","light","dark"}` instead of a bare theme id. A bare id left by an
|
|
77
|
+
older version still loads — it pins its own slot — but a downgrade will not
|
|
78
|
+
read the new blob and falls back to the default theme.
|
|
67
79
|
|
|
68
80
|
### Stylesheets
|
|
69
81
|
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
<script lang="ts" generics="T">
|
|
2
|
+
/**
|
|
3
|
+
* Ordered full-bleed panels stepped with prev/next, dot indicators, arrow
|
|
4
|
+
* keys and horizontal swipe.
|
|
5
|
+
*
|
|
6
|
+
* ARIA follows the WAI-ARIA carousel pattern in its *group* flavour: the
|
|
7
|
+
* root is a region with `aria-roledescription="carousel"`, every slide is a
|
|
8
|
+
* `group` with `aria-roledescription="slide"` named "N of M", and the dots
|
|
9
|
+
* are plain buttons. The tablist flavour was rejected because it makes the
|
|
10
|
+
* dots the primary navigation and turns each slide into a `tabpanel`, which
|
|
11
|
+
* is the wrong model when prev/next is the main affordance and the panels
|
|
12
|
+
* are unlabelled content rather than named destinations. The slide container
|
|
13
|
+
* is a polite live region while the carousel is not auto-rotating.
|
|
14
|
+
*/
|
|
15
|
+
import type { Snippet } from 'svelte';
|
|
16
|
+
import IconButton from './IconButton.svelte';
|
|
17
|
+
import { clampSlide, nextSlideForKey, stepSlide, swipeStep } from './carousel-keyboard.js';
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
slides,
|
|
21
|
+
slide,
|
|
22
|
+
index = $bindable(0),
|
|
23
|
+
onchange,
|
|
24
|
+
loop = false,
|
|
25
|
+
autoplay = 0,
|
|
26
|
+
dots = true,
|
|
27
|
+
controls = true,
|
|
28
|
+
counter = false,
|
|
29
|
+
label = 'Carousel',
|
|
30
|
+
class: klass = '',
|
|
31
|
+
style: styleProp = '',
|
|
32
|
+
slideClass = ''
|
|
33
|
+
}: {
|
|
34
|
+
/** One entry per panel. Entries may themselves be snippets when no `slide` renderer is given. */
|
|
35
|
+
slides: T[];
|
|
36
|
+
/** Renders one panel from its entry and position. */
|
|
37
|
+
slide?: Snippet<[T, number]>;
|
|
38
|
+
/** Active panel; bindable. Clamped (or wrapped with `loop`) into range. */
|
|
39
|
+
index?: number;
|
|
40
|
+
onchange?: (index: number) => void;
|
|
41
|
+
/** Wrap from the last panel to the first and back. */
|
|
42
|
+
loop?: boolean;
|
|
43
|
+
/** Milliseconds between automatic advances. `0` (default) disables it. When
|
|
44
|
+
* on, rotation pauses on hover and focus and a play/pause control appears. */
|
|
45
|
+
autoplay?: number;
|
|
46
|
+
dots?: boolean;
|
|
47
|
+
controls?: boolean;
|
|
48
|
+
/** Show an `N / M` position counter. */
|
|
49
|
+
counter?: boolean;
|
|
50
|
+
label?: string;
|
|
51
|
+
class?: string;
|
|
52
|
+
style?: string;
|
|
53
|
+
slideClass?: string;
|
|
54
|
+
} = $props();
|
|
55
|
+
|
|
56
|
+
const count = $derived(slides.length);
|
|
57
|
+
const baseId = `carousel-${Math.random().toString(36).slice(2, 8)}`;
|
|
58
|
+
|
|
59
|
+
$effect(() => {
|
|
60
|
+
const clamped = clampSlide(index, count, false);
|
|
61
|
+
if (clamped !== index) index = clamped;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
let rootEl = $state<HTMLElement | null>(null);
|
|
65
|
+
|
|
66
|
+
function go(next: number, focusDot = false) {
|
|
67
|
+
const target = clampSlide(next, count, loop);
|
|
68
|
+
if (target === index) return;
|
|
69
|
+
index = target;
|
|
70
|
+
onchange?.(target);
|
|
71
|
+
if (focusDot) {
|
|
72
|
+
queueMicrotask(() =>
|
|
73
|
+
rootEl?.querySelector<HTMLButtonElement>(`#${baseId}-dot-${target}`)?.focus()
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const prev = () => go(stepSlide(index, count, -1, loop));
|
|
78
|
+
const next = () => go(stepSlide(index, count, 1, loop));
|
|
79
|
+
const atStart = $derived(!loop && index <= 0);
|
|
80
|
+
const atEnd = $derived(!loop && index >= count - 1);
|
|
81
|
+
|
|
82
|
+
function onkeydown(e: KeyboardEvent) {
|
|
83
|
+
const tag = (e.target as HTMLElement | null)?.tagName;
|
|
84
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
|
85
|
+
const target = nextSlideForKey(index, count, e.key, loop);
|
|
86
|
+
if (target === undefined) return;
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
const inDots = (e.target as HTMLElement | null)?.closest('.dots') !== null;
|
|
89
|
+
go(target, inDots);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let startX = 0;
|
|
93
|
+
let startY = 0;
|
|
94
|
+
let pointerId: number | null = null;
|
|
95
|
+
function onpointerdown(e: PointerEvent) {
|
|
96
|
+
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
97
|
+
pointerId = e.pointerId;
|
|
98
|
+
startX = e.clientX;
|
|
99
|
+
startY = e.clientY;
|
|
100
|
+
}
|
|
101
|
+
function onpointerup(e: PointerEvent) {
|
|
102
|
+
if (e.pointerId !== pointerId) return;
|
|
103
|
+
pointerId = null;
|
|
104
|
+
const step = swipeStep(e.clientX - startX, e.clientY - startY);
|
|
105
|
+
if (step) go(stepSlide(index, count, step, loop));
|
|
106
|
+
}
|
|
107
|
+
function onpointercancel() {
|
|
108
|
+
pointerId = null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let playing = $derived(autoplay > 0);
|
|
112
|
+
let hovered = $state(false);
|
|
113
|
+
let focused = $state(false);
|
|
114
|
+
const rotating = $derived(playing && autoplay > 0 && count > 1 && !hovered && !focused);
|
|
115
|
+
$effect(() => {
|
|
116
|
+
if (!rotating) return;
|
|
117
|
+
const id = setInterval(() => go(stepSlide(index, count, 1, true)), autoplay);
|
|
118
|
+
return () => clearInterval(id);
|
|
119
|
+
});
|
|
120
|
+
</script>
|
|
121
|
+
|
|
122
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
123
|
+
<section
|
|
124
|
+
bind:this={rootEl}
|
|
125
|
+
class="carousel {klass}"
|
|
126
|
+
style={styleProp}
|
|
127
|
+
aria-roledescription="carousel"
|
|
128
|
+
aria-label={label}
|
|
129
|
+
data-tsu="Carousel"
|
|
130
|
+
{onkeydown}
|
|
131
|
+
onpointerenter={() => (hovered = true)}
|
|
132
|
+
onpointerleave={() => (hovered = false)}
|
|
133
|
+
onfocusin={() => (focused = true)}
|
|
134
|
+
onfocusout={(e) => {
|
|
135
|
+
if (!rootEl?.contains(e.relatedTarget as Node | null)) focused = false;
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
139
|
+
<div
|
|
140
|
+
class="viewport"
|
|
141
|
+
aria-live={rotating ? 'off' : 'polite'}
|
|
142
|
+
{onpointerdown}
|
|
143
|
+
{onpointerup}
|
|
144
|
+
{onpointercancel}
|
|
145
|
+
>
|
|
146
|
+
<div class="track" style:transform="translateX(-{index * 100}%)">
|
|
147
|
+
{#each slides as item, i (i)}
|
|
148
|
+
<div
|
|
149
|
+
class="slide {slideClass}"
|
|
150
|
+
role="group"
|
|
151
|
+
aria-roledescription="slide"
|
|
152
|
+
aria-label="{i + 1} of {count}"
|
|
153
|
+
aria-hidden={i !== index}
|
|
154
|
+
inert={i !== index}
|
|
155
|
+
id="{baseId}-slide-{i}"
|
|
156
|
+
>
|
|
157
|
+
{#if slide}
|
|
158
|
+
{@render slide(item, i)}
|
|
159
|
+
{:else}
|
|
160
|
+
{@render (item as unknown as Snippet)()}
|
|
161
|
+
{/if}
|
|
162
|
+
</div>
|
|
163
|
+
{/each}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{#if count > 1 && (controls || dots || counter || autoplay > 0)}
|
|
168
|
+
<div class="bar">
|
|
169
|
+
{#if controls}
|
|
170
|
+
<IconButton icon="chevron-left" label="Previous slide" variant="ghost" box="sm" disabled={atStart} onclick={prev} />
|
|
171
|
+
{/if}
|
|
172
|
+
{#if dots}
|
|
173
|
+
<div class="dots" role="group" aria-label="Choose slide">
|
|
174
|
+
{#each slides as _, i (i)}
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
class="dot"
|
|
178
|
+
class:active={i === index}
|
|
179
|
+
id="{baseId}-dot-{i}"
|
|
180
|
+
aria-label="Slide {i + 1}"
|
|
181
|
+
aria-current={i === index ? 'true' : undefined}
|
|
182
|
+
aria-controls="{baseId}-slide-{i}"
|
|
183
|
+
tabindex={i === index ? 0 : -1}
|
|
184
|
+
onclick={() => go(i)}
|
|
185
|
+
></button>
|
|
186
|
+
{/each}
|
|
187
|
+
</div>
|
|
188
|
+
{/if}
|
|
189
|
+
{#if counter}
|
|
190
|
+
<span class="counter">{index + 1} / {count}</span>
|
|
191
|
+
{/if}
|
|
192
|
+
{#if autoplay > 0}
|
|
193
|
+
<IconButton
|
|
194
|
+
icon={playing ? 'pause' : 'play'}
|
|
195
|
+
label={playing ? 'Stop automatic rotation' : 'Start automatic rotation'}
|
|
196
|
+
variant="ghost"
|
|
197
|
+
box="sm"
|
|
198
|
+
pressed={playing}
|
|
199
|
+
onclick={() => (playing = !playing)}
|
|
200
|
+
/>
|
|
201
|
+
{/if}
|
|
202
|
+
{#if controls}
|
|
203
|
+
<IconButton icon="chevron-right" label="Next slide" variant="ghost" box="sm" disabled={atEnd} onclick={next} />
|
|
204
|
+
{/if}
|
|
205
|
+
</div>
|
|
206
|
+
{/if}
|
|
207
|
+
</section>
|
|
208
|
+
|
|
209
|
+
<style>
|
|
210
|
+
.carousel {
|
|
211
|
+
display: flex;
|
|
212
|
+
flex-direction: column;
|
|
213
|
+
gap: var(--sp-3);
|
|
214
|
+
min-width: 0;
|
|
215
|
+
}
|
|
216
|
+
.viewport {
|
|
217
|
+
overflow: hidden;
|
|
218
|
+
border-radius: var(--r-md);
|
|
219
|
+
touch-action: pan-y;
|
|
220
|
+
user-select: none;
|
|
221
|
+
}
|
|
222
|
+
.track {
|
|
223
|
+
display: flex;
|
|
224
|
+
width: 100%;
|
|
225
|
+
transition: transform 0.35s var(--ease);
|
|
226
|
+
}
|
|
227
|
+
.slide {
|
|
228
|
+
flex: 0 0 100%;
|
|
229
|
+
min-width: 0;
|
|
230
|
+
}
|
|
231
|
+
.bar {
|
|
232
|
+
display: flex;
|
|
233
|
+
align-items: center;
|
|
234
|
+
justify-content: center;
|
|
235
|
+
gap: var(--sp-2);
|
|
236
|
+
}
|
|
237
|
+
.dots {
|
|
238
|
+
display: flex;
|
|
239
|
+
align-items: center;
|
|
240
|
+
gap: var(--sp-2);
|
|
241
|
+
}
|
|
242
|
+
.dot {
|
|
243
|
+
width: 0.5rem;
|
|
244
|
+
height: 0.5rem;
|
|
245
|
+
padding: 0;
|
|
246
|
+
border: 0;
|
|
247
|
+
border-radius: var(--r-pill);
|
|
248
|
+
background: var(--border);
|
|
249
|
+
cursor: pointer;
|
|
250
|
+
transition:
|
|
251
|
+
background 0.12s var(--ease),
|
|
252
|
+
transform 0.12s var(--ease);
|
|
253
|
+
}
|
|
254
|
+
.dot:hover {
|
|
255
|
+
background: var(--text-faint);
|
|
256
|
+
}
|
|
257
|
+
.dot.active {
|
|
258
|
+
background: var(--accent);
|
|
259
|
+
transform: scale(1.25);
|
|
260
|
+
}
|
|
261
|
+
.dot:focus-visible {
|
|
262
|
+
outline: 2px solid var(--accent);
|
|
263
|
+
outline-offset: 2px;
|
|
264
|
+
}
|
|
265
|
+
.counter {
|
|
266
|
+
color: var(--text-muted);
|
|
267
|
+
font-size: var(--fs-sm);
|
|
268
|
+
font-variant-numeric: tabular-nums;
|
|
269
|
+
}
|
|
270
|
+
@media (prefers-reduced-motion: reduce) {
|
|
271
|
+
.track,
|
|
272
|
+
.dot {
|
|
273
|
+
transition: none;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
</style>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ordered full-bleed panels stepped with prev/next, dot indicators, arrow
|
|
3
|
+
* keys and horizontal swipe.
|
|
4
|
+
*
|
|
5
|
+
* ARIA follows the WAI-ARIA carousel pattern in its *group* flavour: the
|
|
6
|
+
* root is a region with `aria-roledescription="carousel"`, every slide is a
|
|
7
|
+
* `group` with `aria-roledescription="slide"` named "N of M", and the dots
|
|
8
|
+
* are plain buttons. The tablist flavour was rejected because it makes the
|
|
9
|
+
* dots the primary navigation and turns each slide into a `tabpanel`, which
|
|
10
|
+
* is the wrong model when prev/next is the main affordance and the panels
|
|
11
|
+
* are unlabelled content rather than named destinations. The slide container
|
|
12
|
+
* is a polite live region while the carousel is not auto-rotating.
|
|
13
|
+
*/
|
|
14
|
+
import type { Snippet } from 'svelte';
|
|
15
|
+
declare function $$render<T>(): {
|
|
16
|
+
props: {
|
|
17
|
+
/** One entry per panel. Entries may themselves be snippets when no `slide` renderer is given. */
|
|
18
|
+
slides: T[];
|
|
19
|
+
/** Renders one panel from its entry and position. */
|
|
20
|
+
slide?: Snippet<[T, number]>;
|
|
21
|
+
/** Active panel; bindable. Clamped (or wrapped with `loop`) into range. */
|
|
22
|
+
index?: number;
|
|
23
|
+
onchange?: (index: number) => void;
|
|
24
|
+
/** Wrap from the last panel to the first and back. */
|
|
25
|
+
loop?: boolean;
|
|
26
|
+
/** Milliseconds between automatic advances. `0` (default) disables it. When
|
|
27
|
+
* on, rotation pauses on hover and focus and a play/pause control appears. */
|
|
28
|
+
autoplay?: number;
|
|
29
|
+
dots?: boolean;
|
|
30
|
+
controls?: boolean;
|
|
31
|
+
/** Show an `N / M` position counter. */
|
|
32
|
+
counter?: boolean;
|
|
33
|
+
label?: string;
|
|
34
|
+
class?: string;
|
|
35
|
+
style?: string;
|
|
36
|
+
slideClass?: string;
|
|
37
|
+
};
|
|
38
|
+
exports: {};
|
|
39
|
+
bindings: "index";
|
|
40
|
+
slots: {};
|
|
41
|
+
events: {};
|
|
42
|
+
};
|
|
43
|
+
declare class __sveltets_Render<T> {
|
|
44
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
45
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
46
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
47
|
+
bindings(): "index";
|
|
48
|
+
exports(): {};
|
|
49
|
+
}
|
|
50
|
+
interface $$IsomorphicComponent {
|
|
51
|
+
new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
52
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
53
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
54
|
+
<T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
55
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
56
|
+
}
|
|
57
|
+
declare const Carousel: $$IsomorphicComponent;
|
|
58
|
+
type Carousel<T> = InstanceType<typeof Carousel<T>>;
|
|
59
|
+
export default Carousel;
|
|
@@ -7,17 +7,45 @@
|
|
|
7
7
|
// has no [data-theme] block, so its swatch carries the :root values.
|
|
8
8
|
import Popover from './Popover.svelte';
|
|
9
9
|
import { type ThemeDef, theme } from '../../stores/theme.svelte';
|
|
10
|
+
import { AUTO_THEME } from '../../theme-mode';
|
|
10
11
|
|
|
11
|
-
let {
|
|
12
|
+
let {
|
|
13
|
+
auto = false,
|
|
14
|
+
autoLabel = 'Auto',
|
|
15
|
+
autoHelp = 'Follow the system light/dark setting',
|
|
16
|
+
lightLabel = 'Light',
|
|
17
|
+
darkLabel = 'Dark',
|
|
18
|
+
class: klass = '',
|
|
19
|
+
}: {
|
|
20
|
+
/** Offer an "auto" row that follows `prefers-color-scheme`, remembering
|
|
21
|
+
* one light and one dark theme. */
|
|
22
|
+
auto?: boolean;
|
|
23
|
+
autoLabel?: string;
|
|
24
|
+
autoHelp?: string;
|
|
25
|
+
lightLabel?: string;
|
|
26
|
+
darkLabel?: string;
|
|
27
|
+
class?: string;
|
|
28
|
+
} = $props();
|
|
12
29
|
|
|
13
30
|
let hovered = $state<ThemeDef | null>(null);
|
|
31
|
+
let hoveredAuto = $state(false);
|
|
32
|
+
const isAuto = $derived(auto && theme.mode === AUTO_THEME);
|
|
14
33
|
const shown = $derived(hovered ?? theme.option);
|
|
15
34
|
const groups = $derived(
|
|
16
35
|
(['light', 'dark'] as const).map((mode) => ({
|
|
17
36
|
mode,
|
|
18
|
-
|
|
37
|
+
label: mode === 'light' ? lightLabel : darkLabel,
|
|
38
|
+
themes: theme.all.filter((t) => t.mode === mode),
|
|
19
39
|
}))
|
|
20
40
|
);
|
|
41
|
+
const named = (id: string) => theme.all.find((t) => t.id === id)?.label ?? id;
|
|
42
|
+
const autoCaption = $derived(`${autoLabel} · ${named(theme.pref.light)} / ${named(theme.pref.dark)}`);
|
|
43
|
+
const title = $derived(isAuto ? `${autoLabel} · ${theme.label}` : `Theme: ${theme.label}`);
|
|
44
|
+
const caption = $derived(
|
|
45
|
+
hoveredAuto || (isAuto && !hovered)
|
|
46
|
+
? autoCaption
|
|
47
|
+
: `${shown.icon ?? theme.fallbackIcon} ${shown.label}`
|
|
48
|
+
);
|
|
21
49
|
</script>
|
|
22
50
|
|
|
23
51
|
{#snippet swatch(id: string)}
|
|
@@ -26,18 +54,19 @@
|
|
|
26
54
|
</span>
|
|
27
55
|
{/snippet}
|
|
28
56
|
|
|
29
|
-
<Popover label=
|
|
30
|
-
{#snippet trigger()}<span class="trigger" data-tsu="ThemePicker" title
|
|
57
|
+
<Popover label={title} placement="bottom-end" triggerClass={klass} box="md">
|
|
58
|
+
{#snippet trigger()}<span class="trigger" data-tsu="ThemePicker" {title}>{@render swatch(theme.current)}{#if isAuto}<span class="auto-dot" aria-hidden="true">◐</span>{/if}</span>{/snippet}
|
|
31
59
|
<div class="panel">
|
|
32
60
|
{#each groups as g (g.mode)}
|
|
33
|
-
<div class="group-label">{g.
|
|
34
|
-
<div class="grid" role="group" aria-label="{g.
|
|
61
|
+
<div class="group-label">{g.label}</div>
|
|
62
|
+
<div class="grid" role="group" aria-label="{g.label} themes">
|
|
35
63
|
{#each g.themes as t (t.id)}
|
|
36
64
|
<button
|
|
37
65
|
type="button"
|
|
38
66
|
class="cell"
|
|
39
|
-
class:current={t.id === theme.current}
|
|
40
|
-
|
|
67
|
+
class:current={!isAuto && t.id === theme.current}
|
|
68
|
+
class:remembered={isAuto && t.id === theme.pref[g.mode]}
|
|
69
|
+
aria-pressed={!isAuto && t.id === theme.current}
|
|
41
70
|
aria-label={t.label}
|
|
42
71
|
title="{t.icon ?? theme.fallbackIcon} {t.label}"
|
|
43
72
|
onclick={() => theme.set(t.id)}
|
|
@@ -51,14 +80,50 @@
|
|
|
51
80
|
{/each}
|
|
52
81
|
</div>
|
|
53
82
|
{/each}
|
|
54
|
-
|
|
83
|
+
{#if auto}
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
class="auto"
|
|
87
|
+
class:current={isAuto}
|
|
88
|
+
aria-pressed={isAuto}
|
|
89
|
+
title={autoCaption}
|
|
90
|
+
onclick={() => theme.choose(AUTO_THEME)}
|
|
91
|
+
onpointerenter={() => (hoveredAuto = true)}
|
|
92
|
+
onpointerleave={() => (hoveredAuto = false)}
|
|
93
|
+
onfocus={() => (hoveredAuto = true)}
|
|
94
|
+
onblur={() => (hoveredAuto = false)}
|
|
95
|
+
>
|
|
96
|
+
<span class="auto-glyph" aria-hidden="true">◐</span>
|
|
97
|
+
<span class="auto-text">
|
|
98
|
+
<span class="auto-name">{autoLabel}</span>
|
|
99
|
+
<span class="auto-help">{autoHelp}</span>
|
|
100
|
+
</span>
|
|
101
|
+
<span class="auto-pair" aria-hidden="true">
|
|
102
|
+
{@render swatch(theme.pref.light)}
|
|
103
|
+
<span class="slash">/</span>
|
|
104
|
+
{@render swatch(theme.pref.dark)}
|
|
105
|
+
</span>
|
|
106
|
+
</button>
|
|
107
|
+
{/if}
|
|
108
|
+
<div class="caption" aria-live="polite">{caption}</div>
|
|
55
109
|
</div>
|
|
56
110
|
</Popover>
|
|
57
111
|
|
|
58
112
|
<style>
|
|
59
113
|
.trigger {
|
|
114
|
+
position: relative;
|
|
60
115
|
display: inline-flex;
|
|
61
116
|
}
|
|
117
|
+
.auto-dot {
|
|
118
|
+
position: absolute;
|
|
119
|
+
right: -0.35rem;
|
|
120
|
+
bottom: -0.35rem;
|
|
121
|
+
border-radius: 50%;
|
|
122
|
+
background: var(--bg);
|
|
123
|
+
color: var(--text-muted);
|
|
124
|
+
font-size: var(--fs-xs);
|
|
125
|
+
line-height: 1;
|
|
126
|
+
}
|
|
62
127
|
.swatch {
|
|
63
128
|
display: grid;
|
|
64
129
|
grid-template-columns: 1fr 1fr;
|
|
@@ -123,14 +188,68 @@
|
|
|
123
188
|
.cell.current {
|
|
124
189
|
border-color: var(--accent);
|
|
125
190
|
}
|
|
126
|
-
.cell
|
|
191
|
+
.cell.remembered {
|
|
192
|
+
border-color: var(--accent);
|
|
193
|
+
border-style: dashed;
|
|
194
|
+
}
|
|
195
|
+
.cell:focus-visible,
|
|
196
|
+
.auto:focus-visible {
|
|
127
197
|
outline: 2px solid var(--accent);
|
|
128
198
|
outline-offset: 1px;
|
|
129
199
|
}
|
|
200
|
+
.auto {
|
|
201
|
+
display: flex;
|
|
202
|
+
align-items: center;
|
|
203
|
+
gap: var(--sp-2);
|
|
204
|
+
width: 100%;
|
|
205
|
+
margin-top: var(--sp-2);
|
|
206
|
+
padding: var(--sp-1) var(--sp-2);
|
|
207
|
+
border: 2px solid transparent;
|
|
208
|
+
border-top: 1px solid var(--border);
|
|
209
|
+
border-radius: var(--r-md);
|
|
210
|
+
background: none;
|
|
211
|
+
color: inherit;
|
|
212
|
+
text-align: left;
|
|
213
|
+
cursor: pointer;
|
|
214
|
+
}
|
|
215
|
+
.auto:hover {
|
|
216
|
+
background: var(--bg-elevated-2);
|
|
217
|
+
}
|
|
218
|
+
.auto.current {
|
|
219
|
+
border-color: var(--accent);
|
|
220
|
+
}
|
|
221
|
+
.auto-glyph {
|
|
222
|
+
font-size: var(--fs-md);
|
|
223
|
+
line-height: 1;
|
|
224
|
+
}
|
|
225
|
+
.auto-text {
|
|
226
|
+
display: flex;
|
|
227
|
+
flex-direction: column;
|
|
228
|
+
flex: 1 1 auto;
|
|
229
|
+
min-width: 0;
|
|
230
|
+
}
|
|
231
|
+
.auto-name {
|
|
232
|
+
font-size: var(--fs-sm);
|
|
233
|
+
}
|
|
234
|
+
.auto-help {
|
|
235
|
+
font-size: var(--fs-xs);
|
|
236
|
+
color: var(--text-faint);
|
|
237
|
+
white-space: normal;
|
|
238
|
+
}
|
|
239
|
+
.auto-pair {
|
|
240
|
+
display: inline-flex;
|
|
241
|
+
align-items: center;
|
|
242
|
+
gap: var(--sp-1);
|
|
243
|
+
}
|
|
244
|
+
.slash {
|
|
245
|
+
color: var(--text-faint);
|
|
246
|
+
font-size: var(--fs-xs);
|
|
247
|
+
}
|
|
130
248
|
.caption {
|
|
131
249
|
margin-top: var(--sp-2);
|
|
132
250
|
text-align: center;
|
|
133
251
|
font-size: var(--fs-sm);
|
|
134
252
|
color: var(--text-muted);
|
|
253
|
+
white-space: normal;
|
|
135
254
|
}
|
|
136
255
|
</style>
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
type $$ComponentProps = {
|
|
2
|
+
/** Offer an "auto" row that follows `prefers-color-scheme`, remembering
|
|
3
|
+
* one light and one dark theme. */
|
|
4
|
+
auto?: boolean;
|
|
5
|
+
autoLabel?: string;
|
|
6
|
+
autoHelp?: string;
|
|
7
|
+
lightLabel?: string;
|
|
8
|
+
darkLabel?: string;
|
|
2
9
|
class?: string;
|
|
3
10
|
};
|
|
4
11
|
declare const ThemePicker: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clamp a slide index into `[0, count)`, wrapping when `loop` is set.
|
|
3
|
+
*
|
|
4
|
+
* @param {number} index
|
|
5
|
+
* @param {number} count
|
|
6
|
+
* @param {boolean} [loop]
|
|
7
|
+
* @returns {number}
|
|
8
|
+
*/
|
|
9
|
+
export function clampSlide(index: number, count: number, loop?: boolean): number;
|
|
10
|
+
/**
|
|
11
|
+
* Step from `current` by `delta` slides. Without `loop` the edges absorb the
|
|
12
|
+
* step, so stepping past the last slide stays on it.
|
|
13
|
+
*
|
|
14
|
+
* @param {number} current
|
|
15
|
+
* @param {number} count
|
|
16
|
+
* @param {number} delta
|
|
17
|
+
* @param {boolean} [loop]
|
|
18
|
+
* @returns {number}
|
|
19
|
+
*/
|
|
20
|
+
export function stepSlide(current: number, count: number, delta: number, loop?: boolean): number;
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a Carousel navigation key to the slide it selects. Left/Right step by
|
|
23
|
+
* one (wrapping only with `loop`), Home/End jump to the first/last slide.
|
|
24
|
+
* Unhandled keys return `undefined` so the browser keeps its default.
|
|
25
|
+
*
|
|
26
|
+
* @param {number} current
|
|
27
|
+
* @param {number} count
|
|
28
|
+
* @param {string} key
|
|
29
|
+
* @param {boolean} [loop]
|
|
30
|
+
* @returns {number | undefined}
|
|
31
|
+
*/
|
|
32
|
+
export function nextSlideForKey(current: number, count: number, key: string, loop?: boolean): number | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* Interpret a completed pointer gesture as a slide step. A gesture counts as a
|
|
35
|
+
* horizontal swipe only when it travels at least `threshold` px on the x axis
|
|
36
|
+
* and further on x than on y; anything else is left to the page (vertical
|
|
37
|
+
* scroll, taps). Swiping left reveals the next slide, so the result is +1.
|
|
38
|
+
*
|
|
39
|
+
* @param {number} dx
|
|
40
|
+
* @param {number} dy
|
|
41
|
+
* @param {number} [threshold]
|
|
42
|
+
* @returns {-1 | 0 | 1}
|
|
43
|
+
*/
|
|
44
|
+
export function swipeStep(dx: number, dy: number, threshold?: number): -1 | 0 | 1;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clamp a slide index into `[0, count)`, wrapping when `loop` is set.
|
|
3
|
+
*
|
|
4
|
+
* @param {number} index
|
|
5
|
+
* @param {number} count
|
|
6
|
+
* @param {boolean} [loop]
|
|
7
|
+
* @returns {number}
|
|
8
|
+
*/
|
|
9
|
+
export function clampSlide(index, count, loop = false) {
|
|
10
|
+
if (count <= 0 || !Number.isFinite(index)) return 0;
|
|
11
|
+
if (loop) return ((Math.trunc(index) % count) + count) % count;
|
|
12
|
+
return Math.min(Math.max(Math.trunc(index), 0), count - 1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Step from `current` by `delta` slides. Without `loop` the edges absorb the
|
|
17
|
+
* step, so stepping past the last slide stays on it.
|
|
18
|
+
*
|
|
19
|
+
* @param {number} current
|
|
20
|
+
* @param {number} count
|
|
21
|
+
* @param {number} delta
|
|
22
|
+
* @param {boolean} [loop]
|
|
23
|
+
* @returns {number}
|
|
24
|
+
*/
|
|
25
|
+
export function stepSlide(current, count, delta, loop = false) {
|
|
26
|
+
return clampSlide(current + delta, count, loop);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a Carousel navigation key to the slide it selects. Left/Right step by
|
|
31
|
+
* one (wrapping only with `loop`), Home/End jump to the first/last slide.
|
|
32
|
+
* Unhandled keys return `undefined` so the browser keeps its default.
|
|
33
|
+
*
|
|
34
|
+
* @param {number} current
|
|
35
|
+
* @param {number} count
|
|
36
|
+
* @param {string} key
|
|
37
|
+
* @param {boolean} [loop]
|
|
38
|
+
* @returns {number | undefined}
|
|
39
|
+
*/
|
|
40
|
+
export function nextSlideForKey(current, count, key, loop = false) {
|
|
41
|
+
if (count <= 0) return undefined;
|
|
42
|
+
if (key === 'ArrowRight') return stepSlide(current, count, 1, loop);
|
|
43
|
+
if (key === 'ArrowLeft') return stepSlide(current, count, -1, loop);
|
|
44
|
+
if (key === 'Home') return 0;
|
|
45
|
+
if (key === 'End') return count - 1;
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Interpret a completed pointer gesture as a slide step. A gesture counts as a
|
|
51
|
+
* horizontal swipe only when it travels at least `threshold` px on the x axis
|
|
52
|
+
* and further on x than on y; anything else is left to the page (vertical
|
|
53
|
+
* scroll, taps). Swiping left reveals the next slide, so the result is +1.
|
|
54
|
+
*
|
|
55
|
+
* @param {number} dx
|
|
56
|
+
* @param {number} dy
|
|
57
|
+
* @param {number} [threshold]
|
|
58
|
+
* @returns {-1 | 0 | 1}
|
|
59
|
+
*/
|
|
60
|
+
export function swipeStep(dx, dy, threshold = 40) {
|
|
61
|
+
if (Math.abs(dx) < threshold || Math.abs(dx) <= Math.abs(dy)) return 0;
|
|
62
|
+
return dx < 0 ? 1 : -1;
|
|
63
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -41,6 +41,7 @@ export { type Attachment, default as AttachmentList, } from './components/molecu
|
|
|
41
41
|
export { type BreadcrumbItem, default as Breadcrumb, } from './components/molecules/Breadcrumb.svelte';
|
|
42
42
|
export { default as Callout } from './components/molecules/Callout.svelte';
|
|
43
43
|
export { default as CapBar } from './components/molecules/CapBar.svelte';
|
|
44
|
+
export { default as Carousel } from './components/molecules/Carousel.svelte';
|
|
44
45
|
export { default as ChatBubble } from './components/molecules/ChatBubble.svelte';
|
|
45
46
|
export { default as CodeBlock } from './components/molecules/CodeBlock.svelte';
|
|
46
47
|
export { default as Composer } from './components/molecules/Composer.svelte';
|
|
@@ -90,6 +91,7 @@ export type { ControlSize } from './size';
|
|
|
90
91
|
export { fontScale, SCALE_LEVELS, type ScaleLevel } from './stores/fontscale.svelte';
|
|
91
92
|
export { type Mode, THEMES, theme } from './stores/theme.svelte';
|
|
92
93
|
export { type Toast, type ToastAction, type ToastOptions, type ToastTone, type ToastToneInput, toasts, } from './stores/toast.svelte';
|
|
94
|
+
export { AUTO_THEME, chooseTheme, DEFAULT_THEME_PREFERENCE, pickerValue, preferenceFrom, resolveTheme, type SlotOf, type ThemeChoice, type ThemePreference, type ThemeSlot, } from './theme-mode';
|
|
93
95
|
export { formatTimestamp, localTimeZone, relativeTime, type TimeInput, type TimestampMode, } from './timestamp';
|
|
94
96
|
export { canonicalTone, type Tone } from './tone';
|
|
95
97
|
export { pathCandidates, type TruncateMode, type TruncateOptions, truncate } from './truncate';
|
package/dist/index.js
CHANGED
|
@@ -47,6 +47,7 @@ export { default as AttachmentList, } from './components/molecules/AttachmentLis
|
|
|
47
47
|
export { default as Breadcrumb, } from './components/molecules/Breadcrumb.svelte';
|
|
48
48
|
export { default as Callout } from './components/molecules/Callout.svelte';
|
|
49
49
|
export { default as CapBar } from './components/molecules/CapBar.svelte';
|
|
50
|
+
export { default as Carousel } from './components/molecules/Carousel.svelte';
|
|
50
51
|
export { default as ChatBubble } from './components/molecules/ChatBubble.svelte';
|
|
51
52
|
export { default as CodeBlock } from './components/molecules/CodeBlock.svelte';
|
|
52
53
|
export { default as Composer } from './components/molecules/Composer.svelte';
|
|
@@ -101,6 +102,7 @@ export { fontScale, SCALE_LEVELS } from './stores/fontscale.svelte';
|
|
|
101
102
|
// ---- stores / actions ----
|
|
102
103
|
export { THEMES, theme } from './stores/theme.svelte';
|
|
103
104
|
export { toasts, } from './stores/toast.svelte';
|
|
105
|
+
export { AUTO_THEME, chooseTheme, DEFAULT_THEME_PREFERENCE, pickerValue, preferenceFrom, resolveTheme, } from './theme-mode';
|
|
104
106
|
export { formatTimestamp, localTimeZone, relativeTime, } from './timestamp';
|
|
105
107
|
export { canonicalTone } from './tone';
|
|
106
108
|
export { pathCandidates, truncate } from './truncate';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SlotOf, type ThemeChoice, type ThemePreference } from '../theme-mode';
|
|
1
2
|
export declare const THEMES: readonly [{
|
|
2
3
|
readonly id: "light";
|
|
3
4
|
readonly label: "Light";
|
|
@@ -155,16 +156,32 @@ export interface ThemeDef {
|
|
|
155
156
|
}
|
|
156
157
|
declare class Theme {
|
|
157
158
|
current: ThemeId;
|
|
159
|
+
/** True while the system asks for a dark scheme; only read in `auto`. */
|
|
160
|
+
systemDark: boolean;
|
|
161
|
+
pref: ThemePreference;
|
|
162
|
+
/** Called with the new preference whenever the user changes it, so an app
|
|
163
|
+
* can mirror it into its own (server-side) settings. */
|
|
164
|
+
onchange?: (pref: ThemePreference) => void;
|
|
158
165
|
readonly fallbackIcon = "\u25C8";
|
|
159
166
|
private registered;
|
|
160
167
|
private fallback;
|
|
161
168
|
private saved;
|
|
162
169
|
constructor();
|
|
170
|
+
get slotOf(): SlotOf;
|
|
171
|
+
/** `auto`, or the pinned slot. */
|
|
172
|
+
get mode(): ThemeChoice;
|
|
173
|
+
/** The theme id painted for the current preference. */
|
|
174
|
+
get resolved(): ThemeId;
|
|
175
|
+
/** What a picker shows as selected: `auto`, or the pinned slot's theme id. */
|
|
176
|
+
get choice(): string;
|
|
163
177
|
get all(): readonly ThemeDef[];
|
|
164
178
|
has(id: string | null | undefined): id is ThemeId;
|
|
165
179
|
register(defs: ThemeDef | ThemeDef[]): void;
|
|
166
180
|
setDefault(id: ThemeId): void;
|
|
167
181
|
private resolve;
|
|
182
|
+
private read;
|
|
183
|
+
private paint;
|
|
184
|
+
private persist;
|
|
168
185
|
private apply;
|
|
169
186
|
get option(): ThemeDef;
|
|
170
187
|
get label(): string;
|
|
@@ -172,6 +189,12 @@ declare class Theme {
|
|
|
172
189
|
get next(): ThemeDef;
|
|
173
190
|
toggle(): void;
|
|
174
191
|
set(mode: ThemeId): void;
|
|
192
|
+
/** A picker choice: `auto`, or a theme id, which also becomes its slot's
|
|
193
|
+
* remembered theme. An unregistered id is ignored. */
|
|
194
|
+
choose(choice: string): ThemePreference;
|
|
195
|
+
/** Replay a preference an app persisted itself, without echoing it back
|
|
196
|
+
* through `onchange`. */
|
|
197
|
+
hydrate(pref: ThemePreference): void;
|
|
175
198
|
}
|
|
176
199
|
export declare const theme: Theme;
|
|
177
200
|
export {};
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { browser } from '../env';
|
|
2
|
+
import { AUTO_THEME, chooseTheme, DEFAULT_THEME_PREFERENCE, preferenceFrom, resolveTheme, } from '../theme-mode';
|
|
2
3
|
// Theme registry. Built-ins live in THEMES (+ one [data-theme="id"] block in
|
|
3
4
|
// styles/themes.css); consumers append their own with theme.register() and ship
|
|
4
5
|
// the matching block in their own stylesheet. `themeColor` drives the mobile
|
|
5
6
|
// browser-chrome <meta theme-color>; `mode` groups the theme into the picker's
|
|
6
7
|
// light/dark sections.
|
|
8
|
+
//
|
|
9
|
+
// `tsumikit-theme` holds a {mode,light,dark} blob; a bare theme id there is a
|
|
10
|
+
// legacy value and migrates through `preferenceFrom`.
|
|
7
11
|
const KEY = 'tsumikit-theme';
|
|
12
|
+
const SCHEME_QUERY = '(prefers-color-scheme: dark)';
|
|
8
13
|
export const THEMES = [
|
|
9
14
|
// ── Light ── bright, paper-white surfaces
|
|
10
15
|
{ id: 'light', label: 'Light', icon: '☀', themeColor: '#f6f7f9', mode: 'light' },
|
|
@@ -55,16 +60,43 @@ export const THEMES = [
|
|
|
55
60
|
const FALLBACK_ICON = '◈';
|
|
56
61
|
class Theme {
|
|
57
62
|
current = $state('dark');
|
|
63
|
+
/** True while the system asks for a dark scheme; only read in `auto`. */
|
|
64
|
+
systemDark = $state(false);
|
|
65
|
+
pref = $state(DEFAULT_THEME_PREFERENCE);
|
|
66
|
+
/** Called with the new preference whenever the user changes it, so an app
|
|
67
|
+
* can mirror it into its own (server-side) settings. */
|
|
68
|
+
onchange;
|
|
58
69
|
fallbackIcon = FALLBACK_ICON;
|
|
59
70
|
registered = $state([]);
|
|
60
71
|
fallback = 'dark';
|
|
61
72
|
saved = null;
|
|
62
73
|
constructor() {
|
|
63
74
|
if (browser) {
|
|
75
|
+
const mq = typeof matchMedia === 'function' ? matchMedia(SCHEME_QUERY) : null;
|
|
76
|
+
this.systemDark = mq?.matches ?? false;
|
|
77
|
+
mq?.addEventListener?.('change', (e) => {
|
|
78
|
+
this.systemDark = e.matches;
|
|
79
|
+
this.paint();
|
|
80
|
+
});
|
|
64
81
|
this.saved = localStorage.getItem(KEY);
|
|
65
82
|
this.resolve();
|
|
66
83
|
}
|
|
67
84
|
}
|
|
85
|
+
get slotOf() {
|
|
86
|
+
return (id) => this.all.find((t) => t.id === id)?.mode ?? null;
|
|
87
|
+
}
|
|
88
|
+
/** `auto`, or the pinned slot. */
|
|
89
|
+
get mode() {
|
|
90
|
+
return this.pref.mode;
|
|
91
|
+
}
|
|
92
|
+
/** The theme id painted for the current preference. */
|
|
93
|
+
get resolved() {
|
|
94
|
+
return resolveTheme(this.pref, this.systemDark);
|
|
95
|
+
}
|
|
96
|
+
/** What a picker shows as selected: `auto`, or the pinned slot's theme id. */
|
|
97
|
+
get choice() {
|
|
98
|
+
return this.pref.mode === AUTO_THEME ? AUTO_THEME : this.resolved;
|
|
99
|
+
}
|
|
68
100
|
get all() {
|
|
69
101
|
const byId = new Map();
|
|
70
102
|
for (const t of THEMES)
|
|
@@ -86,9 +118,38 @@ class Theme {
|
|
|
86
118
|
this.resolve();
|
|
87
119
|
}
|
|
88
120
|
resolve() {
|
|
89
|
-
this.
|
|
121
|
+
this.pref = this.read();
|
|
122
|
+
this.paint();
|
|
123
|
+
}
|
|
124
|
+
read() {
|
|
125
|
+
const seed = chooseTheme(DEFAULT_THEME_PREFERENCE, this.fallback, this.slotOf);
|
|
126
|
+
if (!this.saved)
|
|
127
|
+
return seed;
|
|
128
|
+
let blob = null;
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(this.saved);
|
|
131
|
+
if (parsed && typeof parsed === 'object')
|
|
132
|
+
blob = parsed;
|
|
133
|
+
}
|
|
134
|
+
catch { }
|
|
135
|
+
return preferenceFrom(blob
|
|
136
|
+
? { themeMode: blob.mode, lightTheme: blob.light, darkTheme: blob.dark }
|
|
137
|
+
: { theme: this.saved }, this.slotOf, seed);
|
|
138
|
+
}
|
|
139
|
+
paint() {
|
|
140
|
+
this.current = this.resolved;
|
|
90
141
|
this.apply();
|
|
91
142
|
}
|
|
143
|
+
persist() {
|
|
144
|
+
this.saved = JSON.stringify(this.pref);
|
|
145
|
+
if (browser) {
|
|
146
|
+
try {
|
|
147
|
+
localStorage.setItem(KEY, this.saved);
|
|
148
|
+
}
|
|
149
|
+
catch { }
|
|
150
|
+
}
|
|
151
|
+
this.onchange?.(this.pref);
|
|
152
|
+
}
|
|
92
153
|
apply() {
|
|
93
154
|
if (!browser)
|
|
94
155
|
return;
|
|
@@ -117,11 +178,22 @@ class Theme {
|
|
|
117
178
|
this.set(this.next.id);
|
|
118
179
|
}
|
|
119
180
|
set(mode) {
|
|
120
|
-
this.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
181
|
+
this.choose(mode);
|
|
182
|
+
}
|
|
183
|
+
/** A picker choice: `auto`, or a theme id, which also becomes its slot's
|
|
184
|
+
* remembered theme. An unregistered id is ignored. */
|
|
185
|
+
choose(choice) {
|
|
186
|
+
this.pref = chooseTheme(this.pref, choice, this.slotOf);
|
|
187
|
+
this.paint();
|
|
188
|
+
this.persist();
|
|
189
|
+
return this.pref;
|
|
190
|
+
}
|
|
191
|
+
/** Replay a preference an app persisted itself, without echoing it back
|
|
192
|
+
* through `onchange`. */
|
|
193
|
+
hydrate(pref) {
|
|
194
|
+
this.pref = pref;
|
|
195
|
+
this.saved = JSON.stringify(pref);
|
|
196
|
+
this.paint();
|
|
125
197
|
}
|
|
126
198
|
}
|
|
127
199
|
export const theme = new Theme();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type ThemeSlot = 'light' | 'dark';
|
|
2
|
+
export type ThemeChoice = ThemeSlot | 'auto';
|
|
3
|
+
export interface ThemePreference {
|
|
4
|
+
/** `auto` follows the system; `light` / `dark` pin the matching slot. */
|
|
5
|
+
mode: ThemeChoice;
|
|
6
|
+
/** Last light theme the user picked. */
|
|
7
|
+
light: string;
|
|
8
|
+
/** Last dark theme the user picked. */
|
|
9
|
+
dark: string;
|
|
10
|
+
}
|
|
11
|
+
/** The picker value that means "follow the system". Never a theme id. */
|
|
12
|
+
export declare const AUTO_THEME = "auto";
|
|
13
|
+
export declare const DEFAULT_THEME_PREFERENCE: ThemePreference;
|
|
14
|
+
/** Which slot a theme id belongs to; `null` when the id is unknown. */
|
|
15
|
+
export type SlotOf = (id: string) => ThemeSlot | null;
|
|
16
|
+
/** The theme id to paint for a preference, given the system's current scheme. */
|
|
17
|
+
export declare function resolveTheme(pref: ThemePreference, systemDark: boolean): string;
|
|
18
|
+
/** Apply a picker choice: `auto` switches mode only; a theme id pins its slot
|
|
19
|
+
* AND becomes that slot's remembered theme. Unknown ids leave the preference
|
|
20
|
+
* untouched so a stale blob can never paint an unregistered theme. */
|
|
21
|
+
export declare function chooseTheme(pref: ThemePreference, choice: string, slotOf: SlotOf): ThemePreference;
|
|
22
|
+
/** Rebuild a preference from persisted fields. Older blobs only carried a
|
|
23
|
+
* single `theme`: it seeds whichever slot it belongs to and pins that mode, so
|
|
24
|
+
* nothing changes for a user who never touched the new picker. Unknown ids
|
|
25
|
+
* fall back to `defaults` rather than being trusted. */
|
|
26
|
+
export declare function preferenceFrom(raw: {
|
|
27
|
+
theme?: string | null;
|
|
28
|
+
themeMode?: string | null;
|
|
29
|
+
lightTheme?: string | null;
|
|
30
|
+
darkTheme?: string | null;
|
|
31
|
+
}, slotOf: SlotOf, defaults?: ThemePreference): ThemePreference;
|
|
32
|
+
/** The picker's current value: `auto`, or the pinned slot's theme id. */
|
|
33
|
+
export declare function pickerValue(pref: ThemePreference): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Pure logic behind the light/dark/auto theme preference: the last light and
|
|
2
|
+
// the last dark theme the user picked, plus an `auto` mode that follows the
|
|
3
|
+
// system's `prefers-color-scheme`. Rune-free so it unit-tests in isolation.
|
|
4
|
+
/** The picker value that means "follow the system". Never a theme id. */
|
|
5
|
+
export const AUTO_THEME = 'auto';
|
|
6
|
+
export const DEFAULT_THEME_PREFERENCE = {
|
|
7
|
+
mode: 'dark',
|
|
8
|
+
light: 'light',
|
|
9
|
+
dark: 'dark',
|
|
10
|
+
};
|
|
11
|
+
/** The theme id to paint for a preference, given the system's current scheme. */
|
|
12
|
+
export function resolveTheme(pref, systemDark) {
|
|
13
|
+
const slot = pref.mode === 'auto' ? (systemDark ? 'dark' : 'light') : pref.mode;
|
|
14
|
+
return slot === 'dark' ? pref.dark : pref.light;
|
|
15
|
+
}
|
|
16
|
+
/** Apply a picker choice: `auto` switches mode only; a theme id pins its slot
|
|
17
|
+
* AND becomes that slot's remembered theme. Unknown ids leave the preference
|
|
18
|
+
* untouched so a stale blob can never paint an unregistered theme. */
|
|
19
|
+
export function chooseTheme(pref, choice, slotOf) {
|
|
20
|
+
if (choice === AUTO_THEME)
|
|
21
|
+
return { ...pref, mode: 'auto' };
|
|
22
|
+
const slot = slotOf(choice);
|
|
23
|
+
if (!slot)
|
|
24
|
+
return pref;
|
|
25
|
+
return { ...pref, mode: slot, [slot]: choice };
|
|
26
|
+
}
|
|
27
|
+
/** Rebuild a preference from persisted fields. Older blobs only carried a
|
|
28
|
+
* single `theme`: it seeds whichever slot it belongs to and pins that mode, so
|
|
29
|
+
* nothing changes for a user who never touched the new picker. Unknown ids
|
|
30
|
+
* fall back to `defaults` rather than being trusted. */
|
|
31
|
+
export function preferenceFrom(raw, slotOf, defaults = DEFAULT_THEME_PREFERENCE) {
|
|
32
|
+
const light = raw.lightTheme && slotOf(raw.lightTheme) === 'light' ? raw.lightTheme : defaults.light;
|
|
33
|
+
const dark = raw.darkTheme && slotOf(raw.darkTheme) === 'dark' ? raw.darkTheme : defaults.dark;
|
|
34
|
+
const pref = { mode: defaults.mode, light, dark };
|
|
35
|
+
if (raw.themeMode === 'auto' || raw.themeMode === 'light' || raw.themeMode === 'dark') {
|
|
36
|
+
return { ...pref, mode: raw.themeMode };
|
|
37
|
+
}
|
|
38
|
+
if (raw.theme)
|
|
39
|
+
return chooseTheme(pref, raw.theme, slotOf);
|
|
40
|
+
return pref;
|
|
41
|
+
}
|
|
42
|
+
/** The picker's current value: `auto`, or the pinned slot's theme id. */
|
|
43
|
+
export function pickerValue(pref) {
|
|
44
|
+
return pref.mode === 'auto' ? AUTO_THEME : pref.mode === 'dark' ? pref.dark : pref.light;
|
|
45
|
+
}
|
package/package.json
CHANGED