@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.
@@ -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?: string | (() => string) | undefined
47
- className?: string | (() => string) | undefined
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: Event) => void) | undefined
120
- onInput?: ((e: InputEvent) => void) | undefined
121
- onSubmit?: ((e: SubmitEvent) => void) | undefined
122
- onReset?: ((e: Event) => void) | undefined
123
- onScroll?: ((e: Event) => void) | undefined
124
- onWheel?: ((e: WheelEvent) => void) | undefined
125
- onDragStart?: ((e: DragEvent) => void) | undefined
126
- onDragEnd?: ((e: DragEvent) => void) | undefined
127
- onDragOver?: ((e: DragEvent) => void) | undefined
128
- onDragEnter?: ((e: DragEvent) => void) | undefined
129
- onDragLeave?: ((e: DragEvent) => void) | undefined
130
- onDrop?: ((e: DragEvent) => void) | undefined
131
- onTouchStart?: ((e: TouchEvent) => void) | undefined
132
- onTouchEnd?: ((e: TouchEvent) => void) | undefined
133
- onTouchMove?: ((e: TouchEvent) => void) | undefined
134
- onPointerDown?: ((e: PointerEvent) => void) | undefined
135
- onPointerUp?: ((e: PointerEvent) => void) | undefined
136
- onPointerMove?: ((e: PointerEvent) => void) | undefined
137
- onPointerEnter?: ((e: PointerEvent) => void) | undefined
138
- onPointerLeave?: ((e: PointerEvent) => void) | undefined
139
- onPointerCancel?: ((e: PointerEvent) => void) | undefined
140
- onPointerOver?: ((e: PointerEvent) => void) | undefined
141
- onPointerOut?: ((e: PointerEvent) => void) | undefined
142
- onTransitionEnd?: ((e: TransitionEvent) => void) | undefined
143
- onAnimationStart?: ((e: AnimationEvent) => void) | undefined
144
- onAnimationEnd?: ((e: AnimationEvent) => void) | undefined
145
- onAnimationIteration?: ((e: AnimationEvent) => void) | undefined
146
- onLoad?: ((e: Event) => void) | undefined
147
- onError?: ((e: Event | string) => void) | undefined
148
- onAbort?: ((e: Event) => void) | undefined
149
- onSelect?: ((e: Event) => void) | undefined
150
- onCopy?: ((e: ClipboardEvent) => void) | undefined
151
- onCut?: ((e: ClipboardEvent) => void) | undefined
152
- onPaste?: ((e: ClipboardEvent) => void) | undefined
153
- // Catch-all for data-* and other arbitrary attributes
154
- [key: string]: unknown
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 SvgAttributes extends PyreonHTMLAttributes<SVGElement> {
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: PyreonHTMLAttributes
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
+ })