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
+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 };
}