From 568f12ea429df2a4dc3051a6a2f6e6930f7d0aea Mon Sep 17 00:00:00 2001 From: gengruilin Date: Tue, 13 Oct 2026 01:53:50 +0800 Subject: [PATCH 1/4] =?UTF-8?q?wip(docs):=20=E4=BF=9D=E5=AD=98=E9=95=BF?= =?UTF-8?q?=E8=A1=A8=E7=AD=9B=E9=80=89=E4=B8=8E=E5=9B=BA=E5=AE=9A=E8=A1=A8?= =?UTF-8?q?=E5=A4=B4=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- scripts/run-gates.ts | 1 + website/.vitepress/theme/index.ts | 12 +- website/.vitepress/theme/table-filter.css | 92 ++++++ website/.vitepress/theme/table-filter.ts | 165 +++++++++++ website/AGENTS.md | 2 + .../tests/expected/table-filter.en-US.html | 1 + .../tests/expected/table-filter.zh-CN.html | 1 + website/tests/page-markdown-actions.spec.ts | 43 ++- website/tests/table-filter.spec.ts | 270 ++++++++++++++++++ 10 files changed, 581 insertions(+), 8 deletions(-) create mode 100644 website/.vitepress/theme/table-filter.css create mode 100644 website/.vitepress/theme/table-filter.ts create mode 100644 website/tests/expected/table-filter.en-US.html create mode 100644 website/tests/expected/table-filter.zh-CN.html create mode 100644 website/tests/table-filter.spec.ts diff --git a/package.json b/package.json index 3b9e5f2b36..1c105de793 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 9c2cf02a10..ac69cfab0a 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -862,6 +862,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..4627416273 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 { 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,10 @@ export default { let viewer: MermaidViewer | undefined let images: ImageViewer | undefined let media: MediaViewer | undefined + let tables: DocumentTables | undefined let disposed = false onMounted(async () => { + tables = new DocumentTables(document, () => lang.value) const [{ installMermaidViewer }, { ImageViewer }, { MediaViewer }] = await Promise.all([ import('./mermaid-viewer.ts'), import('./image-viewer.ts'), import('./media-viewer.ts'), ]) @@ -30,12 +34,16 @@ export default { viewer = installMermaidViewer(document, () => lang.value, media) images = new ImageViewer(document, () => lang.value, media) }) + onContentUpdated(() => { tables?.refresh() }) + watch(lang, () => { tables?.refresh() }, { flush: 'post' }) watch([() => route.path, lang, isDark], () => { viewer?.refresh() images?.refresh() }, { flush: 'post' }) onBeforeUnmount(() => { disposed = true + 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..0fe28ba8f8 --- /dev/null +++ b/website/.vitepress/theme/table-filter.css @@ -0,0 +1,92 @@ +.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: 18px; + transform: rotate(45deg); +} + +.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; +} + +.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..18f07ed242 --- /dev/null +++ b/website/.vitepress/theme/table-filter.ts @@ -0,0 +1,165 @@ +/** Client-only filtering and page-scroll headers for original Markdown tables. */ +const messages = { + en: { filter: 'Filter table', clear: 'Clear', empty: 'No matching rows.' }, + zh: { 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.className = 'vpi-plus' + 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.filter + 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() + } + + positionHeader(top: number): void { + // Horizontal overflow makes the table the sticky scroll container; translate its own head to retain column alignment. + const bounds = this.table.getBoundingClientRect() + const offset = Math.min(Math.max(top - bounds.top, 0), Math.max(bounds.height - this.head.offsetHeight, 0)) + 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.#frame !== undefined) return + this.#frame = window.requestAnimationFrame(() => { + this.#frame = undefined + const navigation = this.doc.querySelectorAll('.VPNav, .VPLocalNav') + const top = Math.max(0, ...Array.from(navigation, element => element.getBoundingClientRect().bottom)) + for (const table of this.#tables.values()) table.positionHeader(top) + }) + } + + /** 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..b1aa0fd028 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. The [focused tests](tests/table-filter.spec.ts) cover controls and lifecycle; verify header alignment in a browser. 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..86725a9b54 --- /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..5137846287 --- /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..aa40e32127 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() {} })) 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) @@ -296,16 +305,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..645b669bf5 --- /dev/null +++ b/website/tests/table-filter.spec.ts @@ -0,0 +1,270 @@ +/** @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.each(['row', 'head'] as const)('refreshes a table whose %s nodes were replaced', (replacement) => { + const fixture = renderTable() + const owner = install() + filter(fixture.section, 'package-9') + if (replacement === 'row') { + const donor = renderTable(1) + const row = donor.rows[0] + assert(row) + fixture.body.firstElementChild?.replaceWith(row) + donor.section.remove() + } else { + fixture.table.deleteTHead() + fixture.table.createTHead().innerHTML = 'Updated heading' + } + owner.refresh() + expect(queryAllByRole(fixture.section, 'searchbox')).toHaveLength(1) + expect(getByRole(fixture.section, 'searchbox').value).toBe('') + expect(visible(fixture.table)).toHaveLength(10) + }) + + it('removes controls when a table becomes short', () => { + const { section, table, rows } = renderTable() + const owner = install() + filter(section, 'unmatched') + table.deleteRow(-1) + owner.refresh() + expect(queryAllByRole(section, 'searchbox')).toHaveLength(0) + expect(visible(table)).toEqual(rows.slice(0, 9)) + expect(table.className).toBe('') + }) + + it('keeps the header below navigation and within the table 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)) + 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)) + }) +}) From 74a58183699c8675d13ce52e065a022814a87c1c Mon Sep 17 00:00:00 2001 From: gengruilin Date: Tue, 13 Oct 2026 11:32:54 +0800 Subject: [PATCH 2/4] =?UTF-8?q?style(docs):=20=E7=BB=9F=E4=B8=80=E8=A1=A8?= =?UTF-8?q?=E6=A0=BC=E6=90=9C=E7=B4=A2=E6=A1=86=E5=8D=A0=E4=BD=8D=E6=96=87?= =?UTF-8?q?=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/.vitepress/theme/table-filter.ts | 2 +- website/tests/expected/table-filter.en-US.html | 2 +- website/tests/expected/table-filter.zh-CN.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/website/.vitepress/theme/table-filter.ts b/website/.vitepress/theme/table-filter.ts index 18f07ed242..365e98214c 100644 --- a/website/.vitepress/theme/table-filter.ts +++ b/website/.vitepress/theme/table-filter.ts @@ -60,7 +60,7 @@ class FilterableTable { } localize(copy: typeof messages.en): void { - this.#input.placeholder = copy.filter + this.#input.placeholder = 'Search...' this.#input.setAttribute('aria-label', copy.filter) this.#clear.setAttribute('aria-label', copy.clear) this.#clear.title = copy.clear diff --git a/website/tests/expected/table-filter.en-US.html b/website/tests/expected/table-filter.en-US.html index 86725a9b54..ad79fe9845 100644 --- a/website/tests/expected/table-filter.en-US.html +++ b/website/tests/expected/table-filter.en-US.html @@ -1 +1 @@ -

No matching rows.

+

No matching rows.

diff --git a/website/tests/expected/table-filter.zh-CN.html b/website/tests/expected/table-filter.zh-CN.html index 5137846287..f500f59037 100644 --- a/website/tests/expected/table-filter.zh-CN.html +++ b/website/tests/expected/table-filter.zh-CN.html @@ -1 +1 @@ -

没有匹配的行。

+

没有匹配的行。

From e4804c305ba397a12291a970526f46d5aece5782 Mon Sep 17 00:00:00 2001 From: gengruilin Date: Tue, 13 Oct 2026 12:01:09 +0800 Subject: [PATCH 3/4] =?UTF-8?q?test(docs):=20=E7=B2=BE=E7=AE=80=E9=95=BF?= =?UTF-8?q?=E8=A1=A8=E7=83=AD=E6=9B=B4=E6=96=B0=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/tests/table-filter.spec.ts | 31 ------------------------------ 1 file changed, 31 deletions(-) diff --git a/website/tests/table-filter.spec.ts b/website/tests/table-filter.spec.ts index 645b669bf5..9a80eae5fc 100644 --- a/website/tests/table-filter.spec.ts +++ b/website/tests/table-filter.spec.ts @@ -181,37 +181,6 @@ describe('documentation table filtering', () => { expect(visible(previous.table)).toEqual(previous.rows) }) - it.each(['row', 'head'] as const)('refreshes a table whose %s nodes were replaced', (replacement) => { - const fixture = renderTable() - const owner = install() - filter(fixture.section, 'package-9') - if (replacement === 'row') { - const donor = renderTable(1) - const row = donor.rows[0] - assert(row) - fixture.body.firstElementChild?.replaceWith(row) - donor.section.remove() - } else { - fixture.table.deleteTHead() - fixture.table.createTHead().innerHTML = 'Updated heading' - } - owner.refresh() - expect(queryAllByRole(fixture.section, 'searchbox')).toHaveLength(1) - expect(getByRole(fixture.section, 'searchbox').value).toBe('') - expect(visible(fixture.table)).toHaveLength(10) - }) - - it('removes controls when a table becomes short', () => { - const { section, table, rows } = renderTable() - const owner = install() - filter(section, 'unmatched') - table.deleteRow(-1) - owner.refresh() - expect(queryAllByRole(section, 'searchbox')).toHaveLength(0) - expect(visible(table)).toEqual(rows.slice(0, 9)) - expect(table.className).toBe('') - }) - it('keeps the header below navigation and within the table after scroll, resize, and filtering', () => { const { table, section } = renderTable() const head = table.tHead From 761d5de69fea2c2c2e3360f2b0b0c8766c545185 Mon Sep 17 00:00:00 2001 From: gengruilin Date: Tue, 13 Oct 2026 13:40:29 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(docs):=20=E4=BF=AE=E5=A4=8D=E9=95=BF?= =?UTF-8?q?=E8=A1=A8=E6=BB=9A=E5=8A=A8=E4=B8=8E=E7=AD=9B=E9=80=89=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/.vitepress/theme/index.ts | 13 ++++++-- website/.vitepress/theme/table-filter.css | 12 +++++-- website/.vitepress/theme/table-filter.ts | 24 +++++++++----- website/AGENTS.md | 2 +- .../tests/expected/table-filter.en-US.html | 2 +- .../tests/expected/table-filter.zh-CN.html | 2 +- website/tests/page-markdown-actions.spec.ts | 33 ++++++++++++++++++- website/tests/table-filter.spec.ts | 16 +++++++-- 8 files changed, 84 insertions(+), 20 deletions(-) diff --git a/website/.vitepress/theme/index.ts b/website/.vitepress/theme/index.ts index 4627416273..36f8c6c053 100644 --- a/website/.vitepress/theme/index.ts +++ b/website/.vitepress/theme/index.ts @@ -1,6 +1,6 @@ /** Default documentation theme with Markdown actions, long-table controls, and image and diagram viewers. */ import DefaultTheme from 'vitepress/theme' -import { onContentUpdated, 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' @@ -23,9 +23,18 @@ export default { 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'), ]) @@ -35,13 +44,13 @@ export default { images = new ImageViewer(document, () => lang.value, media) }) onContentUpdated(() => { tables?.refresh() }) - watch(lang, () => { tables?.refresh() }, { flush: 'post' }) 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() diff --git a/website/.vitepress/theme/table-filter.css b/website/.vitepress/theme/table-filter.css index 0fe28ba8f8..c54f3ab210 100644 --- a/website/.vitepress/theme/table-filter.css +++ b/website/.vitepress/theme/table-filter.css @@ -47,8 +47,8 @@ } .dsh-table-toolbar button span { - font-size: 18px; - transform: rotate(45deg); + font-size: 22px; + line-height: 1; } .dsh-table-toolbar input:focus-visible, @@ -71,6 +71,14 @@ 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; diff --git a/website/.vitepress/theme/table-filter.ts b/website/.vitepress/theme/table-filter.ts index 365e98214c..df4293dd8e 100644 --- a/website/.vitepress/theme/table-filter.ts +++ b/website/.vitepress/theme/table-filter.ts @@ -1,7 +1,7 @@ /** Client-only filtering and page-scroll headers for original Markdown tables. */ const messages = { - en: { filter: 'Filter table', clear: 'Clear', empty: 'No matching rows.' }, - zh: { filter: '筛选表格', clear: '清空', empty: '没有匹配的行。' }, + en: { placeholder: 'Search...', filter: 'Filter table', clear: 'Clear', empty: 'No matching rows.' }, + zh: { placeholder: 'Search...', filter: '筛选表格', clear: '清空', empty: '没有匹配的行。' }, } class FilterableTable { @@ -35,7 +35,7 @@ class FilterableTable { this.#clear = doc.createElement('button') this.#clear.type = 'button' const clearIcon = doc.createElement('span') - clearIcon.className = 'vpi-plus' + clearIcon.textContent = '×' clearIcon.setAttribute('aria-hidden', 'true') this.#clear.append(clearIcon) this.#status = doc.createElement('p') @@ -60,7 +60,7 @@ class FilterableTable { } localize(copy: typeof messages.en): void { - this.#input.placeholder = 'Search...' + this.#input.placeholder = copy.placeholder this.#input.setAttribute('aria-label', copy.filter) this.#clear.setAttribute('aria-label', copy.clear) this.#clear.title = copy.clear @@ -80,10 +80,14 @@ class FilterableTable { this.changed() } - positionHeader(top: number): void { + 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. - const bounds = this.table.getBoundingClientRect() - const offset = Math.min(Math.max(top - bounds.top, 0), Math.max(bounds.height - this.head.offsetHeight, 0)) this.head.style.setProperty('--dsh-table-header-offset', `${offset}px`) } @@ -145,12 +149,14 @@ export class DocumentTables { } #schedule(): void { - if (this.#frame !== undefined) return + 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)) - for (const table of this.#tables.values()) table.positionHeader(top) + const positions = Array.from(this.#tables.values(), table => ({ table, offset: table.measureHeaderOffset(top) })) + for (const { table, offset } of positions) table.positionHeader(offset) }) } diff --git a/website/AGENTS.md b/website/AGENTS.md index b1aa0fd028..1d94386e0e 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -22,4 +22,4 @@ The default-theme extension shares one fullscreen modal and pan/zoom controller 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. The [focused tests](tests/table-filter.spec.ts) cover controls and lifecycle; verify header alignment in a browser. +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 index ad79fe9845..84aceb464e 100644 --- a/website/tests/expected/table-filter.en-US.html +++ b/website/tests/expected/table-filter.en-US.html @@ -1 +1 @@ -

No matching rows.

+

No matching rows.

diff --git a/website/tests/expected/table-filter.zh-CN.html b/website/tests/expected/table-filter.zh-CN.html index f500f59037..f33f7ac5f0 100644 --- a/website/tests/expected/table-filter.zh-CN.html +++ b/website/tests/expected/table-filter.zh-CN.html @@ -1 +1 @@ -

没有匹配的行。

+

没有匹配的行。

diff --git a/website/tests/page-markdown-actions.spec.ts b/website/tests/page-markdown-actions.spec.ts index aa40e32127..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, onContentUpdated() {} })) +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']?.()), } } })) @@ -104,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` } diff --git a/website/tests/table-filter.spec.ts b/website/tests/table-filter.spec.ts index 9a80eae5fc..fa707073e3 100644 --- a/website/tests/table-filter.spec.ts +++ b/website/tests/table-filter.spec.ts @@ -124,7 +124,7 @@ describe('documentation table filtering', () => { 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('') + expect(clear.textContent).toBe('×') clear.focus() clear.click() expect(visible(fixture.table)).toEqual(fixture.rows) @@ -181,7 +181,16 @@ describe('documentation table filtering', () => { expect(visible(previous.table)).toEqual(previous.rows) }) - it('keeps the header below navigation and within the table after scroll, resize, and filtering', () => { + 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) @@ -190,7 +199,8 @@ describe('documentation table filtering', () => { 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)) + 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()