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 { 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}>
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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 à…",
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
Reference in New Issue
Block a user