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

📑 @tournant/tabs #62

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/tabs/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Copyright 2019 Oscar Braunert <[email protected]>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to self: Need to update the license header in communard.


Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
64 changes: 64 additions & 0 deletions packages/tabs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# @tournant/tabs

## Installation

No rocket science here. Although rockets are cool, to be honest. 🚀 You can install the component using NPM or Yarn.

```
npm install @tournant/tabs --save
```

If you use Yarn:

```
yarn add @tournant/tabs
```

Once the component is installed you need to import it wherever you want to use it.

```js
import { TournantTabsWidget, TournantTab, TournantTabpanel } from '@tournant/tabs'
```

Don’t forget to add it to the registered components (been there, done that):

```js
components: {
TournantTabsWidget,
TournantTab,
TournantTabpanel
// ... all the other amazing components
}
```

## Usage

At first, use the wrapper called `TournantTabsWidget`. Inside it establish two slot areas:
- one named "tabs"
- one named "panels".

Inside them, place the triggers inside tabs, and the panel content inside panels, like this:

```
<tournant-tabs-widget initial="foo">
<template slot="tabs">
<tournant-tab name="foo">Item 1</tournant-tab>
<tournant-tab name="bar">Item 2</tournant-tab>
<tournant-tab name="baz">Item 3</tournant-tab>
</template>
<template slot="panels">
<tournant-tabpanel name="foo">Panel 1</tournant-tabpanel>
<tournant-tabpanel name="bar">Panel 2</tournant-tabpanel>
<tournant-tabpanel name="baz">Panel 3</tournant-tabpanel>
</template>
</tournant-tabs-widget>
```
Finally, decide on the initial tab. Pass the tab/panel name into `<TournantTabsWidget>`'s initial prop.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would love to have examples that got deeper than this. What happens if there is interactive contents, headline structure etc. Also, we should avoid foo, bar, baz as variable names. Rather come up with a simple real life example. Maybe cooking related?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First Course, Second Course, Third Course, Dessert. I like it.

## Bugs & Enhancements

Note that this component follows the [WAI ARIA tabpanel Authoring Practice](https://www.w3.org/TR/wai-aria-practices-1.1/#tabpanel).

If you found a bug, please create a [bug ticket](https://github.com/tournantdev/ui/issues/new?assignees=&labels=component:tabs&template=bug_report.md&title=).

For enhancements please refer to the [contributing guidelines](https://github.com/tournantdev/ui/blob/master/CONTRIBUTING.md).
14 changes: 14 additions & 0 deletions packages/tabs/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const base = require('../../jest.config.base.js')
const { name } = require('./package')

// Package name is scoped to @tournant org, split @tournant/package-name for use in path matcher
const folderName = name.split('/')[1]

module.exports = {
...base,
roots: [`<rootDir>/packages/${folderName}`],
displayName: name,
name,
rootDir: '../..',
modulePaths: [`<rootDir>/packages/${folderName}`]
}
26 changes: 26 additions & 0 deletions packages/tabs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@tournant/tabs",
"version": "0.1.0",
"description": "An accessible implementation of a tabpanel widget",
"keywords": [],
"main": "./dist/tabs.ssr.js",
"module": "./dist/tabs.js",
"unpkg": "./dist/browser.min.js",
"files": [
"dist",
"src/**/*.vue"
],
"repository": "https://github.com/tournantdev/ui",
"bugs": "https://github.com/tournantdev/ui/issues",
"homepage": "https://ui.tournant.dev",
"author": "Marcus Herrmann",
"license": "MIT",
"scripts": {
"build": "rollup -c ../../_build/rollup.config.js --environment BUILD:production",
"lint": "vue-cli-service lint",
"test": "cd ../.. && jest packages/tabs --color"
},
"peerDependencies": {
"vue": "^2.6.10"
}
}
29 changes: 29 additions & 0 deletions packages/tabs/src/components/tab.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<template>
<button :id="id" role="tab" tabindex="-1" @click="onClick">
<slot />
</button>
</template>
<script>
export default {
name: 'TournantTabsWidget',
props: {
name: {
type: String,
default: ''
}
},
data() {
return {
id: null
}
},
mounted() {
this.id = `tab-${this.name}`
},
methods: {
onClick(val) {
this.$emit('click', val)
}
}
}
</script>
24 changes: 24 additions & 0 deletions packages/tabs/src/components/tabpanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<template>
<div :id="id" role="tabpanel" hidden aria-labelledby>
<slot />
</div>
</template>
<script>
export default {
name: 'TournantTabpanel',
props: {
name: {
type: String,
default: ''
}
},
data() {
return {
id: null
}
},
mounted() {
this.id = `panel-${this.name}`
}
}
</script>
113 changes: 113 additions & 0 deletions packages/tabs/src/components/tabswidget.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<template>
<div>
<div
ref="tablist"
class="t-ui-tabswidget"
role="tablist"
@click="clickHandler"
@keydown="keydownHandler"
>
<slot name="tabs"></slot>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we could simplify the implementation by using a prop instead of a slot here. Would probably also reduce the amount of code we would have to write in the event handlers down here. At the moment it seems like we miss to leverage most of the benefits Vue provides us for modifying attributes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reasoning behind using <tournant-tab> was that that way, people could place more than just text in Tabs, for example an icon. This wouldn't be possible if tabs were an array, e.g. ["Foo", "Bar", "Baz"]. I know what you mean regarding this approach not being vue-ish, though. I'm wondering for quite some while now: is there a third way?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm … good point. Let me think about this. Of the top of my head: When using an array of objects we could provide some level of customisation (e.g. class names and text). If we change Tab.vue into a functional component we might(!) be able to provide an option to render child components through it (say you want to add an icon that is a component). Either way, we should be very clear what is and what is not usable (e.g. don’t put complex structures in there as the button will likely eat its semantics). Locking this down has some advantages, as keeping it open.

If we keep TournantTab, we have to make sure that the name for the ID is slugified. A random ID wouldn’t work here as Tab and Tabpanel need to have the same name, right?

In the end: It depends. Need to get those stickers now.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we keep TournantTab, we have to make sure that the name for the ID is slugified. A random ID wouldn’t work here as Tab and Tabpanel need to have the same name, right?

Correct.

</div>
<div ref="panellist">
<slot name="panels"></slot>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slot here makes sense to provide a mechanism for implementors to add any kind of content.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this actionable or rather thinking out loud? If the latter: Yes, that's what I was thinking.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was thinking out loud agreement :)

</div>
</div>
</template>

<script>
export default {
name: 'TournantTabsWidget',
props: {
initial: {
type: String,
required: true,
default: null
}
},
data() {
return {
tabs: [],
panels: [],
activeTab: `tab-${this.initial}`
}
},
mounted() {
let slotEl = null
for (slotEl of this.$slots.tabs) {
if (
slotEl.componentOptions &&
slotEl.componentOptions.tag === 'tournant-tab'
) {
if (slotEl.componentInstance.$el.disabled) return
this.tabs.push(slotEl.componentInstance)
}
}
for (slotEl of this.$slots.panels) {
if (
slotEl.componentOptions &&
slotEl.componentOptions.tag === 'tournant-tabpanel'
) {
this.panels.push(slotEl.componentInstance)
}
}
this.panels.forEach((panel, i) => {
panel.$el.setAttribute('aria-labelledby', this.tabs[i].id)
})
this.changeTab(this.activeTab)
},
methods: {
changeTab(tab) {
this.$nextTick(() => {
const tabEl = this.$refs.tablist.querySelector(`#${tab}`)
this.panels.forEach(panel => {
panel.$el.hidden = true
})
this.$refs.panellist.querySelector(
`[aria-labelledby=${tab}]`
).hidden = false
this.$refs.tablist.querySelectorAll('[aria-selected]').forEach(el => {
el.removeAttribute('aria-selected')
el.setAttribute('tabindex', '-1')
})
tabEl.setAttribute('aria-selected', 'true')
tabEl.setAttribute('tabindex', '0')
tabEl.focus()
this.activeTab = tab
this.$emit('activeTab', tab.replace('tab-', ''))
this.$emit('click')
})
},
clickHandler(e) {
this.changeTab(e.target.id)
},
keydownHandler: function(e) {
const activeElem = this.$refs.tablist.querySelector(`#${this.activeTab}`)
const activeIndex = Array.from(this.$refs.tablist.children).indexOf(
activeElem
)
let targetTab
switch (e.keyCode) {
case 37:
if (activeIndex - 1 < 0) {
targetTab = this.tabs[this.tabs.length - 1]
} else {
targetTab = this.tabs[activeIndex - 1]
}
this.changeTab(targetTab.id)
break
case 39:
if (activeIndex + 1 > this.tabs.length - 1) {
targetTab = this.tabs[0]
} else {
targetTab = this.tabs[activeIndex + 1]
}
this.changeTab(targetTab.id)
break
default:
return
}
}
Comment on lines +84 to +110
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we break this up into multiple methods for the single events?

}
}
</script>
5 changes: 5 additions & 0 deletions packages/tabs/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import TournantTabsWidget from './components/tabswidget.vue'
import TournantTab from './components/tab.vue'
import TournantTabpanel from './components/tabpanel.vue'

export { TournantTabsWidget, TournantTab, TournantTabpanel }
29 changes: 29 additions & 0 deletions packages/tabs/src/wrapper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { TournantTabsWidget, TournantTab, TournantTabpanel } from './index.js'

// Declare install function executed by Vue.use()
export function install(Vue) {
if (install.installed) return
install.installed = true
Vue.component('TournantTabsWidget', TournantTabsWidget)
Vue.component('TournantTab', TournantTab)
Vue.component('TournantTabpanel', TournantTabpanel)
}

// Create module definition for Vue.use()
const plugin = {
install
}

// Auto-install when vue is found (eg. in browser via <script> tag)
let GlobalVue = null
if (typeof window !== 'undefined') {
GlobalVue = window.Vue
} else if (typeof global !== 'undefined') {
GlobalVue = global.Vue
}
if (GlobalVue) {
GlobalVue.use(plugin)
}

// To allow use as module (npm/webpack/etc.) export component
export default { TournantTabsWidget, TournantTab, TournantTabpanel }
49 changes: 49 additions & 0 deletions packages/tabs/tests/tabs.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { TournantTabsWidget, TournantTab, TournantTabpanel } from '../src/'

export default { title: '@tournant/tabs' }

export const Basic = () => ({
components: { TournantTabsWidget, TournantTab, TournantTabpanel },
template: `
<tournant-tabs-widget initial="foo">
<template slot="tabs">
<tournant-tab name="foo">Item 1</tournant-tab>
<tournant-tab name="bar">Item 2</tournant-tab>
<tournant-tab name="baz">Item 3</tournant-tab>
</template>
<template slot="panels">
<tournant-tabpanel name="foo">Panel 1</tournant-tabpanel>
<tournant-tabpanel name="bar">Panel 2</tournant-tabpanel>
<tournant-tabpanel name="baz">Panel 3</tournant-tabpanel>
</template>
</tournant-tabs-widget>`
})

export const WithInteractiveContent = () => ({
components: { TournantTabsWidget, TournantTab, TournantTabpanel },
template: `
<tournant-tabs-widget initial="foo">
<template slot="tabs">
<tournant-tab name="foo">Link</tournant-tab>
<tournant-tab name="bar">Boring</tournant-tab>
<tournant-tab name="baz">Hidden fun</tournant-tab>
</template>
<template slot="panels">
<tournant-tabpanel name="foo">
<h2>The tab with a link</h2>
<p>Some tabs have complex content. This, for example, has a linky</p>
<p>For more information visit our <a href="https://blog.tournant.dev">tabs development notes blog post</a>.
</tournant-tabpanel>
<tournant-tabpanel name="bar">
<h2>This is a boring tab</h2>
<p>It’s boring because it’s boring</p>
</tournant-tabpanel>
<tournant-tabpanel name="baz">
<h2>This has hidden fun</h2>
<p>Though I guess the hidden fun isn’t hidden if it is in the name of the tab.</p>
<p>Any, here’s a GIF for you.</p>
<img src="https://gif.ovlb.net/tip-tap.gif" alt="GIF of an owl tip-tapping through a living room" />
</tournant-tabpanel>
</template>
</tournant-tabs-widget>`
})
Loading