From 9d64ec60c468627f524fd5ce8065f5e98bcf3028 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Mon, 24 Aug 2026 21:36:46 +0530 Subject: [PATCH] feat(ui): add typed navbar menu items --- bun.lock | 2 +- docs/ui-visual-contract-0.8.json | 2 +- packages/ui/README.md | 56 +++++++++ packages/ui/components/Navbar.wrn | 137 ++++++++++++++++++++-- packages/ui/package.json | 3 +- packages/ui/src/index.ts | 20 +++- packages/ui/src/navigation.ts | 120 +++++++++++++++++++ packages/ui/test/navigation-types.test.ts | 51 ++++++++ packages/ui/test/ui.test.ts | 32 +++-- 9 files changed, 396 insertions(+), 27 deletions(-) create mode 100644 packages/ui/src/navigation.ts create mode 100644 packages/ui/test/navigation-types.test.ts diff --git a/bun.lock b/bun.lock index f0d200d8..01a19c7c 100644 --- a/bun.lock +++ b/bun.lock @@ -688,7 +688,7 @@ }, "packages/ui": { "name": "@wrnexus/ui", - "version": "0.8.28", + "version": "0.8.29", "dependencies": { "@wrnexus/core": "workspace:*", }, diff --git a/docs/ui-visual-contract-0.8.json b/docs/ui-visual-contract-0.8.json index f4b56f55..17519a3b 100644 --- a/docs/ui-visual-contract-0.8.json +++ b/docs/ui-visual-contract-0.8.json @@ -60,7 +60,7 @@ "packages/ui/components/MetricGrid.wrn": "1fcebf51dcbe8520fe16bda904c1ef206c25c3900438d53877492c64ea21a1a5", "packages/ui/components/Modal.wrn": "7fa4b877772736f29f690e24d8742922b310397ef3083f0b78acb67a9f0d14b2", "packages/ui/components/Nav.wrn": "c45b0ecb42250f4b3ede33c8932a025f789dda9ef48dbf332459ae458163e69a", - "packages/ui/components/Navbar.wrn": "ce33334b98a363ea2c3e4ba111077ff2f3dc875f87e839729cce365789de6f65", + "packages/ui/components/Navbar.wrn": "e8f6a6367d2fe4086dc1f0fbbadc49828470a3c78ad27e30220368a5682392e5", "packages/ui/components/PageHeader.wrn": "321cde36ce8d521a57901033d46e8b437f42e558cca27f49ca26f7172aff1d54", "packages/ui/components/Pagination.wrn": "d54226705d4556f76ee5f0d6ae82d101ca6d006397756d547a9aa5954415ba39", "packages/ui/components/PinInput.wrn": "4dc398456f6392d7925db941debb484c7cb0358ecef527f696fe5ec4800a7602", diff --git a/packages/ui/README.md b/packages/ui/README.md index 12712863..b96babba 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -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 diff --git a/packages/ui/components/Navbar.wrn b/packages/ui/components/Navbar.wrn index 75caa739..e377d4ec 100644 --- a/packages/ui/components/Navbar.wrn +++ b/packages/ui/components/Navbar.wrn @@ -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"