🐛 fix(vscode): 悬停提示不用原生 title,webview 统一用自绘提示框,代码阅读的种类图标停上去能看到种类名
VS Code 1.139 带的 Electron 43 在 macOS 上只在鼠标压着带 title 的元素自身边缘时才弹原生提示, 停在它里面的图标、文字上不弹(electron/electron#49843,Electron 38 起)。种类图标 20px 的框 几乎被 16px 的图形占满,所以基本看不到种类名;块右上角按钮同样受影响。页面上核对过:图标中心点 命中的是图标里的线条,没有盖在上面的东西。 - components/ui/tooltip.tsx 改成整页只挂一次的 TooltipHost:元素只写 data-tooltip,事件统一交给它; 样子照 VS Code 悬停框(editorHoverWidget 那组变量、widget-shadow 阴影),停 500ms 才弹、弹着时换元素 立刻换,移开 / 按下 / 按键 / 滚动收起;键盘聚焦立刻弹,提示不是元素自己的名字时挂 aria-describedby - 代码阅读里所有 title(种类图标、块按钮、筛选栏、问答区、中文名的「原名」)改成 data-tooltip; SelectMenu 的 title 属性改名 tooltip;看板工单详情原来包一层的 Tooltip 也改成 data-tooltip - 何时弹 / 收的规则抽成纯函数加测试;更新代码阅读 spec
This commit is contained in:
1 parent
15790ce7c8
commit
ec34ace7cf
17 files changed
+399
-187
No files matched your search
@@ -115,7 +115,7 @@
|
||||
- 每一句是一块:导入、函数、类、枚举、异常、类型、字段、常量、变量、条件、否则、循环、尝试、捕获、最终、返回、语句、注释。
|
||||
- 嵌套的块放在父块下面、往右缩进;每一层左边一条竖线(颜色 `--vscode-editorIndentGuide-background`)。
|
||||
- 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类图标、内容、右上角一排图标按钮。
|
||||
- 种类图标(原型 `kindBadge`、画板「Prototype / 块种类图标」):块左边只画图标、不带字,和一行字等高(20px 的框,图形 16px,lucide 的线宽 2.2),颜色是种类色;种类的名字(翻译插件给 `kind` 值起的,见「筛选」;库规则的种类是规则给的 `badge.label`)写在悬停提示(`title`,同块右上角按钮的悬停提示)和 `aria-label` 里,`role="img"`。和折叠箭头、内容同一行顶对齐。每种内置种类由插件定图标、颜色、名字:图标从插件定义的一组图标里取(见下),内置种类 → 图标只在 webview 的一处(`kindIcons.tsx` 的 `KIND_ICONS`);颜色在 `codeReader.css` 的 `--cr-kind-*`(见「颜色」);名字由翻译插件给。同一类语义共用一个颜色,图标区分具体是哪种:
|
||||
- 种类图标(原型 `kindBadge`、画板「Prototype / 块种类图标」):块左边只画图标、不带字,和一行字等高(20px 的框,图形 16px,lucide 的线宽 2.2),颜色是种类色;种类的名字(翻译插件给 `kind` 值起的,见「筛选」;库规则的种类是规则给的 `badge.label`)写在悬停提示(见下「悬停提示」,同块右上角按钮)和 `aria-label` 里,`role="img"`。和折叠箭头、内容同一行顶对齐。每种内置种类由插件定图标、颜色、名字:图标从插件定义的一组图标里取(见下),内置种类 → 图标只在 webview 的一处(`kindIcons.tsx` 的 `KIND_ICONS`);颜色在 `codeReader.css` 的 `--cr-kind-*`(见「颜色」);名字由翻译插件给。同一类语义共用一个颜色,图标区分具体是哪种:
|
||||
|
||||
| 种类 | 图标 | 种类 | 图标 | 种类 | 图标 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
@@ -129,7 +129,8 @@
|
||||
库规则的种类用规则给的图标(`KindBadge.icon`):SQLAlchemy 数据表 `database`、数据类 `package`、Celery 应用 `server-cog`、定时任务 `calendar-clock`、FastAPI 接口 `globe`。
|
||||
|
||||
这组图标由插件自己定义,是一个封闭的集合:契约里的 `KIND_ICON_NAMES`(上表 18 个加库规则用的 5 个,lucide 的 kebab-case 名字),类型 `KindIconName` 是它们的联合,`KindBadge.icon` 只能写其中一个,写别的是类型错误。webview 给每个名字配一个逐个 import 的 lucide 图标组件(`kindIcons.tsx` 的 `KIND_ICON_COMPONENTS`,打包只带这些,预览脚本约 157 kB);webview 的 `KindIconName` 照契约手抄,`kindIconNames.test.ts` 核对两边一样。规则要新图标就把名字加进这组、在 webview 配上图标,没有按任意名字找图标的路。
|
||||
- 右上角的图标按钮(原型组件 `Reader / BlockTools`,普通块、属性表块、库规则的卡片共用):只有图标、不带字,名字写在悬停提示(`title`)和 `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 能走到,回车 / 空格切换。
|
||||
- 右上角的图标按钮(原型组件 `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` 挂到元素上,读屏当描述念。
|
||||
- 种类图标和底色按种类区分(颜色见「颜色」);注释块虚线框。
|
||||
- 内容两种状态,每块单独切:
|
||||
- 预览:函数、类、枚举、异常、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。一行译文的块带说明(Python 字段的属性 docstring)时,说明按 Markdown 排在译文下面。
|
||||
@@ -1391,7 +1392,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit
|
||||
- 页面(`webview-ui/.../displayNames.test.tsx`,渲染成 HTML):只收这个版本的、下标和长度都对上才换、用到的地方写中文名和「原名 x」悬停提示、下标照旧、定义处后面淡淡的原名、框里也换、单击时筛选栏写中文名且当前那一处按下标认。
|
||||
- 真 VS Code(Pylance):routes.py 的条件读成「如果 当前用户.role 不等于 UserRole.管理员」(管理员来自 models.py),参数表「当前用户 current_user」,docstring 兜底的「要删的那一项的编号」,单击高亮、⌘↓ 跳、⌘ 点击「管理员」跳进 models.py 的预览(「管理员 ADMIN」,没有说明列),改文档时中文名一帧都没少。
|
||||
- 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中;库规则按语言过滤、保持注册顺序,注销后不再给。
|
||||
- 库规则(宿主):导入了库(`from sqlalchemy.orm import …`、`import sqlalchemy as sa`、`import os, sqlalchemy`、函数里的导入)才启用,`sqlalchemy_utils`、相对导入 `.sqlalchemy` 不算;先注册的规则优先;规则抛错、替身不合约定时记下来、接着问下一个、都没认照常显示,document 消息不带 error;编辑范围是整个类、没并进来的子块(夹在中间的注释、两个字段中间的方法)照源码顺序留下,卡片「编辑」写回整个类,能编辑的范围只在块和它的子孙之间重叠;筛选的种类计数、名字取规则的标签;`rules/` 下的非测试文件只 import contract。
|
||||
- 库规则(宿主):导入了库(`from sqlalchemy.orm import …`、`import sqlalchemy as sa`、`import os, sqlalchemy`、函数里的导入)才启用,`sqlalchemy_utils`、相对导入 `.sqlalchemy` 不算;先注册的规则优先;规则抛错、替身不合约定时记下来、接着问下一个、都没认照常显示,document 消息不带 error;编辑范围是整个类、没并进来的子块(夹在中间的注释、两个字段中间的方法)照源码顺序留下,卡片「编辑」写回整个类,能编辑的范围只在块和它的子孙之间重叠;筛选的种类计数、名字取规则的标签;规则自己报的筛选属性名字取规则的 `attributeNames`(翻译插件给了的以它为准,`attributes.test.ts` 的合并、`documentMessage.test.ts` 的「请求方法」),规则起名时抛错记下来、照写 id;`rules/` 下的非测试文件只 import contract。
|
||||
- 同一行不在两处能改(webview `editing.test.ts`):卡片编辑时盖住它留下的子块(字段中间的方法连同主体),跳到被盖住的块改闪卡片;只编辑头部的块不盖住子块,一行写完的块盖住同一行的子块;点「编辑」时重叠的块退出编辑;任意点法下正在编辑、画出来的块两两不重叠。
|
||||
- SQLAlchemy 规则:luffy `referral/models.py` 的节选(`fixtures/sqlalchemyModels.py`)里 `ReferralRelation` 的属性表逐字同上面的样例,其余模型覆盖 `Mapped[bool]`、`int | None` 可空、`default=None`、`server_default`、别名的 `PgUUID`、`_enum` 辅助函数、关系、唯一约束、删除时置空 / 级联删除;枚举类、函数照常显示;卡片的编辑范围是整个类、每行只在一块里;两个字段中间写着的方法照常是子块、卡片「编辑」写回整个类、字段前面的注释并进卡片、讲方法的注释留下;类名、字段名的定义范围和带下标的词都指回源码原文;webview 里跳到类名闪整块、跳到字段名只闪字段表里那一行并先展开「字段」那一行,卡片默认折叠;`import sqlalchemy as sa` 的老写法 `Column`、类型全名、只写注解的字段、不认得的类型照写法、docstring 当说明、方法留作子块。
|
||||
- 库规则的引用:引用标的不是自己、规则没给 `resolve`、段没带下标时当替身不合约定;查引用交给标它的规则,规则不在、没给 `resolve`、出错时记下来、当作没有定义;视图数据里的引用(下标 → 引用)只从规则认领的块的属性表里取,查定义时是引用的问规则、其余问语言扩展,只对记下它的那个文档版本有效(`editor/definitionSources.test.ts`)。
|
||||
@@ -1413,8 +1414,9 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit
|
||||
- 菜单(`explorer/rowMenus.test.ts`):标题栏先「选工单」、审查时才有「显示全部」、再「刷新」,字和图标;审查时注释按钮不出、复制照旧;命令面板里「显示全部」只在审查时有。
|
||||
- 工作副本(`test/git/reviewWorktree.test.ts`,真的 git:bare 的 origin 上推 `refs/pull/7/head`):拉 PR 的提交建不挂分支的 worktree(父目录不在也建、本机不多出分支),目录手删后同一路径能再建;快进:PR 往前走了且干净 → 快进,已经最新 / 本地在它之上 → 不动,只有未跟踪的文件照样快进,有没提交的改动、本地另有提交 → 不动且改动留着,目录不是工作副本的根 → 报错、外面的仓库不动。
|
||||
- 复制出什么(`explorer/copyText.test.ts`):文件、目录的名字 / 相对路径 / 绝对路径;不带后缀只去最后一个扩展名(`a.test.ts`、`archive.tar.gz`),`.env` 原样、`.env.local` → `.env`、没有扩展名的原样;相对路径总用 `/`,绝对路径照本机写法(Windows 反斜杠)。
|
||||
- 块右上角的图标按钮(webview `BlockTools.test.tsx`,渲染成 HTML):一组一个按钮、没有字、`title` 和 `aria-label` 是「编辑」/「预览」、有图标、编辑时带按下标记;给了「问 AI」时它排在前面、只有图标、问答区展开时带按下标记和 `aria-expanded`。
|
||||
- 块的种类图标(webview `kindIcons.test.tsx`,渲染成 HTML):内置种类的图标和原型一一对应;这组里每个名字画的就是 lucide 里同名的图标;webview 配了图标的名字和契约的 `KIND_ICON_NAMES` 一样、没有多的(`kindIconNames.test.ts`);只有图标、没有字,`role="img"`,`title` 和 `aria-label` 是种类名,20px 的框、16px 的图形;库规则的卡片用规则给的图标。各库规则的测试核对 `badge` 的名字、颜色、图标。
|
||||
- 块右上角的图标按钮(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`。亮、暗两套颜色都看过。
|
||||
- 问 AI:
|
||||
- 位置记号(`qa/anchor.test.ts`,真实的 Python 解析):有名字 / 没名字的块的记号、同一层重复的编号、两个函数里一样的条件互不影响;上面插几行、改空白记号不变;改别的函数的名字只动它和它里面的块;没名字的块自己的源码改了记号变、有名字的块改签名不变。
|
||||
- 存法(`qa/qaMap.test.ts`):记一次、删一次、删光不挂 / 不记;文件缺失、解析失败、不是对象当没有;认不出的块和问答跳过、没会话 id 的照认;再问时接着这块最近那条的会话(删了最近的接着剩下里最近的,没问过、没记会话 id 的重新开);用哪个 profile 答的跟着这条存、读得回来,没有的、空的、不是字符串的当没有;手改的顺序、重复的块读进来整理好;写出来排好序、两格缩进、读得回来、删光留 `{}`;改名、挪目录、删除跟着走。图片:文件名跟着这条存、读得回来,跑到别处的路径、不是字符串的名字跳过;删一次问答它的图片跟着删、别的留着;源文件删了图片跟着删,改名、挪目录图片不动。
|
||||
|
||||
@@ -42,14 +42,14 @@ describe('块下面的问答区', () => {
|
||||
expect(html.match(/data-history=""/g)).toHaveLength(2)
|
||||
expect(text(html)).toContain('为什么普通用户不能看别人的资料?3 天前')
|
||||
expect(text(html)).toContain('UserRole 还有哪些角色?昨天')
|
||||
expect(html.match(/title="已理解,删除" aria-label="已理解,删除"/g)).toHaveLength(2)
|
||||
expect(html.match(/data-tooltip="已理解,删除" aria-label="已理解,删除"/g)).toHaveLength(2)
|
||||
expect(text(html)).toContain('这是权限控制:不是管理员、又不是查自己时抛 Forbidden。')
|
||||
expect(html).toContain('<hr class="cr-qa-divider"/>')
|
||||
expect(html).toContain('data-current=""')
|
||||
expect(html).toContain('data-streaming=""><p><code>Forbidden</code> 定义在 <strong>core</strong></p>')
|
||||
expect(text(html)).toContain('读取 exceptions.py')
|
||||
expect(html).toContain('placeholder="继续问… Enter 发送,Esc 收起"')
|
||||
expect(html).toContain('title="停止" aria-label="停止"')
|
||||
expect(html).toContain('data-tooltip="停止" aria-label="停止"')
|
||||
expect(html).not.toContain('aria-label="发送"')
|
||||
})
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('块下面的问答区', () => {
|
||||
const trigger = row.slice(row.lastIndexOf('<button', select), row.indexOf('</button>', select))
|
||||
expect(trigger).toContain('aria-haspopup="listbox"')
|
||||
expect(trigger).toContain('aria-expanded="false"')
|
||||
expect(trigger).toContain('title="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"')
|
||||
expect(trigger).toContain('data-tooltip="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"')
|
||||
expect(text(trigger)).toBe('sonnet')
|
||||
const none = render({ open: true, profiles: { options: [], selected: '' } })
|
||||
expect(text(none.slice(none.indexOf('cr-qa-profile')))).toContain('默认')
|
||||
|
||||
@@ -19,11 +19,11 @@ function Thumbs({ urls, pending, onOpen, onRemove }: { urls: string[], pending?:
|
||||
{urls.map((url, index) => (
|
||||
// 同一张图可能贴两次、地址一样;缩略图没有自己的状态,按位置认就行
|
||||
<span key={index} className="cr-qa-thumb">
|
||||
<button type="button" className="cr-qa-thumb-open" title="看大图" aria-label={`看第 ${index + 1} 张图片的大图`} onClick={() => onOpen(url)}>
|
||||
<button type="button" className="cr-qa-thumb-open" data-tooltip="看大图" aria-label={`看第 ${index + 1} 张图片的大图`} onClick={() => onOpen(url)}>
|
||||
<img src={url} alt="" />
|
||||
</button>
|
||||
{onRemove && (
|
||||
<button type="button" className="cr-qa-thumb-remove" title="去掉这张图片" aria-label={`去掉第 ${index + 1} 张图片`} onClick={() => onRemove(index)}>
|
||||
<button type="button" className="cr-qa-thumb-remove" data-tooltip="去掉这张图片" aria-label={`去掉第 ${index + 1} 张图片`} onClick={() => onRemove(index)}>
|
||||
<X size={10} strokeWidth={2.4} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
@@ -55,7 +55,7 @@ function ImagePreview({ url, onClose }: { url: string, onClose: () => void }): R
|
||||
return createPortal(
|
||||
<div className="cr-qa-lightbox" role="dialog" aria-modal="true" aria-label="图片大图" onClick={onClose} onKeyDown={onKeyDown}>
|
||||
<img src={url} alt="" />
|
||||
<button ref={close} type="button" className="cr-qa-lightbox-close" title="关闭(Esc)" aria-label="关闭大图">
|
||||
<button ref={close} type="button" className="cr-qa-lightbox-close" data-tooltip="关闭(Esc)" aria-label="关闭大图">
|
||||
<X size={16} strokeWidth={1.8} aria-hidden />
|
||||
</button>
|
||||
</div>,
|
||||
@@ -71,16 +71,16 @@ function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView
|
||||
<div className="cr-qa-entry" data-history="">
|
||||
<div className="cr-qa-head">
|
||||
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
|
||||
<button type="button" className="cr-qa-question" aria-expanded={expanded} title={expanded ? '收起回答' : '展开整段回答'} onClick={toggle}>
|
||||
<button type="button" className="cr-qa-question" aria-expanded={expanded} data-tooltip={expanded ? '收起回答' : '展开整段回答'} onClick={toggle}>
|
||||
{entry.question}
|
||||
</button>
|
||||
<span className="cr-qa-when">
|
||||
<time dateTime={entry.createdAt} title={new Date(entry.createdAt).toLocaleString()}>
|
||||
<time dateTime={entry.createdAt} data-tooltip={new Date(entry.createdAt).toLocaleString()}>
|
||||
{relativeTime(entry.createdAt, now)}
|
||||
</time>
|
||||
{entry.profile && <span title="用这个 Claude Code 配置(profile)答的">{` · ${entry.profile}`}</span>}
|
||||
{entry.profile && <span data-tooltip="用这个 Claude Code 配置(profile)答的">{` · ${entry.profile}`}</span>}
|
||||
</span>
|
||||
<button type="button" className="cr-tool cr-qa-delete" title="已理解,删除" aria-label="已理解,删除" onClick={onDelete}>
|
||||
<button type="button" className="cr-tool cr-qa-delete" data-tooltip="已理解,删除" aria-label="已理解,删除" onClick={onDelete}>
|
||||
<Trash2 size={13} strokeWidth={1.8} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
@@ -218,17 +218,17 @@ function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, on
|
||||
options={profiles.options.map(option => ({ value: option.id, label: option.label }))}
|
||||
onChange={onSelectProfile}
|
||||
placeholder="默认"
|
||||
title="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"
|
||||
tooltip="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"
|
||||
className="cr-qa-profile"
|
||||
/>
|
||||
{running
|
||||
? (
|
||||
<button type="button" className="cr-tool" title="停止" aria-label="停止" onClick={() => onStop(running.askId)}>
|
||||
<button type="button" className="cr-tool" data-tooltip="停止" aria-label="停止" onClick={() => onStop(running.askId)}>
|
||||
<CircleStop size={14} strokeWidth={1.8} aria-hidden />
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<button type="button" className="cr-tool" title="发送(Enter)" aria-label="发送" disabled={value.trim() === ''} onClick={send}>
|
||||
<button type="button" className="cr-tool" data-tooltip="发送(Enter)" aria-label="发送" disabled={value.trim() === ''} onClick={send}>
|
||||
<SendHorizontal size={14} strokeWidth={1.8} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,8 @@ describe('块右上角的图标按钮', () => {
|
||||
expect(html).toMatch(/^<div[^>]* role="group" aria-label="块操作"/)
|
||||
expect(visibleText(html)).toBe('')
|
||||
expect(html.match(/<button/g)).toHaveLength(1)
|
||||
expect(html).toContain(`title="${label}" aria-label="${label}"`)
|
||||
expect(html).toContain(`data-tooltip="${label}" aria-label="${label}"`)
|
||||
expect(html).not.toContain(' title=')
|
||||
expect(html).toMatch(/<button[^>]*><svg/)
|
||||
expect(html.includes('data-active')).toBe(editing)
|
||||
})
|
||||
@@ -25,9 +26,9 @@ describe('块右上角的图标按钮', () => {
|
||||
expect(visibleText(html)).toBe('')
|
||||
const buttons = [...html.matchAll(/<button[^>]*>/g)].map(match => match[0])
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons[0]).toContain('title="问 AI" aria-label="问 AI"')
|
||||
expect(buttons[0]).toContain('data-tooltip="问 AI" aria-label="问 AI"')
|
||||
expect(buttons[0]).toContain(`aria-expanded="${asking}"`)
|
||||
expect(buttons[0].includes('data-active')).toBe(asking)
|
||||
expect(buttons[1]).toContain('title="编辑"')
|
||||
expect(buttons[1]).toContain('data-tooltip="编辑"')
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import type { ReactElement } from 'react'
|
||||
import { Eye, Pencil, Sparkles } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* 块右上角的一排图标按钮(普通块、属性表、库规则的卡片共用):只放图标,名字写在悬停提示(title)和读屏用的 aria-label 里。
|
||||
* 块右上角的一排图标按钮(普通块、属性表、库规则的卡片共用):只放图标,名字写在悬停提示(data-tooltip)和读屏用的 aria-label 里。
|
||||
* 「问 AI」:点开 / 收起块下面的问答区,展开时带按下的底色。
|
||||
* 「编辑 / 预览」切换:预览时是铅笔「编辑」,编辑时是眼睛「预览」、带按下的底色。
|
||||
*/
|
||||
@@ -23,7 +23,7 @@ export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk }: {
|
||||
type="button"
|
||||
className="cr-tool flex h-5 w-6 items-center justify-center rounded"
|
||||
data-active={asking ? '' : undefined}
|
||||
title="问 AI"
|
||||
data-tooltip="问 AI"
|
||||
aria-label="问 AI"
|
||||
aria-expanded={asking === true}
|
||||
onClick={onToggleAsk}
|
||||
@@ -35,7 +35,7 @@ export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk }: {
|
||||
type="button"
|
||||
className="cr-tool flex h-5 w-6 items-center justify-center rounded"
|
||||
data-active={editing ? '' : undefined}
|
||||
title={label}
|
||||
data-tooltip={label}
|
||||
aria-label={label}
|
||||
onClick={onToggleEdit}
|
||||
>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function FilterBar({ attributes, excluded, view, highlight, onChange, tra
|
||||
<span className="ml-auto flex h-6 min-w-0 items-center gap-1.5 pl-2 text-xs whitespace-nowrap text-[var(--vscode-descriptionForeground)]">
|
||||
{highlight && (
|
||||
<>
|
||||
<span className="cr-occurrence-name max-w-[240px] min-w-0 truncate" data-occurrence="normal" title={highlight.name}>{highlight.name}</span>
|
||||
<span className="cr-occurrence-name max-w-[240px] min-w-0 truncate" data-occurrence="normal" data-tooltip={highlight.name}>{highlight.name}</span>
|
||||
<span aria-hidden>·</span>
|
||||
{occurrenceCount(highlight)}
|
||||
<span className="mx-1.5 h-3 border-l border-[var(--vscode-panel-border)]" aria-hidden />
|
||||
@@ -66,7 +66,7 @@ export function FilterBar({ attributes, excluded, view, highlight, onChange, tra
|
||||
value={translatorId}
|
||||
options={translators.map(t => ({ value: t.id, label: t.label }))}
|
||||
onChange={onSelectTranslator}
|
||||
title="选择翻译方式"
|
||||
tooltip="选择翻译方式"
|
||||
className="w-36 text-left"
|
||||
/>
|
||||
</span>
|
||||
@@ -165,7 +165,7 @@ function FacetMenu({ facet, onToggle, onSetAll }: {
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
title={label.text}
|
||||
data-tooltip={label.text}
|
||||
onClick={() => open ? setOpen(false) : openAt(0)}
|
||||
onKeyDown={onButtonKeyDown}
|
||||
>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function SegmentText({ segments }: { segments: Inline[] }): ReactElement
|
||||
data-occurrence={highlight && segmentOccurrence(piece, highlight.occurrences)}
|
||||
data-current={highlight && isCurrent(piece, highlight) ? '' : undefined}
|
||||
data-name={shown?.name}
|
||||
title={shown && `原名 ${piece.text}`}
|
||||
data-tooltip={shown && `原名 ${piece.text}`}
|
||||
>
|
||||
{shown ? shown.name : piece.text}
|
||||
{shown?.definition && <span className="cr-original">{piece.text}</span>}
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('页面上的中文名', () => {
|
||||
{ text: 'ADMIN', role: 'variable', offset: 130 },
|
||||
], [USE, ADMIN])
|
||||
expect(html.replace(/<[^>]*>/g, '')).toBe('如果 当前用户.role 不等于 UserRole.管理员')
|
||||
expect(html).toContain('<span class="cr-role-variable" data-offset="90" data-name="当前用户" title="原名 current_user">当前用户</span>')
|
||||
expect(html).toContain('<span class="cr-role-variable" data-offset="90" data-name="当前用户" data-tooltip="原名 current_user">当前用户</span>')
|
||||
expect(html).toContain('<span class="cr-role-variable" data-offset="103">role</span>')
|
||||
expect(html).not.toContain('cr-original')
|
||||
})
|
||||
@@ -63,8 +63,8 @@ describe('页面上的中文名', () => {
|
||||
{ text: 'current_user', role: 'variable', offset: 40 },
|
||||
{ style: 'iteration', label: '列表', segments: [{ text: 'ADMIN', role: 'variable', offset: 130 }] },
|
||||
], [CURRENT_USER, ADMIN])
|
||||
expect(html).toContain('data-offset="40" data-name="当前用户" title="原名 current_user">当前用户<span class="cr-original">current_user</span></span>')
|
||||
expect(html).toContain('data-offset="130" data-name="管理员" title="原名 ADMIN">管理员</span>')
|
||||
expect(html).toContain('data-offset="40" data-name="当前用户" data-tooltip="原名 current_user">当前用户<span class="cr-original">current_user</span></span>')
|
||||
expect(html).toContain('data-offset="130" data-name="管理员" data-tooltip="原名 ADMIN">管理员</span>')
|
||||
})
|
||||
|
||||
it('单击显示成中文名的名字:筛选栏写中文名,当前那一处按点中的下标认', () => {
|
||||
|
||||
@@ -41,10 +41,11 @@ describe('块的种类图标', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('只画图标不带字,名字在悬停提示和 aria-label 里;20px 的框、16px 的图形', () => {
|
||||
it('只画图标不带字,名字在悬停提示(data-tooltip,不用原生 title)和 aria-label 里;20px 的框、16px 的图形', () => {
|
||||
const html = render({ kind: 'function' }, '函数')
|
||||
expect(visibleText(html)).toBe('')
|
||||
expect(html).toMatch(/^<span class="cr-kind [^"]*size-5[^"]*" role="img" title="函数" aria-label="函数"><svg/)
|
||||
expect(html).toMatch(/^<span class="cr-kind [^"]*size-5[^"]*" role="img" data-tooltip="函数" aria-label="函数"><svg/)
|
||||
expect(html).not.toContain(' title=')
|
||||
expect(html).toContain('lucide-square-function')
|
||||
expect(html).toContain('width="16" height="16"')
|
||||
})
|
||||
@@ -52,6 +53,6 @@ describe('块的种类图标', () => {
|
||||
it('库规则的卡片用规则给的图标,名字是规则给的', () => {
|
||||
const html = render({ kind: 'sqlalchemy.table', badge: { label: '数据表', color: { dark: '#e8a860', light: '#a3590d' }, icon: 'database' } }, '数据表')
|
||||
expect(html).toContain('lucide-database')
|
||||
expect(html).toContain('title="数据表" aria-label="数据表"')
|
||||
expect(html).toContain('data-tooltip="数据表" aria-label="数据表"')
|
||||
})
|
||||
})
|
||||
@@ -82,12 +82,12 @@ export const KIND_ICONS: Record<BlockKind, KindIconName> = {
|
||||
/**
|
||||
* 块左边的种类:只画图标,和一行字等高(20px 的框、16px 的图形),颜色是种类色(--kind)。图标是库规则给的(badge.icon),
|
||||
* 没有就是内置种类的(KIND_ICONS,没带 badge 的块 kind 是 BlockKind);名字(翻译插件给种类起的,库规则的种类是规则给的)
|
||||
* 写在悬停提示(title)和读屏念的 aria-label 里。
|
||||
* 写在悬停提示(data-tooltip,见 components/ui/tooltip.tsx)和读屏念的 aria-label 里。
|
||||
*/
|
||||
export function KindIcon({ block, name }: { block: Pick<BlockView, 'kind' | 'badge'>, name: string }): ReactElement {
|
||||
const Icon = KIND_ICON_COMPONENTS[block.badge?.icon ?? KIND_ICONS[block.kind as BlockKind]]
|
||||
return (
|
||||
<span className="cr-kind flex size-5 shrink-0 items-center justify-center" role="img" title={name} aria-label={name}>
|
||||
<span className="cr-kind flex size-5 shrink-0 items-center justify-center" role="img" data-tooltip={name} aria-label={name}>
|
||||
<Icon size={16} strokeWidth={2.2} aria-hidden />
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||
import { TooltipHost } from '@/components/ui/tooltip'
|
||||
import { App } from './App'
|
||||
import './codeReader.css'
|
||||
|
||||
@@ -8,6 +9,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
<TooltipHost />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -9,7 +9,6 @@ import { isSessionMissing } from '../lib/missingSession'
|
||||
import { toReadOnlySchema } from '../lib/readOnlySchema'
|
||||
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
|
||||
import { PropertyGrid } from './property-grid'
|
||||
import { Tooltip } from './ui/tooltip'
|
||||
|
||||
/** 会话 id 在共享状态里、本机却没有会话文件时,各会话行的统一说明。 */
|
||||
const MISSING_SESSION_DESC = '本机没有该会话文件(会话在别的机器上创建),无法 resume'
|
||||
@@ -839,86 +838,81 @@ export function IssueDetailPanel({
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
<Tooltip label="在浏览器里打开">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenInBrowser(issue.htmlUrl)}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
{issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
data-tooltip="在浏览器里打开"
|
||||
onClick={() => onOpenInBrowser(issue.htmlUrl)}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
{issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
|
||||
</button>
|
||||
{/* Close/delete write to gitea by number — hide for youtrack cards to
|
||||
avoid acting on a gitea issue of the same number. */}
|
||||
{issue.source !== 'youtrack' && !readOnly && (
|
||||
<>
|
||||
{issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && (
|
||||
<Tooltip label={`接管 ${issue.handoffFrom ?? '?'} 的移交`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isHandoffRunning(issue.number)}
|
||||
onClick={() => onAcceptHandoff(issue.number)}
|
||||
aria-label="接管"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded border border-green-500/60 bg-green-500/10 px-2 py-1 text-xs text-green-500 hover:bg-green-500/20 disabled:opacity-50"
|
||||
>
|
||||
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
|
||||
接管
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
data-tooltip={`接管 ${issue.handoffFrom ?? '?'} 的移交`}
|
||||
disabled={isHandoffRunning(issue.number)}
|
||||
onClick={() => onAcceptHandoff(issue.number)}
|
||||
aria-label="接管"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded border border-green-500/60 bg-green-500/10 px-2 py-1 text-xs text-green-500 hover:bg-green-500/20 disabled:opacity-50"
|
||||
>
|
||||
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
|
||||
接管
|
||||
</button>
|
||||
)}
|
||||
{!issue.handoffAttachmentId && issue.column !== 'done' && ((issue.assignees ?? []).length === 0 || (me !== undefined && (issue.assignees ?? []).includes(me))) && (
|
||||
<Tooltip label="移交给同事">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isHandoffRunning(issue.number)}
|
||||
onClick={() => onStartHandoff(issue.number)}
|
||||
aria-label="移交"
|
||||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-blue-500/60 hover:bg-blue-500/10 hover:text-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRightLeft className="size-3.5" />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
data-tooltip="移交给同事"
|
||||
disabled={isHandoffRunning(issue.number)}
|
||||
onClick={() => onStartHandoff(issue.number)}
|
||||
aria-label="移交"
|
||||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-blue-500/60 hover:bg-blue-500/10 hover:text-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRightLeft className="size-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
{issue.handoffAttachmentId && !(me !== undefined && (issue.assignees ?? []).includes(me)) && (
|
||||
<Tooltip label={`${issue.handoffFrom ?? '?'} 已移交,等 ${(issue.assignees ?? []).join(', ') || '?'} 接管`}>
|
||||
<span className="inline-flex shrink-0 items-center rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 text-[10px] opacity-70">
|
||||
待接管
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 text-[10px] opacity-70"
|
||||
data-tooltip={`${issue.handoffFrom ?? '?'} 已移交,等 ${(issue.assignees ?? []).join(', ') || '?'} 接管`}
|
||||
>
|
||||
待接管
|
||||
</span>
|
||||
)}
|
||||
{(issue.column === 'in-progress' || issue.column === 'review') && (
|
||||
<Tooltip label="重置为待办(停会话、删 worktree 和未合并 PR)">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResetToTodo(issue.number)}
|
||||
aria-label="重置为待办"
|
||||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="关闭工单(Gitea 里可重开)">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCloseIssue(issue.number)}
|
||||
aria-label="关闭工单"
|
||||
data-tooltip="重置为待办(停会话、删 worktree 和未合并 PR)"
|
||||
onClick={() => onResetToTodo(issue.number)}
|
||||
aria-label="重置为待办"
|
||||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
|
||||
>
|
||||
<CircleSlash className="size-3.5" />
|
||||
<RotateCcw className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="删除工单(不可撤销)">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => issue && onDeleteIssue?.(issue.number)}
|
||||
aria-label="删除工单"
|
||||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-red-500/60 hover:bg-red-500/10 hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-tooltip="关闭工单(Gitea 里可重开)"
|
||||
onClick={() => onCloseIssue(issue.number)}
|
||||
aria-label="关闭工单"
|
||||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
|
||||
>
|
||||
<CircleSlash className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-tooltip="删除工单(不可撤销)"
|
||||
onClick={() => issue && onDeleteIssue?.(issue.number)}
|
||||
aria-label="删除工单"
|
||||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-red-500/60 hover:bg-red-500/10 hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -424,7 +424,7 @@ function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTa
|
||||
<SelectMenu
|
||||
value={pickedProfile}
|
||||
onChange={setUserPicked}
|
||||
title="换 profile 后点行打开;已打开的终端下次才生效"
|
||||
tooltip="换 profile 后点行打开;已打开的终端下次才生效"
|
||||
variant="ghost"
|
||||
options={[
|
||||
...(storedMissing
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface SelectMenuProps {
|
||||
options: SelectMenuOption[]
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
title?: string
|
||||
tooltip?: string
|
||||
disabled?: boolean
|
||||
variant?: 'input' | 'ghost'
|
||||
className?: string
|
||||
@@ -60,7 +60,7 @@ export function SelectMenu({
|
||||
options,
|
||||
onChange,
|
||||
placeholder = '请选择',
|
||||
title,
|
||||
tooltip,
|
||||
disabled = false,
|
||||
variant = 'input',
|
||||
className,
|
||||
@@ -265,7 +265,7 @@ export function SelectMenu({
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
data-tooltip={tooltip}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={onTriggerClick}
|
||||
@@ -334,8 +334,8 @@ export function SelectMenu({
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate" title={opt.label}>{opt.label}</span>
|
||||
{opt.hint && <span className="truncate text-[10px] opacity-70" title={opt.hint}>{opt.hint}</span>}
|
||||
<span className="truncate" data-tooltip={opt.label}>{opt.label}</span>
|
||||
{opt.hint && <span className="truncate text-[10px] opacity-70" data-tooltip={opt.hint}>{opt.hint}</span>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Tip, TooltipController } from './tooltip'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTooltipController, placeTooltip, SHOW_DELAY_MS } from './tooltip'
|
||||
|
||||
interface Anchor { text: string }
|
||||
|
||||
const icon: Anchor = { text: '函数' }
|
||||
const route: Anchor = { text: '接口' }
|
||||
const blank: Anchor = { text: '' }
|
||||
|
||||
function setup(): { controller: TooltipController<Anchor>, changes: Array<Tip<Anchor> | null> } {
|
||||
const changes: Array<Tip<Anchor> | null> = []
|
||||
const controller = createTooltipController<Anchor>(anchor => anchor.text, tip => changes.push(tip))
|
||||
return { controller, changes }
|
||||
}
|
||||
|
||||
describe('悬停提示什么时候出、什么时候收', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('鼠标停在带提示的元素上,过一小会儿才弹', () => {
|
||||
const { controller, changes } = setup()
|
||||
controller.hover(icon)
|
||||
vi.advanceTimersByTime(SHOW_DELAY_MS - 1)
|
||||
expect(changes).toEqual([])
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(changes).toEqual([{ anchor: icon, text: '函数' }])
|
||||
})
|
||||
|
||||
it('在同一个元素里移动(划过图标的线条和空白)不重新计时', () => {
|
||||
const { controller, changes } = setup()
|
||||
controller.hover(icon)
|
||||
vi.advanceTimersByTime(SHOW_DELAY_MS - 100)
|
||||
controller.hover(icon)
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(changes).toEqual([{ anchor: icon, text: '函数' }])
|
||||
})
|
||||
|
||||
it('没等到就移开,不弹', () => {
|
||||
const { controller, changes } = setup()
|
||||
controller.hover(icon)
|
||||
vi.advanceTimersByTime(SHOW_DELAY_MS / 2)
|
||||
controller.hover(null)
|
||||
vi.advanceTimersByTime(SHOW_DELAY_MS)
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('弹着时移到另一个带提示的元素上立刻换,移到没有提示的地方就收', () => {
|
||||
const { controller, changes } = setup()
|
||||
controller.hover(icon)
|
||||
vi.advanceTimersByTime(SHOW_DELAY_MS)
|
||||
controller.hover(route)
|
||||
expect(changes.at(-1)).toEqual({ anchor: route, text: '接口' })
|
||||
controller.hover(null)
|
||||
expect(changes.at(-1)).toBeNull()
|
||||
controller.hover(icon)
|
||||
expect(changes.at(-1)).toBeNull()
|
||||
vi.advanceTimersByTime(SHOW_DELAY_MS)
|
||||
expect(changes.at(-1)).toEqual({ anchor: icon, text: '函数' })
|
||||
})
|
||||
|
||||
it('按下鼠标、按键、滚动就收;鼠标不离开这个元素不再弹,移出去再回来照常弹', () => {
|
||||
const { controller, changes } = setup()
|
||||
controller.hover(icon)
|
||||
vi.advanceTimersByTime(SHOW_DELAY_MS)
|
||||
controller.dismiss()
|
||||
expect(changes.at(-1)).toBeNull()
|
||||
controller.hover(icon)
|
||||
vi.advanceTimersByTime(SHOW_DELAY_MS)
|
||||
expect(changes.at(-1)).toBeNull()
|
||||
controller.hover(null)
|
||||
controller.hover(icon)
|
||||
vi.advanceTimersByTime(SHOW_DELAY_MS)
|
||||
expect(changes.at(-1)).toEqual({ anchor: icon, text: '函数' })
|
||||
})
|
||||
|
||||
it('等着弹的时候按下鼠标,这次不弹了', () => {
|
||||
const { controller, changes } = setup()
|
||||
controller.hover(icon)
|
||||
controller.dismiss()
|
||||
vi.advanceTimersByTime(SHOW_DELAY_MS)
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('键盘聚焦立刻弹,焦点离开就收;别的元素失焦不影响', () => {
|
||||
const { controller, changes } = setup()
|
||||
controller.focus(route)
|
||||
expect(changes).toEqual([{ anchor: route, text: '接口' }])
|
||||
controller.blur(icon)
|
||||
expect(changes).toHaveLength(1)
|
||||
controller.blur(route)
|
||||
expect(changes.at(-1)).toBeNull()
|
||||
})
|
||||
|
||||
it('提示文字是空的不弹', () => {
|
||||
const { controller, changes } = setup()
|
||||
controller.hover(blank)
|
||||
vi.advanceTimersByTime(SHOW_DELAY_MS)
|
||||
controller.focus(blank)
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('提示框放在哪', () => {
|
||||
const size = { width: 40, height: 18 }
|
||||
|
||||
it('上方放得下就在上方,水平对着元素中间', () => {
|
||||
expect(placeTooltip({ top: 100, bottom: 120, left: 50, width: 20 }, size, 900)).toEqual({ top: 76, left: 40 })
|
||||
})
|
||||
|
||||
it('上方放不下翻到下方', () => {
|
||||
expect(placeTooltip({ top: 10, bottom: 30, left: 50, width: 20 }, size, 900)).toEqual({ top: 36, left: 40 })
|
||||
})
|
||||
|
||||
it('左右钳在视口里,贴边留 8px', () => {
|
||||
expect(placeTooltip({ top: 100, bottom: 120, left: 0, width: 20 }, size, 900).left).toBe(8)
|
||||
expect(placeTooltip({ top: 100, bottom: 120, left: 890, width: 10 }, size, 900).left).toBe(852)
|
||||
})
|
||||
})
|
||||
@@ -1,95 +1,182 @@
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** 悬停多久才弹,避免扫过一排按钮时闪一串气泡。 */
|
||||
const SHOW_DELAY_MS = 300
|
||||
/** 气泡与触发元素之间的间距。 */
|
||||
/** 鼠标停多久才弹,扫过一排按钮、一行代码时不闪一串提示。 */
|
||||
export const SHOW_DELAY_MS = 500
|
||||
/** 提示框与元素之间的间距。 */
|
||||
const GAP_PX = 6
|
||||
/** 贴边留白,气泡不顶到 webview 边缘。 */
|
||||
/** 贴边留白,提示框不顶到 webview 边缘。 */
|
||||
const GUTTER_PX = 8
|
||||
|
||||
interface TooltipProps {
|
||||
/** 气泡文案;为空则不弹。 */
|
||||
label: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
/** 正在显示的提示:挂在哪个元素上、写什么。 */
|
||||
export interface Tip<A> {
|
||||
anchor: A
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface TooltipController<A> {
|
||||
/** 鼠标移到这个带提示的元素上;null 是移到了没有提示的地方或移出页面 */
|
||||
hover: (anchor: A | null) => void
|
||||
/** 键盘把焦点移到这个带提示的元素上 */
|
||||
focus: (anchor: A) => void
|
||||
/** 焦点离开这个元素 */
|
||||
blur: (anchor: A) => void
|
||||
/** 按下鼠标、按键、滚动:收起,鼠标不离开眼下这个元素就不再弹 */
|
||||
dismiss: () => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 悬停提示气泡。
|
||||
*
|
||||
* 原生 `title` 在 webview 里延迟长、样式是系统的,暗色主题下很突兀,
|
||||
* 这里自己渲染:portal 到 body,避开 overflow 容器裁剪;位置优先在上方,
|
||||
* 上方放不下就翻到下方,左右钳在视口内。
|
||||
* 悬停提示什么时候出、换成谁、什么时候收(不碰 DOM,TooltipHost 把页面事件喂进来):
|
||||
* 鼠标停在带提示的元素上 SHOW_DELAY_MS 后出,已经出着时移到另一个上立刻换;键盘聚焦立刻出;移开、失焦、dismiss 就收。
|
||||
*/
|
||||
export function Tooltip({ label, children, className }: TooltipProps): ReactElement {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const wrapRef = useRef<HTMLSpanElement | null>(null)
|
||||
export function createTooltipController<A>(textOf: (anchor: A) => string, onChange: (tip: Tip<A> | null) => void): TooltipController<A> {
|
||||
let hovered: A | null = null
|
||||
let shown: Tip<A> | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const set = (tip: Tip<A> | null): void => {
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
if (tip?.anchor === shown?.anchor && tip?.text === shown?.text)
|
||||
return
|
||||
shown = tip
|
||||
onChange(tip)
|
||||
}
|
||||
const show = (anchor: A): void => {
|
||||
const text = textOf(anchor)
|
||||
set(text ? { anchor, text } : null)
|
||||
}
|
||||
const schedule = (anchor: A): void => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(show, SHOW_DELAY_MS, anchor)
|
||||
}
|
||||
|
||||
return {
|
||||
hover(anchor) {
|
||||
if (anchor === hovered)
|
||||
return
|
||||
hovered = anchor
|
||||
if (anchor === null)
|
||||
set(null)
|
||||
else if (shown)
|
||||
show(anchor)
|
||||
else
|
||||
schedule(anchor)
|
||||
},
|
||||
focus: show,
|
||||
blur(anchor) {
|
||||
if (shown?.anchor === anchor)
|
||||
set(null)
|
||||
},
|
||||
dismiss: () => set(null),
|
||||
dispose: () => clearTimeout(timer),
|
||||
}
|
||||
}
|
||||
|
||||
/** 提示框放在哪:优先在元素上方,上方放不下翻到下方;水平对着元素中间,钳在视口内。 */
|
||||
export function placeTooltip(
|
||||
anchor: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'width'>,
|
||||
size: Pick<DOMRect, 'width' | 'height'>,
|
||||
viewportWidth: number,
|
||||
): { top: number, left: number } {
|
||||
const above = anchor.top - size.height - GAP_PX
|
||||
const centered = anchor.left + anchor.width / 2 - size.width / 2
|
||||
return {
|
||||
top: above >= GUTTER_PX ? above : anchor.bottom + GAP_PX,
|
||||
left: Math.max(GUTTER_PX, Math.min(centered, viewportWidth - GUTTER_PX - size.width)),
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面上带提示的元素:提示文字写在 data-tooltip 上。 */
|
||||
function anchorOf(target: EventTarget | null): Element | null {
|
||||
return target instanceof Element ? target.closest('[data-tooltip]:not([data-tooltip=""])') : null
|
||||
}
|
||||
|
||||
/** 提示是不是已经是元素的名字(aria-label 或它的字):是就不再挂成描述,读屏不念两遍。 */
|
||||
function isOwnName(anchor: Element, text: string): boolean {
|
||||
return anchor.getAttribute('aria-label') === text || anchor.textContent?.trim() === text
|
||||
}
|
||||
|
||||
/**
|
||||
* 整个 webview 唯一的悬停提示:页面根上挂一次,元素只写 data-tooltip="提示"(名字照旧写在 aria-label 里)。
|
||||
* 不用原生 title:macOS 上 VS Code 的 Electron 只在鼠标压着元素自身边缘时才弹原生提示,停在里面的图标、文字上不弹
|
||||
* (electron/electron#49843),样式也是系统的。这里画成 VS Code 悬停框的样子(--vscode-editorHoverWidget-*),
|
||||
* portal 到 body 不被 overflow 容器裁掉;键盘聚焦(:focus-visible)立刻弹,并用 aria-describedby 挂到元素上。
|
||||
*/
|
||||
export function TooltipHost(): ReactElement | null {
|
||||
const id = useId()
|
||||
const [tip, setTip] = useState<Tip<Element> | null>(null)
|
||||
const bubbleRef = useRef<HTMLDivElement | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
||||
const hide = useCallback((): void => {
|
||||
clearTimeout(timerRef.current)
|
||||
setVisible(false)
|
||||
}, [])
|
||||
|
||||
const show = useCallback((): void => {
|
||||
if (!label)
|
||||
return
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (wrapRef.current)
|
||||
setVisible(true)
|
||||
}, SHOW_DELAY_MS)
|
||||
}, [label])
|
||||
|
||||
useEffect(() => () => clearTimeout(timerRef.current), [])
|
||||
|
||||
// 气泡先在左上角量一次尺寸再落位。必须量在 left:0:fixed 元素的可用宽度是
|
||||
// 「视口宽 - left」,贴右边量会被挤窄、行数变多,按那个高度定位就会浮高一截。
|
||||
useEffect(() => {
|
||||
if (!visible)
|
||||
return
|
||||
const bubble = bubbleRef.current
|
||||
const rect = wrapRef.current?.getBoundingClientRect()
|
||||
if (!bubble || !rect)
|
||||
return
|
||||
const { width, height } = bubble.getBoundingClientRect()
|
||||
const above = rect.top - height - GAP_PX
|
||||
const top = above >= GUTTER_PX ? above : rect.bottom + GAP_PX
|
||||
let left = rect.left + rect.width / 2 - width / 2
|
||||
left = Math.max(GUTTER_PX, Math.min(left, window.innerWidth - GUTTER_PX - width))
|
||||
bubble.style.top = `${top}px`
|
||||
bubble.style.left = `${left}px`
|
||||
bubble.style.visibility = 'visible'
|
||||
}, [visible])
|
||||
let described: Element | null = null
|
||||
const controller = createTooltipController<Element>(
|
||||
anchor => anchor.getAttribute('data-tooltip') ?? '',
|
||||
(next) => {
|
||||
described?.removeAttribute('aria-describedby')
|
||||
described = next && !isOwnName(next.anchor, next.text) ? next.anchor : null
|
||||
described?.setAttribute('aria-describedby', id)
|
||||
setTip(next)
|
||||
},
|
||||
)
|
||||
const onPointerOver = (event: PointerEvent): void => controller.hover(anchorOf(event.target))
|
||||
const onPointerOut = (event: PointerEvent): void => {
|
||||
if (event.relatedTarget === null)
|
||||
controller.hover(null)
|
||||
}
|
||||
const onFocusIn = (event: FocusEvent): void => {
|
||||
const anchor = anchorOf(event.target)
|
||||
if (anchor && event.target instanceof Element && event.target.matches(':focus-visible'))
|
||||
controller.focus(anchor)
|
||||
}
|
||||
const onFocusOut = (event: FocusEvent): void => {
|
||||
const anchor = anchorOf(event.target)
|
||||
if (anchor)
|
||||
controller.blur(anchor)
|
||||
}
|
||||
const dismiss = (): void => controller.dismiss()
|
||||
document.addEventListener('pointerover', onPointerOver)
|
||||
document.addEventListener('pointerout', onPointerOut)
|
||||
document.addEventListener('focusin', onFocusIn)
|
||||
document.addEventListener('focusout', onFocusOut)
|
||||
document.addEventListener('pointerdown', dismiss, true)
|
||||
document.addEventListener('keydown', dismiss, true)
|
||||
window.addEventListener('scroll', dismiss, true)
|
||||
return () => {
|
||||
document.removeEventListener('pointerover', onPointerOver)
|
||||
document.removeEventListener('pointerout', onPointerOut)
|
||||
document.removeEventListener('focusin', onFocusIn)
|
||||
document.removeEventListener('focusout', onFocusOut)
|
||||
document.removeEventListener('pointerdown', dismiss, true)
|
||||
document.removeEventListener('keydown', dismiss, true)
|
||||
window.removeEventListener('scroll', dismiss, true)
|
||||
controller.dispose()
|
||||
described?.removeAttribute('aria-describedby')
|
||||
}
|
||||
}, [id])
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={wrapRef}
|
||||
className={cn('inline-flex shrink-0', className)}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={hide}
|
||||
onMouseDown={hide}
|
||||
onFocus={show}
|
||||
onBlur={hide}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{visible && label && createPortal(
|
||||
<div
|
||||
ref={bubbleRef}
|
||||
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,var(--vscode-panel-border))] bg-[var(--vscode-editorHoverWidget-background,var(--vscode-editorWidget-background))] px-2 py-1 text-[11px] leading-tight break-words text-[var(--vscode-editorHoverWidget-foreground,var(--vscode-foreground))] shadow-md"
|
||||
>
|
||||
{label}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
// 先在左上角量尺寸再落位:fixed 元素的可用宽度是「视口宽 - left」,贴右边量会被挤窄、行数变多,按那个高度定位就浮高一截
|
||||
useLayoutEffect(() => {
|
||||
const bubble = bubbleRef.current
|
||||
if (!tip || !bubble)
|
||||
return
|
||||
Object.assign(bubble.style, { top: '0px', left: '0px' })
|
||||
const { top, left } = placeTooltip(tip.anchor.getBoundingClientRect(), bubble.getBoundingClientRect(), window.innerWidth)
|
||||
Object.assign(bubble.style, { top: `${top}px`, left: `${left}px`, visibility: 'visible' })
|
||||
}, [tip])
|
||||
|
||||
return tip && createPortal(
|
||||
<div
|
||||
ref={bubbleRef}
|
||||
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)]"
|
||||
>
|
||||
{tip.text}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -2,12 +2,14 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { TooltipHost } from './components/ui/tooltip'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
<TooltipHost />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
Reference in new issue
Block a user