@askrjs/themes 0.0.17 → 0.0.20

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.
@@ -1,6 +1,6 @@
1
1
  import type { JSX } from "@askrjs/askr/jsx-runtime";
2
2
  import { defineScope, getSignal, readScope, state } from "@askrjs/askr";
3
- import type { JSXElement } from "@askrjs/askr/foundations/structures";
3
+ import { cloneElement, isElement, type JSXElement } from "@askrjs/askr/foundations/structures";
4
4
  import { Button } from "@askrjs/ui";
5
5
  import type { ButtonNativeProps, PressEvent } from "@askrjs/ui";
6
6
 
@@ -67,7 +67,6 @@ export const CAT_THEME_OPTIONS: readonly ThemeOption[] = [
67
67
 
68
68
  const DEFAULT_STORAGE_KEY = "askr-theme";
69
69
  const STATIC_CHILDREN = Symbol.for("askr.static-children");
70
- const STATIC_CHILD_SLOTS_CACHE = Symbol.for("__askrStaticChildSlots");
71
70
  const documentThemeCoordinators = new WeakMap<Document, ThemeCoordinator>();
72
71
  type ThemeCoordinator = ReturnType<typeof createThemeCoordinator>;
73
72
  type InternalThemeScopeValue = ThemeScopeValue & {
@@ -110,7 +109,9 @@ export function ThemeScope(props: ThemeScopeProps): JSX.Element {
110
109
  const ownedCoordinator = state<ThemeCoordinator>(getDefaultThemeCoordinator())();
111
110
  const coordinator = parentScope.coordinator ?? ownedCoordinator;
112
111
  const scopeDepth = parentScope.depth + 1;
113
- coordinator.register(scopeId, scopeDepth, currentTheme, scopeSignal);
112
+ coordinator.register(scopeId, scopeDepth, currentTheme, scopeSignal, (nextTheme) => {
113
+ if (themeState() !== nextTheme) themeState.set(nextTheme);
114
+ });
114
115
 
115
116
  const setTheme = (nextTheme: ThemeName) => {
116
117
  themeState.set(nextTheme);
@@ -143,6 +144,29 @@ export function ThemeScope(props: ThemeScopeProps): JSX.Element {
143
144
  (nextTheme) => localResolvedSystemTheme.set(nextTheme),
144
145
  scopeSignal,
145
146
  );
147
+ if (element && typeof window !== "undefined") {
148
+ const onStorage = (event: StorageEvent) => {
149
+ let storageMatches = event.storageArea == null;
150
+ try {
151
+ storageMatches ||= event.storageArea === window.localStorage;
152
+ } catch {
153
+ // Locked-down/private contexts may deny access to localStorage.
154
+ }
155
+ if (!storageMatches || event.key !== storageKey) {
156
+ return;
157
+ }
158
+ const nextTheme = event.newValue as ThemeName | null;
159
+ if (!nextTheme) return;
160
+ themeState.set(nextTheme);
161
+ coordinator.activate(scopeId, nextTheme);
162
+ };
163
+ window.addEventListener("storage", onStorage);
164
+ scopeSignal.addEventListener(
165
+ "abort",
166
+ () => window.removeEventListener("storage", onStorage),
167
+ { once: true },
168
+ );
169
+ }
146
170
  if (!element || persistenceAdoption.complete) return;
147
171
  persistenceAdoption.complete = true;
148
172
  const storedTheme = readStoredTheme(storageKey);
@@ -173,6 +197,20 @@ function getDefaultThemeCoordinator(): ThemeCoordinator {
173
197
  return coordinator;
174
198
  }
175
199
 
200
+ function removeEmptyGeneratedStyleRegistries(root: Node | null): void {
201
+ const ownerDocument =
202
+ root?.nodeType === 9
203
+ ? (root as Document)
204
+ : (root?.ownerDocument ?? (typeof document === "undefined" ? null : document));
205
+ if (!ownerDocument) return;
206
+
207
+ for (const registry of ownerDocument.querySelectorAll<HTMLStyleElement>(
208
+ "style[data-askr-style-registry]",
209
+ )) {
210
+ if (!registry.textContent?.trim()) registry.remove();
211
+ }
212
+ }
213
+
176
214
  function createThemeCoordinator() {
177
215
  const scopes = new Map<
178
216
  symbol,
@@ -181,6 +219,7 @@ function createThemeCoordinator() {
181
219
  sequence: number;
182
220
  theme: ThemeName;
183
221
  signal: AbortSignal;
222
+ onThemeChange: (themeName: ThemeName) => void;
184
223
  }
185
224
  >();
186
225
  let nextSequence = 0;
@@ -213,6 +252,7 @@ function createThemeCoordinator() {
213
252
  setTimeout(() => {
214
253
  scheduled = false;
215
254
  syncActive();
255
+ if (scopes.size === 0) removeEmptyGeneratedStyleRegistries(root);
216
256
  }, 0);
217
257
  };
218
258
 
@@ -234,13 +274,20 @@ function createThemeCoordinator() {
234
274
  }
235
275
  schedule();
236
276
  },
237
- register(id: symbol, depth: number, themeName: ThemeName, signal: AbortSignal) {
277
+ register(
278
+ id: symbol,
279
+ depth: number,
280
+ themeName: ThemeName,
281
+ signal: AbortSignal,
282
+ onThemeChange: (themeName: ThemeName) => void,
283
+ ) {
238
284
  const existing = scopes.get(id);
239
285
  scopes.set(id, {
240
286
  depth,
241
287
  sequence: existing?.sequence ?? nextSequence++,
242
288
  theme: themeName,
243
289
  signal,
290
+ onThemeChange,
244
291
  });
245
292
  if (!existing) {
246
293
  signal.addEventListener(
@@ -259,6 +306,13 @@ function createThemeCoordinator() {
259
306
  const scope = scopes.get(id);
260
307
  if (scope) scope.theme = themeName;
261
308
  explicitOwner = id;
309
+ const ownerDepth = scope?.depth;
310
+ for (const [scopeId, registered] of scopes) {
311
+ if (scopeId !== id && registered.depth === ownerDepth) {
312
+ registered.theme = themeName;
313
+ registered.onThemeChange(themeName);
314
+ }
315
+ }
262
316
  syncThemeTarget(target(), themeName);
263
317
  },
264
318
  });
@@ -422,16 +476,6 @@ function renderThemeToggleIconSlots(
422
476
  ));
423
477
  }
424
478
 
425
- function isJSXElement(value: unknown): value is JSXElement {
426
- return (
427
- typeof value === "object" &&
428
- value !== null &&
429
- "$$typeof" in value &&
430
- "type" in value &&
431
- "props" in value
432
- );
433
- }
434
-
435
479
  function cloneThemeToggleIcon(icon: unknown, key?: string): unknown {
436
480
  if (Array.isArray(icon)) {
437
481
  const clonedChildren = icon.map((child) => cloneThemeToggleIcon(child));
@@ -444,7 +488,7 @@ function cloneThemeToggleIcon(icon: unknown, key?: string): unknown {
444
488
  return clonedChildren;
445
489
  }
446
490
 
447
- if (!isJSXElement(icon)) return icon;
491
+ if (!isElement(icon)) return icon;
448
492
 
449
493
  const props = icon.props as Record<string, unknown> | undefined;
450
494
  const clonedProps = props ? { ...props } : {};
@@ -453,15 +497,11 @@ function cloneThemeToggleIcon(icon: unknown, key?: string): unknown {
453
497
  clonedProps.children = cloneThemeToggleIcon(clonedProps.children);
454
498
  }
455
499
 
456
- const iconKey = (icon.key ?? key ?? null) as string | number | null;
457
- const clonedIcon = {
458
- ...icon,
459
- key: iconKey,
460
- props: clonedProps,
461
- };
462
-
463
- delete (clonedIcon as Record<symbol, unknown>)[STATIC_CHILD_SLOTS_CACHE];
464
- return clonedIcon;
500
+ const clonedIcon = cloneElement(icon, clonedProps);
501
+ return {
502
+ ...clonedIcon,
503
+ key: icon.key ?? key ?? null,
504
+ } satisfies JSXElement;
465
505
  }
466
506
 
467
507
  function syncThemeTarget(
package/src/ssr.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { styleRulesForHtml } from "./components/_internal/style";
2
-
3
1
  const STYLE_REGISTRY_ATTR = "data-askr-style-registry";
2
+ const STYLE_CLASS_PREFIX = "ak-style-";
3
+ const CLASS_ATTRIBUTE_PATTERN = /\sclass=(?:"([^"]*)"|'([^']*)')/g;
4
+ const MAX_STYLE_RULES = 512;
4
5
 
5
6
  type DocumentRenderArgsLike = {
6
7
  appHtml: string;
@@ -22,6 +23,16 @@ function escapeStyleRawText(value: string): string {
22
23
  return value.replace(/<\/style/gi, "<\\/style");
23
24
  }
24
25
 
26
+ function generatedStyleClasses(html: string): Set<string> {
27
+ const classes = new Set<string>();
28
+ for (const attribute of html.matchAll(CLASS_ATTRIBUTE_PATTERN)) {
29
+ for (const className of (attribute[1] ?? attribute[2] ?? "").split(/\s+/)) {
30
+ if (className.startsWith(STYLE_CLASS_PREFIX)) classes.add(className);
31
+ }
32
+ }
33
+ return classes;
34
+ }
35
+
25
36
  function findHeadEnd(html: string): number {
26
37
  let index = 0;
27
38
  while (index < html.length) {
@@ -72,24 +83,38 @@ export function withThemeStyles<TArgs extends DocumentRenderArgsLike>(
72
83
  return (args) => {
73
84
  const documentHtml = documentRenderer(args);
74
85
  const registeredStyles = args.context.styles;
75
- const rules = registeredStyles
76
- ? (() => {
77
- const styles = new Map<string, string>();
78
- for (const style of registeredStyles) {
79
- if (!style.id || !style.cssText) {
80
- throw new TypeError("Invalid SSR theme style registration.");
81
- }
82
- const existing = styles.get(style.id);
83
- if (existing !== undefined && existing !== style.cssText) {
84
- throw new RangeError(
85
- `SSR style registration collision for ${JSON.stringify(style.id)}.`,
86
- );
87
- }
88
- styles.set(style.id, style.cssText);
89
- }
90
- return Array.from(styles.values());
91
- })()
92
- : styleRulesForHtml(args.appHtml);
86
+ const generatedClasses = generatedStyleClasses(documentHtml);
87
+ if (registeredStyles === undefined) {
88
+ if (generatedClasses.size > 0) {
89
+ throw new Error(
90
+ "Generated theme classes require request-local SSR style registrations from @askrjs/askr.",
91
+ );
92
+ }
93
+ return documentHtml;
94
+ }
95
+
96
+ const styles = new Map<string, string>();
97
+ for (const style of registeredStyles) {
98
+ if (!style.id || !style.cssText) {
99
+ throw new TypeError("Invalid SSR theme style registration.");
100
+ }
101
+ const existing = styles.get(style.id);
102
+ if (existing !== undefined && existing !== style.cssText) {
103
+ throw new RangeError(`SSR style registration collision for ${JSON.stringify(style.id)}.`);
104
+ }
105
+ styles.set(style.id, style.cssText);
106
+ if (styles.size > MAX_STYLE_RULES) {
107
+ throw new RangeError("Theme style registry capacity exceeded.");
108
+ }
109
+ }
110
+ for (const className of generatedClasses) {
111
+ if (!styles.has(className)) {
112
+ throw new Error(
113
+ `Missing request-local SSR style registration for ${JSON.stringify(className)}.`,
114
+ );
115
+ }
116
+ }
117
+ const rules = Array.from(styles.values());
93
118
  if (rules.length === 0) return documentHtml;
94
119
 
95
120
  const nonce =
@@ -6,8 +6,10 @@
6
6
  }
7
7
 
8
8
  :where([data-slot="theme-picker"]) {
9
+ appearance: none;
9
10
  display: inline-flex;
10
- min-block-size: var(--ak-density-control-height-md);
11
+ block-size: var(--ak-density-control-height-md, 2.25rem);
12
+ min-block-size: var(--ak-density-control-height-md, 2.25rem);
11
13
  min-inline-size: min(8rem, 100%);
12
14
  max-inline-size: 100%;
13
15
  padding-inline: var(--ak-density-control-padding-x-md) 2.1rem;
@@ -24,17 +24,21 @@
24
24
  }
25
25
 
26
26
  :where([data-slot="nav-group-label"]) {
27
+ max-inline-size: 100%;
27
28
  margin: 0;
28
29
  color: var(--ak-color-text-muted);
29
30
  font-size: var(--ak-font-size-xs);
30
31
  font-weight: var(--ak-font-weight-medium);
31
32
  line-height: var(--ak-line-height-tight);
33
+ overflow-wrap: anywhere;
32
34
  }
33
35
 
34
36
  :where([data-slot="nav-item"]) {
37
+ max-inline-size: 100%;
35
38
  min-block-size: 2rem;
36
39
  color: var(--ak-color-text-muted);
37
40
  text-decoration: none;
41
+ white-space: normal;
38
42
  transition:
39
43
  background var(--ak-duration-fast) var(--ak-ease-standard),
40
44
  color var(--ak-duration-fast) var(--ak-ease-standard);
@@ -158,7 +158,7 @@
158
158
  --ak-section-3: var(--ak-space-6);
159
159
  --ak-section-4: var(--ak-space-7);
160
160
 
161
- --ak-z-dropdown: 1000;
161
+ --ak-z-dropdown: 1500;
162
162
  --ak-z-index-base: auto;
163
163
  --ak-z-index-sm: 10;
164
164
  --ak-z-index-md: 100;
@@ -158,7 +158,7 @@
158
158
  --ak-section-3: var(--ak-space-6);
159
159
  --ak-section-4: var(--ak-space-7);
160
160
 
161
- --ak-z-dropdown: 1000;
161
+ --ak-z-dropdown: 1500;
162
162
  --ak-z-index-base: auto;
163
163
  --ak-z-index-sm: 10;
164
164
  --ak-z-index-md: 100;