From 5f803c3fb0125278e00b36cba58db3f01ecb1f05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 25 Aug 2026 16:58:32 +0000 Subject: [PATCH] theme browse steps the displayed list, favourites block out of the rotation --- .../Dashboard/Layout/Header/ThemeSwitcher.tsx | 13 +++++-- src/workspaces/theme/src/useThemeKeys.ts | 34 +++++++++++++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/ThemeSwitcher.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/ThemeSwitcher.tsx index 442cf60e..bca49b61 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/ThemeSwitcher.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/ThemeSwitcher.tsx @@ -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 ( diff --git a/src/workspaces/theme/src/useThemeKeys.ts b/src/workspaces/theme/src/useThemeKeys.ts index f1aa0b3d..905ab698 100644 --- a/src/workspaces/theme/src/useThemeKeys.ts +++ b/src/workspaces/theme/src/useThemeKeys.ts @@ -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); const startedOn = useRef(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 }; }