From bd4335039e417d778ccffc579289b248a4e9ec16 Mon Sep 17 00:00:00 2001 From: Adrien Lepoutre Date: Tue, 27 Jan 2026 14:13:22 +0100 Subject: [PATCH 1/8] feat: drawer component --- docs/stories/04-components/Drawer.mdx | 94 +++ docs/stories/04-components/Drawer.stories.tsx | 208 +++++++ .../src/components/Dialog/Dialog.tsx | 2 +- .../src/components/Drawer/Drawer.tsx | 584 ++++++++++++++++++ .../src/components/Drawer/index.ts | 1 + .../src/components/Modal/Modal.tsx | 2 +- .../design-system/src/components/index.ts | 1 + packages/design-system/src/styles/motion.ts | 48 +- packages/design-system/src/styles/type.ts | 120 ++-- 9 files changed, 1005 insertions(+), 55 deletions(-) create mode 100644 docs/stories/04-components/Drawer.mdx create mode 100644 docs/stories/04-components/Drawer.stories.tsx create mode 100644 packages/design-system/src/components/Drawer/Drawer.tsx create mode 100644 packages/design-system/src/components/Drawer/index.ts diff --git a/docs/stories/04-components/Drawer.mdx b/docs/stories/04-components/Drawer.mdx new file mode 100644 index 000000000..f050ceabf --- /dev/null +++ b/docs/stories/04-components/Drawer.mdx @@ -0,0 +1,94 @@ +import { Meta, Canvas, ArgTypes } from '@storybook/addon-docs/blocks'; +import { Drawer } from '@strapi/design-system'; +import * as Dialog from '@radix-ui/react-dialog'; + +import * as DrawerStories from './Drawer.stories'; + + + +# Drawer + +- [Overview](#overview) +- [Usage](#usage) +- [Props](#props) +- [Positions](#positions) +- [Header visible](#header-visible) +- [Accessibility](#accessibility) + +## Overview + +A dismissible drawer that slides in from an edge of the viewport. Built on Radix UI Dialog for accessibility and keyboard/outside-click dismissal. + + + + + +## Usage + +```js +import { Drawer } from '@strapi/design-system'; +``` + +## Props + +### Root + +Shares Radix Dialog Root component parameters (with an additional `headerVisible`): + + +### Trigger + +Shares Radix Dialog Trigger component parameters. + +The `Trigger` component uses `asChild` — it renders the child and merges drawer open/close behaviour onto it. + +### Content + + +Also forwards Radix Dialog Content props (`onOpenAutoFocus`, `onCloseAutoFocus`, `onEscapeKeyDown`, `onPointerDownOutside`, `onInteractOutside`, etc.). + +### Close + +Shares Radix Dialog Close component parameters. +Uses `asChild` — wrap a button (or other focusable element) to close the drawer on activation. + +Example: +```js +import { Drawer } from '@strapi/design-system'; + + + + +``` + +### Header + + + +### Title + +Use for the drawer heading. Renders as `h2` for accessibility. + +### Body + +Optional. Scrollable content area of the drawer (hidden by default when `defaultOpen` on Drawer.Content is `false`). + +### Footer + +Optional. Flex container for custom actions. + +## Positions + +The drawer can be positioned on any edge via the `side` prop. + +## Header visible + +With `headerVisible` on Root, when `open` is false only the header is visible and the overlay is hidden. A toggle button in the header opens and closes the drawer. + +## Accessibility + +Uses [Radix UI Dialog](https://www.radix-ui.com/primitives/docs/components/dialog), which implements the [Dialog WAI-ARIA pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialogmodal). The drawer is dismissible via: + +- **Escape** key +- **Click outside** (on the overlay) +- **Close** button in the header (when using `Drawer.Header`) diff --git a/docs/stories/04-components/Drawer.stories.tsx b/docs/stories/04-components/Drawer.stories.tsx new file mode 100644 index 000000000..a15a73d67 --- /dev/null +++ b/docs/stories/04-components/Drawer.stories.tsx @@ -0,0 +1,208 @@ +import * as React from 'react'; + +import { Meta, StoryObj } from '@storybook/react-vite'; +import { Button, Drawer, Field, Flex } from '@strapi/design-system'; +import { outdent } from 'outdent'; +import { fn } from 'storybook/test'; + +interface DrawerArgs + extends Drawer.Props, + Pick< + Drawer.ContentProps, + | 'side' + | 'width' + | 'maxHeight' + | 'onOpenAutoFocus' + | 'onCloseAutoFocus' + | 'onEscapeKeyDown' + | 'onPointerDownOutside' + | 'onInteractOutside' + > { + headerVisible?: boolean; + overlayVisible?: boolean; +} + +const meta: Meta = { + title: 'Components/Drawer', + component: Drawer.Root, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { + docs: { + source: { + code: outdent` + + + + + + + Drawer title + + +

Drawer content goes here.

+
+ + + + + + +
+
+ `, + }, + }, + chromatic: { disableSnapshot: false }, + }, + args: { + defaultOpen: false, + side: 'right', + headerVisible: false, + overlayVisible: true, + onOpenChange: fn(), + onOpenAutoFocus: fn(), + onCloseAutoFocus: fn(), + onEscapeKeyDown: fn(), + }, + argTypes: { + side: { + control: 'select', + options: ['top', 'right', 'bottom', 'left'], + }, + }, + render: ({ + side, + width, + maxHeight, + headerVisible, + overlayVisible, + onOpenAutoFocus, + onCloseAutoFocus, + onEscapeKeyDown, + ...args + }) => { + return ( + + {!headerVisible && ( + + + + )} + + + Drawer title + + + + Example field + + + + + + + + + + + + ); + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Base = { + args: { + defaultOpen: false, + }, + + name: 'base', +} satisfies Story; + +export const DefaultOpen = { + args: { + defaultOpen: true, + }, + name: 'default open', +} satisfies Story; + +export const SideRight = { + args: { + defaultOpen: true, + side: 'right', + }, + name: 'side right', +} satisfies Story; + +export const SideLeft = { + args: { + defaultOpen: true, + side: 'left', + }, + name: 'side left', +} satisfies Story; + +export const SideTop = { + args: { + defaultOpen: true, + side: 'top', + }, + name: 'side top', +} satisfies Story; + +export const SideBottom = { + args: { + defaultOpen: true, + side: 'bottom', + }, + name: 'side bottom', +} satisfies Story; + +export const HeaderVisible = { + parameters: { + docs: { + source: { + code: outdent` + + + + Drawer title + + +

Toggle to expand and see content + overlay.

+
+ + + + + + +
+
+ `, + }, + }, + }, + args: { + defaultOpen: false, + headerVisible: true, + side: 'bottom', + }, + name: 'header visible', +} satisfies Story; diff --git a/packages/design-system/src/components/Dialog/Dialog.tsx b/packages/design-system/src/components/Dialog/Dialog.tsx index 96e053826..3fc23b93a 100644 --- a/packages/design-system/src/components/Dialog/Dialog.tsx +++ b/packages/design-system/src/components/Dialog/Dialog.tsx @@ -54,7 +54,7 @@ const Overlay = styled(AlertDialog.Overlay)` will-change: opacity; @media (prefers-reduced-motion: no-preference) { - animation: ${ANIMATIONS.overlayFadeIn} ${(props) => props.theme.motion.timings['200']} + animation: ${ANIMATIONS.fadeIn} ${(props) => props.theme.motion.timings['200']} ${(props) => props.theme.motion.easings.authenticMotion}; } `; diff --git a/packages/design-system/src/components/Drawer/Drawer.tsx b/packages/design-system/src/components/Drawer/Drawer.tsx new file mode 100644 index 000000000..1f7524810 --- /dev/null +++ b/packages/design-system/src/components/Drawer/Drawer.tsx @@ -0,0 +1,584 @@ +import * as React from 'react'; + +import * as Dialog from '@radix-ui/react-dialog'; +import { CaretDown, Cross } from '@strapi/icons'; +import { css, CSSProperties, styled } from 'styled-components'; + +import { createContext } from '../../helpers/context'; +import { handleResponsiveValues, ResponsiveProperty } from '../../helpers/handleResponsiveValues'; +import { setOpacity } from '../../helpers/setOpacity'; +import { useControllableState } from '../../hooks/useControllableState'; +import { Box } from '../../primitives/Box'; +import { Flex, type FlexComponent, type FlexProps } from '../../primitives/Flex'; +import { Typography, TypographyProps } from '../../primitives/Typography'; +import { ANIMATIONS } from '../../styles/motion'; +import { ScrollArea, ScrollAreaProps } from '../../utilities/ScrollArea'; +import { IconButton } from '../IconButton'; + +export type DrawerSide = 'top' | 'right' | 'bottom' | 'left'; + +const DEFAULT_HORIZONTAL_PADDING = { + initial: 4, + large: 3, +} as ResponsiveProperty; + +const DEFAULT_VERTICAL_PADDING = { + initial: 3, + large: 3, +} as ResponsiveProperty; + +const OPENING_ANIMATION_DURATION = 200; +const CLOSING_ANIMATION_DURATION = 120; + +/* ------------------------------------------------------------------------------------------------- + * Drawer context (open, headerVisible) + * -----------------------------------------------------------------------------------------------*/ + +interface DrawerContextValue { + open: boolean; + onOpenChange: (open: boolean) => void; + headerVisible: boolean; + overlayVisible: boolean; +} + +const [DrawerProvider, useDrawer] = createContext('Drawer', null); + +const DRAWER_ANIMATIONS = { + bottom: { + in: [ANIMATIONS.fadeIn, ANIMATIONS.slideUpIn], + out: [ANIMATIONS.fadeOut, ANIMATIONS.slideUpOut], + }, + top: { + in: [ANIMATIONS.fadeIn, ANIMATIONS.slideDownIn], + out: [ANIMATIONS.fadeOut, ANIMATIONS.slideDownOut], + }, + left: { + in: [ANIMATIONS.fadeIn, ANIMATIONS.slideLeftIn], + out: [ANIMATIONS.fadeOut, ANIMATIONS.slideLeftOut], + }, + right: { + in: [ANIMATIONS.fadeIn, ANIMATIONS.slideRightIn], + out: [ANIMATIONS.fadeOut, ANIMATIONS.slideRightOut], + }, +} as const; + +/* ------------------------------------------------------------------------------------------------- + * Root + * -----------------------------------------------------------------------------------------------*/ + +interface RootProps extends Dialog.DialogProps { + /** + * When true, only the header is visible when drawer is closed. + * Toggling open shows overlay + full content body. + */ + headerVisible?: boolean; + /** + * When true, the overlay is never shown. + */ + overlayVisible?: boolean; +} + +const Root = React.forwardRef( + ({ headerVisible = false, overlayVisible = true, open: openProp, defaultOpen, onOpenChange, children, ...props }) => { + const [open, setOpen] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen ?? false, + onChange: onOpenChange, + }); + + const handleOpenChange = React.useCallback( + (next: boolean) => { + setOpen(next); + onOpenChange?.(next); + }, + [setOpen, onOpenChange], + ); + + const dialogOpen = headerVisible ? true : open ?? false; + const isOpen = open ?? false; + + return ( + + + {children} + + + ); + }, +); + +Root.displayName = 'Drawer.Root'; + +/* ------------------------------------------------------------------------------------------------- + * Trigger + * -----------------------------------------------------------------------------------------------*/ + +type TriggerElement = HTMLButtonElement; + +interface TriggerProps extends Omit {} + +const Trigger = React.forwardRef((props, forwardedRef) => { + return ; +}); + +/* ------------------------------------------------------------------------------------------------- + * Content + * -----------------------------------------------------------------------------------------------*/ + +type ContentElement = HTMLDivElement; + +interface ContentProps extends Omit { + /** + * The edge from which the drawer slides in. + */ + side?: DrawerSide; + /** + * Width of the drawer when `side` is `'left'` or `'right'`. + * Ignored for `'top'` and `'bottom'`. + */ + width?: string; + /** + * Max height of the drawer when `side` is `'top'` or `'bottom'`. + * Ignored for `'left'` and `'right'`. + */ + maxHeight?: string; + /** + * Padding of the drawer content (can be a number or an object of responsive breakpoints). + */ + padding?: ResponsiveProperty; +} + +const Content = React.forwardRef( + ({ side = 'right', width = '32rem', maxHeight = '80vh', padding = 0, children, ...props }, forwardedRef) => { + const ctx = useDrawer('Drawer.Content'); + const open = ctx?.open ?? false; + const headerVisible = ctx?.headerVisible ?? false; + const overlayVisible = ctx?.overlayVisible ?? true; + + const showOverlay = overlayVisible && open; + const [shouldRenderOverlay, setShouldRenderOverlay] = React.useState(showOverlay); + const overlayRef = React.useRef(null); + + // Keep overlay mounted during exit animation; unmount only after animation ends + React.useEffect(() => { + if (showOverlay) { + setShouldRenderOverlay(true); + return; + } + const el = overlayRef.current; + if (!el) { + setShouldRenderOverlay(false); + return; + } + const onEnd = () => setShouldRenderOverlay(false); + el.addEventListener('animationend', onEnd, { once: true }); + return () => el.removeEventListener('animationend', onEnd); + }, [showOverlay]); + + return ( + + {shouldRenderOverlay && } + + {children} + + + ); + }, +); + +const Overlay = styled(Dialog.Overlay)<{ $isVisible: boolean }>` + background: ${(props) => setOpacity(props.theme.colors.neutral800, 0.2)}; + position: fixed; + inset: 0; + z-index: ${(props) => props.theme.zIndices.overlay}; + will-change: opacity; + + @media (prefers-reduced-motion: no-preference) { + ${({ $isVisible, theme }) => + $isVisible + ? css` + animation: ${ANIMATIONS.fadeIn} ${theme.motion.timings[OPENING_ANIMATION_DURATION]} + ${theme.motion.easings.authenticMotion} forwards; + ` + : css` + animation: ${ANIMATIONS.fadeOut} ${theme.motion.timings[CLOSING_ANIMATION_DURATION]} + ${theme.motion.easings.easeOutQuad} forwards; + `} + } +`; + +interface ContentImplProps { + $side: DrawerSide; + $width: string; + $maxHeight: string; + $headerVisible: boolean; + $open: boolean; + $padding?: ResponsiveProperty; +} + +const ContentImpl = styled(Dialog.Content)` + overflow: hidden; + display: flex; + flex-direction: column; + position: fixed; + background-color: ${(props) => props.theme.colors.neutral0}; + box-shadow: ${(props) => props.theme.shadows.popupShadow}; + z-index: ${(props) => props.theme.zIndices.modal}; + + ${({ $side, theme, $width, $maxHeight, $padding, $open }) => { + const anims = DRAWER_ANIMATIONS[$side]; + const radius = $padding ? theme.borderRadius : 0; + const base = css` + border-radius: ${radius}; + + @media (prefers-reduced-motion: no-preference) { + &[data-state='open'] { + animation-duration: ${theme.motion.timings[OPENING_ANIMATION_DURATION]}; + animation-timing-function: ${theme.motion.easings.authenticMotion}; + animation-name: ${anims.in[0]}, ${anims.in[1]}; + } + &[data-state='closed'] { + animation-duration: ${theme.motion.timings[CLOSING_ANIMATION_DURATION]}; + animation-timing-function: ${theme.motion.easings.easeOutQuad}; + animation-name: ${anims.out[0]}, ${anims.out[1]}; + } + } + `; + switch ($side) { + case 'bottom': + return css` + ${handleResponsiveValues({ bottom: $padding ?? 0 }, theme)} + ${handleResponsiveValues({ left: $padding ?? 0 }, theme)} + ${handleResponsiveValues({ right: $padding ?? 0 }, theme)} + max-height: ${$maxHeight}; + ${base} + `; + case 'top': + return css` + ${handleResponsiveValues({ top: $padding ?? 0 }, theme)} + ${handleResponsiveValues({ left: $padding ?? 0 }, theme)} + ${handleResponsiveValues({ right: $padding ?? 0 }, theme)} + max-height: ${$maxHeight}; + ${base} + `; + case 'left': + return css` + ${$open ? handleResponsiveValues({ top: $padding ?? 0 }, theme) : null} + ${handleResponsiveValues({ left: $padding ?? 0 }, theme)} + ${handleResponsiveValues({ bottom: $padding ?? 0 }, theme)} + width: ${$width}; + max-width: calc(100vw - ${theme.spaces[8]}); + ${base} + `; + case 'right': + default: + return css` + ${$open ? handleResponsiveValues({ top: $padding ?? 0 }, theme) : null} + ${handleResponsiveValues({ bottom: $padding ?? 0 }, theme)} + ${handleResponsiveValues({ right: $padding ?? 0 }, theme)} + width: ${$width}; + max-width: calc(100vw - ${theme.spaces[8]}); + ${base} + `; + } + }} + + /* When headerVisible && !open: overlay not used; content impl is direct child of Portal. Ensure it’s positioned. */ + ${({ $headerVisible, $open }) => + $headerVisible && + !$open && + css` + box-shadow: none; + `} + + > form { + display: flex; + flex-direction: column; + overflow: hidden; + } +`; + +/* ------------------------------------------------------------------------------------------------- + * Close + * -----------------------------------------------------------------------------------------------*/ + +type CloseElement = HTMLButtonElement; + +interface CloseProps extends Omit {} + +const Close = React.forwardRef((props, forwardedRef) => { + return ; +}); + +/* ------------------------------------------------------------------------------------------------- + * Header + * -----------------------------------------------------------------------------------------------*/ + +type HeaderElement = HTMLDivElement; + +interface HeaderProps extends Omit, 'tag'> { + /** + * The label for the close button. + */ + closeLabel?: string; + /** + * A custom close button to replace the default close button. Put `null` to remove it. + */ + customCloseButton?: React.ReactNode | null; + /** + * The label for the expand/collapse toggle when using `headerVisible` on Root (can be a string or an object with `expand` and `collapse` labels). + */ + toggleLabel?: string | { expand: string; collapse: string }; + /** + * A custom toggle button to replace the default toggle button. Put `null` to remove it. + */ + customToggleButton?: React.ReactNode | null; +} + +const DEFAULT_TOGGLE_LABELS = { expand: 'Expand drawer', collapse: 'Collapse drawer' } as const; + +const ToggleButton = styled(IconButton)` + padding: 0; + border: none; + background: ${({ theme }) => theme.colors.neutral200}; + width: 3.2rem; + height: 3.2rem; + border-radius: 1.6rem; + display: flex; + align-items: center; + justify-content: center; +`; + +const Header = React.forwardRef( + ( + { children, closeLabel = 'Close drawer', customCloseButton, toggleLabel, customToggleButton, ...restProps }, + forwardedRef, + ) => { + const drawer = useDrawer('Drawer.Header'); + const open = drawer?.open ?? false; + const onOpenChange = drawer?.onOpenChange ?? (() => {}); + const headerVisible = drawer?.headerVisible ?? false; + const toggleLabelResolved = + toggleLabel === undefined + ? DEFAULT_TOGGLE_LABELS + : typeof toggleLabel === 'string' + ? { expand: toggleLabel, collapse: toggleLabel } + : toggleLabel; + + return ( + + {children} + {!headerVisible && open ? ( + + {/* The purpose would be to be able to completely remove the close button by passing null */} + {typeof customCloseButton !== 'undefined' ? ( + customCloseButton + ) : ( + + + + )} + + ) : headerVisible && typeof customToggleButton !== 'undefined' ? ( + customToggleButton + ) : headerVisible ? ( + onOpenChange(!open)} + aria-expanded={open} + > + + + + + ) : null} + + ); + }, +); + +const Head = styled>(Flex)` + border-bottom: solid 1px ${(props) => props.theme.colors.neutral150}; + flex-shrink: 0; +`; + +const ToggleIconWrapper = styled.span<{ $expanded: boolean }>` + display: inline-flex; + + @media (prefers-reduced-motion: no-preference) { + transition: transform ${(props) => props.theme.motion.timings['200']} + ${(props) => props.theme.motion.easings.authenticMotion}; + } + + ${(props) => (props.$expanded ? 'transform: rotate(180deg);' : '')} +`; + +/* ------------------------------------------------------------------------------------------------- + * Title + * -----------------------------------------------------------------------------------------------*/ + +type TitleElement = HTMLHeadingElement; + +interface TitleProps extends TypographyProps<'h2'> {} + +const Title = React.forwardRef((props, forwardedRef) => { + return ( + + + + ); +}); + +/* ------------------------------------------------------------------------------------------------- + * Body + * -----------------------------------------------------------------------------------------------*/ + +type BodyElement = HTMLDivElement; + +interface BodyProps extends ScrollAreaProps {} + +const Body = React.forwardRef(({ children, ...restProps }, forwardedRef) => { + const drawer = useDrawer('Drawer.Body'); + const headerVisible = drawer?.headerVisible ?? false; + const open = drawer?.open ?? false; + const expandable = headerVisible; + + const content = ( + + {children} + + ); + + if (!expandable) return content; + + return ( + + {content} + + ); +}); + +const BodyScroll = styled(ScrollArea)` + flex: 1; + min-height: 0; + ${({ theme }) => + handleResponsiveValues( + { paddingLeft: DEFAULT_HORIZONTAL_PADDING, paddingRight: DEFAULT_HORIZONTAL_PADDING }, + theme, + )}; + + & > div { + margin: 0 -2px 0 -2px; + padding-left: 2px; + padding-right: 2px; + ${({ theme }) => + handleResponsiveValues({ paddingTop: DEFAULT_VERTICAL_PADDING, paddingBottom: DEFAULT_VERTICAL_PADDING }, theme)}; + + & > div { + display: block !important; + } + } +`; + +/* ------------------------------------------------------------------------------------------------- + * Footer + * -----------------------------------------------------------------------------------------------*/ + +type FooterElement = HTMLDivElement; + +interface FooterProps extends Omit, 'tag'> {} + +const Footer = React.forwardRef((props, forwardedRef) => { + const drawer = useDrawer('Drawer.Footer'); + const headerVisible = drawer?.headerVisible ?? false; + const open = drawer?.open ?? false; + const expandable = headerVisible; + + const content = ( + + ); + + if (!expandable) return content; + + return {content}; +}); + +const Foot = styled>(Flex)` + border-top: solid 1px ${(props) => props.theme.colors.neutral150}; + flex-shrink: 0; +`; + +const ExpandableSection = styled.div<{ $open: boolean; $flex?: boolean }>` + overflow: hidden; + display: flex; + flex-direction: column; + ${(props) => props.$flex && 'flex: 1; min-height: 0;'} + + max-height: ${(props) => (props.$open ? '2000px' : '0')}; + opacity: ${(props) => (props.$open ? 1 : 0)}; + + @media (prefers-reduced-motion: no-preference) { + transition: + max-height ${(p) => p.theme.motion.timings['200']} ${(p) => p.theme.motion.easings.authenticMotion}, + opacity ${(p) => p.theme.motion.timings['200']} ${(p) => p.theme.motion.easings.authenticMotion}; + } +`; + +type Props = RootProps; + +export { Root, Trigger, Close, Content, Header, Title, Body, Footer }; +export type { + RootProps, + Props, + TriggerElement, + TriggerProps, + CloseElement, + CloseProps, + ContentProps, + ContentElement, + HeaderElement, + HeaderProps, + TitleElement, + TitleProps, + BodyElement, + BodyProps, + FooterElement, + FooterProps, +}; diff --git a/packages/design-system/src/components/Drawer/index.ts b/packages/design-system/src/components/Drawer/index.ts new file mode 100644 index 000000000..574b4958d --- /dev/null +++ b/packages/design-system/src/components/Drawer/index.ts @@ -0,0 +1 @@ +export * as Drawer from './Drawer'; diff --git a/packages/design-system/src/components/Modal/Modal.tsx b/packages/design-system/src/components/Modal/Modal.tsx index 495b34751..55904a44c 100644 --- a/packages/design-system/src/components/Modal/Modal.tsx +++ b/packages/design-system/src/components/Modal/Modal.tsx @@ -57,7 +57,7 @@ const Overlay = styled(Dialog.Overlay)` will-change: opacity; @media (prefers-reduced-motion: no-preference) { - animation: ${ANIMATIONS.overlayFadeIn} ${(props) => props.theme.motion.timings['200']} + animation: ${ANIMATIONS.fadeIn} ${(props) => props.theme.motion.timings['200']} ${(props) => props.theme.motion.easings.authenticMotion}; } `; diff --git a/packages/design-system/src/components/index.ts b/packages/design-system/src/components/index.ts index 06fb8e23c..5abf348a1 100644 --- a/packages/design-system/src/components/index.ts +++ b/packages/design-system/src/components/index.ts @@ -13,6 +13,7 @@ export * from './Dialog'; export * from './DatePicker'; export * from './DateTimePicker'; export * from './Divider'; +export * from './Drawer'; export * from './EmptyStateLayout'; export * from './Field'; export * from './IconButton'; diff --git a/packages/design-system/src/styles/motion.ts b/packages/design-system/src/styles/motion.ts index 125db31a9..c73d3f13d 100644 --- a/packages/design-system/src/styles/motion.ts +++ b/packages/design-system/src/styles/motion.ts @@ -53,14 +53,6 @@ const TRANSITIONS = { }; const ANIMATIONS = { - overlayFadeIn: keyframes` - from { - opacity: 0; - } - to { - opacity: 0.2; - } - `, modalPopIn: keyframes` from { transform:translate(-50%, -50%) scale(0.8); @@ -141,6 +133,46 @@ const ANIMATIONS = { transform: translateY(10px); } `, + slideLeftIn: keyframes` + from { + opacity: 0; + transform: translateX(-10px); + } + to { + opacity: 1; + transform: translateX(0); + } + `, + slideLeftOut: keyframes` + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(-10px); + } + `, + slideRightIn: keyframes` + from { + opacity: 0; + transform: translateX(10px); + } + to { + opacity: 1; + transform: translateX(0); + } + `, + slideRightOut: keyframes` + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(10px); + } + `, fadeIn: keyframes` from { opacity: 0; diff --git a/packages/design-system/src/styles/type.ts b/packages/design-system/src/styles/type.ts index 27412db83..d38465186 100644 --- a/packages/design-system/src/styles/type.ts +++ b/packages/design-system/src/styles/type.ts @@ -49,8 +49,9 @@ const variant = ({ $variant = OMEGA, theme, $fontSize, $lineHeight }: VariantPro /* ------------------------------------------------------------- * Font Size * -------------------------------------------------------------*/ - ${fontSizeStyles || - css` + ${ + fontSizeStyles || + css` /* Mobile: 2.8rem */ font-size: ${theme.fontSizes[6]}; @@ -58,13 +59,15 @@ const variant = ({ $variant = OMEGA, theme, $fontSize, $lineHeight }: VariantPro /* Tablet + Desktop: 3.2rem */ font-size: ${theme.fontSizes[7]}; } - `} + ` + } /* ------------------------------------------------------------- * Line Height * -------------------------------------------------------------*/ - ${lineHeightStyles || - css` + ${ + lineHeightStyles || + css` /* Mobile: 3.2rem */ line-height: ${theme.lineHeights[0]}; @@ -72,7 +75,8 @@ const variant = ({ $variant = OMEGA, theme, $fontSize, $lineHeight }: VariantPro /* Tablet + Desktop: 4rem */ line-height: ${theme.lineHeights[2]}; } - `} + ` + } `; } case BETA: { @@ -82,8 +86,9 @@ const variant = ({ $variant = OMEGA, theme, $fontSize, $lineHeight }: VariantPro /* ------------------------------------------------------------- * Font Size * -------------------------------------------------------------*/ - ${fontSizeStyles || - css` + ${ + fontSizeStyles || + css` /* Mobile: 2rem */ font-size: ${theme.fontSizes[5]}; @@ -91,16 +96,19 @@ const variant = ({ $variant = OMEGA, theme, $fontSize, $lineHeight }: VariantPro /* Tablet + Desktop: 1.8rem */ font-size: ${theme.fontSizes[4]}; } - `} + ` + } /* ------------------------------------------------------------- * Line Height * -------------------------------------------------------------*/ - ${lineHeightStyles || - css` + ${ + lineHeightStyles || + css` /* Mobile: 2.4rem */ line-height: ${theme.lineHeights[1]}; - `} + ` + } `; } case DELTA: { @@ -110,8 +118,9 @@ const variant = ({ $variant = OMEGA, theme, $fontSize, $lineHeight }: VariantPro /* ------------------------------------------------------------- * Font Size * -------------------------------------------------------------*/ - ${fontSizeStyles || - css` + ${ + fontSizeStyles || + css` /* Mobile: 1.8rem */ font-size: ${theme.fontSizes[4]}; @@ -119,13 +128,15 @@ const variant = ({ $variant = OMEGA, theme, $fontSize, $lineHeight }: VariantPro /* Tablet + Desktop: 1.6rem */ font-size: ${theme.fontSizes[3]}; } - `} + ` + } /* ------------------------------------------------------------- * Line Height * -------------------------------------------------------------*/ - ${lineHeightStyles || - css` + ${ + lineHeightStyles || + css` /* Mobile: 2.4rem */ line-height: ${theme.lineHeights[3]}; @@ -133,7 +144,8 @@ const variant = ({ $variant = OMEGA, theme, $fontSize, $lineHeight }: VariantPro /* Tablet + Desktop: 2rem */ line-height: ${theme.lineHeights[2]}; } - `} + ` + } `; } case EPSILON: { @@ -141,8 +153,9 @@ const variant = ({ $variant = OMEGA, theme, $fontSize, $lineHeight }: VariantPro /* ------------------------------------------------------------- * Font Size * -------------------------------------------------------------*/ - ${fontSizeStyles || - css` + ${ + fontSizeStyles || + css` /* Mobile: 1.8rem */ font-size: ${theme.fontSizes[4]}; @@ -150,13 +163,15 @@ const variant = ({ $variant = OMEGA, theme, $fontSize, $lineHeight }: VariantPro /* Tablet + Desktop: 1.6rem */ font-size: ${theme.fontSizes[3]}; } - `} + ` + } /* ------------------------------------------------------------- * Line Height * -------------------------------------------------------------*/ - ${lineHeightStyles || - css` + ${ + lineHeightStyles || + css` /* Mobile: 2.4rem */ line-height: ${theme.lineHeights[3]}; @@ -164,7 +179,8 @@ const variant = ({ $variant = OMEGA, theme, $fontSize, $lineHeight }: VariantPro /* Tablet + Desktop: 2.4rem */ line-height: ${theme.lineHeights[6]}; } - `} + ` + } `; } case OMEGA: { @@ -172,8 +188,9 @@ const variant = ({ $variant = OMEGA, theme, $fontSize, $lineHeight }: VariantPro /* ------------------------------------------------------------- * Font Size * -------------------------------------------------------------*/ - ${fontSizeStyles || - css` + ${ + fontSizeStyles || + css` /* Mobile: 1.6rem */ font-size: ${theme.fontSizes[3]}; @@ -181,13 +198,15 @@ const variant = ({ $variant = OMEGA, theme, $fontSize, $lineHeight }: VariantPro /* Tablet + Desktop: 1.4rem */ font-size: ${theme.fontSizes[2]}; } - `} + ` + } /* ------------------------------------------------------------- * Line Height * -------------------------------------------------------------*/ - ${lineHeightStyles || - css` + ${ + lineHeightStyles || + css` /* Mobile: 2.4rem */ line-height: ${theme.lineHeights[6]}; @@ -195,7 +214,8 @@ const variant = ({ $variant = OMEGA, theme, $fontSize, $lineHeight }: VariantPro /* Tablet + Desktop: 2.0rem */ line-height: ${theme.lineHeights[4]}; } - `} + ` + } `; } case PI: { @@ -203,20 +223,24 @@ const variant = ({ $variant = OMEGA, theme, $fontSize, $lineHeight }: VariantPro /* ------------------------------------------------------------- * Font Size * -------------------------------------------------------------*/ - ${fontSizeStyles || - css` + ${ + fontSizeStyles || + css` /* All: 1.2rem */ font-size: ${theme.fontSizes[1]}; - `} + ` + } /* ------------------------------------------------------------- * Line Height * -------------------------------------------------------------*/ - ${lineHeightStyles || - css` + ${ + lineHeightStyles || + css` /* All: 1.6rem */ line-height: ${theme.lineHeights[3]}; - `} + ` + } `; } case SIGMA: { @@ -227,20 +251,24 @@ const variant = ({ $variant = OMEGA, theme, $fontSize, $lineHeight }: VariantPro /* ------------------------------------------------------------- * Font Size * -------------------------------------------------------------*/ - ${fontSizeStyles || - css` + ${ + fontSizeStyles || + css` /* All: 1.1rem */ font-size: ${theme.fontSizes[0]}; - `} + ` + } /* ------------------------------------------------------------- * Line Height * -------------------------------------------------------------*/ - ${lineHeightStyles || - css` + ${ + lineHeightStyles || + css` /* All: 1.6rem */ line-height: ${theme.lineHeights[5]}; - `} + ` + } `; } default: { @@ -248,8 +276,9 @@ const variant = ({ $variant = OMEGA, theme, $fontSize, $lineHeight }: VariantPro /* ------------------------------------------------------------- * Font Size * -------------------------------------------------------------*/ - ${fontSizeStyles || - css` + ${ + fontSizeStyles || + css` /* Mobile: 1.6rem */ font-size: ${theme.fontSizes[3]}; @@ -257,7 +286,8 @@ const variant = ({ $variant = OMEGA, theme, $fontSize, $lineHeight }: VariantPro /* Tablet + Desktop: 1.4rem */ font-size: ${theme.fontSizes[2]}; } - `} + ` + } `; } } From 15974063a57e738ba450c7be9456b111e62c05d6 Mon Sep 17 00:00:00 2001 From: Adrien Lepoutre Date: Wed, 28 Jan 2026 12:28:10 +0100 Subject: [PATCH 2/8] fix: design updates fix: update lodash to match cms version --- docs/stories/04-components/Drawer.stories.tsx | 15 +- packages/design-system/package.json | 4 +- .../src/components/Drawer/Drawer.test.tsx | 280 ++++++++++++++++++ .../src/components/Drawer/Drawer.tsx | 215 ++++++++------ packages/design-system/src/styles/motion.ts | 83 ++++++ yarn.lock | 21 +- 6 files changed, 517 insertions(+), 101 deletions(-) create mode 100644 packages/design-system/src/components/Drawer/Drawer.test.tsx diff --git a/docs/stories/04-components/Drawer.stories.tsx b/docs/stories/04-components/Drawer.stories.tsx index a15a73d67..975f6f9ae 100644 --- a/docs/stories/04-components/Drawer.stories.tsx +++ b/docs/stories/04-components/Drawer.stories.tsx @@ -11,7 +11,10 @@ interface DrawerArgs Drawer.ContentProps, | 'side' | 'width' + | 'height' + | 'maxWidth' | 'maxHeight' + | 'padding' | 'onOpenAutoFocus' | 'onCloseAutoFocus' | 'onEscapeKeyDown' @@ -79,7 +82,10 @@ const meta: Meta = { render: ({ side, width, + height, + maxWidth, maxHeight, + padding, headerVisible, overlayVisible, onOpenAutoFocus, @@ -97,7 +103,10 @@ const meta: Meta = { - + Drawer title @@ -203,6 +212,8 @@ export const HeaderVisible = { defaultOpen: false, headerVisible: true, side: 'bottom', + width: '100%', + padding: 0, }, name: 'header visible', } satisfies Story; diff --git a/packages/design-system/package.json b/packages/design-system/package.json index fe5fa497d..4868619b8 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -35,12 +35,12 @@ "@radix-ui/react-use-callback-ref": "1.0.1", "@strapi/ui-primitives": "2.1.2", "@uiw/react-codemirror": "4.22.2", - "lodash": "4.17.21", + "lodash": "4.17.23", "react-remove-scroll": "2.5.10" }, "devDependencies": { "@strapi/icons": "2.1.2", - "@types/lodash": "^4.17.15", + "@types/lodash": "^4.17.23", "@vitejs/plugin-react-swc": "^3.7.0", "jest": "29.7.0", "react": "18.3.1", diff --git a/packages/design-system/src/components/Drawer/Drawer.test.tsx b/packages/design-system/src/components/Drawer/Drawer.test.tsx new file mode 100644 index 000000000..b4777f450 --- /dev/null +++ b/packages/design-system/src/components/Drawer/Drawer.test.tsx @@ -0,0 +1,280 @@ +import { render, screen } from '@test/utils'; + +import { Button } from '../Button'; + +import * as Drawer from './Drawer'; + +const defaultProps = { + trigger: ( + + + + ), + content: ( + + + Drawer title + + +

Drawer body content

+
+ + + + + + +
+ ), +}; + +describe('Drawer', () => { + describe('without headerVisible', () => { + it('should render only the trigger when closed', () => { + render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + expect(screen.getByRole('button', { name: 'Open drawer' })).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByText('Drawer body content')).not.toBeInTheDocument(); + }); + + it('should open the drawer when the trigger is clicked', async () => { + const { user } = render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + await user.click(screen.getByRole('button', { name: 'Open drawer' })); + + expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); + expect(screen.getByText('Drawer body content')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Close drawer' })).toBeInTheDocument(); + }); + + it('should close the drawer when the close button is clicked', async () => { + const { user } = render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Close drawer' })); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByText('Drawer body content')).not.toBeInTheDocument(); + }); + + it('should close the drawer when Cancel (Drawer.Close) is clicked', async () => { + const { user } = render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('should close the drawer when Escape is pressed', async () => { + const { user } = render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('should be open by default when defaultOpen is true', () => { + render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); + expect(screen.getByText('Drawer body content')).toBeInTheDocument(); + }); + + it('should call onOpenChange when open state changes', async () => { + const onOpenChange = jest.fn(); + const { user } = render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + await user.click(screen.getByRole('button', { name: 'Open drawer' })); + expect(onOpenChange).toHaveBeenCalledWith(true); + + await user.click(screen.getByRole('button', { name: 'Close drawer' })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('should support controlled open state', async () => { + const onOpenChange = jest.fn(); + const { user, rerender } = render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Open drawer' })); + expect(onOpenChange).toHaveBeenCalledWith(true); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + rerender( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Close drawer' })); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); + + rerender( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + describe('with headerVisible', () => { + it('should render header without trigger when closed', () => { + render( + + + + Drawer title + + +

Drawer body content

+
+ + + + + + +
+
, + ); + + expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Drawer title' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Expand drawer' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Open drawer' })).not.toBeInTheDocument(); + expect(screen.getByText('Drawer body content')).not.toBeVisible(); + }); + + it('should expand and collapse body when toggle is clicked', async () => { + const { user } = render( + + + + Drawer title + + +

Drawer body content

+
+ + + + + + +
+
, + ); + + // Expand + await user.click(screen.getByRole('button', { name: 'Expand drawer' })); + expect(screen.getByText('Drawer body content')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Collapse drawer' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument(); + + // Collapse + await user.click(screen.getByRole('button', { name: 'Collapse drawer' })); + expect(screen.getByText('Drawer body content')).not.toBeVisible(); + expect(screen.getByRole('button', { name: 'Expand drawer' })).toBeInTheDocument(); + }); + + it('should close via Cancel when expanded', async () => { + const { user } = render( + + + + Drawer title + + +

Drawer body content

+
+ + + + + + +
+
, + ); + + await user.click(screen.getByRole('button', { name: 'Expand drawer' })); + expect(screen.getByText('Drawer body content')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.getByText('Drawer body content')).not.toBeVisible(); + expect(screen.getByRole('button', { name: 'Expand drawer' })).toBeInTheDocument(); + }); + }); + + describe('accessibility', () => { + it('should have accessible dialog with title', async () => { + const { user } = render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + await user.click(screen.getByRole('button', { name: 'Open drawer' })); + + const dialog = screen.getByRole('dialog', { name: 'Drawer title' }); + expect(dialog).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Drawer title' })).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/design-system/src/components/Drawer/Drawer.tsx b/packages/design-system/src/components/Drawer/Drawer.tsx index 1f7524810..fcab71688 100644 --- a/packages/design-system/src/components/Drawer/Drawer.tsx +++ b/packages/design-system/src/components/Drawer/Drawer.tsx @@ -17,18 +17,45 @@ import { IconButton } from '../IconButton'; export type DrawerSide = 'top' | 'right' | 'bottom' | 'left'; -const DEFAULT_HORIZONTAL_PADDING = { +/* ------------------------------------------------------------------------------------------------- + * Drawer default properties + * -----------------------------------------------------------------------------------------------*/ +// Sizes +const DEFAULT_WIDTH = '32rem'; +const DEFAULT_HEIGHT = 'auto'; +const DEFAULT_MAX_WIDTH = '100%'; + +// Paddings +const HORIZONTAL_INNER_PADDING = { initial: 4, large: 3, } as ResponsiveProperty; - -const DEFAULT_VERTICAL_PADDING = { +const VERTICAL_INNER_PADDING = { initial: 3, large: 3, } as ResponsiveProperty; +// Animations const OPENING_ANIMATION_DURATION = 200; const CLOSING_ANIMATION_DURATION = 120; +const DRAWER_ANIMATIONS = { + bottom: { + in: ANIMATIONS.drawerSlideUpIn, + out: ANIMATIONS.drawerSlideUpOut, + }, + top: { + in: ANIMATIONS.drawerSlideDownIn, + out: ANIMATIONS.drawerSlideDownOut, + }, + left: { + in: ANIMATIONS.drawerSlideLeftIn, + out: ANIMATIONS.drawerSlideLeftOut, + }, + right: { + in: ANIMATIONS.drawerSlideRightIn, + out: ANIMATIONS.drawerSlideRightOut, + }, +} as const; /* ------------------------------------------------------------------------------------------------- * Drawer context (open, headerVisible) @@ -43,25 +70,6 @@ interface DrawerContextValue { const [DrawerProvider, useDrawer] = createContext('Drawer', null); -const DRAWER_ANIMATIONS = { - bottom: { - in: [ANIMATIONS.fadeIn, ANIMATIONS.slideUpIn], - out: [ANIMATIONS.fadeOut, ANIMATIONS.slideUpOut], - }, - top: { - in: [ANIMATIONS.fadeIn, ANIMATIONS.slideDownIn], - out: [ANIMATIONS.fadeOut, ANIMATIONS.slideDownOut], - }, - left: { - in: [ANIMATIONS.fadeIn, ANIMATIONS.slideLeftIn], - out: [ANIMATIONS.fadeOut, ANIMATIONS.slideLeftOut], - }, - right: { - in: [ANIMATIONS.fadeIn, ANIMATIONS.slideRightIn], - out: [ANIMATIONS.fadeOut, ANIMATIONS.slideRightOut], - }, -} as const; - /* ------------------------------------------------------------------------------------------------- * Root * -----------------------------------------------------------------------------------------------*/ @@ -79,7 +87,10 @@ interface RootProps extends Dialog.DialogProps { } const Root = React.forwardRef( - ({ headerVisible = false, overlayVisible = true, open: openProp, defaultOpen, onOpenChange, children, ...props }) => { + ( + { headerVisible = false, overlayVisible = true, open: openProp, defaultOpen, onOpenChange, children, ...props }, + forwardedRef, + ) => { const [open, setOpen] = useControllableState({ prop: openProp, defaultProp: defaultOpen ?? false, @@ -98,16 +109,18 @@ const Root = React.forwardRef( const isOpen = open ?? false; return ( - - - {children} - - +
+ + + {children} + + +
); }, ); @@ -138,13 +151,19 @@ interface ContentProps extends Omit { */ side?: DrawerSide; /** - * Width of the drawer when `side` is `'left'` or `'right'`. - * Ignored for `'top'` and `'bottom'`. + * Width of the drawer. */ width?: string; /** - * Max height of the drawer when `side` is `'top'` or `'bottom'`. - * Ignored for `'left'` and `'right'`. + * Maximum width of the drawer. + */ + maxWidth?: string; + /** + * Height of the drawer. + */ + height?: string; + /** + * Maximum height of the drawer. */ maxHeight?: string; /** @@ -154,7 +173,7 @@ interface ContentProps extends Omit { } const Content = React.forwardRef( - ({ side = 'right', width = '32rem', maxHeight = '80vh', padding = 0, children, ...props }, forwardedRef) => { + ({ side = 'right', width, maxWidth, height, maxHeight, padding = 2, children, ...props }, forwardedRef) => { const ctx = useDrawer('Drawer.Content'); const open = ctx?.open ?? false; const headerVisible = ctx?.headerVisible ?? false; @@ -183,18 +202,20 @@ const Content = React.forwardRef( return ( {shouldRenderOverlay && } - - {children} - + {children} + ); }, @@ -223,87 +244,100 @@ const Overlay = styled(Dialog.Overlay)<{ $isVisible: boolean }>` interface ContentImplProps { $side: DrawerSide; - $width: string; - $maxHeight: string; + $width?: string; + $maxWidth?: string; + $height?: string; + $maxHeight?: string; $headerVisible: boolean; $open: boolean; $padding?: ResponsiveProperty; } -const ContentImpl = styled(Dialog.Content)` +const ContentContainer = styled(Dialog.Content)` overflow: hidden; display: flex; flex-direction: column; position: fixed; - background-color: ${(props) => props.theme.colors.neutral0}; - box-shadow: ${(props) => props.theme.shadows.popupShadow}; z-index: ${(props) => props.theme.zIndices.modal}; - ${({ $side, theme, $width, $maxHeight, $padding, $open }) => { - const anims = DRAWER_ANIMATIONS[$side]; + ${({ $side, theme, $width, $maxWidth, $height, $maxHeight, $padding, $headerVisible, $open }) => { + const animation = DRAWER_ANIMATIONS[$side]; const radius = $padding ? theme.borderRadius : 0; const base = css` - border-radius: ${radius}; + width: ${$width || DEFAULT_WIDTH}; + height: ${$height || DEFAULT_HEIGHT}; + ${handleResponsiveValues({ padding: $padding }, theme)} @media (prefers-reduced-motion: no-preference) { &[data-state='open'] { animation-duration: ${theme.motion.timings[OPENING_ANIMATION_DURATION]}; animation-timing-function: ${theme.motion.easings.authenticMotion}; - animation-name: ${anims.in[0]}, ${anims.in[1]}; + animation-name: ${animation.in}; } &[data-state='closed'] { animation-duration: ${theme.motion.timings[CLOSING_ANIMATION_DURATION]}; animation-timing-function: ${theme.motion.easings.easeOutQuad}; - animation-name: ${anims.out[0]}, ${anims.out[1]}; + animation-name: ${animation.out}; } } + ${ContentInner} { + border-radius: ${radius}; + + /* When headerVisible && !open: overlay not used; content impl is direct child of Portal. Ensure it’s positioned. */ + ${$headerVisible && + !$open && + css` + box-shadow: none; + `} + } `; switch ($side) { case 'bottom': return css` - ${handleResponsiveValues({ bottom: $padding ?? 0 }, theme)} - ${handleResponsiveValues({ left: $padding ?? 0 }, theme)} - ${handleResponsiveValues({ right: $padding ?? 0 }, theme)} - max-height: ${$maxHeight}; + bottom: 0; + left: 50%; + transform: translateX(-50%); ${base} + max-width: ${$maxWidth || DEFAULT_MAX_WIDTH}; + max-height: ${$maxHeight || '80vh'}; `; case 'top': return css` - ${handleResponsiveValues({ top: $padding ?? 0 }, theme)} - ${handleResponsiveValues({ left: $padding ?? 0 }, theme)} - ${handleResponsiveValues({ right: $padding ?? 0 }, theme)} - max-height: ${$maxHeight}; + top: 0; + left: 50%; + transform: translateX(-50%); ${base} + max-width: ${$maxWidth || DEFAULT_MAX_WIDTH}; + max-height: ${$maxHeight || '80vh'}; `; case 'left': return css` - ${$open ? handleResponsiveValues({ top: $padding ?? 0 }, theme) : null} - ${handleResponsiveValues({ left: $padding ?? 0 }, theme)} - ${handleResponsiveValues({ bottom: $padding ?? 0 }, theme)} - width: ${$width}; - max-width: calc(100vw - ${theme.spaces[8]}); + left: 0; + bottom: 0; ${base} + max-width: ${$maxWidth || DEFAULT_MAX_WIDTH}; + max-height: ${$maxHeight || '100vh'}; `; case 'right': default: return css` - ${$open ? handleResponsiveValues({ top: $padding ?? 0 }, theme) : null} - ${handleResponsiveValues({ bottom: $padding ?? 0 }, theme)} - ${handleResponsiveValues({ right: $padding ?? 0 }, theme)} - width: ${$width}; - max-width: calc(100vw - ${theme.spaces[8]}); + right: 0; + bottom: 0; ${base} + max-width: ${$maxWidth || DEFAULT_MAX_WIDTH}; + max-height: ${$maxHeight || '100vh'}; `; } }} +`; - /* When headerVisible && !open: overlay not used; content impl is direct child of Portal. Ensure it’s positioned. */ - ${({ $headerVisible, $open }) => - $headerVisible && - !$open && - css` - box-shadow: none; - `} +const ContentInner = styled(Flex)` + flex: 1; + align-items: stretch; + flex-direction: column; + background-color: ${(props) => props.theme.colors.neutral0}; + box-shadow: ${(props) => props.theme.shadows.popupShadow}; + overflow: hidden; > form { display: flex; @@ -384,10 +418,10 @@ const Header = React.forwardRef( ref={forwardedRef} alignItems="center" gap={2} - paddingTop={DEFAULT_VERTICAL_PADDING} - paddingBottom={DEFAULT_VERTICAL_PADDING} - paddingLeft={DEFAULT_HORIZONTAL_PADDING} - paddingRight={DEFAULT_HORIZONTAL_PADDING} + paddingTop={VERTICAL_INNER_PADDING} + paddingBottom={VERTICAL_INNER_PADDING} + paddingLeft={HORIZONTAL_INNER_PADDING} + paddingRight={HORIZONTAL_INNER_PADDING} background="neutral0" justifyContent="space-between" {...restProps} @@ -425,8 +459,11 @@ const Header = React.forwardRef( ); const Head = styled>(Flex)` - border-bottom: solid 1px ${(props) => props.theme.colors.neutral150}; flex-shrink: 0; + + & + * { + border-top: solid 1px ${(props) => props.theme.colors.neutral150}; + } `; const ToggleIconWrapper = styled.span<{ $expanded: boolean }>` @@ -489,17 +526,14 @@ const BodyScroll = styled(ScrollArea)` flex: 1; min-height: 0; ${({ theme }) => - handleResponsiveValues( - { paddingLeft: DEFAULT_HORIZONTAL_PADDING, paddingRight: DEFAULT_HORIZONTAL_PADDING }, - theme, - )}; + handleResponsiveValues({ paddingLeft: HORIZONTAL_INNER_PADDING, paddingRight: HORIZONTAL_INNER_PADDING }, theme)}; & > div { margin: 0 -2px 0 -2px; padding-left: 2px; padding-right: 2px; ${({ theme }) => - handleResponsiveValues({ paddingTop: DEFAULT_VERTICAL_PADDING, paddingBottom: DEFAULT_VERTICAL_PADDING }, theme)}; + handleResponsiveValues({ paddingTop: VERTICAL_INNER_PADDING, paddingBottom: VERTICAL_INNER_PADDING }, theme)}; & > div { display: block !important; @@ -524,14 +558,15 @@ const Footer = React.forwardRef((props, forwardedRef const content = ( ); diff --git a/packages/design-system/src/styles/motion.ts b/packages/design-system/src/styles/motion.ts index c73d3f13d..668a7345d 100644 --- a/packages/design-system/src/styles/motion.ts +++ b/packages/design-system/src/styles/motion.ts @@ -189,6 +189,89 @@ const ANIMATIONS = { opacity: 0; } `, + /** + * Drawer animations + */ + drawerSlideUpIn: keyframes` + from { + opacity: 0; + transform: translate(-50%, 100%); + } + to { + opacity: 1; + transform: translate(-50%, 0); + } + `, + drawerSlideUpOut: keyframes` + from { + opacity: 1; + transform: translate(-50%, 0); + } + to { + opacity: 0; + transform: translate(-50%, 100%); + } + `, + drawerSlideDownIn: keyframes` + from { + opacity: 0; + transform: translate(-50%, -100%); + } + to { + opacity: 1; + transform: translate(-50%, 0); + } + `, + drawerSlideDownOut: keyframes` + from { + opacity: 1; + transform: translate(-50%, 0); + } + to { + opacity: 0; + transform: translate(-50%, -100%); + } + `, + drawerSlideLeftIn: keyframes` + from { + opacity: 0; + transform: translateX(-100%); + } + to { + opacity: 1; + transform: translateX(0); + } + `, + drawerSlideLeftOut: keyframes` + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(-100%); + } + `, + drawerSlideRightIn: keyframes` + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } + `, + drawerSlideRightOut: keyframes` + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(100%); + } + `, }; export { ANIMATIONS, EASINGS, TRANSITIONS, TIMINGS }; diff --git a/yarn.lock b/yarn.lock index 2c16a627f..b38b803e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4083,11 +4083,11 @@ __metadata: "@radix-ui/react-use-callback-ref": 1.0.1 "@strapi/icons": 2.1.2 "@strapi/ui-primitives": 2.1.2 - "@types/lodash": ^4.17.15 + "@types/lodash": ^4.17.23 "@uiw/react-codemirror": 4.22.2 "@vitejs/plugin-react-swc": ^3.7.0 jest: 29.7.0 - lodash: 4.17.21 + lodash: 4.17.23 react: 18.3.1 react-dom: 18.3.1 react-remove-scroll: 2.5.10 @@ -5013,10 +5013,10 @@ __metadata: languageName: node linkType: hard -"@types/lodash@npm:^4.17.15": - version: 4.17.20 - resolution: "@types/lodash@npm:4.17.20" - checksum: dc7bb4653514dd91117a4c4cec2c37e2b5a163d7643445e4757d76a360fabe064422ec7a42dde7450c5e7e0e7e678d5e6eae6d2a919abcddf581d81e63e63839 +"@types/lodash@npm:^4.17.23": + version: 4.17.23 + resolution: "@types/lodash@npm:4.17.23" + checksum: 38638641526759688656b9930c0a2714536bdc2b84d5a2d4dc4b7825ba39a74ceedcc9971a9c7511189dad987426135b647616e4f49f2d67893617bdb7c85f84 languageName: node linkType: hard @@ -11428,7 +11428,14 @@ __metadata: languageName: node linkType: hard -"lodash@npm:4.17.21, lodash@npm:^4.17.21, lodash@npm:~4.17.15": +"lodash@npm:4.17.23": + version: 4.17.23 + resolution: "lodash@npm:4.17.23" + checksum: 7daad39758a72872e94651630fbb54ba76868f904211089721a64516ce865506a759d9ad3d8ff22a2a49a50a09db5d27c36f22762d21766e47e3ba918d6d7bab + languageName: node + linkType: hard + +"lodash@npm:^4.17.21, lodash@npm:~4.17.15": version: 4.17.21 resolution: "lodash@npm:4.17.21" checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7 From 74077d3573f53280323be9e46ac50c2a497163ab Mon Sep 17 00:00:00 2001 From: Adrien Lepoutre Date: Wed, 28 Jan 2026 15:21:54 +0100 Subject: [PATCH 3/8] fix: delay modal property when headerVisible --- docs/stories/04-components/Drawer.stories.tsx | 2 +- .../src/components/Drawer/Drawer.tsx | 68 ++++++++++++++----- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/docs/stories/04-components/Drawer.stories.tsx b/docs/stories/04-components/Drawer.stories.tsx index 975f6f9ae..b6d867792 100644 --- a/docs/stories/04-components/Drawer.stories.tsx +++ b/docs/stories/04-components/Drawer.stories.tsx @@ -177,7 +177,7 @@ export const SideTop = { export const SideBottom = { args: { - defaultOpen: true, + defaultOpen: false, side: 'bottom', }, name: 'side bottom', diff --git a/packages/design-system/src/components/Drawer/Drawer.tsx b/packages/design-system/src/components/Drawer/Drawer.tsx index fcab71688..5d0ca55d8 100644 --- a/packages/design-system/src/components/Drawer/Drawer.tsx +++ b/packages/design-system/src/components/Drawer/Drawer.tsx @@ -107,6 +107,24 @@ const Root = React.forwardRef( const dialogOpen = headerVisible ? true : open ?? false; const isOpen = open ?? false; + const modal = !!(isOpen && overlayVisible); + const [delayedModal, setDelayedModal] = React.useState(false); + + /** + * When headerVisible is true, delay switching modal until after expand/collapse + * animation to prevent remount. The remount would prevent the expand/collapse + * transition from happening. + */ + React.useEffect(() => { + if (!headerVisible) return; + if (modal) { + const t = setTimeout(() => setDelayedModal(true), OPENING_ANIMATION_DURATION); + return () => clearTimeout(t); + } else { + const t = setTimeout(() => setDelayedModal(false), CLOSING_ANIMATION_DURATION); + return () => clearTimeout(t); + } + }, [headerVisible, modal]); return (
@@ -116,7 +134,12 @@ const Root = React.forwardRef( headerVisible={headerVisible} overlayVisible={overlayVisible} > - + {children} @@ -268,18 +291,22 @@ const ContentContainer = styled(Dialog.Content)` height: ${$height || DEFAULT_HEIGHT}; ${handleResponsiveValues({ padding: $padding }, theme)} - @media (prefers-reduced-motion: no-preference) { - &[data-state='open'] { - animation-duration: ${theme.motion.timings[OPENING_ANIMATION_DURATION]}; - animation-timing-function: ${theme.motion.easings.authenticMotion}; - animation-name: ${animation.in}; + ${!$headerVisible && + css` + @media (prefers-reduced-motion: no-preference) { + &[data-state='open'] { + animation-duration: ${theme.motion.timings[OPENING_ANIMATION_DURATION]}; + animation-timing-function: ${theme.motion.easings.authenticMotion}; + animation-name: ${animation.in}; + } + &[data-state='closed'] { + animation-duration: ${theme.motion.timings[CLOSING_ANIMATION_DURATION]}; + animation-timing-function: ${theme.motion.easings.easeOutQuad}; + animation-name: ${animation.out}; + } } - &[data-state='closed'] { - animation-duration: ${theme.motion.timings[CLOSING_ANIMATION_DURATION]}; - animation-timing-function: ${theme.motion.easings.easeOutQuad}; - animation-name: ${animation.out}; - } - } + `} + ${ContentInner} { border-radius: ${radius}; @@ -585,14 +612,23 @@ const ExpandableSection = styled.div<{ $open: boolean; $flex?: boolean }>` display: flex; flex-direction: column; ${(props) => props.$flex && 'flex: 1; min-height: 0;'} - - max-height: ${(props) => (props.$open ? '2000px' : '0')}; + max-height: ${(props) => (props.$open ? '100vh' : '0')}; opacity: ${(props) => (props.$open ? 1 : 0)}; @media (prefers-reduced-motion: no-preference) { transition: - max-height ${(p) => p.theme.motion.timings['200']} ${(p) => p.theme.motion.easings.authenticMotion}, - opacity ${(p) => p.theme.motion.timings['200']} ${(p) => p.theme.motion.easings.authenticMotion}; + max-height + ${(p) => + p.$open + ? p.theme.motion.timings[OPENING_ANIMATION_DURATION] + : p.theme.motion.timings[CLOSING_ANIMATION_DURATION]} + ${(p) => p.theme.motion.easings.authenticMotion}, + opacity + ${(p) => + p.$open + ? p.theme.motion.timings[OPENING_ANIMATION_DURATION] + : p.theme.motion.timings[CLOSING_ANIMATION_DURATION]} + ${(p) => p.theme.motion.easings.authenticMotion}; } `; From 8b7ff043a2dc7d1f4dd5b96ee9b1db2670a7c0cc Mon Sep 17 00:00:00 2001 From: Adrien Lepoutre Date: Wed, 28 Jan 2026 16:05:01 +0100 Subject: [PATCH 4/8] fix: add hasClose and hasToggle --- docs/stories/04-components/Drawer.stories.tsx | 38 +-- .../src/components/Drawer/Drawer.test.tsx | 280 ------------------ .../src/components/Drawer/Drawer.tsx | 92 +++--- 3 files changed, 54 insertions(+), 356 deletions(-) delete mode 100644 packages/design-system/src/components/Drawer/Drawer.test.tsx diff --git a/docs/stories/04-components/Drawer.stories.tsx b/docs/stories/04-components/Drawer.stories.tsx index b6d867792..87a039ba1 100644 --- a/docs/stories/04-components/Drawer.stories.tsx +++ b/docs/stories/04-components/Drawer.stories.tsx @@ -7,20 +7,8 @@ import { fn } from 'storybook/test'; interface DrawerArgs extends Drawer.Props, - Pick< - Drawer.ContentProps, - | 'side' - | 'width' - | 'height' - | 'maxWidth' - | 'maxHeight' - | 'padding' - | 'onOpenAutoFocus' - | 'onCloseAutoFocus' - | 'onEscapeKeyDown' - | 'onPointerDownOutside' - | 'onInteractOutside' - > { + Pick, + Pick { headerVisible?: boolean; overlayVisible?: boolean; } @@ -44,7 +32,7 @@ const meta: Meta = { - + Drawer title @@ -68,10 +56,8 @@ const meta: Meta = { side: 'right', headerVisible: false, overlayVisible: true, - onOpenChange: fn(), - onOpenAutoFocus: fn(), - onCloseAutoFocus: fn(), - onEscapeKeyDown: fn(), + hasClose: true, + hasToggle: true, }, argTypes: { side: { @@ -88,9 +74,8 @@ const meta: Meta = { padding, headerVisible, overlayVisible, - onOpenAutoFocus, - onCloseAutoFocus, - onEscapeKeyDown, + hasClose, + hasToggle, ...args }) => { return ( @@ -107,11 +92,8 @@ const meta: Meta = { {...(maxWidth !== undefined && { maxWidth })} {...(maxHeight !== undefined && { maxHeight })} {...(padding !== undefined && { padding })} - onOpenAutoFocus={onOpenAutoFocus} - onCloseAutoFocus={onCloseAutoFocus} - onEscapeKeyDown={onEscapeKeyDown} > - + Drawer title @@ -177,7 +159,7 @@ export const SideTop = { export const SideBottom = { args: { - defaultOpen: false, + defaultOpen: true, side: 'bottom', }, name: 'side bottom', @@ -190,7 +172,7 @@ export const HeaderVisible = { code: outdent` - + Drawer title diff --git a/packages/design-system/src/components/Drawer/Drawer.test.tsx b/packages/design-system/src/components/Drawer/Drawer.test.tsx deleted file mode 100644 index b4777f450..000000000 --- a/packages/design-system/src/components/Drawer/Drawer.test.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { render, screen } from '@test/utils'; - -import { Button } from '../Button'; - -import * as Drawer from './Drawer'; - -const defaultProps = { - trigger: ( - - - - ), - content: ( - - - Drawer title - - -

Drawer body content

-
- - - - - - -
- ), -}; - -describe('Drawer', () => { - describe('without headerVisible', () => { - it('should render only the trigger when closed', () => { - render( - - {defaultProps.trigger} - {defaultProps.content} - , - ); - - expect(screen.getByRole('button', { name: 'Open drawer' })).toBeInTheDocument(); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(screen.queryByText('Drawer body content')).not.toBeInTheDocument(); - }); - - it('should open the drawer when the trigger is clicked', async () => { - const { user } = render( - - {defaultProps.trigger} - {defaultProps.content} - , - ); - - await user.click(screen.getByRole('button', { name: 'Open drawer' })); - - expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); - expect(screen.getByText('Drawer body content')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Close drawer' })).toBeInTheDocument(); - }); - - it('should close the drawer when the close button is clicked', async () => { - const { user } = render( - - {defaultProps.trigger} - {defaultProps.content} - , - ); - - expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'Close drawer' })); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(screen.queryByText('Drawer body content')).not.toBeInTheDocument(); - }); - - it('should close the drawer when Cancel (Drawer.Close) is clicked', async () => { - const { user } = render( - - {defaultProps.trigger} - {defaultProps.content} - , - ); - - expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'Cancel' })); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - - it('should close the drawer when Escape is pressed', async () => { - const { user } = render( - - {defaultProps.trigger} - {defaultProps.content} - , - ); - - expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); - - await user.keyboard('{Escape}'); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - - it('should be open by default when defaultOpen is true', () => { - render( - - {defaultProps.trigger} - {defaultProps.content} - , - ); - - expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); - expect(screen.getByText('Drawer body content')).toBeInTheDocument(); - }); - - it('should call onOpenChange when open state changes', async () => { - const onOpenChange = jest.fn(); - const { user } = render( - - {defaultProps.trigger} - {defaultProps.content} - , - ); - - await user.click(screen.getByRole('button', { name: 'Open drawer' })); - expect(onOpenChange).toHaveBeenCalledWith(true); - - await user.click(screen.getByRole('button', { name: 'Close drawer' })); - expect(onOpenChange).toHaveBeenCalledWith(false); - }); - - it('should support controlled open state', async () => { - const onOpenChange = jest.fn(); - const { user, rerender } = render( - - {defaultProps.trigger} - {defaultProps.content} - , - ); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'Open drawer' })); - expect(onOpenChange).toHaveBeenCalledWith(true); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - - rerender( - - {defaultProps.trigger} - {defaultProps.content} - , - ); - expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'Close drawer' })); - expect(onOpenChange).toHaveBeenCalledWith(false); - expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); - - rerender( - - {defaultProps.trigger} - {defaultProps.content} - , - ); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - }); - - describe('with headerVisible', () => { - it('should render header without trigger when closed', () => { - render( - - - - Drawer title - - -

Drawer body content

-
- - - - - - -
-
, - ); - - expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'Drawer title' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Expand drawer' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Open drawer' })).not.toBeInTheDocument(); - expect(screen.getByText('Drawer body content')).not.toBeVisible(); - }); - - it('should expand and collapse body when toggle is clicked', async () => { - const { user } = render( - - - - Drawer title - - -

Drawer body content

-
- - - - - - -
-
, - ); - - // Expand - await user.click(screen.getByRole('button', { name: 'Expand drawer' })); - expect(screen.getByText('Drawer body content')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Collapse drawer' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument(); - - // Collapse - await user.click(screen.getByRole('button', { name: 'Collapse drawer' })); - expect(screen.getByText('Drawer body content')).not.toBeVisible(); - expect(screen.getByRole('button', { name: 'Expand drawer' })).toBeInTheDocument(); - }); - - it('should close via Cancel when expanded', async () => { - const { user } = render( - - - - Drawer title - - -

Drawer body content

-
- - - - - - -
-
, - ); - - await user.click(screen.getByRole('button', { name: 'Expand drawer' })); - expect(screen.getByText('Drawer body content')).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'Cancel' })); - expect(screen.getByText('Drawer body content')).not.toBeVisible(); - expect(screen.getByRole('button', { name: 'Expand drawer' })).toBeInTheDocument(); - }); - }); - - describe('accessibility', () => { - it('should have accessible dialog with title', async () => { - const { user } = render( - - {defaultProps.trigger} - {defaultProps.content} - , - ); - - await user.click(screen.getByRole('button', { name: 'Open drawer' })); - - const dialog = screen.getByRole('dialog', { name: 'Drawer title' }); - expect(dialog).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'Drawer title' })).toBeInTheDocument(); - }); - }); -}); diff --git a/packages/design-system/src/components/Drawer/Drawer.tsx b/packages/design-system/src/components/Drawer/Drawer.tsx index 5d0ca55d8..81b5d8ae8 100644 --- a/packages/design-system/src/components/Drawer/Drawer.tsx +++ b/packages/design-system/src/components/Drawer/Drawer.tsx @@ -108,23 +108,6 @@ const Root = React.forwardRef( const dialogOpen = headerVisible ? true : open ?? false; const isOpen = open ?? false; const modal = !!(isOpen && overlayVisible); - const [delayedModal, setDelayedModal] = React.useState(false); - - /** - * When headerVisible is true, delay switching modal until after expand/collapse - * animation to prevent remount. The remount would prevent the expand/collapse - * transition from happening. - */ - React.useEffect(() => { - if (!headerVisible) return; - if (modal) { - const t = setTimeout(() => setDelayedModal(true), OPENING_ANIMATION_DURATION); - return () => clearTimeout(t); - } else { - const t = setTimeout(() => setDelayedModal(false), CLOSING_ANIMATION_DURATION); - return () => clearTimeout(t); - } - }, [headerVisible, modal]); return (
@@ -134,12 +117,7 @@ const Root = React.forwardRef( headerVisible={headerVisible} overlayVisible={overlayVisible} > - + {children} @@ -392,6 +370,11 @@ const Close = React.forwardRef((props, forwardedRef) = type HeaderElement = HTMLDivElement; interface HeaderProps extends Omit, 'tag'> { + /** + * Whether to show the close button. + * By default, the close button is only shown if `headerVisible` is `false` and the drawer is open. + */ + hasClose?: boolean; /** * The label for the close button. */ @@ -400,6 +383,11 @@ interface HeaderProps extends Omit, 'tag'> { * A custom close button to replace the default close button. Put `null` to remove it. */ customCloseButton?: React.ReactNode | null; + /** + * Whether to show the toggle button. + * By default, the toggle button is only shown if `headerVisible` is `true`. + */ + hasToggle?: boolean; /** * The label for the expand/collapse toggle when using `headerVisible` on Root (can be a string or an object with `expand` and `collapse` labels). */ @@ -426,7 +414,16 @@ const ToggleButton = styled(IconButton)` const Header = React.forwardRef( ( - { children, closeLabel = 'Close drawer', customCloseButton, toggleLabel, customToggleButton, ...restProps }, + { + children, + hasClose = true, + closeLabel = 'Close drawer', + customCloseButton, + hasToggle = true, + toggleLabel, + customToggleButton, + ...restProps + }, forwardedRef, ) => { const drawer = useDrawer('Drawer.Header'); @@ -455,31 +452,30 @@ const Header = React.forwardRef( tag="header" > {children} - {!headerVisible && open ? ( - - {/* The purpose would be to be able to completely remove the close button by passing null */} - {typeof customCloseButton !== 'undefined' ? ( - customCloseButton - ) : ( - - - + {headerVisible + ? hasToggle && + (customToggleButton ?? ( + onOpenChange(!open)} + aria-expanded={open} + > + + + + + )) + : hasClose && + open && ( + + {customCloseButton ?? ( + + + + )} + )} - - ) : headerVisible && typeof customToggleButton !== 'undefined' ? ( - customToggleButton - ) : headerVisible ? ( - onOpenChange(!open)} - aria-expanded={open} - > - - - - - ) : null} ); }, From a7187658e72e32e5d6831f9a401171f97294972f Mon Sep 17 00:00:00 2001 From: Adrien Lepoutre Date: Wed, 28 Jan 2026 16:56:56 +0100 Subject: [PATCH 5/8] fix: remove useless import --- docs/stories/04-components/Drawer.stories.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/stories/04-components/Drawer.stories.tsx b/docs/stories/04-components/Drawer.stories.tsx index 87a039ba1..0e776d3f2 100644 --- a/docs/stories/04-components/Drawer.stories.tsx +++ b/docs/stories/04-components/Drawer.stories.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import { Meta, StoryObj } from '@storybook/react-vite'; import { Button, Drawer, Field, Flex } from '@strapi/design-system'; import { outdent } from 'outdent'; -import { fn } from 'storybook/test'; interface DrawerArgs extends Drawer.Props, From ab55841067e338897fe8300fca661ec55744540b Mon Sep 17 00:00:00 2001 From: Adrien Lepoutre Date: Thu, 29 Jan 2026 10:14:39 +0100 Subject: [PATCH 6/8] fix: pr comments --- docs/stories/04-components/Drawer.mdx | 4 +- docs/stories/04-components/Drawer.stories.tsx | 74 ++++-- .../src/components/Drawer/Drawer.test.tsx | 251 ++++++++++++++++++ .../src/components/Drawer/Drawer.tsx | 75 ++++-- packages/design-system/src/styles/motion.ts | 40 --- 5 files changed, 350 insertions(+), 94 deletions(-) create mode 100644 packages/design-system/src/components/Drawer/Drawer.test.tsx diff --git a/docs/stories/04-components/Drawer.mdx b/docs/stories/04-components/Drawer.mdx index f050ceabf..a758c64ec 100644 --- a/docs/stories/04-components/Drawer.mdx +++ b/docs/stories/04-components/Drawer.mdx @@ -79,7 +79,9 @@ Optional. Flex container for custom actions. ## Positions -The drawer can be positioned on any edge via the `side` prop. +The drawer can be positioned on any edge via the `direction` prop (can be `left`, `right`, `top` or `bottom`). + +The width will be forced at 100% on mobile devices. ## Header visible diff --git a/docs/stories/04-components/Drawer.stories.tsx b/docs/stories/04-components/Drawer.stories.tsx index 0e776d3f2..477cc10e4 100644 --- a/docs/stories/04-components/Drawer.stories.tsx +++ b/docs/stories/04-components/Drawer.stories.tsx @@ -1,12 +1,12 @@ import * as React from 'react'; import { Meta, StoryObj } from '@storybook/react-vite'; -import { Button, Drawer, Field, Flex } from '@strapi/design-system'; +import { Button, Drawer, Field, Flex, Typography } from '@strapi/design-system'; import { outdent } from 'outdent'; interface DrawerArgs extends Drawer.Props, - Pick, + Pick, Pick { headerVisible?: boolean; overlayVisible?: boolean; @@ -30,7 +30,7 @@ const meta: Meta = { - + Drawer title @@ -52,20 +52,20 @@ const meta: Meta = { }, args: { defaultOpen: false, - side: 'right', + direction: 'right', headerVisible: false, overlayVisible: true, hasClose: true, hasToggle: true, }, argTypes: { - side: { + direction: { control: 'select', options: ['top', 'right', 'bottom', 'left'], }, }, render: ({ - side, + direction, width, height, maxWidth, @@ -85,7 +85,7 @@ const meta: Meta = { )} = { Drawer title - - Example field - - + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla molestie odio dui. Cras egestas ultrices + pulvinar. Cras in pellentesque neque. Vestibulum imperdiet ex vitae felis lobortis, sed facilisis ligula + laoreet. Proin iaculis molestie felis. Curabitur ultrices turpis nec rutrum facilisis. Suspendisse in + ligula in est euismod feugiat. Proin vestibulum eros massa, et accumsan justo interdum dictum. Vestibulum + sed varius urna. Ut pellentesque fermentum sodales. Suspendisse venenatis eros vitae lorem rutrum tempus. + Suspendisse diam erat, semper gravida finibus et, tincidunt a erat. Curabitur placerat imperdiet urna, sit + amet aliquet lorem rutrum sit amet. Aliquam et molestie nisl. Quisque at nisl et eros sollicitudin blandit + bibendum ac augue. Quisque nec facilisis tortor. Nulla ex urna, scelerisque ultricies leo eget, iaculis + laoreet ipsum. Suspendisse potenti. Sed eget porttitor lectus. Nullam in ipsum vel enim facilisis gravida. + Duis posuere porttitor erat eu maximus. Morbi viverra tempus ante. Phasellus gravida ligula vel elit + lacinia, ut finibus urna interdum. In cursus rhoncus accumsan. Cras at mattis massa. Mauris tempor ipsum + quam. Integer augue eros, accumsan in ante nec, tincidunt ultrices nisl. Integer ut enim enim. Vivamus + posuere metus et nibh pharetra, at bibendum dolor porttitor. Fusce iaculis eleifend lectus, ut rutrum + sapien auctor et. In vitae risus lacus. Quisque ex mauris, venenatis vel nisi in, dictum suscipit tortor. + Aliquam non justo cursus, dictum libero eget, viverra velit. Sed velit nisi, rutrum ut tempor et, ornare + sit amet risus. Mauris ornare eleifend justo et viverra. Morbi feugiat nulla vitae sodales auctor. Donec + laoreet quam nibh, vel mattis ligula venenatis et. Ut vel tempor eros. Suspendisse at est scelerisque, + rutrum mi non, congue enim. Ut laoreet feugiat ante non lobortis. Vivamus convallis libero condimentum + nisl pellentesque, quis feugiat nunc suscipit. Maecenas laoreet dui nec nisi placerat tincidunt. Integer + elementum, mauris eu suscipit rutrum, lectus sapien porttitor odio, a egestas velit nibh in eros. + Vestibulum eu quam eu lorem bibendum maximus. Sed ac commodo sapien. Suspendisse congue lacus id finibus + auctor. Cras eu placerat leo. Pellentesque ut elit bibendum, auctor massa eget, mollis lacus. In porttitor + semper ante, sed gravida metus porta id. In scelerisque neque non laoreet faucibus. Aenean molestie + pellentesque leo quis pharetra. Nam sit amet dictum lacus. Fusce eget condimentum justo. Integer lacus + lectus, sagittis non ultrices sit amet, hendrerit ac mi. Quisque non laoreet erat. Mauris nec nulla erat. + Suspendisse potenti. + @@ -132,36 +156,36 @@ export const DefaultOpen = { name: 'default open', } satisfies Story; -export const SideRight = { +export const DirectionRight = { args: { defaultOpen: true, - side: 'right', + direction: 'right', }, - name: 'side right', + name: 'direction right', } satisfies Story; -export const SideLeft = { +export const DirectionLeft = { args: { defaultOpen: true, - side: 'left', + direction: 'left', }, - name: 'side left', + name: 'direction left', } satisfies Story; -export const SideTop = { +export const DirectionTop = { args: { defaultOpen: true, - side: 'top', + direction: 'top', }, - name: 'side top', + name: 'direction top', } satisfies Story; -export const SideBottom = { +export const DirectionBottom = { args: { defaultOpen: true, - side: 'bottom', + direction: 'bottom', }, - name: 'side bottom', + name: 'direction bottom', } satisfies Story; export const HeaderVisible = { @@ -170,7 +194,7 @@ export const HeaderVisible = { source: { code: outdent` - + Drawer title @@ -192,7 +216,7 @@ export const HeaderVisible = { args: { defaultOpen: false, headerVisible: true, - side: 'bottom', + direction: 'bottom', width: '100%', padding: 0, }, diff --git a/packages/design-system/src/components/Drawer/Drawer.test.tsx b/packages/design-system/src/components/Drawer/Drawer.test.tsx new file mode 100644 index 000000000..f19676c8d --- /dev/null +++ b/packages/design-system/src/components/Drawer/Drawer.test.tsx @@ -0,0 +1,251 @@ +import { render, screen } from '@test/utils'; + +import { Button } from '../Button'; + +import * as Drawer from './Drawer'; + +const DrawerHeader = ({ hasClose = true, hasToggle = true }: { hasClose?: boolean; hasToggle?: boolean }) => ( + + Drawer title + +); + +const defaultProps = { + trigger: ( + + + + ), + content: ( + + + +

Drawer body content

+
+ + + + + + +
+ ), +}; + +describe('Drawer', () => { + describe('without headerVisible', () => { + it('should open the drawer when the trigger is clicked', async () => { + const { user } = render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + expect(screen.queryByRole('dialog', { name: 'Drawer title' })).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Open drawer' })); + + expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Drawer title' })).toBeInTheDocument(); + expect(screen.getByText('Drawer body content')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument(); + }); + + it('should close the drawer when the close button is clicked', async () => { + const { user } = render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Close drawer' })); + + expect(screen.queryByRole('dialog', { name: 'Drawer title' })).not.toBeInTheDocument(); + }); + + it('should close the drawer when Cancel (Drawer.Close) is clicked', async () => { + const { user } = render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(screen.queryByRole('dialog', { name: 'Drawer title' })).not.toBeInTheDocument(); + }); + + it('should be open by default when defaultOpen is true', () => { + render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); + expect(screen.getByText('Drawer body content')).toBeInTheDocument(); + }); + + it('should close the drawer when Escape is pressed', async () => { + const { user } = render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + await user.keyboard('{Escape}'); + + expect(screen.queryByRole('dialog', { name: 'Drawer title' })).not.toBeInTheDocument(); + }); + + it('should close the drawer when the overlay is clicked', async () => { + const { user } = render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); + await user.click(screen.getByTestId('drawer-overlay')); + + expect(screen.queryByRole('dialog', { name: 'Drawer title' })).not.toBeInTheDocument(); + }); + + it('should not render overlay when overlayVisible is false', () => { + render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); + expect(screen.queryByTestId('drawer-overlay')).not.toBeInTheDocument(); + }); + + it('should not display close button when hasClose is false', async () => { + render( + + + , + ); + + expect(screen.queryByRole('button', { name: 'Close drawer' })).not.toBeInTheDocument(); + }); + + it('should call onOpenChange when open state changes', async () => { + const onOpenChange = jest.fn(); + const { user } = render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + await user.click(screen.getByRole('button', { name: 'Open drawer' })); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + }); + + it('should support controlled open state', async () => { + const onOpenChange = jest.fn(); + const { user, rerender } = render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + expect(screen.queryByRole('dialog', { name: 'Drawer title' })).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Open drawer' })); + expect(onOpenChange).toHaveBeenCalledWith(true); + expect(screen.queryByRole('dialog', { name: 'Drawer title' })).not.toBeInTheDocument(); + + rerender( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + }); + + describe('with headerVisible', () => { + it('should render header without trigger when closed', () => { + render({defaultProps.content}); + + expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Drawer title' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Expand drawer' })).toBeInTheDocument(); + expect(screen.getByText('Drawer body content')).not.toBeVisible(); + }); + + it('should expand and collapse body when toggle is clicked', async () => { + const { user } = render({defaultProps.content}); + + await user.click(screen.getByRole('button', { name: 'Expand drawer' })); + expect(screen.getByRole('button', { name: 'Collapse drawer' })).toBeInTheDocument(); + expect(screen.getByText('Drawer body content')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Collapse drawer' })); + expect(screen.getByText('Drawer body content')).not.toBeVisible(); + expect(screen.getByRole('button', { name: 'Expand drawer' })).toBeInTheDocument(); + }); + + it('should close via Cancel when expanded', async () => { + const { user } = render({defaultProps.content}); + + await user.click(screen.getByRole('button', { name: 'Expand drawer' })); + await screen.findByRole('button', { name: 'Collapse drawer' }); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.getByText('Drawer body content')).not.toBeVisible(); + expect(screen.getByRole('button', { name: 'Expand drawer' })).toBeInTheDocument(); + }); + + it('should not display toggle button when hasToggle is false', async () => { + render( + + + , + ); + + expect(screen.queryByRole('button', { name: 'Expand drawer' })).not.toBeInTheDocument(); + }); + }); + + describe('accessibility', () => { + it('should have accessible dialog with title', async () => { + const { user } = render( + + {defaultProps.trigger} + {defaultProps.content} + , + ); + + await user.click(screen.getByRole('button', { name: 'Open drawer' })); + + const dialog = screen.getByRole('dialog', { name: 'Drawer title' }); + expect(dialog).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Drawer title' })).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/design-system/src/components/Drawer/Drawer.tsx b/packages/design-system/src/components/Drawer/Drawer.tsx index 81b5d8ae8..f731ff037 100644 --- a/packages/design-system/src/components/Drawer/Drawer.tsx +++ b/packages/design-system/src/components/Drawer/Drawer.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import * as Dialog from '@radix-ui/react-dialog'; import { CaretDown, Cross } from '@strapi/icons'; -import { css, CSSProperties, styled } from 'styled-components'; +import { css, CSSProperties, keyframes, styled } from 'styled-components'; import { createContext } from '../../helpers/context'; import { handleResponsiveValues, ResponsiveProperty } from '../../helpers/handleResponsiveValues'; @@ -15,7 +15,7 @@ import { ANIMATIONS } from '../../styles/motion'; import { ScrollArea, ScrollAreaProps } from '../../utilities/ScrollArea'; import { IconButton } from '../IconButton'; -export type DrawerSide = 'top' | 'right' | 'bottom' | 'left'; +export type DrawerDirection = 'top' | 'right' | 'bottom' | 'left'; /* ------------------------------------------------------------------------------------------------- * Drawer default properties @@ -57,6 +57,28 @@ const DRAWER_ANIMATIONS = { }, } as const; +const OPEN_DRAWER_ANIMATION = keyframes` + from { + max-height: 0; + opacity: 0; + } + to { + max-height: 100vh; + opacity: 1; + } +`; + +const CLOSE_DRAWER_ANIMATION = keyframes` + from { + max-height: 100vh; + opacity: 1; + } + to { + max-height: 0; + opacity: 0; + } +`; + /* ------------------------------------------------------------------------------------------------- * Drawer context (open, headerVisible) * -----------------------------------------------------------------------------------------------*/ @@ -150,7 +172,7 @@ interface ContentProps extends Omit { /** * The edge from which the drawer slides in. */ - side?: DrawerSide; + direction?: DrawerDirection; /** * Width of the drawer. */ @@ -174,7 +196,7 @@ interface ContentProps extends Omit { } const Content = React.forwardRef( - ({ side = 'right', width, maxWidth, height, maxHeight, padding = 2, children, ...props }, forwardedRef) => { + ({ direction = 'right', width, maxWidth, height, maxHeight, padding = 2, children, ...props }, forwardedRef) => { const ctx = useDrawer('Drawer.Content'); const open = ctx?.open ?? false; const headerVisible = ctx?.headerVisible ?? false; @@ -202,10 +224,10 @@ const Content = React.forwardRef( return ( - {shouldRenderOverlay && } + {shouldRenderOverlay && } ` `; interface ContentImplProps { - $side: DrawerSide; + $direction: DrawerDirection; $width?: string; $maxWidth?: string; $height?: string; @@ -261,14 +283,17 @@ const ContentContainer = styled(Dialog.Content)` position: fixed; z-index: ${(props) => props.theme.zIndices.modal}; - ${({ $side, theme, $width, $maxWidth, $height, $maxHeight, $padding, $headerVisible, $open }) => { - const animation = DRAWER_ANIMATIONS[$side]; + ${({ $direction, theme, $width, $maxWidth, $height, $maxHeight, $padding, $headerVisible, $open }) => { + const animation = DRAWER_ANIMATIONS[$direction]; const radius = $padding ? theme.borderRadius : 0; const base = css` - width: ${$width || DEFAULT_WIDTH}; + width: 100%; height: ${$height || DEFAULT_HEIGHT}; ${handleResponsiveValues({ padding: $padding }, theme)} + ${theme.breakpoints.medium} { + width: ${$width || DEFAULT_WIDTH}; + } ${!$headerVisible && css` @media (prefers-reduced-motion: no-preference) { @@ -284,7 +309,7 @@ const ContentContainer = styled(Dialog.Content)` } } `} - + ${ContentInner} { border-radius: ${radius}; @@ -296,7 +321,7 @@ const ContentContainer = styled(Dialog.Content)` `} } `; - switch ($side) { + switch ($direction) { case 'bottom': return css` bottom: 0; @@ -595,7 +620,11 @@ const Footer = React.forwardRef((props, forwardedRef if (!expandable) return content; - return {content}; + return ( + + {content} + + ); }); const Foot = styled>(Flex)` @@ -607,24 +636,14 @@ const ExpandableSection = styled.div<{ $open: boolean; $flex?: boolean }>` overflow: hidden; display: flex; flex-direction: column; - ${(props) => props.$flex && 'flex: 1; min-height: 0;'} + ${(props) => (props.$flex ? 'flex: 1; min-height: 0;' : 'flex-shrink: 0;')} max-height: ${(props) => (props.$open ? '100vh' : '0')}; - opacity: ${(props) => (props.$open ? 1 : 0)}; @media (prefers-reduced-motion: no-preference) { - transition: - max-height - ${(p) => - p.$open - ? p.theme.motion.timings[OPENING_ANIMATION_DURATION] - : p.theme.motion.timings[CLOSING_ANIMATION_DURATION]} - ${(p) => p.theme.motion.easings.authenticMotion}, - opacity - ${(p) => - p.$open - ? p.theme.motion.timings[OPENING_ANIMATION_DURATION] - : p.theme.motion.timings[CLOSING_ANIMATION_DURATION]} - ${(p) => p.theme.motion.easings.authenticMotion}; + animation: ${({ $open }) => ($open ? OPEN_DRAWER_ANIMATION : CLOSE_DRAWER_ANIMATION)} + ${({ $open, theme }) => + $open ? theme.motion.timings[CLOSING_ANIMATION_DURATION] : theme.motion.timings[OPENING_ANIMATION_DURATION]} + ${(p) => p.theme.motion.easings.authenticMotion}; } `; diff --git a/packages/design-system/src/styles/motion.ts b/packages/design-system/src/styles/motion.ts index 668a7345d..fc3cb7dfb 100644 --- a/packages/design-system/src/styles/motion.ts +++ b/packages/design-system/src/styles/motion.ts @@ -133,46 +133,6 @@ const ANIMATIONS = { transform: translateY(10px); } `, - slideLeftIn: keyframes` - from { - opacity: 0; - transform: translateX(-10px); - } - to { - opacity: 1; - transform: translateX(0); - } - `, - slideLeftOut: keyframes` - from { - opacity: 1; - transform: translateX(0); - } - to { - opacity: 0; - transform: translateX(-10px); - } - `, - slideRightIn: keyframes` - from { - opacity: 0; - transform: translateX(10px); - } - to { - opacity: 1; - transform: translateX(0); - } - `, - slideRightOut: keyframes` - from { - opacity: 1; - transform: translateX(0); - } - to { - opacity: 0; - transform: translateX(10px); - } - `, fadeIn: keyframes` from { opacity: 0; From 5d69d314c0affd85ed62b59c813c812ed5a83f43 Mon Sep 17 00:00:00 2001 From: Adrien Lepoutre Date: Thu, 29 Jan 2026 10:48:21 +0100 Subject: [PATCH 7/8] fix: prevent animation on mount --- docs/stories/04-components/Drawer.stories.tsx | 34 ++--------- .../src/components/Drawer/Drawer.tsx | 57 +++++++++++++++---- 2 files changed, 51 insertions(+), 40 deletions(-) diff --git a/docs/stories/04-components/Drawer.stories.tsx b/docs/stories/04-components/Drawer.stories.tsx index 477cc10e4..12b4906e2 100644 --- a/docs/stories/04-components/Drawer.stories.tsx +++ b/docs/stories/04-components/Drawer.stories.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Meta, StoryObj } from '@storybook/react-vite'; -import { Button, Drawer, Field, Flex, Typography } from '@strapi/design-system'; +import { Button, Drawer, Field, Flex } from '@strapi/design-system'; import { outdent } from 'outdent'; interface DrawerArgs @@ -96,34 +96,10 @@ const meta: Meta = { Drawer title - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla molestie odio dui. Cras egestas ultrices - pulvinar. Cras in pellentesque neque. Vestibulum imperdiet ex vitae felis lobortis, sed facilisis ligula - laoreet. Proin iaculis molestie felis. Curabitur ultrices turpis nec rutrum facilisis. Suspendisse in - ligula in est euismod feugiat. Proin vestibulum eros massa, et accumsan justo interdum dictum. Vestibulum - sed varius urna. Ut pellentesque fermentum sodales. Suspendisse venenatis eros vitae lorem rutrum tempus. - Suspendisse diam erat, semper gravida finibus et, tincidunt a erat. Curabitur placerat imperdiet urna, sit - amet aliquet lorem rutrum sit amet. Aliquam et molestie nisl. Quisque at nisl et eros sollicitudin blandit - bibendum ac augue. Quisque nec facilisis tortor. Nulla ex urna, scelerisque ultricies leo eget, iaculis - laoreet ipsum. Suspendisse potenti. Sed eget porttitor lectus. Nullam in ipsum vel enim facilisis gravida. - Duis posuere porttitor erat eu maximus. Morbi viverra tempus ante. Phasellus gravida ligula vel elit - lacinia, ut finibus urna interdum. In cursus rhoncus accumsan. Cras at mattis massa. Mauris tempor ipsum - quam. Integer augue eros, accumsan in ante nec, tincidunt ultrices nisl. Integer ut enim enim. Vivamus - posuere metus et nibh pharetra, at bibendum dolor porttitor. Fusce iaculis eleifend lectus, ut rutrum - sapien auctor et. In vitae risus lacus. Quisque ex mauris, venenatis vel nisi in, dictum suscipit tortor. - Aliquam non justo cursus, dictum libero eget, viverra velit. Sed velit nisi, rutrum ut tempor et, ornare - sit amet risus. Mauris ornare eleifend justo et viverra. Morbi feugiat nulla vitae sodales auctor. Donec - laoreet quam nibh, vel mattis ligula venenatis et. Ut vel tempor eros. Suspendisse at est scelerisque, - rutrum mi non, congue enim. Ut laoreet feugiat ante non lobortis. Vivamus convallis libero condimentum - nisl pellentesque, quis feugiat nunc suscipit. Maecenas laoreet dui nec nisi placerat tincidunt. Integer - elementum, mauris eu suscipit rutrum, lectus sapien porttitor odio, a egestas velit nibh in eros. - Vestibulum eu quam eu lorem bibendum maximus. Sed ac commodo sapien. Suspendisse congue lacus id finibus - auctor. Cras eu placerat leo. Pellentesque ut elit bibendum, auctor massa eget, mollis lacus. In porttitor - semper ante, sed gravida metus porta id. In scelerisque neque non laoreet faucibus. Aenean molestie - pellentesque leo quis pharetra. Nam sit amet dictum lacus. Fusce eget condimentum justo. Integer lacus - lectus, sagittis non ultrices sit amet, hendrerit ac mi. Quisque non laoreet erat. Mauris nec nulla erat. - Suspendisse potenti. - + + Example field + + diff --git a/packages/design-system/src/components/Drawer/Drawer.tsx b/packages/design-system/src/components/Drawer/Drawer.tsx index f731ff037..841097b57 100644 --- a/packages/design-system/src/components/Drawer/Drawer.tsx +++ b/packages/design-system/src/components/Drawer/Drawer.tsx @@ -88,6 +88,7 @@ interface DrawerContextValue { onOpenChange: (open: boolean) => void; headerVisible: boolean; overlayVisible: boolean; + wasFirstOpen: boolean; } const [DrawerProvider, useDrawer] = createContext('Drawer', null); @@ -127,6 +128,13 @@ const Root = React.forwardRef( [setOpen, onOpenChange], ); + // Used to skip the animation on component mount when the drawer is closed but the header is visible. + const [wasFirstOpen, setWasFirstOpen] = React.useState(false); + + React.useEffect(() => { + if (open) setWasFirstOpen(true); + }, [open]); + const dialogOpen = headerVisible ? true : open ?? false; const isOpen = open ?? false; const modal = !!(isOpen && overlayVisible); @@ -138,6 +146,7 @@ const Root = React.forwardRef( onOpenChange={handleOpenChange} headerVisible={headerVisible} overlayVisible={overlayVisible} + wasFirstOpen={wasFirstOpen} > {children} @@ -564,7 +573,7 @@ const Body = React.forwardRef(({ children, ...restProps if (!expandable) return content; return ( - + {content} ); @@ -620,11 +629,7 @@ const Footer = React.forwardRef((props, forwardedRef if (!expandable) return content; - return ( - - {content} - - ); + return {content}; }); const Foot = styled>(Flex)` @@ -632,18 +637,48 @@ const Foot = styled>(Flex)` flex-shrink: 0; `; -const ExpandableSection = styled.div<{ $open: boolean; $flex?: boolean }>` +const ExpandableSection = ({ + open, + flex = false, + children, +}: { + open: boolean; + flex?: boolean; + children: React.ReactNode; +}) => { + const drawer = useDrawer('Drawer.ExpandableSection'); + const wasFirstOpen = drawer?.wasFirstOpen ?? false; + + // Before the first opening, the animation should be skipped to prevent it from happening on component mount. + const skipAnimation = !wasFirstOpen && !open; + + return ( + + {children} + + ); +}; + +const ExpandableSectionImpl = styled.div<{ + $open: boolean; + $flex?: boolean; + $skipAnimation?: boolean; +}>` overflow: hidden; display: flex; flex-direction: column; ${(props) => (props.$flex ? 'flex: 1; min-height: 0;' : 'flex-shrink: 0;')} max-height: ${(props) => (props.$open ? '100vh' : '0')}; + opacity: ${(props) => (props.$open ? 1 : 0)}; @media (prefers-reduced-motion: no-preference) { - animation: ${({ $open }) => ($open ? OPEN_DRAWER_ANIMATION : CLOSE_DRAWER_ANIMATION)} - ${({ $open, theme }) => - $open ? theme.motion.timings[CLOSING_ANIMATION_DURATION] : theme.motion.timings[OPENING_ANIMATION_DURATION]} - ${(p) => p.theme.motion.easings.authenticMotion}; + ${({ $skipAnimation, $open, theme }) => + !$skipAnimation && + css` + animation: ${$open ? OPEN_DRAWER_ANIMATION : CLOSE_DRAWER_ANIMATION} + ${$open ? theme.motion.timings[OPENING_ANIMATION_DURATION] : theme.motion.timings[CLOSING_ANIMATION_DURATION]} + ${theme.motion.easings.authenticMotion}; + `} } `; From f2b3e71c17e97325b965259690bf3c34804c12e5 Mon Sep 17 00:00:00 2001 From: Adrien Lepoutre Date: Tue, 17 Feb 2026 15:59:32 +0100 Subject: [PATCH 8/8] fix: make portal and overlay composable elements --- docs/stories/04-components/Drawer.stories.tsx | 134 +++++++++--------- .../DesignSystemProvider.stories.tsx | 117 +++++++++++++++ .../src/components/Drawer/Drawer.test.tsx | 39 ++--- .../src/components/Drawer/Drawer.tsx | 81 ++++++----- 4 files changed, 241 insertions(+), 130 deletions(-) create mode 100644 docs/stories/05-utilities/DesignSystemProvider.stories.tsx diff --git a/docs/stories/04-components/Drawer.stories.tsx b/docs/stories/04-components/Drawer.stories.tsx index 12b4906e2..7c9d87d0d 100644 --- a/docs/stories/04-components/Drawer.stories.tsx +++ b/docs/stories/04-components/Drawer.stories.tsx @@ -9,7 +9,6 @@ interface DrawerArgs Pick, Pick { headerVisible?: boolean; - overlayVisible?: boolean; } const meta: Meta = { @@ -30,20 +29,23 @@ const meta: Meta = { - - - Drawer title - - -

Drawer content goes here.

-
- - - - - - -
+ + + + + Drawer title + + +

Drawer content goes here.

+
+ + + + + + +
+
`, }, @@ -54,7 +56,6 @@ const meta: Meta = { defaultOpen: false, direction: 'right', headerVisible: false, - overlayVisible: true, hasClose: true, hasToggle: true, }, @@ -64,50 +65,41 @@ const meta: Meta = { options: ['top', 'right', 'bottom', 'left'], }, }, - render: ({ - direction, - width, - height, - maxWidth, - maxHeight, - padding, - headerVisible, - overlayVisible, - hasClose, - hasToggle, - ...args - }) => { + render: ({ direction, width, height, maxWidth, maxHeight, padding, headerVisible, hasClose, hasToggle, ...args }) => { return ( - + {!headerVisible && ( )} - - - Drawer title - - - - Example field - - - - - - - - - - + + + + + Drawer title + + + + Example field + + + + + + + + + + + ); }, @@ -119,7 +111,8 @@ type Story = StoryObj; export const Base = { args: { - defaultOpen: false, + defaultOpen: true, + headerVisible: true, }, name: 'base', @@ -170,20 +163,23 @@ export const HeaderVisible = { source: { code: outdent` - - - Drawer title - - -

Toggle to expand and see content + overlay.

-
- - - - - - -
+ + + + + Drawer title + + +

Toggle to expand and see content + overlay.

+
+ + + + + + +
+
`, }, diff --git a/docs/stories/05-utilities/DesignSystemProvider.stories.tsx b/docs/stories/05-utilities/DesignSystemProvider.stories.tsx new file mode 100644 index 000000000..fea65c9ac --- /dev/null +++ b/docs/stories/05-utilities/DesignSystemProvider.stories.tsx @@ -0,0 +1,117 @@ +import * as React from 'react'; + +import { Meta, StoryObj } from '@storybook/react-vite'; +import { Button, Combobox, ComboboxOption, DesignSystemProvider, Typography, Box } from '@strapi/design-system'; +import { useTheme } from 'styled-components'; +import { outdent } from 'outdent'; + +const meta: Meta = { + title: 'Utilities/DesignSystemProvider/Nested Combobox (Repro)', + component: DesignSystemProvider, +}; + +export default meta; + +type Story = StoryObj; + +/** + * This story reproduces the issue described in: + * https://github.com/strapi/design-system/issues/1998 + * + * **Expected behavior:** Unmounting the nested DesignSystemProvider should only affect + * the Combobox it wraps, not the global design system context. + * + * **Bug:** When unmounting, the global Strapi Admin context breaks (styles disappear). + * + * **Note:** This bug may only manifest in Strapi Admin, not in Storybook, because + * Storybook's global provider setup might differ from Strapi Admin's. + */ +export const NestedComboboxWithLocalProvider: Story = { + render: () => { + const [visible, setVisible] = React.useState(true); + const theme = useTheme(); + + return ( + + + Global Context Check (should always work) + + + + This box uses the global DesignSystemProvider. If styles break here after + unmounting the nested provider below, the bug is reproduced. + + + + + + Nested Provider (simulates Strapi plugin) + + + + {visible && ( + + + + Nested provider active (this should unmount when clicking the button above) + + + Item 1 + Item 2 + Item 3 + + + + )} + + {!visible && ( + + + Nested provider unmounted. Check if the "Global Context Check" box above still + has proper styling. + + + )} + + ); + }, + parameters: { + docs: { + source: { + code: outdent` + import { DesignSystemProvider, Combobox, ComboboxOption } from '@strapi/design-system'; + import { useTheme } from 'styled-components'; + + const Example = () => { + const [visible, setVisible] = React.useState(true); + const theme = useTheme(); + + return ( + <> + + + {visible && ( + + + Item 1 + Item 2 + Item 3 + + + )} + + ); + }; + `, + }, + }, + }, + name: 'Nested DesignSystemProvider with Combobox', +}; + diff --git a/packages/design-system/src/components/Drawer/Drawer.test.tsx b/packages/design-system/src/components/Drawer/Drawer.test.tsx index f19676c8d..b6beef3f2 100644 --- a/packages/design-system/src/components/Drawer/Drawer.test.tsx +++ b/packages/design-system/src/components/Drawer/Drawer.test.tsx @@ -17,18 +17,21 @@ const defaultProps = { ), content: ( - - - -

Drawer body content

-
- - - - - - -
+ + + + + +

Drawer body content

+
+ + + + + + +
+
), }; @@ -120,18 +123,6 @@ describe('Drawer', () => { expect(screen.queryByRole('dialog', { name: 'Drawer title' })).not.toBeInTheDocument(); }); - it('should not render overlay when overlayVisible is false', () => { - render( - - {defaultProps.trigger} - {defaultProps.content} - , - ); - - expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeInTheDocument(); - expect(screen.queryByTestId('drawer-overlay')).not.toBeInTheDocument(); - }); - it('should not display close button when hasClose is false', async () => { render( diff --git a/packages/design-system/src/components/Drawer/Drawer.tsx b/packages/design-system/src/components/Drawer/Drawer.tsx index 841097b57..3f46d3c98 100644 --- a/packages/design-system/src/components/Drawer/Drawer.tsx +++ b/packages/design-system/src/components/Drawer/Drawer.tsx @@ -87,7 +87,6 @@ interface DrawerContextValue { open: boolean; onOpenChange: (open: boolean) => void; headerVisible: boolean; - overlayVisible: boolean; wasFirstOpen: boolean; } @@ -103,17 +102,10 @@ interface RootProps extends Dialog.DialogProps { * Toggling open shows overlay + full content body. */ headerVisible?: boolean; - /** - * When true, the overlay is never shown. - */ - overlayVisible?: boolean; } const Root = React.forwardRef( - ( - { headerVisible = false, overlayVisible = true, open: openProp, defaultOpen, onOpenChange, children, ...props }, - forwardedRef, - ) => { + ({ headerVisible = false, open: openProp, defaultOpen, onOpenChange, children, ...props }, forwardedRef) => { const [open, setOpen] = useControllableState({ prop: openProp, defaultProp: defaultOpen ?? false, @@ -137,7 +129,6 @@ const Root = React.forwardRef( const dialogOpen = headerVisible ? true : open ?? false; const isOpen = open ?? false; - const modal = !!(isOpen && overlayVisible); return (
@@ -145,10 +136,9 @@ const Root = React.forwardRef( open={isOpen} onOpenChange={handleOpenChange} headerVisible={headerVisible} - overlayVisible={overlayVisible} wasFirstOpen={wasFirstOpen} > - + {children} @@ -159,6 +149,16 @@ const Root = React.forwardRef( Root.displayName = 'Drawer.Root'; +/* ------------------------------------------------------------------------------------------------- + * Portal + * -----------------------------------------------------------------------------------------------*/ + +interface PortalProps extends Omit {} + +const Portal = (props: PortalProps) => { + return ; +}; + /* ------------------------------------------------------------------------------------------------- * Trigger * -----------------------------------------------------------------------------------------------*/ @@ -209,31 +209,8 @@ const Content = React.forwardRef( const ctx = useDrawer('Drawer.Content'); const open = ctx?.open ?? false; const headerVisible = ctx?.headerVisible ?? false; - const overlayVisible = ctx?.overlayVisible ?? true; - - const showOverlay = overlayVisible && open; - const [shouldRenderOverlay, setShouldRenderOverlay] = React.useState(showOverlay); - const overlayRef = React.useRef(null); - - // Keep overlay mounted during exit animation; unmount only after animation ends - React.useEffect(() => { - if (showOverlay) { - setShouldRenderOverlay(true); - return; - } - const el = overlayRef.current; - if (!el) { - setShouldRenderOverlay(false); - return; - } - const onEnd = () => setShouldRenderOverlay(false); - el.addEventListener('animationend', onEnd, { once: true }); - return () => el.removeEventListener('animationend', onEnd); - }, [showOverlay]); - return ( - {shouldRenderOverlay && } ( }, ); -const Overlay = styled(Dialog.Overlay)<{ $isVisible: boolean }>` +/* ------------------------------------------------------------------------------------------------- + * Overlay + * -----------------------------------------------------------------------------------------------*/ + +const Overlay = React.forwardRef((props, forwardedRef) => { + const ctx = useDrawer('Drawer.Overlay'); + const open = ctx?.open ?? false; + const [shouldRenderOverlay, setShouldRenderOverlay] = React.useState(open); + const overlayRef = React.useRef(null); + + // Keep overlay mounted during exit animation; unmount only after animation ends + React.useEffect(() => { + if (open) { + setShouldRenderOverlay(true); + return; + } + const el = overlayRef.current; + if (!el) { + setShouldRenderOverlay(false); + return; + } + const onEnd = () => setShouldRenderOverlay(false); + el.addEventListener('animationend', onEnd, { once: true }); + return () => el.removeEventListener('animationend', onEnd); + }, [open]); + + return ; +}); + +const OverlayImpl = styled(Dialog.Overlay)<{ $isVisible: boolean }>` background: ${(props) => setOpacity(props.theme.colors.neutral800, 0.2)}; position: fixed; inset: 0; @@ -684,7 +690,7 @@ const ExpandableSectionImpl = styled.div<{ type Props = RootProps; -export { Root, Trigger, Close, Content, Header, Title, Body, Footer }; +export { Root, Trigger, Close, Content, Header, Title, Body, Footer, Overlay, Portal }; export type { RootProps, Props, @@ -702,4 +708,5 @@ export type { BodyProps, FooterElement, FooterProps, + PortalProps, };