mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-12 20:37:12 +02:00
fix(ui): harden responsive lyrics interactions
Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
parent
ff1dcc9773
commit
d562462a3b
13 changed files with 838 additions and 180 deletions
|
|
@ -25,7 +25,6 @@ import {
|
|||
KARAOKE_LINE_MOTION_EASING,
|
||||
KARAOKE_MANUAL_SCROLL_PAUSE_MS,
|
||||
KARAOKE_SCROLLBAR_VISIBLE_MS,
|
||||
KARAOKE_TRANSLATION_IDLE_OPACITY,
|
||||
} from './lyricsKaraokeConstants'
|
||||
import {
|
||||
animateScrollTop,
|
||||
|
|
@ -112,7 +111,6 @@ const useStyles = makeStyles((theme) => ({
|
|||
'--lyrics-translation-current-color':
|
||||
'var(--lyrics-translation-idle-color, currentColor)',
|
||||
'--lyrics-layer-opacity': KARAOKE_IDLE_LAYER_OPACITY,
|
||||
'--lyrics-translation-opacity': KARAOKE_TRANSLATION_IDLE_OPACITY,
|
||||
transform: 'translateY(0)',
|
||||
transition: `background-color 150ms ${KARAOKE_LINE_MOTION_EASING}`,
|
||||
'&[role="button"]:focus-visible': {
|
||||
|
|
@ -136,7 +134,6 @@ const useStyles = makeStyles((theme) => ({
|
|||
'--lyrics-translation-current-color':
|
||||
'var(--lyrics-translation-active-color, var(--lyrics-translation-idle-color, currentColor))',
|
||||
'--lyrics-layer-opacity': 1,
|
||||
'--lyrics-translation-opacity': 1,
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
|
|
@ -182,17 +179,17 @@ const useStyles = makeStyles((theme) => ({
|
|||
},
|
||||
auxLine: {
|
||||
display: 'block',
|
||||
opacity: 1,
|
||||
fontWeight: 600,
|
||||
fontSize: 15,
|
||||
lineHeight: KARAOKE_AUX_LINE_HEIGHT,
|
||||
overflowWrap: 'anywhere',
|
||||
whiteSpace: 'pre-wrap',
|
||||
letterSpacing: 0,
|
||||
opacity: 'var(--lyrics-translation-opacity)',
|
||||
color: 'var(--lyrics-translation-current-color, currentColor)',
|
||||
WebkitTextFillColor:
|
||||
'var(--lyrics-translation-current-color, currentColor)',
|
||||
transition: KARAOKE_LAYER_OPACITY_TRANSITION,
|
||||
transition: 'none',
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
transform: 'none',
|
||||
|
|
@ -412,6 +409,7 @@ const usePrefersReducedMotion = () => {
|
|||
|
||||
const LyricsPanel = ({
|
||||
visible = true,
|
||||
obscured = false,
|
||||
mainLyric,
|
||||
translationLyric,
|
||||
pronunciationLyric,
|
||||
|
|
@ -436,7 +434,9 @@ const LyricsPanel = ({
|
|||
const [hasTopFade, setHasTopFade] = useState(false)
|
||||
const [scrollEndPadding, setScrollEndPadding] = useState(0)
|
||||
const [scrollStartPadding, setScrollStartPadding] = useState(0)
|
||||
const [focusedLineIndex, setFocusedLineIndex] = useState(null)
|
||||
const prefersReducedMotion = usePrefersReducedMotion()
|
||||
const timelineVisible = visible && !obscured
|
||||
const activeLineAnchorRatio = inline
|
||||
? KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO
|
||||
: KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO
|
||||
|
|
@ -466,10 +466,26 @@ const LyricsPanel = ({
|
|||
} = useLyricsTimeline({
|
||||
lines: mainLines,
|
||||
audioInstance,
|
||||
visible: visible && hasTimedMainLines,
|
||||
visible: timelineVisible && hasTimedMainLines,
|
||||
reducedMotion: prefersReducedMotion,
|
||||
})
|
||||
const activeIndexSet = useMemo(() => new Set(activeIndexes), [activeIndexes])
|
||||
const seekableLineIndexes = useMemo(() => {
|
||||
if (!audioInstance) return []
|
||||
return mainLines.reduce((indexes, line, index) => {
|
||||
if (line.renderable !== false && line.start != null) indexes.push(index)
|
||||
return indexes
|
||||
}, [])
|
||||
}, [audioInstance, mainLines])
|
||||
const tabbableLineIndex = seekableLineIndexes.includes(focusedLineIndex)
|
||||
? focusedLineIndex
|
||||
: seekableLineIndexes.includes(activeIndex)
|
||||
? activeIndex
|
||||
: seekableLineIndexes[0]
|
||||
|
||||
useEffect(() => {
|
||||
setFocusedLineIndex(null)
|
||||
}, [mainLyric])
|
||||
|
||||
const trByMainIndex = useMemo(() => {
|
||||
if (!showTranslation || translationLines.length === 0) return {}
|
||||
|
|
@ -630,7 +646,7 @@ const LyricsPanel = ({
|
|||
}, [clearManualScrollTimer, mainLyric, visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !hasTimedMainLines || scrollTargetIndex < 0) {
|
||||
if (!timelineVisible || !hasTimedMainLines || scrollTargetIndex < 0) {
|
||||
cancelScrollAnimation(scrollAnimationRef)
|
||||
return undefined
|
||||
}
|
||||
|
|
@ -661,7 +677,7 @@ const LyricsPanel = ({
|
|||
layoutVersion,
|
||||
prefersReducedMotion,
|
||||
scrollTargetIndex,
|
||||
visible,
|
||||
timelineVisible,
|
||||
])
|
||||
|
||||
if (!visible) return null
|
||||
|
|
@ -697,6 +713,38 @@ const LyricsPanel = ({
|
|||
resumeAutoScroll()
|
||||
}
|
||||
|
||||
const focusSeekableLine = (index) => {
|
||||
setFocusedLineIndex(index)
|
||||
getLineNode(index)?.focus()
|
||||
}
|
||||
|
||||
const handleLineKeyDown = (event, index, line) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
seekToLine(line)
|
||||
return
|
||||
}
|
||||
|
||||
const currentPosition = seekableLineIndexes.indexOf(index)
|
||||
if (currentPosition < 0) return
|
||||
let nextPosition = null
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||||
nextPosition = Math.min(
|
||||
currentPosition + 1,
|
||||
seekableLineIndexes.length - 1,
|
||||
)
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
||||
nextPosition = Math.max(currentPosition - 1, 0)
|
||||
} else if (event.key === 'Home') {
|
||||
nextPosition = 0
|
||||
} else if (event.key === 'End') {
|
||||
nextPosition = seekableLineIndexes.length - 1
|
||||
}
|
||||
if (nextPosition == null) return
|
||||
event.preventDefault()
|
||||
focusSeekableLine(seekableLineIndexes[nextPosition])
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(classes.root, { [classes.inlineRoot]: inline })}
|
||||
|
|
@ -780,8 +828,22 @@ const LyricsPanel = ({
|
|||
data-testid="lyrics-line-group"
|
||||
style={buildLineGroupStyle(canSeekLine, layerStyles)}
|
||||
role={canSeekLine ? 'button' : undefined}
|
||||
tabIndex={canSeekLine ? 0 : undefined}
|
||||
tabIndex={
|
||||
canSeekLine ? (idx === tabbableLineIndex ? 0 : -1) : undefined
|
||||
}
|
||||
onClick={() => seekToLine(line)}
|
||||
onFocus={
|
||||
canSeekLine ? () => setFocusedLineIndex(idx) : undefined
|
||||
}
|
||||
onBlur={
|
||||
canSeekLine
|
||||
? (event) => {
|
||||
if (!bodyRef.current?.contains(event.relatedTarget)) {
|
||||
setFocusedLineIndex(null)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseDown={
|
||||
canSeekLine
|
||||
? (event) => {
|
||||
|
|
@ -789,15 +851,11 @@ const LyricsPanel = ({
|
|||
}
|
||||
: undefined
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
canSeekLine &&
|
||||
(event.key === 'Enter' || event.key === ' ')
|
||||
) {
|
||||
event.preventDefault()
|
||||
seekToLine(line)
|
||||
}
|
||||
}}
|
||||
onKeyDown={
|
||||
canSeekLine
|
||||
? (event) => handleLineKeyDown(event, idx, line)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{lineLanes.length > 1 ? (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import {
|
|||
KARAOKE_HIGHLIGHT_LEAD_MS,
|
||||
KARAOKE_LINE_LIFT_PX,
|
||||
KARAOKE_MANUAL_SCROLL_PAUSE_MS,
|
||||
KARAOKE_TRANSLATION_IDLE_OPACITY,
|
||||
TOKEN_FUTURE_ALPHA,
|
||||
} from './lyricsKaraokeConstants'
|
||||
|
||||
|
|
@ -356,7 +355,7 @@ describe('<LyricsPanel />', () => {
|
|||
expect(pronunciation.style.color).toBe('')
|
||||
})
|
||||
|
||||
it('dims inactive translations without moving them', () => {
|
||||
it('keeps translations subordinate and stable between line states', () => {
|
||||
const propsAt = (currentTime) => ({
|
||||
mainLyric,
|
||||
pronunciationLyric: {
|
||||
|
|
@ -388,17 +387,17 @@ describe('<LyricsPanel />', () => {
|
|||
expect(window.getComputedStyle(mainRow).transition).toContain(
|
||||
`opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`,
|
||||
)
|
||||
expect(window.getComputedStyle(translationRow).transition).toContain(
|
||||
`opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`,
|
||||
)
|
||||
expect(window.getComputedStyle(translationRow).transition).toBe('none')
|
||||
pronunciation.forEach((token) =>
|
||||
expect(token).toHaveAttribute('data-timed', 'false'),
|
||||
)
|
||||
expect(window.getComputedStyle(translationRow).opacity).toBe('1')
|
||||
expect(
|
||||
window
|
||||
.getComputedStyle(lineGroup)
|
||||
.getPropertyValue('--lyrics-translation-opacity'),
|
||||
).toBe('1')
|
||||
lineGroup.style.getPropertyValue('--lyrics-translation-idle-color'),
|
||||
).toBe(theme.palette.text.secondary)
|
||||
expect(
|
||||
lineGroup.style.getPropertyValue('--lyrics-translation-active-color'),
|
||||
).toBe(theme.palette.text.secondary)
|
||||
expect(window.getComputedStyle(translationRow).transform).toBe('')
|
||||
expect(window.getComputedStyle(translationRow).marginTop).toBe(
|
||||
`${theme.spacing(0.6)}px`,
|
||||
|
|
@ -406,12 +405,8 @@ describe('<LyricsPanel />', () => {
|
|||
|
||||
rerenderPanel(propsAt(1.1))
|
||||
|
||||
const inactiveOpacity = Number(
|
||||
window
|
||||
.getComputedStyle(lineGroup)
|
||||
.getPropertyValue('--lyrics-translation-opacity'),
|
||||
)
|
||||
expect(inactiveOpacity).toBe(KARAOKE_TRANSLATION_IDLE_OPACITY)
|
||||
expect(window.getComputedStyle(translationRow).opacity).toBe('1')
|
||||
expect(window.getComputedStyle(translationRow).transition).toBe('none')
|
||||
expect(window.getComputedStyle(translationRow).transform).toBe('')
|
||||
})
|
||||
|
||||
|
|
@ -766,6 +761,97 @@ describe('<LyricsPanel />', () => {
|
|||
expect(audioInstance.currentTime).toBe(2.3)
|
||||
})
|
||||
|
||||
it('uses roving focus for seekable lines without seeking on navigation', () => {
|
||||
const audioInstance = createAudio({ currentTime: 1.5 })
|
||||
renderPanel({
|
||||
mainLyric: {
|
||||
synced: true,
|
||||
line: [
|
||||
{ start: 0, end: 900, value: 'First seekable line' },
|
||||
{ value: 'Untimed line' },
|
||||
{ start: 1000, end: 1900, value: 'Active seekable line' },
|
||||
{ start: 2000, end: 2900, value: 'Last seekable line' },
|
||||
],
|
||||
},
|
||||
audioInstance,
|
||||
})
|
||||
|
||||
const first = screen
|
||||
.getByText('First seekable line')
|
||||
.closest('[data-testid="lyrics-line-group"]')
|
||||
const untimed = screen
|
||||
.getByText('Untimed line')
|
||||
.closest('[data-testid="lyrics-line-group"]')
|
||||
const active = screen
|
||||
.getByText('Active seekable line')
|
||||
.closest('[data-testid="lyrics-line-group"]')
|
||||
const last = screen
|
||||
.getByText('Last seekable line')
|
||||
.closest('[data-testid="lyrics-line-group"]')
|
||||
|
||||
expect(first).toHaveAttribute('tabindex', '-1')
|
||||
expect(untimed).not.toHaveAttribute('tabindex')
|
||||
expect(active).toHaveAttribute('tabindex', '0')
|
||||
expect(last).toHaveAttribute('tabindex', '-1')
|
||||
|
||||
active.focus()
|
||||
fireEvent.keyDown(active, { key: 'ArrowDown' })
|
||||
expect(document.activeElement).toBe(last)
|
||||
expect(last).toHaveAttribute('tabindex', '0')
|
||||
expect(audioInstance.currentTime).toBe(1.5)
|
||||
|
||||
fireEvent.keyDown(last, { key: 'ArrowRight' })
|
||||
expect(document.activeElement).toBe(last)
|
||||
fireEvent.keyDown(last, { key: 'ArrowUp' })
|
||||
expect(document.activeElement).toBe(active)
|
||||
fireEvent.keyDown(active, { key: 'ArrowLeft' })
|
||||
expect(document.activeElement).toBe(first)
|
||||
expect(audioInstance.currentTime).toBe(1.5)
|
||||
|
||||
fireEvent.keyDown(first, { key: 'End' })
|
||||
expect(document.activeElement).toBe(last)
|
||||
fireEvent.keyDown(last, { key: 'Home' })
|
||||
expect(document.activeElement).toBe(first)
|
||||
expect(audioInstance.currentTime).toBe(1.5)
|
||||
|
||||
fireEvent.keyDown(first, { key: ' ' })
|
||||
expect(audioInstance.currentTime).toBe(0)
|
||||
fireEvent.keyDown(last, { key: 'Enter' })
|
||||
expect(audioInstance.currentTime).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps lyrics mounted but pauses timeline frames while obscured', () => {
|
||||
const audioInstance = createAudio({ currentTime: 0.25 })
|
||||
audioInstance.paused = false
|
||||
window.requestAnimationFrame.mockImplementation(() => 7)
|
||||
window.requestAnimationFrame.mockClear()
|
||||
window.cancelAnimationFrame.mockClear()
|
||||
|
||||
const { rerenderPanel } = renderPanel({
|
||||
mainLyric: tokenizedMainLyric,
|
||||
audioInstance,
|
||||
obscured: true,
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument()
|
||||
expect(window.requestAnimationFrame).not.toHaveBeenCalled()
|
||||
|
||||
rerenderPanel({
|
||||
mainLyric: tokenizedMainLyric,
|
||||
audioInstance,
|
||||
obscured: false,
|
||||
})
|
||||
expect(window.requestAnimationFrame).toHaveBeenCalled()
|
||||
|
||||
rerenderPanel({
|
||||
mainLyric: tokenizedMainLyric,
|
||||
audioInstance,
|
||||
obscured: true,
|
||||
})
|
||||
expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument()
|
||||
expect(window.cancelAnimationFrame).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('pauses auto-scroll only for genuine manual scroll intent', async () => {
|
||||
vi.useFakeTimers()
|
||||
const requestAnimationFrameSpy = vi
|
||||
|
|
@ -834,21 +920,23 @@ describe('<LyricsPanel />', () => {
|
|||
const accessibleTheme = createTheme({ palette: { type } })
|
||||
const background = accessibleTheme.palette.background.default
|
||||
|
||||
renderPanel(
|
||||
{
|
||||
mainLyric: tokenizedMainLyric,
|
||||
translationLyric: {
|
||||
synced: true,
|
||||
line: [{ start: 0, end: 1000, value: 'Readable translation' }],
|
||||
},
|
||||
showTranslation: true,
|
||||
audioInstance: { currentTime: 0.25, paused: true },
|
||||
const propsAt = (currentTime) => ({
|
||||
mainLyric: tokenizedMainLyric,
|
||||
translationLyric: {
|
||||
synced: true,
|
||||
line: [{ start: 0, end: 1000, value: 'Readable translation' }],
|
||||
},
|
||||
accessibleTheme,
|
||||
)
|
||||
showTranslation: true,
|
||||
audioInstance: { currentTime, paused: true },
|
||||
})
|
||||
const { rerenderPanel } = renderPanel(propsAt(0.25), accessibleTheme)
|
||||
|
||||
const group = screen.getByTestId('lyrics-line-group')
|
||||
expect(screen.getByText('Readable translation')).toBeInTheDocument()
|
||||
const translation = screen
|
||||
.getByText('Readable translation')
|
||||
.closest('[data-tokenized]')
|
||||
expect(translation).toBeInTheDocument()
|
||||
expect(window.getComputedStyle(translation).opacity).toBe('1')
|
||||
const token = screen.getAllByTestId('lyrics-token')[0]
|
||||
const gradientNode = [token, ...token.querySelectorAll('*')].find(
|
||||
(node) => node.style.backgroundImage.includes('linear-gradient'),
|
||||
|
|
@ -865,11 +953,6 @@ describe('<LyricsPanel />', () => {
|
|||
expect(
|
||||
group.style.getPropertyValue('--lyrics-translation-active-color'),
|
||||
).toBe(accessibleTheme.palette.text.secondary)
|
||||
expect(
|
||||
window
|
||||
.getComputedStyle(group)
|
||||
.getPropertyValue('--lyrics-translation-opacity'),
|
||||
).toBe('1')
|
||||
expect(parseCssColor(futureColor).alpha).toBe(TOKEN_FUTURE_ALPHA)
|
||||
expect(contrastRatio(futureColor, background)).toBeGreaterThanOrEqual(4.5)
|
||||
expect(
|
||||
|
|
@ -880,6 +963,16 @@ describe('<LyricsPanel />', () => {
|
|||
).toBeLessThan(
|
||||
contrastRatio(accessibleTheme.palette.text.primary, background),
|
||||
)
|
||||
|
||||
rerenderPanel(propsAt(1.25))
|
||||
expect(group).toHaveAttribute('data-active', 'false')
|
||||
expect(window.getComputedStyle(translation).opacity).toBe('1')
|
||||
expect(
|
||||
group.style.getPropertyValue('--lyrics-translation-idle-color'),
|
||||
).toBe(accessibleTheme.palette.text.secondary)
|
||||
expect(
|
||||
group.style.getPropertyValue('--lyrics-translation-active-color'),
|
||||
).toBe(accessibleTheme.palette.text.secondary)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
import { alpha, makeStyles } from '@material-ui/core/styles'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useRef } from 'react'
|
||||
import LyricsLayerControls from './LyricsLayerControls'
|
||||
import LyricsPanel from './LyricsPanel'
|
||||
import {
|
||||
LYRICS_SIDEBAR_DEFAULT_WIDTH,
|
||||
LYRICS_SIDEBAR_MAX_WIDTH,
|
||||
LYRICS_SIDEBAR_MIN_WIDTH,
|
||||
LYRICS_SIDEBAR_TRANSITION_MS,
|
||||
LYRICS_SIDEBAR_WIDTH_STEP,
|
||||
clampSidebarMaxWidth,
|
||||
clampSidebarWidth,
|
||||
loadSidebarWidth,
|
||||
notifySidebarWidthChange,
|
||||
saveSidebarWidth,
|
||||
} from './lyricsSidebarWidth'
|
||||
import useEnterExitTransition from './useEnterExitTransition'
|
||||
import useRestoreFocusOnExit from './useRestoreFocusOnExit'
|
||||
|
|
@ -83,8 +82,14 @@ const useStyles = makeStyles((theme) => ({
|
|||
},
|
||||
}))
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
const LyricsSidebar = ({
|
||||
visible,
|
||||
obscuredByQueue = false,
|
||||
width = LYRICS_SIDEBAR_DEFAULT_WIDTH,
|
||||
maxWidth = LYRICS_SIDEBAR_MAX_WIDTH,
|
||||
onWidthChange = noop,
|
||||
mainLyric,
|
||||
translationLyric,
|
||||
pronunciationLyric,
|
||||
|
|
@ -100,27 +105,28 @@ const LyricsSidebar = ({
|
|||
labels = {},
|
||||
returnFocusRef,
|
||||
}) => {
|
||||
const [width, setWidth] = useState(loadSidebarWidth)
|
||||
const resolvedMaxWidth = clampSidebarMaxWidth(maxWidth)
|
||||
const resolvedWidth = clampSidebarWidth(width, resolvedMaxWidth)
|
||||
const sidebarRef = useRef(null)
|
||||
const resizerRef = useRef(null)
|
||||
const widthRef = useRef(width)
|
||||
const widthRef = useRef(resolvedWidth)
|
||||
const resizeCleanupRef = useRef(null)
|
||||
const { rendered, entered } = useEnterExitTransition(
|
||||
visible,
|
||||
LYRICS_SIDEBAR_TRANSITION_MS,
|
||||
)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const interactive = entered && !obscuredByQueue
|
||||
const classes = useStyles()
|
||||
|
||||
useRestoreFocusOnExit({
|
||||
surfaceRef: sidebarRef,
|
||||
entered,
|
||||
entered: interactive,
|
||||
returnFocusRef,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
widthRef.current = width
|
||||
}, [width])
|
||||
widthRef.current = resolvedWidth
|
||||
}, [resolvedWidth])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
|
|
@ -130,26 +136,21 @@ const LyricsSidebar = ({
|
|||
[],
|
||||
)
|
||||
|
||||
const applyWidth = useCallback((next) => {
|
||||
const resolvedWidth = clampSidebarWidth(
|
||||
typeof next === 'function' ? next(widthRef.current) : next,
|
||||
)
|
||||
widthRef.current = resolvedWidth
|
||||
if (sidebarRef.current) {
|
||||
sidebarRef.current.style.width = `${resolvedWidth}px`
|
||||
}
|
||||
resizerRef.current?.setAttribute('aria-valuenow', String(resolvedWidth))
|
||||
notifySidebarWidthChange(resolvedWidth)
|
||||
return resolvedWidth
|
||||
}, [])
|
||||
|
||||
const updateWidth = useCallback(
|
||||
const applyWidth = useCallback(
|
||||
(next, { persist = false } = {}) => {
|
||||
const resolvedWidth = applyWidth(next)
|
||||
setWidth(resolvedWidth)
|
||||
if (persist) saveSidebarWidth(resolvedWidth)
|
||||
const nextWidth = clampSidebarWidth(
|
||||
typeof next === 'function' ? next(widthRef.current) : next,
|
||||
resolvedMaxWidth,
|
||||
)
|
||||
widthRef.current = nextWidth
|
||||
if (sidebarRef.current) {
|
||||
sidebarRef.current.style.width = `${nextWidth}px`
|
||||
}
|
||||
resizerRef.current?.setAttribute('aria-valuenow', String(nextWidth))
|
||||
onWidthChange(nextWidth, { persist })
|
||||
return nextWidth
|
||||
},
|
||||
[applyWidth],
|
||||
[onWidthChange, resolvedMaxWidth],
|
||||
)
|
||||
|
||||
const handleResizePointerDown = useCallback(
|
||||
|
|
@ -166,7 +167,6 @@ const LyricsSidebar = ({
|
|||
const canCapture =
|
||||
pointerId != null && typeof target.setPointerCapture === 'function'
|
||||
const listenerTarget = window
|
||||
setIsResizing(true)
|
||||
|
||||
const handlePointerMove = (moveEvent) => {
|
||||
latestWidth = applyWidth(startWidth + startX - moveEvent.clientX)
|
||||
|
|
@ -190,9 +190,7 @@ const LyricsSidebar = ({
|
|||
// Ignore stale pointer capture state.
|
||||
}
|
||||
}
|
||||
setWidth(latestWidth)
|
||||
if (persist) saveSidebarWidth(latestWidth)
|
||||
setIsResizing(false)
|
||||
if (persist) onWidthChange(latestWidth, { persist: true })
|
||||
if (resizeCleanupRef.current === cleanupResize) {
|
||||
resizeCleanupRef.current = null
|
||||
}
|
||||
|
|
@ -214,30 +212,30 @@ const LyricsSidebar = ({
|
|||
}
|
||||
}
|
||||
},
|
||||
[applyWidth],
|
||||
[applyWidth, onWidthChange],
|
||||
)
|
||||
|
||||
const handleResizeKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
updateWidth((current) => current + LYRICS_SIDEBAR_WIDTH_STEP, {
|
||||
applyWidth((current) => current + LYRICS_SIDEBAR_WIDTH_STEP, {
|
||||
persist: true,
|
||||
})
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
updateWidth((current) => current - LYRICS_SIDEBAR_WIDTH_STEP, {
|
||||
applyWidth((current) => current - LYRICS_SIDEBAR_WIDTH_STEP, {
|
||||
persist: true,
|
||||
})
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault()
|
||||
updateWidth(LYRICS_SIDEBAR_MIN_WIDTH, { persist: true })
|
||||
applyWidth(LYRICS_SIDEBAR_MIN_WIDTH, { persist: true })
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault()
|
||||
updateWidth(LYRICS_SIDEBAR_MAX_WIDTH, { persist: true })
|
||||
applyWidth(resolvedMaxWidth, { persist: true })
|
||||
}
|
||||
},
|
||||
[updateWidth],
|
||||
[applyWidth, resolvedMaxWidth],
|
||||
)
|
||||
|
||||
if (!rendered) return null
|
||||
|
|
@ -248,15 +246,14 @@ const LyricsSidebar = ({
|
|||
data-testid="lyrics-sidebar"
|
||||
ref={sidebarRef}
|
||||
style={{
|
||||
width: widthRef.current,
|
||||
width: resolvedWidth,
|
||||
transform: entered ? 'translateX(0)' : 'translateX(100%)',
|
||||
opacity: entered ? 1 : 0,
|
||||
pointerEvents: entered ? 'auto' : 'none',
|
||||
pointerEvents: interactive ? 'auto' : 'none',
|
||||
}}
|
||||
aria-label={labels.title || 'Lyrics'}
|
||||
aria-hidden={!entered}
|
||||
inert={entered ? undefined : ''}
|
||||
data-resizing={isResizing ? 'true' : 'false'}
|
||||
aria-hidden={!interactive}
|
||||
inert={interactive ? undefined : ''}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -266,8 +263,8 @@ const LyricsSidebar = ({
|
|||
aria-label={labels.resize || 'Resize lyrics sidebar'}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={LYRICS_SIDEBAR_MIN_WIDTH}
|
||||
aria-valuemax={LYRICS_SIDEBAR_MAX_WIDTH}
|
||||
aria-valuenow={width}
|
||||
aria-valuemax={resolvedMaxWidth}
|
||||
aria-valuenow={resolvedWidth}
|
||||
role="separator"
|
||||
onPointerDown={handleResizePointerDown}
|
||||
onKeyDown={handleResizeKeyDown}
|
||||
|
|
@ -285,6 +282,7 @@ const LyricsSidebar = ({
|
|||
/>
|
||||
<LyricsPanel
|
||||
visible={visible}
|
||||
obscured={obscuredByQueue}
|
||||
mainLyric={mainLyric}
|
||||
translationLyric={translationLyric}
|
||||
pronunciationLyric={pronunciationLyric}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
|
|
@ -10,9 +10,9 @@ import { ThemeProvider, createTheme } from '@material-ui/core/styles'
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import LyricsSidebar from './LyricsSidebar'
|
||||
import {
|
||||
LYRICS_SIDEBAR_DEFAULT_WIDTH,
|
||||
LYRICS_SIDEBAR_MAX_WIDTH,
|
||||
LYRICS_SIDEBAR_MIN_WIDTH,
|
||||
LYRICS_SIDEBAR_STORAGE_KEY,
|
||||
LYRICS_SIDEBAR_TRANSITION_MS,
|
||||
} from './lyricsSidebarWidth'
|
||||
|
||||
|
|
@ -33,9 +33,34 @@ const lyric = {
|
|||
}
|
||||
const originalPointerEvent = window.PointerEvent
|
||||
|
||||
const ControlledLyricsSidebar = ({
|
||||
initialWidth = LYRICS_SIDEBAR_DEFAULT_WIDTH,
|
||||
maxWidth = LYRICS_SIDEBAR_MAX_WIDTH,
|
||||
onWidthChange,
|
||||
...props
|
||||
}) => {
|
||||
const [width, setWidth] = useState(initialWidth)
|
||||
const handleWidthChange = useCallback(
|
||||
(nextWidth, options) => {
|
||||
setWidth(nextWidth)
|
||||
onWidthChange?.(nextWidth, options)
|
||||
},
|
||||
[onWidthChange],
|
||||
)
|
||||
|
||||
return (
|
||||
<LyricsSidebar
|
||||
width={width}
|
||||
maxWidth={maxWidth}
|
||||
onWidthChange={handleWidthChange}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarView = (props = {}) => (
|
||||
<ThemeProvider theme={theme}>
|
||||
<LyricsSidebar
|
||||
<ControlledLyricsSidebar
|
||||
visible
|
||||
mainLyric={lyric}
|
||||
showTranslation
|
||||
|
|
@ -122,32 +147,66 @@ describe('<LyricsSidebar />', () => {
|
|||
expect(document.activeElement).toBe(returnTarget)
|
||||
})
|
||||
|
||||
it('clamps persisted and keyboard-resized widths', () => {
|
||||
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '999')
|
||||
renderSidebar()
|
||||
it('keeps lyrics mounted but noninteractive while the queue obscures them', () => {
|
||||
const returnTarget = document.createElement('button')
|
||||
returnTarget.dataset.lyricsReturnTarget = 'true'
|
||||
document.body.appendChild(returnTarget)
|
||||
const returnFocusRef = { current: returnTarget }
|
||||
const { rerender } = renderSidebar({ returnFocusRef })
|
||||
const sidebar = screen.getByTestId('lyrics-sidebar')
|
||||
const panel = screen.getByTestId('karaoke-lyrics-panel')
|
||||
screen.getByTestId('lyrics-sidebar-resizer').focus()
|
||||
|
||||
rerender(
|
||||
sidebarView({
|
||||
obscuredByQueue: true,
|
||||
returnFocusRef,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('lyrics-sidebar')).toBe(sidebar)
|
||||
expect(screen.getByTestId('karaoke-lyrics-panel')).toBe(panel)
|
||||
expect(sidebar).toHaveStyle({
|
||||
transform: 'translateX(0)',
|
||||
opacity: '1',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
expect(sidebar).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(sidebar).toHaveAttribute('inert')
|
||||
expect(document.activeElement).toBe(returnTarget)
|
||||
|
||||
rerender(sidebarView({ returnFocusRef }))
|
||||
|
||||
expect(screen.getByTestId('lyrics-sidebar')).toBe(sidebar)
|
||||
expect(screen.getByTestId('karaoke-lyrics-panel')).toBe(panel)
|
||||
expect(sidebar).toHaveStyle({ pointerEvents: 'auto' })
|
||||
expect(sidebar).toHaveAttribute('aria-hidden', 'false')
|
||||
expect(sidebar).not.toHaveAttribute('inert')
|
||||
})
|
||||
|
||||
it('clamps controlled keyboard resizing to the available width', () => {
|
||||
const onWidthChange = vi.fn()
|
||||
renderSidebar({ initialWidth: 999, maxWidth: 435, onWidthChange })
|
||||
|
||||
const sidebar = screen.getByTestId('lyrics-sidebar')
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
|
||||
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` })
|
||||
expect(resizer).toHaveAttribute(
|
||||
'aria-valuenow',
|
||||
String(LYRICS_SIDEBAR_MAX_WIDTH),
|
||||
)
|
||||
expect(sidebar).toHaveStyle({ width: '435px' })
|
||||
expect(resizer).toHaveAttribute('aria-valuemax', '435')
|
||||
expect(resizer).toHaveAttribute('aria-valuenow', '435')
|
||||
|
||||
fireEvent.keyDown(resizer, { key: 'Home' })
|
||||
|
||||
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MIN_WIDTH}px` })
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe(
|
||||
String(LYRICS_SIDEBAR_MIN_WIDTH),
|
||||
)
|
||||
expect(onWidthChange).toHaveBeenLastCalledWith(LYRICS_SIDEBAR_MIN_WIDTH, {
|
||||
persist: true,
|
||||
})
|
||||
|
||||
fireEvent.keyDown(resizer, { key: 'End' })
|
||||
|
||||
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` })
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe(
|
||||
String(LYRICS_SIDEBAR_MAX_WIDTH),
|
||||
)
|
||||
expect(sidebar).toHaveStyle({ width: '435px' })
|
||||
expect(resizer).toHaveAttribute('aria-valuenow', '435')
|
||||
expect(onWidthChange).toHaveBeenLastCalledWith(435, { persist: true })
|
||||
})
|
||||
|
||||
it('clamps pointer resizing from the left separator', async () => {
|
||||
|
|
@ -156,7 +215,8 @@ describe('<LyricsSidebar />', () => {
|
|||
writable: true,
|
||||
value: MouseEvent,
|
||||
})
|
||||
renderSidebar()
|
||||
const onWidthChange = vi.fn()
|
||||
renderSidebar({ maxWidth: 435, onWidthChange })
|
||||
|
||||
const sidebar = screen.getByTestId('lyrics-sidebar')
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
|
|
@ -167,10 +227,10 @@ describe('<LyricsSidebar />', () => {
|
|||
|
||||
fireEvent.pointerDown(resizer, { clientX: 500 })
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: -100 }))
|
||||
await waitFor(() =>
|
||||
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` }),
|
||||
)
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBeNull()
|
||||
await waitFor(() => expect(sidebar).toHaveStyle({ width: '435px' }))
|
||||
expect(resizer).toHaveAttribute('aria-valuemax', '435')
|
||||
expect(resizer).toHaveAttribute('aria-valuenow', '435')
|
||||
expect(onWidthChange).toHaveBeenLastCalledWith(435, { persist: false })
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 1000 }))
|
||||
await waitFor(() =>
|
||||
|
|
@ -178,9 +238,9 @@ describe('<LyricsSidebar />', () => {
|
|||
)
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointerup'))
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe(
|
||||
String(LYRICS_SIDEBAR_MIN_WIDTH),
|
||||
)
|
||||
expect(onWidthChange).toHaveBeenLastCalledWith(LYRICS_SIDEBAR_MIN_WIDTH, {
|
||||
persist: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the live pointer width through an unrelated rerender', async () => {
|
||||
|
|
@ -214,7 +274,8 @@ describe('<LyricsSidebar />', () => {
|
|||
writable: true,
|
||||
value: MouseEvent,
|
||||
})
|
||||
const { unmount } = renderSidebar()
|
||||
const onWidthChange = vi.fn()
|
||||
const { unmount } = renderSidebar({ onWidthChange })
|
||||
|
||||
const sidebar = screen.getByTestId('lyrics-sidebar')
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
|
|
@ -226,13 +287,14 @@ describe('<LyricsSidebar />', () => {
|
|||
)
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointercancel'))
|
||||
await waitFor(() =>
|
||||
expect(sidebar).toHaveAttribute('data-resizing', 'false'),
|
||||
)
|
||||
const callsAfterCancel = onWidthChange.mock.calls.length
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 1000 }))
|
||||
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` })
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBeNull()
|
||||
expect(onWidthChange).toHaveBeenCalledTimes(callsAfterCancel)
|
||||
expect(onWidthChange).not.toHaveBeenCalledWith(expect.anything(), {
|
||||
persist: true,
|
||||
})
|
||||
|
||||
fireEvent.pointerDown(resizer, { clientX: 500 })
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 1000 }))
|
||||
|
|
@ -241,9 +303,13 @@ describe('<LyricsSidebar />', () => {
|
|||
)
|
||||
|
||||
unmount()
|
||||
const callsBeforePointerUp = onWidthChange.mock.calls.length
|
||||
window.dispatchEvent(new MouseEvent('pointerup'))
|
||||
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBeNull()
|
||||
expect(onWidthChange).toHaveBeenCalledTimes(callsBeforePointerUp)
|
||||
expect(onWidthChange).not.toHaveBeenCalledWith(expect.anything(), {
|
||||
persist: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses icon toggle buttons with pressed and disabled states', () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MOBILE_KARAOKE_LYRICS_TRANSITION_MS } from './lyricsKaraokeConstants'
|
||||
import useEnterExitTransition from './useEnterExitTransition'
|
||||
import useRestoreFocusOnExit from './useRestoreFocusOnExit'
|
||||
|
||||
export const MOBILE_KARAOKE_LYRICS_TRANSITION_MS = 260
|
||||
export const MOBILE_KARAOKE_LYRICS_HOST_SELECTOR =
|
||||
'.react-jinke-music-player-mobile-cover'
|
||||
export const MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS = 'nd-mobile-lyrics-active'
|
||||
|
|
@ -15,7 +15,12 @@ const resolveMobileLyricsHost = () => {
|
|||
return document.querySelector(MOBILE_KARAOKE_LYRICS_HOST_SELECTOR)
|
||||
}
|
||||
|
||||
const MobileKaraokeLyricsPortal = ({ active, children, returnFocusRef }) => {
|
||||
const MobileKaraokeLyricsPortal = ({
|
||||
active,
|
||||
children,
|
||||
obscured = false,
|
||||
returnFocusRef,
|
||||
}) => {
|
||||
const { rendered, entered } = useEnterExitTransition(
|
||||
active,
|
||||
MOBILE_KARAOKE_LYRICS_TRANSITION_MS,
|
||||
|
|
@ -24,10 +29,11 @@ const MobileKaraokeLyricsPortal = ({ active, children, returnFocusRef }) => {
|
|||
active ? resolveMobileLyricsHost() : null,
|
||||
)
|
||||
const layerRef = useRef(null)
|
||||
const interactive = entered && !obscured
|
||||
|
||||
useRestoreFocusOnExit({
|
||||
surfaceRef: layerRef,
|
||||
entered,
|
||||
entered: interactive,
|
||||
returnFocusRef,
|
||||
surfaceKey: host,
|
||||
})
|
||||
|
|
@ -71,9 +77,9 @@ const MobileKaraokeLyricsPortal = ({ active, children, returnFocusRef }) => {
|
|||
className={MOBILE_KARAOKE_LYRICS_LAYER_CLASS}
|
||||
ref={layerRef}
|
||||
data-entered={entered ? 'true' : 'false'}
|
||||
aria-hidden={!entered}
|
||||
inert={entered ? undefined : ''}
|
||||
style={{ pointerEvents: entered ? 'auto' : 'none' }}
|
||||
aria-hidden={!interactive}
|
||||
inert={interactive ? undefined : ''}
|
||||
style={{ pointerEvents: interactive ? 'auto' : 'none' }}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import MobileKaraokeLyricsPortal, {
|
|||
MOBILE_KARAOKE_LYRICS_ENTERED_CLASS,
|
||||
MOBILE_KARAOKE_LYRICS_HOST_SELECTOR,
|
||||
MOBILE_KARAOKE_LYRICS_LAYER_CLASS,
|
||||
MOBILE_KARAOKE_LYRICS_TRANSITION_MS,
|
||||
} from './MobileKaraokeLyricsPortal'
|
||||
import { MOBILE_KARAOKE_LYRICS_TRANSITION_MS } from './lyricsKaraokeConstants'
|
||||
import usePlayerLyrics from './usePlayerLyrics'
|
||||
|
||||
const { defaultLyricsResponse, useEnhancedLyricsMock } = vi.hoisted(() => {
|
||||
|
|
@ -49,19 +49,28 @@ const createHost = () => {
|
|||
return host
|
||||
}
|
||||
|
||||
const portal = (text, active = true, returnFocusRef) => (
|
||||
<MobileKaraokeLyricsPortal active={active} returnFocusRef={returnFocusRef}>
|
||||
const portal = (text, active = true, returnFocusRef, obscured = false) => (
|
||||
<MobileKaraokeLyricsPortal
|
||||
active={active}
|
||||
obscured={obscured}
|
||||
returnFocusRef={returnFocusRef}
|
||||
>
|
||||
<span>{text}</span>
|
||||
</MobileKaraokeLyricsPortal>
|
||||
)
|
||||
|
||||
const MobileLyricsHarness = ({ trackId = 'track-1', isRadio = false }) => {
|
||||
const MobileLyricsHarness = ({
|
||||
trackId = 'track-1',
|
||||
isRadio = false,
|
||||
obscuredByQueue = false,
|
||||
}) => {
|
||||
const { toolbarLyricsProps, mobileLyricsSurface } = usePlayerLyrics({
|
||||
trackId,
|
||||
trackUpdatedAt: 'now',
|
||||
isRadio,
|
||||
audioInstance: null,
|
||||
isDesktop: false,
|
||||
obscuredByQueue,
|
||||
translate: (key) => key,
|
||||
})
|
||||
return (
|
||||
|
|
@ -157,6 +166,38 @@ describe('<MobileKaraokeLyricsPortal />', () => {
|
|||
expect(document.activeElement).toBe(returnTarget)
|
||||
})
|
||||
|
||||
it('keeps open lyrics mounted and inert while the queue obscures them', async () => {
|
||||
const host = createHost()
|
||||
const { rerender } = render(<MobileLyricsHarness />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' }))
|
||||
const layer = host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`)
|
||||
const panel = within(host).getByTestId('karaoke-lyrics-panel')
|
||||
await waitFor(() => expect(layer).toHaveAttribute('aria-hidden', 'false'))
|
||||
|
||||
rerender(<MobileLyricsHarness obscuredByQueue />)
|
||||
|
||||
expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
|
||||
expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS)
|
||||
expect(host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`)).toBe(
|
||||
layer,
|
||||
)
|
||||
expect(within(host).getByTestId('karaoke-lyrics-panel')).toBe(panel)
|
||||
expect(layer).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(layer).toHaveAttribute('inert')
|
||||
expect(layer).toHaveStyle({ pointerEvents: 'none' })
|
||||
expect(useEnhancedLyricsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ requested: true }),
|
||||
)
|
||||
|
||||
rerender(<MobileLyricsHarness />)
|
||||
|
||||
expect(within(host).getByTestId('karaoke-lyrics-panel')).toBe(panel)
|
||||
await waitFor(() => expect(layer).toHaveAttribute('aria-hidden', 'false'))
|
||||
expect(layer).not.toHaveAttribute('inert')
|
||||
expect(layer).toHaveStyle({ pointerEvents: 'auto' })
|
||||
})
|
||||
|
||||
it('attaches when the mobile cover host appears after activation', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
|
|
@ -248,7 +289,6 @@ describe('<MobileKaraokeLyricsPortal />', () => {
|
|||
fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' }))
|
||||
|
||||
const controls = within(host).getByTestId('lyrics-mobile-layer-controls')
|
||||
expect(controls).toHaveAttribute('data-placement', 'mobile')
|
||||
expect(controls).toHaveStyle({
|
||||
bottom: '8px',
|
||||
left: '50%',
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ const Player = () => {
|
|||
const currentTrackIdRef = useRef(null)
|
||||
const stoppedRef = useRef(false)
|
||||
const [audioInstance, setAudioInstance] = useState(null)
|
||||
const [audioListsPanelVisible, setAudioListsPanelVisible] = useState(false)
|
||||
const isDesktop = useMediaQuery(PLAYER_DESKTOP_MEDIA_QUERY)
|
||||
const isMobilePlayer =
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
|
|
@ -158,6 +159,7 @@ const Player = () => {
|
|||
isRadio: playerState.current?.isRadio || false,
|
||||
audioInstance,
|
||||
isDesktop,
|
||||
obscuredByQueue: audioListsPanelVisible,
|
||||
translate,
|
||||
})
|
||||
|
||||
|
|
@ -501,6 +503,7 @@ const Player = () => {
|
|||
{...options}
|
||||
className={classes.player}
|
||||
onAudioListsChange={onAudioListsChange}
|
||||
onAudioListsPanelChange={setAudioListsPanelVisible}
|
||||
onAudioVolumeChange={onAudioVolumeChange}
|
||||
onAudioProgress={onAudioProgress}
|
||||
onAudioPlay={onAudioPlay}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ export const KARAOKE_CHARACTER_LIFT_PX = KARAOKE_LINE_LIFT_PX
|
|||
export const KARAOKE_CHARACTER_RISE_MS = KARAOKE_ANIMATION_MS
|
||||
export const KARAOKE_CHARACTER_WAVE_SPAN_RATIO = 0.36
|
||||
export const KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS = 140
|
||||
export const KARAOKE_TRANSLATION_IDLE_OPACITY = 0.62
|
||||
export const KARAOKE_IDLE_LAYER_OPACITY = 0.49
|
||||
export const KARAOKE_LINE_RELEASE_MS = KARAOKE_LINE_OPACITY_MS
|
||||
export const MOBILE_KARAOKE_LYRICS_TRANSITION_MS = 260
|
||||
export const KARAOKE_SCROLL_PRE_ROLL_MS = 320
|
||||
export const KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO = 0.1
|
||||
export const KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO = 0.42
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
export const LYRICS_SIDEBAR_STORAGE_KEY = 'nd.lyricsSidebar.width.v1'
|
||||
export const LYRICS_SIDEBAR_WIDTH_EVENT = 'nd:lyrics-sidebar-width'
|
||||
export const LYRICS_SIDEBAR_DEFAULT_WIDTH = 360
|
||||
export const LYRICS_SIDEBAR_MIN_WIDTH = 300
|
||||
export const LYRICS_SIDEBAR_MAX_WIDTH = 520
|
||||
export const LYRICS_ROUTE_MIN_WIDTH = 320
|
||||
export const LYRICS_SIDEBAR_WIDTH_STEP = 20
|
||||
export const LYRICS_SIDEBAR_TRANSITION_MS = 260
|
||||
|
||||
export const clampSidebarWidth = (value) => {
|
||||
export const clampSidebarMaxWidth = (value) => {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return LYRICS_SIDEBAR_DEFAULT_WIDTH
|
||||
if (!Number.isFinite(numeric)) return LYRICS_SIDEBAR_MAX_WIDTH
|
||||
return Math.round(
|
||||
Math.min(
|
||||
LYRICS_SIDEBAR_MAX_WIDTH,
|
||||
|
|
@ -17,6 +17,28 @@ export const clampSidebarWidth = (value) => {
|
|||
)
|
||||
}
|
||||
|
||||
export const clampSidebarWidth = (
|
||||
value,
|
||||
maxWidth = LYRICS_SIDEBAR_MAX_WIDTH,
|
||||
) => {
|
||||
const resolvedMaxWidth = clampSidebarMaxWidth(maxWidth)
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return Math.min(LYRICS_SIDEBAR_DEFAULT_WIDTH, resolvedMaxWidth)
|
||||
}
|
||||
return Math.round(
|
||||
Math.min(resolvedMaxWidth, Math.max(LYRICS_SIDEBAR_MIN_WIDTH, numeric)),
|
||||
)
|
||||
}
|
||||
|
||||
export const getSidebarMaxWidth = (contentWidth) => {
|
||||
const numeric = Number(contentWidth)
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return LYRICS_SIDEBAR_MAX_WIDTH
|
||||
}
|
||||
return clampSidebarMaxWidth(numeric - LYRICS_ROUTE_MIN_WIDTH)
|
||||
}
|
||||
|
||||
const hasLocalStorage = () => {
|
||||
try {
|
||||
return typeof window !== 'undefined' && Boolean(window.localStorage)
|
||||
|
|
@ -37,16 +59,6 @@ export const loadSidebarWidth = () => {
|
|||
}
|
||||
}
|
||||
|
||||
export const notifySidebarWidthChange = (width) => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(LYRICS_SIDEBAR_WIDTH_EVENT, {
|
||||
detail: { width: clampSidebarWidth(width) },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const saveSidebarWidth = (width) => {
|
||||
const resolvedWidth = clampSidebarWidth(width)
|
||||
if (!hasLocalStorage()) return
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { MOBILE_KARAOKE_LYRICS_TRANSITION_MS } from './lyricsKaraokeConstants'
|
||||
import { PLAYER_MOBILE_MEDIA_QUERY } from './playerBreakpoints'
|
||||
|
||||
const MOBILE_LYRICS_TRANSITION =
|
||||
'opacity 260ms cubic-bezier(0.22, 1, 0.36, 1), transform 260ms cubic-bezier(0.22, 1, 0.36, 1)'
|
||||
const MOBILE_LYRICS_TRANSITION = `opacity ${MOBILE_KARAOKE_LYRICS_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1), transform ${MOBILE_KARAOKE_LYRICS_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`
|
||||
|
||||
const useStyle = makeStyles(
|
||||
(theme) => ({
|
||||
|
|
@ -48,6 +48,9 @@ const useStyle = makeStyles(
|
|||
'& .play-mode-title': {
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'& .audio-lists-panel': {
|
||||
zIndex: theme.zIndex.appBar,
|
||||
},
|
||||
'& .music-player-panel .panel-content div.img-rotate': {
|
||||
// Customize desktop player when cover animation is disabled
|
||||
animationDuration: (props) => !props.enableCoverAnimation && '0s',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const usePlayerLyrics = ({
|
|||
isRadio,
|
||||
audioInstance,
|
||||
isDesktop,
|
||||
obscuredByQueue = false,
|
||||
translate,
|
||||
}) => {
|
||||
const [lyricsVisiblePreference, setLyricsVisiblePreference] = useState(false)
|
||||
|
|
@ -125,6 +126,7 @@ const usePlayerLyrics = ({
|
|||
loading: lyricsLoading,
|
||||
error: lyricsError,
|
||||
labels,
|
||||
obscuredByQueue,
|
||||
returnFocusRef: lyricsToggleRef,
|
||||
}),
|
||||
[
|
||||
|
|
@ -139,6 +141,7 @@ const usePlayerLyrics = ({
|
|||
lyricsError,
|
||||
lyricsLoading,
|
||||
lyricsVisible,
|
||||
obscuredByQueue,
|
||||
showPronunciation,
|
||||
showTranslation,
|
||||
togglePronunciation,
|
||||
|
|
@ -150,9 +153,12 @@ const usePlayerLyrics = ({
|
|||
() => (
|
||||
<MobileKaraokeLyricsPortal
|
||||
active={useInlineMobileLyrics}
|
||||
obscured={obscuredByQueue}
|
||||
returnFocusRef={lyricsToggleRef}
|
||||
>
|
||||
<LyricsPanel
|
||||
visible
|
||||
obscured={obscuredByQueue}
|
||||
mainLyric={lyricLayers.main}
|
||||
translationLyric={lyricLayers.translation}
|
||||
pronunciationLyric={lyricLayers.pronunciation}
|
||||
|
|
@ -187,6 +193,7 @@ const usePlayerLyrics = ({
|
|||
lyricsError,
|
||||
lyricsLoading,
|
||||
labels,
|
||||
obscuredByQueue,
|
||||
showPronunciation,
|
||||
showTranslation,
|
||||
togglePronunciation,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { setSidebarVisibility } from 'react-admin'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import LyricsSidebar from '../audioplayer/LyricsSidebar'
|
||||
import { useLyricsLayout } from '../audioplayer/LyricsLayoutContext'
|
||||
import {
|
||||
LYRICS_SIDEBAR_WIDTH_EVENT,
|
||||
LYRICS_ROUTE_MIN_WIDTH,
|
||||
LYRICS_SIDEBAR_MIN_WIDTH,
|
||||
clampSidebarWidth,
|
||||
getSidebarMaxWidth,
|
||||
loadSidebarWidth,
|
||||
saveSidebarWidth,
|
||||
} from '../audioplayer/lyricsSidebarWidth'
|
||||
|
||||
const MIN_CONTENT_WITH_LYRICS =
|
||||
LYRICS_ROUTE_MIN_WIDTH + LYRICS_SIDEBAR_MIN_WIDTH
|
||||
|
||||
const useStyles = makeStyles(
|
||||
() => ({
|
||||
frame: {
|
||||
|
|
@ -20,25 +28,126 @@ const useStyles = makeStyles(
|
|||
const LyricsLayoutFrame = ({ children }) => {
|
||||
const { desktopLyricsProps } = useLyricsLayout()
|
||||
const hasVisibleLyrics = Boolean(desktopLyricsProps?.visible)
|
||||
const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth)
|
||||
const [preferredSidebarWidth, setPreferredSidebarWidth] =
|
||||
useState(loadSidebarWidth)
|
||||
const [contentWidth, setContentWidth] = useState(null)
|
||||
const frameRef = useRef(null)
|
||||
const autoCollapsedRef = useRef(false)
|
||||
const manualSidebarOverrideRef = useRef(false)
|
||||
const pendingSidebarStateRef = useRef(null)
|
||||
const previousSidebarOpenRef = useRef(null)
|
||||
const dispatch = useDispatch()
|
||||
const sidebarOpen = useSelector(
|
||||
(state) => state.admin?.ui?.sidebarOpen ?? false,
|
||||
)
|
||||
const classes = useStyles()
|
||||
const sidebarMaxWidth = getSidebarMaxWidth(contentWidth)
|
||||
const sidebarWidth = clampSidebarWidth(preferredSidebarWidth, sidebarMaxWidth)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasVisibleLyrics) return undefined
|
||||
const parent = frameRef.current?.parentElement
|
||||
if (!parent) return undefined
|
||||
|
||||
const handleSidebarWidth = (event) => {
|
||||
const next = clampSidebarWidth(event.detail?.width)
|
||||
setSidebarWidth((current) => (current === next ? current : next))
|
||||
const updateContentWidth = (nextWidth) => {
|
||||
const numeric = Number(nextWidth)
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return
|
||||
const rounded = Math.round(numeric)
|
||||
setContentWidth((current) => (current === rounded ? current : rounded))
|
||||
}
|
||||
|
||||
setSidebarWidth(loadSidebarWidth())
|
||||
window.addEventListener(LYRICS_SIDEBAR_WIDTH_EVENT, handleSidebarWidth)
|
||||
return () =>
|
||||
window.removeEventListener(LYRICS_SIDEBAR_WIDTH_EVENT, handleSidebarWidth)
|
||||
const measureParent = () => {
|
||||
updateContentWidth(parent.clientWidth)
|
||||
}
|
||||
|
||||
measureParent()
|
||||
const ResizeObserverConstructor = window.ResizeObserver
|
||||
if (!ResizeObserverConstructor) {
|
||||
window.addEventListener('resize', measureParent)
|
||||
return () => window.removeEventListener('resize', measureParent)
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserverConstructor((entries) => {
|
||||
const entry = entries.find((candidate) => candidate.target === parent)
|
||||
const borderBoxSize = Array.isArray(entry?.borderBoxSize)
|
||||
? entry.borderBoxSize[0]
|
||||
: entry?.borderBoxSize
|
||||
updateContentWidth(
|
||||
borderBoxSize?.inlineSize ||
|
||||
entry?.target?.clientWidth ||
|
||||
entry?.contentRect?.width,
|
||||
)
|
||||
})
|
||||
resizeObserver.observe(parent)
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasVisibleLyrics) {
|
||||
setPreferredSidebarWidth(loadSidebarWidth())
|
||||
}
|
||||
}, [hasVisibleLyrics])
|
||||
|
||||
useEffect(() => {
|
||||
const previousSidebarOpen = previousSidebarOpenRef.current
|
||||
previousSidebarOpenRef.current = sidebarOpen
|
||||
if (previousSidebarOpen == null || previousSidebarOpen === sidebarOpen) {
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingSidebarStateRef.current === sidebarOpen) {
|
||||
pendingSidebarStateRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
if (hasVisibleLyrics) {
|
||||
manualSidebarOverrideRef.current = true
|
||||
autoCollapsedRef.current = false
|
||||
}
|
||||
}, [hasVisibleLyrics, sidebarOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasVisibleLyrics) {
|
||||
if (
|
||||
autoCollapsedRef.current &&
|
||||
!manualSidebarOverrideRef.current &&
|
||||
!sidebarOpen
|
||||
) {
|
||||
autoCollapsedRef.current = false
|
||||
pendingSidebarStateRef.current = true
|
||||
dispatch(setSidebarVisibility(true))
|
||||
}
|
||||
manualSidebarOverrideRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
manualSidebarOverrideRef.current ||
|
||||
!Number.isFinite(contentWidth) ||
|
||||
contentWidth <= 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (sidebarOpen && contentWidth < MIN_CONTENT_WITH_LYRICS) {
|
||||
autoCollapsedRef.current = true
|
||||
pendingSidebarStateRef.current = false
|
||||
dispatch(setSidebarVisibility(false))
|
||||
return
|
||||
}
|
||||
}, [contentWidth, dispatch, hasVisibleLyrics, sidebarOpen])
|
||||
|
||||
const handleSidebarWidthChange = useCallback(
|
||||
(nextWidth, { persist = false } = {}) => {
|
||||
const resolvedWidth = clampSidebarWidth(nextWidth, sidebarMaxWidth)
|
||||
setPreferredSidebarWidth(resolvedWidth)
|
||||
if (persist) saveSidebarWidth(resolvedWidth)
|
||||
},
|
||||
[sidebarMaxWidth],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={frameRef}
|
||||
className={classes.frame}
|
||||
data-testid="lyrics-layout-frame"
|
||||
data-lyrics-sidebar-visible={hasVisibleLyrics ? 'true' : 'false'}
|
||||
|
|
@ -47,7 +156,14 @@ const LyricsLayoutFrame = ({ children }) => {
|
|||
}}
|
||||
>
|
||||
{children}
|
||||
{desktopLyricsProps && <LyricsSidebar {...desktopLyricsProps} />}
|
||||
{desktopLyricsProps && (
|
||||
<LyricsSidebar
|
||||
{...desktopLyricsProps}
|
||||
width={sidebarWidth}
|
||||
maxWidth={sidebarMaxWidth}
|
||||
onWidthChange={handleSidebarWidthChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,25 @@
|
|||
import React, { useEffect } from 'react'
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react'
|
||||
import { ThemeProvider, createTheme } from '@material-ui/core/styles'
|
||||
import { setSidebarVisibility } from 'react-admin'
|
||||
import { Provider } from 'react-redux'
|
||||
import { createStore } from 'redux'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
LyricsLayoutProvider,
|
||||
useLyricsLayout,
|
||||
} from '../audioplayer/LyricsLayoutContext'
|
||||
import { LYRICS_SIDEBAR_TRANSITION_MS } from '../audioplayer/lyricsSidebarWidth'
|
||||
import {
|
||||
LYRICS_SIDEBAR_STORAGE_KEY,
|
||||
LYRICS_SIDEBAR_TRANSITION_MS,
|
||||
} from '../audioplayer/lyricsSidebarWidth'
|
||||
import LyricsLayoutFrame from './LyricsLayoutFrame'
|
||||
|
||||
const theme = createTheme({
|
||||
|
|
@ -32,6 +45,61 @@ const lyricsProps = {
|
|||
onTogglePronunciation: vi.fn(),
|
||||
}
|
||||
|
||||
const originalResizeObserver = window.ResizeObserver
|
||||
const resizeObservers = []
|
||||
|
||||
class TestResizeObserver {
|
||||
constructor(callback) {
|
||||
this.callback = callback
|
||||
this.target = null
|
||||
this.disconnected = false
|
||||
resizeObservers.push(this)
|
||||
}
|
||||
|
||||
observe(target) {
|
||||
this.target = target
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.disconnected = true
|
||||
}
|
||||
}
|
||||
|
||||
const notifyContentWidth = (width) => {
|
||||
act(() => {
|
||||
resizeObservers.forEach((observer) => {
|
||||
if (observer.disconnected || !observer.target) return
|
||||
observer.callback([
|
||||
{
|
||||
target: observer.target,
|
||||
contentRect: { width },
|
||||
borderBoxSize: [{ inlineSize: width }],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const sidebarVisibilityActionType = setSidebarVisibility(true).type
|
||||
|
||||
const createLayoutStore = (sidebarOpen = false) =>
|
||||
createStore(
|
||||
(state, action) => {
|
||||
if (action.type !== sidebarVisibilityActionType) return state
|
||||
return {
|
||||
...state,
|
||||
admin: {
|
||||
...state.admin,
|
||||
ui: {
|
||||
...state.admin.ui,
|
||||
sidebarOpen: action.payload,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
{ admin: { ui: { sidebarOpen } } },
|
||||
)
|
||||
|
||||
const PublishLyricsProps = ({ props }) => {
|
||||
const { setDesktopLyricsProps } = useLyricsLayout()
|
||||
|
||||
|
|
@ -43,22 +111,27 @@ const PublishLyricsProps = ({ props }) => {
|
|||
return null
|
||||
}
|
||||
|
||||
const frame = (props) => (
|
||||
<ThemeProvider theme={theme}>
|
||||
<LyricsLayoutProvider>
|
||||
<PublishLyricsProps props={props} />
|
||||
<LyricsLayoutFrame>
|
||||
<div data-testid="route-content">Albums grid</div>
|
||||
</LyricsLayoutFrame>
|
||||
</LyricsLayoutProvider>
|
||||
</ThemeProvider>
|
||||
const frame = (props, store) => (
|
||||
<Provider store={store}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<LyricsLayoutProvider>
|
||||
<PublishLyricsProps props={props} />
|
||||
<LyricsLayoutFrame>
|
||||
<div data-testid="route-content">Albums grid</div>
|
||||
</LyricsLayoutFrame>
|
||||
</LyricsLayoutProvider>
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
)
|
||||
|
||||
const renderFrame = (props) => {
|
||||
const view = render(frame(props))
|
||||
const renderFrame = (props, { sidebarOpen = false, store } = {}) => {
|
||||
window.ResizeObserver = TestResizeObserver
|
||||
const layoutStore = store || createLayoutStore(sidebarOpen)
|
||||
const view = render(frame(props, layoutStore))
|
||||
return {
|
||||
...view,
|
||||
rerenderFrame: (nextProps) => view.rerender(frame(nextProps)),
|
||||
store: layoutStore,
|
||||
rerenderFrame: (nextProps) => view.rerender(frame(nextProps, layoutStore)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +139,9 @@ describe('<LyricsLayoutFrame />', () => {
|
|||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
cleanup()
|
||||
localStorage.clear()
|
||||
resizeObservers.splice(0)
|
||||
window.ResizeObserver = originalResizeObserver
|
||||
})
|
||||
|
||||
it('reserves a native right-hand lyrics pane beside route content', async () => {
|
||||
|
|
@ -82,6 +158,186 @@ describe('<LyricsLayoutFrame />', () => {
|
|||
expect(window.getComputedStyle(sidebar).position).toBe('fixed')
|
||||
})
|
||||
|
||||
it('caps a persisted width against the measured route reserve without overwriting it', async () => {
|
||||
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
|
||||
renderFrame(lyricsProps)
|
||||
const sidebar = await screen.findByTestId('lyrics-sidebar')
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
|
||||
notifyContentWidth(755)
|
||||
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '435px',
|
||||
})
|
||||
expect(sidebar).toHaveStyle({ width: '435px' })
|
||||
expect(resizer).toHaveAttribute('aria-valuemax', '435')
|
||||
expect(resizer).toHaveAttribute('aria-valuenow', '435')
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('520')
|
||||
|
||||
notifyContentWidth(840)
|
||||
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '520px',
|
||||
})
|
||||
expect(sidebar).toHaveStyle({ width: '520px' })
|
||||
expect(resizer).toHaveAttribute('aria-valuemax', '520')
|
||||
expect(resizer).toHaveAttribute('aria-valuenow', '520')
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('520')
|
||||
})
|
||||
|
||||
it('keeps the layout width reserved while the queue obscures lyrics', async () => {
|
||||
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
|
||||
renderFrame({ ...lyricsProps, obscuredByQueue: true })
|
||||
const sidebar = await screen.findByTestId('lyrics-sidebar')
|
||||
|
||||
notifyContentWidth(755)
|
||||
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '435px',
|
||||
})
|
||||
expect(sidebar).toHaveStyle({
|
||||
width: '435px',
|
||||
transform: 'translateX(0)',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
expect(sidebar).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(sidebar).toHaveAttribute('inert')
|
||||
expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses an open app menu only when the minimum pane cannot fit', async () => {
|
||||
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
|
||||
const { rerenderFrame, store } = renderFrame(lyricsProps, {
|
||||
sidebarOpen: true,
|
||||
})
|
||||
await screen.findByTestId('lyrics-sidebar')
|
||||
|
||||
notifyContentWidth(570)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(false),
|
||||
)
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '300px',
|
||||
})
|
||||
expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute(
|
||||
'aria-valuemax',
|
||||
'300',
|
||||
)
|
||||
|
||||
notifyContentWidth(755)
|
||||
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '435px',
|
||||
})
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(false)
|
||||
|
||||
rerenderFrame({ ...lyricsProps, visible: false })
|
||||
|
||||
await waitFor(() =>
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(true),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the app menu open at the 960px and 1280px layout caps', async () => {
|
||||
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
|
||||
const { store } = renderFrame(lyricsProps, { sidebarOpen: true })
|
||||
await screen.findByTestId('lyrics-sidebar')
|
||||
|
||||
notifyContentWidth(720)
|
||||
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(true)
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '400px',
|
||||
})
|
||||
expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute(
|
||||
'aria-valuemax',
|
||||
'400',
|
||||
)
|
||||
|
||||
notifyContentWidth(1040)
|
||||
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '520px',
|
||||
})
|
||||
expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute(
|
||||
'aria-valuemax',
|
||||
'520',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an automatic collapse until lyrics close when space returns', async () => {
|
||||
const { rerenderFrame, store } = renderFrame(lyricsProps, {
|
||||
sidebarOpen: true,
|
||||
})
|
||||
await screen.findByTestId('lyrics-sidebar')
|
||||
notifyContentWidth(570)
|
||||
await waitFor(() =>
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(false),
|
||||
)
|
||||
|
||||
notifyContentWidth(1040)
|
||||
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(false)
|
||||
|
||||
rerenderFrame({ ...lyricsProps, visible: false })
|
||||
|
||||
await waitFor(() =>
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(true),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not restore the menu after a manual override', async () => {
|
||||
const { rerenderFrame, store } = renderFrame(lyricsProps, {
|
||||
sidebarOpen: true,
|
||||
})
|
||||
await screen.findByTestId('lyrics-sidebar')
|
||||
notifyContentWidth(570)
|
||||
await waitFor(() =>
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(false),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
store.dispatch(setSidebarVisibility(true))
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(true),
|
||||
)
|
||||
act(() => {
|
||||
store.dispatch(setSidebarVisibility(false))
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(false),
|
||||
)
|
||||
|
||||
rerenderFrame({ ...lyricsProps, visible: false })
|
||||
|
||||
expect(store.getState().admin.ui.sidebarOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('persists deliberate keyboard resizing at the measured cap', async () => {
|
||||
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
|
||||
renderFrame(lyricsProps)
|
||||
await screen.findByTestId('lyrics-sidebar')
|
||||
notifyContentWidth(755)
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
|
||||
fireEvent.keyDown(resizer, { key: 'Home' })
|
||||
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '300px',
|
||||
})
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('300')
|
||||
|
||||
fireEvent.keyDown(resizer, { key: 'End' })
|
||||
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '435px',
|
||||
})
|
||||
expect(resizer).toHaveAttribute('aria-valuenow', '435')
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('435')
|
||||
})
|
||||
|
||||
it('releases the layout frame after the sidebar exit transition', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { rerenderFrame } = renderFrame(lyricsProps)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue