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
+6 -4
View File
@@ -62,10 +62,12 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
### Changed ### Changed
- **Right-click menus got their act together** — images, folders, albums, and - **Menus got their act together** — right-click menus (images, folders,
the theme switcher now share one menu style with one set of manners: they albums, the theme switcher) and every dropdown (sort, folder scope, settings,
stay on screen instead of wandering off the edge, all close on Escape, and sidebar) now share one style with one set of manners: they stay on screen
they can do proper submenus now. 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, - **Neater lightbox details** — image and video metadata now sits in two columns,
so the info panel shows more at a glance with less scrolling. so the info panel shows more at a glance with less scrolling.
- **Faster Explore revisits** — returning to a folder's visual clusters should - **Faster Explore revisits** — returning to a folder's visual clusters should
+3 -3
View File
@@ -3,7 +3,7 @@ import { motion, useReducedMotion } from "framer-motion";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { ExploreMode, ExploreTagEntry, RelatedTagEntry, VisualClusterEntry, useGalleryStore } from "../store"; import { ExploreMode, ExploreTagEntry, RelatedTagEntry, VisualClusterEntry, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { ThemedDropdown } from "./ThemedDropdown"; import { Dropdown } from "./menu";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import { mediaSrc } from "../lib/mediaSrc"; import { mediaSrc } from "../lib/mediaSrc";
@@ -1036,9 +1036,9 @@ function TagManageList({
</span> </span>
) : null} ) : null}
</div> </div>
<ThemedDropdown <Dropdown
value={sort} value={sort}
onChange={(value) => setSort(value as TagManageSort)} onChange={setSort}
options={TAG_MANAGE_SORTS} options={TAG_MANAGE_SORTS}
ariaLabel="Sort managed tags" ariaLabel="Sort managed tags"
align="right" align="right"
+29 -84
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useMemo } from "react";
import { useGalleryStore } from "../store"; import { useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip"; import { Dropdown, DropdownOption } from "./menu";
/** /**
* In-view folder scope picker for feature views (Timeline / Explore / * 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. * current view active — unlike sidebar folder clicks, which jump to Gallery.
*/ */
export function FolderScopeDropdown() { export function FolderScopeDropdown() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const folders = useGalleryStore((state) => state.folders); const folders = useGalleryStore((state) => state.folders);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const setViewFolderScope = useGalleryStore((state) => state.setViewFolderScope); const setViewFolderScope = useGalleryStore((state) => state.setViewFolderScope);
useEffect(() => { const options = useMemo<DropdownOption<number | null>[]>(
const close = (e: MouseEvent) => { () => [
if (!ref.current?.contains(e.target as Node)) setOpen(false); { value: null, label: "All Media" },
}; ...folders.map((folder) => ({
window.addEventListener("pointerdown", close); value: folder.id,
return () => window.removeEventListener("pointerdown", close); label: folder.name,
}, []); hint: <span className="tabular-nums">{folder.image_count.toLocaleString()}</span>,
})),
const currentLabel = ],
selectedFolderId === null [folders],
? "All Media" );
: folders.find((folder) => folder.id === selectedFolderId)?.name ?? "All Media";
const select = (folderId: number | null) => {
setViewFolderScope(folderId);
setOpen(false);
};
return ( return (
<div ref={ref} className="feature-scope-dropdown relative"> <Dropdown
<Tooltip label="Change folder scope" anchorToCursor> value={selectedFolderId}
<button options={options}
onClick={() => setOpen((v) => !v)} onChange={setViewFolderScope}
className={`feature-scope-trigger flex max-w-56 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${ ariaLabel="Folder scope"
open trigger="ghost"
? "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white" size="md"
: "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" triggerTooltip="Change folder scope"
}`} triggerClassName="max-w-56"
> panelClassName="min-w-52 max-h-80 overflow-y-auto"
<svg className="h-3.5 w-3.5 shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> triggerIcon={
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> <svg className="h-3.5 w-3.5 shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</svg> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
<span className="truncate">{currentLabel}</span> </svg>
<svg }
className={`h-3 w-3 shrink-0 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>
</Tooltip>
{open ? (
<div className="feature-scope-menu absolute right-0 top-full z-30 mt-1.5 max-h-80 min-w-52 overflow-y-auto rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur light-theme:border-gray-700/50">
<button
className={`feature-scope-option flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
selectedFolderId === null ? "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={() => select(null)}
>
All Media
{selectedFolderId === null ? (
<svg className="h-3.5 w-3.5 shrink-0 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>
{folders.map((folder) => {
const active = selectedFolderId === folder.id;
return (
<button
key={folder.id}
className={`feature-scope-option flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
active ? "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={() => select(folder.id)}
>
<span className="min-w-0 truncate">{folder.name}</span>
<span className="flex shrink-0 items-center gap-2">
<span className="text-[11px] tabular-nums text-gray-600">{folder.image_count.toLocaleString()}</span>
{active ? (
<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}
</span>
</button>
);
})}
</div>
) : null}
</div>
); );
} }
+8 -8
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react"; 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 { FfmpegStatusRow } from "./onboarding/StepWelcome";
import { ThemedDropdown } from "./ThemedDropdown"; import { Dropdown } from "./menu";
import { getChangelogForVersion } from "../changelog"; import { getChangelogForVersion } from "../changelog";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import { TAGGER_MODELS } from "../taggerModels"; import { TAGGER_MODELS } from "../taggerModels";
@@ -776,9 +776,9 @@ export function SettingsModal() {
<> <>
<SettingsGroup title="Appearance"> <SettingsGroup title="Appearance">
<SettingsItem label="Theme" description="Choose the app palette. Subtle Light uses a warm, low-glare background."> <SettingsItem label="Theme" description="Choose the app palette. Subtle Light uses a warm, low-glare background.">
<ThemedDropdown <Dropdown
value={theme} value={theme}
onChange={(value) => setTheme(value as AppTheme)} onChange={setTheme}
ariaLabel="App theme" ariaLabel="App theme"
options={[ options={[
{ value: "phokus", label: "Phokus" }, { value: "phokus", label: "Phokus" },
@@ -874,9 +874,9 @@ export function SettingsModal() {
label="Playback order" label="Playback order"
description="Sequential follows the current lightbox order. Random picks another image from the same collection." description="Sequential follows the current lightbox order. Random picks another image from the same collection."
> >
<ThemedDropdown <Dropdown
value={slideshowOrder} value={slideshowOrder}
onChange={(value) => setSlideshowOrder(value as SlideshowOrder)} onChange={setSlideshowOrder}
ariaLabel="Slideshow order" ariaLabel="Slideshow order"
options={[ options={[
{ value: "sequential", label: "Sequential" }, { value: "sequential", label: "Sequential" },
@@ -888,9 +888,9 @@ export function SettingsModal() {
label="Transition" label="Transition"
description="Soft fade keeps images still. Gentle motion adds a slow, subtle drift while the next image settles in." description="Soft fade keeps images still. Gentle motion adds a slow, subtle drift while the next image settles in."
> >
<ThemedDropdown <Dropdown
value={slideshowTransition} value={slideshowTransition}
onChange={(value) => setSlideshowTransition(value as SlideshowTransition)} onChange={setSlideshowTransition}
ariaLabel="Slideshow transition" ariaLabel="Slideshow transition"
options={[ options={[
{ value: "soft-fade", label: "Soft fade" }, { value: "soft-fade", label: "Soft fade" },
+5 -5
View File
@@ -2,8 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Reorder, useDragControls } from "framer-motion"; import { Reorder, useDragControls } from "framer-motion";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { useGalleryStore, Folder, Album, IndexProgress } from "../store"; import { useGalleryStore, Folder, Album, IndexProgress } from "../store";
import { ThemedDropdown } from "./ThemedDropdown"; import { ContextMenu, Dropdown, MenuItem, MenuSeparator } from "./menu";
import { ContextMenu, MenuItem, MenuSeparator } from "./menu";
import { mediaSrc } from "../lib/mediaSrc"; import { mediaSrc } from "../lib/mediaSrc";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
@@ -788,11 +787,12 @@ export function Sidebar() {
{folders.length > 0 && ( {folders.length > 0 && (
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-1"> <div className="flex items-center justify-between gap-2 px-5 pt-3 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600">Libraries</span> <span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600">Libraries</span>
<ThemedDropdown <Dropdown
value={librarySort} value={librarySort}
onChange={(value) => setLibrarySort(value as LibrarySort)} onChange={setLibrarySort}
ariaLabel="Library order" ariaLabel="Library order"
compact trigger="compact"
panelClassName="min-w-0"
options={[ options={[
{ value: "az", label: "A-Z" }, { value: "az", label: "A-Z" },
{ value: "za", label: "Z-A" }, { value: "za", label: "Z-A" },
-106
View File
@@ -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<HTMLDivElement>(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 (
<div ref={ref} className="relative">
<button
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((currentOpen) => !currentOpen)}
className={`flex items-center justify-between gap-2 rounded-md border transition-colors ${
compact
? "border-white/10 bg-white/[0.04] px-1.5 py-0.5 text-[10px] font-medium light-theme:border-gray-700/50 light-theme:bg-gray-900"
: "min-w-40 border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs 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" : "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"}`}
>
<span>{current?.label}</span>
<svg
className={`h-3 w-3 shrink-0 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
role="listbox"
aria-label={ariaLabel}
className={`absolute top-full z-50 mt-1.5 min-w-full rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl shadow-black/30 backdrop-blur-xl light-theme:border-gray-700/50 ${
align === "right" ? "right-0" : "left-0"
}`}
>
{options.map((option) => {
const selected = option.value === value;
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={selected}
className={`flex w-full items-center justify-between gap-4 whitespace-nowrap rounded-lg px-3 py-2 text-left text-xs transition-colors ${
selected
? "bg-white/[0.08] text-white light-theme:bg-gray-900 light-theme:text-white"
: "text-gray-400 hover:bg-white/[0.055] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => {
onChange(option.value);
setOpen(false);
}}
>
<span>{option.label}</span>
{selected ? (
<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>
);
}
+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 { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { ColorFilter } from "./ColorFilter"; import { ColorFilter } from "./ColorFilter";
import { Dropdown, useDismissable } from "./menu";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
@@ -30,71 +31,6 @@ function getSortOptions(mediaFilter: MediaFilter) {
return BASE_SORT_OPTIONS; 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({ function FilterPill({
label, label,
active, active,
@@ -253,14 +189,7 @@ export function Toolbar() {
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [clearSearch, hasActiveSearch]); }, [clearSearch, hasActiveSearch]);
useEffect(() => { useDismissable(searchShellRef, () => setSearchPanelOpen(false));
const close = (event: PointerEvent) => {
if (searchShellRef.current?.contains(event.target as Node)) return;
setSearchPanelOpen(false);
};
window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close);
}, []);
const showTagSuggestions = searchCommand === "tag" && searchPanelOpen; const showTagSuggestions = searchCommand === "tag" && searchPanelOpen;
const showCommandHints = !searchCommand && searchPanelOpen; const showCommandHints = !searchCommand && searchPanelOpen;
@@ -431,7 +360,15 @@ export function Toolbar() {
</div> </div>
{/* Sort */} {/* 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 */} {/* Divider */}
<div className="h-4 w-px bg-white/10 shrink-0" /> <div className="h-4 w-px bg-white/10 shrink-0" />
+136
View File
@@ -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<T> {
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<T extends string | number | null>({
value,
options,
onChange,
ariaLabel,
align = "right",
trigger = "solid",
size = "sm",
triggerIcon,
triggerTooltip,
triggerClassName = "",
panelClassName = "",
}: {
value: T;
options: DropdownOption<T>[];
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<HTMLDivElement>(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 = (
<button
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((currentOpen) => !currentOpen)}
className={`dropdown-trigger flex items-center justify-between transition-colors ${style.base} ${
open ? style.open : style.closed
} ${triggerClassName}`}
>
{triggerIcon}
<span className="min-w-0 truncate">{current?.label}</span>
<svg
className={`h-3 w-3 shrink-0 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>
);
return (
<div ref={ref} className="relative">
{triggerTooltip ? (
<Tooltip label={triggerTooltip} anchorToCursor>
{button}
</Tooltip>
) : (
button
)}
{open ? (
<div
role="listbox"
aria-label={ariaLabel}
className={`absolute top-full z-50 mt-1.5 min-w-full ${align === "right" ? "right-0" : "left-0"}`}
>
<MenuCloseContext.Provider value={() => setOpen(false)}>
<MenuPanel size={size} className={panelClassName}>
{options.map((option) => {
const selected = Object.is(option.value, value);
return (
<MenuItem
key={String(option.value)}
role="option"
label={option.label}
hint={option.hint}
active={selected}
checked={selected}
onSelect={() => onChange(option.value)}
/>
);
})}
</MenuPanel>
</MenuCloseContext.Provider>
</div>
) : null}
</div>
);
}
+31 -5
View File
@@ -14,7 +14,7 @@ export type MenuSize = "sm" | "md";
export const MenuCloseContext = createContext<() => void>(() => {}); export const MenuCloseContext = createContext<() => void>(() => {});
const MenuSizeContext = createContext<MenuSize>("md"); const MenuSizeContext = createContext<MenuSize>("md");
function itemClass(size: MenuSize, danger: boolean, disabled: boolean): string { function itemClass(size: MenuSize, danger: boolean, disabled: boolean, active = false): string {
const base = const base =
size === "sm" size === "sm"
? "w-full rounded-md px-3 py-1.5 text-[12px]" ? "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" ? "text-gray-600 cursor-not-allowed"
: danger : danger
? "text-red-400 hover:bg-red-500/15 hover:text-red-300" ? "text-red-400 hover:bg-red-500/15 hover:text-red-300"
: "text-gray-200 hover:bg-white/[0.06] hover:text-white"; : active
return `${base} ${tone} transition-colors`; ? "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 inherited = useContext(MenuSizeContext);
const resolved = size ?? inherited; 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 ( return (
<MenuSizeContext.Provider value={resolved}> <MenuSizeContext.Provider value={resolved}>
<div <div
className={`${resolved === "sm" ? "min-w-40" : "min-w-52"} rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl shadow-black/40 backdrop-blur light-theme:border-gray-700/50 ${className}`} className={`menu-panel ${widthClass} rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl shadow-black/40 backdrop-blur light-theme:border-gray-700/50 ${className}`}
> >
{children} {children}
</div> </div>
@@ -58,27 +74,36 @@ export function MenuItem({
onSelect, onSelect,
danger = false, danger = false,
disabled = false, disabled = false,
active = false,
checked, checked,
hint, hint,
keepOpen = false, keepOpen = false,
role,
}: { }: {
label: ReactNode; label: ReactNode;
onSelect?: () => void; onSelect?: () => void;
danger?: boolean; danger?: boolean;
disabled?: 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). */ /** Renders a trailing check mark; use for exclusive-choice menus (themes, sort orders). */
checked?: boolean; checked?: boolean;
hint?: ReactNode; hint?: ReactNode;
/** Skip the automatic menu close after selecting. */ /** Skip the automatic menu close after selecting. */
keepOpen?: boolean; keepOpen?: boolean;
role?: React.AriaRole;
}) { }) {
const size = useContext(MenuSizeContext); const size = useContext(MenuSizeContext);
const close = useContext(MenuCloseContext); const close = useContext(MenuCloseContext);
return ( return (
<button <button
type="button" type="button"
role={role}
aria-selected={role === "option" ? checked ?? active : undefined}
disabled={disabled} disabled={disabled}
className={`${itemClass(size, danger, disabled)} flex items-center justify-between gap-3`} data-tone={itemTone(danger, disabled)}
data-active={active || undefined}
className={`${itemClass(size, danger, disabled, active)} flex items-center justify-between gap-3`}
onClick={() => { onClick={() => {
if (disabled) return; if (disabled) return;
onSelect?.(); onSelect?.();
@@ -174,6 +199,7 @@ export function SubMenu({
disabled={disabled} disabled={disabled}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={open} aria-expanded={open}
data-tone={itemTone(false, disabled)}
className={`${itemClass(size, false, disabled)} flex items-center justify-between gap-3`} className={`${itemClass(size, false, disabled)} flex items-center justify-between gap-3`}
// Open-only (no toggle): hover already opened it for mouse users, so a // Open-only (no toggle): hover already opened it for mouse users, so a
// toggle would close the panel on the very click meant to open it. // toggle would close the panel on the very click meant to open it.
+3 -1
View File
@@ -1,4 +1,6 @@
export { ContextMenu } from "./ContextMenu"; export { ContextMenu } from "./ContextMenu";
export { MenuItem, MenuLabel, MenuPanel, MenuSeparator, SubMenu } from "./Menu"; export { Dropdown } from "./Dropdown";
export type { DropdownOption } from "./Dropdown";
export { MenuCloseContext, MenuItem, MenuLabel, MenuPanel, MenuSeparator, SubMenu } from "./Menu";
export type { MenuSize } from "./Menu"; export type { MenuSize } from "./Menu";
export { useDismissable } from "./useDismissable"; export { useDismissable } from "./useDismissable";
+11 -7
View File
@@ -365,36 +365,40 @@ html[data-theme="subtle-light"] .tag-manager-empty {
border-color: #d8d2c7 !important; border-color: #d8d2c7 !important;
} }
html[data-theme="subtle-light"] .feature-scope-trigger { /* Menu surfaces — context menus, dropdown panels, and menubar panels all
share the .menu-panel / .menu-item hooks from src/components/menu/, so one
block themes every floating menu. Danger and disabled items keep their
tones via the palette variable remap, so only default rows are recolored. */
html[data-theme="subtle-light"] .dropdown-trigger {
background: #f8f6ef !important; background: #f8f6ef !important;
border-color: #d0c8ba !important; border-color: #d0c8ba !important;
color: #4b5563 !important; color: #4b5563 !important;
} }
html[data-theme="subtle-light"] .feature-scope-trigger:hover, html[data-theme="subtle-light"] .dropdown-trigger:hover,
html[data-theme="subtle-light"] .feature-scope-dropdown:has(.feature-scope-menu) .feature-scope-trigger { html[data-theme="subtle-light"] .dropdown-trigger[aria-expanded="true"] {
background: #ece8dd !important; background: #ece8dd !important;
border-color: #bfb6a7 !important; border-color: #bfb6a7 !important;
color: #111827 !important; color: #111827 !important;
} }
html[data-theme="subtle-light"] .feature-scope-menu { html[data-theme="subtle-light"] .menu-panel {
background: #fbfaf6 !important; background: #fbfaf6 !important;
border-color: #d0c8ba !important; border-color: #d0c8ba !important;
color: #1f2937 !important; color: #1f2937 !important;
box-shadow: 0 18px 44px rgb(28 25 23 / 0.2) !important; box-shadow: 0 18px 44px rgb(28 25 23 / 0.2) !important;
} }
html[data-theme="subtle-light"] .feature-scope-option { html[data-theme="subtle-light"] .menu-item[data-tone="default"] {
color: #4b5563 !important; color: #4b5563 !important;
} }
html[data-theme="subtle-light"] .feature-scope-option:hover { html[data-theme="subtle-light"] .menu-item[data-tone="default"]:hover {
background: #ece8dd !important; background: #ece8dd !important;
color: #111827 !important; color: #111827 !important;
} }
html[data-theme="subtle-light"] .feature-scope-option.bg-white\/6 { html[data-theme="subtle-light"] .menu-item[data-active="true"] {
background: #d8d4ca !important; background: #d8d4ca !important;
color: #111827 !important; color: #111827 !important;
} }