From ef343dbec06c2d26c4efe266ee5426b032ac423a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 27 Sep 2026 04:38:42 +0200 Subject: [PATCH] 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). --- docs/DESIGN.md | 13 +- frontend/src/components/ui/Modal.tsx | 26 ++- .../ui/SidePanel.drawer.dom.test.tsx | 144 ++++++++++++++ frontend/src/components/ui/SidePanel.tsx | 187 +++++++++++++----- frontend/src/components/ui/surfaces.ts | 2 + frontend/src/i18n/locales/en/sidebar.json | 1 + frontend/src/i18n/locales/hu/sidebar.json | 1 + frontend/src/index.css | 12 +- 8 files changed, 323 insertions(+), 63 deletions(-) create mode 100644 frontend/src/components/ui/SidePanel.drawer.dom.test.tsx diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 4fb1e656..a2981ead 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 | diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index bc19d888..31882805 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -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({

{title}

+ {headerActions && ( +
{headerActions}
+ )} { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ + t: (k: string, o?: Record) => (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( + } + collapsed={collapsed} + onToggleCollapse={onToggleCollapse} + > +

panel body

+
, + ); + 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"); + }); +}); diff --git a/frontend/src/components/ui/SidePanel.tsx b/frontend/src/components/ui/SidePanel.tsx index e0ef7ad9..1648588c 100644 --- a/frontend/src/components/ui/SidePanel.tsx +++ b/frontend/src/components/ui/SidePanel.tsx @@ -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 ? ( + + {title} + + ) : ( + + {title} + + ); + const countBadge = + count > 0 ? ( + + {count} + + ) : null; + const editButtons = ( + <> + {editing && onReset && ( + + + + )} + {onToggleEditing && ( + + {editing ? : } + + )} + + ); + const body = ( + <> + {editing && editHint &&
{editHint}
} + {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. */} 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 ? : }
{iconExpanded ?? {icon}} - {onTitleClick ? ( - - {title} - - ) : ( - - {title} - - )} - {count > 0 && ( - - {count} - - )} - {editing && onReset && ( - - - - )} - {onToggleEditing && ( - - {editing ? : } - - )} + {titleNode} + {countBadge} + {editButtons} {/* 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" > -
- {editing && editHint &&
{editHint}
} - {children} -
+
{body}
)} + + {drawerOpen && ( + setDrawerOpen(false)} + title={ + + {iconExpanded ?? {icon}} + {titleNode} + {countBadge} + + } + headerActions={ + <> + {pinRoom && ( + { + setDrawerOpen(false); + onToggleCollapse(); + }} + > + + + )} + {editButtons} + + } + > +
+ {!editing && actions && ( +
+ {actions} +
+ )} + {body} +
+
+ )} ); } diff --git a/frontend/src/components/ui/surfaces.ts b/frontend/src/components/ui/surfaces.ts index ae642811..403442f6 100644 --- a/frontend/src/components/ui/surfaces.ts +++ b/frontend/src/components/ui/surfaces.ts @@ -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). */ diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index e995c012..28e3db2a 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -13,6 +13,7 @@ "collapse": "Collapse", "pin": "Pin panel open", "unpin": "Unpin panel", + "openPanel": "Open {{title}}", "any": "Any", "all": "All", "from": "From", diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index 0edb212d..cfb3e83a 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -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", diff --git a/frontend/src/index.css b/frontend/src/index.css index 316fa24f..b394f4f9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; } }