Files
WRNexusJS/packages/ui/components/Scrollspy.wrn
T
ClintchizandClaude Opus 5.5 1bed8e4b74 feat(ui): component redesign pass and fixes, release ui 0.8.87
Redesign
- Shared field foundation rules (duplicated across 15 form components and
  emitted globally) are now identical everywhere, so later copies no longer
  silently override earlier redesigns.
- Drawn checkbox and radio controls, white switch thumbs (the thumb was the
  text colour, a black blob in light mode), recessed field wells, keycap
  actions in Modal, Drawer, Popover, Toaster, Pagination, Stepper, DataTable.
- Menus (Dropdown, ContextMenu, Popover, Select dropdown, Navbar panels) share
  one flat raised panel with neutral hover rows and mono section labels.
- Alerts with an accent rail and icon chip; card headers without the grey
  band; accordion, collapse, list, list group, timeline, stats, metrics,
  chat, skeleton, avatar group, clipboard, splitter, section, sidebar,
  navbar and upload progress restyled.
- Chart rebuilt: nice y-axis ticks, gridlines, rounded bars, line and area
  modes, hover tooltips, hidden series leave the scale.

Fixes
- Checkbox and radio labels toggle their input again (empty for="" broke it).
- TimePicker kept every clicked time pressed; only the chosen hour and
  minute are pressed now, and nothing is pressed before a value exists.
- ColorPicker hex readout now follows the picked colour.
- Disabled FileInput no longer shows the native file-input text.
- Date and time panels float instead of pushing content.
- ContextMenu opened at the pointer closes on scroll.
- Drawer left/top/bottom placements slide from their own edge again.
- Image-only Card no longer renders an empty body strip.
- Timeline supports item.time, current/upcoming states, sizes and
  compact/alternate variants; showcase demos use distinct variants.
- ChatBubble: delivery receipts (sending/sent/delivered/read/failed/error)
  and meta aligned to its bubble.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:18:13 +05:30

186 lines
5.1 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 = ""
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 {
<nav
{...attrs}
data-ui-component="Scrollspy"
class='wrn-scrollspy {class}'
data-color='{color}'
data-size='{size}'
data-sticky='{sticky ? "true" : "false"}' data-collapse-at='{collapseAt}' style='--wrn-scrollspy-top: {topOffset}; --wrn-scrollspy-section-offset: {sectionOffset};'
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-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-subtle, var(--wrn-color-text-muted));
font-family: var(--wrn-font-mono, monospace);
font-size: 0.65rem;
font-weight: 600;
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(--wrn-color-text);
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);
}
}
}
}