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 { Check, Star } from 'lucide-react';
import { favouritesFirst, groupByFamily, rotateToSelected } from 'theme';
@@ -38,7 +38,7 @@ export const ThemeSwitcher = () => {
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
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.
}, [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
// 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(',')],
);
/*
The OVERLAY's ordering: the same rotation, favourites IN PLACE.
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]);
@@ -179,11 +198,27 @@ export const ThemeSwitcher = () => {
}}
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. */}
{/* The filter, mirrored from the dropdown — same state, so the two surfaces never disagree
about what "the list" currently is. `tab` 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>
)}
<ul className="flex max-h-[70vh] flex-col gap-1 overflow-y-auto">
{ordered.map((entry) => {
{overlayList.map((entry) => {
const isFocused = entry.id === focused;
return (
<li key={entry.id}>
+3 -3
View File
@@ -478,11 +478,11 @@
"codeEditor": "Code-Editor"
},
"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}}",
"star": "markieren",
"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",
"favouritesOnly": "Nur Favoriten ({{count}})",
"favourites": "Favoriten ({{count}})",
@@ -494,7 +494,7 @@
"addToFavouritesKey": "Zu Favoriten hinzufügen (f)",
"removeFromFavouritesKey": "Aus Favoriten entfernen (f)",
"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": {
"placeholder": "Gehe zu…",
+1 -1
View File
@@ -494,7 +494,7 @@
"addToFavouritesKey": "Add to favourites (f)",
"removeFromFavouritesKey": "Remove from favourites (f)",
"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": {
"placeholder": "Go to…",
+1 -1
View File
@@ -494,7 +494,7 @@
"addToFavouritesKey": "Añadir a favoritos (f)",
"removeFromFavouritesKey": "Quitar de favoritos (f)",
"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": {
"placeholder": "Ir a…",
+1 -1
View File
@@ -494,7 +494,7 @@
"addToFavouritesKey": "Ajouter aux favoris (f)",
"removeFromFavouritesKey": "Retirer des favoris (f)",
"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": {
"placeholder": "Aller à…",
+1 -1
View File
@@ -494,7 +494,7 @@
"addToFavouritesKey": "Adicionar aos favoritos (f)",
"removeFromFavouritesKey": "Remover dos favoritos (f)",
"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": {
"placeholder": "Ir para…",
+1 -1
View File
@@ -494,7 +494,7 @@
"addToFavouritesKey": "Lägg till i favoriter (f)",
"removeFromFavouritesKey": "Ta bort från favoriter (f)",
"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": {
"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
* regardless of how many previews happened in between.
*
* Browsing walks `browseOrder` — the list as DISPLAYED — not the registry. The overlay pins favourites
* in a block at the top, and stepping in registry order through a display ordered differently meant the
* focus hopped up into that block and back out whenever a registry-neighbour happened to be starred.
* The favourites block is a shortlist, deliberately outside the rotation; the global cycle
* (`ctrl+shift+←/→`) still covers every theme, because with no list on screen there is no order to
* disagree with.
* Browsing walks `browseOrder` — the list as DISPLAYED — not the registry. The two orders must be the
* same list or the focus visibly jumps, which is exactly what happened when the overlay lifted
* favourites to a top block while stepping walked registry order. The overlay now shows one plain
* rotation and `tab` flips a favourites-only filter (`onToggleFilter`); either way, what you see is
* what `j`/`k` walk.
*
* ── Not a UI ──
*
@@ -61,6 +60,8 @@ type ThemeKeysOptions = {
* browse falls back to the full registry rotation.
*/
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 = {}) {
@@ -71,6 +72,8 @@ export function useThemeKeys(options: ThemeKeysOptions = {}) {
onToggleFavorite.current = options.onToggleFavorite;
const browseOrder = useRef(options.browseOrder);
browseOrder.current = options.browseOrder;
const onToggleFilter = useRef(options.onToggleFilter);
onToggleFilter.current = options.onToggleFilter;
const [open, setOpen] = useState(false);
const [focused, setFocused] = useState<ThemeId>(themeId);
const startedOn = useRef<ThemeId>(themeId);
@@ -188,6 +191,12 @@ export function useThemeKeys(options: ThemeKeysOptions = {}) {
event.preventDefault();
onToggleFavorite.current?.(focus);
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 'ArrowDown':