@workday/canvas-kit-react
Version:
The parent module that contains all Workday Canvas Kit React components
1,000 lines (946 loc) • 39.6 kB
text/typescript
import React from 'react';
import {assert} from './assert';
import {memoize} from './memoize';
import {MergeProps, mergeProps, RemoveNulls} from './mergeProps';
import {Model} from './models';
/**
* Adds the `as` to the style interface to support `as` in styled components
*/
export type StyledType = {
as?: React.ElementType;
};
// For React class components
type Constructor<T> = new (...args: any[]) => T;
/**
* Extract a Ref from T if it exists
* This will return the following:
*
* - `undefined` => `never`
* - `'button'` => `React.Ref<HTMLButtonElement>`
* - `ElementComponent<'button', ButtonProps>` => `React.Ref<HTMLButtonElement>`
*/
type ExtractRef<T> = T extends undefined // test if T was even passed in
? never // T not passed in, we'll set the ref to `never`
: T extends Constructor<infer C>
? React.LegacyRef<C>
: React.Ref<
T extends keyof ElementTagNameMap // test if T is an element string like 'button' or 'div'
? ElementTagNameMap[T] // if yes, the ref should be the element interface. `'button' => HTMLButtonElement`
: T extends ElementComponent<infer U, any> // if no, check if we can infer the the element type from an `ElementComponent` interface
? U extends keyof ElementTagNameMap // test inferred U to see if it extends an element string
? ElementTagNameMap[U] // if yes, use the inferred U and convert to an element interface. `'button' => HTMLButtonElement`
: U // if no, fall back to inferred U. Hopefully it is already an element interface
: T extends React.FC<{ref?: infer R}> // test if T extends a React functional component with a ref (Emotion's styled components do this)
? R extends React.RefObject<infer E> // if yes, extract the element interface. This step unwraps the ref. Otherwise we'll get React.Ref<React.Ref<Element>>
? E // if yes, use the inferred E
: never // never here prevents double refs. Basically the return would be React.Ref<E | {this value}>. I'm not entirely sure why...
: T // if no, fall back to T. Hopefully it is already an element interface
>;
/**
* Generic component props with "as" prop
* @template P Additional props
* @template ElementType React component or string element
*/
export type PropsWithoutAs<P, ElementType extends React.ElementType> = P &
Omit<React.ComponentProps<ElementType>, 'as' | keyof P> & {
/**
* Optional ref. If the component represents an element, this ref will be a reference to the
* real DOM element of the component. If `as` is set to an element, it will be that element.
* If `as` is a component, the reference will be to that component (or element if the component
* uses `React.forwardRef`).
*/
ref?: ExtractRef<ElementType>;
};
/**
* Extracts only the HTML attribute interface from `JSX.IntrinsicElements[E]`. This effectively removes `ref` and `key`
* without using `Omit` which makes the returned type more difficult to understand.
*
* For example:
* JSX.IntrinsicElements['button'] // React.ClassAttributes<HTMLButtonElement> & React.ButtonHTMLAttributes<HTMLButtonElement>
* ExtractHTMLAttributes<JSX.IntrinsicElements['button']> // React.HTMLButtonAttributes<HTMLButtonElement>
*/
type ExtractHTMLAttributes<T extends React.DetailedHTMLProps<any, any>> =
T extends React.DetailedHTMLProps<infer P, any> ? P : T;
/**
* Extract props from any component that was created using `createComponent`. It will return the
* HTML attribute interface of the default element used with `createComponent`. If you use `as`, the
* HTML attribute interface can change, so you can use an override to the element you wish to use.
* You can also disable the HTML attribute by passing `never`:
*
* - `ExtractProps<typeof Card>`: `CardProps & React.HTMLAttributes<HTMLDivElement>`
* - `ExtractProps<typeof Card, 'aside'>`: `CardProps & React.HTMLAttributes<HTMLElement>`
* - `ExtractProps<typeof Card, never>`: `CardProps`
*
* @template TComponent The component you wish to extract props from. Needs 'typeof` in front:
* `typeof Card`
* @template TElement An optional override of the element that will be used. Define this if you use
* an `as` on the component
*
* @example
* interface MyProps extends ExtractProps<typeof Card.Body> {}
*
* ExtractProps<typeof Card>; // CardProps & React.HTMLAttributes<HTMLDivElement>
* ExtractProps<typeof Card, 'aside'>; // CardProps & React.HTMLAttributes<HTMLElement>
* ExtractProps<typeof Card, never>; // CardProps
*/
export type ExtractProps<
TComponent,
TElement extends
| keyof JSX.IntrinsicElements
| ElementComponent<any, any>
| Component<any>
| React.ComponentType<any>
| undefined
| never = undefined
> = ExtractMaybeModel<
TComponent,
TComponent extends {__element: infer E; __props: infer P} //ElementComponent<infer E, infer P> // test if `TComponent` is an `ElementComponent`, while inferring both default element and props associated
? [TElement] extends [never] // test if user passed `never` for the `TElement` override. We have to test `never` first, otherwise TS gets confused and `ExtractProps` will return `never`. https://github.com/microsoft/TypeScript/issues/23182
? P // else attach only inferred props `P`
: TElement extends undefined // else test if TElement was defined
? E extends keyof JSX.IntrinsicElements // test if the inferred element `E` is in `JSX.IntrinsicElements`
? P & ExtractHTMLAttributes<JSX.IntrinsicElements[E]> // `TElement` wasn't explicitly defined, so let's fall back to the inferred element's HTML attribute interface + props `P`
: P & ExtractProps<E> // E isn't in `JSX.IntrinsicElements`, return inferred props `P` + props extracted from component `E`.
: TElement extends keyof JSX.IntrinsicElements // `TElement` was defined, test if it is in `JSX.IntrinsicElements`
? P & ExtractHTMLAttributes<JSX.IntrinsicElements[TElement]> // `TElement` is in `JSX.IntrinsicElements`, return inferred props `P` + HTML attributes of `TElement`
: P & ExtractProps<TElement> // `TElement` is not in `JSX.IntrinsicElements`, return inferred props `P` + props extracted from component `TElement`.
: TComponent extends {__props: infer P} // test if `TComponent` is a `Component`, while inferring props `P`
? P // else attach only inferred props `P`
: TComponent extends React.ComponentType<infer P> // test if `TComponent` is a `React.ComponentType` (class or functional component)
? P // it was a `React.ComponentType`, return inferred props `P`
: {} // We don't know what `TComponent` was, return an empty object
>;
// If the component has a model, be sure to add it to the prop interface
type ExtractMaybeModel<TComponent, P> = TComponent extends {__model: infer M} // test if a model is used
? P & PropsWithModel<M>
: P;
/**
* `PropsWithModel` adds the `model` and `elemPropsHook` props. If a model-based component has an
* `as` referencing another model-based component, the `model` of the `as` component should win.
*/
export type PropsWithModel<TModel, ElementType = {}> = {
/**
* Optional model to pass to the component. This will override the default model created for the
* component. This can be useful if you want to access to the state and events of the model, or if
* you have nested components of the same type and you need to override the model provided by
* React Context.
*/
model?: ElementType extends {__model: infer M} ? M : TModel; // If the `ElementType` is a `ElementComponentM`, extract the model. Otherwise fall back to the `TModel` type
/**
* Optional hook that receives the model and all props to be applied to the element. If you use
* this, it is your responsibility to return props, merging as appropriate. For example, returning
* an empty object will disable all elemProps hooks associated with this component. This allows
* finer control over a component without creating a new one.
*/
elemPropsHook?: <TProps>(model: TModel, elemProps: TProps) => any;
};
/**
* Component type that allows for `as` to change the element or component type.
* Passing `as` will correctly change the allowed interface of the JSX element
*/
export type ElementComponent<E extends React.ElementType, P> = {
displayName?: string;
<ElementType extends React.ElementType>(
props: PropsWithoutAs<P, ElementType> & {
/**
* Optional override of the default element used by the component. Any valid tag or Component.
* If you provided a Component, this component should forward the ref using `React.forwardRef`
* and spread extra props to a root element.
*/
as: ElementType;
}
): JSX.Element;
(props: PropsWithoutAs<P, E>): JSX.Element;
as<E extends React.ElementType>(as: E): ElementComponent<E, P>;
/** @private Only used internally to hold the element type for extraction */
__element: E;
/** @private Only used internally to hold the element type for extraction */
__props: P;
};
/**
* Component type that allows for `as` to change the element or component type.
* Passing `as` will correctly change the allowed interface of the JSX element.
* Same as `ElementComponent`, but adds a model to the interface.
*/
export type ElementComponentM<E extends React.ElementType, P, TModel> = {
displayName?: string;
<ElementType extends React.ElementType>(
props: PropsWithoutAs<P, ElementType> &
PropsWithModel<TModel, ElementType> & {
/**
* Optional override of the default element used by the component. Any valid tag or Component.
* If you provided a Component, this component should forward the ref using `React.forwardRef`
* and spread extra props to a root element.
*/
as: ElementType;
}
): JSX.Element;
(props: PropsWithoutAs<P, E> & PropsWithModel<TModel>): JSX.Element;
as<E extends React.ElementType>(as: E): ElementComponentM<E, P, TModel>;
/** @private Only used internally to hold the element type for extraction */
__element: E;
/** @private Only used internally to hold the element type for extraction */
__props: P;
/** @private Only used internally to hold the element type for extraction */
__model: TModel;
};
export type ComponentM<P, TModel> = {
displayName?: string;
(props: P & PropsWithModel<TModel>): JSX.Element;
/** @private Only used internally to hold the element type for extraction */
__props: P;
/** @private Only used internally to hold the element type for extraction */
__model: TModel;
};
export type Component<P> = {
displayName?: string;
(props: P): JSX.Element;
/** @private Only used internally to hold the element type for extraction */
__props: P;
};
interface RefForwardingComponent<T, P = {}> {
(
props: React.PropsWithChildren<P>,
/**
* A ref to be forwarded. Pass it along to the root element. If no element was passed, this
* will result in a `never`
*/
ref: ExtractRef<T>,
/**
* An element - either a JSX element or a `ElementComponent`. This should be passed as an `as`
* to a root element or be the root element. If no element was passed, this will result in a
* `never`
*/
Element: T extends undefined ? never : T
): JSX.Element | null;
}
function defaultGetElemProps(input: any) {
return input;
}
export const createContainer =
<
E extends
| keyof JSX.IntrinsicElements
| React.ComponentType
| ElementComponent<any, any>
| ElementComponentM<any, any, any>
| undefined = undefined
>(
as?: E
) =>
<
TModelHook extends ((config: any) => Model<any, any>) & {
Context?: React.Context<any>;
} & {defaultConfig?: Record<string, any>},
TDefaultContext extends Model<any, any>,
TElemPropsHook,
SubComponents = {}
>({
displayName,
modelHook,
elemPropsHook,
defaultContext,
subComponents,
}: {
displayName?: string;
modelHook: TModelHook;
elemPropsHook?: TElemPropsHook;
defaultContext?: TDefaultContext;
subComponents?: SubComponents;
}) => {
assert(
modelHook.Context,
'createContainer only works on models with context. Please use `createModelHook` to create the `modelHook`'
);
const Context = modelHook.Context;
return <Props>(
Component: (
props: CompoundProps<Props, TElemPropsHook, E>,
Element: E extends undefined ? never : E,
model: TModelHook extends (config: infer TConfig) => infer TModel ? TModel : never
) => JSX.Element | null
): (TModelHook extends (config: infer TConfig) => infer TModel
? E extends undefined
? ComponentM<Props & TConfig, TModel>
: ElementComponentM<
// E is not `undefined` here, but Typescript thinks it could be, so we add another `undefined`
// check and cast to a `React.FC` to match a valid signature for `ElementComponent`.
// `React.FC` was chosen as the simplest valid interface.
E extends undefined ? React.FC : E,
Props & TConfig,
TModel
>
: never) &
SubComponents => {
const ReturnedComponent = React.forwardRef<
E,
Props & {as?: React.ElementType} & {model?: any}
>(({as: asOverride, model, ...props}, ref) => {
const localModel = useDefaultModel(model, props, modelHook, asOverride);
const elemProps = ((modelHook as any).getElemProps || defaultGetElemProps)(props);
const finalElemProps = elemPropsHook
? (elemPropsHook as any)(localModel, elemProps, ref)
: elemProps;
// make sure there's always a ref being passed, even if there are no elemProps hooks to run
if (ref && !finalElemProps.hasOwnProperty('ref')) {
finalElemProps.ref = ref;
}
return React.createElement(
Context.Provider,
{value: localModel},
Component(
finalElemProps,
// Cast to `any` to avoid: "ts(2345): Type 'undefined' is not assignable to type 'E extends
// undefined ? never : E'" I'm not sure I can actually cast to this conditional type and it
// doesn't actually matter, so cast to `any` it is.
(asOverride || as) as any,
localModel
)
);
});
Object.keys(subComponents || {}).forEach(key => {
// `ReturnedComponent` is a `React.ForwardRefExoticComponent` which has no additional keys so
// we'll cast to `Record<string, any>` for assignment. Note the lack of type checking
// properties. Take care when changing the runtime of this function.
(ReturnedComponent as Record<string, any>)[key] = (subComponents as Record<string, any>)[
key
];
// Add a displayName if one isn't already created. This prevents us from having to add
// `displayName` to subcomponents if a container component has a `displayName`
if (displayName && !(subComponents as Record<string, any>)[key].displayName) {
(subComponents as Record<string, any>)[key].displayName = `${displayName}.${key}`;
}
});
ReturnedComponent.displayName = displayName;
(ReturnedComponent as any).__hasModel = true;
// The `any`s are here because `ElementComponent` takes care of the `as` type and the
// `ReturnComponent` type is overridden
(ReturnedComponent as any).as = memoize(
(as: any) =>
createContainer(as)({displayName, subComponents, modelHook, elemPropsHook})(
Component as any
),
as => as
);
// Cast as `any`. We have already specified the return type. Be careful making changes to this
// file due to this `any` `ReturnedComponent` is a `React.ForwardRefExoticComponent`, but we want
// it to be either an `Component` or `ElementComponent`
return ReturnedComponent as any;
};
};
/**
* If elemProps returns `null` for a prop, that prop is to be removed from the prop
* list. This is useful for passing props to an elemProp hook that should not be exposed
* to the DOM element
*/
type RemoveNull<T> = {[K in keyof T]: Exclude<T[K], null>};
/**
* Props for the compound component based on props, elemPropsHook, and element. It will
* return a prop interface according to all these inputs. The following will be added to
* the passed in `Props` type:
* - The prop interface returned by the `elemPropsHook` function
* - if there is no detected `ref` in the `Props` interface, a `ref` will be added based on the element type E
* - if there is no detected `children` in the `Props` interface, `children` will be added based on `ReactNode`
*/
type CompoundProps<Props, TElemPropsHook, E> = Props &
(TElemPropsHook extends (...args: any[]) => infer TProps // try to infer TProps returned from the elemPropsHook function
? RemoveNull<Omit<TProps, 'ref'> & {ref: ExtractRef<E>}>
: {ref: ExtractRef<E>}) &
(Props extends {children: any}
? {}
: {
children?: React.ReactNode;
});
export const createSubcomponent =
<
E extends
| keyof JSX.IntrinsicElements
| React.ComponentType
| ElementComponent<any, any>
| ElementComponentM<any, any, any>
| undefined = undefined
>(
as?: E
) =>
<
TElemPropsHook, // normally we'd put a constraint here, but doing so causes the `infer` below to fail to infer the return props
TModelHook extends ((config: any) => Model<any, any>) & {Context?: React.Context<any>},
SubComponents = {}
>({
displayName,
modelHook,
elemPropsHook,
subComponents,
}: {
/** @deprecated ⚠️ `displayName` has been deprecated and will be removed in a future major version. You no longer need to use `displayName`. A `displayName` will be automatically added if it belongs to a container. */
displayName?: string;
modelHook: TModelHook;
elemPropsHook?: TElemPropsHook;
subComponents?: SubComponents;
}) => {
assert(
modelHook.Context,
'createSubcomponent only works on models with context. Please use `createModelHook` to create the `modelHook`'
);
return <Props = {}>(
Component: (
props: CompoundProps<Props, TElemPropsHook, E>,
Element: E extends undefined ? never : E,
model: TModelHook extends (...args: any[]) => infer TModel ? TModel : never
) => JSX.Element | null
): (TModelHook extends (...args: any[]) => infer TModel
? ElementComponentM<
// E is not `undefined` here, but Typescript thinks it could be, so we add another `undefined`
// check and cast to a `React.FC` to match a valid signature for `ElementComponent`.
// `React.FC` was chosen as the simplest valid interface.
E extends undefined ? React.FC : E,
Props,
TModel
>
: never) &
SubComponents => {
const ReturnedComponent = React.forwardRef<
E,
Props & {as?: React.ElementType} & {model?: any; elemPropsHook?: (...args: any) => any}
>(({as: asOverride, model, elemPropsHook: additionalPropsHook, ...props}, ref) => {
const localModel = useModelContext(modelHook.Context!, model, asOverride);
// maybeModelProps reattached the `model` prop if the passed model is incompatible with the
// modelHook's context. This fixes issues when using the `as` prop on model element components
// that both have a model
const maybeModelProps =
model && localModel !== model ? {...props, model, ref} : {...props, ref};
const elemProps = elemPropsHook
? (elemPropsHook as any)(localModel, maybeModelProps, ref)
: maybeModelProps;
return Component(
additionalPropsHook ? additionalPropsHook(localModel, elemProps, ref) : elemProps,
// Cast to `any` to avoid: "ts(2345): Type 'undefined' is not assignable to type 'E extends
// undefined ? never : E'" I'm not sure I can actually cast to this conditional type and it
// doesn't actually matter, so cast to `any` it is.
(asOverride || as) as any,
localModel
);
});
Object.keys(subComponents || {}).forEach(key => {
// `ReturnedComponent` is a `React.ForwardRefExoticComponent` which has no additional keys so
// we'll cast to `Record<string, any>` for assignment. Note the lack of type checking
// properties. Take care when changing the runtime of this function.
(ReturnedComponent as Record<string, any>)[key] = (subComponents as Record<string, any>)[
key
];
});
if (displayName) {
ReturnedComponent.displayName = displayName;
}
(ReturnedComponent as any).__hasModel = true;
// The `any`s are here because `ElementComponent` takes care of the `as` type and the
// `ReturnComponent` type is overridden
(ReturnedComponent as any).as = memoize(
(as: any) =>
createSubcomponent(as)({displayName, subComponents, modelHook, elemPropsHook})(
Component as any
),
as => as
);
// Cast as `any`. We have already specified the return type. Be careful making changes to this
// file due to this `any` `ReturnedComponent` is a `React.ForwardRefExoticComponent`, but we want
// it to be either an `Component` or `ElementComponent`
return ReturnedComponent as any;
};
};
/**
* Factory function that creates components to be exported. It enforces React ref forwarding, `as`
* prop, display name, and sub-components, and handles proper typing without much boiler plate. The
* return type is `Component<element, Props>` which looks like `Component<'div', Props>` which is a
* clean interface that tells you the default element that is used.
*/
export const createComponent =
<
E extends
| keyof JSX.IntrinsicElements
| React.ComponentType
| ElementComponent<any, any>
| undefined = undefined
>(
as?: E
) =>
<P, SubComponents = {}>({
displayName,
Component,
subComponents,
}: {
/** This is what the component will look like in the React dev tools. Encouraged to more easily
* understand the component tree */
displayName?: string;
/** The component function. The function looks like:
* @example
* Component: ({children}, ref, Element) {
* // `Element` is what's passed to the `as` of your component. If no `as` was defined, it
* // will be the default element. It will be 'div' or even a another Component!
* return (
* <Element ref={ref}>{children}</Element>
* )
* }
*
* @example
* Component: ({children}, ref, Element) {
* // `Element` can be passed via `as` to the next component
* return (
* <AnotherElement as={Element} ref={ref}>{children}</AnotherElement>
* )
* }
*/
Component: RefForwardingComponent<E, P>;
/**
* Used in container components
*/
subComponents?: SubComponents;
}): (E extends undefined
? Component<P>
: ElementComponent<
// E is not `undefined` here, but Typescript thinks it could be, so we add another `undefined`
// check and cast to a `React.FC` to match a valid signature for `ElementComponent`.
// `React.FC` was chosen as the simplest valid interface.
E extends undefined ? React.FC : E,
P
>) &
SubComponents => {
const ReturnedComponent = React.forwardRef<E, P & {as?: React.ElementType}>(
({as: asOverride, ...props}, ref) => {
return Component(
props as any,
ref as ExtractRef<E>,
// Cast to `any` to avoid: "ts(2345): Type 'undefined' is not assignable to type 'E extends
// undefined ? never : E'" I'm not sure I can actually cast to this conditional type and it
// doesn't actually matter, so cast to `any` it is.
(asOverride || as) as any
);
}
);
Object.keys(subComponents || {}).forEach(key => {
// `ReturnedComponent` is a `React.ForwardRefExoticComponent` which has no additional keys so
// we'll cast to `Record<string, any>` for assignment. Note the lack of type checking
// properties. Take care when changing the runtime of this function.
(ReturnedComponent as Record<string, any>)[key] = (subComponents as Record<string, any>)[key];
});
ReturnedComponent.displayName = displayName;
// The `any`s are here because `ElementComponent` takes care of the `as` type and the
// `ReturnComponent` type is overridden
(ReturnedComponent as any).as = memoize(
(as: any) => createComponent(as)({displayName, Component, subComponents}),
as => as
);
// Cast as `any`. We have already specified the return type. Be careful making changes to this
// file due to this `any` `ReturnedComponent` is a `React.ForwardRefExoticComponent`, but we want
// it to be either an `Component` or `ElementComponent`
return ReturnedComponent as any;
};
/**
* An `elemPropsHook` is a React hook that takes a model, ref, and elemProps and returns props and
* attributes to be spread to an element or component.
*
* ```tsx
* const useMyHook = createElemPropsHook(useMyModel)((model) => {
* return {
* id: model.state.id
* }
* })
* ```
*
* **Note:** If your hook needs to use a ref, it must be forked using `useLocalRef` or `useForkRef`
* and return the forked ref:
*
* ```tsx
* const useMyHook = createElemPropsHook(useMyModel)((model, ref, elemProps) => {
* const {localRef, elementRef} = useLocalRef(ref);
*
* React.useLayoutEffect(() => {
* console.log('element', localRef.current) // logs the DOM element
* }, [])
*
* return {
* ref: elementRef
* }
* })
* ```
*/
export const createElemPropsHook =
<TModelHook extends (config: any) => Model<any, any>>(modelHook: TModelHook) =>
<const PO extends {}, const PI extends {}>(
fn: (
model: TModelHook extends (config: any) => infer TModel ? TModel : Model<any, any>,
ref?: React.Ref<unknown>,
elemProps?: PI
) => PO
): BehaviorHook<
TModelHook extends (config: any) => infer TModel ? TModel : Model<any, any>,
PO
> => {
return ((model, elemProps, ref) => {
const props = mergeProps(fn(model, ref, elemProps || ({} as any)), elemProps || ({} as any));
if (!props.hasOwnProperty('ref') && ref) {
// This is the weird "incoming ref isn't in props, but outgoing ref is in props" thing
// @ts-ignore TS says `ref` isn't on `PO`, but we always add it anyways
props.ref = ref;
}
return props;
}) as BehaviorHook<
TModelHook extends (config: any) => infer TModel ? TModel : Model<any, any>,
PO
>;
};
/**
* Factory function to crate a behavior hook with correct generic types. It takes a function that
* return props and returns a function that will also require `elemProps` and will call `mergeProps` for
* you. If your hook makes use of the `ref`, you will have to also use `useLocalRef` to properly fork
* the ref.
*
* @example
* const useMyHook = createHook((model: MyModel, ref) => {
* const { localRef, elementRef } = useLocalRef(ref);
* // do whatever with `localRef` which is a RefObject
*
* return {
* onClick: model.events.doSomething,
* ref: elementRef,
* };
* });
*
* // Equivalent to:
* const useMyHook = <P extends {}>(
* model: MyModel,
* elemProps: P,
* ref: React.Ref<unknown>
* ) => {
* const { localRef, elementRef } = useLocalRef(ref);
* // do whatever with `localRef` which is a RefObject
*
* return mergeProps({
* onClick: model.events.doSomething,
* ref: elementRef,
* }, elemProps);
* };
*
* @param fn Function that takes a model and optional ref and returns props
*/
export const createHook = <M extends Model<any, any>, PO extends {}, PI extends {}>(
fn: (model: M, ref?: React.Ref<unknown>, elemProps?: PI) => PO
): BehaviorHook<M, PO> => {
return ((model, elemProps, ref) => {
const props = mergeProps(fn(model, ref, elemProps || ({} as any)), elemProps || ({} as any));
if (!props.hasOwnProperty('ref') && ref) {
// This is the weird "incoming ref isn't in props, but outgoing ref is in props" thing
// @ts-ignore TS says `ref` isn't on `PO`, but we always add it anyways
props.ref = ref;
}
return props;
}) as BehaviorHook<M, PO>;
};
/**
* @deprecated ⚠️ `subModelHook` has been deprecated and will be removed in a future major version. Please use `createSubModelElemPropsHook` instead.
*/
export const subModelHook = <M extends Model<any, any>, SM extends Model<any, any>, O extends {}>(
fn: (model: M) => SM,
hook: BehaviorHook<SM, O>
): BehaviorHook<M, O> => {
return ((model: M, props: any, ref: React.Ref<unknown>) => {
return hook(fn(model), props, ref);
}) as BehaviorHook<M, O>;
};
/**
* Creates an elemPropsHook that returns the elemProps from another hook that is meant for a
* subModel. Usually only used when composing elemProps hooks.
*
* For example:
*
* ```tsx
* const useMySubModel = () => {}
*
* const useMyModel = () => {
* const subModel = useMySubModel()
*
* return {
* state,
* events,
* subModel,
* }
* }
*
* const useMyComponent = composeHook(
* createElemPropsHook(useMyModel)(model => ({ id: '' })),
* createSubModelElemPropsHook(useMyModel)(m => m.subModel, useSomeOtherComponent)
* )
* ```
*/
export function createSubModelElemPropsHook<M extends () => Model<any, any>>(modelHook: M) {
return <SM extends Model<any, any>, O extends {}>(
fn: (model: ReturnType<M>) => SM,
elemPropsHook: BehaviorHook<SM, O>
): BehaviorHook<ReturnType<M>, O> => {
return ((model: ReturnType<M>, props: any, ref: React.Ref<unknown>) => {
return elemPropsHook(fn(model), props, ref);
}) as BehaviorHook<ReturnType<M>, O>;
};
}
/** Simplify and speed up inference by capturing types in the signature itself */
interface BaseHook<M extends Model<any, any>, O extends {}> {
/**
* Capture the model type in TypeScript only. Do not use in runtime!
*
* @private
*/
__model: M;
/**
* Capture the hook's output type in TypeScript only. Do not use in runtime! This is used to cache
* and speed up the output types during inference
*
* @private
*/
__output: O;
}
// TypeScript function parameters are contravariant while return types are covariant. This is a
// problem when someone hands us a model that correctly extends `Model<any, any>`, but adds extra
// properties to the model. So `M extends Model<any, any>`. But the `BehaviorHook` is the return
// type which will reverse the direction which is no longer true: `Model<any, any> extends M`. In
// order to avoid this issue, we use the `bivarianceHack` found in ReactJS type definitions. This
// hack forces Typescript to treat `M` as a bivariant allowing extension to go either direction.
// Normally this would be less type safe, but we're using a generic `M` as a placeholder so there
// isn't a real issue. Not 100% this is a bug, but the "hack" is a bit messy.
// https://www.stephanboyer.com/post/132/what-are-covariance-and-contravariance
// https://stackoverflow.com/questions/52667959/what-is-the-purpose-of-bivariancehack-in-typescript-types/52668133
/**
* A BehaviorHook is a React hook that takes a model, elemProps, and a ref and returns props and
* attributes to apply to an element or component.
*/
export interface BehaviorHook<M extends Model<any, any>, O extends {}> extends BaseHook<M, O> {
<P extends {}>(model: M, elemProps?: P, ref?: React.Ref<unknown>): O & P;
}
function setRef<T>(ref: React.Ref<T> | undefined, value: T): void {
if (ref) {
if (typeof ref === 'function') {
ref(value);
} else {
// Refs are readonly, but we can technically write to it without issue
(ref as React.MutableRefObject<T>).current = value;
}
}
}
/**
* This function will create a new forked ref out of two input Refs. This is useful for components
* that use `React.forwardRef`, but also need internal access to a Ref.
*
* This function is inspired by https://www.npmjs.com/package/@rooks/use-fork-ref
*
* @example
* React.forwardRef((props, ref) => {
* // Returns a RefObject with a `current` property
* const myRef = React.useRef(ref)
*
* // Returns a forked Ref function to pass to an element.
* // This forked ref will update both `myRef` and `ref` when React updates the element ref
* const elementRef = useForkRef(ref, myRef)
*
* useEffect(() => {
* console.log(myRef.current) // `current` is the DOM instance
* // `ref` might be null since it depends on if someone passed a `ref` to your component
* // `elementRef` is a function and we cannot get a current value out of it
* })
*
* return <div ref={elementRef}/>
* })
*/
export function useForkRef<T>(ref1?: React.Ref<T>, ref2?: React.Ref<T>): React.RefCallback<T> {
return (value: T) => {
setRef(ref1, value);
setRef(ref2, value);
};
}
/**
* This functions handles the common use case where a component needs a local ref and needs to
* forward a ref to an element.
* @param ref The React ref passed from the `createComponent` factory function
*
* @example
* const MyComponent = ({children, ...elemProps}: MyProps, ref, Element) => {
* const { localRef, elementRef } = useLocalRef(ref);
*
* // do something with `localRef` which is a `RefObject` with a `current` property
*
* return <Element ref={elementRef} {...elemProps} />
* }
*/
export function useLocalRef<T>(ref?: React.Ref<T>) {
const localRef = React.useRef<T>(null);
const elementRef = useForkRef(ref, localRef);
return {localRef, elementRef};
}
/**
* Returns a model, or calls the model hook with config. Clever way around the conditional React
* hook ESLint rule.
* @param model A model, if provided
* @param config Config for a model
* @param modelHook A model hook that takes valid config
* @example
* const ContainerComponent = ({children, model, ...config}: ContainerProps) => {
* const value = useDefaultModel(model, config, useContainerModel);
*
* // ...
* }
*/
export function useDefaultModel<T, C>(
model: T | undefined,
config: C,
modelHook: (config: C) => T,
as?: React.ElementType
) {
// Make sure we don't pass the `model` to a component if it is incompatible with that component.
// Otherwise we'll have strange runtime failures when a component or elemProps hooks try to
// access the `state` or `events`
if (
!model ||
(as &&
(as as any).__hasModel &&
(model as any).__UNSTABLE_modelContext !== (modelHook as any).Context)
) {
return modelHook(config);
}
return model;
}
/**
* Returns a model, or returns a model context. Clever way around the conditional React hook ESLint
* rule
* @param model A model, if provided
* @param context The context of a model
* @example
* const SubComponent = ({children, model, ...elemProps}: SubComponentProps, ref, Element) => {
* const {state, events} = useModelContext(model, SubComponentModelContext, Element);
*
* // ...
* }
*/
export function useModelContext<T>(
context: React.Context<T>,
model?: T,
as?: React.ElementType
): T {
const contextModel = React.useContext(context);
if (
!model ||
(as && (as as any).__hasModel && (model as any).__UNSTABLE_modelContext !== context)
) {
return contextModel;
}
return model;
}
/**
* Compose many hooks together. Each hook should make a call to `mergeProps` which is automatically
* done by `createElemPropsHook` and `createHook. Returns a function that will receive a model and
* return props to be applied to a component. Hooks run from last to first, but props override from
* first to last. This means the last hook will run first, passing `elemProps` to the next last
* hook. There is a special exception, which is `null`. `null` means "remove this prop" and the null
* handling takes precedence to the first. Take care when using `null` as it will remove props
* passed in even from the developer. It can be useful when passing data between composed hooks or
* then redirecting a prop somewhere else.
*
* For example:
*
* ```ts
* const useHook1 = createElemPropsHook(useMyModel)((model, ref, elemProps) => {
* console.log('useHook1', elemProps)
* return {
* a: 'useHook1',
* c: 'useHook1',
* d: null, // remove the `d` prop
* }
* })
*
* const useHook2 = createElemPropsHook(useMyModel)((model, ref, elemProps) => {
* console.log('useHook2', elemProps)
* return {
* b: 'useHook2',
* c: 'useHook2',
* d: 'useHook2',
* }
* })
*
* const useHook3 = composeHooks(
* useHook1, // run last, will have access to `useHook2`'s elemProps, but can remove a prop with `null`
* useHook2 // run first and will override all of `useHook1`'s props
* )
* const props = useHook3(model, { c: 'props', d: 'props' })
* console.log('props', props)
* ```
*
* The output would be:
*
* ```ts
* useHook2 {c: 'props', d: 'props'}
* useHook1 {b: 'useHook2', c: 'props', d: 'props'}
* props {a: 'useHook1', b: 'useHook2', c: 'props', d: null}
* ```
*/
export function composeHooks<
H1 extends BaseHook<any, {}>,
H2 extends BaseHook<any, {}>,
H3 extends BaseHook<any, {}>,
H4 extends BaseHook<any, {}>,
H5 extends BaseHook<any, {}>,
H6 extends BaseHook<any, {}>,
H7 extends BaseHook<any, {}>
>(
hook1: H1,
hook2: H2,
hook3?: H3,
hook4?: H4,
hook5?: H5,
hook6?: H6,
hook7?: H7,
// TypeScript will only infer up to 6, but the types will still exist for those 6. The rest of the
// hooks won't add to the interface, but that seems to be an okay fallback
...hooks: BehaviorHook<any, any>[]
): H1 extends BaseHook<infer M, infer O1>
? H2 extends BaseHook<any, infer O2>
? H3 extends BaseHook<any, infer O3>
? H4 extends BaseHook<any, infer O4>
? H5 extends BaseHook<any, infer O5>
? H6 extends BaseHook<any, infer O6>
? H7 extends BaseHook<any, infer O7>
? BehaviorHook<
M,
RemoveNulls<
MergeProps<
O1,
MergeProps<
O2,
MergeProps<O3, MergeProps<O4, MergeProps<O5, MergeProps<O6, O7>>>>
>
>
>
>
: never
: never
: never
: never
: never
: never
: never;
export function composeHooks<M extends Model<any, any>, P extends {}, O extends {}>(
...hooks: ((model: M, props: P, ref: React.Ref<unknown>) => O)[]
): BehaviorHook<M, O> {
return ((model, props, ref) => {
const returnProps = [...hooks].reverse().reduce((props: any, hook) => {
return hook(model, props, props.ref || ref);
}, props);
// remove null props values
for (const key in returnProps) {
if (returnProps[key] === null) {
delete returnProps[key];
}
}
if (!returnProps.hasOwnProperty('ref') && ref) {
// This is the weird "incoming ref isn't in props, but outgoing ref is in props" thing
returnProps.ref = ref;
}
return returnProps;
}) as BehaviorHook<M, O>;
}