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 (
{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) => (
+ <>
+
+ {typeof children === 'function' ? children(renderProps) : children}
+ >
+ )}
+
+ )
+}
diff --git a/src/frontend/src/primitives/TextField.tsx b/src/frontend/src/primitives/TextField.tsx
deleted file mode 100644
index aedb845d..00000000
--- a/src/frontend/src/primitives/TextField.tsx
+++ /dev/null
@@ -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 (
-
- {label}
-
- {description}
-
-
- )
-}
diff --git a/src/frontend/src/primitives/Ul.tsx b/src/frontend/src/primitives/Ul.tsx
new file mode 100644
index 00000000..3144dcd9
--- /dev/null
+++ b/src/frontend/src/primitives/Ul.tsx
@@ -0,0 +1,8 @@
+import { styled } from '../styled-system/jsx'
+
+export const Ul = styled('ul', {
+ base: {
+ listStyle: 'disc',
+ paddingLeft: 1,
+ },
+})
diff --git a/src/frontend/src/primitives/index.ts b/src/frontend/src/primitives/index.ts
index d8fdd70c..68e46411 100644
--- a/src/frontend/src/primitives/index.ts
+++ b/src/frontend/src/primitives/index.ts
@@ -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'