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>
This commit is contained in:
@@ -1,21 +1,172 @@
|
||||
// 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: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
|
||||
change(payload: { href: string; label: string })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
label: string = "Scrollspy"
|
||||
size: string = "default"
|
||||
items: unknown[] = []
|
||||
active: string = ""
|
||||
orientation: string = "horizontal"
|
||||
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 class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--scrollspy wire-next--{orientation} {class}" aria-label="{label}">
|
||||
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each}
|
||||
<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user