import * as React from "react"; import { forEachNode, forEachNodeReplace } from "./forEachNode"; import { NoInfer, Pretty } from "./typeUtils"; type AllowedNodes = React.ElementType | StringConstructor | NumberConstructor; type GetProps = T extends StringConstructor ? never : T extends NumberConstructor ? never : React.ComponentPropsWithRef; type GetNode = T extends NumberConstructor ? number : T extends StringConstructor ? string : React.ReactElement< GetProps, Exclude >; // In an ideal world, OverrideNodeProps would be an union of two objects where one has 'node' // and the other one has 'props` but TS doesn't enforce that and // using Union.Strict gives a really cryptic error. type OverrideNodeProps< T extends AllowedNodes, U extends "throw" | "ignore" | "remove" = "throw", TNode = GetNode, TProps = Pretty>, > = { /** * An array specifying allowed React intrinsic element names or custom component names. * It restricts the provided element types or direct children types (if no content is provided). * Include capital String or capital Number to denote string and number nodes. * Combine with `enforce` to handle disallowed nodes and with `props` or `node` to transform matching nodes. */ allowedNodes?: readonly T[]; /** * Specifies the action to take with nodes that aren't allowed, in combination with `allowedNodes`. * By default an error is thrown when it comes across a disallowed node. */ enforce?: U; children?: U extends "ignore" ? React.ReactNode : TNode; /** * Transforms the props of provided elements or fallback elements. You can provide a function that will be * called with the current props of the element, and the returned object will be merged into the existing props. * Alternatively, you can use an object with prop names and transformation functions. * Return value of transformation functions overrides the prop. There are some predefined Transformation * functions on OverrideNode object which you can use. */ props?: | ((props: TProps) => Partial) | { [K in keyof TProps]?: ( prop: Exclude, propName: K, slotName: string, ) => TProps[K]; }; /** * Called with each provided node and can be used to transform or replace each node. */ node?: (node: TNode) => React.ReactNode; }; export type OverrideConfig = React.ReactElement< OverrideNodeProps >; export type OverrideNode = { ( props: OverrideNodeProps, ): React.ReactElement | null; /** Concatenates a string x with a space to the original prop. */ stringAppend: ( x: string, ) => ( prop: T, ) => | string | (undefined extends T ? undefined : never) | (null extends T ? null : never); /** prepends a string x with a space to the original prop. */ stringPrepend: ( x: string, ) => ( prop: T, ) => | string | (undefined extends T ? undefined : never) | (null extends T ? null : never); /** Intercepts a call to a prop and executes the x function before the original function. */ chainBefore: any>( x: NoInfer, ) => (prop: T, propName: keyof any, slotName: string) => T; /** Intercepts a call to a prop and executes the x function after the original function. */ chainAfter: any>( x: NoInfer, ) => (prop: T, propName: keyof any, slotName: string) => T; /** Overrides prop */ override: (x: NoInfer) => (prop: T) => T; }; export const OverrideNode = (() => { throw new Error( `\`\` can only be a direct child of a slot element. \ Make sure \`OverrideNode\` is not wrapped in an element other than \`slot\``, ); }) as unknown as OverrideNode; function stringConcat(a: unknown, b: unknown): string { let newA = a == undefined ? "" : "" + a; let newB = b == undefined ? "" : "" + b; return newA && newB ? `${newA} ${newB}` : `${newA}${newB}`; } function executeOrThrow( prop: ((...args: any[]) => any) | undefined, args: any[], propName: keyof any, slotName: string, ) { if (typeof prop === "function") { return prop(...args); } else if (prop == undefined) { return undefined; } throw new Error( `Expected the ${propName.toString()} prop for an element for ${slotName} slot to be a \`function\` or \`undefined\`, instead saw ${ prop === null ? null : typeof prop } `, ); } OverrideNode.stringAppend = (x) => (prop): any => { if (x === undefined || x === null) { return prop; } return stringConcat(prop, x); }; OverrideNode.stringPrepend = (x) => (prop): any => { if (x === undefined || x === null) { return prop; } return stringConcat(x, prop); }; OverrideNode.chainBefore = (x) => (prop, propName, slotName): any => { return function (...args: Parameters) { x(...args); return executeOrThrow(prop, args, propName, slotName); }; }; OverrideNode.chainAfter = (x) => (prop, propName, slotName): any => { return function (...args: Parameters) { const returnVal = executeOrThrow(prop, args, propName, slotName); x(...args); return returnVal; }; }; OverrideNode.override = (x) => () => { return x; }; export function extractOverrideConfig( nodes: React.ReactNode, appendConfig: OverrideConfig[], slotName: string, ): { children: React.ReactNode; config: OverrideConfig[]; } { const config: OverrideConfig[] = []; const children = React.Children.map(nodes, (node) => { if (React.isValidElement(node) && node.type === OverrideNode) { node = node as OverrideConfig; config.push(node); if (node.props.children) { return ( {node.props.children} ); } else { // Replace OverrideNode that has no children // with nulls to maintain the shape of children. return null; } } else { if (appendConfig.length) { return ( {node} ); } return node; } }); return { config: config.concat(appendConfig), children }; } function OverrideChildren(props: { children: React.ReactNode; config: OverrideConfig[]; slotName: string; }) { return applyOverrideToAll( props.children, props.config, 0, props.slotName, () => { console.error( `The '${props.slotName}' slot attempted to render a function as fallback content, which is not a valid React element. It will be excluded from the final result.`, ); }, ); } function isAllowed( type: any, allowedNodes: readonly AllowedNodes[] | undefined, ) { if (!Array.isArray(allowedNodes)) { return true; } return allowedNodes.includes(type); } function enforce( enforceType: "throw" | "ignore" | "remove" | undefined, child: T, allowedNodes: readonly AllowedNodes[], slotName: string, ) { switch (enforceType) { case "ignore": return child; case "remove": return null; case "throw": default: { const error = `${ React.isValidElement(child) ? `${ typeof child.type === "function" ? (child.type as any).displayName || child.type.name : child.type } element` : typeof child } is not a valid node type for the '${slotName}' slot. Allowed nodes are: ${allowedNodes.map( (n) => n === String ? "string literal" : n === Number ? "number literal" : typeof n === "function" ? (n as any).displayName || n.name : n, )}`; if (process.env.NODE_ENV === "production") { console.error(error); return null; } throw new Error(error); } } } export function applyOverrideToAll( children: React.ReactNode, config: OverrideConfig[], configStartIndex: number, slotName: string, logFunctionError: ((slotName: string) => void) | null, ) { if (logFunctionError) { forEachNode(children, (child) => { if (typeof child === "function") { logFunctionError(slotName); } }); } return React.Children.map(children, (child) => { return applyOverride( child as Exclude>, config, configStartIndex, slotName, ); }); } export function applyOverride( child: Exclude>, config: OverrideConfig[], configStartIndex: number, slotName: string, ): React.ReactNode { let newChild = child; for (let i = configStartIndex; i < config.length; i++) { switch (newChild) { case undefined: case null: case true: case false: return null; } let currentConfig = config[i].props; if ("props" in currentConfig && "node" in currentConfig) { console.error( `\`OverrideNode\` cannot have both \`props\` and \`node\` as props, only the \`node\` function will be executed. Found This error on \`OverrideNode\` of '${slotName}' slot`, ); } if ( !isAllowed( typeof newChild === "string" ? String : typeof newChild === "number" ? Number : React.isValidElement(newChild) ? newChild.type : newChild, currentConfig.allowedNodes, ) ) { newChild = enforce( currentConfig.enforce, newChild, currentConfig.allowedNodes!, slotName, ); continue; } if (typeof currentConfig.node === "function") { const overriddenNodes = currentConfig.node(newChild); // Consumer can override node's type and even return multiple nodes, // so we need to start applying the remaining override config to each of the returned nodes. return forEachNodeReplace(overriddenNodes, (node) => { if (typeof node === "function") { console.error( `A function was returned to replace a React node in the \`node\` property of the '${slotName}' slot's \`OverrideNode\`. Functions are not valid React elements and will be removed from the final result.`, ); return null; } return applyOverride(node, config, i + 1, slotName); }); } if (React.isValidElement(newChild)) { if (typeof currentConfig.props === "function") { newChild = React.cloneElement( newChild, currentConfig.props(newChild.props as any), ); } else if ( typeof currentConfig.props === "object" && typeof currentConfig.props !== null ) { const newProps: typeof currentConfig.props = {}; for (const [prop, override] of Object.entries(currentConfig.props)) { if (typeof override === "function") { newProps[prop] = override( (newChild.props as any)[prop], prop, slotName, ); } } newChild = React.cloneElement(newChild, newProps); } } } return newChild; }