Tabs, upgraded
The tabs are complete HTML when they arrive - the first panel shows. Once tabs.js defines <pad-tabs>, the element takes the root PAD rendered and adds clicks and arrow keys.
{shadow}_tags/tabs.pad
customElements.definethis.shadowRoot
Live
PAD renders <pad-tabs> with its shadow root - the tablist and the panel area - and every tab and panel as plain HTML inside it.
The browser builds the shadow root from the template while it parses the page: no script, no flash of unstyled tabs.
tabs.js defines the element: it finds the root already there and adds clicks and the arrow keys.
How it is built
PAD, on the server the browser
{tabs}
{sections}<button slot="tab" role="tab" id="tab-{$key}" aria-controls="panel-{$key}" aria-selected="{first@sections ? 'true' : 'false'}">{$label}</button>{/sections}
{sections}<section slot="panel" role="tabpanel" id="panel-{$key}" aria-labelledby="tab-{$key}"><p>{$text}</p></section>{/sections}
{/tabs}
<script type="module" src="{$padRoot}webcomponents/elements/tabs.js"></script><?php
// The tabs and their panels: a button with slot="tab" and a section with slot="panel" each,
// the light DOM of <pad-tabs>. Before tabs.js has defined the element, the page's CSS shows
// the first panel only; after, the element switches them.
$sections = [ [ 'key' => 'server', 'label' => 'On the server', 'text' => 'PAD renders <pad-tabs> with its shadow root - the tablist and the panel area - and every tab and panel as plain HTML inside it.' ],
[ 'key' => 'wire', 'label' => 'In the HTML', 'text' => 'The browser builds the shadow root from the template while it parses the page: no script, no flash of unstyled tabs.' ],
[ 'key' => 'client', 'label' => 'In the browser', 'text' => 'tabs.js defines the element: it finds the root already there and adds clicks and the arrow keys.' ] ];
?><pad-tabs>
{shadow css='www:elements/tabs.css'}
<div class="tablist" role="tablist" part="tablist"><slot name="tab"></slot></div>
<div class="panels"><slot name="panel"></slot></div>
{/shadow}
@content@
</pad-tabs>// <pad-tabs>: the shadow root is the one PAD rendered - this only adds behaviour. A click
// or an arrow key picks a tab; its panel shows, the others get hidden.
class PadTabs extends HTMLElement {
connectedCallback() {
this.tabs = [...this.querySelectorAll('[slot="tab"]')];
this.panels = [...this.querySelectorAll('[slot="panel"]')];
this.addEventListener('click', (event) => {
const tab = event.target.closest('[slot="tab"]');
if (tab) this.pick(this.tabs.indexOf(tab));
});
this.addEventListener('keydown', (event) => {
const at = this.tabs.indexOf(document.activeElement);
if (at < 0) return;
const step = { ArrowRight: 1, ArrowLeft: -1 }[event.key];
if (!step) return;
event.preventDefault();
this.pick((at + step + this.tabs.length) % this.tabs.length, true);
});
this.pick(0);
}
pick(at, focus) {
this.tabs.forEach((tab, index) => {
tab.setAttribute('aria-selected', String(index === at));
tab.tabIndex = index === at ? 0 : -1;
});
this.panels.forEach((panel, index) => { panel.hidden = index !== at; });
if (focus) this.tabs[at].focus();
}
}
customElements.define('pad-tabs', PadTabs);/* The shadow root of <pad-tabs>: the tab row and the panel area; the tabs and panels are the
page's light DOM, slotted in. */
:host { display: block; }
.tablist { display: flex; gap: 6px; padding: 4px; border-radius: 999px; background: var(--surface-2, #eee); width: fit-content; }
::slotted([slot="tab"]) {
all: unset; cursor: pointer; padding: 8px 16px; border-radius: 999px;
font: 600 14px/1.2 system-ui, sans-serif; color: var(--muted, #666);
}
::slotted([slot="tab"][aria-selected="true"]) { background: var(--surface, #fff); color: var(--text, #111); box-shadow: 0 1px 3px rgba(0, 0, 0, .15); }
::slotted([slot="tab"]:focus-visible) { outline: 3px solid var(--accent-soft, #fdd); }
.panels { margin-top: 14px; padding: 16px 18px; border-radius: 14px; border: 1px solid var(--line, #ddd); background: var(--surface, #fff); }
::slotted([slot="panel"][hidden]) { display: none; }