diff --git a/apps/www/src/content/docs/components/sheet/demo.ts b/apps/www/src/content/docs/components/sheet/demo.ts index a016a0e4e..ace913371 100644 --- a/apps/www/src/content/docs/components/sheet/demo.ts +++ b/apps/www/src/content/docs/components/sheet/demo.ts @@ -5,12 +5,15 @@ import { getPropsString } from '@/lib/utils'; export const getCode = (props: any) => { return ` - + - Sheet - A simple sheet + + Sheet + A simple sheet + + Content goes here `; }; @@ -23,9 +26,9 @@ export const playground = { options: ['top', 'right', 'bottom', 'left'], defaultValue: 'right' }, - close: { + showCloseButton: { type: 'checkbox', - defaultValue: false + defaultValue: true } }, getCode @@ -35,15 +38,19 @@ export const basicDemo = { type: 'code', code: ` - - - - - Sheet Title - Sheet description goes here - Main content of the sheet - -` + + + + + + Sheet Title + Sheet description goes here + + + Main content of the sheet + + + ` }; export const positionDemo = { @@ -51,40 +58,52 @@ export const positionDemo = { code: ` - - - - - Top Sheet - Slides in from the Top - + + + + + + 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 + - - - - - - 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/components/sheet/index.mdx b/apps/www/src/content/docs/components/sheet/index.mdx index e6a6066f1..b3f842b20 100644 --- a/apps/www/src/content/docs/components/sheet/index.mdx +++ b/apps/www/src/content/docs/components/sheet/index.mdx @@ -22,13 +22,26 @@ import { Sheet } from "@raystack/apsara"; +### Sheet.Header Props + +- `children`: React.ReactNode - Content to render inside the header +- `className`: string - Additional CSS class name + ### Sheet.Title Props -- Inherits all HTML heading element props +- Inherits all Base UI Dialog.Title props ### Sheet.Description Props -- Inherits all HTML paragraph element props +- Inherits all Base UI Dialog.Description props + +### Sheet.Body Props + +- Inherits all HTML div element props + +### Sheet.Footer Props + +- Inherits all HTML div element props ## Examples @@ -41,11 +54,3 @@ import { Sheet } from "@raystack/apsara"; The Sheet can slide in from different sides of the screen. - -## Accessibility - -Sheet components are built with proper accessibility features following WAI-ARIA guidelines: - -- Uses semantic HTML elements for proper structure -- Dismiss button includes `aria-label` and `aria-description` for screen readers -- Interactive elements are keyboard accessible diff --git a/apps/www/src/content/docs/components/sheet/props.ts b/apps/www/src/content/docs/components/sheet/props.ts index 1233dc8ac..6e2a0ade9 100644 --- a/apps/www/src/content/docs/components/sheet/props.ts +++ b/apps/www/src/content/docs/components/sheet/props.ts @@ -1,9 +1,12 @@ export interface SheetProps { - /** The content to be rendered inside the sheet. */ - children: React.ReactNode; + /** Boolean to control the default open state. */ + defaultOpen?: boolean; + + /** Controlled open state. */ + open?: boolean; - /** Accessible label for the sheet trigger. */ - ariaLabel?: string; + /** Callback when open state changes. */ + onOpenChange?: (open: boolean) => void; } export interface SheetContentProps { @@ -11,14 +14,22 @@ export interface SheetContentProps { side?: 'top' | 'right' | 'bottom' | 'left'; /** Whether to show the close button. */ - close?: boolean; + showCloseButton?: boolean; - /** Accessible label for the sheet content. */ - ariaLabel?: string; + /** Props to pass to the backdrop/overlay component. */ + overlayProps?: { + className?: string; + style?: React.CSSProperties; - /** Accessible description for the sheet content. */ - ariaDescription?: string; + forceRender?: boolean; + }; /** The content to be rendered inside the sheet. */ - children: React.ReactNode; + children?: React.ReactNode; + + /** Additional CSS class name. */ + className?: string; + + /** Additional inline styles. */ + style?: React.CSSProperties; } diff --git a/packages/raystack/components/sheet/__tests__/sheet.test.tsx b/packages/raystack/components/sheet/__tests__/sheet.test.tsx index e4f62ab9a..a9b3e81df 100644 --- a/packages/raystack/components/sheet/__tests__/sheet.test.tsx +++ b/packages/raystack/components/sheet/__tests__/sheet.test.tsx @@ -1,9 +1,10 @@ +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, SheetProps } from '../sheet'; +import { Sheet } from '../sheet'; import styles from '../sheet.module.css'; const TRIGGER_TEXT = 'Open Sheet'; @@ -12,17 +13,19 @@ const SHEET_CONTENT = 'This is test sheet content'; const SHEET_DESCRIPTION = 'This is test sheet description'; const BasicSheet = ({ - canClose = false, + showCloseButton = true, ...props -}: SheetProps & { canClose?: boolean }) => ( +}: DialogPrimitive.Root.Props & { showCloseButton?: boolean }) => ( - + - - {SHEET_TITLE} - {SHEET_DESCRIPTION} - {SHEET_CONTENT} + + + {SHEET_TITLE} + {SHEET_DESCRIPTION} + + {SHEET_CONTENT} ); @@ -50,9 +53,7 @@ describe('Sheet', () => { await renderAndOpenSheet(); await waitFor(() => { - expect( - screen.getByRole('dialog', { hidden: true }) - ).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByText(SHEET_TITLE)).toBeInTheDocument(); expect(screen.getByText(SHEET_DESCRIPTION)).toBeInTheDocument(); }); @@ -62,7 +63,7 @@ describe('Sheet', () => { await renderAndOpenSheet(); await waitFor(() => { - const sheet = screen.getByRole('dialog', { hidden: true }); + const sheet = screen.getByRole('dialog'); expect(sheet.closest('body')).toBe(document.body); }); }); @@ -86,7 +87,7 @@ describe('Sheet', () => { await renderAndOpenSheet(); await waitFor(() => { - const content = screen.getByRole('dialog', { hidden: true }); + const content = screen.getByRole('dialog'); expect(content).toHaveClass(styles['sheetContent-right']); }); }); @@ -95,12 +96,14 @@ describe('Sheet', () => { const user = userEvent.setup(); render( - + - {SHEET_TITLE} - {SHEET_CONTENT} + + {SHEET_TITLE} + + {SHEET_CONTENT} ); @@ -108,7 +111,7 @@ describe('Sheet', () => { await user.click(screen.getByText(TRIGGER_TEXT)); await waitFor(() => { - const content = screen.getByRole('dialog', { hidden: true }); + const content = screen.getByRole('dialog'); expect(content).toHaveClass(styles['sheetContent-left']); }); }); @@ -117,12 +120,14 @@ describe('Sheet', () => { const user = userEvent.setup(); render( - + - {SHEET_TITLE} - {SHEET_CONTENT} + + {SHEET_TITLE} + + {SHEET_CONTENT} ); @@ -130,7 +135,7 @@ describe('Sheet', () => { await user.click(screen.getByText(TRIGGER_TEXT)); await waitFor(() => { - const content = screen.getByRole('dialog', { hidden: true }); + const content = screen.getByRole('dialog'); expect(content).toHaveClass(styles['sheetContent-top']); }); }); @@ -139,12 +144,14 @@ describe('Sheet', () => { const user = userEvent.setup(); render( - + - {SHEET_TITLE} - {SHEET_CONTENT} + + {SHEET_TITLE} + + {SHEET_CONTENT} ); @@ -152,7 +159,7 @@ describe('Sheet', () => { await user.click(screen.getByText(TRIGGER_TEXT)); await waitFor(() => { - const content = screen.getByRole('dialog', { hidden: true }); + const content = screen.getByRole('dialog'); expect(content).toHaveClass(styles['sheetContent-bottom']); }); }); @@ -161,12 +168,14 @@ describe('Sheet', () => { const user = userEvent.setup(); render( - + - {SHEET_TITLE} - {SHEET_CONTENT} + + {SHEET_TITLE} + + {SHEET_CONTENT} ); @@ -174,7 +183,7 @@ describe('Sheet', () => { await user.click(screen.getByText(TRIGGER_TEXT)); await waitFor(() => { - const content = screen.getByRole('dialog', { hidden: true }); + const content = screen.getByRole('dialog'); expect(content).toHaveClass('custom-sheet'); expect(content).toHaveClass(styles.sheetContent); }); @@ -182,26 +191,26 @@ describe('Sheet', () => { }); describe('Close Behavior', () => { - it('renders close button when close prop is true', async () => { - await renderAndOpenSheet(); + it('renders close button when showCloseButton prop is true', async () => { + await renderAndOpenSheet(); - expect(screen.getByLabelText('Close')).toBeInTheDocument(); + expect(screen.getByLabelText('Close Sheet')).toBeInTheDocument(); }); - it('does not render close button when close prop is false', async () => { - await renderAndOpenSheet(); + it('does not render close button when showCloseButton prop is false', async () => { + await renderAndOpenSheet(); await waitFor(() => { - expect(screen.queryByLabelText('Close')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Close Sheet')).not.toBeInTheDocument(); }); }); it('closes sheet when close button is clicked', async () => { const user = userEvent.setup(); - await renderAndOpenSheet(); + await renderAndOpenSheet(); - await user.click(screen.getByLabelText('Close')); + await user.click(screen.getByLabelText('Close Sheet')); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByText(SHEET_TITLE)).not.toBeInTheDocument(); @@ -244,11 +253,13 @@ describe('Sheet', () => { rerender( - {SHEET_CONTENT} + + {SHEET_CONTENT} + ); - expect(screen.getByRole('dialog', { hidden: true })).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); }); it('calls onOpenChange when state changes', async () => { @@ -259,7 +270,10 @@ describe('Sheet', () => { const trigger = screen.getByText(TRIGGER_TEXT); await user.click(trigger); - expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalled(); + // Base UI passes the open state as the first argument + const callArgs = onOpenChange.mock.calls[0]; + expect(callArgs[0]).toBe(true); }); }); @@ -274,60 +288,13 @@ describe('Sheet', () => { // }); // }); - // it('has default ARIA label', async () => { - // await renderAndOpenSheet(); - - // await waitFor(() => { - // const dialog = screen.getByRole('dialog', { hidden: true }); - // expect(dialog).toHaveAttribute('aria-label', 'Sheet with overlay'); - // }); - // }); - - it('uses custom ARIA label when provided', async () => { - const user = userEvent.setup(); - render( - - - - - - {SHEET_CONTENT} - - - ); - - await user.click(screen.getByText(TRIGGER_TEXT)); - - await waitFor(() => { - const dialog = screen.getByRole('dialog', { hidden: true }); - expect(dialog).toHaveAttribute('aria-label', 'Custom sheet label'); - }); - }); - - it('handles ARIA description when provided', async () => { - const user = userEvent.setup(); - render( - - - - - - {SHEET_CONTENT} - - - ); - - await user.click(screen.getByText(TRIGGER_TEXT)); + it('has proper ARIA attributes', async () => { + await renderAndOpenSheet(); await waitFor(() => { - const dialog = screen.getByRole('dialog', { hidden: true }); - expect(dialog).toHaveAttribute( - 'aria-describedby', - 'sheet with overlay' - ); - expect( - screen.getByText('This sheet contains important information') - ).toBeInTheDocument(); + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveAttribute('aria-label', 'Sheet'); }); }); }); diff --git a/packages/raystack/components/sheet/sheet-content.tsx b/packages/raystack/components/sheet/sheet-content.tsx new file mode 100644 index 000000000..8108ae80f --- /dev/null +++ b/packages/raystack/components/sheet/sheet-content.tsx @@ -0,0 +1,73 @@ +'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-misc.tsx b/packages/raystack/components/sheet/sheet-misc.tsx new file mode 100644 index 000000000..ff5047849 --- /dev/null +++ b/packages/raystack/components/sheet/sheet-misc.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { Dialog as DialogPrimitive } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { + type ElementRef, + forwardRef, + type HTMLAttributes, + type ReactNode +} from 'react'; +import styles from './sheet.module.css'; + +export const SheetHeader = ({ + children, + className +}: { + children: ReactNode; + className?: string; +}) =>
{children}
; +SheetHeader.displayName = 'Sheet.Header'; + +export const SheetTitle = forwardRef< + ElementRef, + DialogPrimitive.Title.Props +>(({ className, ...props }, ref) => ( + +)); +SheetTitle.displayName = 'Sheet.Title'; + +export const SheetDescription = forwardRef< + ElementRef, + DialogPrimitive.Description.Props +>(({ className, ...props }, ref) => ( + +)); +SheetDescription.displayName = 'Sheet.Description'; + +export const SheetBody = forwardRef< + HTMLDivElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +SheetBody.displayName = 'Sheet.Body'; + +export const SheetFooter = forwardRef< + HTMLDivElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +SheetFooter.displayName = 'Sheet.Footer'; diff --git a/packages/raystack/components/sheet/sheet.module.css b/packages/raystack/components/sheet/sheet.module.css index 01d826814..616607f2a 100644 --- a/packages/raystack/components/sheet/sheet.module.css +++ b/packages/raystack/components/sheet/sheet.module.css @@ -6,7 +6,7 @@ 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); @@ -16,26 +16,21 @@ outline: none; } -.sheetContent[data-state="open"] { - animation: slideIn 150ms cubic-bezier(0.22, 1, 0.36, 1); -} - -.sheetContent[data-state="closed"] { - animation: slideOut 150ms cubic-bezier(0.22, 1, 0.36, 1); +.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-state="open"] { - animation: slideInTop 150ms cubic-bezier(0.22, 1, 0.36, 1); -} - -.sheetContent-top[data-state="closed"] { - animation: slideOutTop 150ms cubic-bezier(0.22, 1, 0.36, 1); +.sheetContent-top[data-starting-style], +.sheetContent-top[data-ending-style] { + transform: translateY(-100%); } .sheetContent-bottom { @@ -43,38 +38,32 @@ height: 300px; bottom: 0; top: auto; + transition: transform 250ms cubic-bezier(0.16, 1, 0.3, 1); } -.sheetContent-bottom[data-state="open"] { - animation: slideInBottom 150ms cubic-bezier(0.22, 1, 0.36, 1); -} - -.sheetContent-bottom[data-state="closed"] { - animation: slideOutBottom 150ms cubic-bezier(0.22, 1, 0.36, 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-state="open"] { - animation: slideInRight 150ms cubic-bezier(0.22, 1, 0.36, 1); -} - -.sheetContent-right[data-state="closed"] { - animation: slideOutRight 150ms cubic-bezier(0.22, 1, 0.36, 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-state="open"] { - animation: slideInLeft 150ms cubic-bezier(0.22, 1, 0.36, 1); -} - -.sheetContent-left[data-state="closed"] { - animation: slideOutLeft 150ms cubic-bezier(0.22, 1, 0.36, 1); +.sheetContent-left[data-starting-style], +.sheetContent-left[data-ending-style] { + transform: translateX(-100%); } .overlay { @@ -82,6 +71,12 @@ 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 { @@ -108,81 +103,44 @@ outline-offset: -1px; } -@keyframes slideInRight { - from { - transform: translateX(100%); - } - to { - transform: translateX(0); - } -} - -@keyframes slideOutRight { - from { - transform: translateX(0); - } - to { - transform: translateX(100%); - } +.header { + display: flex; + flex-direction: column; + gap: var(--rs-space-2); + padding-bottom: var(--rs-space-4); } -@keyframes slideInLeft { - from { - transform: translateX(-100%); - } - to { - transform: translateX(0); - } -} - -@keyframes slideOutLeft { - from { - transform: translateX(0); - } - to { - transform: translateX(-100%); - } -} - -@keyframes slideInTop { - from { - transform: translateY(-100%); - } - to { - transform: translateY(0); - } +.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); } -@keyframes slideOutTop { - from { - transform: translateY(0); - } - to { - transform: translateY(-100%); - } +.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); } -@keyframes slideInBottom { - from { - transform: translateY(100%); - } - to { - transform: translateY(0); - } +.body { + flex: 1; + overflow-y: auto; } -@keyframes slideOutBottom { - from { - transform: translateY(0); - } - to { - transform: translateY(100%); - } +.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 { - animation: none; + transition: none; } } diff --git a/packages/raystack/components/sheet/sheet.tsx b/packages/raystack/components/sheet/sheet.tsx index 234d06112..06f0d5c25 100644 --- a/packages/raystack/components/sheet/sheet.tsx +++ b/packages/raystack/components/sheet/sheet.tsx @@ -1,129 +1,23 @@ -'use client'; - -import { Cross1Icon } from '@radix-ui/react-icons'; -import { cva, VariantProps } from 'class-variance-authority'; -import { Dialog as DialogPrimitive } from 'radix-ui'; +import { Dialog as DialogPrimitive } from '@base-ui/react'; +import { SheetContent } from './sheet-content'; import { - ComponentProps, - ComponentPropsWithoutRef, - ElementRef, - forwardRef -} from 'react'; -import { DialogDescription, DialogTitle } from '../dialog/dialog'; -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 DialogContentProps - extends ComponentPropsWithoutRef, - VariantProps { - ariaLabel?: string; - ariaDescription?: string; -} - -export const SheetContent = forwardRef< - ElementRef, - DialogContentProps & { close?: boolean; children?: React.ReactNode } ->( - ( - { className, children, close, side, ariaLabel, ariaDescription, ...props }, - forwardedRef - ) => { - return ( - - - - {children} - {close && ( - - - )} - {ariaDescription && ( -
- {ariaDescription} -
- )} -
-
-
- ); - } -); - -const overlay = cva(styles.overlay); -export interface OverlayProps - extends ComponentPropsWithoutRef, - VariantProps {} - -const Overlay = forwardRef< - ElementRef, - OverlayProps ->(({ className, ...props }, ref) => ( -