mirror of
https://github.com/runyanjake/cooking.git
synced 2026-11-10 15:14:50 -08:00
Extract responsive Sidebar component; fix mobile scroll bleed
The mobile filter drawer let touch scrolling chain through to the page. New components/Sidebar.tsx owns the drawer/sticky-column shell: locks page scroll while open, contains overscroll, closes on Escape/overlay/resize to lg, manages focus, and hides the closed drawer from tab order. RecipeLayout now composes Sidebar + RecipesSidebar; filtering is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
parent
d601895982
commit
a33ce23f91
@ -31,7 +31,8 @@ app/ # Next.js App Router pages
|
|||||||
components/
|
components/
|
||||||
Header.tsx / Footer.tsx # Site chrome
|
Header.tsx / Footer.tsx # Site chrome
|
||||||
RecipesClient.tsx # Recipe listing with filter state
|
RecipesClient.tsx # Recipe listing with filter state
|
||||||
RecipeLayout.tsx # Sidebar layout (mobile drawer, desktop persistent)
|
RecipeLayout.tsx # Recipe listing layout: filter button, Sidebar + results
|
||||||
|
Sidebar.tsx # Responsive sidebar shell (mobile drawer, desktop sticky column)
|
||||||
RecipesSidebar.tsx # Search + category/tag facet lists with result counts
|
RecipesSidebar.tsx # Search + category/tag facet lists with result counts
|
||||||
FacetGroup.tsx # Collapsible sidebar facet section
|
FacetGroup.tsx # Collapsible sidebar facet section
|
||||||
ActiveFilters.tsx # Removable chips for active filters (above results)
|
ActiveFilters.tsx # Removable chips for active filters (above results)
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
## State and Data Flow
|
## State and Data Flow
|
||||||
|
|
||||||
- **RecipeLayout** owns sidebar open/close state; passes `handleFilterChange` (memoised with `useCallback`) down to RecipesSidebar
|
- **RecipeLayout** owns sidebar open/close state; passes `handleFilterChange` (memoised with `useCallback`) down to RecipesSidebar
|
||||||
|
- **Sidebar** is the generic shell: a modal drawer below `lg`, a sticky column at `lg`+. While open it locks page scroll, closes on Escape/overlay/resize to `lg`, and moves focus in (restoring it on close). Its body has `overscroll-contain` so drawer scrolling never chains to the page. `onClose` must be stable (`useCallback`) since its effects depend on it
|
||||||
- **RecipesSidebar** owns local UI state (debounced search input, tag filter text, show-more toggles) and reports filter changes via `onFilterChange`
|
- **RecipesSidebar** owns local UI state (debounced search input, tag filter text, show-more toggles) and reports filter changes via `onFilterChange`
|
||||||
- **RecipesClient** owns filter state (synced to the URL), the filtered recipe list, and facet counts (all memoised with `useMemo`). Each facet's counts apply every filter except its own; options with zero results are hidden
|
- **RecipesClient** owns filter state (synced to the URL), the filtered recipe list, and facet counts (all memoised with `useMemo`). Each facet's counts apply every filter except its own; options with zero results are hidden
|
||||||
- **ActiveFilters** renders removable chips for the active filters above the results grid
|
- **ActiveFilters** renders removable chips for the active filters above the results grid
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, ReactNode } from 'react';
|
import { useState, useCallback, ReactNode } from 'react';
|
||||||
|
import Sidebar from './Sidebar';
|
||||||
import RecipesSidebar from './RecipesSidebar';
|
import RecipesSidebar from './RecipesSidebar';
|
||||||
import type { FacetCounts, FilterState } from '@/lib/types';
|
import type { FacetCounts, FilterState } from '@/lib/types';
|
||||||
|
|
||||||
@ -29,33 +30,17 @@ export default function RecipeLayout({
|
|||||||
onFilterChange(newFilters);
|
onFilterChange(newFilters);
|
||||||
}, [onFilterChange]);
|
}, [onFilterChange]);
|
||||||
|
|
||||||
// Close the mobile drawer on Escape
|
const closeSidebar = useCallback(() => setSidebarOpen(false), []);
|
||||||
useEffect(() => {
|
|
||||||
if (!sidebarOpen) return;
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') setSidebarOpen(false);
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [sidebarOpen]);
|
|
||||||
|
|
||||||
const activeFilterCount =
|
const activeFilterCount =
|
||||||
(filters.search ? 1 : 0) + (filters.category ? 1 : 0) + filters.selectedTags.length;
|
(filters.search ? 1 : 0) + (filters.category ? 1 : 0) + filters.selectedTags.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Mobile Overlay */}
|
|
||||||
{sidebarOpen && (
|
|
||||||
<div
|
|
||||||
className="lg:hidden fixed inset-0 bg-black/50 z-40"
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile filter button */}
|
{/* Mobile filter button */}
|
||||||
<div className="lg:hidden mb-4">
|
<div className="lg:hidden mb-4">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-700 px-4 py-2 text-sm font-medium text-gray-800 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 dark:border-gray-700 px-4 py-2 text-sm font-medium text-gray-800 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
aria-expanded={sidebarOpen}
|
aria-expanded={sidebarOpen}
|
||||||
@ -74,55 +59,29 @@ export default function RecipeLayout({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex lg:gap-10">
|
<div className="flex lg:gap-10">
|
||||||
{/* Sidebar - sticky column on desktop (lg+), slide-out drawer on mobile */}
|
<Sidebar
|
||||||
<aside
|
|
||||||
id="recipes-sidebar"
|
id="recipes-sidebar"
|
||||||
className={`
|
title="Filters"
|
||||||
fixed inset-y-0 left-0 z-50 w-80 max-w-[85vw]
|
open={sidebarOpen}
|
||||||
bg-white dark:bg-gray-900
|
onClose={closeSidebar}
|
||||||
transition-transform duration-300 ease-in-out
|
footer={
|
||||||
lg:sticky lg:top-8 lg:z-0 lg:w-64 lg:max-w-none lg:shrink-0 lg:self-start
|
<button
|
||||||
lg:max-h-[calc(100vh-4rem)] lg:bg-transparent lg:translate-x-0 lg:transition-none
|
type="button"
|
||||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
onClick={closeSidebar}
|
||||||
`}
|
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 transition-colors"
|
||||||
aria-label="Recipe filters"
|
>
|
||||||
|
Show {resultCount} {resultCount === 1 ? 'recipe' : 'recipes'}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="flex h-full flex-col lg:max-h-[calc(100vh-4rem)]">
|
<RecipesSidebar
|
||||||
{/* Mobile drawer header */}
|
categories={categories}
|
||||||
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-800">
|
tags={tags}
|
||||||
<span className="text-base font-semibold text-gray-900 dark:text-white">Filters</span>
|
facetCounts={facetCounts}
|
||||||
<button
|
filters={filters}
|
||||||
onClick={() => setSidebarOpen(false)}
|
onFilterChange={handleFilterChange}
|
||||||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
/>
|
||||||
aria-label="Close filters"
|
</Sidebar>
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 lg:p-0 lg:pr-2">
|
|
||||||
<RecipesSidebar
|
|
||||||
categories={categories}
|
|
||||||
tags={tags}
|
|
||||||
facetCounts={facetCounts}
|
|
||||||
filters={filters}
|
|
||||||
onFilterChange={handleFilterChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile drawer footer */}
|
|
||||||
<div className="lg:hidden border-t border-gray-200 dark:border-gray-800 p-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
Show {resultCount} {resultCount === 1 ? 'recipe' : 'recipes'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
114
components/Sidebar.tsx
Normal file
114
components/Sidebar.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
footer?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches Tailwind's `lg` breakpoint, where the sidebar becomes a persistent column
|
||||||
|
const DESKTOP_QUERY = '(min-width: 1024px)';
|
||||||
|
|
||||||
|
// Responsive sidebar: a modal drawer below `lg`, a sticky column at `lg` and up.
|
||||||
|
// While the drawer is open the page behind it can't scroll, and scrolling inside
|
||||||
|
// it never chains through to the page.
|
||||||
|
export default function Sidebar({ id, title, open, onClose, footer, children }: SidebarProps) {
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Lock page scroll, close on Escape, and move focus into the drawer while open
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
const previousOverflow = root.style.overflow;
|
||||||
|
root.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
const previousFocus = document.activeElement as HTMLElement | null;
|
||||||
|
panelRef.current?.focus({ preventScroll: true });
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
root.style.overflow = previousOverflow;
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
previousFocus?.focus({ preventScroll: true });
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
// The drawer only exists below `lg`; close it if the viewport grows past that
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const query = window.matchMedia(DESKTOP_QUERY);
|
||||||
|
const handleChange = () => {
|
||||||
|
if (query.matches) onClose();
|
||||||
|
};
|
||||||
|
handleChange();
|
||||||
|
query.addEventListener('change', handleChange);
|
||||||
|
return () => query.removeEventListener('change', handleChange);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="lg:hidden fixed inset-0 z-40 bg-black/50 overscroll-none"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<aside
|
||||||
|
id={id}
|
||||||
|
aria-label={title}
|
||||||
|
className={`
|
||||||
|
fixed inset-y-0 left-0 z-50 h-dvh w-80 max-w-[85vw]
|
||||||
|
bg-white dark:bg-gray-900 shadow-xl
|
||||||
|
transition-[transform,visibility] duration-300 ease-in-out
|
||||||
|
${open ? 'visible translate-x-0' : 'invisible -translate-x-full'}
|
||||||
|
lg:visible lg:sticky lg:top-8 lg:z-0 lg:h-auto lg:max-h-[calc(100vh-4rem)] lg:w-64 lg:max-w-none
|
||||||
|
lg:shrink-0 lg:self-start lg:translate-x-0 lg:bg-transparent lg:shadow-none lg:transition-none
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="flex h-full flex-col focus:outline-none lg:max-h-[calc(100vh-4rem)]"
|
||||||
|
>
|
||||||
|
{/* Drawer header (mobile only) */}
|
||||||
|
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<span className="text-base font-semibold text-gray-900 dark:text-white">{title}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
aria-label={`Close ${title.toLowerCase()}`}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrolls on its own; overscroll-contain stops it chaining to the page */}
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 lg:p-0 lg:pr-2">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{footer && (
|
||||||
|
<div className="lg:hidden border-t border-gray-200 dark:border-gray-800 p-4">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user