Files
WRNexusJS/packages/ui/components/Scrollspy.wrn
ClintchizandClaude Opus 5 52660cbb8e feat(ui): build Scrollspy, fix aria-current across the navigation group
Scrollspy replaces a scaffold that rendered bare anchors. The runtime observes
the sections the links point at and writes the marker straight onto the links:
an IntersectionObserver callback fires long after the client function that
registered it returned, so a state write there would be dropped.

Navbar and Breadcrumb both emitted aria-current="" for every inactive link.
That is not a valid value -- the attribute takes a token or must be absent --
so every link claimed a state it did not have. Breadcrumb had it too, despite
being the strongest component in the group.

Navbar also takes roving arrow-key focus across its menu bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:18:25 +05:30

173 lines
4.4 KiB
Plaintext

// Scrollspy -- a table of contents that follows the reader.
//
// <Scrollspy items='[{"label":"Overview","href":"#overview"}]' />
//
// Each href points at an element on the page. The runtime observes those
// elements and moves aria-current to the link for whichever one is in view.
//
// The runtime writes the marker straight onto the links rather than into
// component state. An IntersectionObserver callback fires long after the
// client function that registered it has returned, and a state write made
// there is dropped -- so the DOM is the only place the answer can live.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
component Scrollspy {
outputs {
change(payload: { href: string; label: string })
}
props {
color: string = "primary"
size: string = "default"
items: unknown[] = []
active: string = ""
label: string = "On this page"
heading: string = ""
class: string = ""
}
functions {
shared function itemList() {
return Array.isArray(items) ? items : []
}
shared function isActive(item) {
return Boolean(item.href) && item.href === active
}
}
view {
<nav
{...attrs}
data-ui-component="Scrollspy"
class='wire-scrollspy {class}'
data-color='{color}'
data-size='{size}'
data-wrn-scrollspy="true"
role="navigation"
aria-label='{label}'
>
<p class="wire-scrollspy__heading" data-show="heading">{heading}</p>
<ul class="wire-scrollspy__list">
{#each itemList() as item}
<li class="wire-scrollspy__item">
<a
class="wire-scrollspy__link"
href='{item.href || "#"}'
data-active='{isActive(item)}'
aria-current='{isActive(item) ? "location" : "false"}'
>
<span class="wire-scrollspy__label">{item.label}</span>
</a>
</li>
{/each}
</ul>
<slot />
</nav>
}
style {
.wire-scrollspy {
--scrollspy-accent: var(--wire-color-primary);
max-width: 100%;
}
.wire-scrollspy[data-color="secondary"] {
--scrollspy-accent: var(--wire-color-secondary);
}
.wire-scrollspy[data-color="success"] {
--scrollspy-accent: var(--wire-color-success);
}
.wire-scrollspy[data-color="danger"] {
--scrollspy-accent: var(--wire-color-danger);
}
.wire-scrollspy[data-color="info"] {
--scrollspy-accent: var(--wire-color-info);
}
.wire-scrollspy[data-size="sm"] {
font-size: 0.82rem;
}
.wire-scrollspy[data-size="lg"] {
font-size: 1rem;
}
.wire-scrollspy__heading {
margin: 0 0 0.5rem;
color: var(--wire-color-text-muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.wire-scrollspy__list {
display: grid;
gap: 0.1rem;
margin: 0;
padding: 0;
list-style: none;
border-left: 1px solid var(--wire-color-border);
}
.wire-scrollspy__link {
display: block;
padding: 0.3rem 0.75rem;
margin-left: -1px;
border-left: 2px solid transparent;
color: var(--wire-color-text-muted);
font-size: 0.85rem;
text-decoration: none;
}
.wire-scrollspy__link:hover {
color: var(--wire-color-text);
}
.wire-scrollspy__link:focus-visible {
outline: 2px solid var(--scrollspy-accent);
outline-offset: -2px;
}
.wire-scrollspy__link[data-active="true"] {
border-left-color: var(--scrollspy-accent);
color: var(--scrollspy-accent);
font-weight: 600;
}
/*
* A table of contents is a sidebar affordance. On a phone it stops being
* a rail and becomes a horizontal strip that scrolls, so it costs one
* line rather than a screenful.
*/
@media (max-width: 767px) {
.wire-scrollspy__list {
grid-auto-flow: column;
grid-auto-columns: max-content;
overflow-x: auto;
border-left: 0;
border-bottom: 1px solid var(--wire-color-border);
}
.wire-scrollspy__link {
margin-left: 0;
border-left: 0;
border-bottom: 2px solid transparent;
white-space: nowrap;
}
.wire-scrollspy__link[data-active="true"] {
border-left-color: transparent;
border-bottom-color: var(--scrollspy-accent);
}
}
}
}