feat(web): add workspace-aware session flow

This commit is contained in:
imccyu
2026-07-25 16:04:48 +08:00
parent 755e2a8c51
commit 9eb9c70a8a
170 changed files with 7573 additions and 3006 deletions
@@ -170,6 +170,71 @@ describe('Menu', () => {
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 positions from the opposite edges for align=end / side=top', () => {
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('')
})
})
describe('Modal', () => {
@@ -104,6 +104,26 @@ describe('Tooltip', () => {
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('forwards the anchor element to the child ref (object and callback)', () => {
const objectRef = { current: null as HTMLButtonElement | null }
const callbackRef = vi.fn()
const { rerender } = render(
<Tooltip label="Add">
<button type="button" ref={objectRef}>anchor</button>
</Tooltip>,
)
expect(objectRef.current).toBe(screen.getByText('anchor'))
// Tooltip's own positioning still works through the merged ref.
fireEvent.mouseEnter(screen.getByText('anchor'))
expect(screen.getByRole('tooltip')).toBeTruthy()
rerender(
<Tooltip label="Add">
<button type="button" ref={callbackRef}>anchor</button>
</Tooltip>,
)
expect(callbackRef).toHaveBeenCalledWith(screen.getByText('anchor'))
})
it('drops an already-visible bubble when disabled flips mid-hover', () => {
const { rerender } = render(
<Tooltip label="Rail">