Bläddra i källkod

fix(docs): preserve menu activation across pointer focus changes

gengruilin 3 dagar sedan
förälder
incheckning
423e86ff7d

+ 2 - 2
.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: 61ddd46c802201f37fa0974561158f91cfb9de2d
-2026-09-15-docs-page-markdown-actions.zh.md: 2fd373688cce97053779a602352bcb0d1e90bd7b
+2026-09-15-docs-page-markdown-actions.md: ff4002fd69d670b743b7394544cf8486b141b5ce
+2026-09-15-docs-page-markdown-actions.zh.md: 4e2db5e55f47104d1b2f401099648cc1d6a7dc34

+ 1 - 1
.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.md

@@ -12,7 +12,7 @@ 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.
 
-Server-rendered pages expose a raw-Markdown link. Client mounting replaces it with the copy button and menu, so MPA builds and pages without JavaScript retain a usable action. The primary button copies the page directly and keeps keyboard focus during copying through `aria-disabled` and `aria-busy`. 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.
+Server-rendered pages expose a raw-Markdown link. Client mounting replaces it with the copy button and menu, so MPA builds and pages without JavaScript retain a usable action. The primary button copies the page directly and keeps keyboard focus during copying through `aria-disabled` and `aria-busy`. 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 presses preserve focus until click activation, including in browsers that do not focus pressed controls. 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.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-09-15-docs-page-markdown-actions.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 [投影器](../../../../scripts/project-doc-site.ts) 根据发布 manifest(元数据清单)为普通内容页提供 `rawMarkdownPath`。[主题](../../../../website/.vitepress/theme/index.ts) 将该路径与站点 base 组合,在正文上方提供复制和查看操作。目录页使用完整的 `index.md` 路由,使相对链接在开发环境和静态构建中保持相同的位置。首页重定向和缺失页面不显示操作。原文输出不包含投影元数据。
 
-服务端渲染的页面提供原文链接。客户端挂载后将其替换为复制按钮和菜单,使 MPA 构建及未运行 JavaScript 的页面保留可用操作。主按钮直接复制页面,并通过 `aria-disabled` 和 `aria-busy` 在复制期间保留键盘焦点。旁边的展开按钮打开带图标、操作标题和说明文字的菜单,使常用操作保持可见,并将 Markdown 选项放在一起。菜单项支持方向键、Home、End 和激活操作;Escape 将焦点恢复到展开按钮,外部指针操作和焦点离开会关闭菜单。菜单状态及其外部指针监听器归属于页面实例。
+服务端渲染的页面提供原文链接。客户端挂载后将其替换为复制按钮和菜单,使 MPA 构建及未运行 JavaScript 的页面保留可用操作。主按钮直接复制页面,并通过 `aria-disabled` 和 `aria-busy` 在复制期间保留键盘焦点。旁边的展开按钮打开带图标、操作标题和说明文字的菜单,使常用操作保持可见,并将 Markdown 选项放在一起。菜单项支持方向键、Home、End 和激活操作;Escape 将焦点恢复到展开按钮,外部指针操作和焦点离开会关闭菜单。菜单按下时保留焦点直至点击执行,也适用于按下控件时不转移焦点的浏览器。菜单状态及其外部指针监听器归属于页面实例。
 
 [开发中间件](../../../../website/raw-markdown.ts) 接受带有显式 `?dsh-raw=1` 标记的浏览器请求,对未发布的原文路由返回 404。脚本导入始终交给 Vite。查看链接在新标签页打开普通原文 URL;复制按需读取相同的投影正文。生成的 Markdown 文件携带 UTF-8 BOM,因为静态托管可能省略响应字符集,导致浏览器直接访问时错误解码中文等非 ASCII 字符。Fetch 解码会在复制前移除 BOM。两种操作都不从渲染后的 DOM 重建 Markdown。
 

+ 5 - 2
website/.vitepress/theme/page-markdown-actions.ts

@@ -178,7 +178,8 @@ export const PageMarkdownActions = defineComponent({
             [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() },
+              onMousedown: (event: MouseEvent) => { if (open.value) event.preventDefault() },
+              onClick: () => { if (open.value) closeMenu(true); else openMenu() },
               onKeydown: (event: KeyboardEvent) => {
                 if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
                   event.preventDefault()
@@ -188,7 +189,9 @@ export const PageMarkdownActions = defineComponent({
             }, [icon('chevron')]),
           ]),
           open.value ? h('div', { id: menuId, role: 'menu', 'aria-label': text.menu,
-            class: 'page-markdown-menu', onKeydown: menuKeydown }, [
+            class: 'page-markdown-menu', onKeydown: menuKeydown,
+            // Keep focus until click activation in browsers that do not focus pressed controls.
+            onMousedown: (event: MouseEvent) => { event.preventDefault() } }, [
             h('button', { ref: menuCopy, role: 'menuitem', type: 'button', tabindex: -1,
               'aria-label': text.copy, 'aria-describedby': `${menuId}-copy`, 'aria-disabled': state.value === 'copying',
               'aria-busy': state.value === 'copying', onClick: copy }, [

+ 31 - 0
website/tests/page-markdown-actions.spec.ts

@@ -164,6 +164,37 @@ describe('page Markdown actions', () => {
     expect(queryByRole(host, 'menu')).toBeNull()
   })
 
+  it.each(['copy', 'view', 'toggle'])('keeps %s activation available when pointer clicks do not focus controls', async (action) => {
+    await mount()
+    const toggle = await openMenu()
+    const target = action === 'toggle' ? toggle : getByRole(host, 'menuitem', {
+      name: action === 'copy' ? 'Copy page' : 'View as Markdown (opens in a new tab)',
+    })
+    let activated = false
+    target.addEventListener('click', (event) => {
+      activated = true
+      if (action === 'view') event.preventDefault()
+    }, { once: true })
+    fireEvent.pointerDown(target)
+    // Safari can blur the focused menu item without focusing the pressed control.
+    if (fireEvent.mouseDown(target)) (document.activeElement as HTMLElement).blur()
+    await nextTick()
+    expect(target.isConnected).toBe(true)
+    expect(queryByRole(host, 'menu')).not.toBeNull()
+    fireEvent.mouseUp(target)
+    fireEvent.click(target)
+    await nextTick()
+    expect(activated).toBe(true)
+    expect(queryByRole(host, 'menu')).toBeNull()
+    expect(document.activeElement).toBe(toggle)
+    if (action === 'copy') {
+      await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
+      expect(write).toHaveBeenCalledOnce()
+    } else {
+      expect(write).not.toHaveBeenCalled()
+    }
+  })
+
   it('copies from the menu in the same click and prevents another copy while it is pending', async () => {
     const pending = deferred<Response>()
     fetchMock.mockReturnValueOnce(pending.promise)