fix(ui): Search focus after clear (#4932)

* wip

* refactor implem

* fixes
This commit is contained in:
Boris Rorsvort
2026-02-21 20:39:38 +01:00
committed by GitHub
parent dc4607c657
commit 74aa4d6fa5
4 changed files with 137 additions and 0 deletions
+1
View File
@@ -41,3 +41,4 @@ export * from './formatRange.js'
export * from './playlistUtils.js'
export * from './PathField.jsx'
export * from './ParticipantsInfo'
export * from './useSearchRefocus'
+50
View File
@@ -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])
}
+84
View File
@@ -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 = `
<div class="RaSearchInput-input">
<input type="text" />
</div>
`
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()
})
})
+2
View File
@@ -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]),