mirror of
https://github.com/enso-org/enso.git
synced 2025-01-09 03:57:54 +03:00
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:
parent
e6ecaff4c4
commit
149a2c8965
@ -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`',
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -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()}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
)
|
||||
})
|
@ -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
|
||||
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -10,3 +10,5 @@ export * from './FormError'
|
||||
export * from './types'
|
||||
export * from './useFormSchema'
|
||||
export * from './schema'
|
||||
export * from './useField'
|
||||
export * from './Field'
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -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()
|
||||
}
|
@ -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])
|
||||
}
|
||||
|
@ -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]
|
||||
}
|
||||
|
@ -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
|
@ -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
|
@ -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,
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Barrel file for the Radio component.
|
||||
*/
|
||||
|
||||
export * from './Radio'
|
||||
export * from './RadioGroup'
|
@ -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: {
|
||||
|
@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user