From 65d4afafd6b91e9ee68e45efcf46c56289f07e67 Mon Sep 17 00:00:00 2001 From: Stevring Date: Tue, 20 Oct 2026 18:57:48 +0800 Subject: [PATCH] fix(client): reveal menu shortcuts during keyboard navigation --- apps/web/tests/hover-input.e2e.ts | 31 +++++++++++++++++-- docs/web-styling.i18n.yaml | 7 +++-- docs/web-styling.md | 11 +++++++ docs/web-styling.zh.md | 11 +++++++ .../client/ui-primitives/README.i18n.yaml | 4 +-- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../client/ui-primitives/src/Menu.module.css | 2 +- 8 files changed, 61 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/hover-input.e2e.ts b/apps/web/tests/hover-input.e2e.ts index 83aadf6339..cae5220a89 100644 --- a/apps/web/tests/hover-input.e2e.ts +++ b/apps/web/tests/hover-input.e2e.ts @@ -54,6 +54,7 @@ it.each([false, true])('keeps workspace and archived search actions reachable wi const browser = await chromium.launch() onTestFinished(() => browser.close()) const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, hasTouch, locale: 'en-US' }) + await page.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }) }) onTestFailed(() => saveFailureShot(page, `row-actions-${String(hasTouch)}`)) const activate = async (target: Locator) => { if (hasTouch) await target.tap() @@ -86,8 +87,34 @@ it.each([false, true])('keeps workspace and archived search actions reachable wi expect(await project.getAttribute('aria-expanded')).toBe('true') await openFixture() if (!hasTouch) await row.hover() - await activate(row.getByRole('button', { name: /^Session actions for / })) - await activate(page.getByRole('menuitem', { name: 'Archive session', exact: true })) + const actions = row.getByRole('button', { name: /^Session actions for / }) + await activate(actions) + const archive = page.getByRole('menuitem', { name: 'Archive session', exact: true }) + const shortcut = archive.locator('[aria-hidden="true"]').filter({ has: page.locator('kbd') }) + await archive.waitFor() + expect(await shortcut.count()).toBe(1) + expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(hasTouch) + expect(await shortcut.isVisible()).toBe(!hasTouch) + for (const key of ['ArrowDown', 'Tab']) { + await page.keyboard.press(key) + expect(await page.locator('html').getAttribute('data-input-modality')).toBe('keyboard') + expect(await page.getByRole('menu').evaluate(menu => menu.contains(document.activeElement))).toBe(true) + expect(await shortcut.isVisible()).toBe(true) + if (hasTouch) { + await page.setViewportSize({ width: 390, height: 900 }) + await expect.poll(() => shortcut.evaluate((element) => { + const bounds = element.getBoundingClientRect() + return bounds.left >= 0 && bounds.right <= window.innerWidth + })).toBe(true) + await page.setViewportSize({ width: 1280, height: 900 }) + } + await page.keyboard.press('Escape') + if (!hasTouch) await row.hover() + await activate(actions) + await archive.waitFor() + expect(await shortcut.isVisible()).toBe(!hasTouch) + } + await activate(archive) await row.waitFor({ state: 'detached' }) await activate(page.getByRole('button', { name: 'View options', exact: true })) await activate(page.getByRole('menuitem', { name: 'All conversations (show archived)', exact: true })) diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml index 6c6b47abde..28ee616353 100644 --- a/docs/web-styling.i18n.yaml +++ b/docs/web-styling.i18n.yaml @@ -9,8 +9,11 @@ en: dc8c41fbf9869179 zh: e45976a585cb9c86 /web-ui-style-reference/component-rules: - en: 1a6ee7a02cef1e1c - zh: 641bf60f7521ae12 + en: 4ee4a6fd2917306a + zh: d7d68c140a698ab0 +/web-ui-style-reference/input-capabilities: + en: bf2e98bce68743e2 + zh: fc58dcbb60bb944a /web-ui-style-reference/corner-radii-and-settings-cards: en: 118972f6c2bd6ce4 zh: 85f8416b03e4dd8b diff --git a/docs/web-styling.md b/docs/web-styling.md index ff65c30659..f2ae2a6ffd 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -30,6 +30,17 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi - Flat borders and separators that use a neutral `--dsw-alias-border-*` token draw at `0.5px` — buttons, inputs, cards, row dividers, and separators drawn as filled boxes (menu separators, the conversation header seam, markdown `hr`, vertical rails) share the hairline weight, which Chromium paints as one device pixel. Dashed affordances and state-colored borders keep 1px; spinner ring tracks keep their width through the spec's explicit allowlist. The ui-theme elevation spec rejects wider neutral solid borders. - Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Compact Thinking Markdown keeps tertiary text color and a resting dotted underline ([compact presentation](../packages/client/ui-primitives/README.md#rendering-agent-output)). Text-leading anchors also lead with the ui-primitives `LinkIconMedium` category glyph riding `currentColor`, which for a well-known external host is that site's own mark instead of the globe; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance. + +## Input capabilities + +`pointer` and `hover` describe the primary pointing device chosen by the browser; `any-hover` considers every available pointing device. None detects a physical keyboard. + +- Use `(pointer: coarse)` for presentation that depends on pointing accuracy, such as touch-sized controls and text fields. +- Keep CSS hover effects under `(hover: hover)` so a touch-first layout remains usable without hover. +- JavaScript hover previews use `(any-hover: hover)` when accepting mouse entry and ignore touch entry. A secondary mouse can therefore open a preview even when the touchscreen remains the primary pointer. Enabling all CSS hover styles through `any-hover` would also enable them for touch-generated `:hover` states. + +On a mixed-input device, a mouse preview can appear while the primary-pointer CSS hover style stays inactive. Keyboard focus and shortcut hints follow actual keyboard interaction independently of these capability queries; [Menu and input modality](../packages/client/ui-primitives/README.md#input-modality) define the shared behavior. + ## Corner radii and settings cards diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md index a52971fad1..6ad928862e 100644 --- a/docs/web-styling.zh.md +++ b/docs/web-styling.zh.md @@ -30,6 +30,17 @@ - 使用中性 `--dsw-alias-border-*` token 的平面边框与分割线一律 `0.5px`——按钮、输入框、卡片、行分割线,以及以填充盒绘制的分隔线(菜单分隔、对话标题栏接缝、markdown `hr`、竖向轨道线)共用发丝线粗细,Chromium 将其绘制为一个设备像素。dashed 记号与状态色 border 保持 1px;spinner 圆环经 spec 的显式豁免保留原宽度。更宽的中性 solid border 会被 ui-theme elevation spec 拒绝。 - 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。紧凑 Thinking Markdown 保持 tertiary 文字色和默认点状下划线([紧凑展示](../packages/client/ui-primitives/README.zh.md#rendering-agent-output))。带文字的锚点另以 ui-primitives 的 `LinkIconMedium` 分类图形(随 `currentColor`)作前置;目的地是已知外部站点时改用该站点自己的标记而非地球;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能。 + +## 输入能力 + +`pointer` 与 `hover` 描述浏览器选定的主指针,`any-hover` 则考虑所有可用指针。三者都不检测物理键盘。 + +- `(pointer: coarse)` 用于依赖指针精度的呈现,例如适合触摸的控件和文本框尺寸。 +- CSS 悬停效果保留在 `(hover: hover)` 下,使以触屏为主的布局无需悬停也能使用。 +- JavaScript 悬停预览在接受 mouse 进入事件时使用 `(any-hover: hover)`,并忽略 touch 进入事件。因此,即使触屏仍是主指针,外接鼠标也能打开预览。如果通过 `any-hover` 启用全部 CSS 悬停样式,触摸产生的 `:hover` 状态也会应用这些样式。 + +混合输入设备上,鼠标预览可以出现,而基于主指针的 CSS 悬停样式保持关闭。键盘焦点和快捷键提示根据实际键盘交互显示,不依赖这些能力查询;共享行为见 [Menu 与输入方式](../packages/client/ui-primitives/README.zh.md#input-modality)。 + ## 圆角与设置卡片 diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 3bc2736a7e..c4b3fe6eb0 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-primitives/use-this-package: - en: 86bf820c4e52f41e - zh: 33565e9238b554b3 + en: d2f5701274be5110 + zh: d60e2617136a6f26 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: en: cb874a0ca53a6747 zh: 31179193dd1d6e12 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index f6fc65bf34..4bfca87305 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -25,7 +25,7 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out ## Use this package -`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. Menus hide these hints when the primary pointer is coarse (`pointer: coarse`); keyboard bindings remain active. `ShortcutKeys` defaults to unboxed keys for menu and inline hints. Its `tooltip` variant uses lighter keycaps over the dark bubble and groups plus-separated combinations into one filled block. `Tooltip.shortcutKeys` vertically centers keycaps beside optional localized action text, or shows only keys when the label is empty. The optional `className` supplies owner interaction styles. `Modal` uses the frame’s shared overlay inset, with a minimum 24px viewport margin. Its mask leaves the Windows caption unpainted while the full-viewport layer blocks background clicks. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime. +`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. Menus hide these hints when the primary pointer is coarse (`pointer: coarse`) and [keyboard navigation](#input-modality) is inactive. Keyboard navigation reveals them until the next pointer press; keyboard bindings remain active. `ShortcutKeys` defaults to unboxed keys for menu and inline hints. Its `tooltip` variant uses lighter keycaps over the dark bubble and groups plus-separated combinations into one filled block. `Tooltip.shortcutKeys` vertically centers keycaps beside optional localized action text, or shows only keys when the label is empty. The optional `className` supplies owner interaction styles. `Modal` uses the frame’s shared overlay inset, with a minimum 24px viewport margin. Its mask leaves the Windows caption unpainted while the full-viewport layer blocks background clicks. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime. Automatic modal entry and modal/menu return focus, including after Escape and application close shortcuts, use `focusWithoutRing(element, options?)` to suppress the outline until Tab or directional navigation resumes normal focus styling. Dialog containers keep their outline-free styling. Existing borders, shadows, and error states remain intact. Mark a dialog's initial control with `data-modal-autofocus` so the modal captures its invoking control before moving focus. Controls mounted with the dialog must not use React `autoFocus`, which runs before that capture. Tab and Shift+Tab from the dialog container enter its first and last focusable controls. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 475893e6cb..e5ebe6f486 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -25,7 +25,7 @@ kind: "package-library" ## 使用本包 -`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。主指针为粗指针(`pointer: coarse`)时,菜单隐藏这些提示,快捷键绑定仍然生效。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。 +`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。主指针为粗指针(`pointer: coarse`)且未使用[键盘导航](#input-modality)时,菜单隐藏这些提示。键盘导航会显示提示,直到下一次指针按下;快捷键绑定始终生效。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。 弹窗自动进入及弹窗、菜单回焦,包括通过 Esc 和应用关闭快捷键触发的回焦,均使用 `focusWithoutRing(element, options?)`,在 Tab 或方向键导航恢复正常焦点样式前抑制外轮廓线。弹窗容器仍不绘制焦点外框。原有边框、阴影和错误状态保持不变。用 `data-modal-autofocus` 标记弹窗的初始控件,让模态层先保存触发控件,再移动焦点。随弹窗挂载的控件不得使用 React `autoFocus`,因为它会在保存触发控件前执行。弹窗容器获得焦点时,Tab 和 Shift+Tab 分别进入第一个和最后一个可聚焦控件。 diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index f14a1af8e8..1fd68864bb 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -209,7 +209,7 @@ } @media (pointer: coarse) { - .shortcut { + :global(html:not([data-input-modality='keyboard'])) .shortcut { display: none; } }