fix: add desktop search dropdown
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { Info, Search as SearchIcon, X } from "lucide-react";
|
import { Info, Search as SearchIcon, X } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState, type Ref } from "react";
|
||||||
import { getJSON, itemsOrEmpty, readJSONCache } from "../api";
|
import { getJSON, itemsOrEmpty, readJSONCache } from "../api";
|
||||||
import { langQuery, type Lang } from "../i18n";
|
import { langQuery, type Lang } from "../i18n";
|
||||||
import type { Post, PostListResponse } from "../types/post";
|
import type { Post, PostListResponse } from "../types/post";
|
||||||
@@ -16,6 +16,9 @@ type SearchPanelProps = {
|
|||||||
query: string;
|
query: string;
|
||||||
onQueryChange: (value: string) => void;
|
onQueryChange: (value: string) => void;
|
||||||
onSearch: () => void;
|
onSearch: () => void;
|
||||||
|
variant?: "mobile" | "desktop";
|
||||||
|
showInput?: boolean;
|
||||||
|
panelRef?: Ref<HTMLDivElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TAG_SOURCE_LIMIT = 80;
|
const TAG_SOURCE_LIMIT = 80;
|
||||||
@@ -59,6 +62,9 @@ export function SearchPanel({
|
|||||||
query,
|
query,
|
||||||
onQueryChange,
|
onQueryChange,
|
||||||
onSearch,
|
onSearch,
|
||||||
|
variant = "mobile",
|
||||||
|
showInput = true,
|
||||||
|
panelRef,
|
||||||
}: SearchPanelProps) {
|
}: SearchPanelProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [tags, setTags] = useState<TagItem[]>([]);
|
const [tags, setTags] = useState<TagItem[]>([]);
|
||||||
@@ -70,12 +76,13 @@ export function SearchPanel({
|
|||||||
const langParam = useMemo(() => langQuery(lang), [lang]);
|
const langParam = useMemo(() => langQuery(lang), [lang]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!showInput) return;
|
||||||
// Avoid scroll-into-view: browsers default-scroll the focused element
|
// Avoid scroll-into-view: browsers default-scroll the focused element
|
||||||
// into the viewport, which moves the underlying page when the search
|
// into the viewport, which moves the underlying page when the search
|
||||||
// overlay opens from a scrolled position. `preventScroll` keeps the page
|
// overlay opens from a scrolled position. `preventScroll` keeps the page
|
||||||
// exactly where it was.
|
// exactly where it was.
|
||||||
inputRef.current?.focus({ preventScroll: true });
|
inputRef.current?.focus({ preventScroll: true });
|
||||||
}, []);
|
}, [showInput]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -143,9 +150,20 @@ export function SearchPanel({
|
|||||||
.finally(() => setIsPostLoading(false));
|
.finally(() => setIsPostLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const panelClassName =
|
||||||
|
variant === "desktop"
|
||||||
|
? "ark-header-menu-enter fixed left-1/2 top-[72px] z-50 max-h-[min(70dvh,640px)] w-[min(720px,calc(100vw-3rem))] -translate-x-1/2 overflow-y-auto overscroll-contain rounded-3xl border border-white/10 bg-[#0f0f13] shadow-2xl shadow-black/60"
|
||||||
|
: "ark-header-menu-enter fixed inset-x-0 bottom-0 top-[64px] z-50 overflow-y-auto overscroll-contain bg-[#0f0f13] md:hidden";
|
||||||
|
const innerClassName =
|
||||||
|
variant === "desktop"
|
||||||
|
? "px-5 pb-6 pt-4"
|
||||||
|
: "border-t border-white/10 px-5 pb-6 pt-3 max-[360px]:px-3";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ark-header-menu-enter fixed inset-x-0 bottom-0 top-[64px] z-50 overflow-y-auto overscroll-contain bg-[#0f0f13] md:hidden">
|
<div ref={panelRef} className={panelClassName}>
|
||||||
<div className="border-t border-white/10 px-5 pb-6 pt-3 max-[360px]:px-3">
|
<div className={innerClassName}>
|
||||||
|
{showInput ? (
|
||||||
|
<>
|
||||||
<div className="flex h-12 items-center gap-2">
|
<div className="flex h-12 items-center gap-2">
|
||||||
<div className="flex h-11 min-w-0 flex-1 items-center gap-2 rounded-full bg-[#191921] px-3 shadow-[0_0_0_2px_rgba(245,180,53,0.12)] ring-1 ring-inset ring-ark-gold">
|
<div className="flex h-11 min-w-0 flex-1 items-center gap-2 rounded-full bg-[#191921] px-3 shadow-[0_0_0_2px_rgba(245,180,53,0.12)] ring-1 ring-inset ring-ark-gold">
|
||||||
<SearchIcon size={18} className="shrink-0 text-ark-gold" />
|
<SearchIcon size={18} className="shrink-0 text-ark-gold" />
|
||||||
@@ -179,8 +197,15 @@ export function SearchPanel({
|
|||||||
<Info size={14} className="shrink-0" />
|
<Info size={14} className="shrink-0" />
|
||||||
<span>{t("searchPanelHint")}</span>
|
<span>{t("searchPanelHint")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="mb-4 flex items-center gap-1.5 pl-1 text-[12px] leading-4 text-[#777985]">
|
||||||
|
<Info size={14} className="shrink-0" />
|
||||||
|
<span>{t("searchPanelHint")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="mt-5">
|
<section className={showInput ? "mt-5" : "mt-0"}>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h2 className="border-l-4 border-ark-gold pl-3 text-base font-bold text-neutral-100">
|
<h2 className="border-l-4 border-ark-gold pl-3 text-base font-bold text-neutral-100">
|
||||||
{t("currentTags")}
|
{t("currentTags")}
|
||||||
|
|||||||
@@ -285,10 +285,13 @@ export function PublicLayout() {
|
|||||||
const outlet = useOutlet();
|
const outlet = useOutlet();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
||||||
|
const [desktopSearchOpen, setDesktopSearchOpen] = useState(false);
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const mobileMenuButtonRef = useRef<HTMLButtonElement>(null);
|
const mobileMenuButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const desktopMenuButtonRef = useRef<HTMLButtonElement>(null);
|
const desktopMenuButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const desktopSearchRef = useRef<HTMLDivElement>(null);
|
||||||
|
const desktopSearchPanelRef = useRef<HTMLDivElement>(null);
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
|
|
||||||
const na = (which: PublicNavWhich) =>
|
const na = (which: PublicNavWhich) =>
|
||||||
@@ -342,6 +345,7 @@ export function PublicLayout() {
|
|||||||
nav(`/browse?q=${encodeURIComponent(s)}`);
|
nav(`/browse?q=${encodeURIComponent(s)}`);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setMobileSearchOpen(false);
|
setMobileSearchOpen(false);
|
||||||
|
setDesktopSearchOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -350,6 +354,33 @@ export function PublicLayout() {
|
|||||||
);
|
);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!desktopSearchOpen) return;
|
||||||
|
|
||||||
|
const closeOnOutside = (event: MouseEvent | TouchEvent) => {
|
||||||
|
const target = event.target as Node;
|
||||||
|
if (
|
||||||
|
desktopSearchRef.current?.contains(target) ||
|
||||||
|
desktopSearchPanelRef.current?.contains(target)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDesktopSearchOpen(false);
|
||||||
|
};
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setDesktopSearchOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", closeOnOutside);
|
||||||
|
document.addEventListener("touchstart", closeOnOutside);
|
||||||
|
window.addEventListener("keydown", closeOnEscape);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", closeOnOutside);
|
||||||
|
document.removeEventListener("touchstart", closeOnOutside);
|
||||||
|
window.removeEventListener("keydown", closeOnEscape);
|
||||||
|
};
|
||||||
|
}, [desktopSearchOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
@@ -448,6 +479,7 @@ export function PublicLayout() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
setDesktopSearchOpen(false);
|
||||||
setMobileSearchOpen((value) => !value);
|
setMobileSearchOpen((value) => !value);
|
||||||
}}
|
}}
|
||||||
className="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-[#191921] outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
|
className="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-[#191921] outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
|
||||||
@@ -470,6 +502,7 @@ export function PublicLayout() {
|
|||||||
ariaLabel={t("langLabel")}
|
ariaLabel={t("langLabel")}
|
||||||
onOpen={() => {
|
onOpen={() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
setDesktopSearchOpen(false);
|
||||||
setMobileSearchOpen(false);
|
setMobileSearchOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -479,6 +512,7 @@ export function PublicLayout() {
|
|||||||
className="inline-flex h-[40px] w-[40px] shrink-0 items-center justify-center rounded-full bg-[#191921] text-[#a8a9ae] outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
|
className="inline-flex h-[40px] w-[40px] shrink-0 items-center justify-center rounded-full bg-[#191921] text-[#a8a9ae] outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMobileSearchOpen(false);
|
setMobileSearchOpen(false);
|
||||||
|
setDesktopSearchOpen(false);
|
||||||
setOpen((v) => !v);
|
setOpen((v) => !v);
|
||||||
}}
|
}}
|
||||||
aria-label="menu"
|
aria-label="menu"
|
||||||
@@ -577,10 +611,16 @@ export function PublicLayout() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 min-[1200px]:flex-none">
|
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 min-[1200px]:flex-none">
|
||||||
<div className="hidden h-10 min-w-0 flex-1 items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] py-2 pl-3 pr-3 shadow-inner md:flex min-[1200px]:w-44 min-[1200px]:flex-none lg:pr-4 xl:w-52">
|
<div
|
||||||
|
ref={desktopSearchRef}
|
||||||
|
className="hidden h-10 min-w-0 flex-1 items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] py-2 pl-3 pr-3 shadow-inner md:flex min-[1200px]:w-44 min-[1200px]:flex-none lg:pr-4 xl:w-52"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={goSearch}
|
onClick={() => {
|
||||||
|
if (q.trim()) goSearch();
|
||||||
|
else setDesktopSearchOpen((value) => !value);
|
||||||
|
}}
|
||||||
className="shrink-0 rounded-full p-1 text-[#c6c7cf] transition hover:bg-white/5 hover:text-ark-gold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/60"
|
className="shrink-0 rounded-full p-1 text-[#c6c7cf] transition hover:bg-white/5 hover:text-ark-gold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/60"
|
||||||
aria-label={t("searchNow")}
|
aria-label={t("searchNow")}
|
||||||
>
|
>
|
||||||
@@ -588,6 +628,8 @@ export function PublicLayout() {
|
|||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
value={q}
|
value={q}
|
||||||
|
onFocus={() => setDesktopSearchOpen(true)}
|
||||||
|
onClick={() => setDesktopSearchOpen(true)}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && goSearch()}
|
onKeyDown={(e) => e.key === "Enter" && goSearch()}
|
||||||
placeholder={t("searchPlaceholder")}
|
placeholder={t("searchPlaceholder")}
|
||||||
@@ -604,7 +646,10 @@ export function PublicLayout() {
|
|||||||
ref={desktopMenuButtonRef}
|
ref={desktopMenuButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg min-[1200px]:hidden"
|
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg min-[1200px]:hidden"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => {
|
||||||
|
setDesktopSearchOpen(false);
|
||||||
|
setOpen((v) => !v);
|
||||||
|
}}
|
||||||
aria-label="menu"
|
aria-label="menu"
|
||||||
>
|
>
|
||||||
{open ? <X size={18} /> : <Menu size={18} />}
|
{open ? <X size={18} /> : <Menu size={18} />}
|
||||||
@@ -680,12 +725,25 @@ export function PublicLayout() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{desktopSearchOpen ? (
|
||||||
|
<SearchPanel
|
||||||
|
panelRef={desktopSearchPanelRef}
|
||||||
|
lang={lang}
|
||||||
|
t={t}
|
||||||
|
query={q}
|
||||||
|
onQueryChange={setQ}
|
||||||
|
onSearch={goSearch}
|
||||||
|
variant="desktop"
|
||||||
|
showInput={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<main
|
<main
|
||||||
className={`mx-auto w-full max-w-[1280px] max-md:pb-[calc(68px+max(env(safe-area-inset-bottom),0px)+1rem)] ${
|
className={`mx-auto w-full max-w-[1280px] max-md:pb-[calc(68px+max(env(safe-area-inset-bottom),0px)+1rem)] ${
|
||||||
isHome
|
isHome
|
||||||
? "flex-1 px-0 pb-6 pt-0 md:px-9 md:pb-10 md:pt-10 xl:px-0"
|
? "flex-1 px-0 pb-6 pt-0 md:px-9 md:pb-10 md:pt-10 xl:px-0"
|
||||||
: footerInContentFlow
|
: footerInContentFlow
|
||||||
? "flex-1 px-0 pb-0 pt-0 md:px-9 md:pt-10 xl:px-0"
|
? "flex-1 px-0 pb-0 pt-0 md:px-9 xl:px-0"
|
||||||
: "flex-1 px-4 pb-6 pt-6 min-[440px]:px-5 sm:px-6 md:px-9 md:pb-10 md:pt-10 xl:px-0"
|
: "flex-1 px-4 pb-6 pt-6 min-[440px]:px-5 sm:px-6 md:px-9 md:pb-10 md:pt-10 xl:px-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user