From 33af5e625b4fa268613a0fa92995b4a5c1d3e00c Mon Sep 17 00:00:00 2001 From: DerPenz Date: Sat, 26 Jul 2025 14:01:05 +0200 Subject: [PATCH] support tab navigation on ActionIcons in command palette --- .../components/command-item-selectable.tsx | 37 +++++++ .../search/components/command-palette.tsx | 101 ++++++++++-------- .../components/library-command-item.tsx | 34 +++++- 3 files changed, 127 insertions(+), 45 deletions(-) create mode 100644 src/renderer/features/search/components/command-item-selectable.tsx diff --git a/src/renderer/features/search/components/command-item-selectable.tsx b/src/renderer/features/search/components/command-item-selectable.tsx new file mode 100644 index 00000000..91e63740 --- /dev/null +++ b/src/renderer/features/search/components/command-item-selectable.tsx @@ -0,0 +1,37 @@ +import { Command } from 'cmdk'; +import { ComponentPropsWithoutRef, ReactNode, useEffect, useRef, useState } from 'react'; + +interface CommandItemSelectableProps + extends Omit, 'children'> { + children: (args: { isHighlighted: boolean }) => ReactNode; +} + +export function CommandItemSelectable({ children, ...itemProps }: CommandItemSelectableProps) { + const ref = useRef(null); + const [isHighlighted, setIsHighlighted] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + setIsHighlighted(el.getAttribute('aria-selected') === 'true'); + + const observer = new MutationObserver(() => { + const selected = el.getAttribute('aria-selected') === 'true'; + setIsHighlighted(selected); + }); + + observer.observe(el, { + attributeFilter: ['aria-selected'], + attributes: true, + }); + + return () => observer.disconnect(); + }, []); + + return ( + + {children({ isHighlighted })} + + ); +} diff --git a/src/renderer/features/search/components/command-palette.tsx b/src/renderer/features/search/components/command-palette.tsx index ec018985..bef4fd41 100644 --- a/src/renderer/features/search/components/command-palette.tsx +++ b/src/renderer/features/search/components/command-palette.tsx @@ -5,6 +5,7 @@ import { generatePath, useNavigate } from 'react-router'; import { usePlayQueueAdd } from '/@/renderer/features/player'; import { Command, CommandPalettePages } from '/@/renderer/features/search/components/command'; +import { CommandItemSelectable } from '/@/renderer/features/search/components/command-item-selectable'; import { GoToCommands } from '/@/renderer/features/search/components/go-to-commands'; import { HomeCommands } from '/@/renderer/features/search/components/home-commands'; import { LibraryCommandItem } from '/@/renderer/features/search/components/library-command-item'; @@ -112,6 +113,13 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => { return 0; }} label="Global Command Menu" + onKeyDown={(e) => { + // Focus the search input when navigating with arrow keys + // to prevent the focus from staying on the command-item ActionIcon + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + searchInputRef.current?.focus(); + } + }} onValueChange={setValue} value={value} > @@ -142,7 +150,7 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => { {showAlbumGroup && ( {data?.albums?.map((album) => ( - { navigate( @@ -155,24 +163,27 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => { }} value={`search-${album.id}`} > - artist.name) - .join(', ')} - title={album.name} - /> - + {({ isHighlighted }) => ( + artist.name) + .join(', ')} + title={album.name} + /> + )} + ))} )} {showArtistGroup && ( {data?.albumArtists.map((artist) => ( - { navigate( @@ -185,30 +196,33 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => { }} value={`search-${artist.id}`} > - - + {({ isHighlighted }) => ( + + )} + ))} )} {showTrackGroup && ( {data?.songs.map((song) => ( - { navigate( @@ -221,17 +235,20 @@ export const CommandPalette = ({ modalProps }: CommandPaletteProps) => { }} value={`search-${song.id}`} > - artist.name) - .join(', ')} - title={song.name} - /> - + {({ isHighlighted }) => ( + artist.name) + .join(', ')} + title={song.name} + /> + )} + ))} )} diff --git a/src/renderer/features/search/components/library-command-item.tsx b/src/renderer/features/search/components/library-command-item.tsx index 3aabd892..8a9e8073 100644 --- a/src/renderer/features/search/components/library-command-item.tsx +++ b/src/renderer/features/search/components/library-command-item.tsx @@ -1,4 +1,4 @@ -import { CSSProperties, MouseEvent, useCallback, useState } from 'react'; +import { CSSProperties, SyntheticEvent, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import styles from './library-command-item.module.css'; @@ -16,6 +16,7 @@ interface LibraryCommandItemProps { handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void; id: string; imageUrl: null | string; + isHighlighted?: boolean; itemType: LibraryItem; subtitle?: string; title?: string; @@ -26,6 +27,7 @@ export const LibraryCommandItem = ({ handlePlayQueueAdd, id, imageUrl, + isHighlighted, itemType, subtitle, title, @@ -33,7 +35,7 @@ export const LibraryCommandItem = ({ const { t } = useTranslation(); const handlePlay = useCallback( - (e: MouseEvent, id: string, playType: Play) => { + (e: SyntheticEvent, id: string, playType: Play) => { e.stopPropagation(); handlePlayQueueAdd?.({ byItemType: { @@ -48,6 +50,8 @@ export const LibraryCommandItem = ({ const [isHovered, setIsHovered] = useState(false); + const showControls = isHighlighted || isHovered; + return ( - {isHovered && ( + {showControls && ( handlePlay(e, id, Play.NOW)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handlePlay(e, id, Play.NOW); + } + }} size="xs" + tabIndex={disabled ? -1 : 0} tooltip={{ label: t('player.play', { postProcess: 'sentenceCase' }), openDelay: 500, @@ -91,7 +101,13 @@ export const LibraryCommandItem = ({ disabled={disabled} icon="mediaShuffle" onClick={(e) => handlePlay(e, id, Play.SHUFFLE)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handlePlay(e, id, Play.SHUFFLE); + } + }} size="xs" + tabIndex={disabled ? -1 : 0} tooltip={{ label: t('player.shuffle', { postProcess: 'sentenceCase' }), openDelay: 500, @@ -103,7 +119,13 @@ export const LibraryCommandItem = ({ disabled={disabled} icon="mediaPlayLast" onClick={(e) => handlePlay(e, id, Play.LAST)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handlePlay(e, id, Play.LAST); + } + }} size="xs" + tabIndex={disabled ? -1 : 0} tooltip={{ label: t('player.addLast', { postProcess: 'sentenceCase' }), @@ -115,7 +137,13 @@ export const LibraryCommandItem = ({ disabled={disabled} icon="mediaPlayNext" onClick={(e) => handlePlay(e, id, Play.NEXT)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handlePlay(e, id, Play.NEXT); + } + }} size="xs" + tabIndex={disabled ? -1 : 0} tooltip={{ label: t('player.addNext', { postProcess: 'sentenceCase' }), openDelay: 500,