diff --git a/src/cdk-experimental/tabs/tabs.ts b/src/cdk-experimental/tabs/tabs.ts
index bf17a9882732..68768319462b 100644
--- a/src/cdk-experimental/tabs/tabs.ts
+++ b/src/cdk-experimental/tabs/tabs.ts
@@ -34,15 +34,20 @@ import {TabListPattern, TabPanelPattern, TabPattern} from '../ui-patterns';
* ```html
*
*
- * - Tab 1
- * - Tab 2
- * - Tab 3
+ * - Tab 1
+ * - Tab 2
+ * - Tab 3
*
*
- *
Tab content 1
- *
Tab content 2
- *
Tab content 3
- *
+ *
+ * Tab content 1
+ *
+ *
+ * Tab content 2
+ *
+ *
+ * Tab content 3
+ *
* ```
*/
@Directive({
diff --git a/src/cdk-experimental/ui-patterns/behaviors/expansion/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/expansion/BUILD.bazel
new file mode 100644
index 000000000000..1e72caacfb44
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/expansion/BUILD.bazel
@@ -0,0 +1,31 @@
+load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project")
+
+package(default_visibility = ["//visibility:public"])
+
+ts_project(
+ name = "expansion",
+ srcs = [
+ "expansion.ts",
+ ],
+ deps = [
+ "//:node_modules/@angular/core",
+ "//src/cdk-experimental/ui-patterns/behaviors/signal-like",
+ ],
+)
+
+ts_project(
+ name = "unit_test_sources",
+ testonly = True,
+ srcs = [
+ "expansion.spec.ts",
+ ],
+ deps = [
+ ":expansion",
+ "//:node_modules/@angular/core",
+ ],
+)
+
+ng_web_test_suite(
+ name = "unit_tests",
+ deps = [":unit_test_sources"],
+)
diff --git a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.spec.ts
new file mode 100644
index 000000000000..3248fb42c169
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.spec.ts
@@ -0,0 +1,46 @@
+/**
+ * @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, WritableSignal} from '@angular/core';
+import {ExpansionControl, ExpansionPanel} from './expansion';
+
+describe('Expansion', () => {
+ let testExpansionControl: ExpansionControl;
+ let panelVisibility: WritableSignal;
+ let testExpansionPanel: ExpansionPanel;
+
+ beforeEach(() => {
+ let expansionControlRef = signal(undefined);
+ let expansionPanelRef = signal(undefined);
+ panelVisibility = signal(false);
+ testExpansionControl = new ExpansionControl({
+ visible: panelVisibility,
+ expansionPanel: expansionPanelRef,
+ });
+ testExpansionPanel = new ExpansionPanel({
+ id: () => 'test-panel',
+ expansionControl: expansionControlRef,
+ });
+ expansionControlRef.set(testExpansionControl);
+ expansionPanelRef.set(testExpansionPanel);
+ });
+
+ it('sets a panel hidden to true by setting a control to invisible.', () => {
+ panelVisibility.set(false);
+ expect(testExpansionPanel.hidden()).toBeTrue();
+ });
+
+ it('sets a panel hidden to false by setting a control to visible.', () => {
+ panelVisibility.set(true);
+ expect(testExpansionPanel.hidden()).toBeFalse();
+ });
+
+ it('gets a controlled panel id from ExpansionControl.', () => {
+ expect(testExpansionControl.controls()).toBe('test-panel');
+ });
+});
diff --git a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts
new file mode 100644
index 000000000000..aadc354acd72
--- /dev/null
+++ b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts
@@ -0,0 +1,68 @@
+/**
+ * @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 {SignalLike} from '../signal-like/signal-like';
+
+/** Inputs for an Expansion control. */
+export interface ExpansionControlInputs {
+ /** Whether an Expansion is visible. */
+ visible: SignalLike;
+
+ /** The controlled Expansion panel. */
+ expansionPanel: SignalLike;
+}
+
+/** Inputs for an Expansion panel. */
+export interface ExpansionPanelInputs {
+ /** A unique identifier for the panel. */
+ id: SignalLike;
+
+ /** The Expansion control. */
+ expansionControl: SignalLike;
+}
+
+/**
+ * An Expansion control.
+ *
+ * Use Expansion behavior if a pattern has a collapsible view that has two elements rely on the
+ * states from each other. For example
+ *
+ * ```html
+ *
+ *
+ * ...
+ *
+ *
+ * Collapsible content
+ *
+ * ```
+ */
+export class ExpansionControl {
+ /** Whether an Expansion is visible. */
+ visible: SignalLike;
+
+ /** The controllerd Expansion panel Id. */
+ controls = computed(() => this.inputs.expansionPanel()?.id());
+
+ constructor(readonly inputs: ExpansionControlInputs) {
+ this.visible = inputs.visible;
+ }
+}
+
+/** A Expansion panel. */
+export class ExpansionPanel {
+ /** A unique identifier for the panel. */
+ id: SignalLike;
+
+ /** Whether the panel is hidden. */
+ hidden = computed(() => !this.inputs.expansionControl()?.visible());
+
+ constructor(readonly inputs: ExpansionPanelInputs) {
+ this.id = inputs.id;
+ }
+}
diff --git a/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel b/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel
index 299e39ee2545..01853ef19a33 100644
--- a/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel
+++ b/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel
@@ -10,6 +10,7 @@ ts_project(
deps = [
"//:node_modules/@angular/core",
"//src/cdk-experimental/ui-patterns/behaviors/event-manager",
+ "//src/cdk-experimental/ui-patterns/behaviors/expansion",
"//src/cdk-experimental/ui-patterns/behaviors/list-focus",
"//src/cdk-experimental/ui-patterns/behaviors/list-navigation",
"//src/cdk-experimental/ui-patterns/behaviors/list-selection",
diff --git a/src/cdk-experimental/ui-patterns/tabs/tabs.ts b/src/cdk-experimental/ui-patterns/tabs/tabs.ts
index 820c145ab468..2ee7375553a8 100644
--- a/src/cdk-experimental/ui-patterns/tabs/tabs.ts
+++ b/src/cdk-experimental/ui-patterns/tabs/tabs.ts
@@ -21,11 +21,15 @@ import {
ListSelectionInputs,
ListSelectionItem,
} from '../behaviors/list-selection/list-selection';
+import {ExpansionControl, ExpansionPanel} from '../behaviors/expansion/expansion';
import {SignalLike} from '../behaviors/signal-like/signal-like';
/** The required inputs to tabs. */
export interface TabInputs extends ListNavigationItem, ListSelectionItem, ListFocusItem {
+ /** The parent tablist that controls the tab. */
tablist: SignalLike;
+
+ /** The remote tabpanel controlled by the tab. */
tabpanel: SignalLike;
}
@@ -37,55 +41,41 @@ export class TabPattern {
/** A local unique identifier for the tab. */
value: SignalLike;
- /** Whether the tab is active. */
- active = computed(() => this.tablist()?.focusManager.activeItem() === this);
+ /** Whether the tab is disabled. */
+ disabled: SignalLike;
- /** Whether the tab is selected. */
- selected = computed(() => this.tablist().selection.inputs.value().includes(this.value()));
+ /** The html element that should receive focus. */
+ element: SignalLike;
- /** A Tabpanel Id controlled by the tab. */
- controls = computed(() => this.tabpanel()?.id());
+ /** Controls the expansion state for the tab. */
+ expansionControl: ExpansionControl;
- /** Whether the tab is disabled. */
- disabled: SignalLike;
+ /** Whether the tab is active. */
+ active = computed(() => this.inputs.tablist().focusManager.activeItem() === this);
- /** A reference to the parent tablist. */
- tablist: SignalLike;
+ /** Whether the tab is selected. */
+ selected = computed(
+ () => !!this.inputs.tablist().selection.inputs.value().includes(this.value()),
+ );
- /** A reference to the corresponding tabpanel. */
- tabpanel: SignalLike;
+ /** A tabpanel Id controlled by the tab. */
+ controls = computed(() => this.expansionControl.controls());
/** The tabindex of the tab. */
- tabindex = computed(() => this.tablist().focusManager.getItemTabindex(this));
+ tabindex = computed(() => this.inputs.tablist().focusManager.getItemTabindex(this));
- /** The html element that should receive focus. */
- element: SignalLike;
-
- constructor(inputs: TabInputs) {
+ constructor(readonly inputs: TabInputs) {
this.id = inputs.id;
this.value = inputs.value;
- this.tablist = inputs.tablist;
- this.tabpanel = inputs.tabpanel;
- this.element = inputs.element;
this.disabled = inputs.disabled;
+ this.element = inputs.element;
+ this.expansionControl = new ExpansionControl({
+ visible: this.selected,
+ expansionPanel: computed(() => inputs.tabpanel()?.expansionPanel),
+ });
}
}
-/** The selection operations that the tablist can perform. */
-interface SelectOptions {
- select?: boolean;
- toggle?: boolean;
- toggleOne?: boolean;
- selectOne?: boolean;
-}
-
-/** The required inputs for the tablist. */
-export type TabListInputs = ListNavigationInputs &
- Omit, 'multi'> &
- ListFocusInputs & {
- disabled: SignalLike;
- };
-
/** The required inputs for the tabpanel. */
export interface TabPanelInputs {
id: SignalLike;
@@ -101,19 +91,37 @@ export class TabPanelPattern {
/** A local unique identifier for the tabpanel. */
value: SignalLike;
- /** A reference to the corresponding tab. */
- tab: SignalLike;
+ /** Represents the expansion state for the tabpanel. */
+ expansionPanel: ExpansionPanel;
/** Whether the tabpanel is hidden. */
- hidden = computed(() => !this.tab()?.selected());
+ hidden = computed(() => this.expansionPanel.hidden());
constructor(inputs: TabPanelInputs) {
this.id = inputs.id;
this.value = inputs.value;
- this.tab = inputs.tab;
+ this.expansionPanel = new ExpansionPanel({
+ id: inputs.id,
+ expansionControl: computed(() => inputs.tab()?.expansionControl),
+ });
}
}
+/** The selection operations that the tablist can perform. */
+interface SelectOptions {
+ select?: boolean;
+ toggle?: boolean;
+ toggleOne?: boolean;
+ selectOne?: boolean;
+}
+
+/** The required inputs for the tablist. */
+export type TabListInputs = ListNavigationInputs &
+ Omit, 'multi'> &
+ ListFocusInputs & {
+ disabled: SignalLike;
+ };
+
/** Controls the state of a tablist. */
export class TabListPattern {
/** Controls navigation for the tablist. */