Wizard
A multi-step flow with per-step validation. PanelControl owns the tab UI, the page owns only the flow.
Live demo
The PanelSet Wizard builds its tab strip and locking by hand, and ends on a hint: that repetitive work "belongs in a small reusable tab-strip layer over PanelSet." This page is that layer. PanelControl handles the tab clicks, keyboard navigation, roving tabindex, and the aria-disabled locking; PanelSet handles the Previous / Next buttons (data-ps-prev / data-ps-next) and disables them at the ends. What is left on the page is purely flow: validate a step, allow or block a forward move, and decide which tabs to lock.
A required checkbox on each step controls whether you can move on. Because tab clicks, next(), prev(), keyboard and deep links all funnel through show(), a single ps:beforeactivate listener guards every path. Stepping back is always allowed.
Step 1. Account
Step 2. Profile
Step 3. Confirm
Try it: with the current step unticked, Next is disabled (focus it to hear why) and the later tabs stay locked. Tick the box and Next enables and the next tab unlocks. A tab further ahead stays locked until you reach it.
Every disabled control here explains itself, and why it is disabled decides who owns the hint. Position (you are at an end) is PanelSet’s: Previous carries data-ps-disabled-hint, attached on the first step. Validation (a step is incomplete) is flow’s: the locked tabs use data-pc-disabled-hint (applied by PanelControl), and Next, which can be blocked either way, has its hint set by the flow script: "last step" at the end, "complete this step" when the current step is unticked. Because the default disabledMode is aria, every disabled control stays focusable, so a keyboard or screen-reader user lands on it and hears the reason (the hint rides aria-describedby only while the control is disabled, never on an enabled one).
Markup
<div class="tabs" id="pcwiz-tabs" role="tablist" data-panelcontrol aria-label="Steps">
<button role="tab" aria-controls="pcwiz-step-1" aria-selected="true">Account</button>
<button role="tab" aria-controls="pcwiz-step-2" data-pc-disabled-hint="pcwiz-lock-hint">Profile</button>
<button role="tab" aria-controls="pcwiz-step-3" data-pc-disabled-hint="pcwiz-lock-hint">Confirm</button>
</div>
<div id="pcwiz" data-panelset data-ps-levels>
<div class="panel-wrapper">
<div class="active" id="pcwiz-step-1" role="tabpanel">
<label>
<input type="checkbox" required><span>I agree to create an account</span>
</label>
</div>
<div id="pcwiz-step-2" role="tabpanel" hidden>…</div>
<div id="pcwiz-step-3" role="tabpanel" hidden>…</div>
</div>
</div>
<div class="wizard-nav">
<button data-ps-prev="#pcwiz" data-ps-disabled-hint="pcwiz-prev-hint">Previous</button>
<button data-ps-next="#pcwiz">Next</button>
</div><span id="pcwiz-lock-hint" hidden>Complete the earlier steps before this one.</span><span id="pcwiz-prev-hint" hidden>You’re on the first step.</span><span id="pcwiz-next-hint" hidden>You’re on the last step.</span><span id="pcwiz-next-incomplete-hint" hidden>Complete this step to continue.</span>
The flow logic
PanelControl handles tab activation, keyboard navigation, roving, and the aria-disabled reflection; PanelSet handles the Previous / Next buttons and disables them at the ends. Both are driven by the markup alone. The page adds only flow:
const strip = document.querySelector('#pcwiz-tabs');
const pc = strip.panelControl; // set by PanelControl.init()
const setEl = document.querySelector('#pcwiz');
const tabs = [...strip.querySelectorAll('[role="tab"]')];
const nextBtn = document.querySelector('[data-ps-next="#pcwiz"]');
const panelOf = tab => document.getElementById(tab.getAttribute('aria-controls'));
const valid = panel => !panel.querySelector('[required]:invalid');
setEl.addEventListener('ps:beforeactivate', e => {
const { outgoingPanel, targetPanel } = e.detail;
const forward = outgoingPanel &&
(outgoingPanel.compareDocumentPosition(targetPanel) & Node.DOCUMENT_POSITION_FOLLOWING);
if (forward && !valid(outgoingPanel)) {
e.preventDefault();
outgoingPanel.querySelector('[required]:invalid')?.focus();
}
});
function refresh() {
const firstInvalid = tabs.findIndex(t => !valid(panelOf(t)));
tabs.forEach((t, i) => pc.setTabState(
t.getAttribute('aria-controls'),
firstInvalid !== -1 && i > firstInvalid ? 'disabled' : 'enabled'
));
const i = tabs.findIndex(t => t.getAttribute('aria-controls') === setEl.panelSet.getActive());
const atEnd = i === tabs.length - 1;
const incomplete = i >= 0 && !valid(panelOf(tabs[i]));
nextBtn.setAttribute('aria-disabled', String(atEnd || incomplete));
const hint = atEnd ? 'pcwiz-next-hint' : incomplete ? 'pcwiz-next-incomplete-hint' : '';
if (hint) nextBtn.setAttribute('aria-describedby', hint);
else nextBtn.removeAttribute('aria-describedby');
}
setEl.addEventListener('change', refresh);
setEl.addEventListener('ps:activationcomplete', refresh);
refresh();
Compared to the hand-built PanelSet version, the tab click handlers, the keyboard logic, and the whole Previous / Next block (setup and end-disabling) are gone.
Stepping is now markup (data-ps-prev / data-ps-next, disabled at the ends by PanelSet), and locking goes through setTabState() (which also keeps the roving tabindex correct) instead of poking aria-disabled directly. What stays on the page is the validation and the lock policy. PanelControl and PanelSet own the UI, the page owns the flow.