From 501e45f25e6dadd9434e878c1e455ae31e1d1ab3 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Mon, 5 May 2025 15:32:51 -0400 Subject: [PATCH 1/4] feat(cdk-experimental/ui-patterns): create nav pattern --- src/cdk-experimental/ui-patterns/BUILD.bazel | 1 + .../ui-patterns/nav/BUILD.bazel | 20 ++ src/cdk-experimental/ui-patterns/nav/link.ts | 80 +++++++ src/cdk-experimental/ui-patterns/nav/nav.ts | 223 ++++++++++++++++++ .../ui-patterns/public-api.ts | 2 + 5 files changed, 326 insertions(+) create mode 100644 src/cdk-experimental/ui-patterns/nav/BUILD.bazel create mode 100644 src/cdk-experimental/ui-patterns/nav/link.ts create mode 100644 src/cdk-experimental/ui-patterns/nav/nav.ts diff --git a/src/cdk-experimental/ui-patterns/BUILD.bazel b/src/cdk-experimental/ui-patterns/BUILD.bazel index 4b8299b571ee..f53aa82a44ec 100644 --- a/src/cdk-experimental/ui-patterns/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/BUILD.bazel @@ -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", ], diff --git a/src/cdk-experimental/ui-patterns/nav/BUILD.bazel b/src/cdk-experimental/ui-patterns/nav/BUILD.bazel new file mode 100644 index 000000000000..857c7e74d794 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/nav/BUILD.bazel @@ -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", + ], +) diff --git a/src/cdk-experimental/ui-patterns/nav/link.ts b/src/cdk-experimental/ui-patterns/nav/link.ts new file mode 100644 index 000000000000..7a31b4ad0ee6 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/nav/link.ts @@ -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 { + focusManager: ListFocus>; + selection: ListSelection, V>; + navigation: ListNavigation>; +} + +/** Represents the required inputs for a link in a nav. */ +export interface LinkInputs + extends ListNavigationItem, + ListSelectionItem, + ListTypeaheadItem, + ListFocusItem { + nav: SignalLike | undefined>; +} + +/** Represents a link in a nav. */ +export class LinkPattern { + /** A unique identifier for the link. */ + id: SignalLike; + + /** The value of the link, typically a URL or route path. */ + value: SignalLike; + + /** 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; + + /** The text used by the typeahead search. */ + searchTerm: SignalLike; + + /** A reference to the parent nav. */ + nav: SignalLike | undefined>; + + /** The tabindex of the link. */ + tabindex = computed(() => this.nav()?.focusManager.getItemTabindex(this)); + + /** The html element that should receive focus. */ + element: SignalLike; + + constructor(args: LinkInputs) { + this.id = args.id; + this.value = args.value; + this.nav = args.nav; + this.element = args.element; + this.disabled = args.disabled; + this.searchTerm = args.searchTerm; + } +} diff --git a/src/cdk-experimental/ui-patterns/nav/nav.ts b/src/cdk-experimental/ui-patterns/nav/nav.ts new file mode 100644 index 000000000000..e6362d893a92 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/nav/nav.ts @@ -0,0 +1,223 @@ +/** + * @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 {ModifierKey as Modifier} from '../behaviors/event-manager/event-manager'; +import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager'; +import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager'; +import {LinkPattern} from './link'; +import {ListSelection, ListSelectionInputs} from '../behaviors/list-selection/list-selection'; +import {ListTypeahead, ListTypeaheadInputs} from '../behaviors/list-typeahead/list-typeahead'; +import {ListNavigation, ListNavigationInputs} from '../behaviors/list-navigation/list-navigation'; +import {ListFocus, ListFocusInputs} from '../behaviors/list-focus/list-focus'; +import {computed, signal} from '@angular/core'; + +/** The selection operations that the nav can perform. */ +interface SelectOptions { + selectOne?: boolean; +} + +/** Represents the required inputs for a nav. */ +export type NavInputs = ListNavigationInputs> & + ListSelectionInputs, V> & + ListTypeaheadInputs> & + ListFocusInputs>; + +/** Controls the state of a nav. */ +export class NavPattern { + /** Controls navigation for the nav. */ + navigation: ListNavigation>; + + /** Controls selection for the nav. */ + selection: ListSelection, V>; + + /** Controls typeahead for the nav. */ + typeahead: ListTypeahead>; + + /** Controls focus for the nav. */ + focusManager: ListFocus>; + + /** Whether the nav is disabled. */ + disabled = computed(() => this.focusManager.isListDisabled()); + + /** The tabindex of the nav. */ + tabindex = computed(() => this.focusManager.getListTabindex()); + + /** The id of the current active item. */ + activedescendant = computed(() => this.focusManager.getActiveDescendant()); + + /** The number of items in the nav. */ + setsize = computed(() => this.navigation.inputs.items().length); + + /** The key used to navigate to the previous item in the list. */ + prevKey = computed(() => { + // Nav is typically vertical, but respect orientation if provided. + if (this.inputs.orientation() === 'horizontal') { + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + } + return 'ArrowUp'; + }); + + /** The key used to navigate to the next item in the list. */ + nextKey = computed(() => { + // Nav is typically vertical, but respect orientation if provided. + if (this.inputs.orientation() === 'horizontal') { + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + } + return 'ArrowDown'; + }); + + /** Represents the space key. Does nothing when the user is actively using typeahead. */ + dynamicSpaceKey = computed(() => (this.typeahead.isTyping() ? '' : ' ')); + + /** The regexp used to decide if a key should trigger typeahead. */ + typeaheadRegexp = /^.$/; // TODO: Ignore spaces? + + /** The keydown event manager for the nav. */ + keydown = computed(() => { + return new KeyboardEventManager() + .on(this.prevKey, () => this.prev({selectOne: true})) + .on(this.nextKey, () => this.next({selectOne: true})) + .on('Home', () => this.first({selectOne: true})) + .on('End', () => this.last({selectOne: true})) + .on(this.typeaheadRegexp, e => this.search(e.key, {selectOne: true})) + .on(this.dynamicSpaceKey, () => this.selection.selectOne()) // Activate link + .on('Enter', () => this.selection.selectOne()); // Activate link + }); + + /** The pointerdown event manager for the nav. */ + pointerdown = computed(() => { + const manager = new PointerEventManager(); + manager.on(e => this.goto(e, {selectOne: true})); + return manager; + }); + + constructor(readonly inputs: NavInputs) { + this.focusManager = new ListFocus(inputs); + // Nav always uses 'follow' selection mode and is single-select. + this.selection = new ListSelection({ + ...inputs, + focusManager: this.focusManager, + multi: signal(false), + selectionMode: signal('follow'), + }); + this.typeahead = new ListTypeahead({...inputs, focusManager: this.focusManager}); + this.navigation = new ListNavigation({ + ...inputs, + focusManager: this.focusManager, + // Nav wrapping is typically desired. + wrap: computed(() => this.inputs.wrap()), + }); + } + + /** Handles keydown events for the nav. */ + onKeydown(event: KeyboardEvent) { + if (!this.disabled()) { + this.keydown().handle(event); + } + } + + /** Handles pointerdown events for the nav. */ + onPointerdown(event: PointerEvent) { + if (!this.disabled()) { + this.pointerdown().handle(event); + } + } + + /** Navigates to the first link in the nav. */ + first(opts?: SelectOptions) { + this._navigate(opts, () => this.navigation.first()); + } + + /** Navigates to the last link in the nav. */ + last(opts?: SelectOptions) { + this._navigate(opts, () => this.navigation.last()); + } + + /** Navigates to the next link in the nav. */ + next(opts?: SelectOptions) { + this._navigate(opts, () => this.navigation.next()); + } + + /** Navigates to the previous link in the nav. */ + prev(opts?: SelectOptions) { + this._navigate(opts, () => this.navigation.prev()); + } + + /** Navigates to the given link in the nav. */ + goto(event: PointerEvent, opts?: SelectOptions) { + const item = this._getItem(event); + this._navigate(opts, () => this.navigation.goto(item)); + } + + /** Handles typeahead search navigation for the nav. */ + search(char: string, opts?: SelectOptions) { + this._navigate(opts, () => this.typeahead.search(char)); + } + + /** + * Sets the nav to its default initial state. + * + * Sets the active index of the nav to the first focusable selected + * item if one exists. Otherwise, sets focus to the first focusable item. + * + * This method should be called once the nav and its links are properly initialized. + */ + setDefaultState() { + let firstItem: LinkPattern | null = null; + + for (const item of this.inputs.items()) { + if (this.focusManager.isFocusable(item)) { + if (!firstItem) { + firstItem = item; + } + if (item.selected()) { + this.inputs.activeIndex.set(item.index()); + return; + } + } + } + + if (firstItem) { + this.inputs.activeIndex.set(firstItem.index()); + } + } + + /** + * Safely performs a navigation operation. + * + * Handles boilerplate calling of focus & selection operations. Also ensures these + * additional operations are only called if the navigation operation moved focus to a new link. + */ + private _navigate(opts: SelectOptions = {}, operation: () => boolean) { + const moved = operation(); + + if (moved) { + this._updateSelection(opts); + } + } + + /** Handles updating selection for the nav. */ + private _updateSelection(opts: SelectOptions = {}) { + // In nav, navigation always implies selection (activation). + if (opts.selectOne) { + this.selection.selectOne(); + } + } + + /** Gets the LinkPattern associated with a pointer event target. */ + private _getItem(e: PointerEvent) { + if (!(e.target instanceof HTMLElement)) { + return; + } + + // Assuming links have a role or specific attribute to identify them. + // Adjust selector as needed based on actual link implementation. + const element = e.target.closest('[role="link"], [cdkLink]'); + return this.inputs.items().find(i => i.element() === element); + } +} diff --git a/src/cdk-experimental/ui-patterns/public-api.ts b/src/cdk-experimental/ui-patterns/public-api.ts index 06383ea9b5bf..c8c2d7790f0a 100644 --- a/src/cdk-experimental/ui-patterns/public-api.ts +++ b/src/cdk-experimental/ui-patterns/public-api.ts @@ -11,4 +11,6 @@ export * from './listbox/option'; export * from './radio/radio-group'; export * from './radio/radio'; export * from './behaviors/signal-like/signal-like'; +export * from './nav/nav'; +export * from './nav/link'; export * from './tabs/tabs'; From 1ba49881a1bba10de50e5c9895e7567fb1a5351c Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Mon, 5 May 2025 16:13:36 -0400 Subject: [PATCH 2/4] feat(cdk-experimental/nav): create CdkNav and CdkLink --- .ng-dev/commit-message.mts | 1 + src/cdk-experimental/config.bzl | 1 + src/cdk-experimental/nav/BUILD.bazel | 17 +++ src/cdk-experimental/nav/index.ts | 9 ++ src/cdk-experimental/nav/nav.ts | 186 +++++++++++++++++++++++++ src/cdk-experimental/nav/public-api.ts | 9 ++ 6 files changed, 223 insertions(+) create mode 100644 src/cdk-experimental/nav/BUILD.bazel create mode 100644 src/cdk-experimental/nav/index.ts create mode 100644 src/cdk-experimental/nav/nav.ts create mode 100644 src/cdk-experimental/nav/public-api.ts diff --git a/.ng-dev/commit-message.mts b/.ng-dev/commit-message.mts index 56b1531b30e9..aea281ad7d40 100644 --- a/.ng-dev/commit-message.mts +++ b/.ng-dev/commit-message.mts @@ -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', diff --git a/src/cdk-experimental/config.bzl b/src/cdk-experimental/config.bzl index 89dd51af4665..bd659c25e9e3 100644 --- a/src/cdk-experimental/config.bzl +++ b/src/cdk-experimental/config.bzl @@ -4,6 +4,7 @@ CDK_EXPERIMENTAL_ENTRYPOINTS = [ "combobox", "deferred-content", "listbox", + "nav", "popover-edit", "scrolling", "selection", diff --git a/src/cdk-experimental/nav/BUILD.bazel b/src/cdk-experimental/nav/BUILD.bazel new file mode 100644 index 000000000000..221f787c4316 --- /dev/null +++ b/src/cdk-experimental/nav/BUILD.bazel @@ -0,0 +1,17 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "listbox", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns", + "//src/cdk/a11y", + "//src/cdk/bidi", + ], +) diff --git a/src/cdk-experimental/nav/index.ts b/src/cdk-experimental/nav/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/cdk-experimental/nav/index.ts @@ -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'; diff --git a/src/cdk-experimental/nav/nav.ts b/src/cdk-experimental/nav/nav.ts new file mode 100644 index 000000000000..bd7071ce85b8 --- /dev/null +++ b/src/cdk-experimental/nav/nav.ts @@ -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 + * + * ``` + */ +@Directive({ + selector: '[cdkNav]', + exportAs: 'cdkNav', + standalone: true, + host: { + 'role': 'navigation', // Common role for