diff --git a/ui/src/common/index.js b/ui/src/common/index.js index f64d4fe0..35622568 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -41,3 +41,4 @@ export * from './formatRange.js' export * from './playlistUtils.js' export * from './PathField.jsx' export * from './ParticipantsInfo' +export * from './useSearchRefocus' diff --git a/ui/src/common/useSearchRefocus.js b/ui/src/common/useSearchRefocus.js new file mode 100644 index 00000000..4daad26f --- /dev/null +++ b/ui/src/common/useSearchRefocus.js @@ -0,0 +1,50 @@ +import { useEffect, useRef } from 'react' +import { useLocation } from 'react-router-dom' + +// Search field names used by SearchInput across different list views: +// - 'name': AlbumList, ArtistList, LibraryList, PlayerList, RadioList, UserList +// - 'title': SongList +// - 'q': PlaylistList +// If a new list view uses a different source field, add it here. +const SEARCH_FIELDS = ['name', 'title', 'q'] + +const getSearchValue = (filter) => { + for (const field of SEARCH_FIELDS) { + if (filter[field]) return filter[field] + } + return '' +} + +export const useSearchRefocus = () => { + const location = useLocation() + const prevSearchValue = useRef(null) + + useEffect(() => { + const params = new URLSearchParams(location.search) + const filterStr = params.get('filter') || '{}' + + let filter = {} + try { + filter = JSON.parse(filterStr) + } catch (e) { + // Invalid JSON, ignore + } + + const searchValue = getSearchValue(filter) + + if (prevSearchValue.current && !searchValue) { + // Use requestAnimationFrame to wait for React to finish re-rendering + // after the URL change before focusing the input + requestAnimationFrame(() => { + // Selector depends on react-admin's internal class naming. + // If react-admin changes these class names, this will need updating. + const input = document.querySelector('[class*="RaSearchInput"] input') + if (input) { + input.focus() + } + }) + } + + prevSearchValue.current = searchValue + }, [location.search]) +} diff --git a/ui/src/common/useSearchRefocus.test.js b/ui/src/common/useSearchRefocus.test.js new file mode 100644 index 00000000..2bce8320 --- /dev/null +++ b/ui/src/common/useSearchRefocus.test.js @@ -0,0 +1,84 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' +import { renderHook } from '@testing-library/react-hooks' +import { useSearchRefocus } from './useSearchRefocus' + +const mockLocation = { search: '' } +vi.mock('react-router-dom', () => ({ + useLocation: () => mockLocation, +})) + +describe('useSearchRefocus', () => { + let container + let rafCallbacks + + beforeEach(() => { + rafCallbacks = [] + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + rafCallbacks.push(cb) + return rafCallbacks.length + }) + + container = document.createElement('div') + container.innerHTML = ` +
+ +
+ ` + document.body.appendChild(container) + mockLocation.search = '' + }) + + afterEach(() => { + vi.restoreAllMocks() + document.body.removeChild(container) + }) + + const flushRAF = () => { + rafCallbacks.forEach((cb) => cb()) + rafCallbacks = [] + } + + it('focuses the input when search filter is cleared', () => { + const input = container.querySelector('input') + const focusSpy = vi.spyOn(input, 'focus') + + mockLocation.search = '?filter={"name":"test"}' + const { rerender } = renderHook(() => useSearchRefocus()) + + expect(focusSpy).not.toHaveBeenCalled() + + mockLocation.search = '?filter={}' + rerender() + flushRAF() + + expect(focusSpy).toHaveBeenCalledTimes(1) + }) + + it('does not focus if filter was already empty', () => { + const input = container.querySelector('input') + const focusSpy = vi.spyOn(input, 'focus') + + mockLocation.search = '?filter={}' + const { rerender } = renderHook(() => useSearchRefocus()) + + mockLocation.search = '?filter={}' + rerender() + flushRAF() + + expect(focusSpy).not.toHaveBeenCalled() + }) + + it('does not focus if filter value changed but not cleared', () => { + const input = container.querySelector('input') + const focusSpy = vi.spyOn(input, 'focus') + + mockLocation.search = '?filter={"name":"test"}' + const { rerender } = renderHook(() => useSearchRefocus()) + + mockLocation.search = '?filter={"name":"other"}' + rerender() + flushRAF() + + expect(focusSpy).not.toHaveBeenCalled() + }) +}) diff --git a/ui/src/layout/Layout.jsx b/ui/src/layout/Layout.jsx index e3f13d25..44cf9b42 100644 --- a/ui/src/layout/Layout.jsx +++ b/ui/src/layout/Layout.jsx @@ -7,6 +7,7 @@ import Menu from './Menu' import AppBar from './AppBar' import Notification from './Notification' import useCurrentTheme from '../themes/useCurrentTheme' +import { useSearchRefocus } from '../common' const useStyles = makeStyles({ root: { paddingBottom: (props) => (props.addPadding ? '80px' : 0) }, @@ -17,6 +18,7 @@ const Layout = (props) => { const queue = useSelector((state) => state.player?.queue) const classes = useStyles({ addPadding: queue.length > 0 }) const dispatch = useDispatch() + useSearchRefocus() const keyHandlers = { TOGGLE_MENU: useCallback(() => dispatch(toggleSidebar()), [dispatch]),