theme browse steps the displayed list, favourites block out of the rotation

This commit is contained in:
2026-08-25 16:58:32 +00:00
parent 4efca226f6
commit 5f803c3fb0
2 changed files with 42 additions and 5 deletions
@@ -35,8 +35,6 @@ export const ThemeSwitcher = () => {
const { theme, themeId, setTheme, themes } = useTheme();
const { t } = useTranslation();
const { favorites, isFavorite, toggle } = useThemeFavorites();
// Declared before the key hook, which needs the toggle: `f` in the list and `ctrl+shift+f` from anywhere.
const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle });
const [onlyFavorites, setOnlyFavorites] = useState(false);
/*
@@ -57,6 +55,17 @@ 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(',')],
);
const { open, setOpen, focused, setFocused } = useThemeKeys({ onToggleFavorite: toggle, browseOrder });
const families = useMemo(() => groupByFamily(ordered), [ordered]);
return (
+31 -3
View File
@@ -36,6 +36,13 @@ 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.
*
* ── Not a UI ──
*
* This hook owns the keys and the selection; something else draws the list. It lives in the theme package
@@ -49,6 +56,11 @@ type ThemeKeysOptions = {
* knowledge. This hook owns keys and selection and nothing else.
*/
onToggleFavorite?: (themeId: ThemeId) => void;
/**
* The ids `j`/`k` traverse while the list is open, in DISPLAY order — see the note above. Absent, the
* browse falls back to the full registry rotation.
*/
browseOrder?: readonly ThemeId[];
};
export function useThemeKeys(options: ThemeKeysOptions = {}) {
@@ -57,6 +69,8 @@ export function useThemeKeys(options: ThemeKeysOptions = {}) {
// on every render, since it closes over a mutation.
const onToggleFavorite = useRef(options.onToggleFavorite);
onToggleFavorite.current = options.onToggleFavorite;
const browseOrder = useRef(options.browseOrder);
browseOrder.current = options.browseOrder;
const [open, setOpen] = useState(false);
const [focused, setFocused] = useState<ThemeId>(themeId);
const startedOn = useRef<ThemeId>(themeId);
@@ -74,6 +88,20 @@ export function useThemeKeys(options: ThemeKeysOptions = {}) {
[themes],
);
// The in-list step. Walks `browseOrder` when given; a focus that is not IN that order — the current
// theme is a favourite, or the mouse hovered one — enters the rotation at its nearest end rather than
// wrapping from an index that does not exist.
const browseStep = useCallback(
(from: ThemeId, delta: number): ThemeId => {
const ids = browseOrder.current;
if (!ids || ids.length === 0) return step(from, delta);
const at = ids.indexOf(from);
if (at === -1) return delta > 0 ? ids[0]! : ids[ids.length - 1]!;
return ids[(at + delta + ids.length) % ids.length]!;
},
[step],
);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const { themeId: current, focused: focus, open: isOpen } = state.current;
@@ -164,13 +192,13 @@ export function useThemeKeys(options: ThemeKeysOptions = {}) {
case 'J':
case 'ArrowDown':
event.preventDefault();
setFocused((prev) => step(prev, 1));
setFocused((prev) => browseStep(prev, 1));
return;
case 'k':
case 'K':
case 'ArrowUp':
event.preventDefault();
setFocused((prev) => step(prev, -1));
setFocused((prev) => browseStep(prev, -1));
return;
case 'l':
case 'L':
@@ -204,7 +232,7 @@ export function useThemeKeys(options: ThemeKeysOptions = {}) {
// the dropdown trigger opening alongside the overlay.
window.addEventListener('keydown', onKeyDown, true);
return () => window.removeEventListener('keydown', onKeyDown, true);
}, [setTheme, step]);
}, [setTheme, step, browseStep]);
return { open, setOpen, focused, setFocused };
}