-
Notifications
You must be signed in to change notification settings - Fork 880
/
create-component.ts
355 lines (326 loc) · 11.6 KB
/
create-component.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
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
/**
* @license
* Copyright 2018 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const NODE_MODE = false;
const global = NODE_MODE ? globalThis : window;
const htmlElementShimNeeded = NODE_MODE && global.HTMLElement === undefined;
if (htmlElementShimNeeded) {
global.HTMLElement = class HTMLElement {} as unknown as typeof HTMLElement;
}
// Match a prop name to a typed event callback by
// adding an Event type as an expected property on a string.
export type EventName<T extends Event = Event> = string & {
__event_type: T;
};
// A key value map matching React prop names to event names
type EventNames = Record<string, EventName | string>;
// A map of expected event listener types based on EventNames
type EventListeners<R extends EventNames> = {
[K in keyof R]: R[K] extends EventName
? (e: R[K]['__event_type']) => void
: (e: Event) => void;
};
type ReactProps<I, E> = Omit<React.HTMLAttributes<I>, keyof E>;
type ElementWithoutPropsOrEventListeners<I, E> = Omit<
I,
keyof E | keyof ReactProps<I, E>
>;
// Props the user is allowed to use, includes standard attributes, children,
// ref, as well as special event and element properties.
type WebComponentProps<
I extends HTMLElement,
E extends EventNames = {}
> = Partial<
ReactProps<I, E> &
ElementWithoutPropsOrEventListeners<I, E> &
EventListeners<E>
>;
// Props used by this component wrapper. This is the WebComponentProps and the
// special `__forwardedRef` property. Note, this ref is special because
// it's both needed in this component to get access to the rendered element
// and must fulfill any ref passed by the user.
type ReactComponentProps<
I extends HTMLElement,
E extends EventNames = {}
> = WebComponentProps<I, E> & {
__forwardedRef?: React.Ref<I>;
};
export type ReactWebComponent<
I extends HTMLElement,
E extends EventNames = {}
> = React.ForwardRefExoticComponent<
React.PropsWithoutRef<WebComponentProps<I, E>> & React.RefAttributes<I>
>;
interface Options<I extends HTMLElement, E extends EventNames = {}> {
tagName: string;
elementClass: Constructor<I>;
react: typeof window.React;
events?: E;
displayName?: string;
}
type Constructor<T> = {new (): T};
const reservedReactProperties = new Set([
'children',
'localName',
'ref',
'style',
'className',
]);
const listenedEvents: WeakMap<
Element,
Map<string, EventListenerObject>
> = new WeakMap();
/**
* Adds an event listener for the specified event to the given node. In the
* React setup, there should only ever be one event listener. Thus, for
* efficiency only one listener is added and the handler for that listener is
* updated to point to the given listener function.
*/
const addOrUpdateEventListener = (
node: Element,
event: string,
listener: (event?: Event) => void
) => {
let events = listenedEvents.get(node);
if (events === undefined) {
listenedEvents.set(node, (events = new Map()));
}
let handler = events.get(event);
if (listener !== undefined) {
// If necessary, add listener and track handler
if (handler === undefined) {
events.set(event, (handler = {handleEvent: listener}));
node.addEventListener(event, handler);
// Otherwise just update the listener with new value
} else {
handler.handleEvent = listener;
}
// Remove listener if one exists and value is undefined
} else if (handler !== undefined) {
events.delete(event);
node.removeEventListener(event, handler);
}
};
/**
* Sets properties and events on custom elements. These properties and events
* have been pre-filtered so we know they should apply to the custom element.
*/
const setProperty = <E extends Element>(
node: E,
name: string,
value: unknown,
old: unknown,
events?: EventNames
) => {
const event = events?.[name];
if (event !== undefined) {
// Dirty check event value.
if (value !== old) {
addOrUpdateEventListener(node, event, value as (e?: Event) => void);
}
} else {
// But don't dirty check properties; elements are assumed to do this.
node[name as keyof E] = value as E[keyof E];
}
};
// Set a React ref. Note, there are 2 kinds of refs and there's no built in
// React API to set a ref.
const setRef = (ref: React.Ref<unknown>, value: Element | null) => {
if (typeof ref === 'function') {
(ref as (e: Element | null) => void)(value);
} else {
(ref as {current: Element | null}).current = value;
}
};
/**
* Creates a React component for a custom element. Properties are distinguished
* from attributes automatically, and events can be configured so they are
* added to the custom element as event listeners.
*
* @param options An options bag containing the parameters needed to generate
* a wrapped web component.
*
* @param options.react The React module, typically imported from the `react` npm
* package.
* @param options.tagName The custom element tag name registered via
* `customElements.define`.
* @param options.elementClass The custom element class registered via
* `customElements.define`.
* @param options.events An object listing events to which the component can listen. The
* object keys are the event property names passed in via React props and the
* object values are the names of the corresponding events generated by the
* custom element. For example, given `{onactivate: 'activate'}` an event
* function may be passed via the component's `onactivate` prop and will be
* called when the custom element fires its `activate` event.
* @param options.displayName A React component display name, used in debugging
* messages. Default value is inferred from the name of custom element class
* registered via `customElements.define`.
*/
export function createComponent<
I extends HTMLElement,
E extends EventNames = {}
>(options: Options<I, E>): ReactWebComponent<I, E>;
/**
* @deprecated Use `createComponent(options)` instead of individual arguments.
*
* Creates a React component for a custom element. Properties are distinguished
* from attributes automatically, and events can be configured so they are
* added to the custom element as event listeners.
*
* @param React The React module, typically imported from the `react` npm
* package.
* @param tagName The custom element tag name registered via
* `customElements.define`.
* @param elementClass The custom element class registered via
* `customElements.define`.
* @param events An object listing events to which the component can listen. The
* object keys are the event property names passed in via React props and the
* object values are the names of the corresponding events generated by the
* custom element. For example, given `{onactivate: 'activate'}` an event
* function may be passed via the component's `onactivate` prop and will be
* called when the custom element fires its `activate` event.
* @param displayName A React component display name, used in debugging
* messages. Default value is inferred from the name of custom element class
* registered via `customElements.define`.
*/
export function createComponent<
I extends HTMLElement,
E extends EventNames = {}
>(
ReactOrOptions: typeof window.React,
tagName: string,
elementClass: Constructor<I>,
events?: E,
displayName?: string
): ReactWebComponent<I, E>;
export function createComponent<
I extends HTMLElement,
E extends EventNames = {}
>(
ReactOrOptions: typeof window.React | Options<I, E> = window.React,
tagName?: string,
elementClass?: Constructor<I>,
events?: E,
displayName?: string
): ReactWebComponent<I, E> {
// digest overloaded parameters
let React: typeof window.React;
let tag: string;
let element: Constructor<I>;
if (tagName === undefined) {
const options = ReactOrOptions as Options<I, E>;
({tagName: tag, elementClass: element, events, displayName} = options);
React = options.react;
} else {
React = ReactOrOptions as typeof window.React;
element = elementClass as Constructor<I>;
tag = tagName;
}
const Component = React.Component;
const createElement = React.createElement;
const eventProps = new Set(Object.keys(events ?? {}));
type Props = ReactComponentProps<I, E>;
class ReactComponent extends Component<Props> {
private _element: I | null = null;
private _elementProps!: {[index: string]: unknown};
private _userRef?: React.Ref<I>;
private _ref?: React.RefCallback<I>;
static displayName = displayName ?? element.name;
private _updateElement(oldProps?: Props) {
if (this._element === null) {
return;
}
// Set element properties to the values in `this.props`
for (const prop in this._elementProps) {
setProperty(
this._element,
prop,
this.props[prop],
oldProps ? oldProps[prop] : undefined,
events
);
}
// Note, the spirit of React might be to "unset" any old values that
// are no longer included; however, there's no reasonable value to set
// them to so we just leave the previous state as is.
}
/**
* Updates element properties correctly setting properties
* on mount.
*/
override componentDidMount() {
this._updateElement();
}
/**
* Updates element properties correctly setting properties
* on every update. Note, this does not include mount.
*/
override componentDidUpdate(old: Props) {
this._updateElement(old);
}
/**
* Renders the custom element with a `ref` prop which allows this
* component to reference the custom element.
*
* Standard attributes are passed to React and element properties and events
* are updated in componentDidMount/componentDidUpdate.
*
*/
override render() {
// Since refs only get fulfilled once, pass a new one if the user's
// ref changed. This allows refs to be fulfilled as expected, going from
// having a value to null.
const userRef = this.props.__forwardedRef ?? null;
if (this._ref === undefined || this._userRef !== userRef) {
this._ref = (value: I | null) => {
if (this._element === null) {
this._element = value;
}
if (userRef !== null) {
setRef(userRef, value);
}
this._userRef = userRef;
};
}
// Filters class properties out and passes the remaining
// attributes to React. This allows attributes to use framework rules
// for setting attributes and render correctly under SSR.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const props: any = {ref: this._ref};
// Note, save element props while iterating to avoid the need to
// iterate again when setting properties.
this._elementProps = {};
for (const [k, v] of Object.entries(this.props)) {
if (k === '__forwardedRef') continue;
if (
eventProps.has(k) ||
(!reservedReactProperties.has(k) &&
!(k in HTMLElement.prototype) &&
k in element.prototype)
) {
this._elementProps[k] = v;
} else {
// React does *not* handle `className` for custom elements so
// coerce it to `class` so it's handled correctly.
props[k === 'className' ? 'class' : k] = v;
}
}
return createElement<React.HTMLAttributes<I>, I>(tag, props);
}
}
const ForwardedComponent: ReactWebComponent<I, E> = React.forwardRef<
I,
WebComponentProps<I, E>
>((props, ref) =>
createElement<Props, ReactComponent, typeof ReactComponent>(
ReactComponent,
{...props, __forwardedRef: ref},
props?.children
)
);
// To ease debugging in the React Developer Tools
ForwardedComponent.displayName = ReactComponent.displayName;
return ForwardedComponent;
}