UNPKG

@aidenlx/player

Version:

Headless web components that make integrating media on the a web a breeze.

263 lines (239 loc) 9.48 kB
/** * Copied from https://github.com/lit/lit/blob/main/packages/labs/react/src/create-component.ts. * * Waiting for https://github.com/lit/lit/issues/2546 to be resovled before going back to * `@lit-labs/react`. */ import * as ReactModule from 'react'; 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, T>( node: E, name: string, value: unknown, old: unknown, events?: StringValued<T>, ) => { const event = events?.[name as keyof T]; 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; } }; type GlobalEvent<EventType> = EventType extends keyof GlobalEventHandlersEventMap ? GlobalEventHandlersEventMap[EventType] : Event; type Events<S> = { [P in keyof S]?: (e: GlobalEvent<S[P]>) => unknown; }; type StringValued<T> = { [P in keyof T]: string; }; type Constructor<T> = { new (): T }; /** * 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 const createComponent = <I extends HTMLElement, E extends Record<string, string>>( React: typeof ReactModule, tagName: string, elementClass: Constructor<I>, events?: E, displayName?: string, ) => { const Component = React.Component; const createElement = React.createElement; // Props the user is allowed to use, includes standard attributes, children, // ref, as well as special event and element properties. // TODO: we might need to omit more properties from HTMLElement than just // 'children', but 'children' is special to JSX, so we must at least do that. type UserProps = React.PropsWithChildren< React.PropsWithRef<Partial<Omit<I, 'children'>> & Events<E> & React.HTMLAttributes<HTMLElement>> >; // Props used by this component wrapper. This is the UserProps 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 ComponentProps = UserProps & { __forwardedRef?: React.Ref<unknown>; }; // Set of properties/events which should be specially handled by the wrapper // and not handled directly by React. const elementClassProps = new Set(Object.keys(events ?? {})); for (const p in elementClass.prototype) { if (!(p in HTMLElement.prototype)) { if (reservedReactProperties.has(p)) { // Note, this effectively warns only for `ref` since the other // reserved props are on HTMLElement.prototype. To address this // would require crawling down the prototype, which doesn't feel worth // it since implementing these properties on an element is extremely // rare. console.warn( `${tagName} contains property ${p} which is a React ` + `reserved property. It will be used by React and not set on ` + `the element.`, ); } else { elementClassProps.add(p); } } } class ReactComponent extends Component<ComponentProps> { private _element: I | null = null; private _elementProps!: { [index: string]: unknown }; private _userRef?: React.Ref<unknown>; private _ref?: React.RefCallback<I>; static displayName = displayName ?? elementClass.name; private _updateElement(oldProps?: ComponentProps) { 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 as keyof ComponentProps], oldProps ? oldProps[prop as keyof ComponentProps] : 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: ComponentProps) { 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 as React.Ref<unknown>; 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 (elementClassProps.has(k)) { 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(tagName, props); } } const ForwardedComponent = React.forwardRef((props?: UserProps, ref?: React.Ref<unknown>) => createElement( ReactComponent, { ...props, __forwardedRef: ref } as ComponentProps, props?.children, ), ); // To ease debugging in the React Developer Tools ForwardedComponent.displayName = ReactComponent.displayName; return ForwardedComponent; };