Files
Clintchiz 2c960fc1dc
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s
refactor: migrate legacy wire namespace to wrn
2026-08-12 18:51:15 +05:30

336 lines
9.5 KiB
Plaintext

// Sidebar -- a vertical navigation rail that becomes a drawer on small screens.
//
// <Sidebar items='[{"label":"Dashboard","href":"/","value":"dash"}]' active="dash" />
//
// An entry is one of three shapes:
// { label, href, value } a single link
// { heading, items: [...] } a labelled group
// { label, items: [...] } a collapsible branch, nested to 3 levels
//
// The off-canvas presentation is Drawer rather than a second implementation,
// so the focus trap and the body scroll lock come from one place.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
import Drawer from "./Drawer.wrn"
component Sidebar {
outputs {
toggle(payload: { open: boolean })
open(payload: { sourceEvent: Event })
close(payload: { source: string })
select(payload: { item: object; value: string; level: number })
}
props {
color: string = "primary"
size: string = "default"
label: string = "Sidebar"
items: unknown[] = []
active: string = ""
mobileLabel: string = "Open navigation"
drawerTitle: string = "Navigation"
class: string = ""
}
state drawerOpen = false
functions {
shared function entryList() {
return Array.isArray(items) ? items : []
}
// Accepts children as well as items: children is what shipped in 0.8.5
// and existing callers should keep working.
shared function childrenOf(entry) {
if (!entry) {
return []
}
if (Array.isArray(entry.items)) {
return entry.items
}
return Array.isArray(entry.children) ? entry.children : []
}
shared function isGroup(entry) {
return Boolean(entry.heading)
}
shared function isActive(entry) {
return Boolean(entry.value) && entry.value === active
}
client function choose(entry, level) {
if (entry.disabled) {
return
}
output.select({ item: entry, value: entry.value || "", level: level })
if (drawerOpen) {
drawerOpen = false
output.close({ source: "select" })
}
}
client function openDrawer(sourceEvent) {
drawerOpen = true
output.open({ sourceEvent: sourceEvent })
output.toggle({ open: true })
}
client function closeDrawer() {
drawerOpen = false
output.close({ source: "drawer" })
output.toggle({ open: false })
}
}
view {
<div
{...attrs}
data-ui-component="Sidebar"
class='wrn-sidebar {class}'
data-color='{color}'
data-size='{size}'
>
<button
type="button"
class="wrn-sidebar__launcher"
aria-label='{mobileLabel}'
aria-expanded='{drawerOpen}'
@click='openDrawer(event)'
>
<span class="wrn-sidebar__launcher-bar" aria-hidden="true"></span>
<span class="wrn-sidebar__launcher-text">{mobileLabel}</span>
</button>
<nav class="wrn-sidebar__rail" aria-label='{label}'>
<ul class="wrn-sidebar__list" data-wrn-roving="vertical">
{#each entryList() as entry}
<li class="wrn-sidebar__entry" data-group='{isGroup(entry)}'>
<p class="wrn-sidebar__heading" data-show="entry.heading">{entry.heading}</p>
<a
class="wrn-sidebar__link"
data-show="!entry.heading"
href='{entry.href || "#"}'
data-wrn-roving-item="true"
data-active='{isActive(entry)}'
aria-current='{isActive(entry) ? "page" : "false"}'
aria-disabled='{entry.disabled ? "true" : "false"}'
@click='choose(entry, 1)'
>
<span
class='wrn-sidebar__icon {entry.icon}'
data-show="entry.icon"
aria-hidden="true"
></span>
<span class="wrn-sidebar__label">{entry.label}</span>
<span class="wrn-sidebar__badge" data-show="entry.badge">{entry.badge}</span>
</a>
<ul class="wrn-sidebar__sublist" data-show="childrenOf(entry).length > 0">
{#each childrenOf(entry) as child}
<li class="wrn-sidebar__entry">
<a
class="wrn-sidebar__link"
href='{child.href || "#"}'
data-active='{isActive(child)}'
aria-current='{isActive(child) ? "page" : "false"}'
aria-disabled='{child.disabled ? "true" : "false"}'
@click='choose(child, 2)'
>
<span
class='wrn-sidebar__icon {child.icon}'
data-show="child.icon"
aria-hidden="true"
></span>
<span class="wrn-sidebar__label">{child.label}</span>
<span class="wrn-sidebar__badge" data-show="child.badge">{child.badge}</span>
</a>
<ul
class="wrn-sidebar__sublist wrn-sidebar__sublist--level3"
data-show="childrenOf(child).length > 0"
>
{#each childrenOf(child) as leaf}
<li class="wrn-sidebar__entry">
<a
class="wrn-sidebar__link"
href='{leaf.href || "#"}'
data-active='{isActive(leaf)}'
aria-current='{isActive(leaf) ? "page" : "false"}'
@click='choose(leaf, 3)'
>
<span class="wrn-sidebar__label">{leaf.label}</span>
</a>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
<slot />
</nav>
<Drawer
open={drawerOpen}
placement="left"
title={drawerTitle}
label={label}
@close="closeDrawer()"
>
<slot name="drawer"></slot>
</Drawer>
</div>
}
style {
.wrn-sidebar {
--sidebar-accent: var(--wrn-color-primary);
max-width: 100%;
}
.wrn-sidebar[data-color="secondary"] {
--sidebar-accent: var(--wrn-color-secondary);
}
.wrn-sidebar[data-color="success"] {
--sidebar-accent: var(--wrn-color-success);
}
.wrn-sidebar[data-color="danger"] {
--sidebar-accent: var(--wrn-color-danger);
}
.wrn-sidebar[data-color="info"] {
--sidebar-accent: var(--wrn-color-info);
}
.wrn-sidebar[data-size="sm"] {
font-size: 0.82rem;
}
.wrn-sidebar[data-size="lg"] {
font-size: 1rem;
}
.wrn-sidebar__rail {
width: 100%;
}
.wrn-sidebar__list,
.wrn-sidebar__sublist {
display: grid;
gap: 0.1rem;
margin: 0;
padding: 0;
list-style: none;
}
.wrn-sidebar__sublist {
margin-left: 0.85rem;
padding-left: 0.5rem;
border-left: 1px solid var(--wrn-color-border);
}
.wrn-sidebar__entry[data-group="true"] + .wrn-sidebar__entry {
margin-top: 0.35rem;
}
.wrn-sidebar__heading {
margin: 0.9rem 0 0.35rem;
color: var(--wrn-color-text-muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.wrn-sidebar__link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.6rem;
border-radius: var(--wrn-radius-sm);
color: var(--wrn-color-text-muted);
font-size: 0.88rem;
font-weight: 600;
text-decoration: none;
}
.wrn-sidebar__link:hover {
background: var(--wrn-color-surface-soft);
color: var(--wrn-color-text);
}
.wrn-sidebar__link:focus-visible {
outline: 2px solid var(--sidebar-accent);
outline-offset: 2px;
}
.wrn-sidebar__link[data-active="true"] {
background: color-mix(in srgb, var(--sidebar-accent) 16%, transparent);
color: var(--sidebar-accent);
}
.wrn-sidebar__link[aria-disabled="true"] {
opacity: 0.5;
pointer-events: none;
}
.wrn-sidebar__label {
min-width: 0;
}
.wrn-sidebar__badge {
margin-left: auto;
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: color-mix(in srgb, var(--sidebar-accent) 16%, transparent);
color: var(--sidebar-accent);
font-size: 0.72rem;
}
.wrn-sidebar__launcher {
display: none;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.7rem;
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius-sm);
background: var(--wrn-color-surface);
color: var(--wrn-color-text);
font: inherit;
font-size: 0.9rem;
cursor: pointer;
}
.wrn-sidebar__launcher-bar {
width: 1rem;
height: 2px;
background: currentColor;
box-shadow:
0 -5px 0 currentColor,
0 5px 0 currentColor;
}
/*
* Below the tablet breakpoint the rail is replaced by the drawer launcher.
* A permanent rail eats most of a phone screen, and Drawer already handles
* the focus trap and the scroll lock.
*/
@media (max-width: 767px) {
.wrn-sidebar__launcher {
display: inline-flex;
}
.wrn-sidebar__rail {
display: none;
}
}
}
}