Skip to content

Nav #31033

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft

Nav #31033

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
1 change: 1 addition & 0 deletions .ng-dev/commit-message.mts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const commitMessage: CommitMessageConfig = {
'cdk-experimental/column-resize',
'cdk-experimental/combobox',
'cdk-experimental/listbox',
'cdk-experimental/nav',
'cdk-experimental/popover-edit',
'cdk-experimental/scrolling',
'cdk-experimental/selection',
Expand Down
1 change: 1 addition & 0 deletions src/cdk-experimental/config.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ CDK_EXPERIMENTAL_ENTRYPOINTS = [
"combobox",
"deferred-content",
"listbox",
"nav",
"popover-edit",
"scrolling",
"selection",
Expand Down
17 changes: 17 additions & 0 deletions src/cdk-experimental/nav/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
load("//tools:defaults.bzl", "ng_project")

package(default_visibility = ["//visibility:public"])

ng_project(
name = "nav",
srcs = glob(
["**/*.ts"],
exclude = ["**/*.spec.ts"],
),
deps = [
"//:node_modules/@angular/core",
"//src/cdk-experimental/ui-patterns",
"//src/cdk/a11y",
"//src/cdk/bidi",
],
)
9 changes: 9 additions & 0 deletions src/cdk-experimental/nav/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

export * from './public-api';
186 changes: 186 additions & 0 deletions src/cdk-experimental/nav/nav.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {Directionality} from '@angular/cdk/bidi';
import {_IdGenerator} from '@angular/cdk/a11y';
import {
AfterViewInit,
booleanAttribute,
computed,
contentChildren,
Directive,
effect,
ElementRef,
inject,
input,
linkedSignal,
model,
signal,
WritableSignal,
} from '@angular/core';
import {toSignal} from '@angular/core/rxjs-interop';
import {LinkPattern, NavPattern} from '../ui-patterns';

/**
* A Nav container.
*
* Represents a list of navigational links. The CdkNav is a container meant to be used with
* CdkLink as follows:
*
* ```html
* <nav cdkNav [(value)]="selectedRoute">
* <a [value]="'/home'" cdkLink>Home</a>
* <a [value]="'/settings'" cdkLink>Settings</a>
* <a [value]="'/profile'" cdkLink [disabled]="true">Profile</a>
* </nav>
* ```
*/
@Directive({
selector: '[cdkNav]',
exportAs: 'cdkNav',
standalone: true,
host: {
'role': 'navigation', // Common role for <nav> elements or nav groups
'class': 'cdk-nav',
'[attr.tabindex]': 'pattern.tabindex()',
'[attr.aria-disabled]': 'pattern.disabled()',
// aria-orientation is not typically used directly on role="navigation"
'[attr.aria-activedescendant]': 'pattern.activedescendant()',
'(keydown)': 'pattern.onKeydown($event)',
'(pointerdown)': 'pattern.onPointerdown($event)',
},
})
export class CdkNav<V> implements AfterViewInit {
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
private readonly _directionality = inject(Directionality);

/** The CdkLinks nested inside of the CdkNav. */
private readonly _cdkLinks = contentChildren(CdkLink, {descendants: true});

/** A signal wrapper for directionality. */
protected textDirection = toSignal(this._directionality.change, {
initialValue: this._directionality.value,
});

/** The Link UIPatterns of the child CdkLinks. */
protected items = computed(() => this._cdkLinks().map(link => link.pattern as LinkPattern<V>));

/** Whether the nav is vertically or horizontally oriented. Affects Arrow Key navigation. */
orientation = input<'vertical' | 'horizontal'>('vertical');

/** Whether focus should wrap when navigating past the first or last link. */
wrap = input(false, {transform: booleanAttribute});

/** Whether disabled items in the list should be skipped when navigating. */
skipDisabled = input(true, {transform: booleanAttribute});

/** The focus strategy used by the nav ('roving' or 'activedescendant'). */
focusMode = input<'roving' | 'activedescendant'>('roving');

/** Whether the entire nav is disabled. */
disabled = input(false, {transform: booleanAttribute});

/** The value of the currently selected link. */
value = model<V[]>([]);

/** The index of the currently focused link. */
activeIndex = model<number>(0);

/** The internal selection value signal used by the ListSelection behavior (always V[]). */
private readonly _selectionValue: WritableSignal<V[]> = signal([]);

/** The amount of time before the typeahead search is reset. */
typeaheadDelay = input<number>(0.5); // Picked arbitrarily.

/** The Nav UIPattern instance providing the core logic. */
pattern: NavPattern<V> = new NavPattern<V>({
...this,
textDirection: this.textDirection,
items: this.items,
multi: signal(false),
selectionMode: signal('explicit'),
});

/** Whether the listbox has received focus yet. */
private _hasFocused = signal(false);

/** Whether the options in the listbox have been initialized. */
private _isViewInitialized = signal(false);

constructor() {
effect(() => {
if (this._isViewInitialized() && !this._hasFocused()) {
this.pattern.setDefaultState();
}
});
}

ngAfterViewInit() {
this._isViewInitialized.set(true);
}

onFocus() {
this._hasFocused.set(true);
}
}

/** A selectable link within a CdkNav container. */
@Directive({
selector: '[cdkLink]',
exportAs: 'cdkLink',
standalone: true,
host: {
'role': 'link',
'class': 'cdk-link',
// cdk-active reflects focus/active descendant state
'[class.cdk-active]': 'pattern.active()',
'[attr.id]': 'pattern.id()',
'[attr.tabindex]': 'pattern.tabindex()',
// Use aria-current="page" for the selected/activated link, common for navigation
'[attr.aria-current]': 'pattern.selected() ? "page" : null',
'[attr.aria-disabled]': 'pattern.disabled()',
},
})
export class CdkLink<V> {
/** A reference to the host link element. */
private readonly _elementRef = inject(ElementRef<HTMLElement>);

/** The parent CdkNav instance. */
private readonly _cdkNav = inject(CdkNav<V>);

/** A unique identifier for the link, lazily generated. */
private readonly _idSignal = signal(inject(_IdGenerator).getId('cdk-link-'));

/** The parent Nav UIPattern from the CdkNav container. */
protected nav = computed(() => this._cdkNav.pattern);

/** A signal reference to the host link element. */
protected element = computed(() => this._elementRef.nativeElement);

/** Whether the link is disabled. Disabled links cannot be selected or navigated to. */
disabled = input(false, {transform: booleanAttribute});

/** The unique value associated with this link (e.g., a route path or identifier). */
value = input.required<V>();

/** Optional text used for typeahead matching. Defaults to the element's textContent. */
label = input<string>();

/** The text used by the typeahead functionality. */
protected searchTerm = computed(() => this.label() ?? this.element().textContent?.trim() ?? '');

/** The Link UIPattern instance providing the core logic for this link. */
pattern: LinkPattern<V> = new LinkPattern<V>({
id: this._idSignal,
value: this.value,
disabled: this.disabled,
searchTerm: this.searchTerm,
nav: this.nav,
element: this.element,
});
}
9 changes: 9 additions & 0 deletions src/cdk-experimental/nav/public-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

export {CdkNav, CdkLink} from './nav';
1 change: 1 addition & 0 deletions src/cdk-experimental/ui-patterns/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ ts_project(
"//:node_modules/@angular/core",
"//src/cdk-experimental/ui-patterns/behaviors/signal-like",
"//src/cdk-experimental/ui-patterns/listbox",
"//src/cdk-experimental/ui-patterns/nav",
"//src/cdk-experimental/ui-patterns/radio",
"//src/cdk-experimental/ui-patterns/tabs",
],
Expand Down
20 changes: 20 additions & 0 deletions src/cdk-experimental/ui-patterns/nav/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
load("//tools:defaults.bzl", "ts_project")

package(default_visibility = ["//visibility:public"])

ts_project(
name = "nav",
srcs = glob(
["**/*.ts"],
exclude = ["**/*.spec.ts"],
),
deps = [
"//:node_modules/@angular/core",
"//src/cdk-experimental/ui-patterns/behaviors/event-manager",
"//src/cdk-experimental/ui-patterns/behaviors/list-focus",
"//src/cdk-experimental/ui-patterns/behaviors/list-navigation",
"//src/cdk-experimental/ui-patterns/behaviors/list-selection",
"//src/cdk-experimental/ui-patterns/behaviors/list-typeahead",
"//src/cdk-experimental/ui-patterns/behaviors/signal-like",
],
)
80 changes: 80 additions & 0 deletions src/cdk-experimental/ui-patterns/nav/link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {computed} from '@angular/core';
import {ListSelection, ListSelectionItem} from '../behaviors/list-selection/list-selection';
import {ListTypeaheadItem} from '../behaviors/list-typeahead/list-typeahead';
import {ListNavigation, ListNavigationItem} from '../behaviors/list-navigation/list-navigation';
import {ListFocus, ListFocusItem} from '../behaviors/list-focus/list-focus';
import {SignalLike} from '../behaviors/signal-like/signal-like';

/**
* Represents the properties exposed by a nav that need to be accessed by a link.
* This exists to avoid circular dependency errors between the nav and link.
*/
interface NavPattern<V> {
focusManager: ListFocus<LinkPattern<V>>;
selection: ListSelection<LinkPattern<V>, V>;
navigation: ListNavigation<LinkPattern<V>>;
}

/** Represents the required inputs for a link in a nav. */
export interface LinkInputs<V>
extends ListNavigationItem,
ListSelectionItem<V>,
ListTypeaheadItem,
ListFocusItem {
nav: SignalLike<NavPattern<V> | undefined>;
}

/** Represents a link in a nav. */
export class LinkPattern<V> {
/** A unique identifier for the link. */
id: SignalLike<string>;

/** The value of the link, typically a URL or route path. */
value: SignalLike<V>;

/** The position of the link in the list. */
index = computed(
() =>
this.nav()
?.navigation.inputs.items()
.findIndex(i => i.id() === this.id()) ?? -1,
);

/** Whether the link is active (focused). */
active = computed(() => this.nav()?.focusManager.activeItem() === this);

/** Whether the link is selected (activated). */
selected = computed(() => this.nav()?.selection.inputs.value().includes(this.value()));

/** Whether the link is disabled. */
disabled: SignalLike<boolean>;

/** The text used by the typeahead search. */
searchTerm: SignalLike<string>;

/** A reference to the parent nav. */
nav: SignalLike<NavPattern<V> | undefined>;

/** The tabindex of the link. */
tabindex = computed(() => this.nav()?.focusManager.getItemTabindex(this));

/** The html element that should receive focus. */
element: SignalLike<HTMLElement>;

constructor(args: LinkInputs<V>) {
this.id = args.id;
this.value = args.value;
this.nav = args.nav;
this.element = args.element;
this.disabled = args.disabled;
this.searchTerm = args.searchTerm;
}
}
Loading
Loading