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
|
### 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,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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
triggerIcon={
|
||||||
<svg className="h-3.5 w-3.5 shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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" />
|
<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>
|
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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 { 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" />
|
||||||
|
|||||||
@@ -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>(() => {});
|
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"
|
||||||
|
: active
|
||||||
|
? "bg-white/[0.08] text-white"
|
||||||
: "text-gray-200 hover:bg-white/[0.06] hover:text-white";
|
: "text-gray-200 hover:bg-white/[0.06] hover:text-white";
|
||||||
return `${base} ${tone} transition-colors`;
|
// 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.
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user