Skip to content

Commit

Permalink
feat(react-composition): refactor ElementsContext (#5373)
Browse files Browse the repository at this point in the history
Co-authored-by: Heather Buchel <[email protected]>
  • Loading branch information
calebpollman and hbuchel authored Jul 9, 2024
1 parent e688f5d commit 1783fe8
Show file tree
Hide file tree
Showing 11 changed files with 414 additions and 170 deletions.
73 changes: 73 additions & 0 deletions packages/react/src/context/elements/ElementsContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React from 'react';
import { ElementDisplayName } from './types';

export interface Elements
extends Partial<Record<ElementDisplayName, React.ComponentType>> {}

const ElementsContext = React.createContext<Elements | undefined>(undefined);

/**
* `ElementsProvider` and its coresponding `useElement` hook provide
* access to the values of the nearest ancestral `ElementsContext`
* value.
*
* In most use cases, there is no need to directly invoke `useElement`;
* `ElementsContext` lookup is handled directly by `BaseElement`
* components returned by `defineBaseElement`.
*
* @example
*
* Add `ElementsContext` aware `BaseElement` components to a
* Connected Component
*
* ```tsx
* // `BaseElement`, renders custom or default element defintion
* const ViewElement = defineBaseElement({
* displayName: "View",
* type: "div",
* });
*
* // `BaseElement` components to be provided through `ElementsContext`
* interface ConnectedComponentElements {
* View: typeof ViewElement;
* }
*
* function createConnectedComponent<T extends ConnectedComponentElements>(
* elements?: T
* ) {
* const Provider = ({ children }: { children?: React.ReactNode }) => (
* <ElementsProvider elements={elements}>
* <Children />
* </ElementsProvider>
* );
*
* function ConnectedComponent() {
* return (
* <Provider>
* <ConnectedComponentContent />
* </Provider>
* );
* }
*
* ConnectedComponent.Provider = Provider;
*
* return ConnectedComponent;
* }
* ```
*/
export function ElementsProvider<T extends Elements>({
elements,
...props
}: {
children?: React.ReactNode;
elements?: T;
}): React.JSX.Element {
return <ElementsContext.Provider {...props} value={elements} />;
}

export const useElement = <T extends keyof Elements>(
name: T
): Elements[T] | undefined => {
const context = React.useContext(ElementsContext);
return context?.[name];
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';

import { ElementsProvider, useElement } from '../ElementsContext';

const ButtonElement = () => <button />;
const ViewElement = () => <button />;

const elements = { Button: ButtonElement, View: ViewElement };

describe('ElementsContext', () => {
it('`useElement` reads `BaseElement` values passed to `ElementsProvider`', () => {
const wrapper = ({ children }: { children?: React.ReactNode }) => (
<ElementsProvider elements={elements}>{children}</ElementsProvider>
);

const {
result: { current: Button },
} = renderHook(() => useElement('Button'), { wrapper });
expect(Button).toBe(ButtonElement);

const {
result: { current: View },
} = renderHook(() => useElement('View'), { wrapper });

expect(View).toBe(ViewElement);
});

it('`useElement` returns `undefined` when lookup fails', () => {
const wrapper = ({ children }: { children?: React.ReactNode }) => (
<ElementsProvider elements={elements}>{children}</ElementsProvider>
);

const invalidElementName = 'Not a Button';

const {
result: { current: Element },
// @ts-expect-error
} = renderHook(() => useElement(invalidElementName), { wrapper });
expect(Element).toBeUndefined();
});
});

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React from 'react';
import { render } from '@testing-library/react';
import { ElementsProvider } from '../ElementsContext';
import defineBaseElement from '../defineBaseElement';

const displayName = 'Input';
const type = 'input';

describe('defineBaseElement', () => {
it('renders a `BaseElement` of the provided element `type` and `displayName`', () => {
const InputElement = defineBaseElement({ type, displayName });

expect(InputElement).toBeDefined();
expect(InputElement.displayName).toBe(displayName);

const className = 'input-classname';

const { container } = render(<InputElement className={className} />);

const element = container.querySelector('input');

expect(element).toBeDefined();

expect(element?.className).toBe(className);
});

it('rendered `BaseElement` returns the value of `displayName` in `ElementsContext` if any', () => {
const InputElement = defineBaseElement({ type, displayName });
const OverrideElement = () => <input type="checkbox" />;

const { container } = render(
<ElementsProvider elements={{ Input: OverrideElement }}>
<InputElement />
</ElementsProvider>
);

const element = container.querySelector('input');

expect(element).toBeDefined();

expect(element?.type).toBe('checkbox');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import React from 'react';
import { render } from '@testing-library/react';
import defineBaseElement from '../defineBaseElement';
import withBaseElementProps from '../withBaseElementProps';

const displayName = 'Input';
const type = 'input';

describe('withBaseElementProps', () => {
it('applies a `defaultProps` object to a `Target` element', () => {
const InputElement = defineBaseElement<'input', 'type'>({
type,
displayName,
});

const { container } = render(<InputElement />);

const element = container.querySelector('input');

expect(element).toBeDefined();

expect(element?.type).toBe('text');

const defaultProps = { type: 'checkbox' };

const WrappedInputElement = withBaseElementProps(
InputElement,
defaultProps
);

const { container: wrappedContainer } = render(<WrappedInputElement />);

const wrappedElement = wrappedContainer.querySelector('input');

expect(wrappedElement).toBeDefined();

expect(wrappedElement?.type).toBe('checkbox');
});

it('resolves and applies a `defaultProps` callback to a `Target` element', () => {
const InputElement = defineBaseElement<'input', 'type'>({
type,
displayName,
});

const { container } = render(<InputElement />);

const element = container.querySelector('input');

expect(element).toBeDefined();

expect(element?.type).toBe('text');

const defaultProps = { type: 'checkbox' };

const WrappedInputElement = withBaseElementProps(
InputElement,
() => defaultProps
);

const { container: wrappedContainer } = render(<WrappedInputElement />);

const wrappedElement = wrappedContainer.querySelector('input');

expect(wrappedElement).toBeDefined();

expect(wrappedElement?.type).toBe('checkbox');
});

it('`defaultProps` are overriden by `props` passed to wrapped `BaseElement`', () => {
const InputElement = defineBaseElement<'input', 'type'>({
type,
displayName,
});

const defaultProps = { type: 'checkbox' };

const WrappedInputElement = withBaseElementProps(
InputElement,
() => defaultProps
);

const { container } = render(<WrappedInputElement />);

const element = container.querySelector('input');

expect(element).toBeDefined();

expect(element?.type).toBe('checkbox');

const { container: nextContainer } = render(
<WrappedInputElement type="image" />
);

const nextElement = nextContainer.querySelector('input');

expect(nextElement).toBeDefined();

expect(nextElement?.type).toBe('image');
});
});
27 changes: 0 additions & 27 deletions packages/react/src/context/elements/createElementsContext.tsx

This file was deleted.

14 changes: 0 additions & 14 deletions packages/react/src/context/elements/defaultElements.tsx

This file was deleted.

Loading

0 comments on commit 1783fe8

Please sign in to comment.