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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user