Radio and RadioGroup components (#10178)

This PR adds 2 new components:
1. Radio
2. RadioGroup

Also makes Form 99% feature-complete

This shall be merged after https://github.com/enso-org/enso/pull/10176
This commit is contained in:
Sergei Garin 2024-06-05 16:19:33 +03:00 committed by GitHub
parent e6ecaff4c4
commit 149a2c8965
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 956 additions and 138 deletions

View File

@ -218,14 +218,6 @@ const RESTRICTED_SYNTAXES = [
selector: 'JSXOpeningElement[name.name=button] > JSXIdentifier',
message: 'Use `Button` or `UnstyledButton` instead of `button`',
},
{
selector: 'JSXOpeningElement[name.name=label] > JSXIdentifier',
message: 'Use `aria.Label` instead of `label`',
},
{
selector: 'JSXOpeningElement[name.name=input] > JSXIdentifier',
message: 'Use `aria.Input` instead of `input`',
},
{
selector: 'JSXOpeningElement[name.name=/^h[123456]$/] > JSXIdentifier',
message: 'Use `aria.Heading` instead of `h1`-`h6`',

View File

@ -16,20 +16,22 @@ import SvgMask from '#/components/SvgMask'
/** Props for a {@link Button}. */
export type ButtonProps =
| (BaseButtonProps & Omit<aria.ButtonProps, 'onPress'> & PropsWithoutHref)
| (BaseButtonProps & Omit<aria.LinkProps, 'onPress'> & PropsWithHref)
| (BaseButtonProps & Omit<aria.ButtonProps, 'onPress' | 'type'> & PropsWithoutHref)
| (BaseButtonProps & Omit<aria.LinkProps, 'onPress' | 'type'> & PropsWithHref)
/**
* Props for a button with an href.
*/
interface PropsWithHref {
readonly href: string
readonly href?: string
readonly type?: never
}
/**
* Props for a button without an href.
*/
interface PropsWithoutHref {
readonly type?: 'button' | 'reset' | 'submit'
readonly href?: never
}
@ -42,7 +44,7 @@ export interface BaseButtonProps extends Omit<twv.VariantProps<typeof BUTTON_STY
/**
* The icon to display in the button
*/
readonly icon?: string | null
readonly icon?: React.ReactElement | string | null
/**
* When `true`, icon will be shown only when hovered.
*/
@ -54,6 +56,8 @@ export interface BaseButtonProps extends Omit<twv.VariantProps<typeof BUTTON_STY
readonly onPress?: (event: aria.PressEvent) => Promise<void> | void
readonly testId?: string
readonly formnovalidate?: boolean
}
export const BUTTON_STYLES = twv.tv({
@ -67,12 +71,12 @@ export const BUTTON_STYLES = twv.tv({
fullWidth: { true: 'w-full' },
size: {
custom: '',
hero: 'px-8 py-4 text-lg',
large: 'px-6 py-3 text-base',
medium: 'px-4 py-2 text-sm',
small: 'px-3 py-1 text-xs',
xsmall: 'px-2 py-1 text-xs',
xxsmall: 'px-1.5 py-0.5 text-xs',
hero: 'px-8 py-4 text-lg font-bold',
large: 'px-6 py-3 text-base font-bold',
medium: 'px-4 py-2 text-sm font-bold',
small: 'px-3 pt-1 pb-[5px] text-xs font-medium',
xsmall: 'px-2 pt-1 pb-[5px] text-xs font-medium',
xxsmall: 'px-1.5 pt-1 pb-[5px] text-xs font-medium',
},
iconOnly: { true: '' },
rounded: {
@ -82,6 +86,8 @@ export const BUTTON_STYLES = twv.tv({
none: 'rounded-none',
small: 'rounded-sm',
xlarge: 'rounded-xl',
xxlarge: 'rounded-2xl',
xxxlarge: 'rounded-3xl',
},
variant: {
custom: 'focus-visible:outline-offset-2',
@ -97,8 +103,7 @@ export const BUTTON_STYLES = twv.tv({
icon: 'w-fit h-fit',
},
submit: 'bg-invite text-white opacity-80 hover:opacity-100 focus-visible:outline-offset-2',
outline:
'border-primary/40 text-primary font-bold hover:border-primary/90 focus-visible:outline-offset-2',
outline: 'border-primary/40 text-primary hover:border-primary focus-visible:outline-offset-2',
},
iconPosition: {
start: { content: '' },
@ -131,6 +136,12 @@ export const BUTTON_STYLES = twv.tv({
{ variant: 'icon', size: 'medium', class: 'p-2 rounded-full', iconOnly: true },
{ variant: 'icon', size: 'large', class: 'p-3 rounded-full', iconOnly: true },
{ variant: 'icon', size: 'hero', class: 'p-4 rounded-full', iconOnly: true },
{ variant: 'link', size: 'xxsmall', class: 'font-medium' },
{ variant: 'link', size: 'xsmall', class: 'font-medium' },
{ variant: 'link', size: 'small', class: 'font-medium' },
{ variant: 'link', size: 'medium', class: 'font-medium' },
{ variant: 'link', size: 'large', class: 'font-medium' },
{ variant: 'link', size: 'hero', class: 'font-medium' },
],
})
@ -232,18 +243,23 @@ export const Button = React.forwardRef(function Button(
})
const childrenFactory = (): React.ReactNode => {
const iconComponent = (() => {
if (icon == null) {
return null
} else if (typeof icon === 'string') {
return <SvgMask src={icon} className={iconClasses()} />
} else {
return <span className={iconClasses()}>{icon}</span>
}
})()
// Icon only button
if (isIconOnly) {
return (
<span className={extraClickZone()}>
<SvgMask src={icon} className={iconClasses()} />
</span>
)
return <span className={extraClickZone()}>{iconComponent}</span>
} else {
// Default button
return (
<>
{icon != null && <SvgMask src={icon} className={iconClasses()} />}
{iconComponent}
<>{children}</>
</>
)
@ -282,7 +298,7 @@ export const Button = React.forwardRef(function Button(
return tooltipElement == null ? (
button
) : (
<ariaComponents.TooltipTrigger>
<ariaComponents.TooltipTrigger delay={0} closeDelay={0}>
{button}
<ariaComponents.Tooltip>{tooltipElement}</ariaComponents.Tooltip>
</ariaComponents.TooltipTrigger>

View File

@ -11,7 +11,7 @@ interface ButtonGroupProps extends React.PropsWithChildren, twv.VariantProps<typ
}
const STYLES = twv.tv({
base: 'flex w-full flex-auto',
base: 'flex w-full flex-1 shrink-0',
variants: {
wrap: { true: 'flex-wrap' },
direction: { column: 'flex-col justify-center', row: 'flex-row items-center' },
@ -22,7 +22,14 @@ const STYLES = twv.tv({
small: 'gap-1.5',
none: 'gap-0',
},
align: { start: 'justify-start', center: 'justify-center', end: 'justify-end' },
align: {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
between: 'justify-between',
around: 'justify-around',
evenly: 'justify-evenly',
},
},
})

View File

@ -38,10 +38,11 @@ export function CloseButton(props: CloseButtonProps) {
return (
<button.Button
variant="icon"
// @ts-expect-error ts fails to infer the type of the className prop
className={values =>
twMerge.twJoin(
'h-3 w-3 bg-primary/30 hover:bg-red-500/80 focus-visible:bg-red-500/80 focus-visible:outline-offset-1',
// @ts-expect-error className can be a function or a string
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
typeof className === 'function' ? className(values) : className
)
}
@ -51,7 +52,9 @@ export function CloseButton(props: CloseButtonProps) {
rounded="full"
icon={icon}
aria-label={ariaLabel}
{...buttonProps}
/* This is safe because we are passing all props to the button */
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
{...(buttonProps as any)}
/>
)
}

View File

@ -70,7 +70,9 @@ export function CopyButton(props: CopyButtonProps) {
return (
<button.Button
{...buttonProps}
/* This is safe because we are passing all props to the button */
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
{...(buttonProps as any)}
variant={variant}
aria-label={ariaLabel}
onPress={() => copyQuery.mutateAsync()}

View File

@ -5,6 +5,7 @@
*/
import * as React from 'react'
import * as sentry from '@sentry/react'
import * as reactQuery from '@tanstack/react-query'
import * as reactHookForm from 'react-hook-form'
@ -12,6 +13,9 @@ import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as errorUtils from '#/utilities/error'
import * as dialog from '../Dialog'
import * as components from './components'
import * as styles from './styles'
import type * as types from './types'
@ -53,6 +57,7 @@ export const Form = React.forwardRef(function Form<
schema,
defaultValues,
gap,
method,
...formProps
} = props
@ -70,6 +75,8 @@ export const Form = React.forwardRef(function Form<
}
)
const dialogContext = dialog.useDialogContext()
React.useImperativeHandle(formRef, () => innerForm, [innerForm])
const formMutation = reactQuery.useMutation({
@ -77,14 +84,24 @@ export const Form = React.forwardRef(function Form<
mutationFn: async (fieldValues: TFieldValues) => {
try {
await onSubmit(fieldValues, innerForm)
if (method === 'dialog') {
dialogContext?.close()
}
} catch (error) {
innerForm.setError('root.submit', {
message: error instanceof Error ? error.message : getText('arbitraryFormErrorMessage'),
})
// TODO: Should we throw the error here?
// Or should we just log it?
// eslint-disable-next-line no-restricted-syntax
throw error
const isJSError = errorUtils.isJSError(error)
if (isJSError) {
sentry.captureException(error, {
contexts: { form: { values: fieldValues } },
})
}
const message = isJSError
? getText('arbitraryFormErrorMessage')
: errorUtils.tryGetMessage(error, getText('arbitraryFormErrorMessage'))
innerForm.setError('root.submit', { message })
}
},
onError: onSubmitFailed,
@ -106,39 +123,43 @@ export const Form = React.forwardRef(function Form<
unregister,
setFocus,
reset,
control,
} = innerForm
const formStateRenderProps: types.FormStateRenderProps<Schema, TFieldValues> = {
formState,
register: (name, options) => {
const registered = register(name, options)
const formStateRenderProps: types.FormStateRenderProps<Schema, TFieldValues, TTransformedValues> =
{
formState,
register: (name, options) => {
const registered = register(name, options)
const onChange: types.UseFormRegisterReturn<Schema, TFieldValues>['onChange'] = value => {
if (typeof value === 'object' && value != null && 'target' in value && 'type' in value) {
return registered.onChange(value)
} else {
return registered.onChange({ target: { event: value } })
const onChange: types.UseFormRegisterReturn<Schema, TFieldValues>['onChange'] = value => {
if (typeof value === 'object' && value != null && 'target' in value && 'type' in value) {
return registered.onChange(value)
} else {
return registered.onChange({ target: { event: value } })
}
}
}
const result: types.UseFormRegisterReturn<Schema, TFieldValues, typeof name> = {
...registered,
...(registered.disabled != null ? { isDisabled: registered.disabled } : {}),
...(registered.required != null ? { isRequired: registered.required } : {}),
isInvalid: !!formState.errors[name],
onChange,
}
const result: types.UseFormRegisterReturn<Schema, TFieldValues, typeof name> = {
...registered,
...(registered.disabled != null ? { isDisabled: registered.disabled } : {}),
...(registered.required != null ? { isRequired: registered.required } : {}),
isInvalid: !!formState.errors[name],
onChange,
}
return result
},
unregister,
setError,
clearErrors,
getValues,
setValue,
setFocus,
reset,
}
return result
},
unregister,
setError,
clearErrors,
getValues,
setValue,
setFocus,
reset,
control,
form: innerForm,
}
const base = styles.FORM_STYLES({
className: typeof className === 'function' ? className(formStateRenderProps) : className,
@ -184,8 +205,10 @@ export const Form = React.forwardRef(function Form<
/* eslint-disable @typescript-eslint/naming-convention */
schema: typeof components.schema
useForm: typeof components.useForm
useField: typeof components.useField
Submit: typeof components.Submit
Reset: typeof components.Reset
Field: typeof components.Field
FormError: typeof components.FormError
useFormSchema: typeof components.useFormSchema
/* eslint-enable @typescript-eslint/naming-convention */
@ -193,7 +216,9 @@ export const Form = React.forwardRef(function Form<
Form.schema = components.schema
Form.useForm = components.useForm
Form.useField = components.useField
Form.useFormSchema = components.useFormSchema
Form.Submit = components.Submit
Form.Reset = components.Reset
Form.FormError = components.FormError
Form.useFormSchema = components.useFormSchema
Form.Field = components.Field

View File

@ -0,0 +1,115 @@
/**
* @file
*
* Field component
*/
import * as React from 'react'
import * as twv from 'tailwind-variants'
import * as aria from '#/components/aria'
import * as text from '../../Text'
import type * as types from './types'
import * as formContext from './useFormContext'
/**
* Props for Field component
*/
export interface FieldComponentProps
extends twv.VariantProps<typeof FIELD_STYLES>,
types.FieldProps,
React.PropsWithChildren {
readonly name: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly form?: types.FormInstance<any, any, any>
readonly isInvalid?: boolean
readonly className?: string
}
export const FIELD_STYLES = twv.tv({
base: 'flex flex-col gap-0.5 items-start',
variants: {
fullWidth: { true: 'w-full' },
isInvalid: {
true: { label: 'text-danger' },
},
},
slots: {
label: text.TEXT_STYLE({ variant: 'subtitle' }),
content: 'flex flex-col items-start w-full',
description: text.TEXT_STYLE({ variant: 'body', color: 'disabled' }),
error: text.TEXT_STYLE({ variant: 'body', color: 'danger' }),
},
})
/**
* Field component
*/
export const Field = React.forwardRef(function Field(
props: FieldComponentProps,
ref: React.ForwardedRef<HTMLFieldSetElement>
) {
const {
form = formContext.useFormContext(),
isInvalid,
children,
className,
label,
description,
fullWidth,
error,
name,
isRequired = false,
} = props
const fieldState = form.getFieldState(name)
const labelId = React.useId()
const descriptionId = React.useId()
const errorId = React.useId()
const invalid = isInvalid === true || fieldState.invalid
const classes = FIELD_STYLES({
fullWidth,
isInvalid: invalid,
})
const hasError = (error ?? fieldState.error?.message) != null
return (
<fieldset
ref={ref}
className={classes.base({ className })}
aria-invalid={invalid}
aria-label={props['aria-label']}
aria-labelledby={labelId}
aria-describedby={descriptionId}
aria-details={props['aria-details']}
aria-errormessage={hasError ? errorId : ''}
aria-required={isRequired}
>
{label != null && (
<aria.Label id={labelId} className={classes.label()}>
{label}
</aria.Label>
)}
<div className={classes.content()}>{children}</div>
{description != null && (
<span id={descriptionId} className={classes.description()}>
{description}
</span>
)}
{hasError && (
<span id={errorId} className={classes.error()}>
{error ?? fieldState.error?.message}
</span>
)}
</fieldset>
)
})

View File

@ -6,33 +6,27 @@
import * as React from 'react'
import * as reactHookForm from 'react-hook-form'
import * as textProvider from '#/providers/TextProvider'
import * as reactAriaComponents from '#/components/AriaComponents'
import type * as types from '../types'
import type * as types from './types'
import * as formContext from './useFormContext'
/**
* Props for the FormError component.
*/
export interface FormErrorProps<
TFieldValues extends types.FieldValues<never>,
TTransformedFieldValues extends types.FieldValues<never>,
> extends Omit<reactAriaComponents.AlertProps, 'children'> {
readonly form?: reactHookForm.UseFormReturn<TFieldValues, unknown, TTransformedFieldValues>
export interface FormErrorProps extends Omit<reactAriaComponents.AlertProps, 'children'> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly form?: types.FormInstance<any, any>
}
/**
* Form error component.
*/
export function FormError<
TFieldValues extends types.FieldValues<never>,
TTransformedFieldValues extends types.FieldValues<never>,
>(props: FormErrorProps<TFieldValues, TTransformedFieldValues>) {
export function FormError(props: FormErrorProps) {
const {
form = reactHookForm.useFormContext(),
form = formContext.useFormContext(),
size = 'medium',
variant = 'error',
...alertProps

View File

@ -5,10 +5,11 @@
*/
import * as React from 'react'
import * as reactHookForm from 'react-hook-form'
import * as ariaComponents from '#/components/AriaComponents'
import type * as types from './types'
import * as formContext from './useFormContext'
/**
* Props for the Reset component.
*/
@ -21,24 +22,33 @@ export interface ResetProps extends Omit<ariaComponents.ButtonProps, 'loading'>
*/
// For this component, we don't need to know the form fields
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly form?: reactHookForm.UseFormReturn<any>
readonly form?: types.FormInstance<any, any>
}
/**
* Reset button for forms.
*/
export function Reset(props: ResetProps): React.JSX.Element {
const { form = reactHookForm.useFormContext(), variant = 'cancel', size = 'medium' } = props
const {
form = formContext.useFormContext(),
variant = 'cancel',
size = 'medium',
testId = 'form-reset-button',
...buttonProps
} = props
const { formState } = form
return (
<ariaComponents.Button
{...props}
/* This is safe because we are passing all props to the button */
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
{...(buttonProps as any)}
type="reset"
variant={variant}
size={size}
isDisabled={formState.isSubmitting}
isDisabled={formState.isSubmitting || !formState.isDirty}
onPress={form.reset}
testId={testId}
/>
)
}

View File

@ -6,12 +6,13 @@
*/
import * as React from 'react'
import * as reactHookForm from 'react-hook-form'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import type * as types from './types'
import * as formContext from './useFormContext'
/**
* Additional props for the Submit component.
*/
@ -25,13 +26,20 @@ interface SubmitButtonBaseProps {
*/
// For this component, we don't need to know the form fields
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly form?: reactHookForm.UseFormReturn<any>
readonly form?: types.FormInstance<any, any>
/**
* Prop that allows to close the parent dialog without submitting the form.
*
* This looks tricky, but it's recommended by MDN as a receipt for closing the dialog without submitting the form.
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog#closing_a_dialog_with_a_required_form_input
*/
readonly formnovalidate?: boolean
}
/**
* Props for the Submit component.
*/
export type SubmitProps = Omit<ariaComponents.ButtonProps, 'loading' | 'variant'> &
export type SubmitProps = Omit<ariaComponents.ButtonProps, 'href' | 'variant'> &
SubmitButtonBaseProps
/**
@ -41,24 +49,35 @@ export type SubmitProps = Omit<ariaComponents.ButtonProps, 'loading' | 'variant'
*/
export function Submit(props: SubmitProps): React.JSX.Element {
const {
form = reactHookForm.useFormContext(),
form = formContext.useFormContext(),
variant = 'submit',
size = 'medium',
testId = 'form-submit-button',
formnovalidate,
loading = false,
children,
...buttonProps
} = props
const { getText } = textProvider.useText()
const dialogContext = ariaComponents.useDialogContext()
const { formState } = form
return (
<ariaComponents.Button
{...props}
type="submit"
/* This is safe because we are passing all props to the button */
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
{...(buttonProps as any)}
type={formnovalidate === true ? 'button' : 'submit'}
variant={variant}
size={size}
loading={formState.isSubmitting}
loading={loading || formState.isSubmitting}
testId={testId}
onPress={() => {
if (formnovalidate === true) {
dialogContext?.close()
}
}}
>
{children ?? getText('submit')}
</ariaComponents.Button>

View File

@ -10,3 +10,5 @@ export * from './FormError'
export * from './types'
export * from './useFormSchema'
export * from './schema'
export * from './useField'
export * from './Field'

View File

@ -2,16 +2,29 @@
* @file
* Types for the Form component.
*/
import type * as React from 'react'
import type * as reactHookForm from 'react-hook-form'
import type * as z from 'zod'
import type * as aria from '#/components/aria'
/**
* Field Values type.
* Field values type.
*/
export type FieldValues<Schema extends TSchema> = [Schema] extends [never]
? reactHookForm.FieldValues
: z.infer<Schema>
// eslint-disable-next-line no-restricted-syntax
export type FieldValues<Schema extends TSchema | undefined> =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Schema extends z.ZodObject<any> ? z.infer<Schema> : reactHookForm.FieldValues
/**
* Field path type.
* @alias reactHookForm.FieldPath
*/
export type FieldPath<
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
> = reactHookForm.FieldPath<TFieldValues>
/**
* Schema type
@ -23,12 +36,16 @@ export type TSchema = z.ZodObject<any>
* Props for the useForm hook.
*/
export interface UseFormProps<Schema extends TSchema, TFieldValues extends FieldValues<Schema>>
extends Omit<reactHookForm.UseFormProps<TFieldValues>, 'resetOptions' | 'resolver'> {
extends Omit<
reactHookForm.UseFormProps<TFieldValues>,
'handleSubmit' | 'resetOptions' | 'resolver'
> {
readonly schema: Schema
}
/**
* Return type of the useForm hook.
* @alias reactHookForm.UseFormReturn
*/
export type UseFormReturn<
Schema extends TSchema,
@ -38,9 +55,50 @@ export type UseFormReturn<
> = reactHookForm.UseFormReturn<TFieldValues, unknown, TTransformedValues>
/**
* Form State type.
* Form state type.
* @alias reactHookForm.FormState
*/
export type FormState<
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
> = reactHookForm.FormState<TFieldValues>
/**
* Form instance type
* @alias UseFormReturn
*/
export type FormInstance<
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends Record<string, unknown> | undefined = undefined,
> = UseFormReturn<Schema, TFieldValues, TTransformedValues>
/**
* Form type interface that check if FieldValues type is compatible with the value type from component
*/
export interface FormWithValueValidation<
BaseValueType,
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
TFieldName extends FieldPath<Schema, TFieldValues>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
> {
readonly form?:
| (BaseValueType extends TFieldValues[TFieldName]
? FormInstance<Schema, TFieldValues, TTransformedValues>
: 'Type mismatch: Field with this name has a different type than the value of the component.')
// eslint-disable-next-line no-restricted-syntax
| undefined
}
/**
* Props for the Field component.
*/
export interface FieldProps extends aria.AriaLabelingProps {
readonly isRequired?: boolean
readonly label?: React.ReactNode
readonly description?: React.ReactNode
readonly error?: React.ReactNode
}

View File

@ -0,0 +1,66 @@
/**
* @file
*
* A hook for creating a field and field state for a form.
*/
import * as reactHookForm from 'react-hook-form'
import type * as types from './types'
import * as formContext from './useFormContext'
/**
* Options for {@link useField} hook.
*/
export interface UseFieldOptions<
BaseValueType,
Schema extends types.TSchema,
TFieldValues extends types.FieldValues<Schema>,
TFieldName extends types.FieldPath<Schema, TFieldValues>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends types.FieldValues<Schema> | undefined = undefined,
> extends types.FormWithValueValidation<
BaseValueType,
Schema,
TFieldValues,
TFieldName,
TTransformedValues
> {
readonly name: TFieldName
readonly isDisabled?: boolean
// eslint-disable-next-line no-restricted-syntax
readonly defaultValue?: TFieldValues[TFieldName] | undefined
}
/**
* A hook that connects a field to a form state.
*/
export function useField<
BaseValueType,
Schema extends types.TSchema,
TFieldValues extends types.FieldValues<Schema>,
TFieldName extends types.FieldPath<Schema, TFieldValues>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends types.FieldValues<Schema> | undefined = undefined,
>(options: UseFieldOptions<BaseValueType, Schema, TFieldValues, TFieldName, TTransformedValues>) {
const { form = formContext.useFormContext(), name, defaultValue, isDisabled = false } = options
// This is safe, because the form is always passed either via the options or via the context.
// The assertion is needed because we use additional type validation for form instance and throw
// ts error if form does not pass the validation.
// eslint-disable-next-line no-restricted-syntax
const formInstance = form as types.FormInstance<Schema, TFieldValues, TTransformedValues>
const { field, fieldState, formState } = reactHookForm.useController({
name,
control: formInstance.control,
disabled: isDisabled,
...(defaultValue != null ? { defaultValue } : {}),
})
return {
field,
fieldState,
formState,
formInstance,
} as const
}

View File

@ -0,0 +1,13 @@
/**
* @file
*
* This file is a wrapper around the react-hook-form useFormContext hook.
*/
import * as reactHookForm from 'react-hook-form'
/**
* Returns the form instance from the context.
*/
export function useFormContext() {
return reactHookForm.useFormContext()
}

View File

@ -16,10 +16,6 @@ export function useFormSchema<Schema extends types.TSchema, T extends types.Fiel
callback: (schema: typeof schemaComponent.schema) => schemaComponent.schema.ZodObject<T>
) {
const callbackEvent = callbackEventHooks.useEventCallback(callback)
const res = React.useMemo(() => callbackEvent(schemaComponent.schema), [callbackEvent])
// We assume that all the fields are optional by default
// This is because we don't want to force the user to provide a value for every field
// But if the user wants to make a field required, they can do so by providing a default value for the field
return res.partial()
return React.useMemo(() => callbackEvent(schemaComponent.schema), [callbackEvent])
}

View File

@ -47,28 +47,37 @@ interface BaseFormProps<
* Otherwise Typescript fails to infer the correct type for the form values.
* This is a known limitation and we are working on a solution.
*/
readonly defaultValues?: reactHookForm.UseFormProps<TFieldValues>['defaultValues']
readonly defaultValues?: components.UseFormProps<Schema, TFieldValues>['defaultValues']
readonly onSubmit: (
values: TFieldValues,
form: components.UseFormReturn<Schema, TFieldValues, TTransformedValues>
) => unknown
readonly style?:
| React.CSSProperties
| ((props: FormStateRenderProps<Schema, TFieldValues>) => React.CSSProperties)
| ((
props: FormStateRenderProps<Schema, TFieldValues, TTransformedValues>
) => React.CSSProperties)
readonly children:
| React.ReactNode
| ((props: FormStateRenderProps<Schema, TFieldValues>) => React.ReactNode)
| ((props: FormStateRenderProps<Schema, TFieldValues, TTransformedValues>) => React.ReactNode)
readonly formRef?: React.MutableRefObject<
components.UseFormReturn<Schema, TFieldValues, TTransformedValues>
>
readonly className?: string | ((props: FormStateRenderProps<Schema, TFieldValues>) => string)
readonly className?:
| string
| ((props: FormStateRenderProps<Schema, TFieldValues, TTransformedValues>) => string)
readonly onSubmitFailed?: (error: unknown) => Promise<void> | void
readonly onSubmitSuccess?: () => Promise<void> | void
readonly onSubmitted?: () => Promise<void> | void
readonly testId?: string
/**
* When set to `dialog`, form submission will close the parent dialog on successful submission.
*/
// eslint-disable-next-line @typescript-eslint/ban-types,no-restricted-syntax
readonly method?: 'dialog' | (string & {})
}
/**
@ -100,13 +109,16 @@ interface FormPropsWithOptions<
}
/**
*
* Register function for a form field.
*/
export type UseFormRegister<
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
> = <
TFieldName extends reactHookForm.FieldPath<TFieldValues> = reactHookForm.FieldPath<TFieldValues>,
TFieldName extends components.FieldPath<Schema, TFieldValues> = components.FieldPath<
Schema,
TFieldValues
>,
>(
name: TFieldName,
options?: reactHookForm.RegisterOptions<TFieldValues, TFieldName>
@ -119,7 +131,10 @@ export type UseFormRegister<
export interface UseFormRegisterReturn<
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
TFieldName extends reactHookForm.FieldPath<TFieldValues> = reactHookForm.FieldPath<TFieldValues>,
TFieldName extends components.FieldPath<Schema, TFieldValues> = components.FieldPath<
Schema,
TFieldValues
>,
> extends Omit<reactHookForm.UseFormRegisterReturn<TFieldName>, 'onChange'> {
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
readonly onChange: <Value>(value: Value) => Promise<boolean | void> | void
@ -131,28 +146,76 @@ export interface UseFormRegisterReturn<
/**
* Form Render Props.
*/
export interface FormStateRenderProps<
export type FormStateRenderProps<
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
> {
/**
* The form state. Contains the current values of the form fields.
*/
readonly formState: components.FormState<Schema, TFieldValues>
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
> = Pick<
components.FormInstance<Schema, TFieldValues, TTransformedValues>,
| 'clearErrors'
| 'control'
| 'formState'
| 'getValues'
| 'reset'
| 'setError'
| 'setFocus'
| 'setValue'
| 'unregister'
// eslint-disable-next-line no-restricted-syntax
> & {
/**
* The form register function.
* Adds a field to the form state.
*/
readonly register: UseFormRegister<Schema, TFieldValues>
/**
* The form unregister function.
* Removes a field from the form state.
* Form Instance
*/
readonly unregister: reactHookForm.UseFormUnregister<TFieldValues>
readonly setValue: reactHookForm.UseFormSetValue<TFieldValues>
readonly getValues: reactHookForm.UseFormGetValues<TFieldValues>
readonly setError: reactHookForm.UseFormSetError<TFieldValues>
readonly clearErrors: reactHookForm.UseFormClearErrors<TFieldValues>
readonly setFocus: reactHookForm.UseFormSetFocus<TFieldValues>
readonly reset: reactHookForm.UseFormReset<TFieldValues>
readonly form: components.FormInstance<Schema, TFieldValues, TTransformedValues>
}
/**
* Base Props for a Form Field.
* @private
*/
interface FormFieldProps<
BaseValueType,
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
TFieldName extends components.FieldPath<Schema, TFieldValues>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
> extends components.FormWithValueValidation<
BaseValueType,
Schema,
TFieldValues,
TFieldName,
TTransformedValues
> {
readonly name: TFieldName
readonly value?: BaseValueType extends TFieldValues[TFieldName] ? TFieldValues[TFieldName] : never
readonly defaultValue?: TFieldValues[TFieldName]
readonly isDisabled?: boolean
readonly isRequired?: boolean
readonly isInvalid?: boolean
}
/**
* Field State Props
*/
export type FieldStateProps<
// eslint-disable-next-line no-restricted-syntax
BaseProps extends { value?: unknown },
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
TFieldName extends components.FieldPath<Schema, TFieldValues>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
> = FormFieldProps<BaseProps['value'], Schema, TFieldValues, TFieldName, TTransformedValues> & {
// to avoid conflicts with the FormFieldProps we need to omit the FormFieldProps from the BaseProps
[K in keyof Omit<
BaseProps,
keyof FormFieldProps<BaseProps['value'], Schema, TFieldValues, TFieldName, TTransformedValues>
>]: BaseProps[K]
}

View File

@ -0,0 +1,160 @@
/**
* @file
*
* A radio button.
*/
import * as React from 'react'
import * as twv from 'tailwind-variants'
import * as aria from '#/components/aria'
import * as mergeRefs from '#/utilities/mergeRefs'
import * as text from '../Text'
import * as radioGroup from './RadioGroup'
import * as radioGroupContext from './RadioGroupContext'
const RADIO_STYLES = twv.tv({
base: 'flex items-center gap-2 cursor-pointer group w-full',
variants: {
isFocused: { true: 'outline-none' },
isFocusVisible: { true: { radio: 'outline outline-2 outline-primary outline-offset-1' } },
isSelected: {
false: { radio: 'border-2 border-primary/30' },
true: { radio: 'border-primary border-[5px]' },
},
isHovered: { true: { radio: 'border-primary/50' } },
isInvalid: { true: { radio: 'border-danger' } },
isDisabled: { true: { base: 'cursor-not-allowed', radio: 'border-gray-200' } },
isPressed: {
true: { radio: 'border-[3px] border-primary' },
},
isSiblingPressed: { true: '' },
},
slots: {
radio:
'w-4 h-4 rounded-full bg-frame aspect-square flex-none transition-[border-color,border-width,outline-offset] duration-50 ease-in-out',
input: 'sr-only',
label: 'flex-1 shrink-0',
},
compoundVariants: [
{ isPressed: true, isSelected: true, class: { radio: 'border-[5px]' } },
{ isPressed: true, isInvalid: true, class: { radio: 'border-red-800' } },
{ isSiblingPressed: true, isSelected: true, class: { radio: 'border-4' } },
],
})
/**
* Props for the {@link Radio} component.
*/
export interface RadioProps extends aria.RadioProps {
readonly label?: string
}
/**
* A radio button.
*/
// eslint-disable-next-line no-restricted-syntax
export const Radio = React.forwardRef(function Radio(
props: RadioProps,
ref: React.ForwardedRef<HTMLLabelElement>
) {
const { children, label, className, ...ariaProps } = props
const inputRef = React.useRef<HTMLInputElement>(null)
const labelRef = React.useRef<HTMLLabelElement>(null)
const id = aria.useId(ariaProps.id)
const state = React.useContext(aria.RadioGroupStateContext)
const { setPressed, clearPressed, isSiblingPressed } = radioGroupContext.useRadioGroupContext({
value: props.value,
})
const { isSelected, isDisabled, isPressed, inputProps, labelProps } = aria.useRadio(
aria.mergeProps<aria.RadioProps>()(ariaProps, {
id,
children: label ?? (typeof children === 'function' ? true : children),
}),
state,
inputRef
)
const { isFocused, isFocusVisible, focusProps } = aria.useFocusRing()
const interactionDisabled = isDisabled || state.isReadOnly
const { hoverProps, isHovered } = aria.useHover({
...props,
isDisabled: interactionDisabled,
})
React.useEffect(() => {
if (isPressed) {
setPressed()
} else {
clearPressed()
}
}, [isPressed, setPressed, clearPressed])
const renderValues = {
isSelected,
isPressed,
isHovered,
isFocused,
isFocusVisible,
isDisabled,
isReadOnly: state.isReadOnly,
isInvalid: state.isInvalid,
isRequired: state.isRequired,
defaultChildren: null,
defaultClassName: '',
}
const {
base,
radio,
input,
label: labelClasses,
} = RADIO_STYLES({
isSiblingPressed,
isFocused,
isFocusVisible,
isHovered,
isSelected,
isInvalid: state.isInvalid,
isDisabled,
isPressed,
className: typeof className === 'function' ? className(renderValues) : className,
})
const renderedChildren = typeof children === 'function' ? children(renderValues) : children
return (
<label
{...aria.mergeProps<React.LabelHTMLAttributes<HTMLLabelElement>>()(hoverProps, labelProps)}
ref={mergeRefs.mergeRefs(labelRef, ref)}
className={base()}
>
<input
{...aria.mergeProps<React.InputHTMLAttributes<HTMLInputElement>>()(inputProps, focusProps)}
ref={inputRef}
id={id}
className={input()}
/>
<div className={radio()} />
<text.Text className={labelClasses()} variant="body" truncate="1">
{label ?? renderedChildren}
</text.Text>
</label>
)
}) as unknown as ((
props: RadioProps & React.RefAttributes<HTMLLabelElement>
// eslint-disable-next-line no-restricted-syntax
) => React.JSX.Element) & {
// eslint-disable-next-line @typescript-eslint/naming-convention
Group: typeof radioGroup.RadioGroup
}
Radio.Group = radioGroup.RadioGroup

View File

@ -0,0 +1,123 @@
/**
* @file
*
* A radio group component.
*/
import * as React from 'react'
import * as twv from 'tailwind-variants'
import * as aria from '#/components/aria'
import * as mergeRefs from '#/utilities/mergeRefs'
import * as formComponent from '../Form'
import * as radioGroupContext from './RadioGroupContext'
/**
* Props for {@link RadioGroup}.
*/
export interface RadioGroupProps<
Schema extends formComponent.TSchema,
TFieldValues extends formComponent.FieldValues<Schema>,
TFieldName extends formComponent.FieldPath<Schema, TFieldValues>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends formComponent.FieldValues<Schema> | undefined = undefined,
> extends formComponent.FieldStateProps<
Omit<aria.AriaRadioGroupProps, 'description' | 'label'>,
Schema,
TFieldValues,
TFieldName,
TTransformedValues
>,
twv.VariantProps<typeof RADIO_GROUP_STYLES>,
formComponent.FieldProps {
readonly children?: React.ReactNode
readonly className?: string
}
export const RADIO_GROUP_STYLES = twv.tv({
base: 'flex flex-col gap-0.5 items-start',
variants: { fullWidth: { true: 'w-full' } },
})
/**
* A radio group component.
*/
// eslint-disable-next-line no-restricted-syntax
export const RadioGroup = React.forwardRef(function RadioGroup<
Schema extends formComponent.TSchema,
TFieldName extends formComponent.FieldPath<Schema, TFieldValues>,
TFieldValues extends formComponent.FieldValues<Schema> = formComponent.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends formComponent.FieldValues<Schema> | undefined = undefined,
>(
props: RadioGroupProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
ref: React.ForwardedRef<HTMLDivElement>
) {
const {
children,
isRequired = false,
isReadOnly = false,
isDisabled = false,
isInvalid = false,
name,
className,
form,
defaultValue,
label,
description,
fullWidth,
...radioGroupProps
} = props
const { field, fieldState, formInstance } = formComponent.Form.useField({
name,
isDisabled,
form,
defaultValue,
})
const invalid = isInvalid || fieldState.invalid
const base = RADIO_GROUP_STYLES({ fullWidth, className })
return (
<aria.RadioGroup
ref={mergeRefs.mergeRefs(ref, field.ref)}
{...radioGroupProps}
className={base}
name={field.name}
value={field.value}
isDisabled={field.disabled ?? isDisabled}
isRequired={isRequired}
isReadOnly={isReadOnly}
isInvalid={invalid}
onChange={field.onChange}
onBlur={field.onBlur}
>
<radioGroupContext.RadioGroupProvider>
<formComponent.Form.Field
name={field.name}
form={formInstance}
label={label}
description={description}
fullWidth={fullWidth}
isInvalid={invalid}
{...radioGroupProps}
>
{children}
</formComponent.Form.Field>
</radioGroupContext.RadioGroupProvider>
</aria.RadioGroup>
)
}) as <
Schema extends formComponent.TSchema,
TFieldName extends formComponent.FieldPath<Schema, TFieldValues>,
TFieldValues extends formComponent.FieldValues<Schema> = formComponent.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends formComponent.FieldValues<Schema> | undefined = undefined,
>(
props: RadioGroupProps<Schema, TFieldValues, TFieldName, TTransformedValues> &
React.RefAttributes<HTMLFormElement>
) => React.JSX.Element

View File

@ -0,0 +1,106 @@
/**
* @file
*
* Context provider for `<RadioGroup />` component.
* Provides useful information about sibling Radio elements within a RadioGroup
* Allows individual Radio components to communicate with each other via context
*
* This component is not related to `RadioGroupStateContext` from `react-aria-components`,
* which is used to manage the state of a radio group (selected value, disabled, etc.)
*
* This component is supposed to provide custom context information for Radio components
* and let them communicate with each other (e.g. to know if a sibling Radio element is being pressed)
*/
import * as React from 'react'
import invariant from 'tiny-invariant'
import * as eventCallback from '#/hooks/eventCallbackHooks'
/**
* Props for {@link RadioGroupContextProps}
*/
export interface RadioGroupContextProps {
/**
* Tells if a Radio element is being pressed
*
* It's not the same as selected value, instead it stores the value user is clicking on at the moment
*/
readonly pressedRadio: string | null
/**
* Sets the pressed Radio element
*/
readonly setPressedRadio: (value: string) => void
/**
* Clears the pressed Radio element
*/
readonly clearPressedRadio: () => void
}
const RadioGroupContext = React.createContext<RadioGroupContextProps | null>(null)
/**
* RadioGroupProvider is a context provider for RadioGroup component
* Allows individual Radio components to communicate with each other.
*/
export function RadioGroupProvider(props: React.PropsWithChildren) {
const { children } = props
const [pressedRadio, setPressedRadio] = React.useState<string | null>(null)
const setRadioPressed = eventCallback.useEventCallback((value: string) => {
setPressedRadio(value)
})
const clearPressedRadio = eventCallback.useEventCallback(() => {
setPressedRadio(null)
})
const value = React.useMemo<RadioGroupContextProps>(
() => ({
pressedRadio,
setPressedRadio: setRadioPressed,
clearPressedRadio,
}),
[pressedRadio, setRadioPressed, clearPressedRadio]
)
return <RadioGroupContext.Provider value={value}>{children}</RadioGroupContext.Provider>
}
/**
* Props for {@link useRadioGroupContext}
*/
export interface UseRadioGroupContextProps {
readonly value: string
}
/**
* Provides useful information about sibling Radio elements within a RadioGroup
*/
export function useRadioGroupContext(props: UseRadioGroupContextProps) {
const { value } = props
const context = React.useContext(RadioGroupContext)
invariant(context != null, 'You can only use radio inside RadioGroup')
/**
* Tells if a sibling Radio element is being pressed
* It's not the same as selected value, instead it says if a user is clicking on a sibling Radio element at the moment
*/
const isSiblingPressed = context.pressedRadio != null && value !== context.pressedRadio
const setPressed = eventCallback.useEventCallback(() => {
context.setPressedRadio(value)
})
const clearPressed = eventCallback.useEventCallback(() => {
context.clearPressedRadio()
})
return {
isSiblingPressed,
setPressed,
clearPressed,
}
}

View File

@ -0,0 +1,8 @@
/**
* @file
*
* Barrel file for the Radio component.
*/
export * from './Radio'
export * from './RadioGroup'

View File

@ -20,7 +20,7 @@ export interface TextProps
readonly lineClamp?: number
}
const TEXT_STYLE = twv.tv({
export const TEXT_STYLE = twv.tv({
base: 'inline-block',
variants: {
color: {

View File

@ -9,12 +9,27 @@ import * as error from '#/utilities/error'
const MESSAGE = 'A custom error message.'
v.test.each([
{ errorObject: new Error(MESSAGE), message: MESSAGE },
{ errorObject: { message: 'a' }, message: 'a' },
{ errorObject: MESSAGE, message: null },
{ errorObject: {}, message: null },
{ errorObject: null, message: null },
])('`error.tryGetMessage`', ({ errorObject, message }) => {
v.expect(error.tryGetMessage<unknown>(errorObject)).toBe(message)
v.describe('`error.tryGetMessage`', () => {
v.test.each([
{ errorObject: new Error(MESSAGE), message: MESSAGE },
{ errorObject: { message: 'a' }, message: 'a' },
{ errorObject: MESSAGE, message: null },
{ errorObject: {}, message: null },
{ errorObject: null, message: null },
])('should correctly parse error objects', ({ errorObject, message }) => {
v.expect(error.tryGetMessage<unknown>(errorObject)).toBe(message)
})
v.test.each([
{ errorObject: new Error(MESSAGE), message: MESSAGE, defaultMessage: 'b' },
{ errorObject: { message: 'a' }, message: 'a', defaultMessage: 'b' },
{ errorObject: MESSAGE, message: 'b', defaultMessage: 'b' },
{ errorObject: {}, message: 'b', defaultMessage: 'b' },
{ errorObject: null, message: 'b', defaultMessage: 'b' },
])(
'should return default message if failed to extract it from the error object',
({ errorObject, message, defaultMessage }) => {
v.expect(error.tryGetMessage<unknown, string>(errorObject, defaultMessage)).toBe(message)
}
)
})

View File

@ -21,14 +21,18 @@ export type MustNotBeKnown<T> =
/** Extracts the `message` property of a value if it is a string. Intended to be used on
* {@link Error}s. */
export function tryGetMessage<T>(error: MustNotBeKnown<T>): string | null {
export function tryGetMessage<T, DefaultMessage extends string | null = null>(
error: MustNotBeKnown<T>,
// eslint-disable-next-line no-restricted-syntax
defaultMessage: DefaultMessage = null as DefaultMessage
): DefaultMessage | string {
const unknownError: unknown = error
return unknownError != null &&
typeof unknownError === 'object' &&
'message' in unknownError &&
typeof unknownError.message === 'string'
? unknownError.message
: null
: defaultMessage
}
/** Extracts the `error` property of a value if it is a string. */
@ -101,3 +105,24 @@ export function assert<T>(makeValue: () => T | '' | 0 | 0n | false | null | unde
return result
}
}
/**
* Checks if the given error is a JavaScript execution error.
*/
export function isJSError(error: unknown): boolean {
if (error instanceof TypeError) {
return true
} else if (error instanceof ReferenceError) {
return true
} else if (error instanceof SyntaxError) {
return true
} else if (error instanceof RangeError) {
return true
} else if (error instanceof URIError) {
return true
} else if (error instanceof EvalError) {
return true
} else {
return false
}
}