fix(ui): Search focus after clear (#4932)
* wip * refactor implem * fixes
This commit is contained in:
@@ -41,3 +41,4 @@ export * from './formatRange.js'
|
|||||||
export * from './playlistUtils.js'
|
export * from './playlistUtils.js'
|
||||||
export * from './PathField.jsx'
|
export * from './PathField.jsx'
|
||||||
export * from './ParticipantsInfo'
|
export * from './ParticipantsInfo'
|
||||||
|
export * from './useSearchRefocus'
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,6 +7,7 @@ import Menu from './Menu'
|
|||||||
import AppBar from './AppBar'
|
import AppBar from './AppBar'
|
||||||
import Notification from './Notification'
|
import Notification from './Notification'
|
||||||
import useCurrentTheme from '../themes/useCurrentTheme'
|
import useCurrentTheme from '../themes/useCurrentTheme'
|
||||||
|
import { useSearchRefocus } from '../common'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: { paddingBottom: (props) => (props.addPadding ? '80px' : 0) },
|
root: { paddingBottom: (props) => (props.addPadding ? '80px' : 0) },
|
||||||
@@ -17,6 +18,7 @@ const Layout = (props) => {
|
|||||||
const queue = useSelector((state) => state.player?.queue)
|
const queue = useSelector((state) => state.player?.queue)
|
||||||
const classes = useStyles({ addPadding: queue.length > 0 })
|
const classes = useStyles({ addPadding: queue.length > 0 })
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
|
useSearchRefocus()
|
||||||
|
|
||||||
const keyHandlers = {
|
const keyHandlers = {
|
||||||
TOGGLE_MENU: useCallback(() => dispatch(toggleSidebar()), [dispatch]),
|
TOGGLE_MENU: useCallback(() => dispatch(toggleSidebar()), [dispatch]),
|
||||||
|
|||||||
Reference in New Issue
Block a user