Files
platform/src/apps/officer-web/Screens/Dashboard/Layout/Header/ThemeSwitcher.tsx
T

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,
)}
</>
);
};