import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { enableAutoUnmount, mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import ActionMenu from '../../components/ActionMenu.vue'
const items = [
{ id: 'edit', label: 'Edit' },
{ id: 'delete', label: 'Delete', danger: true },
]
enableAutoUnmount(afterEach)
describe('ActionMenu', () => {
it('should open the menu from a #trigger slot button', async () => {
const wrapper = mount(ActionMenu, {
attachTo: document.body,
props: { label: 'Import / Export', items },
slots: {
trigger: ``,
},
})
await wrapper.get('.custom').trigger('click')
expect(document.body.querySelector('.mint-action-menu__panel')).not.toBeNull()
})
it('should render the default trigger with the Lucide ellipsis icon', () => {
const wrapper = mount(ActionMenu, {
props: { label: 'More user actions', items },
})
expect(wrapper.find('svg.lucide-ellipsis').exists()).toBe(true)
})
it('should keep the default trigger icon at 16 pixels', () => {
const styles = readFileSync(
resolve(process.cwd(), 'src/styles/components/action-menu.css'),
'utf8',
)
expect(styles).toMatch(
/\.mint-action-menu__trigger-icon\s*\{[^}]*width:\s*1rem;[^}]*height:\s*1rem;/s,
)
})
it('should expose menu semantics on the square trigger', () => {
const wrapper = mount(ActionMenu, {
props: { label: 'More user actions', items },
})
expect(wrapper.get('button').attributes('aria-haspopup')).toBe('menu')
})
it('should teleport the menu to the document body when opened', async () => {
const wrapper = mount(ActionMenu, {
attachTo: document.body,
props: { label: 'More user actions', items },
})
await wrapper.get('button').trigger('click')
expect(document.body.querySelector('.mint-action-menu__panel')).not.toBeNull()
})
it('should emit the selected action item when an item is clicked', async () => {
const wrapper = mount(ActionMenu, {
attachTo: document.body,
props: { label: 'More user actions', items },
})
await wrapper.get('button').trigger('click')
const menuItems = document.body.querySelectorAll('.mint-action-menu__item')
menuItems[0]?.click()
await wrapper.vm.$nextTick()
expect(wrapper.emitted('select')).toEqual([[items[0]]])
})
it('should render link items as semantic anchors', async () => {
const wrapper = mount(ActionMenu, {
attachTo: document.body,
props: {
label: 'More plugin actions',
items: [{ id: 'docs', label: 'Documentation', href: '/docs' }],
},
})
await wrapper.get('button').trigger('click')
expect(document.body.querySelector('.mint-action-menu__item')?.tagName).toBe('A')
})
it('should mark destructive items with the danger treatment', async () => {
const wrapper = mount(ActionMenu, {
attachTo: document.body,
props: { label: 'More user actions', items },
})
await wrapper.get('button').trigger('click')
const menuItems = document.body.querySelectorAll('.mint-action-menu__item')
expect(menuItems[1]?.classList.contains('mint-action-menu__item--danger')).toBe(true)
})
it('should render the panel on the shared popover surface when opened', async () => {
const wrapper = mount(ActionMenu, {
attachTo: document.body,
props: { label: 'More user actions', items },
})
await wrapper.get('button').trigger('click')
expect(document.body.querySelector('.mint-action-menu__panel')?.classList.contains('mint-popover')).toBe(true)
})
it('should render items as shared menu items when opened', async () => {
const wrapper = mount(ActionMenu, {
attachTo: document.body,
props: { label: 'More user actions', items },
})
await wrapper.get('button').trigger('click')
expect(document.body.querySelectorAll('.mint-action-menu__item.mint-menu-item')).toHaveLength(2)
})
it('should precede a danger item with a separator when other items come first', async () => {
const wrapper = mount(ActionMenu, {
attachTo: document.body,
props: { label: 'More user actions', items },
})
await wrapper.get('button').trigger('click')
const danger = document.body.querySelector('.mint-action-menu__item--danger')
expect(danger?.previousElementSibling?.classList.contains('mint-menu-separator')).toBe(true)
})
it('should not render a separator when the danger item is first', async () => {
const wrapper = mount(ActionMenu, {
attachTo: document.body,
props: { label: 'More user actions', items: [items[1], items[0]] },
})
await wrapper.get('button').trigger('click')
expect(document.body.querySelector('.mint-menu-separator')).toBeNull()
})
it('should close an open menu when its scroll context moves', async () => {
const wrapper = mount(ActionMenu, {
attachTo: document.body,
props: { label: 'More user actions', items },
})
await wrapper.get('button').trigger('click')
window.dispatchEvent(new Event('scroll'))
await wrapper.vm.$nextTick()
expect(document.body.querySelector('.mint-action-menu__panel')).toBeNull()
})
it('should open from the trigger keyboard shortcuts and focus the first item', async () => {
const wrapper = mount(ActionMenu, {
attachTo: document.body,
props: { label: 'More user actions', items },
})
await wrapper.get('button').trigger('keydown', { key: 'ArrowDown' })
await wrapper.vm.$nextTick()
const menuItems = document.body.querySelectorAll('.mint-action-menu__item')
expect(menuItems).toHaveLength(2)
expect(document.activeElement).toBe(menuItems[0])
})
it('should open focused on the last item with ArrowUp', async () => {
const wrapper = mount(ActionMenu, {
attachTo: document.body,
props: { label: 'More user actions', items },
})
await wrapper.get('button').trigger('keydown', { key: 'ArrowUp' })
await wrapper.vm.$nextTick()
const menuItems = document.body.querySelectorAll('.mint-action-menu__item')
expect(document.activeElement).toBe(menuItems[1])
})
it('should cycle items with the arrow keys and jump with Home/End', async () => {
const wrapper = mount(ActionMenu, {
attachTo: document.body,
props: { label: 'More user actions', items },
})
await wrapper.get('button').trigger('click')
await wrapper.vm.$nextTick()
const panel = document.body.querySelector('.mint-action-menu__panel')
const menuItems = document.body.querySelectorAll('.mint-action-menu__item')
function press(key: string) {
panel?.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
}
press('ArrowDown')
expect(document.activeElement).toBe(menuItems[1])
press('ArrowDown')
expect(document.activeElement).toBe(menuItems[0])
press('End')
expect(document.activeElement).toBe(menuItems[1])
press('Home')
expect(document.activeElement).toBe(menuItems[0])
})
it('should close on Tab without forcing focus back to the trigger', async () => {
const wrapper = mount(ActionMenu, {
attachTo: document.body,
props: { label: 'More user actions', items },
})
await wrapper.get('button').trigger('click')
document.body.querySelector('.mint-action-menu__panel')?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }),
)
await wrapper.vm.$nextTick()
expect(document.body.querySelector('.mint-action-menu__panel')).toBeNull()
expect(document.activeElement).not.toBe(wrapper.get('button').element)
})
it('should not open from the keyboard while disabled', async () => {
const wrapper = mount(ActionMenu, {
attachTo: document.body,
props: { label: 'More user actions', items, disabled: true },
})
await wrapper.get('button').trigger('keydown', { key: 'ArrowDown' })
await wrapper.vm.$nextTick()
expect(document.body.querySelector('.mint-action-menu__panel')).toBeNull()
})
it('should close when a click lands outside the trigger and panel', async () => {
const wrapper = mount(ActionMenu, {
attachTo: document.body,
props: { label: 'More user actions', items },
})
await wrapper.get('button').trigger('click')
expect(document.body.querySelector('.mint-action-menu__panel')).not.toBeNull()
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await wrapper.vm.$nextTick()
expect(document.body.querySelector('.mint-action-menu__panel')).toBeNull()
})
it('should return focus to the trigger when Escape closes the menu', async () => {
const wrapper = mount(ActionMenu, {
attachTo: document.body,
props: { label: 'More user actions', items },
})
const trigger = wrapper.get('button')
await trigger.trigger('click')
document.body.querySelector('.mint-action-menu__item')?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
)
await wrapper.vm.$nextTick()
expect(document.activeElement).toBe(trigger.element)
})
it('should render a checked item as a checked menu radio when checked is set', async () => {
const wrapper = mount(ActionMenu, {
attachTo: document.body,
props: { label: 'Sort', items: [{ id: 'a', label: 'A', checked: true }, { id: 'b', label: 'B', checked: false }] },
})
await wrapper.get('button').trigger('click')
const radios = [...document.body.querySelectorAll('[role="menuitemradio"]')]
expect(radios.map((item) => item.getAttribute('aria-checked'))).toEqual(['true', 'false'])
})
})