From 053a2bd84699a67e6b6c623c471cdf49753405a9 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 07:22:22 +0100 Subject: [PATCH] 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. --- CHANGELOG.md | 10 +- src/components/ExploreView.tsx | 6 +- src/components/FolderScopeDropdown.tsx | 113 ++++++-------------- src/components/SettingsModal.tsx | 16 +-- src/components/Sidebar.tsx | 10 +- src/components/ThemedDropdown.tsx | 106 ------------------- src/components/Toolbar.tsx | 85 ++-------------- src/components/menu/Dropdown.tsx | 136 +++++++++++++++++++++++++ src/components/menu/Menu.tsx | 36 ++++++- src/components/menu/index.ts | 4 +- src/index.css | 18 ++-- 11 files changed, 243 insertions(+), 297 deletions(-) delete mode 100644 src/components/ThemedDropdown.tsx create mode 100644 src/components/menu/Dropdown.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index d7870bc..69cc5cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,10 +62,12 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Changed -- **Right-click menus got their act together** — images, folders, albums, and - the theme switcher now share one menu style with one set of manners: they - stay on screen instead of wandering off the edge, all close on Escape, and - they can do proper submenus now. +- **Menus got their act together** — right-click menus (images, folders, + albums, the theme switcher) and every dropdown (sort, folder scope, settings, + sidebar) now share one style with one set of manners: they stay on screen + instead of wandering off the edge, all close on Escape, and right-click menus + can do proper submenus now. Subtle Light dresses them all the same way too, + instead of saving the nice outfit for one dropdown. - **Neater lightbox details** — image and video metadata now sits in two columns, so the info panel shows more at a glance with less scrolling. - **Faster Explore revisits** — returning to a folder's visual clusters should diff --git a/src/components/ExploreView.tsx b/src/components/ExploreView.tsx index 76a522b..d69be2d 100644 --- a/src/components/ExploreView.tsx +++ b/src/components/ExploreView.tsx @@ -3,7 +3,7 @@ import { motion, useReducedMotion } from "framer-motion"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ExploreMode, ExploreTagEntry, RelatedTagEntry, VisualClusterEntry, useGalleryStore } from "../store"; import { FolderScopeDropdown } from "./FolderScopeDropdown"; -import { ThemedDropdown } from "./ThemedDropdown"; +import { Dropdown } from "./menu"; import { Tooltip } from "./Tooltip"; import { mediaSrc } from "../lib/mediaSrc"; @@ -1036,9 +1036,9 @@ function TagManageList({ ) : null} - setSort(value as TagManageSort)} + onChange={setSort} options={TAG_MANAGE_SORTS} ariaLabel="Sort managed tags" align="right" diff --git a/src/components/FolderScopeDropdown.tsx b/src/components/FolderScopeDropdown.tsx index 9bbed40..c2de0c2 100644 --- a/src/components/FolderScopeDropdown.tsx +++ b/src/components/FolderScopeDropdown.tsx @@ -1,6 +1,6 @@ -import { useEffect, useRef, useState } from "react"; +import { useMemo } from "react"; import { useGalleryStore } from "../store"; -import { Tooltip } from "./Tooltip"; +import { Dropdown, DropdownOption } from "./menu"; /** * In-view folder scope picker for feature views (Timeline / Explore / @@ -8,93 +8,38 @@ import { Tooltip } from "./Tooltip"; * current view active — unlike sidebar folder clicks, which jump to Gallery. */ export function FolderScopeDropdown() { - const [open, setOpen] = useState(false); - const ref = useRef(null); - const folders = useGalleryStore((state) => state.folders); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const setViewFolderScope = useGalleryStore((state) => state.setViewFolderScope); - useEffect(() => { - const close = (e: MouseEvent) => { - if (!ref.current?.contains(e.target as Node)) setOpen(false); - }; - window.addEventListener("pointerdown", close); - return () => window.removeEventListener("pointerdown", close); - }, []); - - const currentLabel = - selectedFolderId === null - ? "All Media" - : folders.find((folder) => folder.id === selectedFolderId)?.name ?? "All Media"; - - const select = (folderId: number | null) => { - setViewFolderScope(folderId); - setOpen(false); - }; + const options = useMemo[]>( + () => [ + { value: null, label: "All Media" }, + ...folders.map((folder) => ({ + value: folder.id, + label: folder.name, + hint: {folder.image_count.toLocaleString()}, + })), + ], + [folders], + ); return ( -
- - - - {open ? ( -
- - {folders.map((folder) => { - const active = selectedFolderId === folder.id; - return ( - - ); - })} -
- ) : null} -
+ + + + } + /> ); } diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 3a38264..cb6ed6c 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, SlideshowOrder, SlideshowTransition, TaggerAcceleration, TaggerModel, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; +import { CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggerModel, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; import { FfmpegStatusRow } from "./onboarding/StepWelcome"; -import { ThemedDropdown } from "./ThemedDropdown"; +import { Dropdown } from "./menu"; import { getChangelogForVersion } from "../changelog"; import { Tooltip } from "./Tooltip"; import { TAGGER_MODELS } from "../taggerModels"; @@ -776,9 +776,9 @@ export function SettingsModal() { <> - setTheme(value as AppTheme)} + onChange={setTheme} ariaLabel="App theme" options={[ { value: "phokus", label: "Phokus" }, @@ -874,9 +874,9 @@ export function SettingsModal() { label="Playback order" description="Sequential follows the current lightbox order. Random picks another image from the same collection." > - setSlideshowOrder(value as SlideshowOrder)} + onChange={setSlideshowOrder} ariaLabel="Slideshow order" options={[ { value: "sequential", label: "Sequential" }, @@ -888,9 +888,9 @@ export function SettingsModal() { label="Transition" description="Soft fade keeps images still. Gentle motion adds a slow, subtle drift while the next image settles in." > - setSlideshowTransition(value as SlideshowTransition)} + onChange={setSlideshowTransition} ariaLabel="Slideshow transition" options={[ { value: "soft-fade", label: "Soft fade" }, diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 95a3031..a9f821c 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -2,8 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Reorder, useDragControls } from "framer-motion"; import { open } from "@tauri-apps/plugin-dialog"; import { useGalleryStore, Folder, Album, IndexProgress } from "../store"; -import { ThemedDropdown } from "./ThemedDropdown"; -import { ContextMenu, MenuItem, MenuSeparator } from "./menu"; +import { ContextMenu, Dropdown, MenuItem, MenuSeparator } from "./menu"; import { mediaSrc } from "../lib/mediaSrc"; import { Tooltip } from "./Tooltip"; @@ -788,11 +787,12 @@ export function Sidebar() { {folders.length > 0 && (
Libraries - setLibrarySort(value as LibrarySort)} + onChange={setLibrarySort} ariaLabel="Library order" - compact + trigger="compact" + panelClassName="min-w-0" options={[ { value: "az", label: "A-Z" }, { value: "za", label: "Z-A" }, diff --git a/src/components/ThemedDropdown.tsx b/src/components/ThemedDropdown.tsx deleted file mode 100644 index db8a833..0000000 --- a/src/components/ThemedDropdown.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { useEffect, useRef, useState } from "react"; - -export interface DropdownOption { - value: string; - label: string; -} - -export function ThemedDropdown({ - value, - options, - onChange, - ariaLabel, - compact = false, - align = "right", -}: { - value: string; - options: DropdownOption[]; - onChange: (value: string) => void; - ariaLabel: string; - compact?: boolean; - align?: "left" | "right"; -}) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - const current = options.find((option) => option.value === value) ?? options[0]; - - useEffect(() => { - const handlePointerDown = (event: PointerEvent) => { - if (!ref.current?.contains(event.target as Node)) setOpen(false); - }; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); - }; - window.addEventListener("pointerdown", handlePointerDown); - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("pointerdown", handlePointerDown); - window.removeEventListener("keydown", handleKeyDown); - }; - }, []); - - return ( -
- - - {open ? ( -
- {options.map((option) => { - const selected = option.value === value; - return ( - - ); - })} -
- ) : null} -
- ); -} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 16b2d0b..76d9c8d 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -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(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 ( -
- - {open ? ( -
- {options.map((option) => ( - - ))} -
- ) : null} -
- ); -} - 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() {
{/* Sort */} - + {/* Divider */}
diff --git a/src/components/menu/Dropdown.tsx b/src/components/menu/Dropdown.tsx new file mode 100644 index 0000000..1832108 --- /dev/null +++ b/src/components/menu/Dropdown.tsx @@ -0,0 +1,136 @@ +import { ReactNode, useRef, useState } from "react"; +import { MenuCloseContext, MenuItem, MenuPanel, MenuSize } from "./Menu"; +import { useDismissable } from "./useDismissable"; +import { Tooltip } from "../Tooltip"; + +export interface DropdownOption { + value: T; + label: string; + /** Trailing detail on the option row (e.g. an item count). */ + hint?: ReactNode; +} + +const TRIGGER_STYLES = { + // Filled, bordered select — settings forms and panel headers. + solid: { + base: "min-w-40 gap-2 rounded-md border px-3 py-1.5 text-xs border-white/10 bg-white/[0.055] light-theme:border-gray-700/50 light-theme:bg-gray-900", + open: "border-white/20 text-white light-theme:border-gray-700 light-theme:text-white", + closed: + "text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:text-white light-theme:hover:border-gray-700 light-theme:hover:bg-gray-800 light-theme:hover:text-white", + }, + // Transparent until engaged — toolbar controls (sort, folder scope). + ghost: { + base: "gap-1.5 rounded-lg border px-3 py-1.5 text-xs", + open: "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white", + closed: + "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", + }, + // Tiny inline picker — sidebar section headers. + compact: { + base: "gap-2 rounded-md border px-1.5 py-0.5 text-[10px] font-medium border-white/10 bg-white/[0.04] light-theme:border-gray-700/50 light-theme:bg-gray-900", + open: "border-white/20 text-white light-theme:border-gray-700 light-theme:text-white", + closed: + "text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:text-white light-theme:hover:border-gray-700 light-theme:hover:bg-gray-800 light-theme:hover:text-white", + }, +} as const; + +/** + * The app-wide select control: a trigger button plus a MenuPanel of options. + * Replaces the former ThemedDropdown / SortDropdown / FolderScopeDropdown + * trio. Options are compared to `value` with Object.is, so number and + * null values (folder scopes) work as well as string unions. + */ +export function Dropdown({ + value, + options, + onChange, + ariaLabel, + align = "right", + trigger = "solid", + size = "sm", + triggerIcon, + triggerTooltip, + triggerClassName = "", + panelClassName = "", +}: { + value: T; + options: DropdownOption[]; + onChange: (value: T) => void; + ariaLabel: string; + align?: "left" | "right"; + trigger?: keyof typeof TRIGGER_STYLES; + size?: MenuSize; + triggerIcon?: ReactNode; + triggerTooltip?: string; + triggerClassName?: string; + panelClassName?: string; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const current = options.find((option) => Object.is(option.value, value)) ?? options[0]; + const style = TRIGGER_STYLES[trigger]; + + useDismissable(ref, () => setOpen(false), open); + + const button = ( + + ); + + return ( +
+ {triggerTooltip ? ( + + {button} + + ) : ( + button + )} + {open ? ( +
+ setOpen(false)}> + + {options.map((option) => { + const selected = Object.is(option.value, value); + return ( + onChange(option.value)} + /> + ); + })} + + +
+ ) : null} +
+ ); +} diff --git a/src/components/menu/Menu.tsx b/src/components/menu/Menu.tsx index ae44ab5..049d2d7 100644 --- a/src/components/menu/Menu.tsx +++ b/src/components/menu/Menu.tsx @@ -14,7 +14,7 @@ export type MenuSize = "sm" | "md"; export const MenuCloseContext = createContext<() => void>(() => {}); const MenuSizeContext = createContext("md"); -function itemClass(size: MenuSize, danger: boolean, disabled: boolean): string { +function itemClass(size: MenuSize, danger: boolean, disabled: boolean, active = false): string { const base = size === "sm" ? "w-full rounded-md px-3 py-1.5 text-[12px]" @@ -23,8 +23,16 @@ function itemClass(size: MenuSize, danger: boolean, disabled: boolean): string { ? "text-gray-600 cursor-not-allowed" : danger ? "text-red-400 hover:bg-red-500/15 hover:text-red-300" - : "text-gray-200 hover:bg-white/[0.06] hover:text-white"; - return `${base} ${tone} transition-colors`; + : active + ? "bg-white/[0.08] text-white" + : "text-gray-200 hover:bg-white/[0.06] hover:text-white"; + // menu-item + data attributes are the stable hooks the subtle-light theme + // targets in index.css — keep them if the utility classes change. + return `menu-item ${base} ${tone} transition-colors`; +} + +function itemTone(danger: boolean, disabled: boolean): "danger" | "disabled" | "default" { + return danger ? "danger" : disabled ? "disabled" : "default"; } /** @@ -42,10 +50,18 @@ export function MenuPanel({ }) { const inherited = useContext(MenuSizeContext); const resolved = size ?? inherited; + // Default min-width steps aside when the caller sets its own width class — + // Tailwind resolves competing min-w utilities by stylesheet order, not + // className order, so merging both would be unpredictable. + const widthClass = /(^|\s)(min-w-|w-)/.test(className) + ? "" + : resolved === "sm" + ? "min-w-40" + : "min-w-52"; return (
{children}
@@ -58,27 +74,36 @@ export function MenuItem({ onSelect, danger = false, disabled = false, + active = false, checked, hint, keepOpen = false, + role, }: { label: ReactNode; onSelect?: () => void; danger?: boolean; disabled?: boolean; + /** Highlights the row as the current choice (dropdown selections, active view). */ + active?: boolean; /** Renders a trailing check mark; use for exclusive-choice menus (themes, sort orders). */ checked?: boolean; hint?: ReactNode; /** Skip the automatic menu close after selecting. */ keepOpen?: boolean; + role?: React.AriaRole; }) { const size = useContext(MenuSizeContext); const close = useContext(MenuCloseContext); return (