diff options
Diffstat (limited to 'js/tests/unit/dropdown.spec.js')
| -rw-r--r-- | js/tests/unit/dropdown.spec.js | 436 |
1 files changed, 394 insertions, 42 deletions
diff --git a/js/tests/unit/dropdown.spec.js b/js/tests/unit/dropdown.spec.js index 658cb65b0..2b6d8cd78 100644 --- a/js/tests/unit/dropdown.spec.js +++ b/js/tests/unit/dropdown.spec.js @@ -1,8 +1,9 @@ import Dropdown from '../../src/dropdown' import EventHandler from '../../src/dom/event-handler' +import { noop } from '../../src/util' /** Test helpers */ -import { getFixture, clearFixture, createEvent, jQueryMock } from '../helpers/fixture' +import { clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture' describe('Dropdown', () => { let fixtureEl @@ -40,24 +41,22 @@ describe('Dropdown', () => { }) describe('constructor', () => { - it('should add a listener on trigger which do not have data-bs-toggle="dropdown"', () => { + it('should take care of element either passed as a CSS selector or DOM element', () => { fixtureEl.innerHTML = [ '<div class="dropdown">', - ' <button class="btn">Dropdown</button>', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', ' <div class="dropdown-menu">', - ' <a class="dropdown-item" href="#">Secondary link</a>', + ' <a class="dropdown-item" href="#">Link</a>', ' </div>', '</div>' ].join('') - const btnDropdown = fixtureEl.querySelector('.btn') - const dropdown = new Dropdown(btnDropdown) - - spyOn(dropdown, 'toggle') - - btnDropdown.click() + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownBySelector = new Dropdown('[data-bs-toggle="dropdown"]') + const dropdownByElement = new Dropdown(btnDropdown) - expect(dropdown.toggle).toHaveBeenCalled() + expect(dropdownBySelector._element).toEqual(btnDropdown) + expect(dropdownByElement._element).toEqual(btnDropdown) }) it('should create offset modifier correctly when offset option is a function', done => { @@ -197,18 +196,17 @@ describe('Dropdown', () => { const firstDropdownEl = fixtureEl.querySelector('.first') const secondDropdownEl = fixtureEl.querySelector('.second') const dropdown1 = new Dropdown(btnDropdown1) - const dropdown2 = new Dropdown(btnDropdown2) firstDropdownEl.addEventListener('shown.bs.dropdown', () => { expect(btnDropdown1.classList.contains('show')).toEqual(true) spyOn(dropdown1._popper, 'destroy') - dropdown2.toggle() + btnDropdown2.click() }) - secondDropdownEl.addEventListener('shown.bs.dropdown', () => { + secondDropdownEl.addEventListener('shown.bs.dropdown', () => setTimeout(() => { expect(dropdown1._popper.destroy).toHaveBeenCalled() done() - }) + })) dropdown1.toggle() }) @@ -234,7 +232,7 @@ describe('Dropdown', () => { btnDropdown.addEventListener('shown.bs.dropdown', () => { expect(btnDropdown.classList.contains('show')).toEqual(true) expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true') - expect(EventHandler.on).toHaveBeenCalled() + expect(EventHandler.on).toHaveBeenCalledWith(jasmine.any(Object), 'mouseover', noop) dropdown.toggle() }) @@ -242,7 +240,7 @@ describe('Dropdown', () => { btnDropdown.addEventListener('hidden.bs.dropdown', () => { expect(btnDropdown.classList.contains('show')).toEqual(false) expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false') - expect(EventHandler.off).toHaveBeenCalled() + expect(EventHandler.off).toHaveBeenCalledWith(jasmine.any(Object), 'mouseover', noop) document.documentElement.ontouchstart = defaultValueOnTouchStart done() @@ -449,6 +447,7 @@ describe('Dropdown', () => { const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') const virtualElement = { + nodeType: 1, getBoundingClientRect() { return { width: 0, @@ -725,7 +724,7 @@ describe('Dropdown', () => { it('should hide a dropdown', done => { fixtureEl.innerHTML = [ '<div class="dropdown">', - ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="true">Dropdown</button>', ' <div class="dropdown-menu show">', ' <a class="dropdown-item" href="#">Secondary link</a>', ' </div>', @@ -738,6 +737,7 @@ describe('Dropdown', () => { btnDropdown.addEventListener('hidden.bs.dropdown', () => { expect(dropdownMenu.classList.contains('show')).toEqual(false) + expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false') done() }) @@ -876,6 +876,39 @@ describe('Dropdown', () => { done() }) }) + + it('should remove event listener on touch-enabled device that was added in show method', done => { + fixtureEl.innerHTML = [ + '<div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', + ' <div class="dropdown-menu">', + ' <a class="dropdown-item" href="#">Dropdwon item</a>', + ' </div>', + '</div>' + ].join('') + + const defaultValueOnTouchStart = document.documentElement.ontouchstart + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdown = new Dropdown(btnDropdown) + + document.documentElement.ontouchstart = () => {} + spyOn(EventHandler, 'off') + + btnDropdown.addEventListener('shown.bs.dropdown', () => { + dropdown.hide() + }) + + btnDropdown.addEventListener('hidden.bs.dropdown', () => { + expect(btnDropdown.classList.contains('show')).toEqual(false) + expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false') + expect(EventHandler.off).toHaveBeenCalled() + + document.documentElement.ontouchstart = defaultValueOnTouchStart + done() + }) + + dropdown.show() + }) }) describe('dispose', () => { @@ -890,21 +923,19 @@ describe('Dropdown', () => { ].join('') const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') - spyOn(btnDropdown, 'addEventListener').and.callThrough() - spyOn(btnDropdown, 'removeEventListener').and.callThrough() const dropdown = new Dropdown(btnDropdown) expect(dropdown._popper).toBeNull() - expect(dropdown._menu).toBeDefined() - expect(dropdown._element).toBeDefined() - expect(btnDropdown.addEventListener).toHaveBeenCalledWith('click', jasmine.any(Function), jasmine.any(Boolean)) + expect(dropdown._menu).not.toBeNull() + expect(dropdown._element).not.toBeNull() + spyOn(EventHandler, 'off') dropdown.dispose() expect(dropdown._menu).toBeNull() expect(dropdown._element).toBeNull() - expect(btnDropdown.removeEventListener).toHaveBeenCalledWith('click', jasmine.any(Function), jasmine.any(Boolean)) + expect(EventHandler.off).toHaveBeenCalledWith(btnDropdown, Dropdown.EVENT_KEY) }) it('should dispose dropdown with Popper', () => { @@ -922,9 +953,9 @@ describe('Dropdown', () => { dropdown.toggle() - expect(dropdown._popper).toBeDefined() - expect(dropdown._menu).toBeDefined() - expect(dropdown._element).toBeDefined() + expect(dropdown._popper).not.toBeNull() + expect(dropdown._menu).not.toBeNull() + expect(dropdown._element).not.toBeNull() dropdown.dispose() @@ -950,7 +981,7 @@ describe('Dropdown', () => { dropdown.toggle() - expect(dropdown._popper).toBeDefined() + expect(dropdown._popper).not.toBeNull() spyOn(dropdown._popper, 'update') spyOn(dropdown, '_detectNavbar') @@ -1002,13 +1033,13 @@ describe('Dropdown', () => { showEventTriggered = true }) - btnDropdown.addEventListener('shown.bs.dropdown', e => { + btnDropdown.addEventListener('shown.bs.dropdown', e => setTimeout(() => { expect(btnDropdown.classList.contains('show')).toEqual(true) expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true') expect(showEventTriggered).toEqual(true) expect(e.relatedTarget).toEqual(btnDropdown) document.body.click() - }) + })) btnDropdown.addEventListener('hide.bs.dropdown', () => { hideEventTriggered = true @@ -1050,6 +1081,47 @@ describe('Dropdown', () => { dropdown.show() }) + it('should not collapse the dropdown when clicking a select option nested in the dropdown', done => { + fixtureEl.innerHTML = [ + '<div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>', + ' <div class="dropdown-menu">', + ' <select>', + ' <option selected>Open this select menu</option>', + ' <option value="1">One</option>', + ' </select>', + ' </div>', + '</div>' + ].join('') + + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + const dropdown = new Dropdown(btnDropdown) + + const hideSpy = spyOn(dropdown, '_completeHide') + + btnDropdown.addEventListener('shown.bs.dropdown', () => { + const clickEvent = new MouseEvent('click', { + bubbles: true + }) + + dropdownMenu.querySelector('option').dispatchEvent(clickEvent) + }) + + dropdownMenu.addEventListener('click', event => { + expect(event.target.tagName).toMatch(/select|option/i) + + Dropdown.clearMenus(event) + + setTimeout(() => { + expect(hideSpy).not.toHaveBeenCalled() + done() + }, 10) + }) + + dropdown.show() + }) + it('should manage bs attribute `data-bs-popper`="none" when dropdown is in navbar', done => { fixtureEl.innerHTML = [ '<nav class="navbar navbar-expand-md navbar-light bg-light">', @@ -1094,7 +1166,7 @@ describe('Dropdown', () => { btnDropdown.addEventListener('shown.bs.dropdown', () => { // Popper adds this attribute when we use it - expect(dropdownMenu.getAttribute('x-placement')).toEqual(null) + expect(dropdownMenu.getAttribute('data-popper-placement')).toEqual(null) done() }) @@ -1467,7 +1539,7 @@ describe('Dropdown', () => { triggerDropdown.click() }) - it('should focus on the first element when using ArrowUp for the first time', done => { + it('should open the dropdown and focus on the last item when using ArrowUp for the first time', done => { fixtureEl.innerHTML = [ '<div class="dropdown">', ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', @@ -1479,22 +1551,47 @@ describe('Dropdown', () => { ].join('') const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') - const item1 = fixtureEl.querySelector('#item1') + const lastItem = fixtureEl.querySelector('#item2') triggerDropdown.addEventListener('shown.bs.dropdown', () => { - const keydown = createEvent('keydown') - keydown.key = 'ArrowUp' + setTimeout(() => { + expect(document.activeElement).toEqual(lastItem, 'item2 is focused') + done() + }) + }) - document.activeElement.dispatchEvent(keydown) - expect(document.activeElement).toEqual(item1, 'item1 is focused') + const keydown = createEvent('keydown') + keydown.key = 'ArrowUp' + triggerDropdown.dispatchEvent(keydown) + }) - done() + it('should open the dropdown and focus on the first item when using ArrowDown for the first time', done => { + fixtureEl.innerHTML = [ + '<div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', + ' <div class="dropdown-menu">', + ' <a id="item1" class="dropdown-item" href="#">A link</a>', + ' <a id="item2" class="dropdown-item" href="#">Another link</a>', + ' </div>', + '</div>' + ].join('') + + const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const firstItem = fixtureEl.querySelector('#item1') + + triggerDropdown.addEventListener('shown.bs.dropdown', () => { + setTimeout(() => { + expect(document.activeElement).toEqual(firstItem, 'item1 is focused') + done() + }) }) - triggerDropdown.click() + const keydown = createEvent('keydown') + keydown.key = 'ArrowDown' + triggerDropdown.dispatchEvent(keydown) }) - it('should not close the dropdown if the user clicks on a text field', done => { + it('should not close the dropdown if the user clicks on a text field within dropdown-menu', done => { fixtureEl.innerHTML = [ '<div class="dropdown">', ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', @@ -1520,7 +1617,7 @@ describe('Dropdown', () => { triggerDropdown.click() }) - it('should not close the dropdown if the user clicks on a textarea', done => { + it('should not close the dropdown if the user clicks on a textarea within dropdown-menu', done => { fixtureEl.innerHTML = [ '<div class="dropdown">', ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', @@ -1546,6 +1643,33 @@ describe('Dropdown', () => { triggerDropdown.click() }) + it('should close the dropdown if the user clicks on a text field that is not contained within dropdown-menu', done => { + fixtureEl.innerHTML = [ + '<div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', + ' <div class="dropdown-menu">', + ' </div>', + '</div>', + '<input type="text">' + ] + + const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const input = fixtureEl.querySelector('input') + + triggerDropdown.addEventListener('hidden.bs.dropdown', () => { + expect().nothing() + done() + }) + + triggerDropdown.addEventListener('shown.bs.dropdown', () => { + input.dispatchEvent(createEvent('click', { + bubbles: true + })) + }) + + triggerDropdown.click() + }) + it('should ignore keyboard events for <input>s and <textarea>s within dropdown-menu, except for escape key', done => { fixtureEl.innerHTML = [ '<div class="dropdown">', @@ -1653,6 +1777,133 @@ describe('Dropdown', () => { done() }, 20) }) + + it('should propagate escape key events if dropdown is closed', done => { + fixtureEl.innerHTML = [ + '<div class="parent">', + ' <div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', + ' <div class="dropdown-menu">', + ' <a class="dropdown-item" href="#">Some Item</a>', + ' </div>', + ' </div>', + '</div>' + ] + + const parent = fixtureEl.querySelector('.parent') + const toggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + + const parentKeyHandler = jasmine.createSpy('parentKeyHandler') + + parent.addEventListener('keydown', parentKeyHandler) + parent.addEventListener('keyup', () => { + expect(parentKeyHandler).toHaveBeenCalled() + done() + }) + + const keydownEscape = createEvent('keydown', { bubbles: true }) + keydownEscape.key = 'Escape' + const keyupEscape = createEvent('keyup', { bubbles: true }) + keyupEscape.key = 'Escape' + + toggle.focus() + toggle.dispatchEvent(keydownEscape) + toggle.dispatchEvent(keyupEscape) + }) + + it('should close dropdown (only) by clicking inside the dropdown menu when it has data-attribute `data-bs-auto-close="inside"`', done => { + fixtureEl.innerHTML = [ + '<div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-auto-close="inside">Dropdown toggle</button>', + ' <div class="dropdown-menu">', + ' <a class="dropdown-item" href="#">Dropdown item</a>', + ' </div>', + '</div>' + ] + + const dropdownToggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + + const expectDropdownToBeOpened = () => setTimeout(() => { + expect(dropdownToggle.classList.contains('show')).toEqual(true) + dropdownMenu.click() + }, 150) + + dropdownToggle.addEventListener('shown.bs.dropdown', () => { + document.documentElement.click() + expectDropdownToBeOpened() + }) + + dropdownToggle.addEventListener('hidden.bs.dropdown', () => setTimeout(() => { + expect(dropdownToggle.classList.contains('show')).toEqual(false) + done() + })) + + dropdownToggle.click() + }) + + it('should close dropdown (only) by clicking outside the dropdown menu when it has data-attribute `data-bs-auto-close="outside"`', done => { + fixtureEl.innerHTML = [ + '<div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-auto-close="outside">Dropdown toggle</button>', + ' <div class="dropdown-menu">', + ' <a class="dropdown-item" href="#">Dropdown item</a>', + ' </div>', + '</div>' + ] + + const dropdownToggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + + const expectDropdownToBeOpened = () => setTimeout(() => { + expect(dropdownToggle.classList.contains('show')).toEqual(true) + document.documentElement.click() + }, 150) + + dropdownToggle.addEventListener('shown.bs.dropdown', () => { + dropdownMenu.click() + expectDropdownToBeOpened() + }) + + dropdownToggle.addEventListener('hidden.bs.dropdown', () => { + expect(dropdownToggle.classList.contains('show')).toEqual(false) + done() + }) + + dropdownToggle.click() + }) + + it('should not close dropdown by clicking inside or outside the dropdown menu when it has data-attribute `data-bs-auto-close="false"`', done => { + fixtureEl.innerHTML = [ + '<div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-auto-close="false">Dropdown toggle</button>', + ' <div class="dropdown-menu">', + ' <a class="dropdown-item" href="#">Dropdown item</a>', + ' </div>', + '</div>' + ] + + const dropdownToggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + + const expectDropdownToBeOpened = (shouldTriggerClick = true) => setTimeout(() => { + expect(dropdownToggle.classList.contains('show')).toEqual(true) + if (shouldTriggerClick) { + document.documentElement.click() + } else { + done() + } + + expectDropdownToBeOpened(false) + }, 150) + + dropdownToggle.addEventListener('shown.bs.dropdown', () => { + dropdownMenu.click() + expectDropdownToBeOpened() + }) + + dropdownToggle.click() + }) }) describe('jQueryInterface', () => { @@ -1666,7 +1917,7 @@ describe('Dropdown', () => { jQueryMock.fn.dropdown.call(jQueryMock) - expect(Dropdown.getInstance(div)).toBeDefined() + expect(Dropdown.getInstance(div)).not.toBeNull() }) it('should not re create a dropdown', () => { @@ -1718,6 +1969,60 @@ describe('Dropdown', () => { }) }) + describe('getOrCreateInstance', () => { + it('should return dropdown instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const dropdown = new Dropdown(div) + + expect(Dropdown.getOrCreateInstance(div)).toEqual(dropdown) + expect(Dropdown.getInstance(div)).toEqual(Dropdown.getOrCreateInstance(div, {})) + expect(Dropdown.getOrCreateInstance(div)).toBeInstanceOf(Dropdown) + }) + + it('should return new instance when there is no dropdown instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Dropdown.getInstance(div)).toEqual(null) + expect(Dropdown.getOrCreateInstance(div)).toBeInstanceOf(Dropdown) + }) + + it('should return new instance when there is no dropdown instance with given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Dropdown.getInstance(div)).toEqual(null) + const dropdown = Dropdown.getOrCreateInstance(div, { + display: 'dynamic' + }) + expect(dropdown).toBeInstanceOf(Dropdown) + + expect(dropdown._config.display).toEqual('dynamic') + }) + + it('should return the instance when exists without given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const dropdown = new Dropdown(div, { + display: 'dynamic' + }) + expect(Dropdown.getInstance(div)).toEqual(dropdown) + + const dropdown2 = Dropdown.getOrCreateInstance(div, { + display: 'static' + }) + expect(dropdown).toBeInstanceOf(Dropdown) + expect(dropdown2).toEqual(dropdown) + + expect(dropdown2._config.display).toEqual('dynamic') + }) + }) + it('should open dropdown when pressing keydown or keyup', done => { fixtureEl.innerHTML = [ '<div class="dropdown">', @@ -1765,4 +2070,51 @@ describe('Dropdown', () => { triggerDropdown.dispatchEvent(keydown) }) + + it('should allow `data-bs-toggle="dropdown"` click events to bubble up', () => { + fixtureEl.innerHTML = [ + '<div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', + ' <div class="dropdown-menu">', + ' <a class="dropdown-item" href="#">Secondary link</a>', + ' </div>', + '</div>' + ].join('') + + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const clickListener = jasmine.createSpy('clickListener') + const delegatedClickListener = jasmine.createSpy('delegatedClickListener') + + btnDropdown.addEventListener('click', clickListener) + document.addEventListener('click', delegatedClickListener) + + btnDropdown.click() + + expect(clickListener).toHaveBeenCalled() + expect(delegatedClickListener).toHaveBeenCalled() + }) + + it('should open the dropdown when clicking the child element inside `data-bs-toggle="dropdown"`', done => { + fixtureEl.innerHTML = [ + '<div class="container">', + ' <div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown"><span id="childElement">Dropdown</span></button>', + ' <div class="dropdown-menu">', + ' <a class="dropdown-item" href="#subMenu">Sub menu</a>', + ' </div>', + ' </div>', + '</div>' + ].join('') + + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const childElement = fixtureEl.querySelector('#childElement') + + btnDropdown.addEventListener('shown.bs.dropdown', () => setTimeout(() => { + expect(btnDropdown.classList.contains('show')).toEqual(true) + expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true') + done() + })) + + childElement.click() + }) }) |
