300 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|