From 82b43da3dc63a501bd6163982208656bb9c0b244 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 10:14:46 +0800 Subject: [PATCH] revert(ui-primitives): drop HoverCard callout and click pinning The question composer expands long copy in place, so no caller uses the callout variant, openOnClick pinning, describeAnchor, or the preview-to-anchor pointer handling this pull request added. HoverCard, its stylesheet, tests, and README return to their merge-base state. --- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 4 +- packages/client/ui-primitives/README.zh.md | 4 +- .../ui-primitives/src/HoverCard.module.css | 50 ---- .../client/ui-primitives/src/HoverCard.tsx | 236 ++++-------------- .../tests/hover-card.client.spec.tsx | 234 +---------------- 6 files changed, 54 insertions(+), 478 deletions(-) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index fc9c17df52..ab799d699a 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -21,8 +21,8 @@ en: cb874a0ca53a6747 zh: 31179193dd1d6e12 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: 3c1f0a4c28aa7240 - zh: 8dbc3c8615c58260 + en: 35403eb2a55cc77d + zh: 8f4466f7e15ae473 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: en: 3a31ad8de6dd0af8 zh: 6b22881a929c33df diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index b1fafe0b64..9e054e13cc 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -88,12 +88,10 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Moving directly from the preview back to its anchor keeps it open. Its `callout` variant draws an anchor caret, keeps an 8px viewport margin, and scrolls content beyond the available space or 240px height cap. With `openOnClick`, clicking or touching the anchor pins the card; pointer leave and blur keep a pinned card open. Another activation, Escape, Tab, or an outside pointerdown dismisses it. Focused callout anchors scroll the full text with arrow keys, PageUp/PageDown, Home, and End. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. -Opt-in `HoverCard.openOnClick` requires one focusable React element that forwards ARIA props. Click and touch activation pin the card; pointer leave and blur keep a pinned card open so its text remains selectable; another anchor activation, Escape, Tab, or a pointerdown outside both anchor and card closes it. While open, the card joins the anchor's accessible description by default. Set `describeAnchor={false}` when the caller provides a stable `aria-describedby` relationship; the card then leaves that relationship unchanged. - `HoverCardGroup` owns private state for one mounted physical list. A `HoverCard` with `grouped` shares the first actually displayed card's dwell: subsequent cards replace the current card immediately during the same pointer interaction. The list and its active portaled card form one pointer interaction: the pointer may enter the card to read or copy, then return to another list item for immediate replacement. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle; the group adds no separate timer. Leaving a list with no active card resets the dwell immediately. Opening an inline card by keyboard focus while the pointer is outside the list does not warm the next pointer visit. Exclusive display includes fading previews: ordinary preview dismissal retains its fade, but a group replacement removes the previous card immediately. Cards without `grouped` remain independent. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index bdd67dcce1..96b9db405a 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -88,12 +88,10 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。指针从预览直接移回其锚点时,预览保持打开。其 `callout` 变体绘制指向锚点的小角,保留 8px 视口边距,并在内容超过可用空间或 240px 高度上限时内部滚动。启用 `openOnClick` 后,点击或触屏激活锚点会固定卡片;指针移出与失焦不会关闭已固定的卡片。再次激活、Escape、Tab 或外部 pointerdown 会关闭它。聚焦的 callout 锚点支持通过方向键、PageUp/PageDown、Home 和 End 滚动全文。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。 -显式启用 `HoverCard.openOnClick` 时,锚点必须是单个可聚焦且会转发 ARIA props 的 React 元素。点击和触屏激活会固定卡片;指针离开和失焦不会关闭固定的卡片,因此文字仍可选择;再次激活锚点、按 Escape 或 Tab,或在锚点及卡片之外按下指针都会关闭卡片。卡片打开期间,默认会加入锚点的无障碍描述。调用方已通过 `aria-describedby` 提供稳定关联时设置 `describeAnchor={false}`;卡片将保持该关联不变。 - `HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:在同一次指针交互中,后续卡片立即替换当前卡片。列表与其当前活动的 portal 卡片构成一次指针交互:指针可进入卡片阅读或复制,再返回其他列表项并立即替换卡片。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置;分组不另设计时器。列表没有活动卡片时,指针离开便立即重置等待。指针位于列表外时,通过键盘聚焦打开行内卡片不会让下一次指针进入跳过等待。互斥显示也包括正在淡出的预览:普通预览关闭保留淡出,但分组内替换会立即移除前一张卡片。未设置 `grouped` 的卡片保持独立。 diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 0542763a8a..50c62fa828 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -88,53 +88,3 @@ backdrop-filter: var(--dsw-menu-backdrop-filter); box-shadow: var(--dsw-elevation-panel); } - -.callout { - --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); - --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); - isolation: isolate; - padding: 0; - border: 0; - background: transparent; - box-shadow: none; -} - -.calloutSurface { - position: relative; - z-index: 1; - padding: 4px; - overflow: hidden; - border: 0; - border-radius: var(--dsw-radius-xl); - background: var(--dsw-specific-bubble); - box-shadow: var(--dsw-elevation-panel); -} - -.calloutBody { - box-sizing: border-box; - max-height: max(0px, calc(var(--dsh-hover-card-max-height) - 8px)); - overflow-y: auto; - overscroll-behavior: contain; - padding: 4px 8px; - border-radius: var(--dsw-radius-lg); - color: var(--dsw-alias-label-primary); -} - -.calloutCaret { - position: absolute; - z-index: 2; - left: var(--dsh-hover-card-anchor-x); - width: 10px; - height: 10px; - background: var(--dsw-specific-bubble); - transform: translateX(-50%) rotate(45deg); - pointer-events: none; -} - -.callout[data-placement='below'] .calloutCaret { - top: -4px; -} - -.callout[data-placement='above'] .calloutCaret { - bottom: -4px; -} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 601f5782ea..e30d671c36 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -1,12 +1,11 @@ -import { cloneElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react' -import type { CSSProperties, ReactElement, ReactNode, RefObject, SyntheticEvent } from 'react' +import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { CSSProperties, ReactNode, RefObject, SyntheticEvent } from 'react' import clsx from 'clsx' import { createPortal } from 'react-dom' import { writeClipboard } from './clipboard.ts' import { usePointerGrace } from './pointer-grace.ts' import { overlayTopMargin } from './overlay-top-margin.ts' import { HoverCardGroupContext } from './HoverCardGroup.tsx' -import { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts' import css from './HoverCard.module.css' /** Preview opacity transition and retained lifetime during dismissal. */ @@ -14,101 +13,64 @@ const PREVIEW_FADE_MS = 100 const PREVIEW_MAX_HEIGHT = 420 const PREVIEW_INSET = 24 const INLINE_PREVIEW_WIDTH = 300 -const CALLOUT_MAX_HEIGHT = 240 -const CALLOUT_WIDTH = 300 -const CALLOUT_ARROW_EDGE_INSET = 16 -const CALLOUT_SURFACE_INSET = 4 const ANCHOR_GAP = 8 const VIEWPORT_MARGIN = 8 -interface DescribedAnchorProps { - 'aria-describedby'?: string | undefined -} - -type HoverCardProps = { - /** Inline media preview using the shared menu material and keyboard focus. */ - inline?: boolean - content: ReactNode - openDelayMs?: number - disabled?: boolean - grouped?: boolean - variant?: 'compact' | 'preview' | 'callout' - widthAnchorRef?: RefObject -} & ({ - anchor: ReactElement - openOnClick: true - describeAnchor?: boolean -} | { - anchor: ReactNode - openOnClick?: false | undefined - describeAnchor?: never -}) & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { - copyText?: undefined - copyLabel?: string - copiedLabel?: string -}) - /** * Render an anchor with a hover-triggered preview card independently of nested tooltips. - * @param props.anchor - the hover target (rendered in place inside a wrapper span). With - * openOnClick, pass one focusable element that forwards ARIA props. + * Touch entry does not open a preview or intercept the anchor's action. + * @param props.anchor - the hover target (rendered in place inside a wrapper span). * @param props.content - card content; the pointer may rest on it, so it is * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). - * @param props.variant - compact card beside the anchor; a preview with container-relative - * width and opacity transitions; or a 300px callout with an anchor caret and 240px height cap. + * @param props.variant - compact card beside the anchor, or a preview above/below it + * with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions. * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. * @param props.inline - keep the anchor in prose; show a contained preview on hover or keyboard focus. * @param props.disabled - suppress opening; turning true dismisses an open card. * @param props.grouped - share first-display dwell across the surrounding list and active portal. * Group replacement removes the previous card immediately, including a fading preview. - * @param props.openOnClick - clicking or touching the anchor pins the card open. Another activation, - * Escape, Tab, or an outside pointerdown dismisses it; pointer leave and blur do not dismiss a pinned card. - * For callouts, focused anchors scroll the card with arrow keys, PageUp/PageDown, Home, and End. - * @param props.describeAnchor - with openOnClick, append the open card to the anchor's accessible - * description (default true). Set false when the caller supplies a stable description relationship. * @param props.copyText - optional primary value copied by activation and * included in the card's accessible name. * @param props.copyLabel - localized accessible activation-label prefix. * @param props.copiedLabel - localized visible success label. * @returns anchor wrapper with the conditional portaled card. */ -export function HoverCard(props: HoverCardProps) { - const { - content, openDelayMs = 500, disabled = false, grouped = false, describeAnchor = true, - copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, - } = props - const openOnClick = props.openOnClick === true - const callout = variant === 'callout' - const anchored = inline || callout - const id = useId() +export function HoverCard({ + anchor, content, openDelayMs = 500, disabled = false, grouped = false, + copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, +}: { + anchor: ReactNode + /** Inline media preview using the shared menu material and keyboard focus. */ + inline?: boolean + content: ReactNode + openDelayMs?: number + disabled?: boolean + grouped?: boolean + variant?: 'compact' | 'preview' + widthAnchorRef?: RefObject +} & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { + copyText?: undefined + copyLabel?: string + copiedLabel?: string +})) { const rootRef = useRef(null) const cardRef = useRef(null) - const scrollRef = useRef(null) const timerRef = useRef | null>(null) const copyTimerRef = useRef | null>(null) const copyHeightRef = useRef(null) const copyEpochRef = useRef(0) - const keyboardFocus = useRef(false) const copyingRef = useRef(false) const mountedRef = useRef(true) const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed') const open = phase !== 'closed' const closing = phase === 'closing' - const [pos, setPos] = useState<{ - left: number - top: number - width?: number - maxHeight?: number - anchorX?: number - placement?: 'above' | 'below' - } | null>(null) + const [pos, setPos] = useState<{ left: number; top: number; width?: number; maxHeight?: number } | null>(null) const positioned = pos !== null const [copied, setCopied] = useState(false) const parentGroup = useContext(HoverCardGroupContext) const group = grouped ? parentGroup : null const [groupOwner] = useState(() => Symbol('hover-card')) - const [pinned, setPinned] = useState(false) const clearCopied = useCallback(() => { if (copyTimerRef.current !== null) { @@ -120,15 +82,12 @@ export function HoverCard(props: HoverCardProps) { }, []) const close = useCallback(() => { - keyboardFocus.current = false copyEpochRef.current += 1 clearCopied() - setPinned(false) setPhase(current => variant === 'preview' && current !== 'closed' ? 'closing' : 'closed') }, [clearCopied, variant]) const { arm: armClose, cancel: cancelClose } = usePointerGrace(close) - useDismissOnOutsidePointer(rootRef, openOnClick && open, close, cardRef) const clearTimer = useCallback(() => { if (timerRef.current !== null) { @@ -181,7 +140,7 @@ export function HoverCard(props: HoverCardProps) { }, []) useEffect(() => { - if (!open || openOnClick || (variant !== 'preview' && !anchored)) return + if (!open || (variant !== 'preview' && !inline)) return const dismiss = (event: KeyboardEvent): void => { if (event.key !== 'Escape') return if (inline) event.stopPropagation() @@ -191,23 +150,7 @@ export function HoverCard(props: HoverCardProps) { } window.addEventListener('keydown', dismiss, inline) return () => { window.removeEventListener('keydown', dismiss, inline) } - }, [open, openOnClick, variant, anchored, cancelClose, close]) - - useEffect(() => { - if (!openOnClick || !open) return - const dismiss = (event: KeyboardEvent): void => { - if (event.key !== 'Escape' && event.key !== 'Tab') return - if (event.key === 'Escape') { - event.preventDefault() - event.stopPropagation() - } - clearTimer() - cancelClose() - close() - } - document.addEventListener('keydown', dismiss, true) - return () => { document.removeEventListener('keydown', dismiss, true) } - }, [open, openOnClick, cancelClose, close]) + }, [open, variant, inline, cancelClose, close]) // Fixed-position from the anchor rect before paint; track the anchor while // open (capture-phase scroll catches nested panes), as in Menu portal mode. @@ -235,34 +178,19 @@ export function HoverCard(props: HoverCardProps) { }) return } - if (anchored) { - const height = callout - ? Math.max(h, (scrollRef.current?.scrollHeight ?? 0) + CALLOUT_SURFACE_INSET * 2) - : Math.max(h, cardRef.current?.scrollHeight ?? 0) - const width = Math.max(0, Math.min(callout ? CALLOUT_WIDTH : INLINE_PREVIEW_WIDTH, - window.innerWidth - VIEWPORT_MARGIN * 2)) + if (inline) { + const height = Math.max(h, cardRef.current?.scrollHeight ?? 0) + const width = Math.max(0, Math.min(INLINE_PREVIEW_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2)) const topMargin = overlayTopMargin(VIEWPORT_MARGIN) const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) const onTop = height > below && above > below - const maxHeight = Math.min(callout ? CALLOUT_MAX_HEIGHT : Number.POSITIVE_INFINITY, - onTop ? above : below) - const anchorCenter = (r.left + r.right) / 2 - const idealLeft = callout ? anchorCenter - width / 2 : r.left - const left = Math.max(VIEWPORT_MARGIN, Math.min(idealLeft, - window.innerWidth - width - VIEWPORT_MARGIN)) - const arrowInset = Math.min(CALLOUT_ARROW_EDGE_INSET, width / 2) + const maxHeight = onTop ? above : below setPos({ - left, + left: Math.max(VIEWPORT_MARGIN, Math.min(r.left, window.innerWidth - width - VIEWPORT_MARGIN)), top: onTop ? r.top - ANCHOR_GAP - Math.min(height, maxHeight) : belowTop, width, maxHeight, - ...(callout - ? { - anchorX: Math.max(arrowInset, Math.min(anchorCenter - left, width - arrowInset)), - placement: onTop ? 'above' as const : 'below' as const, - } - : {}), }) return } @@ -270,8 +198,8 @@ export function HoverCard(props: HoverCardProps) { setPos({ left: r.right + ANCHOR_GAP, top }) } place() - const observer = (variant === 'preview' || anchored) && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null - for (const element of [cardRef.current, scrollRef.current, rootRef.current, widthAnchorRef?.current]) { + const observer = (variant === 'preview' || inline) && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null + for (const element of [cardRef.current, rootRef.current, widthAnchorRef?.current]) { if (element !== null && element !== undefined) observer?.observe(element) } window.addEventListener('scroll', place, true) @@ -281,19 +209,19 @@ export function HoverCard(props: HoverCardProps) { window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open, variant, widthAnchorRef, positioned, anchored, callout]) + }, [open, variant, widthAnchorRef, positioned, inline]) // The first placement ran before the card mounted (height read 0): once the // card's real height is measurable, correct the bottom-edge clamp. The // correction converges — a clamped top satisfies the guard, so it runs once. useLayoutEffect(() => { - if (!open || pos === null || variant === 'preview' || anchored) return + if (!open || pos === null || variant === 'preview' || inline) return /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 if (pos.top + h > window.innerHeight - VIEWPORT_MARGIN) { setPos({ left: pos.left, top: window.innerHeight - h - VIEWPORT_MARGIN }) } - }, [open, pos, variant, anchored]) + }, [open, pos, variant, inline]) const copy = async (text: string): Promise => { if (copied || copyingRef.current) return @@ -310,43 +238,21 @@ export function HoverCard(props: HoverCardProps) { } const copyable = copyText !== undefined - const describedAnchor = props.openOnClick === true && describeAnchor - ? cloneElement(props.anchor, { - 'aria-describedby': open - ? [props.anchor.props['aria-describedby'], id].filter(Boolean).join(' ') - : props.anchor.props['aria-describedby'], - }) - : props.anchor const dismissFromAnchor = (event: SyntheticEvent): void => { // Portal content is a React child, but its clicks and text selection do not activate the anchor. if (cardRef.current?.contains(event.target as Node)) return - keyboardFocus.current = false clearTimer() cancelClose() - if (openOnClick && !disabled && !pinned) { - setPinned(true) - setPhase('open') - return - } close() } - const cardContent = copied ? : content const card = open && pos !== null && (
{ - cancelClose() - setPhase('open') - }} - onPointerLeave={(event) => { - // Portal-to-anchor movement stays within this card's interaction. - if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) { - cancelClose() - return - } - if (!pinned && !(anchored && keyboardFocus.current)) armClose() - }} > - {callout - ? ( - <> -
) return ( { - if (!disabled && event.target.matches(':focus-visible')) { - keyboardFocus.current = true - cancelClose() - setPhase('open') - } + className={clsx(css.root, inline && css.inline)} + onFocus={inline ? (event) => { + if (!disabled && event.target.matches(':focus-visible')) { cancelClose(); setPhase('open') } } : undefined} - onBlur={anchored ? (event) => { - keyboardFocus.current = false - if (!pinned && !event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } - } : undefined} - onKeyDown={callout && open ? (event) => { - const scroll = scrollRef.current - if (scroll === null || !rootRef.current?.contains(event.target as Node)) return - let top: number - switch (event.key) { - case 'ArrowDown': top = scroll.scrollTop + scroll.clientHeight / 8; break - case 'ArrowUp': top = scroll.scrollTop - scroll.clientHeight / 8; break - case 'PageDown': top = scroll.scrollTop + scroll.clientHeight; break - case 'PageUp': top = scroll.scrollTop - scroll.clientHeight; break - case 'Home': top = 0; break - case 'End': top = scroll.scrollHeight; break - default: return - } - event.preventDefault() - event.stopPropagation() - scroll.scrollTop = top + onBlur={inline ? (event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } } : undefined} onPointerEnter={(event) => { if (disabled || event.pointerType === 'touch') return @@ -441,14 +304,13 @@ export function HoverCard(props: HoverCardProps) { onPointerLeave={() => { clearTimer() // Leaving a closed card schedules a no-op close; only arm while - // open, matching Menu's shape. Keyboard focus remains an independent - // open trigger after the pointer leaves. - if (open && !pinned && !(anchored && keyboardFocus.current)) armClose() + // open, matching Menu's shape. + if (open) armClose() }} - onPointerDownCapture={openOnClick ? undefined : dismissFromAnchor} + onPointerDownCapture={dismissFromAnchor} onClickCapture={dismissFromAnchor} > - {describedAnchor} + {anchor} {open && copyable && {copied ? copiedLabel : ''}} {card !== false && createPortal(card, document.body)} diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index cfdf11bc10..65fbad0a64 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -22,7 +22,7 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number function mount(props: { widthAnchorRef?: { current: HTMLElement | null } - variant?: 'compact' | 'preview' | 'callout' + variant?: 'compact' | 'preview' openDelayMs?: number disabled?: boolean copyText?: string @@ -165,27 +165,6 @@ describe('HoverCard', () => { expect(screen.queryByText('card body')).toBeNull() }) - it('caps a callout, keeps its caret on the anchor, and flips it above when needed', () => { - const { wrapper } = mount({ variant: 'callout' }) - fireEvent.pointerEnter(wrapper) - act(() => { vi.advanceTimersByTime(500) }) - const card = screen.getByText('card body').closest('[data-placement]') as HTMLElement - const body = card.querySelector('[data-hover-card-scroll]') as HTMLElement - expect(card.style.width).toBe('300px') - expect(card.style.left).toBe('8px') - expect(card.style.top).toBe('82px') - expect(card.dataset.placement).toBe('below') - expect(card.style.getPropertyValue('--dsh-hover-card-max-height')).toBe('240px') - expect(card.style.getPropertyValue('--dsh-hover-card-anchor-x')).toBe('142px') - - Object.defineProperty(body, 'scrollHeight', { configurable: true, value: 600 }) - wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 700, width: 100, height: 34 }) - fireEvent(window, new Event('resize')) - expect(card.style.top).toBe('452px') - expect(card.dataset.placement).toBe('above') - expect(card.style.getPropertyValue('--dsh-hover-card-max-height')).toBe('240px') - }) - it('observes preview and container sizes until dismissal', () => { let resize = () => {} const observe = vi.fn() @@ -246,127 +225,6 @@ describe('HoverCard', () => { expect(screen.getByText('card body')).toBeTruthy() }) - it('pins on click for touch access and keeps the card through pointer leave and blur', () => { - render( - <> -

Existing description

- details} - content={
card body
} - /> - , - ) - const anchor = screen.getByRole('button', { name: 'details' }) - const wrapper = anchor.parentElement as HTMLElement - const touch = new Event('pointerover', { bubbles: true }) - Object.defineProperty(touch, 'pointerType', { value: 'touch' }) - fireEvent(wrapper, touch) - act(() => { vi.advanceTimersByTime(500) }) - expect(screen.queryByText('card body')).toBeNull() - - fireEvent.click(anchor) - const card = screen.getByText('card body').parentElement as HTMLElement - expect(card.getAttribute('data-pinned')).toBe('true') - expect(anchor.getAttribute('aria-describedby')).toBe(`existing-description ${card.id}`) - fireEvent.pointerLeave(wrapper) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - fireEvent.blur(anchor, { relatedTarget: document.body }) - expect(screen.getByText('card body')).toBeTruthy() - - fireEvent.pointerLeave(card) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.pointerDown(card) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.pointerDown(document.body) - expect(screen.queryByText('card body')).toBeNull() - expect(anchor.getAttribute('aria-describedby')).toBe('existing-description') - }) - - it('preserves a caller-owned accessible description while click-pinned', () => { - render(<> -

Stable description

- details} - content={
card body
} - /> - ) - const anchor = screen.getByRole('button', { name: 'details' }) - expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') - fireEvent.click(anchor) - expect(screen.getByText('card body')).toBeTruthy() - expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') - fireEvent.pointerDown(document.body) - expect(screen.queryByText('card body')).toBeNull() - expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') - }) - - it('dismisses a click-open card on another click, Escape, or Tab', () => { - const ownerKey = vi.fn() - render(
- details} content={
card body
} /> -
) - const anchor = screen.getByRole('button', { name: 'details' }) - - fireEvent.click(anchor) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.click(anchor) - expect(screen.queryByText('card body')).toBeNull() - - fireEvent.click(anchor) - fireEvent.keyDown(anchor, { key: 'a' }) - expect(screen.getByText('card body')).toBeTruthy() - ownerKey.mockClear() - expect(fireEvent.keyDown(anchor, { key: 'Escape' })).toBe(false) - expect(ownerKey).not.toHaveBeenCalled() - expect(screen.queryByText('card body')).toBeNull() - - fireEvent.click(anchor) - expect(fireEvent.keyDown(anchor, { key: 'Tab' })).toBe(true) - expect(screen.queryByText('card body')).toBeNull() - }) - - it('drops a click pin when the card is disabled', () => { - const pinnedCard = (disabled = false) => ( - details} content={
card body
} /> - ) - const view = render(pinnedCard()) - const anchor = screen.getByRole('button', { name: 'details' }) - fireEvent.click(anchor) - expect(screen.getByText('card body')).toBeTruthy() - view.rerender(pinnedCard(true)) - expect(screen.queryByText('card body')).toBeNull() - - }) - - it('removes click-pin dismissal listeners on unmount', () => { - const view = render( - details} content={
card body
} />, - ) - const registrations = vi.spyOn(document, 'addEventListener') - const removals = vi.spyOn(document, 'removeEventListener') - fireEvent.click(screen.getByRole('button', { name: 'details' })) - expect(screen.getByText('card body')).toBeTruthy() - - const listeners = registrations.mock.calls.filter(([type]) => type === 'keydown' || type === 'pointerdown') - expect(listeners.map(([type]) => type).sort()).toEqual(['keydown', 'pointerdown']) - view.unmount() - - for (const [type, listener, options] of listeners) { - const removed = removals.mock.calls.find(([removedType, removedListener]) => - removedType === type && removedListener === listener) - expect(removed).toBeDefined() - expect(removed?.[2]).toBe(options) - } - expect(fireEvent.keyDown(document.body, { key: 'Escape' })).toBe(true) - fireEvent.pointerDown(document.body) - }) - it('dismisses on keyboard clicks and cancels a pending hover', () => { const { anchor, wrapper } = mount({ variant: 'preview' }) fireEvent.pointerEnter(wrapper) @@ -443,37 +301,6 @@ describe('HoverCard', () => { expect(screen.getByText('card body')).toBeTruthy() }) - it('keeps the card open while its own pointer is inside and closes after leaving it', () => { - const { wrapper } = mount({ variant: 'callout' }) - fireEvent.pointerEnter(wrapper) - act(() => { vi.advanceTimersByTime(500) }) - const card = screen.getByText('card body').closest('[data-placement]') as HTMLElement - fireEvent.pointerLeave(wrapper) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) }) - fireEvent.pointerEnter(card) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.pointerLeave(card) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.queryByText('card body')).toBeNull() - }) - - it('keeps a preview open when the pointer returns directly from its portal to the anchor', () => { - const { anchor, wrapper } = mount({ variant: 'preview' }) - fireEvent.pointerEnter(wrapper) - act(() => { vi.advanceTimersByTime(500) }) - const card = screen.getByText('card body').parentElement as HTMLElement - fireEvent.pointerEnter(card) - fireEvent(card, new MouseEvent('pointerout', { bubbles: true, relatedTarget: anchor })) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS + 100) }) - expect(card.hasAttribute('data-closing')).toBe(false) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.pointerLeave(wrapper) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - act(() => { vi.advanceTimersByTime(100) }) - expect(screen.queryByText('card body')).toBeNull() - }) - it('re-entering while open does not queue a second dwell', () => { const { wrapper } = mount() fireEvent.pointerEnter(wrapper) @@ -829,22 +656,6 @@ it('opens inline previews only for keyboard focus and closes when focus leaves t matches.mockRestore() }) -it('keeps an inline preview open when the pointer leaves its focused anchor', () => { - render(details} content={thumbnail} />) - const anchor = screen.getByRole('button', { name: 'details' }) - const matches = vi.spyOn(anchor, 'matches').mockReturnValue(true) - fireEvent.focus(anchor) - expect(screen.getByText('thumbnail')).toBeTruthy() - const activeElement = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(anchor) - fireEvent.pointerLeave(anchor.parentElement as HTMLElement) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.getByText('thumbnail')).toBeTruthy() - activeElement.mockRestore() - fireEvent.blur(anchor, { relatedTarget: document.body }) - expect(screen.queryByText('thumbnail')).toBeNull() - matches.mockRestore() -}) - it('dismisses an inline hover without anchor focus and lets the next Escape reach the owner', () => { const ownerKey = vi.fn() render(
@@ -895,46 +706,3 @@ it('flips inline media above a bottom link and constrains it without covering th expect(card.style.top).toBe('42px') expect(Number.parseFloat(card.style.maxHeight)).toBe(window.innerHeight - 50) }) - -it('closes a compact copyable card after its mouse-focused card loses the pointer', () => { - const { wrapper } = mount({ copyText: 'value' }) - fireEvent.pointerEnter(wrapper) - act(() => { vi.advanceTimersByTime(500) }) - const card = screen.getByRole('button', { name: 'Copy: value' }) - act(() => { card.focus() }) - expect(document.activeElement).toBe(card) - fireEvent.pointerLeave(card) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.queryByText('card body')).toBeNull() -}) - -it('closes a callout hover after click toggling leaves mouse focus on the anchor', () => { - render(details} content="body" />) - const anchor = screen.getByRole('button', { name: 'details' }) - act(() => { anchor.focus() }) - fireEvent.click(anchor) - fireEvent.click(anchor) - fireEvent.pointerEnter(anchor.parentElement as HTMLElement) - act(() => { vi.advanceTimersByTime(500) }) - expect(screen.getByText('body')).toBeTruthy() - fireEvent.pointerLeave(anchor.parentElement as HTMLElement) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.queryByText('body')).toBeNull() -}) - -it('scrolls an open callout with the focused anchor keyboard controls', () => { - render(details} content="body" />) - const anchor = screen.getByRole('button', { name: 'details' }) - fireEvent.click(anchor) - const scroll = document.querySelector('[data-hover-card-scroll]') as HTMLElement - Object.defineProperty(scroll, 'clientHeight', { configurable: true, value: 160 }) - Object.defineProperty(scroll, 'scrollHeight', { configurable: true, value: 640 }) - expect(fireEvent.keyDown(scroll, { key: 'PageDown' })).toBe(true) - expect(scroll.scrollTop).toBe(0) - for (const [key, top] of [['ArrowDown', 20], ['ArrowUp', 0], ['PageDown', 160], ['PageUp', 0], ['End', 640], ['Home', 0]] as const) { - expect(fireEvent.keyDown(anchor, { key })).toBe(false) - expect(scroll.scrollTop).toBe(top) - } - fireEvent.keyDown(anchor, { key: 'Tab' }) - expect(screen.queryByText('body')).toBeNull() -})