refactor(ui): consolidate dropdowns into one shared Dropdown

ThemedDropdown, Toolbar''s local SortDropdown, and FolderScopeDropdown
were three hand-rolled implementations of the same select pattern. They
are replaced by a single generic Dropdown (src/components/menu/) built
on MenuPanel/MenuItem, with solid/ghost/compact trigger variants and
Object.is value comparison so number|null folder scopes work alongside
string unions. Call sites drop their `value as X` casts.

MenuItem gains an `active` state plus stable menu-panel/menu-item class
hooks, and the subtle-light CSS that previously dressed only the folder
scope dropdown (feature-scope-*) now themes every menu surface --
dropdowns, context menus, and submenus alike.
This commit is contained in:
2026-07-04 07:22:22 +01:00
parent 54016df830
commit 053a2bd846
11 changed files with 243 additions and 297 deletions
+11 -74
View File
@@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { ColorFilter } from "./ColorFilter";
import { Dropdown, useDismissable } from "./menu";
import { Tooltip } from "./Tooltip";
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
@@ -30,71 +31,6 @@ function getSortOptions(mediaFilter: MediaFilter) {
return BASE_SORT_OPTIONS;
}
function SortDropdown({
value,
onChange,
options,
}: {
value: SortOrder;
onChange: (v: SortOrder) => void;
options: { value: SortOrder; label: string }[];
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const current = options.find((o) => o.value === value) ?? BASE_SORT_OPTIONS[0];
useEffect(() => {
const close = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close);
}, []);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
open
? "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:border-gray-700/40 light-theme:text-gray-600 light-theme:hover:border-gray-700 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
>
<span>{current?.label ?? "Sort"}</span>
<svg
className={`h-3 w-3 text-gray-500 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open ? (
<div className="absolute right-0 top-full z-30 mt-1.5 min-w-44 rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur light-theme:border-gray-700/50">
{options.map((option) => (
<button
key={option.value}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
option.value === value
? "bg-white/6 text-white light-theme:bg-gray-900 light-theme:text-white"
: "text-gray-400 hover:bg-white/5 hover:text-white light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => { onChange(option.value); setOpen(false); }}
>
{option.label}
{option.value === value ? (
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
))}
</div>
) : null}
</div>
);
}
function FilterPill({
label,
active,
@@ -253,14 +189,7 @@ export function Toolbar() {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [clearSearch, hasActiveSearch]);
useEffect(() => {
const close = (event: PointerEvent) => {
if (searchShellRef.current?.contains(event.target as Node)) return;
setSearchPanelOpen(false);
};
window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close);
}, []);
useDismissable(searchShellRef, () => setSearchPanelOpen(false));
const showTagSuggestions = searchCommand === "tag" && searchPanelOpen;
const showCommandHints = !searchCommand && searchPanelOpen;
@@ -431,7 +360,15 @@ export function Toolbar() {
</div>
{/* Sort */}
<SortDropdown value={sort} onChange={setSort} options={sortOptions} />
<Dropdown
value={sort}
onChange={setSort}
options={sortOptions}
ariaLabel="Sort order"
trigger="ghost"
size="md"
panelClassName="min-w-44"
/>
{/* Divider */}
<div className="h-4 w-px bg-white/10 shrink-0" />