284 lines
14 KiB
TypeScript
284 lines
14 KiB
TypeScript
import { useEffect, 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();
|
|
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
|
|
|
/*
|
|
The DROPDOWN's ordering.
|
|
|
|
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(',')]);
|
|
|
|
/*
|
|
The OVERLAY's ordering: the same rotation, favourites IN PLACE.
|
|
|
|
The dropdown lifts favourites to a top block, and the overlay used to as well — which made keyboard
|
|
browsing hop into the block and back out, because the rotation and the display disagreed about what
|
|
"next" meant. In the overlay you are browsing, not picking from a menu, so it shows ONE list in
|
|
rotation order and the favourites filter (`tab`, or the star button) is how the shortlist is reached.
|
|
*/
|
|
const overlayList = useMemo(() => {
|
|
const rotated = rotateToSelected(themes, themeId);
|
|
return onlyFavorites ? rotated.filter((entry) => favorites.has(entry.id)) : rotated;
|
|
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
|
|
|
|
const browseOrder = useMemo(() => overlayList.map((entry) => entry.id), [overlayList]);
|
|
|
|
const { open, setOpen, focused, setFocused } = useThemeKeys({
|
|
onToggleFavorite: toggle,
|
|
browseOrder,
|
|
onToggleFilter: () => setOnlyFavorites((was) => !was),
|
|
});
|
|
|
|
// The filter can pull the focused theme out from under the browse — flipping to favourites-only while
|
|
// an unstarred one is focused. Land on the first of what is now shown rather than focusing nothing.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
if (overlayList.some((entry) => entry.id === focused)) return;
|
|
const first = overlayList[0];
|
|
if (first) setFocused(first.id);
|
|
}, [open, overlayList, focused, setFocused]);
|
|
|
|
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 filter, mirrored from the dropdown — same state, so the two surfaces never disagree
|
|
about what "the list" currently is. `f` does the same thing from the keyboard. */}
|
|
<div className="mb-2 text-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOnlyFavorites((was) => !was)}
|
|
className={`inline-flex cursor-pointer items-center gap-1.5 rounded px-2 py-0.5 text-xs font-medium transition-opacity ${
|
|
onlyFavorites ? 'opacity-100' : 'opacity-60 hover:opacity-100'
|
|
}`}
|
|
>
|
|
<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>
|
|
{overlayList.length === 0 && (
|
|
<p className="px-2 py-6 text-center text-xs opacity-60">{t('theme.noFavourites')}</p>
|
|
)}
|
|
{/* FIXED dimensions, not max/content: flipping the favourites filter must not resize the
|
|
panel — the list is 200+ themes on one side of the flip and a handful on the other, and
|
|
a modal that jumps between two sizes on every `f` reads as broken. Long names truncate;
|
|
they already did when the widest name happened to be filtered out. */}
|
|
<ul className="flex h-[70vh] w-[26rem] max-w-[80vw] flex-col gap-1 overflow-y-auto">
|
|
{overlayList.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.removeFromFavourites') : t('theme.addToFavourites')}
|
|
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,
|
|
)}
|
|
</>
|
|
);
|
|
};
|