Files
WRNexusJS/packages/ui/components/Navbar.wrn
T
Clintchiz 3b402b4e36
Quality / quality (windows-latest) (push) Waiting to run
Quality / quality (ubuntu-latest) (push) Failing after 11m48s
fix(ui): collapse empty navbar topbar and add CTA spacing
2026-08-24 18:28:39 +05:30

672 lines
20 KiB
Plaintext

component Navbar {
outputs {
toggle(payload: { open: boolean })
open(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
close(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
select(payload: { item: string | number | boolean | null | object; value: string | number | boolean; level: string })
action(payload: { item: string | number | boolean | null | object; value: string | number | boolean })
}
props {
size: string = "default"
color: string = "primary"
label: string = "Primary navigation"
topbarLabel: string = "Utility navigation"
brand: Record<string, unknown> = {}
items: unknown[] = []
actions: unknown[] = []
active: string = ""
sticky: boolean = false
openOnHover: boolean = false
maxWidth: string = "full"
mobileLabel: string = "Toggle navigation"
translationPrefix: string = ""
class: string = ""
}
state mobileOpen: boolean = false
functions {
shared function translationKey(item, field) {
if (!item) return ""
const explicit = item[field + "Key"]
if (explicit) return explicit
if (!translationPrefix) return ""
const source = item.value || item.href || item.label || item.title || ""
const slug = String(source).replace(/^\/+|\/+$/g, "").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase()
return slug ? translationPrefix + "." + slug + (field === "description" ? ".description" : "") : ""
}
client function toggleNavigation() {
mobileOpen = !mobileOpen
output.toggle({ open: mobileOpen })
output[mobileOpen ? "open" : "close"]({ source: "mobile" })
}
client function selectItem(item, level) {
mobileOpen = false
output.select({ item: item, value: item.value || "", level: level })
}
client function selectAction(item) {
mobileOpen = false
output.action({ item: item, value: item.value || "" })
}
shared function isItemActive(item) {
if (!item) return false
if (item.value && item.value === active) return true
if (!item.children || !item.children.length) return false
return item.children.some(function (child) {
return isItemActive(child)
})
}
client function toggleDropdown(event, item) {
output[event.currentTarget.open ? "open" : "close"]({
source: "menu",
item: item
})
}
}
view {
<header class="wrn-navbar wrn-navbar--width-{maxWidth} wrn-component--color-{color} wrn-component--size-{size} {sticky ? 'wrn-navbar--sticky' : ''} {class}" data-wrn-navbar data-open-on-hover="{openOnHover}">
<div class="wrn-navbar__topbar" aria-label="{topbarLabel}">
<slot name="topbar" />
</div>
<div class="wrn-navbar__main">
{#if brand.logo || brand.icon || brand.label || brand.description}
<a class="wrn-navbar__brand" href="{brand.href || '/'}" aria-label="{brand.ariaLabel || brand.label || label}">
{#if brand.logo}
<img class="wrn-navbar__brand-logo" src="{brand.logo}" alt="{brand.logoAlt || brand.label || ''}" width="{brand.logoWidth || ''}" height="{brand.logoHeight || ''}" />
{/if}
{#if brand.icon}
<span class="wrn-navbar__brand-icon {brand.icon}" aria-hidden="true"></span>
{/if}
{#if brand.label || brand.description}
<span class="wrn-navbar__brand-copy">
{#if brand.label}<strong data-t="{brand.labelKey || (translationPrefix ? translationPrefix + '.brand' : '')}">{brand.label}</strong>{/if}
{#if brand.description}<small data-t="{brand.descriptionKey || (translationPrefix ? translationPrefix + '.brand.description' : '')}">{brand.description}</small>{/if}
</span>
{/if}
</a>
{/if}
<button type="button" class="wrn-navbar__toggle" aria-label="{mobileLabel}" aria-expanded="{mobileOpen}" @click="toggleNavigation()">
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button>
<div class="wrn-navbar__collapse {mobileOpen ? 'is-open' : ''}">
<nav class="wrn-navbar__menus" aria-label="{label}" data-wrn-roving="horizontal">
{#each items as item}
{#if item.children && item.children.length}
<details class="wrn-navbar__dropdown wrn-navbar__dropdown--{item.type || 'dropdown'}" name="wrn-navbar-menu" @toggle="toggleDropdown(event, item)">
<summary data-wrn-roving-item="true" aria-current="{isItemActive(item) ? 'page' : 'false'}">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span data-t="{translationKey(item, 'label')}">{item.label}</span>
<span class="wrn-navbar__chevron" aria-hidden="true"></span>
</summary>
<div class="wrn-navbar__panel wrn-navbar__panel--columns-{item.columns || 1}">
{#if item.description}<p class="wrn-navbar__panel-intro" data-t="{translationKey(item, 'description')}">{item.description}</p>{/if}
{#each item.children as child}
<div class="wrn-navbar__group">
{#if child.children && child.children.length}
{#if child.label}<strong class="wrn-navbar__group-title" data-t="{translationKey(child, 'label')}">{child.label}</strong>{/if}
{#if child.description}<small data-t="{translationKey(child, 'description')}">{child.description}</small>{/if}
{#each child.children as nested}
<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}
<span><strong data-t="{translationKey(nested, 'label')}">{nested.label}</strong>{#if nested.description}<small data-t="{translationKey(nested, 'description')}">{nested.description}</small>{/if}</span>
</a>
{/each}
{:else}
<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}
<span><strong data-t="{translationKey(child, 'label')}">{child.label}</strong>{#if child.description}<small data-t="{translationKey(child, 'description')}">{child.description}</small>{/if}</span>
</a>
{/if}
</div>
{/each}
</div>
</details>
{:else}
<a class="wrn-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}
<span data-t="{translationKey(item, 'label')}">{item.label}</span>
</a>
{/if}
{/each}
</nav>
<div class="wrn-navbar__actions">
{#each actions as item}
<a class="wrn-navbar__action wrn-navbar__action--{item.variant || 'link'}" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" @click="selectAction(item)">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span data-t="{translationKey(item, 'label')}">{item.label}</span>
</a>
{/each}
<slot name="actions" />
</div>
</div>
</div>
</header>
}
style {
/* Full application navigation */
.wrn-navbar {
position: relative;
z-index: 40;
width: 100%;
color: var(--wrn-color-text);
background: color-mix(in srgb, var(--wrn-color-surface) 96%, transparent);
border-bottom: 1px solid var(--wrn-color-border);
}
.wrn-navbar--sticky {
position: sticky;
top: 0;
backdrop-filter: blur(16px);
}
.wrn-navbar__topbar:not(:has(> *)) {
display: none;
}
.wrn-navbar__topbar:has(> *) {
display: flex;
min-height: 2.25rem;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.35rem clamp(1rem, 4vw, 3rem);
color: var(--wrn-color-muted);
background: var(--wrn-color-surface-2);
border-bottom: 1px solid var(--wrn-color-border);
font-size: 0.75rem;
font-weight: 400;
}
.wrn-navbar__main {
display: flex;
min-height: 4.25rem;
width: min(100%, 90rem);
margin-inline: auto;
padding: 0.75rem clamp(1rem, 4vw, 3rem);
align-items: center;
gap: 1.5rem;
}
.wrn-navbar--width-full .wrn-navbar__main {
width: 100%;
max-width: none;
}
.wrn-navbar--width-compact .wrn-navbar__main {
width: min(86%, 80rem);
}
.wrn-navbar__brand,
.wrn-navbar__menu-link,
.wrn-navbar__dropdown summary,
.wrn-navbar__action,
.wrn-navbar__panel a {
color: inherit;
text-decoration: none;
}
.wrn-navbar__brand {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 0.75rem;
flex: 0 0 auto;
}
.wrn-navbar__brand-logo {
display: block;
width: auto;
max-width: 11rem;
height: 2.5rem;
object-fit: contain;
}
.wrn-navbar__brand-icon {
width: 2.5rem;
height: 2.5rem;
color: var(--wrn-component-color, var(--wrn-color-primary));
}
.wrn-navbar__brand-copy {
display: grid;
min-width: 0;
line-height: 1.2;
}
.wrn-navbar__brand-copy strong {
font-size: 0.9rem;
font-weight: 600;
}
.wrn-navbar__brand-copy small {
margin-top: 0.2rem;
color: var(--wrn-color-muted);
font-size: 0.6875rem;
font-weight: 400;
}
.wrn-navbar__collapse,
.wrn-navbar__menus,
.wrn-navbar__actions {
display: flex;
align-items: center;
}
.wrn-navbar__collapse {
min-width: 0;
flex: 1;
gap: 1rem;
}
.wrn-navbar__menus {
justify-content: center;
flex: 1;
gap: 0.25rem;
}
.wrn-navbar__actions {
justify-content: flex-end;
gap: 0.5rem;
}
.wrn-navbar__menu-link,
.wrn-navbar__dropdown > summary,
.wrn-navbar__action {
display: inline-flex;
min-height: 2.75rem;
padding: 0.65rem 0.8rem;
align-items: center;
gap: 0.45rem;
border-radius: 0.65rem;
cursor: pointer;
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap;
}
.wrn-navbar__menu-link:hover,
.wrn-navbar__dropdown > summary:hover {
color: var(--wrn-color-text);
background: var(--wrn-color-surface-2);
}
.wrn-navbar__menu-link[aria-current="page"],
.wrn-navbar__dropdown > summary[aria-current="page"] {
color: var(--wrn-component-color, var(--wrn-color-primary));
font-weight: 600;
background: color-mix(
in srgb,
var(--wrn-component-color, var(--wrn-color-primary)) 12%,
transparent
);
box-shadow: inset 0 -2px 0 var(--wrn-component-color, var(--wrn-color-primary));
}
.wrn-navbar__menu-link[aria-current="page"]:hover,
.wrn-navbar__dropdown > summary[aria-current="page"]:hover {
color: var(--wrn-component-color, var(--wrn-color-primary));
background: color-mix(
in srgb,
var(--wrn-component-color, var(--wrn-color-primary)) 16%,
transparent
);
}
.wrn-navbar__dropdown {
position: relative;
}
.wrn-navbar__dropdown > summary {
list-style: none;
}
.wrn-navbar__dropdown > summary::-webkit-details-marker {
display: none;
}
.wrn-navbar__chevron {
width: 0.45rem;
height: 0.45rem;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: rotate(45deg) translateY(-0.15rem);
transition: transform var(--wrn-motion-fast, 150ms) ease;
}
.wrn-navbar__dropdown[open] .wrn-navbar__chevron {
transform: rotate(225deg) translate(-0.1rem, -0.1rem);
}
.wrn-navbar__panel {
position: absolute;
top: calc(100% + 0.55rem);
left: 50%;
display: grid;
width: max-content;
min-width: 15rem;
max-width: min(90vw, 64rem);
padding: 0.65rem;
gap: 0.45rem;
border: 1px solid var(--wrn-color-border);
border-radius: 0.9rem;
color: var(--wrn-color-text);
background: var(--wrn-color-surface);
box-shadow: 0 18px 48px rgb(15 23 42 / 0.16);
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
transform-origin: top center;
transition:
opacity 180ms ease,
transform 180ms cubic-bezier(0.22, 1, 0.36, 1),
display 180ms allow-discrete;
}
.wrn-navbar__panel::before {
position: absolute;
right: 0;
bottom: 100%;
left: 0;
height: 0.65rem;
content: "";
}
.wrn-navbar__dropdown:not([open]) > .wrn-navbar__panel {
display: none;
opacity: 0;
transform: translateX(-50%) translateY(-0.5rem) scale(0.98);
}
@starting-style {
.wrn-navbar__dropdown[open] > .wrn-navbar__panel {
opacity: 0;
transform: translateX(-50%) translateY(-0.5rem) scale(0.98);
}
}
.wrn-navbar__dropdown--mega {
position: static;
}
.wrn-navbar__dropdown--mega .wrn-navbar__panel {
right: auto;
left: 50%;
width: min(calc(100vw - 2rem), 64rem);
}
.wrn-navbar__panel--columns-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.wrn-navbar__panel--columns-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.wrn-navbar__panel--columns-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.wrn-navbar__panel-intro {
grid-column: 1 / -1;
margin: 0;
padding: 0.5rem 0.65rem;
color: var(--wrn-color-muted);
font-size: 0.8125rem;
font-weight: 400;
}
.wrn-navbar__group {
display: grid;
align-content: start;
gap: 0.25rem;
}
.wrn-navbar__group-title {
padding: 0.55rem 0.65rem 0.25rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.wrn-navbar__group > small {
padding: 0 0.65rem 0.4rem;
color: var(--wrn-color-muted);
font-size: 0.7rem;
font-weight: 400;
}
.wrn-navbar__panel a {
display: flex;
min-height: 2.75rem;
padding: 0.65rem;
align-items: flex-start;
gap: 0.65rem;
border-radius: 0.6rem;
font-size: 0.8rem;
font-weight: 450;
}
.wrn-navbar__panel a:hover {
background: var(--wrn-color-surface-2);
}
.wrn-navbar__panel a span:last-child {
display: grid;
gap: 0.15rem;
}
.wrn-navbar__panel a strong {
font-weight: 500;
}
.wrn-navbar__panel a small {
color: var(--wrn-color-muted);
font-size: 0.7rem;
font-weight: 400;
}
.wrn-navbar__action {
border: 1px solid transparent;
}
.wrn-navbar__action--primary {
color: var(--wrn-color-on-primary, #fff);
background: var(--wrn-component-color, var(--wrn-color-primary));
font-weight: 600;
}
.wrn-navbar__action--outline {
border-color: var(--wrn-color-border);
}
.wrn-navbar__toggle {
display: none;
width: 2.75rem;
height: 2.75rem;
margin-left: auto;
padding: 0.65rem;
border: 1px solid var(--wrn-color-border);
border-radius: 0.65rem;
color: inherit;
background: var(--wrn-color-surface);
}
.wrn-navbar__toggle span {
display: block;
height: 2px;
margin: 0.25rem 0;
background: currentColor;
}
@media (max-width: 900px) {
.wrn-navbar__main {
min-height: 3.5rem;
padding-block: 0.4rem;
}
.wrn-navbar--width-compact .wrn-navbar__main {
width: 100%;
}
.wrn-navbar__toggle {
display: block;
}
.wrn-navbar__collapse {
position: absolute;
top: 100%;
right: 0;
left: 0;
display: none;
padding: 0.75rem 1rem 1rem;
align-items: stretch;
flex-direction: column;
background: var(--wrn-color-surface);
border-bottom: 1px solid var(--wrn-color-border);
box-shadow: 0 16px 32px rgb(15 23 42 / 0.12);
height: calc(100dvh - 3.5rem);
max-height: calc(100dvh - 3.5rem);
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
}
.wrn-navbar__collapse.is-open {
display: flex;
}
.wrn-navbar:has(.wrn-navbar__collapse.is-open)::after {
position: fixed;
z-index: -1;
inset: 0;
background: rgb(15 23 42 / 0.42);
content: "";
}
.wrn-navbar__menus,
.wrn-navbar__actions {
width: 100%;
align-items: stretch;
flex-direction: column;
}
.wrn-navbar__menu-link,
.wrn-navbar__dropdown > summary,
.wrn-navbar__action {
width: 100%;
}
.wrn-navbar__panel,
.wrn-navbar__dropdown--mega .wrn-navbar__panel {
position: static;
width: 100%;
max-width: none;
margin-top: 0.25rem;
box-shadow: none;
transform: none;
overflow: hidden;
animation: wrn-navbar-mobile-panel 180ms ease both;
}
.wrn-navbar__dropdown:not([open]) > .wrn-navbar__panel,
.wrn-navbar__dropdown[open] > .wrn-navbar__panel {
transform: none;
}
.wrn-navbar__panel[class*="wrn-navbar__panel--columns-"] {
grid-template-columns: 1fr;
}
.wrn-navbar__panel::before {
display: none;
}
}
@media (max-width: 600px) {
.wrn-navbar__brand-copy small {
display: none;
}
}
@keyframes wrn-navbar-mobile-panel {
from {
opacity: 0;
transform: translateY(-0.35rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Shared component foundation. */
.wrn-component {
--wrn-component-color: var(--wrn-color-primary);
--wrn-component-scale: 1;
margin: 0;
color: var(--wrn-color-text);
font-family: var(--wrn-font-sans, inherit);
}
.wrn-component--color-primary {
--wrn-component-color: var(--wrn-color-primary);
}
.wrn-component--color-secondary {
--wrn-component-color: var(--wrn-color-secondary);
}
.wrn-component--color-success {
--wrn-component-color: var(--wrn-color-success);
}
.wrn-component--color-warning {
--wrn-component-color: var(--wrn-color-warning);
}
.wrn-component--color-danger {
--wrn-component-color: var(--wrn-color-danger);
}
.wrn-component--color-info {
--wrn-component-color: var(--wrn-color-info);
}
.wrn-component--size-xs {
--wrn-component-scale: 0.78;
}
.wrn-component--size-sm {
--wrn-component-scale: 0.88;
}
.wrn-component--size-default,
.wrn-component--size-md {
--wrn-component-scale: 1;
}
.wrn-component--size-lg {
--wrn-component-scale: 1.14;
}
.wrn-component--size-xl {
--wrn-component-scale: 1.28;
}
.wrn-component:not(.wrn-btn) {
font-size: calc(1em * var(--wrn-component-scale));
}
.wrn-component :is(a, button, input, select, textarea):focus-visible {
outline-color: var(--wrn-component-color);
}
.wrn-component p {
margin: 0;
color: var(--wrn-color-muted);
line-height: 1.6;
}
}
}