@pyreon/core 0.7.5 → 0.7.7
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/analysis/index.js.html +1 -1
- package/lib/index.js +101 -1
- package/lib/index.js.map +1 -1
- package/lib/jsx-dev-runtime.js.map +1 -1
- package/lib/jsx-runtime.js.map +1 -1
- package/lib/types/index.d.ts +570 -8
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/jsx-dev-runtime.d.ts +70 -53
- package/lib/types/jsx-dev-runtime.d.ts.map +1 -1
- package/lib/types/jsx-runtime.d.ts +71 -54
- package/lib/types/jsx-runtime.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +17 -1
- package/src/jsx-runtime.ts +82 -66
- package/src/props.ts +108 -0
- package/src/style.ts +42 -0
- package/src/tests/cx.test.ts +41 -0
- package/src/tests/props.test.ts +77 -0
package/src/jsx-runtime.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* <div class="x" /> → jsx("div", { class: "x" })
|
|
7
7
|
*/
|
|
8
8
|
import { Fragment, h } from "./h"
|
|
9
|
+
import type { ClassValue } from "./style"
|
|
9
10
|
import type { ComponentFn, Props, VNode, VNodeChild } from "./types"
|
|
10
11
|
|
|
11
12
|
export { Fragment }
|
|
@@ -36,15 +37,20 @@ export const jsxs = jsx
|
|
|
36
37
|
// ─── JSX types ────────────────────────────────────────────────────────────────
|
|
37
38
|
|
|
38
39
|
type Booleanish = boolean | "true" | "false"
|
|
39
|
-
type CSSProperties = { [K in keyof CSSStyleDeclaration]?: string | number }
|
|
40
|
-
type StyleValue = string | CSSProperties
|
|
40
|
+
export type CSSProperties = { [K in keyof CSSStyleDeclaration]?: string | number }
|
|
41
|
+
export type StyleValue = string | CSSProperties
|
|
42
|
+
|
|
43
|
+
/** Event with typed currentTarget — used in element-specific event handlers. */
|
|
44
|
+
export type TargetedEvent<T extends Element, E extends Event = Event> = E & {
|
|
45
|
+
readonly currentTarget: T
|
|
46
|
+
}
|
|
41
47
|
|
|
42
48
|
/** Common HTML attributes accepted by all Pyreon elements */
|
|
43
|
-
interface PyreonHTMLAttributes<E extends Element = HTMLElement> {
|
|
49
|
+
export interface PyreonHTMLAttributes<E extends Element = HTMLElement> {
|
|
44
50
|
// Identity
|
|
45
51
|
id?: string | undefined
|
|
46
|
-
class?:
|
|
47
|
-
className?:
|
|
52
|
+
class?: ClassValue | (() => ClassValue) | undefined
|
|
53
|
+
className?: ClassValue | (() => ClassValue) | undefined
|
|
48
54
|
style?: StyleValue | (() => StyleValue) | undefined
|
|
49
55
|
// Accessible
|
|
50
56
|
role?: string | undefined
|
|
@@ -100,62 +106,67 @@ interface PyreonHTMLAttributes<E extends Element = HTMLElement> {
|
|
|
100
106
|
// innerHTML
|
|
101
107
|
innerHTML?: string | undefined
|
|
102
108
|
dangerouslySetInnerHTML?: { __html: string } | undefined
|
|
103
|
-
// Events
|
|
104
|
-
onClick?: ((e: MouseEvent) => void) | undefined
|
|
105
|
-
onDblClick?: ((e: MouseEvent) => void) | undefined
|
|
106
|
-
onMouseDown?: ((e: MouseEvent) => void) | undefined
|
|
107
|
-
onMouseUp?: ((e: MouseEvent) => void) | undefined
|
|
108
|
-
onMouseEnter?: ((e: MouseEvent) => void) | undefined
|
|
109
|
-
onMouseLeave?: ((e: MouseEvent) => void) | undefined
|
|
110
|
-
onMouseMove?: ((e: MouseEvent) => void) | undefined
|
|
111
|
-
onMouseOver?: ((e: MouseEvent) => void) | undefined
|
|
112
|
-
onMouseOut?: ((e: MouseEvent) => void) | undefined
|
|
113
|
-
onContextMenu?: ((e: MouseEvent) => void) | undefined
|
|
114
|
-
onKeyDown?: ((e: KeyboardEvent) => void) | undefined
|
|
115
|
-
onKeyUp?: ((e: KeyboardEvent) => void) | undefined
|
|
116
|
-
onKeyPress?: ((e: KeyboardEvent) => void) | undefined
|
|
117
|
-
onFocus?: ((e: FocusEvent) => void) | undefined
|
|
118
|
-
onBlur?: ((e: FocusEvent) => void) | undefined
|
|
119
|
-
onChange?: ((e:
|
|
120
|
-
onInput?: ((e: InputEvent) => void) | undefined
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
109
|
+
// Events — typed currentTarget via generic E
|
|
110
|
+
onClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
|
|
111
|
+
onDblClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
|
|
112
|
+
onMouseDown?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
|
|
113
|
+
onMouseUp?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
|
|
114
|
+
onMouseEnter?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
|
|
115
|
+
onMouseLeave?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
|
|
116
|
+
onMouseMove?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
|
|
117
|
+
onMouseOver?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
|
|
118
|
+
onMouseOut?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
|
|
119
|
+
onContextMenu?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
|
|
120
|
+
onKeyDown?: ((e: TargetedEvent<E, KeyboardEvent>) => void) | undefined
|
|
121
|
+
onKeyUp?: ((e: TargetedEvent<E, KeyboardEvent>) => void) | undefined
|
|
122
|
+
onKeyPress?: ((e: TargetedEvent<E, KeyboardEvent>) => void) | undefined
|
|
123
|
+
onFocus?: ((e: TargetedEvent<E, FocusEvent>) => void) | undefined
|
|
124
|
+
onBlur?: ((e: TargetedEvent<E, FocusEvent>) => void) | undefined
|
|
125
|
+
onChange?: ((e: TargetedEvent<E>) => void) | undefined
|
|
126
|
+
onInput?: ((e: TargetedEvent<E, InputEvent>) => void) | undefined
|
|
127
|
+
onBeforeInput?: ((e: TargetedEvent<E, InputEvent>) => void) | undefined
|
|
128
|
+
onSubmit?: ((e: TargetedEvent<E, SubmitEvent>) => void) | undefined
|
|
129
|
+
onReset?: ((e: TargetedEvent<E>) => void) | undefined
|
|
130
|
+
onInvalid?: ((e: TargetedEvent<E>) => void) | undefined
|
|
131
|
+
onScroll?: ((e: TargetedEvent<E>) => void) | undefined
|
|
132
|
+
onWheel?: ((e: TargetedEvent<E, WheelEvent>) => void) | undefined
|
|
133
|
+
onResize?: ((e: TargetedEvent<E>) => void) | undefined
|
|
134
|
+
onDragStart?: ((e: TargetedEvent<E, DragEvent>) => void) | undefined
|
|
135
|
+
onDragEnd?: ((e: TargetedEvent<E, DragEvent>) => void) | undefined
|
|
136
|
+
onDragOver?: ((e: TargetedEvent<E, DragEvent>) => void) | undefined
|
|
137
|
+
onDragEnter?: ((e: TargetedEvent<E, DragEvent>) => void) | undefined
|
|
138
|
+
onDragLeave?: ((e: TargetedEvent<E, DragEvent>) => void) | undefined
|
|
139
|
+
onDrop?: ((e: TargetedEvent<E, DragEvent>) => void) | undefined
|
|
140
|
+
onTouchStart?: ((e: TargetedEvent<E, TouchEvent>) => void) | undefined
|
|
141
|
+
onTouchEnd?: ((e: TargetedEvent<E, TouchEvent>) => void) | undefined
|
|
142
|
+
onTouchMove?: ((e: TargetedEvent<E, TouchEvent>) => void) | undefined
|
|
143
|
+
onPointerDown?: ((e: TargetedEvent<E, PointerEvent>) => void) | undefined
|
|
144
|
+
onPointerUp?: ((e: TargetedEvent<E, PointerEvent>) => void) | undefined
|
|
145
|
+
onPointerMove?: ((e: TargetedEvent<E, PointerEvent>) => void) | undefined
|
|
146
|
+
onPointerEnter?: ((e: TargetedEvent<E, PointerEvent>) => void) | undefined
|
|
147
|
+
onPointerLeave?: ((e: TargetedEvent<E, PointerEvent>) => void) | undefined
|
|
148
|
+
onPointerCancel?: ((e: TargetedEvent<E, PointerEvent>) => void) | undefined
|
|
149
|
+
onPointerOver?: ((e: TargetedEvent<E, PointerEvent>) => void) | undefined
|
|
150
|
+
onPointerOut?: ((e: TargetedEvent<E, PointerEvent>) => void) | undefined
|
|
151
|
+
onTransitionEnd?: ((e: TargetedEvent<E, TransitionEvent>) => void) | undefined
|
|
152
|
+
onAnimationStart?: ((e: TargetedEvent<E, AnimationEvent>) => void) | undefined
|
|
153
|
+
onAnimationEnd?: ((e: TargetedEvent<E, AnimationEvent>) => void) | undefined
|
|
154
|
+
onAnimationIteration?: ((e: TargetedEvent<E, AnimationEvent>) => void) | undefined
|
|
155
|
+
onToggle?: ((e: TargetedEvent<E>) => void) | undefined
|
|
156
|
+
onLoad?: ((e: TargetedEvent<E>) => void) | undefined
|
|
157
|
+
onError?: ((e: TargetedEvent<E> | string) => void) | undefined
|
|
158
|
+
onAbort?: ((e: TargetedEvent<E>) => void) | undefined
|
|
159
|
+
onSelect?: ((e: TargetedEvent<E>) => void) | undefined
|
|
160
|
+
onCopy?: ((e: TargetedEvent<E, ClipboardEvent>) => void) | undefined
|
|
161
|
+
onCut?: ((e: TargetedEvent<E, ClipboardEvent>) => void) | undefined
|
|
162
|
+
onPaste?: ((e: TargetedEvent<E, ClipboardEvent>) => void) | undefined
|
|
163
|
+
// data-* and aria-* catch-all (typed attributes above catch typos)
|
|
164
|
+
[key: `data-${string}`]: unknown
|
|
165
|
+
[key: `aria-${string}`]: unknown
|
|
155
166
|
}
|
|
156
167
|
|
|
157
168
|
/** Attributes specific to form inputs */
|
|
158
|
-
interface InputAttributes extends PyreonHTMLAttributes<HTMLInputElement> {
|
|
169
|
+
export interface InputAttributes extends PyreonHTMLAttributes<HTMLInputElement> {
|
|
159
170
|
type?: string | (() => string) | undefined
|
|
160
171
|
value?: string | number | (() => string | number) | undefined
|
|
161
172
|
defaultValue?: string | number | undefined
|
|
@@ -185,14 +196,14 @@ interface InputAttributes extends PyreonHTMLAttributes<HTMLInputElement> {
|
|
|
185
196
|
height?: number | string | undefined
|
|
186
197
|
}
|
|
187
198
|
|
|
188
|
-
interface AnchorAttributes extends PyreonHTMLAttributes<HTMLAnchorElement> {
|
|
199
|
+
export interface AnchorAttributes extends PyreonHTMLAttributes<HTMLAnchorElement> {
|
|
189
200
|
href?: string | (() => string) | undefined
|
|
190
201
|
target?: "_blank" | "_self" | "_parent" | "_top" | string | undefined
|
|
191
202
|
rel?: string | undefined
|
|
192
203
|
download?: string | boolean | undefined
|
|
193
204
|
}
|
|
194
205
|
|
|
195
|
-
interface ButtonAttributes extends PyreonHTMLAttributes<HTMLButtonElement> {
|
|
206
|
+
export interface ButtonAttributes extends PyreonHTMLAttributes<HTMLButtonElement> {
|
|
196
207
|
type?: "button" | "submit" | "reset" | undefined
|
|
197
208
|
disabled?: boolean | (() => boolean) | undefined
|
|
198
209
|
name?: string | undefined
|
|
@@ -205,7 +216,7 @@ interface ButtonAttributes extends PyreonHTMLAttributes<HTMLButtonElement> {
|
|
|
205
216
|
formTarget?: string | undefined
|
|
206
217
|
}
|
|
207
218
|
|
|
208
|
-
interface TextareaAttributes extends PyreonHTMLAttributes<HTMLTextAreaElement> {
|
|
219
|
+
export interface TextareaAttributes extends PyreonHTMLAttributes<HTMLTextAreaElement> {
|
|
209
220
|
value?: string | (() => string) | undefined
|
|
210
221
|
defaultValue?: string | undefined
|
|
211
222
|
placeholder?: string | (() => string) | undefined
|
|
@@ -222,7 +233,7 @@ interface TextareaAttributes extends PyreonHTMLAttributes<HTMLTextAreaElement> {
|
|
|
222
233
|
wrap?: "hard" | "soft" | undefined
|
|
223
234
|
}
|
|
224
235
|
|
|
225
|
-
interface SelectAttributes extends PyreonHTMLAttributes<HTMLSelectElement> {
|
|
236
|
+
export interface SelectAttributes extends PyreonHTMLAttributes<HTMLSelectElement> {
|
|
226
237
|
value?: string | string[] | (() => string | string[]) | undefined
|
|
227
238
|
defaultValue?: string | string[] | undefined
|
|
228
239
|
disabled?: boolean | (() => boolean) | undefined
|
|
@@ -241,7 +252,7 @@ interface OptionAttributes extends PyreonHTMLAttributes<HTMLOptionElement> {
|
|
|
241
252
|
label?: string | undefined
|
|
242
253
|
}
|
|
243
254
|
|
|
244
|
-
interface FormAttributes extends PyreonHTMLAttributes<HTMLFormElement> {
|
|
255
|
+
export interface FormAttributes extends PyreonHTMLAttributes<HTMLFormElement> {
|
|
245
256
|
action?: string | undefined
|
|
246
257
|
method?: "get" | "post" | undefined
|
|
247
258
|
encType?: string | undefined
|
|
@@ -251,7 +262,7 @@ interface FormAttributes extends PyreonHTMLAttributes<HTMLFormElement> {
|
|
|
251
262
|
autoComplete?: string | undefined
|
|
252
263
|
}
|
|
253
264
|
|
|
254
|
-
interface ImgAttributes extends PyreonHTMLAttributes<HTMLImageElement> {
|
|
265
|
+
export interface ImgAttributes extends PyreonHTMLAttributes<HTMLImageElement> {
|
|
255
266
|
src?: string | (() => string) | undefined
|
|
256
267
|
alt?: string | (() => string) | undefined
|
|
257
268
|
width?: number | string | (() => number | string) | undefined
|
|
@@ -391,7 +402,12 @@ interface OlAttributes extends PyreonHTMLAttributes<HTMLOListElement> {
|
|
|
391
402
|
type?: "1" | "a" | "A" | "i" | "I" | undefined
|
|
392
403
|
}
|
|
393
404
|
|
|
394
|
-
interface
|
|
405
|
+
interface CanvasAttributes extends PyreonHTMLAttributes<HTMLCanvasElement> {
|
|
406
|
+
width?: number | string | undefined
|
|
407
|
+
height?: number | string | undefined
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export interface SvgAttributes extends PyreonHTMLAttributes<SVGElement> {
|
|
395
411
|
viewBox?: string | undefined
|
|
396
412
|
xmlns?: string | undefined
|
|
397
413
|
fill?: string | (() => string) | undefined
|
|
@@ -546,7 +562,7 @@ declare global {
|
|
|
546
562
|
source: SourceAttributes
|
|
547
563
|
track: PyreonHTMLAttributes
|
|
548
564
|
picture: PyreonHTMLAttributes
|
|
549
|
-
canvas:
|
|
565
|
+
canvas: CanvasAttributes
|
|
550
566
|
svg: SvgAttributes
|
|
551
567
|
path: SvgAttributes
|
|
552
568
|
circle: SvgAttributes
|
package/src/props.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Prop utilities for component authoring.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Split props into two groups: keys you want and the rest.
|
|
5
|
+
* Unlike destructuring, this preserves reactivity (getters on the original object).
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const [own, html] = splitProps(props, ["label", "icon"])
|
|
9
|
+
* return <button {...html}><Icon name={own.icon} /> {own.label}</button>
|
|
10
|
+
*/
|
|
11
|
+
export function splitProps<T extends Record<string, unknown>, K extends (keyof T)[]>(
|
|
12
|
+
props: T,
|
|
13
|
+
keys: K,
|
|
14
|
+
): [Pick<T, K[number]>, Omit<T, K[number]>] {
|
|
15
|
+
const picked = {} as Pick<T, K[number]>
|
|
16
|
+
const rest = {} as Omit<T, K[number]>
|
|
17
|
+
const keySet = new Set<string | symbol>(keys as (string | symbol)[])
|
|
18
|
+
|
|
19
|
+
for (const key of Object.keys(props)) {
|
|
20
|
+
const desc = Object.getOwnPropertyDescriptor(props, key)
|
|
21
|
+
if (!desc) continue
|
|
22
|
+
if (keySet.has(key)) {
|
|
23
|
+
Object.defineProperty(picked, key, desc)
|
|
24
|
+
} else {
|
|
25
|
+
Object.defineProperty(rest, key, desc)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return [picked, rest]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Merge default values with component props. Defaults are used when
|
|
34
|
+
* the prop is `undefined`. Preserves getter reactivity.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* const merged = mergeProps({ size: "md", variant: "primary" }, props)
|
|
38
|
+
* // merged.size is reactive — falls back to "md" when props.size is undefined
|
|
39
|
+
*/
|
|
40
|
+
export function mergeProps<T extends Record<string, unknown>>(...sources: T[]): T {
|
|
41
|
+
const result = {} as T
|
|
42
|
+
for (const source of sources) {
|
|
43
|
+
for (const key of Object.keys(source)) {
|
|
44
|
+
const desc = Object.getOwnPropertyDescriptor(source, key)
|
|
45
|
+
if (!desc) continue
|
|
46
|
+
// If the source has a getter, wrap it to check if the value is undefined
|
|
47
|
+
// and fall back to the previously defined value
|
|
48
|
+
const existing = Object.getOwnPropertyDescriptor(result, key)
|
|
49
|
+
if (desc.get && existing) {
|
|
50
|
+
const prevGet = existing.get ?? (() => existing.value)
|
|
51
|
+
const nextGet = desc.get
|
|
52
|
+
Object.defineProperty(result, key, {
|
|
53
|
+
get: () => {
|
|
54
|
+
const v = nextGet()
|
|
55
|
+
return v !== undefined ? v : prevGet()
|
|
56
|
+
},
|
|
57
|
+
enumerable: true,
|
|
58
|
+
configurable: true,
|
|
59
|
+
})
|
|
60
|
+
} else if (desc.get) {
|
|
61
|
+
Object.defineProperty(result, key, desc)
|
|
62
|
+
} else if (existing?.get) {
|
|
63
|
+
// New source has a static value, previous had a getter
|
|
64
|
+
const prevGet = existing.get
|
|
65
|
+
const staticVal = desc.value
|
|
66
|
+
if (staticVal !== undefined) {
|
|
67
|
+
Object.defineProperty(result, key, desc)
|
|
68
|
+
} else {
|
|
69
|
+
Object.defineProperty(result, key, {
|
|
70
|
+
get: prevGet,
|
|
71
|
+
enumerable: true,
|
|
72
|
+
configurable: true,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
// Both static — later value wins if defined
|
|
77
|
+
if (desc.value !== undefined || !existing) {
|
|
78
|
+
Object.defineProperty(result, key, desc)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return result
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Unique ID ───────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
let _idCounter = 0
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Generate a unique ID string for accessibility attributes (htmlFor, aria-describedby, etc.).
|
|
92
|
+
* SSR-safe: uses a deterministic counter that resets per request context.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* const id = createUniqueId()
|
|
96
|
+
* return <>
|
|
97
|
+
* <label for={id}>Name</label>
|
|
98
|
+
* <input id={id} />
|
|
99
|
+
* </>
|
|
100
|
+
*/
|
|
101
|
+
export function createUniqueId(): string {
|
|
102
|
+
return `pyreon-${++_idCounter}`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Reset the ID counter (called by SSR per-request). */
|
|
106
|
+
export function _resetIdCounter(): void {
|
|
107
|
+
_idCounter = 0
|
|
108
|
+
}
|
package/src/style.ts
CHANGED
|
@@ -49,6 +49,48 @@ export const CSS_UNITLESS = new Set([
|
|
|
49
49
|
"strokeWidth",
|
|
50
50
|
])
|
|
51
51
|
|
|
52
|
+
// ─── Class utilities ─────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/** Value accepted by the `class` prop — string, array, object, or nested mix. */
|
|
55
|
+
export type ClassValue =
|
|
56
|
+
| string
|
|
57
|
+
| number
|
|
58
|
+
| boolean
|
|
59
|
+
| null
|
|
60
|
+
| undefined
|
|
61
|
+
| ClassValue[]
|
|
62
|
+
| Record<string, boolean | null | undefined | (() => boolean)>
|
|
63
|
+
|
|
64
|
+
function cxObject(obj: Record<string, boolean | null | undefined | (() => boolean)>): string {
|
|
65
|
+
let result = ""
|
|
66
|
+
for (const key in obj) {
|
|
67
|
+
const v = obj[key]
|
|
68
|
+
const truthy = typeof v === "function" ? v() : v
|
|
69
|
+
if (truthy) result = result ? `${result} ${key}` : key
|
|
70
|
+
}
|
|
71
|
+
return result
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function cxArray(arr: ClassValue[]): string {
|
|
75
|
+
let result = ""
|
|
76
|
+
for (const item of arr) {
|
|
77
|
+
const resolved = cx(item)
|
|
78
|
+
if (resolved) result = result ? `${result} ${resolved}` : resolved
|
|
79
|
+
}
|
|
80
|
+
return result
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Resolve a ClassValue into a flat class string (like clsx/cx). */
|
|
84
|
+
export function cx(value: ClassValue): string {
|
|
85
|
+
if (value == null || value === false || value === true) return ""
|
|
86
|
+
if (typeof value === "string") return value
|
|
87
|
+
if (typeof value === "number") return String(value)
|
|
88
|
+
if (Array.isArray(value)) return cxArray(value)
|
|
89
|
+
return cxObject(value)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Style utilities ─────────────────────────────────────────────────────────
|
|
93
|
+
|
|
52
94
|
/** Convert a camelCase CSS property name to kebab-case. */
|
|
53
95
|
export function toKebabCase(str: string): string {
|
|
54
96
|
return str.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { cx } from "../style"
|
|
2
|
+
|
|
3
|
+
describe("cx", () => {
|
|
4
|
+
test("returns empty string for falsy values", () => {
|
|
5
|
+
expect(cx(null)).toBe("")
|
|
6
|
+
expect(cx(undefined)).toBe("")
|
|
7
|
+
expect(cx(false)).toBe("")
|
|
8
|
+
expect(cx(true)).toBe("")
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test("passes through strings", () => {
|
|
12
|
+
expect(cx("foo bar")).toBe("foo bar")
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test("converts numbers to strings", () => {
|
|
16
|
+
expect(cx(42)).toBe("42")
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("filters and joins arrays", () => {
|
|
20
|
+
expect(cx(["foo", false, "bar", null, "baz"])).toBe("foo bar baz")
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("resolves object keys with truthy values", () => {
|
|
24
|
+
expect(cx({ active: true, hidden: false, bold: true })).toBe("active bold")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("resolves object values that are functions", () => {
|
|
28
|
+
expect(cx({ active: () => true, hidden: () => false })).toBe("active")
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("handles nested arrays and objects", () => {
|
|
32
|
+
expect(cx(["base", { active: true }, ["nested", { deep: true }]])).toBe(
|
|
33
|
+
"base active nested deep",
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("handles empty inputs", () => {
|
|
38
|
+
expect(cx([])).toBe("")
|
|
39
|
+
expect(cx({})).toBe("")
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createUniqueId, mergeProps, splitProps } from "../props"
|
|
2
|
+
|
|
3
|
+
describe("splitProps", () => {
|
|
4
|
+
test("splits known keys from rest", () => {
|
|
5
|
+
const props = { label: "Hi", icon: "star", class: "btn", id: "x" }
|
|
6
|
+
const [own, html] = splitProps(props, ["label", "icon"])
|
|
7
|
+
expect(own).toEqual({ label: "Hi", icon: "star" })
|
|
8
|
+
expect(html).toEqual({ class: "btn", id: "x" })
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test("preserves getters", () => {
|
|
12
|
+
let count = 0
|
|
13
|
+
const props = Object.defineProperty({} as Record<string, unknown>, "value", {
|
|
14
|
+
get: () => ++count,
|
|
15
|
+
enumerable: true,
|
|
16
|
+
configurable: true,
|
|
17
|
+
})
|
|
18
|
+
const [own] = splitProps(props, ["value"])
|
|
19
|
+
expect(own.value).toBe(1)
|
|
20
|
+
expect(own.value).toBe(2) // getter called again
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("handles empty keys array", () => {
|
|
24
|
+
const props = { a: 1, b: 2 }
|
|
25
|
+
const [own, rest] = splitProps(props, [])
|
|
26
|
+
expect(own).toEqual({})
|
|
27
|
+
expect(rest).toEqual({ a: 1, b: 2 })
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe("mergeProps", () => {
|
|
32
|
+
test("later sources override earlier", () => {
|
|
33
|
+
const result = mergeProps({ a: 1, b: 2 }, { b: 3, c: 4 })
|
|
34
|
+
expect(result).toEqual({ a: 1, b: 3, c: 4 })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("undefined values don't override defined", () => {
|
|
38
|
+
const result = mergeProps({ size: "md" }, { size: undefined as string | undefined })
|
|
39
|
+
expect(result.size).toBe("md")
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("preserves getters from sources", () => {
|
|
43
|
+
let count = 0
|
|
44
|
+
const source = Object.defineProperty({} as Record<string, unknown>, "val", {
|
|
45
|
+
get: () => ++count,
|
|
46
|
+
enumerable: true,
|
|
47
|
+
configurable: true,
|
|
48
|
+
})
|
|
49
|
+
const result = mergeProps({ val: 0 }, source)
|
|
50
|
+
expect(result.val).toBe(1)
|
|
51
|
+
expect(result.val).toBe(2)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("getter returning undefined falls back to previous value", () => {
|
|
55
|
+
let override: string | undefined
|
|
56
|
+
const source = Object.defineProperty({} as Record<string, unknown>, "size", {
|
|
57
|
+
get: () => override,
|
|
58
|
+
enumerable: true,
|
|
59
|
+
configurable: true,
|
|
60
|
+
})
|
|
61
|
+
const result = mergeProps({ size: "md" }, source)
|
|
62
|
+
expect(result.size).toBe("md") // getter returns undefined, fallback
|
|
63
|
+
|
|
64
|
+
override = "lg"
|
|
65
|
+
expect(result.size).toBe("lg") // getter returns value
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe("createUniqueId", () => {
|
|
70
|
+
test("returns incrementing IDs", () => {
|
|
71
|
+
const id1 = createUniqueId()
|
|
72
|
+
const id2 = createUniqueId()
|
|
73
|
+
expect(id1).toMatch(/^pyreon-\d+$/)
|
|
74
|
+
expect(id2).toMatch(/^pyreon-\d+$/)
|
|
75
|
+
expect(id1).not.toBe(id2)
|
|
76
|
+
})
|
|
77
|
+
})
|