336 lines
9.5 KiB
Plaintext
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;
|
|
}
|
|
}
|
|
}
|
|
}
|