diff --git a/package.json b/package.json index 078904611d..01e6ce1bad 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,7 @@ "docs:build": "tsx website/build.ts && pnpm run verify-doc-site-fragments", "docs:build:mpa": "tsx website/build.ts --mpa && pnpm run verify-doc-site-fragments", "docs:preview": "pnpm --filter @deepseek-ai/website run preview", - "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts website/tests/mermaid-viewer.spec.ts website/tests/image-viewer.spec.ts website/tests/code-groups.spec.ts website/tests/page-markdown-actions.spec.ts website/tests/raw-markdown.spec.ts && pnpm run docs:build", + "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts website/tests/mermaid-viewer.spec.ts website/tests/image-viewer.spec.ts website/tests/code-groups.spec.ts website/tests/page-markdown-actions.spec.ts website/tests/raw-markdown.spec.ts website/tests/table-filter.spec.ts && pnpm run docs:build", "website:dev": "pnpm run docs:dev", "website:build": "pnpm run docs:build", "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index a6dc2095e2..9163aca348 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -873,6 +873,7 @@ function docSyncLeafGates(options: { 'website/tests/mermaid-viewer.spec.ts', 'website/tests/image-viewer.spec.ts', 'website/tests/code-groups.spec.ts', + 'website/tests/table-filter.spec.ts', 'website/tests/page-markdown-actions.spec.ts', 'website/tests/raw-markdown.spec.ts', ], { label: 'documentation site checks', diff --git a/website/.vitepress/theme/index.ts b/website/.vitepress/theme/index.ts index b1e2508eac..36f8c6c053 100644 --- a/website/.vitepress/theme/index.ts +++ b/website/.vitepress/theme/index.ts @@ -1,14 +1,16 @@ -/** Default documentation theme with Markdown actions and a client-only image and diagram viewer. */ +/** Default documentation theme with Markdown actions, long-table controls, and image and diagram viewers. */ import DefaultTheme from 'vitepress/theme' -import { useData, useRoute, type Theme } from 'vitepress' +import { getScrollOffset, onContentUpdated, useData, useRoute, type Theme } from 'vitepress' import { defineComponent, h, onBeforeUnmount, onMounted, watch } from 'vue' import type { MermaidViewer } from './mermaid-viewer.ts' import type { ImageViewer } from './image-viewer.ts' import type { MediaViewer } from './media-viewer.ts' import { PageMarkdownActions } from './page-markdown-actions.ts' +import { DocumentTables } from './table-filter.ts' import './media-viewer.css' import './code-group.css' import './page-markdown-actions.css' +import './table-filter.css' export default { extends: DefaultTheme, @@ -20,8 +22,19 @@ export default { let viewer: MermaidViewer | undefined let images: ImageViewer | undefined let media: MediaViewer | undefined + let tables: DocumentTables | undefined + let initialScroll: number | undefined let disposed = false onMounted(async () => { + tables = new DocumentTables(document, () => lang.value) + // VitePress computes the initial hash position before hydration inserts table controls. + const anchor = document.querySelector(':target') + if (anchor) initialScroll = window.requestAnimationFrame(() => { + initialScroll = undefined + if (!anchor.isConnected) return + const top = window.scrollY + anchor.getBoundingClientRect().top - getScrollOffset() + window.scrollTo(0, top + parseFloat(window.getComputedStyle(anchor).paddingTop)) + }) const [{ installMermaidViewer }, { ImageViewer }, { MediaViewer }] = await Promise.all([ import('./mermaid-viewer.ts'), import('./image-viewer.ts'), import('./media-viewer.ts'), ]) @@ -30,12 +43,16 @@ export default { viewer = installMermaidViewer(document, () => lang.value, media) images = new ImageViewer(document, () => lang.value, media) }) + onContentUpdated(() => { tables?.refresh() }) watch([() => route.path, lang, isDark], () => { viewer?.refresh() images?.refresh() }, { flush: 'post' }) onBeforeUnmount(() => { disposed = true + if (initialScroll !== undefined) window.cancelAnimationFrame(initialScroll) + tables?.dispose() + tables = undefined viewer?.dispose() images?.dispose() media?.close() diff --git a/website/.vitepress/theme/table-filter.css b/website/.vitepress/theme/table-filter.css new file mode 100644 index 0000000000..c54f3ab210 --- /dev/null +++ b/website/.vitepress/theme/table-filter.css @@ -0,0 +1,100 @@ +.dsh-table-controls { + margin-top: 20px; +} + +.dsh-table-toolbar { + position: relative; + display: flex; +} + +.dsh-table-toolbar input { + min-width: 0; + width: 100%; + border: 1px solid var(--vp-c-divider); + border-radius: 6px; + padding: 6px 40px 6px 12px; + color: var(--vp-c-text-1); + background: var(--vp-c-bg); + font: inherit; +} + +.dsh-table-toolbar input::placeholder { + color: var(--vp-c-text-2); +} + +.dsh-table-toolbar input::-webkit-search-cancel-button { + display: none; +} + +.dsh-table-toolbar button { + position: absolute; + inset: 4px 4px 4px auto; + display: grid; + place-items: center; + width: 32px; + border-radius: 4px; + color: var(--vp-c-text-2); + background: transparent; +} + +.dsh-table-toolbar button[hidden] { + display: none; +} + +.dsh-table-toolbar button:hover { + color: var(--vp-c-brand-1); + background: var(--vp-c-bg-soft); +} + +.dsh-table-toolbar button span { + font-size: 22px; + line-height: 1; +} + +.dsh-table-toolbar input:focus-visible, +.dsh-table-toolbar button:focus-visible { + outline: 2px solid var(--vp-c-brand-1); + outline-offset: 2px; +} + +.vp-doc .dsh-table-controls p { + margin: 8px 0 0; + color: var(--vp-c-text-2); + font-size: 14px; +} + +.vp-doc .dsh-table-controls p:empty { + margin: 0; +} + +.vp-doc table.dsh-filterable-table { + margin-top: 8px; +} + +.vp-doc .dsh-filterable-table tbody tr { + background-color: var(--vp-c-bg); +} + +.vp-doc .dsh-filterable-table tbody tr:nth-child(2n of :not([hidden])) { + background-color: var(--vp-c-bg-soft); +} + +.dsh-filterable-table > thead { + position: relative; + z-index: 1; + transform: translateY(var(--dsh-table-header-offset, 0px)); +} + +@media print { + .dsh-table-controls { + display: none; + } + + .dsh-filterable-table > thead { + transform: none; + } + + .dsh-filterable-table > tbody > tr[hidden] { + display: table-row; + } +} diff --git a/website/.vitepress/theme/table-filter.ts b/website/.vitepress/theme/table-filter.ts new file mode 100644 index 0000000000..df4293dd8e --- /dev/null +++ b/website/.vitepress/theme/table-filter.ts @@ -0,0 +1,171 @@ +/** Client-only filtering and page-scroll headers for original Markdown tables. */ +const messages = { + en: { placeholder: 'Search...', filter: 'Filter table', clear: 'Clear', empty: 'No matching rows.' }, + zh: { placeholder: 'Search...', filter: '筛选表格', clear: '清空', empty: '没有匹配的行。' }, +} + +class FilterableTable { + readonly #controls: HTMLDivElement + readonly #input: HTMLInputElement + readonly #clear: HTMLButtonElement + readonly #status: HTMLParagraphElement + readonly #events = new AbortController() + readonly #rows: { element: HTMLTableRowElement; text: string; hidden: HTMLTableRowElement['hidden'] }[] + #empty = '' + + constructor( + private readonly table: HTMLTableElement, + private readonly head: HTMLTableSectionElement, + rows: HTMLTableRowElement[], + copy: typeof messages.en, + private readonly changed: () => void, + ) { + const doc = table.ownerDocument + this.#rows = rows.map(element => ({ + element, + text: Array.from(element.cells, cell => cell.innerText).join(' ').toLowerCase(), + hidden: element.hidden, + })) + this.#controls = doc.createElement('div') + this.#controls.className = 'dsh-table-controls' + const toolbar = doc.createElement('div') + toolbar.className = 'dsh-table-toolbar' + this.#input = doc.createElement('input') + this.#input.type = 'search' + this.#clear = doc.createElement('button') + this.#clear.type = 'button' + const clearIcon = doc.createElement('span') + clearIcon.textContent = '×' + clearIcon.setAttribute('aria-hidden', 'true') + this.#clear.append(clearIcon) + this.#status = doc.createElement('p') + this.#status.setAttribute('role', 'status') + toolbar.append(this.#input, this.#clear) + this.#controls.append(toolbar, this.#status) + table.before(this.#controls) + table.classList.add('dsh-filterable-table') + const options = { signal: this.#events.signal } + this.#input.addEventListener('input', () => { this.#filter() }, options) + this.#clear.addEventListener('click', () => { + this.#input.value = '' + this.#filter() + this.#input.focus() + }, options) + this.localize(copy) + } + + matches(head: HTMLTableSectionElement, rows: HTMLTableRowElement[]): boolean { + return this.head === head && rows.length === this.#rows.length + && rows.every((row, index) => row === this.#rows[index]?.element) + } + + localize(copy: typeof messages.en): void { + this.#input.placeholder = copy.placeholder + this.#input.setAttribute('aria-label', copy.filter) + this.#clear.setAttribute('aria-label', copy.clear) + this.#clear.title = copy.clear + this.#empty = copy.empty + this.#filter() + } + + #filter(): void { + const query = this.#input.value.trim().toLowerCase() + let visible = 0 + for (const row of this.#rows) { + row.element.hidden = row.hidden || !row.text.includes(query) + if (!row.element.hidden) visible++ + } + this.#clear.hidden = this.#input.value.length === 0 + this.#status.textContent = visible === 0 ? this.#empty : '' + this.changed() + } + + measureHeaderOffset(top: number): number { + return Math.min( + Math.max(top - this.table.getBoundingClientRect().top, 0), Math.max(this.table.clientHeight - this.head.offsetHeight, 0), + ) + } + + positionHeader(offset: number): void { + // Horizontal overflow makes the table the sticky scroll container; translate its own head to retain column alignment. + this.head.style.setProperty('--dsh-table-header-offset', `${offset}px`) + } + + dispose(): void { + this.#events.abort() + this.#controls.remove() + this.table.classList.remove('dsh-filterable-table') + this.head.style.removeProperty('--dsh-table-header-offset') + for (const row of this.#rows) row.element.hidden = row.hidden + } +} + +/** Owns long-table controls and layout subscriptions for one mounted documentation theme. */ +export class DocumentTables { + readonly #tables = new Map() + readonly #events = new AbortController() + readonly #sizes: ResizeObserver + #frame: number | undefined + + /** + * @param doc Browser document containing the rendered VitePress article. + * @param language Current VitePress language, read again on content refresh. + */ + constructor(private readonly doc: Document, private readonly language: () => string) { + this.#sizes = new ResizeObserver(() => { this.#schedule() }) + const content = doc.querySelector('#VPContent') + if (content) this.#sizes.observe(content) + const options = { passive: true, signal: this.#events.signal } + window.addEventListener('scroll', () => { this.#schedule() }, options) + window.addEventListener('resize', () => { this.#schedule() }, options) + this.refresh() + } + + /** Reconcile rendered tables after content updates, preserving queries for unchanged rows and headers. */ + refresh(): void { + const tables = new Set(this.doc.querySelectorAll('.VPDoc main .vp-doc table')) + for (const [table, entry] of this.#tables) { + if (tables.has(table)) continue + entry.dispose() + this.#sizes.unobserve(table) + this.#tables.delete(table) + } + const copy = this.language().startsWith('zh') ? messages.zh : messages.en + for (const table of tables) { + const rows = Array.from(table.tBodies).flatMap(body => Array.from(body.rows)) + const existing = this.#tables.get(table) + if (table.tHead && rows.length >= 10 && existing?.matches(table.tHead, rows)) { + existing.localize(copy) + continue + } + existing?.dispose() + this.#sizes.unobserve(table) + this.#tables.delete(table) + if (!table.tHead || rows.length < 10) continue + this.#tables.set(table, new FilterableTable(table, table.tHead, rows, copy, () => { this.#schedule() })) + this.#sizes.observe(table) + } + this.#schedule() + } + + #schedule(): void { + if (this.#tables.size === 0 || this.#frame !== undefined) return + this.#frame = window.requestAnimationFrame(() => { + this.#frame = undefined + if (this.#tables.size === 0) return + const navigation = this.doc.querySelectorAll('.VPNav, .VPLocalNav') + const top = Math.max(0, ...Array.from(navigation, element => element.getBoundingClientRect().bottom)) + const positions = Array.from(this.#tables.values(), table => ({ table, offset: table.measureHeaderOffset(top) })) + for (const { table, offset } of positions) table.positionHeader(offset) + }) + } + + /** Remove controls and subscriptions, cancel pending layout work, and restore original row visibility. */ + dispose(): void { + this.#events.abort() + this.#sizes.disconnect() + if (this.#frame !== undefined) window.cancelAnimationFrame(this.#frame) + for (const table of this.#tables.values()) table.dispose() + this.#tables.clear() + } +} diff --git a/website/AGENTS.md b/website/AGENTS.md index 3e068363a0..1d94386e0e 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -21,3 +21,5 @@ Content-page Markdown actions use the projector's `rawMarkdownPath` and the site The default-theme extension shares one fullscreen modal and pan/zoom controller between Mermaid diagrams and standalone content images. Keep enhancements separate from Markdown projection and preserve source elements. Images must be loaded, have nonempty alt text, and occupy their own paragraph; linked, inline, decorative, and `data-no-zoom` images keep their existing interaction. Image views expose intrinsic size through the 100% control. Route, language, theme, and source replacement changes close the active view; theme disposal releases every observer, listener, and scroll lock. The [viewer reference](.vitepress/theme/mermaid-viewer.ts) explains SVG isolation and verification. Native code groups keep their radio controls in separate forms so copied search excerpts cannot clear the page's selected platform. Search excerpts and MPA builds display every command block because tab switching is unavailable; the [code-group adapter](.vitepress/code-groups.ts) and its [focused tests](tests/code-groups.spec.ts) own this behavior. + +Long tables with at least ten data rows receive independent text filters and page-scroll headers. Filtering matches visible cell text, ignores case and surrounding query whitespace, and preserves original rows and links; clearing restores their order and visibility. Smaller tables stay unchanged. Keep filtering in the [theme adapter](.vitepress/theme/table-filter.ts), preserve native horizontal scrolling, and leave raw Markdown complete. Initial hash navigation accounts for controls inserted during hydration. The [focused tests](tests/table-filter.spec.ts) cover controls and lifecycle; verify header alignment and cold-open anchor links in a production browser build. diff --git a/website/tests/expected/table-filter.en-US.html b/website/tests/expected/table-filter.en-US.html new file mode 100644 index 0000000000..84aceb464e --- /dev/null +++ b/website/tests/expected/table-filter.en-US.html @@ -0,0 +1 @@ +

No matching rows.

diff --git a/website/tests/expected/table-filter.zh-CN.html b/website/tests/expected/table-filter.zh-CN.html new file mode 100644 index 0000000000..f33f7ac5f0 --- /dev/null +++ b/website/tests/expected/table-filter.zh-CN.html @@ -0,0 +1 @@ +

没有匹配的行。

diff --git a/website/tests/page-markdown-actions.spec.ts b/website/tests/page-markdown-actions.spec.ts index 966e4ae961..01dcec1ebe 100644 --- a/website/tests/page-markdown-actions.spec.ts +++ b/website/tests/page-markdown-actions.spec.ts @@ -7,7 +7,7 @@ import { fireEvent, getByRole, queryAllByRole, queryByRole, waitFor } from '@tes import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import Theme from '../.vitepress/theme/index.ts' -vi.mock('vitepress', () => ({ useData: () => data, useRoute: () => route })) +vi.mock('vitepress', () => ({ useData: () => data, useRoute: () => route, onContentUpdated() {}, getScrollOffset: () => 64 })) vi.mock('vitepress/theme', () => ({ default: { Layout: { setup: (_props: unknown, { slots }: { slots: Slots }) => () => h('main', null, slots['doc-before']?.()), } } })) @@ -48,6 +48,15 @@ function response(text = '# Quickstart\n\n[Reference](../reference/index.md)\n', return new Response(text, { status, headers: { 'content-type': `${type}; charset=utf-8` } }) } +function readBlob(blob: Blob): Promise { + const reader = new FileReader() + const done = new Promise((resolve) => { + reader.addEventListener('load', () => { resolve(reader.result) }, { once: true }) + }) + reader.readAsText(blob) + return done +} + async function mount() { app = createApp(Theme.Layout) app.mount(host) @@ -95,6 +104,37 @@ afterEach(async () => { }) describe('page Markdown actions', () => { + it('positions the initial hash after layout enhancements and cancels it on unmount', async () => { + const anchor = document.createElement('h2') + anchor.id = 'after-table' + anchor.style.paddingTop = '16px' + document.body.append(anchor) + const url = location.href + const state: unknown = history.state + history.replaceState(null, '', '#after-table') + const scroll = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + const frame = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(42) + const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}) + try { + vi.spyOn(anchor, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 300, 100, 20)) + await mount() + expect(scroll).not.toHaveBeenCalled() + const callback = frame.mock.calls[0]?.[0] + assert(callback) + callback(0) + expect(scroll).toHaveBeenCalledWith(0, 252) + app?.unmount() + await mount() + app?.unmount() + app = undefined + expect(cancel).toHaveBeenCalledWith(42) + } finally { + anchor.remove() + history.replaceState(state, '', url) + vi.restoreAllMocks() + } + }) + it.each(['en-US', 'zh-CN'])('preserves the accessible controls and status in %s', async (lang) => { data.lang.value = lang data.frontmatter.value = { rawMarkdownPath: `${lang === 'en-US' ? 'en/' : ''}guide/quickstart.md` } @@ -296,16 +336,40 @@ describe('page Markdown actions', () => { pending.resolve(response()) await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') }) expect(copied?.type).toBe('text/plain') - const reader = new FileReader() - const text = new Promise((resolve) => { reader.addEventListener('load', () => { resolve(reader.result) }, { once: true }) }) assert(copied !== undefined) - reader.readAsText(copied) - expect(await text).toBe('# Quickstart\n\n[Reference](../reference/index.md)\n') + expect(await readBlob(copied)).toBe('# Quickstart\n\n[Reference](../reference/index.md)\n') expect(button.getAttribute('aria-disabled')).toBe('false') expect(button.getAttribute('aria-busy')).toBe('false') expect(document.activeElement).toBe(button) }) + it('copies and links complete Markdown while the page table is filtered', async () => { + const content = document.createElement('div') + content.className = 'VPDoc' + const values = Array.from({ length: 10 }, (_, index) => `service-${index}`) + const markdown = `| Service |\n| --- |\n${values.map(value => `| ${value} |\n`).join('')}` + content.innerHTML = `
${values.map(value => ``).join('')}
Service
${value}
` + for (const cell of content.querySelectorAll('td')) { + Object.defineProperty(cell, 'innerText', { get: () => cell.textContent }) + } + document.body.append(content) + try { + fetchMock.mockResolvedValueOnce(response(markdown)) + await mount() + fireEvent.input(getByRole(content, 'searchbox'), { target: { value: 'service-9' } }) + expect(content.querySelectorAll('tbody tr:not([hidden])')).toHaveLength(1) + fireEvent.click(getByRole(host, 'button', { name: 'Copy page' })) + await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') }) + assert(copied) + expect(await readBlob(copied)).toBe(markdown) + await openMenu() + expect(getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' }).getAttribute('href')) + .toBe('/en/guide/quickstart.md') + } finally { + content.remove() + } + }) + it.each(['network', '404', 'html'])('reports a %s response failure without success and allows retry', async (failure) => { if (failure === 'network') fetchMock.mockRejectedValueOnce(new Error('offline')) else fetchMock.mockResolvedValueOnce(response('not Markdown', failure === '404' ? 404 : 200, failure === 'html' ? 'text/html' : 'text/markdown')) diff --git a/website/tests/table-filter.spec.ts b/website/tests/table-filter.spec.ts new file mode 100644 index 0000000000..fa707073e3 --- /dev/null +++ b/website/tests/table-filter.spec.ts @@ -0,0 +1,249 @@ +/** @vitest-environment jsdom */ +/** Independent table filtering, localized controls, and layout resource ownership. */ +import assert from 'node:assert/strict' +import { fireEvent, getByRole, queryAllByRole } from '@testing-library/dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DocumentTables } from '../.vitepress/theme/table-filter.ts' + +let tables: DocumentTables | undefined +let article: HTMLElement +let language: string +let frameId: number +const frames = new Map() +const observers: ResizeObserverFixture[] = [] + +class ResizeObserverFixture implements ResizeObserver { + readonly observe = vi.fn<(target: Element) => void>() + readonly unobserve = vi.fn<(target: Element) => void>() + readonly disconnect = vi.fn<() => void>() + constructor(private readonly callback: ResizeObserverCallback) { observers.push(this) } + notify(): void { this.callback([], this) } +} + +beforeEach(() => { + language = 'en-US' + frameId = 0 + document.body.innerHTML = '
' + const content = document.querySelector('.vp-doc') + assert(content) + article = content + vi.stubGlobal('ResizeObserver', ResizeObserverFixture) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.set(++frameId, callback) + return frameId + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => { frames.delete(id) }) +}) + +afterEach(() => { + tables?.dispose() + tables = undefined + frames.clear() + observers.length = 0 + document.body.replaceChildren() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function renderTable(count = 10) { + const section = document.createElement('section') + const table = document.createElement('table') + table.innerHTML = 'ServicePackageDescription' + const body = table.tBodies[0] + assert(body) + for (let index = 0; index < count; index++) { + const row = body.insertRow() + row.innerHTML = index === 0 + ? 'ctx.llmllm-deepseekStreams completions' + : `service-${index}package-${index}说明 ${index}` + for (const cell of row.cells) { + // jsdom has no layout-backed innerText; these fixtures contain only visible text. + Object.defineProperty(cell, 'innerText', { get: () => cell.textContent }) + } + } + section.append(table) + article.append(section) + const rows = Array.from(body.rows) + return { section, table, body, rows } +} + +function install(): DocumentTables { + tables = new DocumentTables(document, () => language) + return tables +} + +function filter(section: HTMLElement, value: string): HTMLInputElement { + const input = getByRole(section, 'searchbox') + fireEvent.input(input, { target: { value } }) + return input +} + +function visible(table: HTMLTableElement): HTMLTableRowElement[] { + return Array.from(table.rows).filter(row => row.parentElement === table.tBodies[0] && !row.hidden) +} + +function paint(): void { + const callbacks = Array.from(frames.values()) + frames.clear() + for (const callback of callbacks) callback(0) +} + +describe('documentation table filtering', () => { + it('enhances article tables from ten data rows, excluding short tables and search excerpts', () => { + const short = renderTable(9) + const long = renderTable(10) + const longer = renderTable(11) + const excerpt = long.section.cloneNode(true) + document.body.append(excerpt) + install() + expect(queryAllByRole(short.section, 'searchbox')).toHaveLength(0) + expect(queryAllByRole(long.section, 'searchbox')).toHaveLength(1) + expect(queryAllByRole(longer.section, 'searchbox')).toHaveLength(1) + expect(queryAllByRole(document.body, 'searchbox')).toHaveLength(2) + expect(visible(long.table)).toEqual(long.rows) + }) + + it.each([ + [' CTX.LLM ', 0], ['LLM-DEEPSEEK', 0], ['streams', 0], ['说明 9', 9], + ])('matches visible text across columns for %s', (query, index) => { + const fixture = renderTable() + install() + filter(fixture.section, query) + expect(visible(fixture.table)).toEqual([fixture.rows[index]]) + expect(fixture.body.rows).toHaveLength(10) + }) + + it('retains cell content and links and restores row order and input focus on clear', () => { + const fixture = renderTable() + const content = fixture.table.textContent + const link = getByRole(fixture.table, 'link', { name: 'llm-deepseek' }) + install() + expect(queryAllByRole(fixture.section, 'button')).toHaveLength(0) + const input = filter(fixture.section, 'unmatched') + expect(visible(fixture.table)).toHaveLength(0) + expect(getByRole(fixture.section, 'status').textContent).toBe('No matching rows.') + expect(fixture.table.textContent).toBe(content) + const clear = getByRole(fixture.section, 'button', { name: 'Clear' }) + expect(clear.textContent).toBe('×') + clear.focus() + clear.click() + expect(visible(fixture.table)).toEqual(fixture.rows) + expect(getByRole(fixture.table, 'link', { name: 'llm-deepseek' })).toBe(link) + expect(link.getAttribute('href')).toBe('/reference/llm') + expect(document.activeElement).toBe(input) + expect(input.value).toBe('') + expect(clear.hidden).toBe(true) + expect(getByRole(fixture.section, 'status').textContent).toBe('') + filter(fixture.section, ' ') + expect(visible(fixture.table)).toEqual(fixture.rows) + expect(clear.hidden).toBe(false) + }) + + it.each(['en-US', 'zh-CN'])('renders accessible controls and an empty result in %s', async (locale) => { + language = locale + const { section } = renderTable() + install() + filter(section, 'unmatched') + const controls = section.firstElementChild + assert(controls) + await expect(`${controls.outerHTML}\n`).toMatchFileSnapshot(`./expected/table-filter.${locale}.html`) + }) + + it('keeps queries isolated across tables and preserves them through repeated content and locale refreshes', () => { + const first = renderTable() + const second = renderTable() + const owner = install() + const input = filter(first.section, 'unmatched') + filter(second.section, 'package-9') + language = 'zh-CN' + owner.refresh() + owner.refresh() + expect(getByRole(first.section, 'searchbox', { name: '筛选表格' })).toBe(input) + expect(getByRole(first.section, 'status').textContent).toBe('没有匹配的行。') + expect(queryAllByRole(article, 'searchbox')).toHaveLength(2) + getByRole(first.section, 'button', { name: '清空' }).click() + expect(visible(first.table)).toEqual(first.rows) + expect(visible(second.table)).toEqual([second.rows[9]]) + }) + + it('drops removed-page controls and starts replacement-page tables without a query', () => { + const previous = renderTable() + const owner = install() + const input = filter(previous.section, 'package-9') + article.replaceChildren() + const next = renderTable() + owner.refresh() + expect(queryAllByRole(previous.section, 'searchbox')).toHaveLength(0) + expect(visible(previous.table)).toEqual(previous.rows) + expect(getByRole(next.section, 'searchbox').value).toBe('') + expect(visible(next.table)).toEqual(next.rows) + fireEvent.input(input, { target: { value: 'unmatched' } }) + expect(visible(previous.table)).toEqual(previous.rows) + }) + + it('skips layout work on pages without long tables', () => { + renderTable(9) + install() + fireEvent.scroll(window) + fireEvent.resize(window) + observers[0]?.notify() + expect(frames.size).toBe(0) + }) + + it('keeps the header below navigation and above the horizontal scrollbar after scroll, resize, and filtering', () => { + const { table, section } = renderTable() + const head = table.tHead + assert(head) + const nav = document.createElement('header') + nav.className = 'VPNav' + document.body.prepend(nav) + const layout = { top: 200, height: 500, nav: 64 } + vi.spyOn(nav, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, 0, 800, layout.nav)) + vi.spyOn(table, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, layout.top, 600, layout.height + 15)) + vi.spyOn(table, 'clientHeight', 'get').mockImplementation(() => layout.height) + vi.spyOn(head, 'offsetHeight', 'get').mockReturnValue(40) + install() + paint() + expect(head.style.getPropertyValue('--dsh-table-header-offset')).toBe('0px') + layout.top = -120 + fireEvent.scroll(window) + fireEvent.scroll(window) + expect(frames.size).toBe(1) + paint() + expect(head.style.getPropertyValue('--dsh-table-header-offset')).toBe('184px') + layout.nav = 48 + fireEvent.resize(window) + paint() + expect(head.style.getPropertyValue('--dsh-table-header-offset')).toBe('168px') + layout.top = -450 + observers[0]?.notify() + paint() + expect(head.style.getPropertyValue('--dsh-table-header-offset')).toBe('460px') + layout.height = 40 + filter(section, 'unmatched') + paint() + expect(head.style.getPropertyValue('--dsh-table-header-offset')).toBe('0px') + }) + + it('restores initial visibility and stops pending frames, input handlers, and layout subscriptions on disposal', () => { + const { section, table, rows } = renderTable() + const first = rows[0] + assert(first) + first.hidden = true + const owner = install() + const input = filter(section, 'unmatched') + expect(frames.size).toBe(1) + owner.dispose() + expect(frames.size).toBe(0) + expect(queryAllByRole(section, 'searchbox')).toHaveLength(0) + expect(table.className).toBe('') + expect(table.tHead?.style.getPropertyValue('--dsh-table-header-offset')).toBe('') + expect(visible(table)).toEqual(rows.slice(1)) + expect(observers[0]?.disconnect).toHaveBeenCalledOnce() + fireEvent.input(input, { target: { value: 'unmatched' } }) + fireEvent.scroll(window) + fireEvent.resize(window) + expect(frames.size).toBe(0) + expect(visible(table)).toEqual(rows.slice(1)) + }) +})