@pyreon/ui-core 0.24.1 → 0.24.3
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 +30 -4
- package/lib/index.js +6 -1
- package/package.json +6 -6
- package/src/PyreonUI.tsx +54 -5
- package/src/__tests__/PyreonUI-inheritance.test.tsx +157 -0
package/lib/index.d.ts
CHANGED
|
@@ -234,15 +234,41 @@ declare const isEqual: (a: unknown, b: unknown) => boolean;
|
|
|
234
234
|
type ThemeMode = 'light' | 'dark';
|
|
235
235
|
type ThemeModeInput = ThemeMode | 'system';
|
|
236
236
|
interface PyreonUIProps {
|
|
237
|
-
/**
|
|
238
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Theme object with breakpoints, rootSize, and custom keys.
|
|
239
|
+
*
|
|
240
|
+
* Optional — when omitted, the theme is INHERITED from the nearest
|
|
241
|
+
* ancestor PyreonUI. This makes nested `<PyreonUI inversed>` work
|
|
242
|
+
* without re-passing the theme:
|
|
243
|
+
*
|
|
244
|
+
* ```tsx
|
|
245
|
+
* <PyreonUI theme={appTheme}>
|
|
246
|
+
* <Header />
|
|
247
|
+
* <PyreonUI inversed> // inherits appTheme, just flips mode
|
|
248
|
+
* <DarkSidebar />
|
|
249
|
+
* </PyreonUI>
|
|
250
|
+
* </PyreonUI>
|
|
251
|
+
* ```
|
|
252
|
+
*
|
|
253
|
+
* At the OUTERMOST PyreonUI with no ancestor, the provided ThemeContext
|
|
254
|
+
* value falls back to the default `{}` (styled components see fields as
|
|
255
|
+
* undefined; no crash). Pass a real theme at the outermost PyreonUI to
|
|
256
|
+
* avoid that.
|
|
257
|
+
*/
|
|
258
|
+
theme?: PyreonTheme | undefined;
|
|
239
259
|
/**
|
|
240
260
|
* Color mode: "light", "dark", or "system" (follows OS preference).
|
|
241
261
|
* Can be a signal or getter for reactive mode switching.
|
|
242
|
-
*
|
|
262
|
+
*
|
|
263
|
+
* When omitted, mode is INHERITED from the nearest ancestor PyreonUI
|
|
264
|
+
* (or `'light'` at the root).
|
|
243
265
|
*/
|
|
244
266
|
mode?: ThemeModeInput | (() => ThemeModeInput) | undefined;
|
|
245
|
-
/**
|
|
267
|
+
/**
|
|
268
|
+
* Flip mode for a nested section (e.g. dark sidebar in light app).
|
|
269
|
+
* Scoped — only affects DESCENDANTS of this PyreonUI; ancestors and
|
|
270
|
+
* siblings see the original mode unchanged.
|
|
271
|
+
*/
|
|
246
272
|
inversed?: boolean | undefined;
|
|
247
273
|
children?: VNodeChild;
|
|
248
274
|
}
|
package/lib/index.js
CHANGED
|
@@ -347,6 +347,7 @@ function autoInit() {
|
|
|
347
347
|
function PyreonUI(props) {
|
|
348
348
|
autoInit();
|
|
349
349
|
const parentModeAccessor = useContext(ModeContext);
|
|
350
|
+
const parentThemeAccessor = useContext(ThemeContext);
|
|
350
351
|
const resolveMode = () => {
|
|
351
352
|
const mode = props.mode;
|
|
352
353
|
let resolved;
|
|
@@ -358,7 +359,11 @@ function PyreonUI(props) {
|
|
|
358
359
|
return props.inversed ? INVERSED[resolved] : resolved;
|
|
359
360
|
};
|
|
360
361
|
const modeComputed = computed(resolveMode);
|
|
361
|
-
const enrichedTheme = computed(() =>
|
|
362
|
+
const enrichedTheme = computed(() => {
|
|
363
|
+
const t = props.theme;
|
|
364
|
+
if (t === void 0 || t === null) return parentThemeAccessor();
|
|
365
|
+
return enrichTheme(t);
|
|
366
|
+
});
|
|
362
367
|
provide(ThemeContext, () => enrichedTheme());
|
|
363
368
|
provide(context, () => ({
|
|
364
369
|
theme: enrichedTheme(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/ui-core",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.3",
|
|
4
4
|
"description": "Core utilities, config, and context for Pyreon UI System",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -38,16 +38,16 @@
|
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@pyreon/manifest": "0.13.1",
|
|
41
|
-
"@pyreon/typescript": "^0.24.
|
|
41
|
+
"@pyreon/typescript": "^0.24.3",
|
|
42
42
|
"@vitus-labs/tools-rolldown": "^2.4.0"
|
|
43
43
|
},
|
|
44
44
|
"engines": {
|
|
45
45
|
"node": ">= 22"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@pyreon/core": "^0.24.
|
|
49
|
-
"@pyreon/reactivity": "^0.24.
|
|
50
|
-
"@pyreon/styler": "^0.24.
|
|
51
|
-
"@pyreon/unistyle": "^0.24.
|
|
48
|
+
"@pyreon/core": "^0.24.3",
|
|
49
|
+
"@pyreon/reactivity": "^0.24.3",
|
|
50
|
+
"@pyreon/styler": "^0.24.3",
|
|
51
|
+
"@pyreon/unistyle": "^0.24.3"
|
|
52
52
|
}
|
|
53
53
|
}
|
package/src/PyreonUI.tsx
CHANGED
|
@@ -12,15 +12,41 @@ export type ThemeMode = 'light' | 'dark'
|
|
|
12
12
|
export type ThemeModeInput = ThemeMode | 'system'
|
|
13
13
|
|
|
14
14
|
export interface PyreonUIProps {
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Theme object with breakpoints, rootSize, and custom keys.
|
|
17
|
+
*
|
|
18
|
+
* Optional — when omitted, the theme is INHERITED from the nearest
|
|
19
|
+
* ancestor PyreonUI. This makes nested `<PyreonUI inversed>` work
|
|
20
|
+
* without re-passing the theme:
|
|
21
|
+
*
|
|
22
|
+
* ```tsx
|
|
23
|
+
* <PyreonUI theme={appTheme}>
|
|
24
|
+
* <Header />
|
|
25
|
+
* <PyreonUI inversed> // inherits appTheme, just flips mode
|
|
26
|
+
* <DarkSidebar />
|
|
27
|
+
* </PyreonUI>
|
|
28
|
+
* </PyreonUI>
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* At the OUTERMOST PyreonUI with no ancestor, the provided ThemeContext
|
|
32
|
+
* value falls back to the default `{}` (styled components see fields as
|
|
33
|
+
* undefined; no crash). Pass a real theme at the outermost PyreonUI to
|
|
34
|
+
* avoid that.
|
|
35
|
+
*/
|
|
36
|
+
theme?: PyreonTheme | undefined
|
|
17
37
|
/**
|
|
18
38
|
* Color mode: "light", "dark", or "system" (follows OS preference).
|
|
19
39
|
* Can be a signal or getter for reactive mode switching.
|
|
20
|
-
*
|
|
40
|
+
*
|
|
41
|
+
* When omitted, mode is INHERITED from the nearest ancestor PyreonUI
|
|
42
|
+
* (or `'light'` at the root).
|
|
21
43
|
*/
|
|
22
44
|
mode?: ThemeModeInput | (() => ThemeModeInput) | undefined
|
|
23
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Flip mode for a nested section (e.g. dark sidebar in light app).
|
|
47
|
+
* Scoped — only affects DESCENDANTS of this PyreonUI; ancestors and
|
|
48
|
+
* siblings see the original mode unchanged.
|
|
49
|
+
*/
|
|
24
50
|
inversed?: boolean | undefined
|
|
25
51
|
children?: VNodeChild
|
|
26
52
|
}
|
|
@@ -128,6 +154,14 @@ export function PyreonUI(props: PyreonUIProps): VNodeChild {
|
|
|
128
154
|
// returns 'light'. This read is TRACKED inside the computed below, so when
|
|
129
155
|
// the parent's mode changes, this child's computed re-evaluates.
|
|
130
156
|
const parentModeAccessor = useContext(ModeContext)
|
|
157
|
+
// Same shape for theme — useContext(ThemeContext) is a reactive accessor.
|
|
158
|
+
// When `props.theme` is omitted, we provide the parent's theme through
|
|
159
|
+
// verbatim (already enriched at the level it was provided). Re-enriching
|
|
160
|
+
// would be idempotent but wasteful, AND would replace the parent's exact
|
|
161
|
+
// `__PYREON__` reference, which downstream identity-keyed caches (styler's
|
|
162
|
+
// class cache, rocketstyle's per-definition WeakMaps) rely on for hits.
|
|
163
|
+
// Pass-through preserves identity.
|
|
164
|
+
const parentThemeAccessor = useContext(ThemeContext)
|
|
131
165
|
const resolveMode = (): ThemeMode => {
|
|
132
166
|
const mode = props.mode
|
|
133
167
|
let resolved: ThemeMode
|
|
@@ -146,7 +180,22 @@ export function PyreonUI(props: PyreonUIProps): VNodeChild {
|
|
|
146
180
|
|
|
147
181
|
// Enrich theme — wrapped in computed so user-preference theme swaps
|
|
148
182
|
// propagate. The enrichment itself is cheap (builds a __PYREON__ block).
|
|
149
|
-
|
|
183
|
+
//
|
|
184
|
+
// When `props.theme` is omitted, return the parent's theme verbatim
|
|
185
|
+
// (already enriched). Without this, `enrichTheme(undefined)` would
|
|
186
|
+
// destructure undefined and throw — but the throw happens LAZILY (the
|
|
187
|
+
// computed is only read when a child consumes ThemeContext), so the
|
|
188
|
+
// failure mode is the cryptic dev-mode warning
|
|
189
|
+
// `[pyreon] Unhandled effect error: TypeError: Cannot destructure property
|
|
190
|
+
// 'breakpoints' of 'theme' as it is undefined`
|
|
191
|
+
// followed by every styled descendant rendering with an empty theme.
|
|
192
|
+
// That's what made the user's nested `<PyreonUI inversed>` "look like
|
|
193
|
+
// inversed wasn't working" — the whole subtree was broken.
|
|
194
|
+
const enrichedTheme = computed(() => {
|
|
195
|
+
const t = props.theme
|
|
196
|
+
if (t === undefined || t === null) return parentThemeAccessor()
|
|
197
|
+
return enrichTheme(t)
|
|
198
|
+
})
|
|
150
199
|
|
|
151
200
|
// Provide to all three context layers:
|
|
152
201
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Verifies the user's three reported issues are FIXED:
|
|
2
|
+
//
|
|
3
|
+
// 1. `inversed` works on a nested PyreonUI when theme is inherited.
|
|
4
|
+
// 2. `theme` is optional — nested `<PyreonUI inversed>` (no theme prop)
|
|
5
|
+
// no longer crashes the ThemeContext getter.
|
|
6
|
+
// 3. Nested PyreonUI inherits theme from the parent's ThemeContext
|
|
7
|
+
// out of the box.
|
|
8
|
+
//
|
|
9
|
+
// PLUS the user's explicit scoping invariant: an INNER inversed PyreonUI
|
|
10
|
+
// must NOT leak its flipped mode back to the outer subtree. This holds
|
|
11
|
+
// trivially via Pyreon's provide() scoping (each `provide()` push lives
|
|
12
|
+
// only inside the providing component's subtree), but worth locking in.
|
|
13
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
14
|
+
import { PyreonUI } from '../PyreonUI'
|
|
15
|
+
|
|
16
|
+
// Spy on provide()
|
|
17
|
+
const provideSpy = vi.spyOn(await import('@pyreon/core'), 'provide')
|
|
18
|
+
|
|
19
|
+
describe('PyreonUI — theme/mode inheritance + scope invariants', () => {
|
|
20
|
+
const theme = {
|
|
21
|
+
rootSize: 16,
|
|
22
|
+
breakpoints: { xs: 0, sm: 576, md: 768 },
|
|
23
|
+
colors: { primary: '#228be6' },
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
provideSpy.mockClear()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('ISSUE 2 FIXED: <PyreonUI inversed> with no theme prop returns a non-throwing ThemeContext getter', () => {
|
|
31
|
+
// The previous broken shape: computed wraps enrichTheme(undefined),
|
|
32
|
+
// which throws lazily when a child reads ThemeContext. With the fix,
|
|
33
|
+
// omitted theme inherits from the parent ThemeContext (default `{}`
|
|
34
|
+
// at the root).
|
|
35
|
+
PyreonUI({ inversed: true, children: null })
|
|
36
|
+
const themeGetter = provideSpy.mock.calls[0]![1] as () => unknown
|
|
37
|
+
expect(() => themeGetter()).not.toThrow()
|
|
38
|
+
// At root with no parent theme, falls back to the ReactiveContext
|
|
39
|
+
// default `{}` — no crash, just an empty theme.
|
|
40
|
+
expect(themeGetter()).toEqual({})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('ISSUE 3 FIXED: nested PyreonUI inherits theme from parent ThemeContext identity', () => {
|
|
44
|
+
// Drive the outer + inner manually. The inner runs inside the outer's
|
|
45
|
+
// setup frame — useContext(ThemeContext) inside the inner reads the
|
|
46
|
+
// outer's provided getter, returning the outer's enriched theme.
|
|
47
|
+
PyreonUI({ theme, children: null }) // outer
|
|
48
|
+
const outerThemeGetter = provideSpy.mock.calls[0]![1] as () => unknown
|
|
49
|
+
const outerEnriched = outerThemeGetter()
|
|
50
|
+
|
|
51
|
+
provideSpy.mockClear()
|
|
52
|
+
PyreonUI({ inversed: true, children: null }) // inner — no theme prop
|
|
53
|
+
const innerThemeGetter = provideSpy.mock.calls[0]![1] as () => unknown
|
|
54
|
+
const innerTheme = innerThemeGetter()
|
|
55
|
+
|
|
56
|
+
// The inner provides the PARENT'S theme by REFERENCE — not a fresh
|
|
57
|
+
// enrichTheme() call. Same `__PYREON__` block identity → downstream
|
|
58
|
+
// identity-keyed caches (styler classCache, rocketstyle WeakMaps) hit.
|
|
59
|
+
expect(innerTheme).toBe(outerEnriched)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('ISSUE 1 FIXED: inversed flips the mode when theme is inherited from parent', () => {
|
|
63
|
+
// The user's reported shape:
|
|
64
|
+
// <PyreonUI theme={theme}> ← provides mode='light'
|
|
65
|
+
// <PyreonUI inversed> ← no theme, only `inversed`
|
|
66
|
+
// <DarkSidebar /> ← reads mode → should be 'dark'
|
|
67
|
+
// </PyreonUI>
|
|
68
|
+
// </PyreonUI>
|
|
69
|
+
//
|
|
70
|
+
// Before the fix: enrichTheme(undefined) crashed in the inner's
|
|
71
|
+
// ThemeContext getter and the whole inner subtree fell back to outer's
|
|
72
|
+
// mode (the inversion was effectively invisible because every styled
|
|
73
|
+
// descendant rendered with the wrong theme).
|
|
74
|
+
PyreonUI({ theme, mode: 'light', children: null }) // outer
|
|
75
|
+
provideSpy.mockClear()
|
|
76
|
+
PyreonUI({ inversed: true, children: null }) // inner — no theme
|
|
77
|
+
const innerModeGetter = provideSpy.mock.calls[2]![1] as () => unknown
|
|
78
|
+
expect(innerModeGetter()).toBe('dark')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('SCOPE INVARIANT: inner inversed does NOT leak to outer', () => {
|
|
82
|
+
// The outer PyreonUI provided ModeContext BEFORE the inner one ran.
|
|
83
|
+
// Inner's provide() pushed a new frame onto the context stack — that
|
|
84
|
+
// frame is identity-removed on inner unmount, leaving the outer's
|
|
85
|
+
// frame intact. We assert on the outer's mode getter staying 'light'
|
|
86
|
+
// after the inner has also been provided.
|
|
87
|
+
PyreonUI({ theme, mode: 'light', children: null }) // outer
|
|
88
|
+
const outerModeGetter = provideSpy.mock.calls[2]![1] as () => unknown
|
|
89
|
+
provideSpy.mockClear()
|
|
90
|
+
PyreonUI({ inversed: true, children: null }) // inner
|
|
91
|
+
|
|
92
|
+
// Outer's mode getter is still the same closure and still returns
|
|
93
|
+
// 'light' — it was never touched by the inner's provide().
|
|
94
|
+
expect(outerModeGetter()).toBe('light')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('SCOPE INVARIANT: outer theme is unaffected by inner inheritance', () => {
|
|
98
|
+
PyreonUI({ theme, children: null }) // outer
|
|
99
|
+
const outerThemeGetter = provideSpy.mock.calls[0]![1] as () => unknown
|
|
100
|
+
const outerThemeBefore = outerThemeGetter()
|
|
101
|
+
provideSpy.mockClear()
|
|
102
|
+
PyreonUI({ inversed: true, children: null }) // inner
|
|
103
|
+
|
|
104
|
+
// Outer's theme getter still returns the same enriched theme.
|
|
105
|
+
expect(outerThemeGetter()).toBe(outerThemeBefore)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// ─── Breakpoints optional — "one-size" app ───────────────────────────────────
|
|
109
|
+
// User requirement: breakpoints should not be required. An app that
|
|
110
|
+
// passes them gets responsive behavior; an app that omits them renders
|
|
111
|
+
// at a single size. The type already permits this (`breakpoints?:` in
|
|
112
|
+
// `PyreonTheme`) and unistyle's `makeItResponsive` already short-circuits
|
|
113
|
+
// when breakpoints are empty:
|
|
114
|
+
//
|
|
115
|
+
// // packages/ui-system/unistyle/src/responsive/makeItResponsive.ts:123
|
|
116
|
+
// if (isEmpty(breakpoints) || isEmpty(__PYREON__)) {
|
|
117
|
+
// return css`${renderStyles(internalTheme)}`
|
|
118
|
+
// }
|
|
119
|
+
//
|
|
120
|
+
// These two specs lock in that the WHOLE chain (PyreonUI →
|
|
121
|
+
// enrichTheme → ThemeContext consumer) works with a theme that has no
|
|
122
|
+
// `breakpoints` AND no `rootSize`. A regression in `enrichTheme` to a
|
|
123
|
+
// strict-required shape would surface here.
|
|
124
|
+
|
|
125
|
+
it('BREAKPOINTS OPTIONAL: theme with no breakpoints renders an enriched theme with empty media', () => {
|
|
126
|
+
// Minimal theme — just colors. No breakpoints, no rootSize. This is
|
|
127
|
+
// the "one-size app" shape.
|
|
128
|
+
const minimal = { colors: { primary: '#228be6' } }
|
|
129
|
+
PyreonUI({ theme: minimal, children: null })
|
|
130
|
+
const themeGetter = provideSpy.mock.calls[0]![1] as () => unknown
|
|
131
|
+
const enriched = themeGetter() as {
|
|
132
|
+
colors: unknown
|
|
133
|
+
__PYREON__: { sortedBreakpoints: unknown; media: unknown }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
expect(enriched.colors).toEqual({ primary: '#228be6' })
|
|
137
|
+
// __PYREON__ exists but both fields are undefined — downstream
|
|
138
|
+
// `makeItResponsive` detects empty + falls back to plain CSS.
|
|
139
|
+
expect(enriched.__PYREON__).toBeDefined()
|
|
140
|
+
expect(enriched.__PYREON__.sortedBreakpoints).toBeUndefined()
|
|
141
|
+
expect(enriched.__PYREON__.media).toBeUndefined()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('BREAKPOINTS OPTIONAL: nested PyreonUI inherits a no-breakpoints theme unchanged', () => {
|
|
145
|
+
const minimal = { colors: { primary: '#228be6' } }
|
|
146
|
+
PyreonUI({ theme: minimal, children: null }) // outer
|
|
147
|
+
const outerThemeGetter = provideSpy.mock.calls[0]![1] as () => unknown
|
|
148
|
+
const outerEnriched = outerThemeGetter()
|
|
149
|
+
|
|
150
|
+
provideSpy.mockClear()
|
|
151
|
+
PyreonUI({ inversed: true, children: null }) // inner — no theme prop
|
|
152
|
+
const innerThemeGetter = provideSpy.mock.calls[0]![1] as () => unknown
|
|
153
|
+
|
|
154
|
+
// Identity-preserved: same enriched theme reference, no re-enrichment.
|
|
155
|
+
expect(innerThemeGetter()).toBe(outerEnriched)
|
|
156
|
+
})
|
|
157
|
+
})
|