PAD + web components

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; }