Merge branch 'code-mode-ui/dispatch-spill' into code-mode-ui/shiki

This commit is contained in:
Tianyi Cui
2026-07-26 21:44:17 +08:00
965 changed files with 23842 additions and 4514 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 4e2a22e77dc1611728477ea0a9d8c50dfc9f7f5d
README.zh.md: 36253971281fd346f9b0ec4648c4b8824ed918a7

View File

@@ -1,5 +1,7 @@
# @deepseek-ai/dsh-client-ui-primitives
English | [中文](README.zh.md)
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8.
## Markdown rendering

View File

@@ -0,0 +1,23 @@
# @deepseek-ai/dsh-client-ui-primitives
[English](README.md) | 中文
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input,以及 markdown 家族(MessageText/MarkdownText/JsonBlock)。契约:api-contracts v3 §8。
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。
## 模型体验
无。该包在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
- **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
- **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。

View File

@@ -0,0 +1,22 @@
/* Block, not inline-flex: consumers wrap full-width list rows and an
* inline wrapper would shrink them; the card still measures this rect. */
.root {
position: relative;
display: block;
}
/* Preview card (figma session hover card): 244 wide, r12, pad 12/16, the
* menu card's elevation. Surface is #2C2C2E in both themes (figma value,
* light/dark identical), so a component-level variable, not a theme token. */
.card {
--dsw-hovercard-bg: #2C2C2E;
position: fixed;
z-index: 100;
box-sizing: border-box;
width: 244px;
padding: 12px 16px;
border-radius: 12px;
background: var(--dsw-hovercard-bg);
box-shadow: var(--dsw-shadow-lv3);
pointer-events: none;
}

View File

@@ -0,0 +1,112 @@
// HoverCard: delayed hover-preview card portaled to document.body.
// Same portal mechanics as Menu: the wrapper span supplies the anchor rect,
// the card is fixed-positioned at its right edge and repositions on
// scroll/resize while open. Display-only — the card ignores pointer events
// and closes the instant the pointer leaves the anchor (no close delay).
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import css from './HoverCard.module.css'
/**
* Render an anchor with a hover-triggered preview card.
* @param props.anchor - the hover target (rendered in place inside a wrapper span).
* @param props.content - card content (display-only, no pointer interaction).
* @param props.openDelayMs - hover dwell before the card shows (default 500).
* @param props.disabled - suppress opening; turning true closes an open card.
* @returns anchor wrapper with the conditional portaled card.
*/
export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }: {
anchor: ReactNode
content: ReactNode
openDelayMs?: number
disabled?: boolean
}) {
const rootRef = useRef<HTMLSpanElement>(null)
const cardRef = useRef<HTMLDivElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [open, setOpen] = useState(false)
const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
const clearTimer = () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
// Owner disabling mid-hover (menu opened, drag started) closes immediately.
useEffect(() => {
if (!disabled) return
clearTimer()
setOpen(false)
}, [disabled])
useEffect(() => clearTimer, [])
// Fixed-position from the anchor rect before paint; track the anchor while
// open (capture-phase scroll catches nested panes), as in Menu portal mode.
useLayoutEffect(() => {
if (!open) { setPos(null); return }
const place = () => {
const wrapper = rootRef.current
/* v8 ignore next -- the ref is attached before the layout effect runs and the listeners die with it. */
if (wrapper === null) return
const r = wrapper.getBoundingClientRect()
const h = cardRef.current?.offsetHeight ?? 0
const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top
setPos({ left: r.right + 8, top })
}
place()
window.addEventListener('scroll', place, true)
window.addEventListener('resize', place)
return () => {
window.removeEventListener('scroll', place, true)
window.removeEventListener('resize', place)
}
}, [open])
// 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) 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 - 8) {
setPos({ left: pos.left, top: window.innerHeight - h - 8 })
}
}, [open, pos])
const card = open && pos !== null && (
<div ref={cardRef} className={css.card} style={pos}>
{content}
</div>
)
return (
<span
ref={rootRef}
className={css.root}
onPointerEnter={() => {
if (disabled) return
clearTimer()
timerRef.current = setTimeout(() => { setOpen(true) }, openDelayMs)
}}
onPointerLeave={() => {
clearTimer()
setOpen(false)
}}
// Any press inside the anchor (row click, menu trigger) dismisses the
// card immediately, without waiting for the owner to flip `disabled`.
onPointerDownCapture={() => {
clearTimer()
setOpen(false)
}}
>
{anchor}
{card !== false && createPortal(card, document.body)}
</span>
)
}

View File

@@ -30,11 +30,13 @@
/* Portal mode: fixed in the viewport, coordinates supplied inline from the
* anchor rect (side/align resolved in JS, the in-place offset rules above
* don't apply). */
* don't apply). Portaled lists must layer above modal overlays (z 1000) —
* an anchor inside a dialog still expects its menu on top. */
.portal {
position: fixed;
top: auto;
left: auto;
z-index: 1100;
}
/* Open above the anchor (empty-state workspace chip: figma 122:9481). */
@@ -109,6 +111,27 @@
background: transparent;
}
/* Destructive row: error text/icon, danger hover fill. */
.danger {
color: var(--dsw-alias-state-error-primary);
}
.danger .itemIcon {
color: var(--dsw-alias-state-error-primary);
}
.danger:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
}
/* Heading row: non-interactive small grey text, padding aligned with items. */
.label {
padding: 8px 10px;
font-size: 12px;
line-height: 16px;
color: var(--dsw-alias-label-tertiary);
}
/* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */
.separator {
height: 1px;

View File

@@ -4,6 +4,7 @@
// the anchor rect, for anchors inside overflow-clipping containers (sidebar).
// The owner controls `open`; outside-click closing uses one document listener
// active only while open. Submenus open on hover/focus inside the same root.
// Entries also cover non-interactive `label` headings and `danger` rows.
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
@@ -19,6 +20,8 @@ export interface MenuItem {
disabled?: boolean
/** Leading icon (figma .Menu_cell gap 8). */
icon?: ReactNode
/** Destructive row: error-colored text/icon and danger hover fill. */
danger?: boolean
/** Nested card opened to the right on hover/focus. */
submenu?: readonly MenuItem[]
}
@@ -29,13 +32,24 @@ export interface MenuSeparator {
id: string
}
/** One primary-menu entry: a row or a separator. */
export type MenuEntry = MenuItem | MenuSeparator
/** Non-interactive heading row above a group of items. */
export interface MenuLabel {
type: 'label'
id: string
text: string
}
/** One primary-menu entry: a row, a separator, or a heading label. */
export type MenuEntry = MenuItem | MenuSeparator | MenuLabel
function isSeparator(entry: MenuEntry): entry is MenuSeparator {
return 'type' in entry && entry.type === 'separator'
}
function isLabel(entry: MenuEntry): entry is MenuLabel {
return 'type' in entry && entry.type === 'label'
}
/**
* Render an anchored dropdown menu.
* @param props.open - whether the list is showing (owner-controlled).
@@ -50,6 +64,8 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator {
* from the anchor rect (repositions on scroll/resize while open). Use when an
* ancestor's overflow clipping would crop the in-place list; default false
* keeps the pure-CSS in-place behavior.
* @param props.closeOnPointerLeave - close the list when the pointer leaves
* it (default false keeps it open until outside click/Escape/selection).
* @param props.getAnchorRect - portal mode only: supply the anchor rect
* directly (e.g. from a host-owned trigger button) instead of measuring the
* Menu's own wrapper span. Required when the wrapper isn't itself laid out at
@@ -58,7 +74,7 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator {
* scroll/resize; return null to skip placement for that frame.
* @returns anchor wrapper with the conditional list.
*/
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, getAnchorRect, className }: {
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: {
open: boolean
anchor: ReactNode
items: readonly MenuEntry[]
@@ -68,6 +84,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
align?: 'start' | 'end'
side?: 'bottom' | 'top'
portal?: boolean
closeOnPointerLeave?: boolean
getAnchorRect?: () => DOMRect | null
className?: string
}) {
@@ -135,11 +152,19 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={fixedPos ?? undefined}
role="menu"
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
// React portals bubble synthetic events through the REACT tree: without
// this stop, an item click re-fires the anchor row's own onClick
// (open/toggle) after onSelect.
onClick={(e) => { e.stopPropagation() }}
>
{items.map(entry => {
if (isSeparator(entry)) {
return <div key={entry.id} className={css.separator} role="separator" />
}
if (isLabel(entry)) {
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
}
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
const subOpen = hasSub && openSubmenuId === entry.id
return (
@@ -152,7 +177,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
<button
type="button"
role="menuitem"
className={clsx(css.item, entry.id === selectedId && css.selected)}
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
disabled={entry.disabled}
aria-haspopup={hasSub ? 'menu' : undefined}
aria-expanded={hasSub ? subOpen : undefined}

View File

@@ -583,3 +583,90 @@ export const IconTreeCorner8x10 = ({ size = 10, className }: IconProps) => (
<path d="M0 0L-0.5 0L-0.5 7L0 7L0.5 7L0.5 0L0 0ZM3 10L3 10.5L8 10.5L8 10L8 9.5L3 9.5L3 10ZM0 7L-0.5 7C-0.5 8.933 1.067 10.5 3 10.5L3 10L3 9.5C1.61929 9.5 0.5 8.38071 0.5 7L0 7Z" fill="currentColor"/>
</svg>
)
/** ic_ds_light_outline_16 */
export const IconLightOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11.3496 8C11.3496 6.14985 9.85015 4.65039 8 4.65039C6.14985 4.65039 4.65039 6.14985 4.65039 8C4.65039 9.85015 6.14985 11.3496 8 11.3496C9.85015 11.3496 11.3496 9.85015 11.3496 8ZM12.6504 8C12.6504 10.5681 10.5681 12.6504 8 12.6504C5.43188 12.6504 3.34961 10.5681 3.34961 8C3.34961 5.43188 5.43188 3.34961 8 3.34961C10.5681 3.34961 12.6504 5.43188 12.6504 8Z"
fill="currentColor"
/>
<path d="M8.65039 0.5V2.5H7.34961V0.5H8.65039Z" fill="currentColor" />
<path d="M8.65039 13.5V15.5H7.34961V13.5H8.65039Z" fill="currentColor" />
<path
d="M3.15808 2.24035L4.57229 3.65456L3.6525 4.57435L2.23829 3.16014L3.15808 2.24035Z"
fill="currentColor"
/>
<path
d="M12.3505 11.4327L13.7647 12.8469L12.8449 13.7667L11.4307 12.3525L12.3505 11.4327Z"
fill="currentColor"
/>
<path
d="M2.24537 12.8469L3.65958 11.4327L4.57937 12.3525L3.16516 13.7667L2.24537 12.8469Z"
fill="currentColor"
/>
<path
d="M11.4377 3.65455L12.852 2.24033L13.7718 3.16012L12.3575 4.57434L11.4377 3.65455Z"
fill="currentColor"
/>
<path d="M0.5 7.35461H2.5V8.6554H0.5L0.5 7.35461Z" fill="currentColor" />
<path d="M13.5 7.35461H15.5V8.6554H13.5V7.35461Z" fill="currentColor" />
</svg>
)
/** ic_ds_dark_outline_16 */
export const IconDarkOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.2764 9.52324C12.5607 9.97754 11.7177 10.242 10.7812 10.242C8.11386 10.2419 5.95042 8.07997 5.9502 5.41289C5.9502 4.48128 6.21453 3.61071 6.67188 2.87285C4.30332 3.4658 2.54992 5.60845 2.5498 8.16093C2.5498 11.1712 4.99103 13.6102 8 13.6102C10.5383 13.6102 12.6709 11.8724 13.2764 9.52324ZM7.05078 5.41289C7.051 7.47224 8.72116 9.1423 10.7812 9.14238C11.9248 9.14238 12.887 8.63397 13.5781 7.8084C13.7266 7.63106 13.9701 7.56547 14.1875 7.64433C14.4049 7.72329 14.5497 7.9297 14.5498 8.16093C14.5498 11.7766 11.6161 14.7098 8 14.7098C4.38402 14.7098 1.4502 11.7792 1.4502 8.16093C1.45033 4.54322 4.3812 1.61015 8 1.61015C8.23027 1.61015 8.43585 1.75352 8.51562 1.96953C8.59536 2.18554 8.53241 2.42829 8.35742 2.57793C7.55573 3.26311 7.05078 4.27876 7.05078 5.41289Z"
fill="currentColor"
/>
</svg>
)
/** ic_ds_followsystem_outline_16 */
export const IconFollowsystemOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.1665 13.5811V14.7803H3.66651V13.5811H12.1665Z" fill="currentColor" />
<path
d="M13.4453 7.02379C13.4453 6.04702 13.4452 5.3616 13.3887 4.83434C13.3333 4.31828 13.2302 4.02378 13.0723 3.80309C12.9446 3.62475 12.7877 3.46883 12.6094 3.34117C12.3887 3.18328 12.0942 3.08007 11.5781 3.02477C11.0508 2.96829 10.3655 2.96715 9.38867 2.96715H6.61035C5.63359 2.96715 4.94816 2.96827 4.4209 3.02477C3.90486 3.0801 3.61034 3.18321 3.38965 3.34117C3.21143 3.46878 3.05534 3.62487 2.92774 3.80309C2.76977 4.02377 2.66667 4.3183 2.61133 4.83434C2.55483 5.3616 2.55371 6.04702 2.55371 7.02379C2.55371 8.0006 2.55485 8.68596 2.61133 9.21324C2.66663 9.72936 2.76983 10.0238 2.92774 10.2445C3.0554 10.4228 3.21131 10.5797 3.38965 10.7074C3.61034 10.8654 3.90484 10.9685 4.4209 11.0238C4.94816 11.0803 5.63359 11.0804 6.61035 11.0804H9.38867C10.3654 11.0804 11.0508 11.0803 11.5781 11.0238C12.0941 10.9685 12.3887 10.8652 12.6094 10.7074C12.7877 10.5797 12.9446 10.4229 13.0723 10.2445C13.2301 10.0238 13.3334 9.72927 13.3887 9.21324C13.4452 8.68596 13.4453 8.00058 13.4453 7.02379ZM14.6455 7.02379C14.6455 7.97428 14.646 8.73509 14.5811 9.34117C14.5149 9.95828 14.3756 10.4858 14.0479 10.9437C13.8436 11.229 13.5938 11.4788 13.3086 11.683C12.8507 12.0108 12.3232 12.15 11.7061 12.2162C11.1 12.2811 10.3391 12.2806 9.38867 12.2806H6.61035C5.66018 12.2806 4.89991 12.2811 4.29395 12.2162C3.67684 12.15 3.14935 12.0108 2.69141 11.683C2.40613 11.4788 2.15639 11.229 1.95215 10.9437C1.62436 10.4858 1.4841 9.95828 1.41797 9.34117C1.35305 8.73511 1.35449 7.97424 1.35449 7.02379C1.35449 6.07366 1.35308 5.31333 1.41797 4.70738C1.4841 4.09028 1.62436 3.56279 1.95215 3.10485C2.15638 2.81956 2.40613 2.56982 2.69141 2.36559C3.14935 2.03779 3.67684 1.89753 4.29395 1.83141C4.8999 1.76652 5.66022 1.76793 6.61035 1.76793H9.38867C10.3391 1.76793 11.1 1.76649 11.7061 1.83141C12.3232 1.89753 12.8507 2.03779 13.3086 2.36559C13.5939 2.56982 13.8436 2.81957 14.0479 3.10485C14.3756 3.56279 14.5149 4.09028 14.5811 4.70738C14.646 5.31335 14.6455 6.07362 14.6455 7.02379Z"
fill="currentColor"
/>
</svg>
)
/** ic_ds_data_outline_16 */
export const IconDataOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.0997 8.54554C12.2905 8.54989 12.3541 8.58056 12.4535 8.74614L12.8849 9.46387C12.9851 9.63071 13.0464 9.66013 13.2388 9.66447H14.1138C14.3417 9.66448 14.3512 9.66937 14.4686 9.86507L14.892 10.5717C14.9942 10.7422 14.9948 10.8247 14.892 10.9961L14.4756 11.6906C14.3741 11.8677 14.3694 11.9379 14.4756 12.115L14.892 12.8096C14.9942 12.9801 14.9947 13.0625 14.892 13.234L14.4686 13.9406C14.3643 14.1028 14.3063 14.1354 14.1138 14.1412H13.2388C13.0465 14.1456 12.985 14.1752 12.8849 14.3418L12.4535 15.0595C12.353 15.2195 12.2895 15.2558 12.0997 15.2601H11.2237C10.9962 15.2601 10.9871 15.2548 10.8699 15.0595L10.4384 14.3418C10.3383 14.175 10.2767 14.1456 10.0846 14.1412H9.2096C9.01854 14.1355 8.95761 14.1006 8.85477 13.9406L8.43139 13.234C8.32562 13.0576 8.33148 12.9862 8.43139 12.8096L8.84771 12.115C8.95165 11.9416 8.94659 11.863 8.84771 11.6906L8.43139 10.9961C8.32767 10.8232 8.33411 10.7437 8.43139 10.5717L8.85477 9.86507C8.95447 9.69891 9.01875 9.67017 9.2096 9.66447H10.0846C10.2741 9.66441 10.3414 9.62547 10.4384 9.46387L10.8699 8.74614C10.987 8.55106 10.9963 8.54554 11.2237 8.54554H12.0997ZM11.6612 10.232C11.3326 10.7798 10.8155 11.0948 10.1743 11.106C10.4443 11.61 10.4425 12.1976 10.1743 12.6987C10.803 12.7096 11.3391 13.0359 11.6612 13.5727C11.9855 13.0323 12.5131 12.7098 13.148 12.6987C12.879 12.196 12.8789 11.6086 13.148 11.106C12.5076 11.0948 11.9894 10.7794 11.6612 10.232Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.51205 0.790627C9.19055 0.790649 10.7401 1.0691 11.892 1.54364C12.4664 1.78029 12.9719 2.07885 13.3436 2.4408C13.7171 2.80467 13.9916 3.27253 13.9918 3.82384V7.90442C13.6067 7.69532 13.1907 7.53597 12.7529 7.43366V5.66454C12.4928 5.82898 12.2028 5.97601 11.892 6.10405C10.74 6.57865 9.19071 6.85706 7.51205 6.85706C5.8337 6.85703 4.285 6.57852 3.13309 6.10405C2.82215 5.97593 2.53164 5.8291 2.27121 5.66454V7.4135C2.27134 7.75678 2.6066 8.27106 3.62502 8.73405C4.58641 9.17097 5.95762 9.45591 7.50499 9.45681C7.24582 9.83133 7.03684 10.2434 6.88706 10.6826C5.44388 10.6162 4.12516 10.3216 3.11192 9.86104C2.81708 9.72698 2.53185 9.56866 2.27121 9.38928V11.2542C2.27158 11.5974 2.60697 12.1109 3.62502 12.5737C4.41933 12.9347 5.4937 13.1898 6.71569 13.2693C6.80349 13.7128 6.9513 14.1345 7.14814 14.5273C5.60324 14.4862 4.18593 14.1889 3.11192 13.7007C2.01039 13.1998 1.03366 12.3814 1.03333 11.2542V3.82384C1.03352 3.27273 1.30721 2.80461 1.68049 2.4408C2.05211 2.07893 2.55887 1.78026 3.13309 1.54364C4.28492 1.06926 5.83393 0.790683 7.51205 0.790627ZM7.51205 2.02851C5.95492 2.02857 4.57354 2.29079 3.60486 2.68979C3.11958 2.88977 2.76667 3.11253 2.5454 3.32788C2.32671 3.54101 2.2714 3.7089 2.27121 3.82384C2.27121 3.93882 2.32624 4.10625 2.5454 4.3198C2.76667 4.53527 3.11927 4.75781 3.60486 4.9579C4.5736 5.35699 5.95467 5.61914 7.51205 5.61918C9.06942 5.61918 10.4505 5.35695 11.4192 4.9579C11.9051 4.75773 12.2584 4.53536 12.4797 4.3198C12.6988 4.10627 12.7529 3.93882 12.7529 3.82384C12.7527 3.70889 12.6984 3.54104 12.4797 3.32788C12.2584 3.11239 11.9049 2.88989 11.4192 2.68979C10.4505 2.29079 9.06925 2.02853 7.51205 2.02851Z"
fill="currentColor"
/>
</svg>
)
/** ic_ds_List_Pen_outline_16 */
export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.8239 3.54733V4.78443H4.63437V3.54733H10.8239Z" fill="currentColor" />
<path d="M10.8239 6.12629V7.36338H4.63437V6.12629H10.8239Z" fill="currentColor" />
<path d="M9.073 8.70524V9.94234H4.63437V8.70524H9.073Z" fill="currentColor" />
<path
d="M9.13321 0.573526C10.0076 0.573525 10.7179 0.572522 11.285 0.63397C11.8645 0.696791 12.3743 0.831648 12.8193 1.1548C13.0776 1.34246 13.3056 1.57047 13.4933 1.82875C13.8164 2.2737 13.9513 2.7836 14.0141 3.36303C14.0755 3.93015 14.0745 4.64049 14.0745 5.51485V6.1757L12.7327 7.5629V5.51485C12.7327 4.61092 12.732 3.9862 12.6803 3.5081C12.6298 3.0427 12.5379 2.79497 12.4083 2.61654C12.3033 2.47211 12.176 2.34472 12.0315 2.23977C11.8531 2.11016 11.6054 2.01823 11.14 1.96777C10.6618 1.91601 10.0372 1.91539 9.13321 1.91539H6.32658C5.42262 1.91539 4.79796 1.91604 4.31983 1.96777C3.85451 2.01819 3.60672 2.11029 3.42827 2.23977C3.28392 2.34465 3.15643 2.47223 3.0515 2.61654C2.9219 2.79496 2.82997 3.04274 2.7795 3.5081C2.72774 3.9862 2.72712 4.61092 2.72712 5.51485V10.023C2.72712 10.9273 2.72773 11.5525 2.7795 12.0307C2.82992 12.4959 2.92205 12.7429 3.0515 12.9213C3.15645 13.0657 3.28384 13.1931 3.42827 13.2981C3.60676 13.4277 3.85408 13.5206 4.31983 13.5711C4.79797 13.6228 5.42259 13.6234 6.32658 13.6234H6.87057L5.57707 14.9593C5.03527 14.9556 4.57031 14.9467 4.17476 14.9039C3.59508 14.841 3.08558 14.7063 2.64048 14.383C2.38215 14.1953 2.15422 13.9684 1.96653 13.7101C1.64319 13.2649 1.50851 12.7546 1.4457 12.1748C1.38432 11.6076 1.38525 10.8974 1.38525 10.023V5.51485C1.38525 4.64049 1.38426 3.93015 1.4457 3.36303C1.50853 2.78363 1.64341 2.27368 1.96653 1.82875C2.15417 1.57059 2.38228 1.34239 2.64048 1.1548C3.08544 0.831805 3.59533 0.696762 4.17476 0.63397C4.74193 0.572552 5.45218 0.573525 6.32658 0.573526H9.13321Z"
fill="currentColor"
/>
<path d="M14.2193 14.9553H10.0124L11.3744 13.6134H14.2193V14.9553Z" fill="currentColor" />
<path
d="M8.24493 13.3711L7.49015 14.8806C7.40148 15.058 7.58961 15.2461 7.76695 15.1574L9.27651 14.4027L14.6147 9.09934L13.5832 8.06775L8.24493 13.3711Z"
fill="currentColor"
/>
</svg>
)

View File

@@ -9,7 +9,8 @@ export type { ButtonVariant } from './Button.tsx'
export { Pill } from './Pill.tsx'
export { Input } from './Input.tsx'
export { Menu } from './Menu.tsx'
export type { MenuEntry, MenuItem, MenuSeparator } from './Menu.tsx'
export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'
export { HoverCard } from './HoverCard.tsx'
export { Modal } from './Modal.tsx'
export { ConnectionBanner } from './ConnectionBanner.tsx'
export { FishLogo } from './FishLogo.tsx'

View File

@@ -136,6 +136,51 @@ describe('Menu', () => {
expect(screen.getByRole('separator')).toBeDefined()
})
it('renders a non-interactive heading label and a danger row', () => {
const onSelect = vi.fn()
render(
<Menu
open
anchor={<span>trigger</span>}
items={[
{ type: 'label', id: 'h', text: 'Group by' },
{ id: 'del', label: 'Delete', danger: true },
]}
onSelect={onSelect}
onClose={() => {}}
/>)
const heading = screen.getByText('Group by')
expect(heading.getAttribute('role')).toBe('presentation')
// The heading is not a menu item — only the danger row is interactive.
expect(screen.getAllByRole('menuitem')).toHaveLength(1)
const danger = screen.getByRole('menuitem', { name: 'Delete' })
expect(danger.className).toMatch(/danger/)
fireEvent.click(danger)
expect(onSelect).toHaveBeenCalledWith('del')
})
it('closeOnPointerLeave closes when the pointer leaves the list; default stays open', () => {
const onClose = vi.fn()
const { rerender } = render(
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
fireEvent.pointerLeave(screen.getByRole('menu'))
expect(onClose).toHaveBeenCalledTimes(1)
rerender(
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
fireEvent.pointerLeave(screen.getByRole('menu'))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('a list click does not bubble to the anchor row (portal synthetic-event path)', () => {
const rowClick = vi.fn()
render(
<div onClick={rowClick}>
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />
</div>)
fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' }))
expect(rowClick).not.toHaveBeenCalled()
})
it('opens a submenu on hover and selects a nested item', () => {
const onSelect = vi.fn()
render(

View File

@@ -0,0 +1,148 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })
/** Anchor wrapper rect: the card positions from this (jsdom rects are all-zero by default). */
function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number }): void {
const wrapper = anchor.parentElement as HTMLElement
wrapper.getBoundingClientRect = () => ({
top: rect.top, right: rect.right, left: rect.right - 100, bottom: rect.top + 34,
width: 100, height: 34, x: rect.right - 100, y: rect.top, toJSON: () => ({}),
} as DOMRect)
}
function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {
const view = render(
<HoverCard anchor={<span>row</span>} content={<div>card body</div>} {...props} />,
)
const anchor = screen.getByText('row')
stubAnchorRect(anchor, { top: 40, right: 200 })
return { view, anchor, wrapper: anchor.parentElement as HTMLElement }
}
describe('HoverCard', () => {
it('opens after the dwell delay, positioned right of the anchor', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
expect(screen.queryByText('card body')).toBeNull()
act(() => { vi.advanceTimersByTime(499) })
expect(screen.queryByText('card body')).toBeNull()
act(() => { vi.advanceTimersByTime(1) })
const card = screen.getByText('card body').parentElement as HTMLElement
expect(card.parentElement).toBe(document.body)
expect(card.style.left).toBe('208px')
expect(card.style.top).toBe('40px')
})
it('honors a custom openDelayMs', () => {
const { wrapper } = mount({ openDelayMs: 50 })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(50) })
expect(screen.getByText('card body')).toBeTruthy()
})
it('pointerleave before the delay cancels the pending open', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(1000) })
expect(screen.queryByText('card body')).toBeNull()
})
it('pointerleave closes an open card immediately; re-enter restarts the dwell', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('card body')).toBeTruthy()
fireEvent.pointerLeave(wrapper)
expect(screen.queryByText('card body')).toBeNull()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('card body')).toBeTruthy()
})
it('a press inside the anchor dismisses the card without waiting for disabled', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('card body')).toBeTruthy()
fireEvent.pointerDown(screen.getByText('row'))
expect(screen.queryByText('card body')).toBeNull()
// The pending timer is also cleared: no reopen after the dwell.
act(() => { vi.advanceTimersByTime(1000) })
expect(screen.queryByText('card body')).toBeNull()
})
it('disabled suppresses opening entirely', () => {
const { wrapper } = mount({ disabled: true })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(1000) })
expect(screen.queryByText('card body')).toBeNull()
})
it('flipping disabled true closes an open card', () => {
const { view, wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('card body')).toBeTruthy()
view.rerender(<HoverCard anchor={<span>row</span>} content={<div>card body</div>} disabled />)
expect(screen.queryByText('card body')).toBeNull()
})
it('corrects the bottom-edge clamp once the mounted card height is measurable', () => {
// First placement reads height 0 (card not yet mounted) and keeps the
// anchor top; the post-mount correction re-clamps with the real height.
window.innerHeight = 300
const offsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')!
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 120 })
try {
const { wrapper } = mount()
stubAnchorRect(screen.getByText('row'), { top: 280, right: 200 })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByText('card body').parentElement as HTMLElement
// 300 - 120 - 8 = 172, instead of the anchor top 280.
expect(card.style.top).toBe('172px')
} finally {
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeight)
}
})
it('clamps inside placement itself when the card is already measured (resize path)', () => {
window.innerHeight = 300
const { wrapper } = mount()
stubAnchorRect(screen.getByText('row'), { top: 280, right: 200 })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByText('card body').parentElement as HTMLElement
Object.defineProperty(card, 'offsetHeight', { value: 120 })
act(() => { fireEvent.resize(window) })
expect(card.style.top).toBe('172px')
})
it('repositions on capture-phase scroll while open and stops listening after close', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
stubAnchorRect(screen.getByText('row'), { top: 90, right: 300 })
act(() => { fireEvent.scroll(document) })
const card = screen.getByText('card body').parentElement as HTMLElement
expect(card.style.left).toBe('308px')
expect(card.style.top).toBe('90px')
fireEvent.pointerLeave(wrapper)
expect(screen.queryByText('card body')).toBeNull()
})
it('unmount clears a pending open timer', () => {
const { view, wrapper } = mount()
fireEvent.pointerEnter(wrapper)
view.unmount()
act(() => { vi.advanceTimersByTime(1000) })
expect(screen.queryByText('card body')).toBeNull()
})
})

View File

@@ -14,8 +14,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
it('exports the full P-I set (43 deepsuite + 7 figma extracts)', () => {
expect(iconNames.length).toBe(50)
it('exports the full P-I set (43 deepsuite + 12 figma extracts)', () => {
expect(iconNames.length).toBe(55)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', name => {