238 lines
12 KiB
TypeScript
238 lines
12 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { Check, Star } from 'lucide-react';
|
|
import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
|
|
import * as Dropdown from '@/components/ui/dropdown-menu';
|
|
import { useTheme, useThemeKeys } from '@/components/ui/ThemeProvider';
|
|
import { useThemeFavorites } from '@/state/useThemeFavorites';
|
|
import { useTranslation } from '@/lib/i18n';
|
|
|
|
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+shift+↑/↓` 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 with no
|
|
* backdrop rather than a modal dialog: you are meant to watch the application change while you browse,
|
|
* so nothing of it should be covered.
|
|
*
|
|
* Ported from the owner's `vimazing`, keys and all.
|
|
*/
|
|
export const ThemeSwitcher = () => {
|
|
const { theme, themeId, setTheme, themes } = useTheme();
|
|
const { t } = useTranslation();
|
|
const { favorites, isFavorite, toggle } = useThemeFavorites();
|
|
// Declared before the key hook, which needs the toggle: `f` in the list and `ctrl+shift+f` from anywhere.
|
|
const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle });
|
|
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
|
|
|
/*
|
|
One ordering, used by both lists.
|
|
|
|
ROTATED to the theme you are wearing rather than sorted: with 200+ installed, the list's only structure
|
|
is the registry order — families sit together in it — and lifting the selected theme out of its family
|
|
would answer "which am I on" by destroying "what is next to it". A rotation answers both. See
|
|
`theme/rotate.ts`.
|
|
|
|
Then favourites first, which is a second rotation-preserving pass rather than a filter: each half keeps
|
|
the order it was given, so the theme you are wearing still leads.
|
|
*/
|
|
const ordered = useMemo(() => {
|
|
const rotated = rotateToSelected(themes, themeId);
|
|
const listed = onlyFavorites ? rotated.filter((entry) => favorites.has(entry.id)) : rotated;
|
|
return favouritesFirst(listed, favorites);
|
|
// `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those.
|
|
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
|
|
|
|
const families = useMemo(() => groupByFamily(ordered), [ordered]);
|
|
|
|
return (
|
|
<>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
title={t('theme.pillTitle', {
|
|
name: theme.name,
|
|
starAction: isFavorite(themeId) ? t('theme.unstar') : t('theme.star'),
|
|
})}
|
|
aria-label={t('theme.pillLabel', { name: theme.name })}
|
|
// Named rather than a palette glyph: with 227 themes the one you are wearing is worth
|
|
// reading at a glance, and an icon cannot say which that is. Same pill as JobsIndicator so
|
|
// the cluster stays one shape.
|
|
className="flex h-8 cursor-pointer items-center rounded-full bg-black/25 px-2.5 text-xs font-semibold transition-colors hover:bg-black/35"
|
|
style={{ color: 'var(--header-fg)' }}
|
|
>
|
|
{theme.name}
|
|
{/* A star on the pill, so "is this one of mine" is answerable without opening anything. Only
|
|
when true — a hollow star on every theme would be chrome that says nothing 200 times. */}
|
|
{isFavorite(themeId) && <Star className="ml-1.5 h-3 w-3 fill-current opacity-80" />}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
{/*
|
|
The CONTENT does not scroll — the list inside it does.
|
|
|
|
It used to carry `overflow-y-auto` itself, which scrolled the instruction line away with the
|
|
themes. Two hundred entries meant the one line telling you the keyboard shortcuts existed was
|
|
visible for the first half-second and then gone for good, which is the same as not having written
|
|
it. Header and footer are now siblings of a scroll box rather than passengers in one.
|
|
*/}
|
|
<DropdownMenuContent align="end" className="z-50 flex max-h-[70vh] w-64 flex-col overflow-hidden p-0">
|
|
<div className="shrink-0 border-b border-border px-2 py-1.5">
|
|
<DropdownMenuLabel className="px-0 py-0 text-xs font-normal leading-relaxed text-muted-foreground">
|
|
<span className="block">{t('theme.cycleHint')}</span>
|
|
<span className="block">{t('theme.everyShortcutHint')}</span>
|
|
</DropdownMenuLabel>
|
|
<button
|
|
type="button"
|
|
onClick={(ev) => {
|
|
// The dropdown closes on any item activation, and this is a filter rather than a choice.
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
setOnlyFavorites((was) => !was);
|
|
}}
|
|
className={`mt-1 inline-flex cursor-pointer items-center gap-1.5 rounded px-1.5 py-0.5 text-xs font-medium transition-colors ${
|
|
onlyFavorites ? 'bg-primary/15 text-primary' : 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
<Star className={`h-3.5 w-3.5 ${onlyFavorites ? 'fill-current' : ''}`} />
|
|
{onlyFavorites
|
|
? t('theme.favouritesOnly', { count: favorites.size })
|
|
: t('theme.favourites', { count: favorites.size })}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-1">
|
|
{ordered.length === 0 ? (
|
|
<p className="px-2 py-6 text-center text-xs text-muted-foreground">{t('theme.noFavourites')}</p>
|
|
) : (
|
|
families.map(([family, entries]) => (
|
|
<div key={family}>
|
|
{entries.map((entry) => (
|
|
<DropdownMenuItem key={entry.id} onClick={() => setTheme(entry.id)} className="cursor-pointer pr-1">
|
|
<Check
|
|
className={`mr-2 h-4 w-4 shrink-0 ${entry.id === themeId ? 'opacity-100' : 'opacity-0'}`}
|
|
/>
|
|
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
|
{/* A sibling of the row's action, never nested inside it — starring is not choosing. */}
|
|
<button
|
|
type="button"
|
|
aria-label={
|
|
isFavorite(entry.id)
|
|
? t('theme.unstarNamed', { name: entry.name })
|
|
: t('theme.starNamed', { name: entry.name })
|
|
}
|
|
title={isFavorite(entry.id) ? t('theme.removeFromFavourites') : t('theme.addToFavourites')}
|
|
onClick={(ev) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
toggle(entry.id);
|
|
}}
|
|
className="ml-1 shrink-0 rounded p-1 text-muted-foreground hover:text-primary"
|
|
>
|
|
<Star className={`h-3.5 w-3.5 ${isFavorite(entry.id) ? 'fill-current text-primary' : ''}`} />
|
|
</button>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{open &&
|
|
createPortal(
|
|
<div
|
|
// No backdrop at all — the whole point of the browse is watching the app change behind the
|
|
// list, so nothing may sit between them. The full-screen div stays for click-outside-to-close.
|
|
className="fixed inset-0 z-[900] flex items-center justify-center"
|
|
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()}
|
|
>
|
|
{/* The SAME `ordered` list as the dropdown — rotated to what you are wearing, favourites
|
|
first. Both lists agreeing is the point: "the one below the one I have on" has to mean the
|
|
same thing in both, or the browse teaches you an order the menu then contradicts. */}
|
|
<ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto">
|
|
{ordered.map((entry) => {
|
|
const isFocused = entry.id === focused;
|
|
return (
|
|
<li key={entry.id}>
|
|
<button
|
|
onMouseEnter={() => setFocused(entry.id)}
|
|
onClick={() => {
|
|
setTheme(entry.id);
|
|
setOpen(false);
|
|
}}
|
|
className={`flex w-full items-center gap-2 rounded-md px-4 py-1 text-left transition-all cursor-pointer ${
|
|
isFocused ? 'text-2xl font-extrabold' : 'text-lg opacity-60 hover:opacity-100'
|
|
}`}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
|
{entry.id === themeId && (
|
|
<span className="shrink-0 text-xs opacity-70">{t('theme.active')}</span>
|
|
)}
|
|
{/*
|
|
A span, not a button: this sits inside the row's own <button>, and a button inside
|
|
a button is invalid HTML that browsers resolve by dropping one of them. The click
|
|
handler and the role are set explicitly instead, and `f` does the same thing from
|
|
the keyboard.
|
|
*/}
|
|
<span
|
|
role="button"
|
|
tabIndex={-1}
|
|
aria-label={
|
|
isFavorite(entry.id)
|
|
? t('theme.unstarNamed', { name: entry.name })
|
|
: t('theme.starNamed', { name: entry.name })
|
|
}
|
|
title={
|
|
isFavorite(entry.id) ? t('theme.removeFromFavouritesKey') : t('theme.addToFavouritesKey')
|
|
}
|
|
onClick={(ev) => {
|
|
// Or the row's own onClick applies the theme and closes the overlay, which is
|
|
// the opposite of what starring is for.
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
toggle(entry.id);
|
|
}}
|
|
className="shrink-0 rounded p-1 opacity-70 hover:opacity-100"
|
|
>
|
|
<Star className={`h-4 w-4 ${isFavorite(entry.id) ? 'fill-current' : ''}`} />
|
|
</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
{/* Always visible: it sits outside the scroll box above, for the same reason the dropdown's
|
|
header does. */}
|
|
<p className="mt-6 text-center text-xs opacity-60">{t('theme.browseHint')}</p>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</>
|
|
);
|
|
};
|