<script src="https://unpkg.com/@webcomponents/custom-elements"></script> <style> body { margin: 0; } /* Style the element from the outside */ /* fancy-tabs { margin-bottom: 32px; --background-color: black; }*/ </style> <fancy-tabs background> <button slot="title">Tab 1</button> <button slot="title" selected>Tab 2</button> <button slot="title">Tab 3</button> <section>content panel 1</section> <section>content panel 2</section> <section>content panel 3</section> </fancy-tabs> <!-- Using <a> instead of button still works! --> <!-- <fancy-tabs background> <a slot="title">Title 1</a> <a slot="title" selected>Title 2</a> <a slot="title">Title 3</a> <section>content panel 1</section> <section>content panel 2</section> <section>content panel 3</section> </fancy-tabs> --> <script> (function () { 'use strict'; // Feature detect if (!(window.customElements && document.body.attachShadow)) { document.querySelector('fancy-tabs').innerHTML = "<b>Your browser doesn't support Shadow DOM and Custom Elements v1.</b>"; return; } // See https://www.w3.org/TR/wai-aria-practices-1.1/#tabpanel customElements.define( 'fancy-tabs', class extends HTMLElement { #shadowRoot; #tabsSlot; #selected; #boundOnTitleClick; #boundOnKeyDown; panels = []; tabs = []; constructor() { super(); // always call super() first in the ctor. // Create shadow DOM for the component. this.#shadowRoot = this.attachShadow({ mode: 'open' }); this.#shadowRoot.innerHTML = ` <style> :host { display: inline-block; width: 100%; font-family: 'Roboto Slab'; contain: content; } :host([background]) { background: var(--background-color, #9E9E9E); border-radius: 10px; padding: 10px; } #panels { box-shadow: 0 2px 2px rgba(0, 0, 0, .3); background: white; border-radius: 3px; padding: 16px; height: 250px; overflow: auto; } #tabs { display: inline-flex; -webkit-user-select: none; user-select: none; } #tabs slot { display: inline-flex; /* Safari bug. Treats <slot> as a parent */ gap: 4px; } /* Safari does not support #id prefixes on ::slotted See https://bugs.webkit.org/show_bug.cgi?id=160538 */ #tabs ::slotted(*) { font: 400 16px/22px 'Roboto'; padding: 16px 8px; margin: 0; text-align: center; width: 100px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; cursor: pointer; border-top-left-radius: 3px; border-top-right-radius: 3px; background: linear-gradient(#fafafa, #eee); border: none; /* if the user users a <button> */ } #tabs ::slotted([aria-selected="true"]) { font-weight: 600; background: white; box-shadow: none; } #tabs ::slotted(:focus) { z-index: 1; /* make sure focus ring doesn't get buried */ } #panels ::slotted([aria-hidden="true"]) { display: none; } </style> <div id="tabs"> <slot id="tabsSlot" name="title"></slot> </div> <div id="panels"> <slot id="panelsSlot"></slot> </div> `; } get selected() { return this.#selected; } set selected(idx) { this.#selected = idx; this.selectTab(idx); // Updated the element's selected attribute value when // backing property changes. this.setAttribute('selected', idx); } connectedCallback() { this.setAttribute('role', 'tablist'); this.#tabsSlot = this.#shadowRoot.querySelector('#tabsSlot'); const panelsSlot = this.#shadowRoot.querySelector('#panelsSlot'); this.tabs = this.#tabsSlot.assignedNodes({ flatten: true }); this.panels = panelsSlot .assignedNodes({ flatten: true }) .filter((el) => el.nodeType === Node.ELEMENT_NODE); // Add aria role="tabpanel" to each content panel. for (const panel of this.panels) { panel.setAttribute('role', 'tabpanel'); panel.setAttribute('tabindex', 0); } // Referernces to we can remove listeners later. this.#boundOnTitleClick = this.#onTitleClick.bind(this); this.#boundOnKeyDown = this.#onKeyDown.bind(this); this.#tabsSlot.addEventListener('click', this.#boundOnTitleClick); this.#tabsSlot.addEventListener('keydown', this.#boundOnKeyDown); this.selected = this.#findFirstSelectedTab() || 0; } disconnectedCallback() { this.#tabsSlot.removeEventListener('click', this.#boundOnTitleClick); this.#tabsSlot.removeEventListener('keydown', this.#boundOnKeyDown); } #onTitleClick(e) { if (e.target.slot === 'title') { this.selected = this.tabs.indexOf(e.target); e.target.focus(); } } #onKeyDown(e) { switch (e.code) { case 'ArrowUp': case 'ArrowLeft': e.preventDefault(); var idx = this.selected - 1; idx = idx < 0 ? this.tabs.length - 1 : idx; this.tabs[idx].click(); break; case 'ArrowDown': case 'ArrowRight': e.preventDefault(); var idx = this.selected + 1; this.tabs[idx % this.tabs.length].click(); break; default: break; } } #findFirstSelectedTab() { let selectedIdx; for (let [i, tab] of this.tabs.entries()) { tab.setAttribute('role', 'tab'); // Allow users to declaratively select a tab // Highlight last tab which has the selected attribute. if (tab.hasAttribute('selected')) { selectedIdx = i; } } return selectedIdx; } selectTab(idx = null) { for (let [i, tab] of this.tabs.entries()) { const select = i === idx; tab.setAttribute('tabindex', select ? 0 : -1); tab.setAttribute('aria-selected', select); this.panels[i].setAttribute('aria-hidden', !select); } } } ); })(); </script> <div id="app"></div>