diff --git a/docs/stories/04-components/Drawer.mdx b/docs/stories/04-components/Drawer.mdx new file mode 100644 index 000000000..a758c64ec --- /dev/null +++ b/docs/stories/04-components/Drawer.mdx @@ -0,0 +1,96 @@ +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 `direction` prop (can be `left`, `right`, `top` or `bottom`). + +The width will be forced at 100% on mobile devices. + +## 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..7c9d87d0d --- /dev/null +++ b/docs/stories/04-components/Drawer.stories.tsx @@ -0,0 +1,196 @@ +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'; + +interface DrawerArgs + extends Drawer.Props, + Pick, + Pick { + headerVisible?: 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, + direction: 'right', + headerVisible: false, + hasClose: true, + hasToggle: true, + }, + argTypes: { + direction: { + control: 'select', + options: ['top', 'right', 'bottom', 'left'], + }, + }, + render: ({ direction, width, height, maxWidth, maxHeight, padding, headerVisible, hasClose, hasToggle, ...args }) => { + return ( + + {!headerVisible && ( + + + + )} + + + + + Drawer title + + + + Example field + + + + + + + + + + + + + ); + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Base = { + args: { + defaultOpen: true, + headerVisible: true, + }, + + name: 'base', +} satisfies Story; + +export const DefaultOpen = { + args: { + defaultOpen: true, + }, + name: 'default open', +} satisfies Story; + +export const DirectionRight = { + args: { + defaultOpen: true, + direction: 'right', + }, + name: 'direction right', +} satisfies Story; + +export const DirectionLeft = { + args: { + defaultOpen: true, + direction: 'left', + }, + name: 'direction left', +} satisfies Story; + +export const DirectionTop = { + args: { + defaultOpen: true, + direction: 'top', + }, + name: 'direction top', +} satisfies Story; + +export const DirectionBottom = { + args: { + defaultOpen: true, + direction: 'bottom', + }, + name: 'direction 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, + direction: 'bottom', + width: '100%', + padding: 0, + }, + name: 'header visible', +} satisfies Story; 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/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/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.test.tsx b/packages/design-system/src/components/Drawer/Drawer.test.tsx new file mode 100644 index 000000000..b6beef3f2 --- /dev/null +++ b/packages/design-system/src/components/Drawer/Drawer.test.tsx @@ -0,0 +1,242 @@ +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 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 new file mode 100644 index 000000000..3f46d3c98 --- /dev/null +++ b/packages/design-system/src/components/Drawer/Drawer.tsx @@ -0,0 +1,712 @@ +import * as React from 'react'; + +import * as Dialog from '@radix-ui/react-dialog'; +import { CaretDown, Cross } from '@strapi/icons'; +import { css, CSSProperties, keyframes, 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 DrawerDirection = 'top' | 'right' | 'bottom' | 'left'; + +/* ------------------------------------------------------------------------------------------------- + * 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 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; + +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) + * -----------------------------------------------------------------------------------------------*/ + +interface DrawerContextValue { + open: boolean; + onOpenChange: (open: boolean) => void; + headerVisible: boolean; + wasFirstOpen: boolean; +} + +const [DrawerProvider, useDrawer] = createContext('Drawer', null); + +/* ------------------------------------------------------------------------------------------------- + * 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; +} + +const Root = React.forwardRef( + ({ headerVisible = false, open: openProp, defaultOpen, onOpenChange, children, ...props }, forwardedRef) => { + const [open, setOpen] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen ?? false, + onChange: onOpenChange, + }); + + const handleOpenChange = React.useCallback( + (next: boolean) => { + setOpen(next); + onOpenChange?.(next); + }, + [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; + + return ( +
+ + + {children} + + +
+ ); + }, +); + +Root.displayName = 'Drawer.Root'; + +/* ------------------------------------------------------------------------------------------------- + * Portal + * -----------------------------------------------------------------------------------------------*/ + +interface PortalProps extends Omit {} + +const Portal = (props: PortalProps) => { + return ; +}; + +/* ------------------------------------------------------------------------------------------------- + * 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. + */ + direction?: DrawerDirection; + /** + * Width of the drawer. + */ + width?: string; + /** + * Maximum width of the drawer. + */ + maxWidth?: string; + /** + * Height of the drawer. + */ + height?: string; + /** + * Maximum height of the drawer. + */ + maxHeight?: string; + /** + * Padding of the drawer content (can be a number or an object of responsive breakpoints). + */ + padding?: ResponsiveProperty; +} + +const Content = React.forwardRef( + ({ 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; + return ( + + + {children} + + + ); + }, +); + +/* ------------------------------------------------------------------------------------------------- + * 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; + 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 { + $direction: DrawerDirection; + $width?: string; + $maxWidth?: string; + $height?: string; + $maxHeight?: string; + $headerVisible: boolean; + $open: boolean; + $padding?: ResponsiveProperty; +} + +const ContentContainer = styled(Dialog.Content)` + overflow: hidden; + display: flex; + flex-direction: column; + position: fixed; + z-index: ${(props) => props.theme.zIndices.modal}; + + ${({ $direction, theme, $width, $maxWidth, $height, $maxHeight, $padding, $headerVisible, $open }) => { + const animation = DRAWER_ANIMATIONS[$direction]; + const radius = $padding ? theme.borderRadius : 0; + const base = css` + 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) { + &[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}; + } + } + `} + + ${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 ($direction) { + case 'bottom': + return css` + bottom: 0; + left: 50%; + transform: translateX(-50%); + ${base} + max-width: ${$maxWidth || DEFAULT_MAX_WIDTH}; + max-height: ${$maxHeight || '80vh'}; + `; + case 'top': + return css` + top: 0; + left: 50%; + transform: translateX(-50%); + ${base} + max-width: ${$maxWidth || DEFAULT_MAX_WIDTH}; + max-height: ${$maxHeight || '80vh'}; + `; + case 'left': + return css` + left: 0; + bottom: 0; + ${base} + max-width: ${$maxWidth || DEFAULT_MAX_WIDTH}; + max-height: ${$maxHeight || '100vh'}; + `; + case 'right': + default: + return css` + right: 0; + bottom: 0; + ${base} + max-width: ${$maxWidth || DEFAULT_MAX_WIDTH}; + max-height: ${$maxHeight || '100vh'}; + `; + } + }} +`; + +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; + 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'> { + /** + * 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. + */ + closeLabel?: string; + /** + * 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). + */ + 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, + hasClose = true, + closeLabel = 'Close drawer', + customCloseButton, + hasToggle = true, + 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 + ? hasToggle && + (customToggleButton ?? ( + onOpenChange(!open)} + aria-expanded={open} + > + + + + + )) + : hasClose && + open && ( + + {customCloseButton ?? ( + + + + )} + + )} + + ); + }, +); + +const Head = styled>(Flex)` + flex-shrink: 0; + + & + * { + border-top: solid 1px ${(props) => props.theme.colors.neutral150}; + } +`; + +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: HORIZONTAL_INNER_PADDING, paddingRight: HORIZONTAL_INNER_PADDING }, theme)}; + + & > div { + margin: 0 -2px 0 -2px; + padding-left: 2px; + padding-right: 2px; + ${({ theme }) => + handleResponsiveValues({ paddingTop: VERTICAL_INNER_PADDING, paddingBottom: VERTICAL_INNER_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 = ({ + 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) { + ${({ $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}; + `} + } +`; + +type Props = RootProps; + +export { Root, Trigger, Close, Content, Header, Title, Body, Footer, Overlay, Portal }; +export type { + RootProps, + Props, + TriggerElement, + TriggerProps, + CloseElement, + CloseProps, + ContentProps, + ContentElement, + HeaderElement, + HeaderProps, + TitleElement, + TitleProps, + BodyElement, + BodyProps, + FooterElement, + FooterProps, + PortalProps, +}; 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..fc3cb7dfb 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); @@ -157,6 +149,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/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]}; } - `} + ` + } `; } } 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