@pyreon/attrs 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 +6 -5
- package/src/__tests__/attrs.test.ts +488 -0
- package/src/__tests__/attrsHoc.test.ts +136 -0
- package/src/__tests__/utils.test.ts +241 -0
- package/src/attrs.ts +126 -0
- package/src/hoc/attrsHoc.ts +60 -0
- package/src/hoc/index.ts +3 -0
- package/src/index.ts +26 -0
- package/src/init.ts +59 -0
- package/src/isAttrsComponent.ts +16 -0
- package/src/types/AttrsComponent.ts +83 -0
- package/src/types/InitAttrsComponent.ts +19 -0
- package/src/types/attrs.ts +2 -0
- package/src/types/config.ts +13 -0
- package/src/types/configuration.ts +40 -0
- package/src/types/hoc.ts +10 -0
- package/src/types/utils.ts +106 -0
- package/src/utils/attrs.ts +40 -0
- package/src/utils/chaining.ts +21 -0
- package/src/utils/collection.ts +14 -0
- package/src/utils/compose.ts +14 -0
- package/src/utils/statics.ts +16 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { calculateChainOptions, removeUndefinedProps } from "../utils/attrs"
|
|
2
|
+
import { chainOptions } from "../utils/chaining"
|
|
3
|
+
import { removeNullableValues } from "../utils/collection"
|
|
4
|
+
import { calculateHocsFuncs } from "../utils/compose"
|
|
5
|
+
import { createStaticsEnhancers } from "../utils/statics"
|
|
6
|
+
|
|
7
|
+
// --------------------------------------------------------
|
|
8
|
+
// removeUndefinedProps
|
|
9
|
+
// --------------------------------------------------------
|
|
10
|
+
describe("removeUndefinedProps", () => {
|
|
11
|
+
it("should remove properties with undefined values", () => {
|
|
12
|
+
const result = removeUndefinedProps({
|
|
13
|
+
a: 1,
|
|
14
|
+
b: undefined,
|
|
15
|
+
c: "hello",
|
|
16
|
+
})
|
|
17
|
+
expect(result).toEqual({ a: 1, c: "hello" })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("should keep null values", () => {
|
|
21
|
+
const result = removeUndefinedProps({ a: null, b: undefined })
|
|
22
|
+
expect(result).toEqual({ a: null })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("should keep false values", () => {
|
|
26
|
+
const result = removeUndefinedProps({ a: false, b: undefined })
|
|
27
|
+
expect(result).toEqual({ a: false })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("should keep zero values", () => {
|
|
31
|
+
const result = removeUndefinedProps({ a: 0, b: undefined })
|
|
32
|
+
expect(result).toEqual({ a: 0 })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("should keep empty string values", () => {
|
|
36
|
+
const result = removeUndefinedProps({ a: "", b: undefined })
|
|
37
|
+
expect(result).toEqual({ a: "" })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("should return empty object when all values are undefined", () => {
|
|
41
|
+
const result = removeUndefinedProps({ a: undefined, b: undefined })
|
|
42
|
+
expect(result).toEqual({})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("should return all props when none are undefined", () => {
|
|
46
|
+
const input = { a: 1, b: "test", c: true }
|
|
47
|
+
const result = removeUndefinedProps(input)
|
|
48
|
+
expect(result).toEqual(input)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("should handle empty object", () => {
|
|
52
|
+
const result = removeUndefinedProps({})
|
|
53
|
+
expect(result).toEqual({})
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// --------------------------------------------------------
|
|
58
|
+
// removeNullableValues
|
|
59
|
+
// --------------------------------------------------------
|
|
60
|
+
describe("removeNullableValues", () => {
|
|
61
|
+
it("should remove null values", () => {
|
|
62
|
+
const result = removeNullableValues({ a: 1, b: null })
|
|
63
|
+
expect(result).toEqual({ a: 1 })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("should remove undefined values", () => {
|
|
67
|
+
const result = removeNullableValues({ a: 1, b: undefined })
|
|
68
|
+
expect(result).toEqual({ a: 1 })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("should remove false values", () => {
|
|
72
|
+
const result = removeNullableValues({ a: 1, b: false })
|
|
73
|
+
expect(result).toEqual({ a: 1 })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("should keep zero values", () => {
|
|
77
|
+
const result = removeNullableValues({ a: 0, b: null })
|
|
78
|
+
expect(result).toEqual({ a: 0 })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("should keep empty string values", () => {
|
|
82
|
+
const result = removeNullableValues({ a: "", b: null })
|
|
83
|
+
expect(result).toEqual({ a: "" })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("should keep truthy values", () => {
|
|
87
|
+
const result = removeNullableValues({ a: 1, b: "test", c: true })
|
|
88
|
+
expect(result).toEqual({ a: 1, b: "test", c: true })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("should handle empty object", () => {
|
|
92
|
+
const result = removeNullableValues({})
|
|
93
|
+
expect(result).toEqual({})
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// --------------------------------------------------------
|
|
98
|
+
// calculateChainOptions
|
|
99
|
+
// --------------------------------------------------------
|
|
100
|
+
describe("calculateChainOptions", () => {
|
|
101
|
+
it("should return empty object when no options provided", () => {
|
|
102
|
+
const calculate = calculateChainOptions(undefined)
|
|
103
|
+
const result = calculate([{}])
|
|
104
|
+
expect(result).toEqual({})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("should return empty object for empty options array", () => {
|
|
108
|
+
const calculate = calculateChainOptions([])
|
|
109
|
+
const result = calculate([{}])
|
|
110
|
+
expect(result).toEqual({})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("should execute a single option function", () => {
|
|
114
|
+
const fn = (props: any) => ({
|
|
115
|
+
color: props.variant === "primary" ? "blue" : "gray",
|
|
116
|
+
})
|
|
117
|
+
const calculate = calculateChainOptions([fn])
|
|
118
|
+
const result = calculate([{ variant: "primary" }])
|
|
119
|
+
expect(result).toEqual({ color: "blue" })
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("should merge results from multiple option functions", () => {
|
|
123
|
+
const fn1 = (_: any) => ({ color: "blue" })
|
|
124
|
+
const fn2 = (_: any) => ({ size: "large" })
|
|
125
|
+
const calculate = calculateChainOptions([fn1, fn2])
|
|
126
|
+
const result = calculate([{}])
|
|
127
|
+
expect(result).toEqual({ color: "blue", size: "large" })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("should let later functions override earlier ones", () => {
|
|
131
|
+
const fn1 = (_: any) => ({ color: "blue" })
|
|
132
|
+
const fn2 = (_: any) => ({ color: "red" })
|
|
133
|
+
const calculate = calculateChainOptions([fn1, fn2])
|
|
134
|
+
const result = calculate([{}])
|
|
135
|
+
expect(result).toEqual({ color: "red" })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("should pass arguments to each option function", () => {
|
|
139
|
+
const fn = vi.fn((_: any) => ({}))
|
|
140
|
+
const calculate = calculateChainOptions([fn])
|
|
141
|
+
const props = { variant: "primary" }
|
|
142
|
+
calculate([props])
|
|
143
|
+
expect(fn).toHaveBeenCalledWith(props)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// --------------------------------------------------------
|
|
148
|
+
// chainOptions
|
|
149
|
+
// --------------------------------------------------------
|
|
150
|
+
describe("chainOptions", () => {
|
|
151
|
+
it("should return default options when opts is undefined", () => {
|
|
152
|
+
const defaults = [() => ({})]
|
|
153
|
+
const result = chainOptions(undefined, defaults)
|
|
154
|
+
expect(result).toEqual(defaults)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("should append function to defaults", () => {
|
|
158
|
+
const fn1 = () => ({ a: 1 })
|
|
159
|
+
const fn2 = () => ({ b: 2 })
|
|
160
|
+
const result = chainOptions(fn2, [fn1])
|
|
161
|
+
expect(result).toHaveLength(2)
|
|
162
|
+
expect(result[0]).toBe(fn1)
|
|
163
|
+
expect(result[1]).toBe(fn2)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("should wrap object in a function and append", () => {
|
|
167
|
+
const obj = { color: "blue" }
|
|
168
|
+
const result = chainOptions(obj, [])
|
|
169
|
+
expect(result).toHaveLength(1)
|
|
170
|
+
expect(result[0]?.()).toEqual(obj)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("should return empty array when no defaults and undefined opts", () => {
|
|
174
|
+
const result = chainOptions(undefined, [])
|
|
175
|
+
expect(result).toEqual([])
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it("should not mutate the defaults array", () => {
|
|
179
|
+
const defaults = [() => ({})]
|
|
180
|
+
const fn = () => ({ a: 1 })
|
|
181
|
+
const result = chainOptions(fn, defaults)
|
|
182
|
+
expect(defaults).toHaveLength(1)
|
|
183
|
+
expect(result).toHaveLength(2)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// --------------------------------------------------------
|
|
188
|
+
// createStaticsEnhancers
|
|
189
|
+
// --------------------------------------------------------
|
|
190
|
+
describe("createStaticsEnhancers", () => {
|
|
191
|
+
it("should assign options to context", () => {
|
|
192
|
+
const context: Record<string, any> = {}
|
|
193
|
+
createStaticsEnhancers({
|
|
194
|
+
context,
|
|
195
|
+
options: { theme: "dark", variant: "primary" },
|
|
196
|
+
})
|
|
197
|
+
expect(context).toEqual({ theme: "dark", variant: "primary" })
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it("should not modify context when options is empty", () => {
|
|
201
|
+
const context: Record<string, any> = { existing: true }
|
|
202
|
+
createStaticsEnhancers({ context, options: {} })
|
|
203
|
+
expect(context).toEqual({ existing: true })
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it("should merge with existing context properties", () => {
|
|
207
|
+
const context: Record<string, any> = { existing: true }
|
|
208
|
+
createStaticsEnhancers({ context, options: { newProp: "value" } })
|
|
209
|
+
expect(context).toEqual({ existing: true, newProp: "value" })
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// --------------------------------------------------------
|
|
214
|
+
// calculateHocsFuncs
|
|
215
|
+
// --------------------------------------------------------
|
|
216
|
+
describe("calculateHocsFuncs", () => {
|
|
217
|
+
it("should return empty array for empty options", () => {
|
|
218
|
+
const result = calculateHocsFuncs({})
|
|
219
|
+
expect(result).toEqual([])
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it("should filter out non-function values", () => {
|
|
223
|
+
const fn = (x: any) => x
|
|
224
|
+
const result = calculateHocsFuncs({ a: fn, b: "string", c: 123 })
|
|
225
|
+
expect(result).toHaveLength(1)
|
|
226
|
+
expect(result[0]).toBe(fn)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it("should reverse the order of functions", () => {
|
|
230
|
+
const fn1 = (x: any) => x
|
|
231
|
+
const fn2 = (x: any) => x
|
|
232
|
+
const result = calculateHocsFuncs({ a: fn1, b: fn2 })
|
|
233
|
+
expect(result[0]).toBe(fn2)
|
|
234
|
+
expect(result[1]).toBe(fn1)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it("should return empty array for undefined input", () => {
|
|
238
|
+
const result = calculateHocsFuncs(undefined as any)
|
|
239
|
+
expect(result).toEqual([])
|
|
240
|
+
})
|
|
241
|
+
})
|
package/src/attrs.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { compose, hoistNonReactStatics, omit, pick } from "@pyreon/ui-core"
|
|
2
|
+
import { attrsHoc } from "./hoc"
|
|
3
|
+
import type { AttrsComponent as AttrsComponentType } from "./types/AttrsComponent"
|
|
4
|
+
import type { Configuration, ExtendedConfiguration } from "./types/configuration"
|
|
5
|
+
import type { InitAttrsComponent } from "./types/InitAttrsComponent"
|
|
6
|
+
import { calculateChainOptions } from "./utils/attrs"
|
|
7
|
+
import { chainOptions } from "./utils/chaining"
|
|
8
|
+
import { calculateHocsFuncs } from "./utils/compose"
|
|
9
|
+
import { createStaticsEnhancers } from "./utils/statics"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Clones the current configuration and merges new options, then creates a
|
|
13
|
+
* fresh component. This makes the chaining API immutable — each `.attrs()`
|
|
14
|
+
* / `.config()` / `.statics()` call returns a brand-new component with an
|
|
15
|
+
* updated configuration rather than mutating the existing one.
|
|
16
|
+
*/
|
|
17
|
+
type CloneAndEnhance = (
|
|
18
|
+
defaultOpts: Configuration,
|
|
19
|
+
opts: Partial<ExtendedConfiguration>,
|
|
20
|
+
) => ReturnType<typeof attrsComponent>
|
|
21
|
+
|
|
22
|
+
const cloneAndEnhance: CloneAndEnhance = (defaultOpts, opts) =>
|
|
23
|
+
attrsComponent({
|
|
24
|
+
...defaultOpts,
|
|
25
|
+
...(opts.name ? { name: opts.name } : undefined),
|
|
26
|
+
...(opts.component ? { component: opts.component } : undefined),
|
|
27
|
+
attrs: chainOptions(opts.attrs, defaultOpts.attrs),
|
|
28
|
+
filterAttrs: [...(defaultOpts.filterAttrs ?? []), ...(opts.filterAttrs ?? [])],
|
|
29
|
+
priorityAttrs: chainOptions(opts.priorityAttrs, defaultOpts.priorityAttrs),
|
|
30
|
+
statics: { ...defaultOpts.statics, ...opts.statics },
|
|
31
|
+
compose: { ...defaultOpts.compose, ...opts.compose },
|
|
32
|
+
} as Parameters<typeof attrsComponent>[0])
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Core factory that builds an attrs-enhanced Pyreon component.
|
|
36
|
+
*
|
|
37
|
+
* Creates a plain ComponentFn that:
|
|
38
|
+
* 1. Wraps the original with attrsHoc (default props) + user HOCs from `.compose()`.
|
|
39
|
+
* 2. Filters out internal props listed in `filterAttrs`.
|
|
40
|
+
* 3. Attaches `data-attrs` attribute in development for debugging.
|
|
41
|
+
*
|
|
42
|
+
* Then adds chaining methods (`.attrs()`, `.config()`, `.compose()`, `.statics()`)
|
|
43
|
+
* as static properties — each calls `cloneAndEnhance` to produce a new component.
|
|
44
|
+
*
|
|
45
|
+
* In Pyreon, there is no forwardRef — ref flows as a normal prop.
|
|
46
|
+
* Components are plain functions that run once per mount.
|
|
47
|
+
*/
|
|
48
|
+
const attrsComponent: InitAttrsComponent = (options) => {
|
|
49
|
+
const componentName = options.name ?? options.component.displayName ?? options.component.name
|
|
50
|
+
|
|
51
|
+
const RenderComponent = options.component
|
|
52
|
+
|
|
53
|
+
// Build the HOC chain: attrsHoc is always first (resolves default props),
|
|
54
|
+
// followed by user-composed HOCs in reverse order (outermost wraps first).
|
|
55
|
+
const hocsFuncs = [attrsHoc(options), ...calculateHocsFuncs(options.compose)]
|
|
56
|
+
|
|
57
|
+
// The inner component receives already-computed props from the HOC chain.
|
|
58
|
+
// It handles prop filtering and final rendering.
|
|
59
|
+
const EnhancedComponent = (props: Record<string, any>) => {
|
|
60
|
+
const needsFiltering = options.filterAttrs && options.filterAttrs.length > 0
|
|
61
|
+
|
|
62
|
+
const filteredProps = needsFiltering ? omit(props, options.filterAttrs) : props
|
|
63
|
+
|
|
64
|
+
const finalProps =
|
|
65
|
+
process.env.NODE_ENV !== "production"
|
|
66
|
+
? { ...filteredProps, "data-attrs": componentName }
|
|
67
|
+
: filteredProps
|
|
68
|
+
|
|
69
|
+
return RenderComponent(finalProps)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Apply the full HOC chain: compose(attrsHoc, ...userHocs)(EnhancedComponent)
|
|
73
|
+
const AttrsComponent: AttrsComponentType = compose(...hocsFuncs)(EnhancedComponent)
|
|
74
|
+
|
|
75
|
+
AttrsComponent.IS_ATTRS = true
|
|
76
|
+
AttrsComponent.displayName = componentName
|
|
77
|
+
AttrsComponent.meta = {}
|
|
78
|
+
|
|
79
|
+
// Copy static properties from the original component.
|
|
80
|
+
hoistNonReactStatics(AttrsComponent, options.component)
|
|
81
|
+
|
|
82
|
+
// Populate `component.meta` with user-defined statics from `.statics()`.
|
|
83
|
+
createStaticsEnhancers({
|
|
84
|
+
context: AttrsComponent.meta,
|
|
85
|
+
options: options.statics,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// ─── Chaining Methods ──────────────────────────────────
|
|
89
|
+
// Each method creates a new component via cloneAndEnhance.
|
|
90
|
+
// The original component is never mutated.
|
|
91
|
+
Object.assign(AttrsComponent, {
|
|
92
|
+
attrs: (attrs: any, { priority, filter }: any = {}) => {
|
|
93
|
+
const result: Record<string, any> = {}
|
|
94
|
+
|
|
95
|
+
if (filter) {
|
|
96
|
+
result.filterAttrs = filter
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (priority) {
|
|
100
|
+
result.priorityAttrs = attrs as ExtendedConfiguration["priorityAttrs"]
|
|
101
|
+
|
|
102
|
+
return cloneAndEnhance(options, result)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
result.attrs = attrs as ExtendedConfiguration["attrs"]
|
|
106
|
+
|
|
107
|
+
return cloneAndEnhance(options, result)
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
config: (opts: any = {}) => {
|
|
111
|
+
const result = pick(opts)
|
|
112
|
+
|
|
113
|
+
return cloneAndEnhance(options, result)
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
compose: (opts: any) => cloneAndEnhance(options, { compose: opts }),
|
|
117
|
+
|
|
118
|
+
statics: (opts: any) => cloneAndEnhance(options, { statics: opts }),
|
|
119
|
+
|
|
120
|
+
getDefaultAttrs: (props: any) => calculateChainOptions(options.attrs)([props]),
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
return AttrsComponent
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export default attrsComponent
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Configuration } from "../types/configuration"
|
|
2
|
+
import type { ComponentFn } from "../types/utils"
|
|
3
|
+
import { calculateChainOptions, removeUndefinedProps } from "../utils/attrs"
|
|
4
|
+
|
|
5
|
+
export type AttrsStyleHOC = ({
|
|
6
|
+
attrs,
|
|
7
|
+
priorityAttrs,
|
|
8
|
+
}: Pick<Configuration, "attrs" | "priorityAttrs">) => (
|
|
9
|
+
WrappedComponent: ComponentFn<any>,
|
|
10
|
+
) => ComponentFn<any>
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates the core HOC that computes default props from the `.attrs()` chain.
|
|
14
|
+
*
|
|
15
|
+
* This is always the outermost HOC in the compose chain, so it runs first.
|
|
16
|
+
* It resolves both priority and normal attrs callbacks, then merges them
|
|
17
|
+
* with the consumer's explicit props following this precedence:
|
|
18
|
+
*
|
|
19
|
+
* priorityAttrs < normalAttrs < explicit props (last wins)
|
|
20
|
+
*
|
|
21
|
+
* In Pyreon, components are plain functions — no forwardRef needed.
|
|
22
|
+
* The ref flows as a normal prop through the chain.
|
|
23
|
+
*/
|
|
24
|
+
const createAttrsHOC: AttrsStyleHOC = ({ attrs, priorityAttrs }) => {
|
|
25
|
+
// Pre-build the chain reducers once (not per render).
|
|
26
|
+
const calculateAttrs = calculateChainOptions(attrs)
|
|
27
|
+
const calculatePriorityAttrs = calculateChainOptions(priorityAttrs)
|
|
28
|
+
|
|
29
|
+
const attrsHoc = (WrappedComponent: ComponentFn<any>) => {
|
|
30
|
+
const HOCComponent: ComponentFn<any> = (props) => {
|
|
31
|
+
// Strip undefined values so they don't shadow defaults from attrs callbacks.
|
|
32
|
+
const filteredProps = removeUndefinedProps(props)
|
|
33
|
+
|
|
34
|
+
// 1. Resolve priority attrs (lowest precedence defaults).
|
|
35
|
+
const prioritizedAttrs = calculatePriorityAttrs([filteredProps])
|
|
36
|
+
// 2. Resolve normal attrs — these see priority + explicit props as input.
|
|
37
|
+
const finalAttrs = calculateAttrs([
|
|
38
|
+
{
|
|
39
|
+
...prioritizedAttrs,
|
|
40
|
+
...filteredProps,
|
|
41
|
+
},
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
// 3. Merge: priority < normal attrs < explicit props (last wins).
|
|
45
|
+
const finalProps = {
|
|
46
|
+
...prioritizedAttrs,
|
|
47
|
+
...finalAttrs,
|
|
48
|
+
...filteredProps,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return WrappedComponent(finalProps)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return HOCComponent
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return attrsHoc
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default createAttrsHOC
|
package/src/hoc/index.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Attrs } from "./init"
|
|
2
|
+
import attrs from "./init"
|
|
3
|
+
import type { IsAttrsComponent } from "./isAttrsComponent"
|
|
4
|
+
import isAttrsComponent from "./isAttrsComponent"
|
|
5
|
+
import type { AttrsComponent } from "./types/AttrsComponent"
|
|
6
|
+
import type { AttrsCb } from "./types/attrs"
|
|
7
|
+
import type { AttrsComponentType, ConfigAttrs } from "./types/config"
|
|
8
|
+
import type { ComposeParam, GenericHoc } from "./types/hoc"
|
|
9
|
+
import type { ComponentFn, ElementType, TObj } from "./types/utils"
|
|
10
|
+
|
|
11
|
+
export type {
|
|
12
|
+
Attrs,
|
|
13
|
+
AttrsCb,
|
|
14
|
+
AttrsComponent,
|
|
15
|
+
AttrsComponentType,
|
|
16
|
+
ComponentFn,
|
|
17
|
+
ComposeParam,
|
|
18
|
+
ConfigAttrs,
|
|
19
|
+
ElementType,
|
|
20
|
+
GenericHoc,
|
|
21
|
+
IsAttrsComponent,
|
|
22
|
+
TObj,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export { attrs, isAttrsComponent }
|
|
26
|
+
export default attrs
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { isEmpty } from "@pyreon/ui-core"
|
|
2
|
+
import attrsComponent from "./attrs"
|
|
3
|
+
import type { InitAttrsComponent } from "./types/InitAttrsComponent"
|
|
4
|
+
import type { ElementType } from "./types/utils"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Public entry point for creating an attrs-enhanced component.
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* const Button = attrs({ name: 'Button', component: Element })
|
|
11
|
+
* .attrs({ tag: 'button' })
|
|
12
|
+
* .attrs<{ primary?: boolean }>(({ primary }) => ({
|
|
13
|
+
* backgroundColor: primary ? 'blue' : 'gray',
|
|
14
|
+
* }))
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export type Attrs = <C extends ElementType>({
|
|
18
|
+
name,
|
|
19
|
+
component,
|
|
20
|
+
}: {
|
|
21
|
+
name: string
|
|
22
|
+
component: C
|
|
23
|
+
}) => ReturnType<InitAttrsComponent<C>>
|
|
24
|
+
|
|
25
|
+
const attrs: Attrs = ({ name, component }) => {
|
|
26
|
+
// Validate required params in development — fail fast with clear errors.
|
|
27
|
+
if (process.env.NODE_ENV !== "production") {
|
|
28
|
+
type Errors = Partial<{
|
|
29
|
+
component: string
|
|
30
|
+
name: string
|
|
31
|
+
}>
|
|
32
|
+
|
|
33
|
+
const errors: Errors = {}
|
|
34
|
+
if (!component) {
|
|
35
|
+
errors.component = "Parameter `component` is missing in params!"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!name) {
|
|
39
|
+
errors.name = "Parameter `name` is missing in params!"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!isEmpty(errors)) {
|
|
43
|
+
throw Error(JSON.stringify(errors))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Bootstrap with empty configuration — all chains start from scratch.
|
|
48
|
+
return attrsComponent({
|
|
49
|
+
name,
|
|
50
|
+
component,
|
|
51
|
+
attrs: [],
|
|
52
|
+
priorityAttrs: [],
|
|
53
|
+
filterAttrs: [],
|
|
54
|
+
compose: {},
|
|
55
|
+
statics: {},
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default attrs
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type IsAttrsComponent = <T>(component: T) => boolean
|
|
2
|
+
|
|
3
|
+
/** Runtime type guard — checks if a component was created by `attrs()`. */
|
|
4
|
+
const isAttrsComponent: IsAttrsComponent = (component) => {
|
|
5
|
+
if (
|
|
6
|
+
component &&
|
|
7
|
+
(typeof component === "object" || typeof component === "function") &&
|
|
8
|
+
Object.hasOwn(component as object, "IS_ATTRS")
|
|
9
|
+
) {
|
|
10
|
+
return true
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default isAttrsComponent
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { VNode } from "@pyreon/core"
|
|
2
|
+
import type { AttrsCb } from "./attrs"
|
|
3
|
+
import type { ConfigAttrs } from "./config"
|
|
4
|
+
import type { ComposeParam } from "./hoc"
|
|
5
|
+
import type { ElementType, ExtractProps, MergeTypes, TObj } from "./utils"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Props passed to the inner enhanced component.
|
|
9
|
+
* In Pyreon there's no forwardRef — ref flows as a normal prop.
|
|
10
|
+
*/
|
|
11
|
+
export type InnerComponentProps = {
|
|
12
|
+
"data-attrs"?: string | undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param OA Origin component props params.
|
|
17
|
+
* @param EA Extended prop types
|
|
18
|
+
* @param S Defined statics
|
|
19
|
+
* @param HOC High-order components
|
|
20
|
+
* @param DFP Calculated final component props
|
|
21
|
+
*/
|
|
22
|
+
export interface AttrsComponent<
|
|
23
|
+
C extends ElementType = ElementType,
|
|
24
|
+
// original component props
|
|
25
|
+
OA extends TObj = {},
|
|
26
|
+
// extended component props
|
|
27
|
+
EA extends TObj = {},
|
|
28
|
+
// statics
|
|
29
|
+
S extends TObj = {},
|
|
30
|
+
// hocs
|
|
31
|
+
HOC extends TObj = {},
|
|
32
|
+
// calculated final props
|
|
33
|
+
DFP extends Record<string, any> = MergeTypes<[OA, EA]>,
|
|
34
|
+
> {
|
|
35
|
+
// The component is callable — Pyreon components are plain functions
|
|
36
|
+
(props: DFP): VNode | null
|
|
37
|
+
|
|
38
|
+
// CONFIG chaining method
|
|
39
|
+
config: <NC extends ElementType | unknown = unknown>({
|
|
40
|
+
name,
|
|
41
|
+
component: NC,
|
|
42
|
+
DEBUG,
|
|
43
|
+
}: ConfigAttrs<NC>) => NC extends ElementType
|
|
44
|
+
? AttrsComponent<NC, ExtractProps<NC>, EA, S, HOC>
|
|
45
|
+
: AttrsComponent<C, OA, EA, S, HOC>
|
|
46
|
+
|
|
47
|
+
// ATTRS chaining method
|
|
48
|
+
attrs: <P extends TObj | unknown = unknown>(
|
|
49
|
+
param: P extends TObj ? Partial<DFP & P> | AttrsCb<DFP & P> : Partial<DFP> | AttrsCb<DFP>,
|
|
50
|
+
config?: Partial<{
|
|
51
|
+
priority: boolean
|
|
52
|
+
filter: unknown extends P ? string[] : (keyof (EA & P))[]
|
|
53
|
+
}>,
|
|
54
|
+
) => P extends TObj
|
|
55
|
+
? AttrsComponent<C, OA, MergeTypes<[EA, P]>, S, HOC>
|
|
56
|
+
: AttrsComponent<C, OA, EA, S, HOC>
|
|
57
|
+
|
|
58
|
+
// COMPOSE chaining method
|
|
59
|
+
compose: <P extends ComposeParam>(
|
|
60
|
+
param: P,
|
|
61
|
+
) => P extends TObj
|
|
62
|
+
? AttrsComponent<C, OA, EA, S, MergeTypes<[HOC, P]>>
|
|
63
|
+
: AttrsComponent<C, OA, EA, S, HOC>
|
|
64
|
+
|
|
65
|
+
// STATICS chaining method
|
|
66
|
+
statics: <P extends TObj | unknown = unknown>(
|
|
67
|
+
param: P,
|
|
68
|
+
) => P extends TObj
|
|
69
|
+
? AttrsComponent<C, OA, EA, MergeTypes<[S, P]>, HOC>
|
|
70
|
+
: AttrsComponent<C, OA, EA, S, HOC>
|
|
71
|
+
|
|
72
|
+
/** Access to all defined statics on the component. */
|
|
73
|
+
meta: S
|
|
74
|
+
|
|
75
|
+
getDefaultAttrs: (props: TObj) => TObj
|
|
76
|
+
|
|
77
|
+
readonly $$originTypes: OA
|
|
78
|
+
readonly $$extendedTypes: EA
|
|
79
|
+
readonly $$types: DFP
|
|
80
|
+
|
|
81
|
+
IS_ATTRS: true
|
|
82
|
+
displayName: string
|
|
83
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AttrsComponent } from "./AttrsComponent"
|
|
2
|
+
import type { Configuration } from "./configuration"
|
|
3
|
+
import type { ElementType, ExtractProps } from "./utils"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Type of the internal `attrsComponent` factory function.
|
|
7
|
+
* Takes a full Configuration and returns an AttrsComponent whose
|
|
8
|
+
* original props (OA) are extracted from the component type C,
|
|
9
|
+
* with all extension slots (EA, S, HOC) starting empty.
|
|
10
|
+
*/
|
|
11
|
+
export type InitAttrsComponent<C extends ElementType = ElementType> = (
|
|
12
|
+
params: Configuration<C>,
|
|
13
|
+
) => AttrsComponent<
|
|
14
|
+
C,
|
|
15
|
+
ExtractProps<C>, // OA — original component props
|
|
16
|
+
{}, // EA — extended props (empty initially)
|
|
17
|
+
{}, // S — statics (empty initially)
|
|
18
|
+
{} // HOC — composed HOCs (empty initially)
|
|
19
|
+
>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ElementType } from "./utils"
|
|
2
|
+
|
|
3
|
+
/** A component that has been enhanced by attrs — identified by the `IS_ATTRS` marker. */
|
|
4
|
+
export type AttrsComponentType = ElementType & {
|
|
5
|
+
IS_ATTRS: true
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Parameters accepted by the `.config()` chaining method. */
|
|
9
|
+
export type ConfigAttrs<C extends ElementType | unknown> = Partial<{
|
|
10
|
+
name: string
|
|
11
|
+
component: C
|
|
12
|
+
DEBUG: boolean
|
|
13
|
+
}>
|