theme overlay is one plain rotation, favourites in place, tab toggles a favourites filter

This commit is contained in:
2026-08-25 17:06:46 +00:00
parent 5f803c3fb0
commit d0f83b58c9
8 changed files with 73 additions and 29 deletions
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Check, Star } from 'lucide-react'; import { Check, Star } from 'lucide-react';
import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme'; import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
@@ -38,7 +38,7 @@ export const ThemeSwitcher = () => {
const [onlyFavorites, setOnlyFavorites] = useState(false); const [onlyFavorites, setOnlyFavorites] = useState(false);
/* /*
One ordering, used by both lists. The DROPDOWN's ordering.
ROTATED to the theme you are wearing rather than sorted: with 200+ installed, the list's only structure 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 is the registry order — families sit together in it — and lifting the selected theme out of its family
@@ -55,16 +55,35 @@ export const ThemeSwitcher = () => {
// `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those. // `favorites` is a fresh Set each render; its CONTENTS are what matter, so key on those.
}, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]); }, [themes, themeId, onlyFavorites, [...favorites].sort().join(',')]);
// What `j`/`k` walk in the overlay: the displayed list MINUS the pinned favourites block. The block is /*
// a shortlist sitting outside the rotation — stepping through it and the main list as one ring made the The OVERLAY's ordering: the same rotation, favourites IN PLACE.
// focus hop up and back on every starred registry-neighbour. With the favourites filter on, the
// favourites ARE the list, so then they are what gets browsed.
const browseOrder = useMemo(
() => (onlyFavorites ? ordered : ordered.filter((entry) => !favorites.has(entry.id))).map((entry) => entry.id),
[ordered, onlyFavorites, [...favorites].sort().join(',')],
);
const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle, browseOrder }); 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]); const families = useMemo(() => groupByFamily(ordered), [ordered]);
@@ -179,11 +198,27 @@ export const ThemeSwitcher = () => {
}} }}
onClick={(ev) => ev.stopPropagation()} onClick={(ev) => ev.stopPropagation()}
> >
{/* The SAME `ordered` list as the dropdown — rotated to what you are wearing, favourites {/* The filter, mirrored from the dropdown — same state, so the two surfaces never disagree
first. Both lists agreeing is the point: "the one below the one I have on" has to mean the about what "the list" currently is. `tab` does the same thing from the keyboard. */}
same thing in both, or the browse teaches you an order the menu then contradicts. */} <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>
)}
<ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto"> <ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto">
{ordered.map((entry) => { {overlayList.map((entry) => {
const isFocused = entry.id === focused; const isFocused = entry.id === focused;
return ( return (
<li key={entry.id}> <li key={entry.id}>
+3 -3
View File
@@ -478,11 +478,11 @@
"codeEditor": "Code-Editor" "codeEditor": "Code-Editor"
}, },
"theme": { "theme": {
"pillTitle": "Design: {{name}} · Strg+Umschalt+←/→ durchblättern · Strg+Umschalt+Leertaste durchsuchen · Strg+Umschalt+F {{starAction}}", "pillTitle": "Design: {{name}} · Strg+Umschalt+←/→ durchblättern · Strg+Umschalt+↑/↓ durchsuchen · Strg+Umschalt+F {{starAction}}",
"pillLabel": "Design: {{name}}", "pillLabel": "Design: {{name}}",
"star": "markieren", "star": "markieren",
"unstar": "Markierung aufheben", "unstar": "Markierung aufheben",
"cycleHint": "Strg+Umschalt+←/→ durchblättern · Strg+Umschalt+Leertaste durchsuchen", "cycleHint": "Strg+Umschalt+←/→ durchblättern · Strg+Umschalt+↑/↓ durchsuchen",
"everyShortcutHint": "Strg+? für alle Tastenkombinationen", "everyShortcutHint": "Strg+? für alle Tastenkombinationen",
"favouritesOnly": "Nur Favoriten ({{count}})", "favouritesOnly": "Nur Favoriten ({{count}})",
"favourites": "Favoriten ({{count}})", "favourites": "Favoriten ({{count}})",
@@ -494,7 +494,7 @@
"addToFavouritesKey": "Zu Favoriten hinzufügen (f)", "addToFavouritesKey": "Zu Favoriten hinzufügen (f)",
"removeFromFavouritesKey": "Aus Favoriten entfernen (f)", "removeFromFavouritesKey": "Aus Favoriten entfernen (f)",
"active": "aktiv", "active": "aktiv",
"browseHint": "j/k oder ↑/↓ bewegen · l oder → Vorschau · f markieren · h oder ← zurücksetzen · Eingabetaste behalten · Esc abbrechen" "browseHint": "j/k oder ↑/↓ bewegen · l oder → Vorschau · f markieren · Tab Favoriten · h oder ← zurücksetzen · Eingabetaste behalten · Esc abbrechen"
}, },
"spotlight": { "spotlight": {
"placeholder": "Gehe zu…", "placeholder": "Gehe zu…",
+1 -1
View File
@@ -494,7 +494,7 @@
"addToFavouritesKey": "Add to favourites (f)", "addToFavouritesKey": "Add to favourites (f)",
"removeFromFavouritesKey": "Remove from favourites (f)", "removeFromFavouritesKey": "Remove from favourites (f)",
"active": "active", "active": "active",
"browseHint": "j/k or ↑/↓ move · l or → preview · f star · h or ← reset · enter keep · esc cancel" "browseHint": "j/k or ↑/↓ move · l or → preview · f star · tab favourites · h or ← reset · enter keep · esc cancel"
}, },
"spotlight": { "spotlight": {
"placeholder": "Go to…", "placeholder": "Go to…",
+1 -1
View File
@@ -494,7 +494,7 @@
"addToFavouritesKey": "Añadir a favoritos (f)", "addToFavouritesKey": "Añadir a favoritos (f)",
"removeFromFavouritesKey": "Quitar de favoritos (f)", "removeFromFavouritesKey": "Quitar de favoritos (f)",
"active": "activo", "active": "activo",
"browseHint": "j/k o ↑/↓ mover · l o → previsualizar · f marcar · h o ← reiniciar · enter mantener · esc cancelar" "browseHint": "j/k o ↑/↓ mover · l o → previsualizar · f marcar · tab favoritos · h o ← reiniciar · enter mantener · esc cancelar"
}, },
"spotlight": { "spotlight": {
"placeholder": "Ir a…", "placeholder": "Ir a…",
+1 -1
View File
@@ -494,7 +494,7 @@
"addToFavouritesKey": "Ajouter aux favoris (f)", "addToFavouritesKey": "Ajouter aux favoris (f)",
"removeFromFavouritesKey": "Retirer des favoris (f)", "removeFromFavouritesKey": "Retirer des favoris (f)",
"active": "actif", "active": "actif",
"browseHint": "j/k ou ↑/↓ déplacer · l ou → aperçu · f marquer · h ou ← réinitialiser · entrée garder · esc annuler" "browseHint": "j/k ou ↑/↓ déplacer · l ou → aperçu · f marquer · tab favoris · h ou ← réinitialiser · entrée garder · esc annuler"
}, },
"spotlight": { "spotlight": {
"placeholder": "Aller à…", "placeholder": "Aller à…",
+1 -1
View File
@@ -494,7 +494,7 @@
"addToFavouritesKey": "Adicionar aos favoritos (f)", "addToFavouritesKey": "Adicionar aos favoritos (f)",
"removeFromFavouritesKey": "Remover dos favoritos (f)", "removeFromFavouritesKey": "Remover dos favoritos (f)",
"active": "ativo", "active": "ativo",
"browseHint": "j/k ou ↑/↓ mover · l ou → pré-visualizar · f marcar · h ou ← reiniciar · enter manter · esc cancelar" "browseHint": "j/k ou ↑/↓ mover · l ou → pré-visualizar · f marcar · tab favoritos · h ou ← reiniciar · enter manter · esc cancelar"
}, },
"spotlight": { "spotlight": {
"placeholder": "Ir para…", "placeholder": "Ir para…",
+1 -1
View File
@@ -494,7 +494,7 @@
"addToFavouritesKey": "Lägg till i favoriter (f)", "addToFavouritesKey": "Lägg till i favoriter (f)",
"removeFromFavouritesKey": "Ta bort från favoriter (f)", "removeFromFavouritesKey": "Ta bort från favoriter (f)",
"active": "aktiv", "active": "aktiv",
"browseHint": "j/k eller ↑/↓ flytta · l eller → förhandsgranska · f stjärna · h eller ← återställ · enter behåll · esc avbryt" "browseHint": "j/k eller ↑/↓ flytta · l eller → förhandsgranska · f stjärna · tab favoriter · h eller ← återställ · enter behåll · esc avbryt"
}, },
"spotlight": { "spotlight": {
"placeholder": "Gå till…", "placeholder": "Gå till…",
+15 -6
View File
@@ -36,12 +36,11 @@ import type { ThemeId } from './themes';
* The starting theme is captured when the list opens, so `h` and `Escape` can undo an entire browse * The starting theme is captured when the list opens, so `h` and `Escape` can undo an entire browse
* regardless of how many previews happened in between. * regardless of how many previews happened in between.
* *
* Browsing walks `browseOrder` — the list as DISPLAYED — not the registry. The overlay pins favourites * Browsing walks `browseOrder` — the list as DISPLAYED — not the registry. The two orders must be the
* in a block at the top, and stepping in registry order through a display ordered differently meant the * same list or the focus visibly jumps, which is exactly what happened when the overlay lifted
* focus hopped up into that block and back out whenever a registry-neighbour happened to be starred. * favourites to a top block while stepping walked registry order. The overlay now shows one plain
* The favourites block is a shortlist, deliberately outside the rotation; the global cycle * rotation and `tab` flips a favourites-only filter (`onToggleFilter`); either way, what you see is
* (`ctrl+shift+←/→`) still covers every theme, because with no list on screen there is no order to * what `j`/`k` walk.
* disagree with.
* *
* ── Not a UI ── * ── Not a UI ──
* *
@@ -61,6 +60,8 @@ type ThemeKeysOptions = {
* browse falls back to the full registry rotation. * browse falls back to the full registry rotation.
*/ */
browseOrder?: readonly ThemeId[]; browseOrder?: readonly ThemeId[];
/** Flip the favourites-only filter — bound to `tab` while the list is open. App state, like the stars. */
onToggleFilter?: () => void;
}; };
export function useThemeKeys(options: ThemeKeysOptions = {}) { export function useThemeKeys(options: ThemeKeysOptions = {}) {
@@ -71,6 +72,8 @@ export function useThemeKeys(options: ThemeKeysOptions = {}) {
onToggleFavorite.current = options.onToggleFavorite; onToggleFavorite.current = options.onToggleFavorite;
const browseOrder = useRef(options.browseOrder); const browseOrder = useRef(options.browseOrder);
browseOrder.current = options.browseOrder; browseOrder.current = options.browseOrder;
const onToggleFilter = useRef(options.onToggleFilter);
onToggleFilter.current = options.onToggleFilter;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [focused, setFocused] = useState<ThemeId>(themeId); const [focused, setFocused] = useState<ThemeId>(themeId);
const startedOn = useRef<ThemeId>(themeId); const startedOn = useRef<ThemeId>(themeId);
@@ -188,6 +191,12 @@ export function useThemeKeys(options: ThemeKeysOptions = {}) {
event.preventDefault(); event.preventDefault();
onToggleFavorite.current?.(focus); onToggleFavorite.current?.(focus);
return; return;
case 'Tab':
// Favourites only ↔ everything. preventDefault or the browser also moves focus, and the
// overlay has nothing focusable to move it to.
event.preventDefault();
onToggleFilter.current?.();
return;
case 'j': case 'j':
case 'J': case 'J':
case 'ArrowDown': case 'ArrowDown':