Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More granular nesting for forms #11653

Open
wants to merge 8 commits into
base: develop
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,27 @@ import { type ButtonProps, Button } from '../Button'
import * as dialogProvider from './DialogProvider'

/** Props for {@link Close} component. */
export type CloseProps = ButtonProps
export type CloseProps = ButtonProps & {
readonly onClose?: () => void
readonly hideOutsideOfDialog?: boolean
}

/** Close button for a dialog. */
export function Close(props: CloseProps) {
const { hideOutsideOfDialog = false, onClose } = props
const dialogContext = dialogProvider.useDialogContext()

invariant(dialogContext, 'Close must be used inside a DialogProvider')

const onPressCallback = useEventCallback<NonNullable<ButtonProps['onPress']>>((event) => {
dialogContext.close()
dialogContext?.close()
onClose?.()
return props.onPress?.(event)
})

if (hideOutsideOfDialog && !dialogContext) {
return null
}

invariant(dialogContext, 'Close must be used inside a DialogProvider')

return <Button {...props} onPress={onPressCallback} />
}
130 changes: 87 additions & 43 deletions app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,17 @@ import * as twv from '#/utilities/tailwindVariants'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as dialogProvider from './DialogProvider'
import * as dialogStackProvider from './DialogStackProvider'
import * as utlities from './utilities'
import * as utilities from './utilities'
import * as variants from './variants'

/** Props for a {@link Popover}. */
export interface PopoverProps
extends Omit<aria.PopoverProps, 'children'>,
extends Omit<aria.PopoverProps, 'children' | 'defaultOpen'>,
twv.VariantProps<typeof POPOVER_STYLES> {
readonly children:
| React.ReactNode
| ((opts: aria.PopoverRenderProps & { readonly close: () => void }) => React.ReactNode)
readonly isDismissable?: boolean
}

export const POPOVER_STYLES = twv.tv({
Expand Down Expand Up @@ -57,9 +58,7 @@ export const POPOVER_STYLES = twv.tv({
},
},
slots: {
dialog: variants.DIALOG_BACKGROUND({
class: 'flex-auto overflow-y-auto max-h-[inherit]',
}),
dialog: variants.DIALOG_BACKGROUND({ class: 'flex-auto overflow-y-auto max-h-[inherit]' }),
},
defaultVariants: { rounded: 'xxlarge', size: 'small' },
})
Expand All @@ -76,35 +75,17 @@ export function Popover(props: PopoverProps) {
className,
size,
rounded,
isDismissable = true,
placement = 'bottom start',
...ariaPopoverProps
} = props

const dialogRef = React.useRef<HTMLDivElement>(null)
// We use as here to make the types more accurate
// eslint-disable-next-line no-restricted-syntax
const contextState = React.useContext(
aria.OverlayTriggerStateContext,
) as aria.OverlayTriggerState | null

const popoverRef = React.useRef<HTMLDivElement>(null)
const root = portal.useStrictPortalContext()
const dialogId = aria.useId()

const close = useEventCallback(() => {
contextState?.close()
})

utlities.useInteractOutside({
ref: dialogRef,
id: dialogId,
onInteractOutside: close,
})

const dialogContextValue = React.useMemo(() => ({ close, dialogId }), [close, dialogId])
const popoverStyle = React.useMemo(() => ({ zIndex: '' }), [])

return (
<aria.Popover
{...ariaPopoverProps}
className={(values) =>
POPOVER_STYLES({
isEntering: values.isEntering,
Expand All @@ -114,29 +95,92 @@ export function Popover(props: PopoverProps) {
className: typeof className === 'function' ? className(values) : className,
}).base()
}
ref={popoverRef}
UNSTABLE_portalContainer={root}
placement={placement}
style={popoverStyle}
style={{ zIndex: '' }}
shouldCloseOnInteractOutside={() => false}
{...ariaPopoverProps}
>
{(opts) => (
<dialogStackProvider.DialogStackRegistrar id={dialogId} type="popover">
<div
id={dialogId}
ref={dialogRef}
className={POPOVER_STYLES({ ...opts, size, rounded }).dialog()}
>
<dialogProvider.DialogProvider value={dialogContextValue}>
<errorBoundary.ErrorBoundary>
<suspense.Suspense loaderProps={SUSPENSE_LOADER_PROPS}>
{typeof children === 'function' ? children({ ...opts, close }) : children}
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</dialogProvider.DialogProvider>
</div>
</dialogStackProvider.DialogStackRegistrar>
<PopoverContent
popoverRef={popoverRef}
size={size}
rounded={rounded}
isDismissable={isDismissable}
opts={opts}
>
{children}
</PopoverContent>
)}
</aria.Popover>
)
}

/**
* Props for a {@link PopoverContent} component.
*/
interface PopoverContentProps {
readonly children: PopoverProps['children']
readonly opts: aria.PopoverRenderProps
readonly size: PopoverProps['size']
readonly rounded: PopoverProps['rounded']
readonly isDismissable: PopoverProps['isDismissable']
readonly popoverRef: React.RefObject<HTMLDivElement>
}

/**
* A component that renders the content of a popover.
*/
function PopoverContent(props: PopoverContentProps) {
const { children, opts, size, rounded, isDismissable = true, popoverRef } = props

const dialogRef = React.useRef<HTMLDivElement>(null)
// We use as here to make the types more accurate
// eslint-disable-next-line no-restricted-syntax
const contextState = React.useContext(
aria.OverlayTriggerStateContext,
) as aria.OverlayTriggerState | null

const dialogId = aria.useId()

const close = useEventCallback(() => {
contextState?.close()
})

utilities.useInteractOutside({
ref: popoverRef,
id: dialogId,
onInteractOutside: useEventCallback(() => {
if (isDismissable) {
close()
} else {
if (popoverRef.current) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
utilities.animateScale(popoverRef.current, 1.02)
}
}
}),
})

const dialogContextValue = React.useMemo(() => ({ close, dialogId }), [close, dialogId])

return (
<aria.FocusScope restoreFocus contain={!opts.isExiting}>
<dialogStackProvider.DialogStackRegistrar id={dialogId} type="popover">
<div
id={dialogId}
ref={dialogRef}
className={POPOVER_STYLES({ ...opts, size, rounded }).dialog()}
>
<errorBoundary.ErrorBoundary>
<suspense.Suspense loaderProps={SUSPENSE_LOADER_PROPS}>
<dialogProvider.DialogProvider value={dialogContextValue}>
{typeof children === 'function' ? children({ ...opts, close }) : children}
</dialogProvider.DialogProvider>
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</div>
</dialogStackProvider.DialogStackRegistrar>
</aria.FocusScope>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,14 @@ export function useInteractOutside(props: UseInteractOutsideProps) {
onInteractOutside: onInteractOutsideCb,
})
}

/**
* Animates the scale of the element.
*/
export function animateScale(element: HTMLElement, scale: number) {
const duration = 200
element.animate(
[{ transform: 'scale(1)' }, { transform: `scale(${scale})` }, { transform: 'scale(1)' }],
{ duration, iterations: 1, direction: 'alternate' },
)
}
77 changes: 50 additions & 27 deletions app/gui/src/dashboard/components/AriaComponents/Form/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,55 +35,75 @@ export const Form = forwardRef(function Form<
className,
style,
onSubmitted = () => {},
onSubmitSuccess = () => {},
onSubmitFailed = () => {},
id = formId,
schema,
defaultValues,
gap,
method,
canSubmitOffline = false,
testId = props['data-testid'],
testId: rawTestId,
...formProps
} = props

const testId = rawTestId ?? formProps['data-testid']

const { getText } = textProvider.useText()

const dialogContext = dialog.useDialogContext()

const onSubmit = useEventCallback(
async (fieldValues: types.FieldValues<Schema>, formInstance: types.UseFormReturn<Schema>) => {
// This is SAFE because we're passing the result transparently, and it's typed outside
// eslint-disable-next-line no-restricted-syntax
const result = (await props.onSubmit?.(fieldValues, formInstance)) as SubmitResult
(
fieldValues: types.FieldValues<Schema>,
formInstance: types.UseFormReturn<Schema, SubmitResult>,
) => props.onSubmit?.(fieldValues, formInstance),
)

const onSubmitSuccess = useEventCallback(
(
data: SubmitResult,
fieldValues: types.FieldValues<Schema>,
formInstance: types.UseFormReturn<Schema, SubmitResult>,
) => {
if (method === 'dialog') {
dialogContext?.close()
}

return result
return props.onSubmitSuccess?.(data, fieldValues, formInstance)
},
)

const innerForm = components.useForm<Schema, SubmitResult>(
form ?? {
...formOptions,
...(defaultValues ? { defaultValues } : {}),
schema,
canSubmitOffline,
onSubmit,
onSubmitFailed,
onSubmitSuccess,
onSubmitted,
shouldFocusError: true,
debugName: `Form ${testId} id: ${id}`,
},
)
const formOpts =
form == null ?
([
{
...formOptions,
...(defaultValues ? { defaultValues } : {}),
schema,
canSubmitOffline,
onSubmit,
onSubmitFailed,
onSubmitSuccess,
onSubmitted,
shouldFocusError: true,
debugName: `Form ${testId} id: ${id}`,
},
] as const)
: ([
form,
{
onSubmit,
onSubmitFailed,
onSubmitSuccess,
onSubmitted,
},
] as const)

const innerForm =
// @ts-expect-error - This is safe as we're spreading arguments transparently
components.useForm<Schema, SubmitResult>(...formOpts)

React.useImperativeHandle(formRef, () => innerForm, [innerForm])
React.useImperativeHandle(form?.closeRef, () => dialogContext?.close ?? (() => {}), [
dialogContext?.close,
])

const base = styles.FORM_STYLES({
className: typeof className === 'function' ? className(innerForm) : className,
Expand All @@ -102,14 +122,13 @@ export const Form = forwardRef(function Form<

return (
<form
{...formProps}
id={id}
ref={ref}
className={base}
style={typeof style === 'function' ? style(innerForm) : style}
noValidate
data-testid={testId}
onSubmit={innerForm.submit}
{...formProps}
{...innerForm.formProps}
>
<aria.FormValidationContext.Provider value={errors}>
<components.FormProvider form={innerForm}>
Expand All @@ -123,6 +142,7 @@ export const Form = forwardRef(function Form<
) => React.JSX.Element) & {
/* eslint-disable @typescript-eslint/naming-convention */
schema: typeof components.schema
createSchema: typeof components.createSchema
useForm: typeof components.useForm
useField: typeof components.useField
Submit: typeof components.Submit
Expand All @@ -138,10 +158,12 @@ export const Form = forwardRef(function Form<
useWatch: typeof components.useWatch
useFieldRegister: typeof components.useFieldRegister
useFieldState: typeof components.useFieldState
useFormState: typeof components.useFormState
/* eslint-enable @typescript-eslint/naming-convention */
}

Form.schema = components.schema
Form.createSchema = components.createSchema
Form.useForm = components.useForm
Form.useField = components.useField
Form.useFormSchema = components.useFormSchema
Expand All @@ -157,3 +179,4 @@ Form.useWatch = components.useWatch
Form.FIELD_STYLES = components.FIELD_STYLES
Form.useFieldRegister = components.useFieldRegister
Form.useFieldState = components.useFieldState
Form.useFormState = components.useFormState
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ import type * as types from './types'

/** Props for the FormError component. */
export interface FormErrorProps extends Omit<reactAriaComponents.AlertProps, 'children'> {
// We do not need to know the form fields.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly form?: types.FormInstance<any>
readonly form?: types.AnyFormInstance
}

/** Form error component. */
Expand Down
Loading
Loading