Merge branch 'feat/bottom-sheet' into release/v1.44.2

This commit is contained in:
Marc-André Bombeck committed 2026-10-22 12:04:28 +02:00
commit ac9693fac9
19 files changed
+2367 -96

No files matched your search

@@ -135,6 +135,9 @@ export default function CommandPalette() {
<Sheet open={open} onOpenChange={setCommandPaletteOpen}>
<SheetContent
side="bottom"
// The palette fills the window: no backdrop to pull it towards,
// and its own Cancel and Escape close it.
draggable={false}
showCloseButton={false}
data-slot="command-palette"
data-variant="sheet"
+16 -65
View File
@@ -629,9 +629,10 @@ function returnFocus(fallback?: HTMLElement | null) {
}
/**
* The modal frames. The bottom sheet opens at a bit over half the height and
* grows to full height from its handle: drag it up or tap it. Dragging down
* shrinks a full sheet, then closes it.
* The modal frames. On a phone the bottom sheet opens at a bit over half the
* height, so the chart stays in view above it, and grows to the full 90 dvh
* when it is pulled up or its grip is tapped (the sheet primitive's preview
* step). Pulled down it closes, from either height.
*/
function DaySheet({
shell,
@@ -649,51 +650,8 @@ function DaySheet({
render: (extra: Partial<DayViewProps>) => React.ReactNode;
}) {
const { t } = useTranslations();
const [full, setFull] = useState(false);
const drag = useRef<{ y: number; moved: boolean } | null>(null);
const bottom = shell === "bottom";
const grabber = bottom ? (
<button
type="button"
data-slot="day-sheet-handle"
aria-label={full ? t("day.sheetShrink") : t("day.sheetExpand")}
aria-expanded={full}
onPointerDown={(event) => {
drag.current = { y: event.clientY, moved: false };
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={(event) => {
const start = drag.current;
if (!start) return;
if (Math.abs(event.clientY - start.y) > 8) start.moved = true;
}}
onPointerUp={(event) => {
const start = drag.current;
drag.current = null;
if (!start) return;
const dy = event.clientY - start.y;
if (!start.moved) return;
if (dy < -40) setFull(true);
else if (dy > 60) {
if (full) setFull(false);
else onDismiss();
}
}}
onClick={() => {
// A drag ends in a click as well; only a tap toggles.
if (drag.current?.moved) return;
setFull((value) => !value);
}}
className="focus-visible:ring-ring/50 mx-auto flex h-6 w-16 shrink-0 touch-none items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
>
<span
className="bg-muted-foreground/40 h-1.5 w-9 rounded-full"
aria-hidden="true"
/>
</button>
) : null;
return (
<Sheet
open
@@ -704,10 +662,15 @@ function DaySheet({
<SheetContent
ref={panelRef as React.Ref<HTMLDivElement>}
side={bottom ? "bottom" : "right"}
preview={bottom ? "58dvh" : undefined}
previewLabels={
bottom
? { expand: t("day.sheetExpand"), shrink: t("day.sheetShrink") }
: undefined
}
showCloseButton={false}
data-slot="day-panel"
data-shell={shell}
data-full={bottom && full ? "true" : undefined}
onOpenAutoFocus={(event) => {
event.preventDefault();
focusTitle();
@@ -729,30 +692,18 @@ function DaySheet({
if (owner && owner !== panelRef.current) event.preventDefault();
}}
className={cn(
// Quicker than the primitive's 500 ms, which read as lag on a
// layer opened from a tap; no slide at all under reduced motion.
"bg-card gap-0 p-0 data-[state=closed]:duration-200 data-[state=open]:duration-300 motion-reduce:animate-none",
"bg-card gap-0 p-0",
bottom
? cn(
"rounded-t-2xl transition-[height] duration-200 motion-reduce:transition-none",
// Full height leaves a strip of the page above the
// sheet, and never less than the status bar of an
// installed app plus a margin: a 2.5rem strip put the
// handle under a 59 px Dynamic Island.
full
? "h-[calc(100dvh-max(2.5rem,env(safe-area-inset-top,0px)+0.75rem))]"
: "h-[58dvh]",
)
: "w-105 max-w-[90vw] sm:max-w-105",
? "rounded-t-2xl"
: // Quicker than the primitive's 500 ms, which read as lag on a
// layer opened from a tap; no slide at all under reduced motion.
"w-105 max-w-[90vw] data-[state=closed]:duration-200 data-[state=open]:duration-300 motion-reduce:animate-none sm:max-w-105",
)}
>
<SheetDescription className="sr-only">
{t("day.description")}
</SheetDescription>
{render({
above: grabber,
headerClassName: bottom ? undefined : "py-3",
})}
{render({ headerClassName: bottom ? undefined : "py-3" })}
</SheetContent>
</Sheet>
);
+13 -6
View File
@@ -75,8 +75,6 @@ export interface DayViewProps {
}>;
titleId: string;
titleRef?: Ref<HTMLHeadingElement>;
/** Shell chrome above the header (the bottom sheet's grabber). */
above?: ReactNode;
/** Classes for the header row, which the docked shell aligns to the top bar. */
headerClassName?: string;
}
@@ -103,7 +101,6 @@ export function DayView({
Title,
titleId,
titleRef,
above,
headerClassName,
}: DayViewProps) {
const { t } = useTranslations();
@@ -162,16 +159,20 @@ export function DayView({
<div
data-slot="day-view"
data-day={date}
className="flex min-h-0 flex-1 flex-col"
// `bg-inherit` passes the sheet's surface down to the footer, which
// paints it while it stands over the body (see `day-footer`).
className={cn("flex min-h-0 flex-1 flex-col", compact && "bg-inherit")}
>
{above}
<div
data-slot="day-header"
className={cn(
"flex shrink-0 items-center gap-1",
// The date starts on the body's reading edge (`px-4` / `px-6`).
compact
? "pt-0.5 pr-2 pb-2 pl-4"
? // Under the sheet's grip: the title row's centre lands where
// every bottom sheet puts it, 52 px down. The header drags
// the sheet, so it never pans the page.
"touch-none pt-1.5 pr-2 pb-2 pl-4"
: "border-border border-b pr-3 pl-6",
headerClassName,
)}
@@ -257,6 +258,7 @@ export function DayView({
<div
data-slot="day-body"
data-sheet-body
className={cn(
"flex min-h-0 flex-1 flex-col overflow-x-clip overflow-y-auto overscroll-contain",
compact ? "gap-6 px-4 pt-0.5 pb-4" : "gap-6 px-6 pt-4 pb-6",
@@ -342,11 +344,16 @@ export function DayView({
{canCapture || (timelineOn && !onTimeline) ? (
<div
data-slot="day-footer"
// On the phone sheet's preview step the footer is held on the
// window's bottom edge while the sheet moves (`bottom-sheet.tsx`),
// over the body: it carries the sheet's own background for that.
data-sheet-footer
className={cn(
"border-border flex shrink-0 items-center justify-between gap-2 border-t",
compact
? "px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))]"
: "px-6 py-3.5",
compact && "bg-inherit",
)}
>
{timelineOn && !onTimeline ? (
+4 -2
View File
@@ -274,7 +274,9 @@ export function BottomNav() {
// The sheet auto-sizes to its content; padding-bottom respects
// the iOS safe area so the inner buttons stay reachable above
// the home-indicator on iPhone-X-class devices.
className="shell-desktop:hidden mx-auto max-w-md rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
// `gap-0`: the grip sits flush above the header, and the grid
// takes the header's old 16 px gap as its own top padding.
className="shell-desktop:hidden mx-auto max-w-md gap-0 rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
data-testid="bottom-nav-more-sheet"
>
<SheetHeader>
@@ -286,7 +288,7 @@ export function BottomNav() {
(de "Benachrichtigungen") then have room to break onto a clean
second line instead of clipping the last character at the tile
edge. */}
<div className="grid grid-cols-2 gap-2 px-3 pb-4">
<div className="grid grid-cols-2 gap-2 px-3 pt-4 pb-4">
{moreHub.map((item) => {
const isActive = isActiveLink(item.href);
return (
@@ -0,0 +1,151 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";
/**
* The bottom sheet's anatomy, rendered statically: the grip, the 44 px X on
* the title row, the preview step's wiring, and that every bottom sheet
* (`<SheetContent side="bottom">`) takes this path while the other sides do
* not. Radix portals do not render on the server, so its Dialog parts are
* passed through as plain elements; what Radix itself does (focus, Escape,
* the backdrop) and the drag in a browser are `e2e/bottom-sheet.spec.ts`.
*/
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key }),
}));
vi.mock("radix-ui", () => {
type P = { children?: ReactNode; [key: string]: unknown };
const strip = ({ asChild: _asChild, ...rest }: P) => rest;
return {
Dialog: {
Root: ({ children }: P) => <>{children}</>,
Portal: ({ children }: P) => <>{children}</>,
Overlay: (props: P) => <div data-mock="overlay" {...strip(props)} />,
Content: ({ children, ...props }: P) => (
<div role="dialog" {...strip(props)}>
{children}
</div>
),
Close: ({ children, ...props }: P) => (
<button type="button" {...strip(props)}>
{children}
</button>
),
Title: ({ children }: P) => <h2>{children}</h2>,
Description: ({ children }: P) => <p>{children}</p>,
Trigger: ({ children }: P) => <>{children}</>,
},
};
});
const { BottomSheetContent, BOTTOM_SHEET_CLASS } =
await import("../bottom-sheet");
const { SheetContent } = await import("../sheet");
function attr(html: string, slot: string): string {
const match = new RegExp(`<[^>]*data-slot="${slot}"[^>]*>`).exec(html);
return match?.[0] ?? "";
}
describe("<BottomSheetContent>", () => {
it("draws a decorative grip and a 44 px X on the title row", () => {
const html = renderToStaticMarkup(
<BottomSheetContent>body</BottomSheetContent>,
);
const grip = attr(html, "sheet-grip");
expect(grip).toContain('aria-hidden="true"');
expect(grip).toContain("h-6");
expect(grip).toContain("touch-none");
expect(html).toContain("h-1.25 w-9 rounded-full");
const close = attr(html, "sheet-close-button");
expect(close).toContain("size-11");
expect(close).toContain("top-7.5");
expect(html).toContain("common.close");
// The swipe closes through a Radix Close of its own, out of reach.
const swipe = attr(html, "sheet-swipe-close");
expect(swipe).toContain("hidden");
expect(swipe).toContain('tabindex="-1"');
});
it("keeps its backdrop out of touch panning", () => {
const html = renderToStaticMarkup(
<BottomSheetContent>body</BottomSheetContent>,
);
expect(attr(html, "sheet-overlay")).toContain("touch-none");
});
it("drops the grip, the drag and the X offset when it is not draggable", () => {
const html = renderToStaticMarkup(
<BottomSheetContent draggable={false}>body</BottomSheetContent>,
);
expect(html).not.toContain('data-slot="sheet-grip"');
expect(html).not.toContain('data-slot="sheet-swipe-close"');
expect(attr(html, "sheet-close-button")).toContain("top-3");
});
it("opens a preview sheet on its preview step, with a grip that expands it", () => {
const html = renderToStaticMarkup(
<BottomSheetContent
preview="58dvh"
previewLabels={{ expand: "Expand", shrink: "Shrink" }}
>
body
</BottomSheetContent>,
);
const content = attr(html, "sheet-content");
expect(content).toContain('data-sheet-snap="preview"');
expect(content).toContain("--sheet-height:90dvh");
expect(content).toContain("--sheet-preview-height:58dvh");
expect(content).toContain("h-(--sheet-height)");
const grip = attr(html, "sheet-grip");
expect(grip).toMatch(/^<button/);
expect(grip).toContain('aria-label="Expand"');
expect(grip).toContain('aria-expanded="false"');
});
it("names its side for the keyboard rule and lets a consumer slot win", () => {
const html = renderToStaticMarkup(
<BottomSheetContent data-slot="mine" className="bg-card gap-0">
body
</BottomSheetContent>,
);
expect(html).toContain('data-sheet-side="bottom"');
expect(html).toContain('data-slot="mine"');
const content = attr(html, "mine");
expect(content).toMatch(/\bbg-card\b/);
expect(content).not.toContain("bg-background");
expect(content).not.toMatch(/\bgap-4\b/);
});
it("moves, opens and closes on its own timing and stays still under reduced motion", () => {
expect(BOTTOM_SHEET_CLASS).toContain("data-[state=open]:duration-260");
expect(BOTTOM_SHEET_CLASS).toContain("data-[state=closed]:duration-220");
expect(BOTTOM_SHEET_CLASS).toContain("ease-out");
expect(BOTTOM_SHEET_CLASS).toContain("motion-reduce:animate-none");
expect(BOTTOM_SHEET_CLASS).toContain("max-h-[90dvh]");
});
});
describe("<SheetContent>", () => {
it("renders every bottom sheet through the bottom sheet", () => {
const html = renderToStaticMarkup(
<SheetContent side="bottom">body</SheetContent>,
);
expect(html).toContain('data-slot="sheet-grip"');
expect(html).toContain('data-sheet-side="bottom"');
});
it("leaves the side sheets as they were", () => {
const html = renderToStaticMarkup(
<SheetContent side="right" preview="58dvh">
body
</SheetContent>,
);
expect(html).not.toContain('data-slot="sheet-grip"');
expect(html).not.toContain("data-sheet-snap");
expect(html).toContain('data-sheet-side="right"');
expect(attr(html, "sheet-close-button")).toContain("top-3 right-4");
});
});
+289
View File
@@ -0,0 +1,289 @@
"use client";
import * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as SheetPrimitive } from "radix-ui";
import { useSheetDrag } from "@/hooks/use-sheet-drag";
import { useTranslations } from "@/lib/i18n/context";
import type { SheetSnap } from "@/lib/sheet-gesture";
import { cn } from "@/lib/utils";
/**
* The bottom sheet: every `<SheetContent side="bottom">` renders through
* here, and so does `ResponsiveSheet` on a phone.
*
* It is Radix's Dialog with a drag on top. Focus moving in, the focus trap,
* Escape, the backdrop tap, `role="dialog"` and its name, and the scroll
* lock on the page behind all stay Radix's. What this adds:
*
* - focus goes back to whatever opened the sheet (Radix only knows a
* `Dialog.Trigger`, which most openers are not), unless the caller's
* `onCloseAutoFocus` places it itself; and `aria-modal`;
* - a grip at the top edge; the grip and the header drag the sheet always,
* the content only while it is scrolled to its very top
* (`use-sheet-drag.ts`, rules in `lib/sheet-gesture.ts`);
* - a release past a quarter of the height, or a flick down, closes it,
* anything less springs back; sideways movement never counts;
* - the backdrop lightens as the sheet goes down, and neither it nor the
* grip and header ever scroll or pull the page behind;
* - opt in, a preview step (`preview`): the sheet opens at that height and
* a pull up, or a tap on the grip, takes it to the full 90 dvh. Its
* footer (`data-sheet-footer`) stays on the window's bottom edge and its
* scrolling body (`data-sheet-body`) ends above it on either step.
*
* The X sits on the title row, 44 px square; the keyboard and a screen
* reader close the sheet through it or Escape, never through a gesture.
*/
export interface BottomSheetOptions {
/**
* Opens the sheet at this height (a CSS length, e.g. `58dvh`) with the
* full 90 dvh one pull up. Needs `previewLabels` for the grip, which then
* becomes a button that toggles the two steps.
*/
preview?: string;
previewLabels?: { expand: string; shrink: string };
/**
* False leaves out the grip and the drag, for a sheet that fills the whole
* window (the command palette): there is no backdrop to pull it towards.
*/
draggable?: boolean;
}
export type BottomSheetContentProps = React.ComponentProps<
typeof SheetPrimitive.Content
> &
BottomSheetOptions & { showCloseButton?: boolean };
/**
* No `bottom-*` here: the sheet's `bottom` comes from the keyboard rule in
* `globals.css`, which a utility would outrank. In 260 ms, out 220 ms, both
* easing out; no slide at all under reduced motion. The `after:` strip
* continues the sheet's own background below its edge, so the short
* resisting travel above the top never shows the page underneath.
*/
export const BOTTOM_SHEET_CLASS =
"bg-background fixed inset-x-0 z-50 flex max-h-[90dvh] flex-col gap-4 border-t shadow-lg transition-transform ease-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom data-[state=open]:duration-260 data-[state=closed]:duration-220 motion-reduce:animate-none motion-reduce:transition-none after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-10 after:bg-inherit after:content-['']";
const OVERLAY_CLASS =
"fixed inset-0 z-50 touch-none bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 ease-out data-[state=open]:duration-260 data-[state=closed]:duration-220 motion-reduce:animate-none";
/** Puts a preview sheet back on its preview step each time it opens. */
function OnOpen({ run }: { run: () => void }) {
React.useLayoutEffect(() => {
run();
// Once per mount, which is once per opening.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
}
function SheetGrip({
snap,
labels,
onToggle,
}: {
snap: SheetSnap;
labels?: { expand: string; shrink: string };
onToggle: () => void;
}) {
const bar = (
<span
aria-hidden="true"
className="bg-muted-foreground/40 h-1.25 w-9 rounded-full"
/>
);
if (labels) {
return (
<button
type="button"
data-slot="sheet-grip"
aria-label={snap === "full" ? labels.shrink : labels.expand}
aria-expanded={snap === "full"}
onClick={onToggle}
className="focus-visible:ring-ring/50 mx-auto flex h-6 w-16 shrink-0 touch-none items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
>
{bar}
</button>
);
}
return (
<div
data-slot="sheet-grip"
aria-hidden="true"
className="flex h-6 shrink-0 touch-none items-center justify-center"
>
{bar}
</div>
);
}
export function BottomSheetContent({
className,
children,
showCloseButton = true,
preview,
previewLabels,
draggable = true,
ref,
style,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onClickCapture,
onOpenAutoFocus,
onCloseAutoFocus,
...props
}: BottomSheetContentProps) {
const { t } = useTranslations();
const contentRef = React.useRef<HTMLDivElement | null>(null);
const overlayRef = React.useRef<HTMLDivElement | null>(null);
const probeRef = React.useRef<HTMLDivElement | null>(null);
const closeRef = React.useRef<HTMLButtonElement | null>(null);
// What had focus when the sheet opened. Radix hands focus back only to a
// `Dialog.Trigger`, and most bottom sheets open from a plain button (the
// More tab, a chart's tooltip), so focus fell to the body on close.
const openerRef = React.useRef<HTMLElement | null>(null);
const initial: SheetSnap = preview ? "preview" : "full";
const [snap, setSnap] = React.useState<SheetSnap>(initial);
const drag = useSheetDrag({
enabled: draggable,
hasPreview: !!preview,
snap,
onSnap: setSnap,
onClose: () => closeRef.current?.click(),
contentRef,
overlayRef,
probeRef,
});
const setContent = React.useCallback(
(node: HTMLDivElement | null) => {
contentRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ref],
);
return (
<SheetPrimitive.Portal data-slot="sheet-portal">
<SheetPrimitive.Overlay
ref={overlayRef}
data-slot="sheet-overlay"
className={OVERLAY_CLASS}
/>
<SheetPrimitive.Content
ref={setContent}
data-slot="sheet-content"
// Read by the keyboard rule in `globals.css`: a bottom sheet stands
// on the on-screen keyboard. Its own attribute rather than
// `data-slot`, which consumers replace with theirs.
data-sheet-side="bottom"
data-sheet-snap={preview ? snap : undefined}
// Radix makes the page behind inert and traps focus; this says so
// to assistive technology that reads the attribute rather than the
// hidden siblings.
aria-modal="true"
style={
preview
? ({
"--sheet-height": "90dvh",
"--sheet-preview-height": preview,
...style,
} as React.CSSProperties)
: style
}
className={cn(
BOTTOM_SHEET_CLASS,
preview && "h-(--sheet-height) max-h-(--sheet-height)",
className,
)}
onPointerDown={(event) => {
onPointerDown?.(event);
drag.onPointerDown?.(event);
}}
onPointerMove={(event) => {
onPointerMove?.(event);
drag.onPointerMove?.(event);
}}
onPointerUp={(event) => {
onPointerUp?.(event);
drag.onPointerUp?.(event);
}}
onPointerCancel={(event) => {
onPointerCancel?.(event);
drag.onPointerCancel?.(event);
}}
onClickCapture={(event) => {
drag.onClickCapture?.(event);
onClickCapture?.(event);
}}
onOpenAutoFocus={(event) => {
const active = document.activeElement;
openerRef.current =
active instanceof HTMLElement && active !== document.body
? active
: null;
onOpenAutoFocus?.(event);
}}
onCloseAutoFocus={(event) => {
onCloseAutoFocus?.(event);
const opener = openerRef.current;
if (event.defaultPrevented || !opener?.isConnected) return;
event.preventDefault();
opener.focus({ preventScroll: true });
}}
{...props}
>
{preview ? (
<>
<OnOpen run={() => setSnap(initial)} />
<div
ref={probeRef}
aria-hidden="true"
className="pointer-events-none invisible absolute top-0 left-0 w-px"
style={{ height: preview }}
/>
</>
) : null}
{draggable ? (
<SheetGrip
snap={snap}
labels={preview ? previewLabels : undefined}
onToggle={() => setSnap(snap === "full" ? "preview" : "full")}
/>
) : null}
{children}
{showCloseButton ? (
<SheetPrimitive.Close
data-slot="sheet-close-button"
className={cn(
// On the title row: the grip's 24 px plus the header's 16 px
// top padding put the title's line centre 52 px down.
draggable ? "top-7.5" : "top-3",
"ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute right-2 inline-flex size-11 touch-none items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
)}
>
<XIcon />
<span className="sr-only">{t("common.close")}</span>
</SheetPrimitive.Close>
) : null}
{draggable ? (
// What a swipe presses: the same close as the X, so the sheet's
// `onOpenChange` hears one kind of close however it was asked.
<SheetPrimitive.Close
ref={closeRef}
hidden
tabIndex={-1}
aria-hidden="true"
data-slot="sheet-swipe-close"
/>
) : null}
</SheetPrimitive.Content>
</SheetPrimitive.Portal>
);
}
+3 -1
View File
@@ -195,7 +195,9 @@ export function ResponsiveSheet({
<SheetHeader
data-slot="responsive-sheet-header"
className={cn(
"border-border/70 gap-1.5 border-b p-4",
// Under the sheet's grip; the header drags the sheet, so a
// touch on it never pans or pulls the page.
"border-border/70 touch-none gap-1.5 border-b p-4",
// Reserve the right gutter ONLY when the primitive paints its
// absolute close button. With `showCloseButton={false}` the
// consumer's own trailing control (headerAction) sits flush to
+41 -14
View File
@@ -6,6 +6,10 @@ import { Dialog as SheetPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import {
BottomSheetContent,
type BottomSheetOptions,
} from "@/components/ui/bottom-sheet";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
@@ -45,14 +49,43 @@ function SheetOverlay({
);
}
/**
* A sheet from one edge. `side="bottom"` is the bottom sheet
* (`bottom-sheet.tsx`): a grip, the drag, and the options in
* `BottomSheetOptions`, which the other sides ignore.
*/
function SheetContent({
side = "right",
preview,
previewLabels,
draggable,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> &
BottomSheetOptions & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
if (side === "bottom") {
return (
<BottomSheetContent
preview={preview}
previewLabels={previewLabels}
draggable={draggable}
{...props}
/>
);
}
return <EdgeSheetContent side={side} {...props} />;
}
function EdgeSheetContent({
className,
children,
side = "right",
side,
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
side: "top" | "right" | "left";
showCloseButton?: boolean;
}) {
const { t } = useTranslations();
@@ -61,14 +94,10 @@ function SheetContent({
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
// Read by the safe-area and on-screen-keyboard rules in
// `globals.css`: a bottom sheet stands on the keyboard, the
// full-height sides keep clear of the status bar and the notch.
// Its own attribute rather than `data-slot`, which consumers
// replace with theirs (`responsive-sheet-content`, `day-panel`).
// A bottom sheet's `bottom` comes from that rule (0 until the
// keyboard opens), so its branch below carries no `bottom-*`: a
// utility would outrank the layered rule and pin it to the edge.
// Read by the safe-area rules in `globals.css`: the full-height
// sides keep clear of the status bar and the notch. Its own
// attribute rather than `data-slot`, which consumers replace with
// theirs.
data-sheet-side={side}
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
@@ -78,8 +107,6 @@ function SheetContent({
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 h-auto border-t",
className,
)}
{...props}
@@ -90,8 +117,8 @@ function SheetContent({
// a 36 px floor, matching the Dialog close-X treatment from
// MB2 (Decision I). 36 px (not 44 px) is the intentional
// exception for fixed icon-only close affordances per the
// dialog primitive's rationale: 44 px would crowd the sheet
// header out of proportion on the bottom-sheet branch.
// dialog primitive's rationale. The bottom sheet's X is 44 px
// on its title row (`bottom-sheet.tsx`).
<SheetPrimitive.Close
data-slot="sheet-close-button"
className="ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute top-3 right-4 inline-flex min-h-11 min-w-11 items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none sm:min-h-9 sm:min-w-9 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"