Fourteen themes was too many to keep under an avatar next to "Sign out" — that is where a preference goes when nobody has decided it matters. It now has its own button in the header, between the controls and the avatar, grouped by family. ── The keys, ported from the owner's vimazing ── ctrl+shift+l / ctrl+shift+h cycle forward / back, from anywhere, no UI ctrl+alt+space open the list j / k move the focus l or space apply the focused theme to the app BEHIND the list h back to where you started enter keep what you are looking at escape restore and close The browse is the point, and it is why the overlay is a portal over a 55% backdrop rather than a modal: you are not reading names, you are watching the application change. The theme you started on is captured when the list opens, so `h` and `escape` undo a whole browse however many previews happened inside it. Typing is still typing: the list ignores j/k/l/h while focus is in an input, textarea, select or anything contenteditable. ── Where it lives ── The key handling is in the theme package — it is behaviour that belongs to themes, and it needs no UI. The list and the button are Officer's, because a dialog would drag a component library into a package that currently has none. Verified by driving the real keyboard in a browser: start duck-dark · ctrl+shift+l -> plain · ctrl+alt+space -> overlay j, l -> tokyo-night (previewed) · escape -> plain, closed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
122 lines
4.8 KiB
TypeScript
122 lines
4.8 KiB
TypeScript
import { createPortal } from 'react-dom';
|
|
import { Palette, Check } from 'lucide-react';
|
|
import * as Dropdown from '@/components/ui/dropdown-menu';
|
|
import type { Theme } from '@/components/ui/ThemeProvider';
|
|
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
|
|
|
|
const {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} = Dropdown;
|
|
|
|
/**
|
|
* Themes, out of the avatar menu and into the header.
|
|
*
|
|
* They were buried under an avatar next to "Sign out", which is where a preference goes when nobody has
|
|
* decided it matters. With fourteen of them it is a thing you use, so it gets its own control.
|
|
*
|
|
* ── The overlay ──
|
|
*
|
|
* `ctrl+alt+space` opens a list in the middle of the screen; `j`/`k` move through it and `l` applies the
|
|
* focused theme to the app BEHIND it. That is the whole idea, and it is why this is a portal over a
|
|
* translucent backdrop rather than a modal dialog: you are meant to watch the application change while
|
|
* you browse, so as little of it as possible should be covered.
|
|
*
|
|
* Ported from the owner's `vimazing`, keys and all.
|
|
*/
|
|
export const ThemeSwitcher = () => {
|
|
const { theme, themeId, setTheme, themes } = useTheme();
|
|
const { open, setOpen, focused, setFocused } = useThemeKeys();
|
|
|
|
// Grouped by family, so `Duck Light`/`Duck Dark` and the two Catppuccins sit together. The registry is
|
|
// readonly, so this builds mutable arrays rather than pushing into its own type.
|
|
const families = themes.reduce<Record<string, Theme[]>>((acc, t) => {
|
|
(acc[t.family] ??= []).push(t);
|
|
return acc;
|
|
}, {});
|
|
|
|
return (
|
|
<>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
title={`Theme: ${theme.name} · ctrl+alt+space for the list, ctrl+shift+h/l to cycle`}
|
|
aria-label="Theme"
|
|
className="flex h-8 w-8 items-center justify-center rounded-full transition-transform hover:scale-110 cursor-pointer"
|
|
style={{ color: 'var(--header-fg)' }}
|
|
>
|
|
<Palette className="h-4 w-4" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="z-50 max-h-[70vh] w-56 overflow-y-auto">
|
|
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
|
ctrl+alt+space · ctrl+shift+h/l
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
{Object.entries(families).map(([family, entries]) => (
|
|
<div key={family}>
|
|
{entries.map((t) => (
|
|
<DropdownMenuItem key={t.id} onClick={() => setTheme(t.id)} className="cursor-pointer">
|
|
<Check className={`mr-2 h-4 w-4 ${t.id === themeId ? 'opacity-100' : 'opacity-0'}`} />
|
|
{t.name}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</div>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{open &&
|
|
createPortal(
|
|
<div
|
|
className="fixed inset-0 z-[900] flex items-center justify-center"
|
|
// Deliberately light: the point is to see the app change behind it.
|
|
style={{ backgroundColor: 'color-mix(in srgb, var(--background) 55%, transparent)' }}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
<div
|
|
className="rounded-2xl border px-10 py-8 shadow-2xl"
|
|
style={{
|
|
backgroundColor: 'var(--popover)',
|
|
borderColor: 'var(--border)',
|
|
color: 'var(--popover-foreground)',
|
|
}}
|
|
onClick={(ev) => ev.stopPropagation()}
|
|
>
|
|
<ul className="flex flex-col gap-1">
|
|
{themes.map((t) => {
|
|
const isFocused = t.id === focused;
|
|
return (
|
|
<li key={t.id}>
|
|
<button
|
|
onMouseEnter={() => setFocused(t.id)}
|
|
onClick={() => {
|
|
setTheme(t.id);
|
|
setOpen(false);
|
|
}}
|
|
className={`w-full rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
|
|
isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100'
|
|
}`}
|
|
>
|
|
{t.name}
|
|
{t.id === themeId && <span className="ml-2 align-middle text-xs opacity-70">active</span>}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
<p className="mt-6 text-center text-xs opacity-60">
|
|
j/k move · l preview · h reset · enter keep · esc cancel
|
|
</p>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</>
|
|
);
|
|
};
|