173 lines
4.4 KiB
Plaintext
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);
|
|
}
|
|
}
|
|
}
|
|
}
|