From eb59250ce9f47e8b7dbca58b8e99da84984d65ee Mon Sep 17 00:00:00 2001 From: gengruilin Date: Tue, 15 Sep 2026 16:52:46 +0800 Subject: [PATCH] feat(docs): present Markdown actions in a split-button menu --- ...09-15-docs-page-markdown-actions.i18n.yaml | 4 +- .../2026-09-15-docs-page-markdown-actions.md | 2 + ...026-09-15-docs-page-markdown-actions.zh.md | 2 + .../theme/page-markdown-actions.css | 128 +++++++++++++--- .../.vitepress/theme/page-markdown-actions.ts | 138 ++++++++++++++--- .../expected/page-markdown-actions.en-US.html | 2 +- .../expected/page-markdown-actions.zh-CN.html | 2 +- .../expected/page-markdown-menu.en-US.html | 1 + .../expected/page-markdown-menu.zh-CN.html | 1 + website/tests/page-markdown-actions.spec.ts | 140 ++++++++++++++++-- 10 files changed, 364 insertions(+), 56 deletions(-) create mode 100644 website/tests/expected/page-markdown-menu.en-US.html create mode 100644 website/tests/expected/page-markdown-menu.zh-CN.html diff --git a/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.i18n.yaml index 08397a9489..cf809b41f5 100644 --- a/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md -2026-09-15-docs-page-markdown-actions.md: cd8791f9491a0cbbe653d09753a4c0c7594970f4 -2026-09-15-docs-page-markdown-actions.zh.md: 211c0c069e4a4c8556458db2d4d226426f419af5 +2026-09-15-docs-page-markdown-actions.md: c7b250d2b5b8285d947f9a5308e938119cc59064 +2026-09-15-docs-page-markdown-actions.zh.md: a9af5150649407efb30de02cc227ce40e77b5fd6 diff --git a/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md b/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md index cd8791f949..c7b250d2b5 100644 --- a/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md +++ b/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md @@ -12,6 +12,8 @@ Readers need a visible way to obtain one documentation page as Markdown. The pub The [projector](../../../../scripts/project-doc-site.ts) supplies ordinary content pages with a `rawMarkdownPath` from the publication manifest. The [theme](../../../../website/.vitepress/theme/index.ts) combines that path with the site base and exposes copy and view actions above the document. Directory pages use their full `index.md` route, which preserves the same relative-link location in development and static builds. Redirect homes and missing pages have no actions. Raw output excludes projection metadata. +The primary button copies the page directly. Its adjacent toggle opens a menu with icons, action titles, and explanatory text, keeping the common action visible while grouping Markdown options. Menu items support arrow keys, Home, End, and activation; Escape restores toggle focus, while outside pointers and focus leaving dismiss the menu. Menu state and its outside-pointer listener belong to the page instance. + The [development middleware](../../../../website/raw-markdown.ts) admits explicitly marked `?dsh-raw=1` browser fetches and returns 404 for unpublished raw routes. Script imports always reach Vite. The view link opens the ordinary raw URL in a new tab; copy reads that same projected body on demand. Emitted Markdown files carry a UTF-8 BOM because static hosts can omit the response charset, causing direct browser navigation to misdecode Chinese and other non-ASCII characters. Fetch decoding removes the BOM before copying. Neither operation reconstructs Markdown from the rendered DOM. The [copy component](../../../../website/.vitepress/theme/page-markdown-actions.ts) calls `clipboard.write` within the click gesture and supplies a promise-backed `text/plain` ClipboardItem. Awaiting the network first would lose user activation in browsers that require it. Each route and language gets its own keyed component instance; disposal aborts unfinished data reads and prevents old writes from changing new-page feedback. A system clipboard write that has already consumed its data cannot be withdrawn. Request and clipboard failures provide localized manual-copy guidance, and success follows the completed write. Denied writes may never consume their data promise, so that promise has its own rejection observer. diff --git a/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.zh.md b/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.zh.md index 211c0c069e..a9af515064 100644 --- a/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.zh.md +++ b/.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.zh.md @@ -12,6 +12,8 @@ Status: implemented [投影器](../../../../scripts/project-doc-site.ts) 根据发布 manifest(元数据清单)为普通内容页提供 `rawMarkdownPath`。[主题](../../../../website/.vitepress/theme/index.ts) 将该路径与站点 base 组合,在正文上方提供复制和查看操作。目录页使用完整的 `index.md` 路由,使相对链接在开发环境和静态构建中保持相同的位置。首页重定向和缺失页面不显示操作。原文输出不包含投影元数据。 +主按钮直接复制页面,旁边的展开按钮打开带图标、操作标题和说明文字的菜单,使常用操作保持可见,并将 Markdown 选项放在一起。菜单项支持方向键、Home、End 和激活操作;Escape 将焦点恢复到展开按钮,外部指针操作和焦点离开会关闭菜单。菜单状态及其外部指针监听器归属于页面实例。 + [开发中间件](../../../../website/raw-markdown.ts) 接受带有显式 `?dsh-raw=1` 标记的浏览器请求,对未发布的原文路由返回 404。脚本导入始终交给 Vite。查看链接在新标签页打开普通原文 URL;复制按需读取相同的投影正文。生成的 Markdown 文件携带 UTF-8 BOM,因为静态托管可能省略响应字符集,导致浏览器直接访问时错误解码中文等非 ASCII 字符。Fetch 解码会在复制前移除 BOM。两种操作都不从渲染后的 DOM 重建 Markdown。 [复制组件](../../../../website/.vitepress/theme/page-markdown-actions.ts) 在点击手势内调用 `clipboard.write`,并提供由 Promise 承载数据的 `text/plain` ClipboardItem。先等待网络会在要求用户激活的浏览器中丢失该激活状态。每个路由和语言组合都有独立的、带 key 的组件实例;释放时取消未完成的数据读取,防止旧写入改变新页面的反馈。已经消费数据的系统剪贴板写入无法撤回。请求失败和剪贴板失败提供本地化的手动复制指引,成功提示在写入完成后显示。被拒绝的写入可能完全不消费数据 Promise,因此该 Promise 有独立的拒绝处理器。 diff --git a/website/.vitepress/theme/page-markdown-actions.css b/website/.vitepress/theme/page-markdown-actions.css index ff102c0f1c..ff46ccafc6 100644 --- a/website/.vitepress/theme/page-markdown-actions.css +++ b/website/.vitepress/theme/page-markdown-actions.css @@ -1,45 +1,131 @@ .page-markdown-actions { + display: flex; + flex-direction: column; + align-items: flex-end; margin-bottom: 16px; - font-size: 13px; + font-size: 14px; line-height: 1.5; - color: var(--vp-c-text-2); + color: var(--vp-c-text-1); +} + +.page-markdown-actions-dropdown { + position: relative; } .page-markdown-actions-controls { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 4px 12px; -} - -.page-markdown-actions-controls :is(button, a) { display: inline-flex; - align-items: center; - gap: 4px; - min-height: 36px; - padding: 4px 8px; border: 1px solid var(--vp-c-divider); - border-radius: 6px; - color: var(--vp-c-text-2); + border-radius: 12px; background: var(--vp-c-bg); } -.page-markdown-actions-controls :is(button, a):hover { - color: var(--vp-c-brand-1); - border-color: var(--vp-c-brand-1); +.page-markdown-actions-controls button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; } -.page-markdown-actions-controls :is(button, a):focus-visible { +.page-markdown-copy { + gap: 8px; + padding: 6px 12px; + border-radius: 11px 0 0 11px; + font-weight: 500; +} + +.page-markdown-toggle { + width: 36px; + border-left: 1px solid var(--vp-c-divider); + border-radius: 0 11px 11px 0; + color: var(--vp-c-text-2); +} + +.page-markdown-toggle[aria-expanded="true"] .page-markdown-icon { + transform: rotate(180deg); +} + +.page-markdown-icon { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.page-markdown-actions :is(button, a):focus-visible { outline: 2px solid var(--vp-c-brand-1); - outline-offset: 3px; + outline-offset: 2px; } -.page-markdown-actions-controls button:disabled { +.page-markdown-actions-controls button:hover, +.page-markdown-menu > :is(button, a):is(:hover, :focus) { + background: var(--vp-c-default-soft); +} + +.page-markdown-actions [disabled], +.page-markdown-actions [aria-disabled="true"] { cursor: wait; opacity: 0.65; } +.page-markdown-menu { + position: absolute; + z-index: 30; + top: calc(100% + 6px); + right: 0; + width: min(360px, calc(100vw - 48px)); + padding: 6px; + border: 1px solid var(--vp-c-divider); + border-radius: 18px; + background: var(--vp-c-bg); + box-shadow: 0 8px 24px rgb(0 0 0 / 8%); +} + +.page-markdown-menu > :is(button, a) { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px; + border-radius: 12px; + text-align: left; +} + +.page-markdown-menu-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + flex-shrink: 0; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; +} + +.page-markdown-menu-text { + min-width: 0; +} + +.page-markdown-menu-title { + display: flex; + align-items: center; + gap: 4px; + font-weight: 600; +} + +.page-markdown-icon-external { + width: 14px; + height: 14px; +} + +.page-markdown-menu-description { + display: block; + color: var(--vp-c-text-2); + font-size: 13px; + line-height: 20px; +} + .page-markdown-actions-status { + color: var(--vp-c-text-2); + font-size: 13px; text-align: right; } diff --git a/website/.vitepress/theme/page-markdown-actions.ts b/website/.vitepress/theme/page-markdown-actions.ts index 589125d983..df8e03fc6a 100644 --- a/website/.vitepress/theme/page-markdown-actions.ts +++ b/website/.vitepress/theme/page-markdown-actions.ts @@ -1,28 +1,49 @@ /** Current-page Markdown actions; each mounted instance owns one page's copy request. */ -import { defineComponent, h, onBeforeUnmount, ref } from 'vue' +import { defineComponent, h, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue' type CopyState = 'idle' | 'copying' | 'copied' | 'requestFailed' | 'clipboardFailed' const messages = { en: { - copy: 'Copy Markdown', - view: 'View Markdown', - newTab: 'View Markdown (opens in a new tab)', + copy: 'Copy page', + copyDescription: 'Copy page as Markdown for LLMs', + view: 'View as Markdown', + viewDescription: 'View this page as plain text', + newTab: 'View as Markdown (opens in a new tab)', + menu: 'Page actions', + more: 'More page actions', copying: 'Copying…', copied: 'Markdown copied.', - requestFailed: 'Could not load Markdown. Open View Markdown to copy it manually.', - clipboardFailed: 'Could not copy. Open View Markdown to copy it manually.', + requestFailed: 'Could not load Markdown. Open the page actions menu and choose View as Markdown to copy it manually.', + clipboardFailed: 'Could not copy. Open the page actions menu and choose View as Markdown to copy it manually.', }, zh: { - copy: '复制 Markdown', - view: '查看 Markdown', - newTab: '查看 Markdown(在新标签页打开)', + copy: '复制页面', + copyDescription: '将页面以 Markdown 格式复制给 LLMs', + view: '以 Markdown 格式查看', + viewDescription: '以纯文本查看此页面', + newTab: '以 Markdown 格式查看(在新标签页打开)', + menu: '页面操作', + more: '更多页面操作', copying: '正在复制…', copied: '已复制 Markdown。', - requestFailed: '无法加载 Markdown,请打开“查看 Markdown”手动复制。', - clipboardFailed: '复制失败,请打开“查看 Markdown”手动复制。', + requestFailed: '无法加载 Markdown,请打开页面操作菜单,选择“以 Markdown 格式查看”后手动复制。', + clipboardFailed: '复制失败,请打开页面操作菜单,选择“以 Markdown 格式查看”后手动复制。', }, -} satisfies Record<'en' | 'zh', Record | 'copy' | 'view' | 'newTab', string>> +} satisfies Record<'en' | 'zh', Record | 'copy' | 'copyDescription' | 'view' | 'viewDescription' | 'newTab' | 'menu' | 'more', string>> + +const icons = { + copy: 'M9 8h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2ZM16 4V3a1 1 0 0 0-1-1H5a2 2 0 0 0-2 2v11', + markdown: 'M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm2 12V8l3 4 3-4v8m5-8v8m-2-2 2 2 2-2', + chevron: 'm6 9 6 6 6-6', + external: 'M7 17 17 7M7 7h10v10', +} + +function icon(name: keyof typeof icons) { + return h('svg', { class: `page-markdown-icon page-markdown-icon-${name}`, viewBox: '0 0 24 24', fill: 'none', + stroke: 'currentColor', 'stroke-width': 1.7, 'stroke-linecap': 'round', 'stroke-linejoin': 'round', + 'aria-hidden': 'true', focusable: 'false' }, [h('path', { d: icons[name] })]) +} /** * Actions keyed by the owning layout to the current route and language. @@ -36,15 +57,60 @@ export const PageMarkdownActions = defineComponent({ }, setup(props) { const state = ref('idle') + const open = ref(false) + const root = ref() + const toggle = ref() + const menuCopy = ref() + const menuView = ref() + const menuId = `page-markdown-${useId()}` + + function closeMenu(restoreFocus = false) { + open.value = false + if (restoreFocus) toggle.value?.focus() + } + + function openMenu(last = false) { + open.value = true + void nextTick(() => { + if (open.value) (last ? menuView.value : menuCopy.value)?.focus() + }) + } + + watch(open, (visible, _previous, onCleanup) => { + if (!visible) return + const dismiss = (event: PointerEvent) => { + if (event.target instanceof Node && !root.value?.contains(event.target)) closeMenu() + } + document.addEventListener('pointerdown', dismiss, true) + onCleanup(() => { document.removeEventListener('pointerdown', dismiss, true) }) + }) + + function menuKeydown(event: KeyboardEvent) { + const items = [menuCopy.value, menuView.value] + const index = items.findIndex(item => item === event.target) + const movement: Partial> = { + ArrowDown: (index + 1) % items.length, ArrowUp: (index + items.length - 1) % items.length, + Home: 0, End: items.length - 1 } + const target = movement[event.key] + if (target !== undefined) { + event.preventDefault() + items[target]?.focus() + } else if (event.key === ' ' && event.target === menuView.value) { + event.preventDefault() + menuView.value.click() + } + } let controller: AbortController | undefined let disposed = false onBeforeUnmount(() => { disposed = true + open.value = false controller?.abort() }) async function copy(): Promise { if (state.value === 'copying') return + if (open.value) closeMenu(true) state.value = 'copying' const clipboard: Partial | undefined = (navigator as Partial).clipboard if (typeof ClipboardItem === 'undefined' || clipboard?.write === undefined) { @@ -85,13 +151,49 @@ export const PageMarkdownActions = defineComponent({ return () => { const text = messages[props.lang.startsWith('zh') ? 'zh' : 'en'] - return h('div', { class: 'page-markdown-actions' }, [ - h('div', { class: 'page-markdown-actions-controls' }, [ - h('button', { type: 'button', disabled: state.value === 'copying', onClick: copy }, - state.value === 'copying' ? text.copying : text.copy), - h('a', { href: props.path, target: '_blank', rel: 'noopener', 'aria-label': text.newTab }, [ - text.view, h('span', { 'aria-hidden': 'true' }, ' ↗'), + return h('div', { + ref: root, class: 'page-markdown-actions', + onFocusout: (event: FocusEvent) => { + if (!(event.relatedTarget instanceof Node) || !root.value?.contains(event.relatedTarget)) closeMenu() + }, + onKeydown: (event: KeyboardEvent) => { + if (open.value && event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + closeMenu(true) + } + }, + }, [ + h('div', { class: 'page-markdown-actions-dropdown' }, [ + h('div', { class: 'page-markdown-actions-controls' }, [ + h('button', { class: 'page-markdown-copy', type: 'button', disabled: state.value === 'copying', onClick: copy }, + [icon('copy'), state.value === 'copying' ? text.copying : text.copy]), + h('button', { ref: toggle, class: 'page-markdown-toggle', type: 'button', 'aria-label': text.more, + 'aria-haspopup': 'menu', 'aria-expanded': open.value, 'aria-controls': menuId, + onClick: () => { if (open.value) closeMenu(); else openMenu() }, + onKeydown: (event: KeyboardEvent) => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + openMenu(event.key === 'ArrowUp') + } + }, + }, [icon('chevron')]), ]), + open.value ? h('div', { id: menuId, role: 'menu', 'aria-label': text.menu, + class: 'page-markdown-menu', onKeydown: menuKeydown }, [ + h('button', { ref: menuCopy, role: 'menuitem', type: 'button', tabindex: -1, + 'aria-label': text.copy, 'aria-describedby': `${menuId}-copy`, 'aria-disabled': state.value === 'copying', onClick: copy }, [ + h('span', { class: 'page-markdown-menu-icon' }, [icon('copy')]), + h('span', { class: 'page-markdown-menu-text' }, [h('span', { class: 'page-markdown-menu-title' }, text.copy), + h('span', { id: `${menuId}-copy`, class: 'page-markdown-menu-description' }, text.copyDescription)]), + ]), + h('a', { ref: menuView, role: 'menuitem', tabindex: -1, href: props.path, target: '_blank', rel: 'noopener', + 'aria-label': text.newTab, 'aria-describedby': `${menuId}-view`, onClick: () => { closeMenu(true) } }, [ + h('span', { class: 'page-markdown-menu-icon' }, [icon('markdown')]), + h('span', { class: 'page-markdown-menu-text' }, [h('span', { class: 'page-markdown-menu-title' }, [text.view, icon('external')]), + h('span', { id: `${menuId}-view`, class: 'page-markdown-menu-description' }, text.viewDescription)]), + ]), + ]) : null, ]), h('p', { class: 'page-markdown-actions-status', role: 'status', 'aria-atomic': 'true' }, state.value === 'idle' ? '' : text[state.value]), diff --git a/website/tests/expected/page-markdown-actions.en-US.html b/website/tests/expected/page-markdown-actions.en-US.html index 98f5af8ee9..c84b518c59 100644 --- a/website/tests/expected/page-markdown-actions.en-US.html +++ b/website/tests/expected/page-markdown-actions.en-US.html @@ -1 +1 @@ -
View Markdown

+

diff --git a/website/tests/expected/page-markdown-actions.zh-CN.html b/website/tests/expected/page-markdown-actions.zh-CN.html index 3eee898e40..0c01c7f0be 100644 --- a/website/tests/expected/page-markdown-actions.zh-CN.html +++ b/website/tests/expected/page-markdown-actions.zh-CN.html @@ -1 +1 @@ -
查看 Markdown

+

diff --git a/website/tests/expected/page-markdown-menu.en-US.html b/website/tests/expected/page-markdown-menu.en-US.html new file mode 100644 index 0000000000..a2a869da2e --- /dev/null +++ b/website/tests/expected/page-markdown-menu.en-US.html @@ -0,0 +1 @@ +

diff --git a/website/tests/expected/page-markdown-menu.zh-CN.html b/website/tests/expected/page-markdown-menu.zh-CN.html new file mode 100644 index 0000000000..4bf39a03eb --- /dev/null +++ b/website/tests/expected/page-markdown-menu.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 eb746ec1b2..21c31d9a7a 100644 --- a/website/tests/page-markdown-actions.spec.ts +++ b/website/tests/page-markdown-actions.spec.ts @@ -50,6 +50,13 @@ function mount() { app.mount(host) } +async function openMenu() { + const toggle = getByRole(host, 'button', { name: 'More page actions' }) + fireEvent.click(toggle) + await nextTick() + return toggle +} + beforeEach(() => { host = document.createElement('div') document.body.append(host) @@ -89,14 +96,120 @@ describe('page Markdown actions', () => { data.frontmatter.value = { rawMarkdownPath: `${lang === 'en-US' ? 'en/' : ''}guide/quickstart.md` } mount() await expect(`${host.innerHTML}\n`).toMatchFileSnapshot(`./expected/page-markdown-actions.${lang}.html`) + fireEvent.click(getByRole(host, 'button', { name: lang === 'en-US' ? 'More page actions' : '更多页面操作' })) + await nextTick() + await expect(`${host.innerHTML}\n`).toMatchFileSnapshot(`./expected/page-markdown-menu.${lang}.html`) }) - it.each(['/', '/deepseek-harness/'])('uses the manifest index route under base %s, independent of the visible URL', (base) => { + it('opens from the keyboard, moves between actions, and restores focus on Escape', async () => { + mount() + const toggle = getByRole(host, 'button', { name: 'More page actions' }) + toggle.focus() + fireEvent.keyDown(toggle, { key: 'ArrowDown' }) + await nextTick() + const copy = getByRole(host, 'menuitem', { name: 'Copy page' }) + const view = getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' }) + expect(document.activeElement).toBe(copy) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(copy, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(view) + fireEvent.keyDown(view, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(copy) + fireEvent.keyDown(copy, { key: 'End' }) + expect(document.activeElement).toBe(view) + fireEvent.keyDown(view, { key: 'Home' }) + expect(document.activeElement).toBe(copy) + fireEvent.keyDown(copy, { key: 'Escape' }) + await nextTick() + expect(queryByRole(host, 'menu')).toBeNull() + expect(document.activeElement).toBe(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + fireEvent.keyDown(toggle, { key: 'ArrowUp' }) + await nextTick() + expect(document.activeElement).toBe(getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' })) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('dismisses on an outside pointer, focus leaving, and a second toggle click', async () => { + mount() + const toggle = await openMenu() + fireEvent.pointerDown(document.body) + await nextTick() + expect(queryByRole(host, 'menu')).toBeNull() + await openMenu() + fireEvent.focusOut(getByRole(host, 'menuitem', { name: 'Copy page' }), { relatedTarget: document.body }) + await nextTick() + expect(queryByRole(host, 'menu')).toBeNull() + await openMenu() + fireEvent.click(toggle) + await nextTick() + expect(queryByRole(host, 'menu')).toBeNull() + }) + + it('copies from the menu in the same click and prevents another copy while it is pending', async () => { + const pending = deferred() + fetchMock.mockReturnValueOnce(pending.promise) + mount() + const toggle = await openMenu() + fireEvent.click(getByRole(host, 'menuitem', { name: 'Copy page' })) + expect(write).toHaveBeenCalledOnce() + await nextTick() + expect(queryByRole(host, 'menu')).toBeNull() + expect(document.activeElement).toBe(toggle) + await openMenu() + const copy = getByRole(host, 'menuitem', { name: 'Copy page' }) + expect(copy.getAttribute('aria-disabled')).toBe('true') + fireEvent.click(copy) + expect(write).toHaveBeenCalledOnce() + expect(getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' }).getAttribute('href')).toBe('/en/guide/quickstart.md') + pending.resolve(response()) + await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') }) + }) + + it('opens the raw link from the menu and returns focus to the toggle', async () => { + mount() + const toggle = await openMenu() + const view = getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' }) + view.addEventListener('click', (event) => { event.preventDefault() }, { once: true }) + fireEvent.keyDown(view, { key: ' ' }) + await nextTick() + expect(queryByRole(host, 'menu')).toBeNull() + expect(document.activeElement).toBe(toggle) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('removes outside-pointer listeners and closes its menu when the route changes', async () => { + mount() + const add = vi.spyOn(document, 'addEventListener') + const remove = vi.spyOn(document, 'removeEventListener') + try { + await openMenu() + const listener = add.mock.calls.find(([name]) => name === 'pointerdown')?.[1] + expect(listener).toBeTypeOf('function') + route.path = '/en/reference/' + data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' } + await nextTick() + expect(queryByRole(host, 'menu')).toBeNull() + expect(remove).toHaveBeenCalledWith('pointerdown', listener, true) + await openMenu() + app?.unmount() + app = undefined + expect(remove.mock.calls.filter(([name]) => name === 'pointerdown')).toHaveLength(2) + fireEvent.pointerDown(document.body) + expect(host.textContent).toBe('') + } finally { + add.mockRestore() + remove.mockRestore() + } + }) + + it.each(['/', '/deepseek-harness/'])('uses the manifest index route under base %s, independent of the visible URL', async (base) => { data.site.value = { base } data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' } route.path = `${base}en/reference/index.html?from=nav#api` mount() - const link = getByRole(host, 'link', { name: 'View Markdown (opens in a new tab)' }) + await openMenu() + const link = getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' }) expect(link.getAttribute('href')).toBe(`${base}en/reference/index.md`) expect(link.getAttribute('target')).toBe('_blank') expect(fetchMock).not.toHaveBeenCalled() @@ -114,7 +227,7 @@ describe('page Markdown actions', () => { const pending = deferred() fetchMock.mockReturnValue(pending.promise) mount() - const button = getByRole(host, 'button', { name: 'Copy Markdown' }) + const button = getByRole(host, 'button', { name: 'Copy page' }) fireEvent.click(button) expect(write).toHaveBeenCalledOnce() expect(copied).toBeUndefined() @@ -140,17 +253,17 @@ describe('page Markdown actions', () => { if (failure === 'network') fetchMock.mockRejectedValueOnce(new Error('offline')) else fetchMock.mockResolvedValueOnce(response('not Markdown', failure === '404' ? 404 : 200, failure === 'html' ? 'text/html' : 'text/markdown')) mount() - fireEvent.click(getByRole(host, 'button')) + fireEvent.click(getByRole(host, 'button', { name: 'Copy page' })) await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not load Markdown.') }) expect(copied).toBeUndefined() - fireEvent.click(getByRole(host, 'button')) + fireEvent.click(getByRole(host, 'button', { name: 'Copy page' })) await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') }) }) it('accepts static hosting that serves Markdown as plain text', async () => { fetchMock.mockResolvedValueOnce(response('# Plain text hosting\n', 200, 'text/plain')) mount() - fireEvent.click(getByRole(host, 'button')) + fireEvent.click(getByRole(host, 'button', { name: 'Copy page' })) await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') }) }) @@ -159,7 +272,7 @@ describe('page Markdown actions', () => { vi.spyOn(raw, 'text').mockRejectedValueOnce(new Error('Connection closed')) fetchMock.mockResolvedValueOnce(raw) mount() - fireEvent.click(getByRole(host, 'button')) + fireEvent.click(getByRole(host, 'button', { name: 'Copy page' })) await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not load Markdown.') }) expect(copied).toBeUndefined() }) @@ -168,7 +281,7 @@ describe('page Markdown actions', () => { const writeDone = deferred() write.mockImplementationOnce(() => writeDone.promise) mount() - fireEvent.click(getByRole(host, 'button')) + fireEvent.click(getByRole(host, 'button', { name: 'Copy page' })) route.path = '/en/reference/' data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' } await nextTick() @@ -182,10 +295,11 @@ describe('page Markdown actions', () => { if (missing === 'item') vi.stubGlobal('ClipboardItem', undefined) else vi.stubGlobal('navigator', {}) mount() - fireEvent.click(getByRole(host, 'button')) + fireEvent.click(getByRole(host, 'button', { name: 'Copy page' })) await nextTick() expect(getByRole(host, 'status').textContent).toContain('Could not copy.') - expect(getByRole(host, 'link')).toBeTruthy() + await openMenu() + expect(getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' })).toBeTruthy() expect(fetchMock).not.toHaveBeenCalled() }) @@ -194,7 +308,7 @@ describe('page Markdown actions', () => { fetchMock.mockReturnValue(pending.promise) write.mockRejectedValueOnce(new DOMException('Denied', 'NotAllowedError')) mount() - fireEvent.click(getByRole(host, 'button')) + fireEvent.click(getByRole(host, 'button', { name: 'Copy page' })) await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not copy.') }) expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(true) pending.reject(new DOMException('Aborted', 'AbortError')) @@ -207,7 +321,7 @@ describe('page Markdown actions', () => { const pending = deferred() fetchMock.mockReturnValueOnce(pending.promise) mount() - fireEvent.click(getByRole(host, 'button')) + fireEvent.click(getByRole(host, 'button', { name: 'Copy page' })) const oldWrite = activeWrite assert(oldWrite !== undefined) const signal = fetchMock.mock.calls[0]?.[1]?.signal @@ -226,7 +340,7 @@ describe('page Markdown actions', () => { if (change === 'unmount') expect(host.textContent).toBe('') else { expect(getByRole(host, 'status').textContent).toBe('') - fireEvent.click(getByRole(host, 'button', { name: change === 'route' ? 'Copy Markdown' : '复制 Markdown' })) + fireEvent.click(getByRole(host, 'button', { name: change === 'route' ? 'Copy page' : '复制页面' })) await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe(change === 'route' ? 'Markdown copied.' : '已复制 Markdown。') }) expect(fetchMock.mock.calls[1]?.[0]).toBe(change === 'route' ? '/en/reference/index.md?dsh-raw=1' : '/guide/quickstart.md?dsh-raw=1') }