theme overlay is one plain rotation, favourites in place, tab toggles a favourites filter
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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 à…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user