Merge pull request #6102 from deepseek-harness/docs/long-table-filter
feat(docs): 为长表增加搜索、清空和固定表头
This commit is contained in:
commit
b33271247b
10 files changed
+614
-8
No files matched your search
+1
-1
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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'))
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user