-
Notifications
You must be signed in to change notification settings - Fork 1
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
base: master
Are you sure you want to change the base?
📑 @tournant/tabs #62
Changes from 5 commits
796167b
259922c
6f0a1d0
bfb3182
abe729e
e0c6c46
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
Copyright 2019 Oscar Braunert <[email protected]> | ||
|
||
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. |
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. | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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). |
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}`] | ||
} |
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" | ||
} | ||
} |
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> |
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> |
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> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reasoning behind using There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Correct. |
||
</div> | ||
<div ref="panellist"> | ||
<slot name="panels"></slot> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> |
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 } |
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 } |
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>` | ||
}) |
There was a problem hiding this comment.
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.