From 1eb1322000a293cf2a31ddcd9a5a533bf3544489 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 4 Jul 2024 11:53:14 +0200 Subject: [PATCH] Add input and field components --- .../src/jsx/components/form/Field.stories.tsx | 90 +++++++++++++++++++ .../src/jsx/components/form/Input.stories.tsx | 78 ++++++++++++++++ packages/snaps-storybook/.eslintrc.js | 10 +++ packages/snaps-storybook/jest.config.js | 2 +- packages/snaps-storybook/package.json | 1 + .../{Button.styles.tsx => Button.styles.ts} | 2 - .../components/snaps/field/Field.styles.ts | 43 +++++++++ .../src/components/snaps/field/Field.tsx | 30 +++++++ .../snaps/field/components/Input.tsx | 62 +++++++++++++ .../snaps/field/components/index.ts | 1 + .../src/components/snaps/field/index.ts | 2 + .../src/components/snaps/index.ts | 4 + .../components/snaps/input/Input.styles.ts | 44 +++++++++ .../src/components/snaps/input/Input.tsx | 32 +++++++ .../src/components/snaps/input/index.ts | 2 + .../src/components/snaps/types.ts | 4 +- packages/snaps-storybook/src/theme/utils.ts | 45 ++++++++-- yarn.lock | 1 + 18 files changed, 440 insertions(+), 13 deletions(-) create mode 100644 packages/snaps-sdk/src/jsx/components/form/Field.stories.tsx create mode 100644 packages/snaps-sdk/src/jsx/components/form/Input.stories.tsx rename packages/snaps-storybook/src/components/snaps/button/{Button.styles.tsx => Button.styles.ts} (95%) create mode 100644 packages/snaps-storybook/src/components/snaps/field/Field.styles.ts create mode 100644 packages/snaps-storybook/src/components/snaps/field/Field.tsx create mode 100644 packages/snaps-storybook/src/components/snaps/field/components/Input.tsx create mode 100644 packages/snaps-storybook/src/components/snaps/field/components/index.ts create mode 100644 packages/snaps-storybook/src/components/snaps/field/index.ts create mode 100644 packages/snaps-storybook/src/components/snaps/input/Input.styles.ts create mode 100644 packages/snaps-storybook/src/components/snaps/input/Input.tsx create mode 100644 packages/snaps-storybook/src/components/snaps/input/index.ts diff --git a/packages/snaps-sdk/src/jsx/components/form/Field.stories.tsx b/packages/snaps-sdk/src/jsx/components/form/Field.stories.tsx new file mode 100644 index 0000000000..a2ad2ececb --- /dev/null +++ b/packages/snaps-sdk/src/jsx/components/form/Field.stories.tsx @@ -0,0 +1,90 @@ +import type { Meta, Story } from '@metamask/snaps-storybook'; + +import { Button } from './Button'; +import type { FieldProps } from './Field'; +import { Field } from './Field'; +import { Input } from './Input'; + +const meta: Meta = { + title: 'Forms/Field', + component: Field, + argTypes: { + label: { + description: 'The label of the field.', + control: 'text', + }, + error: { + description: 'The error message of the field.', + control: 'text', + }, + children: { + description: 'The form component to render inside the field.', + table: { + type: { + summary: + '[InputElement, ButtonElement] | DropdownElement | FileInputElement | InputElement | CheckboxElement', + }, + }, + }, + }, +}; + +export default meta; + +/** + * The field component wraps an input element with a label and optional error + * message. + */ +export const Default: Story = { + render: (props) => , + args: { + label: 'Field label', + children: ( + + ), + }, +}; + +/** + * The field component can display an error message. + */ +export const Error: Story = { + render: (props) => , + args: { + label: 'Field label', + error: 'Field error', + children: ( + + ), + }, +}; + +/** + * Inputs can be combined with a button, for example, to submit a form, or to + * perform some action. + */ +export const InputWithButton: Story = { + render: (props) => , + args: { + label: 'Field label', + children: [ + , + , + ], + }, +}; diff --git a/packages/snaps-sdk/src/jsx/components/form/Input.stories.tsx b/packages/snaps-sdk/src/jsx/components/form/Input.stories.tsx new file mode 100644 index 0000000000..b24b9c449f --- /dev/null +++ b/packages/snaps-sdk/src/jsx/components/form/Input.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta, Story } from '@metamask/snaps-storybook'; + +import type { InputProps } from './Input'; +import { Input } from './Input'; + +const meta: Meta = { + title: 'Forms/Input', + component: Input, + argTypes: { + name: { + description: + 'The name of the input field. This is used to identify the input field in the form data.', + }, + type: { + description: 'The type of the input field.', + options: ['text', 'password', 'number'], + control: 'select', + }, + value: { + description: 'The default value of the input field.', + control: 'text', + }, + placeholder: { + description: 'The placeholder text of the input field.', + control: 'text', + }, + }, +}; + +export default meta; + +/** + * The input component renders an input field. + */ +export const Default: Story = { + render: (props) => , + args: { + name: 'input', + placeholder: 'This is the placeholder text', + }, +}; + +/** + * Number inputs only accept numbers. + */ +export const Number: Story = { + render: (props) => , + args: { + name: 'input', + type: 'number', + placeholder: 'This input only accepts numbers', + }, +}; + +/** + * Password inputs hide the entered text. + */ +export const Password: Story = { + render: (props) => , + args: { + name: 'input', + type: 'password', + placeholder: 'This is a password input', + }, +}; + +/** + * It's possible to set a default value for the input. The value can be changed + * by the user. + */ +export const DefaultValue: Story = { + render: (props) => , + args: { + name: 'input', + value: 'Default value', + placeholder: 'This input has a default value', + }, +}; diff --git a/packages/snaps-storybook/.eslintrc.js b/packages/snaps-storybook/.eslintrc.js index a47fd0b65d..7a42669cc7 100644 --- a/packages/snaps-storybook/.eslintrc.js +++ b/packages/snaps-storybook/.eslintrc.js @@ -4,4 +4,14 @@ module.exports = { parserOptions: { tsconfigRootDir: __dirname, }, + + overrides: [ + { + files: ['**/theme/**/*.ts', '**/components/snaps/**/*.styles.ts'], + rules: { + '@typescript-eslint/naming-convention': 'off', + '@typescript-eslint/unbound-method': 'off', + }, + }, + ], }; diff --git a/packages/snaps-storybook/jest.config.js b/packages/snaps-storybook/jest.config.js index 17019b1bfa..cde979cb23 100644 --- a/packages/snaps-storybook/jest.config.js +++ b/packages/snaps-storybook/jest.config.js @@ -31,7 +31,7 @@ module.exports = deepmerge(baseConfig, { { tsconfig: { jsx: 'react-jsx', - jsxImportSource: resolve(__dirname, './src/jsx'), + jsxImportSource: 'react', }, }, ], diff --git a/packages/snaps-storybook/package.json b/packages/snaps-storybook/package.json index d17bf325b9..4b852ee185 100644 --- a/packages/snaps-storybook/package.json +++ b/packages/snaps-storybook/package.json @@ -48,6 +48,7 @@ "@babel/parser": "^7.24.7", "@babel/traverse": "^7.24.7", "@babel/types": "^7.23.0", + "@chakra-ui/anatomy": "^2.1.1", "@chakra-ui/react": "^2.6.1", "@emotion/react": "^11.10.8", "@emotion/styled": "^11.10.8", diff --git a/packages/snaps-storybook/src/components/snaps/button/Button.styles.tsx b/packages/snaps-storybook/src/components/snaps/button/Button.styles.ts similarity index 95% rename from packages/snaps-storybook/src/components/snaps/button/Button.styles.tsx rename to packages/snaps-storybook/src/components/snaps/button/Button.styles.ts index d2efdf587f..6b437ad162 100644 --- a/packages/snaps-storybook/src/components/snaps/button/Button.styles.tsx +++ b/packages/snaps-storybook/src/components/snaps/button/Button.styles.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/naming-convention */ - import { defineStyle, defineStyleConfig } from '@chakra-ui/react'; export const styles = defineStyleConfig({ diff --git a/packages/snaps-storybook/src/components/snaps/field/Field.styles.ts b/packages/snaps-storybook/src/components/snaps/field/Field.styles.ts new file mode 100644 index 0000000000..148f13ca0b --- /dev/null +++ b/packages/snaps-storybook/src/components/snaps/field/Field.styles.ts @@ -0,0 +1,43 @@ +import { formAnatomy, formErrorAnatomy } from '@chakra-ui/anatomy'; +import { + createMultiStyleConfigHelpers, + defineStyleConfig, +} from '@chakra-ui/react'; + +const { definePartsStyle, defineMultiStyleConfig } = + createMultiStyleConfigHelpers(formAnatomy.keys); + +const { + definePartsStyle: defineErrorPartsStyle, + defineMultiStyleConfig: defineErrorMultiStyleConfig, +} = createMultiStyleConfigHelpers(formErrorAnatomy.keys); + +export const styles = { + FormControl: defineMultiStyleConfig({ + baseStyle: definePartsStyle({ + helperText: { + color: 'error.default', + marginTop: '1', + fontSize: '2xs', + }, + }), + }), + + FormError: defineErrorMultiStyleConfig({ + baseStyle: defineErrorPartsStyle({ + text: { + color: 'error.default', + fontSize: '2xs', + marginTop: '1', + }, + }), + }), + + FormLabel: defineStyleConfig({ + baseStyle: { + color: 'text.default', + fontSize: 'sm', + marginBottom: '0', + }, + }), +}; diff --git a/packages/snaps-storybook/src/components/snaps/field/Field.tsx b/packages/snaps-storybook/src/components/snaps/field/Field.tsx new file mode 100644 index 0000000000..ad68b63e79 --- /dev/null +++ b/packages/snaps-storybook/src/components/snaps/field/Field.tsx @@ -0,0 +1,30 @@ +import { FormControl, FormErrorMessage, FormLabel } from '@chakra-ui/react'; +import type { FieldProps } from '@metamask/snaps-sdk/jsx'; +import type { FunctionComponent } from 'react'; + +import type { RenderProps } from '../../Renderer'; +import { Input } from './components'; + +/** + * The field component, which wraps an input element with a label and optional + * error message. See the {@link FieldProps} type for the props. + * + * @param props - The props of the component. + * @param props.label - The label of the field. + * @param props.error - The error message of the field. + * @param props.children - The input field to render inside the field. + * @param props.Renderer - The renderer to use for the input field. + * @returns The field element. + */ +export const Field: FunctionComponent> = ({ + label, + error, + children, + Renderer, +}) => ( + + {label} + + {error && {error}} + +); diff --git a/packages/snaps-storybook/src/components/snaps/field/components/Input.tsx b/packages/snaps-storybook/src/components/snaps/field/components/Input.tsx new file mode 100644 index 0000000000..d02e8b8d42 --- /dev/null +++ b/packages/snaps-storybook/src/components/snaps/field/components/Input.tsx @@ -0,0 +1,62 @@ +import { InputGroup, InputRightElement } from '@chakra-ui/react'; +import type { FieldProps } from '@metamask/snaps-sdk/jsx'; +import type { FunctionComponent } from 'react'; +import { useEffect, useState, useRef } from 'react'; + +import type { RendererProps } from '../../../Renderer'; + +/** + * The props for the {@link Input} component. + */ +export type InputProps = { + /** + * The input element to render. + */ + element: FieldProps['children']; + + /** + * The renderer to use for the input field. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + Renderer: FunctionComponent; +}; + +/** + * This is a wrapper of the input component, which allows for rendering + * different types of input fields. + * + * @param props - The component props. + * @param props.element - The input element to render. + * @param props.Renderer - The renderer to use for the input field. + * @returns The rendered input component. + */ +export const Input: FunctionComponent = ({ element, Renderer }) => { + const ref = useRef(null); + const [width, setWidth] = useState(0); + + useEffect(() => { + if (ref.current) { + setWidth(ref.current.offsetWidth); + } + }, [element, ref]); + + if (Array.isArray(element)) { + const [input, button] = element; + return ( + + + + + + + ); + } + + return ; +}; diff --git a/packages/snaps-storybook/src/components/snaps/field/components/index.ts b/packages/snaps-storybook/src/components/snaps/field/components/index.ts new file mode 100644 index 0000000000..ba9fe7ebc6 --- /dev/null +++ b/packages/snaps-storybook/src/components/snaps/field/components/index.ts @@ -0,0 +1 @@ +export * from './Input'; diff --git a/packages/snaps-storybook/src/components/snaps/field/index.ts b/packages/snaps-storybook/src/components/snaps/field/index.ts new file mode 100644 index 0000000000..2c0bdd061d --- /dev/null +++ b/packages/snaps-storybook/src/components/snaps/field/index.ts @@ -0,0 +1,2 @@ +export { Field as Component } from './Field'; +export { styles } from './Field.styles'; diff --git a/packages/snaps-storybook/src/components/snaps/index.ts b/packages/snaps-storybook/src/components/snaps/index.ts index 4ffcbcb382..eb3e3eafdc 100644 --- a/packages/snaps-storybook/src/components/snaps/index.ts +++ b/packages/snaps-storybook/src/components/snaps/index.ts @@ -9,8 +9,10 @@ import * as Box from './box'; import * as Button from './button'; import * as Copyable from './copyable'; import * as Divider from './divider'; +import * as Field from './field'; import * as Footer from './footer'; import * as Heading from './heading'; +import * as Input from './input'; import * as Italic from './italic'; import * as Link from './link'; import * as Row from './row'; @@ -26,8 +28,10 @@ export const SNAPS_COMPONENTS: Record = { Button, Copyable, Divider, + Field, Footer, Heading, + Input, Italic, Link, Row, diff --git a/packages/snaps-storybook/src/components/snaps/input/Input.styles.ts b/packages/snaps-storybook/src/components/snaps/input/Input.styles.ts new file mode 100644 index 0000000000..4aa97c6b3a --- /dev/null +++ b/packages/snaps-storybook/src/components/snaps/input/Input.styles.ts @@ -0,0 +1,44 @@ +import { inputAnatomy } from '@chakra-ui/anatomy'; +import { createMultiStyleConfigHelpers } from '@chakra-ui/react'; + +const { definePartsStyle, defineMultiStyleConfig } = + createMultiStyleConfigHelpers(inputAnatomy.keys); + +export const styles = defineMultiStyleConfig({ + baseStyle: definePartsStyle({ + field: { + color: 'text.default', + fontSize: 'sm', + paddingX: '4', + paddingY: '2', + }, + }), + + variants: { + outline: definePartsStyle({ + field: { + background: 'background.default', + borderWidth: '1px', + borderStyle: 'solid', + borderColor: 'border.default', + borderRadius: 'base', + _focus: { + outline: '5px auto', + outlineColor: 'primary.default', + outlineOffset: '0', + }, + _placeholder: { + color: 'text.alternative', + }, + _invalid: { + borderColor: 'error.default', + boxShadow: 'none', + }, + }, + }), + }, + + defaultProps: { + variant: 'outline', + }, +}); diff --git a/packages/snaps-storybook/src/components/snaps/input/Input.tsx b/packages/snaps-storybook/src/components/snaps/input/Input.tsx new file mode 100644 index 0000000000..c756d39165 --- /dev/null +++ b/packages/snaps-storybook/src/components/snaps/input/Input.tsx @@ -0,0 +1,32 @@ +import { Input as ChakraInput } from '@chakra-ui/react'; +import type { InputProps } from '@metamask/snaps-sdk/jsx'; +import type { FunctionComponent } from 'react'; + +import type { RenderProps } from '../../Renderer'; + +/** + * The input component renders an input field. See the {@link InputProps} type + * for the props. + * + * @param props - The props of the component. + * @param props.name - The name of the input field. + * @param props.type - The type of the input field. + * @param props.value - The default value of the input field. + * @param props.placeholder - The placeholder text of the input field. + * @returns The input element. + */ +export const Input: FunctionComponent> = ({ + name, + type, + value, + placeholder, +}) => { + return ( + + ); +}; diff --git a/packages/snaps-storybook/src/components/snaps/input/index.ts b/packages/snaps-storybook/src/components/snaps/input/index.ts new file mode 100644 index 0000000000..7d09aa5c21 --- /dev/null +++ b/packages/snaps-storybook/src/components/snaps/input/index.ts @@ -0,0 +1,2 @@ +export { Input as Component } from './Input'; +export { styles } from './Input.styles'; diff --git a/packages/snaps-storybook/src/components/snaps/types.ts b/packages/snaps-storybook/src/components/snaps/types.ts index ecc5ec65e3..65114c9a00 100644 --- a/packages/snaps-storybook/src/components/snaps/types.ts +++ b/packages/snaps-storybook/src/components/snaps/types.ts @@ -1,6 +1,6 @@ import type { ComponentType } from 'react'; -import type { Styles } from '../../theme/utils'; +import type { MultiStyles, Styles } from '../../theme/utils'; /** * A component that can be rendered in Storybook. @@ -15,5 +15,5 @@ export type Component = { /** * The optional Chakra UI styles for the component. */ - styles?: Styles; + styles?: Styles | MultiStyles | Record; }; diff --git a/packages/snaps-storybook/src/theme/utils.ts b/packages/snaps-storybook/src/theme/utils.ts index 8130808e83..a2f12e7c72 100644 --- a/packages/snaps-storybook/src/theme/utils.ts +++ b/packages/snaps-storybook/src/theme/utils.ts @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import type { createMultiStyleConfigHelpers } from '@chakra-ui/react'; import type { defineStyleConfig } from '@chakra-ui/styled-system'; import type { Theme } from '@metamask/design-tokens'; import { darkTheme, lightTheme } from '@metamask/design-tokens'; +import { hasProperty } from '@metamask/utils'; import type { Component } from '../components'; @@ -73,8 +75,28 @@ export function getShadows() { ); } +export type MultiStyles = ReturnType< + ReturnType['defineMultiStyleConfig'] +>; + export type Styles = ReturnType; +/** + * Check if the styles provided are a record of styles. + * + * @param styles - The styles to check. + * @returns Whether the styles are a record of styles. + */ +export function isStylesRecord( + styles?: Styles | MultiStyles | Record, +): styles is Record { + return ( + styles !== undefined && + !hasProperty(styles, 'baseStyle') && + !hasProperty(styles, 'variants') + ); +} + /** * Extract the styles from the components provided. * @@ -82,12 +104,19 @@ export type Styles = ReturnType; * @returns The styles extracted from the components. */ export function getComponents(components: Record) { - return Object.fromEntries( - Object.entries(components) - .filter(([, component]) => component.styles !== undefined) - .map(([componentName, component]) => [ - componentName, - component.styles as Styles, - ]), - ); + return Object.entries(components) + .filter(([, component]) => component.styles !== undefined) + .reduce((accumulator, [componentName, component]) => { + if (isStylesRecord(component.styles)) { + return { + ...accumulator, + ...component.styles, + }; + } + + return { + ...accumulator, + [componentName]: component.styles, + }; + }, {}); } diff --git a/yarn.lock b/yarn.lock index 3ada9642b7..46848dd2d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6439,6 +6439,7 @@ __metadata: "@babel/parser": ^7.24.7 "@babel/traverse": ^7.24.7 "@babel/types": ^7.23.0 + "@chakra-ui/anatomy": ^2.1.1 "@chakra-ui/react": ^2.6.1 "@emotion/react": ^11.10.8 "@emotion/styled": ^11.10.8