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 @@ +
class([$ui('tab-panel', 'base')]) }} + @if($selected) data-panel-active="true" @else inert hidden @endif + id="{{ $name.'-panel-'.$index }}" + data-Tabs-panel="" + role="tabpanel" + aria-labelledby="{{ $name.'-'.$index }}"> + {{ $slot }} +
diff --git a/resources/views/components/tabs/tablist.blade.php b/resources/views/components/tabs/tablist.blade.php new file mode 100644 index 0000000..5c4e5c4 --- /dev/null +++ b/resources/views/components/tabs/tablist.blade.php @@ -0,0 +1,19 @@ + diff --git a/resources/views/components/tabs/tabs.blade.php b/resources/views/components/tabs/tabs.blade.php new file mode 100644 index 0000000..cedf578 --- /dev/null +++ b/resources/views/components/tabs/tabs.blade.php @@ -0,0 +1,14 @@ +
class([$ui('tabs', 'base')]) }} data-behavior="Tabs"> + + @if($title) + {{ $title }} + @endif + + + + {{ $slot }} +
diff --git a/resources/views/stories/tabs/README.md b/resources/views/stories/tabs/README.md new file mode 100644 index 0000000..0fb00c2 --- /dev/null +++ b/resources/views/stories/tabs/README.md @@ -0,0 +1,67 @@ +The tabs component renders a list with button to show/hide an active content defined into the slot. +Each component must be defined using the tabs-panel component with the same $name so ids are matching between panels and buttons. + +In the example below, the `$tabNames` array will define the label text for each button and the `$contents` Array will define the inner content for each panel. + +## Usage + +```html + + @foreach ($contents as $content) + + {{ $content }} + + @endforeach + +``` + +## Accessibility + +The markup and the JS behavior are based on https://www.w3.org/WAI/ARIA/apg/patterns/tabs/examples/tabs-manual/ +Hidden panels are not focusable, when selecting a tab the content is getting focused. + +You can navigate using keyboard : + +`Right Arrow`: +When a tab has focus: Moves focus to the next tab. +If focus is on the last tab, moves focus to the first tab. + +`Left Arrow`: +When a tab has focus: +Moves focus to the previous tab. +If focus is on the first tab, moves focus to the last tab. + +## Theming + +### Config + +```json +{ + "base": "container", + "title": "f-heading-03 w-full", + "tabList": "" +} +``` + +`title`: +Style of the main tab component title + +`tabList`: +Visual styling for the wraper element for the list of tab buttons + +### Config for tab panel + +```json +{ + "base": "" +} +``` + +You can also set some default styling for the tab panels wrapper div. diff --git a/resources/views/stories/tabs/tabs.blade.php b/resources/views/stories/tabs/tabs.blade.php new file mode 100644 index 0000000..9e968d1 --- /dev/null +++ b/resources/views/stories/tabs/tabs.blade.php @@ -0,0 +1,24 @@ +@storybook([ + 'status' => 'readyForQA', + 'layout' => 'fullscreen', + 'args' => [ + 'title' => 'Tabs', + 'name' => 'faq', + 'tabsNames' => ['Tab 1', 'Tab 2', 'Tab 3'], + 'titleLevel' => 3, + 'contents' => ['

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.

'], + ], +]) + + + @foreach ($contents as $content) + + {!! $content !!} + + @endforeach + diff --git a/src/Components/TabList.php b/src/Components/TabList.php new file mode 100644 index 0000000..280311a --- /dev/null +++ b/src/Components/TabList.php @@ -0,0 +1,48 @@ +name = $name . '_tab'; + $this->ariaLabel = $ariaLabel; + $this->selectedIndex = $selectedIndex; + $this->tabListId = $tabListId; + $this->tabsNames = $tabsNames; + $this->tabButtonVariant = $tabButtonVariant; + + parent::__construct($ui); + } + + public function shouldRender(): bool + { + return count($this->tabsNames) > 0; + } + + public function render(): View + { + return view('vitrine-ui::components.tabs.tablist'); + } +} diff --git a/src/Components/TabPanel.php b/src/Components/TabPanel.php new file mode 100644 index 0000000..da95ee9 --- /dev/null +++ b/src/Components/TabPanel.php @@ -0,0 +1,27 @@ +selected = $selected; + $this->name = $name . '_tab'; + $this->index = $index; + parent::__construct($ui); + } + + public function render(): View + { + return view('vitrine-ui::components.tabs.tab-panel'); + } +} diff --git a/src/Components/Tabs.php b/src/Components/Tabs.php new file mode 100644 index 0000000..f6fc89d --- /dev/null +++ b/src/Components/Tabs.php @@ -0,0 +1,55 @@ +ariaLabel = $ariaLabel; + $this->startIndex = $startIndex; + $this->name = $name; + $this->title = $title; + $this->titleLevel = min(6, max(1, $titleLevel)); + $this->tabListId = 'tabs-' . Str::random(6); + $this->tabsNames = $tabsNames; + $this->tabButtonVariant = $tabButtonVariant; + + parent::__construct($ui); + } + + public function shouldRender(): bool + { + return count($this->tabsNames) > 0; + } + + public function render(): View + { + return view('vitrine-ui::components.tabs.tabs'); + } +}