diff --git a/config/vitrine-ui.php b/config/vitrine-ui.php index 4c1b906..ab13e06 100644 --- a/config/vitrine-ui.php +++ b/config/vitrine-ui.php @@ -69,6 +69,9 @@ 'media' => A17\VitrineUI\Components\Media::class, 'modal' => A17\VitrineUI\Components\Modal::class, 'pagination' => A17\VitrineUI\Components\Pagination::class, + 'tabs' => A17\VitrineUI\Components\Tabs::class, + 'tabs-list' => A17\VitrineUI\Components\TabList::class, + 'tabs-panel' => A17\VitrineUI\Components\TabPanel::class, 'tag' => A17\VitrineUI\Components\Tag::class, 'video-background' => A17\VitrineUI\Components\VideoBackground::class, 'wysiwyg' => A17\VitrineUI\Components\Wysiwyg::class, diff --git a/resources/frontend/scripts/behaviors/Tabs.js b/resources/frontend/scripts/behaviors/Tabs.js new file mode 100644 index 0000000..40ce98e --- /dev/null +++ b/resources/frontend/scripts/behaviors/Tabs.js @@ -0,0 +1,211 @@ +import { createBehavior } from '@area17/a17-behaviors' +import { customEvents } from '../constants/customEvents' + +/* + Markup and JS taken from: + https://www.w3.org/WAI/ARIA/apg/patterns/tabs/examples/tabs-manual/ +*/ + +const Tabs = createBehavior( + 'Tabs', + { + setSelectedTab(selectedTab, init) { + if (this.current.tab === selectedTab) { + return + } + + let nextCurrentTab = { + tab: selectedTab, + tabpanel: document.getElementById( + selectedTab.getAttribute('aria-controls') + ) + } + + this.transitionTime = + 16 + + parseInt( + window + .getComputedStyle(this.$node) + .getPropertyValue('--tab-transition-time') || 200 + ) + + if ( + this.$node.dataset.tabsImmediate === true || + this.$node.dataset.tabsImmediate === 'true' + ) { + this.transitionTime = 0 + } + + this.current.tab.setAttribute('aria-selected', 'false') + this.current.tab.tabIndex = -1 + this.current.tabpanel.inert = true + this.current.tabpanel.dispatchEvent(new CustomEvent(customEvents.TABS_HIDDEN)) + document.dispatchEvent( + new CustomEvent(customEvents.TABS_HIDDEN) + ) + if (!init && this.options.scrollonclick) { + this.$node.scrollIntoView(true) + } + + setTimeout(() => { + this.current.tabpanel.hidden = true + + nextCurrentTab.tab.setAttribute('aria-selected', 'true') + nextCurrentTab.tab.removeAttribute('tabindex') + nextCurrentTab.tabpanel.hidden = false + window.requestAnimationFrame(() => { + nextCurrentTab.tabpanel.inert = false + nextCurrentTab.tabpanel.dispatchEvent( + new CustomEvent(customEvents.TABS_SHOWN) + ) + document.dispatchEvent( + new CustomEvent(customEvents.TABS_SHOWN, { detail: this.current }) + ) + }) + + this.current = nextCurrentTab + this.$node.dataset.tabsImmediate = false + if (!init) { + document.dispatchEvent( + new CustomEvent(customEvents.TABS_OPENED, { detail: this.current }) + ) + } + }, this.transitionTime) + }, + moveFocusToTab(currentTab) { + currentTab.focus() + }, + moveFocusToPreviousTab(currentTab) { + if (currentTab === this.firstTab) { + this.moveFocusToTab(this.lastTab) + } else { + const index = this.tabs.indexOf(currentTab) + this.moveFocusToTab(this.tabs[index - 1]) + } + }, + moveFocusToNextTab(currentTab) { + if (currentTab === this.lastTab) { + this.moveFocusToTab(this.firstTab) + } else { + const index = this.tabs.indexOf(currentTab) + this.moveFocusToTab(this.tabs[index + 1]) + } + }, + onKeydown(event) { + const tgt = event.currentTarget + let flag = false + + switch (event.key) { + case 'ArrowLeft': + this.moveFocusToPreviousTab(tgt) + flag = true + break + + case 'ArrowRight': + this.moveFocusToNextTab(tgt) + flag = true + break + + case 'Home': + this.moveFocusToTab(this.firstTab) + flag = true + break + + case 'End': + this.moveFocusToTab(this.lastTab) + flag = true + break + + default: + break + } + + if (flag) { + event.stopPropagation() + event.preventDefault() + } + }, + onClick(event) { + event.preventDefault() + event.stopPropagation() + this.setSelectedTab(event.currentTarget, false) + }, + selectTab(event) { + if (event && event.detail && event.detail.immediate === true) { + this.$node.dataset.tabsImmediate = true + } + this.setSelectedTab(event.currentTarget, true) + } + }, + { + init() { + this.transitionTime = 216 + this.options.scrollonclick = this.options.scrollonclick === 'true' + + this.tablistNode = this.$node + + this.tabs = [] + + this.firstTab = null + this.lastTab = null + + this.firstTabPanel = null + + this.tabs = Array.from( + this.tablistNode.querySelectorAll('[role=tab]') + ) + this.tabpanels = [] + + this.tabs?.forEach((tab) => { + const tabpanel = document.getElementById( + tab.getAttribute('aria-controls') + ) + + if (!tab.getAttribute('aria-selected')) { + tab.setAttribute('aria-selected', 'false') + } + + this.tabpanels.push(tabpanel) + + tab.addEventListener('keydown', this.onKeydown) + tab.addEventListener('click', this.onClick) + tab.addEventListener('tabs:select', this.selectTab) + + if (!this.firstTab) { + this.firstTab = tab + this.firstTabPanel = tabpanel + } + this.lastTab = tab + + if (tab.getAttribute('aria-selected') === 'true') { + this.current = { + tab: tab, + tabpanel: tabpanel + } + } else { + tabpanel.hidden = true + tabpanel.inert = true + } + }) + + if (!this.current) { + this.current = { + tab: this.firstTab, + tabpanel: this.firstTabPanel + } + } + + this.$node.dataset.tabsImmediate = true + this.setSelectedTab(this.current.tab, true) + }, + destroy() { + this.tabs?.forEach((tab) => { + tab.removeEventListener('keydown', this.onKeydown) + tab.removeEventListener('click', this.onClick) + tab.removeEventListener('tabs:select', this.selectTab) + }) + } + } +) + +export default Tabs diff --git a/resources/frontend/scripts/behaviors/index.js b/resources/frontend/scripts/behaviors/index.js index d7ccc2d..e614815 100644 --- a/resources/frontend/scripts/behaviors/index.js +++ b/resources/frontend/scripts/behaviors/index.js @@ -23,5 +23,6 @@ export { default as PasswordInput } from './PasswordInput' export { default as RadioGroup } from './RadioGroup' export { default as RangeInput } from './RangeInput' export { default as ShowVideo } from './ShowVideo' +export { default as Tabs } from './Tabs' export { default as VideoBackground } from './VideoBackground' export { default as VideoBackgroundVideoJs } from './VideoBackgroundVideoJs' diff --git a/resources/frontend/scripts/constants/customEvents.js b/resources/frontend/scripts/constants/customEvents.js index df12f82..24cb854 100644 --- a/resources/frontend/scripts/constants/customEvents.js +++ b/resources/frontend/scripts/constants/customEvents.js @@ -34,5 +34,9 @@ export const customEvents = { DATE_PICKER_UPDATE: 'DatePicker:MinMaxUpdate' /* node event */, INPUT_VALIDATED: 'Input:Validated' /* node event */, - INPUT_RESET: 'Input:Reset' /* node event */ + INPUT_RESET: 'Input:Reset' /* node event */, + + TABS_OPENED: 'Tabs:opened', /* node event */ + TABS_SHOWN: 'Tabs:shown', /* node event */ + TABS_HIDDEN: 'Tabs:hidden' /* node event */ } diff --git a/resources/frontend/theme/components/tab-list.json b/resources/frontend/theme/components/tab-list.json new file mode 100644 index 0000000..64b11a7 --- /dev/null +++ b/resources/frontend/theme/components/tab-list.json @@ -0,0 +1,4 @@ +{ + "base": "flex items-center gap-6", + "button": "" +} diff --git a/resources/frontend/theme/components/tab-panel.json b/resources/frontend/theme/components/tab-panel.json new file mode 100644 index 0000000..be35dc3 --- /dev/null +++ b/resources/frontend/theme/components/tab-panel.json @@ -0,0 +1,3 @@ +{ + "base": "" +} diff --git a/resources/frontend/theme/components/tabs.json b/resources/frontend/theme/components/tabs.json new file mode 100644 index 0000000..853dd98 --- /dev/null +++ b/resources/frontend/theme/components/tabs.json @@ -0,0 +1,5 @@ +{ + "base": "container", + "title": "f-heading-03 w-full", + "tabList": "" +} diff --git a/resources/views/components/tabs/tab-panel.blade.php b/resources/views/components/tabs/tab-panel.blade.php new file mode 100644 index 0000000..24cfb75 --- /dev/null +++ b/resources/views/components/tabs/tab-panel.blade.php @@ -0,0 +1,8 @@ +
Content 1: Lorem ipsum dolor sit amet consectetur adipisicing elit. Nemo autem cum voluptatibus exercitationem ea explicabo eum deleniti repudiandae alias delectus nam minima, vel totam consectetur officiis ex! Reprehenderit, sunt accusamus.
', 'Content 2: Lorem ipsum dolor sit amet consectetur adipisicing elit. Nemo autem cum voluptatibus exercitationem ea explicabo eum deleniti repudiandae alias delectus nam minima, vel totam consectetur officiis ex! Reprehenderit, sunt accusamus.
', 'Content 3: Lorem ipsum dolor sit amet consectetur adipisicing elit. Nemo autem cum voluptatibus exercitationem ea explicabo eum deleniti repudiandae alias delectus nam minima, vel totam consectetur officiis ex! Reprehenderit, sunt accusamus.
'], + ], +]) + +