From c8f96b1f22fcff6f43ea736410d42c6e495c26b1 Mon Sep 17 00:00:00 2001 From: Emmanuel Pelletier Date: Tue, 23 Jul 2024 19:51:06 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20new=20Form=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - improve the Form component to abstract the few things we'll certainly do all the time (data parsing, action buttons rendering) - add a Field component, the main way to render form fields. It mainly wraps react aria components with our styling. The Checkbox component is a bit tricky to go around some current limitations with react aria --- src/frontend/panda.config.ts | 2 +- .../src/features/home/routes/Home.tsx | 51 ++--- src/frontend/src/primitives/Checkbox.tsx | 166 ++++++++++++++++ src/frontend/src/primitives/Dialog.tsx | 7 +- src/frontend/src/primitives/Field.tsx | 187 ++++++++++++++++++ .../src/primitives/FieldDescription.tsx | 20 ++ src/frontend/src/primitives/FieldErrors.tsx | 36 ++++ src/frontend/src/primitives/Form.tsx | 61 ++++++ src/frontend/src/primitives/Input.tsx | 19 ++ src/frontend/src/primitives/Radio.tsx | 89 +++++++++ src/frontend/src/primitives/TextField.tsx | 62 ------ src/frontend/src/primitives/Ul.tsx | 8 + src/frontend/src/primitives/index.ts | 6 +- 13 files changed, 616 insertions(+), 98 deletions(-) create mode 100644 src/frontend/src/primitives/Checkbox.tsx create mode 100644 src/frontend/src/primitives/Field.tsx create mode 100644 src/frontend/src/primitives/FieldDescription.tsx create mode 100644 src/frontend/src/primitives/FieldErrors.tsx create mode 100644 src/frontend/src/primitives/Form.tsx create mode 100644 src/frontend/src/primitives/Input.tsx create mode 100644 src/frontend/src/primitives/Radio.tsx delete mode 100644 src/frontend/src/primitives/TextField.tsx create mode 100644 src/frontend/src/primitives/Ul.tsx diff --git a/src/frontend/panda.config.ts b/src/frontend/panda.config.ts index bb80f4c6..f86a5d3e 100644 --- a/src/frontend/panda.config.ts +++ b/src/frontend/panda.config.ts @@ -233,7 +233,7 @@ const config: Config = { paragraph: { value: '{spacing.1}' }, heading: { value: '{spacing.1}' }, gutter: { value: '{spacing.1}' }, - textfield: { value: '{spacing.0.5}' }, + textfield: { value: '{spacing.1}' }, }, }), textStyles: defineTextStyles({ diff --git a/src/frontend/src/features/home/routes/Home.tsx b/src/frontend/src/features/home/routes/Home.tsx index 43545ea5..c55b3d32 100644 --- a/src/frontend/src/features/home/routes/Home.tsx +++ b/src/frontend/src/features/home/routes/Home.tsx @@ -1,7 +1,5 @@ import { useTranslation } from 'react-i18next' import { navigate } from 'wouter/use-browser-location' -import { Form } from 'react-aria-components' -import { HStack } from '@/styled-system/jsx' import { Button, P, @@ -10,11 +8,13 @@ import { H, VerticallyOffCenter, Dialog, - TextField, + Form, + Field, + Ul, } from '@/primitives' -import { useCloseDialog } from '@/primitives/Dialog' +import { HStack } from '@/styled-system/jsx' import { authUrl, useUser } from '@/features/auth' -import { navigateToNewRoom } from '@/features/rooms' +import { isRoomValid, navigateToNewRoom } from '@/features/rooms' import { Screen } from '@/layout/Screen' export const Home = () => { @@ -59,44 +59,33 @@ export const Home = () => { const JoinMeetingDialogContent = () => { const { t } = useTranslation('home') - const closeDialog = useCloseDialog() - const fieldOk = /^.*([a-z]{3}-[a-z]{4}-[a-z]{3})$/ return (
{ - event.preventDefault() - const roomInput = document.getElementById( - 'join-meeting-input' - ) as HTMLInputElement - const value = roomInput.value - const matches = value.match(fieldOk) - if (matches) { - navigate(`/${matches[1]}`) - } + onSubmit={(data) => { + navigate(`/${(data.roomId as string).trim()}`) }} + submitLabel={t('joinInputSubmit')} > - { - if (!fieldOk.test(value)) { - return t('joinInputError') - } - return null + return !isRoomValid(value.trim()) ? ( + <> +

{t('joinInputError')}

+
    +
  • {window.location.origin}/uio-azer-jkl
  • +
  • uio-azer-jkl
  • +
+ + ) : null }} /> - - - - {t('joinMeetingTipHeading')}

{t('joinMeetingTipContent')}

diff --git a/src/frontend/src/primitives/Checkbox.tsx b/src/frontend/src/primitives/Checkbox.tsx new file mode 100644 index 00000000..c7c4d888 --- /dev/null +++ b/src/frontend/src/primitives/Checkbox.tsx @@ -0,0 +1,166 @@ +import { type ReactNode, useId, useState } from 'react' +import { + type CheckboxProps as RACCheckboxProps, + Checkbox as RACCheckbox, + CheckboxContext, +} from 'react-aria-components' +import { type StyledVariantProps } from '@/styled-system/types' +import { styled } from '@/styled-system/jsx' +import { FieldErrors } from './FieldErrors' +import { FieldDescription } from './FieldDescription' + +// styled taken from example at https://react-spectrum.adobe.com/react-aria/Checkbox.html +export const StyledCheckbox = styled(RACCheckbox, { + base: { + display: 'flex', + alignItems: 'center', + gap: 0.375, + forcedColorAdjust: 'none', + width: 'fit-content', + '& .mt-Checkbox-checkbox': { + borderColor: 'control.border', + flexShrink: 0, + width: '1.375rem', + height: '1.375rem', + border: '1px solid', + borderRadius: 4, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + transition: 'all 200ms', + }, + '& svg': { + stroke: 'primary.text', + width: '0.875rem', + height: '0.875rem', + flexShrink: 0, + fill: 'none', + strokeWidth: '3px', + strokeDasharray: '22px', + strokeDashoffset: '66', + transition: 'all 200ms', + }, + '&[data-pressed] .mt-Checkbox-checkbox': { + borderColor: 'focusRing', + }, + '&[data-focus-visible] .mt-Checkbox-checkbox': { + outline: '2px solid!', + outlineColor: 'focusRing!', + outlineOffset: '2px!', + }, + '&[data-selected] .mt-Checkbox-checkbox': { + borderColor: 'primary', + backgroundColor: 'primary', + }, + '&[data-selected][data-pressed] .mt-Checkbox-checkbox': { + borderColor: 'primary.active', + backgroundColor: 'primary.active', + }, + '&[data-selected] svg': { + strokeDashoffset: '44', + }, + '&[data-mt-checkbox-invalid="true"] .mt-Checkbox-checkbox': { + borderColor: 'danger', + }, + '&[data-selected][data-mt-checkbox-invalid="true"] .mt-Checkbox-checkbox': { + backgroundColor: 'danger', + }, + }, + variants: { + size: { + sm: { + base: {}, + '& .mt-Checkbox-checkbox': { + width: '1.125rem', + height: '1.125rem', + }, + '& svg': { + width: '0.625rem', + height: '0.625rem', + }, + }, + }, + }, +}) + +export type CheckboxProps = StyledVariantProps & + RACCheckboxProps & { description?: ReactNode } + +/** + * RAC Checkbox wrapper that adds support for description/error messages + * + * This is because description and error messages are not supported for + * standalone checkboxes (see https://github.com/adobe/react-spectrum/issues/6192) + * + * We do some trickery to go around a few things so that we can trigger + * the error message with the `validate` prop like other fields. + * + * Used internally by checkbox fields and checkbox group fields. + */ +export const Checkbox = ({ + isInvalid, + description, + children, + ...props +}: CheckboxProps) => { + const [error, setError] = useState(null) + const errorId = useId() + const descriptionId = useId() + + if (isInvalid !== undefined) { + console.error( + 'Checkbox: passing isInvalid is not supported, use the validate prop instead' + ) + return null + } + + return ( +
+ + + {(renderProps) => { + renderProps.isInvalid && !!props.validate + ? setError(props.validate(renderProps.isSelected)) + : setError(null) + + return ( + <> +
+ +
+
+ {typeof children === 'function' + ? children(renderProps) + : children} +
+ + ) + }} +
+
+ {!!description && ( + {description} + )} + {!!error && } +
+ ) +} diff --git a/src/frontend/src/primitives/Dialog.tsx b/src/frontend/src/primitives/Dialog.tsx index 2690bda0..33d1beca 100644 --- a/src/frontend/src/primitives/Dialog.tsx +++ b/src/frontend/src/primitives/Dialog.tsx @@ -118,6 +118,9 @@ export const Dialog = ({ title, children, ...dialogProps }: DialogProps) => { } export const useCloseDialog = () => { - const dialogState = useContext(OverlayTriggerStateContext)! - return dialogState.close + const dialogState = useContext(OverlayTriggerStateContext) + if (dialogState) { + return dialogState.close + } + return null } diff --git a/src/frontend/src/primitives/Field.tsx b/src/frontend/src/primitives/Field.tsx new file mode 100644 index 00000000..689d36b9 --- /dev/null +++ b/src/frontend/src/primitives/Field.tsx @@ -0,0 +1,187 @@ +import { styled } from '@/styled-system/jsx' +import { type ReactNode } from 'react' +import { + Label, + TextField as RACTextField, + FieldError as RACFieldError, + CheckboxGroup, + RadioGroup, + type TextFieldProps, + type CheckboxProps, + type CheckboxGroupProps, + type RadioGroupProps, +} from 'react-aria-components' +import { FieldDescription } from './FieldDescription' +import { FieldErrors } from './FieldErrors' +import { Input } from './Input' +import { Radio } from './Radio' +import { Checkbox } from './Checkbox' +import { Div } from './Div' + +const FieldWrapper = styled('div', { + base: { + marginBottom: 'textfield', + }, +}) + +const StyledLabel = styled(Label, { + base: { + display: 'block', + }, +}) + +type OmittedRACProps = 'type' | 'label' | 'items' | 'description' | 'validate' +type Items = { items: Array<{ value: string; label: ReactNode }> } +type PartialTextFieldProps = Omit +type PartialCheckboxProps = Omit +type PartialCheckboxGroupProps = Omit +type PartialRadioGroupProps = Omit +type FieldProps = ( + | ({ + type: 'text' + items?: never + validate?: ( + value: string + ) => ReactNode | ReactNode[] | true | null | undefined + } & PartialTextFieldProps) + | ({ + type: 'checkbox' + validate?: ( + value: boolean + ) => ReactNode | ReactNode[] | true | null | undefined + items?: never + } & PartialCheckboxProps) + | ({ + type: 'checkboxGroup' + validate?: ( + value: string[] + ) => ReactNode | ReactNode[] | true | null | undefined + } & Items & + PartialCheckboxGroupProps) + | ({ + type: 'radioGroup' + validate?: ( + value: string | null + ) => ReactNode | ReactNode[] | true | null | undefined + } & Items & + PartialRadioGroupProps) +) & { + label: string + description?: string +} + +/** + * Form field. + * + * This is the only component you should need when creating forms, besides the wrapping Form component. + * + * It has a specific type: a text input, a select, a checkbox, a checkbox group or a radio group. + * It can have a `description`, a help text shown below the label. + * On submit, it shows the errors that the `validate` prop returns based on the field value. + * You can render React nodes as error messages if needed, but you usually return strings. + * + * You can directly pass HTML input props if needed (like required, pattern, etc) + */ +export const Field = ({ + type, + label, + description, + items, + validate, + ...props +}: FieldProps) => { + const LabelAndDescription = ( + <> + {label} + {description} + + ) + const RACFieldErrors = ( + + {({ validationErrors }) => { + return + }} + + ) + + if (type === 'text') { + return ( + + + {LabelAndDescription} + + {RACFieldErrors} + + + ) + } + + if (type === 'checkbox') { + return ( + + + {label} + + + ) + } + + if (type === 'checkboxGroup') { + return ( + + + {LabelAndDescription} +
+ {items.map((item, index) => ( + + + {item.label} + + + ))} +
+ {RACFieldErrors} +
+
+ ) + } + + if (type === 'radioGroup') { + return ( + + + {LabelAndDescription} + {items.map((item, index) => ( + + {item.label} + + ))} + {RACFieldErrors} + + + ) + } +} + +const FieldItem = ({ + children, + last, +}: { + children: ReactNode + last?: boolean +}) => { + return
{children}
+} diff --git a/src/frontend/src/primitives/FieldDescription.tsx b/src/frontend/src/primitives/FieldDescription.tsx new file mode 100644 index 00000000..f2f19aec --- /dev/null +++ b/src/frontend/src/primitives/FieldDescription.tsx @@ -0,0 +1,20 @@ +import { styled } from '@/styled-system/jsx' +import { Text, TextProps } from 'react-aria-components' + +const StyledDescription = styled(Text, { + base: { + display: 'block', + textStyle: 'sm', + color: 'default.subtle-text', + marginBottom: 0.125, + }, +}) + +/** + * Styled field description. + * + * Used internally by Fields. + */ +export const FieldDescription = (props: TextProps) => { + return +} diff --git a/src/frontend/src/primitives/FieldErrors.tsx b/src/frontend/src/primitives/FieldErrors.tsx new file mode 100644 index 00000000..91812b13 --- /dev/null +++ b/src/frontend/src/primitives/FieldErrors.tsx @@ -0,0 +1,36 @@ +import { styled } from '@/styled-system/jsx' +import { type ReactNode, Fragment } from 'react' + +const StyledErrors = styled('div', { + base: { + display: 'block', + textStyle: 'sm', + color: 'danger', + marginTop: 0.125, + }, +}) + +/** + * Styled list of given errors. + * + * Used internally by Fields. + */ +export const FieldErrors = ({ + id, + errors, +}: { + id?: string + errors: ReactNode[] +}) => { + return ( + + {errors.map((error, i) => { + return typeof error === 'string' ? ( +

{error}

+ ) : ( + {error} + ) + })} +
+ ) +} diff --git a/src/frontend/src/primitives/Form.tsx b/src/frontend/src/primitives/Form.tsx new file mode 100644 index 00000000..b0883850 --- /dev/null +++ b/src/frontend/src/primitives/Form.tsx @@ -0,0 +1,61 @@ +import { type FormEvent } from 'react' +import { Form as RACForm, type FormProps } from 'react-aria-components' +import { useTranslation } from 'react-i18next' +import { HStack } from '@/styled-system/jsx' +import { Button, useCloseDialog } from '@/primitives' + +/** + * From wrapper that exposes form data on submit and adds submit/cancel buttons + * + * Wrap all your Fields in this component. + * If the form is in a dialog, the cancel button closes the dialog unless you pass a custom onCancelButtonPress handler. + */ +export const Form = ({ + onSubmit, + submitLabel, + withCancelButton = true, + onCancelButtonPress, + children, + ...props +}: Omit & { + onSubmit?: ( + data: { + [k: string]: FormDataEntryValue + }, + event: FormEvent + ) => void + submitLabel: string + withCancelButton?: boolean + onCancelButtonPress?: () => void +}) => { + const { t } = useTranslation() + const closeDialog = useCloseDialog() + const onCancel = withCancelButton + ? onCancelButtonPress || closeDialog + : undefined + + return ( + { + event.preventDefault() + const formData = Object.fromEntries(new FormData(event.currentTarget)) + if (onSubmit) { + onSubmit(formData, event) + } + }} + > + {children} + + + {!!onCancel && ( + + )} + + + ) +} diff --git a/src/frontend/src/primitives/Input.tsx b/src/frontend/src/primitives/Input.tsx new file mode 100644 index 00000000..56336bfa --- /dev/null +++ b/src/frontend/src/primitives/Input.tsx @@ -0,0 +1,19 @@ +import { styled } from '@/styled-system/jsx' +import { Input as RACInput } from 'react-aria-components' + +/** + * Styled RAC Input. + * + * Used internally by Fields. + */ +export const Input = styled(RACInput, { + base: { + width: 'full', + paddingY: 0.125, + paddingX: 0.25, + border: '1px solid', + borderColor: 'control.border', + color: 'control.text', + borderRadius: 4, + }, +}) diff --git a/src/frontend/src/primitives/Radio.tsx b/src/frontend/src/primitives/Radio.tsx new file mode 100644 index 00000000..2e84fdd6 --- /dev/null +++ b/src/frontend/src/primitives/Radio.tsx @@ -0,0 +1,89 @@ +import { + type RadioProps as RACRadioProps, + Radio as RACRadio, +} from 'react-aria-components' +import { styled } from '@/styled-system/jsx' +import { type StyledVariantProps } from '@/styled-system/types' + +// styled taken from example at https://react-spectrum.adobe.com/react-aria/Checkbox.html and changed for round radios +export const StyledRadio = styled(RACRadio, { + base: { + display: 'flex', + alignItems: 'center', + gap: 0.375, + forcedColorAdjust: 'none', + width: 'fit-content', + '& .mt-Radio': { + borderRadius: 'full', + flexShrink: 0, + width: '1.125rem', + height: '1.125rem', + border: '1px solid {colors.control.border}', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + transition: 'all 200ms', + }, + '& .mt-Radio-check': { + width: '0.5rem', + height: '0.5rem', + borderRadius: 'full', + backgroundColor: 'transparent', + transition: 'all 200ms', + }, + '&[data-pressed] .mt-Radio': { + borderColor: 'primary.active', + }, + '&[data-focus-visible] .mt-Radio': { + outline: '2px solid!', + outlineColor: 'focusRing!', + outlineOffset: '2px!', + }, + '&[data-selected] .mt-Radio': { + borderColor: 'primary', + }, + '&[data-selected] .mt-Radio-check': { + backgroundColor: 'primary', + }, + '&[data-selected][data-pressed] .mt-Radio-check': { + backgroundColor: 'primary.active', + }, + }, + variants: { + size: { + sm: { + base: {}, + '& .radio': { + width: '1.125rem', + height: '1.125rem', + }, + '& svg': { + width: '0.625rem', + height: '0.625rem', + }, + }, + }, + }, +}) + +export type RadioProps = StyledVariantProps & RACRadioProps + +/** + * Styled radio button. + * + * Used internally by RadioGroups in Fields. + */ +export const Radio = ({ children, ...props }: RadioProps) => { + return ( + + {(renderProps) => ( + <> +