Files
siftlode/frontend/src/components/ui/SidePanel.tsx
T
peter a9ac303d3e fix(side-panel): the key's focus steps into the peeked card in the commit that opens it
A frame later never came in a page the browser was not painting (measured: a hidden page runs no
requestAnimationFrame), and the focus stayed on the inert tab under the card.
2026-09-27 19:58:18 +02:00

291 lines
12 KiB
TypeScript

import { useLayoutEffect, useRef, 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
// one system, and the same peek-open model as the nav rail: unpinned it sits as a small tab docked
// 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,
count = 0,
collapsed,
onToggleCollapse,
editing = false,
onToggleEditing,
onReset,
editHint,
actions,
iconExpanded,
onTitleClick,
titleActionLabel,
children,
}: {
title: string;
icon: ReactNode;
count?: number;
collapsed: boolean;
onToggleCollapse: () => void;
// Customize (reorder/hide) mode — omit onToggleEditing to hide the pencil entirely.
editing?: boolean;
onToggleEditing?: () => void;
onReset?: () => void;
editHint?: string;
// Extra header controls shown when NOT editing (e.g. the feed's Clear all + Share view).
actions?: ReactNode;
// Optional override for the icon in the EXPANDED header only (the collapsed tab keeps the plain
// `icon`) — the feed passes its saved-views popover trigger here so the header icon opens the picker.
iconExpanded?: ReactNode;
// When set, the expanded-header title becomes a button (the feed opens its Personal Views modal).
onTitleClick?: () => void;
// Accessible name / tooltip describing what onTitleClick does (the plain title text alone doesn't).
titleActionLabel?: string;
children: ReactNode;
}) {
const { t } = useTranslation();
const cardRef = useRef<HTMLElement>(null);
const tabRef = useRef<HTMLButtonElement>(null);
const fade = useScrollFade();
const peek = useHoverFocusWithin();
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);
// A key landing on the tab peeks the card open over it, and the tab then goes inert below — with
// the focus still on it: a hidden control, out of the tree, that Enter still pinned (round 2, F1).
// The focus steps into the card as it opens, onto its pin: the same control. In the commit that
// mounts the card, not a frame later — a frame never comes in a page the browser is not painting.
useLayoutEffect(() => {
const tab = tabRef.current;
if (!expanded || pinned || drawerMode || !tab || document.activeElement !== tab) return;
if (tab.matches(":focus-visible"))
cardRef.current?.querySelector<HTMLElement>("button")?.focus();
}, [expanded, pinned, drawerMode]);
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
// content instead of pushing it. Pinned reserves --rail-pinned-w: 16.75rem (in-flow, pushes) on a
// full-width page or a narrow window, but 2.25rem (overlay, doesn't push) when the centred content
// leaves room for the rail to sit in its left margin — see PageShell. Default 16.75rem = old w-67.
<div
// Focus is tracked container-wide (keyboard: tab anywhere inside holds it open). HOVER is NOT
// 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.
{...(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
hover that opened the card, and unmounting it while focused would throw keyboard focus to
nowhere. It only goes transparent: the card's rounded corner cuts away its own pixels, and
this square-cornered tab sits right under that arc, so it would otherwise bleed through.
`opacity-0` (not `invisible`/`hidden`) keeps it hoverable, and a focus already on it stays.
But no Tab lands on it while the card covers it (E28 review F6): an invisible stop, where the
card's own pin says the same. A keyboard pin hands the focus into the card it opened. */}
<Pressable
{...(drawerMode ? {} : peek.hoverHandlers)}
onClick={
drawerMode
? () => setDrawerOpen(true)
: (e) => {
onToggleCollapse();
if (e.detail === 0)
requestAnimationFrame(() =>
cardRef.current?.querySelector<HTMLElement>("button")?.focus(),
);
}
}
ref={tabRef}
// Under an open card the tab is transparent and inert: out of the Tab order AND of the
// accessibility tree, where it would repeat the card's own pin button by name.
tabIndex={expanded ? -1 : undefined}
aria-hidden={expanded || undefined}
aria-label={
drawerMode
? t("sidebar.openPanel", { title })
: pinned
? t("sidebar.unpinNamed", { title })
: t("sidebar.pinNamed", { title })
}
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" : ""}`}
>
<span className="text-accent">{icon}</span>
{count > 0 && (
<span className="bg-accent text-accent-fg text-3xs font-bold px-1.5 py-0.5 rounded-full tabular-nums">
{count > 9 ? "9+" : count}
</span>
)}
<span
className="text-xs font-semibold uppercase tracking-[0.18em] text-muted"
style={{ writingMode: "vertical-rl", transform: "rotate(180deg)" }}
>
{title}
</span>
</Pressable>
{expanded && (
<aside
ref={cardRef}
{...peek.hoverHandlers}
className={`${SURFACE.panel} absolute top-3 bottom-3 left-0 z-panel w-64 flex flex-col overflow-hidden`}
>
{/* Row 1 — identity only: pin, icon, title (flexes + truncates last), count, customize.
Module actions live on their own row below so the title always has room. */}
<div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border/60 flex-none">
<IconButton
label={pinned ? t("sidebar.unpinNamed", { title }) : t("sidebar.pinNamed", { title })}
size="sm"
variant="ghost"
onClick={onToggleCollapse}
className="-ml-1"
>
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</IconButton>
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
{titleNode}
{countBadge}
{editButtons}
</div>
{/* Row 2 — module actions (e.g. Clear all / Share), only when not customizing. */}
{!editing && actions && (
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b border-border/60 flex-none">
{actions}
</div>
)}
<div
ref={fade.ref}
style={fade.style}
className="flex-1 min-h-0 overflow-y-auto no-scrollbar"
>
<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.pinNamed", { title })}
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>
);
}