Files
WRNexusJS/packages/ui/components/Timeline.wrn
T
ClintchizandClaude Opus 5 a154e6c26e Keep a loop item's property from being read as a prop of the same name
A component that renders a list, such as FeatureGrid, passes each item
into a child: title={item.title || item.label || "Feature"}. The
compiler decided whether that attribute needed a client binding by
searching the expression for each prop name, so `item.label` matched the
grid's own `label` prop and a binding was emitted.

On hydration the binding is re-evaluated where the loop item is out of
scope, so the expression fell through to its default and replaced the
server's value: every card's title became "Feature". A name after a dot
belongs to whatever precedes it, so it no longer counts as a reference.

Timeline headings follow the same idea: its items were always h4, which
skipped a level and broke the heading order of a page that gives the
timeline no title of its own. They now take h3 in that case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:09:28 +05:30

146 lines
7.2 KiB
Plaintext

component Timeline {
outputs {
select(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
props {
size: string = "default"
color: string = "primary"
title: string = "Timeline"
description: string = ""
items: unknown[] = []
variant: string = "default"
numbered: boolean = false
class: string = ""
}
view {
<section
data-ui-component="Timeline"
aria-label='{title}'
class='wrn-timeline {class}'
data-size='{size}'
data-color='{color}'
data-variant='{variant}'
>
{#if title || description}
<header class="wrn-timeline__header">
{#if title}
<h3 class="wrn-timeline__title">{title}</h3>
{/if}
{#if description}
<p class="wrn-timeline__description">{description}</p>
{/if}
</header>
{/if}
<ol class="wrn-timeline__list">
{#each items as item, index}
<li
class="wrn-timeline__item"
data-interactive='{item.href ? "true" : "false"}'
@click='output.select({ item: item, index: index })'
>
<span
class="wrn-timeline__marker"
data-status='{item.status || "default"}'
>
{#if numbered}
<span class="wrn-timeline__number" aria-hidden="true">{index + 1}</span>
{:else}
<span class='{item.icon || "icon-[lucide--circle-check]"}' aria-hidden="true"></span>
{/if}
</span>
<article
class="wrn-timeline__card"
data-variant='{variant}'
>
<div class="wrn-timeline__card-header">
<div>
<!--
Items sit under this component's own title when it has one.
Without that title they are the first heading here, so they
take its level instead of skipping one and leaving a gap in
the page's heading order.
-->
{#if title}
<h4 class="wrn-timeline__item-title">{item.title || item.label}</h4>
{:else}
<h3 class="wrn-timeline__item-title">{item.title || item.label}</h3>
{/if}
{#if item.subtitle}
<p class="wrn-timeline__subtitle">{item.subtitle}</p>
{/if}
</div>
{#if item.date || item.meta}
<time class="wrn-timeline__time">{item.date || item.meta}</time>
{/if}
</div>
{#if item.description}
<p class="wrn-timeline__copy">{item.description}</p>
{/if}
{#if item.href}
<a href='{item.href}' class="wrn-timeline__action">
<span>{item.actionLabel || "View details"}</span>
<span class="icon-[lucide--arrow-right] wrn-timeline__action-icon" aria-hidden="true"></span>
</a>
{/if}
</article>
</li>
{:empty}
<li class="wrn-timeline__empty">No timeline items available.</li>
{/each}
</ol>
<slot></slot>
</section>
}
style {
.wrn-timeline { --wrn-timeline-accent: var(--wrn-color-primary); width: 100%; min-width: 0; color: var(--wrn-color-text); }
.wrn-timeline[data-color="success"] { --wrn-timeline-accent: var(--wrn-color-success); }
.wrn-timeline[data-color="info"] { --wrn-timeline-accent: var(--wrn-color-info); }
.wrn-timeline__header { margin-bottom: 1.5rem; }
.wrn-timeline__title { color: var(--wrn-color-text); font-size: 1.25rem; font-weight: 700; }
.wrn-timeline__description { max-width: 42rem; margin-top: 0.5rem; color: var(--wrn-color-text-muted); line-height: 1.75rem; }
.wrn-timeline__list { position: relative; display: grid; gap: 1rem; margin: 0; padding: 0 0 0 3rem; list-style: none; }
.wrn-timeline__list::before { position: absolute; inset-block: 1rem; inset-inline-start: 1rem; width: 2px; border-radius: 999px; background: color-mix(in srgb, var(--wrn-timeline-accent) 30%, var(--wrn-color-border)); content: ""; }
.wrn-timeline__item { position: relative; min-width: 0; }
.wrn-timeline__item:last-child { padding-bottom: 0; }
.wrn-timeline__marker {
position: absolute; z-index: 1; top: 1rem; inset-inline-start: -3rem; display: flex; width: 2rem; height: 2rem;
align-items: center; justify-content: center; border: 4px solid var(--wrn-color-background);
border-radius: 50%; color: var(--wrn-color-on-primary); background: var(--wrn-timeline-accent); box-shadow: var(--wrn-shadow-1);
}
.wrn-timeline__marker:is([data-status="complete"], [data-status="success"]) { background: var(--wrn-color-success); }
.wrn-timeline__marker:is([data-status="warning"], [data-status="pending"]) { background: var(--wrn-color-warning); }
.wrn-timeline__marker:is([data-status="danger"], [data-status="failed"]) { background: var(--wrn-color-danger); }
.wrn-timeline__number { font-size: 0.75rem; font-weight: 800; }
.wrn-timeline__card { min-width: 0; padding: 1.25rem; border: 1px solid var(--wrn-color-border); border-radius: calc(var(--wrn-radius) * 1.5); background: var(--wrn-color-surface-raised); box-shadow: var(--wrn-shadow-1); transition: border-color 160ms ease, transform 160ms ease, box-shadow 160ms ease; }
.wrn-timeline__item[data-interactive="true"] { cursor: pointer; }
.wrn-timeline__item[data-interactive="true"]:hover .wrn-timeline__card { border-color: color-mix(in srgb, var(--wrn-timeline-accent) 45%, var(--wrn-color-border)); transform: translateY(-2px); box-shadow: var(--wrn-shadow-2); }
.wrn-timeline__card[data-variant="minimal"] { padding: 0; border-color: transparent; background: transparent; box-shadow: none; }
.wrn-timeline__card-header { display: flex; flex-direction: column; gap: 0.5rem; }
.wrn-timeline__item-title { color: var(--wrn-color-text); font-weight: 700; }
.wrn-timeline__subtitle { margin-top: 0.25rem; color: var(--wrn-color-primary); font-size: 0.875rem; font-weight: 500; }
.wrn-timeline__time { flex: none; color: var(--wrn-color-text-muted); font-size: 0.875rem; }
.wrn-timeline__copy { margin-top: 0.75rem; color: var(--wrn-color-text-muted); font-size: 0.875rem; line-height: 1.5rem; }
.wrn-timeline__action { display: inline-flex; align-items: center; gap: 0.5rem; margin-top: 1rem; color: var(--wrn-color-primary); font-size: 0.875rem; font-weight: 600; }
.wrn-timeline__action:hover { color: var(--wrn-color-primary-hover); }
.wrn-timeline__action-icon { width: 1rem; height: 1rem; }
.wrn-timeline__empty { padding-left: 2rem; color: var(--wrn-color-text-muted); font-size: 0.875rem; }
@media (min-width: 40rem) {
.wrn-timeline__card-header { flex-direction: row; align-items: flex-start; justify-content: space-between; }
}
@media (max-width: 39.99rem) {
.wrn-timeline__list { padding-inline-start: 2.5rem; }
.wrn-timeline__list::before { inset-inline-start: 0.75rem; }
.wrn-timeline__marker { inset-inline-start: -2.5rem; }
.wrn-timeline__card { padding: 1rem; }
}
}
}