コミットを比較

...

2 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 3f2cddcd07 #385 2026-07-04 17:40:55 +09:00
みてるぞ 88b0195e03 #385 2026-07-04 15:41:37 +09:00
9個のファイルの変更242行の追加91行の削除
+17 -19
ファイルの表示
@@ -488,25 +488,23 @@ const MaterialSidebar: FC = () => {
</div>
</div>
<div className="hidden md:block">
<SidebarComponent sidebarKey="materials">
<div className="space-y-4">
<MaterialFilterButtons
materialFilter={materialFilter}
onChange={handleFilterChange}/>
{isLoading && (
<p className="text-sm text-neutral-500 dark:text-stone-400"></p>)}
{isError && (
<p className="text-sm text-red-600 dark:text-red-300">
</p>)}
{(!isLoading && !isError) && (
<ul>
{renderDesktopTree (visibleRootTags)}
</ul>)}
</div>
</SidebarComponent>
</div>
<SidebarComponent sidebarKey="materials" className="hidden md:block">
<div className="space-y-4">
<MaterialFilterButtons
materialFilter={materialFilter}
onChange={handleFilterChange}/>
{isLoading && (
<p className="text-sm text-neutral-500 dark:text-stone-400"></p>)}
{isError && (
<p className="text-sm text-red-600 dark:text-red-300">
</p>)}
{(!isLoading && !isError) && (
<ul>
{renderDesktopTree (visibleRootTags)}
</ul>)}
</div>
</SidebarComponent>
</>)
}
+6 -3
ファイルの表示
@@ -146,10 +146,13 @@ const DropSlot = ({ cat }: { cat: Category }) => {
}
type Props = { post: Post; sp?: boolean }
type Props = {
className?: string
post: Post
sp?: boolean }
const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
sp = Boolean (sp)
const qc = useQueryClient ()
@@ -278,7 +281,7 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
}, [baseTags])
return (
<SidebarComponent sidebarKey="post-detail-tags">
<SidebarComponent sidebarKey="post-detail-tags" className={className}>
<TagSearch/>
<DndContext
sensors={sensors}
+124
ファイルの表示
@@ -0,0 +1,124 @@
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 sidebarTree = (props: Partial<Parameters<typeof SidebarComponent>[0]>) => (
<HelmetProvider>
<div data-sidebar-container>
<SidebarComponent sidebarKey="test-sidebar" {...props}>
Sidebar body
</SidebarComponent>
</div>
</HelmetProvider>)
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 (sidebarTree (props))
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')
})
it ('clamps current width on maxWidth changes without re-reading storage', async () => {
localStorage.setItem ('sidebar:test-sidebar:left:width', '420')
const { container, rerender } = renderSidebar ({ maxWidth: 420 }, 900)
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('420px')
})
rerender (sidebarTree ({ maxWidth: 300 }))
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('300px')
})
rerender (sidebarTree ({ maxWidth: 500 }))
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('300px')
})
})
})
+39 -29
ファイルの表示
@@ -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
}
@@ -76,6 +69,7 @@ const SidebarComponent: FC<Props> = ({
side = 'left',
}) => {
const rootRef = useRef<HTMLDivElement | null> (null)
const maxWidthRef = useRef (maxWidth)
const getContainerWidth = useCallback (() => {
const containerWidth = rootRef.current
?.closest ('[data-sidebar-container]')
@@ -90,18 +84,20 @@ const SidebarComponent: FC<Props> = ({
typeof window === 'undefined' ? containerWidth : window.innerWidth
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
const maxAllowedWidth =
maxWidth == null
maxWidthRef.current == null
? getSidebarMaxWidth (containerWidth)
: Math.max (MIN_SIDEBAR_WIDTH, Math.min (viewportMaxWidth, maxWidth))
: Math.max (
MIN_SIDEBAR_WIDTH,
Math.min (viewportMaxWidth, maxWidthRef.current))
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
}, [maxWidth])
}, [])
const [width, setWidth] = useState (() => (
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)
@@ -160,6 +156,21 @@ const SidebarComponent: FC<Props> = ({
restoreBodyStyle ()
}, [clearLongPressTimer, restoreBodyStyle, sidebarKey])
const applyWidth = useCallback ((nextWidth: number) => {
if (nextWidth === widthRef.current)
return
widthRef.current = nextWidth
setWidth (nextWidth)
}, [])
useEffect (() => {
maxWidthRef.current = maxWidth
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth, maxWidth])
const updateWidth = useCallback ((clientX: number) => {
const state = resizeRef.current
if (state == null)
@@ -170,9 +181,8 @@ const SidebarComponent: FC<Props> = ({
? clientX - state.startX
: state.startX - clientX
const nextWidth = getClampedWidth (state.startWidth + delta, containerWidth)
widthRef.current = nextWidth
setWidth (nextWidth)
}, [getClampedWidth, getContainerWidth])
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth])
const handlePointerDown = (ev: PointerEvent<HTMLDivElement>) => {
if (ev.pointerType !== 'mouse' && ev.pointerType !== 'touch')
@@ -242,10 +252,9 @@ const SidebarComponent: FC<Props> = ({
const resetWidth = useCallback (() => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (DEFAULT_SIDEBAR_WIDTH, containerWidth)
widthRef.current = nextWidth
setWidth (nextWidth)
applyWidth (nextWidth)
window.localStorage.removeItem (storageKeyForSidebar (sidebarKey, side))
}, [getClampedWidth, getContainerWidth, sidebarKey, side])
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
const handleDoubleClick = (ev: MouseEvent<HTMLDivElement>) => {
ev.preventDefault ()
@@ -256,11 +265,16 @@ const SidebarComponent: FC<Props> = ({
useEffect (() => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (
readStoredSidebarWidth (sidebarKey, side, containerWidth),
readStoredSidebarWidth (sidebarKey, side),
containerWidth)
widthRef.current = nextWidth
setWidth (nextWidth)
}, [getClampedWidth, getContainerWidth, sidebarKey, side])
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
useEffect (() => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth])
useEffect (() => {
if (typeof window === 'undefined')
@@ -269,16 +283,12 @@ const SidebarComponent: FC<Props> = ({
const handleResize = () => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
if (nextWidth === widthRef.current)
return
widthRef.current = nextWidth
setWidth (nextWidth)
applyWidth (nextWidth)
}
window.addEventListener ('resize', handleResize)
return () => window.removeEventListener ('resize', handleResize)
}, [getClampedWidth, getContainerWidth])
}, [applyWidth, getClampedWidth, getContainerWidth])
useEffect (() => {
onWidthChange?.(width)
+4 -2
ファイルの表示
@@ -6,9 +6,11 @@ import type { FC } from 'react'
const MaterialBasePage: FC = () => (
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<div
data-sidebar-container
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<MaterialSidebar/>
<Outlet/>
</div>)
export default MaterialBasePage
export default MaterialBasePage
+5 -7
ファイルの表示
@@ -92,16 +92,16 @@ const PostDetailPage: FC<Props> = ({ user }) => {
: 'bg-gray-500 hover:bg-gray-600')
return (
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<div
data-sidebar-container
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<Helmet>
{(post?.thumbnail || post?.thumbnailBase) && (
<meta name="thumbnail" content={post.thumbnail! || post.thumbnailBase!}/>)}
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>}
</Helmet>
<div className="hidden md:block">
{post && <TagDetailSidebar post={post}/>}
</div>
{post && <TagDetailSidebar post={post} className="hidden md:block"/>}
<MainArea className="relative">
{post
@@ -179,9 +179,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
: 'Loading...'}
</MainArea>
<div className="md:hidden">
{post && <TagDetailSidebar post={post} sp/>}
</div>
{post && <TagDetailSidebar post={post} sp className="md:hidden"/>}
</div>)
}
+1
ファイルの表示
@@ -70,6 +70,7 @@ const PostListPage: FC = () => {
return (
<div
data-sidebar-container
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"
ref={containerRef}>
<Helmet>
+38 -30
ファイルの表示
@@ -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 }`))
+8 -1
ファイルの表示
@@ -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)
}
}