merge: navigation components phase 3
Quality / quality (ubuntu-latest) (push) Failing after 12m47s
Quality / quality (windows-latest) (push) Canceled after 0s

Scrollspy built, Navbar given roving focus, and the invalid empty aria-current
fixed across Navbar and Breadcrumb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 19:22:10 +05:30
co-authored by Claude Opus 5
13 changed files with 443 additions and 430 deletions
+3 -3
View File
@@ -13,7 +13,7 @@
"packages/ui/components/BackToTop.wrn": "b12c56ec8b7aabad68cbdc47f4b3237e2c21a61398f0aa51f9add6229b7357cd", "packages/ui/components/BackToTop.wrn": "b12c56ec8b7aabad68cbdc47f4b3237e2c21a61398f0aa51f9add6229b7357cd",
"packages/ui/components/Badge.wrn": "6b55b4d85100d605cf168857aec1dc57da62c1f6a0a5cd6dd877023f24b189cc", "packages/ui/components/Badge.wrn": "6b55b4d85100d605cf168857aec1dc57da62c1f6a0a5cd6dd877023f24b189cc",
"packages/ui/components/Blockquote.wrn": "6016dd4450de7cbf5c3cb413599baa2e502321e8eedef54439a0df6d0aae8bcb", "packages/ui/components/Blockquote.wrn": "6016dd4450de7cbf5c3cb413599baa2e502321e8eedef54439a0df6d0aae8bcb",
"packages/ui/components/Breadcrumb.wrn": "8bf6239b95b382ce68f8411aac0d364f67a1c278dfbbf08f1501cd0482751e30", "packages/ui/components/Breadcrumb.wrn": "df27101d41cf018d55b6909e0399286a4661637140e341c8624615051d485142",
"packages/ui/components/ButtonGroup.wrn": "0d97fbeed531d1d8ef4b59531923b47b34100d0de71d7bf524b02c668920b3d8", "packages/ui/components/ButtonGroup.wrn": "0d97fbeed531d1d8ef4b59531923b47b34100d0de71d7bf524b02c668920b3d8",
"packages/ui/components/CTASection.wrn": "2d2241d4f1018cfd2f8ea1ddd5fa8c8fcb8d1bbb2d4e4498cb9f39ff47a3801c", "packages/ui/components/CTASection.wrn": "2d2241d4f1018cfd2f8ea1ddd5fa8c8fcb8d1bbb2d4e4498cb9f39ff47a3801c",
"packages/ui/components/Card.wrn": "4370df341235819b8a968a3f812209c2e6c29e4471d06578aaa5b577fb6e2dc8", "packages/ui/components/Card.wrn": "4370df341235819b8a968a3f812209c2e6c29e4471d06578aaa5b577fb6e2dc8",
@@ -66,7 +66,7 @@
"packages/ui/components/MetricGrid.wrn": "6018a98c10628ed240ed236ed916c0ff60994d192c3aadafd10bc876be0f9364", "packages/ui/components/MetricGrid.wrn": "6018a98c10628ed240ed236ed916c0ff60994d192c3aadafd10bc876be0f9364",
"packages/ui/components/Modal.wrn": "59d4ae9d6dd2700692d9edecfe53ee868e9f864363a4885961d1813cb2bee51f", "packages/ui/components/Modal.wrn": "59d4ae9d6dd2700692d9edecfe53ee868e9f864363a4885961d1813cb2bee51f",
"packages/ui/components/Nav.wrn": "7dc456b879e9248bde267b986cdcb138b8f3127d98b51e27bc9708129953f5e2", "packages/ui/components/Nav.wrn": "7dc456b879e9248bde267b986cdcb138b8f3127d98b51e27bc9708129953f5e2",
"packages/ui/components/Navbar.wrn": "e68f9d3643e500e43124e9c7a6d4c7f6722657377ea7313f3e3cf5093a81b360", "packages/ui/components/Navbar.wrn": "cf28cf4cdd23c85821d3302e6881115c2c14977e7cb7142d306cf559fea2a518",
"packages/ui/components/PageHeader.wrn": "0761235a4924eec09877be40b292d27b70db22d210be7d8e989493165c20df86", "packages/ui/components/PageHeader.wrn": "0761235a4924eec09877be40b292d27b70db22d210be7d8e989493165c20df86",
"packages/ui/components/Pagination.wrn": "9ceb74745b159150b015bbbb1974c68e14a7d4b92bd03491cb23b8855aa07983", "packages/ui/components/Pagination.wrn": "9ceb74745b159150b015bbbb1974c68e14a7d4b92bd03491cb23b8855aa07983",
"packages/ui/components/PinInput.wrn": "5196f584de8d548a5dfa03688c3c95da3c948cead2662b05e927386ccea74299", "packages/ui/components/PinInput.wrn": "5196f584de8d548a5dfa03688c3c95da3c948cead2662b05e927386ccea74299",
@@ -77,7 +77,7 @@
"packages/ui/components/Radio.wrn": "0b2d38a5aee3e859280e4590fa1d509d789cc2239082054872fa342817735f1b", "packages/ui/components/Radio.wrn": "0b2d38a5aee3e859280e4590fa1d509d789cc2239082054872fa342817735f1b",
"packages/ui/components/RangeSlider.wrn": "21eb7a5df0ee2cb5e78f628eb920265998eb9f1a4933d4e5c57db0323fd66b41", "packages/ui/components/RangeSlider.wrn": "21eb7a5df0ee2cb5e78f628eb920265998eb9f1a4933d4e5c57db0323fd66b41",
"packages/ui/components/Rating.wrn": "a2d74dc748fc7d98892684c760518b87fa65411b1102e3611252b87245b3ffcb", "packages/ui/components/Rating.wrn": "a2d74dc748fc7d98892684c760518b87fa65411b1102e3611252b87245b3ffcb",
"packages/ui/components/Scrollspy.wrn": "bbe0788f63c2bc8850649c1dee78391d485a1c93876015bc536d838273d5fae4", "packages/ui/components/Scrollspy.wrn": "76d1c17580c8d460f1222ce97585bdcac9779141f2f30d5460c84ff9f75413d8",
"packages/ui/components/SearchBox.wrn": "315f54f1feaaa47a815d1e2d7a35b492f09fbfadfc6d37228e1009e19e0652ad", "packages/ui/components/SearchBox.wrn": "315f54f1feaaa47a815d1e2d7a35b492f09fbfadfc6d37228e1009e19e0652ad",
"packages/ui/components/Section.wrn": "21485de07d8bb986837a65c61c5dd01ba6c2f3d4e37d58c154a584c7b58de0ed", "packages/ui/components/Section.wrn": "21485de07d8bb986837a65c61c5dd01ba6c2f3d4e37d58c154a584c7b58de0ed",
"packages/ui/components/SectionHeader.wrn": "512cb96636ee1f0540a0f4b4c75603fc0fe7536e7ed7ed7dc82173a16f48a4d3", "packages/ui/components/SectionHeader.wrn": "512cb96636ee1f0540a0f4b4c75603fc0fe7536e7ed7ed7dc82173a16f48a4d3",
@@ -1,12 +1,12 @@
// Generated by scripts/generate-showcase.mjs. Do not edit directly. // Generated by scripts/generate-showcase.mjs. Do not edit directly.
page ScrollspyDetail { page ScrollspyDetail {
layout = "showcase" layout = "showcase"
state playground_size = ctx.url.searchParams.get("pg_size") ?? "default"
state playground_color = ctx.url.searchParams.get("pg_color") ?? "primary" state playground_color = ctx.url.searchParams.get("pg_color") ?? "primary"
state playground_label = ctx.url.searchParams.get("pg_label") ?? "Scrollspy" state playground_size = ctx.url.searchParams.get("pg_size") ?? "default"
state playground_items = JSON.parse(ctx.url.searchParams.get("pg_items") ?? "[{\"id\":\"scrollspy-0-1\",\"key\":\"scrollspy-0-1\",\"value\":\"primary\",\"label\":\"Primary workflow\",\"title\":\"Primary workflow\",\"description\":\"A clean default configuration for everyday product interfaces.\",\"text\":\"A configurable sample message.\",\"href\":\"#scrollspy-demo\",\"actionHref\":\"#scrollspy-demo\",\"actionLabel\":\"View details\",\"icon\":\"icon-[lucide--sparkles]\",\"iconClass\":\"icon-[lucide--sparkles]\",\"variant\":\"primary\",\"status\":\"Active\",\"time\":\"09:30\",\"current\":true,\"selected\":true,\"checked\":true,\"disabled\":false,\"outgoing\":false,\"open\":true,\"number\":1,\"count\":16,\"percentage\":72,\"color\":\"#7c3aed\",\"target\":\"_self\",\"ariaLabel\":\"Open primary workflow 1\",\"items\":[{\"label\":\"Nested option A\",\"value\":\"nested-a\"},{\"label\":\"Nested option B\",\"value\":\"nested-b\"}],\"links\":[{\"label\":\"Documentation\",\"href\":\"#documentation\"},{\"label\":\"API reference\",\"href\":\"#api-reference\"}],\"values\":[\"Included\",\"Standard\"]},{\"id\":\"scrollspy-0-2\",\"key\":\"scrollspy-0-2\",\"value\":\"secondary\",\"label\":\"Additional option\",\"title\":\"Supporting example\",\"description\":\"A clean default configuration for everyday product interfaces.\",\"text\":\"Another configurable message.\",\"href\":\"#scrollspy-demo\",\"actionHref\":\"#scrollspy-demo\",\"actionLabel\":\"View details\",\"icon\":\"icon-[lucide--sparkles]\",\"iconClass\":\"icon-[lucide--sparkles]\",\"variant\":\"primary\",\"status\":\"Active\",\"time\":\"09:30\",\"current\":false,\"selected\":false,\"checked\":false,\"disabled\":false,\"outgoing\":true,\"open\":true,\"number\":2,\"count\":32,\"percentage\":72,\"color\":\"#7c3aed\",\"target\":\"_self\",\"ariaLabel\":\"Open primary workflow 2\",\"items\":[{\"label\":\"Nested option A\",\"value\":\"nested-a\"},{\"label\":\"Nested option B\",\"value\":\"nested-b\"}],\"links\":[{\"label\":\"Documentation\",\"href\":\"#documentation\"},{\"label\":\"API reference\",\"href\":\"#api-reference\"}],\"values\":[\"Included\",\"Standard\"]}]") state playground_items = JSON.parse(ctx.url.searchParams.get("pg_items") ?? "[{\"id\":\"scrollspy-0-1\",\"key\":\"scrollspy-0-1\",\"value\":\"primary\",\"label\":\"Primary workflow\",\"title\":\"Primary workflow\",\"description\":\"A clean default configuration for everyday product interfaces.\",\"text\":\"A configurable sample message.\",\"href\":\"#scrollspy-demo\",\"actionHref\":\"#scrollspy-demo\",\"actionLabel\":\"View details\",\"icon\":\"icon-[lucide--sparkles]\",\"iconClass\":\"icon-[lucide--sparkles]\",\"variant\":\"primary\",\"status\":\"Active\",\"time\":\"09:30\",\"current\":true,\"selected\":true,\"checked\":true,\"disabled\":false,\"outgoing\":false,\"open\":true,\"number\":1,\"count\":16,\"percentage\":72,\"color\":\"#7c3aed\",\"target\":\"_self\",\"ariaLabel\":\"Open primary workflow 1\",\"items\":[{\"label\":\"Nested option A\",\"value\":\"nested-a\"},{\"label\":\"Nested option B\",\"value\":\"nested-b\"}],\"links\":[{\"label\":\"Documentation\",\"href\":\"#documentation\"},{\"label\":\"API reference\",\"href\":\"#api-reference\"}],\"values\":[\"Included\",\"Standard\"]},{\"id\":\"scrollspy-0-2\",\"key\":\"scrollspy-0-2\",\"value\":\"secondary\",\"label\":\"Additional option\",\"title\":\"Supporting example\",\"description\":\"A clean default configuration for everyday product interfaces.\",\"text\":\"Another configurable message.\",\"href\":\"#scrollspy-demo\",\"actionHref\":\"#scrollspy-demo\",\"actionLabel\":\"View details\",\"icon\":\"icon-[lucide--sparkles]\",\"iconClass\":\"icon-[lucide--sparkles]\",\"variant\":\"primary\",\"status\":\"Active\",\"time\":\"09:30\",\"current\":false,\"selected\":false,\"checked\":false,\"disabled\":false,\"outgoing\":true,\"open\":true,\"number\":2,\"count\":32,\"percentage\":72,\"color\":\"#7c3aed\",\"target\":\"_self\",\"ariaLabel\":\"Open primary workflow 2\",\"items\":[{\"label\":\"Nested option A\",\"value\":\"nested-a\"},{\"label\":\"Nested option B\",\"value\":\"nested-b\"}],\"links\":[{\"label\":\"Documentation\",\"href\":\"#documentation\"},{\"label\":\"API reference\",\"href\":\"#api-reference\"}],\"values\":[\"Included\",\"Standard\"]}]")
state playground_active = ctx.url.searchParams.get("pg_active") ?? "" state playground_active = ctx.url.searchParams.get("pg_active") ?? ""
state playground_orientation = ctx.url.searchParams.get("pg_orientation") ?? "horizontal" state playground_label = ctx.url.searchParams.get("pg_label") ?? "Scrollspy"
state playground_heading = ctx.url.searchParams.get("pg_heading") ?? ""
state playground_class = ctx.url.searchParams.get("pg_class") ?? "showcase-instance showcase-instance--1" state playground_class = ctx.url.searchParams.get("pg_class") ?? "showcase-instance showcase-instance--1"
seo { seo {
title = "Scrollspy" title = "Scrollspy"
@@ -30,7 +30,7 @@ page ScrollspyDetail {
<div class="playground-workbench"> <div class="playground-workbench">
<div class="playground-preview"> <div class="playground-preview">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div> <div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="playground-preview-source" data-playground-preview><Scrollspy size='{playground_size}' color='{playground_color}' label='{playground_label}' items='{playground_items}' active='{playground_active}' orientation='{playground_orientation}' class='{playground_class}' /></div> <div class="playground-preview-source" data-playground-preview><Scrollspy color='{playground_color}' size='{playground_size}' items='{playground_items}' active='{playground_active}' label='{playground_label}' heading='{playground_heading}' class='{playground_class}' /></div>
<section class="playground-code" aria-label="Current component code"> <section class="playground-code" aria-label="Current component code">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component code</span><button type="button" data-playground-copy><span class="icon-[lucide--copy] size-4" aria-hidden="true"></span>Copy</button></header> <header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component code</span><button type="button" data-playground-copy><span class="icon-[lucide--copy] size-4" aria-hidden="true"></span>Copy</button></header>
<pre><code data-playground-code>&lt;Scrollspy <pre><code data-playground-code>&lt;Scrollspy
@@ -142,6 +142,7 @@ page ScrollspyDetail {
] ]
<span>&#125;</span> <span>&#125;</span>
]' ]'
label="Scrollspy"
/&gt;</code></pre> /&gt;</code></pre>
</section> </section>
<div class="playground-status" data-playground-status aria-live="polite"></div> <div class="playground-status" data-playground-status aria-live="polite"></div>
@@ -149,7 +150,7 @@ page ScrollspyDetail {
</div> </div>
<form class="playground-controls" data-playground-form> <form class="playground-controls" data-playground-form>
<header><div><span>Component props</span><strong>7 controls</strong></div><button type="reset"><span class="icon-[lucide--rotate-ccw] size-4" aria-hidden="true"></span>Reset</button></header> <header><div><span>Component props</span><strong>7 controls</strong></div><button type="reset"><span class="icon-[lucide--rotate-ccw] size-4" aria-hidden="true"></span>Reset</button></header>
<div class="playground-fields"><label class="playground-field" for="playground-scrollspy-size"><span><strong>size</strong><small>string</small></span><select id="playground-scrollspy-size" name="pg_size"><option value="default" selected>default</option><option value="xs">xs</option><option value="sm">sm</option><option value="md">md</option><option value="lg">lg</option><option value="xl">xl</option><option value="icon">icon</option><option value="icon-xs">icon-xs</option><option value="icon-sm">icon-sm</option><option value="icon-lg">icon-lg</option></select></label><label class="playground-field" for="playground-scrollspy-color"><span><strong>color</strong><small>string</small></span><select id="playground-scrollspy-color" name="pg_color"><option value="primary" selected>primary</option><option value="secondary">secondary</option><option value="success">success</option><option value="warning">warning</option><option value="danger">danger</option><option value="info">info</option></select></label><label class="playground-field" for="playground-scrollspy-label"><span><strong>label</strong><small>string</small></span><input id="playground-scrollspy-label" name="pg_label" type="text" value="Scrollspy" /></label><label class="playground-field" for="playground-scrollspy-items"><span><strong>items</strong><small>unknown[]</small></span><textarea id="playground-scrollspy-items" name="pg_items" rows="5" spellcheck="false" data-playground-json>[ <div class="playground-fields"><label class="playground-field" for="playground-scrollspy-color"><span><strong>color</strong><small>string</small></span><select id="playground-scrollspy-color" name="pg_color"><option value="primary" selected>primary</option><option value="secondary">secondary</option><option value="success">success</option><option value="warning">warning</option><option value="danger">danger</option><option value="info">info</option></select></label><label class="playground-field" for="playground-scrollspy-size"><span><strong>size</strong><small>string</small></span><select id="playground-scrollspy-size" name="pg_size"><option value="default" selected>default</option><option value="xs">xs</option><option value="sm">sm</option><option value="md">md</option><option value="lg">lg</option><option value="xl">xl</option><option value="icon">icon</option><option value="icon-xs">icon-xs</option><option value="icon-sm">icon-sm</option><option value="icon-lg">icon-lg</option></select></label><label class="playground-field" for="playground-scrollspy-items"><span><strong>items</strong><small>unknown[]</small></span><textarea id="playground-scrollspy-items" name="pg_items" rows="5" spellcheck="false" data-playground-json>[
{ {
"id": "scrollspy-0-1", "id": "scrollspy-0-1",
"key": "scrollspy-0-1", "key": "scrollspy-0-1",
@@ -256,7 +257,7 @@ page ScrollspyDetail {
"Standard" "Standard"
] ]
} }
]</textarea><em data-playground-error></em></label><label class="playground-field" for="playground-scrollspy-active"><span><strong>active</strong><small>string</small></span><input id="playground-scrollspy-active" name="pg_active" type="text" value="" /></label><label class="playground-field" for="playground-scrollspy-orientation"><span><strong>orientation</strong><small>string</small></span><select id="playground-scrollspy-orientation" name="pg_orientation"><option value="horizontal" selected>horizontal</option><option value="vertical">vertical</option></select></label><label class="playground-field" for="playground-scrollspy-class"><span><strong>class</strong><small>string</small></span><input id="playground-scrollspy-class" name="pg_class" type="text" value="showcase-instance showcase-instance--1" /></label></div> ]</textarea><em data-playground-error></em></label><label class="playground-field" for="playground-scrollspy-active"><span><strong>active</strong><small>string</small></span><input id="playground-scrollspy-active" name="pg_active" type="text" value="" /></label><label class="playground-field" for="playground-scrollspy-label"><span><strong>label</strong><small>string</small></span><input id="playground-scrollspy-label" name="pg_label" type="text" value="Scrollspy" /></label><label class="playground-field" for="playground-scrollspy-heading"><span><strong>heading</strong><small>string</small></span><input id="playground-scrollspy-heading" name="pg_heading" type="text" value="" /></label><label class="playground-field" for="playground-scrollspy-class"><span><strong>class</strong><small>string</small></span><input id="playground-scrollspy-class" name="pg_class" type="text" value="showcase-instance showcase-instance--1" /></label></div>
</form> </form>
</div> </div>
</section> </section>
@@ -265,382 +266,75 @@ page ScrollspyDetail {
<section class="detail-section"><div class="detail-section-heading"><span>Live examples</span><h2>Designed for real product surfaces</h2><p>Compare configurations and resize the browser to check responsive behavior.</p></div><div class="demo-list"> <section class="detail-section"><div class="detail-section-heading"><span>Live examples</span><h2>Designed for real product surfaces</h2><p>Compare configurations and resize the browser to check responsive behavior.</p></div><div class="demo-list">
<article class="demo-case"> <article class="demo-case">
<header class="demo-case-header"> <header class="demo-case-header">
<div><span class="demo-case-eyebrow">Recommended</span><h2>Production default</h2><p>Balanced spacing, hierarchy, and content for the most common product workflow.</p></div> <div><span class="demo-case-eyebrow">Recommended</span><h2>Table of contents</h2><p>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, so the marker follows the reader without any component state.</p></div>
<span class="demo-case-number">01</span> <span class="demo-case-number">01</span>
</header> </header>
<div class="demo-workbench"> <div class="demo-workbench">
<div id="scrollspy-demo-1" class="demo-canvas demo-canvas--1"> <div id="scrollspy-demo-1" class="demo-canvas demo-canvas--1">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div> <div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><Scrollspy size="default" label="Scrollspy" items='[{"id":"scrollspy-0-1","key":"scrollspy-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#scrollspy-demo","actionHref":"#scrollspy-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"scrollspy-0-2","key":"scrollspy-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#scrollspy-demo","actionHref":"#scrollspy-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' class="showcase-instance showcase-instance--1" /></div> <div class="demo-render"><Scrollspy size="default" items='[{"label":"Overview","href":"#overview"},{"label":"Installation","href":"#installation"},{"label":"Usage","href":"#usage"},{"label":"API","href":"#api"}]' active="#overview" label="Scrollspy" heading="On this page" class="showcase-instance showcase-instance--1" /></div>
</div> </div>
<section class="demo-code" aria-label="Production default usage"> <section class="demo-code" aria-label="Table of contents usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header> <header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Scrollspy <pre><code>&lt;Scrollspy
items='[ items='[
<span>&#123;</span> <span>&#123;</span>
"id": "scrollspy-0-1", "label": "Overview",
"key": "scrollspy-0-1", "href": "#overview"
"value": "primary",
"label": "Primary workflow",
"title": "Primary workflow",
"description": "A clean default configuration for everyday product interfaces.",
"text": "A configurable sample message.",
"href": "#scrollspy-demo",
"actionHref": "#scrollspy-demo",
"actionLabel": "View details",
"icon": "icon-[lucide--sparkles]",
"iconClass": "icon-[lucide--sparkles]",
"variant": "primary",
"status": "Active",
"time": "09:30",
"current": true,
"selected": true,
"checked": true,
"disabled": false,
"outgoing": false,
"open": true,
"number": 1,
"count": 16,
"percentage": 72,
"color": "#7c3aed",
"target": "_self",
"ariaLabel": "Open primary workflow 1",
"items": [
<span>&#123;</span>
"label": "Nested option A",
"value": "nested-a"
<span>&#125;</span>,
<span>&#123;</span>
"label": "Nested option B",
"value": "nested-b"
<span>&#125;</span>
],
"links": [
<span>&#123;</span>
"label": "Documentation",
"href": "#documentation"
<span>&#125;</span>,
<span>&#123;</span>
"label": "API reference",
"href": "#api-reference"
<span>&#125;</span>
],
"values": [
"Included",
"Standard"
]
<span>&#125;</span>, <span>&#125;</span>,
<span>&#123;</span> <span>&#123;</span>
"id": "scrollspy-0-2", "label": "Installation",
"key": "scrollspy-0-2", "href": "#installation"
"value": "secondary", <span>&#125;</span>,
"label": "Additional option", <span>&#123;</span>
"title": "Supporting example", "label": "Usage",
"description": "A clean default configuration for everyday product interfaces.", "href": "#usage"
"text": "Another configurable message.", <span>&#125;</span>,
"href": "#scrollspy-demo", <span>&#123;</span>
"actionHref": "#scrollspy-demo", "label": "API",
"actionLabel": "View details", "href": "#api"
"icon": "icon-[lucide--sparkles]",
"iconClass": "icon-[lucide--sparkles]",
"variant": "primary",
"status": "Active",
"time": "09:30",
"current": false,
"selected": false,
"checked": false,
"disabled": false,
"outgoing": true,
"open": true,
"number": 2,
"count": 32,
"percentage": 72,
"color": "#7c3aed",
"target": "_self",
"ariaLabel": "Open primary workflow 2",
"items": [
<span>&#123;</span>
"label": "Nested option A",
"value": "nested-a"
<span>&#125;</span>,
<span>&#123;</span>
"label": "Nested option B",
"value": "nested-b"
<span>&#125;</span>
],
"links": [
<span>&#123;</span>
"label": "Documentation",
"href": "#documentation"
<span>&#125;</span>,
<span>&#123;</span>
"label": "API reference",
"href": "#api-reference"
<span>&#125;</span>
],
"values": [
"Included",
"Standard"
]
<span>&#125;</span> <span>&#125;</span>
]' ]'
active="#overview"
label="Scrollspy"
heading="On this page"
/&gt;</code></pre> /&gt;</code></pre>
</section> </section>
</div> </div>
</article> </article>
<article class="demo-case"> <article class="demo-case">
<header class="demo-case-header"> <header class="demo-case-header">
<div><span class="demo-case-eyebrow">Dense UI</span><h2>Compact application</h2><p>A tighter variation for dashboards, side panels, tables, and operational interfaces.</p></div> <div><span class="demo-case-eyebrow">Advanced</span><h2>Compact rail</h2><p>On a phone the rail becomes a horizontal strip that scrolls, so a long contents list costs one line rather than a screenful.</p></div>
<span class="demo-case-number">02</span> <span class="demo-case-number">02</span>
</header> </header>
<div class="demo-workbench"> <div class="demo-workbench">
<div id="scrollspy-demo-2" class="demo-canvas demo-canvas--2"> <div id="scrollspy-demo-2" class="demo-canvas demo-canvas--2">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div> <div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><Scrollspy size="sm" label="Compact example" items='[{"id":"scrollspy-1-1","key":"scrollspy-1-1","value":"primary","label":"Secondary workflow","title":"Secondary workflow","description":"A compact configuration designed for dense application layouts.","text":"A configurable sample message.","href":"#scrollspy-demo","actionHref":"#scrollspy-demo","actionLabel":"View details","icon":"icon-[lucide--zap]","iconClass":"icon-[lucide--zap]","variant":"secondary","status":"Active","time":"10:15","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":24,"percentage":48,"color":"#0284c7","target":"_self","ariaLabel":"Open secondary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"scrollspy-1-2","key":"scrollspy-1-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A compact configuration designed for dense application layouts.","text":"Another configurable message.","href":"#scrollspy-demo","actionHref":"#scrollspy-demo","actionLabel":"View details","icon":"icon-[lucide--zap]","iconClass":"icon-[lucide--zap]","variant":"secondary","status":"Active","time":"10:15","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":48,"percentage":48,"color":"#0284c7","target":"_self","ariaLabel":"Open secondary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' class="showcase-instance showcase-instance--2" /></div> <div class="demo-render"><Scrollspy size="sm" items='[{"label":"Getting started","href":"#getting-started"},{"label":"Configuration","href":"#configuration"},{"label":"Troubleshooting","href":"#troubleshooting"}]' active="#configuration" label="Compact example" heading="Sections" class="showcase-instance showcase-instance--2" /></div>
</div> </div>
<section class="demo-code" aria-label="Compact application usage"> <section class="demo-code" aria-label="Compact rail usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header> <header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Scrollspy <pre><code>&lt;Scrollspy
size="sm" size="sm"
items='[
<span>&#123;</span>
"label": "Getting started",
"href": "#getting-started"
<span>&#125;</span>,
<span>&#123;</span>
"label": "Configuration",
"href": "#configuration"
<span>&#125;</span>,
<span>&#123;</span>
"label": "Troubleshooting",
"href": "#troubleshooting"
<span>&#125;</span>
]'
active="#configuration"
label="Compact example" label="Compact example"
items='[ heading="Sections"
<span>&#123;</span>
"id": "scrollspy-1-1",
"key": "scrollspy-1-1",
"value": "primary",
"label": "Secondary workflow",
"title": "Secondary workflow",
"description": "A compact configuration designed for dense application layouts.",
"text": "A configurable sample message.",
"href": "#scrollspy-demo",
"actionHref": "#scrollspy-demo",
"actionLabel": "View details",
"icon": "icon-[lucide--zap]",
"iconClass": "icon-[lucide--zap]",
"variant": "secondary",
"status": "Active",
"time": "10:15",
"current": true,
"selected": true,
"checked": true,
"disabled": false,
"outgoing": false,
"open": true,
"number": 1,
"count": 24,
"percentage": 48,
"color": "#0284c7",
"target": "_self",
"ariaLabel": "Open secondary workflow 1",
"items": [
<span>&#123;</span>
"label": "Nested option A",
"value": "nested-a"
<span>&#125;</span>,
<span>&#123;</span>
"label": "Nested option B",
"value": "nested-b"
<span>&#125;</span>
],
"links": [
<span>&#123;</span>
"label": "Documentation",
"href": "#documentation"
<span>&#125;</span>,
<span>&#123;</span>
"label": "API reference",
"href": "#api-reference"
<span>&#125;</span>
],
"values": [
"Included",
"Standard"
]
<span>&#125;</span>,
<span>&#123;</span>
"id": "scrollspy-1-2",
"key": "scrollspy-1-2",
"value": "secondary",
"label": "Additional option",
"title": "Supporting example",
"description": "A compact configuration designed for dense application layouts.",
"text": "Another configurable message.",
"href": "#scrollspy-demo",
"actionHref": "#scrollspy-demo",
"actionLabel": "View details",
"icon": "icon-[lucide--zap]",
"iconClass": "icon-[lucide--zap]",
"variant": "secondary",
"status": "Active",
"time": "10:15",
"current": false,
"selected": false,
"checked": false,
"disabled": false,
"outgoing": true,
"open": true,
"number": 2,
"count": 48,
"percentage": 48,
"color": "#0284c7",
"target": "_self",
"ariaLabel": "Open secondary workflow 2",
"items": [
<span>&#123;</span>
"label": "Nested option A",
"value": "nested-a"
<span>&#125;</span>,
<span>&#123;</span>
"label": "Nested option B",
"value": "nested-b"
<span>&#125;</span>
],
"links": [
<span>&#123;</span>
"label": "Documentation",
"href": "#documentation"
<span>&#125;</span>,
<span>&#123;</span>
"label": "API reference",
"href": "#api-reference"
<span>&#125;</span>
],
"values": [
"Included",
"Standard"
]
<span>&#125;</span>
]'
/&gt;</code></pre>
</section>
</div>
</article>
<article class="demo-case">
<header class="demo-case-header">
<div><span class="demo-case-eyebrow">Extended</span><h2>Rich configuration</h2><p>A more expressive variation using additional data, stronger emphasis, and optional states.</p></div>
<span class="demo-case-number">03</span>
</header>
<div class="demo-workbench">
<div id="scrollspy-demo-3" class="demo-canvas demo-canvas--3">
<div class="demo-browser-chrome"><span></span><span></span><span></span><small>Live preview</small></div>
<div class="demo-render"><Scrollspy size="lg" label="Advanced example" items='[{"id":"scrollspy-2-1","key":"scrollspy-2-1","value":"primary","label":"Advanced workflow","title":"Advanced workflow","description":"A richer configuration with more supporting information and actions.","text":"A configurable sample message.","href":"#scrollspy-demo","actionHref":"#scrollspy-demo","actionLabel":"Explore workflow","icon":"icon-[lucide--circle-check]","iconClass":"icon-[lucide--circle-check]","variant":"success","status":"Complete","time":"11:45","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":32,"percentage":91,"color":"#059669","target":"_self","ariaLabel":"Open advanced workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Unlimited"]},{"id":"scrollspy-2-2","key":"scrollspy-2-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A richer configuration with more supporting information and actions.","text":"Another configurable message.","href":"#scrollspy-demo","actionHref":"#scrollspy-demo","actionLabel":"Explore workflow","icon":"icon-[lucide--circle-check]","iconClass":"icon-[lucide--circle-check]","variant":"success","status":"Complete","time":"11:45","current":false,"selected":false,"checked":false,"disabled":true,"outgoing":true,"open":true,"number":2,"count":64,"percentage":91,"color":"#059669","target":"_self","ariaLabel":"Open advanced workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Unlimited"]}]' class="showcase-instance showcase-instance--3" /></div>
</div>
<section class="demo-code" aria-label="Rich configuration usage">
<header><span><span class="icon-[lucide--code-2] size-4" aria-hidden="true"></span>Component usage</span><small>.wrn</small></header>
<pre><code>&lt;Scrollspy
size="lg"
label="Advanced example"
items='[
<span>&#123;</span>
"id": "scrollspy-2-1",
"key": "scrollspy-2-1",
"value": "primary",
"label": "Advanced workflow",
"title": "Advanced workflow",
"description": "A richer configuration with more supporting information and actions.",
"text": "A configurable sample message.",
"href": "#scrollspy-demo",
"actionHref": "#scrollspy-demo",
"actionLabel": "Explore workflow",
"icon": "icon-[lucide--circle-check]",
"iconClass": "icon-[lucide--circle-check]",
"variant": "success",
"status": "Complete",
"time": "11:45",
"current": true,
"selected": true,
"checked": true,
"disabled": false,
"outgoing": false,
"open": true,
"number": 1,
"count": 32,
"percentage": 91,
"color": "#059669",
"target": "_self",
"ariaLabel": "Open advanced workflow 1",
"items": [
<span>&#123;</span>
"label": "Nested option A",
"value": "nested-a"
<span>&#125;</span>,
<span>&#123;</span>
"label": "Nested option B",
"value": "nested-b"
<span>&#125;</span>
],
"links": [
<span>&#123;</span>
"label": "Documentation",
"href": "#documentation"
<span>&#125;</span>,
<span>&#123;</span>
"label": "API reference",
"href": "#api-reference"
<span>&#125;</span>
],
"values": [
"Included",
"Unlimited"
]
<span>&#125;</span>,
<span>&#123;</span>
"id": "scrollspy-2-2",
"key": "scrollspy-2-2",
"value": "secondary",
"label": "Additional option",
"title": "Supporting example",
"description": "A richer configuration with more supporting information and actions.",
"text": "Another configurable message.",
"href": "#scrollspy-demo",
"actionHref": "#scrollspy-demo",
"actionLabel": "Explore workflow",
"icon": "icon-[lucide--circle-check]",
"iconClass": "icon-[lucide--circle-check]",
"variant": "success",
"status": "Complete",
"time": "11:45",
"current": false,
"selected": false,
"checked": false,
"disabled": true,
"outgoing": true,
"open": true,
"number": 2,
"count": 64,
"percentage": 91,
"color": "#059669",
"target": "_self",
"ariaLabel": "Open advanced workflow 2",
"items": [
<span>&#123;</span>
"label": "Nested option A",
"value": "nested-a"
<span>&#125;</span>,
<span>&#123;</span>
"label": "Nested option B",
"value": "nested-b"
<span>&#125;</span>
],
"links": [
<span>&#123;</span>
"label": "Documentation",
"href": "#documentation"
<span>&#125;</span>,
<span>&#123;</span>
"label": "API reference",
"href": "#api-reference"
<span>&#125;</span>
],
"values": [
"Included",
"Unlimited"
]
<span>&#125;</span>
]'
/&gt;</code></pre> /&gt;</code></pre>
</section> </section>
</div> </div>
@@ -654,10 +348,10 @@ const component = document.querySelector("[data-ui-component=\"Scrollspy\"], [da
if (component) registerOutputHandler(component, "change", (payload) =&gt; <span>&#123;</span> if (component) registerOutputHandler(component, "change", (payload) =&gt; <span>&#123;</span>
console.log("change", payload) console.log("change", payload)
<span>&#125;</span>)</code></pre></section></div></div></section> <span>&#125;</span>)</code></pre></section></div></div></section>
<section id="api" class="detail-section"><div class="detail-section-heading"><span>Component API</span><h2>Props and configuration</h2><p>All content and behavior shown above is supplied through these props and slots.</p></div><div class="docs-table-wrap"><table class="docs-table"><thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Required</th></tr></thead><tbody><tr><td><code>size</code></td><td>string</td><td><code>"default"</code></td><td>No</td></tr><tr><td><code>color</code></td><td>string</td><td><code>"primary"</code></td><td>No</td></tr><tr><td><code>label</code></td><td>string</td><td><code>"Scrollspy"</code></td><td>No</td></tr><tr><td><code>items</code></td><td>unknown[]</td><td><code>[]</code></td><td>No</td></tr><tr><td><code>active</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>orientation</code></td><td>string</td><td><code>"horizontal"</code></td><td>No</td></tr><tr><td><code>class</code></td><td>string</td><td><code>""</code></td><td>No</td></tr></tbody></table></div></section> <section id="api" class="detail-section"><div class="detail-section-heading"><span>Component API</span><h2>Props and configuration</h2><p>All content and behavior shown above is supplied through these props and slots.</p></div><div class="docs-table-wrap"><table class="docs-table"><thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Required</th></tr></thead><tbody><tr><td><code>color</code></td><td>string</td><td><code>"primary"</code></td><td>No</td></tr><tr><td><code>size</code></td><td>string</td><td><code>"default"</code></td><td>No</td></tr><tr><td><code>items</code></td><td>unknown[]</td><td><code>[]</code></td><td>No</td></tr><tr><td><code>active</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>label</code></td><td>string</td><td><code>"On this page"</code></td><td>No</td></tr><tr><td><code>heading</code></td><td>string</td><td><code>""</code></td><td>No</td></tr><tr><td><code>class</code></td><td>string</td><td><code>""</code></td><td>No</td></tr></tbody></table></div></section>
</main> </main>
<aside class="detail-aside"> <aside class="detail-aside">
<div class="detail-toc"><strong>On this page</strong><a href="#playground">Playground</a><a href="#scrollspy-demo-1">Production default</a><a href="#scrollspy-demo-2">Compact application</a><a href="#scrollspy-demo-3">Rich configuration</a><a href="#events">Outputs</a><a href="#api">Props API</a></div> <div class="detail-toc"><strong>On this page</strong><a href="#playground">Playground</a><a href="#scrollspy-demo-1">Table of contents</a><a href="#scrollspy-demo-2">Compact rail</a><a href="#events">Outputs</a><a href="#api">Props API</a></div>
</aside> </aside>
</div> </div>
<nav class="detail-pagination"> <nav class="detail-pagination">
@@ -17,11 +17,11 @@ page ComponentShowcase {
<section class="home-product-grid"><article><span class="icon-[lucide--component] size-7"></span><strong>108 components</strong><p>Accessible primitives for every product surface.</p><a href="/base">Browse components →</a></article><article><span class="icon-[lucide--layout-template] size-7"></span><strong>Ready-made blocks</strong><p>Composable sections assembled from WRNexus UI.</p><a href="/block-library">Explore blocks →</a></article><article><span class="icon-[lucide--panels-top-left] size-7"></span><strong>Page templates</strong><p>Complete responsive pages ready to customize.</p><a href="/templates">View templates →</a></article></section> <section class="home-product-grid"><article><span class="icon-[lucide--component] size-7"></span><strong>108 components</strong><p>Accessible primitives for every product surface.</p><a href="/base">Browse components →</a></article><article><span class="icon-[lucide--layout-template] size-7"></span><strong>Ready-made blocks</strong><p>Composable sections assembled from WRNexus UI.</p><a href="/block-library">Explore blocks →</a></article><article><span class="icon-[lucide--panels-top-left] size-7"></span><strong>Page templates</strong><p>Complete responsive pages ready to customize.</p><a href="/templates">View templates →</a></article></section>
<section class="showcase-stats"> <section class="showcase-stats">
<div class="showcase-stat"><strong>108</strong><span>Unique components</span></div> <div class="showcase-stat"><strong>108</strong><span>Unique components</span></div>
<div class="showcase-stat"><strong>420</strong><span>Live configurations</span></div> <div class="showcase-stat"><strong>419</strong><span>Live configurations</span></div>
<div class="showcase-stat"><strong>0</strong><span>Duplicate implementations</span></div> <div class="showcase-stat"><strong>0</strong><span>Duplicate implementations</span></div>
<div class="showcase-stat"><strong>11</strong><span>Focused categories</span></div> <div class="showcase-stat"><strong>11</strong><span>Focused categories</span></div>
</section> </section>
<section class="home-categories"><div class="home-section-heading"><span>Explore the system</span><h2>Everything your product needs</h2></div><div class="home-category-grid"><a class="home-category home-category--1" href="/advanced-forms"><span class="home-category-index">01</span><div><strong>Advanced Forms</strong><p>8 components · 112 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--2" href="/base"><span class="home-category-index">02</span><div><strong>Base</strong><p>27 components · 86 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--3" href="/core"><span class="home-category-index">03</span><div><strong>Core</strong><p>5 components · 13 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--4" href="/data"><span class="home-category-index">04</span><div><strong>Data</strong><p>3 components · 9 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--1" href="/forms"><span class="home-category-index">05</span><div><strong>Forms</strong><p>12 components · 36 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--2" href="/integrations"><span class="home-category-index">06</span><div><strong>Integrations</strong><p>11 components · 33 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--3" href="/layout"><span class="home-category-index">07</span><div><strong>Layout</strong><p>15 components · 45 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--4" href="/marketing"><span class="home-category-index">08</span><div><strong>Marketing</strong><p>10 components · 30 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--1" href="/navigation"><span class="home-category-index">09</span><div><strong>Navigation</strong><p>10 components · 29 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--2" href="/overlays"><span class="home-category-index">010</span><div><strong>Overlays</strong><p>6 components · 18 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--3" href="/tables"><span class="home-category-index">011</span><div><strong>Tables</strong><p>1 components · 9 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a></div></section> <section class="home-categories"><div class="home-section-heading"><span>Explore the system</span><h2>Everything your product needs</h2></div><div class="home-category-grid"><a class="home-category home-category--1" href="/advanced-forms"><span class="home-category-index">01</span><div><strong>Advanced Forms</strong><p>8 components · 112 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--2" href="/base"><span class="home-category-index">02</span><div><strong>Base</strong><p>27 components · 86 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--3" href="/core"><span class="home-category-index">03</span><div><strong>Core</strong><p>5 components · 13 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--4" href="/data"><span class="home-category-index">04</span><div><strong>Data</strong><p>3 components · 9 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--1" href="/forms"><span class="home-category-index">05</span><div><strong>Forms</strong><p>12 components · 36 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--2" href="/integrations"><span class="home-category-index">06</span><div><strong>Integrations</strong><p>11 components · 33 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--3" href="/layout"><span class="home-category-index">07</span><div><strong>Layout</strong><p>15 components · 45 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--4" href="/marketing"><span class="home-category-index">08</span><div><strong>Marketing</strong><p>10 components · 30 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--1" href="/navigation"><span class="home-category-index">09</span><div><strong>Navigation</strong><p>10 components · 28 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--2" href="/overlays"><span class="home-category-index">010</span><div><strong>Overlays</strong><p>6 components · 18 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a><a class="home-category home-category--3" href="/tables"><span class="home-category-index">011</span><div><strong>Tables</strong><p>1 components · 9 live demos</p></div><span class="icon-[lucide--arrow-up-right] size-5" aria-hidden="true"></span></a></div></section>
</div> </div>
} }
} }
@@ -10,7 +10,7 @@ page NavigationShowcase {
<span class="showcase-eyebrow">Component category</span> <span class="showcase-eyebrow">Component category</span>
<h1 class="showcase-title">Navigation</h1> <h1 class="showcase-title">Navigation</h1>
<p class="showcase-description">10 unique, responsive, theme-aware components. Open a component to inspect multiple live configurations and its complete props API.</p> <p class="showcase-description">10 unique, responsive, theme-aware components. Open a component to inspect multiple live configurations and its complete props API.</p>
<div class="category-meta"><span>10 components</span><span>Multiple use cases</span><span>29 live configurations</span></div> <div class="category-meta"><span>10 components</span><span>Multiple use cases</span><span>28 live configurations</span></div>
</header> </header>
<div class="catalog-grid"> <div class="catalog-grid">
<article class="catalog-card" data-interactive-preview="false"> <article class="catalog-card" data-interactive-preview="false">
@@ -76,7 +76,7 @@ page NavigationShowcase {
<article class="catalog-card" data-interactive-preview="false"> <article class="catalog-card" data-interactive-preview="false">
<div class="catalog-card-preview"> <div class="catalog-card-preview">
<div class="catalog-card-glow" aria-hidden="true"></div> <div class="catalog-card-glow" aria-hidden="true"></div>
<div class="catalog-card-stage"><Scrollspy size="default" label="Scrollspy" items='[{"id":"scrollspy-0-1","key":"scrollspy-0-1","value":"primary","label":"Primary workflow","title":"Primary workflow","description":"A clean default configuration for everyday product interfaces.","text":"A configurable sample message.","href":"#scrollspy-demo","actionHref":"#scrollspy-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":true,"selected":true,"checked":true,"disabled":false,"outgoing":false,"open":true,"number":1,"count":16,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 1","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]},{"id":"scrollspy-0-2","key":"scrollspy-0-2","value":"secondary","label":"Additional option","title":"Supporting example","description":"A clean default configuration for everyday product interfaces.","text":"Another configurable message.","href":"#scrollspy-demo","actionHref":"#scrollspy-demo","actionLabel":"View details","icon":"icon-[lucide--sparkles]","iconClass":"icon-[lucide--sparkles]","variant":"primary","status":"Active","time":"09:30","current":false,"selected":false,"checked":false,"disabled":false,"outgoing":true,"open":true,"number":2,"count":32,"percentage":72,"color":"#7c3aed","target":"_self","ariaLabel":"Open primary workflow 2","items":[{"label":"Nested option A","value":"nested-a"},{"label":"Nested option B","value":"nested-b"}],"links":[{"label":"Documentation","href":"#documentation"},{"label":"API reference","href":"#api-reference"}],"values":["Included","Standard"]}]' class="showcase-instance showcase-instance--1" /></div> <div class="catalog-card-stage"><Scrollspy size="default" items='[{"label":"Overview","href":"#overview"},{"label":"Installation","href":"#installation"},{"label":"Usage","href":"#usage"},{"label":"API","href":"#api"}]' active="#overview" label="Scrollspy" heading="On this page" class="showcase-instance showcase-instance--1" /></div>
</div> </div>
<div class="catalog-card-body"> <div class="catalog-card-body">
<div><span class="catalog-card-category">Navigation</span><h2>Scrollspy</h2><p>Theme-aware, responsive scrollspy component.</p></div> <div><span class="catalog-card-category">Navigation</span><h2>Scrollspy</h2><p>Theme-aware, responsive scrollspy component.</p></div>
@@ -253,6 +253,38 @@ const DATATABLE_ROWS =
'[{"id": 1, "name": "Northwind", "plan": "Scale", "owner": "A. Okafor", "seats": 6, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 2, "name": "Acme Industrial", "plan": "Team", "owner": "R. Silva", "seats": 13, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 3, "name": "Globex", "plan": "Enterprise", "owner": "M. Chen", "seats": 20, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 4, "name": "Initech", "plan": "Starter", "owner": "J. Dubois", "seats": 27, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 5, "name": "Umbrella", "plan": "Scale", "owner": "P. Novak", "seats": 34, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 6, "name": "Stark Labs", "plan": "Team", "owner": "A. Okafor", "seats": 41, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 7, "name": "Wayne Foods", "plan": "Enterprise", "owner": "R. Silva", "seats": 48, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 8, "name": "Soylent", "plan": "Starter", "owner": "M. Chen", "seats": 55, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 9, "name": "Hooli", "plan": "Scale", "owner": "J. Dubois", "seats": 62, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 10, "name": "Vehement", "plan": "Team", "owner": "P. Novak", "seats": 69, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 11, "name": "Massive Dynamic", "plan": "Enterprise", "owner": "A. Okafor", "seats": 76, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 12, "name": "Cyberdyne", "plan": "Starter", "owner": "R. Silva", "seats": 83, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 13, "name": "Tyrell", "plan": "Scale", "owner": "M. Chen", "seats": 90, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 14, "name": "Aperture", "plan": "Team", "owner": "J. Dubois", "seats": 97, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 15, "name": "Black Mesa", "plan": "Enterprise", "owner": "P. Novak", "seats": 104, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}]'; '[{"id": 1, "name": "Northwind", "plan": "Scale", "owner": "A. Okafor", "seats": 6, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 2, "name": "Acme Industrial", "plan": "Team", "owner": "R. Silva", "seats": 13, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 3, "name": "Globex", "plan": "Enterprise", "owner": "M. Chen", "seats": 20, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 4, "name": "Initech", "plan": "Starter", "owner": "J. Dubois", "seats": 27, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 5, "name": "Umbrella", "plan": "Scale", "owner": "P. Novak", "seats": 34, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 6, "name": "Stark Labs", "plan": "Team", "owner": "A. Okafor", "seats": 41, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 7, "name": "Wayne Foods", "plan": "Enterprise", "owner": "R. Silva", "seats": 48, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 8, "name": "Soylent", "plan": "Starter", "owner": "M. Chen", "seats": 55, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 9, "name": "Hooli", "plan": "Scale", "owner": "J. Dubois", "seats": 62, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 10, "name": "Vehement", "plan": "Team", "owner": "P. Novak", "seats": 69, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 11, "name": "Massive Dynamic", "plan": "Enterprise", "owner": "A. Okafor", "seats": 76, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 12, "name": "Cyberdyne", "plan": "Starter", "owner": "R. Silva", "seats": 83, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 13, "name": "Tyrell", "plan": "Scale", "owner": "M. Chen", "seats": 90, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 14, "name": "Aperture", "plan": "Team", "owner": "J. Dubois", "seats": 97, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 15, "name": "Black Mesa", "plan": "Enterprise", "owner": "P. Novak", "seats": 104, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}]';
export const componentProfiles = { export const componentProfiles = {
Scrollspy: {
demos: [
standard(
"Table of contents",
"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, so the marker follows the reader without any component state.",
{
heading: "On this page",
items: [
{ label: "Overview", href: "#overview" },
{ label: "Installation", href: "#installation" },
{ label: "Usage", href: "#usage" },
{ label: "API", href: "#api" },
],
active: "#overview",
},
),
advanced(
"Compact rail",
"On a phone the rail becomes a horizontal strip that scrolls, so a long contents list costs one line rather than a screenful.",
{
heading: "Sections",
size: "sm",
items: [
{ label: "Getting started", href: "#getting-started" },
{ label: "Configuration", href: "#configuration" },
{ label: "Troubleshooting", href: "#troubleshooting" },
],
active: "#configuration",
},
),
],
},
MegaMenu: { MegaMenu: {
demos: [ demos: [
standard( standard(
@@ -2,7 +2,7 @@
"generatedFrom": "packages/ui/component-reference.json", "generatedFrom": "packages/ui/component-reference.json",
"componentCount": 108, "componentCount": 108,
"categoryCount": 11, "categoryCount": 11,
"totalDemoCount": 420, "totalDemoCount": 419,
"components": [ "components": [
{ {
"name": "Accordion", "name": "Accordion",
@@ -990,11 +990,11 @@
"slug": "scrollspy", "slug": "scrollspy",
"category": "navigation", "category": "navigation",
"purpose": "Theme-aware, responsive scrollspy component.", "purpose": "Theme-aware, responsive scrollspy component.",
"demoCount": 3, "demoCount": 2,
"propCount": 7, "propCount": 7,
"slots": ["default"], "slots": ["default"],
"events": ["change"], "events": ["change"],
"profiled": false "profiled": true
}, },
{ {
"name": "SearchBox", "name": "SearchBox",
+97 -42
View File
@@ -2908,16 +2908,12 @@ export const REACTIVE_RUNTIME = String.raw`
} }
/* /*
* Modal dialog behaviour: focus, focus restore, a Tab trap and a scroll lock. * Modal dialog focus, focus restore, Tab trap and scroll lock. Shared by
* Modal and Drawer, and here rather than in them because a client function
* cannot hold the previously focused element across a close.
* *
* These live here rather than in Modal.wrn and Drawer.wrn because every one * Fixing focus also repairs Escape: both bind @keydown on their own root,
* of them is the same code, and because a client function cannot hold the * so until focus moved inside, closeOnEscape did nothing.
* "element that had focus before we opened" across a close -- state written
* after an await or inside a callback is dropped.
*
* Fixing focus also repairs Escape. Both components bind @keydown on their
* own root, so the handler only ever runs when focus is inside the dialog;
* until something moved focus there, closeOnEscape did nothing at all.
*/ */
var DIALOG_SELECTOR = '[role="dialog"][aria-modal="true"]'; var DIALOG_SELECTOR = '[role="dialog"][aria-modal="true"]';
var FOCUSABLE_SELECTOR = var FOCUSABLE_SELECTOR =
@@ -2927,13 +2923,8 @@ export const REACTIVE_RUNTIME = String.raw`
var dialogRestoreFocus = null; var dialogRestoreFocus = null;
var dialogScrollLock = null; var dialogScrollLock = null;
/* // data-show, not measured size: the test DOM reports everything as
* Open/closed is expressed by the data-show marker these components already // zero-sized, which is what left the trap uncovered.
* emit, not by measured size. Measuring looks more thorough but made the
* trap and the scroll lock impossible to cover: the test DOM reports every
* element as zero-sized, so a dialog never counted as open and none of this
* behaviour ran under test.
*/
function isDialogVisible(dialog) { function isDialogVisible(dialog) {
if (!dialog || !dialog.isConnected) return false; if (!dialog || !dialog.isConnected) return false;
if (dialog.hasAttribute("hidden")) return false; if (dialog.hasAttribute("hidden")) return false;
@@ -2946,7 +2937,7 @@ export const REACTIVE_RUNTIME = String.raw`
var candidates = dialog.querySelectorAll(FOCUSABLE_SELECTOR); var candidates = dialog.querySelectorAll(FOCUSABLE_SELECTOR);
for (var index = 0; index < candidates.length; index += 1) { for (var index = 0; index < candidates.length; index += 1) {
var candidate = candidates[index]; var candidate = candidates[index];
// Same rule as the roving items: markers, not measurement. // Markers, not measurement.
if (candidate.hasAttribute("hidden")) continue; if (candidate.hasAttribute("hidden")) continue;
if (candidate.closest('[data-show="false"]')) continue; if (candidate.closest('[data-show="false"]')) continue;
found.push(candidate); found.push(candidate);
@@ -2978,8 +2969,7 @@ export const REACTIVE_RUNTIME = String.raw`
} }
openDialogs.push(dialog); openDialogs.push(dialog);
// Prefer a real control so keyboard users land somewhere useful, and fall // Prefer a real control; the panel carries tabindex="-1" as a fallback.
// back to the panel itself, which carries tabindex="-1" for exactly this.
var targets = focusableWithin(dialog); var targets = focusableWithin(dialog);
var target = targets.length ? targets[0] : dialog; var target = targets.length ? targets[0] : dialog;
if (target && target.focus) target.focus(); if (target && target.focus) target.focus();
@@ -3016,8 +3006,7 @@ export const REACTIVE_RUNTIME = String.raw`
var dialog = openDialogs[openDialogs.length - 1]; var dialog = openDialogs[openDialogs.length - 1];
var targets = focusableWithin(dialog); var targets = focusableWithin(dialog);
if (!targets.length) { if (!targets.length) {
// Nothing to cycle through; keep focus on the panel rather than letting // Nothing to cycle; keep focus on the panel rather than the page behind.
// Tab walk out into the page sitting behind the backdrop.
event.preventDefault(); event.preventDefault();
if (dialog.focus) dialog.focus(); if (dialog.focus) dialog.focus();
return; return;
@@ -3057,33 +3046,23 @@ export const REACTIVE_RUNTIME = String.raw`
} }
/* /*
* Roving arrow-key focus, the ARIA pattern shared by tabs, menus, sidebars * Roving arrow-key focus. A container marked data-wrn-roving owns its
* and steppers. A container marked data-wrn-roving owns its * [data-wrn-roving-item] descendants: one carries tabindex="0" so Tab
* [data-wrn-roving-item] descendants: exactly one carries tabindex="0" so * reaches the group once, and the arrows move within it. Here rather than
* Tab reaches the group once, and the arrow keys move within it. * in five components because focus bookkeeping cannot live in component
* * state.
* This lives here rather than in each component because it is the same code
* five times over, and because focus bookkeeping cannot be held in component
* state -- a client function writing after it returns has that write dropped.
*/ */
var ROVING_SELECTOR = "[data-wrn-roving]"; var ROVING_SELECTOR = "[data-wrn-roving]";
var ROVING_ITEM_SELECTOR = "[data-wrn-roving-item]"; var ROVING_ITEM_SELECTOR = "[data-wrn-roving-item]";
/* // Templates stringify these: "" and "false" mean opted out, and a bare
* Templates stringify these, so data-wrn-roving="" and // [attr] selector matches either, so the value must be checked.
* data-wrn-roving-item="false" mean "not this time". A bare [attr] selector // Bare means yes; only an explicit "false" opts out.
* matches either, so the value must be checked.
*/
// A bare data-wrn-roving-item means yes; only an explicit "false" opts out.
function rovingItemOff(value) { function rovingItemOff(value) {
return value === null || value === "false"; return value === null || value === "false";
} }
/* // The container must name an axis; empty is what {cond ? "x" : ""} emits.
* The container is stricter: it must name an axis. An empty value is what a
* template emits for {cond ? "horizontal" : ""}, so empty means off rather
* than defaulting to horizontal.
*/
function rovingOrientation(container) { function rovingOrientation(container) {
var value = container.getAttribute("data-wrn-roving"); var value = container.getAttribute("data-wrn-roving");
if (value === null || value === "" || value === "false") return ""; if (value === null || value === "" || value === "false") return "";
@@ -3100,8 +3079,7 @@ export const REACTIVE_RUNTIME = String.raw`
if (candidate.closest(ROVING_SELECTOR) !== container) continue; if (candidate.closest(ROVING_SELECTOR) !== container) continue;
if (candidate.hasAttribute("disabled")) continue; if (candidate.hasAttribute("disabled")) continue;
if (candidate.getAttribute("aria-disabled") === "true") continue; if (candidate.getAttribute("aria-disabled") === "true") continue;
// Markers, not measurement: the test DOM reports every element as // Markers, not measurement (see isDialogVisible).
// zero-sized, which is what left the dialog trap uncovered.
if (candidate.hasAttribute("hidden")) continue; if (candidate.hasAttribute("hidden")) continue;
if (candidate.closest('[data-show="false"]')) continue; if (candidate.closest('[data-show="false"]')) continue;
found.push(candidate); found.push(candidate);
@@ -3193,6 +3171,82 @@ export const REACTIVE_RUNTIME = String.raw`
syncRovingGroups(); syncRovingGroups();
} }
// Scrollspy. The marker goes onto the links, not into state: an observer
// callback fires after the client function returned, so that write is lost.
function applyScrollspyCurrent(nav, href) {
var links = nav.querySelectorAll('a[href^="#"]');
var changed = false;
var label = "";
for (var index = 0; index < links.length; index += 1) {
var link = links[index];
var current = link.getAttribute("href") === href;
if (current) label = (link.textContent || "").trim();
if ((link.getAttribute("data-active") === "true") !== current) changed = true;
link.setAttribute("data-active", current ? "true" : "false");
link.setAttribute("aria-current", current ? "location" : "false");
}
if (!changed) return;
// Named for the component output so a parent @change binding receives it.
nav.dispatchEvent(new CustomEvent("change", { detail: { href: href, label: label } }));
}
function wireScrollspy(nav) {
if (nav.__wrnScrollspyWired) return;
nav.__wrnScrollspyWired = true;
nav.addEventListener("click", function (event) {
var t = event.target;
var link = t && t.closest ? t.closest('a[href^="#"]') : null;
if (link && nav.contains(link)) applyScrollspyCurrent(nav, link.getAttribute("href"));
});
if (typeof IntersectionObserver === "undefined") return;
var links = nav.querySelectorAll('a[href^="#"]');
var targets = [];
for (var index = 0; index < links.length; index += 1) {
var href = links[index].getAttribute("href");
var section = document.getElementById(href.slice(1));
if (section) targets.push({ section: section, href: href });
}
if (!targets.length) return;
var visible = {};
var observer = new IntersectionObserver(
function (entries) {
for (var entryIndex = 0; entryIndex < entries.length; entryIndex += 1) {
visible[entries[entryIndex].target.id] = entries[entryIndex].isIntersecting;
}
// First visible section in document order wins, so up and down
// settle on the same link.
for (var pick = 0; pick < targets.length; pick += 1) {
if (visible[targets[pick].section.id]) {
applyScrollspyCurrent(nav, targets[pick].href);
return;
}
}
},
// Biased to the upper third: the current section is the one being read.
{ rootMargin: "-80px 0px -55% 0px" },
);
for (var watch = 0; watch < targets.length; watch += 1) observer.observe(targets[watch].section);
}
function setupScrollspy() {
if (window.__wrnexusScrollspyBound) return;
window.__wrnexusScrollspyBound = true;
var wireAll = function () {
var navs = document.querySelectorAll("[data-wrn-scrollspy]");
for (var index = 0; index < navs.length; index += 1) wireScrollspy(navs[index]);
};
wireAll();
if (typeof MutationObserver === "function") {
new MutationObserver(function () {
window.setTimeout(wireAll, 0);
}).observe(document.documentElement, { subtree: true, childList: true });
}
}
function hydrateScopes(root) { function hydrateScopes(root) {
var host = root || document; var host = root || document;
@@ -5411,6 +5465,7 @@ export const REACTIVE_RUNTIME = String.raw`
setupAnchoredOverlays(); setupAnchoredOverlays();
setupModalDialogs(); setupModalDialogs();
setupRovingFocus(); setupRovingFocus();
setupScrollspy();
window.__wrnexusRepositionAnchored = repositionAnchored; window.__wrnexusRepositionAnchored = repositionAnchored;
window.__wrnexusHydrateScopes = hydrateScopes; window.__wrnexusHydrateScopes = hydrateScopes;
window.__wrnexusInvalidateClientModule = function (url) { clientModuleCache.delete(url); }; window.__wrnexusInvalidateClientModule = function (url) { clientModuleCache.delete(url); };
+2 -2
View File
@@ -898,9 +898,9 @@ Theme-aware, responsive pagination component.
Theme-aware, responsive scrollspy component. Theme-aware, responsive scrollspy component.
- Mount: `data-component="Scrollspy"` - Mount: `data-component="Scrollspy"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Scrollspy"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""` - Props: `color: string = "primary"`, `size: string = "default"`, `items: unknown[] = []`, `active: string = ""`, `label: string = "On this page"`, `heading: string = ""`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: `change({ 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)` - Outputs: `change({ href: string; label: string })`
### Sidebar ### Sidebar
+12 -12
View File
@@ -10716,13 +10716,6 @@
"category": "navigation", "category": "navigation",
"purpose": "Theme-aware, responsive scrollspy component.", "purpose": "Theme-aware, responsive scrollspy component.",
"props": [ "props": [
{
"name": "size",
"type": "string",
"required": false,
"default": "\"default\"",
"options": []
},
{ {
"name": "color", "name": "color",
"type": "string", "type": "string",
@@ -10731,10 +10724,10 @@
"options": [] "options": []
}, },
{ {
"name": "label", "name": "size",
"type": "string", "type": "string",
"required": false, "required": false,
"default": "\"Scrollspy\"", "default": "\"default\"",
"options": [] "options": []
}, },
{ {
@@ -10752,10 +10745,17 @@
"options": [] "options": []
}, },
{ {
"name": "orientation", "name": "label",
"type": "string", "type": "string",
"required": false, "required": false,
"default": "\"horizontal\"", "default": "\"On this page\"",
"options": []
},
{
"name": "heading",
"type": "string",
"required": false,
"default": "\"\"",
"options": [] "options": []
}, },
{ {
@@ -10770,7 +10770,7 @@
"outputs": [ "outputs": [
{ {
"name": "change", "name": "change",
"payloadType": "{ 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" "payloadType": "{ href: string; label: string }"
} }
], ],
"events": ["change"], "events": ["change"],
+1 -1
View File
@@ -87,7 +87,7 @@ label: string = "Breadcrumb"
{:else} {:else}
<span <span
class="wire-breadcrumb__current" class="wire-breadcrumb__current"
aria-current='{item.active || item.current || (active && active === (item.value || item.label || item.title)) || (!active && itemIndex === items.length - 1) ? "page" : ""}' aria-current='{item.active || item.current || (active && active === (item.value || item.label || item.title)) || (!active && itemIndex === items.length - 1) ? "page" : "false"}'
aria-disabled='{item.disabled ? "true" : "false"}' aria-disabled='{item.disabled ? "true" : "false"}'
> >
{#if item.icon} {#if item.icon}
+5 -5
View File
@@ -86,11 +86,11 @@ size: string = "default"
</button> </button>
<div class="wire-navbar__collapse {mobileOpen ? 'is-open' : ''}"> <div class="wire-navbar__collapse {mobileOpen ? 'is-open' : ''}">
<nav class="wire-navbar__menus" aria-label="{label}"> <nav class="wire-navbar__menus" aria-label="{label}" data-wrn-roving="horizontal">
{#each items as item} {#each items as item}
{#if item.children && item.children.length} {#if item.children && item.children.length}
<details class="wire-navbar__dropdown wire-navbar__dropdown--{item.type || 'dropdown'}" name="wire-navbar-menu" @toggle="toggleDropdown(event, item)"> <details class="wire-navbar__dropdown wire-navbar__dropdown--{item.type || 'dropdown'}" name="wire-navbar-menu" @toggle="toggleDropdown(event, item)">
<summary aria-current="{isItemActive(item) ? 'page' : ''}"> <summary data-wrn-roving-item="true" aria-current="{isItemActive(item) ? 'page' : 'false'}">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if} {#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span> <span>{item.label}</span>
<span class="wire-navbar__chevron" aria-hidden="true"></span> <span class="wire-navbar__chevron" aria-hidden="true"></span>
@@ -103,13 +103,13 @@ size: string = "default"
{#if child.label}<strong class="wire-navbar__group-title">{child.label}</strong>{/if} {#if child.label}<strong class="wire-navbar__group-title">{child.label}</strong>{/if}
{#if child.description}<small>{child.description}</small>{/if} {#if child.description}<small>{child.description}</small>{/if}
{#each child.children as nested} {#each child.children as nested}
<a href="{nested.href || '#'}" target="{nested.target || ''}" rel="{nested.rel || ''}" aria-current="{nested.value === active ? 'page' : ''}" @click="selectItem(nested, 3)"> <a href="{nested.href || '#'}" target="{nested.target || ''}" rel="{nested.rel || ''}" aria-current="{nested.value === active ? 'page' : 'false'}" @click="selectItem(nested, 3)">
{#if nested.icon}<span class="{nested.icon}" aria-hidden="true"></span>{/if} {#if nested.icon}<span class="{nested.icon}" aria-hidden="true"></span>{/if}
<span><strong>{nested.label}</strong>{#if nested.description}<small>{nested.description}</small>{/if}</span> <span><strong>{nested.label}</strong>{#if nested.description}<small>{nested.description}</small>{/if}</span>
</a> </a>
{/each} {/each}
{:else} {:else}
<a href="{child.href || '#'}" target="{child.target || ''}" rel="{child.rel || ''}" aria-current="{child.value === active ? 'page' : ''}" @click="selectItem(child, 2)"> <a href="{child.href || '#'}" target="{child.target || ''}" rel="{child.rel || ''}" aria-current="{child.value === active ? 'page' : 'false'}" @click="selectItem(child, 2)">
{#if child.icon}<span class="{child.icon}" aria-hidden="true"></span>{/if} {#if child.icon}<span class="{child.icon}" aria-hidden="true"></span>{/if}
<span><strong>{child.label}</strong>{#if child.description}<small>{child.description}</small>{/if}</span> <span><strong>{child.label}</strong>{#if child.description}<small>{child.description}</small>{/if}</span>
</a> </a>
@@ -119,7 +119,7 @@ size: string = "default"
</div> </div>
</details> </details>
{:else} {:else}
<a class="wire-navbar__menu-link" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" aria-current="{item.value === active ? 'page' : ''}" @click="selectItem(item, 1)"> <a class="wire-navbar__menu-link" data-wrn-roving-item="true" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" aria-current="{item.value === active ? 'page' : 'false'}" @click="selectItem(item, 1)">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if} {#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span> <span>{item.label}</span>
</a> </a>
+157 -6
View File
@@ -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 { component Scrollspy {
outputs { 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 { props {
size: string = "default"
color: string = "primary" color: string = "primary"
label: string = "Scrollspy" size: string = "default"
items: unknown[] = [] items: unknown[] = []
active: string = "" active: string = ""
orientation: string = "horizontal" label: string = "On this page"
heading: string = ""
class: string = "" class: string = ""
} }
functions {
shared function itemList() {
return Array.isArray(items) ? items : []
}
shared function isActive(item) {
return Boolean(item.href) && item.href === active
}
}
view { view {
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--scrollspy wire-next--{orientation} {class}" aria-label="{label}"> <nav
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each} {...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 /> <slot />
</nav> </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);
}
}
}
} }
+81
View File
@@ -2787,3 +2787,84 @@ test("sidebar composes Drawer for its off-canvas presentation", async () => {
expect(source).toContain('import Drawer from "./Drawer.wrn"'); expect(source).toContain('import Drawer from "./Drawer.wrn"');
expect(source).toContain("<Drawer"); expect(source).toContain("<Drawer");
}); });
test("scrollspy renders section links and marks the runtime observation target", async () => {
const source = readFileSync(uiComponentPath("Scrollspy"), "utf8");
const html = await renderComponent(source, {
label: "On this page",
items: [
{ label: "Overview", href: "#overview" },
{ label: "Install", href: "#install" },
{ label: "Usage", href: "#usage" },
],
active: "#install",
});
const dom = mountHtml(html);
const root = dom.querySelector(".wire-scrollspy") as HTMLElement;
expect(root.getAttribute("role")).toBe("navigation");
// The runtime owns which link is current; it needs a marker to find the nav.
expect(root.getAttribute("data-wrn-scrollspy")).toBe("true");
const links = [...dom.querySelectorAll(".wire-scrollspy__link")];
expect(links).toHaveLength(3);
expect(links[1]!.getAttribute("aria-current")).toBe("location");
expect(links[0]!.getAttribute("aria-current")).toBe("false");
expect(links[1]!.getAttribute("href")).toBe("#install");
});
test("scrollspy survives an empty item list", async () => {
const source = readFileSync(uiComponentPath("Scrollspy"), "utf8");
const html = await renderComponent(source, { items: [] });
const dom = mountHtml(html);
expect(dom.querySelector(".wire-scrollspy")).not.toBeNull();
expect(dom.querySelectorAll(".wire-scrollspy__link")).toHaveLength(0);
});
test("navbar uses a valid aria-current and takes roving focus on its menu", async () => {
const source = readFileSync(uiComponentPath("Navbar"), "utf8");
/*
* aria-current="" is not a valid value -- the attribute has to be absent or
* carry a token. Emitting an empty string made every link look like it
* declared a state it did not have.
*/
expect(source).not.toContain("? 'page' : ''");
expect(source).toContain("data-wrn-roving");
const html = await renderComponent(source, {
label: "Main",
items: [
{ label: "Home", href: "/", value: "home" },
{ label: "Docs", href: "/docs", value: "docs" },
],
active: "docs",
});
const dom = mountHtml(html);
const menus = dom.querySelector(".wire-navbar__menus") as HTMLElement;
expect(menus.getAttribute("data-wrn-roving")).toBe("horizontal");
/*
* Asserted against the rendered markup rather than the parsed DOM: the test
* DOM drops aria-current from these anchors, while the served HTML carries
* it correctly.
*/
expect(html).toContain('href="/docs"');
expect(html).toMatch(/aria-current="page"/);
expect(html).not.toMatch(/aria-current=""/);
});
test("breadcrumb marks the trail end without emitting an empty aria-current", async () => {
const source = readFileSync(uiComponentPath("Breadcrumb"), "utf8");
const html = await renderComponent(source, {
items: [
{ label: "Docs", href: "/docs" },
{ label: "Components", href: "/docs/components" },
{ label: "Breadcrumb" },
],
});
// aria-current="" is not a valid value; the attribute takes a token.
expect(html).not.toMatch(/aria-current=""/);
expect(html).toMatch(/aria-current="page"/);
const dom = mountHtml(html);
expect(dom.querySelector(".wire-breadcrumb")!.getAttribute("aria-label")).toBeTruthy();
expect(dom.querySelectorAll(".wire-breadcrumb__item").length).toBeGreaterThanOrEqual(3);
});