Sheet
Side sheet overlay for Tile UI.
packages/react/src/components/sheet/sheet.tsx
import React, { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Slot } from '@radix-ui/react-slot';
import { getSheetState, getSheetTranslateStyle, sheetStyleKeys } from '../lib/core';
import type { SheetBaseProps, SheetSide } from '../lib/core';
import { usePortalContainer, type PortalContainer } from '../portal';
import styles from './sheet.module.scss';
interface SheetContextValue {
open: boolean;
setOpen: (open: boolean) => void;
close: () => void;
titleId: string;
descriptionId: string;
}
const SheetContext = createContext<SheetContextValue | null>(null);
function useSheetContext(): SheetContextValue {
const context = useContext(SheetContext);
if (!context) {
throw new Error('Sheet sub-components must be used within <Sheet>.');
}
return context;
}
export interface SheetProps extends SheetBaseProps {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
}
function Sheet({ open, defaultOpen = false, onOpenChange, children }: SheetProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const onOpenChangeRef = useRef(onOpenChange);
useEffect(() => {
onOpenChangeRef.current = onOpenChange;
});
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) {
setInternalOpen(next);
}
onOpenChangeRef.current?.(next);
},
[open],
);
const close = useCallback(() => {
setOpen(false);
}, [setOpen]);
const isOpen = open !== undefined ? open : internalOpen;
const titleId = useId();
const descriptionId = useId();
return <SheetContext.Provider value={{ open: isOpen, setOpen, close, titleId, descriptionId }}>{children}</SheetContext.Provider>;
}
Sheet.displayName = 'Sheet';
export interface SheetTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
}
const SheetTrigger = React.forwardRef<HTMLButtonElement, SheetTriggerProps>(({ asChild = false, className = '', children, onClick, ...props }, ref) => {
const context = useSheetContext();
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
type={asChild ? undefined : 'button'}
className={className}
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
context.setOpen(true);
}}
{...props}>
{children}
</Comp>
);
});
SheetTrigger.displayName = 'SheetTrigger';
export interface SheetCloseProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
}
const SheetClose = React.forwardRef<HTMLButtonElement, SheetCloseProps>(({ asChild = false, className = '', children, onClick, ...props }, ref) => {
const context = useSheetContext();
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
type={asChild ? undefined : 'button'}
className={className}
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
context.close();
}}
{...props}>
{children}
</Comp>
);
});
SheetClose.displayName = 'SheetClose';
export interface SheetOverlayProps extends React.HTMLAttributes<HTMLDivElement> {}
const SheetOverlay = React.forwardRef<HTMLDivElement, SheetOverlayProps>(({ className = '', onClick, ...props }, ref) => {
const context = useSheetContext();
if (!context.open) {
return null;
}
return (
<div
ref={ref}
data-state={getSheetState(context.open)}
className={`${styles[sheetStyleKeys.overlay]} ${className}`}
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
onClick?.(event);
context.close();
}}
{...props}
/>
);
});
SheetOverlay.displayName = 'SheetOverlay';
export interface SheetContentProps extends React.HTMLAttributes<HTMLDivElement> {
side?: SheetSide;
showCloseButton?: boolean;
container?: PortalContainer;
}
const SheetContent = React.forwardRef<HTMLDivElement, SheetContentProps>(
({ className = '', side = 'right', showCloseButton = true, container, style, children, ...props }, ref) => {
const { open, close, titleId, descriptionId } = useSheetContext();
const portalContainer = usePortalContainer(container);
const contentRef = useRef<HTMLDivElement | null>(null);
const [isVisible, setIsVisible] = useState(false);
function setContentRef(element: HTMLDivElement | null) {
contentRef.current = element;
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
ref.current = element;
}
}
useLayoutEffect(() => {
if (!open) {
return;
}
const previouslyFocused = document.activeElement as HTMLElement | null;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close();
return;
}
if (event.key === 'Tab') {
const container = contentRef.current;
if (!container) {
return;
}
const focusables = Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
).filter((el) => el.getClientRects().length > 0);
if (focusables.length === 0) {
event.preventDefault();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (event.shiftKey) {
if (active === first || active === container || !container.contains(active)) {
event.preventDefault();
last.focus();
}
} else if (active === last || !container.contains(active)) {
event.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
const contentEl = contentRef.current;
if (contentEl) {
contentEl.focus();
}
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
previouslyFocused?.focus();
};
}, [close, open]);
useEffect(() => {
if (!open) {
return;
}
const frame = requestAnimationFrame(() => {
setIsVisible(true);
});
return () => cancelAnimationFrame(frame);
}, [open]);
if (!open || !portalContainer) {
return null;
}
return createPortal(
<>
<SheetOverlay />
<div
ref={setContentRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
tabIndex={-1}
data-state={getSheetState(open)}
data-side={side}
style={{ transform: isVisible ? '' : getSheetTranslateStyle(side), opacity: isVisible ? '' : 0, ...style }}
className={`${styles[sheetStyleKeys.content]} ${className}`}
{...props}>
{children}
{showCloseButton && (
<button type="button" aria-label="关闭" className={styles[sheetStyleKeys.close]} onClick={() => close()}>
<svg
className={styles[sheetStyleKeys.xIcon]}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
)}
</div>
</>,
portalContainer,
);
},
);
SheetContent.displayName = 'SheetContent';
export interface SheetHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
const SheetHeader = React.forwardRef<HTMLDivElement, SheetHeaderProps>(({ className = '', children, ...props }, ref) => {
return (
<div ref={ref} className={`${styles[sheetStyleKeys.header]} ${className}`} {...props}>
{children}
</div>
);
});
SheetHeader.displayName = 'SheetHeader';
export interface SheetFooterProps extends React.HTMLAttributes<HTMLDivElement> {}
const SheetFooter = React.forwardRef<HTMLDivElement, SheetFooterProps>(({ className = '', children, ...props }, ref) => {
return (
<div ref={ref} className={`${styles[sheetStyleKeys.footer]} ${className}`} {...props}>
{children}
</div>
);
});
SheetFooter.displayName = 'SheetFooter';
export interface SheetTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
const SheetTitle = React.forwardRef<HTMLHeadingElement, SheetTitleProps>(({ className = '', children, ...props }, ref) => {
const context = useSheetContext();
return (
<h2 ref={ref} id={context.titleId} className={`${styles[sheetStyleKeys.title]} ${className}`} {...props}>
{children}
</h2>
);
});
SheetTitle.displayName = 'SheetTitle';
export interface SheetDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {}
const SheetDescription = React.forwardRef<HTMLParagraphElement, SheetDescriptionProps>(({ className = '', children, ...props }, ref) => {
const context = useSheetContext();
return (
<p ref={ref} id={context.descriptionId} className={`${styles[sheetStyleKeys.description]} ${className}`} {...props}>
{children}
</p>
);
});
SheetDescription.displayName = 'SheetDescription';
export { Sheet, SheetTrigger, SheetClose, SheetOverlay, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };
export default Sheet;
No selection tags yet — empty means no evidence, not lack of support.
DependenciesBundle weight · light
- ui-primitives
- @radix-ui/react-slot