443 lines
12 KiB
Plaintext
443 lines
12 KiB
Plaintext
// Stepper -- ordered progress through a sequence.
|
|
//
|
|
// <Stepper steps='[{"label":"Account"},{"label":"Billing"}]' active={1} />
|
|
//
|
|
// Each step can be authored by hand instead of using the built-in body, by
|
|
// passing a slot named for its index:
|
|
//
|
|
// <Stepper steps={steps} active={1}>
|
|
// <div data-slot="step-1">...anything...</div>
|
|
// </Stepper>
|
|
//
|
|
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
|
// and silently swallows the rule that follows it.
|
|
component Stepper {
|
|
outputs {
|
|
change(payload: { index: number; step: object })
|
|
back(payload: { index: number; step: object })
|
|
next(payload: { index: number; step: object })
|
|
skip(payload: { index: number; step: object })
|
|
finish(payload: { index: number; step: object })
|
|
}
|
|
|
|
props {
|
|
color: string = "primary"
|
|
size: string = "default"
|
|
steps: unknown[] = []
|
|
active: number = 0
|
|
orientation: string = "horizontal"
|
|
clickable: boolean = false
|
|
label: string = "Progress"
|
|
// Render each step body, showing only the active one -- same contract as
|
|
// Tabs, so a wizard does not have to hand-roll the panel switching.
|
|
showPanel: boolean = false
|
|
controls: boolean = false
|
|
allowSkip: boolean = false
|
|
// Lets a form hold the step. The component never validates anything
|
|
// itself: the page owns the form, so it sets this while the step is
|
|
// incomplete and clears it once the step passes.
|
|
nextDisabled: boolean = false
|
|
backLabel: string = "Back"
|
|
nextLabel: string = "Next"
|
|
skipLabel: string = "Skip"
|
|
finishLabel: string = "Finish"
|
|
class: string = ""
|
|
}
|
|
|
|
functions {
|
|
shared function stepList() {
|
|
return Array.isArray(steps) ? steps : []
|
|
}
|
|
|
|
shared function activeIndex() {
|
|
var count = stepList().length
|
|
if (count < 1) {
|
|
return 0
|
|
}
|
|
var value = Number(active)
|
|
if (!value || value < 0) {
|
|
return 0
|
|
}
|
|
return Math.min(value, count - 1)
|
|
}
|
|
|
|
shared function statusFor(index) {
|
|
if (index < activeIndex()) {
|
|
return "complete"
|
|
}
|
|
if (index === activeIndex()) {
|
|
return "current"
|
|
}
|
|
return "upcoming"
|
|
}
|
|
|
|
// An empty orientation is how the roving runtime is told to stay out of
|
|
// the way, so a read-only stepper never takes arrow-key focus.
|
|
shared function rovingAxis() {
|
|
if (!clickable) {
|
|
return ""
|
|
}
|
|
return orientation === "vertical" ? "vertical" : "horizontal"
|
|
}
|
|
|
|
shared function isActiveStep(index) {
|
|
return index === activeIndex()
|
|
}
|
|
|
|
shared function lastIndex() {
|
|
return Math.max(0, stepList().length - 1)
|
|
}
|
|
|
|
shared function onLastStep() {
|
|
return activeIndex() >= lastIndex()
|
|
}
|
|
|
|
shared function activeStep() {
|
|
var list = stepList()
|
|
return list.length ? list[activeIndex()] : {}
|
|
}
|
|
|
|
client function goBack() {
|
|
var target = Math.max(0, activeIndex() - 1)
|
|
output.back({ index: target, step: stepList()[target] || {} })
|
|
output.change({ index: target, step: stepList()[target] || {} })
|
|
}
|
|
|
|
client function goNext() {
|
|
if (nextDisabled) {
|
|
return
|
|
}
|
|
if (onLastStep()) {
|
|
output.finish({ index: activeIndex(), step: activeStep() })
|
|
return
|
|
}
|
|
var target = Math.min(lastIndex(), activeIndex() + 1)
|
|
output.next({ index: target, step: stepList()[target] || {} })
|
|
output.change({ index: target, step: stepList()[target] || {} })
|
|
}
|
|
|
|
client function goSkip() {
|
|
var target = Math.min(lastIndex(), activeIndex() + 1)
|
|
output.skip({ index: target, step: stepList()[target] || {} })
|
|
output.change({ index: target, step: stepList()[target] || {} })
|
|
}
|
|
|
|
client function selectStep(index, step) {
|
|
if (!clickable) {
|
|
return
|
|
}
|
|
output.change({ index: index, step: step })
|
|
}
|
|
}
|
|
|
|
view {
|
|
<div
|
|
{...attrs}
|
|
data-ui-component="Stepper"
|
|
class='wrn-stepper {class}'
|
|
data-orientation='{orientation}'
|
|
data-color='{color}'
|
|
data-size='{size}'
|
|
data-clickable='{clickable}'
|
|
>
|
|
<ol
|
|
class="wrn-stepper__list"
|
|
data-wrn-roving='{rovingAxis()}'
|
|
aria-label='{label}'
|
|
>
|
|
{#each stepList() as step, index}
|
|
<li
|
|
class="wrn-stepper__step"
|
|
data-status='{statusFor(index)}'
|
|
aria-current='{statusFor(index) === "current" ? "step" : "false"}'
|
|
>
|
|
<button
|
|
type="button"
|
|
class="wrn-stepper__button"
|
|
data-wrn-roving-item='{clickable ? "true" : "false"}'
|
|
@click='selectStep(index, step)'
|
|
>
|
|
<span class="wrn-stepper__marker" aria-hidden="true">
|
|
<span class='wrn-stepper__icon {step.icon}' data-show="step.icon"></span>
|
|
<span class="wrn-stepper__number" data-show="!step.icon">{index + 1}</span>
|
|
</span>
|
|
<span class="wrn-stepper__body">
|
|
<span class="wrn-stepper__label">{step.label}</span>
|
|
<span class="wrn-stepper__description" data-show="step.description">
|
|
{step.description}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
<span class="wrn-stepper__custom">
|
|
<slot name="step-{index}"></slot>
|
|
</span>
|
|
</li>
|
|
{/each}
|
|
</ol>
|
|
|
|
<div class="wrn-stepper__panels" data-show="showPanel">
|
|
{#each stepList() as step, index}
|
|
<div
|
|
class="wrn-stepper__panel"
|
|
data-show='isActiveStep(index)'
|
|
aria-hidden='{index === activeIndex() ? "false" : "true"}'
|
|
>
|
|
<h4 class="wrn-stepper__panel-title" data-show="step.title">{step.title}</h4>
|
|
<p class="wrn-stepper__panel-body" data-show="step.content">{step.content}</p>
|
|
<slot name="panel-{index}"></slot>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="wrn-stepper__controls" data-show="controls">
|
|
<button
|
|
type="button"
|
|
class="wrn-stepper__button-control wrn-stepper__back"
|
|
disabled='{activeIndex() === 0}'
|
|
@click='goBack()'
|
|
>
|
|
{backLabel}
|
|
</button>
|
|
<span class="wrn-stepper__controls-spacer"></span>
|
|
<button
|
|
type="button"
|
|
class="wrn-stepper__button-control wrn-stepper__skip"
|
|
data-show="allowSkip && !onLastStep()"
|
|
@click='goSkip()'
|
|
>
|
|
{skipLabel}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="wrn-stepper__button-control wrn-stepper__next"
|
|
data-primary="true"
|
|
disabled='{nextDisabled}'
|
|
@click='goNext()'
|
|
>
|
|
{onLastStep() ? finishLabel : nextLabel}
|
|
</button>
|
|
</div>
|
|
|
|
<slot />
|
|
</div>
|
|
}
|
|
|
|
style {
|
|
.wrn-stepper {
|
|
--stepper-accent: var(--wrn-color-primary);
|
|
max-width: 100%;
|
|
}
|
|
|
|
.wrn-stepper__list {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin: 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.wrn-stepper[data-color="secondary"] {
|
|
--stepper-accent: var(--wrn-color-secondary);
|
|
}
|
|
|
|
.wrn-stepper[data-color="success"] {
|
|
--stepper-accent: var(--wrn-color-success);
|
|
}
|
|
|
|
.wrn-stepper[data-color="danger"] {
|
|
--stepper-accent: var(--wrn-color-danger);
|
|
}
|
|
|
|
.wrn-stepper[data-color="info"] {
|
|
--stepper-accent: var(--wrn-color-info);
|
|
}
|
|
|
|
.wrn-stepper[data-size="sm"] {
|
|
font-size: 0.82rem;
|
|
}
|
|
|
|
.wrn-stepper[data-size="lg"] {
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.wrn-stepper[data-orientation="vertical"] .wrn-stepper__list {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.wrn-stepper__step {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1 1 0;
|
|
min-width: 0;
|
|
gap: 0.35rem;
|
|
}
|
|
|
|
.wrn-stepper__button {
|
|
appearance: none;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
width: 100%;
|
|
padding: 0.5rem;
|
|
border: 0;
|
|
border-radius: var(--wrn-radius-sm);
|
|
background: transparent;
|
|
color: inherit;
|
|
font: inherit;
|
|
text-align: left;
|
|
cursor: default;
|
|
}
|
|
|
|
.wrn-stepper[data-clickable="true"] .wrn-stepper__button {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.wrn-stepper[data-clickable="true"] .wrn-stepper__button:hover {
|
|
background: var(--wrn-color-surface-soft);
|
|
}
|
|
|
|
.wrn-stepper__button:focus-visible {
|
|
outline: 2px solid var(--stepper-accent);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.wrn-stepper__marker {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex: 0 0 auto;
|
|
width: 2rem;
|
|
height: 2rem;
|
|
border: 1px solid var(--wrn-color-border);
|
|
border-radius: 999px;
|
|
background: var(--wrn-color-surface);
|
|
font-size: 0.85rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.wrn-stepper__step[data-status="complete"] .wrn-stepper__marker {
|
|
border-color: var(--stepper-accent);
|
|
background: var(--stepper-accent);
|
|
color: var(--wrn-color-primary-contrast);
|
|
}
|
|
|
|
.wrn-stepper__step[data-status="current"] .wrn-stepper__marker {
|
|
border-color: var(--stepper-accent);
|
|
color: var(--stepper-accent);
|
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--stepper-accent) 22%, transparent);
|
|
}
|
|
|
|
.wrn-stepper__step[data-status="upcoming"] .wrn-stepper__marker {
|
|
color: var(--wrn-color-text-muted);
|
|
}
|
|
|
|
.wrn-stepper__body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
}
|
|
|
|
.wrn-stepper__label {
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.wrn-stepper__description {
|
|
color: var(--wrn-color-text-muted);
|
|
font-size: 0.78rem;
|
|
}
|
|
|
|
.wrn-stepper__step[data-status="upcoming"] .wrn-stepper__label {
|
|
color: var(--wrn-color-text-muted);
|
|
}
|
|
|
|
.wrn-stepper__panels {
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.wrn-stepper__panel {
|
|
padding: 1rem;
|
|
border: 1px solid var(--wrn-color-border);
|
|
border-radius: var(--wrn-radius-md);
|
|
background: var(--wrn-color-surface);
|
|
animation: wrn-stepper-in 200ms ease both;
|
|
}
|
|
|
|
.wrn-stepper__panel-title {
|
|
margin: 0 0 0.35rem;
|
|
font-size: 0.95rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.wrn-stepper__panel-body {
|
|
margin: 0;
|
|
color: var(--wrn-color-text-muted);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.wrn-stepper__controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin-top: 0.85rem;
|
|
}
|
|
|
|
.wrn-stepper__controls-spacer {
|
|
flex: 1 1 auto;
|
|
}
|
|
|
|
.wrn-stepper__button-control {
|
|
appearance: none;
|
|
padding: 0.45rem 0.9rem;
|
|
border: 1px solid var(--wrn-color-border);
|
|
border-radius: var(--wrn-radius-sm);
|
|
background: var(--wrn-color-surface);
|
|
color: var(--wrn-color-text);
|
|
font: inherit;
|
|
font-size: 0.86rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.wrn-stepper__button-control:hover:not(:disabled) {
|
|
background: var(--wrn-color-surface-soft);
|
|
}
|
|
|
|
.wrn-stepper__button-control:focus-visible {
|
|
outline: 2px solid var(--stepper-accent);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.wrn-stepper__button-control[data-primary="true"] {
|
|
border-color: var(--stepper-accent);
|
|
background: var(--stepper-accent);
|
|
color: var(--wrn-color-primary-contrast);
|
|
}
|
|
|
|
/* A held step has to look held, or the button reads as broken. */
|
|
.wrn-stepper__button-control:disabled {
|
|
opacity: 0.45;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
@keyframes wrn-stepper-in {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateX(0.75rem);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: none;
|
|
}
|
|
}
|
|
|
|
/* A horizontal stepper cannot stay side by side on a phone. */
|
|
@media (max-width: 639px) {
|
|
.wrn-stepper__list {
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
}
|
|
}
|