(frontend) new Form components

- 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
This commit is contained in:
Emmanuel Pelletier
2024-07-23 19:51:06 +02:00
parent df5f0dbf9f
commit c8f96b1f22
13 changed files with 616 additions and 98 deletions

View File

@@ -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({

View File

@@ -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 (
<Div>
<Form
onSubmit={(event) => {
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')}
>
<TextField
id="join-meeting-input"
<Field
type="text"
name="roomId"
label={t('joinInputLabel')}
description={t('joinInputExample', {
example: 'https://meet.numerique.gouv.fr/azer-tyu-qsdf',
})}
validate={(value) => {
if (!fieldOk.test(value)) {
return t('joinInputError')
}
return null
return !isRoomValid(value.trim()) ? (
<>
<p>{t('joinInputError')}</p>
<Ul>
<li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li>
</Ul>
</>
) : null
}}
/>
<HStack gap="gutter">
<Button type="submit" variant="primary">
{t('joinInputSubmit')}
</Button>
<Button variant="primary" outline onPress={closeDialog}>
{t('cancel', { ns: 'global' })}
</Button>
</HStack>
</Form>
<H lvl={2}>{t('joinMeetingTipHeading')}</H>
<P last>{t('joinMeetingTipContent')}</P>

View File

@@ -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<typeof StyledCheckbox> &
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<ReactNode | null>(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 (
<div>
<CheckboxContext.Provider
value={{
'aria-describedby': [
!!description && descriptionId,
!!error && errorId,
]
.filter(Boolean)
.join(' '),
// @ts-expect-error Any html attribute is actually valid
'data-mt-checkbox-invalid': !!error,
}}
>
<StyledCheckbox {...props}>
{(renderProps) => {
renderProps.isInvalid && !!props.validate
? setError(props.validate(renderProps.isSelected))
: setError(null)
return (
<>
<div className="mt-Checkbox-checkbox">
<svg
width={18}
height={18}
viewBox="0 0 18 18"
aria-hidden="true"
preserveAspectRatio="xMinYMin meet"
>
<polyline points="1 9 7 14 15 4" />
</svg>
</div>
<div>
{typeof children === 'function'
? children(renderProps)
: children}
</div>
</>
)
}}
</StyledCheckbox>
</CheckboxContext.Provider>
{!!description && (
<FieldDescription id={descriptionId}>{description}</FieldDescription>
)}
{!!error && <FieldErrors id={errorId} errors={[error]} />}
</div>
)
}

View File

@@ -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
}

View File

@@ -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<TextFieldProps, OmittedRACProps>
type PartialCheckboxProps = Omit<CheckboxProps, OmittedRACProps>
type PartialCheckboxGroupProps = Omit<CheckboxGroupProps, OmittedRACProps>
type PartialRadioGroupProps = Omit<RadioGroupProps, OmittedRACProps>
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 = (
<>
<StyledLabel>{label}</StyledLabel>
<FieldDescription slot="description">{description}</FieldDescription>
</>
)
const RACFieldErrors = (
<RACFieldError>
{({ validationErrors }) => {
return <FieldErrors errors={validationErrors} />
}}
</RACFieldError>
)
if (type === 'text') {
return (
<FieldWrapper>
<RACTextField
validate={validate as unknown as TextFieldProps['validate']}
{...(props as PartialTextFieldProps)}
>
{LabelAndDescription}
<Input />
{RACFieldErrors}
</RACTextField>
</FieldWrapper>
)
}
if (type === 'checkbox') {
return (
<FieldWrapper>
<Checkbox
validate={validate as unknown as CheckboxProps['validate']}
description={description}
{...(props as PartialCheckboxProps)}
>
{label}
</Checkbox>
</FieldWrapper>
)
}
if (type === 'checkboxGroup') {
return (
<FieldWrapper>
<CheckboxGroup
validate={validate as unknown as CheckboxGroupProps['validate']}
{...(props as PartialCheckboxGroupProps)}
>
{LabelAndDescription}
<Div marginTop={0.25}>
{items.map((item, index) => (
<FieldItem last={index === items.length - 1} key={item.value}>
<Checkbox size="sm" value={item.value}>
{item.label}
</Checkbox>
</FieldItem>
))}
</Div>
{RACFieldErrors}
</CheckboxGroup>
</FieldWrapper>
)
}
if (type === 'radioGroup') {
return (
<FieldWrapper>
<RadioGroup
validate={validate as unknown as RadioGroupProps['validate']}
{...(props as PartialRadioGroupProps)}
>
{LabelAndDescription}
{items.map((item, index) => (
<FieldItem last={index === items.length - 1} key={item.value}>
<Radio value={item.value}>{item.label}</Radio>
</FieldItem>
))}
{RACFieldErrors}
</RadioGroup>
</FieldWrapper>
)
}
}
const FieldItem = ({
children,
last,
}: {
children: ReactNode
last?: boolean
}) => {
return <Div {...(!last ? { marginBottom: 0.25 } : {})}>{children}</Div>
}

View File

@@ -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 <StyledDescription {...props} />
}

View File

@@ -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 (
<StyledErrors id={id}>
{errors.map((error, i) => {
return typeof error === 'string' ? (
<p key={error}>{error}</p>
) : (
<Fragment key={i}>{error}</Fragment>
)
})}
</StyledErrors>
)
}

View File

@@ -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<FormProps, 'onSubmit'> & {
onSubmit?: (
data: {
[k: string]: FormDataEntryValue
},
event: FormEvent<HTMLFormElement>
) => void
submitLabel: string
withCancelButton?: boolean
onCancelButtonPress?: () => void
}) => {
const { t } = useTranslation()
const closeDialog = useCloseDialog()
const onCancel = withCancelButton
? onCancelButtonPress || closeDialog
: undefined
return (
<RACForm
{...props}
onSubmit={(event) => {
event.preventDefault()
const formData = Object.fromEntries(new FormData(event.currentTarget))
if (onSubmit) {
onSubmit(formData, event)
}
}}
>
{children}
<HStack gap="gutter">
<Button type="submit" variant="primary">
{submitLabel}
</Button>
{!!onCancel && (
<Button variant="primary" outline onPress={() => onCancel()}>
{t('cancel')}
</Button>
)}
</HStack>
</RACForm>
)
}

View File

@@ -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,
},
})

View File

@@ -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<typeof StyledRadio> & RACRadioProps
/**
* Styled radio button.
*
* Used internally by RadioGroups in Fields.
*/
export const Radio = ({ children, ...props }: RadioProps) => {
return (
<StyledRadio {...props}>
{(renderProps) => (
<>
<div className="mt-Radio" aria-hidden="true">
<div className="mt-Radio-check" />
</div>
{typeof children === 'function' ? children(renderProps) : children}
</>
)}
</StyledRadio>
)
}

View File

@@ -1,62 +0,0 @@
import { styled } from '@/styled-system/jsx'
import {
Label,
Input,
TextField as RACTextField,
Text,
FieldError,
} from 'react-aria-components'
const StyledRACTextField = styled(RACTextField, {
base: {
marginBottom: 'textfield',
},
})
const StyledLabel = styled(Label, {
base: {
display: 'block',
},
})
const StyledInput = styled(Input, {
base: {
width: 'full',
paddingY: 0.125,
paddingX: 0.25,
border: '1px solid',
borderColor: 'control.border',
color: 'control.text',
borderRadius: 4,
},
})
const StyledDescription = styled(Text, {
base: {
display: 'block',
textStyle: 'sm',
color: 'default.subtle-text',
marginTop: 0.125,
},
})
const StyledFieldError = styled(FieldError, {
base: {
display: 'block',
textStyle: 'sm',
color: 'danger',
marginTop: 0.125,
},
})
export const TextField = ({ validate, label, description, ...inputProps }) => {
const labelFor = inputProps.id
return (
<StyledRACTextField validate={validate}>
<StyledLabel htmlFor={labelFor}>{label}</StyledLabel>
<StyledInput {...inputProps} />
<StyledDescription slot="description">{description}</StyledDescription>
<StyledFieldError />
</StyledRACTextField>
)
}

View File

@@ -0,0 +1,8 @@
import { styled } from '../styled-system/jsx'
export const Ul = styled('ul', {
base: {
listStyle: 'disc',
paddingLeft: 1,
},
})

View File

@@ -4,8 +4,10 @@ export { Badge } from './Badge'
export { Bold } from './Bold'
export { Box } from './Box'
export { Button } from './Button'
export { Dialog } from './Dialog'
export { Dialog, useCloseDialog } from './Dialog'
export { Div } from './Div'
export { Field } from './Field'
export { Form } from './Form'
export { H } from './H'
export { Hr } from './Hr'
export { Italic } from './Italic'
@@ -14,5 +16,5 @@ export { P } from './P'
export { Popover } from './Popover'
export { PopoverList } from './PopoverList'
export { Text } from './Text'
export { TextField } from './TextField'
export { Ul } from './Ul'
export { VerticallyOffCenter } from './VerticallyOffCenter'