Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/components/navigation-rail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<!-- catalog-only-start -->
<!-- ---
name: Navigation rail
title: Navigation rail
order: 19
---->
<!-- catalog-only-end -->

# Navigation rail

Navigation rails provide access to the top-level destinations of an app from
the side of a desktop or tablet layout.

> Navigation rail is currently experimental and is available under `labs`.

## Usage

Import the component and navigation tabs:

```ts
import '@material/web/labs/navigationrail/navigation-rail.js';
import '@material/web/labs/navigationtab/navigation-tab.js';
```

```html
<md-navigation-rail aria-label="Main navigation">
<md-navigation-tab label="Home">
<md-icon slot="active-icon">home</md-icon>
<md-icon slot="inactive-icon">home</md-icon>
</md-navigation-tab>
<md-navigation-tab label="Settings">
<md-icon slot="active-icon">settings</md-icon>
<md-icon slot="inactive-icon">settings</md-icon>
</md-navigation-tab>
</md-navigation-rail>
```

The rail uses `active-index` to select a destination. It emits the
`navigation-bar-activated` event when a destination changes. Arrow Up and Arrow
Down move focus between destinations; Home and End move to the first and last
destination.
24 changes: 20 additions & 4 deletions labs/navigationbar/internal/navigation-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export class NavigationBar
extends navigationBarBaseClass
implements NavigationBarState
{
/**
* The direction used for keyboard navigation. Navigation bars are
* horizontal; navigation rails override this to be vertical.
*/
protected navigationDirection: 'horizontal' | 'vertical' = 'horizontal';

@property({type: Number, attribute: 'active-index'}) activeIndex = 0;

@property({type: Boolean, attribute: 'hide-inactive-labels'})
Expand Down Expand Up @@ -128,8 +134,19 @@ export class NavigationBar
return;
}

const toNextTab =
(key === 'ArrowRight' && !isRTL) || (key === 'ArrowLeft' && isRTL);
const nextKey =
this.navigationDirection === 'vertical'
? 'ArrowDown'
: isRTL
? 'ArrowLeft'
: 'ArrowRight';
const previousKey =
this.navigationDirection === 'vertical'
? 'ArrowUp'
: isRTL
? 'ArrowRight'
: 'ArrowLeft';
const toNextTab = key === nextKey;
if (toNextTab && focusedTabIndex === maxIndex) {
this.tabs[0].focus();
return;
Expand All @@ -139,8 +156,7 @@ export class NavigationBar
return;
}

const toPreviousTab =
(key === 'ArrowLeft' && !isRTL) || (key === 'ArrowRight' && isRTL);
const toPreviousTab = key === previousKey;
if (toPreviousTab && focusedTabIndex === 0) {
this.tabs[maxIndex].focus();
return;
Expand Down
26 changes: 23 additions & 3 deletions labs/navigationbar/md-navigation-bar_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,16 @@ import {NavigationTabHarness} from '../navigationtab/harness.js';
import {MdNavigationTab} from '../navigationtab/navigation-tab.js';

import {NavigationBarHarness} from './harness.js';
import {NavigationRail} from '../navigationrail/internal/navigation-rail.js';
import {MdNavigationBar} from './navigation-bar.js';

@customElement('md-test-navigation-rail-direction')
class TestNavigationRail extends NavigationRail {
get direction() {
return this.navigationDirection;
}
}

@customElement('md-test-navigation-bar')
class TestMdNavigationBar extends MdNavigationBar {}
@customElement('md-test-navigation-bar-tab')
Expand Down Expand Up @@ -44,7 +52,8 @@ const navBarWithNavTabsElement = (propsInit: Partial<NavigationBarProps>) => {
<md-test-navigation-bar
.activeIndex="${propsInit.activeIndex ?? 0}"
.hideInactiveLabels="${propsInit.hideInactiveLabels === true}"
aria-label="${ifDefined(propsInit.ariaLabel)}">
aria-label="${ifDefined(propsInit.ariaLabel)}"
>
<md-test-navigation-bar-tab label="One"></md-test-navigation-bar-tab>
<md-test-navigation-bar-tab label="Two"></md-test-navigation-bar-tab>
</md-test-navigation-bar>
Expand All @@ -53,10 +62,12 @@ const navBarWithNavTabsElement = (propsInit: Partial<NavigationBarProps>) => {

// The following is a Navbar with the tabs being out of sync with the bar.
const navBarWithIncorrectTabsElement = html` <md-test-navigation-bar
activeIndex="0">
activeIndex="0"
>
<md-test-navigation-bar-tab
label="One"
hideInactiveLabel></md-test-navigation-bar-tab>
hideInactiveLabel
></md-test-navigation-bar-tab>
<md-test-navigation-bar-tab label="One" active></md-test-navigation-bar-tab>
</md-test-navigation-bar>`;

Expand Down Expand Up @@ -89,6 +100,15 @@ describe('md-navigation-bar', () => {
expect(harness.element.hideInactiveLabels).toBeFalse();
expect(navBarBase.getAttribute('aria-label')).toEqual(null);
});

describe('NavigationRail keyboard direction', () => {
it('uses vertical arrow keys', () => {
const rail = document.createElement(
'md-test-navigation-rail-direction',
) as TestNavigationRail;
expect(rail.direction).toBe('vertical');
});
});
});

describe('activeIndex', () => {
Expand Down
14 changes: 14 additions & 0 deletions labs/navigationrail/_navigation-rail.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0
//

@use './internal/navigation-rail';

@mixin theme($tokens) {
@include navigation-rail.theme($tokens);
}

@mixin styles() {
@include navigation-rail.styles();
}
4 changes: 4 additions & 0 deletions labs/navigationrail/demo/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "navigationrail",
"title": "Navigation rail"
}
43 changes: 43 additions & 0 deletions labs/navigationrail/demo/stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import '@material/web/icon/icon.js';
import '@material/web/labs/navigationrail/navigation-rail.js';
import '@material/web/labs/navigationtab/navigation-tab.js';

import {css, html} from 'lit';

import {MaterialStoryInit} from './material-collection.js';

const story: MaterialStoryInit = {
name: '<md-navigation-rail>',
styles: css`
:host {
--md-icon-font: 'Material Icons';
height: 360px;
}
`,
render() {
return html`
<md-navigation-rail aria-label="Main navigation">
<md-navigation-tab label="Home">
<md-icon slot="active-icon">home</md-icon>
<md-icon slot="inactive-icon">home</md-icon>
</md-navigation-tab>
<md-navigation-tab label="Search">
<md-icon slot="active-icon">search</md-icon>
<md-icon slot="inactive-icon">search</md-icon>
</md-navigation-tab>
<md-navigation-tab label="Settings">
<md-icon slot="active-icon">settings</md-icon>
<md-icon slot="inactive-icon">settings</md-icon>
</md-navigation-tab>
</md-navigation-rail>
`;
},
};

export const stories = [story];
9 changes: 9 additions & 0 deletions labs/navigationrail/harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {NavigationBarHarness} from '../navigationbar/harness.js';

export class NavigationRailHarness extends NavigationBarHarness {}
30 changes: 30 additions & 0 deletions labs/navigationrail/internal/_navigation-rail.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0
//

@use '../../navigationbar/internal/_navigation-bar.scss' as navigation-bar;

@mixin theme($tokens) {
@include navigation-bar.theme($tokens);
}

@mixin styles() {
@include navigation-bar.styles();

:host {
height: 100%;
width: auto;
}

.md3-navigation-bar {
flex-direction: column;
height: 100%;
width: var(--md-navigation-rail-width, 80px);
}

.md3-navigation-bar__tabs-slot-container {
flex-direction: column;
height: 100%;
}
}
8 changes: 8 additions & 0 deletions labs/navigationrail/internal/navigation-rail-styles.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0
//

@use './navigation-rail';

@include navigation-rail.styles();
14 changes: 14 additions & 0 deletions labs/navigationrail/internal/navigation-rail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {NavigationBar} from '../../navigationbar/internal/navigation-bar.js';

/**
* Base class for the vertical Material 3 navigation rail.
*/
export class NavigationRail extends NavigationBar {
protected override navigationDirection = 'vertical' as const;
}
28 changes: 28 additions & 0 deletions labs/navigationrail/navigation-rail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {CSSResultOrNative} from 'lit';
import {customElement} from 'lit/decorators.js';

import {NavigationRail} from './internal/navigation-rail.js';
import {styles} from './internal/navigation-rail-styles.cssresult.js';

declare global {
interface HTMLElementTagNameMap {
'md-navigation-rail': MdNavigationRail;
}
}

/**
* A vertical navigation component for switching between top-level destinations.
*
* @slot - Navigation tabs.
* @fires navigation-bar-activated
*/
@customElement('md-navigation-rail')
export class MdNavigationRail extends NavigationRail {
static override styles: CSSResultOrNative[] = [styles];
}