@pyreon/ui-core 0.11.1 → 0.11.2
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/package.json +8 -7
- package/src/PyreonUI.tsx +138 -0
- package/src/__tests__/PyreonUI.test.tsx +81 -0
- package/src/__tests__/compose.test.ts +32 -0
- package/src/__tests__/config.test.ts +102 -0
- package/src/__tests__/context.test.tsx +70 -0
- package/src/__tests__/hoistNonReactStatics.test.tsx +166 -0
- package/src/__tests__/isEmpty.test.ts +53 -0
- package/src/__tests__/isEqual.test.ts +114 -0
- package/src/__tests__/render.test.tsx +72 -0
- package/src/__tests__/useStableValue.test.ts +113 -0
- package/src/__tests__/utils.test.ts +537 -0
- package/src/compose.ts +11 -0
- package/src/config.ts +57 -0
- package/src/context.tsx +40 -0
- package/src/hoistNonReactStatics.ts +59 -0
- package/src/html/htmlElementAttrs.ts +106 -0
- package/src/html/htmlTags.ts +151 -0
- package/src/html/index.ts +11 -0
- package/src/index.ts +55 -0
- package/src/isEmpty.ts +20 -0
- package/src/isEqual.ts +27 -0
- package/src/render.tsx +44 -0
- package/src/types.ts +5 -0
- package/src/useStableValue.ts +21 -0
- package/src/utils.ts +157 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/ui-core",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.2",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/pyreon/pyreon",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"!lib/**/*.map",
|
|
25
25
|
"!lib/analysis",
|
|
26
26
|
"README.md",
|
|
27
|
-
"LICENSE"
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"src"
|
|
28
29
|
],
|
|
29
30
|
"engines": {
|
|
30
31
|
"node": ">= 22"
|
|
@@ -39,13 +40,13 @@
|
|
|
39
40
|
"typecheck": "tsc --noEmit"
|
|
40
41
|
},
|
|
41
42
|
"peerDependencies": {
|
|
42
|
-
"@pyreon/core": "^0.11.
|
|
43
|
-
"@pyreon/styler": "^0.11.
|
|
44
|
-
"@pyreon/reactivity": "^0.11.
|
|
45
|
-
"@pyreon/unistyle": "^0.11.
|
|
43
|
+
"@pyreon/core": "^0.11.2",
|
|
44
|
+
"@pyreon/styler": "^0.11.2",
|
|
45
|
+
"@pyreon/reactivity": "^0.11.2",
|
|
46
|
+
"@pyreon/unistyle": "^0.11.2"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@vitus-labs/tools-rolldown": "^1.15.3",
|
|
49
|
-
"@pyreon/typescript": "^0.11.
|
|
50
|
+
"@pyreon/typescript": "^0.11.2"
|
|
50
51
|
}
|
|
51
52
|
}
|
package/src/PyreonUI.tsx
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { VNodeChild } from "@pyreon/core"
|
|
2
|
+
import { createContext, provide, useContext } from "@pyreon/core"
|
|
3
|
+
import { signal } from "@pyreon/reactivity"
|
|
4
|
+
import { ThemeContext } from "@pyreon/styler"
|
|
5
|
+
import type { PyreonTheme } from "@pyreon/unistyle"
|
|
6
|
+
import { enrichTheme } from "@pyreon/unistyle"
|
|
7
|
+
import { context as coreContext } from "./context"
|
|
8
|
+
|
|
9
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export type ThemeMode = "light" | "dark"
|
|
12
|
+
export type ThemeModeInput = ThemeMode | "system"
|
|
13
|
+
|
|
14
|
+
export interface PyreonUIProps {
|
|
15
|
+
/** Theme object with breakpoints, rootSize, and custom keys. */
|
|
16
|
+
theme: PyreonTheme
|
|
17
|
+
/**
|
|
18
|
+
* Color mode: "light", "dark", or "system" (follows OS preference).
|
|
19
|
+
* @default "light"
|
|
20
|
+
*/
|
|
21
|
+
mode?: ThemeModeInput | undefined
|
|
22
|
+
/** Flip mode for a nested section (e.g. dark sidebar in light app). */
|
|
23
|
+
inversed?: boolean | undefined
|
|
24
|
+
children?: VNodeChild
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ─── System mode detection ──────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const _isBrowser = typeof window !== "undefined" && typeof matchMedia === "function"
|
|
30
|
+
|
|
31
|
+
/** Reactive signal tracking the OS dark mode preference. Lazy-initialized on first use. */
|
|
32
|
+
let _systemMode: ReturnType<typeof signal<ThemeMode>> | undefined
|
|
33
|
+
|
|
34
|
+
function getSystemMode(): ReturnType<typeof signal<ThemeMode>> {
|
|
35
|
+
if (_systemMode) return _systemMode
|
|
36
|
+
|
|
37
|
+
const prefersDark = _isBrowser && matchMedia("(prefers-color-scheme: dark)").matches
|
|
38
|
+
_systemMode = signal<ThemeMode>(prefersDark ? "dark" : "light")
|
|
39
|
+
|
|
40
|
+
if (_isBrowser) {
|
|
41
|
+
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
|
|
42
|
+
_systemMode!.set(e.matches ? "dark" : "light")
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return _systemMode
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Mode context ───────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const ModeContext = createContext<ThemeMode>("light")
|
|
52
|
+
|
|
53
|
+
const INVERSED: Record<ThemeMode, ThemeMode> = { light: "dark", dark: "light" }
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read the resolved color mode ("light" | "dark") from the nearest PyreonUI.
|
|
57
|
+
* Reactive — updates when the mode prop changes or when OS preference changes
|
|
58
|
+
* (if mode="system").
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* const mode = useMode() // "light" | "dark"
|
|
62
|
+
*/
|
|
63
|
+
export function useMode(): ThemeMode {
|
|
64
|
+
return useContext(ModeContext)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Auto-init ──────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
let _autoInitDone = false
|
|
70
|
+
|
|
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
|
+
function autoInit(): void {
|
|
77
|
+
if (_autoInitDone) return
|
|
78
|
+
_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
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── PyreonUI ───────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Unified provider for the Pyreon UI system.
|
|
90
|
+
*
|
|
91
|
+
* Replaces the need for separate UnistyleProvider, RocketstyleProvider,
|
|
92
|
+
* and ThemeProvider — one component, zero init.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```tsx
|
|
96
|
+
* <PyreonUI theme={{ rootSize: 16, breakpoints: { xs: 0, sm: 576, md: 768 } }} mode="system">
|
|
97
|
+
* <App />
|
|
98
|
+
* </PyreonUI>
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export function PyreonUI({ theme, mode = "light", inversed, children }: PyreonUIProps): VNodeChild {
|
|
102
|
+
autoInit()
|
|
103
|
+
|
|
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
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Apply inversion for nested dark/light sections
|
|
113
|
+
if (inversed) {
|
|
114
|
+
resolvedMode = INVERSED[resolvedMode]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Enrich theme with responsive utilities (__PYREON__)
|
|
118
|
+
const enrichedTheme = enrichTheme(theme)
|
|
119
|
+
|
|
120
|
+
// Provide to all three context layers:
|
|
121
|
+
|
|
122
|
+
// 1. Styler ThemeContext — for styled() components and useTheme()
|
|
123
|
+
provide(ThemeContext, enrichedTheme)
|
|
124
|
+
|
|
125
|
+
// 2. Core context — for elements, attrs, coolgrid, rocketstyle
|
|
126
|
+
// Includes mode + isDark/isLight for rocketstyle dimension resolution
|
|
127
|
+
provide(coreContext, {
|
|
128
|
+
theme: enrichedTheme,
|
|
129
|
+
mode: resolvedMode,
|
|
130
|
+
isDark: resolvedMode === "dark",
|
|
131
|
+
isLight: resolvedMode === "light",
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// 3. Mode context — for useMode() hook
|
|
135
|
+
provide(ModeContext, resolvedMode)
|
|
136
|
+
|
|
137
|
+
return children ?? null
|
|
138
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { h } from "@pyreon/core"
|
|
2
|
+
import { describe, expect, it, vi } from "vitest"
|
|
3
|
+
import { PyreonUI } from "../PyreonUI"
|
|
4
|
+
|
|
5
|
+
// Spy on provide to verify context provision
|
|
6
|
+
const provideSpy = vi.spyOn(await import("@pyreon/core"), "provide")
|
|
7
|
+
|
|
8
|
+
/** Get the value argument (2nd arg) from a provide() call by index. */
|
|
9
|
+
const getProvideValue = (callIndex: number): any => provideSpy.mock.calls[callIndex]![1]
|
|
10
|
+
|
|
11
|
+
describe("PyreonUI", () => {
|
|
12
|
+
const theme = {
|
|
13
|
+
rootSize: 16,
|
|
14
|
+
breakpoints: { xs: 0, sm: 576, md: 768 },
|
|
15
|
+
colors: { primary: "#228be6" },
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
provideSpy.mockClear()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("renders children", () => {
|
|
23
|
+
const child = h("div", null, "hello")
|
|
24
|
+
const result = PyreonUI({ theme, children: child })
|
|
25
|
+
expect(result).toBe(child)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("returns null when no children", () => {
|
|
29
|
+
const result = PyreonUI({ theme })
|
|
30
|
+
expect(result).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("calls provide three times (ThemeContext, core context, mode context)", () => {
|
|
34
|
+
PyreonUI({ theme, children: null })
|
|
35
|
+
expect(provideSpy).toHaveBeenCalledTimes(3)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("defaults mode to light", () => {
|
|
39
|
+
PyreonUI({ theme, children: null })
|
|
40
|
+
|
|
41
|
+
const coreCtx = getProvideValue(1)
|
|
42
|
+
expect(coreCtx.mode).toBe("light")
|
|
43
|
+
expect(coreCtx.isLight).toBe(true)
|
|
44
|
+
expect(coreCtx.isDark).toBe(false)
|
|
45
|
+
expect(getProvideValue(2)).toBe("light")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("provides dark mode", () => {
|
|
49
|
+
PyreonUI({ theme, mode: "dark", children: null })
|
|
50
|
+
|
|
51
|
+
const coreCtx = getProvideValue(1)
|
|
52
|
+
expect(coreCtx.mode).toBe("dark")
|
|
53
|
+
expect(coreCtx.isDark).toBe(true)
|
|
54
|
+
expect(coreCtx.isLight).toBe(false)
|
|
55
|
+
expect(getProvideValue(2)).toBe("dark")
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("inverts mode when inversed=true", () => {
|
|
59
|
+
PyreonUI({ theme, mode: "light", inversed: true, children: null })
|
|
60
|
+
expect(getProvideValue(2)).toBe("dark")
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("inverts dark to light", () => {
|
|
64
|
+
PyreonUI({ theme, mode: "dark", inversed: true, children: null })
|
|
65
|
+
expect(getProvideValue(2)).toBe("light")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("enriches theme with __PYREON__ before providing", () => {
|
|
69
|
+
PyreonUI({ theme, children: null })
|
|
70
|
+
|
|
71
|
+
const providedTheme = getProvideValue(0)
|
|
72
|
+
expect(providedTheme.__PYREON__).toBeDefined()
|
|
73
|
+
expect(providedTheme.__PYREON__.sortedBreakpoints).toEqual(["xs", "sm", "md"])
|
|
74
|
+
expect(providedTheme.colors).toEqual({ primary: "#228be6" })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("works with system mode (resolves to light in happy-dom)", () => {
|
|
78
|
+
PyreonUI({ theme, mode: "system", children: null })
|
|
79
|
+
expect(getProvideValue(2)).toBe("light")
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import compose from "../compose"
|
|
3
|
+
|
|
4
|
+
describe("compose", () => {
|
|
5
|
+
it("should compose two functions right-to-left", () => {
|
|
6
|
+
const double = (x: number) => x * 2
|
|
7
|
+
const addOne = (x: number) => x + 1
|
|
8
|
+
const composed = compose(addOne, double)
|
|
9
|
+
// double(3) = 6, then addOne(6) = 7
|
|
10
|
+
expect(composed(3)).toBe(7)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("should compose three functions right-to-left", () => {
|
|
14
|
+
const add = (x: number) => x + 1
|
|
15
|
+
const mul = (x: number) => x * 3
|
|
16
|
+
const sub = (x: number) => x - 2
|
|
17
|
+
// sub(5) = 3, mul(3) = 9, add(9) = 10
|
|
18
|
+
expect(compose(add, mul, sub)(5)).toBe(10)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("should work with a single function", () => {
|
|
22
|
+
const identity = (x: number) => x
|
|
23
|
+
expect(compose(identity)(42)).toBe(42)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("should pass value through string transforms", () => {
|
|
27
|
+
const upper = (s: string) => s.toUpperCase()
|
|
28
|
+
const exclaim = (s: string) => `${s}!`
|
|
29
|
+
// exclaim('hello') = 'hello!', upper('hello!') = 'HELLO!'
|
|
30
|
+
expect(compose(upper, exclaim)("hello")).toBe("HELLO!")
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
2
|
+
import config, { init } from "../config"
|
|
3
|
+
|
|
4
|
+
describe("Configuration", () => {
|
|
5
|
+
it("has default component as div", () => {
|
|
6
|
+
expect(config.component).toBe("div")
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it("has default textComponent as span", () => {
|
|
10
|
+
expect(config.textComponent).toBe("span")
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("has css function", () => {
|
|
14
|
+
expect(config.css).toBeDefined()
|
|
15
|
+
expect(typeof config.css).toBe("function")
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("has styled function", () => {
|
|
19
|
+
expect(config.styled).toBeDefined()
|
|
20
|
+
expect(typeof config.styled).toBe("function")
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("has keyframes function", () => {
|
|
24
|
+
expect(config.keyframes).toBeDefined()
|
|
25
|
+
expect(typeof config.keyframes).toBe("function")
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe("init", () => {
|
|
29
|
+
const originalCss = config.css
|
|
30
|
+
const originalStyled = config.styled
|
|
31
|
+
const originalKeyframes = config.keyframes
|
|
32
|
+
const originalComponent = config.component
|
|
33
|
+
const originalTextComponent = config.textComponent
|
|
34
|
+
const originalCreateMediaQueries = config.createMediaQueries
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
// restore defaults
|
|
38
|
+
config.css = originalCss
|
|
39
|
+
config.styled = originalStyled
|
|
40
|
+
config.keyframes = originalKeyframes
|
|
41
|
+
config.component = originalComponent
|
|
42
|
+
config.textComponent = originalTextComponent
|
|
43
|
+
config.createMediaQueries = originalCreateMediaQueries
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("updates css engine", () => {
|
|
47
|
+
const mockCss = (() => "") as any
|
|
48
|
+
init({ css: mockCss })
|
|
49
|
+
expect(config.css).toBe(mockCss)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("updates styled engine", () => {
|
|
53
|
+
const mockStyled = (() => "") as any
|
|
54
|
+
init({ styled: mockStyled })
|
|
55
|
+
expect(config.styled).toBe(mockStyled)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("updates component", () => {
|
|
59
|
+
init({ component: "section" })
|
|
60
|
+
expect(config.component).toBe("section")
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("updates textComponent", () => {
|
|
64
|
+
init({ textComponent: "p" })
|
|
65
|
+
expect(config.textComponent).toBe("p")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("updates keyframes", () => {
|
|
69
|
+
const mockKeyframes = (() => "anim") as any
|
|
70
|
+
init({ keyframes: mockKeyframes })
|
|
71
|
+
expect(config.keyframes).toBe(mockKeyframes)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("updates createMediaQueries", () => {
|
|
75
|
+
const mockCreateMQ = (() => ({})) as any
|
|
76
|
+
init({ createMediaQueries: mockCreateMQ })
|
|
77
|
+
expect(config.createMediaQueries).toBe(mockCreateMQ)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("only updates provided fields", () => {
|
|
81
|
+
init({ component: "article" })
|
|
82
|
+
expect(config.component).toBe("article")
|
|
83
|
+
expect(config.textComponent).toBe("span")
|
|
84
|
+
expect(config.css).toBe(originalCss)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("does nothing with empty object", () => {
|
|
88
|
+
init({})
|
|
89
|
+
expect(config.component).toBe("div")
|
|
90
|
+
expect(config.textComponent).toBe("span")
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("can be called multiple times to swap engine", () => {
|
|
94
|
+
const first = (() => "first") as any
|
|
95
|
+
const second = (() => "second") as any
|
|
96
|
+
init({ css: first })
|
|
97
|
+
expect(config.css).toBe(first)
|
|
98
|
+
init({ css: second })
|
|
99
|
+
expect(config.css).toBe(second)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { popContext, useContext } from "@pyreon/core"
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
3
|
+
import Provider, { context } from "../context"
|
|
4
|
+
|
|
5
|
+
describe("Provider", () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
// Clean up any pushed context frames
|
|
8
|
+
try {
|
|
9
|
+
popContext()
|
|
10
|
+
} catch {
|
|
11
|
+
// Ignore if no context was pushed
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("returns children when no theme is provided", () => {
|
|
16
|
+
const children = "Hello"
|
|
17
|
+
const result = Provider({ children })
|
|
18
|
+
expect(result).toBe("Hello")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("returns children with empty theme", () => {
|
|
22
|
+
const children = "Hello"
|
|
23
|
+
const result = Provider({ theme: {}, children })
|
|
24
|
+
expect(result).toBe("Hello")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("returns children with null theme", () => {
|
|
28
|
+
const children = "Hello"
|
|
29
|
+
// @ts-expect-error testing null theme
|
|
30
|
+
const result = Provider({ theme: null, children })
|
|
31
|
+
expect(result).toBe("Hello")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("returns children when theme is provided and pushes context", () => {
|
|
35
|
+
const theme = { rootSize: 16, breakpoints: { xs: 0 } }
|
|
36
|
+
const children = "Styled"
|
|
37
|
+
const result = Provider({ theme, children })
|
|
38
|
+
expect(result).toBe("Styled")
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("pushes context with theme and extra props", () => {
|
|
42
|
+
const theme = { rootSize: 16 }
|
|
43
|
+
const children = "Content"
|
|
44
|
+
Provider({ theme, children, custom: "value" })
|
|
45
|
+
// After Provider runs, context should have been pushed
|
|
46
|
+
// We can verify by reading the context value
|
|
47
|
+
const ctx = useContext(context)
|
|
48
|
+
expect(ctx.theme).toEqual({ rootSize: 16 })
|
|
49
|
+
expect(ctx.custom).toBe("value")
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("returns null when no children and no theme", () => {
|
|
53
|
+
const result = Provider({})
|
|
54
|
+
expect(result).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("returns null when theme is provided but no children", () => {
|
|
58
|
+
const theme = { rootSize: 16 }
|
|
59
|
+
const result = Provider({ theme })
|
|
60
|
+
expect(result).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe("context", () => {
|
|
65
|
+
it("exports context object with an id", () => {
|
|
66
|
+
expect(context).toBeDefined()
|
|
67
|
+
expect(context.id).toBeDefined()
|
|
68
|
+
expect(typeof context.id).toBe("symbol")
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import hoistNonReactStatics from "../hoistNonReactStatics"
|
|
3
|
+
|
|
4
|
+
describe("hoistNonReactStatics", () => {
|
|
5
|
+
it("copies custom static properties from source to target", () => {
|
|
6
|
+
const Source = () => null
|
|
7
|
+
;(Source as any).customStatic = "hello"
|
|
8
|
+
;(Source as any).anotherStatic = 42
|
|
9
|
+
|
|
10
|
+
const Target = () => null
|
|
11
|
+
|
|
12
|
+
hoistNonReactStatics(Target, Source)
|
|
13
|
+
|
|
14
|
+
expect((Target as any).customStatic).toBe("hello")
|
|
15
|
+
expect((Target as any).anotherStatic).toBe(42)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("does not copy component statics (displayName, defaultProps)", () => {
|
|
19
|
+
const Source = () => null
|
|
20
|
+
Source.displayName = "SourceComponent"
|
|
21
|
+
;(Source as any).defaultProps = { bar: 1 }
|
|
22
|
+
;(Source as any).customProp = "should copy"
|
|
23
|
+
|
|
24
|
+
const Target = () => null
|
|
25
|
+
Target.displayName = "TargetComponent"
|
|
26
|
+
|
|
27
|
+
hoistNonReactStatics(Target, Source)
|
|
28
|
+
|
|
29
|
+
expect(Target.displayName).toBe("TargetComponent")
|
|
30
|
+
expect((Target as any).defaultProps).toBeUndefined()
|
|
31
|
+
expect((Target as any).customProp).toBe("should copy")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("does not copy known JS statics (name, length, prototype)", () => {
|
|
35
|
+
const Source = () => null
|
|
36
|
+
;(Source as any).customProp = "value"
|
|
37
|
+
|
|
38
|
+
const Target = () => null
|
|
39
|
+
const originalName = Target.name
|
|
40
|
+
|
|
41
|
+
hoistNonReactStatics(Target, Source)
|
|
42
|
+
|
|
43
|
+
expect(Target.name).toBe(originalName)
|
|
44
|
+
expect((Target as any).customProp).toBe("value")
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("respects the excludeList", () => {
|
|
48
|
+
const Source = () => null
|
|
49
|
+
;(Source as any).foo = "included"
|
|
50
|
+
;(Source as any).bar = "excluded"
|
|
51
|
+
;(Source as any).baz = "included"
|
|
52
|
+
|
|
53
|
+
const Target = () => null
|
|
54
|
+
|
|
55
|
+
hoistNonReactStatics(Target, Source, { bar: true })
|
|
56
|
+
|
|
57
|
+
expect((Target as any).foo).toBe("included")
|
|
58
|
+
expect((Target as any).bar).toBeUndefined()
|
|
59
|
+
expect((Target as any).baz).toBe("included")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("returns the target component", () => {
|
|
63
|
+
const Source = () => null
|
|
64
|
+
const Target = () => null
|
|
65
|
+
|
|
66
|
+
const result = hoistNonReactStatics(Target, Source)
|
|
67
|
+
expect(result).toBe(Target)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("handles string source (HTML tag) gracefully", () => {
|
|
71
|
+
const Target = () => null
|
|
72
|
+
|
|
73
|
+
const result = hoistNonReactStatics(Target, "div" as any)
|
|
74
|
+
expect(result).toBe(Target)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("copies symbol-keyed properties", () => {
|
|
78
|
+
const sym = Symbol("custom")
|
|
79
|
+
const Source = () => null
|
|
80
|
+
;(Source as any)[sym] = "symbol value"
|
|
81
|
+
|
|
82
|
+
const Target = () => null
|
|
83
|
+
|
|
84
|
+
hoistNonReactStatics(Target, Source)
|
|
85
|
+
|
|
86
|
+
expect((Target as any)[sym]).toBe("symbol value")
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("copies getters and setters via property descriptors", () => {
|
|
90
|
+
const Source = () => null
|
|
91
|
+
let value = 0
|
|
92
|
+
Object.defineProperty(Source, "counter", {
|
|
93
|
+
get: () => value,
|
|
94
|
+
set: (v) => {
|
|
95
|
+
value = v
|
|
96
|
+
},
|
|
97
|
+
enumerable: true,
|
|
98
|
+
configurable: true,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const Target = () => null
|
|
102
|
+
|
|
103
|
+
hoistNonReactStatics(Target, Source)
|
|
104
|
+
|
|
105
|
+
expect((Target as any).counter).toBe(0)
|
|
106
|
+
;(Target as any).counter = 5
|
|
107
|
+
expect((Target as any).counter).toBe(5)
|
|
108
|
+
// shares the same backing variable
|
|
109
|
+
expect((Source as any).counter).toBe(5)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("does not throw on non-configurable target properties", () => {
|
|
113
|
+
const Source = () => null
|
|
114
|
+
;(Source as any).locked = "source value"
|
|
115
|
+
|
|
116
|
+
const Target = () => null
|
|
117
|
+
Object.defineProperty(Target, "locked", {
|
|
118
|
+
value: "target value",
|
|
119
|
+
writable: false,
|
|
120
|
+
configurable: false,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(() => hoistNonReactStatics(Target, Source)).not.toThrow()
|
|
124
|
+
expect((Target as any).locked).toBe("target value")
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("hoists statics from prototype chain", () => {
|
|
128
|
+
function Base() {
|
|
129
|
+
// constructor stub
|
|
130
|
+
}
|
|
131
|
+
Base.prototype = Object.create(null)
|
|
132
|
+
;(Base as any).inheritedStatic = "from base"
|
|
133
|
+
|
|
134
|
+
function Source() {
|
|
135
|
+
// constructor stub
|
|
136
|
+
}
|
|
137
|
+
Source.prototype = Object.create(null)
|
|
138
|
+
Object.setPrototypeOf(Source, Base)
|
|
139
|
+
;(Source as any).ownStatic = "from source"
|
|
140
|
+
|
|
141
|
+
const Target = () => null
|
|
142
|
+
|
|
143
|
+
hoistNonReactStatics(Target, Source as any)
|
|
144
|
+
|
|
145
|
+
expect((Target as any).ownStatic).toBe("from source")
|
|
146
|
+
expect((Target as any).inheritedStatic).toBe("from base")
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("works with components that have no custom statics", () => {
|
|
150
|
+
const Source = () => null
|
|
151
|
+
const Target = () => null
|
|
152
|
+
|
|
153
|
+
expect(() => hoistNonReactStatics(Target, Source)).not.toThrow()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("stops prototype recursion at Object.prototype", () => {
|
|
157
|
+
const Source = () => null
|
|
158
|
+
;(Source as any).custom = "value"
|
|
159
|
+
// Source's prototype is Function.prototype which has proto Object.prototype
|
|
160
|
+
// The recursion should walk up and stop at Object.prototype
|
|
161
|
+
|
|
162
|
+
const Target = () => null
|
|
163
|
+
expect(() => hoistNonReactStatics(Target, Source)).not.toThrow()
|
|
164
|
+
expect((Target as any).custom).toBe("value")
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import isEmpty from "../isEmpty"
|
|
3
|
+
|
|
4
|
+
describe("isEmpty", () => {
|
|
5
|
+
it("should return true for null", () => {
|
|
6
|
+
expect(isEmpty(null)).toBe(true)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it("should return true for undefined", () => {
|
|
10
|
+
expect(isEmpty(undefined)).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("should return true for empty object", () => {
|
|
14
|
+
expect(isEmpty({})).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it("should return true for empty array", () => {
|
|
18
|
+
expect(isEmpty([])).toBe(true)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("should return false for non-empty object", () => {
|
|
22
|
+
expect(isEmpty({ a: 1 })).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("should return false for non-empty array", () => {
|
|
26
|
+
expect(isEmpty([1])).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("should return false for array with falsy values", () => {
|
|
30
|
+
expect(isEmpty([0, null, undefined])).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("should return false for object with falsy values", () => {
|
|
34
|
+
expect(isEmpty({ a: 0, b: null })).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("should return true for Object.create(null) with no properties", () => {
|
|
38
|
+
expect(isEmpty(Object.create(null))).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("should return false for Object.create(null) with properties", () => {
|
|
42
|
+
const obj = Object.create(null)
|
|
43
|
+
obj.a = 1
|
|
44
|
+
expect(isEmpty(obj)).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("should return true for non-object truthy primitives", () => {
|
|
48
|
+
// Covers typeof param !== 'object' branch
|
|
49
|
+
expect(isEmpty(42 as any)).toBe(true)
|
|
50
|
+
expect(isEmpty("hello" as any)).toBe(true)
|
|
51
|
+
expect(isEmpty(true as any)).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
})
|