@gitlab/ui
Version:
GitLab UI Components
560 lines (465 loc) • 19.9 kB
JavaScript
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { computePosition, autoUpdate, offset, autoPlacement } from '@floating-ui/dom';
import {
ARROW_DOWN,
GL_DROPDOWN_FOCUS_CONTENT,
GL_DROPDOWN_HIDDEN,
GL_DROPDOWN_SHOWN,
GL_DROPDOWN_BEFORE_CLOSE,
GL_DROPDOWN_CONTENTS_CLASS,
} from '../constants';
import { waitForAnimationFrame } from '../../../../utils/test_utils';
import { DEFAULT_OFFSET, FIXED_WIDTH_CLASS } from './constants';
import GlBaseDropdown from './base_dropdown.vue';
jest.mock('@floating-ui/dom');
const mockStopAutoUpdate = jest.fn();
offset.mockImplementation((options) => options);
autoPlacement.mockImplementation((options) => options);
const DEFAULT_BTN_TOGGLE_CLASSES = [
'btn',
'btn-default',
'btn-md',
'gl-button',
'gl-new-dropdown-toggle',
];
describe('base dropdown', () => {
let wrapper;
const buildWrapper = (propsData, slots = {}, listeners = {}) => {
wrapper = mount(GlBaseDropdown, {
propsData: {
toggleId: 'dropdown-toggle-btn-1',
...propsData,
},
slots: {
default: `<div class="${GL_DROPDOWN_CONTENTS_CLASS}"><button /></div>`,
...slots,
},
attachTo: document.body,
listeners,
});
};
beforeEach(() => {
jest.clearAllMocks();
autoUpdate.mockImplementation(() => {
return mockStopAutoUpdate;
});
computePosition.mockImplementation(() => new Promise(() => {}));
});
const findDefaultDropdownToggle = () => wrapper.find('.btn.gl-new-dropdown-toggle');
const findCustomDropdownToggle = () => wrapper.find('.gl-new-dropdown-custom-toggle');
const findDropdownToggleText = () => findDefaultDropdownToggle().find('.gl-button-text');
const findDropdownMenu = () => wrapper.find('.gl-new-dropdown-panel');
const moveFocusWithinDropdown = () => findDropdownMenu().find('button').element.focus();
describe('Floating UI instance', () => {
it("starts Floating UI's when opening the dropdown", async () => {
buildWrapper();
await findDefaultDropdownToggle().trigger('click');
expect(autoUpdate).toHaveBeenCalledTimes(1);
});
it('stops Floating UI when closing the dropdown', async () => {
buildWrapper();
await findDefaultDropdownToggle().trigger('click');
await findDefaultDropdownToggle().trigger('click');
expect(autoUpdate).toHaveBeenCalledTimes(1);
expect(mockStopAutoUpdate).toHaveBeenCalledTimes(1);
});
it('restarts Floating UI when reopening the dropdown', async () => {
buildWrapper();
await findDefaultDropdownToggle().trigger('click');
await findDefaultDropdownToggle().trigger('click');
await findDefaultDropdownToggle().trigger('click');
expect(autoUpdate).toHaveBeenCalledTimes(2);
expect(mockStopAutoUpdate).toHaveBeenCalledTimes(1);
});
it("stops Floating UI's auto updates on destroy", async () => {
buildWrapper();
await findDefaultDropdownToggle().trigger('click');
wrapper.destroy();
expect(mockStopAutoUpdate).toHaveBeenCalled();
});
describe('computePosition', () => {
beforeEach(() => {
autoUpdate.mockImplementation(jest.requireActual('@floating-ui/dom').autoUpdate);
});
it('initializes Floating UI with reference and floating elements and config for left-aligned menu', async () => {
buildWrapper();
await findDefaultDropdownToggle().trigger('click');
expect(computePosition).toHaveBeenCalledWith(
findDefaultDropdownToggle().element,
findDropdownMenu().element,
{
placement: 'bottom-start',
strategy: 'absolute',
middleware: [
offset({ mainAxis: DEFAULT_OFFSET }),
autoPlacement({
alignment: 'start',
allowedPlacements: ['bottom-start', 'top-start', 'bottom-end', 'top-end'],
}),
],
}
);
});
it('initializes Floating UI with reference and floating elements and config for center-aligned menu', async () => {
buildWrapper({ placement: 'center' });
await findDefaultDropdownToggle().trigger('click');
expect(computePosition).toHaveBeenCalledWith(
findDefaultDropdownToggle().element,
findDropdownMenu().element,
{
placement: 'bottom',
strategy: 'absolute',
middleware: [
offset({ mainAxis: DEFAULT_OFFSET }),
autoPlacement({
alignment: undefined,
allowedPlacements: ['bottom', 'top'],
}),
],
}
);
});
it('initializes Floating UI with reference and floating elements and config for right-aligned menu', async () => {
buildWrapper({ placement: 'right' });
await findDefaultDropdownToggle().trigger('click');
expect(computePosition).toHaveBeenCalledWith(
findDefaultDropdownToggle().element,
findDropdownMenu().element,
{
placement: 'bottom-end',
strategy: 'absolute',
middleware: [
offset({ mainAxis: DEFAULT_OFFSET }),
autoPlacement({
alignment: 'end',
allowedPlacements: ['bottom-start', 'top-start', 'bottom-end', 'top-end'],
}),
],
}
);
});
it('initializes Floating UI with reference and floating elements and config for `right-start` aligned menu', async () => {
buildWrapper({ placement: 'right-start' });
await findDefaultDropdownToggle().trigger('click');
expect(computePosition).toHaveBeenCalledWith(
findDefaultDropdownToggle().element,
findDropdownMenu().element,
{
placement: 'right-start',
strategy: 'absolute',
middleware: [
offset({ mainAxis: DEFAULT_OFFSET }),
autoPlacement({
alignment: 'start',
allowedPlacements: ['right-start', 'right-end', 'left-start', 'left-end'],
}),
],
}
);
});
it("passes custom offset to Floating UI's middleware", async () => {
const customOffset = { mainAxis: 10, crossAxis: 40 };
buildWrapper({
placement: 'right',
offset: customOffset,
});
await findDefaultDropdownToggle().trigger('click');
expect(computePosition).toHaveBeenCalledWith(
findDefaultDropdownToggle().element,
findDropdownMenu().element,
{
placement: 'bottom-end',
strategy: 'absolute',
middleware: [offset(customOffset), autoPlacement(expect.any(Object))],
}
);
});
describe('positioningStrategy', () => {
it('uses the absolute positioning strategy by default', async () => {
buildWrapper();
await findDefaultDropdownToggle().trigger('click');
await nextTick();
expect(computePosition).toHaveBeenCalledWith(
findDefaultDropdownToggle().element,
findDropdownMenu().element,
expect.objectContaining({
strategy: 'absolute',
})
);
expect(findDropdownMenu().classes()).toContain('gl-absolute');
});
it('applies the fixed positioning strategy properly', async () => {
buildWrapper({
positioningStrategy: 'fixed',
});
await findDefaultDropdownToggle().trigger('click');
await nextTick();
expect(computePosition).toHaveBeenCalledWith(
findDefaultDropdownToggle().element,
findDropdownMenu().element,
expect.objectContaining({
strategy: 'fixed',
})
);
expect(findDropdownMenu().classes()).toContain('gl-fixed');
});
});
});
});
describe('renders content to the default slot', () => {
const defaultContent = 'Some content here';
const slots = { default: defaultContent };
it('renders the content', () => {
buildWrapper({}, slots);
expect(wrapper.find('.gl-new-dropdown-inner').html()).toContain(defaultContent);
});
});
describe.each`
props | toggleClasses
${{}} | ${['gl-new-dropdown-caret-only']}
${{ toggleText: 'toggleText' }} | ${[]}
${{ icon: 'close' }} | ${['gl-new-dropdown-icon-only', 'btn-icon']}
${{ icon: 'close', toggleText: 'toggleText', textSrOnly: true }} | ${['gl-new-dropdown-icon-only', 'btn-icon']}
${{ icon: 'close', textSrOnly: true }} | ${['gl-new-dropdown-icon-only', 'btn-icon']}
${{ toggleText: 'toggleText', noCaret: true }} | ${['gl-new-dropdown-toggle-no-caret']}
`('dropdown with props $props', ({ props, toggleClasses }) => {
beforeEach(async () => {
buildWrapper(props);
await nextTick();
});
it(`sets toggle button classes to '${toggleClasses}'`, () => {
const classes = findDefaultDropdownToggle().classes().sort();
expect(classes).toEqual([...DEFAULT_BTN_TOGGLE_CLASSES, ...toggleClasses].sort());
});
});
describe.each`
toggleClass | expectedClasses | type
${'my-class'} | ${[...DEFAULT_BTN_TOGGLE_CLASSES, 'my-class']} | ${'string'}
${{ 'my-class': true }} | ${[...DEFAULT_BTN_TOGGLE_CLASSES, 'my-class']} | ${'object'}
${['cls-1', 'cls-2']} | ${[...DEFAULT_BTN_TOGGLE_CLASSES, 'cls-1', 'cls-2']} | ${'array'}
${null} | ${[...DEFAULT_BTN_TOGGLE_CLASSES]} | ${'null'}
`('with toggle classes', ({ toggleClass, expectedClasses, type }) => {
beforeEach(async () => {
buildWrapper({ toggleClass });
await nextTick();
});
it(`class is inherited from toggle class of type ${type}`, () => {
expect(findDefaultDropdownToggle().classes().sort()).toEqual(
expect.arrayContaining(expectedClasses.sort())
);
});
});
describe('block prop', () => {
it('does not apply block style if false', () => {
buildWrapper({ block: false });
expect(wrapper.classes()).not.toContain('gl-w-full');
expect(findDropdownToggleText().classes()).not.toContain('gl-w-full');
});
it('applies block style if true', () => {
buildWrapper({ block: true });
expect(wrapper.classes()).toContain('gl-display-block!');
expect(findDropdownToggleText().classes()).toContain('gl-w-full');
expect(findDefaultDropdownToggle().props('block')).toBe(true);
});
});
describe('default toggle', () => {
beforeEach(() => {
buildWrapper();
});
it('should open the menu on click but keep focus on toggle', async () => {
const toggle = findDefaultDropdownToggle();
const menu = findDropdownMenu();
toggle.element.focus();
// open menu clicking toggle btn
await toggle.trigger('click');
expect(menu.classes('gl-display-block!')).toBe(true);
expect(toggle.attributes('aria-expanded')).toBe('true');
expect(toggle.element).toHaveFocus();
// close menu clicking toggle btn
await toggle.trigger('click');
expect(menu.classes('gl-display-block!')).toBe(false);
expect(toggle.attributes('aria-expanded')).toBe('false');
expect(wrapper.emitted(GL_DROPDOWN_HIDDEN)).toHaveLength(1);
expect(toggle.element).toHaveFocus();
});
it('should emit `GL_DROPDOWN_FOCUS_CONTENT` event on `ARROW_DOWN`', () => {
findDefaultDropdownToggle().trigger('keydown', { code: ARROW_DOWN });
expect(wrapper.emitted(GL_DROPDOWN_FOCUS_CONTENT)).toHaveLength(1);
});
it('should close menu on Escape and focus toggle', async () => {
const toggle = findDefaultDropdownToggle();
const menu = findDropdownMenu();
// open menu clicking toggle btn
await toggle.trigger('click');
expect(menu.classes('gl-display-block!')).toBe(true);
expect(toggle.attributes('aria-expanded')).toBe('true');
moveFocusWithinDropdown();
// close menu by pressing ESC key
await menu.trigger('keydown.esc');
expect(menu.classes('gl-display-block!')).toBe(false);
expect(toggle.attributes('aria-expanded')).toBe('false');
expect(wrapper.emitted(GL_DROPDOWN_HIDDEN)).toHaveLength(1);
expect(toggle.element).toHaveFocus();
});
it('should close menu on Escape when focus is on toggle', async () => {
const toggle = findDefaultDropdownToggle();
const menu = findDropdownMenu();
await toggle.trigger('click');
expect(menu.classes('gl-display-block!')).toBe(true);
expect(toggle.attributes('aria-expanded')).toBe('true');
await toggle.trigger('keydown.esc');
expect(menu.classes('gl-display-block!')).toBe(false);
expect(toggle.attributes('aria-expanded')).toBe('false');
expect(wrapper.emitted(GL_DROPDOWN_HIDDEN)).toHaveLength(1);
});
describe('when the consumer takes over the focus', () => {
let consumerButton;
beforeEach(() => {
consumerButton = document.createElement('button');
document.body.appendChild(consumerButton);
});
afterEach(() => {
consumerButton.remove();
});
it('does not steal the focus back from the consumer when closing the dropdown', async () => {
const toggle = findDefaultDropdownToggle();
const menu = findDropdownMenu();
// open menu clicking toggle btn
await toggle.trigger('click');
moveFocusWithinDropdown();
// consumer focuses some element
consumerButton.focus();
// close menu by pressing ESC key
await menu.trigger('keydown.esc');
expect(consumerButton).toHaveFocus();
});
});
});
describe('beforeClose event', () => {
let event;
beforeEach(() => {
event = undefined;
buildWrapper(undefined, undefined, {
[GL_DROPDOWN_BEFORE_CLOSE]({ originalEvent, preventDefault }) {
event = originalEvent;
preventDefault();
},
});
});
it('should prevent closing', async () => {
const toggle = findDefaultDropdownToggle();
const menu = findDropdownMenu();
await toggle.trigger('click');
moveFocusWithinDropdown();
await menu.trigger('keydown.esc');
expect(menu.classes('gl-display-block!')).toBe(true);
expect(toggle.attributes('aria-expanded')).toBe('true');
expect(wrapper.emitted(GL_DROPDOWN_HIDDEN)).toBeUndefined();
expect(toggle.element).not.toHaveFocus();
});
it('should contain original keyboard event', async () => {
const toggle = findDefaultDropdownToggle();
const menu = findDropdownMenu();
await toggle.trigger('click');
moveFocusWithinDropdown();
await menu.trigger('keydown.esc');
expect(event.type).toBe('keydown');
});
it('should contain original toggle click event', async () => {
const toggle = findDefaultDropdownToggle();
await toggle.trigger('click');
await toggle.trigger('click');
expect(event.type).toBe('click');
});
it('should contain original outside click event', async () => {
const outsideElement = document.createElement('div');
document.body.appendChild(outsideElement);
const toggle = findDefaultDropdownToggle();
await toggle.trigger('click');
const click = new MouseEvent('click', { bubbles: true });
outsideElement.dispatchEvent(click);
expect(event).toBe(click);
});
});
describe('Custom toggle', () => {
const customToggleTestId = 'custom-toggle';
const toggleContent = `<button data-testid="${customToggleTestId}">Custom toggle</button>`;
const findFirstToggleElement = () =>
findCustomDropdownToggle().find(`[data-testid="${customToggleTestId}"]`);
beforeEach(() => {
const slots = { toggle: toggleContent };
buildWrapper({}, slots);
});
it('does not render default toggle button', () => {
expect(findDefaultDropdownToggle().exists()).toBe(false);
});
it('renders the custom toggle instead', () => {
expect(findCustomDropdownToggle().exists()).toBe(true);
});
it('renders provided via slot content as custom toggle', () => {
expect(findCustomDropdownToggle().html()).toContain(toggleContent);
});
describe('toggle visibility', () => {
beforeEach(() => {
autoUpdate.mockImplementation(jest.requireActual('@floating-ui/dom').autoUpdate);
computePosition.mockImplementation(() => Promise.resolve);
});
it('should toggle menu visibility on toggle click', async () => {
const toggle = findCustomDropdownToggle();
const firstToggleChild = findFirstToggleElement();
const menu = findDropdownMenu();
// open menu clicking toggle btn
await toggle.trigger('click');
expect(menu.classes('gl-display-block!')).toBe(true);
expect(firstToggleChild.attributes('aria-expanded')).toBe('true');
await waitForAnimationFrame();
expect(wrapper.emitted(GL_DROPDOWN_SHOWN)).toHaveLength(1);
// close menu clicking toggle btn again
await toggle.trigger('click');
expect(menu.classes('gl-display-block!')).toBe(false);
expect(firstToggleChild.attributes('aria-expanded')).toBe('false');
expect(wrapper.emitted(GL_DROPDOWN_HIDDEN)).toHaveLength(1);
});
it('should close the menu when Escape is pressed inside menu and focus first child in the toggle', async () => {
const toggle = findCustomDropdownToggle();
const firstToggleChild = findFirstToggleElement();
const menu = findDropdownMenu();
// open menu clicking toggle btn
await toggle.trigger('click');
expect(menu.classes('gl-display-block!')).toBe(true);
// close menu pressing ESC on it
moveFocusWithinDropdown();
await menu.trigger('keydown.esc');
expect(menu.classes('gl-display-block!')).toBe(false);
expect(firstToggleChild.attributes('aria-expanded')).toBe('false');
expect(wrapper.emitted(GL_DROPDOWN_HIDDEN)).toHaveLength(1);
expect(toggle.find(`[data-testid="${customToggleTestId}"]`).element).toHaveFocus();
});
it('should close menu on Escape when focus is on toggle', async () => {
const toggle = findCustomDropdownToggle();
const menu = findDropdownMenu();
await toggle.trigger('click');
expect(menu.classes('gl-display-block!')).toBe(true);
await toggle.trigger('keydown.esc');
expect(menu.classes('gl-display-block!')).toBe(false);
expect(wrapper.emitted(GL_DROPDOWN_HIDDEN)).toHaveLength(1);
});
});
it('should emit `GL_DROPDOWN_FOCUS_CONTENT` event on `ARROW_DOWN`', () => {
const toggle = findCustomDropdownToggle();
toggle.trigger('keydown', { code: ARROW_DOWN });
expect(wrapper.emitted(GL_DROPDOWN_FOCUS_CONTENT)).toHaveLength(1);
});
});
describe('fluid width', () => {
it('uses a fixed width by default', () => {
buildWrapper();
expect(findDropdownMenu().classes()).toContain(FIXED_WIDTH_CLASS);
});
it('drops the fixed width when `fluidWidth` is `true`', () => {
buildWrapper({ fluidWidth: true });
expect(findDropdownMenu().classes()).not.toContain(FIXED_WIDTH_CLASS);
});
});
});