Merge origin/master into worktree-websettings
ui-sidebar shell landed on master (#643: geometry-only shell, the browsing region moved to the sidebar.workspaces slot); this branch's sidebar.settings foot seat re-applies on top — the hardcoded foot row becomes the seat, the seat rides the railIn crossfade, and the shell spec's renderSlot stub key-splits region vs settings.
This commit is contained in:
22
packages/client/ui-primitives/src/HoverCard.module.css
Normal file
22
packages/client/ui-primitives/src/HoverCard.module.css
Normal 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;
|
||||
}
|
||||
112
packages/client/ui-primitives/src/HoverCard.tsx
Normal file
112
packages/client/ui-primitives/src/HoverCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -111,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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
148
packages/client/ui-primitives/tests/hover-card.spec.tsx
Normal file
148
packages/client/ui-primitives/tests/hover-card.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user