My diagnosis in the previous commit was wrong. The component root was not being replaced by a reactive re-render: the client router owns popstate and swaps the whole page shell on back and forward, which discards component state entirely. Every mechanism that tried to push state into the component from outside was therefore doomed -- clicking a tab, announcing an event, tracking the last applied value. In url mode the query parameter is now simply the source of truth, read where the selection is computed. Whatever render happens next produces the right tab, with no listener to lose and nothing to keep in step. This deletes the runtime tab sync entirely -- 1590 bytes -- and fixes the back/forward cases that were previously broken. Verified in the showcase: click writes the url, two backs and two forwards each land on the right tab, and a ?tab= deep link opens on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
331 lines
8.9 KiB
Plaintext
331 lines
8.9 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)
|
|
}
|
|
|
|
/*
|
|
* In url mode the query parameter is the source of truth, not local state.
|
|
*
|
|
* The client router owns popstate and swaps the whole page on back and
|
|
* forward, which discards component state anyway. Reading the URL means
|
|
* the right tab simply falls out of whatever render happens next, with no
|
|
* listener to lose and nothing to keep in step.
|
|
*/
|
|
shared function currentValue() {
|
|
if (mode === "url" && typeof window !== "undefined" && window.location) {
|
|
var fromUrl = new URLSearchParams(window.location.search).get(param || "tab")
|
|
return fromUrl ? fromUrl : defaultValue()
|
|
}
|
|
if (activeValue) {
|
|
return activeValue
|
|
}
|
|
return defaultValue()
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
|
|
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}'
|
|
>
|
|
<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;
|
|
}
|
|
}
|
|
}
|
|
}
|