Merge pull request #6102 from deepseek-harness/docs/long-table-filter

feat(docs): 为长表增加搜索、清空和固定表头
This commit is contained in:
Yichen Jiang authored and GitHub committed 2026-10-23 16:34:21 +08:00
commit b33271247b
10 files changed
+614 -8

No files matched your search

+1 -1
View File
@@ -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",
+1
View File
@@ -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',
+19 -2
View File
@@ -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()
+100
View File
@@ -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;
}
}
+171
View File
@@ -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<HTMLTableElement, FilterableTable>()
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<HTMLTableElement>('.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()
}
}
+2
View File
@@ -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.
@@ -0,0 +1 @@
<div class="dsh-table-controls"><div class="dsh-table-toolbar"><input type="search" placeholder="Search..." aria-label="Filter table"><button type="button" aria-label="Clear" title="Clear"><span aria-hidden="true">×</span></button></div><p role="status">No matching rows.</p></div>
@@ -0,0 +1 @@
<div class="dsh-table-controls"><div class="dsh-table-toolbar"><input type="search" placeholder="Search..." aria-label="筛选表格"><button type="button" aria-label="清空" title="清空"><span aria-hidden="true">×</span></button></div><p role="status">没有匹配的行。</p></div>
+69 -5
View File
@@ -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<string | ArrayBuffer | null> {
const reader = new FileReader()
const done = new Promise<string | ArrayBuffer | null>((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 = `<main><div class="vp-doc"><table><thead><tr><th>Service</th></tr></thead><tbody>${values.map(value => `<tr><td>${value}</td></tr>`).join('')}</tbody></table></div></main>`
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'))
+249
View File
@@ -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<number, FrameRequestCallback>()
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 = '<div id="VPContent" class="VPDoc"><main><div class="vp-doc"></div></main></div>'
const content = document.querySelector<HTMLElement>('.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 = '<thead><tr><th>Service</th><th>Package</th><th>Description</th></tr></thead><tbody></tbody>'
const body = table.tBodies[0]
assert(body)
for (let index = 0; index < count; index++) {
const row = body.insertRow()
row.innerHTML = index === 0
? '<td><code>ctx.llm</code></td><td><a href="/reference/llm">llm-deepseek</a></td><td>Streams completions</td>'
: `<td>service-${index}</td><td>package-${index}</td><td>说明 ${index}</td>`
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<HTMLInputElement>(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<HTMLButtonElement>(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<HTMLInputElement>(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))
})
})