@dorsk/tsumikit 0.23.0 → 0.24.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 +107 -5
- package/dist/components/atoms/Scrim.svelte +81 -0
- package/dist/components/atoms/Scrim.svelte.d.ts +14 -0
- package/dist/components/layouts/ResizablePanel.svelte +238 -120
- package/dist/components/layouts/ResizablePanel.svelte.d.ts +24 -8
- package/dist/components/layouts/resizable-panel-frame.d.ts +70 -0
- package/dist/components/layouts/resizable-panel-frame.js +161 -0
- package/dist/components/molecules/ThemePicker.svelte +11 -13
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/stores/theme.svelte.d.ts +21 -5
- package/dist/stores/theme.svelte.js +48 -22
- package/dist/styles/app.css +7 -297
- package/dist/styles/reset.css +97 -0
- package/dist/styles/syntax.css +94 -0
- package/dist/styles/themes.css +729 -0
- package/dist/styles/tokens.css +207 -0
- package/dist/styles/utilities.css +111 -0
- package/dist/styles/variables.css +5 -926
- package/package.json +7 -2
|
@@ -1,20 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
2
|
type $$ComponentProps = {
|
|
3
3
|
/** Content shown while the panel is expanded. */
|
|
4
4
|
panel: Snippet;
|
|
5
|
-
/** Main content beside the panel. */
|
|
6
|
-
children
|
|
5
|
+
/** Main content beside the panel (optional in overlay mode). */
|
|
6
|
+
children?: Snippet;
|
|
7
7
|
/** Physical edge occupied by the panel. */
|
|
8
8
|
side?: 'left' | 'right';
|
|
9
|
-
/** Accessible name for the panel landmark. */
|
|
9
|
+
/** Accessible name for the panel landmark / dialog. */
|
|
10
10
|
label?: string;
|
|
11
11
|
/** Initial expanded width in pixels. */
|
|
12
12
|
width?: number;
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/** Pixel number or CSS length (`'12rem'`, `'30vw'`). */
|
|
14
|
+
minWidth?: number | string;
|
|
15
|
+
/** Pixel number or CSS length (`'40rem'`, `'90vw'`). */
|
|
16
|
+
maxWidth?: number | string;
|
|
15
17
|
/** localStorage key used to restore the expanded width. */
|
|
16
18
|
widthKey?: string;
|
|
17
|
-
/** Bindable collapsed state. */
|
|
19
|
+
/** Bindable collapsed state (inline mode). */
|
|
18
20
|
collapsed?: boolean;
|
|
19
21
|
/** Persist collapsed state as `${widthKey}:collapsed`. */
|
|
20
22
|
persistCollapsed?: boolean;
|
|
@@ -25,7 +27,21 @@ type $$ComponentProps = {
|
|
|
25
27
|
/** Keep the collapse handle in view when the panel scrolls past the
|
|
26
28
|
* viewport, repositioning on scroll/resize via requestAnimationFrame. */
|
|
27
29
|
stickyHandle?: boolean;
|
|
30
|
+
/** `inline` shares the row with `children`; `overlay` fixes the panel to
|
|
31
|
+
* its viewport edge as a non-modal drawer above the page. */
|
|
32
|
+
mode?: 'inline' | 'overlay';
|
|
33
|
+
/** Bindable drawer visibility (overlay mode). */
|
|
34
|
+
open?: boolean;
|
|
35
|
+
/** Overlay mode: Escape, scrim click or the edge control closed the drawer. */
|
|
36
|
+
onclose?: () => void;
|
|
37
|
+
/** Overlay mode: dim the page behind the drawer; clicking it closes (default true). */
|
|
38
|
+
scrim?: boolean;
|
|
39
|
+
/** Overlay mode: viewport width (CSS length) under which the drawer spans
|
|
40
|
+
* the full viewport and hides its resize handle and scrim. */
|
|
41
|
+
fullWidthBelow?: string;
|
|
42
|
+
/** Overlay mode: cap the width at the viewport and re-clamp on window resize. */
|
|
43
|
+
clampToViewport?: boolean;
|
|
28
44
|
};
|
|
29
|
-
declare const ResizablePanel: import("svelte").Component<$$ComponentProps, {}, "collapsed">;
|
|
45
|
+
declare const ResizablePanel: import("svelte").Component<$$ComponentProps, {}, "collapsed" | "open">;
|
|
30
46
|
type ResizablePanel = ReturnType<typeof ResizablePanel>;
|
|
31
47
|
export default ResizablePanel;
|
|
@@ -14,3 +14,73 @@ export function createFrameBatcher<T>(requestFrame: (callback: FrameRequestCallb
|
|
|
14
14
|
flush(value: T): void;
|
|
15
15
|
discard(): void;
|
|
16
16
|
};
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a width prop that is either a pixel number or a CSS length.
|
|
19
|
+
* Plain `px` strings parse directly; anything else is handed to `measure`,
|
|
20
|
+
* which lays the length out and returns its pixel size (or `undefined` when
|
|
21
|
+
* there is no DOM to measure in).
|
|
22
|
+
*
|
|
23
|
+
* @param {number | string} value
|
|
24
|
+
* @param {(css: string) => number | undefined} measure
|
|
25
|
+
* @returns {number | undefined}
|
|
26
|
+
*/
|
|
27
|
+
export function resolveLength(value: number | string, measure: (css: string) => number | undefined): number | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} ResizeHandleParams
|
|
30
|
+
* @property {'left' | 'right'} side Edge the resized box sits on; dragging away from it grows the box.
|
|
31
|
+
* @property {(width: number) => void} onwidth Called once per animation frame while dragging and on every keyboard step.
|
|
32
|
+
* @property {(width: number) => void} [oncommit] Called with the settled width on pointer release and after each keyboard step.
|
|
33
|
+
* @property {() => void} [onreset] Double-click on the handle.
|
|
34
|
+
* @property {(active: boolean) => void} [onactive] Drag start/end, for a `resizing` class.
|
|
35
|
+
* @property {() => number} [measure] Current width in px; defaults to the handle's parent box.
|
|
36
|
+
* @property {number} [min]
|
|
37
|
+
* @property {number} [max]
|
|
38
|
+
* @property {number} [step] Pixels per arrow key press (default 16).
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Svelte action turning any element into a pointer + keyboard width grip.
|
|
42
|
+
* One pointer-capture / rAF-coalesced implementation shared by
|
|
43
|
+
* ResizablePanel and consumer-built grips.
|
|
44
|
+
*
|
|
45
|
+
* Usage: <div role="separator" tabindex="0" use:resizeHandle={{ side, min, max, onwidth }}></div>
|
|
46
|
+
*
|
|
47
|
+
* @param {HTMLElement} node
|
|
48
|
+
* @param {ResizeHandleParams} params
|
|
49
|
+
*/
|
|
50
|
+
export function resizeHandle(node: HTMLElement, params: ResizeHandleParams): {
|
|
51
|
+
/** @param {ResizeHandleParams} next */
|
|
52
|
+
update(next: ResizeHandleParams): void;
|
|
53
|
+
destroy(): void;
|
|
54
|
+
};
|
|
55
|
+
export type ResizeHandleParams = {
|
|
56
|
+
/**
|
|
57
|
+
* Edge the resized box sits on; dragging away from it grows the box.
|
|
58
|
+
*/
|
|
59
|
+
side: "left" | "right";
|
|
60
|
+
/**
|
|
61
|
+
* Called once per animation frame while dragging and on every keyboard step.
|
|
62
|
+
*/
|
|
63
|
+
onwidth: (width: number) => void;
|
|
64
|
+
/**
|
|
65
|
+
* Called with the settled width on pointer release and after each keyboard step.
|
|
66
|
+
*/
|
|
67
|
+
oncommit?: ((width: number) => void) | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* Double-click on the handle.
|
|
70
|
+
*/
|
|
71
|
+
onreset?: (() => void) | undefined;
|
|
72
|
+
/**
|
|
73
|
+
* Drag start/end, for a `resizing` class.
|
|
74
|
+
*/
|
|
75
|
+
onactive?: ((active: boolean) => void) | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Current width in px; defaults to the handle's parent box.
|
|
78
|
+
*/
|
|
79
|
+
measure?: (() => number) | undefined;
|
|
80
|
+
min?: number | undefined;
|
|
81
|
+
max?: number | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* Pixels per arrow key press (default 16).
|
|
84
|
+
*/
|
|
85
|
+
step?: number | undefined;
|
|
86
|
+
};
|
|
@@ -61,3 +61,164 @@ export function createFrameBatcher(requestFrame, cancelFrame, apply) {
|
|
|
61
61
|
},
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve a width prop that is either a pixel number or a CSS length.
|
|
67
|
+
* Plain `px` strings parse directly; anything else is handed to `measure`,
|
|
68
|
+
* which lays the length out and returns its pixel size (or `undefined` when
|
|
69
|
+
* there is no DOM to measure in).
|
|
70
|
+
*
|
|
71
|
+
* @param {number | string} value
|
|
72
|
+
* @param {(css: string) => number | undefined} measure
|
|
73
|
+
* @returns {number | undefined}
|
|
74
|
+
*/
|
|
75
|
+
export function resolveLength(value, measure) {
|
|
76
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
|
|
77
|
+
const px = /^\s*(-?\d*\.?\d+)px\s*$/.exec(value);
|
|
78
|
+
if (px) return Number(px[1]);
|
|
79
|
+
const measured = measure(value);
|
|
80
|
+
return measured !== undefined && Number.isFinite(measured) ? measured : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @typedef {object} ResizeHandleParams
|
|
85
|
+
* @property {'left' | 'right'} side Edge the resized box sits on; dragging away from it grows the box.
|
|
86
|
+
* @property {(width: number) => void} onwidth Called once per animation frame while dragging and on every keyboard step.
|
|
87
|
+
* @property {(width: number) => void} [oncommit] Called with the settled width on pointer release and after each keyboard step.
|
|
88
|
+
* @property {() => void} [onreset] Double-click on the handle.
|
|
89
|
+
* @property {(active: boolean) => void} [onactive] Drag start/end, for a `resizing` class.
|
|
90
|
+
* @property {() => number} [measure] Current width in px; defaults to the handle's parent box.
|
|
91
|
+
* @property {number} [min]
|
|
92
|
+
* @property {number} [max]
|
|
93
|
+
* @property {number} [step] Pixels per arrow key press (default 16).
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Svelte action turning any element into a pointer + keyboard width grip.
|
|
98
|
+
* One pointer-capture / rAF-coalesced implementation shared by
|
|
99
|
+
* ResizablePanel and consumer-built grips.
|
|
100
|
+
*
|
|
101
|
+
* Usage: <div role="separator" tabindex="0" use:resizeHandle={{ side, min, max, onwidth }}></div>
|
|
102
|
+
*
|
|
103
|
+
* @param {HTMLElement} node
|
|
104
|
+
* @param {ResizeHandleParams} params
|
|
105
|
+
*/
|
|
106
|
+
export function resizeHandle(node, params) {
|
|
107
|
+
let current = params;
|
|
108
|
+
let active = false;
|
|
109
|
+
let startX = 0;
|
|
110
|
+
let startWidth = 0;
|
|
111
|
+
|
|
112
|
+
const frames = createFrameBatcher(
|
|
113
|
+
(callback) => requestAnimationFrame(callback),
|
|
114
|
+
(handle) => cancelAnimationFrame(handle),
|
|
115
|
+
/** @param {number} width */
|
|
116
|
+
(width) => current.onwidth(width),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
/** @param {number} width */
|
|
120
|
+
function clamp(width) {
|
|
121
|
+
const min = current.min ?? 1;
|
|
122
|
+
const max = current.max ?? Number.POSITIVE_INFINITY;
|
|
123
|
+
return Math.round(Math.max(min, Math.min(width, Math.max(min, max))));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function direction() {
|
|
127
|
+
return current.side === 'right' ? -1 : 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function measure() {
|
|
131
|
+
if (current.measure) return current.measure();
|
|
132
|
+
return node.parentElement?.getBoundingClientRect().width ?? 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @param {number} clientX */
|
|
136
|
+
function widthAt(clientX) {
|
|
137
|
+
return clamp(startWidth + (clientX - startX) * direction());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** @param {PointerEvent} event */
|
|
141
|
+
function down(event) {
|
|
142
|
+
if (event.button !== 0) return;
|
|
143
|
+
active = true;
|
|
144
|
+
startX = event.clientX;
|
|
145
|
+
startWidth = measure();
|
|
146
|
+
node.setPointerCapture(event.pointerId);
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
current.onactive?.(true);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @param {PointerEvent} event */
|
|
152
|
+
function move(event) {
|
|
153
|
+
if (!active) return;
|
|
154
|
+
frames.schedule(widthAt(event.clientX));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** @param {PointerEvent} event */
|
|
158
|
+
function up(event) {
|
|
159
|
+
if (!active) return;
|
|
160
|
+
active = false;
|
|
161
|
+
const width = widthAt(event.clientX);
|
|
162
|
+
frames.flush(width);
|
|
163
|
+
try {
|
|
164
|
+
node.releasePointerCapture(event.pointerId);
|
|
165
|
+
} catch {
|
|
166
|
+
// Pointer capture may already have been released by the browser.
|
|
167
|
+
}
|
|
168
|
+
current.onactive?.(false);
|
|
169
|
+
current.oncommit?.(width);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** @param {KeyboardEvent} event */
|
|
173
|
+
function keydown(event) {
|
|
174
|
+
const step = current.step ?? 16;
|
|
175
|
+
/** @type {number | undefined} */
|
|
176
|
+
let next;
|
|
177
|
+
switch (event.key) {
|
|
178
|
+
case 'ArrowLeft':
|
|
179
|
+
next = measure() - step * direction();
|
|
180
|
+
break;
|
|
181
|
+
case 'ArrowRight':
|
|
182
|
+
next = measure() + step * direction();
|
|
183
|
+
break;
|
|
184
|
+
case 'Home':
|
|
185
|
+
next = current.min;
|
|
186
|
+
break;
|
|
187
|
+
case 'End':
|
|
188
|
+
next = current.max;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
if (next === undefined) return;
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
const width = clamp(next);
|
|
194
|
+
current.onwidth(width);
|
|
195
|
+
current.oncommit?.(width);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function reset() {
|
|
199
|
+
current.onreset?.();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
node.addEventListener('pointerdown', down);
|
|
203
|
+
node.addEventListener('pointermove', move);
|
|
204
|
+
node.addEventListener('pointerup', up);
|
|
205
|
+
node.addEventListener('pointercancel', up);
|
|
206
|
+
node.addEventListener('keydown', keydown);
|
|
207
|
+
node.addEventListener('dblclick', reset);
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
/** @param {ResizeHandleParams} next */
|
|
211
|
+
update(next) {
|
|
212
|
+
current = next;
|
|
213
|
+
},
|
|
214
|
+
destroy() {
|
|
215
|
+
node.removeEventListener('pointerdown', down);
|
|
216
|
+
node.removeEventListener('pointermove', move);
|
|
217
|
+
node.removeEventListener('pointerup', up);
|
|
218
|
+
node.removeEventListener('pointercancel', up);
|
|
219
|
+
node.removeEventListener('keydown', keydown);
|
|
220
|
+
node.removeEventListener('dblclick', reset);
|
|
221
|
+
frames.discard();
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -1,23 +1,21 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
// Theme switcher: a compact icon-button hosting a native <select> over the
|
|
3
|
-
// full theme registry, wired to the global
|
|
4
|
-
// gives correct mobile/keyboard behaviour and
|
|
5
|
-
// The button glyph reflects the active theme
|
|
3
|
+
// full theme registry (built-ins + theme.register()), wired to the global
|
|
4
|
+
// theme store. The native control gives correct mobile/keyboard behaviour and
|
|
5
|
+
// outside-click handling for free. The button glyph reflects the active theme.
|
|
6
6
|
import SelectButton from './SelectButton.svelte';
|
|
7
|
-
import {
|
|
7
|
+
import { type ThemeDef, theme } from '../../stores/theme.svelte';
|
|
8
8
|
|
|
9
9
|
let { class: klass = '' }: { class?: string } = $props();
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
// scannable. `<optgroup>` keeps native popup/keyboard behaviour for free.
|
|
13
|
-
const toOption = (t: (typeof THEMES)[number]) => ({
|
|
11
|
+
const toOption = (t: ThemeDef) => ({
|
|
14
12
|
value: t.id,
|
|
15
|
-
label: `${t.icon} ${t.label}`
|
|
13
|
+
label: `${t.icon ?? theme.fallbackIcon} ${t.label}`
|
|
16
14
|
});
|
|
17
|
-
const groups = [
|
|
18
|
-
{ label: '— light', options:
|
|
19
|
-
{ label: '— dark', options:
|
|
20
|
-
];
|
|
15
|
+
const groups = $derived([
|
|
16
|
+
{ label: '— light', options: theme.all.filter((t) => t.mode === 'light').map(toOption) },
|
|
17
|
+
{ label: '— dark', options: theme.all.filter((t) => t.mode === 'dark').map(toOption) }
|
|
18
|
+
]);
|
|
21
19
|
</script>
|
|
22
20
|
|
|
23
21
|
<SelectButton
|
|
@@ -28,5 +26,5 @@
|
|
|
28
26
|
title={`Theme: ${theme.label}`}
|
|
29
27
|
value={theme.current}
|
|
30
28
|
{groups}
|
|
31
|
-
onchange={(v) => theme.set(v
|
|
29
|
+
onchange={(v) => theme.set(v)}
|
|
32
30
|
/>
|
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export { default as Icon } from './components/atoms/Icon.svelte';
|
|
|
11
11
|
export { default as Input } from './components/atoms/Input.svelte';
|
|
12
12
|
export { default as Link } from './components/atoms/Link.svelte';
|
|
13
13
|
export { default as Progress } from './components/atoms/Progress.svelte';
|
|
14
|
+
export { default as Scrim } from './components/atoms/Scrim.svelte';
|
|
14
15
|
export { default as SegmentedProgress, type ProgressSegment, } from './components/atoms/SegmentedProgress.svelte';
|
|
15
16
|
export { default as Select } from './components/atoms/Select.svelte';
|
|
16
17
|
export { default as Slider } from './components/atoms/Slider.svelte';
|
|
@@ -25,6 +26,7 @@ export { default as Container } from './components/layouts/Container.svelte';
|
|
|
25
26
|
export { default as NavItem } from './components/layouts/NavItem.svelte';
|
|
26
27
|
export { default as NavSection } from './components/layouts/NavSection.svelte';
|
|
27
28
|
export { default as ResizablePanel } from './components/layouts/ResizablePanel.svelte';
|
|
29
|
+
export { type ResizeHandleParams, resizeHandle, } from './components/layouts/resizable-panel-frame.js';
|
|
28
30
|
export { default as Stack } from './components/layouts/Stack.svelte';
|
|
29
31
|
export { type AccordionItem, default as Accordion, } from './components/molecules/Accordion.svelte';
|
|
30
32
|
export { type BreadcrumbItem, default as Breadcrumb, } from './components/molecules/Breadcrumb.svelte';
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,7 @@ export { default as Icon } from './components/atoms/Icon.svelte';
|
|
|
16
16
|
export { default as Input } from './components/atoms/Input.svelte';
|
|
17
17
|
export { default as Link } from './components/atoms/Link.svelte';
|
|
18
18
|
export { default as Progress } from './components/atoms/Progress.svelte';
|
|
19
|
+
export { default as Scrim } from './components/atoms/Scrim.svelte';
|
|
19
20
|
export { default as SegmentedProgress, } from './components/atoms/SegmentedProgress.svelte';
|
|
20
21
|
export { default as Select } from './components/atoms/Select.svelte';
|
|
21
22
|
export { default as Slider } from './components/atoms/Slider.svelte';
|
|
@@ -32,6 +33,7 @@ export { default as Container } from './components/layouts/Container.svelte';
|
|
|
32
33
|
export { default as NavItem } from './components/layouts/NavItem.svelte';
|
|
33
34
|
export { default as NavSection } from './components/layouts/NavSection.svelte';
|
|
34
35
|
export { default as ResizablePanel } from './components/layouts/ResizablePanel.svelte';
|
|
36
|
+
export { resizeHandle, } from './components/layouts/resizable-panel-frame.js';
|
|
35
37
|
export { default as Stack } from './components/layouts/Stack.svelte';
|
|
36
38
|
export { default as Accordion, } from './components/molecules/Accordion.svelte';
|
|
37
39
|
export { default as Breadcrumb, } from './components/molecules/Breadcrumb.svelte';
|
|
@@ -145,17 +145,33 @@ export declare const THEMES: readonly [{
|
|
|
145
145
|
}];
|
|
146
146
|
export type Mode = (typeof THEMES)[number]['id'];
|
|
147
147
|
export type ThemeMode = (typeof THEMES)[number]['mode'];
|
|
148
|
-
type
|
|
148
|
+
export type ThemeId = Mode | (string & {});
|
|
149
|
+
export interface ThemeDef {
|
|
150
|
+
id: ThemeId;
|
|
151
|
+
label: string;
|
|
152
|
+
mode: ThemeMode;
|
|
153
|
+
icon?: string;
|
|
154
|
+
themeColor?: string;
|
|
155
|
+
}
|
|
149
156
|
declare class Theme {
|
|
150
|
-
current:
|
|
157
|
+
current: ThemeId;
|
|
158
|
+
readonly fallbackIcon = "\u25C8";
|
|
159
|
+
private registered;
|
|
160
|
+
private fallback;
|
|
161
|
+
private saved;
|
|
151
162
|
constructor();
|
|
163
|
+
get all(): readonly ThemeDef[];
|
|
164
|
+
has(id: string | null | undefined): id is ThemeId;
|
|
165
|
+
register(defs: ThemeDef | ThemeDef[]): void;
|
|
166
|
+
setDefault(id: ThemeId): void;
|
|
167
|
+
private resolve;
|
|
152
168
|
private apply;
|
|
153
|
-
get option():
|
|
169
|
+
get option(): ThemeDef;
|
|
154
170
|
get label(): string;
|
|
155
171
|
get icon(): string;
|
|
156
|
-
get next():
|
|
172
|
+
get next(): ThemeDef;
|
|
157
173
|
toggle(): void;
|
|
158
|
-
set(mode:
|
|
174
|
+
set(mode: ThemeId): void;
|
|
159
175
|
}
|
|
160
176
|
export declare const theme: Theme;
|
|
161
177
|
export {};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { browser } from '../env';
|
|
2
|
-
// Theme registry
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// `mode` groups the theme into the picker's
|
|
2
|
+
// Theme registry. Built-ins live in THEMES (+ one [data-theme="id"] block in
|
|
3
|
+
// styles/themes.css); consumers append their own with theme.register() and ship
|
|
4
|
+
// the matching block in their own stylesheet. `themeColor` drives the mobile
|
|
5
|
+
// browser-chrome <meta theme-color>; `mode` groups the theme into the picker's
|
|
6
|
+
// light/dark sections.
|
|
6
7
|
const KEY = 'tsumikit-theme';
|
|
7
8
|
export const THEMES = [
|
|
8
9
|
// ── Light ── bright, paper-white surfaces
|
|
@@ -51,50 +52,75 @@ export const THEMES = [
|
|
|
51
52
|
{ id: 'monokai', label: 'Monokai', icon: '✸', themeColor: '#272822', mode: 'dark' },
|
|
52
53
|
{ id: 'amoled', label: 'AMOLED (high contrast)', icon: '◼', themeColor: '#000000', mode: 'dark' },
|
|
53
54
|
];
|
|
54
|
-
const
|
|
55
|
-
function isMode(value) {
|
|
56
|
-
return THEMES.some((t) => t.id === value);
|
|
57
|
-
}
|
|
58
|
-
function optionFor(mode) {
|
|
59
|
-
return THEMES.find((t) => t.id === mode) ?? THEMES[0];
|
|
60
|
-
}
|
|
55
|
+
const FALLBACK_ICON = '◈';
|
|
61
56
|
class Theme {
|
|
62
57
|
current = $state('dark');
|
|
58
|
+
fallbackIcon = FALLBACK_ICON;
|
|
59
|
+
registered = $state([]);
|
|
60
|
+
fallback = 'dark';
|
|
61
|
+
saved = null;
|
|
63
62
|
constructor() {
|
|
64
63
|
if (browser) {
|
|
65
|
-
|
|
66
|
-
this.
|
|
67
|
-
this.apply();
|
|
64
|
+
this.saved = localStorage.getItem(KEY);
|
|
65
|
+
this.resolve();
|
|
68
66
|
}
|
|
69
67
|
}
|
|
68
|
+
get all() {
|
|
69
|
+
const byId = new Map();
|
|
70
|
+
for (const t of THEMES)
|
|
71
|
+
byId.set(t.id, t);
|
|
72
|
+
for (const t of this.registered)
|
|
73
|
+
byId.set(t.id, t);
|
|
74
|
+
return [...byId.values()];
|
|
75
|
+
}
|
|
76
|
+
has(id) {
|
|
77
|
+
return id != null && this.all.some((t) => t.id === id);
|
|
78
|
+
}
|
|
79
|
+
register(defs) {
|
|
80
|
+
const list = Array.isArray(defs) ? defs : [defs];
|
|
81
|
+
this.registered = [...this.registered.filter((r) => !list.some((d) => d.id === r.id)), ...list];
|
|
82
|
+
this.resolve();
|
|
83
|
+
}
|
|
84
|
+
setDefault(id) {
|
|
85
|
+
this.fallback = id;
|
|
86
|
+
this.resolve();
|
|
87
|
+
}
|
|
88
|
+
resolve() {
|
|
89
|
+
this.current = this.has(this.saved) ? this.saved : this.fallback;
|
|
90
|
+
this.apply();
|
|
91
|
+
}
|
|
70
92
|
apply() {
|
|
71
93
|
if (!browser)
|
|
72
94
|
return;
|
|
73
95
|
document.documentElement.setAttribute('data-theme', this.current);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
96
|
+
const color = this.option.themeColor;
|
|
97
|
+
if (color)
|
|
98
|
+
document
|
|
99
|
+
.querySelector('meta[name="theme-color"]')
|
|
100
|
+
?.setAttribute('content', color);
|
|
77
101
|
}
|
|
78
102
|
get option() {
|
|
79
|
-
return
|
|
103
|
+
return this.all.find((t) => t.id === this.current) ?? this.all[0];
|
|
80
104
|
}
|
|
81
105
|
get label() {
|
|
82
106
|
return this.option.label;
|
|
83
107
|
}
|
|
84
108
|
get icon() {
|
|
85
|
-
return this.option.icon;
|
|
109
|
+
return this.option.icon ?? FALLBACK_ICON;
|
|
86
110
|
}
|
|
87
111
|
get next() {
|
|
88
|
-
const
|
|
89
|
-
|
|
112
|
+
const all = this.all;
|
|
113
|
+
const i = all.findIndex((t) => t.id === this.current);
|
|
114
|
+
return all[(i + 1) % all.length];
|
|
90
115
|
}
|
|
91
116
|
toggle() {
|
|
92
117
|
this.set(this.next.id);
|
|
93
118
|
}
|
|
94
119
|
set(mode) {
|
|
120
|
+
this.saved = mode;
|
|
95
121
|
this.current = mode;
|
|
96
122
|
if (browser)
|
|
97
|
-
localStorage.setItem(KEY,
|
|
123
|
+
localStorage.setItem(KEY, mode);
|
|
98
124
|
this.apply();
|
|
99
125
|
}
|
|
100
126
|
}
|