diff options
Diffstat (limited to 'spec/frontend/tabs/index_spec.js')
-rw-r--r-- | spec/frontend/tabs/index_spec.js | 260 |
1 files changed, 260 insertions, 0 deletions
diff --git a/spec/frontend/tabs/index_spec.js b/spec/frontend/tabs/index_spec.js new file mode 100644 index 00000000000..98617b404ff --- /dev/null +++ b/spec/frontend/tabs/index_spec.js @@ -0,0 +1,260 @@ +import { GlTabsBehavior, TAB_SHOWN_EVENT } from '~/tabs'; +import { ACTIVE_PANEL_CLASS, ACTIVE_TAB_CLASSES } from '~/tabs/constants'; +import { getFixture, setHTMLFixture } from 'helpers/fixtures'; + +const tabsFixture = getFixture('tabs/tabs.html'); + +describe('GlTabsBehavior', () => { + let glTabs; + let tabShownEventSpy; + + const findByTestId = (testId) => document.querySelector(`[data-testid="${testId}"]`); + const findTab = (name) => findByTestId(`${name}-tab`); + const findPanel = (name) => findByTestId(`${name}-panel`); + + const getAttributes = (element) => + Array.from(element.attributes).reduce((acc, attr) => { + acc[attr.name] = attr.value; + return acc; + }, {}); + + const expectActiveTabAndPanel = (name) => { + const tab = findTab(name); + const panel = findPanel(name); + + expect(glTabs.activeTab).toBe(tab); + + expect(getAttributes(tab)).toMatchObject({ + 'aria-controls': panel.id, + 'aria-selected': 'true', + role: 'tab', + id: expect.any(String), + }); + + ACTIVE_TAB_CLASSES.forEach((klass) => { + expect(tab.classList.contains(klass)).toBe(true); + }); + + expect(getAttributes(panel)).toMatchObject({ + 'aria-labelledby': tab.id, + role: 'tabpanel', + }); + + expect(panel.classList.contains(ACTIVE_PANEL_CLASS)).toBe(true); + }; + + const expectInactiveTabAndPanel = (name) => { + const tab = findTab(name); + const panel = findPanel(name); + + expect(glTabs.activeTab).not.toBe(tab); + + expect(getAttributes(tab)).toMatchObject({ + 'aria-controls': panel.id, + 'aria-selected': 'false', + role: 'tab', + tabindex: '-1', + id: expect.any(String), + }); + + ACTIVE_TAB_CLASSES.forEach((klass) => { + expect(tab.classList.contains(klass)).toBe(false); + }); + + expect(getAttributes(panel)).toMatchObject({ + 'aria-labelledby': tab.id, + role: 'tabpanel', + }); + + expect(panel.classList.contains(ACTIVE_PANEL_CLASS)).toBe(false); + }; + + const expectGlTabShownEvent = (name) => { + expect(tabShownEventSpy).toHaveBeenCalledTimes(1); + + const [event] = tabShownEventSpy.mock.calls[0]; + expect(event.target).toBe(findTab(name)); + + expect(event.detail).toEqual({ + activeTabPanel: findPanel(name), + }); + }; + + const triggerKeyDown = (code, element) => { + const event = new KeyboardEvent('keydown', { code }); + + element.dispatchEvent(event); + }; + + it('throws when instantiated without an element', () => { + expect(() => new GlTabsBehavior()).toThrow('Cannot instantiate'); + }); + + describe('when given an element', () => { + afterEach(() => { + glTabs.destroy(); + }); + + beforeEach(() => { + setHTMLFixture(tabsFixture); + + const tabsEl = findByTestId('tabs'); + tabShownEventSpy = jest.fn(); + tabsEl.addEventListener(TAB_SHOWN_EVENT, tabShownEventSpy); + + glTabs = new GlTabsBehavior(tabsEl); + }); + + it('instantiates', () => { + expect(glTabs).toEqual(expect.any(GlTabsBehavior)); + }); + + it('sets the active tab', () => { + expectActiveTabAndPanel('foo'); + }); + + it(`does not fire an initial ${TAB_SHOWN_EVENT} event`, () => { + expect(tabShownEventSpy).not.toHaveBeenCalled(); + }); + + describe('clicking on an inactive tab', () => { + beforeEach(() => { + findTab('bar').click(); + }); + + it('changes the active tab', () => { + expectActiveTabAndPanel('bar'); + }); + + it('deactivates the previously active tab', () => { + expectInactiveTabAndPanel('foo'); + }); + + it(`dispatches a ${TAB_SHOWN_EVENT} event`, () => { + expectGlTabShownEvent('bar'); + }); + }); + + describe('clicking on the active tab', () => { + beforeEach(() => { + findTab('foo').click(); + }); + + it('does nothing', () => { + expectActiveTabAndPanel('foo'); + expect(tabShownEventSpy).not.toHaveBeenCalled(); + }); + }); + + describe('keyboard navigation', () => { + it.each(['ArrowRight', 'ArrowDown'])('pressing %s moves to next tab', (code) => { + expectActiveTabAndPanel('foo'); + + triggerKeyDown(code, glTabs.activeTab); + + expectActiveTabAndPanel('bar'); + expectInactiveTabAndPanel('foo'); + expectGlTabShownEvent('bar'); + tabShownEventSpy.mockClear(); + + triggerKeyDown(code, glTabs.activeTab); + + expectActiveTabAndPanel('qux'); + expectInactiveTabAndPanel('bar'); + expectGlTabShownEvent('qux'); + tabShownEventSpy.mockClear(); + + // We're now on the last tab, so the active tab should not change + triggerKeyDown(code, glTabs.activeTab); + + expectActiveTabAndPanel('qux'); + expect(tabShownEventSpy).not.toHaveBeenCalled(); + }); + + it.each(['ArrowLeft', 'ArrowUp'])('pressing %s moves to previous tab', (code) => { + // First, make the last tab active + findTab('qux').click(); + tabShownEventSpy.mockClear(); + + // Now start moving backwards + expectActiveTabAndPanel('qux'); + + triggerKeyDown(code, glTabs.activeTab); + + expectActiveTabAndPanel('bar'); + expectInactiveTabAndPanel('qux'); + expectGlTabShownEvent('bar'); + tabShownEventSpy.mockClear(); + + triggerKeyDown(code, glTabs.activeTab); + + expectActiveTabAndPanel('foo'); + expectInactiveTabAndPanel('bar'); + expectGlTabShownEvent('foo'); + tabShownEventSpy.mockClear(); + + // We're now on the first tab, so the active tab should not change + triggerKeyDown(code, glTabs.activeTab); + + expectActiveTabAndPanel('foo'); + expect(tabShownEventSpy).not.toHaveBeenCalled(); + }); + }); + + describe('destroying', () => { + beforeEach(() => { + glTabs.destroy(); + }); + + it('removes interactivity', () => { + const inactiveTab = findTab('bar'); + + // clicks do nothing + inactiveTab.click(); + expectActiveTabAndPanel('foo'); + expect(tabShownEventSpy).not.toHaveBeenCalled(); + + // keydown events do nothing + triggerKeyDown('ArrowDown', inactiveTab); + expectActiveTabAndPanel('foo'); + expect(tabShownEventSpy).not.toHaveBeenCalled(); + }); + }); + + describe('activateTab method', () => { + it.each` + tabState | name + ${'active'} | ${'foo'} + ${'inactive'} | ${'bar'} + `('can programmatically activate an $tabState tab', ({ name }) => { + glTabs.activateTab(findTab(name)); + expectActiveTabAndPanel(name); + expectGlTabShownEvent(name, 'foo'); + }); + }); + }); + + describe('using aria-controls instead of href to link tabs to panels', () => { + beforeEach(() => { + setHTMLFixture(tabsFixture); + + const tabsEl = findByTestId('tabs'); + ['foo', 'bar', 'qux'].forEach((name) => { + const tab = findTab(name); + const panel = findPanel(name); + + tab.setAttribute('href', '#'); + tab.setAttribute('aria-controls', panel.id); + }); + + glTabs = new GlTabsBehavior(tabsEl); + }); + + it('connects the panels to their tabs correctly', () => { + findTab('bar').click(); + + expectActiveTabAndPanel('bar'); + expectInactiveTabAndPanel('foo'); + }); + }); +}); |