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:
+6
-4
@@ -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
|
||||
|
||||
@@ -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({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<ThemedDropdown
|
||||
<Dropdown
|
||||
value={sort}
|
||||
onChange={(value) => setSort(value as TagManageSort)}
|
||||
onChange={setSort}
|
||||
options={TAG_MANAGE_SORTS}
|
||||
ariaLabel="Sort managed tags"
|
||||
align="right"
|
||||
|
||||
@@ -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<HTMLDivElement>(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<DropdownOption<number | null>[]>(
|
||||
() => [
|
||||
{ value: null, label: "All Media" },
|
||||
...folders.map((folder) => ({
|
||||
value: folder.id,
|
||||
label: folder.name,
|
||||
hint: <span className="tabular-nums">{folder.image_count.toLocaleString()}</span>,
|
||||
})),
|
||||
],
|
||||
[folders],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="feature-scope-dropdown relative">
|
||||
<Tooltip label="Change folder scope" anchorToCursor>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
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 ${
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
<span className="truncate">{currentLabel}</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>
|
||||
</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>
|
||||
<Dropdown
|
||||
value={selectedFolderId}
|
||||
options={options}
|
||||
onChange={setViewFolderScope}
|
||||
ariaLabel="Folder scope"
|
||||
trigger="ghost"
|
||||
size="md"
|
||||
triggerTooltip="Change folder scope"
|
||||
triggerClassName="max-w-56"
|
||||
panelClassName="min-w-52 max-h-80 overflow-y-auto"
|
||||
triggerIcon={
|
||||
<svg className="h-3.5 w-3.5 shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<>
|
||||
<SettingsGroup title="Appearance">
|
||||
<SettingsItem label="Theme" description="Choose the app palette. Subtle Light uses a warm, low-glare background.">
|
||||
<ThemedDropdown
|
||||
<Dropdown
|
||||
value={theme}
|
||||
onChange={(value) => 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."
|
||||
>
|
||||
<ThemedDropdown
|
||||
<Dropdown
|
||||
value={slideshowOrder}
|
||||
onChange={(value) => 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."
|
||||
>
|
||||
<ThemedDropdown
|
||||
<Dropdown
|
||||
value={slideshowTransition}
|
||||
onChange={(value) => setSlideshowTransition(value as SlideshowTransition)}
|
||||
onChange={setSlideshowTransition}
|
||||
ariaLabel="Slideshow transition"
|
||||
options={[
|
||||
{ value: "soft-fade", label: "Soft fade" },
|
||||
|
||||
@@ -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 && (
|
||||
<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>
|
||||
<ThemedDropdown
|
||||
<Dropdown
|
||||
value={librarySort}
|
||||
onChange={(value) => 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" },
|
||||
|
||||
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export type MenuSize = "sm" | "md";
|
||||
export const MenuCloseContext = createContext<() => void>(() => {});
|
||||
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 =
|
||||
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 (
|
||||
<MenuSizeContext.Provider value={resolved}>
|
||||
<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}
|
||||
</div>
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
role={role}
|
||||
aria-selected={role === "option" ? checked ?? active : undefined}
|
||||
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={() => {
|
||||
if (disabled) return;
|
||||
onSelect?.();
|
||||
@@ -174,6 +199,7 @@ export function SubMenu({
|
||||
disabled={disabled}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
data-tone={itemTone(false, disabled)}
|
||||
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
|
||||
// toggle would close the panel on the very click meant to open it.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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 { useDismissable } from "./useDismissable";
|
||||
|
||||
+11
-7
@@ -365,36 +365,40 @@ html[data-theme="subtle-light"] .tag-manager-empty {
|
||||
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;
|
||||
border-color: #d0c8ba !important;
|
||||
color: #4b5563 !important;
|
||||
}
|
||||
|
||||
html[data-theme="subtle-light"] .feature-scope-trigger:hover,
|
||||
html[data-theme="subtle-light"] .feature-scope-dropdown:has(.feature-scope-menu) .feature-scope-trigger {
|
||||
html[data-theme="subtle-light"] .dropdown-trigger:hover,
|
||||
html[data-theme="subtle-light"] .dropdown-trigger[aria-expanded="true"] {
|
||||
background: #ece8dd !important;
|
||||
border-color: #bfb6a7 !important;
|
||||
color: #111827 !important;
|
||||
}
|
||||
|
||||
html[data-theme="subtle-light"] .feature-scope-menu {
|
||||
html[data-theme="subtle-light"] .menu-panel {
|
||||
background: #fbfaf6 !important;
|
||||
border-color: #d0c8ba !important;
|
||||
color: #1f2937 !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;
|
||||
}
|
||||
|
||||
html[data-theme="subtle-light"] .feature-scope-option:hover {
|
||||
html[data-theme="subtle-light"] .menu-item[data-tone="default"]:hover {
|
||||
background: #ece8dd !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;
|
||||
color: #111827 !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user