✨(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:
@@ -233,7 +233,7 @@ const config: Config = {
|
|||||||
paragraph: { value: '{spacing.1}' },
|
paragraph: { value: '{spacing.1}' },
|
||||||
heading: { value: '{spacing.1}' },
|
heading: { value: '{spacing.1}' },
|
||||||
gutter: { value: '{spacing.1}' },
|
gutter: { value: '{spacing.1}' },
|
||||||
textfield: { value: '{spacing.0.5}' },
|
textfield: { value: '{spacing.1}' },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
textStyles: defineTextStyles({
|
textStyles: defineTextStyles({
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { navigate } from 'wouter/use-browser-location'
|
import { navigate } from 'wouter/use-browser-location'
|
||||||
import { Form } from 'react-aria-components'
|
|
||||||
import { HStack } from '@/styled-system/jsx'
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
P,
|
P,
|
||||||
@@ -10,11 +8,13 @@ import {
|
|||||||
H,
|
H,
|
||||||
VerticallyOffCenter,
|
VerticallyOffCenter,
|
||||||
Dialog,
|
Dialog,
|
||||||
TextField,
|
Form,
|
||||||
|
Field,
|
||||||
|
Ul,
|
||||||
} from '@/primitives'
|
} from '@/primitives'
|
||||||
import { useCloseDialog } from '@/primitives/Dialog'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { authUrl, useUser } from '@/features/auth'
|
import { authUrl, useUser } from '@/features/auth'
|
||||||
import { navigateToNewRoom } from '@/features/rooms'
|
import { isRoomValid, navigateToNewRoom } from '@/features/rooms'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
@@ -59,44 +59,33 @@ export const Home = () => {
|
|||||||
|
|
||||||
const JoinMeetingDialogContent = () => {
|
const JoinMeetingDialogContent = () => {
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
const closeDialog = useCloseDialog()
|
|
||||||
const fieldOk = /^.*([a-z]{3}-[a-z]{4}-[a-z]{3})$/
|
|
||||||
return (
|
return (
|
||||||
<Div>
|
<Div>
|
||||||
<Form
|
<Form
|
||||||
onSubmit={(event) => {
|
onSubmit={(data) => {
|
||||||
event.preventDefault()
|
navigate(`/${(data.roomId as string).trim()}`)
|
||||||
const roomInput = document.getElementById(
|
|
||||||
'join-meeting-input'
|
|
||||||
) as HTMLInputElement
|
|
||||||
const value = roomInput.value
|
|
||||||
const matches = value.match(fieldOk)
|
|
||||||
if (matches) {
|
|
||||||
navigate(`/${matches[1]}`)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
|
submitLabel={t('joinInputSubmit')}
|
||||||
>
|
>
|
||||||
<TextField
|
<Field
|
||||||
id="join-meeting-input"
|
type="text"
|
||||||
|
name="roomId"
|
||||||
label={t('joinInputLabel')}
|
label={t('joinInputLabel')}
|
||||||
description={t('joinInputExample', {
|
description={t('joinInputExample', {
|
||||||
example: 'https://meet.numerique.gouv.fr/azer-tyu-qsdf',
|
example: 'https://meet.numerique.gouv.fr/azer-tyu-qsdf',
|
||||||
})}
|
})}
|
||||||
validate={(value) => {
|
validate={(value) => {
|
||||||
if (!fieldOk.test(value)) {
|
return !isRoomValid(value.trim()) ? (
|
||||||
return t('joinInputError')
|
<>
|
||||||
}
|
<p>{t('joinInputError')}</p>
|
||||||
return null
|
<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>
|
</Form>
|
||||||
<H lvl={2}>{t('joinMeetingTipHeading')}</H>
|
<H lvl={2}>{t('joinMeetingTipHeading')}</H>
|
||||||
<P last>{t('joinMeetingTipContent')}</P>
|
<P last>{t('joinMeetingTipContent')}</P>
|
||||||
|
|||||||
166
src/frontend/src/primitives/Checkbox.tsx
Normal file
166
src/frontend/src/primitives/Checkbox.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -118,6 +118,9 @@ export const Dialog = ({ title, children, ...dialogProps }: DialogProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useCloseDialog = () => {
|
export const useCloseDialog = () => {
|
||||||
const dialogState = useContext(OverlayTriggerStateContext)!
|
const dialogState = useContext(OverlayTriggerStateContext)
|
||||||
return dialogState.close
|
if (dialogState) {
|
||||||
|
return dialogState.close
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
187
src/frontend/src/primitives/Field.tsx
Normal file
187
src/frontend/src/primitives/Field.tsx
Normal 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>
|
||||||
|
}
|
||||||
20
src/frontend/src/primitives/FieldDescription.tsx
Normal file
20
src/frontend/src/primitives/FieldDescription.tsx
Normal 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} />
|
||||||
|
}
|
||||||
36
src/frontend/src/primitives/FieldErrors.tsx
Normal file
36
src/frontend/src/primitives/FieldErrors.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
src/frontend/src/primitives/Form.tsx
Normal file
61
src/frontend/src/primitives/Form.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
src/frontend/src/primitives/Input.tsx
Normal file
19
src/frontend/src/primitives/Input.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
89
src/frontend/src/primitives/Radio.tsx
Normal file
89
src/frontend/src/primitives/Radio.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
8
src/frontend/src/primitives/Ul.tsx
Normal file
8
src/frontend/src/primitives/Ul.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { styled } from '../styled-system/jsx'
|
||||||
|
|
||||||
|
export const Ul = styled('ul', {
|
||||||
|
base: {
|
||||||
|
listStyle: 'disc',
|
||||||
|
paddingLeft: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -4,8 +4,10 @@ export { Badge } from './Badge'
|
|||||||
export { Bold } from './Bold'
|
export { Bold } from './Bold'
|
||||||
export { Box } from './Box'
|
export { Box } from './Box'
|
||||||
export { Button } from './Button'
|
export { Button } from './Button'
|
||||||
export { Dialog } from './Dialog'
|
export { Dialog, useCloseDialog } from './Dialog'
|
||||||
export { Div } from './Div'
|
export { Div } from './Div'
|
||||||
|
export { Field } from './Field'
|
||||||
|
export { Form } from './Form'
|
||||||
export { H } from './H'
|
export { H } from './H'
|
||||||
export { Hr } from './Hr'
|
export { Hr } from './Hr'
|
||||||
export { Italic } from './Italic'
|
export { Italic } from './Italic'
|
||||||
@@ -14,5 +16,5 @@ export { P } from './P'
|
|||||||
export { Popover } from './Popover'
|
export { Popover } from './Popover'
|
||||||
export { PopoverList } from './PopoverList'
|
export { PopoverList } from './PopoverList'
|
||||||
export { Text } from './Text'
|
export { Text } from './Text'
|
||||||
export { TextField } from './TextField'
|
export { Ul } from './Ul'
|
||||||
export { VerticallyOffCenter } from './VerticallyOffCenter'
|
export { VerticallyOffCenter } from './VerticallyOffCenter'
|
||||||
|
|||||||
Reference in New Issue
Block a user