Navigation Menu
A site's navigation: links and buttons that show panels of links.
- v1.0.0
- WAI-ARIA: Disclosure navigation
- SSR
- Zoneless
- 3.5 kB gzip
import { Component } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from '@/ui/navigation-menu/navigation-menu';
/**
* A site's navigation: two buttons with panels of links, and a top-level
* link to the current page. Each panel follows its button in the page, so
* Tab moves into its links.
*/
@Component({
selector: 'example-navigation-menu-demo',
imports: [
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
NdIcon,
],
template: `
<nav ndNavigationMenu aria-label="Site">
<ul ndNavigationMenuList>
<li ndNavigationMenuItem>
<button ndNavigationMenuTrigger>
Getting started <svg ndIcon="chevron-down"></svg>
</button>
<div ndNavigationMenuContent>
<ul class="grid w-[300px] gap-1 md:w-[400px] md:grid-cols-2">
@for (link of guides; track link.href) {
<li>
<a ndNavigationMenuLink [href]="link.href">
<span class="font-medium">{{ link.title }}</span>
<span class="text-muted-foreground line-clamp-2">
{{ link.text }}
</span>
</a>
</li>
}
</ul>
</div>
</li>
<li ndNavigationMenuItem>
<button ndNavigationMenuTrigger>
Components <svg ndIcon="chevron-down"></svg>
</button>
<div ndNavigationMenuContent>
<ul class="grid w-[200px] gap-1">
@for (link of components; track link.href) {
<li>
<a ndNavigationMenuLink [href]="link.href">{{
link.title
}}</a>
</li>
}
</ul>
</div>
</li>
<li ndNavigationMenuItem>
<a ndNavigationMenuLink href="#docs" active [class]="triggerStyle">
Docs
</a>
</li>
</ul>
</nav>
`,
})
export class NavigationMenuDemoExample {
protected readonly triggerStyle = navigationMenuTriggerStyle;
protected readonly guides = [
{
href: '#introduction',
title: 'Introduction',
text: 'Accessible Angular components you copy into your app.',
},
{
href: '#installation',
title: 'Installation',
text: 'Set up Ndop in a new or existing Angular app.',
},
{
href: '#theming',
title: 'Theming',
text: 'Colors, radius and fonts, as CSS variables.',
},
{
href: '#forms',
title: 'Forms',
text: 'Bind Signal Forms, reactive or template-driven forms.',
},
];
protected readonly components = [
{ href: '#dialog', title: 'Dialog' },
{ href: '#select', title: 'Select' },
{ href: '#tabs', title: 'Tabs' },
];
}
A navigation menu is a site's main navigation: links, and buttons that show panels of more links below them. It uses no menu roles, as screen readers expect for links, and each panel stays right after its button in the page, so Tab moves into its links and they're in the server-rendered HTML. A panel opens on click, Enter or Space, or when the pointer rests on its button, and Escape closes it.
Installation
npx @ndop/cli add navigation-menupnpm dlx @ndop/cli add navigation-menuyarn dlx @ndop/cli add navigation-menubunx @ndop/cli add navigation-menuInstall the dependencies
npm install @ndop/primitives @ndop/corepnpm add @ndop/primitives @ndop/coreyarn add @ndop/primitives @ndop/corebun add @ndop/primitives @ndop/coreCopy the code into your project
At the paths your
ndop.jsonaliases point to; these use the defaults.src/app/ui/navigation-menu/navigation-menu.tsimport { computed, Directive, input } from '@angular/core'; import { NdNavigationMenu, NdNavigationMenuContent, NdNavigationMenuItem, NdNavigationMenuLink, NdNavigationMenuList, NdNavigationMenuTrigger, } from '@ndop/primitives/navigation-menu'; import { cn } from '@/lib/utils'; /** * Classes of a navigation menu's buttons; give them to a top-level link so * it looks like one. */ export const navigationMenuTriggerStyle = 'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-[color,box-shadow] outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent/50 data-[state=open]:text-accent-foreground data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent'; /** * A site's navigation (`@ndop/primitives/navigation-menu`): links, and * buttons that show panels of links below them. Each panel stays after its * button in the page, so its links render on the server. Put it on a `nav` * named with `aria-label`. * * @example * ```html * <nav ndNavigationMenu aria-label="Main"> * <ul ndNavigationMenuList> * <li ndNavigationMenuItem> * <button ndNavigationMenuTrigger> * Docs <svg ndIcon="chevron-down"></svg> * </button> * <div ndNavigationMenuContent> * <a ndNavigationMenuLink href="/docs">Introduction</a> * </div> * </li> * </ul> * </nav> * ``` */ @Directive({ selector: '[ndNavigationMenu]', hostDirectives: [ { directive: NdNavigationMenu, inputs: ['value', 'openDelay', 'closeDelay'], outputs: ['valueChange'], }, ], host: { 'data-slot': 'navigation-menu', // Panels sit under their own items, as shadcn's `viewport={false}`. 'data-viewport': 'false', '[class]': 'classes()', }, }) export class NavigationMenu { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center', this.class(), ), ); } /** * The list of top-level items, on a `ul`. * * @example * ```html * <ul ndNavigationMenuList>…</ul> * ``` */ @Directive({ selector: '[ndNavigationMenuList]', hostDirectives: [NdNavigationMenuList], host: { 'data-slot': 'navigation-menu-list', '[class]': 'classes()' }, }) export class NavigationMenuList { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'group flex flex-1 list-none items-center justify-center gap-1', this.class(), ), ); } /** * One top-level item, on an `li`: a link, or a button with its panel. * * @example * ```html * <li ndNavigationMenuItem>…</li> * ``` */ @Directive({ selector: '[ndNavigationMenuItem]', hostDirectives: [{ directive: NdNavigationMenuItem, inputs: ['value'] }], host: { 'data-slot': 'navigation-menu-item', '[class]': 'classes()' }, }) export class NavigationMenuItem { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('relative', this.class())); } /** * The button that shows its item's panel. Put the chevron icon in it, * last; it turns over while the panel is open. * * @example * ```html * <button ndNavigationMenuTrigger>Docs <svg ndIcon="chevron-down"></svg></button> * ``` */ @Directive({ selector: 'button[ndNavigationMenuTrigger]', hostDirectives: [ { directive: NdNavigationMenuTrigger, inputs: ['disabled'] }, ], host: { 'data-slot': 'navigation-menu-trigger', '[class]': 'classes()' }, }) export class NavigationMenuTrigger { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( navigationMenuTriggerStyle, 'group', '[&>svg]:relative [&>svg]:top-[1px] [&>svg]:ms-1 [&>svg]:size-3 [&>svg]:transition [&>svg]:duration-300 group-data-[state=open]:[&>svg]:rotate-180', this.class(), ), ); } /** * Its item's panel of links, shown below the item from the `md` * breakpoint, and in the flow of the page below it. * * @example * ```html * <div ndNavigationMenuContent>…</div> * ``` */ @Directive({ selector: '[ndNavigationMenuContent]', hostDirectives: [NdNavigationMenuContent], host: { 'data-slot': 'navigation-menu-content', '[class]': 'classes()' }, }) export class NavigationMenuContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'top-0 start-0 w-full p-2 pe-2.5 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out md:absolute md:w-auto', 'group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95', 'z-50', this.class(), ), ); } /** * A link, at the top level or in a panel; `active` marks the current page. * At the top level, give it {@link navigationMenuTriggerStyle} to match the * buttons. * * @example * ```html * <a ndNavigationMenuLink href="/docs" active>Docs</a> * ``` */ @Directive({ selector: 'a[ndNavigationMenuLink]', hostDirectives: [{ directive: NdNavigationMenuLink, inputs: ['active'] }], host: { 'data-slot': 'navigation-menu-link', '[class]': 'classes()' }, }) export class NavigationMenuLink { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( "flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground data-[active=true]:hover:bg-accent data-[active=true]:focus:bg-accent [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", this.class(), ), ); }src/app/lib/utils.ts/** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core';Update the import paths to match your project
The files import each other through the
@/uiand@/libaliases.
Usage
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from '@/ui/navigation-menu/navigation-menu';<nav ndNavigationMenu aria-label="Site">
<ul ndNavigationMenuList>
<li ndNavigationMenuItem>
<button ndNavigationMenuTrigger>
Getting started <svg ndIcon="chevron-down"></svg>
</button>
<div ndNavigationMenuContent>
<ul class="grid w-[300px] gap-1 md:w-[400px] md:grid-cols-2">
@for (link of guides; track link.href) {
<li>
<a ndNavigationMenuLink [href]="link.href">
<span class="font-medium">{{ link.title }}</span>
<span class="text-muted-foreground line-clamp-2">
{{ link.text }}
</span>
</a>
</li>
}
</ul>
</div>
</li>
<li ndNavigationMenuItem>
<button ndNavigationMenuTrigger>
Components <svg ndIcon="chevron-down"></svg>
</button>
<div ndNavigationMenuContent>
<ul class="grid w-[200px] gap-1">
@for (link of components; track link.href) {
<li>
<a ndNavigationMenuLink [href]="link.href">{{
link.title
}}</a>
</li>
}
</ul>
</div>
</li>
<li ndNavigationMenuItem>
<a ndNavigationMenuLink href="#docs" active [class]="triggerStyle">
Docs
</a>
</li>
</ul>
</nav>Accessibility
Implements the Disclosure navigation pattern of the WAI-ARIA Authoring Practices.
Keyboard
Keys the component handles.
| Key | Action |
|---|---|
| Tab | Moves focus through the buttons, top-level links and the open panel's links, in document order. |
| EnterorSpace | On a button, shows or hides its panel. |
| ArrowDown | On a button, opens its panel and focuses its first link. |
| ArrowRightorArrowLeft | On a top-level button or link, moves focus to the next or previous one, wrapping. |
| HomeorEnd | On a top-level button or link, moves focus to the first or last one. |
| Escape | Closes the open panel and returns focus to its button. |
Focus
- Opening a panel with
Enter,Spaceor a click keeps focus on the button. - Focus leaving the navigation closes the open panel.
Right-to-left
In right-to-left layouts the top-level items run from right to left, so ArrowLeft moves to the next one and ArrowRight to the previous one. Direction comes from the nearest dir attribute.
API reference
NavigationMenu
[ndNavigationMenu]A site's navigation (@ndop/primitives/navigation-menu): links, and
buttons that show panels of links below them. Each panel stays after its
button in the page, so its links render on the server. Put it on a nav
named with aria-label.
| NavigationMenu binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[openDelay]How long the pointer rests on a button before its panel opens, in ms. | number | 200 |
[closeDelay]How long a panel stays after the pointer leaves its item, in ms. | number | 300 |
[(value)]The open item's value, or null when every panel is closed. Two-way
bindable with [(value)]. | string | null | null |
| Data attribute | Values |
|---|---|
data-slot | navigation-menu |
data-viewport | false |
data-state | — |
NavigationMenuList
[ndNavigationMenuList]The list of top-level items, on a ul.
| NavigationMenuList binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | navigation-menu-list |
NavigationMenuItem
[ndNavigationMenuItem]One top-level item, on an li: a link, or a button with its panel.
| NavigationMenuItem binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[value]Its value, which the root's value holds while it's open. | string | injectId('nd-navigation-menu-item') |
| Data attribute | Values |
|---|---|
data-slot | navigation-menu-item |
data-state | — |
NavigationMenuTrigger
button[ndNavigationMenuTrigger]The button that shows its item's panel. Put the chevron icon in it, last; it turns over while the panel is open.
Manages aria-expanded, aria-controls.
| NavigationMenuTrigger binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[disabled]Disables it. | boolean | false |
| Data attribute | Values |
|---|---|
data-slot | navigation-menu-trigger |
data-state | — |
data-disabled | — |
NavigationMenuContent
[ndNavigationMenuContent]Its item's panel of links, shown below the item from the md
breakpoint, and in the flow of the page below it.
Manages id, hidden.
| NavigationMenuContent binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | navigation-menu-content |
data-state | — |
NavigationMenuLink
a[ndNavigationMenuLink]A link, at the top level or in a panel; active marks the current page.
At the top level, give it navigationMenuTriggerStyle to match the
buttons.
Manages aria-current.
| NavigationMenuLink binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[active]Marks the link to the current page. | boolean | false |
| Data attribute | Values |
|---|---|
data-slot | navigation-menu-link |
data-active | — |
Agent notes
Do
- Put it on a
navwith anaria-label. - Mark the link to the current page with
active. - Give top-level links
navigationMenuTriggerStyleso they match the buttons.
Don't
- Don't put actions or form fields in a panel; use Dropdown Menu or Popover.
- Don't hide the only link to an important page in a panel.