starlight-theme-bejamas 0.1.18 → 0.2.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/CHANGELOG.md +10 -0
- package/package.json +3 -1
- package/src/config.test.ts +40 -0
- package/src/config.ts +4 -3
- package/src/global.d.ts +4 -2
- package/src/lib/theme-choice.test.ts +251 -0
- package/src/lib/theme-choice.ts +346 -0
- package/src/lib/theme-stylesheet.test.ts +16 -0
- package/src/lib/theme-stylesheet.ts +13 -0
- package/src/overrides/MobileTableOfContents.astro +6 -6
- package/src/overrides/PageTitle.astro +1 -1
- package/src/overrides/TableOfContents/TableOfContentsList.astro +1 -1
- package/src/overrides/ThemeProvider.astro +152 -31
- package/src/overrides/ThemeSelect.astro +32 -47
- package/src/styles/theme.css +4 -30
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# starlight-theme-bejamas
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#102](https://github.com/bejamas/ui/pull/102) [`25987b8`](https://github.com/bejamas/ui/commit/25987b872d38280dfe47f820019f86c1cb00eb6d) Thanks [@thomkrupa](https://github.com/thomkrupa)! - Improve the Starlight Bejamas theme runtime and expose the shared theme-choice helpers for consumers that need to integrate with theme state directly.
|
|
8
|
+
- Unify light, dark, and auto theme choice handling across the theme picker, page chrome, and preview surfaces so theme state stays in sync more reliably.
|
|
9
|
+
- Refresh browser theme-color metadata and the active theme stylesheet more consistently when theme state or presets change.
|
|
10
|
+
- Sync semantic icon updates as part of the theme runtime so icon-library changes propagate cleanly with preset-driven theme updates.
|
|
11
|
+
- Export `starlight-theme-bejamas/lib/theme-choice` as a public entrypoint for consumers that want to read, apply, or react to the shared theme-choice state.
|
|
12
|
+
|
|
3
13
|
## 0.1.18
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "starlight-theme-bejamas",
|
|
3
3
|
"author": "Bejamas",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A Starlight theme using bejamas/ui",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./src/index.ts",
|
|
10
10
|
"./config": "./src/config.ts",
|
|
11
|
+
"./lib/theme-choice": "./src/lib/theme-choice.ts",
|
|
11
12
|
"./styles/theme.css": "./src/styles/theme.css",
|
|
12
13
|
"./overrides/Header.astro": "./src/overrides/Header.astro",
|
|
13
14
|
"./overrides/PageFrame.astro": "./src/overrides/PageFrame.astro",
|
|
@@ -41,6 +42,7 @@
|
|
|
41
42
|
"astro": ">=5.5.0"
|
|
42
43
|
},
|
|
43
44
|
"dependencies": {
|
|
45
|
+
"@bejamas/semantic-icons": "workspace:*",
|
|
44
46
|
"@expressive-code/plugin-line-numbers": "^0.41.3",
|
|
45
47
|
"@fontsource-variable/inter": "^5.2.8",
|
|
46
48
|
"@fontsource/inter": "^5.2.8",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { StarlightThemeBejamasConfigSchema } from "./config";
|
|
4
|
+
|
|
5
|
+
describe("starlight-theme-bejamas config schema", () => {
|
|
6
|
+
test("parses nav and component maps under zod v4", () => {
|
|
7
|
+
const result = StarlightThemeBejamasConfigSchema.safeParse({
|
|
8
|
+
nav: [
|
|
9
|
+
{
|
|
10
|
+
label: {
|
|
11
|
+
en: "Docs",
|
|
12
|
+
fr: "Documentation",
|
|
13
|
+
},
|
|
14
|
+
href: "/docs/introduction",
|
|
15
|
+
attrs: {
|
|
16
|
+
rel: "prefetch",
|
|
17
|
+
"data-track": "docs-link",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
components: {
|
|
22
|
+
ThemeSelect: "./src/components/ThemeSwitcher.astro",
|
|
23
|
+
PageTitle: "./src/components/PageTitle.astro",
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(result.success).toBe(true);
|
|
28
|
+
if (!result.success) {
|
|
29
|
+
throw result.error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
expect(result.data.nav?.[0]?.label).toEqual({
|
|
33
|
+
en: "Docs",
|
|
34
|
+
fr: "Documentation",
|
|
35
|
+
});
|
|
36
|
+
expect(result.data.components?.ThemeSelect).toBe(
|
|
37
|
+
"./src/components/ThemeSwitcher.astro",
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -3,7 +3,8 @@ import type { HTMLAttributes } from "astro/types";
|
|
|
3
3
|
import { z } from "astro/zod";
|
|
4
4
|
|
|
5
5
|
const linkHTMLAttributesSchema = z.record(
|
|
6
|
-
z.
|
|
6
|
+
z.string(),
|
|
7
|
+
z.union([z.string(), z.number(), z.boolean(), z.undefined()]),
|
|
7
8
|
) as z.Schema<
|
|
8
9
|
Omit<HTMLAttributes<"a">, keyof AstroBuiltinAttributes | "children">
|
|
9
10
|
>;
|
|
@@ -25,7 +26,7 @@ const navLinkSchema = z.object({
|
|
|
25
26
|
* The value can be a string, or for multilingual sites, an object with values for each different locale. When using
|
|
26
27
|
* the object form, the keys must be BCP-47 tags (e.g. en, fr, or zh-CN).
|
|
27
28
|
*/
|
|
28
|
-
label: z.union([z.string(), z.record(z.string())]),
|
|
29
|
+
label: z.union([z.string(), z.record(z.string(), z.string())]),
|
|
29
30
|
/**
|
|
30
31
|
* The link to the topic’s content which an be a relative link to local files or the full URL of an external page.
|
|
31
32
|
*
|
|
@@ -39,7 +40,7 @@ const navLinkSchema = z.object({
|
|
|
39
40
|
|
|
40
41
|
export const StarlightThemeBejamasConfigSchema = z.object({
|
|
41
42
|
nav: z.array(navLinkSchema).optional(),
|
|
42
|
-
components: z.record(z.string()).optional(),
|
|
43
|
+
components: z.record(z.string(), z.string()).optional(),
|
|
43
44
|
});
|
|
44
45
|
|
|
45
46
|
export type StarlightThemeBejamasUserConfig = z.input<
|
package/src/global.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
declare global {
|
|
2
2
|
interface Window {
|
|
3
3
|
StarlightThemeProvider?: {
|
|
4
|
-
|
|
4
|
+
refreshThemeColors?: (theme?: string) => void;
|
|
5
|
+
updatePickers?: (theme?: string) => void;
|
|
5
6
|
};
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
const StarlightThemeProvider: {
|
|
9
|
-
|
|
10
|
+
refreshThemeColors?: (theme?: string) => void;
|
|
11
|
+
updatePickers?: (theme?: string) => void;
|
|
10
12
|
};
|
|
11
13
|
|
|
12
14
|
namespace App {
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
STARLIGHT_THEME_STORAGE_KEY,
|
|
4
|
+
THEME_TOGGLE_CHANGED_EVENT,
|
|
5
|
+
applyThemeModeToRoot,
|
|
6
|
+
buildThemeChoiceBootstrapInlineScript,
|
|
7
|
+
getThemeChoiceFromRoot,
|
|
8
|
+
getThemeModeFromRoot,
|
|
9
|
+
loadStoredThemeChoice,
|
|
10
|
+
parseStoredThemeChoice,
|
|
11
|
+
resolveThemeMode,
|
|
12
|
+
setThemeChoice,
|
|
13
|
+
storeThemeChoice,
|
|
14
|
+
syncThemeChoiceControls,
|
|
15
|
+
} from "./theme-choice";
|
|
16
|
+
|
|
17
|
+
function createThemeRoot() {
|
|
18
|
+
const classes = new Set<string>();
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
classes,
|
|
22
|
+
root: {
|
|
23
|
+
dataset: {} as Record<string, string | undefined>,
|
|
24
|
+
classList: {
|
|
25
|
+
add: (...tokens: string[]) => {
|
|
26
|
+
for (const token of tokens) {
|
|
27
|
+
classes.add(token);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
remove: (...tokens: string[]) => {
|
|
31
|
+
for (const token of tokens) {
|
|
32
|
+
classes.delete(token);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
contains: (token: string) => classes.has(token),
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("theme choice runtime", () => {
|
|
42
|
+
it("parses only supported stored theme choices", () => {
|
|
43
|
+
expect(parseStoredThemeChoice("light")).toBe("light");
|
|
44
|
+
expect(parseStoredThemeChoice("dark")).toBe("dark");
|
|
45
|
+
expect(parseStoredThemeChoice("auto")).toBe("auto");
|
|
46
|
+
expect(parseStoredThemeChoice("")).toBe("auto");
|
|
47
|
+
expect(parseStoredThemeChoice("system")).toBe("auto");
|
|
48
|
+
expect(parseStoredThemeChoice(undefined)).toBe("auto");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("loads the stored theme choice from localStorage-compatible storage", () => {
|
|
52
|
+
expect(
|
|
53
|
+
loadStoredThemeChoice({
|
|
54
|
+
getItem: (key) =>
|
|
55
|
+
key === STARLIGHT_THEME_STORAGE_KEY ? "dark" : null,
|
|
56
|
+
}),
|
|
57
|
+
).toBe("dark");
|
|
58
|
+
|
|
59
|
+
expect(
|
|
60
|
+
loadStoredThemeChoice({
|
|
61
|
+
getItem: () => "unexpected",
|
|
62
|
+
}),
|
|
63
|
+
).toBe("auto");
|
|
64
|
+
|
|
65
|
+
expect(
|
|
66
|
+
loadStoredThemeChoice({
|
|
67
|
+
getItem: () => {
|
|
68
|
+
throw new Error("storage unavailable");
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
).toBe("auto");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("resolves auto against the preferred color scheme", () => {
|
|
75
|
+
expect(resolveThemeMode("light", false)).toBe("light");
|
|
76
|
+
expect(resolveThemeMode("dark", true)).toBe("dark");
|
|
77
|
+
expect(resolveThemeMode("auto", true)).toBe("light");
|
|
78
|
+
expect(resolveThemeMode("auto", false)).toBe("dark");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("applies theme attributes and class state to the target root", () => {
|
|
82
|
+
const { root, classes } = createThemeRoot();
|
|
83
|
+
|
|
84
|
+
classes.add("dark");
|
|
85
|
+
applyThemeModeToRoot(root, "auto", "light");
|
|
86
|
+
|
|
87
|
+
expect(root.dataset.theme).toBe("light");
|
|
88
|
+
expect(root.dataset.themeChoice).toBe("auto");
|
|
89
|
+
expect(classes.has("light")).toBe(true);
|
|
90
|
+
expect(classes.has("dark")).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("stores explicit theme choices and clears auto to the empty localStorage value", () => {
|
|
94
|
+
const stored = new Map<string, string>();
|
|
95
|
+
const storage = {
|
|
96
|
+
setItem: (key: string, value: string) => {
|
|
97
|
+
stored.set(key, value);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
storeThemeChoice(storage, "dark");
|
|
102
|
+
storeThemeChoice(storage, "auto");
|
|
103
|
+
|
|
104
|
+
expect(stored.get(STARLIGHT_THEME_STORAGE_KEY)).toBe("");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("reads theme choice and effective mode from the document root", () => {
|
|
108
|
+
const { root, classes } = createThemeRoot();
|
|
109
|
+
|
|
110
|
+
root.dataset.themeChoice = "auto";
|
|
111
|
+
root.dataset.theme = "";
|
|
112
|
+
classes.add("dark");
|
|
113
|
+
|
|
114
|
+
expect(getThemeChoiceFromRoot(root)).toBe("auto");
|
|
115
|
+
expect(getThemeModeFromRoot(root, "light")).toBe("dark");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("syncs tabs-based and legacy select-based controls to the requested choice", () => {
|
|
119
|
+
let tabsValue = "";
|
|
120
|
+
let selectValue = "";
|
|
121
|
+
const nativeSelect = { value: "" };
|
|
122
|
+
const tabsRoot = {
|
|
123
|
+
dataset: {} as Record<string, string | undefined>,
|
|
124
|
+
dispatchEvent: (event: Event) => {
|
|
125
|
+
tabsValue =
|
|
126
|
+
(event as CustomEvent<{ value?: string }>).detail?.value ?? "";
|
|
127
|
+
return true;
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
const selectRoot = {
|
|
131
|
+
dataset: {} as Record<string, string | undefined>,
|
|
132
|
+
dispatchEvent: (event: Event) => {
|
|
133
|
+
selectValue =
|
|
134
|
+
(event as CustomEvent<{ value?: string }>).detail?.value ?? "";
|
|
135
|
+
return true;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
const picker = {
|
|
139
|
+
querySelector: (selector: string) => {
|
|
140
|
+
if (selector === '[data-slot="tabs"][data-theme-choice-tabs]') {
|
|
141
|
+
return tabsRoot;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (selector === "select") {
|
|
145
|
+
return nativeSelect;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (selector === '#starlight-theme-select, [data-slot="select"]') {
|
|
149
|
+
return selectRoot;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return null;
|
|
153
|
+
},
|
|
154
|
+
querySelectorAll: () => [] as unknown as NodeListOf<Element>,
|
|
155
|
+
};
|
|
156
|
+
const root = {
|
|
157
|
+
querySelectorAll: (selector: string) => {
|
|
158
|
+
if (selector === "starlight-theme-select") {
|
|
159
|
+
return [picker] as unknown as NodeListOf<Element>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (selector === '[data-slot="tabs"][data-theme-choice-tabs]') {
|
|
163
|
+
return [tabsRoot] as unknown as NodeListOf<Element>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return [] as unknown as NodeListOf<Element>;
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
syncThemeChoiceControls("light", root as unknown as ParentNode);
|
|
171
|
+
|
|
172
|
+
expect(tabsRoot.dataset.defaultValue).toBe("light");
|
|
173
|
+
expect(tabsRoot.dataset.value).toBe("light");
|
|
174
|
+
expect(tabsValue).toBe("light");
|
|
175
|
+
expect(nativeSelect.value).toBe("light");
|
|
176
|
+
expect(selectRoot.dataset.defaultValue).toBe("light");
|
|
177
|
+
expect(selectRoot.dataset.value).toBe("light");
|
|
178
|
+
expect(selectValue).toBe("light");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("applies the shared global theme choice path including storage, controls, and event dispatch", () => {
|
|
182
|
+
const { root, classes } = createThemeRoot();
|
|
183
|
+
const stored = new Map<string, string>();
|
|
184
|
+
const dispatched: Array<{ theme: string; themeChoice: string }> = [];
|
|
185
|
+
let tabsValue = "";
|
|
186
|
+
const tabsRoot = {
|
|
187
|
+
dataset: {} as Record<string, string | undefined>,
|
|
188
|
+
dispatchEvent: (event: Event) => {
|
|
189
|
+
tabsValue =
|
|
190
|
+
(event as CustomEvent<{ value?: string }>).detail?.value ?? "";
|
|
191
|
+
return true;
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
const controlsRoot = {
|
|
195
|
+
querySelectorAll: (selector: string) => {
|
|
196
|
+
if (selector === '[data-slot="tabs"][data-theme-choice-tabs]') {
|
|
197
|
+
return [tabsRoot] as unknown as NodeListOf<Element>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return [] as unknown as NodeListOf<Element>;
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
const dispatchTarget = {
|
|
204
|
+
dispatchEvent: (event: Event) => {
|
|
205
|
+
const customEvent = event as CustomEvent<{
|
|
206
|
+
theme?: string;
|
|
207
|
+
themeChoice?: string;
|
|
208
|
+
}>;
|
|
209
|
+
|
|
210
|
+
if (event.type === THEME_TOGGLE_CHANGED_EVENT) {
|
|
211
|
+
dispatched.push({
|
|
212
|
+
theme: customEvent.detail?.theme ?? "",
|
|
213
|
+
themeChoice: customEvent.detail?.themeChoice ?? "",
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return true;
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const effectiveTheme = setThemeChoice("dark", {
|
|
222
|
+
root,
|
|
223
|
+
prefersLight: true,
|
|
224
|
+
storage: {
|
|
225
|
+
setItem: (key, value) => {
|
|
226
|
+
stored.set(key, value);
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
controlsRoot: controlsRoot as unknown as ParentNode,
|
|
230
|
+
dispatchTarget: dispatchTarget as unknown as EventTarget,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(effectiveTheme).toBe("dark");
|
|
234
|
+
expect(root.dataset.themeChoice).toBe("dark");
|
|
235
|
+
expect(root.dataset.theme).toBe("dark");
|
|
236
|
+
expect(classes.has("dark")).toBe(true);
|
|
237
|
+
expect(stored.get(STARLIGHT_THEME_STORAGE_KEY)).toBe("dark");
|
|
238
|
+
expect(tabsValue).toBe("dark");
|
|
239
|
+
expect(dispatched).toEqual([{ theme: "dark", themeChoice: "dark" }]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("builds an inline bootstrap script that owns root state and control sync", () => {
|
|
243
|
+
const script = buildThemeChoiceBootstrapInlineScript();
|
|
244
|
+
|
|
245
|
+
expect(script).toContain(STARLIGHT_THEME_STORAGE_KEY);
|
|
246
|
+
expect(script).toContain("dataset.themeChoice");
|
|
247
|
+
expect(script).toContain('[data-slot="tabs"][data-theme-choice-tabs]');
|
|
248
|
+
expect(script).toContain(THEME_TOGGLE_CHANGED_EVENT);
|
|
249
|
+
expect(script).toContain("prefers-color-scheme: light");
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
export const STARLIGHT_THEME_STORAGE_KEY = "starlight-theme";
|
|
2
|
+
export const THEME_TOGGLE_CHANGED_EVENT = "theme-toggle-changed";
|
|
3
|
+
|
|
4
|
+
export type ThemeChoice = "auto" | "dark" | "light";
|
|
5
|
+
export type ThemeMode = "dark" | "light";
|
|
6
|
+
|
|
7
|
+
export interface ThemeRoot {
|
|
8
|
+
dataset: Record<string, string | undefined>;
|
|
9
|
+
classList: {
|
|
10
|
+
add: (...tokens: string[]) => void;
|
|
11
|
+
remove: (...tokens: string[]) => void;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type ThemeClassList = ThemeRoot["classList"] & {
|
|
16
|
+
contains?: (token: string) => boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type ThemeRootElement = ThemeRoot & {
|
|
20
|
+
classList: ThemeClassList;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type ThemeStorageReader = Pick<Storage, "getItem"> | null | undefined;
|
|
24
|
+
type ThemeStorageWriter = Pick<Storage, "setItem"> | null | undefined;
|
|
25
|
+
|
|
26
|
+
const THEME_CHOICE_TAB_SELECTOR = '[data-slot="tabs"][data-theme-choice-tabs]';
|
|
27
|
+
|
|
28
|
+
function getElementsWithSelf<T extends Element>(
|
|
29
|
+
root: ParentNode,
|
|
30
|
+
selector: string,
|
|
31
|
+
): T[] {
|
|
32
|
+
const matches: T[] = [];
|
|
33
|
+
|
|
34
|
+
if (
|
|
35
|
+
typeof Element !== "undefined" &&
|
|
36
|
+
root instanceof Element &&
|
|
37
|
+
root.matches(selector)
|
|
38
|
+
) {
|
|
39
|
+
matches.push(root as T);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
matches.push(...Array.from(root.querySelectorAll<T>(selector)));
|
|
43
|
+
|
|
44
|
+
return matches;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseStoredThemeChoice(value: unknown): ThemeChoice {
|
|
48
|
+
return value === "auto" || value === "dark" || value === "light"
|
|
49
|
+
? value
|
|
50
|
+
: "auto";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function loadStoredThemeChoice(storage: ThemeStorageReader): ThemeChoice {
|
|
54
|
+
try {
|
|
55
|
+
return parseStoredThemeChoice(
|
|
56
|
+
storage?.getItem(STARLIGHT_THEME_STORAGE_KEY),
|
|
57
|
+
);
|
|
58
|
+
} catch {
|
|
59
|
+
return "auto";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function resolveThemeMode(
|
|
64
|
+
themeChoice: ThemeChoice,
|
|
65
|
+
prefersLight: boolean,
|
|
66
|
+
): ThemeMode {
|
|
67
|
+
if (themeChoice === "light" || themeChoice === "dark") {
|
|
68
|
+
return themeChoice;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return prefersLight ? "light" : "dark";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function applyThemeModeToRoot(
|
|
75
|
+
root: ThemeRoot,
|
|
76
|
+
themeChoice: ThemeChoice,
|
|
77
|
+
themeMode: ThemeMode,
|
|
78
|
+
): void {
|
|
79
|
+
root.dataset.theme = themeMode;
|
|
80
|
+
root.dataset.themeChoice = themeChoice;
|
|
81
|
+
root.classList.remove("light", "dark");
|
|
82
|
+
root.classList.add(themeMode);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function storeThemeChoice(
|
|
86
|
+
storage: ThemeStorageWriter,
|
|
87
|
+
themeChoice: ThemeChoice,
|
|
88
|
+
): void {
|
|
89
|
+
try {
|
|
90
|
+
storage?.setItem(
|
|
91
|
+
STARLIGHT_THEME_STORAGE_KEY,
|
|
92
|
+
themeChoice === "light" || themeChoice === "dark" ? themeChoice : "",
|
|
93
|
+
);
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getThemeChoiceFromRoot(
|
|
98
|
+
root: Pick<ThemeRoot, "dataset">,
|
|
99
|
+
): ThemeChoice {
|
|
100
|
+
return parseStoredThemeChoice(root.dataset.themeChoice);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function getThemeModeFromRoot(
|
|
104
|
+
root: ThemeRootElement,
|
|
105
|
+
fallbackMode: ThemeMode,
|
|
106
|
+
): ThemeMode {
|
|
107
|
+
if (root.dataset.theme === "light" || root.dataset.theme === "dark") {
|
|
108
|
+
return root.dataset.theme;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (root.classList.contains?.("dark")) {
|
|
112
|
+
return "dark";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (root.classList.contains?.("light")) {
|
|
116
|
+
return "light";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return fallbackMode;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function syncThemeChoiceTabControls(
|
|
123
|
+
themeChoice: ThemeChoice,
|
|
124
|
+
root: ParentNode,
|
|
125
|
+
): void {
|
|
126
|
+
const tabsRoots = new Set<HTMLElement>();
|
|
127
|
+
|
|
128
|
+
for (const tabsRoot of getElementsWithSelf<HTMLElement>(
|
|
129
|
+
root,
|
|
130
|
+
THEME_CHOICE_TAB_SELECTOR,
|
|
131
|
+
)) {
|
|
132
|
+
tabsRoots.add(tabsRoot);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const picker of getElementsWithSelf<HTMLElement>(
|
|
136
|
+
root,
|
|
137
|
+
"starlight-theme-select",
|
|
138
|
+
)) {
|
|
139
|
+
const tabsRoot = picker.querySelector<HTMLElement>(THEME_CHOICE_TAB_SELECTOR);
|
|
140
|
+
if (tabsRoot) {
|
|
141
|
+
tabsRoots.add(tabsRoot);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const tabsRoot of tabsRoots) {
|
|
146
|
+
tabsRoot.dataset.defaultValue = themeChoice;
|
|
147
|
+
tabsRoot.dataset.value = themeChoice;
|
|
148
|
+
tabsRoot.dispatchEvent(
|
|
149
|
+
new CustomEvent("tabs:set", { detail: { value: themeChoice } }),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function syncLegacyThemeChoiceSelectControls(
|
|
155
|
+
themeChoice: ThemeChoice,
|
|
156
|
+
root: ParentNode,
|
|
157
|
+
): void {
|
|
158
|
+
for (const picker of getElementsWithSelf<HTMLElement>(
|
|
159
|
+
root,
|
|
160
|
+
"starlight-theme-select",
|
|
161
|
+
)) {
|
|
162
|
+
const nativeSelect = picker.querySelector<HTMLSelectElement>("select");
|
|
163
|
+
if (nativeSelect) {
|
|
164
|
+
nativeSelect.value = themeChoice;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const selectRoot = picker.querySelector<HTMLElement>(
|
|
168
|
+
'#starlight-theme-select, [data-slot="select"]',
|
|
169
|
+
);
|
|
170
|
+
if (!selectRoot) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
selectRoot.dataset.defaultValue = themeChoice;
|
|
175
|
+
selectRoot.dataset.value = themeChoice;
|
|
176
|
+
selectRoot.dispatchEvent(
|
|
177
|
+
new CustomEvent("select:set", { detail: { value: themeChoice } }),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function syncThemeChoiceControls(
|
|
183
|
+
themeChoice: ThemeChoice,
|
|
184
|
+
root: ParentNode = document,
|
|
185
|
+
): void {
|
|
186
|
+
syncThemeChoiceTabControls(themeChoice, root);
|
|
187
|
+
syncLegacyThemeChoiceSelectControls(themeChoice, root);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function dispatchThemeToggleChange(
|
|
191
|
+
target: EventTarget,
|
|
192
|
+
themeMode: ThemeMode,
|
|
193
|
+
themeChoice: ThemeChoice,
|
|
194
|
+
): void {
|
|
195
|
+
target.dispatchEvent(
|
|
196
|
+
new CustomEvent(THEME_TOGGLE_CHANGED_EVENT, {
|
|
197
|
+
detail: {
|
|
198
|
+
theme: themeMode,
|
|
199
|
+
themeChoice,
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function setThemeChoice(
|
|
206
|
+
themeChoice: ThemeChoice,
|
|
207
|
+
options: {
|
|
208
|
+
storage?: ThemeStorageWriter;
|
|
209
|
+
root?: ThemeRootElement;
|
|
210
|
+
prefersLight?: boolean;
|
|
211
|
+
controlsRoot?: ParentNode;
|
|
212
|
+
dispatchTarget?: EventTarget;
|
|
213
|
+
emitEvent?: boolean;
|
|
214
|
+
} = {},
|
|
215
|
+
): ThemeMode {
|
|
216
|
+
const root = options.root ?? (document.documentElement as HTMLElement);
|
|
217
|
+
const effectiveTheme = resolveThemeMode(
|
|
218
|
+
themeChoice,
|
|
219
|
+
options.prefersLight ??
|
|
220
|
+
window.matchMedia("(prefers-color-scheme: light)").matches,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
applyThemeModeToRoot(root, themeChoice, effectiveTheme);
|
|
224
|
+
storeThemeChoice(options.storage ?? localStorage, themeChoice);
|
|
225
|
+
syncThemeChoiceControls(themeChoice, options.controlsRoot ?? document);
|
|
226
|
+
|
|
227
|
+
if (options.emitEvent !== false) {
|
|
228
|
+
dispatchThemeToggleChange(
|
|
229
|
+
options.dispatchTarget ?? window,
|
|
230
|
+
effectiveTheme,
|
|
231
|
+
themeChoice,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return effectiveTheme;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function buildThemeChoiceBootstrapInlineScript(): string {
|
|
239
|
+
return `(function () {
|
|
240
|
+
const storageKey = ${JSON.stringify(STARLIGHT_THEME_STORAGE_KEY)};
|
|
241
|
+
const eventName = ${JSON.stringify(THEME_TOGGLE_CHANGED_EVENT)};
|
|
242
|
+
const parseStoredThemeChoice = (value) =>
|
|
243
|
+
value === "auto" || value === "dark" || value === "light" ? value : "auto";
|
|
244
|
+
const loadStoredThemeChoice = () => {
|
|
245
|
+
try {
|
|
246
|
+
return parseStoredThemeChoice(
|
|
247
|
+
typeof localStorage !== "undefined" ? localStorage.getItem(storageKey) : null,
|
|
248
|
+
);
|
|
249
|
+
} catch {
|
|
250
|
+
return "auto";
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
const resolveThemeMode = (themeChoice, prefersLight) => {
|
|
254
|
+
if (themeChoice === "light" || themeChoice === "dark") {
|
|
255
|
+
return themeChoice;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return prefersLight ? "light" : "dark";
|
|
259
|
+
};
|
|
260
|
+
const applyThemeModeToRoot = (themeChoice) => {
|
|
261
|
+
const themeMode = resolveThemeMode(
|
|
262
|
+
themeChoice,
|
|
263
|
+
matchMedia("(prefers-color-scheme: light)").matches,
|
|
264
|
+
);
|
|
265
|
+
const root = document.documentElement;
|
|
266
|
+
|
|
267
|
+
root.dataset.theme = themeMode;
|
|
268
|
+
root.dataset.themeChoice = themeChoice;
|
|
269
|
+
root.classList.remove("light", "dark");
|
|
270
|
+
root.classList.add(themeMode);
|
|
271
|
+
|
|
272
|
+
return themeMode;
|
|
273
|
+
};
|
|
274
|
+
const syncThemeChoiceControls = (themeChoice) => {
|
|
275
|
+
document
|
|
276
|
+
.querySelectorAll('[data-slot="tabs"][data-theme-choice-tabs]')
|
|
277
|
+
.forEach((tabsRoot) => {
|
|
278
|
+
tabsRoot.dataset.defaultValue = themeChoice;
|
|
279
|
+
tabsRoot.dataset.value = themeChoice;
|
|
280
|
+
tabsRoot.dispatchEvent(
|
|
281
|
+
new CustomEvent("tabs:set", { detail: { value: themeChoice } }),
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
document.querySelectorAll("starlight-theme-select").forEach((picker) => {
|
|
286
|
+
const nativeSelect = picker.querySelector("select");
|
|
287
|
+
if (nativeSelect instanceof HTMLSelectElement) {
|
|
288
|
+
nativeSelect.value = themeChoice;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const selectRoot = picker.querySelector(
|
|
292
|
+
'#starlight-theme-select, [data-slot="select"]',
|
|
293
|
+
);
|
|
294
|
+
if (!(selectRoot instanceof HTMLElement)) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
selectRoot.dataset.defaultValue = themeChoice;
|
|
299
|
+
selectRoot.dataset.value = themeChoice;
|
|
300
|
+
selectRoot.dispatchEvent(
|
|
301
|
+
new CustomEvent("select:set", { detail: { value: themeChoice } }),
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
};
|
|
305
|
+
const dispatchThemeToggleChange = (themeMode, themeChoice) => {
|
|
306
|
+
window.dispatchEvent(
|
|
307
|
+
new CustomEvent(eventName, {
|
|
308
|
+
detail: {
|
|
309
|
+
theme: themeMode,
|
|
310
|
+
themeChoice,
|
|
311
|
+
},
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
};
|
|
315
|
+
const syncThemeChoice = (themeChoice, emitEvent) => {
|
|
316
|
+
const themeMode = applyThemeModeToRoot(themeChoice);
|
|
317
|
+
syncThemeChoiceControls(themeChoice);
|
|
318
|
+
if (emitEvent) {
|
|
319
|
+
dispatchThemeToggleChange(themeMode, themeChoice);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
const syncStoredThemeChoice = (emitEvent) => {
|
|
323
|
+
syncThemeChoice(loadStoredThemeChoice(), emitEvent);
|
|
324
|
+
};
|
|
325
|
+
const mediaQuery = matchMedia("(prefers-color-scheme: light)");
|
|
326
|
+
const onMediaChange = () => {
|
|
327
|
+
if (loadStoredThemeChoice() === "auto") {
|
|
328
|
+
syncThemeChoice("auto", true);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
syncStoredThemeChoice(false);
|
|
333
|
+
|
|
334
|
+
window.addEventListener("storage", (event) => {
|
|
335
|
+
if (event.key === storageKey) {
|
|
336
|
+
syncStoredThemeChoice(true);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
if (typeof mediaQuery.addEventListener === "function") {
|
|
341
|
+
mediaQuery.addEventListener("change", onMediaChange);
|
|
342
|
+
} else if (typeof mediaQuery.addListener === "function") {
|
|
343
|
+
mediaQuery.addListener(onMediaChange);
|
|
344
|
+
}
|
|
345
|
+
})();`;
|
|
346
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildCurrentThemeStylesheetHref } from "./theme-stylesheet";
|
|
3
|
+
|
|
4
|
+
describe("current theme stylesheet helpers", () => {
|
|
5
|
+
test("adds a cache-busting query param to relative hrefs", () => {
|
|
6
|
+
expect(
|
|
7
|
+
buildCurrentThemeStylesheetHref("/r/themes/current-theme.css", 123),
|
|
8
|
+
).toBe("/r/themes/current-theme.css?v=123");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("preserves existing query params when cache-busting", () => {
|
|
12
|
+
expect(
|
|
13
|
+
buildCurrentThemeStylesheetHref("/r/themes/current-theme.css?foo=bar", 123),
|
|
14
|
+
).toBe("/r/themes/current-theme.css?foo=bar&v=123");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const CURRENT_THEME_STYLESHEET_SELECTOR =
|
|
2
|
+
'link[data-current-theme-stylesheet]';
|
|
3
|
+
|
|
4
|
+
export function buildCurrentThemeStylesheetHref(
|
|
5
|
+
href: string,
|
|
6
|
+
version: string | number,
|
|
7
|
+
) {
|
|
8
|
+
const url = new URL(href, "https://ui.bejamas.com");
|
|
9
|
+
url.searchParams.set("v", String(version));
|
|
10
|
+
|
|
11
|
+
const isAbsolute = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href);
|
|
12
|
+
return isAbsolute ? url.toString() : `${url.pathname}${url.search}${url.hash}`;
|
|
13
|
+
}
|
|
@@ -87,12 +87,12 @@ const { toc } = Astro.locals.starlightRoute;
|
|
|
87
87
|
align-items: center;
|
|
88
88
|
justify-content: space-between;
|
|
89
89
|
border: 1px solid var(--sl-color-gray-5);
|
|
90
|
-
border-radius:
|
|
90
|
+
border-radius: calc(var(--radius) - 2px);
|
|
91
91
|
padding-block: 0.5rem;
|
|
92
92
|
padding-inline-start: 0.75rem;
|
|
93
93
|
padding-inline-end: 0.5rem;
|
|
94
94
|
line-height: 1;
|
|
95
|
-
background-color: var(--
|
|
95
|
+
background-color: var(--background);
|
|
96
96
|
user-select: none;
|
|
97
97
|
cursor: pointer;
|
|
98
98
|
font-weight: 500;
|
|
@@ -133,11 +133,11 @@ const { toc } = Astro.locals.starlightRoute;
|
|
|
133
133
|
transition: background 200ms ease-out;
|
|
134
134
|
}
|
|
135
135
|
details[open] .toggle {
|
|
136
|
-
color: var(--
|
|
136
|
+
color: var(--foreground);
|
|
137
137
|
border-color: var(--sl-color-accent);
|
|
138
138
|
}
|
|
139
139
|
details .toggle:hover {
|
|
140
|
-
color: var(--
|
|
140
|
+
color: var(--foreground);
|
|
141
141
|
border-color: var(--sl-color-gray-2);
|
|
142
142
|
}
|
|
143
143
|
|
|
@@ -152,7 +152,7 @@ const { toc } = Astro.locals.starlightRoute;
|
|
|
152
152
|
white-space: nowrap;
|
|
153
153
|
text-overflow: ellipsis;
|
|
154
154
|
overflow: hidden;
|
|
155
|
-
color: var(--
|
|
155
|
+
color: var(--foreground);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
.dropdown {
|
|
@@ -164,7 +164,7 @@ const { toc } = Astro.locals.starlightRoute;
|
|
|
164
164
|
85vh - var(--sl-nav-height) - var(--sl-mobile-toc-height)
|
|
165
165
|
);
|
|
166
166
|
overflow-y: auto;
|
|
167
|
-
background-color: var(--
|
|
167
|
+
background-color: var(--popover);
|
|
168
168
|
box-shadow: var(--sl-shadow-md);
|
|
169
169
|
overscroll-behavior: contain;
|
|
170
170
|
}
|
|
@@ -70,7 +70,7 @@ const { toc, isMobile = false, depth = 0 } = Astro.props;
|
|
|
70
70
|
.isMobile a[aria-current="true"],
|
|
71
71
|
.isMobile a[aria-current="true"]:hover,
|
|
72
72
|
.isMobile a[aria-current="true"]:focus {
|
|
73
|
-
color: var(--
|
|
73
|
+
color: var(--foreground);
|
|
74
74
|
background-color: unset;
|
|
75
75
|
}
|
|
76
76
|
.isMobile a[aria-current="true"]::after {
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { buildThemeChoiceBootstrapInlineScript } from "../lib/theme-choice";
|
|
3
|
+
|
|
4
|
+
const themeChoiceBootstrapScript = buildThemeChoiceBootstrapInlineScript();
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<script is:inline set:html={themeChoiceBootstrapScript} />
|
|
8
|
+
|
|
1
9
|
{/* This is intentionally inlined to avoid FOUC. */}
|
|
2
10
|
<script is:inline>
|
|
3
11
|
window.StarlightThemeProvider = (() => {
|
|
4
|
-
const storageKey = "starlight-theme";
|
|
5
12
|
const themeMetaMedias = [
|
|
6
13
|
"(prefers-color-scheme: light)",
|
|
7
14
|
"(prefers-color-scheme: dark)",
|
|
@@ -14,12 +21,12 @@
|
|
|
14
21
|
theme === "auto" || theme === "dark" || theme === "light"
|
|
15
22
|
? theme
|
|
16
23
|
: "auto";
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
const getPreferredColorScheme = () =>
|
|
25
|
+
window.matchMedia("(prefers-color-scheme: light)").matches
|
|
26
|
+
? "light"
|
|
27
|
+
: "dark";
|
|
21
28
|
|
|
22
|
-
const
|
|
29
|
+
const readThemeColors = () => {
|
|
23
30
|
const html = document.documentElement;
|
|
24
31
|
const prevTheme = html.dataset.theme;
|
|
25
32
|
const prevDark = html.classList.contains("dark");
|
|
@@ -45,7 +52,7 @@
|
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
return { light, dark };
|
|
48
|
-
}
|
|
55
|
+
};
|
|
49
56
|
|
|
50
57
|
function ensureThemeMeta(head, media) {
|
|
51
58
|
const baseSelector = `meta[name="theme-color"][media="${media}"]`;
|
|
@@ -67,6 +74,7 @@
|
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
function syncThemeColor(theme) {
|
|
77
|
+
const themeColors = readThemeColors();
|
|
70
78
|
const color = themeColors[theme];
|
|
71
79
|
const head = document.head;
|
|
72
80
|
if (!color || !head) return;
|
|
@@ -77,23 +85,25 @@
|
|
|
77
85
|
});
|
|
78
86
|
}
|
|
79
87
|
|
|
80
|
-
const themeChoice = getStoredThemeChoice();
|
|
81
88
|
const effectiveTheme =
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
: themeChoice;
|
|
87
|
-
document.documentElement.dataset.theme = effectiveTheme;
|
|
88
|
-
document.documentElement.dataset.themeChoice = themeChoice;
|
|
89
|
-
document.documentElement.classList.toggle(
|
|
90
|
-
"dark",
|
|
91
|
-
effectiveTheme === "dark",
|
|
92
|
-
);
|
|
89
|
+
document.documentElement.dataset.theme === "light" ||
|
|
90
|
+
document.documentElement.dataset.theme === "dark"
|
|
91
|
+
? document.documentElement.dataset.theme
|
|
92
|
+
: getPreferredColorScheme();
|
|
93
93
|
syncThemeColor(effectiveTheme);
|
|
94
94
|
return {
|
|
95
|
+
refreshThemeColors(theme) {
|
|
96
|
+
const themeChoice = parseTheme(
|
|
97
|
+
theme ?? document.documentElement.dataset.themeChoice,
|
|
98
|
+
);
|
|
99
|
+
const effectiveTheme =
|
|
100
|
+
themeChoice === "auto" ? getPreferredColorScheme() : themeChoice;
|
|
101
|
+
syncThemeColor(effectiveTheme);
|
|
102
|
+
},
|
|
95
103
|
updatePickers(theme) {
|
|
96
|
-
const themeChoice = parseTheme(
|
|
104
|
+
const themeChoice = parseTheme(
|
|
105
|
+
theme ?? document.documentElement.dataset.themeChoice,
|
|
106
|
+
);
|
|
97
107
|
document
|
|
98
108
|
.querySelectorAll("starlight-theme-select")
|
|
99
109
|
.forEach((picker) => {
|
|
@@ -110,19 +120,130 @@
|
|
|
110
120
|
new CustomEvent("select:set", { detail: { value: themeChoice } }),
|
|
111
121
|
);
|
|
112
122
|
}
|
|
113
|
-
|
|
114
|
-
/** @type {HTMLTemplateElement | null} */
|
|
115
|
-
const tmpl = document.querySelector(`#theme-icons`);
|
|
116
|
-
const newIcon =
|
|
117
|
-
tmpl && tmpl.content.querySelector("." + themeChoice);
|
|
118
|
-
if (newIcon) {
|
|
119
|
-
const oldIcon = picker.querySelector("svg.label-icon");
|
|
120
|
-
if (oldIcon) {
|
|
121
|
-
oldIcon.replaceChildren(...newIcon.cloneNode(true).childNodes);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
123
|
});
|
|
125
124
|
},
|
|
126
125
|
};
|
|
127
126
|
})();
|
|
128
127
|
</script>
|
|
128
|
+
|
|
129
|
+
<script>
|
|
130
|
+
import { syncSemanticIconsInRoot } from "@bejamas/semantic-icons/browser";
|
|
131
|
+
import {
|
|
132
|
+
buildCurrentThemeStylesheetHref,
|
|
133
|
+
CURRENT_THEME_STYLESHEET_SELECTOR,
|
|
134
|
+
} from "../lib/theme-stylesheet";
|
|
135
|
+
|
|
136
|
+
const PRESET_CHANGE_EVENT = "bejamas:preset-change";
|
|
137
|
+
const SETUP_KEY = "__bejamasCurrentThemeStylesheetSync";
|
|
138
|
+
let reloadFrame = 0;
|
|
139
|
+
let reloadVersion = 0;
|
|
140
|
+
let pendingIconLibrary = null;
|
|
141
|
+
|
|
142
|
+
function syncDocumentIconLibrary() {
|
|
143
|
+
const renderedIcon = document.querySelector("[data-icon-library]");
|
|
144
|
+
const iconLibrary =
|
|
145
|
+
renderedIcon instanceof SVGElement
|
|
146
|
+
? renderedIcon.dataset.iconLibrary
|
|
147
|
+
: document.documentElement.dataset.iconLibrary;
|
|
148
|
+
|
|
149
|
+
if (iconLibrary) {
|
|
150
|
+
document.documentElement.dataset.iconLibrary = iconLibrary;
|
|
151
|
+
syncSemanticIconsInRoot(document, iconLibrary);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function refreshCurrentThemeStylesheet() {
|
|
156
|
+
const links = Array.from(
|
|
157
|
+
document.head.querySelectorAll(CURRENT_THEME_STYLESHEET_SELECTOR),
|
|
158
|
+
).filter(
|
|
159
|
+
(link): link is HTMLLinkElement => link instanceof HTMLLinkElement,
|
|
160
|
+
);
|
|
161
|
+
const currentLink = links.at(-1);
|
|
162
|
+
|
|
163
|
+
if (!currentLink) {
|
|
164
|
+
window.StarlightThemeProvider?.refreshThemeColors?.();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
reloadVersion += 1;
|
|
169
|
+
|
|
170
|
+
const replacement = currentLink.cloneNode(false) as HTMLLinkElement;
|
|
171
|
+
const sourceHref = currentLink.getAttribute("href") ?? currentLink.href;
|
|
172
|
+
replacement.href = buildCurrentThemeStylesheetHref(sourceHref, `${Date.now()}-${reloadVersion}`);
|
|
173
|
+
|
|
174
|
+
replacement.addEventListener(
|
|
175
|
+
"load",
|
|
176
|
+
() => {
|
|
177
|
+
links.forEach((link) => {
|
|
178
|
+
if (link !== replacement) {
|
|
179
|
+
link.remove();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
if (pendingIconLibrary) {
|
|
183
|
+
document.documentElement.dataset.iconLibrary = pendingIconLibrary;
|
|
184
|
+
syncSemanticIconsInRoot(document, pendingIconLibrary);
|
|
185
|
+
pendingIconLibrary = null;
|
|
186
|
+
}
|
|
187
|
+
window.StarlightThemeProvider?.refreshThemeColors?.();
|
|
188
|
+
},
|
|
189
|
+
{ once: true },
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
replacement.addEventListener(
|
|
193
|
+
"error",
|
|
194
|
+
() => {
|
|
195
|
+
replacement.remove();
|
|
196
|
+
if (pendingIconLibrary) {
|
|
197
|
+
document.documentElement.dataset.iconLibrary = pendingIconLibrary;
|
|
198
|
+
syncSemanticIconsInRoot(document, pendingIconLibrary);
|
|
199
|
+
pendingIconLibrary = null;
|
|
200
|
+
}
|
|
201
|
+
window.StarlightThemeProvider?.refreshThemeColors?.();
|
|
202
|
+
},
|
|
203
|
+
{ once: true },
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
currentLink.after(replacement);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const scheduleCurrentThemeStylesheetRefresh = () => {
|
|
210
|
+
if (reloadFrame) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
reloadFrame = requestAnimationFrame(() => {
|
|
215
|
+
reloadFrame = 0;
|
|
216
|
+
refreshCurrentThemeStylesheet();
|
|
217
|
+
});
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
if (!(SETUP_KEY in window)) {
|
|
221
|
+
window[SETUP_KEY] = true;
|
|
222
|
+
syncDocumentIconLibrary();
|
|
223
|
+
|
|
224
|
+
window.addEventListener("theme-toggle-changed", (event) => {
|
|
225
|
+
const themeChoice =
|
|
226
|
+
event instanceof CustomEvent && typeof event.detail?.themeChoice === "string"
|
|
227
|
+
? event.detail.themeChoice
|
|
228
|
+
: undefined;
|
|
229
|
+
|
|
230
|
+
window.StarlightThemeProvider?.refreshThemeColors?.(themeChoice);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
window.addEventListener(PRESET_CHANGE_EVENT, (event) => {
|
|
234
|
+
const presetChange = event;
|
|
235
|
+
|
|
236
|
+
pendingIconLibrary =
|
|
237
|
+
presetChange instanceof CustomEvent &&
|
|
238
|
+
typeof presetChange.detail?.iconLibrary === "string"
|
|
239
|
+
? presetChange.detail.iconLibrary
|
|
240
|
+
: null;
|
|
241
|
+
|
|
242
|
+
scheduleCurrentThemeStylesheetRefresh();
|
|
243
|
+
});
|
|
244
|
+
document.addEventListener("astro:after-swap", () => {
|
|
245
|
+
window.StarlightThemeProvider?.refreshThemeColors?.();
|
|
246
|
+
syncDocumentIconLibrary();
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
</script>
|
|
@@ -36,61 +36,46 @@ import { SunIcon, MoonIcon, LaptopIcon } from "@lucide/astro";
|
|
|
36
36
|
</div>
|
|
37
37
|
</starlight-theme-select>
|
|
38
38
|
|
|
39
|
-
<script is:inline>
|
|
40
|
-
StarlightThemeProvider.updatePickers();
|
|
41
|
-
</script>
|
|
42
|
-
|
|
43
39
|
<script>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const loadTheme = (): Theme =>
|
|
51
|
-
parseTheme(
|
|
52
|
-
typeof localStorage !== "undefined" && localStorage.getItem(storageKey),
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
function storeTheme(theme: Theme): void {
|
|
56
|
-
if (typeof localStorage !== "undefined") {
|
|
57
|
-
localStorage.setItem(
|
|
58
|
-
storageKey,
|
|
59
|
-
theme === "light" || theme === "dark" ? theme : "",
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
40
|
+
import {
|
|
41
|
+
getThemeChoiceFromRoot,
|
|
42
|
+
parseStoredThemeChoice,
|
|
43
|
+
setThemeChoice,
|
|
44
|
+
syncThemeChoiceControls,
|
|
45
|
+
} from "../lib/theme-choice";
|
|
63
46
|
|
|
64
|
-
|
|
65
|
-
|
|
47
|
+
class StarlightThemeSelect extends HTMLElement {
|
|
48
|
+
private selectRoot: HTMLElement | null = null;
|
|
66
49
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
document.documentElement.dataset.theme = resolved;
|
|
71
|
-
document.documentElement.dataset.themeChoice = theme;
|
|
72
|
-
document.documentElement.classList.toggle("dark", resolved === "dark");
|
|
73
|
-
storeTheme(theme);
|
|
74
|
-
}
|
|
50
|
+
private onSelectChange = (event: Event) => {
|
|
51
|
+
const value = (event as CustomEvent<{ value?: unknown }>).detail?.value;
|
|
52
|
+
if (typeof value !== "string") return;
|
|
75
53
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
54
|
+
const nextTheme = parseStoredThemeChoice(value);
|
|
55
|
+
const currentTheme = getThemeChoiceFromRoot(document.documentElement);
|
|
56
|
+
if (nextTheme === currentTheme) return;
|
|
79
57
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
58
|
+
setThemeChoice(nextTheme);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
connectedCallback() {
|
|
62
|
+
this.selectRoot = this.querySelector<HTMLElement>("#starlight-theme-select");
|
|
63
|
+
syncThemeChoiceControls(
|
|
64
|
+
getThemeChoiceFromRoot(document.documentElement),
|
|
65
|
+
this,
|
|
66
|
+
);
|
|
67
|
+
this.selectRoot?.addEventListener("select:change", this.onSelectChange);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
disconnectedCallback() {
|
|
71
|
+
this.selectRoot?.removeEventListener("select:change", this.onSelectChange);
|
|
72
|
+
this.selectRoot = null;
|
|
90
73
|
}
|
|
91
74
|
}
|
|
92
75
|
|
|
93
|
-
customElements.
|
|
76
|
+
if (!customElements.get("starlight-theme-select")) {
|
|
77
|
+
customElements.define("starlight-theme-select", StarlightThemeSelect);
|
|
78
|
+
}
|
|
94
79
|
</script>
|
|
95
80
|
|
|
96
81
|
<style>
|
package/src/styles/theme.css
CHANGED
|
@@ -49,10 +49,8 @@
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
:root {
|
|
52
|
-
--sl-font:
|
|
53
|
-
--sl-font-mono:
|
|
54
|
-
|
|
55
|
-
--radius: 0.5rem;
|
|
52
|
+
--sl-font: var(--font-sans);
|
|
53
|
+
--sl-font-mono: var(--font-mono);
|
|
56
54
|
|
|
57
55
|
--sl-color-text-accent: var(--accent-foreground);
|
|
58
56
|
|
|
@@ -74,8 +72,7 @@
|
|
|
74
72
|
|
|
75
73
|
--sl-color-gray-2: var(--input);
|
|
76
74
|
|
|
77
|
-
--default-font-family:
|
|
78
|
-
--font-sans: "Inter Variable", sans-serif;
|
|
75
|
+
--default-font-family: var(--font-sans);
|
|
79
76
|
|
|
80
77
|
--sl-color-bg: var(--background);
|
|
81
78
|
--sl-color-fg: var(--foreground);
|
|
@@ -107,6 +104,7 @@
|
|
|
107
104
|
--ec-frm-inlBtnBgHoverOrFocusOpa: 1;
|
|
108
105
|
--ec-frm-trmIcon: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBMaWNlbnNlOiBNSVQuIE1hZGUgYnkgcGhvc3Bob3I6IGh0dHBzOi8vZ2l0aHViLmNvbS9waG9zcGhvci1pY29ucy9waG9zcGhvci1pY29ucyAtLT4KPHN2ZyBmaWxsPSIjMDAwMDAwIiB3aWR0aD0iMThweCIgaGVpZ2h0PSIxOHB4IiB2aWV3Qm94PSIwIDAgMjU2IDI1NiIgaWQ9IkZsYXQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTExNy4zMTQ5NCwxMzMuOTc5NDlsLTcyLDY0YTguMDAwMTgsOC4wMDAxOCwwLDAsMS0xMC42Mjk4OC0xMS45NTlMOTkuOTU4NSwxMjgsMzQuNjg1MDYsNjkuOTc5NDlhOC4wMDAxOCw4LjAwMDE4LDAsMCwxLDEwLjYyOTg4LTExLjk1OWw3Miw2NGE4LjAwMDU0LDguMDAwNTQsMCwwLDEsMCwxMS45NTlaTTIxNS45OTQxNCwxODRoLTk2YTgsOCwwLDAsMCwwLDE2aDk2YTgsOCwwLDEsMCwwLTE2WiIvPgo8L3N2Zz4=);
|
|
109
106
|
--ecGtrBrdWd: 5px;
|
|
107
|
+
--sl-nav-gap: calc(var(--spacing) * 2);
|
|
110
108
|
}
|
|
111
109
|
|
|
112
110
|
@media (min-width: 50em) {
|
|
@@ -284,15 +282,6 @@
|
|
|
284
282
|
padding-inline: 0.5rem;
|
|
285
283
|
}
|
|
286
284
|
|
|
287
|
-
site-search button {
|
|
288
|
-
--tw-shadow: var(--shadow-sm);
|
|
289
|
-
box-shadow:
|
|
290
|
-
var(--tw-inset-shadow), var(--tw-inset-ring-shadow),
|
|
291
|
-
var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
|
292
|
-
border-radius: calc(var(--radius) - 2px);
|
|
293
|
-
max-width: calc(var(--spacing) * 50);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
285
|
.expressive-code .frame {
|
|
297
286
|
box-shadow: none;
|
|
298
287
|
}
|
|
@@ -421,21 +410,6 @@
|
|
|
421
410
|
text-underline-offset: 4px;
|
|
422
411
|
}
|
|
423
412
|
|
|
424
|
-
button[data-open-modal] {
|
|
425
|
-
border-color: var(--border);
|
|
426
|
-
color: var(--foreground);
|
|
427
|
-
box-shadow: none;
|
|
428
|
-
background-color: var(--background);
|
|
429
|
-
height: calc(var(--spacing) * 9);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
button[data-open-modal] kbd {
|
|
433
|
-
background-color: var(--muted);
|
|
434
|
-
color: var(--muted-foreground);
|
|
435
|
-
border-radius: calc(var(--radius) - 4px);
|
|
436
|
-
gap: 0;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
413
|
.tablist-wrapper ~ [role="tabpanel"] {
|
|
440
414
|
margin-top: calc(var(--spacing) * 2.5);
|
|
441
415
|
}
|