feat(ui): add typed navbar menu items
Quality / quality (ubuntu-latest) (push) Failing after 11m12s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-24 21:36:46 +05:30
parent fc1eea3939
commit 9d64ec60c4
9 changed files with 396 additions and 27 deletions
+56
View File
@@ -90,6 +90,62 @@ the component directory at runtime.
For the full catalog, see [`COMPONENTS.md`](./COMPONENTS.md). The machine-readable
equivalent is exported as `@wrnexus/ui/component-reference.json`.
### Typed Navbar items
Keep navigation data in a shared TypeScript module and validate it with the
package contract. `NavbarItem` is a discriminated union of `link`, `dropdown`,
and `mega`; a mega item accepts every `MegaMenu` layout option.
```ts
import type { NavbarItem } from "@wrnexus/ui";
export const navigation = [
{ type: "link", label: "Pricing", href: "/pricing" },
{
type: "dropdown",
label: "Company",
items: [
{ label: "About", href: "/about" },
{ label: "Contact", href: "/contact" },
],
},
{
type: "mega",
label: "Products",
mega: {
variant: "icon-grid",
panelWidth: "2xl",
columnCount: 3,
rail: [{ label: "All products", href: "/products" }],
columns: [
{
heading: "Work",
items: [
{
label: "Projects",
href: "/products/projects",
icon: "icon-[lucide--folders]",
description: "Plan and deliver work",
badge: "New",
},
],
},
],
featured: {
title: "What's new",
actionLabel: "Explore",
actionHref: "/new",
},
actionLabel: "View all products",
actionHref: "/products",
},
},
] satisfies NavbarItem[];
```
Importing from `@wrnexus/ui/navigation` is also supported. Existing untyped
Navbar data using `children` remains accepted at runtime for migration.
## API
The JS module (`@wrnexus/ui`) exposes five helpers used by the build tooling to
+126 -11
View File
@@ -44,6 +44,60 @@ size: string = "default"
const slug = String(source).replace(/^\/+|\/+$/g, "").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase()
return slug ? translationPrefix + "." + slug + (field === "description" ? ".description" : "") : ""
}
shared function itemType(item) {
if (item && item.type) {
return item.type
}
return item && ((item.items && item.items.length) || (item.children && item.children.length)) ? "dropdown" : "link"
}
shared function dropdownItems(item) {
if (!item) {
return []
}
if (Array.isArray(item.items)) {
return item.items
}
return Array.isArray(item.children) ? item.children : []
}
shared function dropdownGroupItems(item) {
if (!item) {
return []
}
if (Array.isArray(item.items)) {
return item.items
}
return Array.isArray(item.children) ? item.children : []
}
shared function megaData(item) {
return item && item.mega && typeof item.mega === "object" ? item.mega : item || {}
}
shared function megaColumns(item) {
const data = megaData(item)
if (Array.isArray(data.columns)) {
return data.columns
}
const legacy = dropdownItems(item)
if (!legacy.length) {
return []
}
const grouped = legacy.some(function (entry) {
return dropdownGroupItems(entry).length > 0
})
if (grouped) {
return legacy.map(function (entry) {
return {
heading: entry.label || "",
description: entry.description || "",
items: dropdownGroupItems(entry)
}
})
}
return [{ heading: item.description || "", items: legacy }]
}
client function toggleNavigation() {
mobileOpen = !mobileOpen
output.toggle({ open: mobileOpen })
@@ -61,10 +115,31 @@ size: string = "default"
}
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) {
if (!item) {
return false
}
if (item.value && item.value === active) {
return true
}
if (itemType(item) === "mega") {
const data = megaData(item)
const links = Array.isArray(data.rail) ? data.rail.slice() : []
megaColumns(item).forEach(function (column) {
if (column && Array.isArray(column.items)) {
column.items.forEach(function (child) {
links.push(child)
})
}
})
return links.some(function (child) {
return child.value && child.value === active
})
}
const entries = dropdownItems(item)
if (!entries.length) {
return false
}
return entries.some(function (child) {
return isItemActive(child)
})
}
@@ -104,7 +179,27 @@ size: string = "default"
<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}
{#if itemType(item) === "mega"}
<MegaMenu
class="wrn-navbar__mega"
data-active="{isItemActive(item) ? 'true' : 'false'}"
label="{item.label}"
icon="{item.icon || ''}"
columns="{megaColumns(item)}"
rail="{megaData(item).rail || []}"
featured="{megaData(item).featured || {}}"
variant="{megaData(item).variant || 'default'}"
density="{megaData(item).density || 'default'}"
panelWidth="{megaData(item).panelWidth || 'xl'}"
panelAlign="{megaData(item).panelAlign || 'start'}"
columnCount="{megaData(item).columnCount || 0}"
fullWidth="{megaData(item).fullWidth || false}"
actionLabel="{megaData(item).actionLabel || ''}"
actionHref="{megaData(item).actionHref || ''}"
actionIcon="{megaData(item).actionIcon || 'icon-[lucide--arrow-right]'}"
footer="{megaData(item).footer || ''}"
/>
{:else if itemType(item) === "dropdown"}
<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}
@@ -113,12 +208,12 @@ size: string = "default"
</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}
{#each dropdownItems(item) as child}
<div class="wrn-navbar__group">
{#if child.children && child.children.length}
{#if dropdownGroupItems(child).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}
{#each dropdownGroupItems(child) 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>
@@ -309,6 +404,18 @@ size: string = "default"
gap: 0.5rem;
}
.wrn-navbar__mega {
flex: 0 0 auto;
}
.wrn-navbar__mega .wrn-mega__trigger {
min-height: 2.75rem;
padding: 0.65rem 0.8rem;
border-radius: 0.65rem;
font-size: 0.8125rem;
font-weight: 500;
}
.wrn-navbar__menu-link,
.wrn-navbar__dropdown > summary,
.wrn-navbar__action {
@@ -325,13 +432,15 @@ size: string = "default"
}
.wrn-navbar__menu-link:hover,
.wrn-navbar__dropdown > summary:hover {
.wrn-navbar__dropdown > summary:hover,
.wrn-navbar__mega .wrn-mega__trigger: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"] {
.wrn-navbar__dropdown > summary[aria-current="page"],
.wrn-navbar__mega[data-active="true"] .wrn-mega__trigger {
color: var(--wrn-component-color, var(--wrn-color-primary));
font-weight: 600;
background: color-mix(
@@ -343,7 +452,8 @@ size: string = "default"
}
.wrn-navbar__menu-link[aria-current="page"]:hover,
.wrn-navbar__dropdown > summary[aria-current="page"]:hover {
.wrn-navbar__dropdown > summary[aria-current="page"]:hover,
.wrn-navbar__mega[data-active="true"] .wrn-mega__trigger:hover {
color: var(--wrn-component-color, var(--wrn-color-primary));
background: color-mix(
in srgb,
@@ -587,6 +697,10 @@ size: string = "default"
align-items: stretch;
flex-direction: column;
}
.wrn-navbar__mega,
.wrn-navbar__mega .wrn-mega__trigger {
width: 100%;
}
.wrn-navbar__menu-link,
.wrn-navbar__dropdown > summary,
.wrn-navbar__action {
@@ -641,6 +755,7 @@ size: string = "default"
flex-direction: column;
}
.wrn-navbar[data-collapse-at="1100px"] :is(.wrn-navbar__menu-link, .wrn-navbar__dropdown > summary, .wrn-navbar__action) { width: 100%; }
.wrn-navbar[data-collapse-at="1100px"] :is(.wrn-navbar__mega, .wrn-navbar__mega .wrn-mega__trigger) { width: 100%; }
.wrn-navbar[data-collapse-at="1100px"] .wrn-navbar__panel { position: static; width: 100%; max-width: none; transform: none; }
}
+2 -1
View File
@@ -1,11 +1,12 @@
{
"name": "@wrnexus/ui",
"version": "0.8.28",
"version": "0.8.29",
"private": true,
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./navigation": "./src/navigation.ts",
"./registry": "./src/registry.ts",
"./components/*": "./components/*",
"./component-catalog.json": "./component-catalog.json",
+19 -1
View File
@@ -24,4 +24,22 @@
* server-only subpath. Import from there instead if you need them.
*/
export {};
export type {
MegaMenuColumn,
MegaMenuDensity,
MegaMenuFeatured,
MegaMenuPanelAlign,
MegaMenuPanelWidth,
MegaMenuRailItem,
MegaMenuVariant,
NavbarAction,
NavbarBrand,
NavbarDropdownGroup,
NavbarDropdownItem,
NavbarItem,
NavbarItemType,
NavbarLinkItem,
NavbarMegaMenuData,
NavbarMegaMenuItem,
NavigationLink,
} from "./navigation";
+120
View File
@@ -0,0 +1,120 @@
/** Shared, serializable navigation contracts for Navbar and application configs. */
export type NavbarItemType = "link" | "dropdown" | "mega";
export type MegaMenuVariant = "default" | "icon-grid" | "catalog" | "dark";
export type MegaMenuDensity = "compact" | "default" | "comfortable";
export type MegaMenuPanelWidth = "sm" | "md" | "lg" | "xl" | "2xl" | "full";
export type MegaMenuPanelAlign = "start" | "center" | "end";
export interface NavigationLink {
label: string;
href: string;
value?: string;
labelKey?: string;
description?: string;
descriptionKey?: string;
icon?: string;
image?: string;
imageAlt?: string;
eyebrow?: string;
badge?: string;
target?: string;
rel?: string;
disabled?: boolean;
}
export interface NavbarLinkItem extends NavigationLink {
type?: "link";
}
export interface NavbarDropdownGroup {
label?: string;
labelKey?: string;
description?: string;
descriptionKey?: string;
items: NavigationLink[];
/** Compatibility alias accepted by Navbar. Prefer `items`. */
children?: NavigationLink[];
}
export interface NavbarDropdownItem {
type: "dropdown";
label: string;
value?: string;
labelKey?: string;
description?: string;
descriptionKey?: string;
icon?: string;
columns?: number;
items: Array<NavigationLink | NavbarDropdownGroup>;
/** Compatibility alias accepted by Navbar. Prefer `items`. */
children?: Array<NavigationLink | NavbarDropdownGroup>;
}
export interface MegaMenuColumn {
heading?: string;
eyebrow?: string;
description?: string;
image?: string;
imageAlt?: string;
items: NavigationLink[];
actionLabel?: string;
actionHref?: string;
}
export interface MegaMenuRailItem extends NavigationLink {}
export interface MegaMenuFeatured {
eyebrow?: string;
image?: string;
imageAlt?: string;
title?: string;
description?: string;
actionLabel?: string;
actionHref?: string;
}
export interface NavbarMegaMenuData {
variant?: MegaMenuVariant;
density?: MegaMenuDensity;
panelWidth?: MegaMenuPanelWidth;
panelAlign?: MegaMenuPanelAlign;
fullWidth?: boolean;
columnCount?: number;
columns: MegaMenuColumn[];
rail?: MegaMenuRailItem[];
featured?: MegaMenuFeatured;
actionLabel?: string;
actionHref?: string;
actionIcon?: string;
footer?: string;
}
export interface NavbarMegaMenuItem {
type: "mega";
label: string;
value?: string;
labelKey?: string;
icon?: string;
mega: NavbarMegaMenuData;
}
export type NavbarItem = NavbarLinkItem | NavbarDropdownItem | NavbarMegaMenuItem;
export interface NavbarAction extends NavigationLink {
variant?: "link" | "outline" | "primary";
}
export interface NavbarBrand {
label?: string;
labelKey?: string;
description?: string;
descriptionKey?: string;
href?: string;
ariaLabel?: string;
icon?: string;
logo?: string;
logoAlt?: string;
logoWidth?: number | string;
logoHeight?: number | string;
}
+51
View File
@@ -0,0 +1,51 @@
import { expect, test } from "bun:test";
import type { NavbarItem } from "../src/navigation";
const items = [
{ type: "link", label: "Pricing", href: "/pricing" },
{
type: "dropdown",
label: "Company",
items: [{ label: "About", href: "/about" }],
},
{
type: "mega",
label: "Products",
mega: {
variant: "icon-grid",
density: "compact",
panelWidth: "2xl",
panelAlign: "center",
columnCount: 3,
fullWidth: true,
rail: [{ label: "All products", href: "/products", icon: "icon-products" }],
columns: [
{
heading: "Work",
items: [
{
label: "Projects",
href: "/projects",
icon: "icon-projects",
description: "Plan and deliver work",
badge: "New",
},
],
},
],
featured: {
title: "What's new",
image: "/new.png",
actionLabel: "Explore",
actionHref: "/new",
},
footer: "One connected platform",
actionLabel: "View all products",
actionHref: "/products",
},
},
] satisfies NavbarItem[];
test("navbar item contract covers links, dropdowns, and complete mega menus", () => {
expect(items.map((item) => item.type)).toEqual(["link", "dropdown", "mega"]);
});
+20 -12
View File
@@ -239,7 +239,9 @@ test("navbar supports brands, nested menus, actions, utility slots, and public e
expect(defaults.get("openOnHover")).toBe("false");
expect(defaults.get("maxWidth")).toBe('"full"');
expect(source).toContain("item.children");
expect(source).toContain("child.children");
expect(source).toContain("dropdownGroupItems(child)");
expect(source).toContain('itemType(item) === "mega"');
expect(source).toContain("megaData(item).featured");
expect(source).toContain("wrn-navbar__dropdown--{item.type || 'dropdown'}");
const outputs = new Set(contract.outputs.map((output) => output.name));
for (const event of ["toggle", "open", "close", "select", "action"]) {
@@ -304,13 +306,19 @@ test("navbar renders a brand, nested dropdowns, mega groups, and actions", async
{
label: "Safety",
type: "mega",
columns: 2,
children: [
{
label: "Resources",
children: [{ label: "Guidance", href: "/guidance" }],
},
],
mega: {
variant: "icon-grid",
columnCount: 2,
rail: [{ label: "Overview", href: "/safety" }],
columns: [
{
heading: "Resources",
items: [{ label: "Guidance", href: "/guidance", badge: "New" }],
},
],
featured: { title: "Safety report", actionLabel: "Read report" },
actionLabel: "All safety resources",
},
},
],
actions: [{ label: "Sign in", href: "/login", variant: "primary" }],
@@ -320,7 +328,9 @@ test("navbar renders a brand, nested dropdowns, mega groups, and actions", async
expect(html).toContain("Public portal");
expect(html).toContain("Reports");
expect(html).toContain("Guidance");
expect(html).toContain("wrn-navbar__dropdown--mega");
expect(html).toContain("wrn-navbar__mega");
expect(html).toContain("Safety report");
expect(html).toContain("All safety resources");
expect(html).toContain("data-wrn-navbar");
expect(html).toContain('name="wrn-navbar-menu"');
expect(html).toContain("Sign in");
@@ -2951,9 +2961,7 @@ test("mega menu supports rails, icons, badges, media, featured content and foote
heading: "Products",
description: "Choose a workspace",
image: "/products.png",
items: [
{ label: "CRM", description: "Customer records", icon: "icon-crm", badge: "New" },
],
items: [{ label: "CRM", description: "Customer records", icon: "icon-crm", badge: "New" }],
actionLabel: "All products",
},
],