diff --git a/src/cdk-experimental/deferred-content/BUILD.bazel b/src/cdk-experimental/deferred-content/BUILD.bazel new file mode 100644 index 000000000000..12ae75c4d10b --- /dev/null +++ b/src/cdk-experimental/deferred-content/BUILD.bazel @@ -0,0 +1,36 @@ +load("//tools:defaults.bzl", "ng_module", "ng_web_test_suite") +load("//tools:defaults2.bzl", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "deferred-content", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "@npm//@angular/core", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = glob( + ["**/*.spec.ts"], + exclude = ["**/*.e2e.spec.ts"], + ), + interop_deps = [ + ":deferred-content", + ], + deps = [ + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/deferred-content/deferred-content.spec.ts b/src/cdk-experimental/deferred-content/deferred-content.spec.ts new file mode 100644 index 000000000000..88a8d5e3b95f --- /dev/null +++ b/src/cdk-experimental/deferred-content/deferred-content.spec.ts @@ -0,0 +1,87 @@ +import {Component, DebugElement, Directive, effect, inject, signal} from '@angular/core'; +import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; +import {DeferredContent, DeferredContentAware} from './deferred-content'; +import {By} from '@angular/platform-browser'; + +describe('DeferredContent', () => { + let fixture: ComponentFixture; + let collapsible: DebugElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TestComponent], + }); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestComponent); + collapsible = fixture.debugElement.query(By.directive(Collapsible)); + }); + + it('removes the content when hidden.', async () => { + collapsible.injector.get(Collapsible).contentVisible.set(false); + await fixture.whenStable(); + expect(collapsible.nativeElement.innerText).toBe(''); + }); + + it('creates the content when visible.', async () => { + collapsible.injector.get(Collapsible).contentVisible.set(true); + await fixture.whenStable(); + expect(collapsible.nativeElement.innerText).toBe('Lazy Content'); + }); + + describe('with preserveContent', () => { + let component: TestComponent; + + beforeEach(() => { + component = fixture.componentInstance; + component.preserveContent.set(true); + }); + + it('creates the content when hidden.', async () => { + collapsible.injector.get(Collapsible).contentVisible.set(false); + await fixture.whenStable(); + expect(collapsible.nativeElement.innerText).toBe('Lazy Content'); + }); + + it('creates the content when visible.', async () => { + collapsible.injector.get(Collapsible).contentVisible.set(true); + await fixture.whenStable(); + expect(collapsible.nativeElement.innerText).toBe('Lazy Content'); + }); + }); +}); + +@Directive({ + selector: '[collapsible]', + hostDirectives: [{directive: DeferredContentAware, inputs: ['preserveContent']}], +}) +class Collapsible { + private readonly _deferredContentAware = inject(DeferredContentAware); + + contentVisible = signal(true); + + constructor() { + effect(() => this._deferredContentAware.contentVisible.set(this.contentVisible())); + } +} + +@Directive({ + selector: 'ng-template[collapsibleContent]', + hostDirectives: [DeferredContent], +}) +class CollapsibleContent {} + +@Component({ + template: ` +
+ + Lazy Content + +
+ `, + imports: [Collapsible, CollapsibleContent], +}) +class TestComponent { + preserveContent = signal(false); +} diff --git a/src/cdk-experimental/deferred-content/deferred-content.ts b/src/cdk-experimental/deferred-content/deferred-content.ts new file mode 100644 index 000000000000..d6782ee453d9 --- /dev/null +++ b/src/cdk-experimental/deferred-content/deferred-content.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 { + computed, + Directive, + effect, + inject, + input, + TemplateRef, + signal, + ViewContainerRef, +} from '@angular/core'; + +/** + * A container directive controls the visibility of its content. + */ +@Directive() +export class DeferredContentAware { + contentVisible = signal(false); + readonly preserveContent = input(false); +} + +/** + * DeferredContent loads/unloads the content based on the visibility. + * The visibilty signal is sent from a parent directive implements + * DeferredContentAware. + * + * Use this directive as a host directive. For example: + * + * ```ts + * @Directive({ + * selector: 'ng-template[cdkAccordionContent]', + * hostDirectives: [DeferredContent], + * }) + * class CdkAccordionContent {} + * ``` + */ +@Directive() +export class DeferredContent { + private readonly _deferredContentAware = inject(DeferredContentAware); + private readonly _templateRef = inject(TemplateRef); + private readonly _viewContainerRef = inject(ViewContainerRef); + private _isRendered = false; + + constructor() { + effect(() => { + if ( + this._deferredContentAware.preserveContent() || + this._deferredContentAware.contentVisible() + ) { + if (this._isRendered) return; + this._viewContainerRef.clear(); + this._viewContainerRef.createEmbeddedView(this._templateRef); + this._isRendered = true; + } else { + this._viewContainerRef.clear(); + this._isRendered = false; + } + }); + } +} diff --git a/src/cdk-experimental/deferred-content/index.ts b/src/cdk-experimental/deferred-content/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/cdk-experimental/deferred-content/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/deferred-content/public-api.ts b/src/cdk-experimental/deferred-content/public-api.ts new file mode 100644 index 000000000000..717c2e431cd0 --- /dev/null +++ b/src/cdk-experimental/deferred-content/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 {DeferredContentAware, DeferredContent} from './deferred-content'; diff --git a/src/cdk-experimental/tabs/BUILD.bazel b/src/cdk-experimental/tabs/BUILD.bazel new file mode 100644 index 000000000000..d6b262a451ed --- /dev/null +++ b/src/cdk-experimental/tabs/BUILD.bazel @@ -0,0 +1,17 @@ +load("//tools:defaults.bzl", "ng_module") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "tabs", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//src/cdk-experimental/deferred-content", + "//src/cdk-experimental/ui-patterns", + "//src/cdk/a11y", + "//src/cdk/bidi", + ], +) diff --git a/src/cdk-experimental/tabs/index.ts b/src/cdk-experimental/tabs/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/cdk-experimental/tabs/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/tabs/public-api.ts b/src/cdk-experimental/tabs/public-api.ts new file mode 100644 index 000000000000..9e61b0dcdfaa --- /dev/null +++ b/src/cdk-experimental/tabs/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 {CdkTabs, CdkTablist, CdkTab, CdkTabpanel, CdkTabcontent} from './tabs'; diff --git a/src/cdk-experimental/tabs/tabs.ts b/src/cdk-experimental/tabs/tabs.ts new file mode 100644 index 000000000000..6cdbb7e13e5d --- /dev/null +++ b/src/cdk-experimental/tabs/tabs.ts @@ -0,0 +1,244 @@ +/** + * @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, + effect, + ElementRef, + inject, + input, + model, +} from '@angular/core'; +import {Directionality} from '@angular/cdk/bidi'; +import {DeferredContent, DeferredContentAware} from '@angular/cdk-experimental/deferred-content'; +import {TabPattern} from '@angular/cdk-experimental/ui-patterns/tabs/tab'; +import {TablistPattern} from '@angular/cdk-experimental/ui-patterns/tabs/tablist'; +import {TabpanelPattern} from '@angular/cdk-experimental/ui-patterns/tabs/tabpanel'; +import {toSignal} from '@angular/core/rxjs-interop'; +import {_IdGenerator} from '@angular/cdk/a11y'; + +/** + * A Tabs container. + * + * Represents a set of layered sections of content. The CdkTabs is a container meant to be used with + * CdkTablist, CdkTab, and CdkTabpanel as follows: + * + * ```html + *
+ * + * + *
Tab content 1
+ *
Tab content 2
+ *
Tab content 3
+ *
+ * ``` + */ +@Directive({ + selector: '[cdkTabs]', + exportAs: 'cdkTabs', + host: { + 'class': 'cdk-tabs', + }, +}) +export class CdkTabs { + /** The CdkTabs nested inside of the container. */ + private readonly _cdkTabs = contentChildren(CdkTab, {descendants: true}); + + /** The CdkTabpanels nested inside of the container. */ + private readonly _cdkTabpanels = contentChildren(CdkTabpanel, {descendants: true}); + + /** The Tab UIPattern of the child Tabs. */ + tabs = computed(() => this._cdkTabs().map(tab => tab.pattern)); + + /** The Tabpanel UIPattern of the child Tabpanels. */ + tabpanels = computed(() => this._cdkTabpanels().map(tabpanel => tabpanel.pattern)); +} + +/** + * A Tablist container. + * + * Controls a list of CdkTab(s). + */ +@Directive({ + selector: '[cdkTablist]', + exportAs: 'cdkTablist', + host: { + 'role': 'tablist', + 'class': 'cdk-tablist', + '[attr.tabindex]': 'pattern.tabindex()', + '[attr.aria-disabled]': 'pattern.disabled()', + '[attr.aria-orientation]': 'pattern.orientation()', + '[attr.aria-activedescendant]': 'pattern.activedescendant()', + '(keydown)': 'pattern.onKeydown($event)', + '(pointerdown)': 'pattern.onPointerdown($event)', + }, +}) +export class CdkTablist { + /** The directionality (LTR / RTL) context for the application (or a subtree of it). */ + private readonly _directionality = inject(Directionality); + + /** The CdkTabs nested inside of the CdkTablist. */ + private readonly _cdkTabs = contentChildren(CdkTab); + + /** A signal wrapper for directionality. */ + protected textDirection = toSignal(this._directionality.change, { + initialValue: this._directionality.value, + }); + + /** The Tab UIPatterns of the child Tabs. */ + protected tabs = computed(() => this._cdkTabs().map(tab => tab.pattern)); + + /** Whether the tablist is vertically or horizontally oriented. */ + orientation = input<'vertical' | 'horizontal'>('horizontal'); + + /** 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 tablist. */ + focusMode = input<'roving' | 'activedescendant'>('roving'); + + /** The selection strategy used by the tablist. */ + selectionMode = input<'follow' | 'explicit'>('follow'); + + /** Whether the tablist is disabled. */ + disabled = input(false, {transform: booleanAttribute}); + + /** The current index that has been navigated to. */ + activeIndex = model(0); + + /** The Tablist UIPattern. */ + pattern: TablistPattern = new TablistPattern({ + ...this, + items: this.tabs, + textDirection: this.textDirection, + }); +} + +/** A selectable tab in a tablist. */ +@Directive({ + selector: '[cdkTab]', + exportAs: 'cdkTab', + host: { + 'role': 'tab', + 'class': 'cdk-tab', + '[attr.id]': 'pattern.id()', + '[attr.tabindex]': 'pattern.tabindex()', + '[attr.aria-selected]': 'pattern.selected()', + '[attr.aria-disabled]': 'pattern.disabled()', + '[attr.aria-controls]': 'pattern.controls()', + }, +}) +export class CdkTab { + /** A reference to the tab element. */ + private readonly _elementRef = inject(ElementRef); + + /** The parent CdkTabs. */ + private readonly _cdkTabs = inject(CdkTabs); + + /** The parent CdkTablist. */ + private readonly _cdkTablist = inject(CdkTablist); + + /** A unique identifier for the tab. */ + private readonly _id = inject(_IdGenerator).getId('cdk-tab-'); + + /** The position of the tab in the list. */ + protected index = computed(() => this._cdkTabs.tabs().findIndex(tab => tab.id() === this._id)); + + /** The parent Tablist UIPattern. */ + protected tablist = computed(() => this._cdkTablist.pattern); + + /** The Tabpanel UIPattern associated with the tab */ + protected tabpanel = computed(() => this._cdkTabs.tabpanels()[this.index()]); + + /** Whether a tab is disabled. */ + disabled = input(false, {transform: booleanAttribute}); + + /** The Tab UIPattern. */ + pattern: TabPattern = new TabPattern({ + ...this, + id: () => this._id, + element: () => this._elementRef.nativeElement, + tablist: this.tablist, + tabpanel: this.tabpanel, + }); +} + +/** + * A Tabpanel container for the resources of layered content associated with a tab. + * + * If a tabpanel is hidden due to its corresponding tab is not activated, the `inert` attribute + * will be applied to the tabpanel element to remove it from the accessibility tree and stop + * all the keyboard and pointer interactions. Note that this does not visually hide the tabpenl + * and a proper styling is required. + */ +@Directive({ + selector: '[cdkTabpanel]', + exportAs: 'cdkTabpanel', + host: { + 'role': 'tabpanel', + 'tabindex': '0', + 'class': 'cdk-tabpanel', + '[attr.id]': 'pattern.id()', + '[attr.inert]': 'pattern.hidden() ? true : null', + }, + hostDirectives: [ + { + directive: DeferredContentAware, + inputs: ['preserveContent'], + }, + ], +}) +export class CdkTabpanel { + /** The DeferredContentAware host directive. */ + private readonly _deferredContentAware = inject(DeferredContentAware); + + /** The parent CdkTabs. */ + private readonly _cdkTabs = inject(CdkTabs); + + /** A unique identifier for the tab. */ + private readonly _id = inject(_IdGenerator).getId('cdk-tabpanel-'); + + /** The position of the tabpanel in the tabs. */ + protected index = computed(() => + this._cdkTabs.tabpanels().findIndex(tabpanel => tabpanel.id() === this._id), + ); + + /** The Tab UIPattern associated with the tabpanel */ + protected tab = computed(() => this._cdkTabs.tabs()[this.index()]); + + /** The Tabpanel UIPattern. */ + pattern: TabpanelPattern = new TabpanelPattern({ + ...this, + id: () => this._id, + tab: this.tab, + }); + + constructor() { + effect(() => this._deferredContentAware.contentVisible.set(!this.pattern.hidden())); + } +} + +/** + * A Tabcontent container for the lazy-loaded content. + */ +@Directive({ + selector: 'ng-template[cdkTabcontent]', + exportAs: 'cdTabcontent', + hostDirectives: [DeferredContent], +}) +export class CdkTabcontent {} diff --git a/src/cdk-experimental/ui-patterns/BUILD.bazel b/src/cdk-experimental/ui-patterns/BUILD.bazel index eaed798277af..6b741a6507c2 100644 --- a/src/cdk-experimental/ui-patterns/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/BUILD.bazel @@ -12,5 +12,6 @@ ts_project( "//:node_modules/@angular/core", "//src/cdk-experimental/ui-patterns/behaviors/signal-like:signal-like_rjs", "//src/cdk-experimental/ui-patterns/listbox:listbox_rjs", + "//src/cdk-experimental/ui-patterns/tabs:tabs_rjs", ], ) diff --git a/src/cdk-experimental/ui-patterns/public-api.ts b/src/cdk-experimental/ui-patterns/public-api.ts index 36c6802f2735..3313e1dceb41 100644 --- a/src/cdk-experimental/ui-patterns/public-api.ts +++ b/src/cdk-experimental/ui-patterns/public-api.ts @@ -9,3 +9,6 @@ export * from './listbox/listbox'; export * from './listbox/option'; export * from './behaviors/signal-like/signal-like'; +export * from './tabs/tab'; +export * from './tabs/tablist'; +export * from './tabs/tabpanel'; diff --git a/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel b/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel new file mode 100644 index 000000000000..ba72355d3ed5 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel @@ -0,0 +1,19 @@ +load("//tools:defaults2.bzl", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "tabs", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/event-manager:event-manager_rjs", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus:list-focus_rjs", + "//src/cdk-experimental/ui-patterns/behaviors/list-navigation:list-navigation_rjs", + "//src/cdk-experimental/ui-patterns/behaviors/list-selection:list-selection_rjs", + "//src/cdk-experimental/ui-patterns/behaviors/signal-like:signal-like_rjs", + ], +) diff --git a/src/cdk-experimental/ui-patterns/tabs/tab.ts b/src/cdk-experimental/ui-patterns/tabs/tab.ts new file mode 100644 index 000000000000..c909371a4ef6 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/tabs/tab.ts @@ -0,0 +1,56 @@ +/** + * @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 '../behaviors/signal-like/signal-like'; +import {ListSelectionItem} from '../behaviors/list-selection/list-selection'; +import {ListNavigationItem} from '../behaviors/list-navigation/list-navigation'; +import {ListFocusItem} from '../behaviors/list-focus/list-focus'; +import {TabpanelPattern} from './tabpanel'; +import {TablistPattern} from './tablist'; + +/** The required inputs to tabs. */ +export interface TabInputs extends ListNavigationItem, ListSelectionItem, ListFocusItem { + tablist: SignalLike; + tabpanel: SignalLike; +} + +/** A tab in a tablist. */ +export class TabPattern { + /** A unique identifier for the tab. */ + id: SignalLike; + + /** Whether the tab is selected. */ + selected = computed(() => this.tablist().selection.inputs.selectedIds().includes(this.id())); + + /** A Tabpanel Id controlled by the tab. */ + controls = computed(() => this.tabpanel().id()); + + /** Whether the tab is disabled. */ + disabled: SignalLike; + + /** A reference to the parent tablist. */ + tablist: SignalLike; + + /** A reference to the corresponding tabpanel. */ + tabpanel: SignalLike; + + /** The tabindex of the tab. */ + tabindex = computed(() => this.tablist().focusManager.getItemTabindex(this)); + + /** The html element that should receive focus. */ + element: SignalLike; + + constructor(inputs: TabInputs) { + this.id = inputs.id; + this.tablist = inputs.tablist; + this.tabpanel = inputs.tabpanel; + this.element = inputs.element; + this.disabled = inputs.disabled; + } +} diff --git a/src/cdk-experimental/ui-patterns/tabs/tablist.ts b/src/cdk-experimental/ui-patterns/tabs/tablist.ts new file mode 100644 index 000000000000..04b987975a03 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/tabs/tablist.ts @@ -0,0 +1,208 @@ +/** + * @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 {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager'; +import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager'; +import {TabPattern} from './tab'; +import {ListSelection, ListSelectionInputs} from '../behaviors/list-selection/list-selection'; +import {ListNavigation, ListNavigationInputs} from '../behaviors/list-navigation/list-navigation'; +import {ListFocus, ListFocusInputs} from '../behaviors/list-focus/list-focus'; +import {computed, signal} from '@angular/core'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; + +/** The selection operations that the tablist can perform. */ +interface SelectOptions { + select?: boolean; + toggle?: boolean; + toggleOne?: boolean; + selectOne?: boolean; + selectAll?: boolean; + selectFromAnchor?: boolean; + selectFromActive?: boolean; +} + +/** The required inputs for the tablist. */ +export type TablistInputs = ListNavigationInputs & + Omit, 'multiselectable' | 'selectedIds'> & + ListFocusInputs & { + disabled: SignalLike; + }; + +/** Controls the state of a tablist. */ +export class TablistPattern { + /** Controls navigation for the tablist. */ + navigation: ListNavigation; + + /** Controls selection for the tablist. */ + selection: ListSelection; + + /** Controls focus for the tablist. */ + focusManager: ListFocus; + + /** Whether the tablist is vertically or horizontally oriented. */ + orientation: SignalLike<'vertical' | 'horizontal'>; + + /** Whether the tablist is disabled. */ + disabled: SignalLike; + + /** The tabindex of the tablist. */ + tabindex = computed(() => this.focusManager.getListTabindex()); + + /** The id of the current active tab. */ + activedescendant = computed(() => this.focusManager.getActiveDescendant()); + + followFocus = computed(() => this.inputs.selectionMode() === 'follow'); + + /** The key used to navigate to the previous tab in the tablist. */ + 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 keydown event manager for the tablist. */ + keydown = computed(() => { + const manager = new KeyboardEventManager(); + + 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})); + } else { + manager + .on(this.prevKey, () => this.prev()) + .on(this.nextKey, () => this.next()) + .on('Home', () => this.first()) + .on('End', () => this.last()) + .on(' ', () => this._updateSelection({selectOne: true})) + .on('Enter', () => this._updateSelection({selectOne: true})); + } + + return manager; + }); + + /** The pointerdown event manager for the tablist. */ + pointerdown = computed(() => { + const manager = new PointerEventManager(); + manager.on(e => this.goto(e, {selectOne: true})); + + return manager; + }); + + constructor(readonly inputs: TablistInputs) { + this.disabled = inputs.disabled; + this.orientation = inputs.orientation; + + this.navigation = new ListNavigation(inputs); + this.selection = new ListSelection({ + ...inputs, + navigation: this.navigation, + multiselectable: signal(false), + selectedIds: signal([]), + }); + this.focusManager = new ListFocus({...inputs, navigation: this.navigation}); + } + + /** Handles keydown events for the tablist. */ + onKeydown(event: KeyboardEvent) { + if (!this.disabled()) { + this.keydown().handle(event); + } + } + + /** The pointerdown event manager for the tablist. */ + onPointerdown(event: PointerEvent) { + if (!this.disabled()) { + this.pointerdown().handle(event); + } + } + + /** Navigates to the first option in the tablist. */ + first(opts?: SelectOptions) { + this.navigation.first(); + this.focusManager.focus(); + this._updateSelection(opts); + } + + /** Navigates to the last option in the tablist. */ + last(opts?: SelectOptions) { + this.navigation.last(); + this.focusManager.focus(); + this._updateSelection(opts); + } + + /** Navigates to the next option in the tablist. */ + next(opts?: SelectOptions) { + this.navigation.next(); + this.focusManager.focus(); + this._updateSelection(opts); + } + + /** Navigates to the previous option in the tablist. */ + prev(opts?: SelectOptions) { + this.navigation.prev(); + this.focusManager.focus(); + this._updateSelection(opts); + } + + /** Navigates to the given item in the tablist. */ + goto(event: PointerEvent, opts?: SelectOptions) { + const item = this._getItem(event); + + if (item) { + this.navigation.goto(item); + this.focusManager.focus(); + this._updateSelection(opts); + } + } + + /** Handles updating selection for the tablist. */ + 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('[role="tab"]'); + return this.inputs.items().find(i => i.element() === element); + } +} diff --git a/src/cdk-experimental/ui-patterns/tabs/tabpanel.ts b/src/cdk-experimental/ui-patterns/tabs/tabpanel.ts new file mode 100644 index 000000000000..ef7f150ee67d --- /dev/null +++ b/src/cdk-experimental/ui-patterns/tabs/tabpanel.ts @@ -0,0 +1,34 @@ +/** + * @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 '../behaviors/signal-like/signal-like'; +import {TabPattern} from './tab'; + +/** The required inputs for the tabpanel. */ +export interface TabpanelInputs { + id: SignalLike; + tab: SignalLike; +} + +/** A tabpanel associated with a tab. */ +export class TabpanelPattern { + /** A unique identifier for the tabpanel. */ + id: SignalLike; + + /** A reference to the corresponding tab. */ + tab: SignalLike; + + /** Whether the tabpanel is hidden. */ + hidden = computed(() => !this.tab().selected()); + + constructor(inputs: TabpanelInputs) { + this.id = inputs.id; + this.tab = inputs.tab; + } +} diff --git a/src/components-examples/cdk-experimental/tabs/BUILD.bazel b/src/components-examples/cdk-experimental/tabs/BUILD.bazel new file mode 100644 index 000000000000..b364e8464ddf --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/BUILD.bazel @@ -0,0 +1,27 @@ +load("//tools:defaults.bzl", "ng_module") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "tabs", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//src/cdk-experimental/tabs", + "//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/tabs/cdk-tabs/cdk-tabs-example.css b/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.css new file mode 100644 index 000000000000..7e37be2750fe --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.css @@ -0,0 +1,76 @@ +.example-tablist-controls { + display: flex; + align-items: center; + gap: 16px; + padding-bottom: 16px; +} + +.example-tabs { + border: 1px solid var(--mat-sys-outline); + min-height: 30vh; +} + +.example-tabs:has(ul[aria-orientation='vertical']) { + display: flex; +} + +.example-tablist { + gap: 8px; + margin: 0; + padding: 8px; + max-height: 50vh; + border-bottom: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + display: flex; + list-style: none; + flex-direction: row; + justify-content: center; + overflow: scroll; +} + +.example-tablist[aria-orientation='vertical'] { + flex-direction: column; + border-bottom: initial; + border-right: 1px solid var(--mat-sys-outline); + justify-content: start; +} + +.example-tablist[aria-orientation='horizontal'] li::before { + display: none; +} + +.example-tablist[aria-orientation='horizontal'] li[aria-selected='true']::before { + display: block; +} + +.example-tab { + gap: 16px; + padding: 16px; + display: flex; + cursor: pointer; + align-items: center; + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-tab:hover, +.example-tab[tabindex='0'] { + outline: 1px solid var(--mat-sys-outline); + background: var(--mat-sys-surface-container); +} + +.example-tab:focus-within { + outline: 2px solid var(--mat-sys-primary); + background: var(--mat-sys-surface-container); +} + +.example-tab[aria-selected='true'] { + background-color: var(--mat-sys-secondary-container); +} + +.example-tabpanel { + margin: 8px; +} + +.example-tabpanel[inert] { + display: none; +} \ No newline at end of file diff --git a/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.html b/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.html new file mode 100644 index 000000000000..643ae4ba6d97 --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.html @@ -0,0 +1,58 @@ +
+ Wrap + Disabled + Skip Disabled + + + Orientation + + Vertical + Horizontal + + + + + Selection strategy + + Explicit + Follow Focus + + + + + Focus strategy + + Roving Tabindex + Active Descendant + + +
+ + +
+
    +
  • tab 1
  • +
  • tab 2
  • +
  • tab 3
  • +
+ +
+ Tabpanel 1 +
+
+ Tabpanel 2 +
+
+ Tabpanel 3 +
+
+ diff --git a/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.ts b/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.ts new file mode 100644 index 000000000000..07929e202e99 --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.ts @@ -0,0 +1,40 @@ +import {Component} from '@angular/core'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import { + CdkTabs, + CdkTablist, + CdkTab, + CdkTabpanel, + CdkTabcontent, +} from '@angular/cdk-experimental/tabs'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatSelectModule} from '@angular/material/select'; +import {FormControl, ReactiveFormsModule} from '@angular/forms'; + +/** @title Tabs using UI Patterns. */ +@Component({ + selector: 'cdk-tabs-example', + exportAs: 'cdkTabsExample', + templateUrl: 'cdk-tabs-example.html', + styleUrl: 'cdk-tabs-example.css', + imports: [ + CdkTabs, + CdkTablist, + CdkTab, + CdkTabpanel, + CdkTabcontent, + MatCheckboxModule, + MatFormFieldModule, + MatSelectModule, + ReactiveFormsModule, + ], +}) +export class CdkTabsExample { + orientation: 'vertical' | 'horizontal' = 'horizontal'; + focusMode: 'roving' | 'activedescendant' = 'roving'; + selectionMode: 'explicit' | 'follow' = 'follow'; + + wrap = new FormControl(true, {nonNullable: true}); + disabled = new FormControl(false, {nonNullable: true}); + skipDisabled = new FormControl(true, {nonNullable: true}); +} diff --git a/src/components-examples/cdk-experimental/tabs/index.ts b/src/components-examples/cdk-experimental/tabs/index.ts new file mode 100644 index 000000000000..2a00cbe51c55 --- /dev/null +++ b/src/components-examples/cdk-experimental/tabs/index.ts @@ -0,0 +1 @@ +export {CdkTabsExample} from './cdk-tabs/cdk-tabs-example'; diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index 87e88e18b767..0d50f2d1f7b6 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -25,6 +25,7 @@ ng_module( "//src/dev-app/cdk-dialog", "//src/dev-app/cdk-experimental-combobox", "//src/dev-app/cdk-experimental-listbox", + "//src/dev-app/cdk-experimental-tabs", "//src/dev-app/cdk-listbox", "//src/dev-app/cdk-menu", "//src/dev-app/checkbox", diff --git a/src/dev-app/cdk-experimental-tabs/BUILD.bazel b/src/dev-app/cdk-experimental-tabs/BUILD.bazel new file mode 100644 index 000000000000..35e85fa61376 --- /dev/null +++ b/src/dev-app/cdk-experimental-tabs/BUILD.bazel @@ -0,0 +1,10 @@ +load("//tools:defaults.bzl", "ng_module") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "cdk-experimental-tabs", + srcs = glob(["**/*.ts"]), + assets = ["cdk-tabs-demo.html"], + deps = ["//src/components-examples/cdk-experimental/tabs"], +) diff --git a/src/dev-app/cdk-experimental-tabs/cdk-tabs-demo.html b/src/dev-app/cdk-experimental-tabs/cdk-tabs-demo.html new file mode 100644 index 000000000000..8f53b8076db1 --- /dev/null +++ b/src/dev-app/cdk-experimental-tabs/cdk-tabs-demo.html @@ -0,0 +1,4 @@ +
+

Tabs using UI Patterns

+ +
\ No newline at end of file diff --git a/src/dev-app/cdk-experimental-tabs/cdk-tabs-demo.ts b/src/dev-app/cdk-experimental-tabs/cdk-tabs-demo.ts new file mode 100644 index 000000000000..39de3f744db4 --- /dev/null +++ b/src/dev-app/cdk-experimental-tabs/cdk-tabs-demo.ts @@ -0,0 +1,17 @@ +/** + * @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 {ChangeDetectionStrategy, Component} from '@angular/core'; +import {CdkTabsExample} from '@angular/components-examples/cdk-experimental/tabs'; + +@Component({ + templateUrl: 'cdk-tabs-demo.html', + imports: [CdkTabsExample], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkExperimentalTabsDemo {} diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index 86d27e04692a..a6c41ca5dbbc 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -60,6 +60,7 @@ export class DevAppLayout { {name: 'CDK Dialog', route: '/cdk-dialog'}, {name: 'CDK Experimental Combobox', route: '/cdk-experimental-combobox'}, {name: 'CDK Experimental Listbox', route: '/cdk-experimental-listbox'}, + {name: 'CDK Experimental Tabs', route: '/cdk-experimental-tabs'}, {name: 'CDK Listbox', route: '/cdk-listbox'}, {name: 'CDK Menu', route: '/cdk-menu'}, {name: 'Autocomplete', route: '/autocomplete'}, diff --git a/src/dev-app/routes.ts b/src/dev-app/routes.ts index eb82f10c1a2e..bc01db461312 100644 --- a/src/dev-app/routes.ts +++ b/src/dev-app/routes.ts @@ -50,6 +50,11 @@ export const DEV_APP_ROUTES: Routes = [ loadComponent: () => import('./cdk-experimental-listbox/cdk-listbox-demo').then(m => m.CdkExperimentalListboxDemo), }, + { + path: 'cdk-experimental-tabs', + loadComponent: () => + import('./cdk-experimental-tabs/cdk-tabs-demo').then(m => m.CdkExperimentalTabsDemo), + }, { path: 'cdk-dialog', loadComponent: () => import('./cdk-dialog/dialog-demo').then(m => m.DialogDemo),