diff --git a/src/assets/scripts/Pinecone/Tabs/index.js b/src/assets/scripts/Pinecone/Tabs/index.js new file mode 100644 index 00000000..afd9221a --- /dev/null +++ b/src/assets/scripts/Pinecone/Tabs/index.js @@ -0,0 +1,305 @@ +/** + * Tabs class. + * + * @see https://github.com/zachleat/seven-minute-tabs/ + */ +class Tabs { + /** + * Constructor. + * + * @param {DomNode} container + * @param {Object} options + */ + constructor( container, options ) { + this.container = container; + this.tablist = this.container.querySelector( '[role="tablist"]' ); + this.buttons = this.container.querySelectorAll( '[role="tab"]' ); + this.panels = this.container.querySelectorAll( '[role="tabpanel"]' ); + this.delay = this.determineDelay(); + + this.config = { + ...{ + groupSelector: '.tabs' + }, + ...options + }; + + this.handleClick = this.handleClick.bind( this ); + this.handleKeyDown = this.handleKeyDown.bind( this ); + this.handleKeyUp = this.handleKeyUp.bind( this ); + this.initButtons = this.initButtons.bind( this ); + this.initPanels = this.initPanels.bind( this ); + + this.initButtons(); + this.initPanels(); + } + + /** + * Enumerate key codes that we need to detect. + */ + get keys() { + return { + end: 35, + home: 36, + left: 37, + up: 38, + right: 39, + down: 40 + }; + } + + /** + * Determine direction based on key pressed. + */ + get direction() { + return { + 37: -1, + 38: -1, + 39: 1, + 40: 1 + }; + } + + /** + * Initialize buttons. + */ + initButtons() { + let count = 0; + for ( const button of this.buttons ) { + const isSelected = 'true' === button.getAttribute( 'aria-selected' ); + button.setAttribute( 'tabindex', isSelected ? '0' : '-1' ); + + button.addEventListener( 'click', this.handleClick ); + button.addEventListener( 'keydown', this.handleKeyDown ); + button.addEventListener( 'keyup', this.handleKeyUp ); + + button.index = count++; + } + } + + /** + * Initialize panels. + */ + initPanels() { + const selectedPanelId = this.container.querySelector( '[role="tab"][aria-selected="true"]' ).getAttribute( 'aria-controls' ); + for( const panel of this.panels ) { + if( panel.getAttribute( 'id' ) !== selectedPanelId ) { + panel.setAttribute( 'hidden', '' ); + } + panel.setAttribute( 'tabindex', '0' ); + } + } + + /** + * Handle click. + * + * @param {Event} event + */ + handleClick( event ) { + const button = event.target; + + if ( 'A' === button.tagName ) { + event.preventDefault(); + } + + this.activateTab( button, false ); + } + + /** + * Deactivate all tabs. + */ + deactivateTabs() { + for ( const button of this.buttons ) { + button.setAttribute( 'tabindex', '-1' ); + button.setAttribute( 'aria-selected', 'false' ); + button.removeEventListener( 'focus', this.focusEventHandler.bind( this ) ); + } + + for ( const panel of this.panels ) { + panel.setAttribute( 'hidden', 'hidden' ); + } + } + + /** + * Handle focus events. + * + * @param {Event} event + */ + focusEventHandler( event ) { + const {target} = event; + + setTimeout( this.checkTabFocus.bind( this ), this.delay, target ); + } + + /** + * Activate a tab. + * + * @param {DomNode} tab + * @param {Boolean} setFocus + */ + activateTab ( tab, setFocus ) { + if( 'tab' !== tab.getAttribute( 'role' ) ) { + tab = tab.closest( '[role="tab"]' ); + } + + setFocus = setFocus || true; + + this.deactivateTabs(); + + tab.removeAttribute( 'tabindex' ); + + tab.setAttribute( 'aria-selected', 'true' ); + + const controls = tab.getAttribute( 'aria-controls' ); + + document.getElementById( controls ).removeAttribute( 'hidden' ); + + if ( setFocus ) { + tab.focus(); + } + } + + /** + * Handle keydown. + * + * @param {Event} event + */ + handleKeyDown( event ) { + const key = event.keyCode; + + switch ( key ) { + case this.keys.end: + event.preventDefault(); + this.activateTab( this.buttons[this.buttons.length - 1] ); + break; + case this.keys.home: + event.preventDefault(); + this.activateTab( this.buttons[0] ); + break; + + case this.keys.up: + case this.keys.down: + this.determineOrientation( event ); + break; + } + } + + /** + * Handle keyup. + * + * @param {Event} event + */ + handleKeyUp( event ) { + const key = event.keyCode; + + switch ( key ) { + case this.keys.left: + case this.keys.right: + this.determineOrientation( event ); + break; + } + } + + /** + * Check which orientation we're in. + * + * @param {Event} event + */ + determineOrientation( event ) { + const key = event.keyCode; + const vertical = 'vertical' == this.tablist.getAttribute( 'aria-orientation' ); + let proceed = false; + + if ( vertical ) { + if ( key === this.keys.up || key === this.keys.down ) { + event.preventDefault(); + proceed = true; + } + } + else { + if ( key === this.keys.left || key === this.keys.right ) { + proceed = true; + } + } + + if ( proceed ) { + this.switchTabOnArrowPress( event ); + } + } + + /** + * Switch tab when arrow key is pressed. + * + * @param {Event} event + */ + switchTabOnArrowPress( event ) { + const pressed = event.keyCode; + + for ( const button of this.buttons ) { + button.addEventListener( 'focus', this.focusEventHandler.bind( this ) ); + } + + if ( this.direction[pressed] ) { + const {target} = event; + if ( target.index !== undefined ) { + if ( this.buttons[target.index + this.direction[pressed]] ) { + this.buttons[target.index + this.direction[pressed]].focus(); + } + else if ( pressed === this.keys.left || pressed === this.keys.up ) { + this.focusLastTab(); + } + else if ( pressed === this.keys.right || pressed == this.keys.down ) { + this.focusFirstTab(); + } + } + } + } + + /** + * Focus the first tab. + */ + focusFirstTab() { + this.buttons[0].focus(); + } + + /** + * Focus the last tab. + */ + focusLastTab() { + this.buttons[this.buttons.length - 1].focus(); + } + + /** + * Determine if there should be a delay. + */ + determineDelay() { + const hasDelay = this.tablist.hasAttribute( 'data-delay' ); + let delay = 0; + + if ( hasDelay ) { + const delayValue = this.tablist.getAttribute( 'data-delay' ); + if ( delayValue ) { + delay = delayValue; + } + else { + delay = 300; + } + } + + return delay; + } + + /** + * + * @param {DomNode} target + */ + checkTabFocus( target ) { + const focused = document.activeElement; + + if ( target === focused ) { + this.activateTab( target, false ); + } + } +} + +export default Tabs; diff --git a/src/assets/scripts/Pinecone/index.js b/src/assets/scripts/Pinecone/index.js index 9c0f1fb9..239a1f41 100644 --- a/src/assets/scripts/Pinecone/index.js +++ b/src/assets/scripts/Pinecone/index.js @@ -12,6 +12,7 @@ import Notification from './Notification/index.js'; import SearchToggle from './SearchToggle/index.js'; import ToggleButton from './ToggleButton/index.js'; import RadioGroup from './RadioGroup/index.js'; +import Tabs from './Tabs/index.js'; -export default { Accordion, Card, DeselectAll, Dialog, DisclosureButton, FilterList, Icon, Menu, MenuButton, NestedCheckbox, Notification, SearchToggle, ToggleButton, RadioGroup }; +export default { Accordion, Card, DeselectAll, Dialog, DisclosureButton, FilterList, Icon, Menu, MenuButton, NestedCheckbox, Notification, SearchToggle, ToggleButton, RadioGroup, Tabs }; diff --git a/src/assets/scripts/pinecone.js b/src/assets/scripts/pinecone.js index fa180e54..0cda6a63 100644 --- a/src/assets/scripts/pinecone.js +++ b/src/assets/scripts/pinecone.js @@ -112,3 +112,10 @@ if ( radioGroups ) { } ); } +const tabGroups = document.querySelectorAll( '.tabs' ); + +if ( tabGroups ) { + Array.prototype.forEach.call( tabGroups, tabGroup => { + new Pinecone.Tabs( tabGroup ); + } ); +} diff --git a/src/assets/styles/components/_tabs.scss b/src/assets/styles/components/_tabs.scss new file mode 100644 index 00000000..1e1edb6c --- /dev/null +++ b/src/assets/styles/components/_tabs.scss @@ -0,0 +1,218 @@ +.tabs [role="tablist"] { + background: var(--grey-400); + border-bottom: solid rem(1) var(--grey-400); + margin-bottom: rem(22); + margin-left: rem(-30); + margin-right: rem(-30); + width: calc(100% + #{rem(60)}); +} + +.tabs a.tab { + @extend %interactive; + + --outline-color: var(--off-white); + --background-color: var(--white); + --hover-color: var(--off-white); + --hover-background-color: var(--blue-500); + --color: var(--blue-500); + --active-color: var(--off-white); + --active-background-color: var(--blue-500); + --focus-background-color: var(--blue-500); + --focus-color: var(--off-white); + --focus-box-shadow: 0 0 0 #{rem(2)} var(--outline-color) inset; + + align-items: center; + border: 0; + border-left: solid rem(3) transparent; + border-right: solid rem(3) transparent; + display: flex; + flex-direction: row; + font-family: $font-family-sans; + font-size: 1rem; + justify-content: space-between; + line-height: 1.75; + max-width: 100%; + padding: rem(16) rem(16) rem(14); + position: relative; + text-align: left; + text-decoration: none; + width: 100%; + + &:hover, + &:active { + border-left-color: transparent; + } + + &:focus { + border: solid rem(3) transparent; + padding: rem(13) rem(16) rem(11); + } + + &[aria-selected="true"] { + border-left-color: var(--red-400); + font-weight: $font-weight-bold; + + &:hover, + &:focus { + border-left-color: transparent; + } + } +} + +.tabs.tabs--inverse [role="tablist"] { + background: var(--dark-mint-500); + border-bottom-color: var(--dark-mint-500); +} + +.tabs.tabs--inverse a.tab { + --outline-color: var(--blue-600); + --background-color: var(--blue-600); + --color: var(--white); + --hover-background-color: var(--off-white); + --hover-color: var(--dark-mint-500); + --active-background-color: var(--blue-50); + --active-color: var(--dark-mint-500); + --focus-background-color: var(--off-white); + --focus-color: var(--dark-mint-500); +} + +.tabs a.tab + a.tab { + margin-top: rem(1); +} + +.tabs [role="tabpanel"] { + width: 100%; +} + +@include breakpoint-up(md) { + .tabs [role="tablist"] { + align-items: flex-end; + background-color: var(--white); + display: flex; + flex-direction: row; + height: rem(80); + justify-content: flex-start; + margin-bottom: rem(54); + padding-left: rem(14); + padding-right: rem(14); + padding-top: 0; + position: relative; + width: 100vw; + } + + .tabs a.tab { + --background-color: transparent; + --border-width: #{rem(2)}; + --outline-color: var(--off-white); + --hover-color: var(--off-white); + --hover-background-color: var(--blue-500); + --color: var(--blue-500); + --active-color: var(--off-white); + --active-background-color: var(--blue-500); + --focus-background-color: var(--blue-500); + --focus-color: var(--off-white); + --focus-box-shadow: + 0 0 0 var(--border-width) var(--focus-background-color) inset, + 0 0 0 calc(var(--border-width) * 2) var(--outline-color) inset; + --active-box-shadow: var(--focus-box-shadow); + + align-items: center; + border: 0; + display: flex; + flex-direction: row; + font-size: 1rem; + font-weight: $font-weight-normal; + height: rem(80); + justify-content: center; + margin: 0; + padding: 0 rem(16); + width: auto; + + &::after { + background-color: transparent; + bottom: 0; + content: ""; + height: rem(3); + left: 0; + margin-left: 1rem; + position: absolute; + width: calc(100% - 2rem); + } + + &:focus, + &:active { + border: 0; + border-left: 0; + box-shadow: var(--focus-box-shadow); + padding: 0 rem(16); + + &::after { + background-color: transparent; + } + } + + &[aria-selected="true"] { + --color: var(--dark-mint-500); + --background-color: transparent; + --hover-color: var(--off-white); + --hover-background-color: var(--blue-500); + --focus-background-color: var(--blue-500); + --focus-color: var(--off-white); + + font-weight: $font-weight-normal; + + &::after { + background-color: var(--red-400); + } + + &:hover { + &::after { + background-color: transparent; + } + } + + &:focus { + border-left-color: transparent; + + &::after { + background-color: transparent; + } + } + } + } + + .tabs a.tab + a.tab { + margin-top: 0; + } + + .tabs.tabs--inverse [role="tablist"] { + background: var(--blue-600); + } + + .tabs.tabs--inverse a.tab[aria-selected="true"] { + --color: var(--white); + --background-color: transparent; + --hover-background-color: var(--off-white); + --hover-color: var(--dark-mint-500); + --active-background-color: var(--blue-50); + --active-color: var(--dark-mint-500); + --focus-background-color: var(--off-white); + --focus-color: var(--dark-mint-500); + } +} + +@include breakpoint-up(lg) { + .tabs [role="tablist"] { + margin-left: calc((100vw - #{rem(1160)}) / 2 * -1); + padding-left: calc(((100vw - #{rem(1160)}) / 2) - #{rem(16)}); + padding-right: calc(((100vw - #{rem(1160)}) / 2) - #{rem(16)}); + } +} + +@include breakpoint-up(xl) { + .tabs [role="tablist"] { + margin-left: calc((100vw - #{rem(1570)}) / 2 * -1); + padding-left: calc(((100vw - #{rem(1570)}) / 2) - #{rem(16)}); + padding-right: calc(((100vw - #{rem(1570)}) / 2) - #{rem(16)}); + } +} diff --git a/src/assets/styles/pinecone.scss b/src/assets/styles/pinecone.scss index 5346e1e9..a90418d8 100644 --- a/src/assets/styles/pinecone.scss +++ b/src/assets/styles/pinecone.scss @@ -37,6 +37,7 @@ @import "components/responsive-blocks"; @import "components/search"; @import "components/saved-search"; +@import "components/tabs"; @import "layouts/header"; @import "layouts/page"; @import "layouts/home"; diff --git a/src/components/20-molecules/180-tabs/README.md b/src/components/20-molecules/180-tabs/README.md new file mode 100644 index 00000000..ac19d338 --- /dev/null +++ b/src/components/20-molecules/180-tabs/README.md @@ -0,0 +1,3 @@ +# Tabs + +This component is based on [Zach Leatherman's implementation](https://github.com/zachleat/seven-minute-tabs) of the [Tabs with Automatic Activation](https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-1/tabs.html) pattern from [WAI-ARIA Authoring Practices](https://www.w3.org/TR/wai-aria-practices/#tabpanel). diff --git a/src/components/20-molecules/180-tabs/tabs.config.js b/src/components/20-molecules/180-tabs/tabs.config.js new file mode 100644 index 00000000..d8d15ca1 --- /dev/null +++ b/src/components/20-molecules/180-tabs/tabs.config.js @@ -0,0 +1,34 @@ +module.exports = { + title: 'Tabs', + status: 'wip', + context: { + label: 'tab group', + tabs: [ + { + title: 'Tab 1', + content: 'Tab one content.', + selected: true + }, + { + title: 'Tab 2', + content: 'Tab two content.', + selected: false + }, + { + title: 'Tab 3', + content: 'Tab three content.', + selected: false + } + ] + }, + variants: [ + { + name: 'Inverse', + label: 'Inverse', + context: { + modifier: 'inverse', + bodyClass: 'has-blue-500-background-color' + } + } + ] +}; diff --git a/src/components/20-molecules/180-tabs/tabs.njk b/src/components/20-molecules/180-tabs/tabs.njk new file mode 100644 index 00000000..36f778c9 --- /dev/null +++ b/src/components/20-molecules/180-tabs/tabs.njk @@ -0,0 +1,13 @@ +
+
+ {{ label }} + {% for tab in tabs %} + {{ tab.title }} + {% endfor %} +
+ {% for tab in tabs %} +
+ {{ tab.content | safe }} +
+ {% endfor %} +