mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 09:28:04 +08:00
feat(docs): implement table of contents panel and related hooks for document navigation
- Added component for displaying a collapsible table of contents. - Introduced hook to manage TOC state, including extraction of headings and scroll tracking. - Updated component to utilize the new TOC functionality. - Enhanced tests for and to ensure proper functionality and edge case handling. - Removed unused ESLint suppressions related to file.
This commit is contained in:
@ -53,6 +53,10 @@ vi.mock('@/hooks/use-theme', () => ({
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
LanguagesSupported: ['en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP'],
|
||||
getDocLanguage: (locale: string) => {
|
||||
const map: Record<string, string> = { 'zh-Hans': 'zh', 'ja-JP': 'ja' }
|
||||
return map[locale] || 'en'
|
||||
},
|
||||
}))
|
||||
|
||||
describe('Doc', () => {
|
||||
@ -63,7 +67,7 @@ describe('Doc', () => {
|
||||
prompt_variables: variables,
|
||||
},
|
||||
},
|
||||
})
|
||||
}) as unknown as Parameters<typeof Doc>[0]['appDetail']
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -123,13 +127,13 @@ describe('Doc', () => {
|
||||
|
||||
describe('null/undefined appDetail', () => {
|
||||
it('should render nothing when appDetail has no mode', () => {
|
||||
render(<Doc appDetail={{}} />)
|
||||
render(<Doc appDetail={{} as unknown as Parameters<typeof Doc>[0]['appDetail']} />)
|
||||
expect(screen.queryByTestId('template-completion-en')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('template-chat-en')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing when appDetail is null', () => {
|
||||
render(<Doc appDetail={null} />)
|
||||
render(<Doc appDetail={null as unknown as Parameters<typeof Doc>[0]['appDetail']} />)
|
||||
expect(screen.queryByTestId('template-completion-en')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
199
web/app/components/develop/__tests__/toc-panel.spec.tsx
Normal file
199
web/app/components/develop/__tests__/toc-panel.spec.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import type { TocItem } from '../hooks/use-doc-toc'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import TocPanel from '../toc-panel'
|
||||
|
||||
/**
|
||||
* Unit tests for the TocPanel presentational component.
|
||||
* Covers collapsed/expanded states, item rendering, active section, and callbacks.
|
||||
*/
|
||||
describe('TocPanel', () => {
|
||||
const defaultProps = {
|
||||
toc: [] as TocItem[],
|
||||
activeSection: '',
|
||||
isTocExpanded: false,
|
||||
onToggle: vi.fn(),
|
||||
onItemClick: vi.fn(),
|
||||
}
|
||||
|
||||
const sampleToc: TocItem[] = [
|
||||
{ href: '#introduction', text: 'Introduction' },
|
||||
{ href: '#authentication', text: 'Authentication' },
|
||||
{ href: '#endpoints', text: 'Endpoints' },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Covers collapsed state rendering
|
||||
describe('collapsed state', () => {
|
||||
it('should render expand button when collapsed', () => {
|
||||
render(<TocPanel {...defaultProps} />)
|
||||
|
||||
expect(screen.getByLabelText('Open table of contents')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render nav or toc items when collapsed', () => {
|
||||
render(<TocPanel {...defaultProps} toc={sampleToc} />)
|
||||
|
||||
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Introduction')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onToggle(true) when expand button is clicked', () => {
|
||||
const onToggle = vi.fn()
|
||||
render(<TocPanel {...defaultProps} onToggle={onToggle} />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Open table of contents'))
|
||||
|
||||
expect(onToggle).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers expanded state with empty toc
|
||||
describe('expanded state - empty', () => {
|
||||
it('should render nav with empty message when toc is empty', () => {
|
||||
render(<TocPanel {...defaultProps} isTocExpanded />)
|
||||
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.develop.noContent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render TOC header with title', () => {
|
||||
render(<TocPanel {...defaultProps} isTocExpanded />)
|
||||
|
||||
expect(screen.getByText('appApi.develop.toc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onToggle(false) when close button is clicked', () => {
|
||||
const onToggle = vi.fn()
|
||||
render(<TocPanel {...defaultProps} isTocExpanded onToggle={onToggle} />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Close'))
|
||||
|
||||
expect(onToggle).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers expanded state with toc items
|
||||
describe('expanded state - with items', () => {
|
||||
it('should render all toc items as links', () => {
|
||||
render(<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} />)
|
||||
|
||||
expect(screen.getByText('Introduction')).toBeInTheDocument()
|
||||
expect(screen.getByText('Authentication')).toBeInTheDocument()
|
||||
expect(screen.getByText('Endpoints')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render links with correct href attributes', () => {
|
||||
render(<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} />)
|
||||
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links).toHaveLength(3)
|
||||
expect(links[0]).toHaveAttribute('href', '#introduction')
|
||||
expect(links[1]).toHaveAttribute('href', '#authentication')
|
||||
expect(links[2]).toHaveAttribute('href', '#endpoints')
|
||||
})
|
||||
|
||||
it('should not render empty message when toc has items', () => {
|
||||
render(<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} />)
|
||||
|
||||
expect(screen.queryByText('appApi.develop.noContent')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Covers active section highlighting
|
||||
describe('active section', () => {
|
||||
it('should apply active style to the matching toc item', () => {
|
||||
render(
|
||||
<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} activeSection="authentication" />,
|
||||
)
|
||||
|
||||
const activeLink = screen.getByText('Authentication').closest('a')
|
||||
expect(activeLink?.className).toContain('font-medium')
|
||||
expect(activeLink?.className).toContain('text-text-primary')
|
||||
})
|
||||
|
||||
it('should apply inactive style to non-matching items', () => {
|
||||
render(
|
||||
<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} activeSection="authentication" />,
|
||||
)
|
||||
|
||||
const inactiveLink = screen.getByText('Introduction').closest('a')
|
||||
expect(inactiveLink?.className).toContain('text-text-tertiary')
|
||||
expect(inactiveLink?.className).not.toContain('font-medium')
|
||||
})
|
||||
|
||||
it('should apply active indicator dot to active item', () => {
|
||||
render(
|
||||
<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} activeSection="endpoints" />,
|
||||
)
|
||||
|
||||
const activeLink = screen.getByText('Endpoints').closest('a')
|
||||
const activeDot = activeLink?.querySelector('span:first-child')
|
||||
expect(activeDot?.className).toContain('bg-text-accent')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers click event delegation
|
||||
describe('item click handling', () => {
|
||||
it('should call onItemClick with the event and item when a link is clicked', () => {
|
||||
const onItemClick = vi.fn()
|
||||
render(
|
||||
<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} onItemClick={onItemClick} />,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Authentication'))
|
||||
|
||||
expect(onItemClick).toHaveBeenCalledTimes(1)
|
||||
expect(onItemClick).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
{ href: '#authentication', text: 'Authentication' },
|
||||
)
|
||||
})
|
||||
|
||||
it('should call onItemClick for each clicked item independently', () => {
|
||||
const onItemClick = vi.fn()
|
||||
render(
|
||||
<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} onItemClick={onItemClick} />,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Introduction'))
|
||||
fireEvent.click(screen.getByText('Endpoints'))
|
||||
|
||||
expect(onItemClick).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers edge cases
|
||||
describe('edge cases', () => {
|
||||
it('should handle single item toc', () => {
|
||||
const singleItem = [{ href: '#only', text: 'Only Section' }]
|
||||
render(<TocPanel {...defaultProps} isTocExpanded toc={singleItem} activeSection="only" />)
|
||||
|
||||
expect(screen.getByText('Only Section')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('link')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle toc items with empty text', () => {
|
||||
const emptyTextItem = [{ href: '#empty', text: '' }]
|
||||
render(<TocPanel {...defaultProps} isTocExpanded toc={emptyTextItem} />)
|
||||
|
||||
expect(screen.getAllByRole('link')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle active section that does not match any item', () => {
|
||||
render(
|
||||
<TocPanel {...defaultProps} isTocExpanded toc={sampleToc} activeSection="nonexistent" />,
|
||||
)
|
||||
|
||||
// All items should be in inactive style
|
||||
const links = screen.getAllByRole('link')
|
||||
links.forEach((link) => {
|
||||
expect(link.className).toContain('text-text-tertiary')
|
||||
expect(link.className).not.toContain('font-medium')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
425
web/app/components/develop/__tests__/use-doc-toc.spec.ts
Normal file
425
web/app/components/develop/__tests__/use-doc-toc.spec.ts
Normal file
@ -0,0 +1,425 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useDocToc } from '../hooks/use-doc-toc'
|
||||
|
||||
/**
|
||||
* Unit tests for the useDocToc custom hook.
|
||||
* Covers TOC extraction, viewport-based expansion, scroll tracking, and click handling.
|
||||
*/
|
||||
describe('useDocToc', () => {
|
||||
const defaultOptions = { appDetail: { mode: 'chat' }, locale: 'en-US' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockReturnValue({ matches: false }),
|
||||
})
|
||||
})
|
||||
|
||||
// Covers initial state values based on viewport width
|
||||
describe('initial state', () => {
|
||||
it('should set isTocExpanded to false on narrow viewport', () => {
|
||||
const { result } = renderHook(() => useDocToc(defaultOptions))
|
||||
|
||||
expect(result.current.isTocExpanded).toBe(false)
|
||||
expect(result.current.toc).toEqual([])
|
||||
expect(result.current.activeSection).toBe('')
|
||||
})
|
||||
|
||||
it('should set isTocExpanded to true on wide viewport', () => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockReturnValue({ matches: true }),
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useDocToc(defaultOptions))
|
||||
|
||||
expect(result.current.isTocExpanded).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers TOC extraction from DOM article headings
|
||||
describe('TOC extraction', () => {
|
||||
it('should extract toc items from article h2 anchors', async () => {
|
||||
vi.useFakeTimers()
|
||||
const article = document.createElement('article')
|
||||
const h2 = document.createElement('h2')
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = '#section-1'
|
||||
anchor.textContent = 'Section 1'
|
||||
h2.appendChild(anchor)
|
||||
article.appendChild(h2)
|
||||
document.body.appendChild(article)
|
||||
|
||||
const { result } = renderHook(() => useDocToc(defaultOptions))
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
expect(result.current.toc).toEqual([
|
||||
{ href: '#section-1', text: 'Section 1' },
|
||||
])
|
||||
expect(result.current.activeSection).toBe('section-1')
|
||||
|
||||
document.body.removeChild(article)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should return empty toc when no article exists', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { result } = renderHook(() => useDocToc(defaultOptions))
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
expect(result.current.toc).toEqual([])
|
||||
expect(result.current.activeSection).toBe('')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should skip h2 headings without anchors', async () => {
|
||||
vi.useFakeTimers()
|
||||
const article = document.createElement('article')
|
||||
const h2NoAnchor = document.createElement('h2')
|
||||
h2NoAnchor.textContent = 'No Anchor'
|
||||
article.appendChild(h2NoAnchor)
|
||||
|
||||
const h2WithAnchor = document.createElement('h2')
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = '#valid'
|
||||
anchor.textContent = 'Valid'
|
||||
h2WithAnchor.appendChild(anchor)
|
||||
article.appendChild(h2WithAnchor)
|
||||
|
||||
document.body.appendChild(article)
|
||||
|
||||
const { result } = renderHook(() => useDocToc(defaultOptions))
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
expect(result.current.toc).toHaveLength(1)
|
||||
expect(result.current.toc[0]).toEqual({ href: '#valid', text: 'Valid' })
|
||||
|
||||
document.body.removeChild(article)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should re-extract toc when appDetail changes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const article = document.createElement('article')
|
||||
document.body.appendChild(article)
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
props => useDocToc(props),
|
||||
{ initialProps: defaultOptions },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
expect(result.current.toc).toEqual([])
|
||||
|
||||
// Add a heading, then change appDetail to trigger re-extraction
|
||||
const h2 = document.createElement('h2')
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = '#new-section'
|
||||
anchor.textContent = 'New Section'
|
||||
h2.appendChild(anchor)
|
||||
article.appendChild(h2)
|
||||
|
||||
rerender({ appDetail: { mode: 'workflow' }, locale: 'en-US' })
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
expect(result.current.toc).toHaveLength(1)
|
||||
|
||||
document.body.removeChild(article)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should re-extract toc when locale changes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const article = document.createElement('article')
|
||||
const h2 = document.createElement('h2')
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = '#sec'
|
||||
anchor.textContent = 'Sec'
|
||||
h2.appendChild(anchor)
|
||||
article.appendChild(h2)
|
||||
document.body.appendChild(article)
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
props => useDocToc(props),
|
||||
{ initialProps: defaultOptions },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
expect(result.current.toc).toHaveLength(1)
|
||||
|
||||
rerender({ appDetail: defaultOptions.appDetail, locale: 'zh-Hans' })
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
// Should still have the toc item after re-extraction
|
||||
expect(result.current.toc).toHaveLength(1)
|
||||
|
||||
document.body.removeChild(article)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
// Covers manual toggle via setIsTocExpanded
|
||||
describe('setIsTocExpanded', () => {
|
||||
it('should toggle isTocExpanded state', () => {
|
||||
const { result } = renderHook(() => useDocToc(defaultOptions))
|
||||
|
||||
expect(result.current.isTocExpanded).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.setIsTocExpanded(true)
|
||||
})
|
||||
|
||||
expect(result.current.isTocExpanded).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.setIsTocExpanded(false)
|
||||
})
|
||||
|
||||
expect(result.current.isTocExpanded).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers smooth-scroll click handler
|
||||
describe('handleTocClick', () => {
|
||||
it('should prevent default and scroll to target element', () => {
|
||||
const scrollContainer = document.createElement('div')
|
||||
scrollContainer.className = 'overflow-auto'
|
||||
scrollContainer.scrollTo = vi.fn()
|
||||
document.body.appendChild(scrollContainer)
|
||||
|
||||
const target = document.createElement('div')
|
||||
target.id = 'target-section'
|
||||
Object.defineProperty(target, 'offsetTop', { value: 500 })
|
||||
scrollContainer.appendChild(target)
|
||||
|
||||
const { result } = renderHook(() => useDocToc(defaultOptions))
|
||||
|
||||
const mockEvent = { preventDefault: vi.fn() } as unknown as React.MouseEvent<HTMLAnchorElement>
|
||||
act(() => {
|
||||
result.current.handleTocClick(mockEvent, { href: '#target-section', text: 'Target' })
|
||||
})
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(scrollContainer.scrollTo).toHaveBeenCalledWith({
|
||||
top: 420, // 500 - 80 (HEADER_OFFSET)
|
||||
behavior: 'smooth',
|
||||
})
|
||||
|
||||
document.body.removeChild(scrollContainer)
|
||||
})
|
||||
|
||||
it('should do nothing when target element does not exist', () => {
|
||||
const { result } = renderHook(() => useDocToc(defaultOptions))
|
||||
|
||||
const mockEvent = { preventDefault: vi.fn() } as unknown as React.MouseEvent<HTMLAnchorElement>
|
||||
act(() => {
|
||||
result.current.handleTocClick(mockEvent, { href: '#nonexistent', text: 'Missing' })
|
||||
})
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Covers scroll-based active section tracking
|
||||
describe('scroll tracking', () => {
|
||||
// Helper: set up DOM with scroll container, article headings, and matching target elements
|
||||
const setupScrollDOM = (sections: Array<{ id: string, text: string, top: number }>) => {
|
||||
const scrollContainer = document.createElement('div')
|
||||
scrollContainer.className = 'overflow-auto'
|
||||
document.body.appendChild(scrollContainer)
|
||||
|
||||
const article = document.createElement('article')
|
||||
sections.forEach(({ id, text, top }) => {
|
||||
// Heading with anchor for TOC extraction
|
||||
const h2 = document.createElement('h2')
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = `#${id}`
|
||||
anchor.textContent = text
|
||||
h2.appendChild(anchor)
|
||||
article.appendChild(h2)
|
||||
|
||||
// Target element for scroll tracking
|
||||
const target = document.createElement('div')
|
||||
target.id = id
|
||||
target.getBoundingClientRect = vi.fn().mockReturnValue({ top })
|
||||
scrollContainer.appendChild(target)
|
||||
})
|
||||
document.body.appendChild(article)
|
||||
|
||||
return {
|
||||
scrollContainer,
|
||||
article,
|
||||
cleanup: () => {
|
||||
document.body.removeChild(scrollContainer)
|
||||
document.body.removeChild(article)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
it('should register scroll listener when toc has items', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { scrollContainer, cleanup } = setupScrollDOM([
|
||||
{ id: 'sec-a', text: 'Section A', top: 0 },
|
||||
])
|
||||
const addSpy = vi.spyOn(scrollContainer, 'addEventListener')
|
||||
const removeSpy = vi.spyOn(scrollContainer, 'removeEventListener')
|
||||
|
||||
const { unmount } = renderHook(() => useDocToc(defaultOptions))
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
expect(addSpy).toHaveBeenCalledWith('scroll', expect.any(Function))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function))
|
||||
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should update activeSection when scrolling past a section', async () => {
|
||||
vi.useFakeTimers()
|
||||
// innerHeight/2 = 384 in jsdom (default 768), so top <= 384 means "scrolled past"
|
||||
const { scrollContainer, cleanup } = setupScrollDOM([
|
||||
{ id: 'intro', text: 'Intro', top: 100 },
|
||||
{ id: 'details', text: 'Details', top: 600 },
|
||||
])
|
||||
|
||||
const { result } = renderHook(() => useDocToc(defaultOptions))
|
||||
|
||||
// Extract TOC items
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
expect(result.current.toc).toHaveLength(2)
|
||||
expect(result.current.activeSection).toBe('intro')
|
||||
|
||||
// Fire scroll — 'intro' (top=100) is above midpoint, 'details' (top=600) is below
|
||||
await act(async () => {
|
||||
scrollContainer.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
|
||||
expect(result.current.activeSection).toBe('intro')
|
||||
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should track the last section above the viewport midpoint', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { scrollContainer, cleanup } = setupScrollDOM([
|
||||
{ id: 'sec-1', text: 'Section 1', top: 50 },
|
||||
{ id: 'sec-2', text: 'Section 2', top: 200 },
|
||||
{ id: 'sec-3', text: 'Section 3', top: 800 },
|
||||
])
|
||||
|
||||
const { result } = renderHook(() => useDocToc(defaultOptions))
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
// Fire scroll — sec-1 (top=50) and sec-2 (top=200) are above midpoint (384),
|
||||
// sec-3 (top=800) is below. The last one above midpoint wins.
|
||||
await act(async () => {
|
||||
scrollContainer.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
|
||||
expect(result.current.activeSection).toBe('sec-2')
|
||||
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should not update activeSection when no section is above midpoint', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { scrollContainer, cleanup } = setupScrollDOM([
|
||||
{ id: 'far-away', text: 'Far Away', top: 1000 },
|
||||
])
|
||||
|
||||
const { result } = renderHook(() => useDocToc(defaultOptions))
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
// Initial activeSection is set by extraction
|
||||
const initialSection = result.current.activeSection
|
||||
|
||||
await act(async () => {
|
||||
scrollContainer.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
|
||||
// Should not change since the element is below midpoint
|
||||
expect(result.current.activeSection).toBe(initialSection)
|
||||
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should handle elements not found in DOM during scroll', async () => {
|
||||
vi.useFakeTimers()
|
||||
const scrollContainer = document.createElement('div')
|
||||
scrollContainer.className = 'overflow-auto'
|
||||
document.body.appendChild(scrollContainer)
|
||||
|
||||
// Article with heading but NO matching target element by id
|
||||
const article = document.createElement('article')
|
||||
const h2 = document.createElement('h2')
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = '#missing-target'
|
||||
anchor.textContent = 'Missing'
|
||||
h2.appendChild(anchor)
|
||||
article.appendChild(h2)
|
||||
document.body.appendChild(article)
|
||||
|
||||
const { result } = renderHook(() => useDocToc(defaultOptions))
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
const initialSection = result.current.activeSection
|
||||
|
||||
// Scroll fires but getElementById returns null — no crash, no change
|
||||
await act(async () => {
|
||||
scrollContainer.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
|
||||
expect(result.current.activeSection).toBe(initialSection)
|
||||
|
||||
document.body.removeChild(scrollContainer)
|
||||
document.body.removeChild(article)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user