diff --git a/src/components/ui/toc-panel.test.ts b/src/components/ui/toc-panel.test.ts new file mode 100644 index 0000000..d24c24e --- /dev/null +++ b/src/components/ui/toc-panel.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TocPanel } from './toc-panel'; +import type { TocItem } from '@/types'; + +// Mock ResizeObserver for JSDOM +class MockResizeObserver { + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); +} +globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; + +// Helper to create TOC items +function createTocItems(count: number, nested = false): TocItem[] { + const items: TocItem[] = []; + for (let i = 0; i < count; i++) { + const item: TocItem = { + id: `item-${i}`, + label: `Chapter ${i + 1}`, + level: 0, + }; + if (nested && i % 3 === 0) { + item.children = [ + { id: `item-${i}-1`, label: `Section ${i + 1}.1`, level: 1 }, + { id: `item-${i}-2`, label: `Section ${i + 1}.2`, level: 1 }, + ]; + } + items.push(item); + } + return items; +} + +// Wait for Lit to render +async function waitForRender(el: TocPanel): Promise { + await el.updateComplete; + await new Promise(resolve => requestAnimationFrame(resolve)); +} + +describe('TocPanel', () => { + let panel: TocPanel; + + beforeEach(() => { + panel = new TocPanel(); + document.body.appendChild(panel); + }); + + afterEach(() => { + panel.remove(); + }); + + describe('standard rendering (< 50 items)', () => { + it('should render all items in standard mode', async () => { + panel.items = createTocItems(10); + panel.open = true; + await waitForRender(panel); + + const list = panel.shadowRoot?.querySelector('.toc-list'); + const virtualList = panel.shadowRoot?.querySelector('.toc-virtual-list'); + + expect(list).not.toBeNull(); + expect(virtualList).toBeNull(); + }); + + it('should render nested children', async () => { + panel.items = createTocItems(10, true); + panel.open = true; + await waitForRender(panel); + + const children = panel.shadowRoot?.querySelectorAll('.toc-children'); + expect(children?.length).toBeGreaterThan(0); + }); + + it('should apply level classes', async () => { + const items: TocItem[] = [ + { id: '1', label: 'Root', level: 0 }, + { + id: '2', label: 'Root 2', level: 0, + children: [ + { id: '2-1', label: 'Child', level: 1 }, + ], + }, + ]; + panel.items = items; + panel.open = true; + await waitForRender(panel); + + const level1Btn = panel.shadowRoot?.querySelector('.toc-item-btn--level-1'); + expect(level1Btn).not.toBeNull(); + }); + + it('should mark active item', async () => { + panel.items = createTocItems(5); + panel.activeId = 'item-2'; + panel.open = true; + await waitForRender(panel); + + const activeBtn = panel.shadowRoot?.querySelector('.toc-item-btn--active'); + expect(activeBtn?.textContent?.trim()).toBe('Chapter 3'); + }); + }); + + describe('virtual scrolling (> 50 items)', () => { + it('should use virtual scrolling for large lists', async () => { + panel.items = createTocItems(100); + panel.open = true; + await waitForRender(panel); + + const virtualList = panel.shadowRoot?.querySelector('.toc-virtual-list'); + const standardList = panel.shadowRoot?.querySelector('.toc-list'); + + expect(virtualList).not.toBeNull(); + expect(standardList).toBeNull(); + }); + + it('should render spacer with correct height', async () => { + panel.items = createTocItems(100); + panel.open = true; + await waitForRender(panel); + + const spacer = panel.shadowRoot?.querySelector('.toc-virtual-spacer') as HTMLElement; + // 100 items * 40px = 4000px + expect(spacer?.style.height).toBe('4000px'); + }); + + it('should position items absolutely', async () => { + panel.items = createTocItems(100); + panel.open = true; + await waitForRender(panel); + + const items = panel.shadowRoot?.querySelectorAll('.toc-virtual-item'); + expect(items?.length).toBeGreaterThan(0); + + const firstItem = items?.[0] as HTMLElement; + expect(firstItem?.style.top).toBe('0px'); + }); + + it('should only render visible items plus buffer', async () => { + panel.items = createTocItems(200); + panel.open = true; + await waitForRender(panel); + + const renderedItems = panel.shadowRoot?.querySelectorAll('.toc-virtual-item'); + // Should render far fewer than 200 items + expect(renderedItems?.length).toBeLessThan(50); + }); + + it('should flatten nested items for virtual scrolling', async () => { + // Create nested items that result in > 50 flat items + const items: TocItem[] = []; + for (let i = 0; i < 20; i++) { + items.push({ + id: `chapter-${i}`, + label: `Chapter ${i + 1}`, + level: 0, + children: [ + { id: `section-${i}-1`, label: `Section ${i + 1}.1`, level: 1 }, + { id: `section-${i}-2`, label: `Section ${i + 1}.2`, level: 1 }, + ], + }); + } + // 20 chapters + 40 sections = 60 flat items + panel.items = items; + panel.open = true; + await waitForRender(panel); + + const virtualList = panel.shadowRoot?.querySelector('.toc-virtual-list'); + expect(virtualList).not.toBeNull(); + + const spacer = panel.shadowRoot?.querySelector('.toc-virtual-spacer') as HTMLElement; + // 60 items * 40px = 2400px + expect(spacer?.style.height).toBe('2400px'); + }); + }); + + describe('events', () => { + it('should dispatch toc-select on item click', async () => { + panel.items = createTocItems(5); + panel.open = true; + await waitForRender(panel); + + const selectHandler = vi.fn(); + panel.addEventListener('toc-select', selectHandler); + + const button = panel.shadowRoot?.querySelector('.toc-item-btn') as HTMLButtonElement; + button?.click(); + + expect(selectHandler).toHaveBeenCalled(); + expect(selectHandler.mock.calls[0][0].detail).toEqual(panel.items[0]); + }); + + it('should dispatch close on backdrop click', async () => { + panel.items = createTocItems(5); + panel.open = true; + await waitForRender(panel); + + const closeHandler = vi.fn(); + panel.addEventListener('close', closeHandler); + + const backdrop = panel.shadowRoot?.querySelector('.backdrop') as HTMLElement; + backdrop?.click(); + + expect(closeHandler).toHaveBeenCalled(); + }); + + it('should dispatch close on Escape key', async () => { + panel.items = createTocItems(5); + panel.open = true; + await waitForRender(panel); + + const closeHandler = vi.fn(); + panel.addEventListener('close', closeHandler); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + expect(closeHandler).toHaveBeenCalled(); + }); + + it('should not close on Escape when panel is closed', async () => { + panel.items = createTocItems(5); + panel.open = false; + await waitForRender(panel); + + const closeHandler = vi.fn(); + panel.addEventListener('close', closeHandler); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + expect(closeHandler).not.toHaveBeenCalled(); + }); + }); + + describe('accessibility', () => { + it('should apply inert when closed', async () => { + panel.items = createTocItems(5); + panel.open = false; + await waitForRender(panel); + + const aside = panel.shadowRoot?.querySelector('.panel'); + expect(aside?.hasAttribute('inert')).toBe(true); + }); + + it('should remove inert when open', async () => { + panel.items = createTocItems(5); + panel.open = true; + await waitForRender(panel); + + const aside = panel.shadowRoot?.querySelector('.panel'); + expect(aside?.hasAttribute('inert')).toBe(false); + }); + + it('should have proper ARIA labels', async () => { + panel.items = createTocItems(5); + panel.open = true; + await waitForRender(panel); + + const aside = panel.shadowRoot?.querySelector('.panel'); + expect(aside?.getAttribute('aria-label')).toBe('Table of contents'); + + const closeBtn = panel.shadowRoot?.querySelector('.close-btn'); + expect(closeBtn?.getAttribute('aria-label')).toBe('Close table of contents'); + }); + + it('should set aria-current on active item', async () => { + panel.items = createTocItems(5); + panel.activeId = 'item-1'; + panel.open = true; + await waitForRender(panel); + + const buttons = panel.shadowRoot?.querySelectorAll('.toc-item-btn'); + const activeBtn = buttons?.[1]; + expect(activeBtn?.getAttribute('aria-current')).toBe('true'); + }); + }); + + describe('empty state', () => { + it('should show empty message when no items', async () => { + panel.items = []; + panel.open = true; + await waitForRender(panel); + + const noToc = panel.shadowRoot?.querySelector('.no-toc'); + expect(noToc?.textContent?.trim()).toBe('No table of contents available'); + }); + }); +}); diff --git a/src/components/ui/toc-panel.ts b/src/components/ui/toc-panel.ts index d7ca66e..b9e7344 100644 --- a/src/components/ui/toc-panel.ts +++ b/src/components/ui/toc-panel.ts @@ -2,8 +2,17 @@ import { LitElement, html, css, type PropertyValues } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import type { TocItem } from '@/types'; +/** Fixed height for each TOC item in pixels */ +const ITEM_HEIGHT = 40; + +/** Number of items to render above/below visible area */ +const BUFFER_SIZE = 5; + +/** Threshold for enabling virtual scrolling */ +const VIRTUAL_SCROLL_THRESHOLD = 50; + /** - * Slide-out Table of Contents panel + * Slide-out Table of Contents panel with virtual scrolling for large lists * * @element toc-panel * @@ -107,15 +116,47 @@ export class TocPanel extends LitElement { list-style: none; } + /* Virtual scroll container */ + .toc-virtual-list { + flex: 1; + overflow-y: auto; + margin: 0; + position: relative; + } + + .toc-virtual-spacer { + pointer-events: none; + } + + .toc-virtual-viewport { + position: absolute; + top: 0; + left: 0; + right: 0; + margin: 0; + padding: 0; + list-style: none; + } + .toc-item { margin: 0; padding: 0; } + .toc-virtual-item { + position: absolute; + left: 0; + right: 0; + height: 40px; + margin: 0; + padding: 0; + } + .toc-item-btn { display: block; width: 100%; - padding: 0.75rem 1rem; + height: 100%; + padding: 0.625rem 1rem; border: none; background: transparent; color: var(--speed-reader-text, #000000); @@ -124,6 +165,10 @@ export class TocPanel extends LitElement { line-height: 1.4; cursor: pointer; transition: background 0.1s; + box-sizing: border-box; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .toc-item-btn:hover { @@ -195,7 +240,16 @@ export class TocPanel extends LitElement { @state() private focusedIndex = -1; + @state() + private virtualScrollOffset = 0; + + @state() + private containerHeight = 0; + private previouslyFocusedElement: HTMLElement | null = null; + private flatItems: TocItem[] = []; + private resizeObserver: ResizeObserver | null = null; + private scrollListenerAttached = false; override connectedCallback(): void { super.connectedCallback(); @@ -205,6 +259,15 @@ export class TocPanel extends LitElement { override disconnectedCallback(): void { super.disconnectedCallback(); document.removeEventListener('keydown', this.handleKeydown); + this.cleanupVirtualScroll(); + } + + private cleanupVirtualScroll(): void { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + this.scrollListenerAttached = false; } override updated(changedProps: PropertyValues): void { @@ -212,11 +275,50 @@ export class TocPanel extends LitElement { if (this.open) { // Store the previously focused element before opening this.previouslyFocusedElement = document.activeElement as HTMLElement; + // Setup virtual scroll observers when opened + this.setupVirtualScroll(); } // Note: focus is moved out BEFORE close via moveFocusOutAndClose() } + + if (changedProps.has('items')) { + // Rebuild flat items when items change + this.flatItems = this.flattenItems(this.items); + } } + private setupVirtualScroll(): void { + // Wait for render to complete before setting up observers + requestAnimationFrame(() => { + const virtualList = this.renderRoot.querySelector('.toc-virtual-list'); + if (!virtualList) return; + + // Setup scroll listener + if (!this.scrollListenerAttached) { + virtualList.addEventListener('scroll', this.handleScroll); + this.scrollListenerAttached = true; + } + + // Setup resize observer for container height + if (!this.resizeObserver) { + this.resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + this.containerHeight = entry.contentRect.height; + } + }); + this.resizeObserver.observe(virtualList); + } + + // Initial measurement + this.containerHeight = virtualList.clientHeight; + }); + } + + private handleScroll = (e: Event): void => { + const target = e.target as HTMLElement; + this.virtualScrollOffset = target.scrollTop; + }; + /** * Move focus out of the panel and then dispatch close event * This prevents aria-hidden focus warnings by moving focus before inert is applied @@ -280,6 +382,81 @@ export class TocPanel extends LitElement { return result; } + /** + * Check if virtual scrolling should be used based on item count + */ + private get useVirtualScroll(): boolean { + // Ensure flatItems is up to date (in case items changed before updated() ran) + if (this.flatItems.length === 0 && this.items.length > 0) { + this.flatItems = this.flattenItems(this.items); + } + return this.flatItems.length > VIRTUAL_SCROLL_THRESHOLD; + } + + /** + * Calculate the range of items to render based on scroll position + */ + private getVisibleRange(): { start: number; end: number } { + const itemCount = this.flatItems.length; + if (itemCount === 0) return { start: 0, end: 0 }; + + // Use a reasonable default if container height not yet measured + // Assume ~500px viewport which fits ~12 items + const effectiveHeight = this.containerHeight > 0 ? this.containerHeight : 500; + const visibleCount = Math.ceil(effectiveHeight / ITEM_HEIGHT); + const startIndex = Math.floor(this.virtualScrollOffset / ITEM_HEIGHT); + + const start = Math.max(0, startIndex - BUFFER_SIZE); + const end = Math.min(itemCount, startIndex + visibleCount + BUFFER_SIZE); + + return { start, end }; + } + + /** + * Render a single TOC item for virtual scrolling (positioned absolutely) + */ + private renderVirtualItem(item: TocItem, index: number): unknown { + const levelClass = item.level > 0 ? `toc-item-btn--level-${Math.min(item.level, 3)}` : ''; + const activeClass = item.id === this.activeId ? 'toc-item-btn--active' : ''; + const top = index * ITEM_HEIGHT; + + return html` +
  • + +
  • + `; + } + + /** + * Render the virtual scrolling list + */ + private renderVirtualList(): unknown { + const { start, end } = this.getVisibleRange(); + const totalHeight = this.flatItems.length * ITEM_HEIGHT; + const visibleItems = this.flatItems.slice(start, end); + + return html` +
    +
    + +
    + `; + } + + /** + * Render a standard (non-virtual) TOC item with nested children + */ private renderTocItem(item: TocItem): unknown { const levelClass = item.level > 0 ? `toc-item-btn--level-${Math.min(item.level, 3)}` : ''; const activeClass = item.id === this.activeId ? 'toc-item-btn--active' : ''; @@ -304,6 +481,17 @@ export class TocPanel extends LitElement { `; } + /** + * Render the standard (non-virtual) list + */ + private renderStandardList(): unknown { + return html` + + `; + } + override render() { return html`
    ${this.items.length > 0 - ? html` - - ` + ? this.useVirtualScroll + ? this.renderVirtualList() + : this.renderStandardList() : html`
    No table of contents available