Skip to content

Instantly share code, notes, and snippets.

@ebidel
Last active March 8, 2024 23:08
Show Gist options
  • Save ebidel/2d2bb0cdec3f2a16cf519dbaa791ce1b to your computer and use it in GitHub Desktop.
Save ebidel/2d2bb0cdec3f2a16cf519dbaa791ce1b to your computer and use it in GitHub Desktop.
Fancy tabs web component - shadow dom v1, custom elements v1, full a11y
<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>
@itsMattShull
Copy link

Is the polyfill at the top for ShadowDOM v1 and Custom Elements v1?

@mkozhukharenko
Copy link

yep

@ephemere000
Copy link

I could not make my custom element container work until I found your example. Chromium bug report: Issue 658119 (code provided).

In my case both the container and the children are custom elements. My container would only work if the children were native elements. When I nested custom elements, the container would not see it's children in connectedCallback (not created yet).

I put my custom children in your container and it worked! I finally realized that you instantiate the container and children BEFORE you define them (upgrade). I had done the reverse and included the definitions at the beginning of the page, like we always do for definitions: styles, scripts,.. It was working until I encountered nested custom elements.

First, I find odd that the custom elements children creation sequence is different from a parent node's perspective when compared to native elements. The children are already created in connectedCallback for native elements, not for custom ones. Second, to make my code work, I have to put the definitions' inclusion at the END of the HTML file!

This is a schizophrenic behavior from a coders' perspective, two different creation sequence schemes and sets of rules. It will become a nightmare very quickly and is not maintainable.

Is my understanding right (I deeply hope not), or I'm missing something fundamental here?

Thanks

@ephemere000
Copy link

ephemere000 commented Oct 25, 2016

I got a reply from dominicc from the Chromium group: "I'm closing this as WontFix because I think this is working as intended and per the spec, even though the behavior is surprising."

Meaning: unintuitive, not clear, not working like native elements and HTML do in general, but what the heck, it is conform to the specs if we look deep into it, so we won't fix and live with it!

The problem was already forseen in the specs design but was dismissed. "Some contributors to the spec considered a callback for "end tag parsed" which would be a good time to inspect children if you were doing "one shot" processing of children, however IIRC the decision was to not add this callback yet."

Acutally, the proposed way is as follows: "If you want a custom element's behavior to depend on its children you should use a MutationObserver or have the children coordinate with the container through events/methods/etc."

The second solution is obviously not viable. The children would have to coordonate with their parents though KNOWN events/methods, meaning that the coder that creates the container MUST ALSO create the children so they can register to or call their container using a KNOWN PUBLISHED interface. Anyone creating a container like your Fancy Tab would also have to create all of the custom element children that it may potentially encounter. If not, even worse, each creator in the community will come up with their own event/methods that all the other custom elements in the world would have to know about and call. Impossible, a no brainer.

Adieu! the ability to create custom containers and elements that can be shared in the community and that are agostic of each other, that behave like native elements. This solution reminds us that a clear interface should be defined between parent and child nodes, a standardized one. I wonder how they made it work for native ones under the hood. We don't have this option with custom elements coming from different sources and libraries.

The second solution, MutationObserver, would be good for a dynamic container where chidren might come and go. A bit overkilled for stable one shot contents containers (for intance GUI). Certainly not necessary if the children are native elements. They use a different logic created by the vendor. OUCH!

One of the 4 conerstones of Webcomponents is the Import mechanism. Some are now looking at Modules instead. All the same. A way to include FIRST the custom elements definitions and then use them in the page. That Import WILL be placed at the beginning of the document, not at the end like the definitions in your example.

I appreciate your example, it made me realize it is pretty naïve (with all due respects, no insult intended and I did it like you intitially), but not usable as a standard example of how to implement an actual custom container. Yours works because of a "hack", an upgrade of the custom elements by including the definitions at the end of the document when everything is already created, which is not the way (and was never the way) the coders intended to include their definitions, through imports or modules at the BEGINING of the document.

Since compostion is a major part of WebComponents and HTML, actually, the whole DOM is about nodes withing nodes contained and layed out within other nodes, the current specs are incomplete and unusable. By importing custom elements at the begining of the document, only native element children are accessible to their parents through some magic implemented by the vendor, creating the children before the parent's callback is invoked. Custom children don't follow this rule. Sad. :(

I doubt anything will be done about it since the Chronium group dismissed it and said "the behavior is surprising" but well, conforms to the [inadequate] spec. So, live with it!

Hopefully, another group will adress this issue. I understand now why Firefox and other vendors are hesitating to implement any of this at the moment. They are waiting for this kind of feedback that they claim they won't dismiss.

Respectfully

@httpstersk
Copy link

httpstersk commented Oct 31, 2016

Hey Eric.

It seems ::slotted + ::before pseudo selectors doesn't play nicely together. CSS ignores whole definition #tabs ::slotted(*)::before { }
https://jsbin.com/gohezulama/4/edit?html,js,output

Is it a bug or did I do something wrong?

Thank you.

@amirulislam862
Copy link

amirulislam862 commented Mar 14, 2018

amirulislam862

@noncototient
Copy link

webcomponents/webcomponentsjs#548

This appears to be fixed now, so the check on line 29 can be removed

@akanshgulati
Copy link

@WinterSilence
Copy link

<!-- Using <a> instead of h2 still works! -->

not h2 - button

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment