♻️ refactor(vscode): 看板的悬停提示也改走 data-tooltip,插件网页只剩一条提示的路;lint 禁止原生 title

- 看板剩下的原生 title(顶栏按钮、工单卡片、工单详情、会话、Profile 表、改动文件树和 diff、属性表)全部改成 data-tooltip,
  禁用的按钮照样弹;只有图标、原来只靠 title 当名字的(审查勾、生成说明、属性说明的 i 图标、没头像的归属人)补上 aria-label
- 提示框按原文换行:摘修复弹框里「迁移」列的几个文件一行一个
- 代码阅读 Markdown 链接的标题写成 data-tooltip
- eslint 规则:网页 TSX 的 DOM 元素上写 title 报错,iframe 和组件自己的 title 属性不算
This commit is contained in:
cruldra committed 2026-09-27 21:39:15 +08:00
1 parent 1f7bf63e27
commit c8620e6e1b
15 files changed
+86 -46

No files matched your search

+2 -2
View File
@@ -130,7 +130,7 @@
这组图标由插件自己定义,是一个封闭的集合:契约里的 `KIND_ICON_NAMES`(上表 18 个加库规则用的 6 个,lucide 的 kebab-case 名字),类型 `KindIconName` 是它们的联合,`KindBadge.icon` 只能写其中一个,写别的是类型错误。webview 给每个名字配一个逐个 import 的 lucide 图标组件(`kindIcons.tsx` 的 `KIND_ICON_COMPONENTS`,打包只带这些,预览脚本约 157 kB);webview 的 `KindIconName` 照契约手抄,`kindIconNames.test.ts` 核对两边一样。规则要新图标就把名字加进这组、在 webview 配上图标,没有按任意名字找图标的路。
- 右上角的图标按钮(原型组件 `Reader / BlockTools`,普通块、属性表块、库规则的卡片共用):只有图标、不带字,名字写在悬停提示和 `aria-label` 里;整排是 `role="group"`(`aria-label="块操作"`)。先是「问 AI」(sparkles 图标,点开 / 收起块下面的问答区,展开时带按下的底色、`aria-expanded`;文件不在工作区文件夹里时没有,见「问 AI」),再是「编辑 / 预览」切换:预览时铅笔「编辑」,编辑时眼睛「预览」、带按下的底色(`--vscode-toolbar-activeBackground`)。按钮宽 24px、高 20px(和种类图标同高),图标 14px、`descriptionForeground` 色;悬停 `--vscode-toolbar-hoverBackground`,键盘聚焦描 1px `--vscode-focusBorder`;Tab 能走到,回车 / 空格切换。
- 悬停提示(整个预览只走这一条路,看板 webview 共用同一个组件):带提示的元素只写 `data-tooltip="提示"`(名字照旧写在 `aria-label` 里),页面根上挂一个 `TooltipHost`(`webview-ui/src/components/ui/tooltip.tsx`)统一弹,不用原生 `title`。原因:macOS 上 VS Code 带的 Electron(1.139 是 Electron 43)只在鼠标压着带 `title` 的元素自身边缘时才弹原生提示,停在它里面的图标、文字上不弹(electron/electron#49843,Electron 38 起),种类图标 20px 的框几乎被 16px 的图形占满,基本弹不出来;样子也是系统的。样子照 VS Code 的悬停框:`--vscode-editorHoverWidget-background` 底、`--vscode-editorHoverWidget-border` 边框、`--vscode-editorHoverWidget-foreground` 字、`0 2px 8px --vscode-widget-shadow` 阴影,11px 字,圆角 4px,最宽 22rem;优先在元素上方,上方放不下翻到下方,水平对着元素中间,贴边留 8px,不接鼠标。鼠标停 500ms 才弹,在同一个元素里移动不重新计时,已经弹着时移到另一个带提示的元素上立刻换;移开、移出页面、按下鼠标、按任意键(含 Esc)、滚动就收,收了之后鼠标不离开这个元素不再弹。键盘把焦点移到带提示的元素上(`:focus-visible`)立刻弹,焦点离开就收;提示不是元素自己的名字(`aria-label` 或它的字)时用 `aria-describedby` 挂到元素上,读屏当描述念。
- 悬停提示(整个插件的网页只走这一条路,预览和看板共用同一个组件):带提示的元素只写 `data-tooltip="提示"`(名字照旧写在 `aria-label` 里,只有图标的按钮必须有 `aria-label`),页面根上挂一个 `TooltipHost`(`webview-ui/src/components/ui/tooltip.tsx`)统一弹,不用原生 `title`;提示里的换行照样换行(看板摘修复弹框里「迁移」列出的几个文件一行一个);注释、回答里 Markdown 链接的标题(`[文字](网址 "标题")`)也写成 `data-tooltip`。网页的 TSX 里写原生 `title` 属性是 lint 错误(`vscode/eslint.config.mjs`),只有 `iframe` 这种必须带标题的元素例外,组件自己叫 `title` 的属性不算。原因:macOS 上 VS Code 带的 Electron(1.139 是 Electron 43)只在鼠标压着带 `title` 的元素自身边缘时才弹原生提示,停在它里面的图标、文字上不弹(electron/electron#49843,Electron 38 起),种类图标 20px 的框几乎被 16px 的图形占满,基本弹不出来;样子也是系统的。样子照 VS Code 的悬停框:`--vscode-editorHoverWidget-background` 底、`--vscode-editorHoverWidget-border` 边框、`--vscode-editorHoverWidget-foreground` 字、`0 2px 8px --vscode-widget-shadow` 阴影,11px 字,圆角 4px,最宽 22rem;优先在元素上方,上方放不下翻到下方,水平对着元素中间,贴边留 8px,不接鼠标。鼠标停 500ms 才弹,在同一个元素里移动不重新计时,已经弹着时移到另一个带提示的元素上立刻换;移开、移出页面、按下鼠标、按任意键(含 Esc)、滚动就收,收了之后鼠标不离开这个元素不再弹。键盘把焦点移到带提示的元素上(`:focus-visible`)立刻弹,焦点离开就收;提示不是元素自己的名字(`aria-label` 或它的字)时用 `aria-describedby` 挂到元素上,读屏当描述念。
- 种类图标和底色按种类区分(颜色见「颜色」);注释块虚线框。
- 内容两种状态,每块单独切:
- 预览:函数、类、枚举、异常、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。一行译文的块带说明(Python 字段的属性 docstring)时,说明按 Markdown 排在译文下面。
@@ -1453,7 +1453,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit
- 复制出什么(`explorer/copyText.test.ts`):文件、目录的名字 / 相对路径 / 绝对路径;不带后缀只去最后一个扩展名(`a.test.ts`、`archive.tar.gz`),`.env` 原样、`.env.local` → `.env`、没有扩展名的原样;相对路径总用 `/`,绝对路径照本机写法(Windows 反斜杠)。
- 块右上角的图标按钮(webview `BlockTools.test.tsx`,渲染成 HTML):一组一个按钮、没有字、悬停提示(`data-tooltip`,没有原生 `title`)和 `aria-label` 是「编辑」/「预览」、有图标、编辑时带按下标记;给了「问 AI」时它排在前面、只有图标、问答区展开时带按下标记和 `aria-expanded`。
- 块的种类图标(webview `kindIcons.test.tsx`,渲染成 HTML):内置种类的图标和原型一一对应;这组里每个名字画的就是 lucide 里同名的图标;webview 配了图标的名字和契约的 `KIND_ICON_NAMES` 一样、没有多的(`kindIconNames.test.ts`);只有图标、没有字,`role="img"`,悬停提示(`data-tooltip`,没有原生 `title`) 和 `aria-label` 是种类名,20px 的框、16px 的图形;库规则的卡片用规则给的图标。各库规则的测试核对 `badge` 的名字、颜色、图标。
- 悬停提示(webview `components/ui/tooltip.test.ts`):停 500ms 才弹、在同一个元素里移动不重新计时、没等到就移开不弹、弹着时移到另一个上立刻换、移到没提示的地方就收、按下鼠标 / 按键 / 滚动就收且鼠标不离开不再弹、等着弹时按下这次不弹、键盘聚焦立刻弹失焦收、提示是空的不弹;位置在上方、上方放不下翻到下方、左右钳在视口内。页面上(无头 Chrome,真的移鼠标、按 Tab):种类图标中心点命中的是图标里的线条、没有盖在上面的东西;停 250ms 还没弹、停满 500ms 在图标上方弹「函数」「接口」,颜色是悬停框的那组变量;Tab 到「编辑」立刻弹、Esc 收;页面上没有原生 `title`。亮、暗两套颜色都看过。
- 悬停提示(webview `components/ui/tooltip.test.ts`):停 500ms 才弹、在同一个元素里移动不重新计时、没等到就移开不弹、弹着时移到另一个上立刻换、移到没提示的地方就收、按下鼠标 / 按键 / 滚动就收且鼠标不离开不再弹、等着弹时按下这次不弹、键盘聚焦立刻弹失焦收、提示是空的不弹;位置在上方、上方放不下翻到下方、左右钳在视口内。Markdown 链接的标题写成 `data-tooltip`(`markdown.test.ts`);网页 TSX 里的原生 `title` 由 lint 规则拦(`button`、`span` 上报错,`iframe`、组件属性不报)。页面上(无头 Chrome,真的移鼠标、按 Tab):种类图标中心点命中的是图标里的线条、没有盖在上面的东西;停 250ms 还没弹、停满 500ms 在图标上方弹「函数」「接口」,颜色是悬停框的那组变量;Tab 到「编辑」立刻弹、Esc 收;页面上没有原生 `title`;看板上「待接管」、禁用的 .env 锁按钮(禁用的按钮照样弹)、刷新按钮、摘修复弹框里「迁移」的三行文件名(一行一个)都弹,Tab 到按钮立刻弹、Esc 收。亮、暗两套颜色都看过。
- 问 AI:
- 位置记号(`qa/anchor.test.ts`,真实的 Python 解析):有名字 / 没名字的块的记号、同一层重复的编号、两个函数里一样的条件互不影响;上面插几行、改空白记号不变;改别的函数的名字只动它和它里面的块;没名字的块自己的源码改了记号变、有名字的块改签名不变。
- 存法(`qa/qaMap.test.ts`):记一次、删一次、删光不挂 / 不记;文件缺失、解析失败、不是对象当没有;认不出的块和问答跳过、没会话 id 的照认;再问时接着这块最近那条的会话(删了最近的接着剩下里最近的,没问过、没记会话 id 的重新开);用哪个 profile 答的跟着这条存、读得回来,没有的、空的、不是字符串的当没有;手改的顺序、重复的块读进来整理好;写出来排好序、两格缩进、读得回来、删光留 `{}`;改名、挪目录、删除跟着走。图片:文件名跟着这条存、读得回来,跑到别处的路径、不是字符串的名字跳过;删一次问答它的图片跟着删、别的留着;源文件删了图片跟着删,改名、挪目录图片不动。
+11
View File
@@ -22,4 +22,15 @@ export default antfu(
}],
},
},
{
// webview 的悬停提示只走 components/ui/tooltip.tsx(元素上写 data-tooltip):原生 title 在 macOS 的 VS Code 里鼠标停在元素里面不弹
// (electron/electron#49843),样式也是系统的。只有必须带 title 的元素(iframe 的标题)例外;组件自己的 title 属性(大写开头)不算
files: ['webview-ui/src/**/*.tsx'],
rules: {
'no-restricted-syntax': ['error', 'TSEnumDeclaration[const=true]', 'TSExportAssignment', {
selector: 'JSXOpeningElement[name.name=/^[a-z]/]:not([name.name="iframe"]) > JSXAttribute[name.name="title"]',
message: '悬停提示写 data-tooltip(components/ui/tooltip.tsx 统一弹),不用原生 title;图标按钮的名字写 aria-label',
}],
},
},
)
@@ -39,4 +39,9 @@ describe('renderMarkdown', () => {
expect(renderMarkdown('[相对](./x.md)')).not.toContain('<a')
expect(renderMarkdown('![图](https://example.com/a.png)')).not.toContain('<img')
})
it('链接的标题走悬停提示(data-tooltip),不写原生 title', () => {
expect(renderMarkdown('[文档](https://example.com "接口说明")')).toBe('<p><a href="https://example.com" data-tooltip="接口说明">文档</a></p>\n')
expect(renderMarkdown('[文档](https://example.com "")')).toBe('<p><a href="https://example.com">文档</a></p>\n')
})
})
@@ -8,6 +8,20 @@ import MarkdownIt from 'markdown-it'
const md = new MarkdownIt({ html: false, linkify: true }).disable('image')
md.validateLink = url => /^(?:https?|mailto):/i.test(url)
/** 链接的标题(`[文字](网址 "标题")`)写成 data-tooltip,和页面上别的悬停提示走同一个提示框(components/ui/tooltip.tsx),不用原生 title。 */
md.core.ruler.push('link_title_tooltip', (state) => {
for (const block of state.tokens) {
for (const token of block.children ?? []) {
const title = token.type === 'link_open' ? token.attrGet('title') : null
if (title === null)
continue
token.attrs = token.attrs!.filter(([name]) => name !== 'title')
if (title)
token.attrSet('data-tooltip', title)
}
}
})
/** 最近渲染过的文字 → HTML,最多留 CACHE_SIZE 段,最久没用的先丢。 */
const CACHE_SIZE = 500
const cache = new Map<string, string>()
@@ -227,7 +227,7 @@ function TabButton({ active, onClick, icon, title, badge }: TabButtonProps) {
<button
type="button"
onClick={onClick}
title={title}
data-tooltip={title}
aria-label={title}
aria-pressed={active}
className={`relative flex h-9 w-full items-center justify-center transition-colors ${
@@ -209,7 +209,7 @@ export function CherryPickModal({ open, branch, groups, firstAfterReleaseSha, on
<span className="flex min-w-0 items-center gap-1.5">
{c.migration && (
<span
title={c.migration.files.join('\n')}
data-tooltip={c.migration.files.join('\n')}
className="shrink-0 rounded border border-current px-1 text-[10px] leading-4 opacity-80"
>
迁移
@@ -35,13 +35,15 @@ function OwnerBadge({ login, avatar }: { login: string, avatar?: string }) {
<img
src={avatar}
alt={login}
title={`${login} 的工单(只读)`}
data-tooltip={`${login} 的工单(只读)`}
className="size-4 shrink-0 rounded-full ring-1 ring-[var(--vscode-panel-border)]"
/>
)
: (
<span
title={`${login} 的工单(只读)`}
data-tooltip={`${login} 的工单(只读)`}
role="img"
aria-label={`${login} 的工单(只读)`}
className="grid size-4 shrink-0 place-items-center rounded-full bg-[var(--vscode-badge-background)] text-[8px] uppercase text-[var(--vscode-badge-foreground)]"
>
{login.slice(0, 1)}
@@ -90,7 +92,7 @@ export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
? (
<span
className="grid place-items-center text-[var(--vscode-disabledForeground)]"
title={lockedPrerequisite != null ? `等待 #${lockedPrerequisite} 完成` : '等待前置工单完成'}
data-tooltip={lockedPrerequisite != null ? `等待 #${lockedPrerequisite} 完成` : '等待前置工单完成'}
aria-label={lockedPrerequisite != null ? `等待 #${lockedPrerequisite} 完成` : '等待前置工单完成'}
>
<Lock className="size-3.5" />
@@ -103,7 +105,7 @@ export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
? (
<span
className="absolute bottom-1 right-1.5 rounded border border-blue-400/50 bg-blue-400/10 px-1 text-[9px] text-blue-400"
title={`${issue.handoffFrom ?? '?'} 已移交,待接管`}
data-tooltip={`${issue.handoffFrom ?? '?'} 已移交,待接管`}
>
待接管
</span>
@@ -230,7 +230,7 @@ export function IssueDetailPanel({
<button
type="button"
onClick={onOpenLogs}
title="查看扩展日志"
data-tooltip="查看扩展日志"
aria-label="查看扩展日志"
className="grid size-5 place-items-center rounded opacity-60 hover:opacity-100"
>
@@ -803,7 +803,7 @@ export function IssueDetailPanel({
{issue.source === 'youtrack' && (
<span
className="ml-2 inline-flex items-center rounded border border-purple-400/50 bg-purple-400/10 px-1.5 py-0.5 align-middle text-[10px] font-normal text-purple-400"
title="此工单来自 YouTrack"
data-tooltip="此工单来自 YouTrack"
>
YouTrack
</span>
@@ -814,7 +814,7 @@ export function IssueDetailPanel({
? (
<span
className="ml-2 inline-flex items-center gap-1 rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 align-middle text-[10px] font-normal text-[var(--vscode-disabledForeground)]"
title={lockTitle}
data-tooltip={lockTitle}
>
等待 #
{prerequisiteNumber}
@@ -828,7 +828,7 @@ export function IssueDetailPanel({
? (
<span
className="inline-flex shrink-0 items-center gap-1.5 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-[11px] opacity-80"
title={`${ownerLogin} 的工单,只能查看`}
data-tooltip={`${ownerLogin} 的工单,只能查看`}
>
{avatars?.[ownerLogin]
? <img src={avatars[ownerLogin]} alt={ownerLogin} className="size-4 rounded-full" />
@@ -930,7 +930,7 @@ export function IssueDetailPanel({
key={att.url}
type="button"
onClick={() => onOpenInBrowser(att.url)}
title={att.name}
data-tooltip={att.name}
className="inline-flex max-w-[200px] items-center gap-1 truncate rounded border border-[var(--vscode-panel-border)] px-2 py-1 hover:bg-black/10 dark:hover:bg-white/10"
>
<ExternalLink className="size-3 shrink-0" />
@@ -177,7 +177,7 @@ export function ManagedSessionsPanel({
<button
type="button"
onClick={openImport}
title="导入已 /rename 的命名会话"
data-tooltip="导入已 /rename 的命名会话"
className="flex shrink-0 items-center gap-1 rounded border border-[var(--vscode-input-border,var(--vscode-panel-border))] bg-[var(--vscode-button-secondaryBackground,var(--vscode-input-background))] px-2 py-1 text-xs text-[var(--vscode-button-secondaryForeground,var(--vscode-foreground))] hover:bg-[var(--vscode-button-secondaryHoverBackground,var(--vscode-toolbar-hoverBackground))]"
>
<Download className="size-3.5" />
@@ -367,7 +367,7 @@ function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTa
<button
type="button"
onClick={resume}
title="点击恢复会话(新终端 resume)"
data-tooltip="点击恢复会话(新终端 resume)"
className="grid size-6 shrink-0 place-items-center rounded text-[var(--vscode-descriptionForeground)] hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:text-[var(--vscode-foreground)]"
aria-label="恢复会话"
>
@@ -411,7 +411,7 @@ function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTa
e.stopPropagation()
beginEdit()
}}
title="双击改名"
data-tooltip="双击改名"
>
{session.name || <span className="opacity-40">(未命名)</span>}
</span>
@@ -462,7 +462,7 @@ function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTa
e.stopPropagation()
onHandoff(session.id)
}}
title="交接给同事"
data-tooltip="交接给同事"
aria-label="交接给同事"
className="grid size-6 shrink-0 place-items-center rounded text-[var(--vscode-descriptionForeground)] opacity-0 transition-opacity hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:text-[var(--vscode-foreground)] group-hover:opacity-70"
>
@@ -476,7 +476,7 @@ function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTa
e.stopPropagation()
onCloseTab(session.id)
}}
title="关闭会话 tab"
data-tooltip="关闭会话 tab"
aria-label="关闭会话 tab"
className="grid size-6 shrink-0 place-items-center rounded text-[var(--vscode-descriptionForeground)] opacity-70 transition-opacity hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:text-[var(--vscode-foreground)]"
>
@@ -490,7 +490,7 @@ function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTa
e.stopPropagation()
onDelete(session.id)
}}
title="从列表移除(不删 jsonl)"
data-tooltip="从列表移除(不删 jsonl)"
aria-label="从列表移除"
className="grid size-6 shrink-0 place-items-center rounded text-[var(--vscode-descriptionForeground)] opacity-0 transition-opacity hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:text-state-red group-hover:opacity-70"
>
@@ -139,7 +139,7 @@ export function PanelHeader({
<button
type="button"
onClick={() => onFilterChange('')}
title="清空筛选"
data-tooltip="清空筛选"
aria-label="清空筛选"
className="absolute right-1 flex h-4 w-4 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)]"
>
@@ -152,7 +152,7 @@ export function PanelHeader({
type="button"
onClick={() => setReleaseMenuOpen(open => !open)}
disabled={releaseDisabled}
title={title}
data-tooltip={title}
aria-label={title}
aria-haspopup="menu"
aria-expanded={releaseMenuOpen}
@@ -196,7 +196,7 @@ export function PanelHeader({
type="button"
onClick={onToggleEnvLock}
disabled={envLockDisabled}
title={envLockTitle}
data-tooltip={envLockTitle}
aria-label={envLockTitle}
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)] disabled:cursor-not-allowed disabled:opacity-50"
>
@@ -211,7 +211,7 @@ export function PanelHeader({
type="button"
onClick={onCommit}
disabled={commitRunning}
title="提交代码"
data-tooltip="提交代码"
aria-label="提交代码"
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)] disabled:cursor-not-allowed disabled:opacity-50"
>
@@ -224,7 +224,7 @@ export function PanelHeader({
<button
type="button"
onClick={onImportYouTrack}
title="导入 YouTrack 工单"
data-tooltip="导入 YouTrack 工单"
aria-label="导入 YouTrack 工单"
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)]"
>
@@ -234,7 +234,7 @@ export function PanelHeader({
<button
type="button"
onClick={onToggleScope}
title={scope === 'mine' ? '当前:只看我的工单,点击切换为全部' : '当前:团队全部工单,点击切换为只看我的'}
data-tooltip={scope === 'mine' ? '当前:只看我的工单,点击切换为全部' : '当前:团队全部工单,点击切换为只看我的'}
aria-label={scope === 'mine' ? '切换为全部工单' : '切换为只看我的'}
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)]"
>
@@ -244,7 +244,7 @@ export function PanelHeader({
type="button"
onClick={onRefresh}
disabled={refreshing}
title={refreshing ? '正在刷新…' : '刷新'}
data-tooltip={refreshing ? '正在刷新…' : '刷新'}
aria-label="刷新"
aria-busy={refreshing}
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)] disabled:cursor-not-allowed"
@@ -254,7 +254,7 @@ export function PanelHeader({
<button
type="button"
onClick={onEditAuth}
title="重新配置 Gitea Token"
data-tooltip="重新配置 Gitea Token"
aria-label="重新配置 Gitea Token"
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)]"
>
@@ -272,7 +272,7 @@ export function ProfileGrid({ data, onSave, onOpen }: ProfileGridProps) {
className={`${headerBase} border-t px-2`}
onDoubleClick={() => beginEdit({ kind: 'header', col })}
onContextMenu={e => openHeaderMenu(e, col)}
title="双击改名,右键删除"
data-tooltip="双击改名,右键删除"
>
{editing && editing.kind === 'header' && editing.col === col
? (
@@ -304,7 +304,7 @@ export function ProfileGrid({ data, onSave, onOpen }: ProfileGridProps) {
<button
type="button"
onClick={addProfile}
title="新增 profile 列"
data-tooltip="新增 profile 列"
aria-label="新增 profile 列"
className="grid size-5 place-items-center rounded opacity-60 hover:bg-[var(--vscode-list-hoverBackground)] hover:opacity-100"
>
@@ -342,7 +342,7 @@ export function ProfileGrid({ data, onSave, onOpen }: ProfileGridProps) {
<button
type="button"
onClick={addKey}
title="新增 key 行"
data-tooltip="新增 key 行"
className="flex items-center gap-1 rounded px-1 py-0.5 text-[var(--vscode-descriptionForeground)] hover:bg-[var(--vscode-list-hoverBackground)] hover:text-[var(--vscode-foreground)]"
>
<Plus className="size-3.5" />
@@ -434,7 +434,7 @@ function RowFragment({
className={`${cellBase} ${editableHover} border-l px-2 font-medium`}
onDoubleClick={() => beginEdit({ kind: 'key', row: rIdx })}
onContextMenu={e => openKeyMenu(e, rIdx)}
title="双击编辑 key,右键删除"
data-tooltip="双击编辑 key,右键删除"
>
{editing && editing.kind === 'key' && editing.row === rIdx
? (
@@ -473,7 +473,7 @@ function RowFragment({
key={`r${rIdx}-c${col}`}
className={`${cellBase} ${editableHover} group px-2`}
onDoubleClick={() => onEditCell(profile)}
title="双击编辑(多行 + 图片)"
data-tooltip="双击编辑(多行 + 图片)"
>
<div className="flex w-full min-w-0 items-center gap-1.5">
<span className="min-w-0 flex-1 truncate">
@@ -492,7 +492,7 @@ function RowFragment({
e.stopPropagation()
onOpen(value)
}}
title={kind === 'url' ? '在浏览器打开' : '在系统中打开'}
data-tooltip={kind === 'url' ? '在浏览器打开' : '在系统中打开'}
aria-label={kind === 'url' ? '在浏览器打开' : '在系统中打开'}
className="grid size-5 shrink-0 place-items-center rounded opacity-0 transition-opacity hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:opacity-100 group-hover:opacity-70"
>
@@ -140,7 +140,7 @@ export function FileDiffPane({
<span
className="min-w-0 flex-1 truncate font-mono text-xs"
style={{ color: confirmed ? 'var(--vscode-descriptionForeground)' : 'var(--vscode-foreground)' }}
title={path}
data-tooltip={path}
>
{path}
</span>
@@ -148,7 +148,8 @@ export function FileDiffPane({
<button
type="button"
onClick={() => onToggleConfirm(!confirmed)}
title={confirmed ? '已审查,点击取消' : '标记为已审查'}
data-tooltip={confirmed ? '已审查,点击取消' : '标记为已审查'}
aria-label={confirmed ? '已审查,点击取消' : '标记为已审查'}
className="shrink-0"
style={{
color: confirmed
@@ -163,7 +164,9 @@ export function FileDiffPane({
type="button"
onClick={onGenerate}
disabled={generating}
title={generating ? '生成中…' : '生成本文件改动说明'}
data-tooltip={generating ? '生成中…' : '生成本文件改动说明'}
aria-label="生成本文件改动说明"
aria-busy={generating}
className="shrink-0 text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)] disabled:opacity-60"
>
{generating ? <Loader2 className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
@@ -70,7 +70,8 @@ export function FileTree({
e.stopPropagation()
onToggleDirConfirm(row.descendantFiles, !isConfirmed)
}}
title={isConfirmed ? '已查看,点击取消' : '标记为已查看'}
data-tooltip={isConfirmed ? '已查看,点击取消' : '标记为已查看'}
aria-label={isConfirmed ? '已查看,点击取消' : '标记为已查看'}
className="shrink-0 transition-opacity"
style={{
opacity: isConfirmed || hoveredPath === row.path ? 1 : 0,
@@ -87,13 +88,13 @@ export function FileTree({
const isConfirmed = confirmed.has(row.path)
const isSelected = selectedPath === row.path
return (
// 文件行:点击选中 → 容器据此拉取并渲染右栏 diff;title 用完整路径。
// 文件行:点击选中 → 容器据此拉取并渲染右栏 diff;悬停提示是状态和完整路径。
<li
key={row.path}
onMouseEnter={() => setHoveredPath(row.path)}
onMouseLeave={() => setHoveredPath(p => (p === row.path ? null : p))}
onClick={() => onSelectFile(row.path)}
title={`${row.status} · ${row.path}`}
data-tooltip={`${row.status} · ${row.path}`}
className={`group flex cursor-pointer items-center gap-2 py-0.5 pr-2 ${
isSelected
? 'bg-[var(--vscode-list-activeSelectionBackground)] text-[var(--vscode-list-activeSelectionForeground)]'
@@ -114,7 +115,8 @@ export function FileTree({
e.stopPropagation()
onToggleFileConfirm(row.path, !isConfirmed)
}}
title={isConfirmed ? '已查看,点击取消' : '标记为已查看'}
data-tooltip={isConfirmed ? '已查看,点击取消' : '标记为已查看'}
aria-label={isConfirmed ? '已查看,点击取消' : '标记为已查看'}
className="shrink-0 transition-opacity"
style={{
opacity: isConfirmed || hoveredPath === row.path ? 1 : 0,
@@ -97,7 +97,7 @@ function RowActionButtons({ actions, className }: { actions: RowAction[] | undef
action.onClick()
}}
disabled={action.disabled}
title={action.title}
data-tooltip={action.title}
aria-label={action.title ?? '次要操作'}
className={`grid size-5 shrink-0 place-items-center ${className ?? ''} ${
action.disabled ? 'cursor-not-allowed opacity-30' : 'opacity-60 hover:opacity-100'
@@ -380,7 +380,7 @@ function PropertyRow({
type="button"
data-property-action="primary"
onDoubleClick={() => firePrimaryAction(() => propDef.onOpen?.(value as string))}
title={value as string}
data-tooltip={value as string}
className="flex min-w-0 select-none items-center gap-1.5 text-left text-xs font-mono text-[var(--vscode-textLink-foreground)] underline-offset-2 hover:text-[var(--vscode-textLink-activeForeground)] hover:underline"
>
{actionPending && <Loader2 className="size-3.5 shrink-0 animate-spin" />}
@@ -468,12 +468,14 @@ function PropertyRow({
<div className="group flex min-h-[36px] border-b border-[var(--vscode-panel-border)] transition-colors last:border-b-0">
{/* 标签列 */}
<div className="flex w-[38%] flex-shrink-0 items-center gap-1.5 border-r border-[var(--vscode-panel-border)] px-3 py-1">
<span className="truncate text-xs font-medium" title={propDef.label}>
<span className="truncate text-xs font-medium" data-tooltip={propDef.label}>
{propDef.label}
</span>
{propDef.description && (
<span
title={propDef.description}
data-tooltip={propDef.description}
role="img"
aria-label={propDef.description}
className="grid size-3 cursor-help place-items-center opacity-60"
>
<Info className="size-3" />
@@ -100,7 +100,8 @@ function isOwnName(anchor: Element, text: string): boolean {
}
/**
* 整个 webview 唯一的悬停提示:页面根上挂一次,元素只写 data-tooltip="提示"(名字照旧写在 aria-label 里)。
* 整个 webview 唯一的悬停提示:页面根上挂一次,元素只写 data-tooltip="提示"(名字照旧写在 aria-label 里),提示里的换行照样换行。
* 网页里的原生 title 属性由 eslint.config.mjs 的规则拦下,只有 iframe 这类必须带 title 的元素例外。
* 不用原生 title:macOS 上 VS Code 的 Electron 只在鼠标压着元素自身边缘时才弹原生提示,停在里面的图标、文字上不弹
* (electron/electron#49843),样式也是系统的。这里画成 VS Code 悬停框的样子(--vscode-editorHoverWidget-*),
* portal 到 body 不被 overflow 容器裁掉;键盘聚焦(:focus-visible)立刻弹,并用 aria-describedby 挂到元素上。
@@ -173,7 +174,7 @@ export function TooltipHost(): ReactElement | null {
id={id}
role="tooltip"
style={{ top: 0, left: 0, visibility: 'hidden' }}
className="pointer-events-none fixed z-50 max-w-[22rem] rounded border border-[var(--vscode-editorHoverWidget-border)] bg-[var(--vscode-editorHoverWidget-background)] px-2 py-1 text-[11px] leading-tight break-words text-[var(--vscode-editorHoverWidget-foreground)] shadow-[0_2px_8px_var(--vscode-widget-shadow)]"
className="pointer-events-none fixed z-50 max-w-[22rem] rounded border border-[var(--vscode-editorHoverWidget-border)] bg-[var(--vscode-editorHoverWidget-background)] px-2 py-1 text-[11px] leading-tight break-words whitespace-pre-line text-[var(--vscode-editorHoverWidget-foreground)] shadow-[0_2px_8px_var(--vscode-widget-shadow)]"
>
{tip.text}
</div>,