Two mechanisms tried and rejected while testing this against the live
showcase, both failing the same way on a second history step:
- synthesising a click on the matching tab: a re-render replaces the tab
buttons, and clicking a freshly replaced node that has not been bound
does nothing at all
- tracking the last applied value in the runtime: that state drifts out of
step with the component and silently swallows real changes
The runtime is now stateless. It announces the value the URL names via a
wrnexus:tabs:restore event and the component applies it, comparing against
its own selection rather than a DOM attribute a re-render owns.
Nav submenus are anchored so the viewport clamp keeps them on screen.
Comments in the runtime template trimmed to stay inside the size budget
rather than raising the ceiling again.
Known limitation: a second consecutive back/forward does not update the
selection, because the re-render replaces the component root without
rebinding its declarative listeners. That is a framework defect, not a Tabs
one, and needs its own fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
354 lines
9.5 KiB
Plaintext
354 lines
9.5 KiB
Plaintext
// Tabs -- a tablist over panels.
|
|
//
|
|
// <Tabs items='[{"label":"Overview","value":"overview"}]' active="overview" />
|
|
//
|
|
// With mode="url" the selection is mirrored into a query parameter using
|
|
// history.pushState, so the panel swaps without a page load and the back
|
|
// button works. The query parameter is used rather than the hash because it
|
|
// survives a reload and does not collide with in-page anchors or Scrollspy.
|
|
//
|
|
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
|
// and silently swallows the rule that follows it.
|
|
component Tabs {
|
|
outputs {
|
|
change(payload: { value: string; item: object; index: number })
|
|
select(payload: { value: string; item: object; index: number })
|
|
}
|
|
|
|
props {
|
|
color: string = "primary"
|
|
size: string = "default"
|
|
items: unknown[] = []
|
|
active: string = ""
|
|
orientation: string = "horizontal"
|
|
mode: string = "client"
|
|
param: string = "tab"
|
|
label: string = "Tabs"
|
|
class: string = ""
|
|
}
|
|
|
|
state activeValue = ""
|
|
|
|
functions {
|
|
shared function itemList() {
|
|
return Array.isArray(items) ? items : []
|
|
}
|
|
|
|
shared function valueOf(item, index) {
|
|
return String(item.value || item.id || index)
|
|
}
|
|
|
|
shared function currentValue() {
|
|
if (activeValue) {
|
|
return activeValue
|
|
}
|
|
if (active) {
|
|
return active
|
|
}
|
|
var list = itemList()
|
|
return list.length ? valueOf(list[0], 0) : ""
|
|
}
|
|
|
|
// The selection this instance started with. The runtime falls back to it
|
|
// when the back button lands on a URL that has no tab parameter at all.
|
|
shared function defaultValue() {
|
|
if (active) {
|
|
return active
|
|
}
|
|
var list = itemList()
|
|
return list.length ? valueOf(list[0], 0) : ""
|
|
}
|
|
|
|
shared function isSelected(item, index) {
|
|
return valueOf(item, index) === currentValue()
|
|
}
|
|
|
|
shared function rovingAxis() {
|
|
return orientation === "vertical" ? "vertical" : "horizontal"
|
|
}
|
|
|
|
// Back and forward arrive here. The runtime announces the value the URL
|
|
// now names; applying it must not write history, or stepping back would
|
|
// push a new entry and trap the user.
|
|
client function applyUrlValue(sourceEvent) {
|
|
var detail = sourceEvent ? sourceEvent.detail : null
|
|
var value = detail ? detail.value : ""
|
|
if (!value) {
|
|
return
|
|
}
|
|
var list = itemList()
|
|
var found = -1
|
|
for (var index = 0; index < list.length; index += 1) {
|
|
if (valueOf(list[index], index) === value) {
|
|
found = index
|
|
}
|
|
}
|
|
if (found === -1) {
|
|
return
|
|
}
|
|
// Idempotent against our own state rather than a DOM attribute the
|
|
// re-render owns: popstate can fire for a value already selected.
|
|
if (value === currentValue()) {
|
|
return
|
|
}
|
|
activeValue = value
|
|
output.change({ value: value, item: list[found], index: found })
|
|
}
|
|
|
|
client function selectTab(item, index, sourceEvent) {
|
|
if (item.disabled) {
|
|
return
|
|
}
|
|
var value = valueOf(item, index)
|
|
activeValue = value
|
|
|
|
if (mode === "url" && window.history && window.history.pushState) {
|
|
var url = new URL(window.location.href)
|
|
url.searchParams.set(param || "tab", value)
|
|
window.history.pushState({}, "", url.toString())
|
|
}
|
|
|
|
output.change({ value: value, item: item, index: index })
|
|
output.select({ value: value, item: item, index: index })
|
|
}
|
|
}
|
|
|
|
view {
|
|
<section
|
|
{...attrs}
|
|
data-ui-component="Tabs"
|
|
class='wire-tabs {class}'
|
|
data-orientation='{orientation}'
|
|
data-color='{color}'
|
|
data-size='{size}'
|
|
data-mode='{mode}'
|
|
data-param='{param}'
|
|
data-wrn-tabs-param='{mode === "url" ? param : ""}'
|
|
data-wrn-tabs-default='{defaultValue()}'
|
|
@wrnexus:tabs:restore='applyUrlValue(event)'
|
|
>
|
|
<div
|
|
class="wire-tabs__list"
|
|
role="tablist"
|
|
aria-label='{label}'
|
|
aria-orientation='{orientation}'
|
|
data-wrn-roving='{rovingAxis()}'
|
|
>
|
|
{#each itemList() as item, index}
|
|
<button
|
|
type="button"
|
|
class="wire-tabs__tab"
|
|
role="tab"
|
|
data-value='{valueOf(item, index)}'
|
|
data-wrn-roving-item="true"
|
|
id='wire-tab-{valueOf(item, index)}'
|
|
aria-controls='wire-panel-{valueOf(item, index)}'
|
|
aria-selected='{isSelected(item, index) ? "true" : "false"}'
|
|
aria-disabled='{item.disabled ? "true" : "false"}'
|
|
@click='selectTab(item, index, event)'
|
|
>
|
|
<span class='wire-tabs__icon {item.icon}' data-show="item.icon" aria-hidden="true"></span>
|
|
<span class="wire-tabs__label">{item.label || item.title}</span>
|
|
<span class="wire-tabs__badge" data-show="item.badge">{item.badge}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="wire-tabs__panels">
|
|
{#each itemList() as item, index}
|
|
<div
|
|
class="wire-tabs__panel"
|
|
role="tabpanel"
|
|
id='wire-panel-{valueOf(item, index)}'
|
|
aria-labelledby='wire-tab-{valueOf(item, index)}'
|
|
tabindex="0"
|
|
data-show='isSelected(item, index)'
|
|
>
|
|
<h3 class="wire-tabs__title" data-show="item.title && item.title !== item.label">
|
|
{item.title}
|
|
</h3>
|
|
<p class="wire-tabs__description" data-show="item.description">{item.description}</p>
|
|
<div class="wire-tabs__content" data-show="item.content">{item.content}</div>
|
|
<slot name="panel-{valueOf(item, index)}"></slot>
|
|
</div>
|
|
{/each}
|
|
<slot />
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
style {
|
|
.wire-tabs {
|
|
--tabs-accent: var(--wire-color-primary);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.wire-tabs[data-color="secondary"] {
|
|
--tabs-accent: var(--wire-color-secondary);
|
|
}
|
|
|
|
.wire-tabs[data-color="success"] {
|
|
--tabs-accent: var(--wire-color-success);
|
|
}
|
|
|
|
.wire-tabs[data-color="danger"] {
|
|
--tabs-accent: var(--wire-color-danger);
|
|
}
|
|
|
|
.wire-tabs[data-color="info"] {
|
|
--tabs-accent: var(--wire-color-info);
|
|
}
|
|
|
|
.wire-tabs[data-size="sm"] {
|
|
font-size: 0.82rem;
|
|
}
|
|
|
|
.wire-tabs[data-size="lg"] {
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.wire-tabs[data-orientation="vertical"] {
|
|
flex-direction: row;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.wire-tabs__list {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
min-width: 0;
|
|
padding: 0.25rem;
|
|
border: 1px solid var(--wire-color-border);
|
|
border-radius: var(--wire-radius-md);
|
|
background: var(--wire-color-surface-soft);
|
|
/* A long tablist scrolls rather than wrapping into an unusable stack. */
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.wire-tabs[data-orientation="vertical"] .wire-tabs__list {
|
|
flex-direction: column;
|
|
flex: 0 0 auto;
|
|
width: 14rem;
|
|
overflow-x: visible;
|
|
}
|
|
|
|
.wire-tabs__tab {
|
|
appearance: none;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.4rem;
|
|
flex: 1 0 auto;
|
|
padding: 0.55rem 0.9rem;
|
|
border: 0;
|
|
border-radius: var(--wire-radius-sm);
|
|
background: transparent;
|
|
color: var(--wire-color-text-muted);
|
|
font: inherit;
|
|
font-size: 0.88rem;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
cursor: pointer;
|
|
transition:
|
|
background 160ms ease,
|
|
color 160ms ease;
|
|
}
|
|
|
|
.wire-tabs[data-orientation="vertical"] .wire-tabs__tab {
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.wire-tabs__tab:hover {
|
|
color: var(--wire-color-text);
|
|
}
|
|
|
|
.wire-tabs__tab:focus-visible {
|
|
outline: 2px solid var(--tabs-accent);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.wire-tabs__tab[aria-selected="true"] {
|
|
background: var(--wire-color-surface);
|
|
color: var(--tabs-accent);
|
|
box-shadow: var(--wire-shadow-1);
|
|
}
|
|
|
|
.wire-tabs__tab[aria-disabled="true"] {
|
|
opacity: 0.5;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.wire-tabs__badge {
|
|
padding: 0.05rem 0.4rem;
|
|
border-radius: 999px;
|
|
background: color-mix(in srgb, var(--tabs-accent) 16%, transparent);
|
|
color: var(--tabs-accent);
|
|
font-size: 0.72rem;
|
|
}
|
|
|
|
.wire-tabs__panels {
|
|
min-width: 0;
|
|
flex: 1 1 auto;
|
|
}
|
|
|
|
.wire-tabs__panel {
|
|
outline: none;
|
|
animation: wire-tabs-in 200ms ease both;
|
|
}
|
|
|
|
.wire-tabs__panel:focus-visible {
|
|
outline: 2px solid var(--tabs-accent);
|
|
outline-offset: 4px;
|
|
border-radius: var(--wire-radius-sm);
|
|
}
|
|
|
|
.wire-tabs__title {
|
|
margin: 0 0 0.35rem;
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.wire-tabs__description {
|
|
margin: 0;
|
|
color: var(--wire-color-text-muted);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.wire-tabs__content {
|
|
margin-top: 0.6rem;
|
|
}
|
|
|
|
@keyframes wire-tabs-in {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(4px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: none;
|
|
}
|
|
}
|
|
|
|
/* Respect a reduced-motion preference: swap instantly instead. */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.wire-tabs__panel {
|
|
animation: none;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 639px) {
|
|
.wire-tabs[data-orientation="vertical"] {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.wire-tabs[data-orientation="vertical"] .wire-tabs__list {
|
|
width: 100%;
|
|
flex-direction: row;
|
|
overflow-x: auto;
|
|
}
|
|
}
|
|
}
|
|
}
|