diff --git a/apps/www/src/app/examples/page.tsx b/apps/www/src/app/examples/page.tsx index be8191c57..f0cd70f19 100644 --- a/apps/www/src/app/examples/page.tsx +++ b/apps/www/src/app/examples/page.tsx @@ -9,6 +9,7 @@ import { DataTable, DatePicker, Dialog, + Drawer, EmptyState, Flex, IconButton, @@ -21,7 +22,6 @@ import { ScrollArea, Search, Select, - Sheet, Sidebar, Spinner, Text, @@ -40,7 +40,7 @@ import React, { useState } from 'react'; const Page = () => { const [dialogOpen, setDialogOpen] = useState(false); const [nestedDialogOpen, setNestedDialogOpen] = useState(false); - const [dialogSheetOpen, setDialogSheetOpen] = useState(false); + const [dialogDrawerOpen, setDialogDrawerOpen] = useState(false); const [search1, setSearch1] = useState(''); const [search2, setSearch2] = useState(''); const [search3, setSearch3] = useState(''); @@ -1536,9 +1536,9 @@ const Page = () => { }> @@ -1584,10 +1584,14 @@ const Page = () => { - - - Sheet Title - This is the sheet content. + + + Drawer Title + This is the drawer content. Team Members: @@ -1708,8 +1712,8 @@ const Page = () => { value={inputValue} onChange={e => setInputValue(e.target.value)} /> - - + + diff --git a/apps/www/src/components/playground/drawer-examples.tsx b/apps/www/src/components/playground/drawer-examples.tsx new file mode 100644 index 000000000..78339de11 --- /dev/null +++ b/apps/www/src/components/playground/drawer-examples.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { Button, Drawer, Flex } from '@raystack/apsara'; +import PlaygroundLayout from './playground-layout'; + +export function DrawerExamples() { + return ( + + + + + + + + Top Drawer + Slides in from the Top + + + + + + + + Right Drawer + Slides in from the Right + + + + + + + + Left Drawer + Slides in from the Left + + + + + + + + Bottom Drawer + Slides in from the Bottom + + + + + ); +} diff --git a/apps/www/src/components/playground/index.ts b/apps/www/src/components/playground/index.ts index a51d8388c..21a27005a 100644 --- a/apps/www/src/components/playground/index.ts +++ b/apps/www/src/components/playground/index.ts @@ -14,6 +14,7 @@ export * from './command-examples'; export * from './container-examples'; export * from './data-table-examples'; export * from './dialog-examples'; +export * from './drawer-examples'; export * from './empty-state-examples'; export * from './filter-chip-examples'; export * from './flex-examples'; @@ -31,7 +32,6 @@ export * from './radio-examples'; export * from './search-examples'; export * from './select-examples'; export * from './separator-examples'; -export * from './sheet-examples'; export * from './sidebar-examples'; export * from './skeleton-examples'; export * from './slider-examples'; diff --git a/apps/www/src/components/playground/sheet-examples.tsx b/apps/www/src/components/playground/sheet-examples.tsx deleted file mode 100644 index ccd7a392f..000000000 --- a/apps/www/src/components/playground/sheet-examples.tsx +++ /dev/null @@ -1,49 +0,0 @@ -'use client'; - -import { Button, Flex, Sheet } from '@raystack/apsara'; -import PlaygroundLayout from './playground-layout'; - -export function SheetExamples() { - return ( - - - - - - - - Top Sheet - Slides in from the Top - - - - - - - - Right Sheet - Slides in from the Right - - - - - - - - Left Sheet - Slides in from the Left - - - - - - - - Bottom Sheet - Slides in from the Bottom - - - - - ); -} diff --git a/apps/www/src/content/docs/(overview)/index.mdx b/apps/www/src/content/docs/(overview)/index.mdx index 0ab0d62b8..985877ddb 100644 --- a/apps/www/src/content/docs/(overview)/index.mdx +++ b/apps/www/src/content/docs/(overview)/index.mdx @@ -25,7 +25,7 @@ Apsara provides over 50 components organized by function: | **Navigation** | Navbar, Sidebar, Breadcrumb, Tabs, Link | | **Data Display** | DataTable, Table, List, Avatar, Badge, Chip, Indicator | | **Forms** | Button, InputField, TextArea, Select, Combobox, Checkbox, Radio, Switch, Slider, ColorPicker, Calendar | -| **Feedback** | Dialog, Sheet, Popover, Tooltip, Toast, Callout, EmptyState, Skeleton, Spinner | +| **Feedback** | Dialog, Drawer, Popover, Tooltip, Toast, Callout, EmptyState, Skeleton, Spinner | | **Utilities** | Command, Search, CopyButton, CodeBlock, ScrollArea | ## Theming diff --git a/apps/www/src/content/docs/components/drawer/demo.ts b/apps/www/src/content/docs/components/drawer/demo.ts new file mode 100644 index 000000000..fe539fafd --- /dev/null +++ b/apps/www/src/content/docs/components/drawer/demo.ts @@ -0,0 +1,109 @@ +'use client'; + +import { getPropsString } from '@/lib/utils'; + +export const getCode = (props: Record) => { + return ` + + + + + + + Drawer + A simple drawer + + Content goes here + + `; +}; + +export const playground = { + type: 'playground', + controls: { + side: { + type: 'select', + options: ['top', 'right', 'bottom', 'left'], + defaultValue: 'right' + }, + showCloseButton: { + type: 'checkbox', + defaultValue: true + } + }, + getCode +}; + +export const basicDemo = { + type: 'code', + code: ` + + + + + + + Drawer Title + Drawer description goes here + + + Main content of the drawer + + + ` +}; + +export const positionDemo = { + type: 'code', + code: ` + + + + + + + + Top Drawer + Slides in from the Top + + Content here + + + + + + + + + Right Drawer + Slides in from the Right + + Content here + + + + + + + + + Left Drawer + Slides in from the Left + + Content here + + + + + + + + + Bottom Drawer + Slides in from the Bottom + + Content here + + + ` +}; diff --git a/apps/www/src/content/docs/components/drawer/index.mdx b/apps/www/src/content/docs/components/drawer/index.mdx new file mode 100644 index 000000000..a54633317 --- /dev/null +++ b/apps/www/src/content/docs/components/drawer/index.mdx @@ -0,0 +1,83 @@ +--- +title: Drawer +description: A panel that slides in from the edge of the screen with swipe-to-dismiss gestures. +source: packages/raystack/components/drawer +--- + +import { playground, basicDemo, positionDemo } from "./demo.ts"; + + + +## Anatomy + +Import and assemble the component: + +```tsx +import { Drawer } from "@raystack/apsara"; + + + + + + + + + + + +``` + +## API Reference + +### Root + +Groups all parts of the drawer. The `side` prop determines both the slide direction and the swipe-to-dismiss direction. + + + +### Content + +Renders the drawer panel that slides in from a screen edge. + + + +### Header + +- `children`: React.ReactNode - Content to render inside the header +- `className`: string - Additional CSS class name + +### Title + +- Inherits all Base UI Drawer.Title props + +### Description + +- Inherits all Base UI Drawer.Description props + +### Body + +- Inherits all HTML div element props + +### Footer + +- Inherits all HTML div element props + +## Examples + +### Basic + + + +### Positioning + +The Drawer can slide in from different sides of the screen. Swipe-to-dismiss is automatically configured based on the `side` prop. + + + +## Accessibility + +- Uses `role="dialog"` with `aria-modal="true"` +- Focus is trapped within the drawer and restored on close +- Supports dismissal with Escape key and swipe gestures +- Title is announced via `aria-labelledby` + diff --git a/apps/www/src/content/docs/components/drawer/props.ts b/apps/www/src/content/docs/components/drawer/props.ts new file mode 100644 index 000000000..467934e15 --- /dev/null +++ b/apps/www/src/content/docs/components/drawer/props.ts @@ -0,0 +1,51 @@ +export interface DrawerProps { + /** The direction from which the drawer appears and can be swiped to dismiss. */ + side?: 'top' | 'right' | 'bottom' | 'left'; + + /** Boolean to control the default open state. */ + defaultOpen?: boolean; + + /** Controlled open state. */ + open?: boolean; + + /** Callback when open state changes. */ + onOpenChange?: (open: boolean, eventDetails: unknown) => void; + + /** Callback fired after any animations complete when the drawer is opened or closed. */ + onOpenChangeComplete?: (open: boolean) => void; + + /** + * Determines if the drawer enters a modal state when open. + * - `true`: focus is trapped, scroll is locked, outside interactions are disabled. + * - `false`: interaction with the rest of the document is allowed. + * - `'trap-focus'`: focus is trapped but scroll and outside interactions remain enabled. + * @default true + */ + modal?: boolean | 'trap-focus'; + + /** Override swipe direction (defaults to matching `side`). */ + swipeDirection?: 'up' | 'down' | 'left' | 'right'; +} + +export interface DrawerContentProps { + /** The direction from which the drawer appears. */ + side?: 'top' | 'right' | 'bottom' | 'left'; + + /** Whether to show the close button. */ + showCloseButton?: boolean; + + /** Props to pass to the backdrop/overlay component. */ + overlayProps?: { + className?: string; + style?: React.CSSProperties; + }; + + /** The content to be rendered inside the drawer. */ + children?: React.ReactNode; + + /** Additional CSS class name. */ + className?: string; + + /** Additional inline styles. */ + style?: React.CSSProperties; +} diff --git a/apps/www/src/content/docs/components/sheet/demo.ts b/apps/www/src/content/docs/components/sheet/demo.ts deleted file mode 100644 index ace913371..000000000 --- a/apps/www/src/content/docs/components/sheet/demo.ts +++ /dev/null @@ -1,109 +0,0 @@ -'use client'; - -import { getPropsString } from '@/lib/utils'; - -export const getCode = (props: any) => { - return ` - - - - - - - Sheet - A simple sheet - - Content goes here - - `; -}; - -export const playground = { - type: 'playground', - controls: { - side: { - type: 'select', - options: ['top', 'right', 'bottom', 'left'], - defaultValue: 'right' - }, - showCloseButton: { - type: 'checkbox', - defaultValue: true - } - }, - getCode -}; - -export const basicDemo = { - type: 'code', - code: ` - - - - - - - Sheet Title - Sheet description goes here - - - Main content of the sheet - - - ` -}; - -export const positionDemo = { - type: 'code', - code: ` - - - - - - - - Top Sheet - Slides in from the Top - - Content here - - - - - - - - - Right Sheet - Slides in from the Right - - Content here - - - - - - - - - Left Sheet - Slides in from the Left - - Content here - - - - - - - - - Bottom Sheet - Slides in from the Bottom - - Content here - - - ` -}; diff --git a/apps/www/src/content/docs/components/sheet/index.mdx b/apps/www/src/content/docs/components/sheet/index.mdx deleted file mode 100644 index 10ee76fab..000000000 --- a/apps/www/src/content/docs/components/sheet/index.mdx +++ /dev/null @@ -1,82 +0,0 @@ ---- -title: Sheet -description: Extends the Dialog component to display content that complements the main content of the screen. -source: packages/raystack/components/sheet ---- - -import { playground, basicDemo, positionDemo } from "./demo.ts"; - - - -## Anatomy - -Import and assemble the component: - -```tsx -import { Sheet } from "@raystack/apsara"; - - - - - - - - - - - -``` - -## API Reference - -### Root - -Groups all parts of the sheet. - - - -### Content - -Renders the sheet panel that slides in from a screen edge. - - - -### Header - -- `children`: React.ReactNode - Content to render inside the header -- `className`: string - Additional CSS class name - -### Title - -- Inherits all Base UI Dialog.Title props - -### Description - -- Inherits all Base UI Dialog.Description props - -### Body - -- Inherits all HTML div element props - -### Footer - -- Inherits all HTML div element props - -## Examples - -### Basic - - - -### Positioning - -The Sheet can slide in from different sides of the screen. - - - -## Accessibility - -- Uses `role="dialog"` with `aria-modal="true"` -- Focus is trapped within the sheet and restored on close -- Supports dismissal with Escape key -- Title is announced via `aria-labelledby` diff --git a/apps/www/src/content/docs/components/sheet/props.ts b/apps/www/src/content/docs/components/sheet/props.ts deleted file mode 100644 index 6e2a0ade9..000000000 --- a/apps/www/src/content/docs/components/sheet/props.ts +++ /dev/null @@ -1,35 +0,0 @@ -export interface SheetProps { - /** Boolean to control the default open state. */ - defaultOpen?: boolean; - - /** Controlled open state. */ - open?: boolean; - - /** Callback when open state changes. */ - onOpenChange?: (open: boolean) => void; -} - -export interface SheetContentProps { - /** The direction from which the sheet appears. */ - side?: 'top' | 'right' | 'bottom' | 'left'; - - /** Whether to show the close button. */ - showCloseButton?: boolean; - - /** Props to pass to the backdrop/overlay component. */ - overlayProps?: { - className?: string; - style?: React.CSSProperties; - - forceRender?: boolean; - }; - - /** The content to be rendered inside the sheet. */ - children?: React.ReactNode; - - /** Additional CSS class name. */ - className?: string; - - /** Additional inline styles. */ - style?: React.CSSProperties; -} diff --git a/packages/raystack/README.md b/packages/raystack/README.md index e45b6c1f0..23f8ad348 100644 --- a/packages/raystack/README.md +++ b/packages/raystack/README.md @@ -81,7 +81,7 @@ function App() { ### Overlay - `Popover` - Contextual overlays -- `Sheet` - Slide-out panels +- `Drawer` - Slide-out panels with swipe-to-dismiss - `Dialog` - Modal dialogs ## Documentation diff --git a/packages/raystack/components/drawer/__tests__/drawer.test.tsx b/packages/raystack/components/drawer/__tests__/drawer.test.tsx new file mode 100644 index 000000000..c7d575212 --- /dev/null +++ b/packages/raystack/components/drawer/__tests__/drawer.test.tsx @@ -0,0 +1,255 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { Button } from '~/components/button'; +import { Drawer } from '../drawer'; +import styles from '../drawer.module.css'; + +const TRIGGER_TEXT = 'Open Drawer'; +const DRAWER_TITLE = 'Test Drawer'; +const DRAWER_CONTENT = 'This is test drawer content'; +const DRAWER_DESCRIPTION = 'This is test drawer description'; + +const BasicDrawer = ({ + showCloseButton = true, + side = 'right' as const, + ...props +}: { + showCloseButton?: boolean; + side?: 'top' | 'right' | 'bottom' | 'left'; +} & Record) => ( + + + + + + + {DRAWER_TITLE} + {DRAWER_DESCRIPTION} + + {DRAWER_CONTENT} + + +); + +async function renderAndOpenDrawer(DrawerElement: React.ReactElement) { + fireEvent.click(render(DrawerElement).getByText(TRIGGER_TEXT)); +} + +describe('Drawer', () => { + describe('Basic Rendering', () => { + it('renders trigger button', () => { + render(); + const trigger = screen.getByText(TRIGGER_TEXT); + expect(trigger).toBeInTheDocument(); + }); + + it('does not show drawer content initially', () => { + render(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByText(DRAWER_TITLE)).not.toBeInTheDocument(); + expect(screen.queryByText(DRAWER_DESCRIPTION)).not.toBeInTheDocument(); + }); + + it('shows drawer when trigger is clicked', async () => { + await renderAndOpenDrawer(); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText(DRAWER_TITLE)).toBeInTheDocument(); + expect(screen.getByText(DRAWER_DESCRIPTION)).toBeInTheDocument(); + }); + }); + + it('renders in portal', async () => { + await renderAndOpenDrawer(); + + await waitFor(() => { + const drawer = screen.getByRole('dialog'); + expect(drawer.closest('body')).toBe(document.body); + }); + }); + }); + + describe('Overlay', () => { + it('renders backdrop', async () => { + await renderAndOpenDrawer(); + + await waitFor(() => { + const backdrop = document.querySelector(`.${styles.backdrop}`); + expect(backdrop).toBeInTheDocument(); + }); + }); + }); + + describe('Drawer Positioning', () => { + it('applies default right side positioning', async () => { + await renderAndOpenDrawer(); + + await waitFor(() => { + const popup = screen.getByRole('dialog'); + expect(popup).toHaveClass(styles['drawerPopup-right']); + }); + }); + + it('applies left side positioning', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText(TRIGGER_TEXT)); + + await waitFor(() => { + const popup = screen.getByRole('dialog'); + expect(popup).toHaveClass(styles['drawerPopup-left']); + }); + }); + + it('applies top side positioning', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText(TRIGGER_TEXT)); + + await waitFor(() => { + const popup = screen.getByRole('dialog'); + expect(popup).toHaveClass(styles['drawerPopup-top']); + }); + }); + + it('applies bottom side positioning', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText(TRIGGER_TEXT)); + + await waitFor(() => { + const popup = screen.getByRole('dialog'); + expect(popup).toHaveClass(styles['drawerPopup-bottom']); + }); + }); + + it('applies custom className', async () => { + const user = userEvent.setup(); + render( + + + + + + + {DRAWER_TITLE} + + {DRAWER_CONTENT} + + + ); + + await user.click(screen.getByText(TRIGGER_TEXT)); + + await waitFor(() => { + const popup = screen.getByRole('dialog'); + expect(popup).toHaveClass('custom-drawer'); + expect(popup).toHaveClass(styles.drawerPopup); + }); + }); + }); + + describe('Close Behavior', () => { + it('renders close button when showCloseButton prop is true', async () => { + await renderAndOpenDrawer(); + + expect(screen.getByLabelText('Close Drawer')).toBeInTheDocument(); + }); + + it('does not render close button when showCloseButton prop is false', async () => { + await renderAndOpenDrawer(); + + await waitFor(() => { + expect(screen.queryByLabelText('Close Drawer')).not.toBeInTheDocument(); + }); + }); + + it('closes drawer when close button is clicked', async () => { + const user = userEvent.setup(); + + await renderAndOpenDrawer(); + + await user.click(screen.getByLabelText('Close Drawer')); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByText(DRAWER_TITLE)).not.toBeInTheDocument(); + expect(screen.queryByText(DRAWER_DESCRIPTION)).not.toBeInTheDocument(); + }); + + it('closes on escape key', async () => { + const user = userEvent.setup(); + await renderAndOpenDrawer(); + + await user.keyboard('{Escape}'); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByText(DRAWER_TITLE)).not.toBeInTheDocument(); + expect(screen.queryByText(DRAWER_DESCRIPTION)).not.toBeInTheDocument(); + }); + + it('closes when backdrop is clicked', async () => { + const user = userEvent.setup(); + await renderAndOpenDrawer(); + + const backdrop = document.querySelector(`.${styles.backdrop}`); + await user.click(backdrop!); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByText(DRAWER_TITLE)).not.toBeInTheDocument(); + expect(screen.queryByText(DRAWER_DESCRIPTION)).not.toBeInTheDocument(); + }); + }); + + describe('Controlled Mode', () => { + it('works as controlled component', () => { + const { rerender } = render( + + {DRAWER_CONTENT} + + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + rerender( + + + {DRAWER_CONTENT} + + + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('calls onOpenChange when state changes', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render(); + + const trigger = screen.getByText(TRIGGER_TEXT); + await user.click(trigger); + + expect(onOpenChange).toHaveBeenCalled(); + const callArgs = onOpenChange.mock.calls[0]; + expect(callArgs[0]).toBe(true); + }); + }); + + describe('Accessibility', () => { + it('has proper ARIA attributes', async () => { + await renderAndOpenDrawer(); + + await waitFor(() => { + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveAttribute('aria-label', 'Drawer'); + }); + }); + }); +}); diff --git a/packages/raystack/components/drawer/drawer-content.tsx b/packages/raystack/components/drawer/drawer-content.tsx new file mode 100644 index 000000000..c316097ae --- /dev/null +++ b/packages/raystack/components/drawer/drawer-content.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { DrawerPreview as DrawerPrimitive } from '@base-ui/react/drawer'; +import { Cross1Icon } from '@radix-ui/react-icons'; +import { cva, cx, type VariantProps } from 'class-variance-authority'; +import { type ElementRef, forwardRef } from 'react'; +import styles from './drawer.module.css'; + +type Side = 'top' | 'right' | 'bottom' | 'left'; + +const drawerPopup = cva(styles.drawerPopup, { + variants: { + side: { + top: styles['drawerPopup-top'], + bottom: styles['drawerPopup-bottom'], + left: styles['drawerPopup-left'], + right: styles['drawerPopup-right'] + } + }, + defaultVariants: { + side: 'right' + } +}); + +export interface DrawerContentProps + extends Omit, + VariantProps { + showCloseButton?: boolean; + overlayProps?: DrawerPrimitive.Backdrop.Props; + children?: React.ReactNode; +} + +export const DrawerContent = forwardRef< + ElementRef, + DrawerContentProps +>( + ( + { + className, + children, + side = 'right', + showCloseButton = true, + overlayProps, + ...props + }, + ref + ) => { + return ( + + + + + + {children} + {showCloseButton && ( + + + )} + + + + + ); + } +); +DrawerContent.displayName = 'Drawer.Content'; diff --git a/packages/raystack/components/sheet/sheet-misc.tsx b/packages/raystack/components/drawer/drawer-misc.tsx similarity index 54% rename from packages/raystack/components/sheet/sheet-misc.tsx rename to packages/raystack/components/drawer/drawer-misc.tsx index ff5047849..785c4c5ec 100644 --- a/packages/raystack/components/sheet/sheet-misc.tsx +++ b/packages/raystack/components/drawer/drawer-misc.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Dialog as DialogPrimitive } from '@base-ui/react'; +import { DrawerPreview as DrawerPrimitive } from '@base-ui/react/drawer'; import { cx } from 'class-variance-authority'; import { type ElementRef, @@ -8,53 +8,53 @@ import { type HTMLAttributes, type ReactNode } from 'react'; -import styles from './sheet.module.css'; +import styles from './drawer.module.css'; -export const SheetHeader = ({ +export const DrawerHeader = ({ children, className }: { children: ReactNode; className?: string; }) =>
{children}
; -SheetHeader.displayName = 'Sheet.Header'; +DrawerHeader.displayName = 'Drawer.Header'; -export const SheetTitle = forwardRef< - ElementRef, - DialogPrimitive.Title.Props +export const DrawerTitle = forwardRef< + ElementRef, + DrawerPrimitive.Title.Props >(({ className, ...props }, ref) => ( - )); -SheetTitle.displayName = 'Sheet.Title'; +DrawerTitle.displayName = 'Drawer.Title'; -export const SheetDescription = forwardRef< - ElementRef, - DialogPrimitive.Description.Props +export const DrawerDescription = forwardRef< + ElementRef, + DrawerPrimitive.Description.Props >(({ className, ...props }, ref) => ( - )); -SheetDescription.displayName = 'Sheet.Description'; +DrawerDescription.displayName = 'Drawer.Description'; -export const SheetBody = forwardRef< +export const DrawerBody = forwardRef< HTMLDivElement, HTMLAttributes >(({ className, ...props }, ref) => (
)); -SheetBody.displayName = 'Sheet.Body'; +DrawerBody.displayName = 'Drawer.Body'; -export const SheetFooter = forwardRef< +export const DrawerFooter = forwardRef< HTMLDivElement, HTMLAttributes >(({ className, ...props }, ref) => (
)); -SheetFooter.displayName = 'Sheet.Footer'; +DrawerFooter.displayName = 'Drawer.Footer'; diff --git a/packages/raystack/components/drawer/drawer-root.tsx b/packages/raystack/components/drawer/drawer-root.tsx new file mode 100644 index 000000000..c841fa8ca --- /dev/null +++ b/packages/raystack/components/drawer/drawer-root.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { DrawerPreview as DrawerPrimitive } from '@base-ui/react/drawer'; + +type Side = 'top' | 'right' | 'bottom' | 'left'; + +const sideToSwipeDirection: Record = { + top: 'top', + right: 'right', + bottom: 'bottom', + left: 'left' +}; + +export interface DrawerRootProps extends DrawerPrimitive.Root.Props { + /** The direction from which the drawer appears and can be swiped to dismiss. */ + side?: Side; +} + +export function DrawerRoot({ + side = 'right', + swipeDirection, + ...props +}: DrawerRootProps) { + return ( + + ); +} +DrawerRoot.displayName = 'Drawer'; diff --git a/packages/raystack/components/drawer/drawer.module.css b/packages/raystack/components/drawer/drawer.module.css new file mode 100644 index 000000000..419970cfa --- /dev/null +++ b/packages/raystack/components/drawer/drawer.module.css @@ -0,0 +1,209 @@ +.backdrop { + position: fixed; + inset: 0; + min-height: 100dvh; + z-index: var(--rs-z-index-portal); + background-color: var(--rs-color-overlay-base-primary); + opacity: calc(1 - var(--drawer-swipe-progress, 0)); + transition: opacity 450ms cubic-bezier(0.32, 0.72, 0, 1); +} + +.backdrop[data-starting-style], +.backdrop[data-ending-style] { + opacity: 0; +} + +.backdrop[data-swiping] { + transition-duration: 0ms; +} + +.backdrop[data-ending-style] { + transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms); +} + +.viewport { + position: fixed; + inset: 0; + display: flex; + z-index: var(--rs-z-index-portal); +} + +/* Right drawer: justify to the right */ +.viewport:has(.drawerPopup-right) { + justify-content: flex-end; + align-items: stretch; +} + +/* Left drawer: justify to the left */ +.viewport:has(.drawerPopup-left) { + justify-content: flex-start; + align-items: stretch; +} + +/* Top drawer: align to the top */ +.viewport:has(.drawerPopup-top) { + align-items: flex-start; +} + +/* Bottom drawer: align to the bottom */ +.viewport:has(.drawerPopup-bottom) { + align-items: flex-end; +} + +.drawerPopup { + box-sizing: border-box; + padding: var(--rs-space-3); + background-color: var(--rs-color-background-base-primary); + color: var(--rs-color-foreground-base-primary); + overflow-y: auto; + overscroll-behavior: contain; + touch-action: auto; + will-change: transform; +} + +.drawerPopup:focus { + outline: none; +} + +.drawerPopup[data-swiping] { + user-select: none; +} + +/* Right side drawer */ +.drawerPopup-right { + width: 250px; + height: 100%; + transition: transform 450ms cubic-bezier(0.32, 0.72, 0, 1); + transform: translateX(var(--drawer-swipe-movement-x)); +} + +.drawerPopup-right[data-starting-style], +.drawerPopup-right[data-ending-style] { + transform: translateX(100%); +} + +.drawerPopup-right[data-ending-style] { + transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms); +} + +/* Left side drawer */ +.drawerPopup-left { + width: 250px; + height: 100%; + transition: transform 450ms cubic-bezier(0.32, 0.72, 0, 1); + transform: translateX(var(--drawer-swipe-movement-x)); +} + +.drawerPopup-left[data-starting-style], +.drawerPopup-left[data-ending-style] { + transform: translateX(-100%); +} + +.drawerPopup-left[data-ending-style] { + transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms); +} + +/* Top side drawer */ +.drawerPopup-top { + width: 100%; + height: 300px; + transition: transform 450ms cubic-bezier(0.32, 0.72, 0, 1); + transform: translateY(var(--drawer-swipe-movement-y)); +} + +.drawerPopup-top[data-starting-style], +.drawerPopup-top[data-ending-style] { + transform: translateY(-100%); +} + +.drawerPopup-top[data-ending-style] { + transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms); +} + +/* Bottom side drawer */ +.drawerPopup-bottom { + width: 100%; + height: 300px; + transition: transform 450ms cubic-bezier(0.32, 0.72, 0, 1); + transform: translateY(var(--drawer-swipe-movement-y)); +} + +.drawerPopup-bottom[data-starting-style], +.drawerPopup-bottom[data-ending-style] { + transform: translateY(100%); +} + +.drawerPopup-bottom[data-ending-style] { + transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms); +} + +.content { + width: 100%; + height: 100%; +} + +.close { + all: unset; + position: absolute; + top: var(--rs-space-3); + right: var(--rs-space-3); + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--rs-radius-2); + color: var(--rs-color-foreground-base-secondary); + padding: var(--rs-space-2); + cursor: pointer; +} + +.close:hover { + background-color: var(--rs-color-background-base-primary-hover); + color: var(--rs-color-foreground-base-primary); +} + +.close:focus-visible { + outline: 2px solid var(--rs-color-border-base-focus); + outline-offset: -1px; +} + +.header { + display: flex; + flex-direction: column; + gap: var(--rs-space-2); + padding-bottom: var(--rs-space-4); +} + +.title { + font-size: var(--rs-font-size-large); + font-weight: var(--rs-font-weight-semibold); + line-height: var(--rs-line-height-large); + letter-spacing: var(--rs-letter-spacing-large); + color: var(--rs-color-foreground-base-primary); +} + +.description { + font-size: var(--rs-font-size-regular); + line-height: var(--rs-line-height-regular); + letter-spacing: var(--rs-letter-spacing-regular); + color: var(--rs-color-foreground-base-secondary); +} + +.body { + flex: 1; + overflow-y: auto; +} + +.footer { + display: flex; + justify-content: flex-end; + gap: var(--rs-space-3); + padding-top: var(--rs-space-4); + border-top: 1px solid var(--rs-color-border-base-primary); +} + +@media (prefers-reduced-motion: reduce) { + .drawerPopup, + .backdrop { + transition: none; + } +} diff --git a/packages/raystack/components/drawer/drawer.tsx b/packages/raystack/components/drawer/drawer.tsx new file mode 100644 index 000000000..2f0439d0a --- /dev/null +++ b/packages/raystack/components/drawer/drawer.tsx @@ -0,0 +1,24 @@ +import { DrawerPreview as DrawerPrimitive } from '@base-ui/react/drawer'; +import { DrawerContent } from './drawer-content'; +import { + DrawerBody, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle +} from './drawer-misc'; +import { DrawerRoot } from './drawer-root'; + +export type { DrawerContentProps } from './drawer-content'; +export type { DrawerRootProps } from './drawer-root'; + +export const Drawer = Object.assign(DrawerRoot, { + Trigger: DrawerPrimitive.Trigger, + Content: DrawerContent, + Header: DrawerHeader, + Title: DrawerTitle, + Description: DrawerDescription, + Body: DrawerBody, + Footer: DrawerFooter, + Close: DrawerPrimitive.Close +}); diff --git a/packages/raystack/components/drawer/index.tsx b/packages/raystack/components/drawer/index.tsx new file mode 100644 index 000000000..8827142e8 --- /dev/null +++ b/packages/raystack/components/drawer/index.tsx @@ -0,0 +1 @@ +export { Drawer } from './drawer'; diff --git a/packages/raystack/components/sheet/__tests__/sheet.test.tsx b/packages/raystack/components/sheet/__tests__/sheet.test.tsx deleted file mode 100644 index f72c23035..000000000 --- a/packages/raystack/components/sheet/__tests__/sheet.test.tsx +++ /dev/null @@ -1,291 +0,0 @@ -import { Dialog as DialogPrimitive } from '@base-ui/react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import { Button } from '~/components/button'; -import { Sheet } from '../sheet'; -import styles from '../sheet.module.css'; - -const TRIGGER_TEXT = 'Open Sheet'; -const SHEET_TITLE = 'Test Sheet'; -const SHEET_CONTENT = 'This is test sheet content'; -const SHEET_DESCRIPTION = 'This is test sheet description'; - -const BasicSheet = ({ - showCloseButton = true, - ...props -}: DialogPrimitive.Root.Props & { showCloseButton?: boolean }) => ( - - - - - - - {SHEET_TITLE} - {SHEET_DESCRIPTION} - - {SHEET_CONTENT} - - -); - -async function renderAndOpenSheet(SheetElement: React.ReactElement) { - fireEvent.click(render(SheetElement).getByText(TRIGGER_TEXT)); -} - -describe('Sheet', () => { - describe('Basic Rendering', () => { - it('renders trigger button', () => { - render(); - const trigger = screen.getByText(TRIGGER_TEXT); - expect(trigger).toBeInTheDocument(); - }); - - it('does not show sheet content initially', () => { - render(); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(screen.queryByText(SHEET_TITLE)).not.toBeInTheDocument(); - expect(screen.queryByText(SHEET_DESCRIPTION)).not.toBeInTheDocument(); - }); - - it('shows sheet when trigger is clicked', async () => { - await renderAndOpenSheet(); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - expect(screen.getByText(SHEET_TITLE)).toBeInTheDocument(); - expect(screen.getByText(SHEET_DESCRIPTION)).toBeInTheDocument(); - }); - }); - - it('renders in portal', async () => { - await renderAndOpenSheet(); - - await waitFor(() => { - const sheet = screen.getByRole('dialog'); - expect(sheet.closest('body')).toBe(document.body); - }); - }); - }); - - describe('Overlay', () => { - it('renders overlay', async () => { - await renderAndOpenSheet(); - - await waitFor(() => { - const overlay = document.querySelector(`.${styles.overlay}`); - expect(overlay).toBeInTheDocument(); - expect(overlay).toHaveAttribute('aria-hidden', 'true'); - expect(overlay).toHaveAttribute('role', 'presentation'); - }); - }); - }); - - describe('Sheet Positioning', () => { - it('applies default right side positioning', async () => { - await renderAndOpenSheet(); - - await waitFor(() => { - const content = screen.getByRole('dialog'); - expect(content).toHaveClass(styles['sheetContent-right']); - }); - }); - - it('applies left side positioning', async () => { - const user = userEvent.setup(); - render( - - - - - - - {SHEET_TITLE} - - {SHEET_CONTENT} - - - ); - - await user.click(screen.getByText(TRIGGER_TEXT)); - - await waitFor(() => { - const content = screen.getByRole('dialog'); - expect(content).toHaveClass(styles['sheetContent-left']); - }); - }); - - it('applies top side positioning', async () => { - const user = userEvent.setup(); - render( - - - - - - - {SHEET_TITLE} - - {SHEET_CONTENT} - - - ); - - await user.click(screen.getByText(TRIGGER_TEXT)); - - await waitFor(() => { - const content = screen.getByRole('dialog'); - expect(content).toHaveClass(styles['sheetContent-top']); - }); - }); - - it('applies bottom side positioning', async () => { - const user = userEvent.setup(); - render( - - - - - - - {SHEET_TITLE} - - {SHEET_CONTENT} - - - ); - - await user.click(screen.getByText(TRIGGER_TEXT)); - - await waitFor(() => { - const content = screen.getByRole('dialog'); - expect(content).toHaveClass(styles['sheetContent-bottom']); - }); - }); - - it('applies custom className', async () => { - const user = userEvent.setup(); - render( - - - - - - - {SHEET_TITLE} - - {SHEET_CONTENT} - - - ); - - await user.click(screen.getByText(TRIGGER_TEXT)); - - await waitFor(() => { - const content = screen.getByRole('dialog'); - expect(content).toHaveClass('custom-sheet'); - expect(content).toHaveClass(styles.sheetContent); - }); - }); - }); - - describe('Close Behavior', () => { - it('renders close button when showCloseButton prop is true', async () => { - await renderAndOpenSheet(); - - expect(screen.getByLabelText('Close Sheet')).toBeInTheDocument(); - }); - - it('does not render close button when showCloseButton prop is false', async () => { - await renderAndOpenSheet(); - - await waitFor(() => { - expect(screen.queryByLabelText('Close Sheet')).not.toBeInTheDocument(); - }); - }); - - it('closes sheet when close button is clicked', async () => { - const user = userEvent.setup(); - - await renderAndOpenSheet(); - - await user.click(screen.getByLabelText('Close Sheet')); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(screen.queryByText(SHEET_TITLE)).not.toBeInTheDocument(); - expect(screen.queryByText(SHEET_DESCRIPTION)).not.toBeInTheDocument(); - }); - - it('closes on escape key', async () => { - const user = userEvent.setup(); - await renderAndOpenSheet(); - - await user.keyboard('{Escape}'); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(screen.queryByText(SHEET_TITLE)).not.toBeInTheDocument(); - expect(screen.queryByText(SHEET_DESCRIPTION)).not.toBeInTheDocument(); - }); - - it('closes when overlay is clicked', async () => { - const user = userEvent.setup(); - await renderAndOpenSheet(); - - const overlay = document.querySelector(`.${styles.overlay}`); - await user.click(overlay!); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(screen.queryByText(SHEET_TITLE)).not.toBeInTheDocument(); - expect(screen.queryByText(SHEET_DESCRIPTION)).not.toBeInTheDocument(); - }); - }); - - describe('Controlled Mode', () => { - it('works as controlled component', () => { - const { rerender } = render( - - {SHEET_CONTENT} - - ); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - - rerender( - - - {SHEET_CONTENT} - - - ); - - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - it('calls onOpenChange when state changes', async () => { - const user = userEvent.setup(); - const onOpenChange = vi.fn(); - render(); - - const trigger = screen.getByText(TRIGGER_TEXT); - await user.click(trigger); - - expect(onOpenChange).toHaveBeenCalled(); - // Base UI passes the open state as the first argument - const callArgs = onOpenChange.mock.calls[0]; - expect(callArgs[0]).toBe(true); - }); - }); - - describe('Accessibility', () => { - it('has proper ARIA attributes', async () => { - await renderAndOpenSheet(); - - await waitFor(() => { - const dialog = screen.getByRole('dialog'); - expect(dialog).toBeInTheDocument(); - expect(dialog).toHaveAttribute('aria-label', 'Sheet'); - }); - }); - }); -}); diff --git a/packages/raystack/components/sheet/index.tsx b/packages/raystack/components/sheet/index.tsx deleted file mode 100644 index 7535f14a6..000000000 --- a/packages/raystack/components/sheet/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { Sheet } from './sheet'; \ No newline at end of file diff --git a/packages/raystack/components/sheet/sheet-content.tsx b/packages/raystack/components/sheet/sheet-content.tsx deleted file mode 100644 index 8108ae80f..000000000 --- a/packages/raystack/components/sheet/sheet-content.tsx +++ /dev/null @@ -1,73 +0,0 @@ -'use client'; - -import { Dialog as DialogPrimitive } from '@base-ui/react'; -import { Cross1Icon } from '@radix-ui/react-icons'; -import { cva, cx, type VariantProps } from 'class-variance-authority'; -import { type ElementRef, forwardRef } from 'react'; -import styles from './sheet.module.css'; - -const sheetContent = cva(styles.sheetContent, { - variants: { - side: { - top: styles['sheetContent-top'], - bottom: styles['sheetContent-bottom'], - left: styles['sheetContent-left'], - right: styles['sheetContent-right'] - } - }, - defaultVariants: { - side: 'right' - } -}); - -export interface SheetContentProps - extends DialogPrimitive.Popup.Props, - VariantProps { - showCloseButton?: boolean; - overlayProps?: DialogPrimitive.Backdrop.Props; -} - -export const SheetContent = forwardRef< - ElementRef, - SheetContentProps ->( - ( - { - className, - children, - side = 'right', - showCloseButton = true, - overlayProps, - ...props - }, - ref - ) => { - return ( - - - - - {children} - {showCloseButton && ( - - - )} - - - - ); - } -); -SheetContent.displayName = 'Sheet.Content'; diff --git a/packages/raystack/components/sheet/sheet.module.css b/packages/raystack/components/sheet/sheet.module.css deleted file mode 100644 index 616607f2a..000000000 --- a/packages/raystack/components/sheet/sheet.module.css +++ /dev/null @@ -1,146 +0,0 @@ -.sheetContent { - position: fixed; - top: 0; - bottom: 0; - width: 250px; - padding: var(--rs-space-3); - z-index: var(--rs-z-index-portal); - will-change: transform; - transition: transform 250ms cubic-bezier(0.16, 1, 0.3, 1); - background-color: var(--rs-color-background-base-primary); - /* border: 1px solid var(--rs-color-border-base-primary); */ - color: var(--rs-color-foreground-base-primary); -} - -.sheetContent:focus { - outline: none; -} - -.sheetContent[data-starting-style], -.sheetContent[data-ending-style] { - transform: translateX(100%); -} - -.sheetContent-top { - width: 100%; - height: 300px; - bottom: auto; - transition: transform 250ms cubic-bezier(0.16, 1, 0.3, 1); -} - -.sheetContent-top[data-starting-style], -.sheetContent-top[data-ending-style] { - transform: translateY(-100%); -} - -.sheetContent-bottom { - width: 100%; - height: 300px; - bottom: 0; - top: auto; - transition: transform 250ms cubic-bezier(0.16, 1, 0.3, 1); -} - -.sheetContent-bottom[data-starting-style], -.sheetContent-bottom[data-ending-style] { - transform: translateY(100%); -} - -.sheetContent-right { - right: 0; - transition: transform 250ms cubic-bezier(0.16, 1, 0.3, 1); -} - -.sheetContent-right[data-starting-style], -.sheetContent-right[data-ending-style] { - transform: translateX(100%); -} - -.sheetContent-left { - left: 0; - transition: transform 250ms cubic-bezier(0.16, 1, 0.3, 1); -} - -.sheetContent-left[data-starting-style], -.sheetContent-left[data-ending-style] { - transform: translateX(-100%); -} - -.overlay { - position: fixed; - inset: 0; - z-index: var(--rs-z-index-portal); - background-color: var(--rs-color-overlay-base-primary); - transition: opacity 150ms cubic-bezier(0.16, 1, 0.3, 1); -} - -.overlay[data-starting-style], -.overlay[data-ending-style] { - opacity: 0; -} - -.close { - all: unset; - position: absolute; - top: var(--rs-space-3); - right: var(--rs-space-3); - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: var(--rs-radius-2); - color: var(--rs-color-foreground-base-secondary); - padding: var(--rs-space-2); - cursor: pointer; -} - -.close:hover { - background-color: var(--rs-color-background-base-primary-hover); - color: var(--rs-color-foreground-base-primary); -} - -.close:focus-visible { - outline: 2px solid var(--rs-color-border-base-focus); - outline-offset: -1px; -} - -.header { - display: flex; - flex-direction: column; - gap: var(--rs-space-2); - padding-bottom: var(--rs-space-4); -} - -.title { - font-size: var(--rs-font-size-large); - font-weight: var(--rs-font-weight-semibold); - line-height: var(--rs-line-height-large); - letter-spacing: var(--rs-letter-spacing-large); - color: var(--rs-color-foreground-base-primary); -} - -.description { - font-size: var(--rs-font-size-regular); - line-height: var(--rs-line-height-regular); - letter-spacing: var(--rs-letter-spacing-regular); - color: var(--rs-color-foreground-base-secondary); -} - -.body { - flex: 1; - overflow-y: auto; -} - -.footer { - display: flex; - justify-content: flex-end; - gap: var(--rs-space-3); - padding-top: var(--rs-space-4); - border-top: 1px solid var(--rs-color-border-base-primary); -} - -@media (prefers-reduced-motion: reduce) { - .sheetContent, - .overlay { - transition: none; - } -} diff --git a/packages/raystack/components/sheet/sheet.tsx b/packages/raystack/components/sheet/sheet.tsx deleted file mode 100644 index 6a0e19893..000000000 --- a/packages/raystack/components/sheet/sheet.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Dialog as DialogPrimitive } from '@base-ui/react'; -import { SheetContent } from './sheet-content'; -import { - SheetBody, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle -} from './sheet-misc'; - -export type { SheetContentProps } from './sheet-content'; - -export const Sheet = Object.assign(DialogPrimitive.Root, { - Trigger: DialogPrimitive.Trigger, - Content: SheetContent, - Header: SheetHeader, - Title: SheetTitle, - Description: SheetDescription, - Body: SheetBody, - Footer: SheetFooter, - Close: DialogPrimitive.Close -}); diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 20696b59e..49b75cd0a 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -28,6 +28,7 @@ export { useDataTable } from './components/data-table'; export { Dialog } from './components/dialog'; +export { Drawer } from './components/drawer'; export { EmptyState } from './components/empty-state'; export { FilterChip } from './components/filter-chip'; export { Flex } from './components/flex'; @@ -48,7 +49,6 @@ export { ScrollArea } from './components/scroll-area'; export { Search } from './components/search'; export { Select } from './components/select'; export { Separator } from './components/separator'; -export { Sheet } from './components/sheet'; export { SidePanel } from './components/side-panel'; export { Sidebar } from './components/sidebar'; export { Skeleton } from './components/skeleton'; diff --git a/packages/raystack/package.json b/packages/raystack/package.json index 35527459d..9548bd01a 100644 --- a/packages/raystack/package.json +++ b/packages/raystack/package.json @@ -114,7 +114,7 @@ }, "dependencies": { "@ariakit/react": "^0.4.16", - "@base-ui/react": "^1.1.0", + "@base-ui/react": "^1.2.0", "@radix-ui/react-icons": "^1.3.2", "@tanstack/match-sorter-utils": "^8.8.4", "@tanstack/react-table": "^8.9.2", diff --git a/packages/raystack/vitest.setup.ts b/packages/raystack/vitest.setup.ts index 78bfba3eb..c95c49f8a 100644 --- a/packages/raystack/vitest.setup.ts +++ b/packages/raystack/vitest.setup.ts @@ -6,3 +6,32 @@ global.ResizeObserver = class ResizeObserver { unobserve() {} disconnect() {} }; + +// Polyfill PointerEvent for tests (required by @base-ui/react) +if (typeof global.PointerEvent === 'undefined') { + class PointerEvent extends MouseEvent { + readonly pointerId: number; + readonly width: number; + readonly height: number; + readonly pressure: number; + readonly tiltX: number; + readonly tiltY: number; + readonly pointerType: string; + readonly isPrimary: boolean; + + constructor(type: string, params: PointerEventInit = {}) { + super(type, params); + this.pointerId = params.pointerId ?? 0; + this.width = params.width ?? 1; + this.height = params.height ?? 1; + this.pressure = params.pressure ?? 0; + this.tiltX = params.tiltX ?? 0; + this.tiltY = params.tiltY ?? 0; + this.pointerType = params.pointerType ?? ''; + this.isPrimary = params.isPrimary ?? false; + } + } + + global.PointerEvent = + PointerEvent as unknown as typeof globalThis.PointerEvent; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e952ef018..261a5c9b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,8 +176,8 @@ importers: specifier: ^0.4.16 version: 0.4.16(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@base-ui/react': - specifier: ^1.1.0 - version: 1.1.0(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + specifier: ^1.2.0 + version: 1.2.0(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@radix-ui/react-icons': specifier: ^1.3.2 version: 1.3.2(react@19.2.1) @@ -1000,8 +1000,8 @@ packages: resolution: {integrity: sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==} engines: {node: '>=6.9.0'} - '@base-ui/react@1.1.0': - resolution: {integrity: sha512-ikcJRNj1mOiF2HZ5jQHrXoVoHcNHdBU5ejJljcBl+VTLoYXR6FidjTN86GjO6hyshi6TZFuNvv0dEOgaOFv6Lw==} + '@base-ui/react@1.2.0': + resolution: {integrity: sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw==} engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17 || ^18 || ^19 @@ -1011,8 +1011,8 @@ packages: '@types/react': optional: true - '@base-ui/utils@0.2.4': - resolution: {integrity: sha512-smZwpMhjO29v+jrZusBSc5T+IJ3vBb9cjIiBjtKcvWmRj9Z4DWGVR3efr1eHR56/bqY5a4qyY9ElkOY5ljo3ng==} + '@base-ui/utils@0.2.5': + resolution: {integrity: sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw==} peerDependencies: '@types/react': ^17 || ^18 || ^19 react: ^17 || ^18 || ^19 @@ -10222,21 +10222,20 @@ snapshots: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 - '@base-ui/react@1.1.0(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@base-ui/react@1.2.0(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@babel/runtime': 7.28.6 - '@base-ui/utils': 0.2.4(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@base-ui/utils': 0.2.5(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@floating-ui/react-dom': 2.1.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@floating-ui/utils': 0.2.10 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - reselect: 5.1.1 tabbable: 6.4.0 use-sync-external-store: 1.6.0(react@19.2.1) optionalDependencies: '@types/react': 19.1.9 - '@base-ui/utils@0.2.4(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@base-ui/utils@0.2.5(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@babel/runtime': 7.28.6 '@floating-ui/utils': 0.2.10