diff --git a/docs/pages/ktabs.vue b/docs/pages/ktabs.vue new file mode 100644 index 000000000..a23bd42b8 --- /dev/null +++ b/docs/pages/ktabs.vue @@ -0,0 +1,71 @@ + + + + + + + diff --git a/docs/pages/ktabslist.vue b/docs/pages/ktabslist.vue new file mode 100644 index 000000000..ed7fa205b --- /dev/null +++ b/docs/pages/ktabslist.vue @@ -0,0 +1,198 @@ + + + + + + + diff --git a/docs/pages/ktabspanel.vue b/docs/pages/ktabspanel.vue new file mode 100644 index 000000000..ed830147e --- /dev/null +++ b/docs/pages/ktabspanel.vue @@ -0,0 +1,31 @@ + + + + + + + diff --git a/docs/pages/tabs.vue b/docs/pages/tabs.vue new file mode 100644 index 000000000..4658082b7 --- /dev/null +++ b/docs/pages/tabs.vue @@ -0,0 +1,290 @@ + + + + \ No newline at end of file diff --git a/docs/tableOfContents.js b/docs/tableOfContents.js index 2c9077257..b863405c4 100644 --- a/docs/tableOfContents.js +++ b/docs/tableOfContents.js @@ -59,6 +59,7 @@ const buttonRelatedKeywords = ['button', 'link']; const textRelatedKeywords = ['text', 'area', 'field', 'box']; const layoutRelatedKeywords = ['grid', 'layout', 'container', 'page']; const responsiveComponentsRelatedKeywords = ['responsive', 'mixin', 'breakpoint']; +const tabsRelatedKeywords = ['tab', 'tabs', 'panel', 'tablist', 'tabpanel']; export default [ new Section({ @@ -154,6 +155,10 @@ export default [ path: '/snackbars', title: 'Snackbars', }), + new Page({ + path: '/tabs', + title: 'Tabs', + }), new Page({ path: '/textfields', title: 'Text fields', @@ -325,6 +330,24 @@ export default [ isCode: true, keywords: [...responsiveComponentsRelatedKeywords, 'element'], }), + new Page({ + path: '/ktabs', + title: 'KTabs', + isCode: true, + keywords: tabsRelatedKeywords, + }), + new Page({ + path: '/ktabslist', + title: 'KTabsList', + isCode: true, + keywords: tabsRelatedKeywords, + }), + new Page({ + path: '/ktabspanel', + title: 'KTabsPanel', + isCode: true, + keywords: tabsRelatedKeywords, + }), ], }), ]; diff --git a/lib/KThemePlugin.js b/lib/KThemePlugin.js index 2b4adec93..670764747 100644 --- a/lib/KThemePlugin.js +++ b/lib/KThemePlugin.js @@ -23,6 +23,9 @@ import KRadioButton from './KRadioButton'; import KRouterLink from './buttons-and-links/KRouterLink'; import KSelect from './KSelect'; import KSwitch from './KSwitch'; +import KTabs from './tabs/KTabs'; +import KTabsList from './tabs/KTabsList'; +import KTabsPanel from './tabs/KTabsPanel'; import KTextbox from './KTextbox'; import KTooltip from './KTooltip'; @@ -107,6 +110,9 @@ export default function KThemePlugin(Vue) { Vue.component('KRouterLink', KRouterLink); Vue.component('KSelect', KSelect); Vue.component('KSwitch', KSwitch); + Vue.component('KTabs', KTabs); + Vue.component('KTabsList', KTabsList); + Vue.component('KTabsPanel', KTabsPanel); Vue.component('KTextbox', KTextbox); Vue.component('KTooltip', KTooltip); } diff --git a/lib/tabs/KTabs.vue b/lib/tabs/KTabs.vue new file mode 100644 index 000000000..cfddac85c --- /dev/null +++ b/lib/tabs/KTabs.vue @@ -0,0 +1,73 @@ + + + + \ No newline at end of file diff --git a/lib/tabs/KTabsList.vue b/lib/tabs/KTabsList.vue new file mode 100644 index 000000000..7954fa916 --- /dev/null +++ b/lib/tabs/KTabsList.vue @@ -0,0 +1,324 @@ + + + + + + + diff --git a/lib/tabs/KTabsPanel.vue b/lib/tabs/KTabsPanel.vue new file mode 100644 index 000000000..992c3099e --- /dev/null +++ b/lib/tabs/KTabsPanel.vue @@ -0,0 +1,82 @@ + + + + \ No newline at end of file diff --git a/lib/tabs/__tests__/KTabs.spec.js b/lib/tabs/__tests__/KTabs.spec.js new file mode 100644 index 000000000..0690e1054 --- /dev/null +++ b/lib/tabs/__tests__/KTabs.spec.js @@ -0,0 +1,117 @@ +import { shallowMount, mount, createLocalVue } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +import KTabs from '../KTabs.vue'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); + +function makeWrapper({ propsData = {} } = {}) { + const router = new VueRouter(); + + return mount(KTabs, { + propsData, + localVue, + router, + }); +} + +const TABS = [ + { id: 'tabLessons', label: 'Lessons' }, + { id: 'tabLearners', label: 'Learners' }, + { id: 'tabGroups', label: 'Groups' }, +]; +const TABS_WITH_ROUTES = [ + { id: 'tabLessons', label: 'Lessons', to: { path: '/lessons' } }, + { id: 'tabLearners', label: 'Learners', to: { path: '/learners' } }, + { id: 'tabGroups', label: 'Groups', to: { path: '/groups' } }, +]; + +// 'KTabs' is just a wrapper component to make tabs implementation +// more comfortable => just basic tests are here to ensure that +// 'KTabsList' and 'KTabsPanel' are binded properly. It is in their +// spec files where majority of tabs functionality is tested. +describe(`KTabs`, () => { + it(`smoke test`, () => { + // stop custom validators from complaining for the smoke test + jest.spyOn(console, 'error').mockImplementation(() => {}); + const wrapper = shallowMount(KTabs); + expect(wrapper.exists()).toBeTruthy(); + console.error.mockRestore(); + }); + + it(`binds all relevant props and 'activeTabId' to 'KTabsList'`, async () => { + const props = { + tabsId: 'coachTabs', + tabs: TABS, + ariaLabel: 'Coach tabs', + ariaLabelledBy: 'id-of-element-with-label', + color: 'colorCode', + colorActive: 'colorActiveCode', + backgroundColor: 'backgroundColorCode', + hoverBackgroundColor: 'hoverBackgroundColorCode', + appearanceOverrides: { + color: 'appearanceOverridesColorCode', + }, + appearanceOverridesActive: { + color: 'appearanceOverridesActiveColorCode', + }, + enablePrint: true, + }; + const wrapper = makeWrapper({ propsData: props }); + await wrapper.vm.$nextTick(); + expect(wrapper.findComponent({ name: 'KTabsList' }).props()).toEqual({ + activeTabId: 'tabLessons', + ...props, + }); + }); + + it(`binds all relevant props and 'activeTabId' to 'KTabsPanel'`, async () => { + const wrapper = makeWrapper({ + propsData: { + tabsId: 'coachTabs', + tabs: TABS, + ariaLabel: 'Coach tabs', + }, + }); + await wrapper.vm.$nextTick(); + expect(wrapper.findComponent({ name: 'KTabsPanel' }).props()).toEqual({ + activeTabId: 'tabLessons', + tabsId: 'coachTabs', + }); + }); + + describe(`when not using the router`, () => { + it(`automatically sets the first tab as active`, async () => { + const wrapper = makeWrapper({ + propsData: { + tabsId: 'coachTabs', + tabs: TABS, + ariaLabel: 'Coach tabs', + }, + }); + await wrapper.vm.$nextTick(); + const tabs = wrapper.findAll('button'); + expect(tabs.at(0).attributes('aria-selected')).toBe('true'); + }); + }); + + describe(`when using the router`, () => { + // just a simple test to complement the opposite test scenario above + // which is the only functionality related to `KTabs` themselves + // (tabs with routes are tested in more detail in `KTabsList` test suite) + it(`doesn't automatically set any of the tabs as active`, async () => { + const wrapper = makeWrapper({ + propsData: { + tabsId: 'coachTabs', + tabs: TABS_WITH_ROUTES, + ariaLabel: 'Coach tabs', + }, + }); + await wrapper.vm.$nextTick(); + const tabs = wrapper.findAll('a'); + expect(tabs.at(0).attributes('aria-selected')).toBe('false'); + expect(tabs.at(1).attributes('aria-selected')).toBe('false'); + expect(tabs.at(2).attributes('aria-selected')).toBe('false'); + }); + }); +}); diff --git a/lib/tabs/__tests__/KTabsList.spec.js b/lib/tabs/__tests__/KTabsList.spec.js new file mode 100644 index 000000000..616c9fe0d --- /dev/null +++ b/lib/tabs/__tests__/KTabsList.spec.js @@ -0,0 +1,247 @@ +import { shallowMount, mount, createLocalVue } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +import KTabsList from '../KTabsList.vue'; + +const localVue = createLocalVue(); +localVue.use(VueRouter); + +const routes = [{ path: '/lessons' }, { path: '/learners' }, { path: '/groups' }]; +const router = new VueRouter({ routes }); + +function makeWrapper({ propsData = {} } = {}) { + return mount(KTabsList, { + propsData, + localVue, + router, + }); +} + +const TABS = [ + { id: 'tabLessons', label: 'Lessons' }, + { id: 'tabLearners', label: 'Learners' }, + { id: 'tabGroups', label: 'Groups' }, +]; +const TABS_WITH_ROUTES = [ + { id: 'tabLessons', label: 'Lessons', to: { path: '/lessons' } }, + { id: 'tabLearners', label: 'Learners', to: { path: '/learners' } }, + { id: 'tabGroups', label: 'Groups', to: { path: '/groups' } }, +]; + +describe(`KTabsList`, () => { + beforeAll(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterAll(() => { + console.error.mockRestore(); + }); + + afterEach(() => { + console.error.mockClear(); + }); + + it(`smoke test`, () => { + const wrapper = shallowMount(KTabsList); + expect(wrapper.exists()).toBeTruthy(); + }); + + it(`shows the console error when missing 'ariaLabel' or 'ariaLabelledBy' props`, () => { + makeWrapper({ + propsData: { + tabsId: 'coachTabs', + tabs: TABS, + activeTabId: 'tabLearners', + }, + }); + expect(console.error).toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith( + `[KTabsList] Missing 'ariaLabel' or 'ariaLabelledBy'` + ); + }); + + describe(`when 'ariaLabel' prop is provided`, () => { + let wrapper; + + beforeEach(() => { + wrapper = makeWrapper({ + propsData: { + ariaLabel: 'Coach tabs', + tabsId: 'coachTabs', + tabs: TABS, + activeTabId: 'tabLearners', + }, + }); + }); + + it(`doesn't show the console error`, () => { + expect(console.error).not.toHaveBeenCalled(); + }); + + it(`has the correct 'aria-label' attribute`, () => { + expect(wrapper.attributes('aria-labelledby')).toBeUndefined(); + expect(wrapper.attributes('aria-label')).toBe('Coach tabs'); + }); + }); + + describe(`when 'ariaLabelledBy' prop is provided`, () => { + let wrapper; + + beforeEach(() => { + wrapper = makeWrapper({ + propsData: { + ariaLabelledBy: 'id-of-element-with-label', + tabsId: 'coachTabs', + tabs: TABS, + activeTabId: 'tabLearners', + }, + }); + }); + + it(`doesn't show the console error`, () => { + expect(console.error).not.toHaveBeenCalled(); + }); + + it(`has the correct 'aria-labelledby' attribute`, () => { + expect(wrapper.attributes('aria-label')).toBeUndefined(); + expect(wrapper.attributes('aria-labelledby')).toBe('id-of-element-with-label'); + }); + }); + + describe(`when tab objects don't have 'to' attribute`, () => { + let wrapper; + + beforeEach(() => { + wrapper = makeWrapper({ + propsData: { + ariaLabel: 'Coach tabs', + tabsId: 'coachTabs', + tabs: TABS, + activeTabId: 'tabLearners', + }, + }); + }); + + it(`renders tabs as buttons with correct label and 'id', 'role', 'type', 'aria-controls' attributes`, () => { + expect(wrapper.find('a').exists()).toBeFalsy(); + + const buttons = wrapper.findAll('button'); + expect(buttons.length).toBe(3); + + expect(buttons.at(0).text()).toBe('Lessons'); + expect(buttons.at(0).attributes('id')).toBe('coachTabs-tabLessons'); + expect(buttons.at(0).attributes('role')).toBe('tab'); + expect(buttons.at(0).attributes('type')).toBe('button'); + expect(buttons.at(0).attributes('aria-controls')).toBe('coachTabs-tabLessons-panel'); + + expect(buttons.at(1).text()).toBe('Learners'); + expect(buttons.at(1).attributes('id')).toBe('coachTabs-tabLearners'); + expect(buttons.at(1).attributes('role')).toBe('tab'); + expect(buttons.at(1).attributes('type')).toBe('button'); + expect(buttons.at(1).attributes('aria-controls')).toBe('coachTabs-tabLearners-panel'); + + expect(buttons.at(2).text()).toBe('Groups'); + expect(buttons.at(2).attributes('id')).toBe('coachTabs-tabGroups'); + expect(buttons.at(2).attributes('role')).toBe('tab'); + expect(buttons.at(2).attributes('type')).toBe('button'); + expect(buttons.at(2).attributes('aria-controls')).toBe('coachTabs-tabGroups-panel'); + }); + + it(`the active tab has 'tabindex' set to 0 and all other tabs to -1`, () => { + const buttons = wrapper.findAll('button'); + expect(buttons.at(0).attributes('tabindex')).toBe('-1'); + expect(buttons.at(1).attributes('tabindex')).toBe('0'); + expect(buttons.at(2).attributes('tabindex')).toBe('-1'); + }); + + it(`the active tab has 'aria-selected' set to 'true' and all other tabs to 'false'`, () => { + const buttons = wrapper.findAll('button'); + expect(buttons.at(0).attributes('aria-selected')).toBe('false'); + expect(buttons.at(1).attributes('aria-selected')).toBe('true'); + expect(buttons.at(2).attributes('aria-selected')).toBe('false'); + }); + + describe(`on a tab click`, () => { + it(`emits 'activate' event with the tab id in the payload`, () => { + const buttons = wrapper.findAll('button'); + buttons.at(2).trigger('click'); + + expect(wrapper.emitted().activate.length).toBe(1); + expect(wrapper.emitted().activate[0][0]).toBe('tabGroups'); + }); + }); + }); + + describe(`when tab objects have 'to' attribute`, () => { + let wrapper; + + beforeEach(() => { + router.push({ path: '/learners' }); + wrapper = makeWrapper({ + propsData: { + ariaLabel: 'Coach tabs', + tabsId: 'coachTabs', + tabs: TABS_WITH_ROUTES, + activeTabId: 'tabLearners', + }, + }); + }); + + it(`renders tabs as links with correct label and 'id', 'role', 'href', 'aria-controls' attributes`, () => { + expect(wrapper.find('button').exists()).toBeFalsy(); + + const links = wrapper.findAll('a'); + expect(links.length).toBe(3); + + expect(links.at(0).text()).toBe('Lessons'); + expect(links.at(0).attributes('id')).toBe('coachTabs-tabLessons'); + expect(links.at(0).attributes('role')).toBe('tab'); + expect(links.at(0).attributes('href')).toBe('#/lessons'); + expect(links.at(0).attributes('aria-controls')).toBe('coachTabs-tabLessons-panel'); + + expect(links.at(1).text()).toBe('Learners'); + expect(links.at(1).attributes('id')).toBe('coachTabs-tabLearners'); + expect(links.at(1).attributes('role')).toBe('tab'); + expect(links.at(1).attributes('href')).toBe('#/learners'); + expect(links.at(1).attributes('aria-controls')).toBe('coachTabs-tabLearners-panel'); + + expect(links.at(2).text()).toBe('Groups'); + expect(links.at(2).attributes('id')).toBe('coachTabs-tabGroups'); + expect(links.at(2).attributes('role')).toBe('tab'); + expect(links.at(2).attributes('href')).toBe('#/groups'); + expect(links.at(2).attributes('aria-controls')).toBe('coachTabs-tabGroups-panel'); + }); + + it(`the active tab has 'tabindex' set to 0 and all other tabs to -1`, () => { + const links = wrapper.findAll('a'); + expect(links.at(0).attributes('tabindex')).toBe('-1'); + expect(links.at(1).attributes('tabindex')).toBe('0'); + expect(links.at(2).attributes('tabindex')).toBe('-1'); + }); + + it(`the active tab has 'aria-selected' set to 'true' and all other tabs to 'false'`, () => { + const links = wrapper.findAll('a'); + expect(links.at(0).attributes('aria-selected')).toBe('false'); + expect(links.at(1).attributes('aria-selected')).toBe('true'); + expect(links.at(2).attributes('aria-selected')).toBe('false'); + }); + + describe(`when mounted`, () => { + it(`emits 'activate' event with the tab id that corresponds to the current route`, () => { + expect(wrapper.emitted().activate.length).toBe(1); + expect(wrapper.emitted().activate[0][0]).toBe('tabLearners'); + }); + }); + + describe(`on a tab click`, () => { + it(`emits 'activate' event with the tab id in the payload`, () => { + const links = wrapper.findAll('a'); + links.at(2).trigger('click'); + + // see `when mounted - emits 'activate' event with the tab id that corresponds to the current route` test to understand why we need to test the second emitted event here + expect(wrapper.emitted().activate.length).toBe(2); + expect(wrapper.emitted().activate[1][0]).toBe('tabGroups'); + }); + }); + }); +}); diff --git a/lib/tabs/__tests__/KTabsPanel.spec.js b/lib/tabs/__tests__/KTabsPanel.spec.js new file mode 100644 index 000000000..f5c2143dd --- /dev/null +++ b/lib/tabs/__tests__/KTabsPanel.spec.js @@ -0,0 +1,103 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import KTabsPanel from '../KTabsPanel.vue'; + +function makeWrapper({ propsData = {}, slots = {} } = {}) { + return mount(KTabsPanel, { + propsData, + slots, + }); +} + +describe(`KTabsPanel`, () => { + it(`smoke test`, () => { + const wrapper = shallowMount(KTabsPanel); + expect(wrapper.exists()).toBeTruthy(); + }); + + it(`renders div with correct 'id', 'role', and 'aria-labelledby'`, () => { + const wrapper = makeWrapper({ + propsData: { tabsId: 'testTabs', activeTabId: 'lessonsTab' }, + }); + expect(wrapper.attributes('id')).toBe('testTabs-lessonsTab-panel'); + expect(wrapper.attributes('role')).toBe('tabpanel'); + expect(wrapper.attributes('aria-labelledby')).toBe('testTabs-lessonsTab'); + }); + + it(`renders content of the default slot`, () => { + const wrapper = makeWrapper({ + propsData: { tabsId: 'testTabs', activeTabId: 'lessonsTab' }, + slots: { + default: 'Default slot content', + }, + }); + expect(wrapper.html()).toContain('Default slot content'); + }); + + describe(`when using named slots`, () => { + it(`renders only content of the slot corresponding to the active tab`, () => { + const wrapper = makeWrapper({ + propsData: { tabsId: 'testTabs', activeTabId: 'lessonsTab' }, + slots: { + groupsTab: 'Groups tab content', + lessonsTab: 'Lessons tab content', + }, + }); + expect(wrapper.html()).not.toContain('Groups tab content'); + expect(wrapper.html()).toContain('Lessons tab content'); + }); + }); + + describe(`when it contains a focusable child element`, () => { + it(`the panel has 'tabindex' set to -1`, async () => { + const wrapper = makeWrapper({ + propsData: { tabsId: 'testTabs', activeTabId: 'lessonsTab' }, + slots: { + default: 'Link', + }, + }); + await wrapper.vm.$nextTick(); // make sure 'tabindex' is updated before testing its value + expect(wrapper.attributes('tabindex')).toBe('-1'); + }); + }); + + describe(`when it doesn't contain any focusable child elements`, () => { + it(`the panel has 'tabindex' is set to 0`, async () => { + const wrapper = makeWrapper({ + propsData: { tabsId: 'testTabs', activeTabId: 'lessonsTab' }, + slots: { + default: 'Default slot content', + }, + }); + await wrapper.vm.$nextTick(); // make sure 'tabindex' is updated before testing its value + expect(wrapper.attributes('tabindex')).toBe('0'); + }); + }); + + it(`'tabindex' is updated whenever the active tab ID changes`, async () => { + const wrapper = makeWrapper({ + propsData: { tabsId: 'testTabs', activeTabId: 'groupsTab' }, + slots: { + groupsTab: ` + + Groups tab content with a focusable element: + Link + `, + lessonsTab: ` + Lessons tab content with no focusable element + `, + }, + }); + await wrapper.vm.$nextTick(); // make sure 'tabindex' is updated before testing its value + expect(wrapper.attributes('tabindex')).toBe('-1'); + + // switch to a tab which has no focusable elements + wrapper.setProps({ activeTabId: 'lessonsTab' }); + await wrapper.vm.$nextTick(); + expect(wrapper.attributes('tabindex')).toBe('0'); + + // switch to a tab with a focusable element + wrapper.setProps({ activeTabId: 'groupsTab' }); + await wrapper.vm.$nextTick(); + expect(wrapper.attributes('tabindex')).toBe('-1'); + }); +}); diff --git a/lib/tabs/tabsMixin.js b/lib/tabs/tabsMixin.js new file mode 100644 index 000000000..2d85012bb --- /dev/null +++ b/lib/tabs/tabsMixin.js @@ -0,0 +1,130 @@ +export default { + props: { + /** + * An array of tab objects `{ id, label, to }` where `id` and `label` + * properties are required and `to` is optional. + * When `to` is provided, tabs render as router links. + * Otherwise, they render as buttons. + */ + tabs: { + type: Array, + required: true, + validator: value => { + if (!value.length) { + console.error('There are no tabs defined'); + return false; + } + const isValidTab = tab => tab.id !== undefined && tab.label !== undefined; + const areAllTabsValid = value.every(isValidTab); + if (!areAllTabsValid) { + console.error(`All tabs are required to have 'id' and 'label' properties defined`); + return false; + } + return true; + }, + }, + /** + * A label that describes the purpose of the set of tabs. + * Providing either `ariaLabel` or `ariaLabelledBy` + * is required. + */ + ariaLabel: { + type: String, + required: false, + default: '', + }, + /** + * ID reference to a DOM element which provides a label + * that describes the purpose of the set of tabs. + * Providing either `ariaLabel` or `ariaLabelledBy` + * is required. + */ + ariaLabelledBy: { + type: String, + required: false, + default: '', + }, + /** + * Tabs text color. + * Defaults to `$themeTokens.annotation`. + */ + color: { + type: String, + required: false, + default: null, + }, + /** + * Text color of an active tab. + * Defaults to `$themeTokens.primary`. + */ + colorActive: { + type: String, + required: false, + default: null, + }, + /** + * Tabs background color. + * Defaults to `$themeTokens.surface`. + */ + backgroundColor: { + type: String, + required: false, + default: null, + }, + /** + * Tabs hover background color. + * Defaults to `$themePalette.grey.v_300`. + */ + hoverBackgroundColor: { + type: String, + required: false, + default: null, + }, + /** + * Tabs styles that complement or override default styles + * or styles defined via props (will be sent to `$computedClass`, + * which means that styles that are accepted by `$computedClass`, + * e.g. pseudo-classes, are supported) + */ + appearanceOverrides: { + type: Object, + required: false, + default: null, + }, + /** + * An active tab styles that complement or override default styles + * or styles defined via props (will be sent to `$computedClass`, + * which means that styles that are accepted by `$computedClass`, + * e.g. pseudo-classes, are supported) + */ + appearanceOverridesActive: { + type: Object, + required: false, + default: null, + }, + /** + * Tab list items are hidden when printing by default. + * `enablePrint` set to `true` makes them visible in print mode. + */ + enablePrint: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + /** + * @returns {Boolean} `true` if at least one tab object contains + * `to` attribute (assuming that a developer wants to + * use tabs together with the router in this case). + */ + useRouter() { + return this.tabs && this.tabs.length && this.tabs.some(tab => tab.to); + }, + }, + mounted() { + if (!this.ariaLabelledBy && !this.ariaLabel) { + console.error(`[${this.$options.name}] Missing 'ariaLabel' or 'ariaLabelledBy'`); + } + }, +}; diff --git a/lib/tabs/utils.js b/lib/tabs/utils.js new file mode 100644 index 000000000..7d5939a72 --- /dev/null +++ b/lib/tabs/utils.js @@ -0,0 +1,38 @@ +/** + * @param {String} tabInterfaceId A tab interface ID + * @param {String} tabId A tab ID + * @returns {String} A tab element ID. It's a compound of a tab ID + * and ID of a tabbed interface it belongs to + * (to prevent bugs when more tabbed interfaces + * with some accidentally identical tab IDs are + * used on the same page) + */ +export function getTabElementId(tabInterfaceId, tabId) { + return tabInterfaceId + '-' + tabId; +} + +/** + * @param {String} tabInterfaceId A tab interface ID + * @param {String} tabId A tab ID + * @returns {String} A corresponding tab panel element ID + */ +export function getTabPanelElementId(tabInterfaceId, tabId) { + return getTabElementId(tabInterfaceId, tabId) + '-panel'; +} + +/** + * @param {HTMLElement} parent A parent element + * @returns {HTMLElement, null} The first focusable child of the parent + * element or `null` when none is found + */ +export function getFirstFocusableChild(parent) { + if (!parent) { + console.error(`[getFirstFocusableChild] 'parent' parameter is required`); + return null; + } + // https://gomakethings.com/how-to-get-the-first-and-last-focusable-elements-in-the-dom/ + const focusableElements = parent.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + return focusableElements.length > 0 ? focusableElements[0] : null; +}