import type { AriaAttributes, ComponentProps, CSSProperties, PropsWithChildren, ReactElement, ReactNode, Ref, RefObject, } from 'react'; import React, { cloneElement, forwardRef, useRef } from 'react'; import type { AriaDialogProps } from 'react-aria'; import { mergeRefs, useEnterAnimation, useExitAnimation, useViewportSize } from '@react-aria/utils'; import classNames from 'classnames'; import { Dialog, useTranslations } from '@shoptet/ui-core-web'; import type { DialogOverlayProps, DialogOverlayState, DialogProps } from '@shoptet/ui-core-web'; import type { BreakpointKey } from '../../../hooks/useScreenHasMinWidth'; import type { ButtonProps } from '../../Button/Button'; import { Button } from '../../Button/Button'; import { getTypographyProps } from '../../Typography/getTypographyProps'; import { dictionary } from './dictionary'; const TITLE_CLASS_NAME = getTypographyProps({ appearance: 'h3' }).className; type InputDialogPresentation = 'sheet' | 'prompt'; type InputDialogSize = 'fit-content' | 'full-screen'; export interface InputDialogProps extends AriaDialogProps, Pick { /** * The id of the dialog. */ id?: string; /** * The children of the dialog. */ children: ReactNode; /** * The label of the close button. * @default 'Close' */ closeButtonLabel?: string; /** * The close button variant. * @default 'secondary' */ closeButtonVariant?: ButtonProps['variant']; /** * Whether the dialog is open (controlled). */ isOpen?: boolean; /** * Whether the dialog is open by default (uncontrolled). */ isDefaultOpen?: boolean; /** * The label of the dialog. */ label: ReactNode; /** * The maximum width of the dialog. * @default true */ maxWidth?: boolean | BreakpointKey; /** * The function that is called when the open state changes. */ onOpenChange?: (isOpen: boolean) => void; /** * The size of the dialog. * @default 'fit-content' */ size?: InputDialogSize; /** * The element that opens the dialog; its open state is reflected via cloned * `aria-expanded` / `aria-controls`. */ trigger: ReactElement; /** * The presentation of the dialog. * @default 'sheet' */ presentation?: InputDialogPresentation; /** * Whether to autofocus the first tabbable element on open. * @default true */ autoFocus?: DialogOverlayProps['autoFocus']; } export interface InputDialogHeaderProps extends PropsWithChildren<{ close: () => void; }> {} export type InputDialogInputProps = PropsWithChildren; export type InputDialogBodyProps = PropsWithChildren; export type InputDialogBodyInnerProps = PropsWithChildren; interface InputDialogContentProps extends ComponentProps<'div'> { overlayState: DialogOverlayState; dialogProps: ComponentProps<'div'>; forwardedRef: Ref; dialogId?: string; viewportHeight: number; maxWidth: InputDialogProps['maxWidth']; size: InputDialogSize; presentation: InputDialogPresentation; label: ReactNode; closeButtonLabel?: string; closeButtonVariant: ButtonProps['variant']; closeLabel: string; } function InputDialogContent({ overlayState, ...rest }: InputDialogContentProps) { const rootRef = useRef(null); const isExiting = useExitAnimation(rootRef, overlayState.open); if (!overlayState.open && !isExiting) { return null; } return ; } interface InputDialogContentInnerProps extends InputDialogContentProps { rootRef: RefObject; isExiting: boolean; } function InputDialogContentInner({ overlayState, dialogProps, forwardedRef, dialogId, viewportHeight, maxWidth, size, presentation, label, closeButtonLabel, closeButtonVariant, closeLabel, children, rootRef, isExiting, ...rest }: InputDialogContentInnerProps) { const isEntering = useEnterAnimation(rootRef); return (
{label}
{children}
); } /** * A dialog that is used to display input field. * Used for creating a mobile friendly dialog that contains input field. * * **⚠️ INTERNAL UI KIT USE ONLY** * * @internal */ export const InputDialog = Object.assign( forwardRef(function InputDialog( { id: dialogId, children, closeButtonLabel, closeButtonVariant = 'secondary', isDefaultOpen, isOpen, label, maxWidth = true, onOpenChange, presentation = 'sheet', size = 'fit-content', trigger, autoFocus = true, a11yLabel, a11yLabeledBy, ...rest }: InputDialogProps, forwardedRef: Ref ) { if (process.env.NODE_ENV !== 'production') { if (isOpen !== undefined && isDefaultOpen !== undefined) { console.warn('[InputDialog]: The "isOpen" and "isDefaultOpen" props cannot be used at the same time.'); } if (isOpen !== undefined && onOpenChange === undefined) { console.warn('[InputDialog]: The "onOpenChange" prop is required when the "isOpen" prop is used.'); } } const overlayTriggerProps: Pick = { isOpen, defaultOpen: isDefaultOpen, onOpenChange, }; const translations = useTranslations(dictionary); const viewport = useViewportSize(); return ( {({ props: { 'aria-expanded': ariaExpanded, 'aria-controls': ariaControls } }) => // Inject only ARIA state; opening stays the trigger's own handler. cloneElement(trigger as ReactElement, { 'aria-expanded': ariaExpanded, 'aria-controls': ariaControls === undefined ? undefined : (dialogId ?? ariaControls), }) } {({ state: overlayState, props: dialogProps }) => ( )} overlayState={overlayState} dialogProps={dialogProps} forwardedRef={forwardedRef} dialogId={dialogId} viewportHeight={viewport.height} maxWidth={maxWidth} size={size} presentation={presentation} label={label} closeButtonLabel={closeButtonLabel} closeButtonVariant={closeButtonVariant} closeLabel={translations.close} > {children} )} ); }), { Input: Object.assign( forwardRef(function InputDialogInput( { children, ...rest }: InputDialogInputProps, forwardedRef: Ref ) { return (
{children}
); }), { displayName: 'InputDialog.Input', } ), Divider: Object.assign( forwardRef(function InputDialogDivider(_unused: Record, forwardedRef: Ref) { return
; }), { displayName: 'InputDialog.Divider', } ), Body: Object.assign( forwardRef(function InputDialogBody( { children, ...rest }: InputDialogBodyProps, forwardedRef: Ref ) { return (
{children}
); }), { displayName: 'InputDialog.Body', } ), BodyInner: Object.assign( forwardRef(function InputDialogBodyInner( { children, ...rest }: InputDialogBodyInnerProps, forwardedRef: Ref ) { return (
{children}
); }) ), } );