The workspace browser's two hover-raised popups both died on the way to them. HoverCard closed on the first pointerleave and rendered its card pointer-events:none, but the card sits 8px off the anchor, so every path to it crossed ground belonging to neither. The row action menus put closeOnPointerLeave's handler on the portaled list, so aiming back at the ... trigger that opened it, or overshooting a list edge, closed it with no window to come back. usePointerGrace owns one cancelable delayed close (200ms) shared by both atoms: leaving arms it, returning cancels it. The hover card becomes hit-testable so resting on it holds it open, and Menu moves pointer-leave dismissal to the wrapper span, where React's enter/leave traversal makes trigger and portaled list one region. Both gestures are pinned in the real browser lane; each fails without the corresponding fix.
413 lines
16 KiB
TypeScript
413 lines
16 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { Button, ConnectionBanner, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
|
|
|
|
afterEach(cleanup)
|
|
|
|
describe('Button', () => {
|
|
it('renders children, icon, and forwards clicks', () => {
|
|
const onClick = vi.fn()
|
|
render(<Button variant="primary" icon={<svg data-testid="ic" />} onClick={onClick}>Go</Button>)
|
|
const button = screen.getByRole('button', { name: 'Go' })
|
|
expect(screen.getByTestId('ic')).toBeDefined()
|
|
fireEvent.click(button)
|
|
expect(onClick).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('disabled blocks interaction', () => {
|
|
const onClick = vi.fn()
|
|
render(<Button disabled onClick={onClick}>No</Button>)
|
|
fireEvent.click(screen.getByRole('button'))
|
|
expect(onClick).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('outline variant renders a bordered cancel-style button', () => {
|
|
render(<Button variant="outline">Cancel</Button>)
|
|
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDefined()
|
|
})
|
|
})
|
|
|
|
describe('Pill', () => {
|
|
it('is a span when static, a button when clickable', () => {
|
|
const { rerender } = render(<Pill active>tab</Pill>)
|
|
expect(screen.queryByRole('button')).toBeNull()
|
|
rerender(<Pill onClick={() => {}}>tab</Pill>)
|
|
expect(screen.getByRole('button', { name: 'tab' })).toBeDefined()
|
|
})
|
|
|
|
it('active and className land on both static and interactive forms', () => {
|
|
const { container, rerender } = render(<Pill className="x">tab</Pill>)
|
|
const asSpan = container.firstElementChild as HTMLElement
|
|
expect(asSpan.classList.contains('x')).toBe(true)
|
|
rerender(<Pill active className="x" onClick={() => {}}>tab</Pill>)
|
|
const asButton = screen.getByRole('button')
|
|
expect(asButton.classList.contains('x')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('Input', () => {
|
|
it('forwards value/onChange and renders the leading icon', () => {
|
|
const onChange = vi.fn()
|
|
render(<Input icon={<svg data-testid="ic" />} value="q" onChange={onChange} placeholder="search" />)
|
|
const input = screen.getByPlaceholderText<HTMLInputElement>('search')
|
|
expect(input.value).toBe('q')
|
|
fireEvent.change(input, { target: { value: 'qq' } })
|
|
expect(onChange).toHaveBeenCalled()
|
|
expect(screen.getByTestId('ic')).toBeDefined()
|
|
})
|
|
})
|
|
|
|
describe('Menu', () => {
|
|
const items = [
|
|
{ id: 'a', label: 'Alpha' },
|
|
{ id: 'b', label: 'Beta', disabled: true },
|
|
]
|
|
|
|
it('shows items only while open; select fires onSelect', () => {
|
|
const onSelect = vi.fn()
|
|
const { rerender } = render(
|
|
<Menu open={false} anchor={<span>trigger</span>} items={items} onSelect={onSelect} onClose={() => {}} />)
|
|
expect(screen.queryByRole('menu')).toBeNull()
|
|
rerender(
|
|
<Menu open anchor={<span>trigger</span>} items={items} selectedId="a" onSelect={onSelect} onClose={() => {}} />)
|
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' }))
|
|
expect(onSelect).toHaveBeenCalledWith('a')
|
|
})
|
|
|
|
it('disabled item does not select; Escape and outside pointerdown close', () => {
|
|
const onSelect = vi.fn()
|
|
const onClose = vi.fn()
|
|
render(
|
|
<Menu open anchor={<span>trigger</span>} items={items} onSelect={onSelect} onClose={onClose} />)
|
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Beta' }))
|
|
expect(onSelect).not.toHaveBeenCalled()
|
|
fireEvent.keyDown(document, { key: 'Escape' })
|
|
expect(onClose).toHaveBeenCalledTimes(1)
|
|
fireEvent.pointerDown(document.body)
|
|
expect(onClose).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('inside pointerdown does not close', () => {
|
|
const onClose = vi.fn()
|
|
render(
|
|
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
|
fireEvent.pointerDown(screen.getByRole('menuitem', { name: 'Alpha' }))
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('selected item shows the trailing check; align=end, side=top, and className apply', () => {
|
|
const { container } = render(
|
|
<Menu
|
|
open
|
|
align="end"
|
|
side="top"
|
|
className="x"
|
|
anchor={<span>trigger</span>}
|
|
items={items}
|
|
selectedId="a"
|
|
onSelect={() => {}}
|
|
onClose={() => {}}
|
|
/>)
|
|
expect((container.firstElementChild as HTMLElement).classList.contains('x')).toBe(true)
|
|
const menu = screen.getByRole('menu')
|
|
expect(menu.className).toMatch(/sideTop|alignEnd/)
|
|
const selected = screen.getByRole('menuitem', { name: 'Alpha' })
|
|
expect(selected.querySelector('svg')).not.toBeNull()
|
|
const other = screen.getByRole('menuitem', { name: 'Beta' })
|
|
expect(other.querySelector('svg')).toBeNull()
|
|
fireEvent.keyDown(document, { key: 'a' })
|
|
})
|
|
|
|
it('renders a leading icon and a separator between groups', () => {
|
|
render(
|
|
<Menu
|
|
open
|
|
compact
|
|
anchor={<span>trigger</span>}
|
|
items={[
|
|
{ id: 'a', label: 'Alpha', icon: <svg data-testid="ic" /> },
|
|
{ type: 'separator', id: 's1' },
|
|
{ id: 'c', label: 'Create' },
|
|
]}
|
|
onSelect={() => {}}
|
|
onClose={() => {}}
|
|
/>)
|
|
expect(screen.getByTestId('ic')).toBeDefined()
|
|
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 a grace after the pointer leaves trigger and list; default never does', () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const onClose = vi.fn()
|
|
const { rerender } = render(
|
|
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
|
const wrapper = screen.getByText('trigger').parentElement as HTMLElement
|
|
fireEvent.pointerLeave(wrapper)
|
|
// Still open through the grace: the pointer may be crossing the gap.
|
|
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 1) })
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
act(() => { vi.advanceTimersByTime(1) })
|
|
expect(onClose).toHaveBeenCalledTimes(1)
|
|
rerender(
|
|
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
|
fireEvent.pointerLeave(wrapper)
|
|
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
|
expect(onClose).toHaveBeenCalledTimes(1)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('coming back inside the grace keeps the list open (trigger and list are one region)', () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const onClose = vi.fn()
|
|
render(
|
|
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
|
const wrapper = screen.getByText('trigger').parentElement as HTMLElement
|
|
fireEvent.pointerLeave(wrapper)
|
|
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) })
|
|
fireEvent.pointerEnter(wrapper)
|
|
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('a close from selection disarms the pending grace close', () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const onClose = vi.fn()
|
|
const { rerender } = render(
|
|
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
|
const wrapper = screen.getByText('trigger').parentElement as HTMLElement
|
|
fireEvent.pointerLeave(wrapper)
|
|
// The owner closes for its own reason (selection/Escape) mid-grace; the
|
|
// armed timer must not survive to shut a list reopened right after.
|
|
rerender(
|
|
<Menu open={false} closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
|
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('leaving a closed list arms nothing', () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const onClose = vi.fn()
|
|
render(
|
|
<Menu open={false} closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
|
fireEvent.pointerLeave(screen.getByText('trigger').parentElement as HTMLElement)
|
|
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
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(
|
|
<Menu
|
|
open
|
|
compact
|
|
anchor={<span>trigger</span>}
|
|
items={[
|
|
{ id: 'plain', label: 'Plain' },
|
|
{
|
|
id: 'new',
|
|
label: 'New Workspace',
|
|
submenu: [
|
|
{ id: 'ok', label: 'Create ok', icon: <svg data-testid="sub-ic" /> },
|
|
],
|
|
},
|
|
]}
|
|
onSelect={onSelect}
|
|
onClose={() => {}}
|
|
/>)
|
|
const plain = screen.getByRole('menuitem', { name: 'Plain' })
|
|
fireEvent.mouseEnter(plain.parentElement as HTMLElement)
|
|
fireEvent.focus(plain)
|
|
const parent = screen.getByRole('menuitem', { name: 'New Workspace' })
|
|
const wrap = parent.parentElement as HTMLElement
|
|
fireEvent.click(parent)
|
|
expect(onSelect).not.toHaveBeenCalled()
|
|
fireEvent.focus(parent)
|
|
fireEvent.mouseEnter(wrap)
|
|
expect(screen.getByTestId('sub-ic')).toBeDefined()
|
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Create ok' }))
|
|
expect(onSelect).toHaveBeenCalledWith('ok')
|
|
fireEvent.mouseLeave(wrap)
|
|
expect(screen.queryByRole('menuitem', { name: 'Create ok' })).toBeNull()
|
|
})
|
|
|
|
it('portal mode prefers getAnchorRect over measuring its own wrapper', () => {
|
|
const rect = { left: 40, right: 72, top: 100, bottom: 128, width: 32, height: 28, x: 40, y: 100, toJSON: () => ({}) } as DOMRect
|
|
render(
|
|
<Menu
|
|
portal
|
|
open
|
|
getAnchorRect={() => rect}
|
|
anchor={null}
|
|
items={items}
|
|
onSelect={() => {}}
|
|
onClose={() => {}}
|
|
/>)
|
|
const menu = screen.getByRole('menu')
|
|
// side=bottom, align=start: below the host-supplied rect, left-aligned.
|
|
expect(menu.style.left).toBe('40px')
|
|
expect(menu.style.top).toBe('132px')
|
|
})
|
|
|
|
it('portal mode skips the frame when getAnchorRect returns null (no menu until a rect exists)', () => {
|
|
render(
|
|
<Menu
|
|
portal
|
|
open
|
|
getAnchorRect={() => null}
|
|
anchor={null}
|
|
items={items}
|
|
onSelect={() => {}}
|
|
onClose={() => {}}
|
|
/>)
|
|
expect(screen.queryByRole('menu')).toBeNull()
|
|
})
|
|
|
|
it('portal mode renders the list under body, positions it fixed, and still closes on outside pointerdown', () => {
|
|
const onSelect = vi.fn()
|
|
const onClose = vi.fn()
|
|
const { container } = render(
|
|
<Menu portal open anchor={<span>trigger</span>} items={items} onSelect={onSelect} onClose={onClose} />)
|
|
const menu = screen.getByRole('menu')
|
|
// Outside the anchor wrapper subtree — overflow-clipping ancestors can't crop it.
|
|
expect(container.contains(menu)).toBe(false)
|
|
expect(menu.parentElement).toBe(document.body)
|
|
expect(menu.style.top).not.toBe('')
|
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' }))
|
|
expect(onSelect).toHaveBeenCalledWith('a')
|
|
fireEvent.pointerDown(menu)
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
// Non-Node targets (e.g. window itself) are ignored, not treated as outside.
|
|
const nonNodeTarget = new Event('pointerdown', { bubbles: true })
|
|
Object.defineProperty(nonNodeTarget, 'target', { value: window })
|
|
document.dispatchEvent(nonNodeTarget)
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
fireEvent.pointerDown(document.body)
|
|
expect(onClose).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
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.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/)
|
|
})
|
|
})
|
|
|
|
describe('Modal', () => {
|
|
it('is absent while closed; Escape and mask click call onClose', () => {
|
|
const onClose = vi.fn()
|
|
const { rerender } = render(
|
|
<Modal open={false} onClose={onClose} title="Create new workspace">body</Modal>)
|
|
expect(screen.queryByRole('dialog')).toBeNull()
|
|
rerender(
|
|
<Modal open onClose={onClose} title="Create new workspace" description="Name it." footer={<button type="button">Create</button>}>
|
|
<input aria-label="name" />
|
|
</Modal>)
|
|
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined()
|
|
expect(screen.getByText('Name it.')).toBeDefined()
|
|
fireEvent.keyDown(document, { key: 'a' })
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
fireEvent.keyDown(document, { key: 'Escape' })
|
|
expect(onClose).toHaveBeenCalledTimes(1)
|
|
// Mask is the presentation sibling behind the dialog.
|
|
const mask = document.querySelector('[aria-hidden="true"]') as HTMLElement
|
|
fireEvent.click(mask)
|
|
expect(onClose).toHaveBeenCalledTimes(2)
|
|
})
|
|
})
|
|
|
|
describe('ConnectionBanner', () => {
|
|
it('renders only while reconnecting', () => {
|
|
const { container, rerender } = render(<ConnectionBanner reconnecting={false} />)
|
|
expect(container.firstChild).toBeNull()
|
|
rerender(<ConnectionBanner reconnecting />)
|
|
expect(container.textContent).toContain('重连')
|
|
})
|
|
})
|