From 84db79e5421837aab5c5025e1b482c9598206487 Mon Sep 17 00:00:00 2001 From: antonin caudron Date: Thu, 29 Aug 2024 15:42:31 +0200 Subject: [PATCH 01/11] Add tabs components with associated behavior --- config/vitrine-ui.php | 3 + resources/frontend/scripts/behaviors/Tabs.js | 205 ++++++++++++++++++ .../frontend/theme/components/tab-list.json | 4 + .../frontend/theme/components/tab-panel.json | 3 + resources/frontend/theme/components/tabs.json | 5 + .../views/components/tabs/tab-panel.blade.php | 8 + .../views/components/tabs/tablist.blade.php | 17 ++ .../views/components/tabs/tabs.blade.php | 13 ++ src/Components/TabList.php | 50 +++++ src/Components/TabPanel.php | 32 +++ src/Components/Tabs.php | 57 +++++ 11 files changed, 397 insertions(+) create mode 100644 resources/frontend/scripts/behaviors/Tabs.js create mode 100644 resources/frontend/theme/components/tab-list.json create mode 100644 resources/frontend/theme/components/tab-panel.json create mode 100644 resources/frontend/theme/components/tabs.json create mode 100644 resources/views/components/tabs/tab-panel.blade.php create mode 100644 resources/views/components/tabs/tablist.blade.php create mode 100644 resources/views/components/tabs/tabs.blade.php create mode 100644 src/Components/TabList.php create mode 100644 src/Components/TabPanel.php create mode 100644 src/Components/Tabs.php diff --git a/config/vitrine-ui.php b/config/vitrine-ui.php index 39634f0..0760835 100644 --- a/config/vitrine-ui.php +++ b/config/vitrine-ui.php @@ -68,6 +68,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..78b478e --- /dev/null +++ b/resources/frontend/scripts/behaviors/Tabs.js @@ -0,0 +1,205 @@ +import { createBehavior } from '@area17/a17-behaviors' + +/* + 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('Tab: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('Tab:shown') + ) + }) + + this.current = nextCurrentTab + this.$node.dataset.tabsImmediate = false + if (!init) { + document.dispatchEvent( + new CustomEvent('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/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..70ca7e4 --- /dev/null +++ b/resources/views/components/tabs/tablist.blade.php @@ -0,0 +1,17 @@ +
class([$ui('tab-list', 'base')]) }} + @if($tabListId) aria-labelledby="{{ $tabListId }}" + @elseif($ariaLabel) aria-label="{{$ariaLabel}}" + @endif> + + @foreach($tabsNames as $tabName) + + {{ $tabName }} + + @endforeach +
diff --git a/resources/views/components/tabs/tabs.blade.php b/resources/views/components/tabs/tabs.blade.php new file mode 100644 index 0000000..f7d0e07 --- /dev/null +++ b/resources/views/components/tabs/tabs.blade.php @@ -0,0 +1,13 @@ +
class([$ui('tabs', 'base')]) }} data-behavior="Tabs"> + + @if($title) + {{ $title }} + @endif + + + + {{ $slot }} +
diff --git a/src/Components/TabList.php b/src/Components/TabList.php new file mode 100644 index 0000000..101f39d --- /dev/null +++ b/src/Components/TabList.php @@ -0,0 +1,50 @@ +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..b64ad68 --- /dev/null +++ b/src/Components/TabPanel.php @@ -0,0 +1,32 @@ +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..9b2063e --- /dev/null +++ b/src/Components/Tabs.php @@ -0,0 +1,57 @@ +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'); + } +} From 7b72ebc7a06c5ee94ab7b1dcd81f3999d833f6ce Mon Sep 17 00:00:00 2001 From: antonin caudron Date: Thu, 29 Aug 2024 16:06:54 +0200 Subject: [PATCH 02/11] Pass button variant to child tabList component and send custom event when panel change # Conflicts: # resources/frontend/scripts/constants/customEvents.js --- resources/frontend/scripts/behaviors/Tabs.js | 14 ++++++++++---- .../frontend/scripts/constants/customEvents.js | 6 +++++- resources/views/components/tabs/tabs.blade.php | 1 + 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/resources/frontend/scripts/behaviors/Tabs.js b/resources/frontend/scripts/behaviors/Tabs.js index 78b478e..40ce98e 100644 --- a/resources/frontend/scripts/behaviors/Tabs.js +++ b/resources/frontend/scripts/behaviors/Tabs.js @@ -1,4 +1,5 @@ import { createBehavior } from '@area17/a17-behaviors' +import { customEvents } from '../constants/customEvents' /* Markup and JS taken from: @@ -38,8 +39,10 @@ const Tabs = createBehavior( this.current.tab.setAttribute('aria-selected', 'false') this.current.tab.tabIndex = -1 this.current.tabpanel.inert = true - this.current.tabpanel.dispatchEvent(new CustomEvent('Tab:hidden')) - + 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) } @@ -53,7 +56,10 @@ const Tabs = createBehavior( window.requestAnimationFrame(() => { nextCurrentTab.tabpanel.inert = false nextCurrentTab.tabpanel.dispatchEvent( - new CustomEvent('Tab:shown') + new CustomEvent(customEvents.TABS_SHOWN) + ) + document.dispatchEvent( + new CustomEvent(customEvents.TABS_SHOWN, { detail: this.current }) ) }) @@ -61,7 +67,7 @@ const Tabs = createBehavior( this.$node.dataset.tabsImmediate = false if (!init) { document.dispatchEvent( - new CustomEvent('tabs:opened', { detail: this.current }) + new CustomEvent(customEvents.TABS_OPENED, { detail: this.current }) ) } }, this.transitionTime) diff --git a/resources/frontend/scripts/constants/customEvents.js b/resources/frontend/scripts/constants/customEvents.js index bc82f7b..62ac6b5 100644 --- a/resources/frontend/scripts/constants/customEvents.js +++ b/resources/frontend/scripts/constants/customEvents.js @@ -32,5 +32,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/views/components/tabs/tabs.blade.php b/resources/views/components/tabs/tabs.blade.php index f7d0e07..cedf578 100644 --- a/resources/views/components/tabs/tabs.blade.php +++ b/resources/views/components/tabs/tabs.blade.php @@ -7,6 +7,7 @@ {{ $slot }} From f9e203a051d8bde2b99f3b9fb5dd647514e31cd8 Mon Sep 17 00:00:00 2001 From: antonin caudron Date: Mon, 23 Sep 2024 15:19:24 +0200 Subject: [PATCH 03/11] Make tabs component compliant with phpstan level 6 --- src/Components/TabList.php | 20 +++++++++----------- src/Components/TabPanel.php | 9 ++------- src/Components/Tabs.php | 22 ++++++++++------------ 3 files changed, 21 insertions(+), 30 deletions(-) diff --git a/src/Components/TabList.php b/src/Components/TabList.php index 101f39d..280311a 100644 --- a/src/Components/TabList.php +++ b/src/Components/TabList.php @@ -2,7 +2,6 @@ namespace A17\VitrineUI\Components; -use Illuminate\Support\Str; use Illuminate\View\View; class TabList extends VitrineComponent @@ -19,16 +18,15 @@ class TabList extends VitrineComponent public ?string $name; public function __construct( - $ariaLabel = null, - $selectedIndex = 0, - $tabListId = null, - $tabButtonVariant = 'primary', - $tabsNames = [], - $name = '', - $ui = [] - ) - { - $this->name = $name.'_tab'; + string $ariaLabel = null, + int $selectedIndex = 0, + string $tabListId = null, + string $tabButtonVariant = 'primary', + array $tabsNames = [], + string $name = '', + array $ui = [], + ) { + $this->name = $name . '_tab'; $this->ariaLabel = $ariaLabel; $this->selectedIndex = $selectedIndex; $this->tabListId = $tabListId; diff --git a/src/Components/TabPanel.php b/src/Components/TabPanel.php index b64ad68..da95ee9 100644 --- a/src/Components/TabPanel.php +++ b/src/Components/TabPanel.php @@ -12,15 +12,10 @@ class TabPanel extends VitrineComponent public bool $selected; - public function __construct( - $selected = false, - $name = '', - $index = 1, - $ui = [] - ) + public function __construct(bool $selected = false, string $name = '', int $index = 1, array $ui = []) { $this->selected = $selected; - $this->name = $name.'_tab'; + $this->name = $name . '_tab'; $this->index = $index; parent::__construct($ui); } diff --git a/src/Components/Tabs.php b/src/Components/Tabs.php index 9b2063e..f6fc89d 100644 --- a/src/Components/Tabs.php +++ b/src/Components/Tabs.php @@ -2,8 +2,8 @@ namespace A17\VitrineUI\Components; -use Illuminate\Support\Str; use Illuminate\View\View; +use Illuminate\Support\Str; class Tabs extends VitrineComponent { @@ -12,7 +12,6 @@ class Tabs extends VitrineComponent public ?string $title; public int $titleLevel = 3; - public int $startIndex = 0; public array $tabsNames = []; @@ -23,16 +22,15 @@ class Tabs extends VitrineComponent public string $tabListId; public function __construct( - $ariaLabel = null, - $tabsNames = [], - $startIndex = 0, - $name = '', - $title = null, - $titleLevel = 3, - $tabButtonVariant = 'primary', - $ui = [] - ) - { + string $ariaLabel = null, + array $tabsNames = [], + int $startIndex = 0, + string $name = '', + string $title = null, + string|int $titleLevel = 3, + string $tabButtonVariant = 'primary', + array $ui = [], + ) { $this->ariaLabel = $ariaLabel; $this->startIndex = $startIndex; $this->name = $name; From 4f8f01e5c8159d4b0d0c40486a006f4d08f7b5be Mon Sep 17 00:00:00 2001 From: Florrie Date: Fri, 4 Oct 2024 17:09:38 -0400 Subject: [PATCH 04/11] Change tablist to list element --- .../views/components/tabs/tablist.blade.php | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/resources/views/components/tabs/tablist.blade.php b/resources/views/components/tabs/tablist.blade.php index 70ca7e4..5c4e5c4 100644 --- a/resources/views/components/tabs/tablist.blade.php +++ b/resources/views/components/tabs/tablist.blade.php @@ -1,17 +1,19 @@ -
class([$ui('tab-list', 'base')]) }} - @if($tabListId) aria-labelledby="{{ $tabListId }}" - @elseif($ariaLabel) aria-label="{{$ariaLabel}}" - @endif> +
    class([$ui('tab-list', 'base')]) }} + @if ($tabListId) aria-labelledby="{{ $tabListId }}" + @elseif($ariaLabel) aria-label="{{ $ariaLabel }}" @endif> - @foreach($tabsNames as $tabName) - - {{ $tabName }} - - @endforeach -
+ @foreach ($tabsNames as $tabName) +
  • + + {{ $tabName }} + +
  • + @endforeach + From 4c1933916483dddd9a3510181896583e45c2a019 Mon Sep 17 00:00:00 2001 From: Antoine Doury Date: Wed, 8 Jan 2025 12:57:50 +0100 Subject: [PATCH 05/11] Add Stories for the tabs component --- resources/views/stories/tabs/README.md | 39 +++++++++++++++++++++ resources/views/stories/tabs/tabs.blade.php | 31 ++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 resources/views/stories/tabs/README.md create mode 100644 resources/views/stories/tabs/tabs.blade.php diff --git a/resources/views/stories/tabs/README.md b/resources/views/stories/tabs/README.md new file mode 100644 index 0000000..35e8b26 --- /dev/null +++ b/resources/views/stories/tabs/README.md @@ -0,0 +1,39 @@ +The tabs component that is based on the Swiper JS library. It renders a list of carousel slides depending on the props passed to the component. + +The `component` prop is mandatory to set the markup for each slides by loading a dynamic component. The dynamic component must have an `item` prop to properly load data for each slide. + +Custom configuration object can setup globally to the `window.A17.sliderConfigurations` JS object. +Additionnal props are present to deactivate controls or pagination. + +## Usage + +```html + +``` + +## Accessibility + +Markup and JS behavior is based on https://www.w3.org/WAI/ARIA/apg/patterns/tabs/examples/tabs-manual/ + +## 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 diff --git a/resources/views/stories/tabs/tabs.blade.php b/resources/views/stories/tabs/tabs.blade.php new file mode 100644 index 0000000..c82d0d0 --- /dev/null +++ b/resources/views/stories/tabs/tabs.blade.php @@ -0,0 +1,31 @@ +@storybook([ + 'status' => 'readyForQA', + 'layout' => 'fullscreen', + 'args' => [ + 'title' => 'Tabs', + 'name' => 'tab', + 'tabsNames' => ['Tab 1', 'Tab 2', 'Tab 3'], + 'titleLevel' => 3, + ], +]) + + +
    +

    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.

    +
    +
    From 0ba2b4fa8124e011985ecf5d73ea96da16807df7 Mon Sep 17 00:00:00 2001 From: Antoine Doury Date: Wed, 8 Jan 2025 13:03:47 +0100 Subject: [PATCH 06/11] Stories - set default status for tabs --- resources/views/stories/tabs/tabs.blade.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/resources/views/stories/tabs/tabs.blade.php b/resources/views/stories/tabs/tabs.blade.php index c82d0d0..c50ac8a 100644 --- a/resources/views/stories/tabs/tabs.blade.php +++ b/resources/views/stories/tabs/tabs.blade.php @@ -3,7 +3,7 @@ 'layout' => 'fullscreen', 'args' => [ 'title' => 'Tabs', - 'name' => 'tab', + 'name' => 'faq', 'tabsNames' => ['Tab 1', 'Tab 2', 'Tab 3'], 'titleLevel' => 3, ], @@ -13,17 +13,21 @@ :name="$name" :tabs-names="$tabsNames" :title-level="$titleLevel"> -
    +

    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.

    -
    + -
    +