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
+133 -54
View File
@@ -1,10 +1,12 @@
import { type ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
import { useScrollFade } from "../../lib/useScrollFade";
import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin";
import { useMediaQuery } from "../../lib/useMediaQuery";
import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable";
import Modal from "./Modal";
import { SURFACE } from "./surfaces";
// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read as
@@ -12,6 +14,18 @@ import { SURFACE } from "./surfaces";
// against the rail's right edge (this panel is the flex sibling right after it) and expands into a
// rounded glass card on hover/focus WITHOUT pushing content; pinned it stays expanded in-flow.
// `collapsed` (persisted per-account) therefore means "unpinned".
//
// On touch, and in a window below 768 (C2), there is no hover to peek with and no room to pin: the tab
// (shown at every width) opens the panel as a DRAWER over the content, with a scrim — ui/Modal's
// start-edge drawer, so Esc, Back, a tap outside, the focus trap and the scroll lock are the dialog's
// own. Pinning stays a button in the panel's header, offered only ≥ 1024 where a pinned panel leaves
// the content room; a pin saved on a desktop is left alone below that, not honoured.
// Where the panel is a drawer rather than a peeking card: a coarse pointer, or a window too narrow to
// lay a panel beside the content. And where a pinned panel still leaves the content room (≥ 1024).
const DRAWER_QUERY = "(pointer: coarse), (max-width: 767.98px)";
const PIN_ROOM_QUERY = "(min-width: 1024px)";
export default function SidePanel({
title,
icon,
@@ -52,8 +66,64 @@ export default function SidePanel({
const { t } = useTranslation();
const fade = useScrollFade();
const peek = useHoverFocusWithin();
const pinned = !collapsed;
const expanded = pinned || peek.active;
const drawerMode = useMediaQuery(DRAWER_QUERY, false);
const pinRoom = useMediaQuery(PIN_ROOM_QUERY, true);
const canPin = !drawerMode || pinRoom;
const pinned = !collapsed && canPin;
// A drawer never peeks: a tap would fire the emulated hover and open the card under the finger.
const expanded = pinned || (!drawerMode && peek.active);
const [drawerOpen, setDrawerOpen] = useState(false);
// Turning the tablet to a width where the panel is a card again, or pinning it, ends the drawer —
// adjusted during render, so it cannot come back open on the next turn.
if (drawerOpen && (!drawerMode || pinned)) setDrawerOpen(false);
const titleNode = onTitleClick ? (
<Pressable
onClick={onTitleClick}
title={titleActionLabel}
aria-label={titleActionLabel}
className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em] transition hover:text-accent"
>
{title}
</Pressable>
) : (
<span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]">
{title}
</span>
);
const countBadge =
count > 0 ? (
<span
title={t("sidebar.activeCount", { count })}
className="shrink-0 min-w-4.5 h-4.5 px-1.5 rounded-full bg-accent/15 text-accent text-2xs font-semibold inline-flex items-center justify-center tabular-nums"
>
{count}
</span>
) : null;
const editButtons = (
<>
{editing && onReset && (
<IconButton label={t("sidebar.resetDefaults")} size="sm" variant="ghost" onClick={onReset}>
<RotateCcw className="w-4 h-4" />
</IconButton>
)}
{onToggleEditing && (
<IconButton
variant={editing ? "ghost-accent" : "ghost"}
onClick={onToggleEditing}
label={editing ? t("sidebar.done") : t("sidebar.customize")}
>
{editing ? <Check /> : <Pencil />}
</IconButton>
)}
</>
);
const body = (
<>
{editing && editHint && <div className="text-2xs text-muted">{editHint}</div>}
{children}
</>
);
return (
// Layout slot: reserves only the tab's gutter when unpinned, so the expanded card overlays
@@ -65,8 +135,8 @@ export default function SidePanel({
// on this full-height slot — that made the whole column next to the nav rail a live target, so
// merely approaching the rail popped the panel open. Hover now lives only on the visible tab
// handle + the expanded card below, so only pointing AT the tab opens it.
{...peek.focusHandlers}
className={`hidden md:block relative shrink-0 transition-[width] ${pinned ? "w-[var(--rail-pinned-w,16.75rem)]" : "w-9"}`}
{...(drawerMode ? {} : peek.focusHandlers)}
className={`${drawerMode ? "block" : "hidden md:block"} relative shrink-0 transition-[width] ${pinned ? "w-[var(--rail-pinned-w,16.75rem)]" : "w-9 pointer-coarse:w-11"}`}
>
{/* The tab: a small handle flush against the nav rail, flat on the docked edge. It stays
mounted while the card is open — swapping it out from under the pointer would drop the very
@@ -75,10 +145,17 @@ export default function SidePanel({
this square-cornered tab sits right under that arc, so it would otherwise bleed through.
`opacity-0` (not `invisible`/`hidden`) keeps it hoverable and focusable. */}
<Pressable
{...peek.hoverHandlers}
onClick={onToggleCollapse}
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
className={`${SURFACE.edgeTab} glass-hover absolute top-3 left-0 z-paneltab flex flex-col items-center gap-2 px-1.5 py-3 transition-opacity ${
{...(drawerMode ? {} : peek.hoverHandlers)}
onClick={drawerMode ? () => setDrawerOpen(true) : onToggleCollapse}
aria-label={
drawerMode
? t("sidebar.openPanel", { title })
: pinned
? t("sidebar.unpin")
: t("sidebar.pin")
}
aria-haspopup={drawerMode ? "dialog" : undefined}
className={`${SURFACE.edgeTab} glass-hover absolute top-3 left-0 z-paneltab flex flex-col items-center gap-2 px-1.5 py-3 pointer-coarse:w-11 transition-opacity ${
expanded ? "opacity-0" : ""
} ${count > 0 ? "ring-1 ring-accent/40" : ""}`}
>
@@ -114,47 +191,9 @@ export default function SidePanel({
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</IconButton>
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
{onTitleClick ? (
<Pressable
onClick={onTitleClick}
title={titleActionLabel}
aria-label={titleActionLabel}
className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em] transition hover:text-accent"
>
{title}
</Pressable>
) : (
<span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]">
{title}
</span>
)}
{count > 0 && (
<span
title={t("sidebar.activeCount", { count })}
className="shrink-0 min-w-4.5 h-4.5 px-1.5 rounded-full bg-accent/15 text-accent text-2xs font-semibold inline-flex items-center justify-center tabular-nums"
>
{count}
</span>
)}
{editing && onReset && (
<IconButton
label={t("sidebar.resetDefaults")}
size="sm"
variant="ghost"
onClick={onReset}
>
<RotateCcw className="w-4 h-4" />
</IconButton>
)}
{onToggleEditing && (
<IconButton
variant={editing ? "ghost-accent" : "ghost"}
onClick={onToggleEditing}
label={editing ? t("sidebar.done") : t("sidebar.customize")}
>
{editing ? <Check /> : <Pencil />}
</IconButton>
)}
{titleNode}
{countBadge}
{editButtons}
</div>
{/* Row 2 — module actions (e.g. Clear all / Share), only when not customizing. */}
@@ -169,13 +208,53 @@ export default function SidePanel({
style={fade.style}
className="flex-1 min-h-0 overflow-y-auto no-scrollbar"
>
<div className="p-3 space-y-3">
{editing && editHint && <div className="text-2xs text-muted">{editHint}</div>}
{children}
</div>
<div className="p-3 space-y-3">{body}</div>
</div>
</aside>
)}
{drawerOpen && (
<Modal
placement="drawer-start"
maxWidth="max-w-80"
initialFocus="close"
onClose={() => setDrawerOpen(false)}
title={
<span className="flex items-center gap-1.5">
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
{titleNode}
{countBadge}
</span>
}
headerActions={
<>
{pinRoom && (
<IconButton
label={t("sidebar.pin")}
size="sm"
variant="ghost"
onClick={() => {
setDrawerOpen(false);
onToggleCollapse();
}}
>
<Pin className="w-4 h-4" />
</IconButton>
)}
{editButtons}
</>
}
>
<div className="space-y-3">
{!editing && actions && (
<div className="flex items-center justify-between gap-2 border-b border-border/60 pb-2">
{actions}
</div>
)}
{body}
</div>
</Modal>
)}
</div>
);
}