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:
+12
-1
@@ -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 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
|
||||
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).
|
||||
|
||||
## 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-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 | 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 |
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export const SURFACE = {
|
||||
panel: "glass rounded-2xl",
|
||||
/** 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",
|
||||
/** 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. */
|
||||
card: "glass-card rounded-xl",
|
||||
/** A menu or a listbox: r12, 4px padding, so its r8 rows nest (12 − 4). */
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"collapse": "Collapse",
|
||||
"pin": "Pin panel open",
|
||||
"unpin": "Unpin panel",
|
||||
"openPanel": "Open {{title}}",
|
||||
"any": "Any",
|
||||
"all": "All",
|
||||
"from": "From",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"collapse": "Összecsukás",
|
||||
"pin": "Panel rögzítése nyitva",
|
||||
"unpin": "Panel rögzítésének feloldása",
|
||||
"openPanel": "{{title}} megnyitása",
|
||||
"any": "Bármelyik",
|
||||
"all": "Összes",
|
||||
"from": "Ettől",
|
||||
|
||||
+11
-1
@@ -895,8 +895,18 @@ html[data-perf="1"] .overlay-backdrop {
|
||||
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) {
|
||||
.overlay-drawer {
|
||||
.overlay-drawer,
|
||||
.overlay-drawer-start {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user