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()