@pyreon/ui-core 0.11.2 → 0.11.4

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/lib/index.d.ts CHANGED
@@ -225,9 +225,10 @@ interface PyreonUIProps {
225
225
  theme: PyreonTheme;
226
226
  /**
227
227
  * Color mode: "light", "dark", or "system" (follows OS preference).
228
+ * Can be a signal or getter for reactive mode switching.
228
229
  * @default "light"
229
230
  */
230
- mode?: ThemeModeInput | undefined;
231
+ mode?: ThemeModeInput | (() => ThemeModeInput) | undefined;
231
232
  /** Flip mode for a nested section (e.g. dark sidebar in light app). */
232
233
  inversed?: boolean | undefined;
233
234
  children?: VNodeChild;
@@ -247,11 +248,17 @@ declare function useMode(): ThemeMode;
247
248
  * Replaces the need for separate UnistyleProvider, RocketstyleProvider,
248
249
  * and ThemeProvider — one component, zero init.
249
250
  *
250
- * @example
251
+ * Mode can be a static string OR a signal/getter for reactive switching:
251
252
  * ```tsx
252
- * <PyreonUI theme={{ rootSize: 16, breakpoints: { xs: 0, sm: 576, md: 768 } }} mode="system">
253
- * <App />
254
- * </PyreonUI>
253
+ * // Static
254
+ * <PyreonUI theme={theme} mode="dark">
255
+ *
256
+ * // Reactive signal
257
+ * const mode = signal<ThemeModeInput>("light")
258
+ * <PyreonUI theme={theme} mode={mode}>
259
+ *
260
+ * // System (follows OS preference)
261
+ * <PyreonUI theme={theme} mode="system">
255
262
  * ```
256
263
  */
257
264
  declare function PyreonUI({
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ThemeContext, css, keyframes, styled } from "@pyreon/styler";
2
2
  import { createContext, h, provide, useContext } from "@pyreon/core";
3
- import { signal } from "@pyreon/reactivity";
3
+ import { computed, signal } from "@pyreon/reactivity";
4
4
  import { enrichTheme } from "@pyreon/unistyle";
5
5
 
6
6
  //#region src/compose.ts
@@ -283,11 +283,12 @@ function getSystemMode() {
283
283
  if (_systemMode) return _systemMode;
284
284
  _systemMode = signal(_isBrowser && matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
285
285
  if (_isBrowser) matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
286
- _systemMode.set(e.matches ? "dark" : "light");
286
+ _systemMode?.set(e.matches ? "dark" : "light");
287
287
  });
288
288
  return _systemMode;
289
289
  }
290
- const ModeContext = createContext("light");
290
+ /** Context value is a getter — consumers call it to read the current mode reactively. */
291
+ const ModeContext = createContext(() => "light");
291
292
  const INVERSED = {
292
293
  light: "dark",
293
294
  dark: "light"
@@ -301,14 +302,9 @@ const INVERSED = {
301
302
  * const mode = useMode() // "light" | "dark"
302
303
  */
303
304
  function useMode() {
304
- return useContext(ModeContext);
305
+ return useContext(ModeContext)();
305
306
  }
306
307
  let _autoInitDone = false;
307
- /**
308
- * Ensure the CSS engine is initialized. If init() was called manually,
309
- * this is a no-op. Otherwise, imports @pyreon/styler defaults.
310
- * Called once on first PyreonUI mount.
311
- */
312
308
  function autoInit() {
313
309
  if (_autoInitDone) return;
314
310
  _autoInitDone = true;
@@ -319,28 +315,42 @@ function autoInit() {
319
315
  * Replaces the need for separate UnistyleProvider, RocketstyleProvider,
320
316
  * and ThemeProvider — one component, zero init.
321
317
  *
322
- * @example
318
+ * Mode can be a static string OR a signal/getter for reactive switching:
323
319
  * ```tsx
324
- * <PyreonUI theme={{ rootSize: 16, breakpoints: { xs: 0, sm: 576, md: 768 } }} mode="system">
325
- * <App />
326
- * </PyreonUI>
320
+ * // Static
321
+ * <PyreonUI theme={theme} mode="dark">
322
+ *
323
+ * // Reactive signal
324
+ * const mode = signal<ThemeModeInput>("light")
325
+ * <PyreonUI theme={theme} mode={mode}>
326
+ *
327
+ * // System (follows OS preference)
328
+ * <PyreonUI theme={theme} mode="system">
327
329
  * ```
328
330
  */
329
331
  function PyreonUI({ theme, mode = "light", inversed, children }) {
330
332
  autoInit();
331
- let resolvedMode;
332
- if (mode === "system") resolvedMode = getSystemMode()();
333
- else resolvedMode = mode;
334
- if (inversed) resolvedMode = INVERSED[resolvedMode];
333
+ const resolveMode = () => {
334
+ const raw = typeof mode === "function" ? mode() : mode;
335
+ const resolved = raw === "system" ? getSystemMode()() : raw;
336
+ return inversed ? INVERSED[resolved] : resolved;
337
+ };
338
+ const modeComputed = computed(resolveMode);
335
339
  const enrichedTheme = enrichTheme(theme);
336
340
  provide(ThemeContext, enrichedTheme);
337
341
  provide(context, {
338
342
  theme: enrichedTheme,
339
- mode: resolvedMode,
340
- isDark: resolvedMode === "dark",
341
- isLight: resolvedMode === "light"
343
+ get mode() {
344
+ return modeComputed();
345
+ },
346
+ get isDark() {
347
+ return modeComputed() === "dark";
348
+ },
349
+ get isLight() {
350
+ return modeComputed() === "light";
351
+ }
342
352
  });
343
- provide(ModeContext, resolvedMode);
353
+ provide(ModeContext, () => modeComputed());
344
354
  return children ?? null;
345
355
  }
346
356
 
@@ -351,7 +361,13 @@ const render = (content, attachProps) => {
351
361
  const t = typeof content;
352
362
  if (t === "string" || t === "number" || t === "boolean" || t === "bigint") return content;
353
363
  if (Array.isArray(content)) return content;
354
- if (typeof content === "function") return h(content, attachProps ?? {});
364
+ if (typeof content === "function") {
365
+ if (attachProps && "key" in attachProps) {
366
+ const { key, ...rest } = attachProps;
367
+ return h(content, rest);
368
+ }
369
+ return h(content, attachProps ?? {});
370
+ }
355
371
  if (typeof content === "object") return content;
356
372
  return content;
357
373
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/ui-core",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/pyreon/pyreon",
@@ -40,13 +40,13 @@
40
40
  "typecheck": "tsc --noEmit"
41
41
  },
42
42
  "peerDependencies": {
43
- "@pyreon/core": "^0.11.2",
44
- "@pyreon/styler": "^0.11.2",
45
- "@pyreon/reactivity": "^0.11.2",
46
- "@pyreon/unistyle": "^0.11.2"
43
+ "@pyreon/core": "^0.11.4",
44
+ "@pyreon/styler": "^0.11.4",
45
+ "@pyreon/reactivity": "^0.11.4",
46
+ "@pyreon/unistyle": "^0.11.4"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@vitus-labs/tools-rolldown": "^1.15.3",
50
- "@pyreon/typescript": "^0.11.2"
50
+ "@pyreon/typescript": "^0.11.4"
51
51
  }
52
52
  }
package/src/PyreonUI.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { VNodeChild } from "@pyreon/core"
2
2
  import { createContext, provide, useContext } from "@pyreon/core"
3
- import { signal } from "@pyreon/reactivity"
3
+ import { computed, signal } from "@pyreon/reactivity"
4
4
  import { ThemeContext } from "@pyreon/styler"
5
5
  import type { PyreonTheme } from "@pyreon/unistyle"
6
6
  import { enrichTheme } from "@pyreon/unistyle"
@@ -16,9 +16,10 @@ export interface PyreonUIProps {
16
16
  theme: PyreonTheme
17
17
  /**
18
18
  * Color mode: "light", "dark", or "system" (follows OS preference).
19
+ * Can be a signal or getter for reactive mode switching.
19
20
  * @default "light"
20
21
  */
21
- mode?: ThemeModeInput | undefined
22
+ mode?: ThemeModeInput | (() => ThemeModeInput) | undefined
22
23
  /** Flip mode for a nested section (e.g. dark sidebar in light app). */
23
24
  inversed?: boolean | undefined
24
25
  children?: VNodeChild
@@ -39,7 +40,7 @@ function getSystemMode(): ReturnType<typeof signal<ThemeMode>> {
39
40
 
40
41
  if (_isBrowser) {
41
42
  matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
42
- _systemMode!.set(e.matches ? "dark" : "light")
43
+ _systemMode?.set(e.matches ? "dark" : "light")
43
44
  })
44
45
  }
45
46
 
@@ -48,7 +49,8 @@ function getSystemMode(): ReturnType<typeof signal<ThemeMode>> {
48
49
 
49
50
  // ─── Mode context ───────────────────────────────────────────────────────────
50
51
 
51
- const ModeContext = createContext<ThemeMode>("light")
52
+ /** Context value is a getter — consumers call it to read the current mode reactively. */
53
+ const ModeContext = createContext<() => ThemeMode>(() => "light")
52
54
 
53
55
  const INVERSED: Record<ThemeMode, ThemeMode> = { light: "dark", dark: "light" }
54
56
 
@@ -61,26 +63,16 @@ const INVERSED: Record<ThemeMode, ThemeMode> = { light: "dark", dark: "light" }
61
63
  * const mode = useMode() // "light" | "dark"
62
64
  */
63
65
  export function useMode(): ThemeMode {
64
- return useContext(ModeContext)
66
+ return useContext(ModeContext)()
65
67
  }
66
68
 
67
69
  // ─── Auto-init ──────────────────────────────────────────────────────────────
68
70
 
69
71
  let _autoInitDone = false
70
72
 
71
- /**
72
- * Ensure the CSS engine is initialized. If init() was called manually,
73
- * this is a no-op. Otherwise, imports @pyreon/styler defaults.
74
- * Called once on first PyreonUI mount.
75
- */
76
73
  function autoInit(): void {
77
74
  if (_autoInitDone) return
78
75
  _autoInitDone = true
79
-
80
- // config already has styler defaults from the import in config.ts,
81
- // so no lazy import needed — the CSS engine is ready.
82
- // If the user called init() with a custom engine, those values are
83
- // already set and we respect them.
84
76
  }
85
77
 
86
78
  // ─── PyreonUI ───────────────────────────────────────────────────────────────
@@ -91,28 +83,33 @@ function autoInit(): void {
91
83
  * Replaces the need for separate UnistyleProvider, RocketstyleProvider,
92
84
  * and ThemeProvider — one component, zero init.
93
85
  *
94
- * @example
86
+ * Mode can be a static string OR a signal/getter for reactive switching:
95
87
  * ```tsx
96
- * <PyreonUI theme={{ rootSize: 16, breakpoints: { xs: 0, sm: 576, md: 768 } }} mode="system">
97
- * <App />
98
- * </PyreonUI>
88
+ * // Static
89
+ * <PyreonUI theme={theme} mode="dark">
90
+ *
91
+ * // Reactive signal
92
+ * const mode = signal<ThemeModeInput>("light")
93
+ * <PyreonUI theme={theme} mode={mode}>
94
+ *
95
+ * // System (follows OS preference)
96
+ * <PyreonUI theme={theme} mode="system">
99
97
  * ```
100
98
  */
101
99
  export function PyreonUI({ theme, mode = "light", inversed, children }: PyreonUIProps): VNodeChild {
102
100
  autoInit()
103
101
 
104
- // Resolve mode: "system" track OS preference, "light"/"dark" use directly
105
- let resolvedMode: ThemeMode
106
- if (mode === "system") {
107
- resolvedMode = getSystemMode()()
108
- } else {
109
- resolvedMode = mode
102
+ // Create a reactive mode getter that resolves "system" and applies inversion.
103
+ // This getter is provided via context — consumers read it lazily in their
104
+ // own reactive scopes, so mode changes propagate automatically.
105
+ const resolveMode = (): ThemeMode => {
106
+ const raw = typeof mode === "function" ? mode() : mode
107
+ const resolved = raw === "system" ? getSystemMode()() : raw
108
+ return inversed ? INVERSED[resolved] : resolved
110
109
  }
111
110
 
112
- // Apply inversion for nested dark/light sections
113
- if (inversed) {
114
- resolvedMode = INVERSED[resolvedMode]
115
- }
111
+ // Wrap in computed for memoization
112
+ const modeComputed = computed(resolveMode)
116
113
 
117
114
  // Enrich theme with responsive utilities (__PYREON__)
118
115
  const enrichedTheme = enrichTheme(theme)
@@ -122,17 +119,24 @@ export function PyreonUI({ theme, mode = "light", inversed, children }: PyreonUI
122
119
  // 1. Styler ThemeContext — for styled() components and useTheme()
123
120
  provide(ThemeContext, enrichedTheme)
124
121
 
125
- // 2. Core context — for elements, attrs, coolgrid, rocketstyle
126
- // Includes mode + isDark/isLight for rocketstyle dimension resolution
122
+ // 2. Core context — provide a reactive object with getters.
123
+ // Rocketstyle reads mode/isDark/isLight from this context.
124
+ // By providing getters, the values update when modeComputed changes.
127
125
  provide(coreContext, {
128
126
  theme: enrichedTheme,
129
- mode: resolvedMode,
130
- isDark: resolvedMode === "dark",
131
- isLight: resolvedMode === "light",
127
+ get mode() {
128
+ return modeComputed()
129
+ },
130
+ get isDark() {
131
+ return modeComputed() === "dark"
132
+ },
133
+ get isLight() {
134
+ return modeComputed() === "light"
135
+ },
132
136
  })
133
137
 
134
- // 3. Mode context — for useMode() hook
135
- provide(ModeContext, resolvedMode)
138
+ // 3. Mode context — getter function for useMode()
139
+ provide(ModeContext, () => modeComputed())
136
140
 
137
141
  return children ?? null
138
142
  }
@@ -38,11 +38,16 @@ describe("PyreonUI", () => {
38
38
  it("defaults mode to light", () => {
39
39
  PyreonUI({ theme, children: null })
40
40
 
41
+ // Core context (2nd call) — has getter properties for reactive mode
41
42
  const coreCtx = getProvideValue(1)
42
43
  expect(coreCtx.mode).toBe("light")
43
44
  expect(coreCtx.isLight).toBe(true)
44
45
  expect(coreCtx.isDark).toBe(false)
45
- expect(getProvideValue(2)).toBe("light")
46
+
47
+ // Mode context (3rd call) — getter function
48
+ const modeGetter = getProvideValue(2)
49
+ expect(typeof modeGetter).toBe("function")
50
+ expect(modeGetter()).toBe("light")
46
51
  })
47
52
 
48
53
  it("provides dark mode", () => {
@@ -52,17 +57,19 @@ describe("PyreonUI", () => {
52
57
  expect(coreCtx.mode).toBe("dark")
53
58
  expect(coreCtx.isDark).toBe(true)
54
59
  expect(coreCtx.isLight).toBe(false)
55
- expect(getProvideValue(2)).toBe("dark")
60
+
61
+ const modeGetter = getProvideValue(2)
62
+ expect(modeGetter()).toBe("dark")
56
63
  })
57
64
 
58
65
  it("inverts mode when inversed=true", () => {
59
66
  PyreonUI({ theme, mode: "light", inversed: true, children: null })
60
- expect(getProvideValue(2)).toBe("dark")
67
+ expect(getProvideValue(2)()).toBe("dark")
61
68
  })
62
69
 
63
70
  it("inverts dark to light", () => {
64
71
  PyreonUI({ theme, mode: "dark", inversed: true, children: null })
65
- expect(getProvideValue(2)).toBe("light")
72
+ expect(getProvideValue(2)()).toBe("light")
66
73
  })
67
74
 
68
75
  it("enriches theme with __PYREON__ before providing", () => {
@@ -76,6 +83,15 @@ describe("PyreonUI", () => {
76
83
 
77
84
  it("works with system mode (resolves to light in happy-dom)", () => {
78
85
  PyreonUI({ theme, mode: "system", children: null })
79
- expect(getProvideValue(2)).toBe("light")
86
+ expect(getProvideValue(2)()).toBe("light")
87
+ })
88
+
89
+ it("mode context is a getter function (reactive-ready)", () => {
90
+ PyreonUI({ theme, mode: "dark", children: null })
91
+ const modeGetter = getProvideValue(2)
92
+ // Mode context is a function, not a static value — consumers call it
93
+ // inside their own reactive scopes for reactive mode switching.
94
+ expect(typeof modeGetter).toBe("function")
95
+ expect(modeGetter()).toBe("dark")
80
96
  })
81
97
  })
package/src/render.tsx CHANGED
@@ -30,6 +30,12 @@ const render: Render = (content, attachProps) => {
30
30
  }
31
31
 
32
32
  if (typeof content === "function") {
33
+ // Extract key from props — it's a VNode concept, not a component prop.
34
+ // Passing key inside props causes JSX runtime warnings.
35
+ if (attachProps && "key" in attachProps) {
36
+ const { key, ...rest } = attachProps
37
+ return h(content as string | ComponentFn, rest as Props)
38
+ }
33
39
  return h(content as string | ComponentFn, (attachProps ?? {}) as Props)
34
40
  }
35
41