Move controls into responsive sidebar drawer

This commit is contained in:
2026-08-05 13:59:12 +02:00
parent 639d9bec75
commit 1963773299
8 changed files with 113 additions and 15 deletions
+1
View File
@@ -36,6 +36,7 @@ This project replaces the Excel/VBA workflow named `Catalog maker - 20` with a N
- Run `npm run css:build` after styling changes; `public/styles.css` is generated output. - Run `npm run css:build` after styling changes; `public/styles.css` is generated output.
- Prefer Tailwind utility classes and Tailwind-style component patterns for buttons, panels, dialogs and responsive layouts. - Prefer Tailwind utility classes and Tailwind-style component patterns for buttons, panels, dialogs and responsive layouts.
- Whenever a reusable pattern appears, prefer Tailwind component patterns (`@layer components` with `@apply`) over handwritten legacy CSS; keep one-off layout details as utility classes where practical. - Whenever a reusable pattern appears, prefer Tailwind component patterns (`@layer components` with `@apply`) over handwritten legacy CSS; keep one-off layout details as utility classes where practical.
- Whenever possible, use Tailwind UI / Tailwind Plus application components and patterns for shells, multi-column layouts, sidebars, drawers, dialogs, navigation and forms; adapt them to this project's dense catalog workflow instead of inventing a separate visual pattern.
- Use Playwright to inspect the rendered result after every visual change at desktop and narrow viewport sizes. - Use Playwright to inspect the rendered result after every visual change at desktop and narrow viewport sizes.
## TypeScript workflow ## TypeScript workflow
+33
View File
@@ -22,6 +22,10 @@ const findSourcesButton = document.querySelector("#findSourcesButton");
const getPicturesButton = document.querySelector("#getPicturesButton"); const getPicturesButton = document.querySelector("#getPicturesButton");
const parserDrawerToggle = document.querySelector("#parserDrawerToggle"); const parserDrawerToggle = document.querySelector("#parserDrawerToggle");
const parserDrawer = document.querySelector("#parserDrawer"); const parserDrawer = document.querySelector("#parserDrawer");
const sidebarDrawer = document.querySelector("#sidebarDrawer");
const sidebarOpenButton = document.querySelector("#sidebarOpenButton");
const sidebarCloseButton = document.querySelector("#sidebarCloseButton");
const sidebarOverlay = document.querySelector("#sidebarOverlay");
const sourceModal = document.querySelector("#sourceModal"); const sourceModal = document.querySelector("#sourceModal");
const sourceTitle = document.querySelector("#sourceTitle"); const sourceTitle = document.querySelector("#sourceTitle");
const sourceList = document.querySelector("#sourceList"); const sourceList = document.querySelector("#sourceList");
@@ -140,6 +144,13 @@ loadProductButton.addEventListener("click", loadProductInfoForCurrentProduct);
findSourcesButton.addEventListener("click", findSourcesForCurrentProduct); findSourcesButton.addEventListener("click", findSourcesForCurrentProduct);
getPicturesButton.addEventListener("click", getPicturesForCurrentProduct); getPicturesButton.addEventListener("click", getPicturesForCurrentProduct);
parserDrawerToggle.addEventListener("click", toggleParserDrawer); parserDrawerToggle.addEventListener("click", toggleParserDrawer);
sidebarOpenButton.addEventListener("click", openSidebar);
sidebarCloseButton.addEventListener("click", closeSidebar);
sidebarOverlay.addEventListener("click", closeSidebar);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape")
closeSidebar();
});
previousProductButton.addEventListener("click", () => { previousProductButton.addEventListener("click", () => {
if (!manufacturerSelect.value || currentPosition <= 1) if (!manufacturerSelect.value || currentPosition <= 1)
return; return;
@@ -183,6 +194,8 @@ for (const input of localDbInputs)
input.addEventListener("change", saveLocalDbSettings); input.addEventListener("change", saveLocalDbSettings);
testLocalDbButton.addEventListener("click", testLocalDbConnection); testLocalDbButton.addEventListener("click", testLocalDbConnection);
loadDatabaseMode(); loadDatabaseMode();
syncSidebarForViewport();
window.matchMedia("(min-width: 981px)").addEventListener("change", syncSidebarForViewport);
setTimeout(() => { setTimeout(() => {
if (manufacturerSelect.value) { if (manufacturerSelect.value) {
loadMappingSources(); loadMappingSources();
@@ -756,6 +769,26 @@ function toggleParserDrawer() {
parserDrawerToggle.setAttribute("aria-expanded", String(!isOpen)); parserDrawerToggle.setAttribute("aria-expanded", String(!isOpen));
parserDrawerToggle.querySelector(".drawer-chevron").textContent = isOpen ? "⌄" : "⌃"; parserDrawerToggle.querySelector(".drawer-chevron").textContent = isOpen ? "⌄" : "⌃";
} }
function syncSidebarForViewport() {
if (window.matchMedia("(min-width: 981px)").matches)
openSidebar();
else
closeSidebar();
}
function openSidebar() {
sidebarDrawer.classList.add("is-open");
sidebarOverlay.hidden = false;
sidebarOpenButton.hidden = true;
sidebarOpenButton.setAttribute("aria-expanded", "true");
sidebarDrawer.setAttribute("aria-hidden", "false");
}
function closeSidebar() {
sidebarDrawer.classList.remove("is-open");
sidebarOverlay.hidden = true;
sidebarOpenButton.hidden = false;
sidebarOpenButton.setAttribute("aria-expanded", "false");
sidebarDrawer.setAttribute("aria-hidden", "true");
}
function loadTheme() { function loadTheme() {
const isDark = localStorage.getItem("catalog-maker:theme") === "dark"; const isDark = localStorage.getItem("catalog-maker:theme") === "dark";
document.documentElement.classList.toggle("dark", isDark); document.documentElement.classList.toggle("dark", isDark);
+34 -1
View File
@@ -22,6 +22,10 @@ const findSourcesButton = document.querySelector("#findSourcesButton");
const getPicturesButton = document.querySelector("#getPicturesButton"); const getPicturesButton = document.querySelector("#getPicturesButton");
const parserDrawerToggle = document.querySelector("#parserDrawerToggle"); const parserDrawerToggle = document.querySelector("#parserDrawerToggle");
const parserDrawer = document.querySelector("#parserDrawer"); const parserDrawer = document.querySelector("#parserDrawer");
const sidebarDrawer = document.querySelector("#sidebarDrawer");
const sidebarOpenButton = document.querySelector("#sidebarOpenButton");
const sidebarCloseButton = document.querySelector("#sidebarCloseButton");
const sidebarOverlay = document.querySelector("#sidebarOverlay");
const sourceModal = document.querySelector("#sourceModal"); const sourceModal = document.querySelector("#sourceModal");
const sourceTitle = document.querySelector("#sourceTitle"); const sourceTitle = document.querySelector("#sourceTitle");
const sourceList = document.querySelector("#sourceList"); const sourceList = document.querySelector("#sourceList");
@@ -147,6 +151,12 @@ loadProductButton.addEventListener("click", loadProductInfoForCurrentProduct);
findSourcesButton.addEventListener("click", findSourcesForCurrentProduct); findSourcesButton.addEventListener("click", findSourcesForCurrentProduct);
getPicturesButton.addEventListener("click", getPicturesForCurrentProduct); getPicturesButton.addEventListener("click", getPicturesForCurrentProduct);
parserDrawerToggle.addEventListener("click", toggleParserDrawer); parserDrawerToggle.addEventListener("click", toggleParserDrawer);
sidebarOpenButton.addEventListener("click", openSidebar);
sidebarCloseButton.addEventListener("click", closeSidebar);
sidebarOverlay.addEventListener("click", closeSidebar);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeSidebar();
});
previousProductButton.addEventListener("click", () => { previousProductButton.addEventListener("click", () => {
if (!manufacturerSelect.value || currentPosition <= 1) return; if (!manufacturerSelect.value || currentPosition <= 1) return;
@@ -185,6 +195,8 @@ for (const input of localDbInputs) input.addEventListener("change", saveLocalDbS
testLocalDbButton.addEventListener("click", testLocalDbConnection); testLocalDbButton.addEventListener("click", testLocalDbConnection);
loadDatabaseMode(); loadDatabaseMode();
syncSidebarForViewport();
window.matchMedia("(min-width: 981px)").addEventListener("change", syncSidebarForViewport);
setTimeout(() => { setTimeout(() => {
if (manufacturerSelect.value) { if (manufacturerSelect.value) {
@@ -837,6 +849,27 @@ function toggleParserDrawer() {
parserDrawerToggle.querySelector(".drawer-chevron").textContent = isOpen ? "⌄" : "⌃"; parserDrawerToggle.querySelector(".drawer-chevron").textContent = isOpen ? "⌄" : "⌃";
} }
function syncSidebarForViewport() {
if (window.matchMedia("(min-width: 981px)").matches) openSidebar();
else closeSidebar();
}
function openSidebar() {
sidebarDrawer.classList.add("is-open");
sidebarOverlay.hidden = false;
sidebarOpenButton.hidden = true;
sidebarOpenButton.setAttribute("aria-expanded", "true");
sidebarDrawer.setAttribute("aria-hidden", "false");
}
function closeSidebar() {
sidebarDrawer.classList.remove("is-open");
sidebarOverlay.hidden = true;
sidebarOpenButton.hidden = false;
sidebarOpenButton.setAttribute("aria-expanded", "false");
sidebarDrawer.setAttribute("aria-hidden", "true");
}
function loadTheme() { function loadTheme() {
const isDark = localStorage.getItem("catalog-maker:theme") === "dark"; const isDark = localStorage.getItem("catalog-maker:theme") === "dark";
document.documentElement.classList.toggle("dark", isDark); document.documentElement.classList.toggle("dark", isDark);
@@ -1177,4 +1210,4 @@ async function loadProductInfoForCurrentProduct() {
} }
} }
export {}; export {};
+1
View File
@@ -12,4 +12,5 @@
<symbol id="erase" viewBox="0 0 24 24"><path d="m4 15 8-8a2 2 0 0 1 2.8 0l4.2 4.2a2 2 0 0 1 0 2.8l-5 5H7zM13 19h7" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8"/></symbol> <symbol id="erase" viewBox="0 0 24 24"><path d="m4 15 8-8a2 2 0 0 1 2.8 0l4.2 4.2a2 2 0 0 1 0 2.8l-5 5H7zM13 19h7" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8"/></symbol>
<symbol id="moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="1.8"/></symbol> <symbol id="moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="1.8"/></symbol>
<symbol id="settings" viewBox="0 0 24 24"><path d="M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="m19 13.5 1.2 1-.2 2-1.5.8-.3 1.6-1.8.9-1.4-.7-1.5.7-1.8-.9-.3-1.6-1.5-.8-.2-2 1.2-1-.1-1.5-1.2-1 .2-2 1.5-.8.3-1.6 1.8-.9 1.4.7 1.5-.7 1.8.9.3 1.6 1.5.8.2 2-1.2 1.1.1 1.5Z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="1.4"/></symbol> <symbol id="settings" viewBox="0 0 24 24"><path d="M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="m19 13.5 1.2 1-.2 2-1.5.8-.3 1.6-1.8.9-1.4-.7-1.5.7-1.8-.9-.3-1.6-1.5-.8-.2-2 1.2-1-.1-1.5-1.2-1 .2-2 1.5-.8.3-1.6 1.8-.9 1.4.7 1.5-.7 1.8.9.3 1.6 1.5.8.2 2-1.2 1.1.1 1.5Z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="1.4"/></symbol>
<symbol id="close" viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.8"/></symbol>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

+13 -1
View File
@@ -10,7 +10,19 @@
</head> </head>
<body> <body>
<main class="catalog-screen"> <main class="catalog-screen">
<aside class="control-panel"> <button class="sidebar-open-button" id="sidebarOpenButton" type="button" aria-controls="sidebarDrawer" aria-expanded="false" aria-label="Open sidebar" title="Open sidebar">
<svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#menu"></use></svg>
</button>
<button class="sidebar-overlay" id="sidebarOverlay" type="button" aria-label="Close sidebar"></button>
<aside class="control-panel sidebar-drawer" id="sidebarDrawer" aria-label="Catalog controls">
<div class="sidebar-drawer-header">
<span class="sidebar-drawer-title">Catalog controls</span>
<button class="sidebar-close-button" id="sidebarCloseButton" type="button" aria-label="Close sidebar" title="Close sidebar">
<svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#close"></use></svg>
</button>
</div>
<div class="record-id" id="recordId">&nbsp;</div> <div class="record-id" id="recordId">&nbsp;</div>
<div class="arrow-row"> <div class="arrow-row">
+1 -1
View File
File diff suppressed because one or more lines are too long
+20 -7
View File
@@ -41,8 +41,16 @@
} }
@layer components { @layer components {
.catalog-screen { @apply grid min-h-screen grid-cols-[172px_292px_minmax(720px,1fr)] bg-catalog-surface; } .catalog-screen { @apply relative grid min-h-screen grid-cols-[292px_minmax(0,1fr)] bg-catalog-surface pl-72; }
.control-panel { @apply flex min-h-screen flex-col gap-1.5 overflow-y-auto bg-slate-900 p-1.5 text-[11px] text-slate-200; } .sidebar-drawer { @apply fixed inset-y-0 left-0 z-40 flex w-72 min-h-screen flex-col gap-1.5 overflow-y-auto bg-slate-900 p-1.5 text-[11px] text-slate-200 shadow-xl transition-transform duration-200 ease-out; }
.sidebar-drawer-header { @apply flex items-center justify-between border-b border-slate-700 pb-1; }
.sidebar-drawer-title { @apply px-1 text-[10px] font-semibold uppercase tracking-wide text-slate-400; }
.sidebar-close-button, .sidebar-open-button { @apply inline-flex h-7 w-7 items-center justify-center rounded border border-slate-700 bg-slate-800 text-slate-200 shadow-sm transition hover:border-slate-500 hover:bg-slate-700; }
.sidebar-close-button .sidebar-icon, .sidebar-open-button .sidebar-icon { @apply h-4 w-4; }
.sidebar-open-button { @apply fixed left-2 top-2 z-30; }
.sidebar-overlay { @apply fixed inset-0 z-30 bg-slate-950/50; }
.sidebar-overlay[hidden] { display: none; }
.sidebar-drawer[aria-hidden="true"] { @apply -translate-x-full; }
.sidebar-section { @apply border-t border-slate-700 pt-1; } .sidebar-section { @apply border-t border-slate-700 pt-1; }
.drawer-trigger { @apply flex min-h-8 w-full items-center gap-2 rounded border border-slate-700 bg-slate-800 px-2 text-left text-[11px] font-semibold text-slate-100 transition hover:border-slate-500 hover:bg-slate-700; } .drawer-trigger { @apply flex min-h-8 w-full items-center gap-2 rounded border border-slate-700 bg-slate-800 px-2 text-left text-[11px] font-semibold text-slate-100 transition hover:border-slate-500 hover:bg-slate-700; }
.drawer-trigger .drawer-chevron { @apply ml-auto text-sm text-slate-400; } .drawer-trigger .drawer-chevron { @apply ml-auto text-sm text-slate-400; }
@@ -88,7 +96,7 @@
.mapping-source-status.is-ok { @apply bg-emerald-100 text-emerald-700; } .mapping-source-status.is-ok { @apply bg-emerald-100 text-emerald-700; }
.mapping-source-status.is-bad { @apply bg-red-100 text-red-700; } .mapping-source-status.is-bad { @apply bg-red-100 text-red-700; }
.mapping-source-status.is-muted { @apply bg-slate-200 text-slate-600; } .mapping-source-status.is-muted { @apply bg-slate-200 text-slate-600; }
.product-panel { @apply bg-slate-300 pt-[22px]; } .product-panel { @apply min-w-0 bg-slate-300 pt-[22px]; }
.field-grid { @apply grid grid-cols-[132px_minmax(0,168px)] content-start; } .field-grid { @apply grid grid-cols-[132px_minmax(0,168px)] content-start; }
.field-label, .field-value { @apply min-h-[22px] border-b border-slate-300 px-1 py-0.5 text-[11px] leading-tight; } .field-label, .field-value { @apply min-h-[22px] border-b border-slate-300 px-1 py-0.5 text-[11px] leading-tight; }
.field-label { @apply bg-slate-200 text-slate-800; } .field-label { @apply bg-slate-200 text-slate-800; }
@@ -147,14 +155,19 @@
.picture-candidate img { @apply h-16 w-[72px] object-contain; } .picture-candidate img { @apply h-16 w-[72px] object-contain; }
.picture-candidate span { @apply min-w-0 truncate; } .picture-candidate span { @apply min-w-0 truncate; }
@media (max-width: 980px) { @media (max-width: 980px) {
.catalog-screen { @apply block; } .catalog-screen { @apply block pl-0; }
.control-panel { @apply min-h-0 grid grid-cols-2; } .sidebar-drawer { @apply w-[min(18rem,calc(100vw-1rem))] min-h-0; }
.control-panel > * { @apply min-w-0; } .sidebar-drawer:not([aria-hidden="true"]) { @apply max-h-screen; }
.sidebar-footer { @apply col-span-2; } .sidebar-drawer[aria-hidden="true"] { @apply -translate-x-full; }
.sidebar-open-button { @apply left-2 top-2; }
.product-panel { @apply pt-0; } .product-panel { @apply pt-0; }
.table-wrap { @apply max-h-[70vh]; } .table-wrap { @apply max-h-[70vh]; }
} }
@media (min-width: 981px) {
.sidebar-overlay { display: none; }
}
html.dark body { @apply bg-slate-950 text-slate-100; } html.dark body { @apply bg-slate-950 text-slate-100; }
html.dark .control-panel { @apply bg-slate-950; } html.dark .control-panel { @apply bg-slate-950; }
html.dark .product-panel { @apply bg-slate-800; } html.dark .product-panel { @apply bg-slate-800; }
+10 -5
View File
@@ -27,17 +27,22 @@ test("catalog maker remains usable on a narrow viewport", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/"); await page.goto("/");
await expect(page.locator("#sidebarDrawer")).toHaveAttribute("aria-hidden", "true");
await page.locator("#sidebarOpenButton").click();
await expect(page.locator("#findSourcesButton")).toBeVisible(); await expect(page.locator("#findSourcesButton")).toBeVisible();
await expect(page.locator("#settingsButton")).toBeVisible(); await expect(page.locator("#settingsButton")).toBeVisible();
await expect(page.locator("#sidebarOverlay")).toBeVisible();
await page.screenshot({ path: "test-results/catalog-maker-mobile.png", fullPage: true }); await page.screenshot({ path: "test-results/catalog-maker-mobile.png", fullPage: true });
}); });
test("parser tools open in a sidebar drawer", async ({ page }) => { test("left controls open in a responsive sidebar drawer", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await expect(page.locator("#parserDrawer")).toBeVisible(); await expect(page.locator("#sidebarDrawer")).toBeVisible();
await page.locator("#parserDrawerToggle").click(); await page.locator("#sidebarCloseButton").click();
await expect(page.locator("#parserDrawer")).toBeHidden(); await expect(page.locator("#sidebarDrawer")).toHaveAttribute("aria-hidden", "true");
await expect(page.locator("#parserDrawerToggle")).toHaveAttribute("aria-expanded", "false"); await page.locator("#sidebarOpenButton").click();
await expect(page.locator("#sidebarDrawer")).toBeVisible();
await expect(page.locator("#sidebarOverlay")).toBeHidden();
}); });
test("theme toggle switches and persists dark mode", async ({ page }) => { test("theme toggle switches and persists dark mode", async ({ page }) => {