Files
WRNexusJS/packages/ui/components/Tabs.wrn
T
Clintchiz 2c960fc1dc
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s
refactor: migrate legacy wire namespace to wrn
2026-08-12 18:51:15 +05:30

363 lines
9.8 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 = ""
// 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 {
<section
{...attrs}
data-ui-component="Tabs"
class='wrn-tabs {class}'
data-orientation='{orientation}'
data-color='{color}'
data-size='{size}'
data-slide='{slideFrom}'
data-mode='{mode}'
data-param='{param}'
>
<div
class="wrn-tabs__list"
role="tablist"
aria-label='{label}'
aria-orientation='{orientation}'
data-wrn-roving='{rovingAxis()}'
>
{#each itemList() as item, index}
<button
type="button"
class="wrn-tabs__tab"
role="tab"
data-value='{valueOf(item, index)}'
data-wrn-roving-item="true"
id='wrn-tab-{valueOf(item, index)}'
aria-controls='wrn-panel-{valueOf(item, index)}'
aria-selected='{isSelected(item, index) ? "true" : "false"}'
aria-disabled='{item.disabled ? "true" : "false"}'
@click='selectTab(item, index, event)'
>
<span class='wrn-tabs__icon {item.icon}' data-show="item.icon" aria-hidden="true"></span>
<span class="wrn-tabs__label">{item.label || item.title}</span>
<span class="wrn-tabs__badge" data-show="item.badge">{item.badge}</span>
</button>
{/each}
</div>
<div class="wrn-tabs__panels">
{#each itemList() as item, index}
<div
class="wrn-tabs__panel"
role="tabpanel"
id='wrn-panel-{valueOf(item, index)}'
aria-labelledby='wrn-tab-{valueOf(item, index)}'
tabindex="0"
data-show='isSelected(item, index)'
>
<h3 class="wrn-tabs__title" data-show="item.title && item.title !== item.label">
{item.title}
</h3>
<p class="wrn-tabs__description" data-show="item.description">{item.description}</p>
<div class="wrn-tabs__content" data-show="item.content">{item.content}</div>
<slot name="panel-{valueOf(item, index)}"></slot>
</div>
{/each}
<slot />
</div>
</section>
}
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;
}
}
}
}