🐛 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
@@ -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>,
|
||||
)
|
||||
Reference in new issue
Block a user