サイド・バーの幅を可変に (#385) #396
@@ -0,0 +1,99 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||||
|
||||
|
||||
const sidebarWidth = (element: Element | null): string =>
|
||||
(element as HTMLElement).style.getPropertyValue ('--sidebar-width')
|
||||
|
||||
|
||||
const sidebarRoot = (container: HTMLElement): Element =>
|
||||
container.querySelector ('[style*="--sidebar-width"]')!
|
||||
|
||||
|
||||
const renderSidebar = (
|
||||
props: Partial<Parameters<typeof SidebarComponent>[0]> = {},
|
||||
containerWidth = 800,
|
||||
) => {
|
||||
Object.defineProperty (HTMLElement.prototype, 'clientWidth', {
|
||||
configurable: true,
|
||||
get () {
|
||||
return this.hasAttribute ('data-sidebar-container') ? containerWidth : 0
|
||||
},
|
||||
})
|
||||
|
||||
const rendered = render (
|
||||
<HelmetProvider>
|
||||
<div data-sidebar-container>
|
||||
<SidebarComponent sidebarKey="test-sidebar" {...props}>
|
||||
Sidebar body
|
||||
</SidebarComponent>
|
||||
</div>
|
||||
</HelmetProvider>)
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
|
||||
describe ('SidebarComponent', () => {
|
||||
beforeEach (() => {
|
||||
Object.defineProperty (window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 1_000,
|
||||
})
|
||||
|
||||
Object.defineProperty (HTMLElement.prototype, 'setPointerCapture', {
|
||||
configurable: true,
|
||||
value: vi.fn (),
|
||||
})
|
||||
Object.defineProperty (HTMLElement.prototype, 'releasePointerCapture', {
|
||||
configurable: true,
|
||||
value: vi.fn (),
|
||||
})
|
||||
Object.defineProperty (HTMLElement.prototype, 'hasPointerCapture', {
|
||||
configurable: true,
|
||||
value: vi.fn (() => true),
|
||||
})
|
||||
|
||||
vi.spyOn (HTMLElement.prototype, 'setPointerCapture').mockImplementation (() => {
|
||||
;
|
||||
})
|
||||
vi.spyOn (HTMLElement.prototype, 'releasePointerCapture').mockImplementation (() => {
|
||||
;
|
||||
})
|
||||
vi.spyOn (HTMLElement.prototype, 'hasPointerCapture').mockReturnValue (true)
|
||||
})
|
||||
|
||||
it ('uses maxWidth without applying the container main-width reserve twice', async () => {
|
||||
localStorage.setItem ('sidebar:test-sidebar:left:width', '700')
|
||||
const onWidthChange = vi.fn ()
|
||||
|
||||
const { container } = renderSidebar ({ maxWidth: 700, onWidthChange }, 800)
|
||||
|
||||
await waitFor (() => {
|
||||
expect (sidebarWidth (sidebarRoot (container))).toBe ('700px')
|
||||
})
|
||||
expect (onWidthChange).toHaveBeenLastCalledWith (700)
|
||||
})
|
||||
|
||||
it ('resets width and removes only its storage key on double click', async () => {
|
||||
localStorage.setItem ('sidebar:test-sidebar:right:width', '420')
|
||||
localStorage.setItem ('sidebar:other-sidebar:right:width', '480')
|
||||
|
||||
const { container } = renderSidebar ({ side: 'right' }, 900)
|
||||
|
||||
await waitFor (() => {
|
||||
expect (sidebarWidth (sidebarRoot (container))).toBe ('420px')
|
||||
})
|
||||
|
||||
fireEvent.doubleClick (screen.getByRole ('separator', { name: 'サイドバー幅' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (sidebarWidth (sidebarRoot (container))).toBe ('256px')
|
||||
})
|
||||
expect (localStorage.getItem ('sidebar:test-sidebar:right:width')).toBeNull ()
|
||||
expect (localStorage.getItem ('sidebar:other-sidebar:right:width')).toBe ('480')
|
||||
})
|
||||
})
|
||||
@@ -47,23 +47,16 @@ const getSidebarMaxWidth = (containerWidth: number): number => {
|
||||
}
|
||||
|
||||
|
||||
const clampSidebarWidth = (width: number, containerWidth: number): number =>
|
||||
Math.min (getSidebarMaxWidth (containerWidth), Math.max (MIN_SIDEBAR_WIDTH, width))
|
||||
|
||||
|
||||
const readStoredSidebarWidth = (
|
||||
sidebarKey: string,
|
||||
side: SidebarSide,
|
||||
containerWidth: number): number => {
|
||||
side: SidebarSide): number => {
|
||||
if (typeof window === 'undefined')
|
||||
return DEFAULT_SIDEBAR_WIDTH
|
||||
|
||||
const value = window.localStorage.getItem (storageKeyForSidebar (sidebarKey, side))
|
||||
const width = value == null ? Number.NaN : Number (value)
|
||||
|
||||
return Number.isFinite (width)
|
||||
? clampSidebarWidth (width, containerWidth)
|
||||
: clampSidebarWidth (DEFAULT_SIDEBAR_WIDTH, containerWidth)
|
||||
return Number.isFinite (width) ? width : DEFAULT_SIDEBAR_WIDTH
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +94,7 @@ const SidebarComponent: FC<Props> = ({
|
||||
typeof window === 'undefined'
|
||||
? DEFAULT_SIDEBAR_WIDTH
|
||||
: getClampedWidth (
|
||||
readStoredSidebarWidth (sidebarKey, side, window.innerWidth),
|
||||
readStoredSidebarWidth (sidebarKey, side),
|
||||
window.innerWidth)))
|
||||
const [resizing, setResizing] = useState (false)
|
||||
const widthRef = useRef (width)
|
||||
@@ -256,7 +249,7 @@ const SidebarComponent: FC<Props> = ({
|
||||
useEffect (() => {
|
||||
const containerWidth = getContainerWidth ()
|
||||
const nextWidth = getClampedWidth (
|
||||
readStoredSidebarWidth (sidebarKey, side, containerWidth),
|
||||
readStoredSidebarWidth (sidebarKey, side),
|
||||
containerWidth)
|
||||
widthRef.current = nextWidth
|
||||
setWidth (nextWidth)
|
||||
|
||||
@@ -105,6 +105,20 @@ const tagSection = (): HTMLElement =>
|
||||
screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')!
|
||||
|
||||
const mockDefaultApi = () => {
|
||||
let commentsFetched = false
|
||||
let currentTheatreInfo = buildTheatreInfo ({
|
||||
postId: currentPost.id,
|
||||
postStartedAt: '2026-01-02T03:04:05.000Z',
|
||||
postElapsedMs: 1_000,
|
||||
watchingUsers,
|
||||
skipVote: {
|
||||
votesCount: 0,
|
||||
requiredCount: 2,
|
||||
watchingUsersCount: watchingUsers.length,
|
||||
voted: false,
|
||||
},
|
||||
})
|
||||
|
||||
api.apiGet.mockImplementation ((path: string) => {
|
||||
switch (path)
|
||||
{
|
||||
@@ -112,13 +126,17 @@ const mockDefaultApi = () => {
|
||||
return Promise.resolve (theatre)
|
||||
|
||||
case '/theatres/7/comments':
|
||||
if (commentsFetched)
|
||||
return Promise.resolve ([])
|
||||
|
||||
commentsFetched = true
|
||||
return Promise.resolve ([
|
||||
buildTheatreComment ({
|
||||
theatreId: 7,
|
||||
no: 2,
|
||||
user: { id: 1, name: 'tester' },
|
||||
content: '視聴コメント',
|
||||
}),
|
||||
buildTheatreComment ({
|
||||
theatreId: 7,
|
||||
no: 2,
|
||||
user: { id: 1, name: 'tester' },
|
||||
content: '視聴コメント',
|
||||
}),
|
||||
])
|
||||
|
||||
case '/theatres/7/programmes':
|
||||
@@ -136,32 +154,22 @@ const mockDefaultApi = () => {
|
||||
switch (path)
|
||||
{
|
||||
case '/theatres/7/watching':
|
||||
return Promise.resolve (buildTheatreInfo ({
|
||||
postId: currentPost.id,
|
||||
postStartedAt: '2026-01-02T03:04:05.000Z',
|
||||
postElapsedMs: 1_000,
|
||||
watchingUsers,
|
||||
skipVote: {
|
||||
votesCount: 0,
|
||||
requiredCount: 2,
|
||||
watchingUsersCount: watchingUsers.length,
|
||||
voted: false,
|
||||
},
|
||||
}))
|
||||
return Promise.resolve (currentTheatreInfo)
|
||||
|
||||
case '/theatres/7/skip_vote':
|
||||
return Promise.resolve (buildTheatreInfo ({
|
||||
postId: currentPost.id,
|
||||
postStartedAt: '2026-01-02T03:04:05.000Z',
|
||||
postElapsedMs: 2_000,
|
||||
watchingUsers,
|
||||
skipVote: {
|
||||
votesCount: 1,
|
||||
requiredCount: 2,
|
||||
watchingUsersCount: watchingUsers.length,
|
||||
voted: true,
|
||||
},
|
||||
}))
|
||||
currentTheatreInfo = buildTheatreInfo ({
|
||||
postId: currentPost.id,
|
||||
postStartedAt: '2026-01-02T03:04:05.000Z',
|
||||
postElapsedMs: 2_000,
|
||||
watchingUsers,
|
||||
skipVote: {
|
||||
votesCount: 1,
|
||||
requiredCount: 2,
|
||||
watchingUsersCount: watchingUsers.length,
|
||||
voted: true,
|
||||
},
|
||||
})
|
||||
return Promise.resolve (currentTheatreInfo)
|
||||
|
||||
default:
|
||||
return Promise.reject (new Error (`Unexpected PUT ${ path }`))
|
||||
|
||||
@@ -266,6 +266,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
|
||||
const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => {
|
||||
theatreInfoReceivedAtRef.current = performance.now ()
|
||||
theatreInfoRef.current = nextInfo
|
||||
setTheatreInfo (nextInfo)
|
||||
}, [])
|
||||
|
||||
@@ -406,6 +407,9 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
setComments (prev => [...newComments, ...prev])
|
||||
}
|
||||
|
||||
if (loadingRef.current)
|
||||
return
|
||||
|
||||
const currentInfo = theatreInfoRef.current
|
||||
const ended =
|
||||
currentInfo.hostFlg
|
||||
@@ -427,8 +431,9 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
return
|
||||
}
|
||||
|
||||
const watchingRequestedAt = performance.now ()
|
||||
const nextInfo = await apiPut<TheatreInfo> (`/theatres/${ id }/watching`)
|
||||
if (!(cancelled))
|
||||
if (!(cancelled) && watchingRequestedAt >= theatreInfoReceivedAtRef.current)
|
||||
applyTheatreInfo (nextInfo)
|
||||
}
|
||||
catch (error)
|
||||
@@ -557,6 +562,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
if (!(id) || !(post))
|
||||
return
|
||||
|
||||
loadingRef.current = true
|
||||
setLoading (true)
|
||||
try
|
||||
{
|
||||
@@ -585,6 +591,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingRef.current = false
|
||||
setLoading (false)
|
||||
}
|
||||
}
|
||||
|
||||
新しい課題から参照
ユーザをブロックする