Files
WRNexusJS/packages/ui/components/Scrollspy.wrn
T
Clintchiz 2c960fc1dc
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s
refactor: migrate legacy wire namespace to wrn
2026-08-12 18:51:15 +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='wrn-scrollspy {class}'
data-color='{color}'
data-size='{size}'
data-wrn-scrollspy="true"
role="navigation"
aria-label='{label}'
>
<p class="wrn-scrollspy__heading" data-show="heading">{heading}</p>
<ul class="wrn-scrollspy__list">
{#each itemList() as item}
<li class="wrn-scrollspy__item">
<a
class="wrn-scrollspy__link"
href='{item.href || "#"}'
data-active='{isActive(item)}'
aria-current='{isActive(item) ? "location" : "false"}'
>
<span class="wrn-scrollspy__label">{item.label}</span>
</a>
</li>
{/each}
</ul>
<slot />
</nav>
}
style {
.wrn-scrollspy {
--scrollspy-accent: var(--wrn-color-primary);
max-width: 100%;
}
.wrn-scrollspy[data-color="secondary"] {
--scrollspy-accent: var(--wrn-color-secondary);
}
.wrn-scrollspy[data-color="success"] {
--scrollspy-accent: var(--wrn-color-success);
}
.wrn-scrollspy[data-color="danger"] {
--scrollspy-accent: var(--wrn-color-danger);
}
.wrn-scrollspy[data-color="info"] {
--scrollspy-accent: var(--wrn-color-info);
}
.wrn-scrollspy[data-size="sm"] {
font-size: 0.82rem;
}
.wrn-scrollspy[data-size="lg"] {
font-size: 1rem;
}
.wrn-scrollspy__heading {
margin: 0 0 0.5rem;
color: var(--wrn-color-text-muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.wrn-scrollspy__list {
display: grid;
gap: 0.1rem;
margin: 0;
padding: 0;
list-style: none;
border-left: 1px solid var(--wrn-color-border);
}
.wrn-scrollspy__link {
display: block;
padding: 0.3rem 0.75rem;
margin-left: -1px;
border-left: 2px solid transparent;
color: var(--wrn-color-text-muted);
font-size: 0.85rem;
text-decoration: none;
}
.wrn-scrollspy__link:hover {
color: var(--wrn-color-text);
}
.wrn-scrollspy__link:focus-visible {
outline: 2px solid var(--scrollspy-accent);
outline-offset: -2px;
}
.wrn-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) {
.wrn-scrollspy__list {
grid-auto-flow: column;
grid-auto-columns: max-content;
overflow-x: auto;
border-left: 0;
border-bottom: 1px solid var(--wrn-color-border);
}
.wrn-scrollspy__link {
margin-left: 0;
border-left: 0;
border-bottom: 2px solid transparent;
white-space: nowrap;
}
.wrn-scrollspy__link[data-active="true"] {
border-left-color: transparent;
border-bottom-color: var(--scrollspy-accent);
}
}
}
}