diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 1c54dae0..a2007366 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/new-docs/.storybook/preview.tsx b/new-docs/.storybook/preview.tsx index 8d2ece4c..fe856910 100644 --- a/new-docs/.storybook/preview.tsx +++ b/new-docs/.storybook/preview.tsx @@ -2,7 +2,14 @@ // does not use the src of core like the docs, but instead use the dist build import "@moai/core/dist/bundle.css"; -import { DocsContainer } from "@storybook/blocks"; +import { + Controls, + Description, + DocsContainer, + Stories, + Subtitle, + Title, +} from "@storybook/blocks"; import type { Preview } from "@storybook/react"; import React, { ReactElement } from "react"; import "../../core/font/remote.css"; @@ -39,6 +46,15 @@ const preview: Preview = { ); }, + page: () => ( + <> + + <Subtitle /> + <Description /> + <Stories /> + <Controls /> + </> + ), }, }, tags: ["autodocs"], diff --git a/new-docs/src/patterns/form.stories.tsx b/new-docs/src/patterns/form.stories.tsx new file mode 100644 index 00000000..1312c510 --- /dev/null +++ b/new-docs/src/patterns/form.stories.tsx @@ -0,0 +1,216 @@ +import { Meta } from "@storybook/react/*"; +import { ErrorMessage, Field, Form, Formik, FormikErrors } from "formik"; +import { CSSProperties, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { Button, FormError, Input, TextArea } from "../../../core/src"; + +/** + * Moai doesn't come with a built-in form solution. Instead, our input components + * (like [Input][3] and [TextArea][4]) are designed to work with popular form + * builders (such as [Formik][1] and [React Hook Form][2]) out of the box. + * + * [1]: https://formik.org/ + * [2]: https://react-hook-form.com/ + * [3]: /docs/components-input--primary + * [4]: /docs/components-textarea--primary + */ +const meta: Meta = { + title: "Patterns/Form", +}; + +export default meta; + +interface FormValues { + title: string; + message: string; +} + +const ERRORS = { + titleRequired: "Title is required", + messageRequired: "Message is required", + messageLength: "Message must be longer than 5 characters", +}; + +const formStyles: CSSProperties = { + display: "flex", + flexDirection: "column", + gap: 16, +}; + +const SubmitButton = ({ busy }: { busy: boolean }): JSX.Element => ( + <Button type="submit" highlight busy={busy} children="Submit" /> +); + +const postToServer = async (values: FormValues): Promise<void> => { + return new Promise((resolve) => { + setTimeout(() => { + alert(JSON.stringify(values, null, 2)); + resolve(); + }, 500); + }); +}; + +/** + * To use Moai's input components with Formik, pass them to the "as" prop of + * Formik's [Field][1] component: + * + * [1]: https://formik.org/docs/api/field + * + * ~~~tsx + * import { Field } from "formik"; + * import { Input } from "../../../core/src"; + * + * <label htmlFor="email">Email</label> + * <Field id="email" type="email" name="email" as={Input} /> + * ~~~ + * + * To show errors, pass FormError to the "component" prop of Formik's + * [ErrorMessage][2] component: + * + * ~~~tsx + * import { ErrorMessage } from "formik"; + * import { FormError } from "../../../core/src"; + * + * <ErrorMessage name="email" component={FormError} /> + * ~~~ + * + * [2]: https://formik.org/docs/api/errormessage + * + * Full example: + */ +export const FormikExample = (): JSX.Element => { + /* import { Input, Button, FormError } from "../../../core/src" */ + + const title = ( + <div> + <label htmlFor="fm-title">Title</label> + <Field id="fm-title" type="text" name="title" as={Input} /> + <ErrorMessage name="title" component={FormError} /> + </div> + ); + + const message = ( + <div> + <label htmlFor="fm-message">Message</label> + <Field id="fm-message" name="message" as={TextArea} /> + <ErrorMessage name="message" component={FormError} /> + </div> + ); + + const validate = (values: FormValues): FormikErrors<FormValues> => { + const errors: FormikErrors<FormValues> = {}; + if (!values.title) errors.title = ERRORS.titleRequired; + if (!values.message) errors.message = ERRORS.messageRequired; + if (values.message.length < 5) errors.message = ERRORS.messageLength; + return errors; + }; + + return ( + <Formik<FormValues> + initialValues={{ title: "", message: "" }} + validate={validate} + onSubmit={async (values, { setSubmitting }) => { + await postToServer(values); + setSubmitting(false); + }} + children={({ isSubmitting: busy }) => ( + <Form style={formStyles}> + {title} + {message} + <SubmitButton busy={busy} /> + </Form> + )} + /> + ); +}; + +/** + * To use Moai's input components with React Hook Form, [render][2] them in the + * "render" prop of RHF's [Controller][1] component: + * + * ~~~tsx + * import { Controller } from "react-hook-form"; + * import { Input } from "../../../core/src"; + * + * <label htmlFor="email">Email</label> + * <Controller + * name="email" + * control={control} + * render={({ field }) => ( + * <Input {...field} id="email" type="email" /> + * )} + * rules={{ required: "Email is required" }} + * /> + * ~~~ + * + * [1]: https://react-hook-form.com/api#Controller + * [2]: https://react-hook-form.com/get-started#IntegratingwithUIlibraries + * + * To show errors, pass RHF's [error messages][3] as children of Moai's FormError + * component: + * + * ~~~tsx + * import { FormError } from "../../../core/src"; + * + * <FormError children={errors.email?.message} /> + * ~~~ + * + * [3]: https://react-hook-form.com/advanced-usage#ErrorMessages + * + * Full example: + */ +export const ReactHookForm = (): JSX.Element => { + /* import { Input, Button, FormError } from "../../../core/src" */ + + const { control, formState, handleSubmit } = useForm<FormValues>(); + const { errors } = formState; + const [busy, setBusy] = useState(false); + + const title = ( + <div> + <label htmlFor="rhf-title">Title</label> + <Controller + name="title" + control={control} + render={({ field }) => ( + <Input {...field} id="rhf-title" type="text" /> + )} + rules={{ required: ERRORS.titleRequired }} + defaultValue="" + /> + <FormError children={errors.title?.message} /> + </div> + ); + + const message = ( + <div> + <label htmlFor="rhf-message">Message</label> + <Controller + name="message" + control={control} + render={({ field }) => <TextArea {...field} id="rhf-message" />} + rules={{ + required: { value: true, message: ERRORS.messageRequired }, + minLength: { value: 5, message: ERRORS.messageLength }, + }} + defaultValue="" + /> + <FormError children={errors.message?.message} /> + </div> + ); + + return ( + <form + onSubmit={handleSubmit(async (data) => { + setBusy(true); + await postToServer(data); + setBusy(false); + })} + style={formStyles} + > + {title} + {message} + <SubmitButton busy={busy} /> + </form> + ); +}; diff --git a/new-docs/src/patterns/icon.stories.tsx b/new-docs/src/patterns/icon.stories.tsx new file mode 100644 index 00000000..9bf09f8e --- /dev/null +++ b/new-docs/src/patterns/icon.stories.tsx @@ -0,0 +1,102 @@ +import { Meta } from "@storybook/react"; +import { SVGAttributes } from "react"; +import { RiSearchLine } from "react-icons/ri"; +import { Button } from "../../../core/src"; + +/** + * Moai doesn't have a built-in icon set. Instead, Moai's components work with any + * SVG icons. This means you can use Moai with popular icon sets, like + * [FontAwesome][1] and [Material Icons][2], or even with [your own icons][4]. + * + * This guide covers the usage of icons inside Moai's components (like in buttons + * and inputs). To display an icon on its own, with the control of its size and + * color, see the [Icon component][3]. + * + * [1]: https://fontawesome.com + * [2]: https://fonts.google.com/icons + * [3]: /docs/components-icon--primary + * [4]: #advanced + */ +const meta: Meta = { + title: "Patterns/Icon", +}; + +export default meta; + +/** + * Moai components that support icons usually have an \`icon\` prop. The + * recommended way to set this prop is using an icon from the [react-icons][1] + * package. It provides icons from many popular sets that can be used directly in + * Moai: + * + * ~~~ts + * + * import { RiSearchLine } from "react-icons/ri"; + * + * ~~~ + * + * [1]: https://react-icons.github.io/react-icons/ + */ +export const Basic = (): JSX.Element => ( + <Button icon={RiSearchLine} children="Search" /> +); + +/** + * In most cases, [screen readers][1] will [skip the icon][2] and only announce + * the text content of a component (e.g. the label of a button). When a component + * has no content to be announced (e.g. an icon-only button), you'll often be + * asked to provide an explicit icon label: + * + * [1]: https://en.wikipedia.org/wiki/Screen_reader + * [2]: https://www.sarasoueidan.com/blog/accessible-icon-buttons/#icon-sitting-next-to-text + */ +export const IconLabel = (): JSX.Element => ( + <Button icon={RiSearchLine} iconLabel="Search" /> +); + +/** + * When using with a component, the color and size of an icon are usually + * controlled by the component itself. For example, in a large, highlight button, + * the icon is white and enlarged: + */ +export const ColorSize = (): JSX.Element => ( + <Button + highlight + size={Button.sizes.large} + icon={RiSearchLine} + children="Search" + /> +); + +/** + * Technically, these \`icon\` props simply expect a [function component][1] that + * returns an SVG element. The type definition looks like this: + * + * ~~~ts + * interface Props { + * style?: CSSProperties; + * className?: string; + * } + * + * type Icon = (props: Props) => JSX.Element; + * ~~~ + * + * This means you can use Moai with your own custom icons (e.g. logos, product + * icons), by creating components that return them as SVG elements. For a full + * icon set, consider tools like [React SVGR][2] to programmatically generate + * these components from SVG files. + * + * [1]: https://reactjs.org/docs/components-and-props.html#function-and-class-components + * [2]: https://react-svgr.com + */ +export const Advanced = (): JSX.Element => { + // In practice, this should be defined outside of your component, or even + // better, automatically generated by a tool like react-svgr. + const Icon = (props: SVGAttributes<SVGElement>) => ( + <svg width="1em" height="1em" viewBox="0 0 48 1" {...props}> + {/* This is just a horizontal line */} + <path d="M0 0h48v1H0z" fill="currentColor" fillRule="evenodd" /> + </svg> + ); + return <Button icon={Icon} children="Search" />; +}; diff --git a/yarn.lock b/yarn.lock index 99004ba8..a47eac30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2379,8 +2379,10 @@ __metadata: eslint-plugin-react-hooks: "npm:^4.6.2" eslint-plugin-react-refresh: "npm:^0.4.7" eslint-plugin-storybook: "npm:^0.8.0" + formik: "npm:^2.4.6" react: "npm:^18.3.1" react-dom: "npm:^18.3.1" + react-hook-form: "npm:^7.52.0" storybook: "npm:^8.1.11" typescript: "npm:^5.5.2" vite: "npm:^5.3.2" @@ -4248,6 +4250,16 @@ __metadata: languageName: node linkType: hard +"@types/hoist-non-react-statics@npm:^3.3.1": + version: 3.3.5 + resolution: "@types/hoist-non-react-statics@npm:3.3.5" + dependencies: + "@types/react": "npm:*" + hoist-non-react-statics: "npm:^3.3.0" + checksum: 10c0/2a3b64bf3d9817d7830afa60ee314493c475fb09570a64e7737084cd482d2177ebdddf888ce837350bac51741278b077683facc9541f052d4bbe8487b4e3e618 + languageName: node + linkType: hard + "@types/http-errors@npm:*": version: 2.0.4 resolution: "@types/http-errors@npm:2.0.4" @@ -6157,6 +6169,13 @@ __metadata: languageName: node linkType: hard +"deepmerge@npm:^2.1.1": + version: 2.2.1 + resolution: "deepmerge@npm:2.2.1" + checksum: 10c0/4379288cabd817587cee92a095ea65d18317b45e48010a2e0d87982b5f432239a144f9c8ebd4ab090cc21f0cb47e51ebfe32921f329b3b3084a2711d5d63e450 + languageName: node + linkType: hard + "deepmerge@npm:^4.2.2": version: 4.3.1 resolution: "deepmerge@npm:4.3.1" @@ -7503,6 +7522,24 @@ __metadata: languageName: node linkType: hard +"formik@npm:^2.4.6": + version: 2.4.6 + resolution: "formik@npm:2.4.6" + dependencies: + "@types/hoist-non-react-statics": "npm:^3.3.1" + deepmerge: "npm:^2.1.1" + hoist-non-react-statics: "npm:^3.3.0" + lodash: "npm:^4.17.21" + lodash-es: "npm:^4.17.21" + react-fast-compare: "npm:^2.0.1" + tiny-warning: "npm:^1.0.2" + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10c0/e2853fc7833649386ff183eac9cb6a69a999c4b05aabe5636152e1cec1eb35ac0d9f511425624f0d7a88c6e19d4fd2aa259399a6683f0475c30170a5a3ea4f79 + languageName: node + linkType: hard + "forwarded@npm:0.2.0": version: 0.2.0 resolution: "forwarded@npm:0.2.0" @@ -8066,6 +8103,15 @@ __metadata: languageName: node linkType: hard +"hoist-non-react-statics@npm:^3.3.0": + version: 3.3.2 + resolution: "hoist-non-react-statics@npm:3.3.2" + dependencies: + react-is: "npm:^16.7.0" + checksum: 10c0/fe0889169e845d738b59b64badf5e55fa3cf20454f9203d1eb088df322d49d4318df774828e789898dcb280e8a5521bb59b3203385662ca5e9218a6ca5820e74 + languageName: node + linkType: hard + "hosted-git-info@npm:^2.1.4": version: 2.8.9 resolution: "hosted-git-info@npm:2.8.9" @@ -9523,6 +9569,13 @@ __metadata: languageName: node linkType: hard +"lodash-es@npm:^4.17.21": + version: 4.17.21 + resolution: "lodash-es@npm:4.17.21" + checksum: 10c0/fb407355f7e6cd523a9383e76e6b455321f0f153a6c9625e21a8827d10c54c2a2341bd2ae8d034358b60e07325e1330c14c224ff582d04612a46a4f0479ff2f2 + languageName: node + linkType: hard + "lodash.camelcase@npm:^4.3.0": version: 4.3.0 resolution: "lodash.camelcase@npm:4.3.0" @@ -11427,6 +11480,13 @@ __metadata: languageName: node linkType: hard +"react-fast-compare@npm:^2.0.1": + version: 2.0.4 + resolution: "react-fast-compare@npm:2.0.4" + checksum: 10c0/f0300c677e95198b5f993cbb8a983dab09586157dc678f9e2b5b29ff941b6677a8776fbbdc425ce102fad86937e36bb45cfcfd797f006270b97ccf287ebfb885 + languageName: node + linkType: hard + "react-fast-compare@npm:^3.0.1": version: 3.2.2 resolution: "react-fast-compare@npm:3.2.2" @@ -11434,6 +11494,15 @@ __metadata: languageName: node linkType: hard +"react-hook-form@npm:^7.52.0": + version: 7.52.0 + resolution: "react-hook-form@npm:7.52.0" + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + checksum: 10c0/058bf5596f314c071863bb133979deb56d0a7817d5bf1908a569c003fe03a15736402b040d3e18aeb259723c6e15c243fe75d2d887ea47ff4be87fc472f31ad5 + languageName: node + linkType: hard + "react-hot-toast@npm:^2.4.1": version: 2.4.1 resolution: "react-hot-toast@npm:2.4.1" @@ -11462,7 +11531,7 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^16.13.1": +"react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" checksum: 10c0/33977da7a5f1a287936a0c85639fec6ca74f4f15ef1e59a6bc20338fc73dc69555381e211f7a3529b8150a1f71e4225525b41b60b52965bda53ce7d47377ada1 @@ -12813,6 +12882,13 @@ __metadata: languageName: node linkType: hard +"tiny-warning@npm:^1.0.2": + version: 1.0.3 + resolution: "tiny-warning@npm:1.0.3" + checksum: 10c0/ef8531f581b30342f29670cb41ca248001c6fd7975ce22122bd59b8d62b4fc84ad4207ee7faa95cde982fa3357cd8f4be650142abc22805538c3b1392d7084fa + languageName: node + linkType: hard + "tinyspy@npm:^2.2.0": version: 2.2.1 resolution: "tinyspy@npm:2.2.1"