375 lines
10 KiB
Plaintext
375 lines
10 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)
|
|
}
|
|
|
|
shared function tabId(item, index) {
|
|
return "wrn-tab-" + valueOf(item, index)
|
|
}
|
|
|
|
shared function panelId(item, index) {
|
|
return "wrn-panel-" + valueOf(item, index)
|
|
}
|
|
|
|
shared function panelSlot(item, index) {
|
|
return "panel-" + valueOf(item, 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='{tabId(item, index)}'
|
|
aria-controls='{panelId(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='{panelId(item, index)}'
|
|
aria-labelledby='{tabId(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='{panelSlot(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;
|
|
}
|
|
}
|
|
}
|
|
}
|