/**
* Component tests for SvMenu: the trigger opens a portalled menu, items/separators
* render with menu semantics, choosing a leaf fires onSelect + closes, and a
* submenu opens on pointer-enter.
*/
import { describe, expect, it, afterEach } from 'vitest'
import { mount, unmount, flushSync, createRawSnippet } from 'svelte'
import SvMenu, { type MenuItem } from './SvMenu.svelte'
const anchor = createRawSnippet(() => ({ render: () => `` }))
function mountMenu(props: Record) {
const target = document.createElement('div')
document.body.appendChild(target)
const app = mount(SvMenu, { target, props: { anchor, ...props } as any })
flushSync()
return { target, destroy: () => { unmount(app); target.remove() } }
}
const menu = () => document.querySelector('.sv-menu')
afterEach(() => document.querySelectorAll('.sv-menu').forEach((n) => n.remove()))
describe('SvMenu', () => {
const items: MenuItem[] = [
{ label: 'Cut', shortcut: 'Ctrl+X', onSelect: () => {} },
{ label: 'Copy', shortcut: 'Ctrl+C' },
{ separator: true },
{ label: 'Share', children: [{ label: 'Email' }, { label: 'Link' }] },
{ label: 'Delete', disabled: true },
]
it('opens on trigger click with menu/menuitem roles + a separator', () => {
const { target, destroy } = mountMenu({ items })
try {
expect(menu()).toBeNull()
target.querySelector('.trigger')!.click()
flushSync()
const m = menu()!
expect(m.querySelector('[role="menu"]')).not.toBeNull()
expect(m.querySelectorAll('[role="menuitem"]').length).toBe(4) // 4 items, separator excluded
expect(m.querySelector('[role="separator"]')).not.toBeNull()
expect(m.querySelector('[aria-disabled="true"]')).not.toBeNull()
} finally { destroy() }
})
it('choosing a leaf fires onSelect and closes', () => {
let chosen: MenuItem | undefined
const { target, destroy } = mountMenu({ items, onSelect: (i: MenuItem) => (chosen = i) })
try {
target.querySelector('.trigger')!.click()
flushSync()
const copy = [...menu()!.querySelectorAll('[role="menuitem"]')].find((b) => b.textContent?.includes('Copy'))!
copy.click()
flushSync()
expect(chosen?.label).toBe('Copy')
expect(menu()).toBeNull()
} finally { destroy() }
})
it('ArrowDown moves the roving tab stop and Enter activates via keyboard', () => {
let chosen: MenuItem | undefined
const { target, destroy } = mountMenu({ items, onSelect: (i: MenuItem) => (chosen = i) })
try {
target.querySelector('.trigger')!.click()
flushSync()
const btns = () => [...menu()!.querySelectorAll('[role="menuitem"]')]
const cut = btns().find((b) => b.textContent?.includes('Cut'))!
// From nothing focused, ArrowDown lands on the first item, then the next.
cut.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
flushSync()
cut.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
flushSync()
const copy = btns().find((b) => b.textContent?.includes('Copy'))!
expect(copy.tabIndex).toBe(0)
expect(cut.tabIndex).toBe(-1)
// Enter on the focused item activates it and closes the menu.
copy.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
flushSync()
expect(chosen?.label).toBe('Copy')
expect(menu()).toBeNull()
} finally { destroy() }
})
it('opens a submenu on pointer-enter of a parent item', () => {
const { target, destroy } = mountMenu({ items })
try {
target.querySelector('.trigger')!.click()
flushSync()
const share = [...menu()!.querySelectorAll('[role="menuitem"]')].find((b) => b.textContent?.includes('Share'))!
expect(share.getAttribute('aria-haspopup')).toBe('menu')
share.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true }))
flushSync()
expect(menu()!.querySelector('.sv-menu__flyout')).not.toBeNull()
const sub = [...menu()!.querySelectorAll('[role="menuitem"]')].map((b) => b.textContent)
expect(sub.some((t) => t?.includes('Email'))).toBe(true)
} finally { destroy() }
})
})