Files
spsays-training/app/search/filters-menu.tsx
T
2026-05-31 11:09:43 +05:30

300 lines
8.5 KiB
TypeScript

"use client";
/**
* SearchFiltersMenu — collapses the search-page filter chip row into
* a single trigger + multi-select dropdown.
*
* Trigger button:
* • Shows "Filters" + a count badge when one or more filters are
* active. The badge sums Users (1, if on) OR content-type count
* (Users is mutually exclusive with content narrowing).
*
* Dropdown content:
* • "Mode" section — single Users toggle (drives the @ prefix on
* the input via onToggleUsers).
* • "Content types" section — Books / Letters / Transcriptions / …
* each a checkbox. When Users is on, this group is disabled
* (content narrowing doesn't apply to user search).
*
* Layout: popover anchored to the trigger on desktop; bottom drawer on
* mobile — same modal-vs-drawer convention the rest of the app's menus
* use (PostCardMenu, SharePostSheet, CollectionsDrawer).
*
* "All" is intentionally NOT a checkbox — it's the implicit state when
* zero content types are selected. We render no row for it.
*/
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
AtSign,
Check,
ChevronDown,
SlidersHorizontal,
} from "lucide-react";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
export type ContentTab = { readonly label: string; readonly value: string };
export function SearchFiltersMenu({
contentTabs,
selectedTypes,
onTypeChange,
isUserChipActive,
onToggleUsers,
isMobile,
}: {
contentTabs: readonly ContentTab[];
selectedTypes: Set<string>;
onTypeChange: (value: string) => void;
isUserChipActive: boolean;
onToggleUsers: () => void;
isMobile: boolean;
}) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
// Users mode is mutually exclusive with content narrowing, so the
// active count is one OR the other — never their sum.
const activeCount = isUserChipActive ? 1 : selectedTypes.size;
// Desktop popover positioning — below the trigger, left-aligned.
useEffect(() => {
if (!open || isMobile) {
setPos(null);
return;
}
function recompute() {
const btn = triggerRef.current;
if (!btn) return;
const rect = btn.getBoundingClientRect();
const width = 280;
const pad = 8;
let left = rect.left;
if (left + width > window.innerWidth - pad) {
left = window.innerWidth - width - pad;
}
if (left < pad) left = pad;
const top = rect.bottom + 6;
setPos({ top, left });
}
recompute();
window.addEventListener("resize", recompute);
window.addEventListener("scroll", recompute, true);
return () => {
window.removeEventListener("resize", recompute);
window.removeEventListener("scroll", recompute, true);
};
}, [open, isMobile]);
// Click-outside + Escape close (desktop only; mobile uses Drawer's
// own dismiss handlers).
useEffect(() => {
if (!open || isMobile) return;
function onPointer(e: PointerEvent) {
if (popoverRef.current?.contains(e.target as Node)) return;
if (triggerRef.current?.contains(e.target as Node)) return;
setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
const t = setTimeout(() => {
document.addEventListener("pointerdown", onPointer);
document.addEventListener("keydown", onKey);
}, 0);
return () => {
clearTimeout(t);
document.removeEventListener("pointerdown", onPointer);
document.removeEventListener("keydown", onKey);
};
}, [open, isMobile]);
const trigger = (
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="menu"
className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
activeCount > 0
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground",
)}
>
<SlidersHorizontal className="size-4" />
Filters
{activeCount > 0 ? (
<span
className={cn(
"ml-0.5 inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-semibold leading-none tabular-nums",
"bg-primary-foreground/20 text-primary-foreground",
)}
>
{activeCount}
</span>
) : null}
<ChevronDown
className={cn(
"size-3.5 transition-transform",
open && "rotate-180",
)}
aria-hidden
/>
</button>
);
const body = (
<div className="py-2">
<FilterSection label="Mode">
<FilterRow
label="Users"
icon={<AtSign className="size-4" />}
checked={isUserChipActive}
onChange={onToggleUsers}
/>
</FilterSection>
<div className="border-t border-border my-1" />
<FilterSection label="Content types">
{contentTabs
// "All" is the implicit default state — no row needed.
.filter((t) => t.value !== "all")
.map((tab) => {
const checked =
!isUserChipActive && selectedTypes.has(tab.value);
return (
<FilterRow
key={tab.value}
label={tab.label}
checked={checked}
disabled={isUserChipActive}
onChange={() => onTypeChange(tab.value)}
/>
);
})}
</FilterSection>
</div>
);
// Mobile: bottom drawer
if (isMobile) {
return (
<>
{trigger}
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="bg-card">
<DrawerHeader className="text-left border-b border-border pb-3">
<DrawerTitle>Filters</DrawerTitle>
</DrawerHeader>
{body}
</DrawerContent>
</Drawer>
</>
);
}
// Desktop: portaled popover
return (
<>
{trigger}
{open && pos
? createPortal(
<div
ref={popoverRef}
role="menu"
style={{
position: "fixed",
top: pos.top,
left: pos.left,
zIndex: 60,
}}
className={cn(
"w-[280px] rounded-lg border border-border bg-card shadow-lg",
"animate-in fade-in-0 zoom-in-95",
)}
>
{body}
</div>,
document.body,
)
: null}
</>
);
}
// ─── Pieces ────────────────────────────────────────────────────────────
function FilterSection({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="px-2 py-1.5">
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-muted-foreground/70 mb-1.5 px-2">
{label}
</p>
{children}
</div>
);
}
function FilterRow({
label,
icon,
checked,
disabled = false,
onChange,
}: {
label: string;
icon?: React.ReactNode;
checked: boolean;
disabled?: boolean;
onChange: () => void;
}) {
return (
<button
type="button"
role="menuitemcheckbox"
aria-checked={checked}
disabled={disabled}
onClick={onChange}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors",
"hover:bg-muted focus:bg-muted outline-none",
disabled &&
"opacity-40 cursor-not-allowed hover:bg-transparent focus:bg-transparent",
)}
>
<span
className={cn(
"size-[18px] rounded border-2 flex items-center justify-center shrink-0 transition-colors",
checked
? "bg-primary border-primary"
: "bg-background border-muted-foreground/40",
)}
>
{checked ? (
<Check className="size-3 text-primary-foreground" strokeWidth={3} />
) : null}
</span>
{icon ? (
<span className="text-muted-foreground shrink-0">{icon}</span>
) : null}
<span className="text-sm text-foreground flex-1 truncate">{label}</span>
</button>
);
}