// Tabs -- a tablist over panels. // // // // 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 = "" // Which way the panel should slide in. Set on every change so the animation // follows the direction of travel rather than always coming from one side. state slideFrom = "forward" 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) var list = itemList() var previous = -1 for (var scan = 0; scan < list.length; scan += 1) { if (valueOf(list[scan], scan) === currentValue()) { previous = scan } } slideFrom = previous > index ? "back" : "forward" 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 {
{#each itemList() as item, index} {/each}
{#each itemList() as item, index}

{item.title}

{item.description}

{item.content}
{/each}
} style { .wrn-tabs { --tabs-accent: var(--wrn-color-primary); display: flex; flex-direction: column; gap: 1rem; max-width: 100%; } .wrn-tabs[data-color="secondary"] { --tabs-accent: var(--wrn-color-secondary); } .wrn-tabs[data-color="success"] { --tabs-accent: var(--wrn-color-success); } .wrn-tabs[data-color="danger"] { --tabs-accent: var(--wrn-color-danger); } .wrn-tabs[data-color="info"] { --tabs-accent: var(--wrn-color-info); } .wrn-tabs[data-size="sm"] { font-size: 0.82rem; } .wrn-tabs[data-size="lg"] { font-size: 1rem; } .wrn-tabs[data-orientation="vertical"] { flex-direction: row; align-items: flex-start; } .wrn-tabs__list { display: flex; gap: 0.25rem; min-width: 0; padding: 0.25rem; border: 1px solid var(--wrn-color-border); border-radius: var(--wrn-radius-md); background: var(--wrn-color-surface-soft); /* A long tablist scrolls rather than wrapping into an unusable stack. */ overflow-x: auto; } .wrn-tabs[data-orientation="vertical"] .wrn-tabs__list { flex-direction: column; flex: 0 0 auto; width: 14rem; overflow-x: visible; } .wrn-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(--wrn-radius-sm); background: transparent; color: var(--wrn-color-text-muted); font: inherit; font-size: 0.88rem; font-weight: 600; white-space: nowrap; cursor: pointer; transition: background 160ms ease, color 160ms ease; } .wrn-tabs[data-orientation="vertical"] .wrn-tabs__tab { justify-content: flex-start; } .wrn-tabs__tab:hover { color: var(--wrn-color-text); } .wrn-tabs__tab:focus-visible { outline: 2px solid var(--tabs-accent); outline-offset: 2px; } .wrn-tabs__tab[aria-selected="true"] { background: var(--wrn-color-surface); color: var(--tabs-accent); box-shadow: var(--wrn-shadow-1); } .wrn-tabs__tab[aria-disabled="true"] { opacity: 0.5; pointer-events: none; } .wrn-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; } .wrn-tabs__panels { min-width: 0; flex: 1 1 auto; } .wrn-tabs__panel { outline: none; animation: wrn-tabs-slide-forward 220ms cubic-bezier(0.22, 1, 0.36, 1) both; } .wrn-tabs[data-slide="back"] .wrn-tabs__panel { animation-name: wrn-tabs-slide-back; } /* The panels clip their own slide so it never widens the page. */ .wrn-tabs__panels { overflow-x: clip; } .wrn-tabs__panel:focus-visible { outline: 2px solid var(--tabs-accent); outline-offset: 4px; border-radius: var(--wrn-radius-sm); } .wrn-tabs__title { margin: 0 0 0.35rem; font-size: 1rem; font-weight: 700; } .wrn-tabs__description { margin: 0; color: var(--wrn-color-text-muted); line-height: 1.6; } .wrn-tabs__content { margin-top: 0.6rem; } @keyframes wrn-tabs-slide-forward { from { opacity: 0; transform: translateX(1.25rem); } to { opacity: 1; transform: none; } } @keyframes wrn-tabs-slide-back { from { opacity: 0; transform: translateX(-1.25rem); } to { opacity: 1; transform: none; } } /* Respect a reduced-motion preference: swap instantly instead. */ @media (prefers-reduced-motion: reduce) { .wrn-tabs__panel { animation: none; } } @media (max-width: 639px) { .wrn-tabs[data-orientation="vertical"] { flex-direction: column; } .wrn-tabs[data-orientation="vertical"] .wrn-tabs__list { width: 100%; flex-direction: row; overflow-x: auto; } } } }