diff --git a/.ng-dev/commit-message.mts b/.ng-dev/commit-message.mts
index 92dbdecf796e..da05c828512e 100644
--- a/.ng-dev/commit-message.mts
+++ b/.ng-dev/commit-message.mts
@@ -11,10 +11,12 @@ export const commitMessage: CommitMessageConfig = {
'multiple', // For when a commit applies to multiple components.
'cdk-experimental/column-resize',
'cdk-experimental/combobox',
+ 'cdk-experimental/listbox',
'cdk-experimental/popover-edit',
'cdk-experimental/scrolling',
'cdk-experimental/selection',
'cdk-experimental/table-scroll-container',
+ 'cdk-experimental/ui-patterns',
'cdk/a11y',
'cdk/accordion',
'cdk/bidi',
diff --git a/src/cdk-experimental/config.bzl b/src/cdk-experimental/config.bzl
index 7198c3d37550..17509954fef2 100644
--- a/src/cdk-experimental/config.bzl
+++ b/src/cdk-experimental/config.bzl
@@ -2,10 +2,12 @@
CDK_EXPERIMENTAL_ENTRYPOINTS = [
"column-resize",
"combobox",
+ "listbox",
"popover-edit",
"scrolling",
"selection",
"table-scroll-container",
+ "ui-patterns",
]
# List of all entry-point targets of the Angular cdk-experimental package.
diff --git a/src/cdk-experimental/listbox/BUILD.bazel b/src/cdk-experimental/listbox/BUILD.bazel
new file mode 100644
index 000000000000..2eaa728c73ae
--- /dev/null
+++ b/src/cdk-experimental/listbox/BUILD.bazel
@@ -0,0 +1,16 @@
+load("//tools:defaults.bzl", "ng_module")
+
+package(default_visibility = ["//visibility:public"])
+
+ng_module(
+ name = "listbox",
+ srcs = glob(
+ ["**/*.ts"],
+ exclude = ["**/*.spec.ts"],
+ ),
+ deps = [
+ "//src/cdk-experimental/ui-patterns",
+ "//src/cdk/a11y",
+ "//src/cdk/bidi",
+ ],
+)
diff --git a/src/cdk-experimental/listbox/index.ts b/src/cdk-experimental/listbox/index.ts
new file mode 100644
index 000000000000..52b3c7a5156f
--- /dev/null
+++ b/src/cdk-experimental/listbox/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/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts
new file mode 100644
index 000000000000..f7e910faac80
--- /dev/null
+++ b/src/cdk-experimental/listbox/listbox.ts
@@ -0,0 +1,158 @@
+/**
+ * @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 {
+ booleanAttribute,
+ computed,
+ contentChildren,
+ Directive,
+ ElementRef,
+ inject,
+ input,
+ model,
+} from '@angular/core';
+import {ListboxPattern, OptionPattern} from '@angular/cdk-experimental/ui-patterns';
+import {Directionality} from '@angular/cdk/bidi';
+import {toSignal} from '@angular/core/rxjs-interop';
+import {_IdGenerator} from '@angular/cdk/a11y';
+
+/**
+ * A listbox container.
+ *
+ * Listboxes are used to display a list of items for a user to select from. The CdkListbox is meant
+ * to be used in conjunction with CdkOption as follows:
+ *
+ * ```html
+ *
+ *
Item 1
+ *
Item 2
+ *
Item 3
+ *
+ * ```
+ */
+@Directive({
+ selector: '[cdkListbox]',
+ exportAs: 'cdkListbox',
+ host: {
+ 'role': 'listbox',
+ 'class': 'cdk-listbox',
+ '[attr.tabindex]': 'pattern.tabindex()',
+ '[attr.aria-disabled]': 'pattern.disabled()',
+ '[attr.aria-orientation]': 'pattern.orientation()',
+ '[attr.aria-multiselectable]': 'pattern.multiselectable()',
+ '[attr.aria-activedescendant]': 'pattern.activedescendant()',
+ '(keydown)': 'pattern.onKeydown($event)',
+ '(pointerdown)': 'pattern.onPointerdown($event)',
+ },
+})
+export class CdkListbox {
+ /** The directionality (LTR / RTL) context for the application (or a subtree of it). */
+ private readonly _directionality = inject(Directionality);
+
+ /** The CdkOptions nested inside of the CdkListbox. */
+ private readonly _cdkOptions = contentChildren(CdkOption, {descendants: true});
+
+ /** A signal wrapper for directionality. */
+ protected textDirection = toSignal(this._directionality.change, {
+ initialValue: this._directionality.value,
+ });
+
+ /** The Option UIPatterns of the child CdkOptions. */
+ protected items = computed(() => this._cdkOptions().map(option => option.pattern));
+
+ /** Whether the list is vertically or horizontally oriented. */
+ orientation = input<'vertical' | 'horizontal'>('vertical');
+
+ /** Whether multiple items in the list can be selected at once. */
+ multiselectable = input(false, {transform: booleanAttribute});
+
+ /** Whether focus should wrap when navigating. */
+ wrap = input(true, {transform: booleanAttribute});
+
+ /** Whether disabled items in the list should be skipped when navigating. */
+ skipDisabled = input(true, {transform: booleanAttribute});
+
+ /** The focus strategy used by the list. */
+ focusMode = input<'roving' | 'activedescendant'>('roving');
+
+ /** The selection strategy used by the list. */
+ selectionMode = input<'follow' | 'explicit'>('follow');
+
+ /** The amount of time before the typeahead search is reset. */
+ typeaheadDelay = input(0.5); // Picked arbitrarily.
+
+ /** Whether the listbox is disabled. */
+ disabled = input(false, {transform: booleanAttribute});
+
+ // TODO(wagnermaciel): Figure out how we want to expose control over the current listbox value.
+ /** The ids of the current selected items. */
+ selectedIds = model([]);
+
+ /** The current index that has been navigated to. */
+ activeIndex = model(0);
+
+ /** The Listbox UIPattern. */
+ pattern: ListboxPattern = new ListboxPattern({
+ ...this,
+ items: this.items,
+ textDirection: this.textDirection,
+ });
+}
+
+/** A selectable option in a CdkListbox. */
+@Directive({
+ selector: '[cdkOption]',
+ exportAs: 'cdkOption',
+ host: {
+ 'role': 'option',
+ 'class': 'cdk-option',
+ '[attr.tabindex]': 'pattern.tabindex()',
+ '[attr.aria-selected]': 'pattern.selected()',
+ '[attr.aria-disabled]': 'pattern.disabled()',
+ },
+})
+export class CdkOption {
+ /** A reference to the option element. */
+ private readonly _elementRef = inject(ElementRef);
+
+ /** The parent CdkListbox. */
+ private readonly _cdkListbox = inject(CdkListbox);
+
+ /** A unique identifier for the option. */
+ private readonly _generatedId = inject(_IdGenerator).getId('cdk-option-');
+
+ // TODO(wagnermaciel): https://github.com/angular/components/pull/30495#discussion_r1972601144.
+ /** A unique identifier for the option. */
+ protected id = computed(() => this._generatedId);
+
+ // TODO(wagnermaciel): See if we want to change how we handle this since textContent is not
+ // reactive. See https://github.com/angular/components/pull/30495#discussion_r1961260216.
+ /** The text used by the typeahead search. */
+ protected searchTerm = computed(() => this.label() ?? this.element().textContent);
+
+ /** The parent Listbox UIPattern. */
+ protected listbox = computed(() => this._cdkListbox.pattern);
+
+ /** A reference to the option element to be focused on navigation. */
+ protected element = computed(() => this._elementRef.nativeElement);
+
+ /** Whether an item is disabled. */
+ disabled = input(false, {transform: booleanAttribute});
+
+ /** The text used by the typeahead search. */
+ label = input();
+
+ /** The Option UIPattern. */
+ pattern = new OptionPattern({
+ ...this,
+ id: this.id,
+ listbox: this.listbox,
+ element: this.element,
+ searchTerm: this.searchTerm,
+ });
+}
diff --git a/src/cdk-experimental/listbox/public-api.ts b/src/cdk-experimental/listbox/public-api.ts
new file mode 100644
index 000000000000..d78fbe19d194
--- /dev/null
+++ b/src/cdk-experimental/listbox/public-api.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 {CdkListbox, CdkOption} from './listbox';
diff --git a/src/cdk-experimental/ui-patterns/BUILD.bazel b/src/cdk-experimental/ui-patterns/BUILD.bazel
new file mode 100644
index 000000000000..f420959e2ef8
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/BUILD.bazel
@@ -0,0 +1,15 @@
+load("//tools:defaults.bzl", "ts_library")
+
+package(default_visibility = ["//visibility:public"])
+
+ts_library(
+ name = "ui-patterns",
+ srcs = glob(
+ ["**/*.ts"],
+ exclude = ["**/*.spec.ts"],
+ ),
+ deps = [
+ "//src/cdk-experimental/ui-patterns/listbox",
+ "@npm//@angular/core",
+ ],
+)
diff --git a/src/cdk-experimental/ui-patterns/behaviors/event-manager/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/event-manager/BUILD.bazel
new file mode 100644
index 000000000000..df8008162f4b
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/event-manager/BUILD.bazel
@@ -0,0 +1,12 @@
+load("//tools:defaults.bzl", "ts_library")
+
+package(default_visibility = ["//visibility:public"])
+
+ts_library(
+ name = "event-manager",
+ srcs = glob(
+ ["**/*.ts"],
+ exclude = ["**/*.spec.ts"],
+ ),
+ deps = ["@npm//@angular/core"],
+)
diff --git a/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts b/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts
new file mode 100644
index 000000000000..6f28962270cd
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts
@@ -0,0 +1,103 @@
+/**
+ * @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
+ */
+
+/**
+ * An event that supports modifier keys.
+ *
+ * Matches the native KeyboardEvent, MouseEvent, and TouchEvent.
+ */
+export interface EventWithModifiers extends Event {
+ ctrlKey: boolean;
+ shiftKey: boolean;
+ altKey: boolean;
+ metaKey: boolean;
+}
+
+/**
+ * Options that are applicable to all event handlers.
+ *
+ * This library has not yet had a need for stopPropagationImmediate.
+ */
+export interface EventHandlerOptions {
+ stopPropagation: boolean;
+ preventDefault: boolean;
+}
+
+/** A basic event handler. */
+export type EventHandler = (event: T) => void;
+
+/** A function that determines whether an event is to be handled. */
+export type EventMatcher = (event: T) => boolean;
+
+/** A config that specifies how to handle a particular event. */
+export interface EventHandlerConfig extends EventHandlerOptions {
+ matcher: EventMatcher;
+ handler: EventHandler;
+}
+
+/** Bit flag representation of the possible modifier keys that can be present on an event. */
+export enum ModifierKey {
+ None = 0,
+ Ctrl = 0b1,
+ Shift = 0b10,
+ Alt = 0b100,
+ Meta = 0b1000,
+}
+
+export type ModifierInputs = ModifierKey | ModifierKey[];
+
+/**
+ * Abstract base class for all event managers.
+ *
+ * Event managers are designed to normalize how event handlers are authored and create a safety net
+ * for common event handling gotchas like remembering to call preventDefault or stopPropagation.
+ */
+export abstract class EventManager {
+ protected configs: EventHandlerConfig[] = [];
+ abstract options: EventHandlerOptions;
+
+ /** Runs the handlers that match with the given event. */
+ handle(event: T): void {
+ for (const config of this.configs) {
+ if (config.matcher(event)) {
+ config.handler(event);
+
+ if (config.preventDefault) {
+ event.preventDefault();
+ }
+
+ if (config.stopPropagation) {
+ event.stopPropagation();
+ }
+ }
+ }
+ }
+
+ /** Configures the event manager to handle specific events. (See subclasses for more). */
+ abstract on(...args: [...unknown[]]): this;
+}
+
+/** Gets bit flag representation of the modifier keys present on the given event. */
+export function getModifiers(event: EventWithModifiers): number {
+ return (
+ (+event.ctrlKey && ModifierKey.Ctrl) |
+ (+event.shiftKey && ModifierKey.Shift) |
+ (+event.altKey && ModifierKey.Alt) |
+ (+event.metaKey && ModifierKey.Meta)
+ );
+}
+
+/**
+ * Checks if the given event has modifiers that are an exact match for any of the given modifier
+ * flag combinations.
+ */
+export function hasModifiers(event: EventWithModifiers, modifiers: ModifierInputs): boolean {
+ const eventModifiers = getModifiers(event);
+ const modifiersList = Array.isArray(modifiers) ? modifiers : [modifiers];
+ return modifiersList.some(modifiers => eventModifiers === modifiers);
+}
diff --git a/src/cdk-experimental/ui-patterns/behaviors/event-manager/keyboard-event-manager.ts b/src/cdk-experimental/ui-patterns/behaviors/event-manager/keyboard-event-manager.ts
new file mode 100644
index 000000000000..f3deafb8a240
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/event-manager/keyboard-event-manager.ts
@@ -0,0 +1,75 @@
+/**
+ * @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 {Signal} from '@angular/core';
+import {
+ EventHandler,
+ EventHandlerOptions,
+ EventManager,
+ hasModifiers,
+ ModifierInputs,
+ ModifierKey,
+} from './event-manager';
+
+/**
+ * Used to represent a keycode.
+ *
+ * This is used to match whether an events keycode should be handled. The ability to match using a
+ * string, Signal, or Regexp gives us more flexibility when authoring event handlers.
+ */
+type KeyCode = string | Signal | RegExp;
+
+/**
+ * An event manager that is specialized for handling keyboard events. By default this manager stops
+ * propagation and prevents default on all events it handles.
+ */
+export class KeyboardEventManager extends EventManager {
+ options: EventHandlerOptions = {
+ preventDefault: true,
+ stopPropagation: true,
+ };
+
+ /** Configures this event manager to handle events with a specific key and no modifiers. */
+ on(key: KeyCode, handler: EventHandler): this;
+
+ /** Configures this event manager to handle events with a specific modifer and key combination. */
+ on(modifiers: ModifierInputs, key: KeyCode, handler: EventHandler): this;
+
+ on(...args: any[]) {
+ const {modifiers, key, handler} = this._normalizeInputs(...args);
+
+ this.configs.push({
+ handler: handler,
+ matcher: event => this._isMatch(event, key, modifiers),
+ ...this.options,
+ });
+
+ return this;
+ }
+
+ private _normalizeInputs(...args: any[]) {
+ const key = args.length === 3 ? args[1] : args[0];
+ const handler = args.length === 3 ? args[2] : args[1];
+ const modifiers = args.length === 3 ? args[0] : ModifierKey.None;
+
+ return {
+ key: key as KeyCode,
+ handler: handler as EventHandler,
+ modifiers: modifiers as ModifierInputs,
+ };
+ }
+
+ private _isMatch(event: T, key: KeyCode, modifiers: ModifierInputs) {
+ if (key instanceof RegExp) {
+ return key.test(event.key);
+ }
+
+ const keyStr = typeof key === 'string' ? key : key();
+ return keyStr.toLowerCase() === event.key.toLowerCase() && hasModifiers(event, modifiers);
+ }
+}
diff --git a/src/cdk-experimental/ui-patterns/behaviors/event-manager/pointer-event-manager.ts b/src/cdk-experimental/ui-patterns/behaviors/event-manager/pointer-event-manager.ts
new file mode 100644
index 000000000000..7631f048e027
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/event-manager/pointer-event-manager.ts
@@ -0,0 +1,91 @@
+/**
+ * @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 {
+ EventHandler,
+ EventHandlerOptions,
+ EventManager,
+ hasModifiers,
+ ModifierInputs,
+ ModifierKey,
+} from './event-manager';
+
+/**
+ * The different mouse buttons that may appear on a pointer event.
+ */
+export enum MouseButton {
+ Main = 0,
+ Auxiliary = 1,
+ Secondary = 2,
+}
+
+/** An event manager that is specialized for handling pointer events. */
+export class PointerEventManager extends EventManager {
+ options: EventHandlerOptions = {
+ preventDefault: false,
+ stopPropagation: false,
+ };
+
+ /**
+ * Configures this event manager to handle events with a specific modifer and mouse button
+ * combination.
+ */
+ on(button: MouseButton, modifiers: ModifierInputs, handler: EventHandler): this;
+
+ /**
+ * Configures this event manager to handle events with a specific mouse button and no modifiers.
+ */
+ on(modifiers: ModifierInputs, handler: EventHandler): this;
+
+ /**
+ * Configures this event manager to handle events with the main mouse button and no modifiers.
+ *
+ * @param handler The handler function
+ * @param options Options for whether to stop propagation or prevent default.
+ */
+ on(handler: EventHandler): this;
+
+ on(...args: any[]) {
+ const {button, handler, modifiers} = this._normalizeInputs(...args);
+
+ this.configs.push({
+ handler,
+ matcher: event => this._isMatch(event, button, modifiers),
+ ...this.options,
+ });
+ return this;
+ }
+
+ private _normalizeInputs(...args: any[]) {
+ if (args.length === 3) {
+ return {
+ button: args[0] as MouseButton,
+ modifiers: args[1] as ModifierInputs,
+ handler: args[2] as EventHandler,
+ };
+ }
+
+ if (typeof args[0] === 'number' && typeof args[1] === 'function') {
+ return {
+ button: MouseButton.Main,
+ modifiers: args[0] as ModifierInputs,
+ handler: args[1] as EventHandler,
+ };
+ }
+
+ return {
+ button: MouseButton.Main,
+ modifiers: ModifierKey.None,
+ handler: args[0] as EventHandler,
+ };
+ }
+
+ _isMatch(event: PointerEvent, button: MouseButton, modifiers: ModifierInputs) {
+ return button === (event.button ?? 0) && hasModifiers(event, modifiers);
+ }
+}
diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/list-focus/BUILD.bazel
new file mode 100644
index 000000000000..e0820a382473
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/BUILD.bazel
@@ -0,0 +1,29 @@
+load("//tools:defaults.bzl", "ng_test_library", "ng_web_test_suite", "ts_library")
+
+package(default_visibility = ["//visibility:public"])
+
+ts_library(
+ name = "list-focus",
+ srcs = glob(
+ ["**/*.ts"],
+ exclude = ["**/*.spec.ts"],
+ ),
+ deps = [
+ "//src/cdk-experimental/ui-patterns/behaviors/list-navigation",
+ "@npm//@angular/core",
+ ],
+)
+
+ng_test_library(
+ name = "unit_test_sources",
+ srcs = glob(["**/*.spec.ts"]),
+ deps = [
+ ":list-focus",
+ "//src/cdk-experimental/ui-patterns/behaviors/list-navigation",
+ ],
+)
+
+ng_web_test_suite(
+ name = "unit_tests",
+ deps = [":unit_test_sources"],
+)
diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts
new file mode 100644
index 000000000000..40dcdccf6185
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts
@@ -0,0 +1,156 @@
+/**
+ * @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, Signal, signal} from '@angular/core';
+import {ListNavigation, ListNavigationInputs} from '../list-navigation/list-navigation';
+import {ListFocus, ListFocusInputs, ListFocusItem} from './list-focus';
+
+describe('List Focus', () => {
+ interface TestItem extends ListFocusItem {
+ tabindex: Signal<-1 | 0>;
+ }
+
+ function getItems(length: number): Signal {
+ return signal(
+ Array.from({length}).map((_, i) => ({
+ index: signal(i),
+ id: signal(`${i}`),
+ tabindex: signal(-1),
+ disabled: signal(false),
+ element: signal({focus: () => {}} as HTMLElement),
+ })),
+ );
+ }
+
+ function getNavigation(
+ items: Signal,
+ args: Partial> = {},
+ ): ListNavigation {
+ return new ListNavigation({
+ items,
+ wrap: signal(false),
+ activeIndex: signal(0),
+ skipDisabled: signal(false),
+ textDirection: signal('ltr'),
+ orientation: signal('vertical'),
+ ...args,
+ });
+ }
+
+ function getFocus(
+ navigation: ListNavigation,
+ args: Partial> = {},
+ ): ListFocus {
+ return new ListFocus({
+ navigation,
+ focusMode: signal('roving'),
+ ...args,
+ });
+ }
+
+ describe('roving', () => {
+ it('should set the list tabindex to -1', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const focus = getFocus(nav);
+ const tabindex = computed(() => focus.getListTabindex());
+ expect(tabindex()).toBe(-1);
+ });
+
+ it('should set the activedescendant to undefined', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const focus = getFocus(nav);
+ expect(focus.getActiveDescendant()).toBeUndefined();
+ });
+
+ it('should set the first items tabindex to 0', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const focus = getFocus(nav);
+
+ items().forEach(i => {
+ i.tabindex = computed(() => focus.getItemTabindex(i));
+ });
+
+ expect(items()[0].tabindex()).toBe(0);
+ expect(items()[1].tabindex()).toBe(-1);
+ expect(items()[2].tabindex()).toBe(-1);
+ expect(items()[3].tabindex()).toBe(-1);
+ expect(items()[4].tabindex()).toBe(-1);
+ });
+
+ it('should update the tabindex of the active item when navigating', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const focus = getFocus(nav);
+
+ items().forEach(i => {
+ i.tabindex = computed(() => focus.getItemTabindex(i));
+ });
+
+ nav.next();
+
+ expect(items()[0].tabindex()).toBe(-1);
+ expect(items()[1].tabindex()).toBe(0);
+ expect(items()[2].tabindex()).toBe(-1);
+ expect(items()[3].tabindex()).toBe(-1);
+ expect(items()[4].tabindex()).toBe(-1);
+ });
+ });
+
+ describe('activedescendant', () => {
+ it('should set the list tabindex to 0', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const focus = getFocus(nav, {
+ focusMode: signal('activedescendant'),
+ });
+ const tabindex = computed(() => focus.getListTabindex());
+ expect(tabindex()).toBe(0);
+ });
+
+ it('should set the activedescendant to the active items id', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const focus = getFocus(nav, {
+ focusMode: signal('activedescendant'),
+ });
+ expect(focus.getActiveDescendant()).toBe(items()[0].id());
+ });
+
+ it('should set the tabindex of all items to -1', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const focus = getFocus(nav, {
+ focusMode: signal('activedescendant'),
+ });
+
+ items().forEach(i => {
+ i.tabindex = computed(() => focus.getItemTabindex(i));
+ });
+
+ expect(items()[0].tabindex()).toBe(-1);
+ expect(items()[1].tabindex()).toBe(-1);
+ expect(items()[2].tabindex()).toBe(-1);
+ expect(items()[3].tabindex()).toBe(-1);
+ expect(items()[4].tabindex()).toBe(-1);
+ });
+
+ it('should update the activedescendant of the list when navigating', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const focus = getFocus(nav, {
+ focusMode: signal('activedescendant'),
+ });
+
+ nav.next();
+ expect(focus.getActiveDescendant()).toBe(items()[1].id());
+ });
+ });
+});
diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts
new file mode 100644
index 000000000000..eba11d1316e4
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts
@@ -0,0 +1,67 @@
+/**
+ * @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 {Signal} from '@angular/core';
+import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation';
+
+/** Represents an item in a collection, such as a listbox option, than may receive focus. */
+export interface ListFocusItem extends ListNavigationItem {
+ /** A unique identifier for the item. */
+ id: Signal;
+
+ /** The html element that should receive focus. */
+ element: Signal;
+}
+
+/** Represents the required inputs for a collection that contains focusable items. */
+export interface ListFocusInputs {
+ /** The focus strategy used by the list. */
+ focusMode: Signal<'roving' | 'activedescendant'>;
+}
+
+/** Controls focus for a list of items. */
+export class ListFocus {
+ /** The navigation controller of the parent list. */
+ navigation: ListNavigation;
+
+ constructor(readonly inputs: ListFocusInputs & {navigation: ListNavigation}) {
+ this.navigation = inputs.navigation;
+ }
+
+ /** The id of the current active item. */
+ getActiveDescendant(): String | undefined {
+ if (this.inputs.focusMode() === 'roving') {
+ return undefined;
+ }
+ return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id();
+ }
+
+ /** The tabindex for the list. */
+ getListTabindex(): -1 | 0 {
+ return this.inputs.focusMode() === 'activedescendant' ? 0 : -1;
+ }
+
+ /** Returns the tabindex for the given item. */
+ getItemTabindex(item: T): -1 | 0 {
+ if (this.inputs.focusMode() === 'activedescendant') {
+ return -1;
+ }
+ const index = this.navigation.inputs.items().indexOf(item);
+ return this.navigation.inputs.activeIndex() === index ? 0 : -1;
+ }
+
+ /** Focuses the current active item. */
+ focus() {
+ if (this.inputs.focusMode() === 'activedescendant') {
+ return;
+ }
+
+ const item = this.navigation.inputs.items()[this.navigation.inputs.activeIndex()];
+ item.element().focus();
+ }
+}
diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/BUILD.bazel
new file mode 100644
index 000000000000..b81395f2c2ce
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/BUILD.bazel
@@ -0,0 +1,23 @@
+load("//tools:defaults.bzl", "ng_test_library", "ng_web_test_suite", "ts_library")
+
+package(default_visibility = ["//visibility:public"])
+
+ts_library(
+ name = "list-navigation",
+ srcs = glob(
+ ["**/*.ts"],
+ exclude = ["**/*.spec.ts"],
+ ),
+ deps = ["@npm//@angular/core"],
+)
+
+ng_test_library(
+ name = "unit_test_sources",
+ srcs = glob(["**/*.spec.ts"]),
+ deps = [":list-navigation"],
+)
+
+ng_web_test_suite(
+ name = "unit_tests",
+ deps = [":unit_test_sources"],
+)
diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.spec.ts
new file mode 100644
index 000000000000..bdbbc3b1f19a
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.spec.ts
@@ -0,0 +1,308 @@
+/**
+ * @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 {Signal, signal, WritableSignal} from '@angular/core';
+import {ListNavigationItem, ListNavigation, ListNavigationInputs} from './list-navigation';
+
+describe('List Navigation', () => {
+ interface TestItem extends ListNavigationItem {
+ disabled: WritableSignal;
+ }
+
+ function getItems(length: number): Signal {
+ return signal(
+ Array.from({length}).map((_, i) => ({
+ index: signal(i),
+ disabled: signal(false),
+ })),
+ );
+ }
+
+ function getNavigation(
+ items: Signal,
+ args: Partial> = {},
+ ): ListNavigation {
+ return new ListNavigation({
+ items,
+ wrap: signal(false),
+ activeIndex: signal(0),
+ skipDisabled: signal(false),
+ textDirection: signal('ltr'),
+ orientation: signal('vertical'),
+ ...args,
+ });
+ }
+
+ describe('#goto', () => {
+ it('should navigate to an item', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+
+ expect(nav.inputs.activeIndex()).toBe(0);
+ nav.goto(items()[3]);
+ expect(nav.inputs.activeIndex()).toBe(3);
+ });
+ });
+
+ describe('#next', () => {
+ it('should navigate next', () => {
+ const nav = getNavigation(getItems(3));
+ nav.next(); // 0 -> 1
+ expect(nav.inputs.activeIndex()).toBe(1);
+ });
+
+ it('should wrap', () => {
+ const nav = getNavigation(getItems(3), {
+ wrap: signal(true),
+ });
+
+ nav.next(); // 0 -> 1
+ nav.next(); // 1 -> 2
+ nav.next(); // 2 -> 0
+
+ expect(nav.inputs.activeIndex()).toBe(0);
+ });
+
+ it('should not wrap', () => {
+ const nav = getNavigation(getItems(3), {
+ wrap: signal(false),
+ });
+
+ nav.next(); // 0 -> 1
+ nav.next(); // 1 -> 2
+ nav.next(); // 2 -> 2
+
+ expect(nav.inputs.activeIndex()).toBe(2);
+ });
+
+ it('should skip disabled items', () => {
+ const nav = getNavigation(getItems(3), {
+ skipDisabled: signal(true),
+ });
+ nav.inputs.items()[1].disabled.set(true);
+
+ nav.next(); // 0 -> 2
+ expect(nav.inputs.activeIndex()).toBe(2);
+ });
+
+ it('should not skip disabled items', () => {
+ const nav = getNavigation(getItems(3), {
+ skipDisabled: signal(false),
+ });
+ nav.inputs.items()[1].disabled.set(true);
+
+ nav.next(); // 0 -> 1
+ expect(nav.inputs.activeIndex()).toBe(1);
+ });
+
+ it('should wrap and skip disabled items', () => {
+ const nav = getNavigation(getItems(3), {
+ wrap: signal(true),
+ skipDisabled: signal(true),
+ });
+ nav.inputs.items()[2].disabled.set(true);
+
+ nav.next(); // 0 -> 1
+ nav.next(); // 1 -> 0
+
+ expect(nav.inputs.activeIndex()).toBe(0);
+ });
+
+ it('should do nothing if other items are disabled', () => {
+ const nav = getNavigation(getItems(3), {
+ skipDisabled: signal(true),
+ });
+ nav.inputs.items()[1].disabled.set(true);
+ nav.inputs.items()[2].disabled.set(true);
+
+ nav.next(); // 0 -> 0
+ expect(nav.inputs.activeIndex()).toBe(0);
+ });
+
+ it('should do nothing if there are no other items to navigate to', () => {
+ const nav = getNavigation(getItems(1));
+ nav.next(); // 0 -> 0
+ expect(nav.inputs.activeIndex()).toBe(0);
+ });
+ });
+
+ describe('#prev', () => {
+ it('should navigate prev', () => {
+ const nav = getNavigation(getItems(3), {
+ activeIndex: signal(2),
+ });
+ nav.prev(); // 2 -> 1
+ expect(nav.inputs.activeIndex()).toBe(1);
+ });
+
+ it('should wrap', () => {
+ const nav = getNavigation(getItems(3), {
+ wrap: signal(true),
+ });
+ nav.prev(); // 0 -> 2
+ expect(nav.inputs.activeIndex()).toBe(2);
+ });
+
+ it('should not wrap', () => {
+ const nav = getNavigation(getItems(3), {
+ wrap: signal(false),
+ });
+ nav.prev(); // 0 -> 0
+ expect(nav.inputs.activeIndex()).toBe(0);
+ });
+
+ it('should skip disabled items', () => {
+ const nav = getNavigation(getItems(3), {
+ activeIndex: signal(2),
+ skipDisabled: signal(true),
+ });
+ nav.inputs.items()[1].disabled.set(true);
+
+ nav.prev(); // 2 -> 0
+ expect(nav.inputs.activeIndex()).toBe(0);
+ });
+
+ it('should not skip disabled items', () => {
+ const nav = getNavigation(getItems(3), {
+ activeIndex: signal(2),
+ skipDisabled: signal(false),
+ });
+ nav.inputs.items()[1].disabled.set(true);
+
+ nav.prev(); // 2 -> 1
+ expect(nav.inputs.activeIndex()).toBe(1);
+ });
+
+ it('should wrap and skip disabled items', () => {
+ const nav = getNavigation(getItems(3), {
+ wrap: signal(true),
+ activeIndex: signal(2),
+ skipDisabled: signal(true),
+ });
+ nav.inputs.items()[0].disabled.set(true);
+
+ nav.prev(); // 2 -> 1
+ nav.prev(); // 1 -> 2
+
+ expect(nav.inputs.activeIndex()).toBe(2);
+ });
+
+ it('should do nothing if other items are disabled', () => {
+ const nav = getNavigation(getItems(3), {
+ activeIndex: signal(2),
+ skipDisabled: signal(true),
+ });
+ nav.inputs.items()[0].disabled.set(true);
+ nav.inputs.items()[1].disabled.set(true);
+
+ nav.prev(); // 2 -> 2
+ expect(nav.inputs.activeIndex()).toBe(2);
+ });
+
+ it('should do nothing if there are no other items to navigate to', () => {
+ const nav = getNavigation(getItems(1));
+ nav.prev(); // 0 -> 0
+ expect(nav.inputs.activeIndex()).toBe(0);
+ });
+ });
+
+ describe('#first', () => {
+ it('should navigate to the first item', () => {
+ const nav = getNavigation(getItems(3), {
+ activeIndex: signal(2),
+ });
+
+ nav.first();
+ expect(nav.inputs.activeIndex()).toBe(0);
+ });
+
+ it('should skip disabled items', () => {
+ const nav = getNavigation(getItems(3), {
+ activeIndex: signal(2),
+ skipDisabled: signal(true),
+ });
+ nav.inputs.items()[0].disabled.set(true);
+
+ nav.first();
+ expect(nav.inputs.activeIndex()).toBe(1);
+ });
+
+ it('should not skip disabled items', () => {
+ const nav = getNavigation(getItems(3), {
+ activeIndex: signal(2),
+ skipDisabled: signal(false),
+ });
+ nav.inputs.items()[0].disabled.set(true);
+
+ nav.first();
+ expect(nav.inputs.activeIndex()).toBe(0);
+ });
+ });
+
+ describe('#last', () => {
+ it('should navigate to the last item', () => {
+ const nav = getNavigation(getItems(3));
+ nav.last();
+ expect(nav.inputs.activeIndex()).toBe(2);
+ });
+
+ it('should skip disabled items', () => {
+ const nav = getNavigation(getItems(3), {
+ skipDisabled: signal(true),
+ });
+ nav.inputs.items()[2].disabled.set(true);
+
+ nav.last();
+ expect(nav.inputs.activeIndex()).toBe(1);
+ });
+
+ it('should not skip disabled items', () => {
+ const nav = getNavigation(getItems(3), {
+ skipDisabled: signal(false),
+ });
+ nav.inputs.items()[2].disabled.set(true);
+
+ nav.last();
+ expect(nav.inputs.activeIndex()).toBe(2);
+ });
+ });
+
+ describe('#isFocusable', () => {
+ it('should return true for enabled items', () => {
+ const nav = getNavigation(getItems(3), {
+ skipDisabled: signal(true),
+ });
+
+ expect(nav.isFocusable(nav.inputs.items()[0])).toBeTrue();
+ expect(nav.isFocusable(nav.inputs.items()[1])).toBeTrue();
+ expect(nav.isFocusable(nav.inputs.items()[2])).toBeTrue();
+ });
+
+ it('should return false for disabled items', () => {
+ const nav = getNavigation(getItems(3), {
+ skipDisabled: signal(true),
+ });
+ nav.inputs.items()[1].disabled.set(true);
+
+ expect(nav.isFocusable(nav.inputs.items()[0])).toBeTrue();
+ expect(nav.isFocusable(nav.inputs.items()[1])).toBeFalse();
+ expect(nav.isFocusable(nav.inputs.items()[2])).toBeTrue();
+ });
+
+ it('should return true for disabled items if skip disabled is false', () => {
+ const nav = getNavigation(getItems(3), {
+ skipDisabled: signal(false),
+ });
+ nav.inputs.items()[1].disabled.set(true);
+
+ expect(nav.isFocusable(nav.inputs.items()[0])).toBeTrue();
+ expect(nav.isFocusable(nav.inputs.items()[1])).toBeTrue();
+ expect(nav.isFocusable(nav.inputs.items()[2])).toBeTrue();
+ });
+ });
+});
diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts
new file mode 100644
index 000000000000..a7cc3897ded4
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts
@@ -0,0 +1,109 @@
+/**
+ * @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 {signal, Signal, WritableSignal} from '@angular/core';
+
+/** Represents an item in a collection, such as a listbox option, than can be navigated to. */
+export interface ListNavigationItem {
+ /** Whether an item is disabled. */
+ disabled: Signal;
+}
+
+/** Represents the required inputs for a collection that has navigable items. */
+export interface ListNavigationInputs {
+ /** Whether focus should wrap when navigating. */
+ wrap: Signal;
+
+ /** The items in the list. */
+ items: Signal;
+
+ /** Whether disabled items in the list should be skipped when navigating. */
+ skipDisabled: Signal;
+
+ /** The current index that has been navigated to. */
+ activeIndex: WritableSignal;
+
+ /** Whether the list is vertically or horizontally oriented. */
+ orientation: Signal<'vertical' | 'horizontal'>;
+
+ /** The direction that text is read based on the users locale. */
+ textDirection: Signal<'rtl' | 'ltr'>;
+}
+
+/** Controls navigation for a list of items. */
+export class ListNavigation {
+ /** The last index that was active. */
+ prevActiveIndex = signal(0);
+
+ constructor(readonly inputs: ListNavigationInputs) {
+ this.prevActiveIndex.set(inputs.activeIndex());
+ }
+
+ /** Navigates to the given item. */
+ goto(item: T) {
+ if (this.isFocusable(item)) {
+ this.prevActiveIndex.set(this.inputs.activeIndex());
+ const index = this.inputs.items().indexOf(item);
+ this.inputs.activeIndex.set(index);
+ }
+ }
+
+ /** Navigates to the next item in the list. */
+ next() {
+ this._advance(1);
+ }
+
+ /** Navigates to the previous item in the list. */
+ prev() {
+ this._advance(-1);
+ }
+
+ /** Navigates to the first item in the list. */
+ first() {
+ const item = this.inputs.items().find(i => this.isFocusable(i));
+
+ if (item) {
+ this.goto(item);
+ }
+ }
+
+ /** Navigates to the last item in the list. */
+ last() {
+ const items = this.inputs.items();
+ for (let i = items.length - 1; i >= 0; i--) {
+ if (this.isFocusable(items[i])) {
+ this.goto(items[i]);
+ return;
+ }
+ }
+ }
+
+ /** Returns true if the given item can be navigated to. */
+ isFocusable(item: T): boolean {
+ return !item.disabled() || !this.inputs.skipDisabled();
+ }
+
+ /** Advances to the next or previous focusable item in the list based on the given delta. */
+ private _advance(delta: 1 | -1) {
+ const items = this.inputs.items();
+ const itemCount = items.length;
+ const startIndex = this.inputs.activeIndex();
+ const step = (i: number) =>
+ this.inputs.wrap() ? (i + delta + itemCount) % itemCount : i + delta;
+
+ // If wrapping is enabled, this loop ultimately terminates when `i` gets back to `startIndex`
+ // in the case that all options are disabled. If wrapping is disabled, the loop terminates
+ // when the index goes out of bounds.
+ for (let i = step(startIndex); i !== startIndex && i < itemCount && i >= 0; i = step(i)) {
+ if (this.isFocusable(items[i])) {
+ this.goto(items[i]);
+ return;
+ }
+ }
+ }
+}
diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/list-selection/BUILD.bazel
new file mode 100644
index 000000000000..60b7f627c039
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/BUILD.bazel
@@ -0,0 +1,29 @@
+load("//tools:defaults.bzl", "ng_test_library", "ng_web_test_suite", "ts_library")
+
+package(default_visibility = ["//visibility:public"])
+
+ts_library(
+ name = "list-selection",
+ srcs = glob(
+ ["**/*.ts"],
+ exclude = ["**/*.spec.ts"],
+ ),
+ deps = [
+ "//src/cdk-experimental/ui-patterns/behaviors/list-navigation",
+ "@npm//@angular/core",
+ ],
+)
+
+ng_test_library(
+ name = "unit_test_sources",
+ srcs = glob(["**/*.spec.ts"]),
+ deps = [
+ ":list-selection",
+ "//src/cdk-experimental/ui-patterns/behaviors/list-navigation",
+ ],
+)
+
+ng_web_test_suite(
+ name = "unit_tests",
+ deps = [":unit_test_sources"],
+)
diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts
new file mode 100644
index 000000000000..18a8f614e692
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts
@@ -0,0 +1,216 @@
+/**
+ * @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 {Signal, signal, WritableSignal} from '@angular/core';
+import {ListSelectionItem, ListSelection, ListSelectionInputs} from './list-selection';
+import {ListNavigation, ListNavigationInputs} from '../list-navigation/list-navigation';
+
+describe('List Selection', () => {
+ interface TestItem extends ListSelectionItem {
+ disabled: WritableSignal;
+ }
+
+ function getItems(length: number): Signal {
+ return signal(
+ Array.from({length}).map((_, i) => ({
+ index: signal(i),
+ id: signal(`${i}`),
+ disabled: signal(false),
+ isAnchor: signal(false),
+ })),
+ );
+ }
+
+ function getNavigation(
+ items: Signal,
+ args: Partial> = {},
+ ): ListNavigation {
+ return new ListNavigation({
+ items,
+ wrap: signal(false),
+ activeIndex: signal(0),
+ skipDisabled: signal(false),
+ textDirection: signal('ltr'),
+ orientation: signal('vertical'),
+ ...args,
+ });
+ }
+
+ function getSelection(
+ items: Signal,
+ navigation: ListNavigation,
+ args: Partial> = {},
+ ): ListSelection {
+ return new ListSelection({
+ items,
+ navigation,
+ selectedIds: signal([]),
+ multiselectable: signal(true),
+ selectionMode: signal('explicit'),
+ ...args,
+ });
+ }
+
+ describe('#select', () => {
+ it('should select an item', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const selection = getSelection(items, nav);
+
+ selection.select(); // [0]
+ expect(selection.inputs.selectedIds()).toEqual(['0']);
+ });
+
+ it('should select multiple options', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const selection = getSelection(items, nav);
+
+ selection.select(); // [0]
+ nav.next();
+ selection.select(); // [0, 1]
+
+ expect(selection.inputs.selectedIds()).toEqual(['0', '1']);
+ });
+
+ it('should not select multiple options', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const selection = getSelection(items, nav, {
+ multiselectable: signal(false),
+ });
+
+ selection.select(); // [0]
+ nav.next();
+ selection.select(); // [1]
+
+ expect(selection.inputs.selectedIds()).toEqual(['1']);
+ });
+
+ it('should not select disabled items', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const selection = getSelection(items, nav);
+ items()[0].disabled.set(true);
+
+ selection.select(); // []
+ expect(selection.inputs.selectedIds()).toEqual([]);
+ });
+
+ it('should do nothing to already selected items', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const selection = getSelection(items, nav);
+
+ selection.select(); // [0]
+ selection.select(); // [0]
+
+ expect(selection.inputs.selectedIds()).toEqual(['0']);
+ });
+ });
+
+ describe('#deselect', () => {
+ it('should deselect an item', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const selection = getSelection(items, nav);
+ selection.deselect(); // []
+ expect(selection.inputs.selectedIds().length).toBe(0);
+ });
+
+ it('should not deselect disabled items', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const selection = getSelection(items, nav);
+
+ selection.select(); // [0]
+ items()[0].disabled.set(true);
+ selection.deselect(); // [0]
+
+ expect(selection.inputs.selectedIds()).toEqual(['0']);
+ });
+ });
+
+ describe('#toggle', () => {
+ it('should select an unselected item', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const selection = getSelection(items, nav);
+
+ selection.toggle(); // [0]
+ expect(selection.inputs.selectedIds()).toEqual(['0']);
+ });
+
+ it('should deselect a selected item', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const selection = getSelection(items, nav);
+ selection.select(); // [0]
+ selection.toggle(); // []
+ expect(selection.inputs.selectedIds().length).toBe(0);
+ });
+ });
+
+ describe('#selectAll', () => {
+ it('should select all items', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const selection = getSelection(items, nav);
+ selection.selectAll();
+ expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2', '3', '4']);
+ });
+
+ it('should do nothing if a list is not multiselectable', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const selection = getSelection(items, nav);
+ selection.selectAll();
+ expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2', '3', '4']);
+ });
+ });
+
+ describe('#deselectAll', () => {
+ it('should deselect all items', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const selection = getSelection(items, nav);
+ selection.deselectAll(); // []
+ expect(selection.inputs.selectedIds().length).toBe(0);
+ });
+ });
+
+ describe('#selectFromAnchor', () => {
+ it('should select all items from an anchor at a lower index', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items);
+ const selection = getSelection(items, nav);
+
+ selection.select(); // [0]
+ nav.next();
+ nav.next();
+ selection.selectFromPrevSelectedItem(); // [0, 1, 2]
+
+ expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2']);
+ });
+
+ it('should select all items from an anchor at a higher index', () => {
+ const items = getItems(5);
+ const nav = getNavigation(items, {
+ activeIndex: signal(3),
+ });
+ const selection = getSelection(items, nav);
+
+ selection.select(); // [3]
+ nav.prev();
+ nav.prev();
+ selection.selectFromPrevSelectedItem(); // [3, 1, 2]
+
+ expect(selection.inputs.selectedIds()).toEqual(['3', '1', '2']);
+ });
+ });
+});
diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts
new file mode 100644
index 000000000000..ac3de73fe325
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts
@@ -0,0 +1,142 @@
+/**
+ * @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 {signal, Signal, WritableSignal} from '@angular/core';
+import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation';
+
+/** Represents an item in a collection, such as a listbox option, than can be selected. */
+export interface ListSelectionItem extends ListNavigationItem {
+ /** A unique identifier for the item. */
+ id: Signal;
+
+ /** Whether an item is disabled. */
+ disabled: Signal;
+}
+
+/** Represents the required inputs for a collection that contains selectable items. */
+export interface ListSelectionInputs {
+ /** The items in the list. */
+ items: Signal;
+
+ /** Whether multiple items in the list can be selected at once. */
+ multiselectable: Signal;
+
+ /** The ids of the current selected items. */
+ selectedIds: WritableSignal;
+
+ /** The selection strategy used by the list. */
+ selectionMode: Signal<'follow' | 'explicit'>;
+}
+
+/** Controls selection for a list of items. */
+export class ListSelection {
+ /** The id of the most recently selected item. */
+ previousSelectedId = signal(undefined);
+
+ /** The navigation controller of the parent list. */
+ navigation: ListNavigation;
+
+ constructor(readonly inputs: ListSelectionInputs & {navigation: ListNavigation}) {
+ this.navigation = inputs.navigation;
+ }
+
+ /** Selects the item at the current active index. */
+ select(item?: T) {
+ item = item ?? this.inputs.items()[this.inputs.navigation.inputs.activeIndex()];
+
+ if (item.disabled() || this.inputs.selectedIds().includes(item.id())) {
+ return;
+ }
+
+ if (!this.inputs.multiselectable()) {
+ this.deselectAll();
+ }
+
+ // TODO: Need to discuss when to drop this.
+ this._anchor();
+ this.inputs.selectedIds.update(ids => ids.concat(item.id()));
+ }
+
+ /** Deselects the item at the current active index. */
+ deselect(item?: T) {
+ item = item ?? this.inputs.items()[this.inputs.navigation.inputs.activeIndex()];
+
+ if (!item.disabled()) {
+ this.inputs.selectedIds.update(ids => ids.filter(id => id !== item.id()));
+ }
+ }
+
+ /** Toggles the item at the current active index. */
+ toggle() {
+ const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()];
+ this.inputs.selectedIds().includes(item.id()) ? this.deselect() : this.select();
+ }
+
+ /** Toggles only the item at the current active index. */
+ toggleOne() {
+ const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()];
+ this.inputs.selectedIds().includes(item.id()) ? this.deselect() : this.selectOne();
+ }
+
+ /** Selects all items in the list. */
+ selectAll() {
+ if (!this.inputs.multiselectable()) {
+ return; // Should we log a warning?
+ }
+
+ for (const item of this.inputs.items()) {
+ this.select(item);
+ }
+
+ this._anchor();
+ }
+
+ /** Deselects all items in the list. */
+ deselectAll() {
+ for (const item of this.inputs.items()) {
+ this.deselect(item);
+ }
+ }
+
+ /** Selects the items in the list starting at the last selected item. */
+ selectFromPrevSelectedItem() {
+ const prevSelectedId = this.inputs.items().findIndex(i => this.previousSelectedId() === i.id());
+ this._selectFromIndex(prevSelectedId);
+ }
+
+ /** Selects the items in the list starting at the last active item. */
+ selectFromActive() {
+ this._selectFromIndex(this.inputs.navigation.prevActiveIndex());
+ }
+
+ /** Selects the items in the list starting at the given index. */
+ private _selectFromIndex(index: number) {
+ if (index === -1) {
+ return;
+ }
+
+ const upper = Math.max(this.inputs.navigation.inputs.activeIndex(), index);
+ const lower = Math.min(this.inputs.navigation.inputs.activeIndex(), index);
+
+ for (let i = lower; i <= upper; i++) {
+ this.select(this.inputs.items()[i]);
+ }
+ }
+
+ /** Sets the selection to only the current active item. */
+ selectOne() {
+ this.deselectAll();
+ this.select();
+ }
+
+ /** Sets the anchor to the current active index. */
+ private _anchor() {
+ const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()];
+ this.previousSelectedId.set(item.id());
+ }
+}
diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/BUILD.bazel
new file mode 100644
index 000000000000..4120c12c0ad0
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/BUILD.bazel
@@ -0,0 +1,29 @@
+load("//tools:defaults.bzl", "ng_test_library", "ng_web_test_suite", "ts_library")
+
+package(default_visibility = ["//visibility:public"])
+
+ts_library(
+ name = "list-typeahead",
+ srcs = glob(
+ ["**/*.ts"],
+ exclude = ["**/*.spec.ts"],
+ ),
+ deps = [
+ "//src/cdk-experimental/ui-patterns/behaviors/list-navigation",
+ "@npm//@angular/core",
+ ],
+)
+
+ng_test_library(
+ name = "unit_test_sources",
+ srcs = glob(["**/*.spec.ts"]),
+ deps = [
+ ":list-typeahead",
+ "//src/cdk-experimental/ui-patterns/behaviors/list-navigation",
+ ],
+)
+
+ng_web_test_suite(
+ name = "unit_tests",
+ deps = [":unit_test_sources"],
+)
diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts
new file mode 100644
index 000000000000..df4c8b853720
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts
@@ -0,0 +1,152 @@
+/**
+ * @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 {Signal, signal, WritableSignal} from '@angular/core';
+import {ListTypeaheadItem, ListTypeahead} from './list-typeahead';
+import {fakeAsync, tick} from '@angular/core/testing';
+import {ListNavigation} from '../list-navigation/list-navigation';
+
+describe('List Typeahead', () => {
+ interface TestItem extends ListTypeaheadItem {
+ disabled: WritableSignal;
+ }
+
+ function getItems(length: number): Signal {
+ return signal(
+ Array.from({length}).map((_, i) => ({
+ index: signal(i),
+ disabled: signal(false),
+ searchTerm: signal(`Item ${i}`),
+ })),
+ );
+ }
+
+ describe('#search', () => {
+ it('should navigate to an item', () => {
+ const items = getItems(5);
+ const activeIndex = signal(0);
+ const navigation = new ListNavigation({
+ items,
+ activeIndex,
+ wrap: signal(false),
+ skipDisabled: signal(false),
+ textDirection: signal('ltr'),
+ orientation: signal('vertical'),
+ });
+ const typeahead = new ListTypeahead({
+ navigation,
+ typeaheadDelay: signal(0.5),
+ });
+
+ typeahead.search('i');
+ expect(activeIndex()).toBe(1);
+
+ typeahead.search('t');
+ typeahead.search('e');
+ typeahead.search('m');
+ typeahead.search(' ');
+ typeahead.search('3');
+ expect(activeIndex()).toBe(3);
+ });
+
+ it('should reset after a delay', fakeAsync(() => {
+ const items = getItems(5);
+ const activeIndex = signal(0);
+ const navigation = new ListNavigation({
+ items,
+ activeIndex,
+ wrap: signal(false),
+ skipDisabled: signal(false),
+ textDirection: signal('ltr'),
+ orientation: signal('vertical'),
+ });
+ const typeahead = new ListTypeahead({
+ navigation,
+ typeaheadDelay: signal(0.5),
+ });
+
+ typeahead.search('i');
+ expect(activeIndex()).toBe(1);
+
+ tick(500);
+
+ typeahead.search('i');
+ expect(activeIndex()).toBe(2);
+ }));
+
+ it('should skip disabled items', () => {
+ const items = getItems(5);
+ const activeIndex = signal(0);
+ const navigation = new ListNavigation({
+ items,
+ activeIndex,
+ wrap: signal(false),
+ skipDisabled: signal(true),
+ textDirection: signal('ltr'),
+ orientation: signal('vertical'),
+ });
+ const typeahead = new ListTypeahead({
+ navigation,
+ typeaheadDelay: signal(0.5),
+ });
+ items()[1].disabled.set(true);
+
+ typeahead.search('i');
+ expect(activeIndex()).toBe(2);
+ });
+
+ it('should not skip disabled items', () => {
+ const items = getItems(5);
+ const activeIndex = signal(0);
+ const navigation = new ListNavigation({
+ items,
+ activeIndex,
+ wrap: signal(false),
+ skipDisabled: signal(false),
+ textDirection: signal('ltr'),
+ orientation: signal('vertical'),
+ });
+ const typeahead = new ListTypeahead({
+ navigation,
+ typeaheadDelay: signal(0.5),
+ });
+ items()[1].disabled.set(true);
+
+ typeahead.search('i');
+ expect(activeIndex()).toBe(1);
+ });
+
+ it('should ignore keys like shift', () => {
+ const items = getItems(5);
+ const activeIndex = signal(0);
+ const navigation = new ListNavigation({
+ items,
+ activeIndex,
+ wrap: signal(false),
+ skipDisabled: signal(false),
+ textDirection: signal('ltr'),
+ orientation: signal('vertical'),
+ });
+ const typeahead = new ListTypeahead({
+ navigation,
+ typeaheadDelay: signal(0.5),
+ });
+
+ typeahead.search('i');
+ typeahead.search('t');
+ typeahead.search('e');
+
+ typeahead.search('Shift');
+
+ typeahead.search('m');
+ typeahead.search(' ');
+ typeahead.search('2');
+ expect(activeIndex()).toBe(2);
+ });
+ });
+});
diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts
new file mode 100644
index 000000000000..64154eda550d
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts
@@ -0,0 +1,92 @@
+/**
+ * @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 {signal, Signal} from '@angular/core';
+import {ListNavigationItem, ListNavigation} from '../list-navigation/list-navigation';
+
+/**
+ * Represents an item in a collection, such as a listbox option, than can be navigated to by
+ * typeahead.
+ */
+export interface ListTypeaheadItem extends ListNavigationItem {
+ /** The text used by the typeahead search. */
+ searchTerm: Signal;
+}
+
+/**
+ * Represents the required inputs for a collection that contains items that can be navigated to by
+ * typeahead.
+ */
+export interface ListTypeaheadInputs {
+ /** The amount of time before the typeahead search is reset. */
+ typeaheadDelay: Signal;
+}
+
+/** Controls typeahead for a list of items. */
+export class ListTypeahead {
+ /** A reference to the timeout for resetting the typeahead search. */
+ timeout?: ReturnType | undefined;
+
+ /** The navigation controller of the parent list. */
+ navigation: ListNavigation;
+
+ /** Keeps track of the characters that typeahead search is being called with. */
+ private _query = signal('');
+
+ /** The index where that the typeahead search was initiated from. */
+ private _startIndex = signal(undefined);
+
+ constructor(readonly inputs: ListTypeaheadInputs & {navigation: ListNavigation}) {
+ this.navigation = inputs.navigation;
+ }
+
+ /** Performs a typeahead search, appending the given character to the search string. */
+ search(char: string) {
+ if (char.length !== 1) {
+ return;
+ }
+
+ if (this._startIndex() === undefined) {
+ this._startIndex.set(this.navigation.inputs.activeIndex());
+ }
+
+ clearTimeout(this.timeout);
+ this._query.update(q => q + char.toLowerCase());
+ const item = this._getItem();
+
+ if (item) {
+ this.navigation.goto(item);
+ }
+
+ this.timeout = setTimeout(() => {
+ this._query.set('');
+ this._startIndex.set(undefined);
+ }, this.inputs.typeaheadDelay() * 1000);
+ }
+
+ /**
+ * Returns the first item whose search term matches the
+ * current query starting from the the current anchor index.
+ */
+ private _getItem() {
+ let items = this.navigation.inputs.items();
+ const after = items.slice(this._startIndex()! + 1);
+ const before = items.slice(0, this._startIndex()!);
+ items = this.navigation.inputs.wrap() ? after.concat(before) : after; // TODO: Always wrap?
+ items.push(this.navigation.inputs.items()[this._startIndex()!]);
+
+ const focusableItems = [];
+ for (const item of items) {
+ if (this.navigation.isFocusable(item)) {
+ focusableItems.push(item);
+ }
+ }
+
+ return focusableItems.find(i => i.searchTerm().toLowerCase().startsWith(this._query()));
+ }
+}
diff --git a/src/cdk-experimental/ui-patterns/index.ts b/src/cdk-experimental/ui-patterns/index.ts
new file mode 100644
index 000000000000..52b3c7a5156f
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/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/ui-patterns/listbox/BUILD.bazel b/src/cdk-experimental/ui-patterns/listbox/BUILD.bazel
new file mode 100644
index 000000000000..00137bf59503
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/listbox/BUILD.bazel
@@ -0,0 +1,19 @@
+load("//tools:defaults.bzl", "ts_library")
+
+package(default_visibility = ["//visibility:public"])
+
+ts_library(
+ name = "listbox",
+ srcs = glob(
+ ["**/*.ts"],
+ exclude = ["**/*.spec.ts"],
+ ),
+ deps = [
+ "//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",
+ "@npm//@angular/core",
+ ],
+)
diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts
new file mode 100644
index 000000000000..69b0f53a624d
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts
@@ -0,0 +1,264 @@
+/**
+ * @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 {OptionPattern} from './option';
+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 listbox can perform. */
+interface SelectOptions {
+ select?: boolean;
+ toggle?: boolean;
+ toggleOne?: boolean;
+ selectOne?: boolean;
+ selectAll?: boolean;
+ selectFromAnchor?: boolean;
+ selectFromActive?: boolean;
+}
+
+/** Represents the required inputs for a listbox. */
+export type ListboxInputs = ListNavigationInputs &
+ ListSelectionInputs &
+ ListTypeaheadInputs &
+ ListFocusInputs & {
+ disabled: Signal;
+ };
+
+/** Controls the state of a listbox. */
+export class ListboxPattern {
+ /** Controls navigation for the listbox. */
+ navigation: ListNavigation;
+
+ /** Controls selection for the listbox. */
+ selection: ListSelection;
+
+ /** Controls typeahead for the listbox. */
+ typeahead: ListTypeahead;
+
+ /** Controls focus for the listbox. */
+ focusManager: ListFocus;
+
+ /** Whether the list is vertically or horizontally oriented. */
+ orientation: Signal<'vertical' | 'horizontal'>;
+
+ /** Whether the listbox is disabled. */
+ disabled: Signal;
+
+ /** The tabindex of the listbox. */
+ tabindex = computed(() => this.focusManager.getListTabindex());
+
+ /** The id of the current active item. */
+ activedescendant = computed(() => this.focusManager.getActiveDescendant());
+
+ /** Whether multiple items in the list can be selected at once. */
+ multiselectable: Signal;
+
+ /** The number of items in the listbox. */
+ setsize = computed(() => this.navigation.inputs.items().length);
+
+ /** Whether the listbox selection follows focus. */
+ followFocus = computed(() => this.inputs.selectionMode() === 'follow');
+
+ /** The key used to navigate to the previous item in the list. */
+ prevKey = computed(() => {
+ if (this.inputs.orientation() === 'vertical') {
+ return 'ArrowUp';
+ }
+ return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
+ });
+
+ /** The key used to navigate to the next item in the list. */
+ nextKey = computed(() => {
+ if (this.inputs.orientation() === 'vertical') {
+ return 'ArrowDown';
+ }
+ return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
+ });
+
+ /** The regexp used to decide if a key should trigger typeahead. */
+ typeaheadRegexp = /^.$/; // TODO: Ignore spaces?
+
+ /** The keydown event manager for the listbox. */
+ keydown = computed(() => {
+ const manager = new KeyboardEventManager();
+
+ if (!this.followFocus()) {
+ manager
+ .on(this.prevKey, () => this.prev())
+ .on(this.nextKey, () => this.next())
+ .on('Home', () => this.first())
+ .on('End', () => this.last())
+ .on(this.typeaheadRegexp, e => this.search(e.key));
+ }
+
+ if (this.followFocus()) {
+ manager
+ .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}));
+ }
+
+ if (this.inputs.multiselectable()) {
+ manager
+ .on(Modifier.Shift, ' ', () => this._updateSelection({selectFromAnchor: true}))
+ .on(Modifier.Shift, 'Enter', () => this._updateSelection({selectFromAnchor: true}))
+ .on(Modifier.Shift, this.prevKey, () => this.prev({toggle: true}))
+ .on(Modifier.Shift, this.nextKey, () => this.next({toggle: true}))
+ .on(Modifier.Ctrl | Modifier.Shift, 'Home', () => this.first({selectFromActive: true}))
+ .on(Modifier.Ctrl | Modifier.Shift, 'End', () => this.last({selectFromActive: true}))
+ .on(Modifier.Ctrl, 'A', () => this._updateSelection({selectAll: true}));
+ }
+
+ if (!this.followFocus() && this.inputs.multiselectable()) {
+ manager.on(' ', () => this._updateSelection({toggle: true}));
+ manager.on('Enter', () => this._updateSelection({toggle: true}));
+ }
+
+ if (!this.followFocus() && !this.inputs.multiselectable()) {
+ manager.on(' ', () => this._updateSelection({toggleOne: true}));
+ manager.on('Enter', () => this._updateSelection({toggleOne: true}));
+ }
+
+ if (this.inputs.multiselectable() && this.followFocus()) {
+ manager
+ .on(Modifier.Ctrl, this.prevKey, () => this.prev())
+ .on(Modifier.Ctrl, this.nextKey, () => this.next())
+ .on(Modifier.Ctrl, 'Home', () => this.first()) // TODO: Not in spec but prob should be.
+ .on(Modifier.Ctrl, 'End', () => this.last()); // TODO: Not in spec but prob should be.
+ }
+
+ return manager;
+ });
+
+ /** The pointerdown event manager for the listbox. */
+ pointerdown = computed(() => {
+ const manager = new PointerEventManager();
+
+ if (this.inputs.multiselectable()) {
+ manager
+ .on(e => this.goto(e, {toggle: true}))
+ .on(Modifier.Shift, e => this.goto(e, {selectFromActive: true}));
+ } else {
+ manager.on(e => this.goto(e, {toggleOne: true}));
+ }
+
+ return manager;
+ });
+
+ constructor(readonly inputs: ListboxInputs) {
+ this.disabled = inputs.disabled;
+ this.orientation = inputs.orientation;
+ this.multiselectable = inputs.multiselectable;
+
+ this.navigation = new ListNavigation(inputs);
+ this.selection = new ListSelection({...inputs, navigation: this.navigation});
+ this.typeahead = new ListTypeahead({...inputs, navigation: this.navigation});
+ this.focusManager = new ListFocus({...inputs, navigation: this.navigation});
+ }
+
+ /** Handles keydown events for the listbox. */
+ onKeydown(event: KeyboardEvent) {
+ if (!this.disabled()) {
+ this.keydown().handle(event);
+ }
+ }
+
+ onPointerdown(event: PointerEvent) {
+ if (!this.disabled()) {
+ this.pointerdown().handle(event);
+ }
+ }
+
+ /** Navigates to the first option in the listbox. */
+ first(opts?: SelectOptions) {
+ this.navigation.first();
+ this.focusManager.focus();
+ this._updateSelection(opts);
+ }
+
+ /** Navigates to the last option in the listbox. */
+ last(opts?: SelectOptions) {
+ this.navigation.last();
+ this.focusManager.focus();
+ this._updateSelection(opts);
+ }
+
+ /** Navigates to the next option in the listbox. */
+ next(opts?: SelectOptions) {
+ this.navigation.next();
+ this.focusManager.focus();
+ this._updateSelection(opts);
+ }
+
+ /** Navigates to the previous option in the listbox. */
+ prev(opts?: SelectOptions) {
+ this.navigation.prev();
+ this.focusManager.focus();
+ this._updateSelection(opts);
+ }
+
+ /** Navigates to the given item in the listbox. */
+ goto(event: PointerEvent, opts?: SelectOptions) {
+ const item = this._getItem(event);
+
+ if (item) {
+ this.navigation.goto(item);
+ this.focusManager.focus();
+ this._updateSelection(opts);
+ }
+ }
+
+ /** Handles typeahead search navigation for the listbox. */
+ search(char: string, opts?: SelectOptions) {
+ this.typeahead.search(char);
+ this.focusManager.focus();
+ this._updateSelection(opts);
+ }
+
+ /** Handles updating selection for the listbox. */
+ private _updateSelection(opts?: SelectOptions) {
+ if (opts?.select) {
+ this.selection.select();
+ }
+ if (opts?.toggle) {
+ this.selection.toggle();
+ }
+ if (opts?.toggleOne) {
+ this.selection.toggleOne();
+ }
+ if (opts?.selectOne) {
+ this.selection.selectOne();
+ }
+ if (opts?.selectAll) {
+ this.selection.selectAll();
+ }
+ if (opts?.selectFromAnchor) {
+ this.selection.selectFromPrevSelectedItem();
+ }
+ if (opts?.selectFromActive) {
+ this.selection.selectFromActive();
+ }
+ }
+
+ private _getItem(e: PointerEvent) {
+ if (!(e.target instanceof HTMLElement)) {
+ return;
+ }
+
+ const element = e.target.closest('[cdkoption]'); // TODO: Use a different identifier.
+ return this.inputs.items().find(i => i.element() === element);
+ }
+}
diff --git a/src/cdk-experimental/ui-patterns/listbox/option.ts b/src/cdk-experimental/ui-patterns/listbox/option.ts
new file mode 100644
index 000000000000..a8bd269621be
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/listbox/option.ts
@@ -0,0 +1,72 @@
+/**
+ * @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, Signal} 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';
+
+/**
+ * Represents the properties exposed by a listbox that need to be accessed by an option.
+ * This exists to avoid circular dependency errors between the listbox and option.
+ */
+interface ListboxPattern {
+ focusManager: ListFocus;
+ selection: ListSelection;
+ navigation: ListNavigation;
+}
+
+/** Represents the required inputs for an option in a listbox. */
+export interface OptionInputs
+ extends ListNavigationItem,
+ ListSelectionItem,
+ ListTypeaheadItem,
+ ListFocusItem {
+ listbox: Signal;
+}
+
+/** Represents an option in a listbox. */
+export class OptionPattern {
+ /** A unique identifier for the option. */
+ id: Signal;
+
+ /** The position of the option in the list. */
+ index = computed(
+ () =>
+ this.listbox()
+ .navigation.inputs.items()
+ .findIndex(i => i.id() === this.id()) ?? -1,
+ );
+
+ /** Whether the option is selected. */
+ selected = computed(() => this.listbox().selection.inputs.selectedIds().includes(this.id()));
+
+ /** Whether the option is disabled. */
+ disabled: Signal;
+
+ /** The text used by the typeahead search. */
+ searchTerm: Signal;
+
+ /** A reference to the parent listbox. */
+ listbox: Signal;
+
+ /** The tabindex of the option. */
+ tabindex = computed(() => this.listbox().focusManager.getItemTabindex(this));
+
+ /** The html element that should receive focus. */
+ element: Signal;
+
+ constructor(args: OptionInputs) {
+ this.id = args.id;
+ this.listbox = args.listbox;
+ this.element = args.element;
+ this.disabled = args.disabled;
+ this.searchTerm = args.searchTerm;
+ }
+}
diff --git a/src/cdk-experimental/ui-patterns/public-api.ts b/src/cdk-experimental/ui-patterns/public-api.ts
new file mode 100644
index 000000000000..a30b67e1e378
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/public-api.ts
@@ -0,0 +1,10 @@
+/**
+ * @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 './listbox/listbox';
+export * from './listbox/option';
diff --git a/src/components-examples/cdk-experimental/listbox/BUILD.bazel b/src/components-examples/cdk-experimental/listbox/BUILD.bazel
new file mode 100644
index 000000000000..5540c6570171
--- /dev/null
+++ b/src/components-examples/cdk-experimental/listbox/BUILD.bazel
@@ -0,0 +1,27 @@
+load("//tools:defaults.bzl", "ng_module")
+
+package(default_visibility = ["//visibility:public"])
+
+ng_module(
+ name = "listbox",
+ srcs = glob(["**/*.ts"]),
+ assets = glob([
+ "**/*.html",
+ "**/*.css",
+ ]),
+ deps = [
+ "//src/cdk-experimental/listbox",
+ "//src/material/checkbox",
+ "//src/material/form-field",
+ "//src/material/select",
+ ],
+)
+
+filegroup(
+ name = "source-files",
+ srcs = glob([
+ "**/*.html",
+ "**/*.css",
+ "**/*.ts",
+ ]),
+)
diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css
new file mode 100644
index 000000000000..b09935f9dfe6
--- /dev/null
+++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css
@@ -0,0 +1,60 @@
+.example-listbox-controls {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding-bottom: 16px;
+}
+
+.example-listbox {
+ gap: 8px;
+ margin: 0;
+ padding: 8px;
+ max-height: 50vh;
+ border: 1px solid var(--mat-sys-outline);
+ border-radius: var(--mat-sys-corner-extra-small);
+ display: flex;
+ list-style: none;
+ flex-direction: column;
+ overflow: scroll;
+}
+
+.example-listbox[aria-orientation='horizontal'] {
+ flex-direction: row;
+}
+
+.example-listbox[aria-orientation='horizontal'] .example-option::before {
+ display: none;
+}
+
+.example-listbox[aria-orientation='horizontal'] .example-option[aria-selected='true']::before {
+ display: block;
+}
+
+.example-label {
+ padding: 16px;
+ flex-shrink: 0;
+}
+
+.example-option {
+ gap: 16px;
+ padding: 16px;
+ display: flex;
+ cursor: pointer;
+ align-items: center;
+ border-radius: var(--mat-sys-corner-extra-small);
+}
+
+.example-option:hover,
+.example-option[tabindex='0'] {
+ outline: 1px solid var(--mat-sys-outline);
+ background: var(--mat-sys-surface-container);
+}
+
+.example-option:focus-within {
+ outline: 2px solid var(--mat-sys-primary);
+ background: var(--mat-sys-surface-container);
+}
+
+.example-option[aria-selected='true'] {
+ background-color: var(--mat-sys-secondary-container);
+}
diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html
new file mode 100644
index 000000000000..4bc60d28b3ec
--- /dev/null
+++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html
@@ -0,0 +1,55 @@
+