fix(ui): 提示浮层 - 修复键盘触发并补真实交互回归
Original-Commit: ff42bb969a037f3271e3da59b752f513c3112b2d
This commit is contained in:
@@ -11,6 +11,7 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
- 修正社区技能的 72 组空作者 ID 和秒级时间戳;使用独立外部作者标识 `community-itswyatt-k`(不是登录账号或权限兜底),保留原作者署名与来源。增加完整种子落库及重复同步保留收藏/加入状态的回归。
|
||||
- 保留 Agent 操作 Schema 的按类型条件必填规则,并保持体积预算;续租独立于任务执行时限,但真实租约失效仍拒绝写终态。资源删除即时 outbox 处理纳入 worker 生命周期,测试先等待 worker,再关闭数据库和删除目录。
|
||||
- CI 从 Go JSON 事件验证五个 PostgreSQL/Redis 指定测试确实运行且通过,缺失、改名、跳过均失败,不再只搜索 SKIP 文本。
|
||||
- Tooltip 触发事件与 `aria-describedby` 直接绑定实际可聚焦控件,修正外层 span 不会接收内层按钮焦点的行为;现有真实导演台 E2E 增加悬停、Escape 和 Tab 焦点断言。
|
||||
- [ ] 本批仍需真实页面验收:设置页导航后的浮层、Tooltip 鼠标与键盘焦点、移动端 dock、模型价格与选择、站点图标;真实上游 SSE、视频音频参数、视频资源转存和公网素材访问不由单元测试代替。既有各 PR 的部署记录不代表本批整合已经部署。
|
||||
|
||||
## Agent 工具自动纠错与折叠展示
|
||||
|
||||
@@ -388,6 +388,26 @@ async function smokeWorkbench(cdp, baseUrl) {
|
||||
const hasCanvas = await cdp.poll(`(() => { const c = document.querySelector('.director-viewport-shell canvas'); return !!c && c.clientWidth > 0; })()`, "canvas", 40000);
|
||||
assert(hasCanvas, "A5 real canvas present in viewport shell");
|
||||
|
||||
// 使用真实产品 dock 验证 Tooltip 的 hover 与键盘触发,不能只验证包装 span 存在。
|
||||
const tooltipButton = 'button[aria-label="移动对象"]';
|
||||
const hoverPoint = await cdp.evaluate(`(() => { const r = document.querySelector(${JSON.stringify(tooltipButton)}).getBoundingClientRect(); return {x:r.x+r.width/2, y:r.y+r.height/2}; })()`);
|
||||
await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", ...hoverPoint });
|
||||
const describedTooltip = `(() => { const b = document.querySelector(${JSON.stringify(tooltipButton)}); const ids = (b?.getAttribute('aria-describedby') || '').split(/\\s+/); return ids.some(id => document.getElementById(id)?.textContent.includes('移动对象')); })()`;
|
||||
assert(await cdp.poll(describedTooltip, "tooltip on hover", 5000), "A5a Tooltip hover describes the actual control");
|
||||
await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 1, y: 1 });
|
||||
await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
|
||||
await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
|
||||
assert(await cdp.poll(`!document.querySelector('[role="tooltip"]')`, "tooltip dismiss", 5000), "A5b Escape dismisses Tooltip");
|
||||
await cdp.evaluate(`document.querySelector(${JSON.stringify(tooltipButton)}).focus()`);
|
||||
for (const modifiers of [8, 0]) {
|
||||
await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Tab", code: "Tab", windowsVirtualKeyCode: 9, modifiers });
|
||||
await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Tab", code: "Tab", windowsVirtualKeyCode: 9, modifiers });
|
||||
}
|
||||
assert(await cdp.poll(`document.activeElement === document.querySelector(${JSON.stringify(tooltipButton)}) && (${describedTooltip})`, "tooltip on keyboard focus", 5000), "A5c Tab focus opens Tooltip without an extra wrapper stop");
|
||||
await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
|
||||
await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
|
||||
await cdp.evaluate("document.activeElement?.blur()");
|
||||
|
||||
// P1-A 起 AutoKey/时间轴归属动画模式:默认摆场模式下它们必须不存在。
|
||||
const layoutGating = await cdp.evaluate(`(() => ({
|
||||
mode: document.querySelector('button[data-mode="layout"]')?.getAttribute('aria-pressed') ?? null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Fragment, isValidElement, type ComponentProps, type ReactNode } from "react";
|
||||
import { Focusable, Tooltip as RACTooltip, TooltipTrigger, type Placement } from "react-aria-components";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -10,7 +10,7 @@ import { cn } from "@/lib/utils";
|
||||
* - 浮层定位、进出场、焦点管理由 RAC 承担(ADR-0008:复杂浮层以 RAC 为无障碍地基)。
|
||||
* - 样式只吃语义 token:bg-surface-strong(light #fff / dark #2a2a2a)+ text-foreground +
|
||||
* border-border hairline,无裸色;motion 走 RAC 动画类 + motion-reduce 关停。
|
||||
* - trigger 包一层 inline-flex span:保证 RAC ref 定位可用(children 可为任意组件)。
|
||||
* - 启用的控件直接接收 RAC 事件、ref 和描述;非交互/禁用内容用 span 接收悬停。
|
||||
* - placement 映射 AntD → RAC(topLeft→top left …)。
|
||||
* 对标:AntD Tooltip;boardui base/tooltip。
|
||||
*/
|
||||
@@ -40,18 +40,29 @@ export interface TooltipProps {
|
||||
export function Tooltip({ title, placement = "top", delay = 350, className, children }: TooltipProps) {
|
||||
if (!title) return <>{children}</>;
|
||||
|
||||
const child = isValidElement<{ disabled?: boolean; loading?: boolean; tabIndex?: number }>(children) ? children : null;
|
||||
const directTrigger =
|
||||
child && child.type !== Fragment && !child.props.disabled && !child.props.loading && (typeof child.type !== "string" || ["button", "a", "input", "select", "textarea", "summary"].includes(child.type) || child.props.tabIndex !== undefined);
|
||||
|
||||
return (
|
||||
<TooltipTrigger delay={delay}>
|
||||
{/*
|
||||
* RAC 1.21 的 TooltipTrigger 通过 context 下发 hover/focus 处理与 triggerRef,
|
||||
* 只有消费该上下文的组件才会真正挂上(原生 <span>/<button> 不会读)。直接套
|
||||
* 普通 span 的写法因此既没有触发事件、也没有定位基准,鼠标悬停永远不弹。
|
||||
* Focusable 消费该上下文并把属性和 ref 合并到子元素:聚焦与悬停都可用,弹窗
|
||||
* 也有定位基准;外层不占 Tab 位,键盘焦点落在内层控件上后冒泡触发。
|
||||
* useFocus 只处理 target === currentTarget,不能依赖内层按钮焦点冒泡到 span。
|
||||
* 直接把事件和 aria-describedby 交给启用的控件,不增加额外 Tab 停靠点。
|
||||
* 自定义控件需透传 DOM props/ref;禁用或非交互内容仍由外壳接收悬停。
|
||||
*/}
|
||||
<Focusable excludeFromTabOrder>
|
||||
<span className="inline-flex">{children}</span>
|
||||
</Focusable>
|
||||
<span className="inline-flex">
|
||||
{directTrigger ? (
|
||||
<Focusable>{child as ComponentProps<typeof Focusable>["children"]}</Focusable>
|
||||
) : (
|
||||
<Focusable excludeFromTabOrder>
|
||||
<span className="inline-flex">{children}</span>
|
||||
</Focusable>
|
||||
)}
|
||||
</span>
|
||||
<RACTooltip placement={PLACEMENT_MAP[placement]} offset={6} className={cn("z-50 max-w-64 rounded-md border border-border bg-surface-strong px-2 py-1 text-xs leading-relaxed text-foreground shadow-md", "ra-pop-in", className)}>
|
||||
{title}
|
||||
</RACTooltip>
|
||||
|
||||
30
web/test/tooltip-trigger.test.tsx
Normal file
30
web/test/tooltip-trigger.test.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { IconButton } from "../src/components/ui/base/buttons";
|
||||
import { Tooltip } from "../src/components/ui/base/tooltip";
|
||||
|
||||
test("tooltip gives native and custom controls one real focus target", () => {
|
||||
for (const child of [<button aria-label="新增">+</button>, <IconButton icon={Plus} aria-label="新增" />]) {
|
||||
const markup = renderToStaticMarkup(<Tooltip title="新增节点">{child}</Tooltip>);
|
||||
expect(markup).toMatch(/<button[^>]*tabindex="0"/);
|
||||
expect(markup).not.toMatch(/<span[^>]*tabindex=/);
|
||||
}
|
||||
});
|
||||
|
||||
test("tooltip preserves explicit tab order and disabled controls", () => {
|
||||
const excluded = renderToStaticMarkup(
|
||||
<Tooltip title="说明">
|
||||
<button tabIndex={-1}>操作</button>
|
||||
</Tooltip>,
|
||||
);
|
||||
expect(excluded).toMatch(/<button[^>]*tabindex="-1"/);
|
||||
const disabled = renderToStaticMarkup(
|
||||
<Tooltip title="不可操作">
|
||||
<button disabled>操作</button>
|
||||
</Tooltip>,
|
||||
);
|
||||
expect(disabled).toMatch(/<button[^>]*disabled/);
|
||||
expect(disabled).not.toContain('tabindex="0"');
|
||||
});
|
||||
Reference in New Issue
Block a user