Skip to content

Commit

Permalink
Add footer component
Browse files Browse the repository at this point in the history
  • Loading branch information
Mrtenz committed Jul 3, 2024
1 parent df24440 commit 190bb37
Show file tree
Hide file tree
Showing 21 changed files with 472 additions and 54 deletions.
48 changes: 48 additions & 0 deletions packages/snaps-sdk/src/jsx/components/Footer.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { Meta, Story } from '@metamask/snaps-storybook';

import type { FooterProps } from './Footer';
import { Footer } from './Footer';
import { Button } from './form';

const meta: Meta<typeof Footer> = {
title: 'Footer',
component: Footer,
argTypes: {
children: {
description:
'The button(s) to render in the footer. If only one button is provided, a cancel button is added automatically.',
table: {
type: {
summary: 'Button | [Button, Button]',
},
},
},
},
};

export default meta;

/**
* The footer component one custom button. A cancel button is added
* automatically if only one button is provided.
*
* When the user clicks the first button, the `onUserInput` handler is called
* with the name of the button (if provided).
*/
export const Default: Story<FooterProps> = {
render: (props) => <Footer {...props} />,
args: {
children: <Button>Submit</Button>,
},
};

/**
* The footer component with two custom buttons. If two buttons are provided,
* no cancel button is added.
*/
export const TwoButtons: Story<FooterProps> = {
render: (props) => <Footer {...props} />,
args: {
children: [<Button>First</Button>, <Button>Second</Button>],
},
};
9 changes: 8 additions & 1 deletion packages/snaps-storybook/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ import type { FunctionComponent } from 'react';
* @returns The header element.
*/
export const Header: FunctionComponent = () => (
<Flex as="header" padding="4" gap="4" background="background.default">
<Flex
as="header"
padding="4"
gap="4"
background="background.default"
boxShadow="md"
clipPath="inset(0px 0px -16px 0px)"
>
<SkeletonCircle width="40px" height="40px" />
<Box alignSelf="center">
<Heading as="h1" fontSize="sm" fontWeight="500" lineHeight="short">
Expand Down
94 changes: 75 additions & 19 deletions packages/snaps-storybook/src/components/Renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,18 @@ import type { FunctionComponent } from 'react';
import { CUSTOM_COMPONENTS } from './custom';
import { SNAPS_COMPONENTS } from './snaps';

/**
* Custom component overrides.
*/
export type Overrides = Record<string, FunctionComponent<RenderProps<any>>>;

/**
* The props that are passed to a rendered component.
*
* @template Type - The type of the props of the component itself. These will be
* merged with the render props.
*/
export type RenderProps<Type> = Type & {
/**
* The unique ID to use as key for the renderer. This is used to ensure that
* the rendered components have unique keys.
*/
id: string;

/**
* The Renderer component to use to render nested elements.
*/
Expand All @@ -39,8 +38,36 @@ export type RendererProps = {
* The JSX element to render.
*/
element: Nestable<string | GenericSnapElement | boolean | null>;

/**
* Custom component overrides.
*/
overrides?: Overrides;
};

/**
* Get the components to use for rendering JSX elements.
*
* @param overrides - Custom component overrides.
* @returns The components to use for rendering JSX elements.
*/
function getComponents(
overrides: Overrides,
): Record<string, FunctionComponent<RenderProps<any>>> {
const snapsComponents = Object.fromEntries(
Object.entries(SNAPS_COMPONENTS).map(([key, value]) => [
key,
value.Component,
]),
);

return {
...CUSTOM_COMPONENTS,
...snapsComponents,
...overrides,
};
}

/**
* The renderer component that renders Snaps JSX elements. It supports rendering
* strings, JSX elements, booleans, null, and arrays thereof.
Expand All @@ -49,9 +76,16 @@ export type RendererProps = {
* @param props.id - The unique ID to use as key for the renderer. This is used
* to ensure that the rendered components have unique keys.
* @param props.element - The JSX element to render.
* @param props.overrides - Custom component overrides.
* @returns The rendered component.
*/
export const Renderer: FunctionComponent<RendererProps> = ({ element, id }) => {
export const Renderer: FunctionComponent<RendererProps> = ({
element,
id,
overrides = {},
}) => {
const components = getComponents(overrides);

if (typeof element === 'string') {
return <>{element}</>;
}
Expand All @@ -68,23 +102,45 @@ export const Renderer: FunctionComponent<RendererProps> = ({ element, id }) => {
id={`${id}-${index}`}
key={`${id}-${index}`}
element={child}
overrides={overrides}
/>
))}
</>
);
}

if (CUSTOM_COMPONENTS[element.type as keyof typeof CUSTOM_COMPONENTS]) {
const Component =
CUSTOM_COMPONENTS[element.type as keyof typeof CUSTOM_COMPONENTS];

// @ts-expect-error - TODO: Fix types.
return <Component id={id} Renderer={Renderer} {...element.props} />;
}

// eslint-disable-next-line import/namespace
const item = SNAPS_COMPONENTS[element.type];
assert(item, `No component found for type: "${element.type}".`);
const Component = components[element.type];
assert(Component, `No component found for type: "${element.type}".`);

return <item.Component id={id} Renderer={Renderer} {...element.props} />;
return (
<Component
id={id}
Renderer={createRenderer(id, overrides)}
{...element.props}
/>
);
};

/**
* Create a renderer component that renders JSX elements with the given base ID
* and overrides.
*
* @param baseId - The base ID to use for the renderer.
* @param baseOverrides - The base custom component overrides.
* @returns The renderer component.
*/
function createRenderer(baseId: string, baseOverrides: Overrides) {
// eslint-disable-next-line @typescript-eslint/naming-convention
return function ChildRenderer({ id, element, overrides }: RendererProps) {
return (
<Renderer
element={element}
id={`${baseId}-${id}`}
overrides={{
...baseOverrides,
...overrides,
}}
/>
);
};
}
43 changes: 27 additions & 16 deletions packages/snaps-storybook/src/components/custom/Extension.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Box } from '@chakra-ui/react';
import { Flex } from '@chakra-ui/react';
import type { JSXElement } from '@metamask/snaps-sdk/jsx';
import type { FunctionComponent } from 'react';

import { Delineator } from '../Delineator';
import { Header } from '../Header';
import type { RenderProps } from '../Renderer';
import { getFooter } from './utils';

/**
* The props for the {@link Extension} component.
Expand All @@ -21,26 +22,36 @@ export type ExtensionProps = {
* header and the content of the Snap in the delineator.
*
* @param props - The component props.
* @param props.id - The unique ID to use as key for the renderer.
* @param props.children - The JSX element to render in the extension.
* @param props.Renderer - The Renderer component to use to render nested
* elements.
* @returns The rendered component.
*/
export const Extension: FunctionComponent<RenderProps<ExtensionProps>> = ({
id,
children,
Renderer,
}) => (
<Box
width="360px"
height="600px"
background="background.alternative"
boxShadow="sm"
>
<Header />
<Delineator>
<Renderer id={`${id}-extension`} element={children} />
</Delineator>
</Box>
);
}) => {
const footer = getFooter(children);

return (
<Flex
direction="column"
width="360px"
height="600px"
background="background.alternative"
boxShadow="md"
>
<Header />
<Delineator>
<Renderer
id="extension"
element={children}
overrides={{
Footer: () => null,
}}
/>
</Delineator>
{footer && <Renderer id="footer" element={footer} />}
</Flex>
);
};
114 changes: 114 additions & 0 deletions packages/snaps-storybook/src/components/custom/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// TODO: Move to `snaps-sdk`?

import type { JSXElement, Nestable } from '@metamask/snaps-sdk/jsx';
import { hasProperty, isPlainObject } from '@metamask/utils';

/**
* Check if a JSX element has children.
*
* @param element - A JSX element.
* @returns `true` if the element has children, `false` otherwise.
*/
export function hasChildren<Element extends JSXElement>(
element: Element,
): element is Element & {
props: { children: Nestable<JSXElement | string> };
} {
return hasProperty(element.props, 'children');
}

/**
* Filter a JSX child to remove `null`, `undefined`, plain booleans, and empty
* strings.
*
* @param child - The JSX child to filter.
* @returns `true` if the child is not `null`, `undefined`, a plain boolean, or
* an empty string, `false` otherwise.
*/
function filterJsxChild(child: JSXElement | string | boolean | null): boolean {
return Boolean(child) && child !== true;
}

/**
* Get the children of a JSX element as an array. If the element has only one
* child, the child is returned as an array.
*
* @param element - A JSX element.
* @returns The children of the element.
*/
export function getJsxChildren(element: JSXElement): (JSXElement | string)[] {
if (hasChildren(element)) {
if (Array.isArray(element.props.children)) {
// @ts-expect-error - Each member of the union type has signatures, but
// none of those signatures are compatible with each other.
return element.props.children.filter(filterJsxChild).flat(Infinity);
}

if (element.props.children) {
return [element.props.children];
}
}

return [];
}

/**
* Walk a JSX tree and call a callback on each node.
*
* @param node - The JSX node to walk.
* @param callback - The callback to call on each node.
* @param depth - The current depth in the JSX tree for a walk.
* @returns The result of the callback, if any.
*/
export function walkJsx<Value>(
node: JSXElement | JSXElement[],
callback: (node: JSXElement, depth: number) => Value | undefined,
depth = 0,
): Value | undefined {
if (Array.isArray(node)) {
for (const child of node) {
const childResult = walkJsx(child as JSXElement, callback, depth);
if (childResult !== undefined) {
return childResult;
}
}

return undefined;
}

const result = callback(node, depth);
if (result !== undefined) {
return result;
}

if (hasChildren(node)) {
const children = getJsxChildren(node);
for (const child of children) {
if (isPlainObject(child)) {
const childResult = walkJsx(child, callback, depth + 1);
if (childResult !== undefined) {
return childResult;
}
}
}
}

return undefined;
}

/**
* Get the footer element from the JSX element tree.
*
* @param element - The JSX element to search for the footer.
* @returns The footer element.
*/
export function getFooter(element: JSXElement) {
// eslint-disable-next-line consistent-return
const footer = walkJsx(element, (node) => {
if (node.type === 'Footer') {
return node;
}
});

return footer;
}
Loading

0 comments on commit 190bb37

Please sign in to comment.