diff --git a/package.json b/package.json index 9a07d7164..d60ed23b4 100644 --- a/package.json +++ b/package.json @@ -42,13 +42,25 @@ "@react-aria/dialog": "^3.1.4", "@react-aria/focus": "^3.5.0", "@react-aria/overlays": "^3.7.3", + "@react-aria/checkbox": "^3.2.3", + "@react-aria/i18n": "^3.3.4", + "@react-aria/label": "^3.2.1", + "@react-aria/numberfield": "^3.1.1", + "@react-aria/radio": "^3.1.6", "@react-aria/separator": "^3.1.3", "@react-aria/textfield": "^3.5.0", + "@react-aria/utils": "^3.11.0", + "@react-aria/visually-hidden": "^3.2.3", + "@react-stately/numberfield": "^3.0.2", + "@react-stately/radio": "^3.3.2", + "@react-stately/toggle": "^3.2.3", + "@react-types/radio": "^3.1.2", "@vanilla-extract/recipes": "^0.2.3", "@vanilla-extract/sprinkles": "^1.3.3", "clsx": "^1.1.1", "react-keyed-flatten-children": "^1.3.0", - "react-use": "^17.3.2" + "react-use": "^17.3.2", + "react-cool-dimensions": "^2.0.7" }, "devDependencies": { "@babel/core": "^7.16.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f539dce6..457fda996 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,13 +8,24 @@ specifiers: '@dessert-box/react': ^0.2.0 '@react-aria/breadcrumbs': ^3.1.5 '@react-aria/button': ^3.3.4 + '@react-aria/checkbox': ^3.2.3 '@react-aria/dialog': ^3.1.4 '@react-aria/focus': ^3.5.0 + '@react-aria/i18n': ^3.3.4 + '@react-aria/label': ^3.2.1 '@react-aria/link': ^3.2.0 + '@react-aria/numberfield': ^3.1.1 '@react-aria/overlays': ^3.7.3 + '@react-aria/radio': ^3.1.6 '@react-aria/separator': ^3.1.3 '@react-aria/textfield': ^3.5.0 + '@react-aria/utils': ^3.11.0 + '@react-aria/visually-hidden': ^3.2.3 + '@react-stately/numberfield': ^3.0.2 + '@react-stately/radio': ^3.3.2 + '@react-stately/toggle': ^3.2.3 '@react-types/button': ^3.4.1 + '@react-types/radio': ^3.1.2 '@storybook/addon-actions': ^6.4.14 '@storybook/addon-essentials': ^6.4.5 '@storybook/addon-links': ^6.4.5 @@ -48,6 +59,7 @@ specifiers: playroom: ^0.27.9 prettier: ^2.5.1 react: ^17.0.2 + react-cool-dimensions: ^2.0.7 react-dom: ^17.0.2 react-keyed-flatten-children: ^1.3.0 react-use: ^17.3.2 @@ -65,15 +77,27 @@ dependencies: '@dessert-box/react': 0.2.0_react@17.0.2 '@react-aria/breadcrumbs': 3.1.5_react@17.0.2 '@react-aria/button': 3.3.4_react@17.0.2 + '@react-aria/checkbox': 3.2.3_react@17.0.2 '@react-aria/dialog': 3.1.4_react@17.0.2 '@react-aria/focus': 3.5.0_react@17.0.2 + '@react-aria/i18n': 3.3.4_react@17.0.2 + '@react-aria/label': 3.2.1_react@17.0.2 '@react-aria/link': 3.2.0_react@17.0.2 + '@react-aria/numberfield': 3.1.1_react-dom@17.0.2+react@17.0.2 '@react-aria/overlays': 3.7.3_react-dom@17.0.2+react@17.0.2 + '@react-aria/radio': 3.1.6_react@17.0.2 '@react-aria/separator': 3.1.3_react@17.0.2 '@react-aria/textfield': 3.5.0_react@17.0.2 + '@react-aria/utils': 3.11.0_react@17.0.2 + '@react-aria/visually-hidden': 3.2.3_react@17.0.2 + '@react-stately/numberfield': 3.0.2_react@17.0.2 + '@react-stately/radio': 3.3.2_react@17.0.2 + '@react-stately/toggle': 3.2.3_react@17.0.2 + '@react-types/radio': 3.1.2_react@17.0.2 '@vanilla-extract/recipes': 0.2.3_@vanilla-extract+css@1.6.8 '@vanilla-extract/sprinkles': 1.3.3_@vanilla-extract+css@1.6.8 clsx: 1.1.1 + react-cool-dimensions: 2.0.7_react@17.0.2 react-keyed-flatten-children: 1.3.0_react@17.0.2 react-use: 17.3.2_react-dom@17.0.2+react@17.0.2 @@ -2278,6 +2302,21 @@ packages: react: 17.0.2 dev: false + /@react-aria/checkbox/3.2.3_react@17.0.2: + resolution: {integrity: sha512-bLNdVefKGFA2+QT84htWHYUpxLqA5r3L4q6ilBLOzcRiKpgQM2OW2bQGLN6Zw26MKjmTzEMrR2Db+a/O5e1fUQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + dependencies: + '@babel/runtime': 7.16.3 + '@react-aria/label': 3.2.1_react@17.0.2 + '@react-aria/toggle': 3.1.5_react@17.0.2 + '@react-aria/utils': 3.11.0_react@17.0.2 + '@react-stately/checkbox': 3.0.3_react@17.0.2 + '@react-stately/toggle': 3.2.3_react@17.0.2 + '@react-types/checkbox': 3.2.3_react@17.0.2 + react: 17.0.2 + dev: false + /@react-aria/dialog/3.1.4_react@17.0.2: resolution: {integrity: sha512-OtQGBol3CfcbBpjqXDqXzH5Ygny44PIuyAsZ1e3dfIdtaI+XHsoglyZnvDaVVealIgedHkMubreZnyNYnlzPLg==} peerDependencies: @@ -2356,6 +2395,41 @@ packages: react: 17.0.2 dev: false + /@react-aria/live-announcer/3.0.1_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-c63UZ4JhXxy29F6FO1LUkQLDRzv17W4g3QQ+sy6tmFw7R5I5r8uh8jR7RCbBX7bdGCLnQDwOQ055KsM/a9MT3A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 + dependencies: + '@babel/runtime': 7.16.3 + '@react-aria/utils': 3.11.0_react@17.0.2 + '@react-aria/visually-hidden': 3.2.3_react@17.0.2 + react: 17.0.2 + react-dom: 17.0.2_react@17.0.2 + dev: false + + /@react-aria/numberfield/3.1.1_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-6ep+sgNe4ZymoBHNfsSiqxhwqeTjN6eRQyCjfrq0WUq5ttjrL9a91w3pSoOGQKI7CQ92y7pvvLcCghkeP2meGQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 + dependencies: + '@babel/runtime': 7.16.3 + '@react-aria/i18n': 3.3.4_react@17.0.2 + '@react-aria/interactions': 3.7.0_react@17.0.2 + '@react-aria/live-announcer': 3.0.1_react-dom@17.0.2+react@17.0.2 + '@react-aria/spinbutton': 3.0.1_react-dom@17.0.2+react@17.0.2 + '@react-aria/textfield': 3.5.0_react@17.0.2 + '@react-aria/utils': 3.11.0_react@17.0.2 + '@react-stately/numberfield': 3.0.2_react@17.0.2 + '@react-types/button': 3.4.1_react@17.0.2 + '@react-types/numberfield': 3.1.0_react@17.0.2 + '@react-types/shared': 3.10.1_react@17.0.2 + '@react-types/textfield': 3.3.0_react@17.0.2 + react: 17.0.2 + react-dom: 17.0.2_react@17.0.2 + dev: false + /@react-aria/overlays/3.7.3_react-dom@17.0.2+react@17.0.2: resolution: {integrity: sha512-N5F/TVJ9KIYgGuOknVMrRnqqzkNKcFos4nxLHQz4TeFZTp4/P+NqEHd/VBmjsSTNEjEuNAivG+U2o4F1NWn/Pw==} peerDependencies: @@ -2375,6 +2449,22 @@ packages: react-dom: 17.0.2_react@17.0.2 dev: false + /@react-aria/radio/3.1.6_react@17.0.2: + resolution: {integrity: sha512-ngpnlSXWcwOB65HoEw510BkG7I/REeM59cz8y1TQ4k1zPX//qsWOpl2ngmT4yZybLAg+B9VwDkdE5kw5KlRo1g==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + dependencies: + '@babel/runtime': 7.16.3 + '@react-aria/focus': 3.5.0_react@17.0.2 + '@react-aria/i18n': 3.3.4_react@17.0.2 + '@react-aria/interactions': 3.7.0_react@17.0.2 + '@react-aria/label': 3.2.1_react@17.0.2 + '@react-aria/utils': 3.11.0_react@17.0.2 + '@react-stately/radio': 3.3.2_react@17.0.2 + '@react-types/radio': 3.1.2_react@17.0.2 + react: 17.0.2 + dev: false + /@react-aria/separator/3.1.3_react@17.0.2: resolution: {integrity: sha512-Vl5UjLvt7NojRZOmKunXzttDqrjZp9i3oIKmwk5ydppchfzvriKsPeFinbWzcRMzIaHOljQ8Gj8yqgGjJtuvuQ==} peerDependencies: @@ -2386,6 +2476,22 @@ packages: react: 17.0.2 dev: false + /@react-aria/spinbutton/3.0.1_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-V2wUhSgJDxSqzo5HPbx7OgGpFeuvxq8/7nNO8mT3cEZfZASUGvjIdCRmAf243qyfo9Yby4zdx9E/BxNOGCZ9cQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 + dependencies: + '@babel/runtime': 7.16.3 + '@react-aria/i18n': 3.3.4_react@17.0.2 + '@react-aria/live-announcer': 3.0.1_react-dom@17.0.2+react@17.0.2 + '@react-aria/utils': 3.11.0_react@17.0.2 + '@react-types/button': 3.4.1_react@17.0.2 + '@react-types/shared': 3.10.1_react@17.0.2 + react: 17.0.2 + react-dom: 17.0.2_react@17.0.2 + dev: false + /@react-aria/ssr/3.1.0_react@17.0.2: resolution: {integrity: sha512-RxqQKmE8sO7TGdrcSlHTcVzMP450hqowtBSd2bBS9oPlcokVkaGq28c3Rwa8ty5ctw4EBCjXqjP7xdcKMGDzug==} peerDependencies: @@ -2409,6 +2515,22 @@ packages: react: 17.0.2 dev: false + /@react-aria/toggle/3.1.5_react@17.0.2: + resolution: {integrity: sha512-Oe6EpRxOJeXmKL9kD6LfoPRERLMj6Romx11KBEk7bkfO5zf8gm/NSmQCzN1h7SGRsUCkbCgVXPK63j5IlHK/Xw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + dependencies: + '@babel/runtime': 7.16.3 + '@react-aria/focus': 3.5.0_react@17.0.2 + '@react-aria/interactions': 3.7.0_react@17.0.2 + '@react-aria/utils': 3.11.0_react@17.0.2 + '@react-stately/toggle': 3.2.3_react@17.0.2 + '@react-types/checkbox': 3.2.3_react@17.0.2 + '@react-types/shared': 3.10.1_react@17.0.2 + '@react-types/switch': 3.1.2_react@17.0.2 + react: 17.0.2 + dev: false + /@react-aria/utils/3.11.0_react@17.0.2: resolution: {integrity: sha512-4yFA8E9xqDCUlolYSsoyp/qxrkiQrnEqx1BQOrKDuicpW7MBJ39pJC23YFMpyK2a6xEptc6xJEeIEFJXp57jJw==} peerDependencies: @@ -2434,6 +2556,31 @@ packages: react: 17.0.2 dev: false + /@react-stately/checkbox/3.0.3_react@17.0.2: + resolution: {integrity: sha512-amT889DTLdbjAVjZ9j9TytN73PszynGIspKi1QSUCvXeA2OVyCwShxhV0Pn7yYX8cMinvGXrjhWdhn0nhYeMdg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + dependencies: + '@babel/runtime': 7.16.3 + '@react-stately/toggle': 3.2.3_react@17.0.2 + '@react-stately/utils': 3.3.0_react@17.0.2 + '@react-types/checkbox': 3.2.3_react@17.0.2 + react: 17.0.2 + dev: false + + /@react-stately/numberfield/3.0.2_react@17.0.2: + resolution: {integrity: sha512-hxJt/Bj9cqJ8EPp9Vb0BL2CMWaRROWvxveiy76zcMMAT1TN33Wjhta+r+RjhJeUqDCHyvgcbYUeyxEbqrcipRA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + dependencies: + '@babel/runtime': 7.16.3 + '@internationalized/number': 3.0.3 + '@react-stately/utils': 3.3.0_react@17.0.2 + '@react-types/numberfield': 3.1.0_react@17.0.2 + '@react-types/shared': 3.10.1_react@17.0.2 + react: 17.0.2 + dev: false + /@react-stately/overlays/3.1.3_react@17.0.2: resolution: {integrity: sha512-X8H/h9F8ZjevwJ7P8ak7v500qQd5x4Y76LsXUXrR6LtcO8FXfp2I+W8sGmBtLZwLQpTJiF1U0WMQqXLE1g6eLA==} peerDependencies: @@ -2445,6 +2592,17 @@ packages: react: 17.0.2 dev: false + /@react-stately/radio/3.3.2_react@17.0.2: + resolution: {integrity: sha512-U1GfO7NflkyYiUP56/iFWwoLuMxE6Ydb4wEY3ZAlkMcWqes9YBQCzfPeckl6f77i+1ldc3Irs3NH9fDrKp8Oow==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + dependencies: + '@babel/runtime': 7.16.3 + '@react-stately/utils': 3.3.0_react@17.0.2 + '@react-types/radio': 3.1.2_react@17.0.2 + react: 17.0.2 + dev: false + /@react-stately/toggle/3.2.3_react@17.0.2: resolution: {integrity: sha512-p5eVjXwNo4y4CeybxfjYmbTzNMNiI67uspbRAJnawWBVWw8X+yIvRfpjYAsqmvsJ+DsvwybSTlQDT6taGoWEsA==} peerDependencies: @@ -2521,6 +2679,15 @@ packages: react: 17.0.2 dev: false + /@react-types/numberfield/3.1.0_react@17.0.2: + resolution: {integrity: sha512-+QfvGqWD/QWOIyOCRDX/KyyV6QWdA/BQZKVpkFd0Vyy11GGT0eiKGyBevlN22/mwQkHbu53smVrRKXlHdB1tUQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + dependencies: + '@react-types/shared': 3.10.1_react@17.0.2 + react: 17.0.2 + dev: false + /@react-types/overlays/3.5.1_react@17.0.2: resolution: {integrity: sha512-T3o6wQ5NNm1rSniIa01bIa6fALC8jbwpYxFMaQRrdEpIvwktt0Fi5Xo6/97+oe4HvzzU0JMhtwWDTdRySvgeZw==} peerDependencies: @@ -2530,6 +2697,15 @@ packages: react: 17.0.2 dev: false + /@react-types/radio/3.1.2_react@17.0.2: + resolution: {integrity: sha512-vkIic8abrVUyl/YjKU3yTVwn8QgebzuadfV89PsaKc3hdmSiHhDsln5wYsfWOEotqMwPrG1aEv9yRMYO78OQXQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + dependencies: + '@react-types/shared': 3.10.1_react@17.0.2 + react: 17.0.2 + dev: false + /@react-types/shared/3.10.1_react@17.0.2: resolution: {integrity: sha512-U3dLJtstvOiZ8XLrWdNv9WXuruoDyfIfSXguTs9N0naDdO+M0MIbt/1Hg7Toe43ueAe56GM14IFL+S0/jhv8ow==} peerDependencies: @@ -2537,6 +2713,16 @@ packages: dependencies: react: 17.0.2 + /@react-types/switch/3.1.2_react@17.0.2: + resolution: {integrity: sha512-EaYWoLvUCpOnt//Ov8VBxOjbs4hBpYE/rBAzzIknXaFvKOu867iZBFL7FJbcemOgC8/dwyaj6GUZ1Gw3Z1g59w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + dependencies: + '@react-types/checkbox': 3.2.3_react@17.0.2 + '@react-types/shared': 3.10.1_react@17.0.2 + react: 17.0.2 + dev: false + /@react-types/textfield/3.3.0_react@17.0.2: resolution: {integrity: sha512-lOf0tx3c3dVaomH/uvKpOKFVTXQ232kLnMhOJTtj97JDX7fTr3SNhDUV0G8Zf4M0vr+l+xkTrJkywYE23rzliw==} peerDependencies: @@ -13524,6 +13710,14 @@ packages: react-dom: 17.0.2_react@17.0.2 dev: true + /react-cool-dimensions/2.0.7_react@17.0.2: + resolution: {integrity: sha512-z1VwkAAJ5d8QybDRuYIXTE41RxGr5GYsv1bQhbOBE8cMfoZQZpcF0odL64vdgrQVzat2jayedj1GoYi80FWcbA==} + peerDependencies: + react: '>= 16.8.0' + dependencies: + react: 17.0.2 + dev: false + /react-dev-utils/11.0.4: resolution: {integrity: sha512-dx0LvIGHcOPtKbeiSUM4jqpBl3TcY7CDjZdfOIcKeznE7BWr9dg0iPG90G5yfVQ+p/rGNMXdbfStvzQZEVEi4A==} engines: {node: '>=10'} diff --git a/src/Button/createButton.tsx b/src/Button/createButton.tsx index 8e197da4d..30f835b6b 100644 --- a/src/Button/createButton.tsx +++ b/src/Button/createButton.tsx @@ -5,7 +5,7 @@ import { ComponentProps, useRef } from "react"; import { AriaButtonProps } from "@react-types/button"; import { useButton } from "@react-aria/button"; import { Label } from "../Typography/Label/Label"; -import { BentoSprinkles } from "src/internal"; +import { BentoSprinkles } from "../internal"; type Size = "small" | "medium"; export type ButtonProps = { diff --git a/src/CheckboxField/CheckboxField.css.ts b/src/CheckboxField/CheckboxField.css.ts new file mode 100644 index 000000000..bc0adfbdf --- /dev/null +++ b/src/CheckboxField/CheckboxField.css.ts @@ -0,0 +1,58 @@ +import { bentoSprinkles } from "../internal"; +import { strictRecipe } from "../util/strictRecipe"; +import { extendedHitAreaRecipe } from "../util/extendedHitArea.css"; + +export const fieldContainer = extendedHitAreaRecipe({ axis: "y" }); + +export const checkboxRecipe = strictRecipe({ + base: [ + { position: "relative", zIndex: "1" }, + bentoSprinkles({ + width: 24, + height: 24, + display: "flex", + alignItems: "center", + padding: 4, + borderRadius: 4, + boxShadow: "outlineInteractive", + }), + ], + variants: { + isSelected: { + false: bentoSprinkles({ + boxShadow: { hover: "outlineInteractiveStrong" }, + background: { default: "backgroundPrimary" }, + }), + true: bentoSprinkles({ + boxShadow: "none", + background: { + default: "solidEnabledBackground", + hover: "solidHoverBackground", + }, + }), + }, + isFocused: { + true: bentoSprinkles({ + boxShadow: "outlineInteractiveStrong", + }), + }, + isDisabled: { + true: bentoSprinkles({ + boxShadow: { default: "outlineDisabled", hover: "outlineDisabled" }, + background: "solidDisabledBackground", + cursor: "notAllowed", + }), + }, + }, + compoundVariants: [ + { + variants: { + isFocused: true, + isSelected: true, + }, + style: bentoSprinkles({ + background: "solidFocusBackground", + }), + }, + ], +}); diff --git a/src/CheckboxField/createCheckboxField.tsx b/src/CheckboxField/createCheckboxField.tsx new file mode 100644 index 000000000..513997173 --- /dev/null +++ b/src/CheckboxField/createCheckboxField.tsx @@ -0,0 +1,110 @@ +import { useCheckbox } from "@react-aria/checkbox"; +import { useFocusRing } from "@react-aria/focus"; +import { useField } from "@react-aria/label"; +import { mergeProps } from "@react-aria/utils"; +import { VisuallyHidden } from "@react-aria/visually-hidden"; +import { useToggleState } from "@react-stately/toggle"; +import { useRef } from "react"; +import { FieldType } from "../Field/createField"; +import { Body, TextChildren } from ".."; +import { BentoSprinkles, Box, Column, Columns } from "../internal"; +import { FieldProps } from "../Field/FieldProps"; +import { vars } from "../vars.css"; +import { checkboxRecipe, fieldContainer } from "./CheckboxField.css"; + +export type CheckboxFieldProps = Omit, "assistiveText"> & { + label: TextChildren; +}; +export type CheckboxFieldConfig = { + labelSpacing: BentoSprinkles["gap"]; +}; + +export function createCheckboxField( + Field: FieldType, + config: CheckboxFieldConfig = { + labelSpacing: 8, + } +) { + return function CheckboxField(props: CheckboxFieldProps) { + const checkboxProps = { + ...props, + value: undefined, + isSelected: props.value, + isDisabled: props.disabled, + children: props.label, + }; + const state = useToggleState(checkboxProps); + const ref = useRef(null); + const { inputProps } = useCheckbox(checkboxProps, state, ref); + const { fieldProps, labelProps, errorMessageProps } = useField(checkboxProps); + const { isFocusVisible, focusProps } = useFocusRing(); + + return ( + + + + + + + + + + + {props.label} + + + + + ); + }; +} + +type CheckboxProps = { + value: boolean; + isDisabled: boolean; + isFocusVisible: boolean; +}; + +function Checkbox({ value, isFocusVisible, isDisabled }: CheckboxProps) { + return ( + + {value && } + + ); +} + +function CheckboxMark({ isDisabled }: { isDisabled: boolean }) { + return ( + + + + ); +} diff --git a/src/Field/createFormFields.tsx b/src/Field/createFormFields.tsx index b9611ae25..4aef97d1c 100644 --- a/src/Field/createFormFields.tsx +++ b/src/Field/createFormFields.tsx @@ -1,37 +1,51 @@ import { ComponentProps } from "react"; import { Body, Label } from ".."; import { createField } from "./createField"; -import { createTextField } from "../TextField/createTextField"; -import { BentoSprinkles } from "src/internal"; +import { createTextField, TextFieldConfig } from "../TextField/createTextField"; +import { CheckboxFieldConfig, createCheckboxField } from "../CheckboxField/createCheckboxField"; +import { + createRadioGroupField, + RadioGroupFieldConfig, +} from "../RadioGroupField/createRadioGroupField"; +import { createNumberInput } from "../NumberInput/createNumberInput"; +import { createNumberField } from "../NumberField/createNumberField"; type FieldsConfig = { - labelSize?: ComponentProps["size"]; - assistiveTextSize?: ComponentProps["size"]; - inputFontSize?: ComponentProps["size"]; - inputRadius?: BentoSprinkles["borderRadius"]; - inputPaddingX?: BentoSprinkles["paddingX"]; - inputPaddingY?: BentoSprinkles["paddingY"]; + labelSize: ComponentProps["size"]; + assistiveTextSize: ComponentProps["size"]; + input: TextFieldConfig; + checkbox: CheckboxFieldConfig; + radioGroup: RadioGroupFieldConfig; }; -export function createFormFields({ - labelSize = "small", - assistiveTextSize = "small", - inputRadius = 8, - inputPaddingX = 16, - inputPaddingY = 16, - inputFontSize = "large", -}: FieldsConfig) { +export function createFormFields( + config: FieldsConfig = { + labelSize: "small", + assistiveTextSize: "small", + input: { + radius: 8, + paddingX: 16, + paddingY: 16, + fontSize: "large", + }, + checkbox: { + labelSpacing: 8, + }, + radioGroup: { + labelSpacing: 8, + }, + } +) { const Field = createField({ - label: { size: labelSize }, - assistiveText: { size: assistiveTextSize, paddingLeft: inputPaddingX }, + label: { size: config.labelSize }, + assistiveText: { size: config.assistiveTextSize, paddingLeft: config.input.paddingX }, }); - const TextField = createTextField(Field, { - radius: inputRadius, - paddingX: inputPaddingX, - paddingY: inputPaddingY, - fontSize: inputFontSize, - }); + const TextField = createTextField(Field, config.input); + const CheckboxField = createCheckboxField(Field, config.checkbox); + const RadioGroupField = createRadioGroupField(Field, config.radioGroup); + const NumberInput = createNumberInput(config.input); + const NumberField = createNumberField(Field, NumberInput); - return { Field, TextField }; + return { CheckboxField, Field, NumberField, RadioGroupField, TextField }; } diff --git a/src/NumberField/createNumberField.tsx b/src/NumberField/createNumberField.tsx new file mode 100644 index 000000000..416fc21e7 --- /dev/null +++ b/src/NumberField/createNumberField.tsx @@ -0,0 +1,58 @@ +import { useLocale } from "@react-aria/i18n"; +import { useNumberField } from "@react-aria/numberfield"; +import { useNumberFieldState } from "@react-stately/numberfield"; +import { useRef } from "react"; +import { LocalizedString } from ".."; +import { FieldProps } from "../Field/FieldProps"; +import { FormatProps } from "../NumberInput/FormatProps"; +import { useFormatOptions } from "../NumberInput/formatOptions"; +import { FieldType } from "../Field/createField"; +import { NumberInputProps } from "src/NumberInput/createNumberInput"; + +type Props = FieldProps & { + placeholder: LocalizedString; + autoFocus?: boolean; +} & FormatProps; + +export function createNumberField( + Field: FieldType, + NumberInput: React.FunctionComponent +) { + return function NumberField(props: Props) { + const { locale } = useLocale(); + const formatOptions = useFormatOptions(props); + const state = useNumberFieldState({ ...props, locale, formatOptions }); + const inputRef = useRef(null); + + const validationState = props.issues ? "invalid" : "valid"; + + const { labelProps, inputProps, descriptionProps, errorMessageProps } = useNumberField( + { + ...props, + errorMessage: props.issues, + description: props.assistiveText, + isDisabled: props.disabled, + validationState, + formatOptions, + }, + state, + inputRef + ); + + return ( + + + + ); + }; +} diff --git a/src/NumberInput/FormatProps.ts b/src/NumberInput/FormatProps.ts new file mode 100644 index 000000000..ccba243b4 --- /dev/null +++ b/src/NumberInput/FormatProps.ts @@ -0,0 +1,11 @@ +export type FormatProps = + | { + kind: "currency"; + currency: string; + } + | { + kind: "percentage"; + } + | { + kind?: "decimal"; + }; diff --git a/src/NumberInput/createNumberInput.tsx b/src/NumberInput/createNumberInput.tsx new file mode 100644 index 000000000..456cd936e --- /dev/null +++ b/src/NumberInput/createNumberInput.tsx @@ -0,0 +1,107 @@ +import { useLocale } from "@react-aria/i18n"; +import { useMemo } from "react"; +import useDimensions from "react-cool-dimensions"; +import { Label, LocalizedString, unsafeLocalizedString } from ".."; +import { Box } from "../internal"; +import { inputRecipe } from "../Field/Field.css"; +import { bodyRecipe } from "../Typography/Body/Body.css"; +import { FormatProps } from "./FormatProps"; +import { TextFieldConfig } from "src/TextField/createTextField"; + +export type NumberInputProps = { + inputProps: React.InputHTMLAttributes; + inputRef: React.Ref; + placeholder?: LocalizedString; + validationState: "valid" | "invalid"; + disabled?: boolean; +} & FormatProps; + +export function createNumberInput(config: TextFieldConfig) { + return function NumberInput(props: NumberInputProps) { + const { locale } = useLocale(); + + const { observe: rightAccessoryRef, width: rightAccessoryWidth } = useDimensions({ + // This is needed to include the padding in the width + useBorderBoxSize: true, + }); + + // Memoizing the currency code calculation to avoid repeating it at every render + const currencyCode = useMemo((): LocalizedString | undefined => { + if (props.kind === "currency") { + const code = Intl.NumberFormat(locale, { + style: "currency", + currency: props.currency, + currencyDisplay: "code", + }) + .formatToParts(0) + .find(({ type }) => type === "currency")?.value; + return code ? unsafeLocalizedString(code) : undefined; + } else { + return undefined; + } + }, [ + locale, + // NOTE(gabro): props.currency would cause TS to error because it's part of a union type + // @ts-expect-error + props.currency, + props.kind, + ]); + + const rightAccessoryContent = ((): LocalizedString | undefined => { + switch (props.kind) { + case "currency": + return currencyCode; + case "percentage": + return unsafeLocalizedString("%"); + case "decimal": + case undefined: + return undefined; + } + })(); + + return ( + + + {rightAccessoryContent && ( + + + + )} + + ); + }; +} diff --git a/src/NumberInput/formatOptions.ts b/src/NumberInput/formatOptions.ts new file mode 100644 index 000000000..5914012da --- /dev/null +++ b/src/NumberInput/formatOptions.ts @@ -0,0 +1,21 @@ +import { useMemo } from "react"; +import { FormatProps } from "./FormatProps"; + +export function useFormatOptions({ kind }: FormatProps) { + // This function must be memoized, see this relevant issue: + // https://github.com/adobe/react-spectrum/issues/1893 + return useMemo((): Intl.NumberFormatOptions | undefined => { + switch (kind) { + case "currency": + return { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }; + + case "percentage": + case "decimal": + case undefined: + return undefined; + } + }, [kind]); +} diff --git a/src/RadioGroupField/Radio.css.ts b/src/RadioGroupField/Radio.css.ts new file mode 100644 index 000000000..8bc0b8d2c --- /dev/null +++ b/src/RadioGroupField/Radio.css.ts @@ -0,0 +1,68 @@ +import { bentoSprinkles } from "../internal"; +import { strictRecipe } from "../util/strictRecipe"; +import { vars } from "../vars.css"; +import { radioOption } from "./RadioGroupField.css"; + +export const radio = bentoSprinkles({ width: 24, height: 24 }); + +export const outerRadioCircleRecipe = strictRecipe({ + variants: { + selected: { + false: [ + bentoSprinkles({ fill: "outlineInput" }), + { + selectors: { + [`${radioOption}:hover:not([disabled]) &`]: { + fill: vars.outlineColor.outlineInteractive, + }, + [`${radioOption}[disabled] &`]: { + fill: vars.outlineColor.outlineDisabled, + }, + }, + }, + ], + true: [ + bentoSprinkles({ fill: "solidEnabledBackground" }), + { + selectors: { + [`${radioOption}:hover:not([disabled]) &`]: { + fill: vars.interactiveBackgroundColor.solidHoverBackground, + }, + [`${radioOption}[disabled] &`]: { + fill: vars.interactiveBackgroundColor.solidDisabledBackground, + }, + }, + }, + ], + }, + focused: { + true: { fill: vars.interactiveBackgroundColor.solidFocusBackground }, + }, + }, +}); + +export const innerRadioCircleRecipe = strictRecipe({ + base: bentoSprinkles({ fill: "backgroundPrimary" }), + variants: { + selected: { + false: { + r: "11", + selectors: { + [`${radioOption}:hover:not([disabled]) &`]: { + r: "10", + }, + }, + }, + true: { r: "5" }, + }, + focused: { + true: {}, + }, + }, + compoundVariants: [ + { + variants: { selected: false, focused: true }, + style: { r: "10" }, + }, + ], +}); diff --git a/src/RadioGroupField/Radio.tsx b/src/RadioGroupField/Radio.tsx new file mode 100644 index 000000000..cf35d257f --- /dev/null +++ b/src/RadioGroupField/Radio.tsx @@ -0,0 +1,13 @@ +import { innerRadioCircleRecipe, outerRadioCircleRecipe, radio } from "./Radio.css"; + +export function Radio({ selected, focused }: { selected: boolean; focused: boolean }) { + // NOTE(gabro): we can't draw svg strokes "inside" the container, so instead of modelling the radio + // as a circle with a stroke, we model it as two overlapping circles (one outer larger circle, which + // we use to draw the radio "border", and one smaller inner circle) + return ( + + ); +} diff --git a/src/RadioGroupField/RadioGroupField.css.ts b/src/RadioGroupField/RadioGroupField.css.ts new file mode 100644 index 000000000..ce7b3fef7 --- /dev/null +++ b/src/RadioGroupField/RadioGroupField.css.ts @@ -0,0 +1,10 @@ +import { style } from "@vanilla-extract/css"; +import { bentoSprinkles } from "../internal"; +import { extendedHitAreaRecipe } from "../util/extendedHitArea.css"; + +export const radioOption = style([ + bentoSprinkles({ + cursor: { disabled: "notAllowed", default: "pointer" }, + }), + extendedHitAreaRecipe({ axis: "y" }), +]); diff --git a/src/RadioGroupField/createRadioGroupField.tsx b/src/RadioGroupField/createRadioGroupField.tsx new file mode 100644 index 000000000..ad9c0dbde --- /dev/null +++ b/src/RadioGroupField/createRadioGroupField.tsx @@ -0,0 +1,117 @@ +import { Body, LocalizedString } from ".."; +import { BentoSprinkles, Box, Column, Columns, Inline, Inset, Stack } from "../internal"; +import { FieldProps } from "../Field/FieldProps"; +import { FieldType } from "../Field/createField"; +import { RadioGroupState, useRadioGroupState } from "@react-stately/radio"; +import { useRadioGroup, useRadio } from "@react-aria/radio"; +import { AriaRadioGroupProps } from "@react-types/radio"; +import { useField } from "@react-aria/label"; +import { radioOption } from "./RadioGroupField.css"; +import { useRef } from "react"; +import { VisuallyHidden } from "@react-aria/visually-hidden"; +import { useFocusRing } from "@react-aria/focus"; +import { Radio } from "./Radio"; + +type Option = { + value: A; + label: LocalizedString; + isDisabled?: boolean; +}; + +export type RadioGroupFieldProps = FieldProps & { + name: string; + options: Array>; + orientation?: "vertical" | "horizontal"; +}; + +export type RadioGroupFieldConfig = { + labelSpacing: BentoSprinkles["gap"]; +}; + +export function createRadioGroupField( + Field: FieldType, + config: RadioGroupFieldConfig = { + labelSpacing: 8, + } +) { + return function RadioGroupField( + props: RadioGroupFieldProps + ) { + const ariaProps: AriaRadioGroupProps = { + ...props, + isDisabled: props.disabled, + value: String(props.value), + onChange: (a) => { + // NOTE(gabro): we represent the value internally as a string, since it's what + // supports, but this hack allows us to accept also booleans and numbers + props.onChange(props.options.find((o) => String(o.value) === a)!.value as A); + }, + }; + const state = useRadioGroupState(ariaProps); + const { labelProps, radioGroupProps } = useRadioGroup(ariaProps, state); + const { errorMessageProps, descriptionProps } = useField({ + ...ariaProps, + labelElementType: "span", + }); + + const radioOptions = props.options.map((option) => ( + + )); + + return ( + + + + {(props.orientation || "vertical") === "vertical" ? ( + {radioOptions} + ) : ( + {radioOptions} + )} + + + + ); + }; + + function RadioOption({ + option, + state, + }: { + option: Option; + state: RadioGroupState; + }) { + const ref = useRef(null); + const { inputProps } = useRadio( + { ...option, value: String(option.value), children: option.label }, + state, + ref + ); + const { isFocusVisible, focusProps } = useFocusRing(); + const selected = state.selectedValue === String(option.value); + + return ( + + + + + + + + + {option.label} + + + ); + } +} diff --git a/src/TextField/createTextField.tsx b/src/TextField/createTextField.tsx index 8058d64fd..132ac7fcb 100644 --- a/src/TextField/createTextField.tsx +++ b/src/TextField/createTextField.tsx @@ -1,12 +1,12 @@ import { useTextField } from "@react-aria/textfield"; import { ComponentProps, useRef } from "react"; -import { bentoSprinkles, Box } from "../internal"; +import { BentoSprinkles, Box } from "../internal"; import { LocalizedString } from "../util/LocalizedString"; import { inputRecipe } from "../Field/Field.css"; import { FieldProps } from "../Field/FieldProps"; import { bodyRecipe } from "../Typography/Body/Body.css"; import { FieldType } from "../Field/createField"; -import { Body, BoxProps } from "src"; +import { Body } from "src"; type Props = FieldProps & { placeholder: LocalizedString; @@ -14,17 +14,14 @@ type Props = FieldProps & { disabled?: boolean; }; -type TextFieldConfig = { - paddingX: BoxProps["paddingX"]; - paddingY: BoxProps["paddingY"]; - radius: BoxProps["borderRadius"]; +export type TextFieldConfig = { + paddingX: BentoSprinkles["paddingX"]; + paddingY: BentoSprinkles["paddingY"]; + radius: BentoSprinkles["borderRadius"]; fontSize: ComponentProps["size"]; }; -export function createTextField( - Field: FieldType, - config: TextFieldConfig -) { +export function createTextField(Field: FieldType, config: TextFieldConfig) { return function TextField(props: Props) { const inputRef = useRef(null); diff --git a/src/util/atoms.ts b/src/util/atoms.ts index bdda51884..4d0d44da4 100644 --- a/src/util/atoms.ts +++ b/src/util/atoms.ts @@ -86,6 +86,6 @@ export const statusProperties = { boxShadow: { ...vars.boxShadow, none: "none" }, outline: { ...vars.outlineColor, none: "none" }, stroke: color, - fill: color, textDecoration: ["none", "underline"], + fill: { ...color, ...background }, } as const; diff --git a/stories/Components/CheckboxField.stories.tsx b/stories/Components/CheckboxField.stories.tsx new file mode 100644 index 000000000..98b306ec0 --- /dev/null +++ b/stories/Components/CheckboxField.stories.tsx @@ -0,0 +1,27 @@ +import { CheckboxField } from "../"; +import { createComponentStories, formatMessage, textArgType } from "../util"; + +const { defaultExport, createControlledStory } = createComponentStories({ + component: CheckboxField, + args: { + label: formatMessage("I agree with the terms and conditions"), + name: "terms-and-conditions", + }, + argTypes: { + label: textArgType, + }, +}); + +export default defaultExport; + +export const Unchecked = createControlledStory(false, {}); + +export const Checked = createControlledStory(true, {}); + +export const Error = createControlledStory(false, { + issues: [formatMessage("This field is required")], +}); + +export const Disabled = createControlledStory(false, { + disabled: true, +}); diff --git a/stories/Components/NumberField.stories.tsx b/stories/Components/NumberField.stories.tsx new file mode 100644 index 000000000..634f90dc3 --- /dev/null +++ b/stories/Components/NumberField.stories.tsx @@ -0,0 +1,37 @@ +import { NumberField } from "../"; +import { createComponentStories, fieldArgTypes, formatMessage, textArgType } from "../util"; + +const { defaultExport, createControlledStory } = createComponentStories({ + component: NumberField, + args: { + name: "applications", + label: formatMessage("Applications"), + placeholder: formatMessage("Number of target applications"), + assistiveText: formatMessage("The number of applications this campaign is targeting"), + }, + argTypes: { + ...fieldArgTypes, + placeholder: textArgType, + }, +}); + +export default defaultExport; + +export const Default = createControlledStory(undefined, {}); + +export const Disabled = createControlledStory(0, { + disabled: true, +}); + +export const Error = createControlledStory(0, { + issues: [formatMessage("Please insert a number greater than 2")], +}); + +export const Currency = createControlledStory(0, { + kind: "currency", + currency: "EUR", +}); + +export const Percentage = createControlledStory(0, { + kind: "percentage", +}); diff --git a/stories/Components/RadioGroupField.stories.tsx b/stories/Components/RadioGroupField.stories.tsx new file mode 100644 index 000000000..e8aaa8269 --- /dev/null +++ b/stories/Components/RadioGroupField.stories.tsx @@ -0,0 +1,51 @@ +import { createComponentStories, fieldArgTypes, formatMessage } from "../util"; +import { RadioGroupField } from ".."; + +const { defaultExport, createControlledStory } = createComponentStories({ + component: RadioGroupField, + args: { + label: formatMessage("Budget options"), + name: "budgetOptions", + assistiveText: formatMessage("Assistive Text"), + options: [ + { + value: "unlimited", + label: formatMessage("Unlimited"), + }, + { + value: 2, + label: formatMessage("Monthly"), + }, + { + value: "yearly", + label: formatMessage("Yearly"), + isDisabled: true, + }, + { + value: false, + label: formatMessage( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." + ), + }, + ], + }, + argTypes: fieldArgTypes, +}); + +export default defaultExport; + +export const Vertical = createControlledStory("unlimited", {}); + +export const Horizontal = createControlledStory("unlimited", { + orientation: "horizontal", +}); + +export const Disabled = createControlledStory("unlimited", { + disabled: true, +}); + +export const WrappingLabel = createControlledStory( + "unlimited", + {}, + { viewport: { defaultViewport: "tablet" } } +); diff --git a/stories/atoms.ts b/stories/atoms.ts index ac7b7518c..6902c16cb 100644 --- a/stories/atoms.ts +++ b/stories/atoms.ts @@ -31,6 +31,6 @@ export const responsiveProperties = { export const statusProperties = { ...bentoAtoms.statusProperties, color, - fill: color, + fill: { ...color, ...bentoAtoms.statusProperties.background }, stroke: color, }; diff --git a/stories/index.tsx b/stories/index.tsx index 9fb756b03..0ef5ee466 100644 --- a/stories/index.tsx +++ b/stories/index.tsx @@ -18,7 +18,7 @@ import { sprinkles } from "./sprinkles.css"; export * from "../src"; export const Box = createBentoBox(sprinkles); export const { Stack, Column, Columns, Inline, Inset } = createLayoutComponents(Box); -export const { TextField } = createFormFields({}); +export const { CheckboxField, NumberField, RadioGroupField, TextField } = createFormFields(); export const Button = createButton({}); export const Banner = createBanner({}); export const { Toast, ToastProvider } = createToast(Button, {}); diff --git a/stories/theme.css.ts b/stories/theme.css.ts index 1bd5020b7..717e36f9c 100644 --- a/stories/theme.css.ts +++ b/stories/theme.css.ts @@ -178,7 +178,7 @@ export const lightTheme = createTheme(bentoVars, { outlineDecorative: colors.neutral20, outlineContainer: colors.neutral05, outlineNegative: colors.negative40, - outlineDisabled: colors.neutral60, + outlineDisabled: "rgba(82, 94, 111, 0.3)", }, boxShadow: { outlineInput: `inset 0px 0px 0px 1px ${colors.neutral50}`,