-
Notifications
You must be signed in to change notification settings - Fork 1.1k
/
Copy pathrender.ts
261 lines (226 loc) · 7.06 KB
/
render.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
import { h, cloneVNode, Slots, Fragment, VNode } from 'vue'
import { match } from './match'
export enum Features {
/** No features at all */
None = 0,
/**
* When used, this will allow us to use one of the render strategies.
*
* **The render strategies are:**
* - **Unmount** _(Will unmount the component.)_
* - **Hidden** _(Will hide the component using the [hidden] attribute.)_
*/
RenderStrategy = 1,
/**
* When used, this will allow the user of our component to be in control. This can be used when
* you want to transition based on some state.
*/
Static = 2,
}
export enum RenderStrategy {
Unmount,
Hidden,
}
export function render({
visible = true,
features = Features.None,
ourProps,
theirProps,
...main
}: {
ourProps: Record<string, any>
theirProps: Record<string, any>
slot: Record<string, any>
attrs: Record<string, any>
slots: Slots
name: string
} & {
features?: Features
visible?: boolean
}) {
let props = mergeProps(theirProps, ourProps)
let mainWithProps = Object.assign(main, { props })
// Visible always render
if (visible) return _render(mainWithProps)
if (features & Features.Static) {
// When the `static` prop is passed as `true`, then the user is in control, thus we don't care about anything else
if (props.static) return _render(mainWithProps)
}
if (features & Features.RenderStrategy) {
let strategy = props.unmount ?? true ? RenderStrategy.Unmount : RenderStrategy.Hidden
return match(strategy, {
[RenderStrategy.Unmount]() {
return null
},
[RenderStrategy.Hidden]() {
return _render({
...main,
props: { ...props, hidden: true, style: { display: 'none' } },
})
},
})
}
// No features enabled, just render
return _render(mainWithProps)
}
function _render({
props,
attrs,
slots,
slot,
name,
}: {
props: Record<string, any>
slot: Record<string, any>
attrs: Record<string, any>
slots: Slots
name: string
}) {
let { as, ...incomingProps } = omit(props, ['unmount', 'static'])
let children = slots.default?.(slot)
let dataAttributes: Record<string, string> = {}
if (slot) {
let exposeState = false
let states = []
for (let [k, v] of Object.entries(slot)) {
if (typeof v === 'boolean') {
exposeState = true
}
if (v === true) {
states.push(k)
}
}
if (exposeState) dataAttributes[`data-headlessui-state`] = states.join(' ')
}
if (as === 'template') {
children = flattenFragments(children as VNode[])
if (Object.keys(incomingProps).length > 0 || Object.keys(attrs).length > 0) {
let [firstChild, ...other] = children ?? []
if (!isValidElement(firstChild) || other.length > 0) {
throw new Error(
[
'Passing props on "template"!',
'',
`The current component <${name} /> is rendering a "template".`,
`However we need to passthrough the following props:`,
Object.keys(incomingProps)
.concat(Object.keys(attrs))
.sort((a, z) => a.localeCompare(z))
.map((line) => ` - ${line}`)
.join('\n'),
'',
'You can apply a few solutions:',
[
'Add an `as="..."` prop, to ensure that we render an actual element instead of a "template".',
'Render a single element as the child so that we can forward the props onto that element.',
]
.map((line) => ` - ${line}`)
.join('\n'),
].join('\n')
)
}
return cloneVNode(firstChild, Object.assign({}, incomingProps, dataAttributes))
}
if (Array.isArray(children) && children.length === 1) {
return children[0]
}
return children
}
return h(as, Object.assign({}, incomingProps, dataAttributes), children)
}
/**
* When passed a structure like this:
* <Example><span>something</span></Example>
*
* And Example is defined as:
* <SomeComponent><slot /></SomeComponent>
*
* We need to turn the fragment that <slot> represents into the slot.
* Luckily by this point it's already rendered into an array of VNodes
* for us so we can just flatten it directly.
*
* We have to do this recursively because there could be multiple
* levels of Component nesting all with <slot> elements interspersed
*
* @param children
* @returns
*/
function flattenFragments(children: VNode[]): VNode[] {
return children.flatMap((child) => {
if (child.type === Fragment) {
return flattenFragments(child.children as VNode[])
}
return [child]
})
}
function mergeProps(...listOfProps: Record<any, any>[]) {
if (listOfProps.length === 0) return {}
if (listOfProps.length === 1) return listOfProps[0]
let target: Record<any, any> = {}
let eventHandlers: Record<
string,
((event: { defaultPrevented: boolean }, ...args: any[]) => void | undefined)[]
> = {}
for (let props of listOfProps) {
for (let prop in props) {
// Collect event handlers
if (prop.startsWith('on') && typeof props[prop] === 'function') {
eventHandlers[prop] ??= []
eventHandlers[prop].push(props[prop])
} else {
// Override incoming prop
target[prop] = props[prop]
}
}
}
// Do not attach any event handlers when there is a `disabled` or `aria-disabled` prop set.
if (target.disabled || target['aria-disabled']) {
return Object.assign(
target,
// Set all event listeners that we collected to `undefined`. This is
// important because of the `cloneElement` from above, which merges the
// existing and new props, they don't just override therefore we have to
// explicitly nullify them.
Object.fromEntries(Object.keys(eventHandlers).map((eventName) => [eventName, undefined]))
)
}
// Merge event handlers
for (let eventName in eventHandlers) {
Object.assign(target, {
[eventName](event: { defaultPrevented: boolean }, ...args: any[]) {
let handlers = eventHandlers[eventName]
for (let handler of handlers) {
if (event instanceof Event && event.defaultPrevented) {
return
}
handler(event, ...args)
}
},
})
}
return target
}
export function compact<T extends Record<any, any>>(object: T) {
let clone = Object.assign({}, object)
for (let key in clone) {
if (clone[key] === undefined) delete clone[key]
}
return clone
}
export function omit<T extends Record<any, any>, Keys extends keyof T>(
object: T,
keysToOmit: readonly Keys[] = []
) {
let clone = Object.assign({}, object)
for (let key of keysToOmit) {
if (key in clone) delete clone[key]
}
return clone as Omit<T, Keys>
}
function isValidElement(input: any): boolean {
if (input == null) return false // No children
if (typeof input.type === 'string') return true // 'div', 'span', ...
if (typeof input.type === 'object') return true // Other components
if (typeof input.type === 'function') return true // Built-ins like Transition
return false // Comments, strings, ...
}