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
+12 -1
View File
@@ -63,7 +63,15 @@ style guide.
or the band's "Filters · n" button slides the panel **over** the content with a scrim; a tap outside, Esc or the band's "Filters · n" button slides the panel **over** the content with a scrim; a tap outside, Esc
or Back closes it, and the content never reflows. Pinning is its own button in the panel header, offered or Back closes it, and the content never reflows. Pinning is its own button in the panel header, offered
only where a pinned panel leaves the content room (≥ 1024 wide). The mouse keeps hover-to-peek and only where a pinned panel leaves the content room (≥ 1024 wide). The mouse keeps hover-to-peek and
click-to-pin. A filter is never unreachable at any supported width. [Nielsen #7, WCAG 1.4.10] click-to-pin. A filter is never unreachable at any supported width. Built in S3f3: `ui/SidePanel`
opens `ui/Modal`'s `drawer-start` placement (the dialog's own Esc, Back, scrim, focus trap and scroll
lock); the tab shows at every width and is 44 wide on touch; a pin saved on a desktop is left alone
below 1024, not honoured. [Nielsen #7, WCAG 1.4.10]
- **A row answers to its own width.** A list row that must fit a narrow column (the download row, the
playlist table, the Channels tag row) stacks by a container query on its own box, not by the
window: the same list sits beside a rail, in a split-screen tablet and in a zoomed-in browser. A
line of facts (the download row's "Ready · MP4 · 18.9 MB · 1:11 · 1w ago") is one line cut at its
end, never a flex row that breaks its pieces apart.
- **Players on touch (C3):** see [Players](#players). - **Players on touch (C3):** see [Players](#players).
## Tokens ## Tokens
@@ -553,3 +561,6 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen #
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it | | 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line | | 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture | | 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
+19 -7
View File
@@ -61,19 +61,23 @@ export default function Modal({
maxWidth = "max-w-lg", maxWidth = "max-w-lg",
placement = "center", placement = "center",
initialFocus, initialFocus,
headerActions,
}: { }: {
title: ReactNode; title: ReactNode;
initialFocus: ModalFocus; initialFocus: ModalFocus;
onClose: () => void; onClose: () => void;
children: ReactNode; children: ReactNode;
maxWidth?: string; 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 * 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 * 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 * — 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 * placement is not a second dialog implementation: duplicating that behaviour is exactly how two
* shells drift apart. */ * 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(); const { t } = useTranslation();
// Browser/mouse Back closes the topmost modal instead of navigating away. // Browser/mouse Back closes the topmost modal instead of navigating away.
@@ -150,6 +154,7 @@ export default function Modal({
}, []); }, []);
const drawer = placement === "drawer"; const drawer = placement === "drawer";
const drawerStart = placement === "drawer-start";
const top = placement === "top"; const top = placement === "top";
// Only on a server render, where the hook has no `document` to build a host from. // Only on a server render, where the hook has no `document` to build a host from.
if (!host) return null; if (!host) return null;
@@ -159,9 +164,11 @@ export default function Modal({
className={`fixed inset-0 z-overlay flex bg-black/70 overlay-backdrop ${ className={`fixed inset-0 z-overlay flex bg-black/70 overlay-backdrop ${
drawer drawer
? "justify-end" ? "justify-end"
: top : drawerStart
? "items-start justify-center p-4 pt-[8dvh] sm:p-6 sm:pt-[10dvh]" ? "justify-start"
: "items-center justify-center p-4 sm:p-6" : 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) => { onMouseDown={(e) => {
pressedOnBackdrop.current = e.target === e.currentTarget; pressedOnBackdrop.current = e.target === e.currentTarget;
@@ -178,8 +185,10 @@ export default function Modal({
tabIndex={-1} tabIndex={-1}
className={ className={
drawer drawer
? `${SURFACE.drawer} ${PANEL} w-full ${maxWidth} h-full rounded-none sm:rounded-l-2xl overlay-drawer` ? `${SURFACE.drawer} ${PANEL} w-full ${maxWidth} h-full overlay-drawer`
: `${SURFACE.panel} ${PANEL} w-full ${maxWidth} max-h-full rounded-2xl` : 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 {/* 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"> <h2 id={titleId} className="text-lg font-semibold leading-snug">
{title} {title}
</h2> </h2>
{headerActions && (
<div className="-my-1 ml-auto flex shrink-0 items-center gap-1">{headerActions}</div>
)}
<IconButton <IconButton
ref={closeRef} ref={closeRef}
label={t("common.close")} label={t("common.close")}
@@ -0,0 +1,144 @@
// @vitest-environment jsdom
//
// C2 (DESIGN "Touch"): on touch, and below 768, the side panel is a DRAWER — the tab opens it over the
// content (ui/Modal's start-edge drawer: Esc, Back, the scrim), it never peeks on a tap's emulated
// hover, and pinning is a button in its header offered only ≥ 1024. A mouse on a wide window keeps
// hover-to-peek and click-to-pin. jsdom has no matchMedia: the stub answers the two queries SidePanel
// asks from three switches, and can flip them live (a tablet turning).
import { describe, it, expect, afterEach, vi } from "vitest";
import { act, render, screen, fireEvent, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) => (o?.title ? `${k}:${String(o.title)}` : k),
}),
};
});
import SidePanel from "./SidePanel";
type Env = { coarse: boolean; narrow: boolean; wide: boolean };
let env: Env = { coarse: false, narrow: false, wide: true };
const listeners = new Set<() => void>();
function stubMedia(next: Env) {
env = next;
window.matchMedia = ((query: string) => ({
get matches() {
// A comma list is an OR: each part answers from its own switch.
return query
.split(",")
.some((q) =>
q.includes("pointer: coarse")
? env.coarse
: q.includes("max-width: 767.98px")
? env.narrow
: q.includes("min-width: 1024px")
? env.wide
: false,
);
},
media: query,
addEventListener: (_: string, l: () => void) => listeners.add(l),
removeEventListener: (_: string, l: () => void) => listeners.delete(l),
})) as unknown as typeof window.matchMedia;
}
function turn(next: Env) {
act(() => {
env = next;
for (const l of [...listeners]) l();
});
}
afterEach(() => {
cleanup();
listeners.clear();
// @ts-expect-error — jsdom ships none; put it back the way it was
delete window.matchMedia;
});
function mount(collapsed = true) {
const onToggleCollapse = vi.fn();
render(
<SidePanel
title="Filters"
icon={<span />}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
>
<p>panel body</p>
</SidePanel>,
);
return { onToggleCollapse };
}
describe("SidePanel · a mouse on a wide window", () => {
it("pins from the tab, as before", () => {
stubMedia({ coarse: false, narrow: false, wide: true });
const { onToggleCollapse } = mount();
fireEvent.click(screen.getByRole("button", { name: "sidebar.pin" }));
expect(onToggleCollapse).toHaveBeenCalledOnce();
expect(screen.queryByRole("dialog")).toBeNull();
});
});
describe("SidePanel · on touch", () => {
it("opens a drawer from the tab instead of pinning, and Esc closes it", () => {
stubMedia({ coarse: true, narrow: false, wide: false });
const { onToggleCollapse } = mount();
expect(screen.queryByText("panel body")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "sidebar.openPanel:Filters" }));
const dialog = screen.getByRole("dialog");
expect(dialog.textContent).toContain("panel body");
expect(onToggleCollapse).not.toHaveBeenCalled();
// Below 1024 a pinned panel would leave the content no room: no pin in the drawer.
expect(screen.queryByRole("button", { name: "sidebar.pin" })).toBeNull();
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("never peeks on the emulated hover a tap fires", () => {
stubMedia({ coarse: true, narrow: false, wide: false });
mount();
fireEvent.mouseEnter(screen.getByRole("button", { name: "sidebar.openPanel:Filters" }));
expect(screen.queryByText("panel body")).toBeNull();
});
it("offers the pin in the drawer's header at 1024 and up, and pinning closes the drawer", () => {
stubMedia({ coarse: true, narrow: false, wide: true });
const { onToggleCollapse } = mount();
fireEvent.click(screen.getByRole("button", { name: "sidebar.openPanel:Filters" }));
fireEvent.click(screen.getByRole("button", { name: "sidebar.pin" }));
expect(onToggleCollapse).toHaveBeenCalledOnce();
expect(screen.queryByRole("dialog")).toBeNull();
});
it("leaves a pin saved on a desktop alone below 1024: a tab, not a pinned card", () => {
stubMedia({ coarse: true, narrow: false, wide: false });
mount(false);
expect(screen.queryByText("panel body")).toBeNull();
expect(screen.getByRole("button", { name: "sidebar.openPanel:Filters" })).toBeTruthy();
});
it("closes for good when the tablet turns to a width where the panel is a card again", () => {
stubMedia({ coarse: false, narrow: true, wide: false });
mount();
fireEvent.click(screen.getByRole("button", { name: "sidebar.openPanel:Filters" }));
expect(screen.getByRole("dialog")).toBeTruthy();
turn({ coarse: false, narrow: false, wide: true });
expect(screen.queryByRole("dialog")).toBeNull();
turn({ coarse: false, narrow: true, wide: false });
expect(screen.queryByRole("dialog")).toBeNull();
});
});
describe("SidePanel · a narrow window with a mouse", () => {
it("is a drawer too (split screen, a small window)", () => {
stubMedia({ coarse: false, narrow: true, wide: false });
mount();
fireEvent.click(screen.getByRole("button", { name: "sidebar.openPanel:Filters" }));
expect(screen.getByRole("dialog").textContent).toContain("panel body");
});
});
+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 { useTranslation } from "react-i18next";
import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react"; import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
import { useScrollFade } from "../../lib/useScrollFade"; import { useScrollFade } from "../../lib/useScrollFade";
import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin"; import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin";
import { useMediaQuery } from "../../lib/useMediaQuery";
import { IconButton } from "./IconButton"; import { IconButton } from "./IconButton";
import { Pressable } from "./Pressable"; import { Pressable } from "./Pressable";
import Modal from "./Modal";
import { SURFACE } from "./surfaces"; import { SURFACE } from "./surfaces";
// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read as // 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 // 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. // rounded glass card on hover/focus WITHOUT pushing content; pinned it stays expanded in-flow.
// `collapsed` (persisted per-account) therefore means "unpinned". // `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({ export default function SidePanel({
title, title,
icon, icon,
@@ -52,8 +66,64 @@ export default function SidePanel({
const { t } = useTranslation(); const { t } = useTranslation();
const fade = useScrollFade(); const fade = useScrollFade();
const peek = useHoverFocusWithin(); const peek = useHoverFocusWithin();
const pinned = !collapsed; const drawerMode = useMediaQuery(DRAWER_QUERY, false);
const expanded = pinned || peek.active; 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 ( return (
// Layout slot: reserves only the tab's gutter when unpinned, so the expanded card overlays // 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 // 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 // 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. // handle + the expanded card below, so only pointing AT the tab opens it.
{...peek.focusHandlers} {...(drawerMode ? {} : peek.focusHandlers)}
className={`hidden md:block relative shrink-0 transition-[width] ${pinned ? "w-[var(--rail-pinned-w,16.75rem)]" : "w-9"}`} 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 {/* 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 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. 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. */} `opacity-0` (not `invisible`/`hidden`) keeps it hoverable and focusable. */}
<Pressable <Pressable
{...peek.hoverHandlers} {...(drawerMode ? {} : peek.hoverHandlers)}
onClick={onToggleCollapse} onClick={drawerMode ? () => setDrawerOpen(true) : onToggleCollapse}
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")} aria-label={
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
? 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" : "" expanded ? "opacity-0" : ""
} ${count > 0 ? "ring-1 ring-accent/40" : ""}`} } ${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" />} {pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</IconButton> </IconButton>
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>} {iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
{onTitleClick ? ( {titleNode}
<Pressable {countBadge}
onClick={onTitleClick} {editButtons}
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>
)}
</div> </div>
{/* Row 2 — module actions (e.g. Clear all / Share), only when not customizing. */} {/* Row 2 — module actions (e.g. Clear all / Share), only when not customizing. */}
@@ -169,13 +208,53 @@ export default function SidePanel({
style={fade.style} style={fade.style}
className="flex-1 min-h-0 overflow-y-auto no-scrollbar" className="flex-1 min-h-0 overflow-y-auto no-scrollbar"
> >
<div className="p-3 space-y-3"> <div className="p-3 space-y-3">{body}</div>
{editing && editHint && <div className="text-2xs text-muted">{editHint}</div>}
{children}
</div>
</div> </div>
</aside> </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> </div>
); );
} }
+2
View File
@@ -11,6 +11,8 @@ export const SURFACE = {
panel: "glass rounded-2xl", panel: "glass rounded-2xl",
/** A dialog that slides in from the edge: square on a phone, r16 on its open side. */ /** A dialog that slides in from the edge: square on a phone, r16 on its open side. */
drawer: "glass rounded-none sm:rounded-l-2xl", drawer: "glass rounded-none sm:rounded-l-2xl",
/** The same, from the START edge (a side panel on touch, C2): r16 on its open side. */
drawerStart: "glass rounded-none sm:rounded-r-2xl",
/** A card or a row card in the page (a video, a user, a download): r12. */ /** A card or a row card in the page (a video, a user, a download): r12. */
card: "glass-card rounded-xl", card: "glass-card rounded-xl",
/** A menu or a listbox: r12, 4px padding, so its r8 rows nest (12 − 4). */ /** A menu or a listbox: r12, 4px padding, so its r8 rows nest (12 − 4). */
@@ -13,6 +13,7 @@
"collapse": "Collapse", "collapse": "Collapse",
"pin": "Pin panel open", "pin": "Pin panel open",
"unpin": "Unpin panel", "unpin": "Unpin panel",
"openPanel": "Open {{title}}",
"any": "Any", "any": "Any",
"all": "All", "all": "All",
"from": "From", "from": "From",
@@ -13,6 +13,7 @@
"collapse": "Összecsukás", "collapse": "Összecsukás",
"pin": "Panel rögzítése nyitva", "pin": "Panel rögzítése nyitva",
"unpin": "Panel rögzítésének feloldása", "unpin": "Panel rögzítésének feloldása",
"openPanel": "{{title}} megnyitása",
"any": "Bármelyik", "any": "Bármelyik",
"all": "Összes", "all": "Összes",
"from": "Ettől", "from": "Ettől",
+11 -1
View File
@@ -895,8 +895,18 @@ html[data-perf="1"] .overlay-backdrop {
opacity: 0; opacity: 0;
} }
} }
.overlay-drawer-start {
animation: drawer-in-start 180ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
@keyframes drawer-in-start {
from {
transform: translateX(-1.5rem);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.overlay-drawer { .overlay-drawer,
.overlay-drawer-start {
animation: none; animation: none;
} }
} }