Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Draft] - Tabs components #1

Merged
merged 13 commits into from
Jan 8, 2025
3 changes: 3 additions & 0 deletions config/vitrine-ui.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
211 changes: 211 additions & 0 deletions resources/frontend/scripts/behaviors/Tabs.js
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions resources/frontend/scripts/behaviors/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
6 changes: 5 additions & 1 deletion resources/frontend/scripts/constants/customEvents.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
}
4 changes: 4 additions & 0 deletions resources/frontend/theme/components/tab-list.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"base": "flex items-center gap-6",
"button": ""
}
3 changes: 3 additions & 0 deletions resources/frontend/theme/components/tab-panel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"base": ""
}
5 changes: 5 additions & 0 deletions resources/frontend/theme/components/tabs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"base": "container",
"title": "f-heading-03 w-full",
"tabList": ""
}
8 changes: 8 additions & 0 deletions resources/views/components/tabs/tab-panel.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div {{ $attributes->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 }}
</div>
19 changes: 19 additions & 0 deletions resources/views/components/tabs/tablist.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<ul role="tablist"
{{ $attributes->class([$ui('tab-list', 'base')]) }}
@if ($tabListId) aria-labelledby="{{ $tabListId }}"
@elseif($ariaLabel) aria-label="{{ $ariaLabel }}" @endif>

@foreach ($tabsNames as $tabName)
<li @if (count($tabsNames) == 1) role="presentation" @endif>
<x-vui-button id="{{ $name . '-' . $loop->index }}"
data-Tabs-button=""
role="tab"
aria-controls="{{ $name . '-panel-' . $loop->index }}"
aria-selected="{{ $loop->index === $selectedIndex ? 'true' : 'false' }}"
:class="$ui('tab-list', 'button')"
:variant="$tabButtonVariant">
{{ $tabName }}
</x-vui-button>
</li>
@endforeach
</ul>
14 changes: 14 additions & 0 deletions resources/views/components/tabs/tabs.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<div {{ $attributes->class([$ui('tabs', 'base')]) }} data-behavior="Tabs">

@if($title)
<x-vui-heading :class="$ui('tabs', 'title')" :id="$tabListId" :level="$titleLevel">{{ $title }}</x-vui-heading>
@endif

<x-vui-tabs-list :class="$ui('tabs', 'tablist')"
:tabs-names="$tabsNames"
:name="$name"
:tab-button-variant="$tabButtonVariant"
:tabListId="isset($title) ? $tabListId : null"/>

{{ $slot }}
</div>
67 changes: 67 additions & 0 deletions resources/views/stories/tabs/README.md
Original file line number Diff line number Diff line change
@@ -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
<x-vui-tabs
:title="$title"
:name="$name"
:tabs-names="$tabsNames"
:title-level="$titleLevel"
>
@foreach ($contents as $content)
<x-vui-tabs-panel :name="$name"
:index="$loop->index"
:selected="$loop->first">
{{ $content }}
</x-vui-tabs-panel>
@endforeach
</x-vui-tabs>
```

## 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.
24 changes: 24 additions & 0 deletions resources/views/stories/tabs/tabs.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
@storybook([
'status' => 'readyForQA',
'layout' => 'fullscreen',
'args' => [
'title' => 'Tabs',
'name' => 'faq',
'tabsNames' => ['Tab 1', 'Tab 2', 'Tab 3'],
'titleLevel' => 3,
'contents' => ['<p>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.</p>', '<p>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.</p>', '<p>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.</p>'],
],
])

<x-vui-tabs :title="$title"
:name="$name"
:tabs-names="$tabsNames"
:title-level="$titleLevel">
@foreach ($contents as $content)
<x-vui-tabs-panel :name="$name"
:index="$loop->index"
:selected="$loop->first">
{!! $content !!}
</x-vui-tabs-panel>
@endforeach
</x-vui-tabs>
Loading