Боковая панель на узком экране: строка с «Меню» и выезжающая панель (#69)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f8610abc81
commit
c9672183cf
@ -139,7 +139,7 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
|
|||||||
.who.none::before{background:transparent;border:1px solid var(--color-text-secondary)}
|
.who.none::before{background:transparent;border:1px solid var(--color-text-secondary)}
|
||||||
|
|
||||||
/* 7. Панель проблем справа от доски, липнет под шапкой; прокручивается содержимое внутри рамки Panel */
|
/* 7. Панель проблем справа от доски, липнет под шапкой; прокручивается содержимое внутри рамки Panel */
|
||||||
.drawer{flex:0 0 340px;max-width:40%;box-sizing:border-box;align-self:flex-start;position:sticky;top:calc(var(--head-h,0px) + 8px);max-height:calc(100vh - var(--head-h,0px) - 24px);display:flex;font-size:var(--fs-body)}
|
.drawer{flex:0 0 340px;max-width:40%;box-sizing:border-box;align-self:flex-start;position:sticky;top:calc(var(--topbar-h,0px) + var(--head-h,0px) + 8px);max-height:calc(100vh - var(--topbar-h,0px) - var(--head-h,0px) - 24px);display:flex;font-size:var(--fs-body)}
|
||||||
.drawer>.ui-panel{display:flex;flex-direction:column;gap:var(--space-3);min-height:0;min-width:0;flex:1 1 auto;padding:var(--space-4) 0 var(--space-4) var(--space-4)}
|
.drawer>.ui-panel{display:flex;flex-direction:column;gap:var(--space-3);min-height:0;min-width:0;flex:1 1 auto;padding:var(--space-4) 0 var(--space-4) var(--space-4)}
|
||||||
.drawer-head{display:flex;justify-content:space-between;align-items:center;gap:var(--space-2);padding-right:var(--space-4)}
|
.drawer-head{display:flex;justify-content:space-between;align-items:center;gap:var(--space-2);padding-right:var(--space-4)}
|
||||||
.drawer-body{overflow-y:auto;min-height:0;padding-right:var(--space-4)}
|
.drawer-body{overflow-y:auto;min-height:0;padding-right:var(--space-4)}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createMemoryRouter } from "react-router";
|
import { createMemoryRouter } from "react-router";
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { App } from "../App.tsx";
|
import { App } from "../App.tsx";
|
||||||
import { getAuth, setToken } from "../auth/token.ts";
|
import { getAuth, setToken } from "../auth/token.ts";
|
||||||
import settings from "../board/__fixtures__/settings.json";
|
import settings from "../board/__fixtures__/settings.json";
|
||||||
@ -177,3 +177,55 @@ describe("боковая панель", () => {
|
|||||||
expect(bar()).not.toHaveClass("is-collapsed");
|
expect(bar()).not.toHaveClass("is-collapsed");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("боковая панель на узком экране", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setToken("tok"); localStorage.clear();
|
||||||
|
vi.stubGlobal("matchMedia", (q: string) => ({ matches: q.includes("max-width: 639px"), media: q, addEventListener() {}, removeEventListener() {} }));
|
||||||
|
});
|
||||||
|
afterEach(() => { vi.unstubAllGlobals(); });
|
||||||
|
|
||||||
|
it("сверху строка с «Меню», названием и «Обновить из Jira»; панели нет", async () => {
|
||||||
|
hub();
|
||||||
|
open("/app/board?project=AWPCA");
|
||||||
|
const top = await screen.findByRole("banner", { name: "Строка приложения" });
|
||||||
|
expect(within(top).getByRole("button", { name: "Меню" })).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(within(top).getByText("Хаб Jira")).toBeInTheDocument();
|
||||||
|
expect(within(top).getByRole("button", { name: "Обновить из Jira" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("«Меню» открывает панель с фокусом на первом разделе; Escape закрывает и возвращает фокус", async () => {
|
||||||
|
hub();
|
||||||
|
open("/app/board?project=AWPCA");
|
||||||
|
const menu = await screen.findByRole("button", { name: "Меню" });
|
||||||
|
await userEvent.click(menu);
|
||||||
|
expect(menu).toHaveAttribute("aria-expanded", "true");
|
||||||
|
const nav = within(bar()).getByRole("navigation", { name: "Разделы" });
|
||||||
|
expect(within(nav).getByRole("link", { name: "Доска связей" })).toHaveFocus();
|
||||||
|
expect(within(bar()).queryByRole("button", { name: "Свернуть панель" })).toBeNull();
|
||||||
|
await userEvent.keyboard("{Escape}");
|
||||||
|
expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
|
||||||
|
expect(menu).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("выбор раздела закрывает панель и открывает раздел", async () => {
|
||||||
|
hub();
|
||||||
|
const router = open("/app/board?project=AWPCA");
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Меню" }));
|
||||||
|
await userEvent.click(within(bar()).getByRole("link", { name: "Задачи" }));
|
||||||
|
expect(router.state.location.pathname).toBe("/app/issues");
|
||||||
|
expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("щелчок по подложке и «Закрыть меню» закрывают панель", async () => {
|
||||||
|
hub();
|
||||||
|
open("/app/board?project=AWPCA");
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Меню" }));
|
||||||
|
await userEvent.click(screen.getByTestId("sidebar-backdrop"));
|
||||||
|
expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Меню" }));
|
||||||
|
await userEvent.click(within(bar()).getByRole("button", { name: "Закрыть меню" }));
|
||||||
|
expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { clearToken } from "../auth/token.ts";
|
import { clearToken } from "../auth/token.ts";
|
||||||
import { withProject } from "../projects/current.ts";
|
import { withProject } from "../projects/current.ts";
|
||||||
import { ProjectPicker } from "../projects/ProjectPicker.tsx";
|
import { ProjectPicker } from "../projects/ProjectPicker.tsx";
|
||||||
import { IconBoard, IconButton, IconList, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts";
|
import { IconBoard, IconButton, IconClose, IconList, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts";
|
||||||
import { DataStatus } from "./DataStatus.tsx";
|
import { DataStatus } from "./DataStatus.tsx";
|
||||||
import { readSidebar, saveSidebar } from "./sidebarPref.ts";
|
import { readSidebar, saveSidebar } from "./sidebarPref.ts";
|
||||||
|
|
||||||
@ -15,19 +15,69 @@ const SECTIONS: { to: Section; label: string; icon: React.ReactNode }[] = [
|
|||||||
{ to: "/issues", label: "Задачи", icon: <IconList /> },
|
{ to: "/issues", label: "Задачи", icon: <IconList /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
// боковая панель (#69, design 1–3); section = null на путях вне разделов — активной ссылки нет:
|
// узкий экран (<640 px): панель не в потоке, а выезжает по кнопке «Меню» из закреплённой строки (design 4)
|
||||||
// название и свёртка, проект, разделы, внизу состояние данных, тема, меню «Ещё»
|
const NARROW = "(max-width: 639px)";
|
||||||
export function Sidebar({ section, project }: { section: Section | null; project: string | null }) {
|
const subscribe = (cb: () => void) => {
|
||||||
|
if (typeof matchMedia !== "function") return () => {};
|
||||||
|
const m = matchMedia(NARROW);
|
||||||
|
m.addEventListener?.("change", cb);
|
||||||
|
return () => m.removeEventListener?.("change", cb);
|
||||||
|
};
|
||||||
|
const isNarrow = () => typeof matchMedia === "function" && matchMedia(NARROW).matches;
|
||||||
|
|
||||||
|
type Props = { section: Section | null; project: string | null };
|
||||||
|
|
||||||
|
// боковая панель (#69, design 1–4); section = null на путях вне разделов — активной ссылки нет
|
||||||
|
export function Sidebar(p: Props) {
|
||||||
|
const narrow = useSyncExternalStore(subscribe, isNarrow);
|
||||||
|
return narrow ? <NarrowShell {...p} /> : <Panel {...p} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NarrowShell(p: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const menuBtn = useRef<HTMLButtonElement>(null);
|
||||||
|
// объект стабилен, пока панель открыта: эффект фокуса и Escape в Panel не перезапускается на каждой отрисовке
|
||||||
|
const drawer = useMemo(() => ({ close: () => { setOpen(false); menuBtn.current?.focus(); }, navigated: () => setOpen(false) }), []);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="topbar" aria-label="Строка приложения">
|
||||||
|
<button ref={menuBtn} type="button" className="ui-icon-btn" aria-label="Меню" title="Меню" aria-expanded={open}
|
||||||
|
onClick={() => setOpen(true)}><IconMenu /></button>
|
||||||
|
<span className="sidebar__brand">Хаб Jira</span>
|
||||||
|
<span className="sidebar__spacer" />
|
||||||
|
<DataStatus project={p.project} compact />
|
||||||
|
</header>
|
||||||
|
{open && <>
|
||||||
|
<div className="sidebar-backdrop" data-testid="sidebar-backdrop" onClick={drawer.close} />
|
||||||
|
<Panel {...p} drawer={drawer} />
|
||||||
|
</>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// содержимое панели: название и свёртка (в выезжающей — «Закрыть меню»), проект, разделы, внизу состояние данных, тема, меню «Ещё»
|
||||||
|
function Panel({ section, project, drawer }: Props & { drawer?: { close(): void; navigated(): void } }) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [collapsed, setCollapsed] = useState(readSidebar);
|
const [stored, setCollapsed] = useState(readSidebar);
|
||||||
|
const collapsed = !drawer && stored;
|
||||||
const fold = (c: boolean) => { setCollapsed(c); saveSidebar(c); };
|
const fold = (c: boolean) => { setCollapsed(c); saveSidebar(c); };
|
||||||
const href = (to: Section) => (project ? to + withProject("", project) : to);
|
const href = (to: Section) => (project ? to + withProject("", project) : to);
|
||||||
|
const first = useRef<HTMLAnchorElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!drawer) return;
|
||||||
|
first.current?.focus();
|
||||||
|
const esc = (e: KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented) drawer.close(); };
|
||||||
|
document.addEventListener("keydown", esc);
|
||||||
|
return () => document.removeEventListener("keydown", esc);
|
||||||
|
}, [drawer]);
|
||||||
return (
|
return (
|
||||||
<aside className={"sidebar" + (collapsed ? " is-collapsed" : "")} aria-label="Панель приложения">
|
<aside className={"sidebar" + (collapsed ? " is-collapsed" : "") + (drawer ? " is-drawer" : "")} aria-label="Панель приложения">
|
||||||
<div className="sidebar__head">
|
<div className="sidebar__head">
|
||||||
{!collapsed && <span className="sidebar__brand">Хаб Jira</span>}
|
{!collapsed && <span className="sidebar__brand">Хаб Jira</span>}
|
||||||
<IconButton label={collapsed ? "Развернуть панель" : "Свернуть панель"} icon={<IconSidebar />}
|
{drawer
|
||||||
aria-expanded={!collapsed} onClick={() => fold(!collapsed)} />
|
? <IconButton label="Закрыть меню" icon={<IconClose />} onClick={drawer.close} />
|
||||||
|
: <IconButton label={collapsed ? "Развернуть панель" : "Свернуть панель"} icon={<IconSidebar />}
|
||||||
|
aria-expanded={!collapsed} onClick={() => fold(!collapsed)} />}
|
||||||
</div>
|
</div>
|
||||||
{collapsed
|
{collapsed
|
||||||
? project && (
|
? project && (
|
||||||
@ -35,9 +85,9 @@ export function Sidebar({ section, project }: { section: Section | null; project
|
|||||||
title={`Проект ${project}`} onClick={() => fold(false)}>{project.slice(0, 6)}</button>)
|
title={`Проект ${project}`} onClick={() => fold(false)}>{project.slice(0, 6)}</button>)
|
||||||
: <ProjectPicker />}
|
: <ProjectPicker />}
|
||||||
<nav className="sidebar__nav" aria-label="Разделы">
|
<nav className="sidebar__nav" aria-label="Разделы">
|
||||||
{SECTIONS.map((s) => (
|
{SECTIONS.map((s, i) => (
|
||||||
<Link key={s.to} to={href(s.to)} className="sidebar__link" aria-current={section === s.to ? "page" : undefined}
|
<Link key={s.to} ref={i === 0 ? first : undefined} to={href(s.to)} className="sidebar__link"
|
||||||
title={collapsed ? s.label : undefined}>
|
aria-current={section === s.to ? "page" : undefined} title={collapsed ? s.label : undefined} onClick={drawer?.navigated}>
|
||||||
{s.icon}<span className={collapsed ? "visually-hidden" : undefined}>{s.label}</span>
|
{s.icon}<span className={collapsed ? "visually-hidden" : undefined}>{s.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -46,3 +46,15 @@
|
|||||||
.data-status--compact .data-status__dot{position:absolute;top:2px;right:2px;pointer-events:none;box-shadow:0 0 0 2px var(--color-bg-surface)}
|
.data-status--compact .data-status__dot{position:absolute;top:2px;right:2px;pointer-events:none;box-shadow:0 0 0 2px var(--color-bg-surface)}
|
||||||
.crash{max-width:640px;margin:48px auto}
|
.crash{max-width:640px;margin:48px auto}
|
||||||
.crash h2{margin:0;font:inherit}
|
.crash h2{margin:0;font:inherit}
|
||||||
|
/* узкий экран (<640 px): закреплённая строка с «Меню» и выезжающая панель поверх страницы (design 4) */
|
||||||
|
.topbar{
|
||||||
|
position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:var(--space-2);height:56px;box-sizing:border-box;
|
||||||
|
padding:0 var(--space-4) 0 var(--space-2);border-bottom:1px solid var(--color-border-default);background:var(--color-bg-surface);
|
||||||
|
}
|
||||||
|
.topbar .sidebar__brand{padding-left:0}
|
||||||
|
.sidebar-backdrop{position:fixed;inset:0;z-index:30;background:var(--color-overlay)}
|
||||||
|
.sidebar.is-drawer{position:fixed;left:0;top:0;bottom:0;z-index:31;width:min(300px,85vw);height:auto;box-shadow:var(--shadow-popover)}
|
||||||
|
@media (max-width:639px){
|
||||||
|
.shell{display:block}
|
||||||
|
:root{--topbar-h:56px}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user