Skip to content

Commit 2c94e70

Browse files
committed
Add footer component
1 parent 5f8cdfa commit 2c94e70

File tree

21 files changed

+472
-54
lines changed

21 files changed

+472
-54
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { Meta, Story } from '@metamask/snaps-storybook';
2+
3+
import type { FooterProps } from './Footer';
4+
import { Footer } from './Footer';
5+
import { Button } from './form';
6+
7+
const meta: Meta<typeof Footer> = {
8+
title: 'Footer',
9+
component: Footer,
10+
argTypes: {
11+
children: {
12+
description:
13+
'The button(s) to render in the footer. If only one button is provided, a cancel button is added automatically.',
14+
table: {
15+
type: {
16+
summary: 'Button | [Button, Button]',
17+
},
18+
},
19+
},
20+
},
21+
};
22+
23+
export default meta;
24+
25+
/**
26+
* The footer component one custom button. A cancel button is added
27+
* automatically if only one button is provided.
28+
*
29+
* When the user clicks the first button, the `onUserInput` handler is called
30+
* with the name of the button (if provided).
31+
*/
32+
export const Default: Story<FooterProps> = {
33+
render: (props) => <Footer {...props} />,
34+
args: {
35+
children: <Button>Submit</Button>,
36+
},
37+
};
38+
39+
/**
40+
* The footer component with two custom buttons. If two buttons are provided,
41+
* no cancel button is added.
42+
*/
43+
export const TwoButtons: Story<FooterProps> = {
44+
render: (props) => <Footer {...props} />,
45+
args: {
46+
children: [<Button>First</Button>, <Button>Second</Button>],
47+
},
48+
};

packages/snaps-storybook/src/components/Header.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@ import type { FunctionComponent } from 'react';
77
* @returns The header element.
88
*/
99
export const Header: FunctionComponent = () => (
10-
<Flex as="header" padding="4" gap="4" background="background.default">
10+
<Flex
11+
as="header"
12+
padding="4"
13+
gap="4"
14+
background="background.default"
15+
boxShadow="md"
16+
clipPath="inset(0px 0px -16px 0px)"
17+
>
1118
<SkeletonCircle width="40px" height="40px" />
1219
<Box alignSelf="center">
1320
<Heading as="h1" fontSize="sm" fontWeight="500" lineHeight="short">

packages/snaps-storybook/src/components/Renderer.tsx

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,18 @@ import type { FunctionComponent } from 'react';
55
import { CUSTOM_COMPONENTS } from './custom';
66
import { SNAPS_COMPONENTS } from './snaps';
77

8+
/**
9+
* Custom component overrides.
10+
*/
11+
export type Overrides = Record<string, FunctionComponent<RenderProps<any>>>;
12+
813
/**
914
* The props that are passed to a rendered component.
1015
*
1116
* @template Type - The type of the props of the component itself. These will be
1217
* merged with the render props.
1318
*/
1419
export type RenderProps<Type> = Type & {
15-
/**
16-
* The unique ID to use as key for the renderer. This is used to ensure that
17-
* the rendered components have unique keys.
18-
*/
19-
id: string;
20-
2120
/**
2221
* The Renderer component to use to render nested elements.
2322
*/
@@ -39,8 +38,36 @@ export type RendererProps = {
3938
* The JSX element to render.
4039
*/
4140
element: Nestable<string | GenericSnapElement | boolean | null>;
41+
42+
/**
43+
* Custom component overrides.
44+
*/
45+
overrides?: Overrides;
4246
};
4347

48+
/**
49+
* Get the components to use for rendering JSX elements.
50+
*
51+
* @param overrides - Custom component overrides.
52+
* @returns The components to use for rendering JSX elements.
53+
*/
54+
function getComponents(
55+
overrides: Overrides,
56+
): Record<string, FunctionComponent<RenderProps<any>>> {
57+
const snapsComponents = Object.fromEntries(
58+
Object.entries(SNAPS_COMPONENTS).map(([key, value]) => [
59+
key,
60+
value.Component,
61+
]),
62+
);
63+
64+
return {
65+
...CUSTOM_COMPONENTS,
66+
...snapsComponents,
67+
...overrides,
68+
};
69+
}
70+
4471
/**
4572
* The renderer component that renders Snaps JSX elements. It supports rendering
4673
* strings, JSX elements, booleans, null, and arrays thereof.
@@ -49,9 +76,16 @@ export type RendererProps = {
4976
* @param props.id - The unique ID to use as key for the renderer. This is used
5077
* to ensure that the rendered components have unique keys.
5178
* @param props.element - The JSX element to render.
79+
* @param props.overrides - Custom component overrides.
5280
* @returns The rendered component.
5381
*/
54-
export const Renderer: FunctionComponent<RendererProps> = ({ element, id }) => {
82+
export const Renderer: FunctionComponent<RendererProps> = ({
83+
element,
84+
id,
85+
overrides = {},
86+
}) => {
87+
const components = getComponents(overrides);
88+
5589
if (typeof element === 'string') {
5690
return <>{element}</>;
5791
}
@@ -68,23 +102,45 @@ export const Renderer: FunctionComponent<RendererProps> = ({ element, id }) => {
68102
id={`${id}-${index}`}
69103
key={`${id}-${index}`}
70104
element={child}
105+
overrides={overrides}
71106
/>
72107
))}
73108
</>
74109
);
75110
}
76111

77-
if (CUSTOM_COMPONENTS[element.type as keyof typeof CUSTOM_COMPONENTS]) {
78-
const Component =
79-
CUSTOM_COMPONENTS[element.type as keyof typeof CUSTOM_COMPONENTS];
80-
81-
// @ts-expect-error - TODO: Fix types.
82-
return <Component id={id} Renderer={Renderer} {...element.props} />;
83-
}
84-
85-
// eslint-disable-next-line import/namespace
86-
const item = SNAPS_COMPONENTS[element.type];
87-
assert(item, `No component found for type: "${element.type}".`);
112+
const Component = components[element.type];
113+
assert(Component, `No component found for type: "${element.type}".`);
88114

89-
return <item.Component id={id} Renderer={Renderer} {...element.props} />;
115+
return (
116+
<Component
117+
id={id}
118+
Renderer={createRenderer(id, overrides)}
119+
{...element.props}
120+
/>
121+
);
90122
};
123+
124+
/**
125+
* Create a renderer component that renders JSX elements with the given base ID
126+
* and overrides.
127+
*
128+
* @param baseId - The base ID to use for the renderer.
129+
* @param baseOverrides - The base custom component overrides.
130+
* @returns The renderer component.
131+
*/
132+
function createRenderer(baseId: string, baseOverrides: Overrides) {
133+
// eslint-disable-next-line @typescript-eslint/naming-convention
134+
return function ChildRenderer({ id, element, overrides }: RendererProps) {
135+
return (
136+
<Renderer
137+
element={element}
138+
id={`${baseId}-${id}`}
139+
overrides={{
140+
...baseOverrides,
141+
...overrides,
142+
}}
143+
/>
144+
);
145+
};
146+
}
Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { Box } from '@chakra-ui/react';
1+
import { Flex } from '@chakra-ui/react';
22
import type { JSXElement } from '@metamask/snaps-sdk/jsx';
33
import type { FunctionComponent } from 'react';
44

55
import { Delineator } from '../Delineator';
66
import { Header } from '../Header';
77
import type { RenderProps } from '../Renderer';
8+
import { getFooter } from './utils';
89

910
/**
1011
* The props for the {@link Extension} component.
@@ -21,26 +22,36 @@ export type ExtensionProps = {
2122
* header and the content of the Snap in the delineator.
2223
*
2324
* @param props - The component props.
24-
* @param props.id - The unique ID to use as key for the renderer.
2525
* @param props.children - The JSX element to render in the extension.
2626
* @param props.Renderer - The Renderer component to use to render nested
2727
* elements.
2828
* @returns The rendered component.
2929
*/
3030
export const Extension: FunctionComponent<RenderProps<ExtensionProps>> = ({
31-
id,
3231
children,
3332
Renderer,
34-
}) => (
35-
<Box
36-
width="360px"
37-
height="600px"
38-
background="background.alternative"
39-
boxShadow="sm"
40-
>
41-
<Header />
42-
<Delineator>
43-
<Renderer id={`${id}-extension`} element={children} />
44-
</Delineator>
45-
</Box>
46-
);
33+
}) => {
34+
const footer = getFooter(children);
35+
36+
return (
37+
<Flex
38+
direction="column"
39+
width="360px"
40+
height="600px"
41+
background="background.alternative"
42+
boxShadow="md"
43+
>
44+
<Header />
45+
<Delineator>
46+
<Renderer
47+
id="extension"
48+
element={children}
49+
overrides={{
50+
Footer: () => null,
51+
}}
52+
/>
53+
</Delineator>
54+
{footer && <Renderer id="footer" element={footer} />}
55+
</Flex>
56+
);
57+
};
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// TODO: Move to `snaps-sdk`?
2+
3+
import type { JSXElement, Nestable } from '@metamask/snaps-sdk/jsx';
4+
import { hasProperty, isPlainObject } from '@metamask/utils';
5+
6+
/**
7+
* Check if a JSX element has children.
8+
*
9+
* @param element - A JSX element.
10+
* @returns `true` if the element has children, `false` otherwise.
11+
*/
12+
export function hasChildren<Element extends JSXElement>(
13+
element: Element,
14+
): element is Element & {
15+
props: { children: Nestable<JSXElement | string> };
16+
} {
17+
return hasProperty(element.props, 'children');
18+
}
19+
20+
/**
21+
* Filter a JSX child to remove `null`, `undefined`, plain booleans, and empty
22+
* strings.
23+
*
24+
* @param child - The JSX child to filter.
25+
* @returns `true` if the child is not `null`, `undefined`, a plain boolean, or
26+
* an empty string, `false` otherwise.
27+
*/
28+
function filterJsxChild(child: JSXElement | string | boolean | null): boolean {
29+
return Boolean(child) && child !== true;
30+
}
31+
32+
/**
33+
* Get the children of a JSX element as an array. If the element has only one
34+
* child, the child is returned as an array.
35+
*
36+
* @param element - A JSX element.
37+
* @returns The children of the element.
38+
*/
39+
export function getJsxChildren(element: JSXElement): (JSXElement | string)[] {
40+
if (hasChildren(element)) {
41+
if (Array.isArray(element.props.children)) {
42+
// @ts-expect-error - Each member of the union type has signatures, but
43+
// none of those signatures are compatible with each other.
44+
return element.props.children.filter(filterJsxChild).flat(Infinity);
45+
}
46+
47+
if (element.props.children) {
48+
return [element.props.children];
49+
}
50+
}
51+
52+
return [];
53+
}
54+
55+
/**
56+
* Walk a JSX tree and call a callback on each node.
57+
*
58+
* @param node - The JSX node to walk.
59+
* @param callback - The callback to call on each node.
60+
* @param depth - The current depth in the JSX tree for a walk.
61+
* @returns The result of the callback, if any.
62+
*/
63+
export function walkJsx<Value>(
64+
node: JSXElement | JSXElement[],
65+
callback: (node: JSXElement, depth: number) => Value | undefined,
66+
depth = 0,
67+
): Value | undefined {
68+
if (Array.isArray(node)) {
69+
for (const child of node) {
70+
const childResult = walkJsx(child as JSXElement, callback, depth);
71+
if (childResult !== undefined) {
72+
return childResult;
73+
}
74+
}
75+
76+
return undefined;
77+
}
78+
79+
const result = callback(node, depth);
80+
if (result !== undefined) {
81+
return result;
82+
}
83+
84+
if (hasChildren(node)) {
85+
const children = getJsxChildren(node);
86+
for (const child of children) {
87+
if (isPlainObject(child)) {
88+
const childResult = walkJsx(child, callback, depth + 1);
89+
if (childResult !== undefined) {
90+
return childResult;
91+
}
92+
}
93+
}
94+
}
95+
96+
return undefined;
97+
}
98+
99+
/**
100+
* Get the footer element from the JSX element tree.
101+
*
102+
* @param element - The JSX element to search for the footer.
103+
* @returns The footer element.
104+
*/
105+
export function getFooter(element: JSXElement) {
106+
// eslint-disable-next-line consistent-return
107+
const footer = walkJsx(element, (node) => {
108+
if (node.type === 'Footer') {
109+
return node;
110+
}
111+
});
112+
113+
return footer;
114+
}

0 commit comments

Comments
 (0)