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']) }) })