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:
+11
-74
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user