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
+3 -3
View File
@@ -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"
+29 -84
View File
@@ -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>
}
/>
);
}
+8 -8
View File
@@ -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" },
+5 -5
View File
@@ -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" },
-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 { 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" />
+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>(() => {});
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.
+3 -1
View File
@@ -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
View File
@@ -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;
}