feat(design): side panels open as a drawer on touch and below 768 (E28 S3f3)

C2 as decided: on a coarse pointer, or a window below 768, the panel's
tab (shown at every width, 44 wide on touch) opens the panel as a
start-edge drawer over the content, with a scrim. It is ui/Modal's new
drawer-start placement, so Esc, Back, the scrim, the focus trap and the
scroll lock are the dialog's own. A tap never peeks; pinning is a button
in the drawer's header, offered only at 1024 and up; a pin saved on a
desktop is left alone below that. A mouse on a wide window keeps
hover-to-peek and click-to-pin.

DESIGN: C2 as built, rows answer to their own width, phone-only layout
deferred to its own epic (principle 2).
This commit is contained in:
2026-09-27 04:38:42 +02:00
parent a58a398427
commit ef343dbec0
8 changed files with 323 additions and 63 deletions
+19 -7
View File
@@ -61,19 +61,23 @@ export default function Modal({
maxWidth = "max-w-lg",
placement = "center",
initialFocus,
headerActions,
}: {
title: ReactNode;
initialFocus: ModalFocus;
onClose: () => void;
children: ReactNode;
maxWidth?: string;
/** `drawer` slides in from the right edge and runs full height; `top` pins the panel near the top
/** `drawer` slides in from the right edge and runs full height (`drawer-start`: from the left, where
* a side panel lives); `top` pins the panel near the top
* of the viewport (where a search-first dialog like the command palette belongs, so the list grows
* downwards instead of the whole thing jumping as results change). Everything else about the shell
* — the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A
* placement is not a second dialog implementation: duplicating that behaviour is exactly how two
* shells drift apart. */
placement?: "center" | "drawer" | "top";
placement?: "center" | "drawer" | "drawer-start" | "top";
/** Controls beside the ✕ in the header (a side panel's pin and customize, C2). */
headerActions?: ReactNode;
}) {
const { t } = useTranslation();
// Browser/mouse Back closes the topmost modal instead of navigating away.
@@ -150,6 +154,7 @@ export default function Modal({
}, []);
const drawer = placement === "drawer";
const drawerStart = placement === "drawer-start";
const top = placement === "top";
// Only on a server render, where the hook has no `document` to build a host from.
if (!host) return null;
@@ -159,9 +164,11 @@ export default function Modal({
className={`fixed inset-0 z-overlay flex bg-black/70 overlay-backdrop ${
drawer
? "justify-end"
: top
? "items-start justify-center p-4 pt-[8dvh] sm:p-6 sm:pt-[10dvh]"
: "items-center justify-center p-4 sm:p-6"
: drawerStart
? "justify-start"
: top
? "items-start justify-center p-4 pt-[8dvh] sm:p-6 sm:pt-[10dvh]"
: "items-center justify-center p-4 sm:p-6"
}`}
onMouseDown={(e) => {
pressedOnBackdrop.current = e.target === e.currentTarget;
@@ -178,8 +185,10 @@ export default function Modal({
tabIndex={-1}
className={
drawer
? `${SURFACE.drawer} ${PANEL} w-full ${maxWidth} h-full rounded-none sm:rounded-l-2xl overlay-drawer`
: `${SURFACE.panel} ${PANEL} w-full ${maxWidth} max-h-full rounded-2xl`
? `${SURFACE.drawer} ${PANEL} w-full ${maxWidth} h-full overlay-drawer`
: drawerStart
? `${SURFACE.drawerStart} ${PANEL} w-full ${maxWidth} h-full overlay-drawer-start`
: `${SURFACE.panel} ${PANEL} w-full ${maxWidth} max-h-full`
}
>
{/* C4: the header and the footer stay put and only the body scrolls, with the B9 fade at
@@ -189,6 +198,9 @@ export default function Modal({
<h2 id={titleId} className="text-lg font-semibold leading-snug">
{title}
</h2>
{headerActions && (
<div className="-my-1 ml-auto flex shrink-0 items-center gap-1">{headerActions}</div>
)}
<IconButton
ref={closeRef}
label={t("common.close")}