// Scrollspy -- a table of contents that follows the reader. // // // // 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 = "" sticky: boolean = false topOffset: string = "6rem" sectionOffset: string = "7rem" collapseAt: string = "800px" class: string = "" } functions { shared function itemList() { return Array.isArray(items) ? items : [] } shared function isActive(item) { return Boolean(item.href) && item.href === active } } view { } style { .wrn-scrollspy { --scrollspy-accent: var(--wrn-color-primary); max-width: 100%; } .wrn-scrollspy[data-sticky="true"] { position: sticky; top: var(--wrn-scrollspy-top); height: max-content; } .wrn-scrollspy__link { scroll-margin-top: var(--wrn-scrollspy-section-offset); } .wrn-scrollspy:not(:has(.wrn-scrollspy__item)):not(:has(slot > *)) { display: none; } .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[data-collapse-at="800px"] { position: static; } .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); } } } }