Merge remote-tracking branch 'origin/master' into feat/web-terminal-card
# Conflicts: # packages/client/ui-conversation/src/client/chat/ToolRow.tsx
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.md {
|
||||
@@ -44,10 +44,6 @@
|
||||
background: var(--dsw-alias-button-primary-hover);
|
||||
}
|
||||
|
||||
.primary:disabled {
|
||||
background: var(--dsw-alias-button-primary-dimmed);
|
||||
}
|
||||
|
||||
.ghost:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
@@ -66,10 +62,6 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.outline:disabled {
|
||||
border-color: var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
background: var(--dsw-alias-button-tool-bar-fill);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
min-width: 218px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
/* Portal mode: fixed in the viewport, coordinates supplied inline from the
|
||||
@@ -50,6 +51,36 @@
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* Viewport fit: the card stops 12px short of the viewport's top/bottom edges
|
||||
* (24 = 2 × the portal MARGIN in Menu.tsx) and taller content scrolls inside
|
||||
* .viewport, so a pinned .footer stays visible. Menus with submenu rows skip
|
||||
* this class — the overflow clip would crop the side card, so they rely on
|
||||
* staying short. */
|
||||
.scrollable {
|
||||
max-height: calc(100vh - 24px);
|
||||
}
|
||||
|
||||
.viewport {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.scrollable .viewport {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Pinned rows below the scroll region; l2 hairline (l1 is near-invisible on
|
||||
* the menu surface) mirrors the .separator spacing. */
|
||||
.footer {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.itemWrap {
|
||||
position: relative;
|
||||
}
|
||||
@@ -78,7 +109,7 @@
|
||||
}
|
||||
|
||||
.item:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
// 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.
|
||||
// Lists keep 12px clearance to the viewport's top/bottom edges and scroll
|
||||
// internally past that; submenu-bearing menus are exempt (see .scrollable).
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
@@ -50,6 +52,9 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
|
||||
return 'type' in entry && entry.type === 'label'
|
||||
}
|
||||
|
||||
/** Unplaced portal list: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real. */
|
||||
const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
||||
|
||||
/**
|
||||
* Render an anchored dropdown menu.
|
||||
* @param props.open - whether the list is showing (owner-controlled).
|
||||
@@ -72,17 +77,20 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
|
||||
* the trigger (render-prop anchors, effect-positioned proxies — measuring the
|
||||
* wrapper there races the host's layout effects). Called on open and on every
|
||||
* scroll/resize; return null to skip placement for that frame.
|
||||
* @param props.footer - rows pinned below the scrolling items area, separated
|
||||
* by a hairline; they stay visible while the items above scroll.
|
||||
* @returns anchor wrapper with the conditional list.
|
||||
*/
|
||||
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: {
|
||||
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, footer, className }: {
|
||||
open: boolean
|
||||
anchor: ReactNode
|
||||
items: readonly MenuEntry[]
|
||||
selectedId?: string
|
||||
footer?: readonly MenuEntry[]
|
||||
selectedId?: string | undefined
|
||||
onSelect: (id: string) => void
|
||||
onClose: () => void
|
||||
align?: 'start' | 'end'
|
||||
side?: 'bottom' | 'top'
|
||||
side?: 'bottom' | 'top' | 'right'
|
||||
portal?: boolean
|
||||
closeOnPointerLeave?: boolean
|
||||
getAnchorRect?: () => DOMRect | null
|
||||
@@ -109,11 +117,34 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
r = rootRef.current?.getBoundingClientRect() ?? null
|
||||
}
|
||||
if (r === null) return
|
||||
setFixedPos({
|
||||
...(align === 'start' ? { left: r.left } : { right: window.innerWidth - r.right }),
|
||||
...(side === 'bottom' ? { top: r.bottom + 4 } : { bottom: window.innerHeight - r.top + 4 }),
|
||||
})
|
||||
const MARGIN = 12
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
const listEl = listRef.current
|
||||
const lw = listEl?.offsetWidth ?? 0
|
||||
const lh = listEl?.offsetHeight ?? 0
|
||||
|
||||
let x: number
|
||||
let y: number
|
||||
if (side === 'right') {
|
||||
x = r.right + 4
|
||||
y = r.top
|
||||
} else if (align === 'start') {
|
||||
x = r.left
|
||||
y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4
|
||||
} else {
|
||||
x = r.right - lw
|
||||
y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4
|
||||
}
|
||||
|
||||
if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN)
|
||||
if (lh > 0) y = Math.min(Math.max(y, MARGIN), vh - lh - MARGIN)
|
||||
|
||||
setFixedPos({ left: x, top: y })
|
||||
}
|
||||
// First run measures the hidden pre-render (same commit as `open`), so
|
||||
// end/top alignment and clamping use real dimensions before anything
|
||||
// paints — no visible jump from a zero-size first guess.
|
||||
place()
|
||||
window.addEventListener('scroll', place, true)
|
||||
window.addEventListener('resize', place)
|
||||
@@ -146,11 +177,77 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
const list = open && (!portal || fixedPos !== null) && (
|
||||
// The submenu card is absolutely positioned outside the list box; the
|
||||
// scroll clip would crop it, so only submenu-free menus get the height cap.
|
||||
const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0)
|
||||
|
||||
const renderEntry = (entry: MenuEntry) => {
|
||||
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 (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={css.itemWrap}
|
||||
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
||||
onMouseLeave={() => { setOpenSubmenuId(null) }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
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}
|
||||
onFocus={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
||||
onClick={() => {
|
||||
if (hasSub) {
|
||||
setOpenSubmenuId(entry.id)
|
||||
return
|
||||
}
|
||||
onSelect(entry.id)
|
||||
}}
|
||||
>
|
||||
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
|
||||
<span className={css.itemLabel}>{entry.label}</span>
|
||||
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
|
||||
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
|
||||
</button>
|
||||
{subOpen && entry.submenu !== undefined && (
|
||||
<div className={css.submenu} role="menu">
|
||||
{entry.submenu.map(sub => (
|
||||
<button
|
||||
key={sub.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={css.item}
|
||||
disabled={sub.disabled}
|
||||
onClick={() => { onSelect(sub.id) }}
|
||||
>
|
||||
{sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
|
||||
<span className={css.itemLabel}>{sub.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Portal lists render hidden until placed: the placement effect measures
|
||||
// this pre-render in the same commit, so the first painted frame is
|
||||
// already at the final position (with getAnchorRect returning null the
|
||||
// list simply stays hidden).
|
||||
const list = open && (
|
||||
<div
|
||||
ref={listRef}
|
||||
className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
||||
style={fixedPos ?? undefined}
|
||||
className={clsx(css.list, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
||||
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
|
||||
role="menu"
|
||||
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
|
||||
// React portals bubble synthetic events through the REACT tree: without
|
||||
@@ -158,63 +255,14 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
// (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 (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={css.itemWrap}
|
||||
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
||||
onMouseLeave={() => { setOpenSubmenuId(null) }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
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}
|
||||
onFocus={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
||||
onClick={() => {
|
||||
if (hasSub) {
|
||||
setOpenSubmenuId(entry.id)
|
||||
return
|
||||
}
|
||||
onSelect(entry.id)
|
||||
}}
|
||||
>
|
||||
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
|
||||
<span className={css.itemLabel}>{entry.label}</span>
|
||||
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
|
||||
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
|
||||
</button>
|
||||
{subOpen && entry.submenu !== undefined && (
|
||||
<div className={css.submenu} role="menu">
|
||||
{entry.submenu.map(sub => (
|
||||
<button
|
||||
key={sub.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={css.item}
|
||||
disabled={sub.disabled}
|
||||
onClick={() => { onSelect(sub.id) }}
|
||||
>
|
||||
{sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
|
||||
<span className={css.itemLabel}>{sub.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className={css.viewport} role="presentation">
|
||||
{items.map(renderEntry)}
|
||||
</div>
|
||||
{footer !== undefined && footer.length > 0 && (
|
||||
<div className={css.footer} role="presentation">
|
||||
{footer.map(renderEntry)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 510;
|
||||
font-weight: 500; /* figma wt510, rendered 500 */
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Ongoing blue has no alias token (state-business-primary is the 500 step,
|
||||
* not this 450) — component-level var pinned to the static scale instead. */
|
||||
.dot,
|
||||
.ring {
|
||||
.matrix {
|
||||
--dsh-state-ongoing: var(--dsw-static-deepseek-450);
|
||||
}
|
||||
|
||||
@@ -42,24 +42,24 @@
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.ring {
|
||||
/* Pixel chase: each outer cell holds a discrete brightness step (flat keyframe
|
||||
* holds, no tweening — the retro feel), peaking when the chase hits it and
|
||||
* decaying over the next three cells. Phase offsets come from per-rect
|
||||
* animation-delay (index * -125ms) set inline by the component. */
|
||||
.matrix {
|
||||
flex: none;
|
||||
color: var(--dsh-state-ongoing);
|
||||
animation: dsh-state-dot-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.stopFrom {
|
||||
stop-color: currentColor;
|
||||
stop-opacity: 1;
|
||||
.cell {
|
||||
fill: currentColor;
|
||||
opacity: 0.15;
|
||||
animation: dsh-state-dot-chase 1s infinite;
|
||||
}
|
||||
|
||||
.stopTo {
|
||||
stop-color: currentColor;
|
||||
stop-opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes dsh-state-dot-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
@keyframes dsh-state-dot-chase {
|
||||
0%, 12.4% { opacity: 1; }
|
||||
12.5%, 24.9% { opacity: 0.6; }
|
||||
25%, 37.4% { opacity: 0.35; }
|
||||
37.5%, 100% { opacity: 0.15; }
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
// StateDot: session state indicator (figma nodes 14:3303/3305/3312, 122:9182).
|
||||
// done/warning/error: 10x10 halo (same color, 10% opacity) around a 6x6 solid
|
||||
// core. ongoing: 10x10 ring, 1px inside stroke, color fading out along a
|
||||
// linear gradient, spinning. Colors resolve through --dsw-* tokens only.
|
||||
// core. ongoing: a pixel-art chase — the 8 outer cells of a 3x3 matrix light
|
||||
// up clockwise with a stepped trail. Colors resolve through --dsw-* tokens only.
|
||||
|
||||
import { useId } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import css from './StateDot.module.css'
|
||||
|
||||
/** Four-color session state semantic (green done / amber approval-waiting / blue running ring / red error). */
|
||||
export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error'
|
||||
|
||||
/** Outer 3x3 matrix cells (2px pixels on a 10px grid), clockwise from top-left. */
|
||||
const MATRIX_CELLS: readonly (readonly [number, number])[] = [
|
||||
[0, 0], [4, 0], [8, 0], [8, 4], [8, 8], [4, 8], [0, 8], [0, 4],
|
||||
]
|
||||
|
||||
/**
|
||||
* Render a state dot.
|
||||
* @param props.state - which of the four states to show.
|
||||
@@ -22,25 +26,29 @@ export function StateDot({ state, size = 10, className }: {
|
||||
size?: number | undefined
|
||||
className?: string | undefined
|
||||
}) {
|
||||
const gradientId = useId()
|
||||
if (state === 'ongoing') {
|
||||
return (
|
||||
<svg
|
||||
className={clsx(css.ring, className)}
|
||||
className={clsx(css.matrix, className)}
|
||||
data-state="ongoing"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 10 10"
|
||||
shapeRendering="crispEdges"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
{/* Gradient handles from the figma node: (0.1,0) -> (0.85,1). */}
|
||||
<linearGradient id={gradientId} x1="1" y1="0" x2="8.5" y2="10" gradientUnits="userSpaceOnUse">
|
||||
<stop className={css.stopFrom} offset="0" />
|
||||
<stop className={css.stopTo} offset="1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="5" cy="5" r="4.5" fill="none" strokeWidth="1" stroke={`url(#${gradientId})`} />
|
||||
{MATRIX_CELLS.map(([x, y], index) => (
|
||||
<rect
|
||||
key={`${x}-${y}`}
|
||||
className={css.cell}
|
||||
x={x}
|
||||
y={y}
|
||||
width="2"
|
||||
height="2"
|
||||
/* Negative delay phases the chase so every cell animates from mount. */
|
||||
style={{ animationDelay: `${(index - MATRIX_CELLS.length) * 125}ms` }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: {
|
||||
{cloneElement(children, {
|
||||
ref: mergedRef,
|
||||
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() },
|
||||
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; hide() },
|
||||
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; setPos(null) },
|
||||
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() },
|
||||
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
|
||||
})}
|
||||
|
||||
@@ -271,14 +271,47 @@ describe('Menu', () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('portal mode positions from the opposite edges for align=end / side=top', () => {
|
||||
it('portal mode resolves align=end / side=top to clamped left/top coordinates', () => {
|
||||
render(
|
||||
<Menu portal open align="end" side="top" anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />)
|
||||
const menu = screen.getByRole('menu')
|
||||
expect(menu.style.right).not.toBe('')
|
||||
expect(menu.style.bottom).not.toBe('')
|
||||
expect(menu.style.left).toBe('')
|
||||
expect(menu.style.top).toBe('')
|
||||
expect(menu.style.left).not.toBe('')
|
||||
expect(menu.style.top).not.toBe('')
|
||||
expect(menu.style.right).toBe('')
|
||||
expect(menu.style.bottom).toBe('')
|
||||
})
|
||||
|
||||
it('renders footer rows in a pinned section below the items; they still select', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(
|
||||
<Menu
|
||||
open
|
||||
anchor={<span>trigger</span>}
|
||||
items={items}
|
||||
footer={[{ id: 'new', label: 'Create new' }]}
|
||||
onSelect={onSelect}
|
||||
onClose={() => {}}
|
||||
/>)
|
||||
const footerItem = screen.getByRole('menuitem', { name: 'Create new' })
|
||||
expect((footerItem.closest('div[class*="footer"]'))).not.toBeNull()
|
||||
expect(screen.getByRole('menuitem', { name: 'Alpha' }).closest('div[class*="footer"]')).toBeNull()
|
||||
fireEvent.click(footerItem)
|
||||
expect(onSelect).toHaveBeenCalledWith('new')
|
||||
})
|
||||
|
||||
it('caps the list height for internal scrolling unless a submenu row is present', () => {
|
||||
const { rerender } = render(
|
||||
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />)
|
||||
expect(screen.getByRole('menu').className).toMatch(/scrollable/)
|
||||
rerender(
|
||||
<Menu
|
||||
open
|
||||
anchor={<span>trigger</span>}
|
||||
items={[{ id: 'p', label: 'Parent', submenu: [{ id: 's', label: 'Sub' }] }]}
|
||||
onSelect={() => {}}
|
||||
onClose={() => {}}
|
||||
/>)
|
||||
expect(screen.getByRole('menu').className).not.toMatch(/scrollable/)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -14,16 +14,17 @@ describe('StateDot', () => {
|
||||
expect(dot.getAttribute('aria-hidden')).toBe('true')
|
||||
})
|
||||
|
||||
it('solid states are spans; ongoing is an svg gradient ring', () => {
|
||||
it('solid states are spans; ongoing is an svg pixel matrix', () => {
|
||||
const { container, rerender } = render(<StateDot state="done" />)
|
||||
expect(container.firstElementChild?.tagName).toBe('SPAN')
|
||||
rerender(<StateDot state="ongoing" />)
|
||||
const ring = container.firstElementChild as SVGSVGElement
|
||||
expect(ring.tagName).toBe('svg')
|
||||
const circle = ring.querySelector('circle')
|
||||
expect(circle?.getAttribute('stroke-width')).toBe('1')
|
||||
expect(circle?.getAttribute('stroke')).toMatch(/^url\(#/)
|
||||
expect(ring.querySelector('linearGradient')).not.toBeNull()
|
||||
const matrix = container.firstElementChild as SVGSVGElement
|
||||
expect(matrix.tagName).toBe('svg')
|
||||
const cells = matrix.querySelectorAll('rect')
|
||||
expect(cells).toHaveLength(8)
|
||||
// Chase phase: every cell carries its own negative animation delay.
|
||||
const delays = [...cells].map(cell => (cell).style.animationDelay)
|
||||
expect(new Set(delays).size).toBe(8)
|
||||
})
|
||||
|
||||
it('sizes via the size prop in both shapes', () => {
|
||||
|
||||
@@ -81,23 +81,20 @@ describe('Tooltip', () => {
|
||||
expect(screen.getByRole('tooltip')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the bubble while either hover or focus is still active', () => {
|
||||
it('mouse leave hides the bubble immediately, even while the anchor stays focused', () => {
|
||||
render(
|
||||
<Tooltip label="Sticky">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
const anchor = screen.getByText('anchor')
|
||||
// Focused AND hovered: leaving with the mouse must not drop the bubble.
|
||||
// Focused AND hovered: leaving with the mouse drops the bubble at once.
|
||||
fireEvent.focus(anchor)
|
||||
fireEvent.mouseEnter(anchor)
|
||||
fireEvent.mouseLeave(anchor)
|
||||
expect(screen.getByRole('tooltip')).toBeTruthy()
|
||||
fireEvent.blur(anchor)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
// Symmetric: blurring while still hovered keeps it, mouseleave ends it.
|
||||
// Re-entering shows it again; blurring while still hovered keeps it.
|
||||
fireEvent.mouseEnter(anchor)
|
||||
fireEvent.focus(anchor)
|
||||
fireEvent.blur(anchor)
|
||||
expect(screen.getByRole('tooltip')).toBeTruthy()
|
||||
fireEvent.mouseLeave(anchor)
|
||||
|
||||
Reference in New Issue
Block a user