Skip to content

Commit

Permalink
Merge pull request #1 from area17/tabs-components
Browse files Browse the repository at this point in the history
[Draft] - Tabs components
  • Loading branch information
mrdoinel authored Jan 8, 2025
2 parents 0ec293f + 0dc7d70 commit bc8f18b
Show file tree
Hide file tree
Showing 15 changed files with 494 additions and 1 deletion.
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

0 comments on commit bc8f18b

Please sign in to comment.