From be79491314a65a0c7182af10dcc3f9ff2fb4fa07 Mon Sep 17 00:00:00 2001 From: Luiggi Date: Thu, 4 Jun 2026 22:15:34 +0200 Subject: [PATCH 1/4] feat(drawer): add organism component --- docs/COMPONENTS.md | 31 +- docs/DESIGN.md | 10 + .../organisms/drawer/Drawer.stories.tsx | 351 +++++++++++ .../organisms/drawer/Drawer.test.tsx | 562 ++++++++++++++++++ src/components/organisms/drawer/Drawer.tsx | 172 ++++++ src/components/organisms/drawer/index.ts | 2 + src/components/organisms/drawer/types.ts | 232 ++++++++ src/components/organisms/drawer/useDrawer.ts | 518 ++++++++++++++++ src/index.ts | 1 + src/styles/theme.css | 31 + 10 files changed, 1909 insertions(+), 1 deletion(-) create mode 100644 src/components/organisms/drawer/Drawer.stories.tsx create mode 100644 src/components/organisms/drawer/Drawer.test.tsx create mode 100644 src/components/organisms/drawer/Drawer.tsx create mode 100644 src/components/organisms/drawer/index.ts create mode 100644 src/components/organisms/drawer/types.ts create mode 100644 src/components/organisms/drawer/useDrawer.ts diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 5ff66e9a..74c1816b 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -925,7 +925,36 @@ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); --- -### 3.16 Announcement Bar / Version Banner +### 3.16 Drawer + +Drawer is a Radix Dialog-backed off-canvas dialog. It uses the approved compound anatomy: `Drawer.Trigger`, `Drawer.Content`, `Drawer.Header`, `Drawer.Title`, `Drawer.Description`, `Drawer.Body`, `Drawer.Footer`, and `Drawer.Close`. `Portal`, `Overlay`, and `Backdrop` remain internal to `Drawer.Content`. + +**Accessibility and anatomy:** + +- `Drawer.Title` is required so the dialog has an accessible name. +- `Drawer.Description` is optional and should explain context when the title alone is not enough. +- Built-in icon-only `Drawer.Close` is named `Close drawer`; custom close controls must provide visible text or their own accessible label. +- Header and footer are non-scrolling; `Drawer.Body` is the internal scroll region for long content. + +**Placement and responsive behavior:** + +- `placement="start" | "end" | "top" | "bottom"`; default is `end`. +- `start` and `end` are logical placements. On `md+`, LTR maps start/end to left/right and RTL maps start/end to right/left. +- Below `md`, side placements adapt to effective bottom sheet layout. +- Side sizes reuse `max-w-modal-*`; top, bottom, and mobile bottom layouts use `max-h-drawer-*` utilities based on `--size-drawer-block-viewport`. +- Bottom and mobile-adapted drawers use safe-area padding for footer actions. + +**Dismissal and motion:** + +- `dismissible` controls outside/backdrop dismissal only. +- `closeOnEscape` controls Escape-key dismissal only. +- Explicit close controls remain available for non-dismissible drawers. +- Overlay fades and panel slides by effective placement using `opacity` and `transform`; never use `transition: all`. +- `motion-reduce` removes drawer entrance/exit motion while preserving focus management. + +--- + +### 3.17 Announcement Bar / Version Banner **Announcement bar (Docusaurus global bar — full width above navbar):** diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 15796bab..1d806dcc 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -318,6 +318,16 @@ font-size: 93%; /* --ifm-code-font-size */ ``` Highlighted line: `rgba(219,20,60,0.10)` (light) / `rgba(255,0,54,0.15)` (dark). +### Drawer + +Drawer es una superficie flotante tipo dialog para tareas suplementarias, navegación, formularios y contenido largo. Usa el mismo overlay que Modal (`opacity`, `blur` o `transparent`) y un panel opaco `surface` con borde, sombra de modal y movimiento por `opacity` + `transform` solamente. + +- `start` y `end` son placements lógicos: en `md+` respetan LTR/RTL; en mobile se adaptan a bottom sheet. +- `top`, `bottom` y el bottom sheet mobile usan utilidades `max-h-drawer-*` basadas en `--size-drawer-block-viewport`. +- Los footers de bottom/mobile drawers usan safe area para no colisionar con home indicators. +- Header y footer permanecen visibles; `Drawer.Body` es la región con scroll interno. +- `prefers-reduced-motion` elimina las transiciones de entrada/salida sin cambiar semántica ni foco. + ### Glow del GitHub Stars badge Patrón reutilizable para cualquier badge con métrica numérica: diff --git a/src/components/organisms/drawer/Drawer.stories.tsx b/src/components/organisms/drawer/Drawer.stories.tsx new file mode 100644 index 00000000..1ec81266 --- /dev/null +++ b/src/components/organisms/drawer/Drawer.stories.tsx @@ -0,0 +1,351 @@ +import { Button } from '@atoms/button'; +import { Input } from '@atoms/input'; +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; +import { type ReactNode, useState } from 'react'; +import { Drawer } from './Drawer'; +import type { DrawerBackdrop, DrawerPlacement, DrawerSize } from './types'; + +const DrawerExample = ({ + backdrop, + buttonText = 'Open drawer', + children, + closeText = 'Done', + description = 'Review supporting information without losing the current page context.', + dismissible, + placement, + size, + title = 'Project details' +}: { + backdrop?: DrawerBackdrop; + buttonText?: string; + children?: ReactNode; + closeText?: string; + description?: string; + dismissible?: boolean; + placement?: DrawerPlacement; + size?: DrawerSize; + title?: string; +}) => ( + + + + + + Custom trigger drawer + Body + Close custom trigger drawer + + + ); + + const trigger = screen.getByRole('button', { name: 'Custom trigger' }); + await user.click(trigger); + + expect(handleClick).toHaveBeenCalledTimes(1); + expect(screen.getByRole('dialog', { name: 'Custom trigger drawer' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Close custom trigger drawer' })); + await waitFor(() => { + expect(trigger).toHaveFocus(); + }); + }); + + it('lets custom close controls rely on their own visible text or explicit accessible name', async () => { + render( + + + Custom close drawer + Body + + + + Close by text + + + ); + + expect(screen.getByRole('button', { name: 'Dismiss panel' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Close by text' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Close drawer' })).not.toBeInTheDocument(); + }); + + it('keeps an explicit close path available for a non-dismissible drawer', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Open drawer' })); + await userEvent.keyboard('{Escape}'); + fireEvent.pointerDown(document.body); + expect(screen.getByRole('dialog', { name: 'Account settings' })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Done' })); + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'Account settings' })).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/organisms/drawer/Drawer.tsx b/src/components/organisms/drawer/Drawer.tsx new file mode 100644 index 00000000..3ea4583e --- /dev/null +++ b/src/components/organisms/drawer/Drawer.tsx @@ -0,0 +1,172 @@ +import * as DrawerPrimitive from '@radix-ui/react-dialog'; +import { cloneElement, type FC, isValidElement, type ReactElement } from 'react'; +import { cn } from '@/lib/utils'; +import type { + DrawerBodyProps, + DrawerCloseProps, + DrawerCompoundComponent, + DrawerContentProps, + DrawerDescriptionProps, + DrawerFooterProps, + DrawerHeaderProps, + DrawerProps, + DrawerTitleProps, + DrawerTriggerProps +} from './types'; +import { + DrawerRootProvider, + useDrawerBody, + useDrawerClose, + useDrawerContent, + useDrawerDescription, + useDrawerFooter, + useDrawerHeader, + useDrawerRoot, + useDrawerTitle, + useDrawerTrigger +} from './useDrawer'; + +const DrawerRoot: FC = (props) => { + const { contextValue, rootProps } = useDrawerRoot(props); + + return ( + + {props.children} + + ); +}; + +const DrawerTrigger: FC = (props) => { + const trigger = useDrawerTrigger(props); + + if (trigger.asChild) { + return trigger.child; + } + + return ( + + ); +}; + +const DrawerContent: FC = (props) => { + const { children, contentProps, overlayProps } = useDrawerContent(props); + + return ( + + + + {children} + + + ); +}; + +const DrawerHeader: FC = ({ children, className, ...props }) => { + const { className: headerClassName } = useDrawerHeader({ children, className }); + + return ( +
+ {children} +
+ ); +}; + +const DrawerTitle: FC = ({ children, className, ...props }) => { + const { className: titleClassName } = useDrawerTitle({ children, className }); + + return ( + + {children} + + ); +}; + +const DrawerDescription: FC = ({ children, className, ...props }) => { + const { className: descriptionClassName } = useDrawerDescription({ children, className }); + + return ( + + {children} + + ); +}; + +const DrawerBody: FC = ({ children, className, ...props }) => { + const { className: bodyClassName } = useDrawerBody({ children, className }); + + return ( +
+ {children} +
+ ); +}; + +const DrawerFooter: FC = ({ children, className, ...props }) => { + const { className: footerClassName } = useDrawerFooter({ children, className }); + + return ( +
+ {children} +
+ ); +}; + +type DrawerCloseChild = ReactElement<{ className?: string }>; + +const DrawerClose: FC = ({ asChild = false, children, className, type = 'button', ...props }) => { + const { className: closeClassName } = useDrawerClose({ asChild, children, className, type, ...props }); + + if (asChild && isValidElement(children)) { + const closeChild = children as DrawerCloseChild; + + return ( + + {cloneElement(closeChild, { + className: cn(closeChild.props.className, closeClassName) + })} + + ); + } + + if (children) { + return ( + + + + ); + } + + return ( + + + + ); +}; + +DrawerTrigger.displayName = 'Drawer.Trigger'; +DrawerContent.displayName = 'Drawer.Content'; +DrawerHeader.displayName = 'Drawer.Header'; +DrawerTitle.displayName = 'Drawer.Title'; +DrawerDescription.displayName = 'Drawer.Description'; +DrawerBody.displayName = 'Drawer.Body'; +DrawerFooter.displayName = 'Drawer.Footer'; +DrawerClose.displayName = 'Drawer.Close'; + +const compoundDrawer = DrawerRoot as DrawerCompoundComponent; + +compoundDrawer['Trigger'] = DrawerTrigger; +compoundDrawer['Content'] = DrawerContent; +compoundDrawer['Header'] = DrawerHeader; +compoundDrawer['Title'] = DrawerTitle; +compoundDrawer['Description'] = DrawerDescription; +compoundDrawer['Body'] = DrawerBody; +compoundDrawer['Footer'] = DrawerFooter; +compoundDrawer['Close'] = DrawerClose; + +export const Drawer = compoundDrawer; diff --git a/src/components/organisms/drawer/index.ts b/src/components/organisms/drawer/index.ts new file mode 100644 index 00000000..08a6c226 --- /dev/null +++ b/src/components/organisms/drawer/index.ts @@ -0,0 +1,2 @@ +export { Drawer } from './Drawer'; +export type * from './types'; diff --git a/src/components/organisms/drawer/types.ts b/src/components/organisms/drawer/types.ts new file mode 100644 index 00000000..01c8aad2 --- /dev/null +++ b/src/components/organisms/drawer/types.ts @@ -0,0 +1,232 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import type { ComponentProps, FC, FocusEvent, KeyboardEvent, MouseEvent, ReactElement, ReactNode } from 'react'; + +export const drawerOverlayVariants = cva( + [ + 'fixed inset-0 z-modal', + 'data-[state=closed]:animate-out data-[state=open]:animate-in', + 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0' + ], + { + variants: { + backdrop: { + opacity: 'bg-overlay-dark', + blur: 'bg-overlay-dark backdrop-blur-overlay', + transparent: 'bg-transparent' + } + }, + defaultVariants: { + backdrop: 'opacity' + } + } +); + +export const drawerPanelVariants = cva( + [ + 'fixed z-modal flex w-full flex-col border shadow-modal outline-none dark:shadow-modal-dark', + 'bg-surface-light text-text-light dark:bg-surface-dark dark:text-text-dark', + 'border-border-light dark:border-border-dark', + 'data-[state=closed]:animate-out data-[state=open]:animate-in', + 'transition-[opacity,transform] duration-250 ease-out motion-reduce:!animate-none motion-reduce:!transition-none' + ], + { + variants: { + placement: { + start: [ + 'inset-x-0 bottom-0 max-h-drawer-md rounded-t-lg border-t', + 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', + 'md:inset-x-auto md:inset-y-0 md:bottom-auto md:h-dvh md:max-h-none md:rounded-none md:border-t-0', + 'ltr:md:left-0 ltr:md:border-r rtl:md:right-0 rtl:md:border-l', + 'md:data-[state=closed]:slide-out-to-start md:data-[state=open]:slide-in-from-start', + 'md:data-[state=closed]:slide-out-to-bottom-0 md:data-[state=open]:slide-in-from-bottom-0' + ], + end: [ + 'inset-x-0 bottom-0 max-h-drawer-md rounded-t-lg border-t', + 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', + 'md:inset-x-auto md:inset-y-0 md:bottom-auto md:h-dvh md:max-h-none md:rounded-none md:border-t-0', + 'ltr:md:right-0 ltr:md:border-l rtl:md:left-0 rtl:md:border-r', + 'md:data-[state=closed]:slide-out-to-end md:data-[state=open]:slide-in-from-end', + 'md:data-[state=closed]:slide-out-to-bottom-0 md:data-[state=open]:slide-in-from-bottom-0' + ], + top: 'inset-x-0 top-0 rounded-b-lg border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', + bottom: + 'inset-x-0 bottom-0 rounded-t-lg border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom' + }, + size: { + xs: '', + sm: '', + md: '', + lg: '', + xl: '', + full: '' + } + }, + compoundVariants: [ + { placement: ['start', 'end'], size: 'xs', class: 'max-h-drawer-xs md:max-h-none md:max-w-modal-xs' }, + { placement: ['start', 'end'], size: 'sm', class: 'max-h-drawer-sm md:max-h-none md:max-w-modal-sm' }, + { placement: ['start', 'end'], size: 'md', class: 'max-h-drawer-md md:max-h-none md:max-w-modal-md' }, + { placement: ['start', 'end'], size: 'lg', class: 'max-h-drawer-lg md:max-h-none md:max-w-modal-lg' }, + { placement: ['start', 'end'], size: 'xl', class: 'max-h-drawer-xl md:max-h-none md:max-w-modal-xl' }, + { placement: ['start', 'end'], size: 'full', class: 'h-dvh max-h-drawer-full md:max-h-dvh md:max-w-full' }, + { placement: ['top', 'bottom'], size: 'xs', class: 'max-h-drawer-xs' }, + { placement: ['top', 'bottom'], size: 'sm', class: 'max-h-drawer-sm' }, + { placement: ['top', 'bottom'], size: 'md', class: 'max-h-drawer-md' }, + { placement: ['top', 'bottom'], size: 'lg', class: 'max-h-drawer-lg' }, + { placement: ['top', 'bottom'], size: 'xl', class: 'max-h-drawer-xl' }, + { placement: ['top', 'bottom'], size: 'full', class: 'max-h-drawer-full' } + ], + defaultVariants: { + placement: 'end', + size: 'md' + } + } +); + +export const drawerHeaderVariants = cva('shrink-0 border-b border-border-light p-6 pb-4 dark:border-border-dark'); +export const drawerTitleVariants = cva('fs-h5 font-semibold text-text-light dark:text-text-dark'); +export const drawerDescriptionVariants = cva('mt-2 text-sm text-text-secondary-light dark:text-text-secondary-dark'); +export const drawerBodyVariants = cva( + 'flex-1 overflow-y-auto p-6 py-4 text-text-secondary-light dark:text-text-secondary-dark' +); +export const drawerFooterVariants = cva( + 'flex shrink-0 items-center justify-end gap-2 border-t border-border-light p-6 pt-4 pb-drawer-safe dark:border-border-dark' +); +export const drawerCloseVariants = cva( + [ + 'inline-flex min-h-11 min-w-11 cursor-pointer items-center justify-center rounded-md px-3 py-2 text-sm font-semibold', + 'text-text-light dark:text-text-dark', + 'hover:bg-black-tint-low dark:hover:bg-white-tint-faint', + 'focus-visible:outline-none focus-visible:shadow-glow-focus-light dark:focus-visible:shadow-glow-focus-dark', + 'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-40', + 'transition-[background,box-shadow,color] duration-150 ease-out' + ].join(' ') +); + +type DrawerPanelVariantProps = VariantProps; +type DrawerOverlayVariantProps = VariantProps; + +export type DrawerPlacement = NonNullable; +export type DrawerSize = NonNullable; +export type DrawerBackdrop = NonNullable; + +export type DrawerProps = { + /** @control object */ + children: ReactNode; + /** @control boolean */ + open?: boolean; + /** + * @control boolean + * @default false + */ + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +export type DrawerTriggerProps = Omit, 'children'> & { + /** @control object */ + children: ReactNode; + /** + * @control boolean + * @default false + */ + asChild?: boolean; + /** + * @control boolean + * @default false + */ + disabled?: boolean; +}; + +export type DrawerContentProps = Omit, 'children' | 'dir'> & { + /** @control object */ + children: ReactNode; + /** + * @control select + * @default end + */ + placement?: DrawerPlacement; + /** + * @control select + * @default md + */ + size?: DrawerSize; + /** + * @control select + * @default opacity + */ + backdrop?: DrawerBackdrop; + /** + * @control boolean + * @default true + */ + dismissible?: boolean; + /** + * @control boolean + * @default true + */ + closeOnEscape?: boolean; +}; + +type DrawerSlotProps = ComponentProps<'div'> & { + /** @control object */ + children: ReactNode; +}; + +export type DrawerHeaderProps = DrawerSlotProps; +export type DrawerBodyProps = DrawerSlotProps; +export type DrawerFooterProps = DrawerSlotProps; + +export type DrawerTitleProps = ComponentProps<'h2'> & { + /** @control object */ + children: ReactNode; +}; + +export type DrawerDescriptionProps = ComponentProps<'p'> & { + /** @control object */ + children: ReactNode; +}; + +export type DrawerCloseProps = Omit, 'children'> & { + /** @control object */ + children?: ReactNode; + /** + * @control boolean + * @default false + */ + asChild?: boolean; +}; + +export type DrawerCompoundComponent = FC & { + // biome-ignore lint/style/useNamingConvention: compound API requires PascalCase slot names + Trigger: FC; + // biome-ignore lint/style/useNamingConvention: compound API requires PascalCase slot names + Content: FC; + // biome-ignore lint/style/useNamingConvention: compound API requires PascalCase slot names + Header: FC; + // biome-ignore lint/style/useNamingConvention: compound API requires PascalCase slot names + Title: FC; + // biome-ignore lint/style/useNamingConvention: compound API requires PascalCase slot names + Description: FC; + // biome-ignore lint/style/useNamingConvention: compound API requires PascalCase slot names + Body: FC; + // biome-ignore lint/style/useNamingConvention: compound API requires PascalCase slot names + Footer: FC; + // biome-ignore lint/style/useNamingConvention: compound API requires PascalCase slot names + Close: FC; +}; + +export type DrawerTriggerElement = ReactElement<{ + 'aria-controls'?: string; + 'aria-disabled'?: boolean; + 'aria-expanded'?: boolean; + 'aria-haspopup'?: 'dialog'; + 'data-disabled'?: 'true'; + className?: string; + disabled?: boolean; + onClick?: (event: MouseEvent) => void; + onFocus?: (event: FocusEvent) => void; + onKeyDown?: (event: KeyboardEvent) => void; + role?: string; + tabIndex?: number; + type?: 'button'; +}>; diff --git a/src/components/organisms/drawer/useDrawer.ts b/src/components/organisms/drawer/useDrawer.ts new file mode 100644 index 00000000..f27f27d8 --- /dev/null +++ b/src/components/organisms/drawer/useDrawer.ts @@ -0,0 +1,518 @@ +import type { DialogContentProps } from '@radix-ui/react-dialog'; +import { + Children, + cloneElement, + createContext, + createElement, + type FocusEvent, + isValidElement, + type KeyboardEvent, + type MouseEvent, + type ReactElement, + type ReactNode, + useCallback, + useContext, + useId, + useMemo, + useState +} from 'react'; +import { cn } from '@/lib/utils'; +import { + type DrawerBackdrop, + type DrawerBodyProps, + type DrawerCloseProps, + type DrawerContentProps, + type DrawerDescriptionProps, + type DrawerFooterProps, + type DrawerHeaderProps, + type DrawerPlacement, + type DrawerProps, + type DrawerSize, + type DrawerTitleProps, + type DrawerTriggerElement, + type DrawerTriggerProps, + drawerBodyVariants, + drawerCloseVariants, + drawerDescriptionVariants, + drawerFooterVariants, + drawerHeaderVariants, + drawerOverlayVariants, + drawerPanelVariants, + drawerTitleVariants +} from './types'; + +type CloseAutoFocusEvent = Parameters>[0]; +type PointerDownOutsideEvent = Parameters>[0]; +type InteractOutsideEvent = Parameters>[0]; +type EscapeKeyDownEvent = Parameters>[0]; + +type DrawerRootContextValue = { + contentId: string; + open: boolean; + recordTriggerElement: (element: HTMLElement | null) => void; + setOpen: (open: boolean) => void; + triggerElement: HTMLElement | null; +}; + +type DrawerRootProviderProps = { + children: ReactNode; + value: DrawerRootContextValue; +}; + +type UseDrawerRootReturn = { + contextValue: DrawerRootContextValue; + rootProps: { + onOpenChange: (open: boolean) => void; + open: boolean; + }; +}; + +type UseDrawerTriggerReturn = + | { + asChild: false; + buttonProps: { + 'aria-controls': string | undefined; + 'aria-disabled': true | undefined; + 'aria-expanded': boolean; + 'aria-haspopup': 'dialog'; + 'data-disabled': 'true' | undefined; + className: string; + disabled: boolean; + onClick: (event: MouseEvent) => void; + onFocus: (event: FocusEvent) => void; + type: 'button'; + }; + child: null; + } + | { + asChild: true; + buttonProps: null; + child: ReactElement; + }; + +type UseDrawerContentReturn = { + children: ReactNode; + contentProps: DialogContentProps & { + 'data-backdrop': DrawerBackdrop; + 'data-block-size-utility': `max-h-drawer-${DrawerSize}`; + 'data-effective-placement': DrawerPlacement; + 'data-inline-size-utility'?: `max-w-modal-${Exclude}` | 'max-w-full'; + 'data-logical-placement': + | `inline-${Extract}` + | `block-${Extract}`; + 'data-ltr-placement': 'left' | 'right' | 'top' | 'bottom'; + 'data-mobile-placement'?: 'bottom'; + 'data-motion': 'placement-slide'; + 'data-placement': DrawerPlacement; + 'data-placement-axis': 'inline' | 'block'; + 'data-reduced-motion': 'supported'; + 'data-rtl-placement': 'left' | 'right' | 'top' | 'bottom'; + 'data-safe-area': 'block-end' | 'none'; + 'data-size': DrawerSize; + 'data-size-mode': 'responsive-inline' | 'block'; + className: string; + id: string; + }; + overlayProps: { + 'data-backdrop': DrawerBackdrop; + className: string; + }; +}; + +type UseDrawerSlotReturn = { + className: string; +}; + +const DrawerRootContext = createContext(null); + +const isDevelopmentRuntime = (): boolean => process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'; + +const getDrawerSlotDisplayName = (child: ReactNode): string | undefined => { + if (!isValidElement(child) || typeof child.type === 'string') { + return undefined; + } + + return (child.type as { displayName?: string }).displayName; +}; + +const hasDrawerSlot = ({ children, displayName }: { children: ReactNode; displayName: string }): boolean => { + return Children.toArray(children).some((child) => { + if (getDrawerSlotDisplayName(child) === displayName) { + return true; + } + + if (!isValidElement<{ children?: ReactNode }>(child)) { + return false; + } + + return hasDrawerSlot({ children: child.props.children, displayName }); + }); +}; + +const isHtmlTag = (element: DrawerTriggerElement, tagName: string): boolean => { + return typeof element.type === 'string' && element.type.toLowerCase() === tagName; +}; + +const isIntrinsicInteractiveTrigger = (element: DrawerTriggerElement): boolean => { + return isHtmlTag(element, 'button') || isHtmlTag(element, 'a') || isHtmlTag(element, 'input'); +}; + +type ComposableEvent = { + defaultPrevented: boolean; + preventDefault: () => void; + stopPropagation: () => void; +}; + +const withComposedHandler = ( + originalHandler: ((event: TEvent) => void) | undefined, + nextHandler: (event: TEvent) => void, + disabled = false +) => { + return (event: TEvent) => { + if (disabled) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + originalHandler?.(event); + + if (event.defaultPrevented) { + return; + } + + nextHandler(event); + }; +}; + +export const DrawerRootProvider = ({ children, value }: DrawerRootProviderProps) => { + return createElement(DrawerRootContext.Provider, { value }, children); +}; + +export const useDrawerRoot = ({ + defaultOpen = false, + onOpenChange, + open: openProp +}: DrawerProps): UseDrawerRootReturn => { + const isControlled = typeof openProp === 'boolean'; + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const [triggerElement, setTriggerElement] = useState(null); + const idBase = useId().replaceAll(':', ''); + const open = isControlled ? openProp : uncontrolledOpen; + + const setOpen = useCallback( + (nextOpen: boolean) => { + if (!isControlled) { + setUncontrolledOpen(nextOpen); + } + + onOpenChange?.(nextOpen); + }, + [isControlled, onOpenChange] + ); + + const recordTriggerElement = useCallback((element: HTMLElement | null) => { + setTriggerElement((currentElement) => (currentElement === element ? currentElement : element)); + }, []); + + const contextValue = useMemo( + () => ({ + contentId: `drawer-${idBase}-content`, + open, + recordTriggerElement, + setOpen, + triggerElement + }), + [idBase, open, recordTriggerElement, setOpen, triggerElement] + ); + + return { + contextValue, + rootProps: { + open, + onOpenChange: setOpen + } + }; +}; + +const useDrawerRootContext = (): DrawerRootContextValue => { + const context = useContext(DrawerRootContext); + + if (!context) { + throw new Error('Drawer compound components must be rendered inside .'); + } + + return context; +}; + +export const useDrawerTrigger = ({ + asChild = false, + children, + className, + disabled = false, + onClick, + onFocus, + ...nativeButtonProps +}: DrawerTriggerProps): UseDrawerTriggerReturn => { + const { contentId, open, recordTriggerElement, setOpen } = useDrawerRootContext(); + + const openDrawer = useCallback( + (element: HTMLElement) => { + if (disabled) { + return; + } + + recordTriggerElement(element); + setOpen(true); + }, + [disabled, recordTriggerElement, setOpen] + ); + + const handleClick = useCallback( + (event: MouseEvent) => { + if (event.defaultPrevented || disabled) { + return; + } + + openDrawer(event.currentTarget); + }, + [disabled, openDrawer] + ); + + const handleFocus = useCallback( + (event: FocusEvent) => { + if (disabled) { + return; + } + + recordTriggerElement(event.currentTarget); + }, + [disabled, recordTriggerElement] + ); + + const handleKeyboardActivation = useCallback( + (event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + + event.preventDefault(); + openDrawer(event.currentTarget); + }, + [openDrawer] + ); + + if (asChild && isValidElement(children)) { + const triggerChild = children as DrawerTriggerElement; + const isIntrinsicInteractive = isIntrinsicInteractiveTrigger(triggerChild); + const childProps: DrawerTriggerElement['props'] = { + ...triggerChild.props, + className: cn(triggerChild.props.className, className), + 'aria-controls': open ? contentId : undefined, + 'aria-disabled': disabled || undefined, + 'aria-expanded': open, + 'aria-haspopup': 'dialog', + onClick: withComposedHandler(triggerChild.props.onClick, handleClick, disabled), + onFocus: withComposedHandler(triggerChild.props.onFocus, handleFocus, disabled) + }; + + if (!isIntrinsicInteractive) { + childProps.role = triggerChild.props.role ?? 'button'; + childProps.tabIndex = disabled ? -1 : (triggerChild.props.tabIndex ?? 0); + childProps.onKeyDown = withComposedHandler(triggerChild.props.onKeyDown, handleKeyboardActivation, disabled); + } + + if (disabled) { + childProps['data-disabled'] = 'true'; + + if (!isHtmlTag(triggerChild, 'button')) { + childProps.tabIndex = -1; + } + } + + if (isHtmlTag(triggerChild, 'button') || typeof triggerChild.type !== 'string') { + childProps.disabled = disabled; + } + + if (isHtmlTag(triggerChild, 'button')) { + childProps.type = 'button'; + } + + return { + asChild: true, + buttonProps: null, + child: cloneElement(triggerChild, childProps) + }; + } + + return { + asChild: false, + child: null, + buttonProps: { + ...nativeButtonProps, + 'aria-controls': open ? contentId : undefined, + 'aria-disabled': disabled || undefined, + 'aria-expanded': open, + 'aria-haspopup': 'dialog', + 'data-disabled': disabled ? 'true' : undefined, + className: cn( + 'inline-flex min-h-11 cursor-pointer items-center justify-center rounded-md px-3 py-2 text-sm font-semibold', + 'focus-visible:outline-none focus-visible:shadow-glow-focus-light dark:focus-visible:shadow-glow-focus-dark', + 'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-40', + className + ), + disabled, + onClick: withComposedHandler(onClick, handleClick, disabled), + onFocus: withComposedHandler(onFocus, handleFocus, disabled), + type: 'button' + } + }; +}; + +const isInlinePlacement = (placement: DrawerPlacement): placement is Extract => { + return placement === 'start' || placement === 'end'; +}; + +const getPlacementAxis = (placement: DrawerPlacement): 'inline' | 'block' => { + return isInlinePlacement(placement) ? 'inline' : 'block'; +}; + +const getLogicalPlacement = ( + placement: DrawerPlacement +): `inline-${Extract}` | `block-${Extract}` => { + return isInlinePlacement(placement) ? `inline-${placement}` : `block-${placement}`; +}; + +const getLtrPlacement = (placement: DrawerPlacement): 'left' | 'right' | 'top' | 'bottom' => { + if (placement === 'start') { + return 'left'; + } + + if (placement === 'end') { + return 'right'; + } + + return placement; +}; + +const getRtlPlacement = (placement: DrawerPlacement): 'left' | 'right' | 'top' | 'bottom' => { + if (placement === 'start') { + return 'right'; + } + + if (placement === 'end') { + return 'left'; + } + + return placement; +}; + +const getInlineSizeUtility = (size: DrawerSize): `max-w-modal-${Exclude}` | 'max-w-full' => { + return size === 'full' ? 'max-w-full' : `max-w-modal-${size}`; +}; + +const getBlockSizeUtility = (size: DrawerSize): `max-h-drawer-${DrawerSize}` => { + return `max-h-drawer-${size}`; +}; + +const getSafeArea = (placement: DrawerPlacement): 'block-end' | 'none' => { + return placement === 'bottom' || isInlinePlacement(placement) ? 'block-end' : 'none'; +}; + +export const useDrawerContent = ({ + backdrop = 'opacity', + children, + className, + closeOnEscape = true, + dismissible = true, + placement = 'end', + size = 'md', + ...restProps +}: DrawerContentProps): UseDrawerContentReturn => { + const { contentId, triggerElement } = useDrawerRootContext(); + + if (isDevelopmentRuntime() && !hasDrawerSlot({ children, displayName: 'Drawer.Title' })) { + throw new Error('Drawer.Content requires a Drawer.Title descendant for accessible dialog naming.'); + } + + const handleCloseAutoFocus = (event: CloseAutoFocusEvent) => { + if (triggerElement?.isConnected) { + event.preventDefault(); + triggerElement.focus(); + } + }; + + const handlePointerDownOutside = (event: PointerDownOutsideEvent) => { + if (!dismissible) { + event.preventDefault(); + } + }; + + const handleInteractOutside = (event: InteractOutsideEvent) => { + if (!dismissible) { + event.preventDefault(); + } + }; + + const handleEscapeKeyDown = (event: EscapeKeyDownEvent) => { + if (!closeOnEscape) { + event.preventDefault(); + } + }; + + return { + children, + overlayProps: { + className: drawerOverlayVariants({ backdrop }), + 'data-backdrop': backdrop + }, + contentProps: { + ...restProps, + id: contentId, + className: cn(drawerPanelVariants({ placement, size }), className), + onCloseAutoFocus: handleCloseAutoFocus, + onPointerDownOutside: handlePointerDownOutside, + onInteractOutside: handleInteractOutside, + onEscapeKeyDown: handleEscapeKeyDown, + 'data-backdrop': backdrop, + 'data-block-size-utility': getBlockSizeUtility(size), + 'data-effective-placement': placement, + 'data-inline-size-utility': isInlinePlacement(placement) ? getInlineSizeUtility(size) : undefined, + 'data-logical-placement': getLogicalPlacement(placement), + 'data-ltr-placement': getLtrPlacement(placement), + 'data-mobile-placement': isInlinePlacement(placement) ? 'bottom' : undefined, + 'data-motion': 'placement-slide', + 'data-placement': placement, + 'data-placement-axis': getPlacementAxis(placement), + 'data-reduced-motion': 'supported', + 'data-rtl-placement': getRtlPlacement(placement), + 'data-safe-area': getSafeArea(placement), + 'data-size': size, + 'data-size-mode': isInlinePlacement(placement) ? 'responsive-inline' : 'block' + } + }; +}; + +export const useDrawerHeader = ({ className }: DrawerHeaderProps): UseDrawerSlotReturn => ({ + className: cn(drawerHeaderVariants(), className) +}); + +export const useDrawerTitle = ({ className }: DrawerTitleProps): UseDrawerSlotReturn => ({ + className: cn(drawerTitleVariants(), className) +}); + +export const useDrawerDescription = ({ className }: DrawerDescriptionProps): UseDrawerSlotReturn => ({ + className: cn(drawerDescriptionVariants(), className) +}); + +export const useDrawerBody = ({ className }: DrawerBodyProps): UseDrawerSlotReturn => ({ + className: cn(drawerBodyVariants(), className) +}); + +export const useDrawerFooter = ({ className }: DrawerFooterProps): UseDrawerSlotReturn => ({ + className: cn(drawerFooterVariants(), className) +}); + +export const useDrawerClose = ({ className }: DrawerCloseProps): UseDrawerSlotReturn => ({ + className: cn(drawerCloseVariants(), className) +}); diff --git a/src/index.ts b/src/index.ts index c61f4076..a2427189 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,7 @@ export { export { Snippet } from './components/molecules/snippet'; // ─── Organisms ─────────────────────────────────────────────────────────────── +export { Drawer } from './components/organisms/drawer'; export { Footer } from './components/organisms/footer'; export { NavigationHeader } from './components/organisms/header'; export { Modal } from './components/organisms/modal'; diff --git a/src/styles/theme.css b/src/styles/theme.css index 95c2980e..762267b4 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -422,6 +422,9 @@ --size-modal-4xl: 56rem; --size-modal-5xl: 64rem; + /* ── Drawer sizing ───────────────────────────────────────────── */ + --size-drawer-block-viewport: calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + /* ── Transiciones ────────────────────────────────────────────── */ --transition-fast: 150ms ease-in-out; --transition-base: 250ms ease; @@ -533,6 +536,34 @@ max-width: min(var(--size-modal-viewport), var(--size-modal-5xl)); } +@utility max-h-drawer-xs { + max-height: min(var(--size-drawer-block-viewport), var(--size-modal-xs)); +} + +@utility max-h-drawer-sm { + max-height: min(var(--size-drawer-block-viewport), var(--size-modal-sm)); +} + +@utility max-h-drawer-md { + max-height: min(var(--size-drawer-block-viewport), var(--size-modal-md)); +} + +@utility max-h-drawer-lg { + max-height: min(var(--size-drawer-block-viewport), var(--size-modal-lg)); +} + +@utility max-h-drawer-xl { + max-height: min(var(--size-drawer-block-viewport), var(--size-modal-xl)); +} + +@utility max-h-drawer-full { + max-height: var(--size-drawer-block-viewport); +} + +@utility pb-drawer-safe { + padding-bottom: max(var(--spacing-6), env(safe-area-inset-bottom)); +} + @media (prefers-reduced-motion: reduce) { [data-slot="popover-content"] { animation: none !important; From b1b786d840fd954c46f8b54c7086178e468557e4 Mon Sep 17 00:00:00 2001 From: Luiggi Date: Thu, 4 Jun 2026 22:18:44 +0200 Subject: [PATCH 2/4] fix(drawer): align content prop typing --- src/components/organisms/drawer/types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/organisms/drawer/types.ts b/src/components/organisms/drawer/types.ts index 01c8aad2..bb75d870 100644 --- a/src/components/organisms/drawer/types.ts +++ b/src/components/organisms/drawer/types.ts @@ -1,3 +1,4 @@ +import type { DialogContentProps } from '@radix-ui/react-dialog'; import { cva, type VariantProps } from 'class-variance-authority'; import type { ComponentProps, FC, FocusEvent, KeyboardEvent, MouseEvent, ReactElement, ReactNode } from 'react'; @@ -137,7 +138,7 @@ export type DrawerTriggerProps = Omit, 'children'> & { disabled?: boolean; }; -export type DrawerContentProps = Omit, 'children' | 'dir'> & { +export type DrawerContentProps = Omit & { /** @control object */ children: ReactNode; /** From 82bca5cb5f583a290365e12f4a29cbd79101db20 Mon Sep 17 00:00:00 2001 From: Luiggi Date: Fri, 5 Jun 2026 09:03:50 +0200 Subject: [PATCH 3/4] fix(drawer): compose content handlers --- .../organisms/drawer/Drawer.test.tsx | 35 +++++++++++++++++++ src/components/organisms/drawer/useDrawer.ts | 33 ++++++++++++++--- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/components/organisms/drawer/Drawer.test.tsx b/src/components/organisms/drawer/Drawer.test.tsx index 7b8ae65a..52f88635 100644 --- a/src/components/organisms/drawer/Drawer.test.tsx +++ b/src/components/organisms/drawer/Drawer.test.tsx @@ -79,6 +79,41 @@ describe('Drawer — dialog behavior and accessibility', () => { expect(screen.getByRole('dialog', { name: 'Account settings' })).toBeInTheDocument(); }); + it('keeps icon-only native fallback triggers at the minimum touch target width', () => { + render( + + + + Icon-only drawer + Body + + + ); + + expect(screen.getByRole('button', { name: 'Open icon-only drawer' })).toHaveClass('min-w-11'); + }); + + it('lets consumer Escape handlers prevent dismissal before internal Drawer handling runs', async () => { + const user = userEvent.setup(); + const handleEscapeKeyDown = vi.fn((event: Event) => { + event.preventDefault(); + }); + + render( + + + Consumer Escape drawer + Body + + + ); + + await user.keyboard('{Escape}'); + + expect(handleEscapeKeyDown).toHaveBeenCalledTimes(1); + expect(screen.getByRole('dialog', { name: 'Consumer Escape drawer' })).toBeInTheDocument(); + }); + it('only links aria-controls while the dialog content is mounted', async () => { render(); diff --git a/src/components/organisms/drawer/useDrawer.ts b/src/components/organisms/drawer/useDrawer.ts index f27f27d8..66be40a5 100644 --- a/src/components/organisms/drawer/useDrawer.ts +++ b/src/components/organisms/drawer/useDrawer.ts @@ -163,6 +163,10 @@ type ComposableEvent = { stopPropagation: () => void; }; +type PreventableEvent = { + defaultPrevented: boolean; +}; + const withComposedHandler = ( originalHandler: ((event: TEvent) => void) | undefined, nextHandler: (event: TEvent) => void, @@ -185,6 +189,21 @@ const withComposedHandler = ( }; }; +const withPreventableComposedHandler = ( + originalHandler: ((event: TEvent) => void) | undefined, + nextHandler: (event: TEvent) => void +) => { + return (event: TEvent) => { + originalHandler?.(event); + + if (event.defaultPrevented) { + return; + } + + nextHandler(event); + }; +}; + export const DrawerRootProvider = ({ children, value }: DrawerRootProviderProps) => { return createElement(DrawerRootContext.Provider, { value }, children); }; @@ -356,7 +375,7 @@ export const useDrawerTrigger = ({ 'aria-haspopup': 'dialog', 'data-disabled': disabled ? 'true' : undefined, className: cn( - 'inline-flex min-h-11 cursor-pointer items-center justify-center rounded-md px-3 py-2 text-sm font-semibold', + 'inline-flex min-h-11 min-w-11 cursor-pointer items-center justify-center rounded-md px-3 py-2 text-sm font-semibold', 'focus-visible:outline-none focus-visible:shadow-glow-focus-light dark:focus-visible:shadow-glow-focus-dark', 'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-40', className @@ -425,6 +444,10 @@ export const useDrawerContent = ({ className, closeOnEscape = true, dismissible = true, + onCloseAutoFocus, + onEscapeKeyDown, + onInteractOutside, + onPointerDownOutside, placement = 'end', size = 'md', ...restProps @@ -470,10 +493,10 @@ export const useDrawerContent = ({ ...restProps, id: contentId, className: cn(drawerPanelVariants({ placement, size }), className), - onCloseAutoFocus: handleCloseAutoFocus, - onPointerDownOutside: handlePointerDownOutside, - onInteractOutside: handleInteractOutside, - onEscapeKeyDown: handleEscapeKeyDown, + onCloseAutoFocus: withPreventableComposedHandler(onCloseAutoFocus, handleCloseAutoFocus), + onPointerDownOutside: withPreventableComposedHandler(onPointerDownOutside, handlePointerDownOutside), + onInteractOutside: withPreventableComposedHandler(onInteractOutside, handleInteractOutside), + onEscapeKeyDown: withPreventableComposedHandler(onEscapeKeyDown, handleEscapeKeyDown), 'data-backdrop': backdrop, 'data-block-size-utility': getBlockSizeUtility(size), 'data-effective-placement': placement, From 0ed388e011ab2599a1a76a31fe4c584ce34ebe80 Mon Sep 17 00:00:00 2001 From: Luiggi Date: Fri, 5 Jun 2026 11:01:54 +0200 Subject: [PATCH 4/4] fix(drawer): forward asChild wrapper props --- .../organisms/drawer/Drawer.test.tsx | 78 ++++++++++++++++++ src/components/organisms/drawer/Drawer.tsx | 80 ++++++++++++++----- src/components/organisms/drawer/types.ts | 1 + src/components/organisms/drawer/useDrawer.ts | 40 +++++++++- 4 files changed, 177 insertions(+), 22 deletions(-) diff --git a/src/components/organisms/drawer/Drawer.test.tsx b/src/components/organisms/drawer/Drawer.test.tsx index 52f88635..a2d688a1 100644 --- a/src/components/organisms/drawer/Drawer.test.tsx +++ b/src/components/organisms/drawer/Drawer.test.tsx @@ -560,6 +560,44 @@ describe('Drawer — composition edge cases', () => { }); }); + it('forwards wrapper props and handlers to asChild triggers', async () => { + const user = userEvent.setup(); + const handleChildClick = vi.fn(); + const handleWrapperClick = vi.fn(); + + render( + + + + + + Tracked trigger drawer + Body + + + ); + + const trigger = screen.getByRole('button', { name: 'Open tracked drawer' }); + expect(trigger).toHaveAttribute('id', 'tracked-drawer-trigger'); + expect(trigger).toHaveAttribute('data-analytics', 'drawer-open'); + expect(trigger).toHaveAttribute('title', 'Tracked drawer trigger'); + + await user.click(trigger); + + expect(handleChildClick).toHaveBeenCalledTimes(1); + expect(handleWrapperClick).toHaveBeenCalledTimes(1); + expect(screen.getByRole('dialog', { name: 'Tracked trigger drawer' })).toBeInTheDocument(); + }); + it('lets custom close controls rely on their own visible text or explicit accessible name', async () => { render( @@ -581,6 +619,46 @@ describe('Drawer — composition edge cases', () => { expect(screen.queryByRole('button', { name: 'Close drawer' })).not.toBeInTheDocument(); }); + it('forwards wrapper props and handlers to asChild close controls', async () => { + const user = userEvent.setup(); + const handleChildClick = vi.fn(); + const handleWrapperClick = vi.fn(); + + render( + + + Tracked close drawer + Body + + + + + + ); + + const close = screen.getByRole('button', { name: 'Dismiss tracked drawer' }); + expect(close).toHaveAttribute('id', 'tracked-drawer-close'); + expect(close).toHaveAttribute('data-analytics', 'drawer-close'); + expect(close).toHaveAttribute('title', 'Tracked drawer close'); + + await user.click(close); + + expect(handleChildClick).toHaveBeenCalledTimes(1); + expect(handleWrapperClick).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'Tracked close drawer' })).not.toBeInTheDocument(); + }); + }); + it('keeps an explicit close path available for a non-dismissible drawer', async () => { render(); diff --git a/src/components/organisms/drawer/Drawer.tsx b/src/components/organisms/drawer/Drawer.tsx index 3ea4583e..28f70b6b 100644 --- a/src/components/organisms/drawer/Drawer.tsx +++ b/src/components/organisms/drawer/Drawer.tsx @@ -1,5 +1,5 @@ import * as DrawerPrimitive from '@radix-ui/react-dialog'; -import { cloneElement, type FC, isValidElement, type ReactElement } from 'react'; +import { cloneElement, type FC, isValidElement, type ReactElement, type ReactNode } from 'react'; import { cn } from '@/lib/utils'; import type { DrawerBodyProps, @@ -26,12 +26,22 @@ import { useDrawerTrigger } from './useDrawer'; +type JsxCompatiblePrimitive = (props: { [key: string]: unknown; children?: ReactNode }) => ReactElement | null; + +const DialogRootPrimitive = DrawerPrimitive.Root as unknown as JsxCompatiblePrimitive; +const DialogPortalPrimitive = DrawerPrimitive.Portal as unknown as JsxCompatiblePrimitive; +const DialogOverlayPrimitive = DrawerPrimitive.Overlay as unknown as JsxCompatiblePrimitive; +const DialogContentPrimitive = DrawerPrimitive.Content as unknown as JsxCompatiblePrimitive; +const DialogTitlePrimitive = DrawerPrimitive.Title as unknown as JsxCompatiblePrimitive; +const DialogDescriptionPrimitive = DrawerPrimitive.Description as unknown as JsxCompatiblePrimitive; +const DialogClosePrimitive = DrawerPrimitive.Close as unknown as JsxCompatiblePrimitive; + const DrawerRoot: FC = (props) => { const { contextValue, rootProps } = useDrawerRoot(props); return ( - {props.children} + {props.children} ); }; @@ -54,12 +64,12 @@ const DrawerContent: FC = (props) => { const { children, contentProps, overlayProps } = useDrawerContent(props); return ( - - - + + + {children} - - + + ); }; @@ -77,9 +87,9 @@ const DrawerTitle: FC = ({ children, className, ...props }) => const { className: titleClassName } = useDrawerTitle({ children, className }); return ( - + {children} - + ); }; @@ -87,9 +97,9 @@ const DrawerDescription: FC = ({ children, className, .. const { className: descriptionClassName } = useDrawerDescription({ children, className }); return ( - + {children} - + ); }; @@ -113,39 +123,71 @@ const DrawerFooter: FC = ({ children, className, ...props }) ); }; -type DrawerCloseChild = ReactElement<{ className?: string }>; +type PreventableEvent = { + defaultPrevented: boolean; +}; + +type DrawerCloseChild = ReactElement<{ + [key: string]: unknown; + className?: string; + onClick?: DrawerCloseProps['onClick']; +}>; + +const composeEventHandlers = ( + ...handlers: Array<((event: TEvent) => void) | undefined> +) => { + const composedHandlers = handlers.filter((handler): handler is (event: TEvent) => void => Boolean(handler)); + + if (composedHandlers.length === 0) { + return undefined; + } + + return (event: TEvent) => { + for (const handler of composedHandlers) { + handler(event); + + if (event.defaultPrevented) { + return; + } + } + }; +}; const DrawerClose: FC = ({ asChild = false, children, className, type = 'button', ...props }) => { const { className: closeClassName } = useDrawerClose({ asChild, children, className, type, ...props }); if (asChild && isValidElement(children)) { const closeChild = children as DrawerCloseChild; + const { onClick, ...forwardedProps } = props; return ( - + {cloneElement(closeChild, { - className: cn(closeChild.props.className, closeClassName) + ...forwardedProps, + ...closeChild.props, + className: cn(closeChild.props.className, closeClassName), + onClick: composeEventHandlers(closeChild.props.onClick, onClick) })} - + ); } if (children) { return ( - + - + ); } return ( - + - + ); }; diff --git a/src/components/organisms/drawer/types.ts b/src/components/organisms/drawer/types.ts index bb75d870..43da9084 100644 --- a/src/components/organisms/drawer/types.ts +++ b/src/components/organisms/drawer/types.ts @@ -217,6 +217,7 @@ export type DrawerCompoundComponent = FC & { }; export type DrawerTriggerElement = ReactElement<{ + [key: string]: unknown; 'aria-controls'?: string; 'aria-disabled'?: boolean; 'aria-expanded'?: boolean; diff --git a/src/components/organisms/drawer/useDrawer.ts b/src/components/organisms/drawer/useDrawer.ts index 66be40a5..accee678 100644 --- a/src/components/organisms/drawer/useDrawer.ts +++ b/src/components/organisms/drawer/useDrawer.ts @@ -167,6 +167,26 @@ type PreventableEvent = { defaultPrevented: boolean; }; +const composeEventHandlers = ( + ...handlers: Array<((event: TEvent) => void) | undefined> +) => { + const composedHandlers = handlers.filter((handler): handler is (event: TEvent) => void => Boolean(handler)); + + if (composedHandlers.length === 0) { + return undefined; + } + + return (event: TEvent) => { + for (const handler of composedHandlers) { + handler(event); + + if (event.defaultPrevented) { + return; + } + } + }; +}; + const withComposedHandler = ( originalHandler: ((event: TEvent) => void) | undefined, nextHandler: (event: TEvent) => void, @@ -323,22 +343,36 @@ export const useDrawerTrigger = ({ if (asChild && isValidElement(children)) { const triggerChild = children as DrawerTriggerElement; + const forwardedProps = nativeButtonProps as DrawerTriggerElement['props']; + const forwardedOnClick = onClick as ((event: MouseEvent) => void) | undefined; + const forwardedOnFocus = onFocus as ((event: FocusEvent) => void) | undefined; const isIntrinsicInteractive = isIntrinsicInteractiveTrigger(triggerChild); + const composedOnKeyDown = composeEventHandlers(triggerChild.props.onKeyDown, forwardedProps.onKeyDown); const childProps: DrawerTriggerElement['props'] = { + ...forwardedProps, ...triggerChild.props, className: cn(triggerChild.props.className, className), 'aria-controls': open ? contentId : undefined, 'aria-disabled': disabled || undefined, 'aria-expanded': open, 'aria-haspopup': 'dialog', - onClick: withComposedHandler(triggerChild.props.onClick, handleClick, disabled), - onFocus: withComposedHandler(triggerChild.props.onFocus, handleFocus, disabled) + onClick: withComposedHandler( + composeEventHandlers(triggerChild.props.onClick, forwardedOnClick), + handleClick, + disabled + ), + onFocus: withComposedHandler( + composeEventHandlers(triggerChild.props.onFocus, forwardedOnFocus), + handleFocus, + disabled + ), + onKeyDown: composedOnKeyDown }; if (!isIntrinsicInteractive) { childProps.role = triggerChild.props.role ?? 'button'; childProps.tabIndex = disabled ? -1 : (triggerChild.props.tabIndex ?? 0); - childProps.onKeyDown = withComposedHandler(triggerChild.props.onKeyDown, handleKeyboardActivation, disabled); + childProps.onKeyDown = withComposedHandler(composedOnKeyDown, handleKeyboardActivation, disabled); } if (disabled) {