From e436a1170e0711bc11495a50740079b2c4c344cf Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Wed, 10 Sep 2025 22:06:04 -0400 Subject: [PATCH 01/23] feat(cdk-experimental/combobox): introduce new signals-based combobox --- src/cdk-experimental/combobox/BUILD.bazel | 29 +- .../combobox/combobox-module.ts | 19 - .../combobox/combobox-popup.ts | 63 --- .../combobox/combobox.spec.ts | 380 ------------------ src/cdk-experimental/combobox/combobox.ts | 321 +-------------- src/cdk-experimental/combobox/public-api.ts | 4 +- .../cdk-experimental/combobox/BUILD.bazel | 29 ++ .../cdk-experimental-combobox/BUILD.bazel | 2 + .../cdk-combobox-demo.css | 69 ++++ .../cdk-combobox-demo.html | 39 +- .../cdk-combobox-demo.ts | 44 +- 11 files changed, 184 insertions(+), 815 deletions(-) delete mode 100644 src/cdk-experimental/combobox/combobox-module.ts delete mode 100644 src/cdk-experimental/combobox/combobox-popup.ts delete mode 100644 src/cdk-experimental/combobox/combobox.spec.ts create mode 100644 src/components-examples/cdk-experimental/combobox/BUILD.bazel create mode 100644 src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.css diff --git a/src/cdk-experimental/combobox/BUILD.bazel b/src/cdk-experimental/combobox/BUILD.bazel index c4b52bf73e3c..1dc58929420e 100644 --- a/src/cdk-experimental/combobox/BUILD.bazel +++ b/src/cdk-experimental/combobox/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite") +load("//tools:defaults.bzl", "ng_project") package(default_visibility = ["//visibility:public"]) @@ -9,33 +9,6 @@ ng_project( exclude = ["**/*.spec.ts"], ), deps = [ - "//:node_modules/@angular/common", "//:node_modules/@angular/core", - "//src:dev_mode_types", - "//src/cdk/a11y", - "//src/cdk/bidi", - "//src/cdk/collections", - "//src/cdk/overlay", ], ) - -ng_project( - name = "unit_test_sources", - testonly = True, - srcs = glob( - ["**/*.spec.ts"], - exclude = ["**/*.e2e.spec.ts"], - ), - deps = [ - ":combobox", - "//:node_modules/@angular/core", - "//:node_modules/@angular/platform-browser", - "//src/cdk/keycodes", - "//src/cdk/testing/private", - ], -) - -ng_web_test_suite( - name = "unit_tests", - deps = [":unit_test_sources"], -) diff --git a/src/cdk-experimental/combobox/combobox-module.ts b/src/cdk-experimental/combobox/combobox-module.ts deleted file mode 100644 index 2fd265112bbe..000000000000 --- a/src/cdk-experimental/combobox/combobox-module.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @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 {NgModule} from '@angular/core'; -import {OverlayModule} from '@angular/cdk/overlay'; -import {CdkCombobox} from './combobox'; -import {CdkComboboxPopup} from './combobox-popup'; - -const EXPORTED_DECLARATIONS = [CdkCombobox, CdkComboboxPopup]; -@NgModule({ - imports: [OverlayModule, ...EXPORTED_DECLARATIONS], - exports: EXPORTED_DECLARATIONS, -}) -export class CdkComboboxModule {} diff --git a/src/cdk-experimental/combobox/combobox-popup.ts b/src/cdk-experimental/combobox/combobox-popup.ts deleted file mode 100644 index 0f5912611c43..000000000000 --- a/src/cdk-experimental/combobox/combobox-popup.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * @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 {Directive, ElementRef, Input, OnInit, inject} from '@angular/core'; -import {_IdGenerator} from '@angular/cdk/a11y'; -import {AriaHasPopupValue, CDK_COMBOBOX, CdkCombobox} from './combobox'; - -@Directive({ - selector: '[cdkComboboxPopup]', - exportAs: 'cdkComboboxPopup', - host: { - 'class': 'cdk-combobox-popup', - '[attr.role]': 'role', - '[id]': 'id', - 'tabindex': '-1', - '(focus)': 'focusFirstElement()', - }, -}) -export class CdkComboboxPopup implements OnInit { - private readonly _elementRef = inject>(ElementRef); - private readonly _combobox = inject(CDK_COMBOBOX); - - @Input() - get role(): AriaHasPopupValue { - return this._role; - } - set role(value: AriaHasPopupValue) { - this._role = value; - } - private _role: AriaHasPopupValue = 'dialog'; - - @Input() - get firstFocus(): HTMLElement { - return this._firstFocusElement; - } - set firstFocus(id: HTMLElement) { - this._firstFocusElement = id; - } - private _firstFocusElement: HTMLElement; - - @Input() id: string = inject(_IdGenerator).getId('cdk-combobox-popup-'); - - ngOnInit() { - this.registerWithPanel(); - } - - registerWithPanel(): void { - this._combobox._registerContent(this.id, this._role); - } - - focusFirstElement() { - if (this._firstFocusElement) { - this._firstFocusElement.focus(); - } else { - this._elementRef.nativeElement.focus(); - } - } -} diff --git a/src/cdk-experimental/combobox/combobox.spec.ts b/src/cdk-experimental/combobox/combobox.spec.ts deleted file mode 100644 index 0b6e0ea1ec01..000000000000 --- a/src/cdk-experimental/combobox/combobox.spec.ts +++ /dev/null @@ -1,380 +0,0 @@ -import {CdkComboboxPopup} from '../combobox'; -import {DOWN_ARROW, ESCAPE} from '@angular/cdk/keycodes'; -import {dispatchKeyboardEvent, dispatchMouseEvent} from '@angular/cdk/testing/private'; -import {Component, DebugElement, ElementRef, ViewChild, signal} from '@angular/core'; -import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; -import {By} from '@angular/platform-browser'; -import {CdkCombobox} from './combobox'; -import {CdkComboboxModule} from './combobox-module'; - -describe('Combobox', () => { - describe('with a basic toggle trigger', () => { - let fixture: ComponentFixture; - let testComponent: ComboboxToggle; - - let combobox: DebugElement; - let comboboxInstance: CdkCombobox; - let comboboxElement: HTMLElement; - - let dialog: DebugElement; - let dialogInstance: CdkComboboxPopup; - let dialogElement: HTMLElement; - - let applyButton: DebugElement; - let applyButtonElement: HTMLElement; - - beforeEach(() => { - fixture = TestBed.createComponent(ComboboxToggle); - fixture.detectChanges(); - - testComponent = fixture.debugElement.componentInstance; - - combobox = fixture.debugElement.query(By.directive(CdkCombobox)); - comboboxInstance = combobox.injector.get(CdkCombobox); - comboboxElement = combobox.nativeElement; - }); - - it('should have the combobox role', () => { - expect(comboboxElement.getAttribute('role')).toBe('combobox'); - }); - - it('should update the aria disabled attribute', () => { - comboboxInstance.disabled = true; - fixture.changeDetectorRef.markForCheck(); - fixture.detectChanges(); - - expect(comboboxElement.getAttribute('aria-disabled')).toBe('true'); - - comboboxInstance.disabled = false; - fixture.changeDetectorRef.markForCheck(); - fixture.detectChanges(); - - expect(comboboxElement.getAttribute('aria-disabled')).toBe('false'); - }); - - it('should have aria-owns and aria-haspopup attributes', () => { - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); - - dialog = fixture.debugElement.query(By.directive(CdkComboboxPopup)); - dialogInstance = dialog.injector.get(CdkComboboxPopup); - - expect(comboboxElement.getAttribute('aria-owns')).toBe(dialogInstance.id); - expect(comboboxElement.getAttribute('aria-haspopup')).toBe('dialog'); - }); - - it('should update aria-expanded attribute upon toggle of panel', () => { - expect(comboboxElement.getAttribute('aria-expanded')).toBe('false'); - - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); - - expect(comboboxElement.getAttribute('aria-expanded')).toBe('true'); - - comboboxInstance.close(); - fixture.detectChanges(); - - expect(comboboxElement.getAttribute('aria-expanded')).toBe('false'); - }); - - it('should toggle focus upon toggling the panel', () => { - comboboxElement.focus(); - testComponent.actions.set('toggle'); - fixture.detectChanges(); - - expect(document.activeElement).toEqual(comboboxElement); - - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeTrue(); - - dialog = fixture.debugElement.query(By.directive(CdkComboboxPopup)); - dialogElement = dialog.nativeElement; - - expect(document.activeElement).toBe(dialogElement); - - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); - - expect(document.activeElement).not.toEqual(dialogElement); - }); - - it('should have a panel that is closed by default', () => { - expect(comboboxInstance.hasPanel()).toBeTrue(); - expect(comboboxInstance.isOpen()).toBeFalse(); - }); - - it('should have an open action of click by default', () => { - expect(comboboxInstance.isOpen()).toBeFalse(); - - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeTrue(); - }); - - it('should not open panel when disabled', () => { - expect(comboboxInstance.isOpen()).toBeFalse(); - comboboxInstance.disabled = true; - fixture.changeDetectorRef.markForCheck(); - fixture.detectChanges(); - - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeFalse(); - }); - - it('should update textContent on close of panel', () => { - expect(comboboxInstance.isOpen()).toBeFalse(); - - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeTrue(); - - testComponent.inputElement.nativeElement.value = 'testing input'; - fixture.detectChanges(); - - applyButton = fixture.debugElement.query(By.css('#applyButton')); - applyButtonElement = applyButton.nativeElement; - - dispatchMouseEvent(applyButtonElement, 'click'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeFalse(); - expect(comboboxElement.textContent).toEqual('testing input'); - }); - - it('should close panel on outside click', () => { - expect(comboboxInstance.isOpen()).toBeFalse(); - - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeTrue(); - - const otherDiv = fixture.debugElement.query(By.css('#other-content')); - const otherDivElement = otherDiv.nativeElement; - - dispatchMouseEvent(otherDivElement, 'click'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeFalse(); - }); - - it('should clean up the overlay on destroy', () => { - expect(document.querySelectorAll('.cdk-overlay-pane').length).toBe(0); - - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); - expect(document.querySelectorAll('.cdk-overlay-pane').length).toBe(1); - - fixture.destroy(); - expect(document.querySelectorAll('.cdk-overlay-pane').length).toBe(0); - }); - }); - - describe('with a coerce open action property function', () => { - let fixture: ComponentFixture; - let testComponent: ComboboxToggle; - - let combobox: DebugElement; - let comboboxInstance: CdkCombobox; - - beforeEach(waitForAsync(() => {})); - - beforeEach(() => { - fixture = TestBed.createComponent(ComboboxToggle); - fixture.detectChanges(); - - testComponent = fixture.debugElement.componentInstance; - - combobox = fixture.debugElement.query(By.directive(CdkCombobox)); - comboboxInstance = combobox.injector.get(CdkCombobox); - }); - - it('should coerce single string into open action', () => { - const openActions = comboboxInstance.openActions; - expect(openActions.length).toBe(1); - expect(openActions[0]).toBe('click'); - }); - - it('should coerce actions separated by space', () => { - testComponent.actions.set('focus click'); - fixture.detectChanges(); - - const openActions = comboboxInstance.openActions; - expect(openActions.length).toBe(2); - expect(openActions[0]).toBe('focus'); - expect(openActions[1]).toBe('click'); - }); - - it('should coerce actions separated by comma', () => { - testComponent.actions.set('focus,click,downKey'); - fixture.detectChanges(); - - const openActions = comboboxInstance.openActions; - expect(openActions.length).toBe(3); - expect(openActions[0]).toBe('focus'); - expect(openActions[1]).toBe('click'); - expect(openActions[2]).toBe('downKey'); - }); - - it('should coerce actions separated by commas and spaces', () => { - testComponent.actions.set('focus click,downKey'); - fixture.detectChanges(); - - const openActions = comboboxInstance.openActions; - expect(openActions.length).toBe(3); - expect(openActions[0]).toBe('focus'); - expect(openActions[1]).toBe('click'); - expect(openActions[2]).toBe('downKey'); - }); - - it('should throw error when given invalid open action', () => { - expect(() => { - testComponent.actions.set('invalidAction'); - fixture.detectChanges(); - }).toThrowError('invalidAction is not a support open action for CdkCombobox'); - }); - }); - - describe('with various open actions', () => { - let fixture: ComponentFixture; - let testComponent: ComboboxToggle; - - let combobox: DebugElement; - let comboboxInstance: CdkCombobox; - let comboboxElement: HTMLElement; - - beforeEach(waitForAsync(() => {})); - - beforeEach(() => { - fixture = TestBed.createComponent(ComboboxToggle); - fixture.detectChanges(); - - testComponent = fixture.debugElement.componentInstance; - - combobox = fixture.debugElement.query(By.directive(CdkCombobox)); - comboboxInstance = combobox.injector.get(CdkCombobox); - comboboxElement = combobox.nativeElement; - }); - - it('should open panel with focus open action', () => { - testComponent.actions.set('focus'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeFalse(); - - comboboxElement.focus(); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeTrue(); - }); - - it('should open panel with click open action', () => { - testComponent.actions.set('click'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeFalse(); - - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeTrue(); - }); - - it('should open panel with downKey open action', () => { - testComponent.actions.set('downKey'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeFalse(); - - dispatchKeyboardEvent(comboboxElement, 'keydown', DOWN_ARROW); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeTrue(); - }); - - it('should toggle panel with toggle open action', () => { - testComponent.actions.set('toggle'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeFalse(); - - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeTrue(); - - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeFalse(); - }); - - it('should close panel on escape key', () => { - testComponent.actions.set('click'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeFalse(); - - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeTrue(); - - dispatchKeyboardEvent(comboboxElement, 'keydown', ESCAPE); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeFalse(); - }); - - it('should handle multiple open actions', () => { - testComponent.actions.set('click downKey'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeFalse(); - - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeTrue(); - - dispatchKeyboardEvent(comboboxElement, 'keydown', ESCAPE); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeFalse(); - - dispatchKeyboardEvent(comboboxElement, 'keydown', DOWN_ARROW); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeTrue(); - }); - }); -}); - -@Component({ - template: ` - -
- - -
- - -
-
`, - imports: [CdkComboboxModule], -}) -class ComboboxToggle { - @ViewChild('input') inputElement: ElementRef; - - actions = signal('click'); -} diff --git a/src/cdk-experimental/combobox/combobox.ts b/src/cdk-experimental/combobox/combobox.ts index 5044d08c802a..4ff50bcca464 100644 --- a/src/cdk-experimental/combobox/combobox.ts +++ b/src/cdk-experimental/combobox/combobox.ts @@ -5,317 +5,20 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ -import {Directionality} from '@angular/cdk/bidi'; -import {BooleanInput, coerceArray, coerceBooleanProperty} from '@angular/cdk/coercion'; -import {DOWN_ARROW, ENTER, ESCAPE, TAB} from '@angular/cdk/keycodes'; -import { - ConnectedPosition, - createBlockScrollStrategy, - createFlexibleConnectedPositionStrategy, - createOverlayRef, - FlexibleConnectedPositionStrategy, - OverlayConfig, - OverlayRef, -} from '@angular/cdk/overlay'; -import {_getEventTarget} from '@angular/cdk/platform'; -import {TemplatePortal} from '@angular/cdk/portal'; -import { - ChangeDetectorRef, - Directive, - ElementRef, - EventEmitter, - HOST_TAG_NAME, - InjectionToken, - Injector, - Input, - OnDestroy, - Output, - TemplateRef, - ViewContainerRef, - inject, - DOCUMENT, -} from '@angular/core'; - -export type AriaHasPopupValue = 'false' | 'true' | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog'; -export type OpenAction = 'focus' | 'click' | 'downKey' | 'toggle'; -export type OpenActionInput = OpenAction | OpenAction[] | string | null | undefined; - -const allowedOpenActions = ['focus', 'click', 'downKey', 'toggle']; - -export const CDK_COMBOBOX = new InjectionToken('CDK_COMBOBOX'); +import {Directive, input} from '@angular/core'; @Directive({ - selector: '[cdkCombobox]', - exportAs: 'cdkCombobox', - host: { - 'role': 'combobox', - 'class': 'cdk-combobox', - '(click)': '_handleInteractions("click")', - '(focus)': '_handleInteractions("focus")', - '(keydown)': '_keydown($event)', - '(document:click)': '_attemptClose($event)', - '[attr.aria-disabled]': 'disabled', - '[attr.aria-owns]': 'contentId', - '[attr.aria-haspopup]': 'contentType', - '[attr.aria-expanded]': 'isOpen()', - '[attr.tabindex]': '_getTabIndex()', - }, - providers: [{provide: CDK_COMBOBOX, useExisting: CdkCombobox}], + selector: 'input[cdkComboboxInput]', + exportAs: 'cdkComboboxInput', + host: {'role': 'combobox'}, }) -export class CdkCombobox implements OnDestroy { - private readonly _tagName = inject(HOST_TAG_NAME); - private readonly _elementRef = inject>(ElementRef); - protected readonly _viewContainerRef = inject(ViewContainerRef); - private readonly _injector = inject(Injector); - private readonly _doc = inject(DOCUMENT); - private readonly _directionality = inject(Directionality, {optional: true}); - private _changeDetectorRef = inject(ChangeDetectorRef); - private _overlayRef: OverlayRef; - private _panelPortal: TemplatePortal; - - @Input('cdkComboboxTriggerFor') - _panelTemplateRef: TemplateRef; - - @Input() - value: T | T[]; - - @Input() - get disabled(): boolean { - return this._disabled; - } - set disabled(value: BooleanInput) { - this._disabled = coerceBooleanProperty(value); - } - private _disabled: boolean = false; - - @Input() - get openActions(): OpenAction[] { - return this._openActions; - } - set openActions(action: OpenActionInput) { - this._openActions = this._coerceOpenActionProperty(action); - } - private _openActions: OpenAction[] = ['click']; - - /** Whether the textContent is automatically updated upon change of the combobox value. */ - @Input() - get autoSetText(): boolean { - return this._autoSetText; - } - set autoSetText(value: BooleanInput) { - this._autoSetText = coerceBooleanProperty(value); - } - private _autoSetText: boolean = true; - - @Output('comboboxPanelOpened') readonly opened: EventEmitter = new EventEmitter(); - @Output('comboboxPanelClosed') readonly closed: EventEmitter = new EventEmitter(); - @Output('panelValueChanged') readonly panelValueChanged: EventEmitter = new EventEmitter< - T[] - >(); - - contentId: string = ''; - contentType: AriaHasPopupValue; - - ngOnDestroy() { - if (this._overlayRef) { - this._overlayRef.dispose(); - } - - this.opened.complete(); - this.closed.complete(); - this.panelValueChanged.complete(); - } - - _keydown(event: KeyboardEvent) { - const {keyCode} = event; - - if (keyCode === DOWN_ARROW) { - if (this.isOpen()) { - // TODO: instead of using a focus function, potentially use cdk/a11y focus trapping - this._doc.getElementById(this.contentId)?.focus(); - } else if (this._openActions.indexOf('downKey') !== -1) { - this.open(); - } - } else if (keyCode === ENTER) { - if (this._openActions.indexOf('toggle') !== -1) { - this.toggle(); - } else if (this._openActions.indexOf('click') !== -1) { - this.open(); - } - } else if (keyCode === ESCAPE) { - event.preventDefault(); - this.close(); - } else if (keyCode === TAB) { - this.close(); - } - } - - /** Handles click or focus interactions. */ - _handleInteractions(interaction: OpenAction) { - if (interaction === 'click') { - if (this._openActions.indexOf('toggle') !== -1) { - this.toggle(); - } else if (this._openActions.indexOf('click') !== -1) { - this.open(); - } - } else if (interaction === 'focus') { - if (this._openActions.indexOf('focus') !== -1) { - this.open(); - } - } - } - - /** Given a click in the document, determines if the click was inside a combobox. */ - _attemptClose(event: MouseEvent) { - if (this.isOpen()) { - let target = _getEventTarget(event); - while (target instanceof Element) { - if (target.className.indexOf('cdk-combobox') !== -1) { - return; - } - target = target.parentElement; - } - } - - this.close(); - } - - /** Toggles the open state of the panel. */ - toggle() { - if (this.hasPanel()) { - this.isOpen() ? this.close() : this.open(); - } - } - - /** If the combobox is closed and not disabled, opens the panel. */ - open() { - if (!this.isOpen() && !this.disabled) { - this.opened.next(); - this._overlayRef = - this._overlayRef || createOverlayRef(this._injector, this._getOverlayConfig()); - this._overlayRef.attach(this._getPanelContent()); - this._changeDetectorRef.markForCheck(); - if (!this._isTextTrigger()) { - // TODO: instead of using a focus function, potentially use cdk/a11y focus trapping - this._doc.getElementById(this.contentId)?.focus(); - } - } - } - - /** If the combobox is open and not disabled, closes the panel. */ - close() { - if (this.isOpen() && !this.disabled) { - this.closed.next(); - this._overlayRef.detach(); - this._changeDetectorRef.markForCheck(); - } - } - - /** Returns true if panel is currently opened. */ - isOpen(): boolean { - return this._overlayRef ? this._overlayRef.hasAttached() : false; - } - - /** Returns true if combobox has a child panel. */ - hasPanel(): boolean { - return !!this._panelTemplateRef; - } - - _getTabIndex(): string | null { - return this.disabled ? null : '0'; - } - - private _setComboboxValue(value: T | T[]) { - const valueChanged = this.value !== value; - this.value = value; - - if (valueChanged) { - this.panelValueChanged.emit(coerceArray(value)); - if (this._autoSetText) { - this._setTextContent(value); - } - } - } - - updateAndClose(value: T | T[]) { - this._setComboboxValue(value); - this.close(); - } - - private _setTextContent(content: T | T[]) { - const contentArray = coerceArray(content); - this._elementRef.nativeElement.textContent = contentArray.join(' '); - } - - private _isTextTrigger() { - // TODO: Should check if the trigger is contenteditable. - const tagName = this._tagName.toLowerCase(); - return tagName === 'input' || tagName === 'textarea'; - } - - private _getOverlayConfig() { - return new OverlayConfig({ - positionStrategy: this._getOverlayPositionStrategy(), - scrollStrategy: createBlockScrollStrategy(this._injector), - direction: this._directionality || undefined, - }); - } - - private _getOverlayPositionStrategy(): FlexibleConnectedPositionStrategy { - return createFlexibleConnectedPositionStrategy(this._injector, this._elementRef).withPositions( - this._getOverlayPositions(), - ); - } - - private _getOverlayPositions(): ConnectedPosition[] { - return [ - {originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top'}, - {originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom'}, - {originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top'}, - {originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom'}, - ]; - } - - private _getPanelInjector() { - return this._injector; - } - - private _getPanelContent() { - const hasPanelChanged = this._panelTemplateRef !== this._panelPortal?.templateRef; - if (this._panelTemplateRef && (!this._panelPortal || hasPanelChanged)) { - this._panelPortal = new TemplatePortal( - this._panelTemplateRef, - this._viewContainerRef, - undefined, - this._getPanelInjector(), - ); - } - - return this._panelPortal; - } - - private _coerceOpenActionProperty(input: OpenActionInput): OpenAction[] { - let actions = typeof input === 'string' ? input.trim().split(/[ ,]+/) : input; - if ( - (typeof ngDevMode === 'undefined' || ngDevMode) && - actions?.some(a => allowedOpenActions.indexOf(a) === -1) - ) { - throw Error(`${input} is not a support open action for CdkCombobox`); - } - return actions as OpenAction[]; - } - - /** Registers the content's id and the content type with the panel. */ - _registerContent(contentId: string, contentType: AriaHasPopupValue) { - if ( - (typeof ngDevMode === 'undefined' || ngDevMode) && - contentType !== 'listbox' && - contentType !== 'dialog' - ) { - throw Error('CdkComboboxPanel currently only supports listbox or dialog content.'); - } - this.contentId = contentId; - this.contentType = contentType; - } +export class CdkComboboxInput { + readonly popup = input.required(); } + +@Directive({ + selector: '[cdkComboboxPopup]', + exportAs: 'cdkComboboxPopup', +}) +export class CdkComboboxPopup {} diff --git a/src/cdk-experimental/combobox/public-api.ts b/src/cdk-experimental/combobox/public-api.ts index 6f6b443c18bb..e6fa2e334860 100644 --- a/src/cdk-experimental/combobox/public-api.ts +++ b/src/cdk-experimental/combobox/public-api.ts @@ -6,6 +6,4 @@ * found in the LICENSE file at https://angular.dev/license */ -export * from './combobox-module'; -export * from './combobox'; -export * from './combobox-popup'; +export {CdkComboboxInput, CdkComboboxPopup} from './combobox'; diff --git a/src/components-examples/cdk-experimental/combobox/BUILD.bazel b/src/components-examples/cdk-experimental/combobox/BUILD.bazel new file mode 100644 index 000000000000..53307e4686d9 --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/BUILD.bazel @@ -0,0 +1,29 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "combobox", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//:node_modules/@angular/core", + "//:node_modules/@angular/forms", + "//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/dev-app/cdk-experimental-combobox/BUILD.bazel b/src/dev-app/cdk-experimental-combobox/BUILD.bazel index 7599efce5d7b..13e5b14d67f8 100644 --- a/src/dev-app/cdk-experimental-combobox/BUILD.bazel +++ b/src/dev-app/cdk-experimental-combobox/BUILD.bazel @@ -7,9 +7,11 @@ ng_project( srcs = glob(["**/*.ts"]), assets = [ "cdk-combobox-demo.html", + "cdk-combobox-demo.css", ], deps = [ "//:node_modules/@angular/core", "//src/cdk-experimental/combobox", + "//src/cdk-experimental/listbox", ], ) diff --git a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.css b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.css new file mode 100644 index 000000000000..280a0abf00ea --- /dev/null +++ b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.css @@ -0,0 +1,69 @@ +.example-combobox-container { + width: 300px; + display: flex; + flex-direction: column; +} + +.example-combobox-input-container { + display: flex; + overflow: hidden; + position: relative; + align-items: center; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.example-icon { + width: 24px; + height: 24px; + font-size: 20px; + display: grid; + place-items: center; + pointer-events: none; +} + +.example-search-icon { + padding: 0 0.5rem; + position: absolute; + opacity: 0.8; +} + +.example-combobox-input { + width: 100%; + border: none; + outline: none; + font-size: 1rem; + padding: 0.8rem 1rem 0.8rem 2.5rem; + background-color: var(--mat-sys-surface); +} +.example-listbox { + display: flex; + flex-direction: column; + overflow: auto; + height: 20rem; + padding: 0.5rem; + border: 1px solid var(--mat-sys-outline); + border-top: none; + border-bottom-left-radius: var(--mat-sys-corner-extra-small); + border-bottom-right-radius: var(--mat-sys-corner-extra-small); +} + +.example-option { + outline: none; + cursor: pointer; + padding: 0.5rem 1rem; + border-radius: var(--mat-sys-corner-extra-small); + display: flex; + align-items: center; + justify-content: space-between; +} + +.example-selected-icon { + visibility: hidden; +} + +.example-option[aria-selected='true'] .example-selected-icon { + visibility: visible; +} diff --git a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html index 70d7a85c7fd3..2519fcbd853e 100644 --- a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html +++ b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html @@ -1,12 +1,31 @@ -Toggle Combobox! -
- +
+
+ search + +
- -
- - +
+
+ @for (fruit of fruits; track fruit) { +
+ {{fruit}} + +
+ }
- +
+
diff --git a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.ts b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.ts index f138241fa601..a464ab1ef2a1 100644 --- a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.ts +++ b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.ts @@ -6,12 +6,50 @@ * found in the LICENSE file at https://angular.dev/license */ -import {CdkComboboxModule} from '@angular/cdk-experimental/combobox'; +import {CdkComboboxInput, CdkComboboxPopup} from '@angular/cdk-experimental/combobox'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; import {ChangeDetectionStrategy, Component} from '@angular/core'; @Component({ templateUrl: 'cdk-combobox-demo.html', - imports: [CdkComboboxModule], + styleUrl: 'cdk-combobox-demo.css', + imports: [CdkComboboxInput, CdkComboboxPopup, CdkListbox, CdkOption], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CdkComboboxDemo {} +export class CdkComboboxDemo { + fruits = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + 'Dates', + 'Figs', + 'Grapes', + 'Grapefruit', + 'Guava', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Mandarin', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion', + 'Peach', + 'Pear', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Raspberries', + 'Strawberry', + 'Tangerine', + 'Watermelon', + ]; +} From 5879210964e710d948ff4adeb44df65027eeee38 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Thu, 11 Sep 2025 12:49:14 -0400 Subject: [PATCH 02/23] feat(cdk-experimental/combobox): set up lazy loading --- src/cdk-experimental/combobox/BUILD.bazel | 1 + src/cdk-experimental/combobox/combobox.ts | 41 ++++++++++++++++--- src/cdk-experimental/combobox/public-api.ts | 2 +- src/cdk-experimental/listbox/BUILD.bazel | 1 + src/cdk-experimental/listbox/listbox.ts | 5 +++ .../ui-patterns/listbox/listbox.ts | 11 ++++- .../cdk-combobox-demo.html | 13 ++---- .../cdk-combobox-demo.ts | 16 +++++++- 8 files changed, 71 insertions(+), 19 deletions(-) diff --git a/src/cdk-experimental/combobox/BUILD.bazel b/src/cdk-experimental/combobox/BUILD.bazel index 1dc58929420e..cfe2786f6629 100644 --- a/src/cdk-experimental/combobox/BUILD.bazel +++ b/src/cdk-experimental/combobox/BUILD.bazel @@ -10,5 +10,6 @@ ng_project( ), deps = [ "//:node_modules/@angular/core", + "//src/cdk-experimental/deferred-content", ], ) diff --git a/src/cdk-experimental/combobox/combobox.ts b/src/cdk-experimental/combobox/combobox.ts index 4ff50bcca464..8651ec50010a 100644 --- a/src/cdk-experimental/combobox/combobox.ts +++ b/src/cdk-experimental/combobox/combobox.ts @@ -6,19 +6,50 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Directive, input} from '@angular/core'; +import {contentChild, Directive, inject} from '@angular/core'; +import {DeferredContent, DeferredContentAware} from '@angular/cdk-experimental/deferred-content'; + +@Directive({ + selector: '[cdkCombobox]', + exportAs: 'cdkCombobox', + hostDirectives: [ + { + directive: DeferredContentAware, + inputs: ['preserveContent'], + }, + ], +}) +export class CdkCombobox { + /** The DeferredContentAware host directive. */ + private readonly _deferredContentAware = inject(DeferredContentAware, {optional: true}); + + /** The combobox popup. */ + readonly popup = contentChild(CdkComboboxPopup); + + constructor() { + this._deferredContentAware?.contentVisible.set(true); + } +} @Directive({ selector: 'input[cdkComboboxInput]', exportAs: 'cdkComboboxInput', host: {'role': 'combobox'}, }) -export class CdkComboboxInput { - readonly popup = input.required(); -} +export class CdkComboboxInput {} + +@Directive({ + selector: 'ng-template[cdkComboboxPopupContent]', + exportAs: 'cdkComboboxPopupContent', + hostDirectives: [DeferredContent], +}) +export class CdkComboboxPopupContent {} @Directive({ selector: '[cdkComboboxPopup]', exportAs: 'cdkComboboxPopup', }) -export class CdkComboboxPopup {} +export class CdkComboboxPopup { + /** The combobox that the popup belongs to. */ + readonly combobox = inject(CdkCombobox, {optional: true}); +} diff --git a/src/cdk-experimental/combobox/public-api.ts b/src/cdk-experimental/combobox/public-api.ts index e6fa2e334860..3f06ec7cdf26 100644 --- a/src/cdk-experimental/combobox/public-api.ts +++ b/src/cdk-experimental/combobox/public-api.ts @@ -6,4 +6,4 @@ * found in the LICENSE file at https://angular.dev/license */ -export {CdkComboboxInput, CdkComboboxPopup} from './combobox'; +export {CdkCombobox, CdkComboboxInput, CdkComboboxPopup, CdkComboboxPopupContent} from './combobox'; diff --git a/src/cdk-experimental/listbox/BUILD.bazel b/src/cdk-experimental/listbox/BUILD.bazel index 9239b69dc50a..481b2cfa9ed7 100644 --- a/src/cdk-experimental/listbox/BUILD.bazel +++ b/src/cdk-experimental/listbox/BUILD.bazel @@ -10,6 +10,7 @@ ng_project( ), deps = [ "//:node_modules/@angular/core", + "//src/cdk-experimental/combobox", "//src/cdk-experimental/ui-patterns", "//src/cdk/a11y", "//src/cdk/bidi", diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index 16a74daea9f2..d4176fb55aec 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -22,6 +22,7 @@ import {ListboxPattern, OptionPattern} from '../ui-patterns'; import {Directionality} from '@angular/cdk/bidi'; import {toSignal} from '@angular/core/rxjs-interop'; import {_IdGenerator} from '@angular/cdk/a11y'; +import {CdkComboboxPopup} from '../combobox'; /** * A listbox container. @@ -53,8 +54,11 @@ import {_IdGenerator} from '@angular/cdk/a11y'; '(pointerdown)': 'pattern.onPointerdown($event)', '(focusin)': 'onFocus()', }, + hostDirectives: [{directive: CdkComboboxPopup}], }) export class CdkListbox { + private readonly _popup = inject(CdkComboboxPopup, {optional: true}); + /** A reference to the listbox element. */ private readonly _elementRef = inject(ElementRef); @@ -109,6 +113,7 @@ export class CdkListbox { activeItem: signal(undefined), textDirection: this.textDirection, element: () => this._elementRef.nativeElement, + isComboboxPopup: () => !!this._popup?.combobox, }); /** Whether the listbox has received focus yet. */ diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index 893095f070af..69e39ff0b4d8 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -15,6 +15,9 @@ import {List, ListInputs} from '../behaviors/list/list'; /** Represents the required inputs for a listbox. */ export type ListboxInputs = ListInputs, V> & { readonly: SignalLike; + + /** Whether the listbox is in a combobox popup. */ + isComboboxPopup: SignalLike; }; /** Controls the state of a listbox. */ @@ -215,13 +218,13 @@ export class ListboxPattern { /** Handles keydown events for the listbox. */ onKeydown(event: KeyboardEvent) { - if (!this.disabled()) { + if (!this.disabled() && !this.inputs.isComboboxPopup()) { this.keydown().handle(event); } } onPointerdown(event: PointerEvent) { - if (!this.disabled()) { + if (!this.disabled() && !this.inputs.isComboboxPopup()) { this.pointerdown().handle(event); } } @@ -237,6 +240,10 @@ export class ListboxPattern { * is called. */ setDefaultState() { + if (this.inputs.isComboboxPopup()) { + return; + } + let firstItem: OptionPattern | null = null; for (const item of this.inputs.items()) { diff --git a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html index 2519fcbd853e..1276703b36c9 100644 --- a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html +++ b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html @@ -1,15 +1,10 @@ -
+
search - +
-
+
@for (fruit of fruits; track fruit) {
}
-
+
diff --git a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.ts b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.ts index a464ab1ef2a1..02916fa27cf4 100644 --- a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.ts +++ b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.ts @@ -6,14 +6,26 @@ * found in the LICENSE file at https://angular.dev/license */ -import {CdkComboboxInput, CdkComboboxPopup} from '@angular/cdk-experimental/combobox'; +import { + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContent, +} from '@angular/cdk-experimental/combobox'; import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; import {ChangeDetectionStrategy, Component} from '@angular/core'; @Component({ templateUrl: 'cdk-combobox-demo.html', styleUrl: 'cdk-combobox-demo.css', - imports: [CdkComboboxInput, CdkComboboxPopup, CdkListbox, CdkOption], + imports: [ + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContent, + CdkListbox, + CdkOption, + ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class CdkComboboxDemo { From 5caad521022d6392c80ca72bf1e2b4fc739b6bfa Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Fri, 12 Sep 2025 15:00:15 -0400 Subject: [PATCH 03/23] feat(cdk-experimental/ui-patterns): create combobox ui pattern --- src/cdk-experimental/combobox/BUILD.bazel | 1 + src/cdk-experimental/combobox/combobox.ts | 80 ++++- src/cdk-experimental/listbox/listbox.ts | 18 +- src/cdk-experimental/ui-patterns/BUILD.bazel | 1 + .../behaviors/list-focus/list-focus.ts | 5 +- .../list-selection/list-selection.ts | 2 +- .../ui-patterns/behaviors/list/list.ts | 9 +- .../ui-patterns/combobox/BUILD.bazel | 34 +++ .../ui-patterns/combobox/combobox.ts | 281 ++++++++++++++++++ .../ui-patterns/listbox/BUILD.bazel | 1 + .../ui-patterns/listbox/listbox.spec.ts | 1 + .../ui-patterns/listbox/listbox.ts | 42 ++- .../ui-patterns/listbox/option.ts | 5 +- .../ui-patterns/public-api.ts | 1 + .../cdk-combobox-demo.css | 50 +++- .../cdk-combobox-demo.html | 2 +- 16 files changed, 500 insertions(+), 33 deletions(-) create mode 100644 src/cdk-experimental/ui-patterns/combobox/BUILD.bazel create mode 100644 src/cdk-experimental/ui-patterns/combobox/combobox.ts diff --git a/src/cdk-experimental/combobox/BUILD.bazel b/src/cdk-experimental/combobox/BUILD.bazel index cfe2786f6629..103d53f9215e 100644 --- a/src/cdk-experimental/combobox/BUILD.bazel +++ b/src/cdk-experimental/combobox/BUILD.bazel @@ -11,5 +11,6 @@ ng_project( deps = [ "//:node_modules/@angular/core", "//src/cdk-experimental/deferred-content", + "//src/cdk-experimental/ui-patterns", ], ) diff --git a/src/cdk-experimental/combobox/combobox.ts b/src/cdk-experimental/combobox/combobox.ts index 8651ec50010a..a17a7714f87c 100644 --- a/src/cdk-experimental/combobox/combobox.ts +++ b/src/cdk-experimental/combobox/combobox.ts @@ -6,8 +6,19 @@ * found in the LICENSE file at https://angular.dev/license */ -import {contentChild, Directive, inject} from '@angular/core'; +import { + afterRenderEffect, + contentChild, + Directive, + ElementRef, + inject, + input, + model, + signal, + WritableSignal, +} from '@angular/core'; import {DeferredContent, DeferredContentAware} from '@angular/cdk-experimental/deferred-content'; +import {ComboboxPattern, ComboboxPopupControls} from '../ui-patterns'; @Directive({ selector: '[cdkCombobox]', @@ -18,25 +29,75 @@ import {DeferredContent, DeferredContentAware} from '@angular/cdk-experimental/d inputs: ['preserveContent'], }, ], + host: { + '[attr.data-expanded]': 'pattern.expanded()', + '(input)': 'pattern.onInput($event)', + '(keydown)': 'pattern.onKeydown($event)', + '(pointerup)': 'pattern.onPointerup($event)', + '(focusin)': 'pattern.onFocusIn()', + '(focusout)': 'pattern.onFocusOut($event)', + }, }) -export class CdkCombobox { +export class CdkCombobox { + /** The element that the combobox is attached to. */ + private readonly _elementRef = inject(ElementRef); + /** The DeferredContentAware host directive. */ private readonly _deferredContentAware = inject(DeferredContentAware, {optional: true}); /** The combobox popup. */ - readonly popup = contentChild(CdkComboboxPopup); + readonly popup = contentChild>(CdkComboboxPopup); + + /** The filter mode for the combobox. */ + filterMode = input<'manual' | 'auto-select' | 'highlight'>('manual'); + + /** Whether the combobox is focused. */ + readonly isFocused = signal(false); + + /** The values of the current selected items. */ + value = model(undefined); + + /** The combobox ui pattern. */ + readonly pattern = new ComboboxPattern({ + ...this, + inputEl: signal(undefined), + containerEl: signal(undefined), + popupControls: () => this.popup()?.actions(), + }); constructor() { - this._deferredContentAware?.contentVisible.set(true); + (this.pattern.inputs.containerEl as WritableSignal).set( + this._elementRef.nativeElement, + ); + + afterRenderEffect(() => { + this._deferredContentAware?.contentVisible.set(this.pattern.isFocused()); + }); } } @Directive({ selector: 'input[cdkComboboxInput]', exportAs: 'cdkComboboxInput', - host: {'role': 'combobox'}, + host: { + 'role': 'combobox', + '[attr.aria-expanded]': 'combobox.pattern.expanded()', + '[attr.aria-activedescendant]': 'combobox.pattern.activedescendant()', + }, }) -export class CdkComboboxInput {} +export class CdkComboboxInput { + /** The element that the combobox is attached to. */ + private readonly _elementRef = inject(ElementRef); + + /** The combobox that the input belongs to. */ + readonly combobox = inject(CdkCombobox); + + constructor() { + (this.combobox.pattern.inputs.inputEl as WritableSignal).set( + this._elementRef.nativeElement, + ); + } +} @Directive({ selector: 'ng-template[cdkComboboxPopupContent]', @@ -49,7 +110,10 @@ export class CdkComboboxPopupContent {} selector: '[cdkComboboxPopup]', exportAs: 'cdkComboboxPopup', }) -export class CdkComboboxPopup { +export class CdkComboboxPopup { /** The combobox that the popup belongs to. */ - readonly combobox = inject(CdkCombobox, {optional: true}); + readonly combobox = inject>(CdkCombobox, {optional: true}); + + /** The actions that the combobox can perform on the popup. */ + readonly actions = signal | undefined>(undefined); } diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index d4176fb55aec..d385f126cede 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -44,6 +44,7 @@ import {CdkComboboxPopup} from '../combobox'; host: { 'role': 'listbox', 'class': 'cdk-listbox', + '[attr.id]': 'id()', '[attr.tabindex]': 'pattern.tabindex()', '[attr.aria-readonly]': 'pattern.readonly()', '[attr.aria-disabled]': 'pattern.disabled()', @@ -57,7 +58,17 @@ import {CdkComboboxPopup} from '../combobox'; hostDirectives: [{directive: CdkComboboxPopup}], }) export class CdkListbox { - private readonly _popup = inject(CdkComboboxPopup, {optional: true}); + /** A unique identifier for the listbox. */ + private readonly _generatedId = inject(_IdGenerator).getId('cdk-listbox-'); + + // TODO(wagnermaciel): https://github.com/angular/components/pull/30495#discussion_r1972601144. + /** A unique identifier for the option. */ + protected id = computed(() => this._generatedId); + + /** A reference to the parent combobox popup, if one exists. */ + private readonly _popup = inject>(CdkComboboxPopup, { + optional: true, + }); /** A reference to the listbox element. */ private readonly _elementRef = inject(ElementRef); @@ -113,13 +124,15 @@ export class CdkListbox { activeItem: signal(undefined), textDirection: this.textDirection, element: () => this._elementRef.nativeElement, - isComboboxPopup: () => !!this._popup?.combobox, + combobox: () => this._popup?.combobox?.pattern, }); /** Whether the listbox has received focus yet. */ private _hasFocused = signal(false); constructor() { + this._popup?.actions?.set(this.pattern.comboboxActions); + afterRenderEffect(() => { if (typeof ngDevMode === 'undefined' || ngDevMode) { const violations = this.pattern.validate(); @@ -153,6 +166,7 @@ export class CdkListbox { '[attr.tabindex]': 'pattern.tabindex()', '[attr.aria-selected]': 'pattern.selected()', '[attr.aria-disabled]': 'pattern.disabled()', + '[attr.inert]': 'pattern.inert()', }, }) export class CdkOption { diff --git a/src/cdk-experimental/ui-patterns/BUILD.bazel b/src/cdk-experimental/ui-patterns/BUILD.bazel index f58daabc832b..f29508aa8de6 100644 --- a/src/cdk-experimental/ui-patterns/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/BUILD.bazel @@ -12,6 +12,7 @@ ts_project( "//:node_modules/@angular/core", "//src/cdk-experimental/ui-patterns/accordion", "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + "//src/cdk-experimental/ui-patterns/combobox", "//src/cdk-experimental/ui-patterns/listbox", "//src/cdk-experimental/ui-patterns/radio-group", "//src/cdk-experimental/ui-patterns/tabs", 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 index 9515e29a30ee..d5fb20070914 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts @@ -22,6 +22,9 @@ export interface ListFocusItem { /** The index of the item in the list. */ index: SignalLike; + + /** Whether the item is currently focused. */ + inert?: SignalLike; } /** Represents the required inputs for a collection that contains focusable items. */ @@ -112,6 +115,6 @@ export class ListFocus { /** Returns true if the given item can be navigated to. */ isFocusable(item: T): boolean { - return !item.disabled() || !this.inputs.skipDisabled(); + return (!item.disabled() || !this.inputs.skipDisabled()) && !item.inert?.(); } } 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 index 0ad0e2b5db1d..3342daf9f3f1 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts @@ -47,7 +47,7 @@ export class ListSelection, V> { select(item?: ListSelectionItem, opts = {anchor: true}) { item = item ?? (this.inputs.focusManager.inputs.activeItem() as ListSelectionItem); - if (item.disabled() || this.inputs.value().includes(item.value())) { + if (!item || item.disabled() || this.inputs.value().includes(item.value())) { return; } diff --git a/src/cdk-experimental/ui-patterns/behaviors/list/list.ts b/src/cdk-experimental/ui-patterns/behaviors/list/list.ts index b80f9730bd9a..cccaf34c6832 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list/list.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list/list.ts @@ -129,6 +129,11 @@ export class List, V> { this._navigate(opts, () => this.navigationBehavior.goto(item)); } + /** Removes focus from the list. */ + unfocus() { + this.inputs.activeItem.set(undefined); + } + /** Marks the given index as the potential start of a range selection. */ anchor(index: number) { this._anchorIndex.set(index); @@ -145,8 +150,8 @@ export class List, V> { } /** Selects the currently active item in the list. */ - select() { - this.selectionBehavior.select(); + select(item?: T) { + this.selectionBehavior.select(item); } /** Sets the selection to only the current active item. */ diff --git a/src/cdk-experimental/ui-patterns/combobox/BUILD.bazel b/src/cdk-experimental/ui-patterns/combobox/BUILD.bazel new file mode 100644 index 000000000000..99e6ec3e6bb2 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/combobox/BUILD.bazel @@ -0,0 +1,34 @@ +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "combobox", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/event-manager", + "//src/cdk-experimental/ui-patterns/behaviors/list", + "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":combobox", + "//:node_modules/@angular/core", + "//src/cdk/keycodes", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/combobox/combobox.ts b/src/cdk-experimental/ui-patterns/combobox/combobox.ts new file mode 100644 index 000000000000..5f51b0eb5773 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/combobox/combobox.ts @@ -0,0 +1,281 @@ +/** + * @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, PointerEventManager} from '../behaviors/event-manager'; +import {computed, signal} from '@angular/core'; +import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like'; +import {ListItem} from '../behaviors/list/list'; + +/** Represents the required inputs for a combobox. */ +export type ComboboxInputs, V> = { + /** The current value of the combobox. */ + value: WritableSignalLike; + + /** The controls for the popup associated with the combobox. */ + popupControls: SignalLike | undefined>; + + /** The HTML input element that serves as the combobox input. */ + inputEl: SignalLike; + + /** The HTML element that serves as the combobox container. */ + containerEl: SignalLike; + + /** The filtering mode for the combobox. */ + filterMode: SignalLike<'manual' | 'auto-select' | 'highlight'>; +}; + +/** An interface that allows combobox popups to expose the necessary controls for the combobox. */ +export type ComboboxPopupControls, V> = { + /** The ID of the active item in the popup. */ + activeId: SignalLike; + + /** Navigates to the next item in the popup. */ + next: () => void; + + /** Navigates to the previous item in the popup. */ + prev: () => void; + + /** Navigates to the first item in the popup. */ + first: () => void; + + /** Navigates to the last item in the popup. */ + last: () => void; + + /** Selects the current item in the popup. */ + select: (item?: T) => void; + + /** Filters the items in the popup. */ + filter: (text: string) => void; + + /** Removes focus from any item in the popup. */ + unfocus: () => void; + + /** Returns the item corresponding to the given event. */ + getItem: (e: PointerEvent) => T | undefined; + + /** Returns the currently selected item in the popup. */ + getSelectedItem: () => T | undefined; + + /** Sets the value of the combobox based on the selected item. */ + setValue: (value: V | undefined) => void; // For re-setting the value if the popup was destroyed. +}; + +/** Controls the state of a combobox. */ +export class ComboboxPattern, V> { + /** Whether the combobox is expanded. */ + expanded = signal(false); + + /** The ID of the active item in the combobox. */ + activedescendant = computed(() => this.inputs.popupControls()?.activeId() ?? null); + + /** The current search string for filtering. */ + searchString = signal(''); + + /** The currently highlighted item in the combobox. */ + highlightedItem = signal(undefined); + + /** Whether the combobox is focused. */ + isFocused = signal(false); + + /** The keydown event manager for the combobox. */ + keydown = computed(() => { + if (!this.expanded()) { + return new KeyboardEventManager() + .on('ArrowDown', () => this.open({first: true})) + .on('ArrowUp', () => this.open({last: true})); + } + + return new KeyboardEventManager() + .on('ArrowDown', () => this.next()) + .on('ArrowUp', () => this.prev()) + .on('Home', () => this.first()) + .on('End', () => this.last()) + .on('Escape', () => this.close()) + .on('Enter', () => this.select({commit: true, close: true})); + }); + + /** The pointerup event manager for the combobox. */ + pointerup = computed(() => + new PointerEventManager().on(e => { + const item = this.inputs.popupControls()?.getItem(e); + + if (item) { + this.select({item, commit: true, close: true}); + this.inputs.inputEl()?.focus(); // Return focus to the input after selecting. + } + + if (e.target === this.inputs.inputEl()) { + this.open(); + } + }), + ); + + constructor(readonly inputs: ComboboxInputs) {} + + /** Handles keydown events for the combobox. */ + onKeydown(event: KeyboardEvent) { + this.keydown().handle(event); + } + + /** Handles pointerup events for the combobox. */ + onPointerup(event: PointerEvent) { + this.pointerup().handle(event); + } + + /** Handles input events for the combobox. */ + onInput(event: Event) { + const inputEl = this.inputs.inputEl(); + + if (!inputEl) { + return; + } + + this.searchString.set(inputEl.value); + this.inputs.popupControls()?.filter(inputEl.value); + + this.open(); + this.inputs.popupControls()?.first(); + + if (event instanceof InputEvent && event.inputType === 'deleteContentBackward') { + this.inputs.popupControls()?.select(); + return; + } + + this.select({highlight: this.inputs.filterMode() === 'highlight'}); + } + + onFocusIn() { + this.isFocused.set(true); + } + + /** Handles focus out events for the combobox. */ + onFocusOut(event: FocusEvent) { + this.isFocused.set(false); + + if ( + !(event.relatedTarget instanceof HTMLElement) || + !this.inputs.containerEl()?.contains(event.relatedTarget) + ) { + this.close(); + + if (this.inputs.filterMode() !== 'manual') { + this.commit(); + } + } + } + + /** Closes the combobox. */ + close() { + this.expanded.set(false); + this.inputs.popupControls()?.unfocus(); + } + + /** Opens the combobox. */ + open(nav?: {first?: boolean; last?: boolean}) { + this.expanded.set(true); + this.inputs.popupControls()?.filter(this.inputs.inputEl()?.value ?? ''); + this.inputs.popupControls()?.setValue(this.inputs.value()); + + if (nav?.first) { + this.first(); + } else if (nav?.last) { + this.last(); + } + } + + highlight() { + const element = this.inputs.inputEl(); + const item = this.inputs.popupControls()?.getSelectedItem(); + + if (!item) { + return; + } + + const isHighlightable = item + .searchTerm() + .toLowerCase() + .startsWith(this.searchString().toLowerCase()); + + if (element && isHighlightable) { + element.value = item.searchTerm(); + element.setSelectionRange(this.searchString().length, item.searchTerm().length); + this.highlightedItem.set(item); + } + } + + /** Navigates to the next focusable item in the combobox popup. */ + next() { + this._navigate(() => this.inputs.popupControls()?.next()); + } + + /** Navigates to the previous focusable item in the combobox popup. */ + prev() { + this._navigate(() => this.inputs.popupControls()?.prev()); + } + + /** Navigates to the first focusable item in the combobox popup. */ + first() { + this._navigate(() => this.inputs.popupControls()?.first()); + } + + /** Navigates to the last focusable item in the combobox popup. */ + last() { + this._navigate(() => this.inputs.popupControls()?.last()); + } + + /** Selects an item in the combobox popup. */ + select(opts: {item?: T; commit?: boolean; close?: boolean; highlight?: boolean} = {}) { + this.inputs.popupControls()?.select(opts.item); + this.inputs.value.set(this.inputs.popupControls()?.getSelectedItem()?.value()); + + if (opts.commit) { + this.commit(); + } + if (opts.close) { + this.close(); + } + if (opts.highlight) { + this.highlight(); + } + } + + /** Updates the value of the input based on the currently selected item. */ + commit() { + const element = this.inputs.inputEl(); + const item = this.inputs.popupControls()?.getSelectedItem(); + + if (element && item) { + element.value = item.searchTerm(); + + if (this.inputs.filterMode() === 'highlight') { + const length = element.value.length; + element.setSelectionRange(length, length); + } + } + } + + /** Navigates and handles additional actions based on filter mode. */ + private _navigate(operation: () => void) { + operation(); + + if (this.inputs.filterMode() === 'auto-select') { + this.select(); + } + + if (this.inputs.filterMode() === 'highlight') { + this.select({commit: true}); + + // This is to handle when the user navigates back to the originally highlighted item. + // E.g. User types "Al", highlights "Alice", then navigates down and back up to "Alice". + const selectedItem = this.inputs.popupControls()?.getSelectedItem(); + if (selectedItem && selectedItem === this.highlightedItem()) { + this.highlight(); + } + } + } +} diff --git a/src/cdk-experimental/ui-patterns/listbox/BUILD.bazel b/src/cdk-experimental/ui-patterns/listbox/BUILD.bazel index 7c917c4814ee..1e4fa1c60905 100644 --- a/src/cdk-experimental/ui-patterns/listbox/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/listbox/BUILD.bazel @@ -13,6 +13,7 @@ ts_project( "//src/cdk-experimental/ui-patterns/behaviors/event-manager", "//src/cdk-experimental/ui-patterns/behaviors/list", "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + "//src/cdk-experimental/ui-patterns/combobox", ], ) diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts index 2c2ecde4793f..6f1149e1ec67 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts @@ -46,6 +46,7 @@ describe('Listbox Pattern', () => { orientation: inputs.orientation ?? signal('vertical'), selectionMode: inputs.selectionMode ?? signal('explicit'), element: signal(document.createElement('div')), + combobox: signal(undefined), }); } diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index 69e39ff0b4d8..baaf54786991 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -11,13 +11,14 @@ import {KeyboardEventManager, PointerEventManager, Modifier} from '../behaviors/ import {computed, signal} from '@angular/core'; import {SignalLike} from '../behaviors/signal-like/signal-like'; import {List, ListInputs} from '../behaviors/list/list'; +import {ComboboxPattern, ComboboxPopupControls} from '../combobox/combobox'; /** Represents the required inputs for a listbox. */ export type ListboxInputs = ListInputs, V> & { readonly: SignalLike; - /** Whether the listbox is in a combobox popup. */ - isComboboxPopup: SignalLike; + /** The combobox controlling the listbox. */ + combobox: SignalLike, V> | undefined>; }; /** Controls the state of a listbox. */ @@ -34,7 +35,7 @@ export class ListboxPattern { readonly: SignalLike; /** The tabindex of the listbox. */ - tabindex = computed(() => this.listBehavior.tabindex()); + tabindex = computed(() => (this.inputs.combobox() ? -1 : this.listBehavior.tabindex())); /** The id of the current active item. */ activedescendant = computed(() => this.listBehavior.activedescendant()); @@ -192,6 +193,11 @@ export class ListboxPattern { this.orientation = inputs.orientation; this.multi = inputs.multi; + if (this.inputs.combobox()) { + this.inputs.focusMode = () => 'activedescendant'; + this.inputs.element = this.inputs.combobox()!.inputs.inputEl; + } + this.listBehavior = new List(inputs); } @@ -218,13 +224,13 @@ export class ListboxPattern { /** Handles keydown events for the listbox. */ onKeydown(event: KeyboardEvent) { - if (!this.disabled() && !this.inputs.isComboboxPopup()) { + if (!this.disabled() && !this.inputs.combobox()) { this.keydown().handle(event); } } onPointerdown(event: PointerEvent) { - if (!this.disabled() && !this.inputs.isComboboxPopup()) { + if (!this.disabled() && !this.inputs.combobox()) { this.pointerdown().handle(event); } } @@ -240,7 +246,7 @@ export class ListboxPattern { * is called. */ setDefaultState() { - if (this.inputs.isComboboxPopup()) { + if (this.inputs.combobox()) { return; } @@ -271,4 +277,28 @@ export class ListboxPattern { const element = e.target.closest('[role="option"]'); return this.inputs.items().find(i => i.element() === element); } + + /** The actions that can be performed on a combobox popup listbox. */ + comboboxActions: ComboboxPopupControls, V> = { + activeId: computed(() => this.listBehavior.activedescendant()), + + next: () => this.listBehavior.next(), + prev: () => this.listBehavior.prev(), + last: () => this.listBehavior.last(), + first: () => this.listBehavior.first(), + unfocus: () => this.listBehavior.unfocus(), + select: item => this.listBehavior.select(item), + + getItem: e => this._getItem(e), + getSelectedItem: () => this.inputs.items().find(i => i.selected()), + + setValue: (value: V | undefined) => this.inputs.value.set(value ? [value] : []), + + filter: (text: string) => { + this.inputs.items().forEach(i => { + const isMatch = i.searchTerm().toLowerCase().includes(text.toLowerCase()); + i.inert.set(isMatch ? null : true); + }); + }, + }; } diff --git a/src/cdk-experimental/ui-patterns/listbox/option.ts b/src/cdk-experimental/ui-patterns/listbox/option.ts index 77d65184c1cc..35653dc8e33d 100644 --- a/src/cdk-experimental/ui-patterns/listbox/option.ts +++ b/src/cdk-experimental/ui-patterns/listbox/option.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed} from '@angular/core'; +import {computed, signal} from '@angular/core'; import {SignalLike} from '../behaviors/signal-like/signal-like'; import {List, ListInputs, ListItem} from '../behaviors/list/list'; @@ -32,6 +32,9 @@ export class OptionPattern { /** The value of the option. */ value: SignalLike; + /** Whether the option is inert. */ + inert = signal(null); + /** The position of the option in the list. */ index = computed(() => this.listbox()?.inputs.items().indexOf(this) ?? -1); diff --git a/src/cdk-experimental/ui-patterns/public-api.ts b/src/cdk-experimental/ui-patterns/public-api.ts index 7260025d2930..9e1bd466c17f 100644 --- a/src/cdk-experimental/ui-patterns/public-api.ts +++ b/src/cdk-experimental/ui-patterns/public-api.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +export * from './combobox/combobox'; export * from './listbox/listbox'; export * from './listbox/option'; export * from './radio-group/radio-group'; diff --git a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.css b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.css index 280a0abf00ea..d4dc2c22f43b 100644 --- a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.css +++ b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.css @@ -1,7 +1,10 @@ .example-combobox-container { width: 300px; display: flex; + overflow: hidden; flex-direction: column; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); } .example-combobox-input-container { @@ -9,10 +12,6 @@ overflow: hidden; position: relative; align-items: center; - border: 1px solid var(--mat-sys-outline); - border-radius: var(--mat-sys-corner-extra-small); - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; } .example-icon { @@ -35,27 +34,34 @@ border: none; outline: none; font-size: 1rem; - padding: 0.8rem 1rem 0.8rem 2.5rem; + padding: 0.7rem 1rem 0.7rem 2.5rem; background-color: var(--mat-sys-surface); } + .example-listbox { display: flex; flex-direction: column; overflow: auto; - height: 20rem; + max-height: 20rem; padding: 0.5rem; - border: 1px solid var(--mat-sys-outline); - border-top: none; - border-bottom-left-radius: var(--mat-sys-corner-extra-small); - border-bottom-right-radius: var(--mat-sys-corner-extra-small); + border-top: 1px solid var(--mat-sys-outline); +} + +.example-listbox:not(:has(.example-option:not([inert]))), +.example-combobox-container[data-expanded='false'] .example-listbox { + height: 0; + padding: 0 0.5rem; + border: none; } .example-option { outline: none; cursor: pointer; - padding: 0.5rem 1rem; + padding: 0.3rem 1rem; border-radius: var(--mat-sys-corner-extra-small); display: flex; + overflow: hidden; + flex-shrink: 0; align-items: center; justify-content: space-between; } @@ -67,3 +73,25 @@ .example-option[aria-selected='true'] .example-selected-icon { visibility: visible; } + +.example-option[inert] { + height: 0; + padding: 0 1rem; +} + +.example-combobox-container:focus-within .cdk-active { + background: color-mix( + in srgb, + var(--mat-sys-on-surface) calc(var(--mat-sys-focus-state-layer-opacity) * 100%), + transparent + ); +} + +.example-combobox-container:focus-within .cdk-active[aria-selected='true'] { + background: color-mix( + in srgb, + var(--mat-sys-primary) calc(var(--mat-sys-pressed-state-layer-opacity) * 100%), + transparent + ); + color: var(--mat-sys-primary); +} diff --git a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html index 1276703b36c9..84bbd1a15adc 100644 --- a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html +++ b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html @@ -5,7 +5,7 @@
-
+
@for (fruit of fruits; track fruit) {
Date: Mon, 15 Sep 2025 11:52:41 -0400 Subject: [PATCH 04/23] docs(cdk-experimental/combobox): add component examples --- .../cdk-combobox-auto-select-example.html | 33 +++++ .../cdk-combobox-auto-select-example.ts | 123 ++++++++++++++++++ .../combobox/cdk-combobox-examples.css | 103 +++++++++++++++ .../cdk-combobox-highlight-example.html | 28 ++++ .../cdk-combobox-highlight-example.ts | 123 ++++++++++++++++++ .../cdk-combobox-manual-example.html | 28 ++++ .../cdk-combobox-manual-example.ts | 123 ++++++++++++++++++ .../cdk-experimental/combobox/index.ts | 3 + .../cdk-experimental-combobox/BUILD.bazel | 3 +- .../cdk-combobox-demo.css | 103 +++------------ .../cdk-combobox-demo.html | 38 +++--- .../cdk-combobox-demo.ts | 57 +------- 12 files changed, 601 insertions(+), 164 deletions(-) create mode 100644 src/components-examples/cdk-experimental/combobox/cdk-combobox-auto-select/cdk-combobox-auto-select-example.html create mode 100644 src/components-examples/cdk-experimental/combobox/cdk-combobox-auto-select/cdk-combobox-auto-select-example.ts create mode 100644 src/components-examples/cdk-experimental/combobox/cdk-combobox-examples.css create mode 100644 src/components-examples/cdk-experimental/combobox/cdk-combobox-highlight/cdk-combobox-highlight-example.html create mode 100644 src/components-examples/cdk-experimental/combobox/cdk-combobox-highlight/cdk-combobox-highlight-example.ts create mode 100644 src/components-examples/cdk-experimental/combobox/cdk-combobox-manual/cdk-combobox-manual-example.html create mode 100644 src/components-examples/cdk-experimental/combobox/cdk-combobox-manual/cdk-combobox-manual-example.ts create mode 100644 src/components-examples/cdk-experimental/combobox/index.ts diff --git a/src/components-examples/cdk-experimental/combobox/cdk-combobox-auto-select/cdk-combobox-auto-select-example.html b/src/components-examples/cdk-experimental/combobox/cdk-combobox-auto-select/cdk-combobox-auto-select-example.html new file mode 100644 index 000000000000..88a011b5181a --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-auto-select/cdk-combobox-auto-select-example.html @@ -0,0 +1,33 @@ +
+
+ search + +
+ +
+ +
+ @for (state of states; track state) { +
+ {{state}} + +
+ } +
+
+
+
diff --git a/src/components-examples/cdk-experimental/combobox/cdk-combobox-auto-select/cdk-combobox-auto-select-example.ts b/src/components-examples/cdk-experimental/combobox/cdk-combobox-auto-select/cdk-combobox-auto-select-example.ts new file mode 100644 index 000000000000..fdfc91f1f02c --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-auto-select/cdk-combobox-auto-select-example.ts @@ -0,0 +1,123 @@ +/** + * @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 { + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContent, +} from '@angular/cdk-experimental/combobox'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + ElementRef, + viewChild, +} from '@angular/core'; + +/** @title Combobox with auto-select filtering. */ +@Component({ + selector: 'cdk-combobox-auto-select-example', + templateUrl: 'cdk-combobox-auto-select-example.html', + styleUrl: '../cdk-combobox-examples.css', + imports: [ + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContent, + CdkListbox, + CdkOption, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkComboboxAutoSelectExample { + popover = viewChild('popover'); + listbox = viewChild>(CdkListbox); + combobox = viewChild>(CdkCombobox); + + states = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', + 'Delaware', + 'Florida', + 'Georgia', + 'Hawaii', + 'Idaho', + 'Illinois', + 'Indiana', + 'Iowa', + 'Kansas', + 'Kentucky', + 'Louisiana', + 'Maine', + 'Maryland', + 'Massachusetts', + 'Michigan', + 'Minnesota', + 'Mississippi', + 'Missouri', + 'Montana', + 'Nebraska', + 'Nevada', + 'New Hampshire', + 'New Jersey', + 'New Mexico', + 'New York', + 'North Carolina', + 'North Dakota', + 'Ohio', + 'Oklahoma', + 'Oregon', + 'Pennsylvania', + 'Rhode Island', + 'South Carolina', + 'South Dakota', + 'Tennessee', + 'Texas', + 'Utah', + 'Vermont', + 'Virginia', + 'Washington', + 'West Virginia', + 'Wisconsin', + 'Wyoming', + ]; + + constructor() { + afterRenderEffect(() => { + const popover = this.popover()!; + const combobox = this.combobox()!; + combobox.pattern.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); + + // TODO(wagnermaciel): Make this easier for developers to do. + this.listbox()?.pattern.inputs.activeItem()?.element().scrollIntoView({block: 'nearest'}); + }); + } + + showPopover() { + const popover = this.popover()!; + const combobox = this.combobox()!; + + const comboboxRect = combobox.pattern.inputs.inputEl()?.getBoundingClientRect(); + const popoverEl = popover.nativeElement; + + if (comboboxRect) { + popoverEl.style.width = `${comboboxRect.width}px`; + popoverEl.style.top = `${comboboxRect.bottom}px`; + popoverEl.style.left = `${comboboxRect.left - 1}px`; + } + + popover.nativeElement.showPopover(); + } +} diff --git a/src/components-examples/cdk-experimental/combobox/cdk-combobox-examples.css b/src/components-examples/cdk-experimental/combobox/cdk-combobox-examples.css new file mode 100644 index 000000000000..10f3f870a90f --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-examples.css @@ -0,0 +1,103 @@ +.example-combobox-container { + position: relative; + width: 300px; + display: flex; + overflow: hidden; + flex-direction: column; + border: 1px solid var(--mat-sys-outline); + border-radius: var(--mat-sys-corner-extra-small); +} + +.example-combobox-container:has(.example-combobox-input[aria-expanded='true']) { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.example-combobox-input-container { + display: flex; + overflow: hidden; + position: relative; + align-items: center; +} + +.example-icon { + width: 24px; + height: 24px; + font-size: 20px; + display: grid; + place-items: center; + pointer-events: none; +} + +.example-search-icon { + padding: 0 0.5rem; + position: absolute; + opacity: 0.8; +} + +.example-combobox-input { + width: 100%; + border: none; + outline: none; + font-size: 1rem; + padding: 0.7rem 1rem 0.7rem 2.5rem; + background-color: var(--mat-sys-surface); +} + +.example-popover { + margin: 0; + padding: 0; + border: 1px solid var(--mat-sys-outline); + border-bottom-right-radius: var(--mat-sys-corner-extra-small); + border-bottom-left-radius: var(--mat-sys-corner-extra-small); + background-color: var(--mat-sys-surface); +} + +.example-listbox { + display: flex; + flex-direction: column; + overflow: auto; + max-height: 10rem; + padding: 0.5rem; +} + +.example-option { + cursor: pointer; + padding: 0.3rem 1rem; + border-radius: var(--mat-sys-corner-extra-small); + display: flex; + overflow: hidden; + flex-shrink: 0; + align-items: center; + justify-content: space-between; +} + +.example-selected-icon { + visibility: hidden; +} + +.example-option[aria-selected='true'] .example-selected-icon { + visibility: visible; +} + +.example-option[inert] { + height: 0; + padding: 0 1rem; +} + +.example-combobox-container:focus-within .cdk-active { + background: color-mix( + in srgb, + var(--mat-sys-on-surface) calc(var(--mat-sys-focus-state-layer-opacity) * 100%), + transparent + ); +} + +.example-combobox-container:focus-within .cdk-active[aria-selected='true'] { + background: color-mix( + in srgb, + var(--mat-sys-primary) calc(var(--mat-sys-pressed-state-layer-opacity) * 100%), + transparent + ); + color: var(--mat-sys-primary); +} diff --git a/src/components-examples/cdk-experimental/combobox/cdk-combobox-highlight/cdk-combobox-highlight-example.html b/src/components-examples/cdk-experimental/combobox/cdk-combobox-highlight/cdk-combobox-highlight-example.html new file mode 100644 index 000000000000..71f0d99dde7f --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-highlight/cdk-combobox-highlight-example.html @@ -0,0 +1,28 @@ +
+
+ search + +
+ +
+ +
+ @for (state of states; track state) { +
+ {{state}} + +
+ } +
+
+
+
diff --git a/src/components-examples/cdk-experimental/combobox/cdk-combobox-highlight/cdk-combobox-highlight-example.ts b/src/components-examples/cdk-experimental/combobox/cdk-combobox-highlight/cdk-combobox-highlight-example.ts new file mode 100644 index 000000000000..b143c32741d7 --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-highlight/cdk-combobox-highlight-example.ts @@ -0,0 +1,123 @@ +/** + * @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 { + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContent, +} from '@angular/cdk-experimental/combobox'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + ElementRef, + viewChild, +} from '@angular/core'; + +/** @title Combobox with highlight filtering. */ +@Component({ + selector: 'cdk-combobox-highlight-example', + templateUrl: 'cdk-combobox-highlight-example.html', + styleUrl: '../cdk-combobox-examples.css', + imports: [ + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContent, + CdkListbox, + CdkOption, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkComboboxHighlightExample { + popover = viewChild('popover'); + listbox = viewChild>(CdkListbox); + combobox = viewChild>(CdkCombobox); + + states = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', + 'Delaware', + 'Florida', + 'Georgia', + 'Hawaii', + 'Idaho', + 'Illinois', + 'Indiana', + 'Iowa', + 'Kansas', + 'Kentucky', + 'Louisiana', + 'Maine', + 'Maryland', + 'Massachusetts', + 'Michigan', + 'Minnesota', + 'Mississippi', + 'Missouri', + 'Montana', + 'Nebraska', + 'Nevada', + 'New Hampshire', + 'New Jersey', + 'New Mexico', + 'New York', + 'North Carolina', + 'North Dakota', + 'Ohio', + 'Oklahoma', + 'Oregon', + 'Pennsylvania', + 'Rhode Island', + 'South Carolina', + 'South Dakota', + 'Tennessee', + 'Texas', + 'Utah', + 'Vermont', + 'Virginia', + 'Washington', + 'West Virginia', + 'Wisconsin', + 'Wyoming', + ]; + + constructor() { + afterRenderEffect(() => { + const popover = this.popover()!; + const combobox = this.combobox()!; + combobox.pattern.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); + + // TODO(wagnermaciel): Make this easier for developers to do. + this.listbox()?.pattern.inputs.activeItem()?.element().scrollIntoView({block: 'nearest'}); + }); + } + + showPopover() { + const popover = this.popover()!; + const combobox = this.combobox()!; + + const comboboxRect = combobox.pattern.inputs.inputEl()?.getBoundingClientRect(); + const popoverEl = popover.nativeElement; + + if (comboboxRect) { + popoverEl.style.width = `${comboboxRect.width}px`; + popoverEl.style.top = `${comboboxRect.bottom}px`; + popoverEl.style.left = `${comboboxRect.left - 1}px`; + } + + popover.nativeElement.showPopover(); + } +} diff --git a/src/components-examples/cdk-experimental/combobox/cdk-combobox-manual/cdk-combobox-manual-example.html b/src/components-examples/cdk-experimental/combobox/cdk-combobox-manual/cdk-combobox-manual-example.html new file mode 100644 index 000000000000..8f3dea2f9c2c --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-manual/cdk-combobox-manual-example.html @@ -0,0 +1,28 @@ +
+
+ search + +
+ +
+ +
+ @for (state of states; track state) { +
+ {{state}} + +
+ } +
+
+
+
diff --git a/src/components-examples/cdk-experimental/combobox/cdk-combobox-manual/cdk-combobox-manual-example.ts b/src/components-examples/cdk-experimental/combobox/cdk-combobox-manual/cdk-combobox-manual-example.ts new file mode 100644 index 000000000000..2b5d8245a1a6 --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-manual/cdk-combobox-manual-example.ts @@ -0,0 +1,123 @@ +/** + * @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 { + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContent, +} from '@angular/cdk-experimental/combobox'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + ElementRef, + viewChild, +} from '@angular/core'; + +/** @title Combobox with manual selection. */ +@Component({ + selector: 'cdk-combobox-manual-example', + templateUrl: 'cdk-combobox-manual-example.html', + styleUrl: '../cdk-combobox-examples.css', + imports: [ + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContent, + CdkListbox, + CdkOption, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkComboboxManualExample { + popover = viewChild('popover'); + listbox = viewChild>(CdkListbox); + combobox = viewChild>(CdkCombobox); + + states = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', + 'Delaware', + 'Florida', + 'Georgia', + 'Hawaii', + 'Idaho', + 'Illinois', + 'Indiana', + 'Iowa', + 'Kansas', + 'Kentucky', + 'Louisiana', + 'Maine', + 'Maryland', + 'Massachusetts', + 'Michigan', + 'Minnesota', + 'Mississippi', + 'Missouri', + 'Montana', + 'Nebraska', + 'Nevada', + 'New Hampshire', + 'New Jersey', + 'New Mexico', + 'New York', + 'North Carolina', + 'North Dakota', + 'Ohio', + 'Oklahoma', + 'Oregon', + 'Pennsylvania', + 'Rhode Island', + 'South Carolina', + 'South Dakota', + 'Tennessee', + 'Texas', + 'Utah', + 'Vermont', + 'Virginia', + 'Washington', + 'West Virginia', + 'Wisconsin', + 'Wyoming', + ]; + + constructor() { + afterRenderEffect(() => { + const popover = this.popover()!; + const combobox = this.combobox()!; + combobox.pattern.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); + + // TODO(wagnermaciel): Make this easier for developers to do. + this.listbox()?.pattern.inputs.activeItem()?.element().scrollIntoView({block: 'nearest'}); + }); + } + + showPopover() { + const popover = this.popover()!; + const combobox = this.combobox()!; + + const comboboxRect = combobox.pattern.inputs.inputEl()?.getBoundingClientRect(); + const popoverEl = popover.nativeElement; + + if (comboboxRect) { + popoverEl.style.width = `${comboboxRect.width}px`; + popoverEl.style.top = `${comboboxRect.bottom}px`; + popoverEl.style.left = `${comboboxRect.left - 1}px`; + } + + popover.nativeElement.showPopover(); + } +} diff --git a/src/components-examples/cdk-experimental/combobox/index.ts b/src/components-examples/cdk-experimental/combobox/index.ts new file mode 100644 index 000000000000..6ea53e4487a3 --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/index.ts @@ -0,0 +1,3 @@ +export {CdkComboboxManualExample} from './cdk-combobox-manual/cdk-combobox-manual-example'; +export {CdkComboboxAutoSelectExample} from './cdk-combobox-auto-select/cdk-combobox-auto-select-example'; +export {CdkComboboxHighlightExample} from './cdk-combobox-highlight/cdk-combobox-highlight-example'; diff --git a/src/dev-app/cdk-experimental-combobox/BUILD.bazel b/src/dev-app/cdk-experimental-combobox/BUILD.bazel index 13e5b14d67f8..80a45cb5fc0f 100644 --- a/src/dev-app/cdk-experimental-combobox/BUILD.bazel +++ b/src/dev-app/cdk-experimental-combobox/BUILD.bazel @@ -11,7 +11,6 @@ ng_project( ], deps = [ "//:node_modules/@angular/core", - "//src/cdk-experimental/combobox", - "//src/cdk-experimental/listbox", + "//src/components-examples/cdk-experimental/combobox", ], ) diff --git a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.css b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.css index d4dc2c22f43b..8233ba47f19b 100644 --- a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.css +++ b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.css @@ -1,97 +1,24 @@ -.example-combobox-container { - width: 300px; - display: flex; - overflow: hidden; - flex-direction: column; - border: 1px solid var(--mat-sys-outline); - border-radius: var(--mat-sys-corner-extra-small); -} - -.example-combobox-input-container { - display: flex; - overflow: hidden; - position: relative; - align-items: center; -} - -.example-icon { - width: 24px; - height: 24px; - font-size: 20px; - display: grid; - place-items: center; - pointer-events: none; -} - -.example-search-icon { - padding: 0 0.5rem; - position: absolute; - opacity: 0.8; -} - -.example-combobox-input { - width: 100%; - border: none; - outline: none; - font-size: 1rem; - padding: 0.7rem 1rem 0.7rem 2.5rem; - background-color: var(--mat-sys-surface); +.example-combobox-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); + gap: 20px; + justify-items: center; } -.example-listbox { - display: flex; - flex-direction: column; - overflow: auto; - max-height: 20rem; - padding: 0.5rem; - border-top: 1px solid var(--mat-sys-outline); -} - -.example-listbox:not(:has(.example-option:not([inert]))), -.example-combobox-container[data-expanded='false'] .example-listbox { - height: 0; - padding: 0 0.5rem; - border: none; -} - -.example-option { - outline: none; - cursor: pointer; - padding: 0.3rem 1rem; - border-radius: var(--mat-sys-corner-extra-small); - display: flex; - overflow: hidden; - flex-shrink: 0; - align-items: center; - justify-content: space-between; -} - -.example-selected-icon { - visibility: hidden; -} +.example-combobox-container { + display: flex; + flex-direction: column; + justify-content: flex-start; -.example-option[aria-selected='true'] .example-selected-icon { - visibility: visible; + /* stylelint-disable material/no-prefixes */ + width: fit-content; } -.example-option[inert] { - height: 0; - padding: 0 1rem; +.example-configurable-combobox-container { + padding-top: 40px; } -.example-combobox-container:focus-within .cdk-active { - background: color-mix( - in srgb, - var(--mat-sys-on-surface) calc(var(--mat-sys-focus-state-layer-opacity) * 100%), - transparent - ); +h2 { + font-size: 1.1rem; } -.example-combobox-container:focus-within .cdk-active[aria-selected='true'] { - background: color-mix( - in srgb, - var(--mat-sys-primary) calc(var(--mat-sys-pressed-state-layer-opacity) * 100%), - transparent - ); - color: var(--mat-sys-primary); -} diff --git a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html index 84bbd1a15adc..2b3b7cc1ffe8 100644 --- a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html +++ b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html @@ -1,26 +1,18 @@ -
-
- search - -
+
+
+
+

Combobox with manual filtering

+ +
- -
- @for (fruit of fruits; track fruit) { -
- {{fruit}} - -
- } +
+

Combobox with auto-select filtering

+
- + +
+

Combobox with highlight filtering

+ +
+
diff --git a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.ts b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.ts index 02916fa27cf4..ba4dea919f93 100644 --- a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.ts +++ b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.ts @@ -7,61 +7,16 @@ */ import { - CdkCombobox, - CdkComboboxInput, - CdkComboboxPopup, - CdkComboboxPopupContent, -} from '@angular/cdk-experimental/combobox'; -import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; + CdkComboboxAutoSelectExample, + CdkComboboxHighlightExample, + CdkComboboxManualExample, +} from '@angular/components-examples/cdk-experimental/combobox'; import {ChangeDetectionStrategy, Component} from '@angular/core'; @Component({ templateUrl: 'cdk-combobox-demo.html', styleUrl: 'cdk-combobox-demo.css', - imports: [ - CdkCombobox, - CdkComboboxInput, - CdkComboboxPopup, - CdkComboboxPopupContent, - CdkListbox, - CdkOption, - ], + imports: [CdkComboboxManualExample, CdkComboboxAutoSelectExample, CdkComboboxHighlightExample], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CdkComboboxDemo { - fruits = [ - 'Apple', - 'Apricot', - 'Banana', - 'Blackberry', - 'Blueberry', - 'Cantaloupe', - 'Cherry', - 'Clementine', - 'Cranberry', - 'Dates', - 'Figs', - 'Grapes', - 'Grapefruit', - 'Guava', - 'Kiwi', - 'Kumquat', - 'Lemon', - 'Lime', - 'Mandarin', - 'Mango', - 'Nectarine', - 'Orange', - 'Papaya', - 'Passion', - 'Peach', - 'Pear', - 'Pineapple', - 'Plum', - 'Pomegranate', - 'Raspberries', - 'Strawberry', - 'Tangerine', - 'Watermelon', - ]; -} +export class CdkComboboxDemo {} From 1370e28af2de40c822270b4fbc0116ce3128d1e0 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 16 Sep 2025 10:32:02 -0400 Subject: [PATCH 05/23] fixup! feat(cdk-experimental/ui-patterns): create combobox ui pattern --- .../ui-patterns/combobox/combobox.ts | 33 ++++++++++++++++--- .../ui-patterns/listbox/listbox.ts | 1 + 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/cdk-experimental/ui-patterns/combobox/combobox.ts b/src/cdk-experimental/ui-patterns/combobox/combobox.ts index 5f51b0eb5773..ec047aec050c 100644 --- a/src/cdk-experimental/ui-patterns/combobox/combobox.ts +++ b/src/cdk-experimental/ui-patterns/combobox/combobox.ts @@ -49,6 +49,9 @@ export type ComboboxPopupControls, V> = { /** Selects the current item in the popup. */ select: (item?: T) => void; + /** Clears the selection state of the popup. */ + clearSelection: () => void; + /** Filters the items in the popup. */ filter: (text: string) => void; @@ -95,7 +98,20 @@ export class ComboboxPattern, V> { .on('ArrowUp', () => this.prev()) .on('Home', () => this.first()) .on('End', () => this.last()) - .on('Escape', () => this.close()) + .on('Escape', () => { + if (this.inputs.filterMode() === 'highlight' && this.inputs.popupControls()?.activeId()) { + this.inputs.popupControls()?.unfocus(); + this.inputs.popupControls()?.clearSelection(); + + const inputEl = this.inputs.inputEl(); + + if (inputEl) { + inputEl.value = this.searchString(); + } + } else { + this.close(); + } + }) // TODO: When filter mode is 'highlight', escape should revert to the last committed value. .on('Enter', () => this.select({commit: true, close: true})); }); @@ -141,7 +157,11 @@ export class ComboboxPattern, V> { this.open(); this.inputs.popupControls()?.first(); - if (event instanceof InputEvent && event.inputType === 'deleteContentBackward') { + if ( + event instanceof InputEvent && + this.inputs.filterMode() !== 'manual' && + event.inputType.match(/delete.*/) + ) { this.inputs.popupControls()?.select(); return; } @@ -161,11 +181,11 @@ export class ComboboxPattern, V> { !(event.relatedTarget instanceof HTMLElement) || !this.inputs.containerEl()?.contains(event.relatedTarget) ) { - this.close(); - if (this.inputs.filterMode() !== 'manual') { this.commit(); } + + this.close(); } } @@ -173,6 +193,7 @@ export class ComboboxPattern, V> { close() { this.expanded.set(false); this.inputs.popupControls()?.unfocus(); + this.inputs.popupControls()?.clearSelection(); } /** Opens the combobox. */ @@ -242,6 +263,10 @@ export class ComboboxPattern, V> { if (opts.highlight) { this.highlight(); } + if (this.inputs.filterMode() === 'manual') { + this.inputs.popupControls()?.clearSelection(); + this.inputs.value.set(undefined); + } } /** Updates the value of the input based on the currently selected item. */ diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index baaf54786991..0abe5c7ce2f0 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -288,6 +288,7 @@ export class ListboxPattern { first: () => this.listBehavior.first(), unfocus: () => this.listBehavior.unfocus(), select: item => this.listBehavior.select(item), + clearSelection: () => this.listBehavior.deselectAll(), getItem: e => this._getItem(e), getSelectedItem: () => this.inputs.items().find(i => i.selected()), From 2241c18698951b40a8707ac817b693e7d7ce6c4f Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 16 Sep 2025 12:45:44 -0400 Subject: [PATCH 06/23] fixup! feat(cdk-experimental/ui-patterns): create combobox ui pattern --- src/cdk-experimental/combobox/combobox.ts | 4 ++++ .../ui-patterns/combobox/combobox.ts | 18 +++++++++++++++++- .../ui-patterns/listbox/listbox.ts | 4 +++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/cdk-experimental/combobox/combobox.ts b/src/cdk-experimental/combobox/combobox.ts index a17a7714f87c..f22813e6558f 100644 --- a/src/cdk-experimental/combobox/combobox.ts +++ b/src/cdk-experimental/combobox/combobox.ts @@ -57,6 +57,10 @@ export class CdkCombobox { /** The values of the current selected items. */ value = model(undefined); + filter = input<(inputText: string, itemText: string) => boolean>((inputText, itemText) => + itemText.toLowerCase().includes(inputText.toLowerCase()), + ); + /** The combobox ui pattern. */ readonly pattern = new ComboboxPattern({ ...this, diff --git a/src/cdk-experimental/ui-patterns/combobox/combobox.ts b/src/cdk-experimental/ui-patterns/combobox/combobox.ts index ec047aec050c..3276c132cf3b 100644 --- a/src/cdk-experimental/ui-patterns/combobox/combobox.ts +++ b/src/cdk-experimental/ui-patterns/combobox/combobox.ts @@ -27,6 +27,9 @@ export type ComboboxInputs, V> = { /** The filtering mode for the combobox. */ filterMode: SignalLike<'manual' | 'auto-select' | 'highlight'>; + + /** The function used to filter items in the combobox. */ + filter: SignalLike<(inputText: string, itemText: string) => boolean>; }; /** An interface that allows combobox popups to expose the necessary controls for the combobox. */ @@ -189,6 +192,19 @@ export class ComboboxPattern, V> { } } + setDefaultState() { + if (this.inputs.value() !== undefined) { + this.inputs.popupControls()?.setValue(this.inputs.value()); + + const inputEl = this.inputs.inputEl(); + const searchTerm = this.inputs.popupControls()?.getSelectedItem()?.searchTerm() ?? ''; + + if (inputEl) { + inputEl.value = searchTerm; + } + } + } + /** Closes the combobox. */ close() { this.expanded.set(false); @@ -223,7 +239,7 @@ export class ComboboxPattern, V> { .startsWith(this.searchString().toLowerCase()); if (element && isHighlightable) { - element.value = item.searchTerm(); + element.value = this.searchString() + item.searchTerm().slice(this.searchString().length); element.setSelectionRange(this.searchString().length, item.searchTerm().length); this.highlightedItem.set(item); } diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index 0abe5c7ce2f0..623d6f18ae94 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -296,8 +296,10 @@ export class ListboxPattern { setValue: (value: V | undefined) => this.inputs.value.set(value ? [value] : []), filter: (text: string) => { + const filterFn = this.inputs.combobox()!.inputs.filter(); + this.inputs.items().forEach(i => { - const isMatch = i.searchTerm().toLowerCase().includes(text.toLowerCase()); + const isMatch = filterFn(text, i.searchTerm()); i.inert.set(isMatch ? null : true); }); }, From d2290ff3fab7ea7338c932b34e5bbef0262c5d7c Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 16 Sep 2025 16:55:31 -0400 Subject: [PATCH 07/23] fixup! feat(cdk-experimental/ui-patterns): create combobox ui pattern --- src/cdk-experimental/listbox/listbox.ts | 28 ++++++---- .../ui-patterns/listbox/combobox-listbox.ts | 51 +++++++++++++++++++ .../ui-patterns/listbox/listbox.spec.ts | 1 - .../ui-patterns/listbox/listbox.ts | 49 ++---------------- .../ui-patterns/public-api.ts | 1 + 5 files changed, 74 insertions(+), 56 deletions(-) create mode 100644 src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index d385f126cede..6cdd8eea208d 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -18,7 +18,7 @@ import { model, signal, } from '@angular/core'; -import {ListboxPattern, OptionPattern} from '../ui-patterns'; +import {ComboboxListboxPattern, ListboxPattern, OptionPattern} from '../ui-patterns'; import {Directionality} from '@angular/cdk/bidi'; import {toSignal} from '@angular/core/rxjs-interop'; import {_IdGenerator} from '@angular/cdk/a11y'; @@ -118,20 +118,28 @@ export class CdkListbox { value = model([]); /** The Listbox UIPattern. */ - pattern: ListboxPattern = new ListboxPattern({ - ...this, - items: this.items, - activeItem: signal(undefined), - textDirection: this.textDirection, - element: () => this._elementRef.nativeElement, - combobox: () => this._popup?.combobox?.pattern, - }); + pattern: ListboxPattern; /** Whether the listbox has received focus yet. */ private _hasFocused = signal(false); constructor() { - this._popup?.actions?.set(this.pattern.comboboxActions); + const inputs = { + ...this, + items: this.items, + activeItem: signal(undefined), + textDirection: this.textDirection, + element: () => this._elementRef.nativeElement, + combobox: () => this._popup?.combobox?.pattern, + }; + + this.pattern = this._popup?.combobox + ? new ComboboxListboxPattern(inputs) + : new ListboxPattern(inputs); + + if (this._popup) { + this._popup.actions.set((this.pattern as ComboboxListboxPattern).comboboxActions); + } afterRenderEffect(() => { if (typeof ngDevMode === 'undefined' || ngDevMode) { diff --git a/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts b/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts new file mode 100644 index 000000000000..a0ad9040ae84 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts @@ -0,0 +1,51 @@ +import {computed} from '@angular/core'; +import {ListboxInputs, ListboxPattern} from './listbox'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; +import {OptionPattern} from './option'; +import {ComboboxPattern, ComboboxPopupControls} from '../combobox/combobox'; + +export type ComboboxListboxInputs = ListboxInputs & { + /** The combobox controlling the listbox. */ + combobox: SignalLike, V> | undefined>; +}; + +export class ComboboxListboxPattern extends ListboxPattern { + override tabindex: SignalLike<-1 | 0> = () => -1; + + constructor(override readonly inputs: ComboboxListboxInputs) { + if (inputs.combobox()) { + inputs.multi = () => false; + inputs.focusMode = () => 'activedescendant'; + inputs.element = inputs.combobox()!.inputs.inputEl; + } + + super(inputs); + } + + override onKeydown(_: KeyboardEvent): void {} + override onPointerdown(_: PointerEvent): void {} + override setDefaultState(): void {} + + /** The actions that can be performed on a combobox popup listbox. */ + comboboxActions: ComboboxPopupControls, V> = { + activeId: computed(() => this.listBehavior.activedescendant()), + next: () => this.listBehavior.next(), + prev: () => this.listBehavior.prev(), + last: () => this.listBehavior.last(), + first: () => this.listBehavior.first(), + unfocus: () => this.listBehavior.unfocus(), + select: item => this.listBehavior.select(item), + clearSelection: () => this.listBehavior.deselectAll(), + getItem: e => this._getItem(e), + getSelectedItem: () => this.inputs.items().find(i => i.selected()), + setValue: (value: V | undefined) => this.inputs.value.set(value ? [value] : []), + filter: (text: string) => { + const filterFn = this.inputs.combobox()!.inputs.filter(); + + this.inputs.items().forEach(i => { + const isMatch = filterFn(text, i.searchTerm()); + i.inert.set(isMatch ? null : true); + }); + }, + }; +} diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts index 6f1149e1ec67..2c2ecde4793f 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts @@ -46,7 +46,6 @@ describe('Listbox Pattern', () => { orientation: inputs.orientation ?? signal('vertical'), selectionMode: inputs.selectionMode ?? signal('explicit'), element: signal(document.createElement('div')), - combobox: signal(undefined), }); } diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index 623d6f18ae94..69c4eea011fa 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -11,14 +11,10 @@ import {KeyboardEventManager, PointerEventManager, Modifier} from '../behaviors/ import {computed, signal} from '@angular/core'; import {SignalLike} from '../behaviors/signal-like/signal-like'; import {List, ListInputs} from '../behaviors/list/list'; -import {ComboboxPattern, ComboboxPopupControls} from '../combobox/combobox'; /** Represents the required inputs for a listbox. */ export type ListboxInputs = ListInputs, V> & { readonly: SignalLike; - - /** The combobox controlling the listbox. */ - combobox: SignalLike, V> | undefined>; }; /** Controls the state of a listbox. */ @@ -35,7 +31,7 @@ export class ListboxPattern { readonly: SignalLike; /** The tabindex of the listbox. */ - tabindex = computed(() => (this.inputs.combobox() ? -1 : this.listBehavior.tabindex())); + tabindex: SignalLike<-1 | 0> = computed(() => this.listBehavior.tabindex()); /** The id of the current active item. */ activedescendant = computed(() => this.listBehavior.activedescendant()); @@ -192,12 +188,6 @@ export class ListboxPattern { this.readonly = inputs.readonly; this.orientation = inputs.orientation; this.multi = inputs.multi; - - if (this.inputs.combobox()) { - this.inputs.focusMode = () => 'activedescendant'; - this.inputs.element = this.inputs.combobox()!.inputs.inputEl; - } - this.listBehavior = new List(inputs); } @@ -224,13 +214,13 @@ export class ListboxPattern { /** Handles keydown events for the listbox. */ onKeydown(event: KeyboardEvent) { - if (!this.disabled() && !this.inputs.combobox()) { + if (!this.disabled()) { this.keydown().handle(event); } } onPointerdown(event: PointerEvent) { - if (!this.disabled() && !this.inputs.combobox()) { + if (!this.disabled()) { this.pointerdown().handle(event); } } @@ -246,10 +236,6 @@ export class ListboxPattern { * is called. */ setDefaultState() { - if (this.inputs.combobox()) { - return; - } - let firstItem: OptionPattern | null = null; for (const item of this.inputs.items()) { @@ -269,7 +255,7 @@ export class ListboxPattern { } } - private _getItem(e: PointerEvent) { + protected _getItem(e: PointerEvent) { if (!(e.target instanceof HTMLElement)) { return; } @@ -277,31 +263,4 @@ export class ListboxPattern { const element = e.target.closest('[role="option"]'); return this.inputs.items().find(i => i.element() === element); } - - /** The actions that can be performed on a combobox popup listbox. */ - comboboxActions: ComboboxPopupControls, V> = { - activeId: computed(() => this.listBehavior.activedescendant()), - - next: () => this.listBehavior.next(), - prev: () => this.listBehavior.prev(), - last: () => this.listBehavior.last(), - first: () => this.listBehavior.first(), - unfocus: () => this.listBehavior.unfocus(), - select: item => this.listBehavior.select(item), - clearSelection: () => this.listBehavior.deselectAll(), - - getItem: e => this._getItem(e), - getSelectedItem: () => this.inputs.items().find(i => i.selected()), - - setValue: (value: V | undefined) => this.inputs.value.set(value ? [value] : []), - - filter: (text: string) => { - const filterFn = this.inputs.combobox()!.inputs.filter(); - - this.inputs.items().forEach(i => { - const isMatch = filterFn(text, i.searchTerm()); - i.inert.set(isMatch ? null : true); - }); - }, - }; } diff --git a/src/cdk-experimental/ui-patterns/public-api.ts b/src/cdk-experimental/ui-patterns/public-api.ts index 9e1bd466c17f..6c875855d86a 100644 --- a/src/cdk-experimental/ui-patterns/public-api.ts +++ b/src/cdk-experimental/ui-patterns/public-api.ts @@ -9,6 +9,7 @@ export * from './combobox/combobox'; export * from './listbox/listbox'; export * from './listbox/option'; +export * from './listbox/combobox-listbox'; export * from './radio-group/radio-group'; export * from './radio-group/radio-button'; export * from './radio-group/toolbar-radio-group'; From 274f8e445ef07177fb647224f51d91f8a0ccb0fa Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Wed, 17 Sep 2025 11:05:36 -0400 Subject: [PATCH 08/23] feat(cdk-experimental/combobox): add tree integration --- src/cdk-experimental/combobox/combobox.ts | 10 +- .../deferred-content/deferred-content.ts | 3 +- src/cdk-experimental/listbox/listbox.ts | 2 +- src/cdk-experimental/tree/BUILD.bazel | 1 + src/cdk-experimental/tree/tree.ts | 49 ++++++-- .../behaviors/expansion/expansion.ts | 2 +- .../ui-patterns/combobox/combobox.ts | 50 ++++++++- .../ui-patterns/listbox/combobox-listbox.ts | 77 +++++++++---- .../ui-patterns/public-api.ts | 2 + .../ui-patterns/tree/BUILD.bazel | 2 + .../ui-patterns/tree/combobox-tree.ts | 106 ++++++++++++++++++ src/cdk-experimental/ui-patterns/tree/tree.ts | 7 +- .../cdk-experimental/combobox/BUILD.bazel | 5 +- 13 files changed, 263 insertions(+), 53 deletions(-) create mode 100644 src/cdk-experimental/ui-patterns/tree/combobox-tree.ts diff --git a/src/cdk-experimental/combobox/combobox.ts b/src/cdk-experimental/combobox/combobox.ts index f22813e6558f..d6061f8c4ce3 100644 --- a/src/cdk-experimental/combobox/combobox.ts +++ b/src/cdk-experimental/combobox/combobox.ts @@ -18,7 +18,7 @@ import { WritableSignal, } from '@angular/core'; import {DeferredContent, DeferredContentAware} from '@angular/cdk-experimental/deferred-content'; -import {ComboboxPattern, ComboboxPopupControls} from '../ui-patterns'; +import {ComboboxPattern, ComboboxListboxControls, ComboboxTreeControls} from '../ui-patterns'; @Directive({ selector: '[cdkCombobox]', @@ -66,7 +66,7 @@ export class CdkCombobox { ...this, inputEl: signal(undefined), containerEl: signal(undefined), - popupControls: () => this.popup()?.actions(), + popupControls: () => this.popup()?.controls(), }); constructor() { @@ -118,6 +118,8 @@ export class CdkComboboxPopup { /** The combobox that the popup belongs to. */ readonly combobox = inject>(CdkCombobox, {optional: true}); - /** The actions that the combobox can perform on the popup. */ - readonly actions = signal | undefined>(undefined); + /** The controls the popup exposes to the combobox. */ + readonly controls = signal< + ComboboxListboxControls | ComboboxTreeControls | undefined + >(undefined); } diff --git a/src/cdk-experimental/deferred-content/deferred-content.ts b/src/cdk-experimental/deferred-content/deferred-content.ts index f98ef1cc9634..1187b1655f37 100644 --- a/src/cdk-experimental/deferred-content/deferred-content.ts +++ b/src/cdk-experimental/deferred-content/deferred-content.ts @@ -14,6 +14,7 @@ import { TemplateRef, signal, ViewContainerRef, + model, } from '@angular/core'; /** @@ -22,7 +23,7 @@ import { @Directive() export class DeferredContentAware { readonly contentVisible = signal(false); - readonly preserveContent = input(false); + readonly preserveContent = model(false); } /** diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index 6cdd8eea208d..005cef5620f5 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -138,7 +138,7 @@ export class CdkListbox { : new ListboxPattern(inputs); if (this._popup) { - this._popup.actions.set((this.pattern as ComboboxListboxPattern).comboboxActions); + this._popup.controls.set(this.pattern as ComboboxListboxPattern); } afterRenderEffect(() => { diff --git a/src/cdk-experimental/tree/BUILD.bazel b/src/cdk-experimental/tree/BUILD.bazel index 2cfda0a3550f..18542e3c1962 100644 --- a/src/cdk-experimental/tree/BUILD.bazel +++ b/src/cdk-experimental/tree/BUILD.bazel @@ -11,6 +11,7 @@ ng_project( ], deps = [ "//:node_modules/@angular/core", + "//src/cdk-experimental/combobox", "//src/cdk-experimental/deferred-content", "//src/cdk-experimental/ui-patterns/tree", "//src/cdk/a11y", diff --git a/src/cdk-experimental/tree/tree.ts b/src/cdk-experimental/tree/tree.ts index 982809aea3e8..6bba755b75c8 100644 --- a/src/cdk-experimental/tree/tree.ts +++ b/src/cdk-experimental/tree/tree.ts @@ -23,7 +23,8 @@ import { import {_IdGenerator} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import {DeferredContent, DeferredContentAware} from '@angular/cdk-experimental/deferred-content'; -import {TreeItemPattern, TreePattern} from '../ui-patterns/tree/tree'; +import {ComboboxTreePattern, TreeItemPattern, TreePattern} from '../ui-patterns'; +import {CdkCombobox, CdkComboboxPopup} from '@angular/cdk-experimental/combobox'; interface HasElement { element: Signal; @@ -74,8 +75,14 @@ function sortDirectives(a: HasElement, b: HasElement) { '(pointerdown)': 'pattern.onPointerdown($event)', '(focusin)': 'onFocus()', }, + hostDirectives: [{directive: CdkComboboxPopup}], }) export class CdkTree { + /** A reference to the parent combobox popup, if one exists. */ + private readonly _popup = inject>(CdkComboboxPopup, { + optional: true, + }); + /** A reference to the tree element. */ private readonly _elementRef = inject(ElementRef); @@ -121,19 +128,30 @@ export class CdkTree { ); /** The UI pattern for the tree. */ - readonly pattern: TreePattern = new TreePattern({ - ...this, - allItems: computed(() => - [...this._unorderedItems()].sort(sortDirectives).map(item => item.pattern), - ), - activeItem: signal(undefined), - element: () => this._elementRef.nativeElement, - }); + readonly pattern: TreePattern; /** Whether the tree has received focus yet. */ private _hasFocused = signal(false); constructor() { + const inputs = { + ...this, + allItems: computed(() => + [...this._unorderedItems()].sort(sortDirectives).map(item => item.pattern), + ), + activeItem: signal(undefined), + element: () => this._elementRef.nativeElement, + combobox: () => this._popup?.combobox?.pattern, + }; + + this.pattern = this._popup?.combobox + ? new ComboboxTreePattern(inputs) + : new TreePattern(inputs); + + if (this._popup?.combobox) { + this._popup?.controls?.set(this.pattern as ComboboxTreePattern); + } + afterRenderEffect(() => { if (!this._hasFocused()) { this.pattern.setDefaultState(); @@ -176,7 +194,7 @@ export class CdkTree { '[attr.aria-setsize]': 'pattern.setsize()', '[attr.aria-posinset]': 'pattern.posinset()', '[attr.tabindex]': 'pattern.tabindex()', - '[attr.inert]': 'pattern.visible() ? null : true', + '[attr.inert]': 'pattern.inert()', }, }) export class CdkTreeItem implements OnInit, OnDestroy, HasElement { @@ -312,10 +330,19 @@ export class CdkTreeItemGroup implements OnInit, OnDestroy, HasElement { /** Tree item that owns the group. */ readonly ownedBy = input.required>(); + /** The combobox that the input belongs to. */ + // readonly combobox = inject(CdkCombobox); + constructor() { + this._deferredContentAware.preserveContent.set(true); // Connect the group's hidden state to the DeferredContentAware's visibility. afterRenderEffect(() => { - this._deferredContentAware.contentVisible.set(this.visible()); + const tree = this.ownedBy().tree(); + if (tree.pattern instanceof ComboboxTreePattern) { + this._deferredContentAware.contentVisible.set( + tree.pattern.inputs.combobox()?.isFocused() ?? false, + ); + } }); } diff --git a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts index a105744f9d70..7c0ed74b9fe7 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts @@ -38,7 +38,7 @@ export class ExpansionControl { this.disabled = inputs.disabled; } - /** Requests the Expansopn manager to open this item. */ + /** Requests the Expansion manager to open this item. */ open() { this.inputs.expansionManager.open(this); } diff --git a/src/cdk-experimental/ui-patterns/combobox/combobox.ts b/src/cdk-experimental/ui-patterns/combobox/combobox.ts index 3276c132cf3b..aabf76beabcc 100644 --- a/src/cdk-experimental/ui-patterns/combobox/combobox.ts +++ b/src/cdk-experimental/ui-patterns/combobox/combobox.ts @@ -17,7 +17,7 @@ export type ComboboxInputs, V> = { value: WritableSignalLike; /** The controls for the popup associated with the combobox. */ - popupControls: SignalLike | undefined>; + popupControls: SignalLike | ComboboxTreeControls | undefined>; /** The HTML input element that serves as the combobox input. */ inputEl: SignalLike; @@ -33,7 +33,10 @@ export type ComboboxInputs, V> = { }; /** An interface that allows combobox popups to expose the necessary controls for the combobox. */ -export type ComboboxPopupControls, V> = { +export type ComboboxListboxControls, V> = { + /** The ARIA role for the popup. */ + role: SignalLike<'listbox' | 'tree' | 'grid'>; + /** The ID of the active item in the popup. */ activeId: SignalLike; @@ -71,6 +74,17 @@ export type ComboboxPopupControls, V> = { setValue: (value: V | undefined) => void; // For re-setting the value if the popup was destroyed. }; +export type ComboboxTreeControls, V> = ComboboxListboxControls & { + /** Expands the currently active item in the popup. */ + expandItem: () => void; + + /** Collapses the currently active item in the popup. */ + collapseItem: () => void; + + /** Checks if the currently active item in the popup is expandable. */ + isItemExpandable: () => boolean; +}; + /** Controls the state of a combobox. */ export class ComboboxPattern, V> { /** Whether the combobox is expanded. */ @@ -88,6 +102,12 @@ export class ComboboxPattern, V> { /** Whether the combobox is focused. */ isFocused = signal(false); + /** The key used to navigate to the previous item in the list. */ + expandKey = computed(() => 'ArrowRight'); // TODO: RTL support. + + /** The key used to navigate to the next item in the list. */ + collapseKey = computed(() => 'ArrowLeft'); // TODO: RTL support. + /** The keydown event manager for the combobox. */ keydown = computed(() => { if (!this.expanded()) { @@ -96,15 +116,21 @@ export class ComboboxPattern, V> { .on('ArrowUp', () => this.open({last: true})); } - return new KeyboardEventManager() + const popupControls = this.inputs.popupControls(); + + if (!popupControls) { + return new KeyboardEventManager(); + } + + const manager = new KeyboardEventManager() .on('ArrowDown', () => this.next()) .on('ArrowUp', () => this.prev()) .on('Home', () => this.first()) .on('End', () => this.last()) .on('Escape', () => { - if (this.inputs.filterMode() === 'highlight' && this.inputs.popupControls()?.activeId()) { - this.inputs.popupControls()?.unfocus(); - this.inputs.popupControls()?.clearSelection(); + if (this.inputs.filterMode() === 'highlight' && popupControls.activeId()) { + popupControls.unfocus(); + popupControls.clearSelection(); const inputEl = this.inputs.inputEl(); @@ -116,6 +142,18 @@ export class ComboboxPattern, V> { } }) // TODO: When filter mode is 'highlight', escape should revert to the last committed value. .on('Enter', () => this.select({commit: true, close: true})); + + if (popupControls.role() === 'tree') { + const treeControls = popupControls as ComboboxTreeControls; + + if (treeControls.isItemExpandable()) { + manager + .on(this.expandKey(), () => treeControls.expandItem()) + .on(this.collapseKey(), () => treeControls.collapseItem()); + } + } + + return manager; }); /** The pointerup event manager for the combobox. */ diff --git a/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts b/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts index a0ad9040ae84..e8a0741b988b 100644 --- a/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts @@ -2,14 +2,23 @@ import {computed} from '@angular/core'; import {ListboxInputs, ListboxPattern} from './listbox'; import {SignalLike} from '../behaviors/signal-like/signal-like'; import {OptionPattern} from './option'; -import {ComboboxPattern, ComboboxPopupControls} from '../combobox/combobox'; +import {ComboboxPattern, ComboboxListboxControls} from '../combobox/combobox'; export type ComboboxListboxInputs = ListboxInputs & { /** The combobox controlling the listbox. */ combobox: SignalLike, V> | undefined>; }; -export class ComboboxListboxPattern extends ListboxPattern { +export class ComboboxListboxPattern + extends ListboxPattern + implements ComboboxListboxControls, V> +{ + role = () => 'listbox' as const; + + /** The id of the active (focused) item in the listbox. */ + activeId = computed(() => this.listBehavior.activedescendant()); + + /** The tabindex for the listbox. Always -1 because the combobox handles focus. */ override tabindex: SignalLike<-1 | 0> = () => -1; constructor(override readonly inputs: ComboboxListboxInputs) { @@ -22,30 +31,52 @@ export class ComboboxListboxPattern extends ListboxPattern { super(inputs); } + /** Noop. The combobox handles keydown events. */ override onKeydown(_: KeyboardEvent): void {} + + /** Noop. The combobox handles pointerdown events. */ override onPointerdown(_: PointerEvent): void {} + + /** Noop. The combobox controls the open state. */ override setDefaultState(): void {} - /** The actions that can be performed on a combobox popup listbox. */ - comboboxActions: ComboboxPopupControls, V> = { - activeId: computed(() => this.listBehavior.activedescendant()), - next: () => this.listBehavior.next(), - prev: () => this.listBehavior.prev(), - last: () => this.listBehavior.last(), - first: () => this.listBehavior.first(), - unfocus: () => this.listBehavior.unfocus(), - select: item => this.listBehavior.select(item), - clearSelection: () => this.listBehavior.deselectAll(), - getItem: e => this._getItem(e), - getSelectedItem: () => this.inputs.items().find(i => i.selected()), - setValue: (value: V | undefined) => this.inputs.value.set(value ? [value] : []), - filter: (text: string) => { - const filterFn = this.inputs.combobox()!.inputs.filter(); - - this.inputs.items().forEach(i => { - const isMatch = filterFn(text, i.searchTerm()); - i.inert.set(isMatch ? null : true); - }); - }, + /** Navigates to the next focusable item in the listbox. */ + next = () => this.listBehavior.next(); + + /** Navigates to the previous focusable item in the listbox. */ + prev = () => this.listBehavior.prev(); + + /** Navigates to the last focusable item in the listbox. */ + last = () => this.listBehavior.last(); + + /** Navigates to the first focusable item in the listbox. */ + first = () => this.listBehavior.first(); + + /** Unfocuses the currently focused item in the listbox. */ + unfocus = () => this.listBehavior.unfocus(); + + /** Selects the specified item in the listbox. */ + select = (item?: OptionPattern) => this.listBehavior.select(item); + + /** Clears the selection in the listbox. */ + clearSelection = () => this.listBehavior.deselectAll(); + + /** Retrieves the OptionPattern associated with a pointer event. */ + getItem = (e: PointerEvent) => this._getItem(e); + + /** Retrieves the currently selected item in the listbox. */ + getSelectedItem = () => this.inputs.items().find(i => i.selected()); + + /** Sets the value of the combobox listbox. */ + setValue = (value: V | undefined) => this.inputs.value.set(value ? [value] : []); + + /** Filters the items in the listbox based on the provided text. */ + filter = (text: string) => { + const filterFn = this.inputs.combobox()!.inputs.filter(); + + this.inputs.items().forEach(i => { + const isMatch = filterFn(text, i.searchTerm()); + i.inert.set(isMatch ? null : true); + }); }; } diff --git a/src/cdk-experimental/ui-patterns/public-api.ts b/src/cdk-experimental/ui-patterns/public-api.ts index 6c875855d86a..55ae55da51a1 100644 --- a/src/cdk-experimental/ui-patterns/public-api.ts +++ b/src/cdk-experimental/ui-patterns/public-api.ts @@ -20,3 +20,5 @@ export * from './toolbar/toolbar-widget'; export * from './toolbar/toolbar-widget-group'; export * from './accordion/accordion'; export * from './toolbar/toolbar'; +export * from './tree/tree'; +export * from './tree/combobox-tree'; diff --git a/src/cdk-experimental/ui-patterns/tree/BUILD.bazel b/src/cdk-experimental/ui-patterns/tree/BUILD.bazel index d22d1f9ebfe1..5f3a7d59c6db 100644 --- a/src/cdk-experimental/ui-patterns/tree/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/tree/BUILD.bazel @@ -5,6 +5,7 @@ package(default_visibility = ["//visibility:public"]) ts_project( name = "tree", srcs = [ + "combobox-tree.ts", "tree.ts", ], deps = [ @@ -13,6 +14,7 @@ ts_project( "//src/cdk-experimental/ui-patterns/behaviors/expansion", "//src/cdk-experimental/ui-patterns/behaviors/list", "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + "//src/cdk-experimental/ui-patterns/combobox", ], ) diff --git a/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts b/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts new file mode 100644 index 000000000000..8e6417e164da --- /dev/null +++ b/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts @@ -0,0 +1,106 @@ +import {computed} from '@angular/core'; +import {TreeInputs, TreePattern, TreeItemPattern} from './tree'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; +import {ComboboxPattern, ComboboxTreeControls} from '../combobox/combobox'; + +export type ComboboxTreeInputs = TreeInputs & { + /** The combobox controlling the tree. */ + combobox: SignalLike, V> | undefined>; +}; + +export class ComboboxTreePattern + extends TreePattern + implements ComboboxTreeControls, V> +{ + /** The ARIA role for the tree. */ + role = () => 'tree' as const; + + /* The id of the active (focused) item in the tree. */ + activeId = computed(() => this.listBehavior.activedescendant()); + + /** The tabindex for the tree. Always -1 because the combobox handles focus. */ + override tabindex: SignalLike<-1 | 0> = () => -1; + + constructor(override readonly inputs: ComboboxTreeInputs) { + if (inputs.combobox()) { + inputs.multi = () => false; + inputs.focusMode = () => 'activedescendant'; + inputs.element = inputs.combobox()!.inputs.inputEl; + } + + super(inputs); + } + + /** Noop. The combobox handles keydown events. */ + override onKeydown(_: KeyboardEvent): void {} + + /** Noop. The combobox handles pointerdown events. */ + override onPointerdown(_: PointerEvent): void {} + + /** Noop. The combobox controls the open state. */ + override setDefaultState(): void { + console.log('setDefaultState'); + } + + /** Navigates to the next focusable item in the tree. */ + next = () => this.listBehavior.next(); + + /** Navigates to the previous focusable item in the tree. */ + prev = () => this.listBehavior.prev(); + + /** Navigates to the last focusable item in the tree. */ + last = () => this.listBehavior.last(); + + /** Navigates to the first focusable item in the tree. */ + first = () => this.listBehavior.first(); + + /** Unfocuses the currently focused item in the tree. */ + unfocus = () => this.listBehavior.unfocus(); + + /** Selects the specified item in the tree or the current active item if not provided. */ + select = (item?: TreeItemPattern) => this.listBehavior.select(item); + + /** Clears the selection in the tree. */ + clearSelection = () => this.listBehavior.deselectAll(); + + /** Retrieves the TreeItemPattern associated with a pointer event. */ + getItem = (e: PointerEvent) => this._getItem(e); + + /** Retrieves the currently selected item in the tree */ + getSelectedItem = () => this.inputs.allItems().find(i => i.selected()); + + /** Sets the value of the combobox tree. */ + setValue = (value: V | undefined) => this.inputs.value.set(value ? [value] : []); + + /** Expands the currently focused item if it is expandable. */ + expandItem = () => this.expand(); + + /** Collapses the currently focused item if it is expandable. */ + collapseItem = () => this.collapse(); + + /** Whether the specified item or the currently active item is expandable. */ + isItemExpandable = (item?: TreeItemPattern) => { + item = item || this.activeItem(); + return item ? item.expandable() : false; + }; + + /** Filters the items in the tree based on the provided text. */ + filter = (text: string) => { + const filterFn = this.inputs.combobox()!.inputs.filter(); + this.inputs.allItems().forEach(i => i.expansion.close()); + + this.inputs.allItems().forEach(i => { + const isMatch = filterFn(text, i.searchTerm()); + + if (isMatch) { + let parent = i.parent(); + while (parent && parent instanceof TreeItemPattern) { + parent.inert.set(null); + parent = parent.parent(); + } + } + + i.inert.set(isMatch ? null : true); + }); + }; +} diff --git a/src/cdk-experimental/ui-patterns/tree/tree.ts b/src/cdk-experimental/ui-patterns/tree/tree.ts index eed86e65434f..1afe148d34da 100644 --- a/src/cdk-experimental/ui-patterns/tree/tree.ts +++ b/src/cdk-experimental/ui-patterns/tree/tree.ts @@ -32,6 +32,9 @@ export interface TreeItemPattern extends TreeItemInputs {} * Represents an item in a Tree. */ export class TreeItemPattern implements ExpansionItem { + /** Whether the option is inert. */ + inert = signal(null); + /** The position of this item among its siblings. */ readonly index = computed(() => this.tree().visibleItems().indexOf(this)); @@ -148,7 +151,7 @@ export class TreePattern { readonly expanded = () => true; /** The tabindex of the tree. */ - readonly tabindex = computed(() => this.listBehavior.tabindex()); + tabindex: SignalLike<-1 | 0> = computed(() => this.listBehavior.tabindex()); /** The id of the current active item. */ readonly activedescendant = computed(() => this.listBehavior.activedescendant()); @@ -425,7 +428,7 @@ export class TreePattern { } /** Retrieves the TreeItemPattern associated with a DOM event, if any. */ - private _getItem(event: Event): TreeItemPattern | undefined { + protected _getItem(event: Event): TreeItemPattern | undefined { if (!(event.target instanceof HTMLElement)) { return; } diff --git a/src/components-examples/cdk-experimental/combobox/BUILD.bazel b/src/components-examples/cdk-experimental/combobox/BUILD.bazel index 53307e4686d9..0181eb24c384 100644 --- a/src/components-examples/cdk-experimental/combobox/BUILD.bazel +++ b/src/components-examples/cdk-experimental/combobox/BUILD.bazel @@ -11,11 +11,8 @@ ng_project( ]), deps = [ "//:node_modules/@angular/core", - "//:node_modules/@angular/forms", "//src/cdk-experimental/listbox", - "//src/material/checkbox", - "//src/material/form-field", - "//src/material/select", + "//src/cdk-experimental/tree", ], ) From 8eb5c3d7d3637cb1058de92ec203ac78f7f2da55 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Wed, 17 Sep 2025 11:06:01 -0400 Subject: [PATCH 09/23] docs(cdk-experimental/combobox): combobox tree examples --- .../combobox/cdk-combobox-examples.css | 52 +++++++++++- ...cdk-combobox-tree-auto-select-example.html | 52 ++++++++++++ .../cdk-combobox-tree-auto-select-example.ts | 82 +++++++++++++++++++ .../cdk-combobox-tree-highlight-example.html | 52 ++++++++++++ .../cdk-combobox-tree-highlight-example.ts | 82 +++++++++++++++++++ .../cdk-combobox-tree-manual-example.html | 52 ++++++++++++ .../cdk-combobox-tree-manual-example.ts | 82 +++++++++++++++++++ .../cdk-experimental/combobox/data.ts | 24 ++++++ .../cdk-experimental/combobox/index.ts | 3 + .../cdk-experimental/tree/tree-common.css | 4 + .../cdk-combobox-demo.html | 15 ++++ .../cdk-combobox-demo.ts | 12 ++- 12 files changed, 508 insertions(+), 4 deletions(-) create mode 100644 src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-auto-select/cdk-combobox-tree-auto-select-example.html create mode 100644 src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-auto-select/cdk-combobox-tree-auto-select-example.ts create mode 100644 src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-highlight/cdk-combobox-tree-highlight-example.html create mode 100644 src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-highlight/cdk-combobox-tree-highlight-example.ts create mode 100644 src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-manual/cdk-combobox-tree-manual-example.html create mode 100644 src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-manual/cdk-combobox-tree-manual-example.ts create mode 100644 src/components-examples/cdk-experimental/combobox/data.ts diff --git a/src/components-examples/cdk-experimental/combobox/cdk-combobox-examples.css b/src/components-examples/cdk-experimental/combobox/cdk-combobox-examples.css index 10f3f870a90f..61ab3b7c6731 100644 --- a/src/components-examples/cdk-experimental/combobox/cdk-combobox-examples.css +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-examples.css @@ -80,9 +80,9 @@ visibility: visible; } -.example-option[inert] { - height: 0; - padding: 0 1rem; +.example-option[inert], +.example-tree-item[inert] { + display: none; } .example-combobox-container:focus-within .cdk-active { @@ -101,3 +101,49 @@ ); color: var(--mat-sys-primary); } + +.example-tree { + padding: 10px; + overflow-x: scroll; +} + +.example-tree-item { + cursor: pointer; + list-style: none; + text-decoration: none; + display: flex; + align-items: center; + gap: 1rem; + padding: 0.3rem 1rem; +} + +li[aria-expanded='false'] + ul[role='group'] { + display: none; +} + +li[inert] + ul[role='group'] { + padding-inline-start: 0; +} + +.example-icon { + margin: 0; + width: 24px; +} + +.example-parent-icon { + transition: transform 0.2s ease; +} + +.example-tree-item[aria-expanded='true'] .example-parent-icon { + transform: rotate(90deg); +} + +.example-selected-icon { + visibility: hidden; + margin-left: auto; +} + +.example-tree-item[aria-current] .example-selected-icon, +.example-tree-item[aria-selected='true'] .example-selected-icon { + visibility: visible; +} diff --git a/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-auto-select/cdk-combobox-tree-auto-select-example.html b/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-auto-select/cdk-combobox-tree-auto-select-example.html new file mode 100644 index 000000000000..d526fc4c6072 --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-auto-select/cdk-combobox-tree-auto-select-example.html @@ -0,0 +1,52 @@ +
+
+ search + +
+ +
+ +
    + +
+
+
+
+ + + @for (node of nodes; track node.name) { +
  • + + {{ node.name }} + +
  • + + @if (node.children) { +
      + + + +
    + } + } +
    diff --git a/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-auto-select/cdk-combobox-tree-auto-select-example.ts b/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-auto-select/cdk-combobox-tree-auto-select-example.ts new file mode 100644 index 000000000000..3688c4e976b1 --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-auto-select/cdk-combobox-tree-auto-select-example.ts @@ -0,0 +1,82 @@ +/** + * @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 { + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContent, +} from '@angular/cdk-experimental/combobox'; +import { + CdkTree, + CdkTreeItem, + CdkTreeItemGroup, + CdkTreeItemGroupContent, +} from '@angular/cdk-experimental/tree'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + ElementRef, + viewChild, +} from '@angular/core'; +import {TREE_DATA, FoodNode} from '../data'; +import {NgTemplateOutlet} from '@angular/common'; + +/** @title Combobox with tree popup and auto-select filtering. */ +@Component({ + selector: 'cdk-combobox-tree-auto-select-example', + templateUrl: 'cdk-combobox-tree-auto-select-example.html', + styleUrl: '../cdk-combobox-examples.css', + imports: [ + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContent, + CdkTree, + CdkTreeItem, + CdkTreeItemGroup, + CdkTreeItemGroupContent, + NgTemplateOutlet, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkComboboxTreeAutoSelectExample { + popover = viewChild('popover'); + tree = viewChild>(CdkTree); + combobox = viewChild>(CdkCombobox); + + nodes = TREE_DATA; + + constructor() { + afterRenderEffect(() => { + const popover = this.popover()!; + const combobox = this.combobox()!; + combobox.pattern.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); + + // TODO(wagnermaciel): Make this easier for developers to do. + this.tree()?.pattern.inputs.activeItem()?.element().scrollIntoView({block: 'nearest'}); + }); + } + + showPopover() { + const popover = this.popover()!; + const combobox = this.combobox()!; + + const comboboxRect = combobox.pattern.inputs.inputEl()?.getBoundingClientRect(); + const popoverEl = popover.nativeElement; + + if (comboboxRect) { + popoverEl.style.width = `${comboboxRect.width}px`; + popoverEl.style.top = `${comboboxRect.bottom}px`; + popoverEl.style.left = `${comboboxRect.left - 1}px`; + } + + popover.nativeElement.showPopover(); + } +} diff --git a/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-highlight/cdk-combobox-tree-highlight-example.html b/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-highlight/cdk-combobox-tree-highlight-example.html new file mode 100644 index 000000000000..c970f721ed5d --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-highlight/cdk-combobox-tree-highlight-example.html @@ -0,0 +1,52 @@ +
    +
    + search + +
    + +
    + +
      + +
    +
    +
    +
    + + + @for (node of nodes; track node.name) { +
  • + + {{ node.name }} + +
  • + + @if (node.children) { +
      + + + +
    + } + } +
    diff --git a/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-highlight/cdk-combobox-tree-highlight-example.ts b/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-highlight/cdk-combobox-tree-highlight-example.ts new file mode 100644 index 000000000000..ef1b71bdcda9 --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-highlight/cdk-combobox-tree-highlight-example.ts @@ -0,0 +1,82 @@ +/** + * @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 { + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContent, +} from '@angular/cdk-experimental/combobox'; +import { + CdkTree, + CdkTreeItem, + CdkTreeItemGroup, + CdkTreeItemGroupContent, +} from '@angular/cdk-experimental/tree'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + ElementRef, + viewChild, +} from '@angular/core'; +import {TREE_DATA, FoodNode} from '../data'; +import {NgTemplateOutlet} from '@angular/common'; + +/** @title Combobox with tree popup and highlight filtering. */ +@Component({ + selector: 'cdk-combobox-tree-highlight-example', + templateUrl: 'cdk-combobox-tree-highlight-example.html', + styleUrl: '../cdk-combobox-examples.css', + imports: [ + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContent, + CdkTree, + CdkTreeItem, + CdkTreeItemGroup, + CdkTreeItemGroupContent, + NgTemplateOutlet, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkComboboxTreeHighlightExample { + popover = viewChild('popover'); + tree = viewChild>(CdkTree); + combobox = viewChild>(CdkCombobox); + + nodes = TREE_DATA; + + constructor() { + afterRenderEffect(() => { + const popover = this.popover()!; + const combobox = this.combobox()!; + combobox.pattern.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); + + // TODO(wagnermaciel): Make this easier for developers to do. + this.tree()?.pattern.inputs.activeItem()?.element().scrollIntoView({block: 'nearest'}); + }); + } + + showPopover() { + const popover = this.popover()!; + const combobox = this.combobox()!; + + const comboboxRect = combobox.pattern.inputs.inputEl()?.getBoundingClientRect(); + const popoverEl = popover.nativeElement; + + if (comboboxRect) { + popoverEl.style.width = `${comboboxRect.width}px`; + popoverEl.style.top = `${comboboxRect.bottom}px`; + popoverEl.style.left = `${comboboxRect.left - 1}px`; + } + + popover.nativeElement.showPopover(); + } +} diff --git a/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-manual/cdk-combobox-tree-manual-example.html b/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-manual/cdk-combobox-tree-manual-example.html new file mode 100644 index 000000000000..0fc3bfce3593 --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-manual/cdk-combobox-tree-manual-example.html @@ -0,0 +1,52 @@ +
    +
    + search + +
    + +
    + +
      + +
    +
    +
    +
    + + + @for (node of nodes; track node.name) { +
  • + + {{ node.name }} + +
  • + + @if (node.children) { +
      + + + +
    + } + } +
    diff --git a/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-manual/cdk-combobox-tree-manual-example.ts b/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-manual/cdk-combobox-tree-manual-example.ts new file mode 100644 index 000000000000..208d0428aaff --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-manual/cdk-combobox-tree-manual-example.ts @@ -0,0 +1,82 @@ +/** + * @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 { + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContent, +} from '@angular/cdk-experimental/combobox'; +import { + CdkTree, + CdkTreeItem, + CdkTreeItemGroup, + CdkTreeItemGroupContent, +} from '@angular/cdk-experimental/tree'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + ElementRef, + viewChild, +} from '@angular/core'; +import {TREE_DATA, FoodNode} from '../data'; +import {NgTemplateOutlet} from '@angular/common'; + +/** @title Combobox with tree popup and manual filtering. */ +@Component({ + selector: 'cdk-combobox-tree-manual-example', + templateUrl: 'cdk-combobox-tree-manual-example.html', + styleUrl: '../cdk-combobox-examples.css', + imports: [ + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContent, + CdkTree, + CdkTreeItem, + CdkTreeItemGroup, + CdkTreeItemGroupContent, + NgTemplateOutlet, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkComboboxTreeManualExample { + popover = viewChild('popover'); + tree = viewChild>(CdkTree); + combobox = viewChild>(CdkCombobox); + + nodes = TREE_DATA; + + constructor() { + afterRenderEffect(() => { + const popover = this.popover()!; + const combobox = this.combobox()!; + combobox.pattern.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); + + // TODO(wagnermaciel): Make this easier for developers to do. + this.tree()?.pattern.inputs.activeItem()?.element().scrollIntoView({block: 'nearest'}); + }); + } + + showPopover() { + const popover = this.popover()!; + const combobox = this.combobox()!; + + const comboboxRect = combobox.pattern.inputs.inputEl()?.getBoundingClientRect(); + const popoverEl = popover.nativeElement; + + if (comboboxRect) { + popoverEl.style.width = `${comboboxRect.width}px`; + popoverEl.style.top = `${comboboxRect.bottom}px`; + popoverEl.style.left = `${comboboxRect.left - 1}px`; + } + + popover.nativeElement.showPopover(); + } +} diff --git a/src/components-examples/cdk-experimental/combobox/data.ts b/src/components-examples/cdk-experimental/combobox/data.ts new file mode 100644 index 000000000000..03cd7a2f0657 --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/data.ts @@ -0,0 +1,24 @@ +export interface FoodNode { + name: string; + children?: FoodNode[]; +} + +export const TREE_DATA: FoodNode[] = [ + { + name: 'Fruit', + children: [{name: 'Apple'}, {name: 'Banana'}, {name: 'Fruit loops'}], + }, + { + name: 'Vegetables', + children: [ + { + name: 'Green', + children: [{name: 'Broccoli'}, {name: 'Brussels sprouts'}], + }, + { + name: 'Orange', + children: [{name: 'Pumpkins'}, {name: 'Carrots'}], + }, + ], + }, +]; diff --git a/src/components-examples/cdk-experimental/combobox/index.ts b/src/components-examples/cdk-experimental/combobox/index.ts index 6ea53e4487a3..b061c0017413 100644 --- a/src/components-examples/cdk-experimental/combobox/index.ts +++ b/src/components-examples/cdk-experimental/combobox/index.ts @@ -1,3 +1,6 @@ export {CdkComboboxManualExample} from './cdk-combobox-manual/cdk-combobox-manual-example'; export {CdkComboboxAutoSelectExample} from './cdk-combobox-auto-select/cdk-combobox-auto-select-example'; export {CdkComboboxHighlightExample} from './cdk-combobox-highlight/cdk-combobox-highlight-example'; +export {CdkComboboxTreeManualExample} from './cdk-combobox-tree-manual/cdk-combobox-tree-manual-example'; +export {CdkComboboxTreeAutoSelectExample} from './cdk-combobox-tree-auto-select/cdk-combobox-tree-auto-select-example'; +export {CdkComboboxTreeHighlightExample} from './cdk-combobox-tree-highlight/cdk-combobox-tree-highlight-example'; diff --git a/src/components-examples/cdk-experimental/tree/tree-common.css b/src/components-examples/cdk-experimental/tree/tree-common.css index f0ffcf980c14..643fb5ab385d 100644 --- a/src/components-examples/cdk-experimental/tree/tree-common.css +++ b/src/components-examples/cdk-experimental/tree/tree-common.css @@ -53,3 +53,7 @@ .example-tree-item[aria-selected='true'] .example-selected-icon { visibility: visible; } + +li[aria-expanded='false'] + ul[role='group'] { + display: none; +} diff --git a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html index 2b3b7cc1ffe8..2bc6cf0c0f45 100644 --- a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html +++ b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.html @@ -14,5 +14,20 @@

    Combobox with auto-select filtering

    Combobox with highlight filtering

    + +
    +

    Combobox with tree popup and manual filtering

    + +
    + +
    +

    Combobox with tree popup and auto-select filtering

    + +
    + +
    +

    Combobox with tree popup and highlight filtering

    + +
    diff --git a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.ts b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.ts index ba4dea919f93..3d94a23b107b 100644 --- a/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.ts +++ b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.ts @@ -10,13 +10,23 @@ import { CdkComboboxAutoSelectExample, CdkComboboxHighlightExample, CdkComboboxManualExample, + CdkComboboxTreeAutoSelectExample, + CdkComboboxTreeHighlightExample, + CdkComboboxTreeManualExample, } from '@angular/components-examples/cdk-experimental/combobox'; import {ChangeDetectionStrategy, Component} from '@angular/core'; @Component({ templateUrl: 'cdk-combobox-demo.html', styleUrl: 'cdk-combobox-demo.css', - imports: [CdkComboboxManualExample, CdkComboboxAutoSelectExample, CdkComboboxHighlightExample], + imports: [ + CdkComboboxManualExample, + CdkComboboxAutoSelectExample, + CdkComboboxHighlightExample, + CdkComboboxTreeManualExample, + CdkComboboxTreeAutoSelectExample, + CdkComboboxTreeHighlightExample, + ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class CdkComboboxDemo {} From 13c1d9f6d0abbb9afd527d0b1c5892982178bccd Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Wed, 17 Sep 2025 11:29:53 -0400 Subject: [PATCH 10/23] fixup! feat(cdk-experimental/combobox): add tree integration --- src/cdk-experimental/deferred-content/deferred-content.ts | 1 - src/cdk-experimental/tree/tree.ts | 4 +++- .../ui-patterns/listbox/combobox-listbox.ts | 8 ++++++++ src/cdk-experimental/ui-patterns/tree/combobox-tree.ts | 8 ++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/cdk-experimental/deferred-content/deferred-content.ts b/src/cdk-experimental/deferred-content/deferred-content.ts index 1187b1655f37..f105ffcbad5a 100644 --- a/src/cdk-experimental/deferred-content/deferred-content.ts +++ b/src/cdk-experimental/deferred-content/deferred-content.ts @@ -10,7 +10,6 @@ import { afterRenderEffect, Directive, inject, - input, TemplateRef, signal, ViewContainerRef, diff --git a/src/cdk-experimental/tree/tree.ts b/src/cdk-experimental/tree/tree.ts index 6bba755b75c8..47ae5d30b75a 100644 --- a/src/cdk-experimental/tree/tree.ts +++ b/src/cdk-experimental/tree/tree.ts @@ -24,7 +24,7 @@ import {_IdGenerator} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import {DeferredContent, DeferredContentAware} from '@angular/cdk-experimental/deferred-content'; import {ComboboxTreePattern, TreeItemPattern, TreePattern} from '../ui-patterns'; -import {CdkCombobox, CdkComboboxPopup} from '@angular/cdk-experimental/combobox'; +import {CdkComboboxPopup} from '@angular/cdk-experimental/combobox'; interface HasElement { element: Signal; @@ -342,6 +342,8 @@ export class CdkTreeItemGroup implements OnInit, OnDestroy, HasElement { this._deferredContentAware.contentVisible.set( tree.pattern.inputs.combobox()?.isFocused() ?? false, ); + } else { + this._deferredContentAware.contentVisible.set(this.visible()); } }); } diff --git a/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts b/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts index e8a0741b988b..bcaac1c0e808 100644 --- a/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts @@ -1,3 +1,11 @@ +/** + * @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 {ListboxInputs, ListboxPattern} from './listbox'; import {SignalLike} from '../behaviors/signal-like/signal-like'; diff --git a/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts b/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts index 8e6417e164da..b59a4c5830bb 100644 --- a/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts +++ b/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts @@ -1,3 +1,11 @@ +/** + * @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 {TreeInputs, TreePattern, TreeItemPattern} from './tree'; import {SignalLike} from '../behaviors/signal-like/signal-like'; From 7609ef358beedb92f7173a383640272c90141fe3 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Wed, 17 Sep 2025 11:31:58 -0400 Subject: [PATCH 11/23] refactor(cdk-experimental/combobox): rename poopup content to container --- src/cdk-experimental/combobox/combobox.ts | 6 +++--- src/cdk-experimental/combobox/public-api.ts | 7 ++++++- .../cdk-combobox-auto-select-example.html | 2 +- .../cdk-combobox-auto-select-example.ts | 4 ++-- .../cdk-combobox-highlight-example.html | 2 +- .../cdk-combobox-highlight-example.ts | 4 ++-- .../cdk-combobox-manual/cdk-combobox-manual-example.html | 2 +- .../cdk-combobox-manual/cdk-combobox-manual-example.ts | 4 ++-- .../cdk-combobox-tree-auto-select-example.html | 2 +- .../cdk-combobox-tree-auto-select-example.ts | 4 ++-- .../cdk-combobox-tree-highlight-example.html | 2 +- .../cdk-combobox-tree-highlight-example.ts | 4 ++-- .../cdk-combobox-tree-manual-example.html | 2 +- .../cdk-combobox-tree-manual-example.ts | 4 ++-- 14 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/cdk-experimental/combobox/combobox.ts b/src/cdk-experimental/combobox/combobox.ts index d6061f8c4ce3..cb3d7597fb92 100644 --- a/src/cdk-experimental/combobox/combobox.ts +++ b/src/cdk-experimental/combobox/combobox.ts @@ -104,11 +104,11 @@ export class CdkComboboxInput { } @Directive({ - selector: 'ng-template[cdkComboboxPopupContent]', - exportAs: 'cdkComboboxPopupContent', + selector: 'ng-template[cdkComboboxPopupContainer]', + exportAs: 'cdkComboboxPopupContainer', hostDirectives: [DeferredContent], }) -export class CdkComboboxPopupContent {} +export class CdkComboboxPopupContainer {} @Directive({ selector: '[cdkComboboxPopup]', diff --git a/src/cdk-experimental/combobox/public-api.ts b/src/cdk-experimental/combobox/public-api.ts index 3f06ec7cdf26..f54c5c49588f 100644 --- a/src/cdk-experimental/combobox/public-api.ts +++ b/src/cdk-experimental/combobox/public-api.ts @@ -6,4 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ -export {CdkCombobox, CdkComboboxInput, CdkComboboxPopup, CdkComboboxPopupContent} from './combobox'; +export { + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContainer, +} from './combobox'; diff --git a/src/components-examples/cdk-experimental/combobox/cdk-combobox-auto-select/cdk-combobox-auto-select-example.html b/src/components-examples/cdk-experimental/combobox/cdk-combobox-auto-select/cdk-combobox-auto-select-example.html index 88a011b5181a..a594ea8df8bb 100644 --- a/src/components-examples/cdk-experimental/combobox/cdk-combobox-auto-select/cdk-combobox-auto-select-example.html +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-auto-select/cdk-combobox-auto-select-example.html @@ -10,7 +10,7 @@
    - +
    @for (state of states; track state) {
    - +
    @for (state of states; track state) {
    - +
    @for (state of states; track state) {
    - +
      - +
        - +
          Date: Thu, 18 Sep 2025 16:46:57 -0400 Subject: [PATCH 12/23] test(cdk-experimental/ui-patterns): combobox unit tests --- src/cdk-experimental/tree/tree.ts | 14 +- .../ui-patterns/combobox/BUILD.bazel | 2 + .../ui-patterns/combobox/combobox.spec.ts | 893 ++++++++++++++++++ .../ui-patterns/combobox/combobox.ts | 65 +- .../ui-patterns/listbox/combobox-listbox.ts | 3 + .../ui-patterns/tree/combobox-tree.ts | 32 +- 6 files changed, 974 insertions(+), 35 deletions(-) create mode 100644 src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts diff --git a/src/cdk-experimental/tree/tree.ts b/src/cdk-experimental/tree/tree.ts index 47ae5d30b75a..7531d0d48a1f 100644 --- a/src/cdk-experimental/tree/tree.ts +++ b/src/cdk-experimental/tree/tree.ts @@ -330,21 +330,13 @@ export class CdkTreeItemGroup implements OnInit, OnDestroy, HasElement { /** Tree item that owns the group. */ readonly ownedBy = input.required>(); - /** The combobox that the input belongs to. */ - // readonly combobox = inject(CdkCombobox); - constructor() { this._deferredContentAware.preserveContent.set(true); // Connect the group's hidden state to the DeferredContentAware's visibility. afterRenderEffect(() => { - const tree = this.ownedBy().tree(); - if (tree.pattern instanceof ComboboxTreePattern) { - this._deferredContentAware.contentVisible.set( - tree.pattern.inputs.combobox()?.isFocused() ?? false, - ); - } else { - this._deferredContentAware.contentVisible.set(this.visible()); - } + this.ownedBy().tree().pattern instanceof ComboboxTreePattern + ? this._deferredContentAware.contentVisible.set(true) + : this._deferredContentAware.contentVisible.set(this.visible()); }); } diff --git a/src/cdk-experimental/ui-patterns/combobox/BUILD.bazel b/src/cdk-experimental/ui-patterns/combobox/BUILD.bazel index 99e6ec3e6bb2..2ae505ee484a 100644 --- a/src/cdk-experimental/ui-patterns/combobox/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/combobox/BUILD.bazel @@ -23,6 +23,8 @@ ts_project( deps = [ ":combobox", "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/listbox", + "//src/cdk-experimental/ui-patterns/tree", "//src/cdk/keycodes", "//src/cdk/testing/private", ], diff --git a/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts b/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts new file mode 100644 index 000000000000..04df7c55f788 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts @@ -0,0 +1,893 @@ +/** + * @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 {ComboboxInputs, ComboboxPattern} from './combobox'; +import {OptionPattern} from '../listbox/option'; +import {ComboboxListboxPattern} from '../listbox/combobox-listbox'; +import {createKeyboardEvent} from '@angular/cdk/testing/private'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; +import {ModifierKeys} from '@angular/cdk/testing'; +import {TreeItemPattern} from '../tree/tree'; +import {ComboboxTreePattern} from '../tree/combobox-tree'; + +// Test types +type TestOption = OptionPattern & { + disabled: WritableSignal; +}; + +type TestInputs = { + readonly [K in keyof ComboboxInputs]: WritableSignal< + ComboboxInputs[K] extends SignalLike ? T : never + >; +}; + +type TreeItemData = {value: string; children?: TreeItemData[]}; + +// Keyboard event helpers +const up = () => createKeyboardEvent('keydown', 38, 'ArrowUp'); +const down = () => createKeyboardEvent('keydown', 40, 'ArrowDown'); +const home = () => createKeyboardEvent('keydown', 36, 'Home'); +const end = () => createKeyboardEvent('keydown', 35, 'End'); +const enter = () => createKeyboardEvent('keydown', 13, 'Enter'); +const escape = () => createKeyboardEvent('keydown', 27, 'Escape'); +const right = () => createKeyboardEvent('keydown', 39, 'ArrowRight'); +const left = () => createKeyboardEvent('keydown', 37, 'ArrowLeft'); + +function clickOption(options: OptionPattern[], index: number, mods?: ModifierKeys) { + return { + target: options[index].element(), + shiftKey: mods?.shift, + ctrlKey: mods?.control, + } as unknown as PointerEvent; +} + +function clickTreeItem(items: TreeItemPattern[], index: number, mods?: ModifierKeys) { + return { + target: items[index].element(), + shiftKey: mods?.shift, + ctrlKey: mods?.control, + } as unknown as PointerEvent; +} + +function clickInput(inputEl: HTMLInputElement) { + return {target: inputEl} as unknown as PointerEvent; +} + +function getComboboxPattern( + inputs: Partial<{ + [K in keyof TestInputs]: TestInputs[K] extends WritableSignal ? T : never; + }> = {}, +) { + const value = signal(inputs.value); + const containerEl = signal(document.createElement('div')); + const inputEl = signal(document.createElement('input')); + containerEl()?.appendChild(inputEl()!); + + const combobox = new ComboboxPattern({ + value, + popupControls: signal(undefined), // will be set later + inputEl, + containerEl, + filterMode: signal(inputs.filterMode ?? 'manual'), + filter: signal( + inputs.filter ?? + ((inputText, itemText) => itemText.toLowerCase().includes(inputText.toLowerCase())), + ), + }); + + return {combobox, inputEl, containerEl}; +} + +function getListboxPattern(combobox: ComboboxPattern, values: string[]) { + const options = signal([]); + + const listbox = new ComboboxListboxPattern({ + items: options, + value: signal(combobox.inputs.value() ? [combobox.inputs.value()!] : []), + combobox: signal(combobox) as any, + activeItem: signal(undefined), + typeaheadDelay: signal(0.5), + wrap: signal(true), + readonly: signal(false), + disabled: signal(false), + skipDisabled: signal(true), + multi: signal(false), + focusMode: signal('activedescendant'), + textDirection: signal('ltr'), + orientation: signal('vertical'), + selectionMode: signal('explicit'), + element: signal(document.createElement('div')), + }); + + options.set( + values.map((v, index) => { + const element = document.createElement('div'); + element.role = 'option'; + return new OptionPattern({ + value: signal(v), + id: signal(`option-${index}`), + disabled: signal(false), + searchTerm: signal(v), + listbox: signal(listbox), + element: signal(element), + }) as TestOption; + }), + ); + + return {listbox, options}; +} + +function getTreePattern( + combobox: ComboboxPattern, string>, + data: TreeItemData[], +) { + const items = signal[]>([]); + + const tree = new ComboboxTreePattern({ + allItems: items, + value: signal(combobox.inputs.value() ? [combobox.inputs.value()!] : []), + combobox: signal(combobox) as any, + activeItem: signal(undefined), + typeaheadDelay: signal(0.5), + wrap: signal(true), + disabled: signal(false), + skipDisabled: signal(true), + multi: signal(false), + focusMode: signal('activedescendant'), + textDirection: signal('ltr'), + orientation: signal('vertical'), + selectionMode: signal('explicit'), + element: signal(document.createElement('div')), + nav: signal(false), + currentType: signal('false'), + }); + + // Recursive function to create tree items + function createTreeItems( + data: TreeItemData[], + parent: TreeItemPattern | ComboboxTreePattern, + ) { + return data.map((node, index) => { + const element = document.createElement('div'); + element.role = 'treeitem'; + const treeItem = new TreeItemPattern({ + value: signal(node.value), + id: signal('tree-item-' + tree.allItems().length), + disabled: signal(false), + searchTerm: signal(node.value), + tree: signal(tree), + parent: signal(parent), + element: signal(element), + hasChildren: signal(!!node.children), + children: signal([]), + }); + + (tree.allItems as WritableSignal[]>).update(items => + items.concat(treeItem), + ); + + if (node.children) { + const children = createTreeItems(node.children, treeItem); + (treeItem.children as WritableSignal[]>).set(children); + } + + return treeItem; + }); + } + + createTreeItems(data, tree); + return {tree, items}; +} + +describe('Combobox with Listbox Pattern', () => { + function getPatterns( + inputs: Partial<{ + [K in keyof TestInputs]: TestInputs[K] extends WritableSignal ? T : never; + }> = {}, + ) { + const {combobox, inputEl, containerEl} = getComboboxPattern(inputs); + const {listbox, options} = getListboxPattern(combobox, [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + ]); + + (combobox.inputs.popupControls as WritableSignal).set(listbox); + + return { + combobox, + listbox, + options: options(), + inputEl: inputEl()!, + containerEl: containerEl()!, + }; + } + + describe('Navigation', () => { + it('should navigate to the first item on ArrowDown', () => { + const {combobox, listbox} = getPatterns(); + combobox.onKeydown(down()); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); + }); + + it('should navigate to the last item on ArrowUp', () => { + const {combobox, listbox} = getPatterns(); + combobox.onKeydown(up()); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[8]); + }); + + it('should navigate to the next item on ArrowDown when open', () => { + const {combobox, listbox} = getPatterns(); + combobox.onKeydown(down()); + combobox.onKeydown(down()); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[1]); + }); + + it('should navigate to the previous item on ArrowUp when open', () => { + const {combobox, listbox} = getPatterns(); + combobox.onKeydown(up()); + combobox.onKeydown(up()); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[7]); + }); + + it('should navigate to the first item on Home when open', () => { + const {combobox, listbox} = getPatterns(); + combobox.onKeydown(up()); + combobox.onKeydown(home()); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); + }); + + it('should navigate to the last item on End when open', () => { + const {combobox, listbox} = getPatterns(); + combobox.onKeydown(down()); + combobox.onKeydown(end()); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[8]); + }); + }); + + describe('Expansion', () => { + it('should open on click', () => { + const {combobox, inputEl} = getPatterns(); + expect(combobox.expanded()).toBe(false); + combobox.onPointerup(clickInput(inputEl)); + expect(combobox.expanded()).toBe(true); + }); + + it('should open on ArrowDown', () => { + const {combobox} = getPatterns(); + expect(combobox.expanded()).toBe(false); + combobox.onKeydown(down()); + expect(combobox.expanded()).toBe(true); + }); + + it('should open on ArrowUp', () => { + const {combobox} = getPatterns(); + expect(combobox.expanded()).toBe(false); + combobox.onKeydown(up()); + expect(combobox.expanded()).toBe(true); + }); + + it('should close on Escape', () => { + const {combobox} = getPatterns(); + combobox.onKeydown(down()); + expect(combobox.expanded()).toBe(true); + combobox.onKeydown(escape()); + expect(combobox.expanded()).toBe(false); + }); + + it('should close on Enter', () => { + const {combobox} = getPatterns(); + combobox.onKeydown(down()); + expect(combobox.expanded()).toBe(true); + combobox.onKeydown(enter()); + expect(combobox.expanded()).toBe(false); + }); + + it('should close on focusout', () => { + const {combobox} = getPatterns(); + combobox.onKeydown(down()); + expect(combobox.expanded()).toBe(true); + combobox.onFocusOut(new FocusEvent('focusout')); + expect(combobox.expanded()).toBe(false); + }); + + it('should not close on focusout if focus moves to an element inside the container', () => { + const {combobox, containerEl} = getPatterns(); + const internalElement = document.createElement('div'); + containerEl.appendChild(internalElement); + combobox.onKeydown(down()); + + expect(combobox.expanded()).toBe(true); + + const event = new FocusEvent('focusout', {relatedTarget: internalElement}); + combobox.onFocusOut(event); + + expect(combobox.expanded()).toBe(true); + }); + }); + + describe('Selection', () => { + describe('when filterMode is "manual"', () => { + it('should select and commit on click', () => { + const {combobox, listbox, inputEl} = getPatterns(); + combobox.onPointerup(clickOption(listbox.inputs.items(), 0)); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); + expect(combobox.inputs.value()).toBe('Apple'); + expect(inputEl.value).toBe('Apple'); + }); + + it('should select and commit to input on Enter', () => { + const {combobox, listbox, inputEl} = getPatterns({filterMode: 'manual'}); + combobox.onKeydown(down()); + combobox.onKeydown(enter()); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); + expect(combobox.inputs.value()).toBe('Apple'); + expect(inputEl.value).toBe('Apple'); + }); + + it('should select on focusout if the input text exactly matches an item', () => { + const {combobox, listbox, inputEl} = getPatterns({filterMode: 'manual'}); + inputEl.value = 'Apple'; + combobox.onInput(new InputEvent('input')); + combobox.onFocusOut(new FocusEvent('focusout')); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); + expect(combobox.inputs.value()).toBe('Apple'); + }); + + it('should deselect on backspace', () => { + const {combobox, listbox, inputEl} = getPatterns({filterMode: 'manual'}); + combobox.onKeydown(down()); + combobox.onKeydown(enter()); + + inputEl.value = 'Appl'; + combobox.onInput(new InputEvent('input', {inputType: 'deleteContentBackward'})); + + expect(listbox.getSelectedItem()).toBe(undefined); + expect(combobox.inputs.value()).toBe(undefined); + }); + + it('should not select on navigation', () => { + const {combobox, listbox} = getPatterns({filterMode: 'manual'}); + combobox.onKeydown(down()); + expect(listbox.getSelectedItem()).toBe(undefined); + expect(combobox.inputs.value()).toBe(undefined); + }); + + it('should not select on input', () => { + const {combobox, listbox, inputEl} = getPatterns({filterMode: 'manual'}); + inputEl.value = 'A'; + combobox.onInput(new InputEvent('input')); + expect(listbox.getSelectedItem()).toBe(undefined); + expect(combobox.inputs.value()).toBe(undefined); + }); + + it('should not select on focusout if the input text does not match an item', () => { + const {combobox, listbox, inputEl} = getPatterns({filterMode: 'manual'}); + inputEl.value = 'Appl'; + combobox.onInput(new InputEvent('input')); + combobox.onFocusOut(new FocusEvent('focusout')); + expect(listbox.getSelectedItem()).toBe(undefined); + expect(combobox.inputs.value()).toBe(undefined); + expect(inputEl.value).toBe('Appl'); + }); + }); + + describe('when filterMode is "auto-select"', () => { + it('should select and commit on click', () => { + const {combobox, listbox, inputEl} = getPatterns({filterMode: 'auto-select'}); + combobox.onPointerup(clickOption(listbox.inputs.items(), 3)); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[3]); + expect(combobox.inputs.value()).toBe('Blackberry'); + expect(inputEl.value).toBe('Blackberry'); + }); + + it('should select and commit on Enter', () => { + const {combobox, listbox, inputEl} = getPatterns({filterMode: 'auto-select'}); + combobox.onKeydown(down()); + combobox.onKeydown(down()); + combobox.onKeydown(down()); + combobox.onKeydown(enter()); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]); + expect(combobox.inputs.value()).toBe('Banana'); + expect(inputEl.value).toBe('Banana'); + }); + + it('should select the first item on arrow down when collapsed', () => { + const {combobox, listbox} = getPatterns({filterMode: 'auto-select'}); + combobox.onKeydown(down()); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); + expect(combobox.inputs.value()).toBe('Apple'); + }); + + it('should select the last item on arrow up when collapsed', () => { + const {combobox, listbox} = getPatterns({filterMode: 'auto-select'}); + combobox.onKeydown(up()); + expect(listbox.getSelectedItem()).toBe( + listbox.inputs.items()[listbox.inputs.items().length - 1], + ); + expect(combobox.inputs.value()).toBe('Cranberry'); + }); + + it('should select on navigation', () => { + const {combobox, listbox} = getPatterns({filterMode: 'auto-select'}); + combobox.onKeydown(down()); + combobox.onKeydown(down()); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[1]); + expect(combobox.inputs.value()).toBe('Apricot'); + }); + + it('should select the first option on input', () => { + const {combobox, listbox, inputEl} = getPatterns({filterMode: 'auto-select'}); + inputEl.value = 'A'; + combobox.onInput(new InputEvent('input')); + + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); + expect(combobox.inputs.value()).toBe('Apple'); + + inputEl.value = 'Apr'; + combobox.onInput(new InputEvent('input')); + + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[1]); + expect(combobox.inputs.value()).toBe('Apricot'); + }); + + it('should commit the selected option on focusout', () => { + const {combobox, inputEl} = getPatterns({filterMode: 'auto-select'}); + combobox.onKeydown(down()); + inputEl.value = 'App'; + combobox.onInput(new InputEvent('input')); + combobox.onFocusOut(new FocusEvent('focusout')); + expect(inputEl.value).toBe('Apple'); + }); + }); + + describe('when filterMode is "highlight"', () => { + it('should select and commit on click', () => { + const {combobox, listbox, inputEl} = getPatterns({filterMode: 'highlight'}); + combobox.onPointerup(clickOption(listbox.inputs.items(), 3)); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[3]); + expect(combobox.inputs.value()).toBe('Blackberry'); + expect(inputEl.value).toBe('Blackberry'); + }); + + it('should select and commit on Enter', () => { + const {combobox, listbox, inputEl} = getPatterns({filterMode: 'highlight'}); + combobox.onKeydown(down()); + combobox.onKeydown(down()); + combobox.onKeydown(down()); + combobox.onKeydown(enter()); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]); + expect(combobox.inputs.value()).toBe('Banana'); + expect(inputEl.value).toBe('Banana'); + }); + + it('should select the first item on arrow down when collapsed', () => { + const {combobox, listbox} = getPatterns({filterMode: 'highlight'}); + combobox.onKeydown(down()); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); + expect(combobox.inputs.value()).toBe('Apple'); + }); + + it('should select the last item on arrow up when collapsed', () => { + const {combobox, listbox} = getPatterns({filterMode: 'highlight'}); + combobox.onKeydown(up()); + expect(listbox.getSelectedItem()).toBe( + listbox.inputs.items()[listbox.inputs.items().length - 1], + ); + expect(combobox.inputs.value()).toBe('Cranberry'); + }); + + it('should select on navigation', () => { + const {combobox, listbox} = getPatterns({filterMode: 'highlight'}); + combobox.onKeydown(down()); + combobox.onKeydown(down()); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[1]); + expect(combobox.inputs.value()).toBe('Apricot'); + }); + + it('should select the first option on input', () => { + const {combobox, listbox, inputEl} = getPatterns({filterMode: 'highlight'}); + inputEl.value = 'A'; + combobox.onInput(new InputEvent('input')); + + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); + expect(combobox.inputs.value()).toBe('Apple'); + + inputEl.value = 'Apr'; + combobox.onInput(new InputEvent('input')); + + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[1]); + expect(combobox.inputs.value()).toBe('Apricot'); + }); + + it('should commit the selected option on navigation', () => { + const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); + combobox.onKeydown(down()); + expect(inputEl.value).toBe('Apple'); + combobox.onKeydown(down()); + expect(inputEl.value).toBe('Apricot'); + }); + + it('should commit the selected option on focusout', () => { + const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); + combobox.onKeydown(down()); + inputEl.value = 'App'; + combobox.onInput(new InputEvent('input')); + combobox.onFocusOut(new FocusEvent('focusout')); + expect(inputEl.value).toBe('Apple'); + }); + + it('should insert a highlighted completion string on input', () => { + const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); + inputEl.value = 'A'; + combobox.onInput(new InputEvent('input')); + expect(inputEl.value).toBe('Apple'); + expect(inputEl.selectionStart).toBe(1); + expect(inputEl.selectionEnd).toBe(5); + }); + + it('should should remember which option was highlighted after navigating', () => { + const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); + inputEl.value = 'A'; + combobox.onInput(new InputEvent('input')); + combobox.onKeydown(down()); + + expect(inputEl.value).toBe('Apricot'); + expect(inputEl.selectionStart).toBe(7); + expect(inputEl.selectionEnd).toBe(7); + + combobox.onKeydown(up()); + + expect(inputEl.value).toBe('Apple'); + expect(inputEl.selectionStart).toBe(1); + expect(inputEl.selectionEnd).toBe(5); + }); + }); + }); +}); + +describe('Combobox with Tree Pattern', () => { + function getPatterns( + inputs: Partial<{ + [K in keyof TestInputs]: TestInputs[K] extends WritableSignal ? T : never; + }> = {}, + ) { + const {combobox, inputEl, containerEl} = getComboboxPattern(inputs); + const {tree, items} = getTreePattern(combobox, [ + {value: 'Fruit', children: [{value: 'Apple'}, {value: 'Banana'}, {value: 'Cantaloupe'}]}, + {value: 'Vegetables', children: [{value: 'Broccoli'}, {value: 'Carrot'}, {value: 'Lettuce'}]}, + {value: 'Grains', children: [{value: 'Rice'}, {value: 'Wheat'}]}, + ]); + + (combobox.inputs.popupControls as WritableSignal).set(tree); + + return { + combobox, + tree, + items: items(), + inputEl: inputEl()!, + containerEl: containerEl()!, + }; + } + + describe('Navigation', () => { + it('should navigate to the first focusable item on ArrowDown', () => { + const {combobox, tree} = getPatterns(); + combobox.onKeydown(down()); + expect(tree.inputs.activeItem()?.searchTerm()).toBe('Fruit'); + }); + + it('should navigate to the last focusable item on ArrowUp', () => { + const {combobox, tree} = getPatterns(); + combobox.onKeydown(up()); + expect(tree.inputs.activeItem()?.searchTerm()).toBe('Grains'); + }); + + it('should navigate to the next focusable item on ArrowDown when open', () => { + const {combobox, tree} = getPatterns(); + combobox.onKeydown(down()); + combobox.onKeydown(down()); + expect(tree.inputs.activeItem()?.searchTerm()).toBe('Vegetables'); + }); + + it('should navigate to the previous item on ArrowUp when open', () => { + const {combobox, tree} = getPatterns(); + combobox.onKeydown(up()); + combobox.onKeydown(up()); + expect(tree.inputs.activeItem()?.searchTerm()).toBe('Vegetables'); + }); + + it('should expand a closed node on ArrowRight', () => { + const {combobox, tree} = getPatterns(); + const before = tree.visibleItems().map(i => i.searchTerm()); + expect(before).toEqual(['Fruit', 'Vegetables', 'Grains']); + combobox.onKeydown(down()); + combobox.onKeydown(right()); + const after = tree.visibleItems().map(i => i.searchTerm()); + expect(after).toEqual(['Fruit', 'Apple', 'Banana', 'Cantaloupe', 'Vegetables', 'Grains']); + }); + + it('should navigate to the next item on ArrowRight when already expanded', () => { + const {combobox, tree} = getPatterns(); + combobox.onKeydown(down()); + combobox.onKeydown(right()); + combobox.onKeydown(right()); + expect(tree.inputs.activeItem()?.searchTerm()).toBe('Apple'); + }); + + it('should collapse an open node on ArrowLeft', () => { + const {combobox, tree} = getPatterns(); + combobox.onKeydown(down()); + combobox.onKeydown(right()); + combobox.onKeydown(left()); + const after = tree.visibleItems().map(i => i.searchTerm()); + expect(after).toEqual(['Fruit', 'Vegetables', 'Grains']); + expect(tree.inputs.activeItem()?.searchTerm()).toBe('Fruit'); + }); + + it('should navigate to the parent node on ArrowLeft when in a child node', () => { + const {combobox, tree} = getPatterns(); + combobox.onKeydown(down()); + combobox.onKeydown(right()); + combobox.onKeydown(right()); + expect(tree.inputs.activeItem()?.searchTerm()).toBe('Apple'); + combobox.onKeydown(left()); + expect(tree.inputs.activeItem()?.searchTerm()).toBe('Fruit'); + }); + + it('should navigate to the first focusable item on Home when open', () => { + const {combobox, tree} = getPatterns(); + combobox.onKeydown(up()); + combobox.onKeydown(home()); + expect(tree.inputs.activeItem()?.searchTerm()).toBe('Fruit'); + }); + + it('should navigate to the last focusable item on End when open', () => { + const {combobox, tree} = getPatterns(); + combobox.onKeydown(down()); + combobox.onKeydown(end()); + expect(tree.inputs.activeItem()?.searchTerm()).toBe('Grains'); + }); + }); + + describe('Selection', () => { + describe('when filterMode is "manual"', () => { + it('should select and commit on click', () => { + const {combobox, tree, inputEl} = getPatterns(); + combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 0)); + expect(combobox.inputs.value()).toBe('Fruit'); + expect(inputEl.value).toBe('Fruit'); + }); + + it('should select and commit to input on Enter', () => { + const {combobox, tree, inputEl} = getPatterns({filterMode: 'manual'}); + combobox.onKeydown(down()); + combobox.onKeydown(enter()); + expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[0]); + expect(combobox.inputs.value()).toBe('Fruit'); + expect(inputEl.value).toBe('Fruit'); + }); + + it('should select on focusout if the input text exactly matches an item', () => { + const {combobox, tree, inputEl} = getPatterns({filterMode: 'manual'}); + combobox.onPointerup(clickInput(inputEl)); + inputEl.value = 'Apple'; + combobox.onInput(new InputEvent('input')); + combobox.onFocusOut(new FocusEvent('focusout')); + expect(combobox.inputs.value()).toBe('Apple'); + }); + + it('should deselect on backspace', () => { + const {combobox, tree, inputEl} = getPatterns({filterMode: 'manual'}); + combobox.onKeydown(down()); + combobox.onKeydown(enter()); + + inputEl.value = 'Appl'; + combobox.onInput(new InputEvent('input', {inputType: 'deleteContentBackward'})); + + expect(tree.getSelectedItem()).toBe(undefined); + expect(combobox.inputs.value()).toBe(undefined); + }); + + it('should not select on navigation', () => { + const {combobox, tree} = getPatterns({filterMode: 'manual'}); + combobox.onKeydown(down()); + expect(tree.getSelectedItem()).toBe(undefined); + expect(combobox.inputs.value()).toBe(undefined); + }); + + it('should not select on input', () => { + const {combobox, tree, inputEl} = getPatterns({filterMode: 'manual'}); + inputEl.value = 'A'; + combobox.onInput(new InputEvent('input')); + expect(tree.getSelectedItem()).toBe(undefined); + expect(combobox.inputs.value()).toBe(undefined); + }); + + it('should not select on focusout if the input text does not match an item', () => { + const {combobox, tree, inputEl} = getPatterns({filterMode: 'manual'}); + inputEl.value = 'Appl'; + combobox.onInput(new InputEvent('input')); + combobox.onFocusOut(new FocusEvent('focusout')); + expect(tree.getSelectedItem()).toBe(undefined); + expect(combobox.inputs.value()).toBe(undefined); + expect(inputEl.value).toBe('Appl'); + }); + }); + + describe('when filterMode is "auto-select"', () => { + it('should select and commit on click', () => { + const {combobox, tree, inputEl} = getPatterns({filterMode: 'auto-select'}); + combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 2)); + expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[2]); + expect(combobox.inputs.value()).toBe('Banana'); + expect(inputEl.value).toBe('Banana'); + }); + + it('should select and commit on Enter', () => { + const {combobox, inputEl} = getPatterns({filterMode: 'auto-select'}); + combobox.onKeydown(down()); + combobox.onKeydown(down()); + combobox.onKeydown(down()); + combobox.onKeydown(enter()); + expect(combobox.inputs.value()).toBe('Grains'); + expect(inputEl.value).toBe('Grains'); + }); + + it('should select the first item on arrow down when collapsed', () => { + const {combobox, tree} = getPatterns({filterMode: 'auto-select'}); + combobox.onKeydown(down()); + expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[0]); + expect(combobox.inputs.value()).toBe('Fruit'); + }); + + it('should select the last focusable item on arrow up when collapsed', () => { + const {combobox} = getPatterns({filterMode: 'auto-select'}); + combobox.onKeydown(up()); + expect(combobox.inputs.value()).toBe('Grains'); + }); + + it('should select on navigation', () => { + const {combobox} = getPatterns({filterMode: 'auto-select'}); + combobox.onKeydown(down()); + combobox.onKeydown(right()); + combobox.onKeydown(right()); + expect(combobox.inputs.value()).toBe('Apple'); + }); + + it('should select the first option on input', () => { + const {combobox, inputEl} = getPatterns({filterMode: 'auto-select'}); + inputEl.value = 'B'; + combobox.onInput(new InputEvent('input')); + + expect(combobox.inputs.value()).toBe('Banana'); + + inputEl.value = 'Bro'; + combobox.onInput(new InputEvent('input')); + + expect(combobox.inputs.value()).toBe('Broccoli'); + }); + + it('should commit the selected option on focusout', () => { + const {combobox, inputEl} = getPatterns({filterMode: 'auto-select'}); + combobox.onKeydown(down()); + inputEl.value = 'App'; + combobox.onInput(new InputEvent('input')); + combobox.onFocusOut(new FocusEvent('focusout')); + expect(inputEl.value).toBe('Apple'); + }); + }); + + describe('when filterMode is "highlight"', () => { + it('should select and commit on click', () => { + const {combobox, tree, inputEl} = getPatterns({filterMode: 'highlight'}); + combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 2)); + expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[2]); + expect(combobox.inputs.value()).toBe('Banana'); + expect(inputEl.value).toBe('Banana'); + }); + + it('should select and commit on Enter', () => { + const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); + combobox.onKeydown(down()); + combobox.onKeydown(down()); + combobox.onKeydown(down()); + combobox.onKeydown(enter()); + expect(combobox.inputs.value()).toBe('Grains'); + expect(inputEl.value).toBe('Grains'); + }); + + it('should select the first item on arrow down when collapsed', () => { + const {combobox, tree} = getPatterns({filterMode: 'highlight'}); + combobox.onKeydown(down()); + expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[0]); + expect(combobox.inputs.value()).toBe('Fruit'); + }); + + it('should select the last focusable item on arrow up when collapsed', () => { + const {combobox} = getPatterns({filterMode: 'highlight'}); + combobox.onKeydown(up()); + expect(combobox.inputs.value()).toBe('Grains'); + }); + + it('should select on navigation', () => { + const {combobox} = getPatterns({filterMode: 'highlight'}); + combobox.onKeydown(down()); + combobox.onKeydown(right()); + combobox.onKeydown(right()); + expect(combobox.inputs.value()).toBe('Apple'); + }); + + it('should select the first option on input', () => { + const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); + inputEl.value = 'B'; + combobox.onInput(new InputEvent('input')); + + expect(combobox.inputs.value()).toBe('Banana'); + + inputEl.value = 'Bro'; + combobox.onInput(new InputEvent('input')); + + expect(combobox.inputs.value()).toBe('Broccoli'); + }); + + it('should commit the selected option on navigation', () => { + const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); + combobox.onKeydown(down()); + expect(inputEl.value).toBe('Fruit'); + combobox.onKeydown(right()); + combobox.onKeydown(right()); + expect(inputEl.value).toBe('Apple'); + combobox.onKeydown(down()); + expect(combobox.inputs.value()).toBe('Banana'); + }); + + it('should commit the selected option on focusout', () => { + const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); + combobox.onKeydown(down()); + inputEl.value = 'App'; + combobox.onInput(new InputEvent('input')); + combobox.onFocusOut(new FocusEvent('focusout')); + expect(inputEl.value).toBe('Apple'); + }); + + it('should insert a highlighted completion string on input', () => { + const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); + inputEl.value = 'A'; + combobox.onInput(new InputEvent('input')); + expect(inputEl.value).toBe('Apple'); + expect(inputEl.selectionStart).toBe(1); + expect(inputEl.selectionEnd).toBe(5); + }); + + it('should should remember which option was highlighted after navigating', () => { + const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); + inputEl.value = 'B'; + combobox.onInput(new InputEvent('input')); + combobox.onKeydown(down()); + + expect(inputEl.value).toBe('Vegetables'); + expect(inputEl.selectionStart).toBe(10); + expect(inputEl.selectionEnd).toBe(10); + + combobox.onKeydown(up()); + + expect(inputEl.value).toBe('Banana'); + expect(inputEl.selectionStart).toBe(1); + expect(inputEl.selectionEnd).toBe(6); + }); + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/combobox/combobox.ts b/src/cdk-experimental/ui-patterns/combobox/combobox.ts index aabf76beabcc..2af9dca71c61 100644 --- a/src/cdk-experimental/ui-patterns/combobox/combobox.ts +++ b/src/cdk-experimental/ui-patterns/combobox/combobox.ts @@ -40,14 +40,17 @@ export type ComboboxListboxControls, V> = { /** The ID of the active item in the popup. */ activeId: SignalLike; + /** The list of items in the popup. */ + items: SignalLike; + /** Navigates to the next item in the popup. */ next: () => void; /** Navigates to the previous item in the popup. */ prev: () => void; - /** Navigates to the first item in the popup. */ - first: () => void; + /** Navigates to the first item in the popup, or the first match if filterText is provided. */ + first: (filterText?: string) => void; /** Navigates to the last item in the popup. */ last: () => void; @@ -75,6 +78,9 @@ export type ComboboxListboxControls, V> = { }; export type ComboboxTreeControls, V> = ComboboxListboxControls & { + /** Whether the currently active item in the popup is collapsible. */ + isItemCollapsible: () => boolean; + /** Expands the currently active item in the popup. */ expandItem: () => void; @@ -146,10 +152,12 @@ export class ComboboxPattern, V> { if (popupControls.role() === 'tree') { const treeControls = popupControls as ComboboxTreeControls; + if (treeControls.isItemExpandable() || treeControls.isItemCollapsible()) { + manager.on(this.collapseKey(), () => this.collapseItem()); + } + if (treeControls.isItemExpandable()) { - manager - .on(this.expandKey(), () => treeControls.expandItem()) - .on(this.collapseKey(), () => treeControls.collapseItem()); + manager.on(this.expandKey(), () => this.expandItem()); } } @@ -196,18 +204,21 @@ export class ComboboxPattern, V> { this.inputs.popupControls()?.filter(inputEl.value); this.open(); - this.inputs.popupControls()?.first(); - - if ( - event instanceof InputEvent && - this.inputs.filterMode() !== 'manual' && - event.inputType.match(/delete.*/) - ) { - this.inputs.popupControls()?.select(); - return; + this.inputs.popupControls()?.first(this.searchString() || undefined); + + if (event instanceof InputEvent && event.inputType.match(/delete.*/)) { + if (this.inputs.filterMode() === 'manual') { + this.inputs.value.set(undefined); + this.inputs.popupControls()?.clearSelection(); + } else { + this.inputs.popupControls()?.select(); + return; + } } - this.select({highlight: this.inputs.filterMode() === 'highlight'}); + if (this.inputs.filterMode() !== 'manual') { + this.select({highlight: this.inputs.filterMode() === 'highlight'}); + } } onFocusIn() { @@ -224,6 +235,15 @@ export class ComboboxPattern, V> { ) { if (this.inputs.filterMode() !== 'manual') { this.commit(); + } else { + const item = this.inputs + .popupControls() + ?.items() + .find(i => i.searchTerm() === this.inputs.inputEl()?.value); + + if (item) { + this.select({item}); + } } this.close(); @@ -247,7 +267,6 @@ export class ComboboxPattern, V> { close() { this.expanded.set(false); this.inputs.popupControls()?.unfocus(); - this.inputs.popupControls()?.clearSelection(); } /** Opens the combobox. */ @@ -303,6 +322,16 @@ export class ComboboxPattern, V> { this._navigate(() => this.inputs.popupControls()?.last()); } + collapseItem() { + const controls = this.inputs.popupControls() as ComboboxTreeControls; + this._navigate(() => controls?.collapseItem()); + } + + expandItem() { + const controls = this.inputs.popupControls() as ComboboxTreeControls; + this._navigate(() => controls?.expandItem()); + } + /** Selects an item in the combobox popup. */ select(opts: {item?: T; commit?: boolean; close?: boolean; highlight?: boolean} = {}) { this.inputs.popupControls()?.select(opts.item); @@ -317,10 +346,6 @@ export class ComboboxPattern, V> { if (opts.highlight) { this.highlight(); } - if (this.inputs.filterMode() === 'manual') { - this.inputs.popupControls()?.clearSelection(); - this.inputs.value.set(undefined); - } } /** Updates the value of the input based on the currently selected item. */ diff --git a/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts b/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts index bcaac1c0e808..18ac5b7d7b7e 100644 --- a/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts @@ -26,6 +26,9 @@ export class ComboboxListboxPattern /** The id of the active (focused) item in the listbox. */ activeId = computed(() => this.listBehavior.activedescendant()); + /** The list of options in the listbox. */ + items: SignalLike[]> = computed(() => this.inputs.items()); + /** The tabindex for the listbox. Always -1 because the combobox handles focus. */ override tabindex: SignalLike<-1 | 0> = () => -1; diff --git a/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts b/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts index b59a4c5830bb..3b06f5724032 100644 --- a/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts +++ b/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts @@ -20,12 +20,18 @@ export class ComboboxTreePattern extends TreePattern implements ComboboxTreeControls, V> { + /** Whether the currently focused item is collapsible. */ + isItemCollapsible = () => this.activeItem()?.parent() instanceof TreeItemPattern; + /** The ARIA role for the tree. */ role = () => 'tree' as const; /* The id of the active (focused) item in the tree. */ activeId = computed(() => this.listBehavior.activedescendant()); + /** The list of items in the tree. */ + items = computed(() => this.inputs.allItems()); + /** The tabindex for the tree. Always -1 because the combobox handles focus. */ override tabindex: SignalLike<-1 | 0> = () => -1; @@ -46,9 +52,7 @@ export class ComboboxTreePattern override onPointerdown(_: PointerEvent): void {} /** Noop. The combobox controls the open state. */ - override setDefaultState(): void { - console.log('setDefaultState'); - } + override setDefaultState(): void {} /** Navigates to the next focusable item in the tree. */ next = () => this.listBehavior.next(); @@ -60,7 +64,18 @@ export class ComboboxTreePattern last = () => this.listBehavior.last(); /** Navigates to the first focusable item in the tree. */ - first = () => this.listBehavior.first(); + first = (filterText?: string) => { + if (!filterText) { + this.listBehavior.first(); + } else { + const filterFn = this.inputs.combobox()!.inputs.filter(); + const match = this.inputs.allItems().find(i => filterFn(filterText, i.searchTerm())); + + if (match) { + this.listBehavior.goto(match); + } + } + }; /** Unfocuses the currently focused item in the tree. */ unfocus = () => this.listBehavior.unfocus(); @@ -94,6 +109,14 @@ export class ComboboxTreePattern /** Filters the items in the tree based on the provided text. */ filter = (text: string) => { + if (!text) { + this.inputs.allItems().forEach(i => { + i.inert.set(null); + i.expansion.close(); + }); + return; + } + const filterFn = this.inputs.combobox()!.inputs.filter(); this.inputs.allItems().forEach(i => i.expansion.close()); @@ -104,6 +127,7 @@ export class ComboboxTreePattern let parent = i.parent(); while (parent && parent instanceof TreeItemPattern) { parent.inert.set(null); + parent.expansion.open(); parent = parent.parent(); } } From 86be5e240e0f6896410315219fd3abaf938b0d3f Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Mon, 22 Sep 2025 14:18:40 -0400 Subject: [PATCH 13/23] test(cdk-experimental/combobox): add unit tests --- src/cdk-experimental/combobox/BUILD.bazel | 25 +- .../combobox/combobox.spec.ts | 1315 +++++++++++++++++ src/cdk-experimental/combobox/combobox.ts | 22 + src/cdk-experimental/listbox/listbox.ts | 23 +- src/cdk-experimental/tree/tree.ts | 31 +- .../ui-patterns/combobox/combobox.spec.ts | 2 + .../ui-patterns/combobox/combobox.ts | 15 +- .../ui-patterns/listbox/combobox-listbox.ts | 6 +- .../ui-patterns/listbox/listbox.ts | 4 + .../ui-patterns/tree/combobox-tree.ts | 2 + src/cdk-experimental/ui-patterns/tree/tree.ts | 3 + 11 files changed, 1442 insertions(+), 6 deletions(-) create mode 100644 src/cdk-experimental/combobox/combobox.spec.ts diff --git a/src/cdk-experimental/combobox/BUILD.bazel b/src/cdk-experimental/combobox/BUILD.bazel index 103d53f9215e..826725165080 100644 --- a/src/cdk-experimental/combobox/BUILD.bazel +++ b/src/cdk-experimental/combobox/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "ng_project") +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") package(default_visibility = ["//visibility:public"]) @@ -14,3 +14,26 @@ ng_project( "//src/cdk-experimental/ui-patterns", ], ) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = glob( + ["**/*.spec.ts"], + exclude = ["**/*.e2e.spec.ts"], + ), + deps = [ + ":combobox", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/axe-core", + "//src/cdk-experimental/listbox", + "//src/cdk-experimental/tree", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/combobox/combobox.spec.ts b/src/cdk-experimental/combobox/combobox.spec.ts new file mode 100644 index 000000000000..5b361142fdf4 --- /dev/null +++ b/src/cdk-experimental/combobox/combobox.spec.ts @@ -0,0 +1,1315 @@ +import {Component, DebugElement, signal} from '@angular/core'; +import {ComponentFixture, TestBed, fakeAsync, tick} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import { + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContainer, +} from '@angular/cdk-experimental/combobox'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import {runAccessibilityChecks} from '@angular/cdk/testing/private'; +import { + CdkTree, + CdkTreeItem, + CdkTreeItemGroup, + CdkTreeItemGroupContent, +} from '@angular/cdk-experimental/tree'; +import {NgTemplateOutlet} from '@angular/common'; + +describe('Combobox', () => { + describe('with Listbox', () => { + let fixture: ComponentFixture; + let inputElement: HTMLInputElement; + let comboboxInstance: CdkCombobox; + + const keydown = (key: string, modifierKeys: {} = {}) => { + focus(); + inputElement.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + ...modifierKeys, + }), + ); + fixture.detectChanges(); + }; + + const input = (value: string) => { + focus(); + inputElement.value = value; + inputElement.dispatchEvent(new Event('input', {bubbles: true})); + fixture.detectChanges(); + }; + + const click = (element: HTMLElement, eventInit?: PointerEventInit) => { + focus(); + element.dispatchEvent(new PointerEvent('pointerup', {bubbles: true, ...eventInit})); + fixture.detectChanges(); + }; + + const focus = () => { + inputElement.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + fixture.detectChanges(); + }; + + const blur = (relatedTarget?: EventTarget) => { + inputElement.dispatchEvent(new FocusEvent('focusout', {bubbles: true, relatedTarget})); + fixture.detectChanges(); + }; + + const up = (modifierKeys?: {}) => keydown('ArrowUp', modifierKeys); + const down = (modifierKeys?: {}) => keydown('ArrowDown', modifierKeys); + const enter = (modifierKeys?: {}) => keydown('Enter', modifierKeys); + const escape = (modifierKeys?: {}) => keydown('Escape', modifierKeys); + + function setupCombobox(opts: {filterMode?: 'manual' | 'auto-select' | 'highlight'} = {}) { + TestBed.configureTestingModule({}); + fixture = TestBed.createComponent(ComboboxListboxExample); + const testComponent = fixture.componentInstance; + + if (opts.filterMode) { + testComponent.filterMode.set(opts.filterMode); + } + + fixture.detectChanges(); + defineTestVariables(); + } + + function defineTestVariables() { + const comboboxDebugElement = fixture.debugElement.query(By.directive(CdkCombobox)); + comboboxInstance = comboboxDebugElement.injector.get(CdkCombobox); + const inputDebugElement = fixture.debugElement.query(By.directive(CdkComboboxInput)); + inputElement = inputDebugElement.nativeElement as HTMLInputElement; + } + + function getOption(text: string): HTMLElement | null { + const options = fixture.debugElement + .queryAll(By.directive(CdkOption)) + .map((debugEl: DebugElement) => debugEl.nativeElement as HTMLElement); + return options.find(option => option.textContent?.trim() === text) || null; + } + + function getOptions(): HTMLElement[] { + return fixture.debugElement + .queryAll(By.directive(CdkOption)) + .map((debugEl: DebugElement) => debugEl.nativeElement as HTMLElement); + } + + afterEach(async () => await runAccessibilityChecks(fixture.nativeElement)); + + describe('ARIA attributes and roles', () => { + beforeEach(() => setupCombobox()); + + it('should have the combobox role on the input', () => { + expect(inputElement.getAttribute('role')).toBe('combobox'); + }); + + it('should have aria-haspopup set to listbox', () => { + focus(); + expect(inputElement.getAttribute('aria-haspopup')).toBe('listbox'); + }); + + it('should set aria-controls to the listbox id', () => { + focus(); + const listbox = fixture.debugElement.query(By.directive(CdkListbox)).nativeElement; + expect(inputElement.getAttribute('aria-controls')).toBe(listbox.id); + }); + + it('should set aria-autocomplete to list for manual mode', () => { + expect(inputElement.getAttribute('aria-autocomplete')).toBe('list'); + }); + + it('should set aria-autocomplete to list for auto-select mode', () => { + fixture.componentInstance.filterMode.set('auto-select'); + fixture.detectChanges(); + expect(inputElement.getAttribute('aria-autocomplete')).toBe('list'); + }); + + it('should set aria-autocomplete to both for highlight mode', () => { + fixture.componentInstance.filterMode.set('highlight'); + fixture.detectChanges(); + expect(inputElement.getAttribute('aria-autocomplete')).toBe('both'); + }); + + it('should set aria-multiselectable to false on the listbox', () => { + focus(); + const listbox = fixture.debugElement.query(By.directive(CdkListbox)).nativeElement; + expect(listbox.getAttribute('aria-multiselectable')).toBe('false'); + }); + + it('should set aria-selected on the selected option', () => { + down(); + enter(); + + const appleOption = getOption('Apple')!; + const apricotOption = getOption('Apricot')!; + + expect(appleOption.getAttribute('aria-selected')).toBe('true'); + expect(apricotOption.getAttribute('aria-selected')).toBe('false'); + }); + + it('should set aria-expanded to false by default', () => { + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should toggle aria-expanded when opening and closing', () => { + down(); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should not have aria-activedescendant by default', () => { + expect(inputElement.hasAttribute('aria-activedescendant')).toBe(false); + }); + + it('should set aria-activedescendant to the active option id', () => { + down(); + const option = getOption('Apple')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(option.id); + }); + }); + + describe('Expansion', () => { + beforeEach(() => setupCombobox()); + + it('should open on click', () => { + click(inputElement); + expect(comboboxInstance.pattern.expanded()).toBe(true); + }); + + it('should open on ArrowDown', () => { + keydown('ArrowDown'); + expect(comboboxInstance.pattern.expanded()).toBe(true); + }); + + it('should open on ArrowUp', () => { + keydown('ArrowUp'); + expect(comboboxInstance.pattern.expanded()).toBe(true); + }); + + it('should close on Escape', () => { + down(); + escape(); + expect(comboboxInstance.pattern.expanded()).toBe(false); + }); + + it('should close on focusout', () => { + focus(); + blur(); + expect(comboboxInstance.pattern.expanded()).toBe(false); + }); + + it('should not close on focusout if focus moves to an element inside the container', () => { + down(); + blur(getOptions()[0]!); + expect(comboboxInstance.pattern.expanded()).toBe(true); + }); + }); + + describe('Navigation', () => { + beforeEach(() => setupCombobox()); + + it('should navigate to the first item on ArrowDown', () => { + down(); + const options = getOptions(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[0].id); + }); + + it('should navigate to the last item on ArrowUp', () => { + up(); + const options = getOptions(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe( + options[options.length - 1].id, + ); + }); + + it('should navigate to the next item on ArrowDown when open', () => { + down(); + down(); + const options = getOptions(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[1].id); + }); + + it('should navigate to the previous item on ArrowUp when open', () => { + down(); + down(); + up(); + const options = getOptions(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[0].id); + }); + + it('should navigate to the first item on Home when open', () => { + down(); + down(); + keydown('Home'); + const options = getOptions(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[0].id); + }); + + it('should navigate to the last item on End when open', () => { + down(); + keydown('End'); + const options = getOptions(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe( + options[options.length - 1].id, + ); + }); + }); + + describe('Selection', () => { + describe('when filterMode is "manual"', () => { + beforeEach(() => setupCombobox({filterMode: 'manual'})); + + it('should select and commit on click', () => { + click(inputElement); + const options = getOptions(); + click(options[0]); + fixture.detectChanges(); + + expect(comboboxInstance.value()).toBe('Apple'); + expect(inputElement.value).toBe('Apple'); + }); + + it('should select and commit to input on Enter', () => { + down(); + enter(); + + expect(comboboxInstance.value()).toBe('Apple'); + expect(inputElement.value).toBe('Apple'); + }); + + it('should not select on navigation', () => { + down(); + down(); + + expect(comboboxInstance.value()).toBe(undefined); + }); + + it('should select on focusout if the input text exactly matches an item', () => { + focus(); + input('Apple'); + blur(); + + expect(comboboxInstance.value()).toBe('Apple'); + }); + + it('should not select on focusout if the input text does not match an item', () => { + focus(); + input('Appl'); + blur(); + + expect(comboboxInstance.value()).toBe(undefined); + expect(inputElement.value).toBe('Appl'); + }); + }); + + describe('when filterMode is "auto-select"', () => { + beforeEach(() => setupCombobox({filterMode: 'auto-select'})); + + it('should select and commit on click', () => { + click(inputElement); + const options = getOptions(); + click(options[1]); + fixture.detectChanges(); + + expect(comboboxInstance.value()).toBe('Apricot'); + expect(inputElement.value).toBe('Apricot'); + }); + + it('should select and commit on Enter', () => { + down(); + down(); + enter(); + + expect(comboboxInstance.value()).toBe('Apricot'); + expect(inputElement.value).toBe('Apricot'); + }); + + it('should select on navigation', () => { + down(); + expect(comboboxInstance.value()).toBe('Apple'); + + down(); + expect(comboboxInstance.value()).toBe('Apricot'); + }); + + it('should select the first option on input', () => { + focus(); + input('B'); + + expect(comboboxInstance.value()).toBe('Banana'); + }); + + it('should commit the selected option on focusout', () => { + focus(); + input('Apr'); + blur(); + + expect(inputElement.value).toBe('Apricot'); + expect(comboboxInstance.value()).toBe('Apricot'); + }); + }); + + describe('when filterMode is "highlight"', () => { + beforeEach(() => setupCombobox({filterMode: 'highlight'})); + + it('should select and commit on click', () => { + click(inputElement); + const options = getOptions(); + click(options[2]); + fixture.detectChanges(); + + expect(comboboxInstance.value()).toBe('Banana'); + expect(inputElement.value).toBe('Banana'); + }); + + it('should select and commit on Enter', () => { + down(); + down(); + down(); + enter(); + + expect(comboboxInstance.value()).toBe('Banana'); + expect(inputElement.value).toBe('Banana'); + }); + + it('should select on navigation', () => { + down(); + expect(comboboxInstance.value()).toBe('Apple'); + + down(); + expect(comboboxInstance.value()).toBe('Apricot'); + }); + + it('should update input value on navigation', () => { + down(); + expect(inputElement.value).toBe('Apple'); + + down(); + expect(inputElement.value).toBe('Apricot'); + }); + + it('should select the first option on input', () => { + focus(); + input('Canta'); + + expect(comboboxInstance.value()).toBe('Cantaloupe'); + }); + + it('should insert a highlighted completion string on input', fakeAsync(() => { + focus(); + input('A'); + tick(); + + expect(inputElement.value).toBe('Apple'); + expect(inputElement.selectionStart).toBe(1); + expect(inputElement.selectionEnd).toBe(5); + })); + + it('should commit the selected option on focusout', () => { + focus(); + input('Apr'); + blur(); + + expect(inputElement.value).toBe('Apricot'); + expect(comboboxInstance.value()).toBe('Apricot'); + }); + }); + }); + + describe('with disabled options', () => { + beforeEach(() => { + setupCombobox(); + fixture.componentInstance.options.set([ + {name: 'Apple'}, + {name: 'Apricot', disabled: true}, + {name: 'Banana'}, + {name: 'Blackberry', disabled: true}, + {name: 'Blueberry'}, + ]); + fixture.detectChanges(); + }); + + it('should not select a disabled option by clicking', () => { + click(inputElement); + const disabledOption = getOption('Apricot')!; + click(disabledOption); + + expect(comboboxInstance.value()).toBeUndefined(); + }); + + it('should skip disabled options during keyboard navigation', () => { + down(); // To Apple + expect(inputElement.getAttribute('aria-activedescendant')).toBe(getOption('Apple')!.id); + + down(); // Should skip Apricot and go to Banana + expect(inputElement.getAttribute('aria-activedescendant')).toBe(getOption('Banana')!.id); + + down(); // Should skip Blackberry and go to Blueberry + expect(inputElement.getAttribute('aria-activedescendant')).toBe(getOption('Blueberry')!.id); + + up(); // Back to Banana + expect(inputElement.getAttribute('aria-activedescendant')).toBe(getOption('Banana')!.id); + + up(); // Back to Apple + expect(inputElement.getAttribute('aria-activedescendant')).toBe(getOption('Apple')!.id); + }); + + it('should not select disabled option with auto-select on input', () => { + fixture.componentInstance.filterMode.set('auto-select'); + fixture.detectChanges(); + + input('Apr'); + + expect(comboboxInstance.value()).toBeUndefined(); + }); + + it('should not select disabled option with highlight on input', () => { + fixture.componentInstance.filterMode.set('highlight'); + fixture.detectChanges(); + + input('Apr'); + + expect(comboboxInstance.value()).toBeUndefined(); + expect(inputElement.value).toBe('Apr'); + }); + }); + + describe('with dynamic data', () => { + beforeEach(() => setupCombobox()); + + it('should update active item if an option is removed', () => { + down(); // -> Apple + down(); // -> Apricot + + const apricot = getOption('Apricot')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(apricot.id); + + fixture.componentInstance.options.set( + fixture.componentInstance.options().filter(n => n.name !== 'Apricot'), + ); + fixture.detectChanges(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(null); + }); + + it('should update the combobox value if the selected item is removed', () => { + down(); // -> Apple + enter(); + expect(comboboxInstance.value()).toBe('Apple'); + expect(inputElement.value).toBe('Apple'); + + fixture.componentInstance.options.set( + fixture.componentInstance.options().filter(n => n.name !== 'Apple'), + ); + fixture.detectChanges(); + + expect(comboboxInstance.value()).toBeUndefined(); + }); + + it('should clear active item if listbox becomes empty', () => { + down(); // -> Apple + expect(inputElement.hasAttribute('aria-activedescendant')).toBe(true); + + fixture.componentInstance.options.set([]); + fixture.detectChanges(); + + expect(inputElement.hasAttribute('aria-activedescendant')).toBe(false); + expect(getOptions().length).toBe(0); + }); + }); + + describe('Filtering', () => { + const getVisibleOptions = () => getOptions().filter(o => !o.inert); + + beforeEach(() => setupCombobox()); + + it('should lazily render options', () => { + expect(getOptions().length).toBe(0); + focus(); + expect(getOptions().length).toBe(9); + }); + + it('should filter the options based on the input value', () => { + focus(); + input('ap'); + + let options = getVisibleOptions(); + expect(options.length).toBe(2); + expect(options[0].textContent?.trim()).toBe('Apple'); + expect(options[1].textContent?.trim()).toBe('Apricot'); + + input('apple'); + options = getVisibleOptions(); + expect(options.length).toBe(1); + expect(options[0].textContent?.trim()).toBe('Apple'); + }); + + it('should show no options if nothing matches', () => { + focus(); + input('xyz'); + const options = getVisibleOptions(); + expect(options.length).toBe(0); + }); + + it('should show all options when the input is cleared', () => { + focus(); + input('Apple'); + expect(getVisibleOptions().length).toBe(1); + + input(''); + expect(getVisibleOptions().length).toBe(9); + }); + + it('should allow changing the filter function', () => { + fixture.componentInstance.filterFn.set( + (inputText, itemText) => itemText.includes(inputText), // Case sensitive filter. + ); + + focus(); + input('apple'); + expect(getVisibleOptions().length).toBe(0); + + input('Apple'); + const options = getVisibleOptions(); + expect(options.length).toBe(1); + expect(options[0].textContent?.trim()).toBe('Apple'); + }); + }); + + // TODO(wagnermaciel): Enable these tests once we have a way to set the value + // describe('with programmatic value changes', () => {}); + }); + + describe('with Tree', () => { + let fixture: ComponentFixture; + let inputElement: HTMLInputElement; + let comboboxInstance: CdkCombobox; + + const keydown = (key: string, modifierKeys: {} = {}) => { + focus(); + inputElement.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + ...modifierKeys, + }), + ); + fixture.detectChanges(); + }; + + const input = (value: string) => { + focus(); + inputElement.value = value; + inputElement.dispatchEvent(new Event('input', {bubbles: true})); + fixture.detectChanges(); + }; + + const click = (element: HTMLElement, eventInit?: PointerEventInit) => { + focus(); + element.dispatchEvent(new PointerEvent('pointerup', {bubbles: true, ...eventInit})); + fixture.detectChanges(); + }; + + const focus = () => { + inputElement.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + fixture.detectChanges(); + }; + + const blur = (relatedTarget?: EventTarget) => { + inputElement.dispatchEvent(new FocusEvent('focusout', {bubbles: true, relatedTarget})); + fixture.detectChanges(); + }; + + const up = (modifierKeys?: {}) => keydown('ArrowUp', modifierKeys); + const down = (modifierKeys?: {}) => keydown('ArrowDown', modifierKeys); + const left = (modifierKeys?: {}) => keydown('ArrowLeft', modifierKeys); + const right = (modifierKeys?: {}) => keydown('ArrowRight', modifierKeys); + const enter = (modifierKeys?: {}) => keydown('Enter', modifierKeys); + const escape = (modifierKeys?: {}) => keydown('Escape', modifierKeys); + + function setupCombobox(opts: {filterMode?: 'manual' | 'auto-select' | 'highlight'} = {}) { + TestBed.configureTestingModule({}); + fixture = TestBed.createComponent(ComboboxTreeExample); + const testComponent = fixture.componentInstance; + + if (opts.filterMode) { + testComponent.filterMode.set(opts.filterMode); + } + + fixture.detectChanges(); + defineTestVariables(); + } + + function defineTestVariables() { + const comboboxDebugElement = fixture.debugElement.query(By.directive(CdkCombobox)); + comboboxInstance = comboboxDebugElement.injector.get(CdkCombobox); + const inputDebugElement = fixture.debugElement.query(By.directive(CdkComboboxInput)); + inputElement = inputDebugElement.nativeElement as HTMLInputElement; + } + + function getTreeItem(text: string): HTMLElement | null { + const items = fixture.debugElement + .queryAll(By.directive(CdkTreeItem)) + .map((debugEl: DebugElement) => debugEl.nativeElement as HTMLElement); + return items.find(item => item.textContent?.trim() === text) || null; + } + + function getTreeItems(): HTMLElement[] { + return fixture.debugElement + .queryAll(By.directive(CdkTreeItem)) + .map((debugEl: DebugElement) => debugEl.nativeElement as HTMLElement); + } + + function getVisibleTreeItems(): HTMLElement[] { + return fixture.debugElement + .queryAll(By.directive(CdkTreeItem)) + .map((debugEl: DebugElement) => debugEl.nativeElement as HTMLElement) + .filter(el => !el.parentElement?.hasAttribute('inert') && !el.hasAttribute('inert')); + } + + afterEach(async () => await runAccessibilityChecks(fixture.nativeElement)); + + describe('ARIA attributes and roles', () => { + beforeEach(() => setupCombobox()); + + it('should have aria-haspopup set to tree', () => { + focus(); + expect(inputElement.getAttribute('aria-haspopup')).toBe('tree'); + }); + + it('should set aria-controls to the tree id', () => { + down(); + const tree = fixture.debugElement.query(By.directive(CdkTree)).nativeElement; + expect(inputElement.getAttribute('aria-controls')).toBe(tree.id); + }); + + it('should set aria-selected on the selected tree item', () => { + down(); // -> Fruit + enter(); + + const fruitItem = getTreeItem('Fruit')!; + expect(fruitItem.getAttribute('aria-selected')).toBe('true'); + }); + + it('should toggle aria-expanded on parent nodes', () => { + down(); // -> Fruit + const fruitItem = getTreeItem('Fruit')!; + expect(fruitItem.getAttribute('aria-expanded')).toBe('false'); + + right(); // Expand Fruit + expect(fruitItem.getAttribute('aria-expanded')).toBe('true'); + + left(); // Collapse Fruit + expect(fruitItem.getAttribute('aria-expanded')).toBe('false'); + }); + }); + + describe('Navigation', () => { + beforeEach(() => setupCombobox()); + + it('should navigate to the first focusable item on ArrowDown', () => { + down(); + const fruitItem = getTreeItem('Fruit')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(fruitItem.id); + }); + + it('should navigate to the last focusable item on ArrowUp', () => { + up(); + const grainsItem = getTreeItem('Grains')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(grainsItem.id); + }); + + it('should navigate to the next focusable item on ArrowDown when open', () => { + down(); + down(); + const vegetablesItem = getTreeItem('Vegetables')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(vegetablesItem.id); + }); + + it('should navigate to the previous item on ArrowUp when open', () => { + up(); + up(); + const vegetablesItem = getTreeItem('Vegetables')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(vegetablesItem.id); + }); + + it('should expand a closed node on ArrowRight', () => { + down(); // To Fruit + expect(getVisibleTreeItems().length).toBe(3); + right(); + fixture.detectChanges(); + expect(getVisibleTreeItems().length).toBe(6); + const appleItem = getTreeItem('Apple')!; + expect(appleItem).not.toBeNull(); + }); + + it('should navigate to the next item on ArrowRight when already expanded', () => { + down(); // To Fruit + right(); // Expand Fruit + right(); // To Apple + const appleItem = getTreeItem('Apple')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(appleItem.id); + }); + + it('should collapse an open node on ArrowLeft', () => { + down(); // To Fruit + right(); // Expand Fruit + fixture.detectChanges(); + expect(getVisibleTreeItems().length).toBe(6); + left(); // Collapse Fruit + fixture.detectChanges(); + expect(getVisibleTreeItems().length).toBe(3); + const fruitItem = getTreeItem('Fruit')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(fruitItem.id); + }); + + it('should navigate to the parent node on ArrowLeft when in a child node', () => { + down(); // To Fruit + right(); // Expand Fruit + right(); // To Apple + const appleItem = getTreeItem('Apple')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(appleItem.id); + left(); // To Fruit + const fruitItem = getTreeItem('Fruit')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(fruitItem.id); + }); + + it('should navigate to the first focusable item on Home when open', () => { + up(); + keydown('Home'); + const fruitItem = getTreeItem('Fruit')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(fruitItem.id); + }); + + it('should navigate to the last focusable item on End when open', () => { + down(); + keydown('End'); + const grainsItem = getTreeItem('Grains')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(grainsItem.id); + }); + }); + + describe('Selection', () => { + describe('when filterMode is "manual"', () => { + beforeEach(() => setupCombobox({filterMode: 'manual'})); + + it('should select and commit on click', () => { + click(inputElement); + const fruitItem = getTreeItem('Fruit')!; + click(fruitItem); + fixture.detectChanges(); + + expect(comboboxInstance.value()).toBe('Fruit'); + expect(inputElement.value).toBe('Fruit'); + }); + + it('should select and commit to input on Enter', () => { + down(); + enter(); + + expect(comboboxInstance.value()).toBe('Fruit'); + expect(inputElement.value).toBe('Fruit'); + }); + + it('should select on focusout if the input text exactly matches an item', () => { + focus(); + input('Apple'); + blur(); + + expect(comboboxInstance.value()).toBe('Apple'); + }); + + it('should not select on navigation', () => { + down(); + down(); + + expect(comboboxInstance.value()).toBe(undefined); + }); + + it('should not select on focusout if the input text does not match an item', () => { + focus(); + input('Appl'); + blur(); + + expect(comboboxInstance.value()).toBe(undefined); + expect(inputElement.value).toBe('Appl'); + }); + }); + + describe('when filterMode is "auto-select"', () => { + beforeEach(() => setupCombobox({filterMode: 'auto-select'})); + + it('should select and commit on click', () => { + click(inputElement); + down(); + right(); + const appleItem = getTreeItem('Apple')!; + click(appleItem); + fixture.detectChanges(); + + expect(comboboxInstance.value()).toBe('Apple'); + expect(inputElement.value).toBe('Apple'); + }); + + it('should select and commit on Enter', () => { + down(); + down(); + enter(); + + expect(comboboxInstance.value()).toBe('Vegetables'); + expect(inputElement.value).toBe('Vegetables'); + }); + + it('should select on navigation', () => { + down(); + expect(comboboxInstance.value()).toBe('Fruit'); + + down(); + expect(comboboxInstance.value()).toBe('Vegetables'); + }); + + it('should select the first option on input', () => { + focus(); + input('B'); + + expect(comboboxInstance.value()).toBe('Banana'); + }); + + it('should commit the selected option on focusout', () => { + focus(); + input('App'); + blur(); + + expect(inputElement.value).toBe('Apple'); + expect(comboboxInstance.value()).toBe('Apple'); + }); + }); + + describe('when filterMode is "highlight"', () => { + beforeEach(() => setupCombobox({filterMode: 'highlight'})); + + it('should select and commit on click', () => { + click(inputElement); + down(); + right(); + const bananaItem = getTreeItem('Banana')!; + click(bananaItem); + fixture.detectChanges(); + + expect(comboboxInstance.value()).toBe('Banana'); + expect(inputElement.value).toBe('Banana'); + }); + + it('should select and commit on Enter', () => { + down(); + down(); + enter(); + + expect(comboboxInstance.value()).toBe('Vegetables'); + expect(inputElement.value).toBe('Vegetables'); + }); + + it('should select on navigation', () => { + down(); + expect(comboboxInstance.value()).toBe('Fruit'); + + down(); + expect(comboboxInstance.value()).toBe('Vegetables'); + }); + + it('should update input value on navigation', () => { + down(); + expect(inputElement.value).toBe('Fruit'); + + down(); + expect(inputElement.value).toBe('Vegetables'); + }); + + it('should select the first option on input', () => { + focus(); + input('Canta'); + + expect(comboboxInstance.value()).toBe('Cantaloupe'); + }); + + it('should insert a highlighted completion string on input', fakeAsync(() => { + focus(); + input('A'); + tick(); + + expect(inputElement.value).toBe('Apple'); + expect(inputElement.selectionStart).toBe(1); + expect(inputElement.selectionEnd).toBe(5); + })); + + it('should commit the selected option on focusout', () => { + focus(); + input('App'); + blur(); + + expect(inputElement.value).toBe('Apple'); + expect(comboboxInstance.value()).toBe('Apple'); + }); + }); + }); + + describe('with disabled items', () => { + beforeEach(() => { + setupCombobox(); + fixture.componentInstance.nodes.set([ + { + name: 'Fruit', + value: 'Fruit', + children: [ + {name: 'Apple', value: 'Apple'}, + {name: 'Banana', value: 'Banana', disabled: true}, + {name: 'Cantaloupe', value: 'Cantaloupe'}, + ], + }, + { + name: 'Vegetables', + value: 'Vegetables', + disabled: true, + children: [ + {name: 'Broccoli', value: 'Broccoli'}, + {name: 'Carrot', value: 'Carrot'}, + ], + }, + { + name: 'Grains', + value: 'Grains', + }, + ]); + fixture.detectChanges(); + }); + + it('should not select a disabled item by clicking', () => { + click(inputElement); + const disabledItem = getTreeItem('Vegetables')!; + click(disabledItem); + + expect(comboboxInstance.value()).toBeUndefined(); + }); + + it('should skip disabled items during keyboard navigation', () => { + down(); // To Fruit + expect(inputElement.getAttribute('aria-activedescendant')).toBe(getTreeItem('Fruit')!.id); + + down(); // Should skip Vegetables and go to Grains + expect(inputElement.getAttribute('aria-activedescendant')).toBe(getTreeItem('Grains')!.id); + + up(); // Back to Fruit + expect(inputElement.getAttribute('aria-activedescendant')).toBe(getTreeItem('Fruit')!.id); + }); + + it('should skip disabled child items during keyboard navigation', () => { + down(); // To Fruit + right(); // Expand Fruit + down(); // To Apple + expect(inputElement.getAttribute('aria-activedescendant')).toBe(getTreeItem('Apple')!.id); + + down(); // Should skip Banana and go to Cantaloupe + expect(inputElement.getAttribute('aria-activedescendant')).toBe( + getTreeItem('Cantaloupe')!.id, + ); + }); + + it('should not select disabled item with auto-select on input', () => { + fixture.componentInstance.filterMode.set('auto-select'); + fixture.detectChanges(); + + input('Vege'); // Matches 'Vegetables', which is disabled. + + expect(comboboxInstance.value()).toBeUndefined(); + }); + + it('should not highlight disabled item with highlight on input', () => { + fixture.componentInstance.filterMode.set('highlight'); + fixture.detectChanges(); + + input('Vege'); // Matches 'Vegetables', which is disabled. + + expect(comboboxInstance.value()).toBeUndefined(); + expect(inputElement.value).toBe('Vege'); + }); + + it('should not select disabled child item with auto-select on input', () => { + fixture.componentInstance.filterMode.set('auto-select'); + fixture.detectChanges(); + + input('Bana'); // Matches 'Banana', which is disabled. + + expect(comboboxInstance.value()).toBeUndefined(); + }); + }); + + describe('with dynamic data', () => { + beforeEach(() => setupCombobox()); + + it('should update active item if a top-level node is removed', () => { + down(); // -> Fruit + down(); // -> Vegetables + + const vegetables = getTreeItem('Vegetables')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(vegetables.id); + + fixture.componentInstance.nodes.set( + fixture.componentInstance.nodes().filter(n => n.name !== 'Vegetables'), + ); + fixture.detectChanges(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(null); + }); + + it('should update active item if a child node is removed', () => { + down(); // -> Fruit + right(); // Expand Fruit + down(); // -> Apple + down(); // -> Banana + + const banana = getTreeItem('Banana')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(banana.id); + + const nodes = fixture.componentInstance.nodes(); + nodes[0].children = nodes[0].children!.filter(c => c.name !== 'Banana'); + fixture.componentInstance.nodes.set([...nodes]); + fixture.detectChanges(); + + expect(inputElement.getAttribute('aria-activedescendant')).toBe(null); + }); + + it('should update the combobox value if the selected item is removed', () => { + down(); // -> Fruit + enter(); + expect(comboboxInstance.value()).toBe('Fruit'); + expect(inputElement.value).toBe('Fruit'); + + fixture.componentInstance.nodes.set( + fixture.componentInstance.nodes().filter(n => n.name !== 'Fruit'), + ); + fixture.detectChanges(); + + expect(comboboxInstance.value()).toBeUndefined(); + }); + + it('should clear active item if tree becomes empty', () => { + down(); // -> Fruit + expect(inputElement.hasAttribute('aria-activedescendant')).toBe(true); + + fixture.componentInstance.nodes.set([]); + fixture.detectChanges(); + + expect(inputElement.hasAttribute('aria-activedescendant')).toBe(false); + expect(getVisibleTreeItems().length).toBe(0); + }); + }); + + describe('Filtering', () => { + beforeEach(() => setupCombobox()); + + it('should lazily render options', () => { + expect(getTreeItems().length).toBe(0); + focus(); + expect(getTreeItems().length).toBe(11); + }); + + it('should filter the options based on the input value', () => { + focus(); + input('vegetables'); + + let items = getVisibleTreeItems(); + expect(items.length).toBe(1); + expect(items[0].textContent?.trim()).toBe('Vegetables'); + }); + + it('should render parents if a child matches', () => { + focus(); + input('broccoli'); + + let items = getVisibleTreeItems(); + expect(items.length).toBe(2); + expect(items[0].textContent?.trim()).toBe('Vegetables'); + expect(items[1].textContent?.trim()).toBe('Broccoli'); + }); + + it('should show no options if nothing matches', () => { + focus(); + input('xyz'); + expect(getVisibleTreeItems().length).toBe(0); + }); + + it('should show all options when the input is cleared', () => { + focus(); + input('Fruit'); + expect(getVisibleTreeItems().length).toBe(1); + + input(''); + expect(getVisibleTreeItems().length).toBe(3); + }); + + it('should expand all nodes when filtering', () => { + focus(); + expect(getVisibleTreeItems().length).toBe(3); + + input('a'); + expect(getTreeItem('Fruit')!.getAttribute('aria-expanded')).toBe('true'); + expect(getTreeItem('Vegetables')!.getAttribute('aria-expanded')).toBe('true'); + expect(getTreeItem('Grains')!.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should allow changing the filter function', () => { + focus(); + fixture.componentInstance.filterFn.set( + (inputText, itemText) => itemText.includes(inputText), // Case sensitive filter. + ); + input('fruit'); + expect(getVisibleTreeItems().length).toBe(0); + + input('Fruit'); + const options = getVisibleTreeItems(); + expect(options.length).toBe(1); + expect(options[0].textContent?.trim()).toBe('Fruit'); + }); + }); + + // TODO(wagnermaciel): Enable these tests once we have a way to set the value + // describe('with programmatic value changes', () => {}); + }); +}); + +@Component({ + template: ` +
          + + + +
          + @for (option of options(); track option.name) { +
        • {{ option.name }}
        • + } +
          +
          +
          + `, + imports: [ + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContainer, + CdkListbox, + CdkOption, + ], +}) +class ComboboxListboxExample { + filterFn = signal<(inputText: string, itemText: string) => boolean>((inputText, itemText) => + itemText.toLowerCase().includes(inputText.toLowerCase()), + ); + filterMode = signal<'manual' | 'auto-select' | 'highlight'>('manual'); + options = signal<{name: string; disabled?: boolean}[]>([ + {name: 'Apple'}, + {name: 'Apricot'}, + {name: 'Banana'}, + {name: 'Blackberry'}, + {name: 'Blueberry'}, + {name: 'Cantaloupe'}, + {name: 'Cherry'}, + {name: 'Clementine'}, + {name: 'Cranberry'}, + ]); +} + +@Component({ + template: ` +
          + + + +
            + +
          + + + @for (node of nodes; track node.value) { +
        • + {{ node.name }} +
        • + + @if (node.children) { +
            + + + +
          + } + } +
          +
          +
          + `, + standalone: true, + imports: [ + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopupContainer, + CdkTree, + CdkTreeItem, + CdkTreeItemGroup, + CdkTreeItemGroupContent, + NgTemplateOutlet, + ], +}) +class ComboboxTreeExample { + filterFn = signal<(inputText: string, itemText: string) => boolean>((inputText, itemText) => + itemText.toLowerCase().includes(inputText.toLowerCase()), + ); + filterMode = signal<'manual' | 'auto-select' | 'highlight'>('manual'); + nodes = signal< + { + name: string; + value: string; + disabled?: boolean; + children?: {name: string; value: string; disabled?: boolean}[]; + }[] + >([ + { + name: 'Fruit', + value: 'Fruit', + children: [ + {name: 'Apple', value: 'Apple'}, + {name: 'Banana', value: 'Banana'}, + {name: 'Cantaloupe', value: 'Cantaloupe'}, + ], + }, + { + name: 'Vegetables', + value: 'Vegetables', + children: [ + {name: 'Broccoli', value: 'Broccoli'}, + {name: 'Carrot', value: 'Carrot'}, + {name: 'Lettuce', value: 'Lettuce'}, + ], + }, + { + name: 'Grains', + value: 'Grains', + children: [ + {name: 'Rice', value: 'Rice'}, + {name: 'Wheat', value: 'Wheat'}, + ], + }, + ]); +} diff --git a/src/cdk-experimental/combobox/combobox.ts b/src/cdk-experimental/combobox/combobox.ts index cb3d7597fb92..fdec49cada02 100644 --- a/src/cdk-experimental/combobox/combobox.ts +++ b/src/cdk-experimental/combobox/combobox.ts @@ -57,10 +57,14 @@ export class CdkCombobox { /** The values of the current selected items. */ value = model(undefined); + /** The function used to filter the options in the popup based on the input text. */ filter = input<(inputText: string, itemText: string) => boolean>((inputText, itemText) => itemText.toLowerCase().includes(inputText.toLowerCase()), ); + /** Whether the listbox has received focus yet. */ + private _hasBeenFocused = signal(false); + /** The combobox ui pattern. */ readonly pattern = new ComboboxPattern({ ...this, @@ -77,6 +81,21 @@ export class CdkCombobox { afterRenderEffect(() => { this._deferredContentAware?.contentVisible.set(this.pattern.isFocused()); }); + + afterRenderEffect(() => { + if (!this._hasBeenFocused() && this.pattern.isFocused()) { + this._hasBeenFocused.set(true); + } + }); + + afterRenderEffect(() => { + if (!this._hasBeenFocused()) { + if (this.value() !== undefined) { + this._deferredContentAware?.contentVisible.set(false); + this.pattern.setDefaultState(); + } + } + }); } } @@ -87,6 +106,9 @@ export class CdkCombobox { 'role': 'combobox', '[attr.aria-expanded]': 'combobox.pattern.expanded()', '[attr.aria-activedescendant]': 'combobox.pattern.activedescendant()', + '[attr.aria-controls]': 'combobox.pattern.popupId()', + '[attr.aria-haspopup]': 'combobox.pattern.hasPopup()', + '[attr.aria-autocomplete]': 'combobox.pattern.autocomplete()', }, }) export class CdkComboboxInput { diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index 005cef5620f5..bf546d6c58e4 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -17,6 +17,7 @@ import { input, model, signal, + untracked, } from '@angular/core'; import {ComboboxListboxPattern, ListboxPattern, OptionPattern} from '../ui-patterns'; import {Directionality} from '@angular/cdk/bidi'; @@ -62,7 +63,7 @@ export class CdkListbox { private readonly _generatedId = inject(_IdGenerator).getId('cdk-listbox-'); // TODO(wagnermaciel): https://github.com/angular/components/pull/30495#discussion_r1972601144. - /** A unique identifier for the option. */ + /** A unique identifier for the listbox. */ protected id = computed(() => this._generatedId); /** A reference to the parent combobox popup, if one exists. */ @@ -126,6 +127,7 @@ export class CdkListbox { constructor() { const inputs = { ...this, + id: this.id, items: this.items, activeItem: signal(undefined), textDirection: this.textDirection, @@ -155,6 +157,25 @@ export class CdkListbox { this.pattern.setDefaultState(); } }); + + afterRenderEffect(() => { + const items = inputs.items(); + const activeItem = untracked(() => inputs.activeItem()); + + if (!items.some(i => i === activeItem) && activeItem) { + this.pattern.listBehavior.unfocus(); + } + }); + + afterRenderEffect(() => { + const items = inputs.items(); + const value = untracked(() => this.value()); + + if (items && value.some(v => !items.some(i => i.value() === v))) { + this.value.set(value.filter(v => items.some(i => i.value() === v))); + this._popup?.combobox?.pattern?.inputs.value.set(this.value()[0]); + } + }); } onFocus() { diff --git a/src/cdk-experimental/tree/tree.ts b/src/cdk-experimental/tree/tree.ts index 7531d0d48a1f..71ad590a99d9 100644 --- a/src/cdk-experimental/tree/tree.ts +++ b/src/cdk-experimental/tree/tree.ts @@ -19,6 +19,7 @@ import { Signal, OnInit, OnDestroy, + untracked, } from '@angular/core'; import {_IdGenerator} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; @@ -66,6 +67,7 @@ function sortDirectives(a: HasElement, b: HasElement) { host: { 'class': 'cdk-tree', 'role': 'tree', + '[attr.id]': 'id()', '[attr.aria-orientation]': 'pattern.orientation()', '[attr.aria-multiselectable]': 'pattern.multi()', '[attr.aria-disabled]': 'pattern.disabled()', @@ -78,6 +80,13 @@ function sortDirectives(a: HasElement, b: HasElement) { hostDirectives: [{directive: CdkComboboxPopup}], }) export class CdkTree { + /** A unique identifier for the tree. */ + private readonly _generatedId = inject(_IdGenerator).getId('cdk-tree-'); + + // TODO(wagnermaciel): https://github.com/angular/components/pull/30495#discussion_r1972601144. + /** A unique identifier for the tree. */ + protected id = computed(() => this._generatedId); + /** A reference to the parent combobox popup, if one exists. */ private readonly _popup = inject>(CdkComboboxPopup, { optional: true, @@ -136,10 +145,11 @@ export class CdkTree { constructor() { const inputs = { ...this, + id: this.id, allItems: computed(() => [...this._unorderedItems()].sort(sortDirectives).map(item => item.pattern), ), - activeItem: signal(undefined), + activeItem: signal | undefined>(undefined), element: () => this._elementRef.nativeElement, combobox: () => this._popup?.combobox?.pattern, }; @@ -157,6 +167,25 @@ export class CdkTree { this.pattern.setDefaultState(); } }); + + afterRenderEffect(() => { + const items = inputs.allItems(); + const activeItem = untracked(() => inputs.activeItem()); + + if (!items.some(i => i === activeItem) && activeItem) { + this.pattern.listBehavior.unfocus(); + } + }); + + afterRenderEffect(() => { + const items = inputs.allItems(); + const value = untracked(() => this.value()); + + if (items && value.some(v => !items.some(i => i.value() === v))) { + this.value.set(value.filter(v => items.some(i => i.value() === v))); + this._popup?.combobox?.pattern?.inputs.value.set(this.value()[0]); + } + }); } onFocus() { diff --git a/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts b/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts index 04df7c55f788..b2ceea2997e7 100644 --- a/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts +++ b/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts @@ -88,6 +88,7 @@ function getListboxPattern(combobox: ComboboxPattern, values const options = signal([]); const listbox = new ComboboxListboxPattern({ + id: signal('listbox-1'), items: options, value: signal(combobox.inputs.value() ? [combobox.inputs.value()!] : []), combobox: signal(combobox) as any, @@ -130,6 +131,7 @@ function getTreePattern( const items = signal[]>([]); const tree = new ComboboxTreePattern({ + id: signal('tree-1'), allItems: items, value: signal(combobox.inputs.value() ? [combobox.inputs.value()!] : []), combobox: signal(combobox) as any, diff --git a/src/cdk-experimental/ui-patterns/combobox/combobox.ts b/src/cdk-experimental/ui-patterns/combobox/combobox.ts index 2af9dca71c61..bfeb781fb9b9 100644 --- a/src/cdk-experimental/ui-patterns/combobox/combobox.ts +++ b/src/cdk-experimental/ui-patterns/combobox/combobox.ts @@ -34,6 +34,9 @@ export type ComboboxInputs, V> = { /** An interface that allows combobox popups to expose the necessary controls for the combobox. */ export type ComboboxListboxControls, V> = { + /** A unique identifier for the popup. */ + id: () => string; + /** The ARIA role for the popup. */ role: SignalLike<'listbox' | 'tree' | 'grid'>; @@ -114,6 +117,15 @@ export class ComboboxPattern, V> { /** The key used to navigate to the next item in the list. */ collapseKey = computed(() => 'ArrowLeft'); // TODO: RTL support. + /** The ID of the popup associated with the combobox. */ + popupId = computed(() => this.inputs.popupControls()?.id() || null); + + /** The autocomplete behavior of the combobox. */ + autocomplete = computed(() => (this.inputs.filterMode() === 'highlight' ? 'both' : 'list')); + + /** The ARIA role of the popup associated with the combobox. */ + hasPopup = computed(() => this.inputs.popupControls()?.role() || null); + /** The keydown event manager for the combobox. */ keydown = computed(() => { if (!this.expanded()) { @@ -227,12 +239,11 @@ export class ComboboxPattern, V> { /** Handles focus out events for the combobox. */ onFocusOut(event: FocusEvent) { - this.isFocused.set(false); - if ( !(event.relatedTarget instanceof HTMLElement) || !this.inputs.containerEl()?.contains(event.relatedTarget) ) { + this.isFocused.set(false); if (this.inputs.filterMode() !== 'manual') { this.commit(); } else { diff --git a/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts b/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts index 18ac5b7d7b7e..55a51c149cb9 100644 --- a/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts @@ -21,7 +21,11 @@ export class ComboboxListboxPattern extends ListboxPattern implements ComboboxListboxControls, V> { - role = () => 'listbox' as const; + /** A unique identifier for the popup. */ + id = computed(() => this.inputs.id()); + + /** The ARIA role for the listbox. */ + role = computed(() => 'listbox' as const); /** The id of the active (focused) item in the listbox. */ activeId = computed(() => this.listBehavior.activedescendant()); diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index 69c4eea011fa..e1e8be7e6390 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -14,6 +14,10 @@ import {List, ListInputs} from '../behaviors/list/list'; /** Represents the required inputs for a listbox. */ export type ListboxInputs = ListInputs, V> & { + /** A unique identifier for the listbox. */ + id: SignalLike; + + /** Whether the listbox is readonly. */ readonly: SignalLike; }; diff --git a/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts b/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts index 3b06f5724032..462146378ec1 100644 --- a/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts +++ b/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts @@ -20,6 +20,8 @@ export class ComboboxTreePattern extends TreePattern implements ComboboxTreeControls, V> { + override id = computed(() => this.inputs.id()); + /** Whether the currently focused item is collapsible. */ isItemCollapsible = () => this.activeItem()?.parent() instanceof TreeItemPattern; diff --git a/src/cdk-experimental/ui-patterns/tree/tree.ts b/src/cdk-experimental/ui-patterns/tree/tree.ts index 1afe148d34da..619568a60560 100644 --- a/src/cdk-experimental/ui-patterns/tree/tree.ts +++ b/src/cdk-experimental/ui-patterns/tree/tree.ts @@ -125,6 +125,9 @@ interface SelectOptions { /** Represents the required inputs for a tree. */ export interface TreeInputs extends Omit, V>, 'items'> { + /** A unique identifier for the tree. */ + id: SignalLike; + /** All items in the tree, in document order (DFS-like, a flattened list). */ allItems: SignalLike[]>; From b2f6983c4e58ad4749d4bbe2def25a737917a175 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Mon, 22 Sep 2025 14:28:22 -0400 Subject: [PATCH 14/23] fixup! test(cdk-experimental/combobox): add unit tests --- .../combobox/combobox.spec.ts | 172 ++++++++++++++---- .../ui-patterns/combobox/combobox.spec.ts | 2 +- 2 files changed, 135 insertions(+), 39 deletions(-) diff --git a/src/cdk-experimental/combobox/combobox.spec.ts b/src/cdk-experimental/combobox/combobox.spec.ts index 5b361142fdf4..9a02e57f72c6 100644 --- a/src/cdk-experimental/combobox/combobox.spec.ts +++ b/src/cdk-experimental/combobox/combobox.spec.ts @@ -171,43 +171,6 @@ describe('Combobox', () => { }); }); - describe('Expansion', () => { - beforeEach(() => setupCombobox()); - - it('should open on click', () => { - click(inputElement); - expect(comboboxInstance.pattern.expanded()).toBe(true); - }); - - it('should open on ArrowDown', () => { - keydown('ArrowDown'); - expect(comboboxInstance.pattern.expanded()).toBe(true); - }); - - it('should open on ArrowUp', () => { - keydown('ArrowUp'); - expect(comboboxInstance.pattern.expanded()).toBe(true); - }); - - it('should close on Escape', () => { - down(); - escape(); - expect(comboboxInstance.pattern.expanded()).toBe(false); - }); - - it('should close on focusout', () => { - focus(); - blur(); - expect(comboboxInstance.pattern.expanded()).toBe(false); - }); - - it('should not close on focusout if focus moves to an element inside the container', () => { - down(); - blur(getOptions()[0]!); - expect(comboboxInstance.pattern.expanded()).toBe(true); - }); - }); - describe('Navigation', () => { beforeEach(() => setupCombobox()); @@ -258,6 +221,73 @@ describe('Combobox', () => { }); }); + describe('Expansion', () => { + beforeEach(() => setupCombobox()); + + it('should open on click', () => { + focus(); + click(inputElement); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should open on ArrowDown', () => { + focus(); + keydown('ArrowDown'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should open on ArrowUp', () => { + focus(); + keydown('ArrowUp'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should close on Escape', () => { + down(); + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on focusout', () => { + focus(); + blur(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should not close on focusout if focus moves to an element inside the container', () => { + down(); + blur(getOption('Apple')!); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should clear the completion string and not close on escape when a completion is present', () => { + fixture.componentInstance.filterMode.set('highlight'); + focus(); + input('A'); + expect(inputElement.value).toBe('Apple'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + escape(); + expect(inputElement.value).toBe('A'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + escape(); + expect(inputElement.value).toBe('A'); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on enter', () => { + down(); + enter(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on click to select an item', () => { + down(); + const fruitItem = getOption('Apple')!; + click(fruitItem); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + }); + describe('Selection', () => { describe('when filterMode is "manual"', () => { beforeEach(() => setupCombobox({filterMode: 'manual'})); @@ -955,6 +985,73 @@ describe('Combobox', () => { }); }); + describe('Expansion', () => { + beforeEach(() => setupCombobox()); + + it('should open on click', () => { + focus(); + click(inputElement); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should open on ArrowDown', () => { + focus(); + keydown('ArrowDown'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should open on ArrowUp', () => { + focus(); + keydown('ArrowUp'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should close on Escape', () => { + down(); + escape(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on focusout', () => { + focus(); + blur(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should not close on focusout if focus moves to an element inside the container', () => { + down(); + blur(getTreeItem('Fruit')!); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should clear the completion string and not close on escape when a completion is present', () => { + fixture.componentInstance.filterMode.set('highlight'); + focus(); + input('A'); + expect(inputElement.value).toBe('Apple'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + escape(); + expect(inputElement.value).toBe('A'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + escape(); + expect(inputElement.value).toBe('A'); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on enter', () => { + down(); + enter(); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should close on click to select an item', () => { + down(); + const fruitItem = getTreeItem('Fruit')!; + click(fruitItem); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); + }); + describe('with disabled items', () => { beforeEach(() => { setupCombobox(); @@ -1260,7 +1357,6 @@ class ComboboxListboxExample {
        `, - standalone: true, imports: [ CdkCombobox, CdkComboboxInput, diff --git a/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts b/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts index b2ceea2997e7..bb3a2b8e3e2e 100644 --- a/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts +++ b/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts @@ -683,7 +683,7 @@ describe('Combobox with Tree Pattern', () => { }); it('should select on focusout if the input text exactly matches an item', () => { - const {combobox, tree, inputEl} = getPatterns({filterMode: 'manual'}); + const {combobox, inputEl} = getPatterns({filterMode: 'manual'}); combobox.onPointerup(clickInput(inputEl)); inputEl.value = 'Apple'; combobox.onInput(new InputEvent('input')); From 5cfc6e8a1aca409a2400a1700b0cf5a7ae3370c5 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Mon, 22 Sep 2025 14:32:44 -0400 Subject: [PATCH 15/23] fixup! test(cdk-experimental/combobox): add unit tests --- .../ui-patterns/listbox/listbox.spec.ts | 1 + src/cdk-experimental/ui-patterns/tree/tree.spec.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts index 2c2ecde4793f..da3244c187b4 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts @@ -32,6 +32,7 @@ const shift = () => createKeyboardEvent('keydown', 16, 'Shift', {shift: true}); describe('Listbox Pattern', () => { function getListbox(inputs: Partial & Pick) { return new ListboxPattern({ + id: signal('listbox-1'), items: inputs.items, value: inputs.value ?? signal([]), activeItem: signal(undefined), diff --git a/src/cdk-experimental/ui-patterns/tree/tree.spec.ts b/src/cdk-experimental/ui-patterns/tree/tree.spec.ts index 0ffc2fda466e..4a7bc3708a5f 100644 --- a/src/cdk-experimental/ui-patterns/tree/tree.spec.ts +++ b/src/cdk-experimental/ui-patterns/tree/tree.spec.ts @@ -136,6 +136,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { + id: signal('tree-1'), activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), @@ -189,6 +190,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { + id: signal('tree-1'), activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), @@ -235,6 +237,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { + id: signal('tree-1'), activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), @@ -420,6 +423,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { + id: signal('tree-1'), activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), @@ -479,6 +483,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { + id: signal('tree-1'), activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), @@ -558,6 +563,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { + id: signal('tree-1'), activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), @@ -717,6 +723,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { + id: signal('tree-1'), activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), @@ -868,6 +875,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { + id: signal('tree-1'), activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), @@ -909,6 +917,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { + id: signal('tree-1'), activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), @@ -963,6 +972,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { + id: signal('tree-1'), activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), @@ -1012,6 +1022,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { + id: signal('tree-1'), activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), @@ -1095,6 +1106,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { + id: signal('tree-1'), activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), @@ -1326,6 +1338,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { + id: signal('tree-1'), activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), From c311dbea0e3759e35c4295eb94e5b2d0136748e7 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Wed, 24 Sep 2025 14:28:37 -0400 Subject: [PATCH 16/23] fixup! test(cdk-experimental/combobox): add unit tests --- .../combobox/combobox.spec.ts | 131 ++++++++++-------- src/cdk-experimental/combobox/combobox.ts | 23 +-- src/cdk-experimental/listbox/listbox.ts | 4 +- src/cdk-experimental/tree/tree.ts | 1 - .../ui-patterns/combobox/combobox.spec.ts | 119 ++++++++-------- .../ui-patterns/combobox/combobox.ts | 26 ++-- .../ui-patterns/tree/combobox-tree.ts | 2 - src/cdk-experimental/ui-patterns/tree/tree.ts | 1 + 8 files changed, 152 insertions(+), 155 deletions(-) diff --git a/src/cdk-experimental/combobox/combobox.spec.ts b/src/cdk-experimental/combobox/combobox.spec.ts index 9a02e57f72c6..bf2bbe3b6fc6 100644 --- a/src/cdk-experimental/combobox/combobox.spec.ts +++ b/src/cdk-experimental/combobox/combobox.spec.ts @@ -21,7 +21,6 @@ describe('Combobox', () => { describe('with Listbox', () => { let fixture: ComponentFixture; let inputElement: HTMLInputElement; - let comboboxInstance: CdkCombobox; const keydown = (key: string, modifierKeys: {} = {}) => { focus(); @@ -77,8 +76,6 @@ describe('Combobox', () => { } function defineTestVariables() { - const comboboxDebugElement = fixture.debugElement.query(By.directive(CdkCombobox)); - comboboxInstance = comboboxDebugElement.injector.get(CdkCombobox); const inputDebugElement = fixture.debugElement.query(By.directive(CdkComboboxInput)); inputElement = inputDebugElement.nativeElement as HTMLInputElement; } @@ -298,7 +295,7 @@ describe('Combobox', () => { click(options[0]); fixture.detectChanges(); - expect(comboboxInstance.value()).toBe('Apple'); + expect(fixture.componentInstance.value()).toEqual(['Apple']); expect(inputElement.value).toBe('Apple'); }); @@ -306,7 +303,7 @@ describe('Combobox', () => { down(); enter(); - expect(comboboxInstance.value()).toBe('Apple'); + expect(fixture.componentInstance.value()).toEqual(['Apple']); expect(inputElement.value).toBe('Apple'); }); @@ -314,7 +311,7 @@ describe('Combobox', () => { down(); down(); - expect(comboboxInstance.value()).toBe(undefined); + expect(fixture.componentInstance.value()).toEqual([]); }); it('should select on focusout if the input text exactly matches an item', () => { @@ -322,7 +319,7 @@ describe('Combobox', () => { input('Apple'); blur(); - expect(comboboxInstance.value()).toBe('Apple'); + expect(fixture.componentInstance.value()).toEqual(['Apple']); }); it('should not select on focusout if the input text does not match an item', () => { @@ -330,7 +327,7 @@ describe('Combobox', () => { input('Appl'); blur(); - expect(comboboxInstance.value()).toBe(undefined); + expect(fixture.componentInstance.value()).toEqual([]); expect(inputElement.value).toBe('Appl'); }); }); @@ -344,7 +341,7 @@ describe('Combobox', () => { click(options[1]); fixture.detectChanges(); - expect(comboboxInstance.value()).toBe('Apricot'); + expect(fixture.componentInstance.value()).toEqual(['Apricot']); expect(inputElement.value).toBe('Apricot'); }); @@ -353,23 +350,23 @@ describe('Combobox', () => { down(); enter(); - expect(comboboxInstance.value()).toBe('Apricot'); + expect(fixture.componentInstance.value()).toEqual(['Apricot']); expect(inputElement.value).toBe('Apricot'); }); it('should select on navigation', () => { down(); - expect(comboboxInstance.value()).toBe('Apple'); + expect(fixture.componentInstance.value()).toEqual(['Apple']); down(); - expect(comboboxInstance.value()).toBe('Apricot'); + expect(fixture.componentInstance.value()).toEqual(['Apricot']); }); it('should select the first option on input', () => { focus(); input('B'); - expect(comboboxInstance.value()).toBe('Banana'); + expect(fixture.componentInstance.value()).toEqual(['Banana']); }); it('should commit the selected option on focusout', () => { @@ -378,7 +375,7 @@ describe('Combobox', () => { blur(); expect(inputElement.value).toBe('Apricot'); - expect(comboboxInstance.value()).toBe('Apricot'); + expect(fixture.componentInstance.value()).toEqual(['Apricot']); }); }); @@ -391,7 +388,7 @@ describe('Combobox', () => { click(options[2]); fixture.detectChanges(); - expect(comboboxInstance.value()).toBe('Banana'); + expect(fixture.componentInstance.value()).toEqual(['Banana']); expect(inputElement.value).toBe('Banana'); }); @@ -401,16 +398,16 @@ describe('Combobox', () => { down(); enter(); - expect(comboboxInstance.value()).toBe('Banana'); + expect(fixture.componentInstance.value()).toEqual(['Banana']); expect(inputElement.value).toBe('Banana'); }); it('should select on navigation', () => { down(); - expect(comboboxInstance.value()).toBe('Apple'); + expect(fixture.componentInstance.value()).toEqual(['Apple']); down(); - expect(comboboxInstance.value()).toBe('Apricot'); + expect(fixture.componentInstance.value()).toEqual(['Apricot']); }); it('should update input value on navigation', () => { @@ -425,7 +422,7 @@ describe('Combobox', () => { focus(); input('Canta'); - expect(comboboxInstance.value()).toBe('Cantaloupe'); + expect(fixture.componentInstance.value()).toEqual(['Cantaloupe']); }); it('should insert a highlighted completion string on input', fakeAsync(() => { @@ -444,7 +441,7 @@ describe('Combobox', () => { blur(); expect(inputElement.value).toBe('Apricot'); - expect(comboboxInstance.value()).toBe('Apricot'); + expect(fixture.componentInstance.value()).toEqual(['Apricot']); }); }); }); @@ -467,7 +464,7 @@ describe('Combobox', () => { const disabledOption = getOption('Apricot')!; click(disabledOption); - expect(comboboxInstance.value()).toBeUndefined(); + expect(fixture.componentInstance.value()).toEqual([]); }); it('should skip disabled options during keyboard navigation', () => { @@ -493,7 +490,7 @@ describe('Combobox', () => { input('Apr'); - expect(comboboxInstance.value()).toBeUndefined(); + expect(fixture.componentInstance.value()).toEqual([]); }); it('should not select disabled option with highlight on input', () => { @@ -502,7 +499,7 @@ describe('Combobox', () => { input('Apr'); - expect(comboboxInstance.value()).toBeUndefined(); + expect(fixture.componentInstance.value()).toEqual([]); expect(inputElement.value).toBe('Apr'); }); }); @@ -527,7 +524,7 @@ describe('Combobox', () => { it('should update the combobox value if the selected item is removed', () => { down(); // -> Apple enter(); - expect(comboboxInstance.value()).toBe('Apple'); + expect(fixture.componentInstance.value()).toEqual(['Apple']); expect(inputElement.value).toBe('Apple'); fixture.componentInstance.options.set( @@ -535,7 +532,7 @@ describe('Combobox', () => { ); fixture.detectChanges(); - expect(comboboxInstance.value()).toBeUndefined(); + expect(fixture.componentInstance.value()).toEqual([]); }); it('should clear active item if listbox becomes empty', () => { @@ -608,14 +605,24 @@ describe('Combobox', () => { }); }); - // TODO(wagnermaciel): Enable these tests once we have a way to set the value - // describe('with programmatic value changes', () => {}); + describe('with programmatic value changes', () => { + // TODO(wagnermaciel): Figure out if there's a way to automatically update the + // input value when the popup value signal is updated programmatically. + it('should update the selected item when the value is set programmatically', () => { + setupCombobox(); + focus(); + fixture.componentInstance.value.set(['Banana']); + fixture.detectChanges(); + expect(fixture.componentInstance.value()).toEqual(['Banana']); + const bananaOption = getOption('Banana')!; + expect(bananaOption.getAttribute('aria-selected')).toBe('true'); + }); + }); }); describe('with Tree', () => { let fixture: ComponentFixture; let inputElement: HTMLInputElement; - let comboboxInstance: CdkCombobox; const keydown = (key: string, modifierKeys: {} = {}) => { focus(); @@ -673,8 +680,6 @@ describe('Combobox', () => { } function defineTestVariables() { - const comboboxDebugElement = fixture.debugElement.query(By.directive(CdkCombobox)); - comboboxInstance = comboboxDebugElement.injector.get(CdkCombobox); const inputDebugElement = fixture.debugElement.query(By.directive(CdkComboboxInput)); inputElement = inputDebugElement.nativeElement as HTMLInputElement; } @@ -831,7 +836,7 @@ describe('Combobox', () => { click(fruitItem); fixture.detectChanges(); - expect(comboboxInstance.value()).toBe('Fruit'); + expect(fixture.componentInstance.value()).toEqual(['Fruit']); expect(inputElement.value).toBe('Fruit'); }); @@ -839,7 +844,7 @@ describe('Combobox', () => { down(); enter(); - expect(comboboxInstance.value()).toBe('Fruit'); + expect(fixture.componentInstance.value()).toEqual(['Fruit']); expect(inputElement.value).toBe('Fruit'); }); @@ -848,14 +853,14 @@ describe('Combobox', () => { input('Apple'); blur(); - expect(comboboxInstance.value()).toBe('Apple'); + expect(fixture.componentInstance.value()).toEqual(['Apple']); }); it('should not select on navigation', () => { down(); down(); - expect(comboboxInstance.value()).toBe(undefined); + expect(fixture.componentInstance.value()).toEqual([]); }); it('should not select on focusout if the input text does not match an item', () => { @@ -863,7 +868,7 @@ describe('Combobox', () => { input('Appl'); blur(); - expect(comboboxInstance.value()).toBe(undefined); + expect(fixture.componentInstance.value()).toEqual([]); expect(inputElement.value).toBe('Appl'); }); }); @@ -879,7 +884,7 @@ describe('Combobox', () => { click(appleItem); fixture.detectChanges(); - expect(comboboxInstance.value()).toBe('Apple'); + expect(fixture.componentInstance.value()).toEqual(['Apple']); expect(inputElement.value).toBe('Apple'); }); @@ -888,23 +893,23 @@ describe('Combobox', () => { down(); enter(); - expect(comboboxInstance.value()).toBe('Vegetables'); + expect(fixture.componentInstance.value()).toEqual(['Vegetables']); expect(inputElement.value).toBe('Vegetables'); }); it('should select on navigation', () => { down(); - expect(comboboxInstance.value()).toBe('Fruit'); + expect(fixture.componentInstance.value()).toEqual(['Fruit']); down(); - expect(comboboxInstance.value()).toBe('Vegetables'); + expect(fixture.componentInstance.value()).toEqual(['Vegetables']); }); it('should select the first option on input', () => { focus(); input('B'); - expect(comboboxInstance.value()).toBe('Banana'); + expect(fixture.componentInstance.value()).toEqual(['Banana']); }); it('should commit the selected option on focusout', () => { @@ -913,7 +918,7 @@ describe('Combobox', () => { blur(); expect(inputElement.value).toBe('Apple'); - expect(comboboxInstance.value()).toBe('Apple'); + expect(fixture.componentInstance.value()).toEqual(['Apple']); }); }); @@ -928,7 +933,7 @@ describe('Combobox', () => { click(bananaItem); fixture.detectChanges(); - expect(comboboxInstance.value()).toBe('Banana'); + expect(fixture.componentInstance.value()).toEqual(['Banana']); expect(inputElement.value).toBe('Banana'); }); @@ -937,16 +942,16 @@ describe('Combobox', () => { down(); enter(); - expect(comboboxInstance.value()).toBe('Vegetables'); + expect(fixture.componentInstance.value()).toEqual(['Vegetables']); expect(inputElement.value).toBe('Vegetables'); }); it('should select on navigation', () => { down(); - expect(comboboxInstance.value()).toBe('Fruit'); + expect(fixture.componentInstance.value()).toEqual(['Fruit']); down(); - expect(comboboxInstance.value()).toBe('Vegetables'); + expect(fixture.componentInstance.value()).toEqual(['Vegetables']); }); it('should update input value on navigation', () => { @@ -961,7 +966,7 @@ describe('Combobox', () => { focus(); input('Canta'); - expect(comboboxInstance.value()).toBe('Cantaloupe'); + expect(fixture.componentInstance.value()).toEqual(['Cantaloupe']); }); it('should insert a highlighted completion string on input', fakeAsync(() => { @@ -980,7 +985,7 @@ describe('Combobox', () => { blur(); expect(inputElement.value).toBe('Apple'); - expect(comboboxInstance.value()).toBe('Apple'); + expect(fixture.componentInstance.value()).toEqual(['Apple']); }); }); }); @@ -1087,7 +1092,7 @@ describe('Combobox', () => { const disabledItem = getTreeItem('Vegetables')!; click(disabledItem); - expect(comboboxInstance.value()).toBeUndefined(); + expect(fixture.componentInstance.value()).toEqual([]); }); it('should skip disabled items during keyboard navigation', () => { @@ -1119,7 +1124,7 @@ describe('Combobox', () => { input('Vege'); // Matches 'Vegetables', which is disabled. - expect(comboboxInstance.value()).toBeUndefined(); + expect(fixture.componentInstance.value()).toEqual([]); }); it('should not highlight disabled item with highlight on input', () => { @@ -1128,7 +1133,7 @@ describe('Combobox', () => { input('Vege'); // Matches 'Vegetables', which is disabled. - expect(comboboxInstance.value()).toBeUndefined(); + expect(fixture.componentInstance.value()).toEqual([]); expect(inputElement.value).toBe('Vege'); }); @@ -1138,7 +1143,7 @@ describe('Combobox', () => { input('Bana'); // Matches 'Banana', which is disabled. - expect(comboboxInstance.value()).toBeUndefined(); + expect(fixture.componentInstance.value()).toEqual([]); }); }); @@ -1179,7 +1184,7 @@ describe('Combobox', () => { it('should update the combobox value if the selected item is removed', () => { down(); // -> Fruit enter(); - expect(comboboxInstance.value()).toBe('Fruit'); + expect(fixture.componentInstance.value()).toEqual(['Fruit']); expect(inputElement.value).toBe('Fruit'); fixture.componentInstance.nodes.set( @@ -1187,7 +1192,7 @@ describe('Combobox', () => { ); fixture.detectChanges(); - expect(comboboxInstance.value()).toBeUndefined(); + expect(fixture.componentInstance.value()).toEqual([]); }); it('should clear active item if tree becomes empty', () => { @@ -1270,8 +1275,18 @@ describe('Combobox', () => { }); }); - // TODO(wagnermaciel): Enable these tests once we have a way to set the value - // describe('with programmatic value changes', () => {}); + describe('with programmatic value changes', () => { + // TODO(wagnermaciel): Figure out if there's a way to automatically update the + // input value when the popup value signal is updated programmatically. + it('should update the selected item when the value is set programmatically', () => { + setupCombobox(); + focus(); + fixture.componentInstance.value.set(['Fruit']); + fixture.detectChanges(); + expect(fixture.componentInstance.value()).toEqual(['Fruit']); + expect(getTreeItem('Fruit')!.getAttribute('aria-selected')).toBe('true'); + }); + }); }); }); @@ -1281,7 +1296,7 @@ describe('Combobox', () => { -
        +
        @for (option of options(); track option.name) {
      • {{ option.name }}
      • } @@ -1299,6 +1314,7 @@ describe('Combobox', () => { ], }) class ComboboxListboxExample { + value = signal([]); filterFn = signal<(inputText: string, itemText: string) => boolean>((inputText, itemText) => itemText.toLowerCase().includes(inputText.toLowerCase()), ); @@ -1322,7 +1338,7 @@ class ComboboxListboxExample { -
          +
            ([]); filterFn = signal<(inputText: string, itemText: string) => boolean>((inputText, itemText) => itemText.toLowerCase().includes(inputText.toLowerCase()), ); diff --git a/src/cdk-experimental/combobox/combobox.ts b/src/cdk-experimental/combobox/combobox.ts index fdec49cada02..fc782495e5d5 100644 --- a/src/cdk-experimental/combobox/combobox.ts +++ b/src/cdk-experimental/combobox/combobox.ts @@ -13,7 +13,6 @@ import { ElementRef, inject, input, - model, signal, WritableSignal, } from '@angular/core'; @@ -54,9 +53,6 @@ export class CdkCombobox { /** Whether the combobox is focused. */ readonly isFocused = signal(false); - /** The values of the current selected items. */ - value = model(undefined); - /** The function used to filter the options in the popup based on the input text. */ filter = input<(inputText: string, itemText: string) => boolean>((inputText, itemText) => itemText.toLowerCase().includes(inputText.toLowerCase()), @@ -69,17 +65,15 @@ export class CdkCombobox { readonly pattern = new ComboboxPattern({ ...this, inputEl: signal(undefined), - containerEl: signal(undefined), + containerEl: () => this._elementRef.nativeElement, popupControls: () => this.popup()?.controls(), }); constructor() { - (this.pattern.inputs.containerEl as WritableSignal).set( - this._elementRef.nativeElement, - ); - afterRenderEffect(() => { - this._deferredContentAware?.contentVisible.set(this.pattern.isFocused()); + if (!this._deferredContentAware?.contentVisible() && this.pattern.isFocused()) { + this._deferredContentAware?.contentVisible.set(true); + } }); afterRenderEffect(() => { @@ -87,15 +81,6 @@ export class CdkCombobox { this._hasBeenFocused.set(true); } }); - - afterRenderEffect(() => { - if (!this._hasBeenFocused()) { - if (this.value() !== undefined) { - this._deferredContentAware?.contentVisible.set(false); - this.pattern.setDefaultState(); - } - } - }); } } diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index bf546d6c58e4..4d99730cc0cf 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -158,6 +158,8 @@ export class CdkListbox { } }); + // Ensure that if the active item is removed from + // the list, the listbox updates it's focus state. afterRenderEffect(() => { const items = inputs.items(); const activeItem = untracked(() => inputs.activeItem()); @@ -167,13 +169,13 @@ export class CdkListbox { } }); + // Ensure that the value is always in sync with the available options. afterRenderEffect(() => { const items = inputs.items(); const value = untracked(() => this.value()); if (items && value.some(v => !items.some(i => i.value() === v))) { this.value.set(value.filter(v => items.some(i => i.value() === v))); - this._popup?.combobox?.pattern?.inputs.value.set(this.value()[0]); } }); } diff --git a/src/cdk-experimental/tree/tree.ts b/src/cdk-experimental/tree/tree.ts index 71ad590a99d9..948154771286 100644 --- a/src/cdk-experimental/tree/tree.ts +++ b/src/cdk-experimental/tree/tree.ts @@ -183,7 +183,6 @@ export class CdkTree { if (items && value.some(v => !items.some(i => i.value() === v))) { this.value.set(value.filter(v => items.some(i => i.value() === v))); - this._popup?.combobox?.pattern?.inputs.value.set(this.value()[0]); } }); } diff --git a/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts b/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts index bb3a2b8e3e2e..e3d25fd3a619 100644 --- a/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts +++ b/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts @@ -64,13 +64,11 @@ function getComboboxPattern( [K in keyof TestInputs]: TestInputs[K] extends WritableSignal ? T : never; }> = {}, ) { - const value = signal(inputs.value); const containerEl = signal(document.createElement('div')); const inputEl = signal(document.createElement('input')); containerEl()?.appendChild(inputEl()!); const combobox = new ComboboxPattern({ - value, popupControls: signal(undefined), // will be set later inputEl, containerEl, @@ -84,13 +82,17 @@ function getComboboxPattern( return {combobox, inputEl, containerEl}; } -function getListboxPattern(combobox: ComboboxPattern, values: string[]) { +function getListboxPattern( + combobox: ComboboxPattern, + values: string[], + initialValue?: string, +) { const options = signal([]); const listbox = new ComboboxListboxPattern({ id: signal('listbox-1'), items: options, - value: signal(combobox.inputs.value() ? [combobox.inputs.value()!] : []), + value: signal(initialValue ? [initialValue] : []), combobox: signal(combobox) as any, activeItem: signal(undefined), typeaheadDelay: signal(0.5), @@ -127,13 +129,14 @@ function getListboxPattern(combobox: ComboboxPattern, values function getTreePattern( combobox: ComboboxPattern, string>, data: TreeItemData[], + initialValue?: string, ) { const items = signal[]>([]); const tree = new ComboboxTreePattern({ id: signal('tree-1'), allItems: items, - value: signal(combobox.inputs.value() ? [combobox.inputs.value()!] : []), + value: signal(initialValue ? [initialValue] : []), combobox: signal(combobox) as any, activeItem: signal(undefined), typeaheadDelay: signal(0.5), @@ -326,7 +329,7 @@ describe('Combobox with Listbox Pattern', () => { const {combobox, listbox, inputEl} = getPatterns(); combobox.onPointerup(clickOption(listbox.inputs.items(), 0)); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); - expect(combobox.inputs.value()).toBe('Apple'); + expect(listbox.inputs.value()).toEqual(['Apple']); expect(inputEl.value).toBe('Apple'); }); @@ -335,7 +338,7 @@ describe('Combobox with Listbox Pattern', () => { combobox.onKeydown(down()); combobox.onKeydown(enter()); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); - expect(combobox.inputs.value()).toBe('Apple'); + expect(listbox.inputs.value()).toEqual(['Apple']); expect(inputEl.value).toBe('Apple'); }); @@ -345,7 +348,7 @@ describe('Combobox with Listbox Pattern', () => { combobox.onInput(new InputEvent('input')); combobox.onFocusOut(new FocusEvent('focusout')); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); - expect(combobox.inputs.value()).toBe('Apple'); + expect(listbox.inputs.value()).toEqual(['Apple']); }); it('should deselect on backspace', () => { @@ -357,14 +360,14 @@ describe('Combobox with Listbox Pattern', () => { combobox.onInput(new InputEvent('input', {inputType: 'deleteContentBackward'})); expect(listbox.getSelectedItem()).toBe(undefined); - expect(combobox.inputs.value()).toBe(undefined); + expect(listbox.inputs.value()).toEqual([]); }); it('should not select on navigation', () => { const {combobox, listbox} = getPatterns({filterMode: 'manual'}); combobox.onKeydown(down()); expect(listbox.getSelectedItem()).toBe(undefined); - expect(combobox.inputs.value()).toBe(undefined); + expect(listbox.inputs.value()).toEqual([]); }); it('should not select on input', () => { @@ -372,7 +375,7 @@ describe('Combobox with Listbox Pattern', () => { inputEl.value = 'A'; combobox.onInput(new InputEvent('input')); expect(listbox.getSelectedItem()).toBe(undefined); - expect(combobox.inputs.value()).toBe(undefined); + expect(listbox.inputs.value()).toEqual([]); }); it('should not select on focusout if the input text does not match an item', () => { @@ -381,7 +384,7 @@ describe('Combobox with Listbox Pattern', () => { combobox.onInput(new InputEvent('input')); combobox.onFocusOut(new FocusEvent('focusout')); expect(listbox.getSelectedItem()).toBe(undefined); - expect(combobox.inputs.value()).toBe(undefined); + expect(listbox.inputs.value()).toEqual([]); expect(inputEl.value).toBe('Appl'); }); }); @@ -391,7 +394,7 @@ describe('Combobox with Listbox Pattern', () => { const {combobox, listbox, inputEl} = getPatterns({filterMode: 'auto-select'}); combobox.onPointerup(clickOption(listbox.inputs.items(), 3)); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[3]); - expect(combobox.inputs.value()).toBe('Blackberry'); + expect(listbox.inputs.value()).toEqual(['Blackberry']); expect(inputEl.value).toBe('Blackberry'); }); @@ -402,7 +405,7 @@ describe('Combobox with Listbox Pattern', () => { combobox.onKeydown(down()); combobox.onKeydown(enter()); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]); - expect(combobox.inputs.value()).toBe('Banana'); + expect(listbox.inputs.value()).toEqual(['Banana']); expect(inputEl.value).toBe('Banana'); }); @@ -410,7 +413,7 @@ describe('Combobox with Listbox Pattern', () => { const {combobox, listbox} = getPatterns({filterMode: 'auto-select'}); combobox.onKeydown(down()); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); - expect(combobox.inputs.value()).toBe('Apple'); + expect(listbox.inputs.value()).toEqual(['Apple']); }); it('should select the last item on arrow up when collapsed', () => { @@ -419,7 +422,7 @@ describe('Combobox with Listbox Pattern', () => { expect(listbox.getSelectedItem()).toBe( listbox.inputs.items()[listbox.inputs.items().length - 1], ); - expect(combobox.inputs.value()).toBe('Cranberry'); + expect(listbox.inputs.value()).toEqual(['Cranberry']); }); it('should select on navigation', () => { @@ -427,7 +430,7 @@ describe('Combobox with Listbox Pattern', () => { combobox.onKeydown(down()); combobox.onKeydown(down()); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[1]); - expect(combobox.inputs.value()).toBe('Apricot'); + expect(listbox.inputs.value()).toEqual(['Apricot']); }); it('should select the first option on input', () => { @@ -436,13 +439,13 @@ describe('Combobox with Listbox Pattern', () => { combobox.onInput(new InputEvent('input')); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); - expect(combobox.inputs.value()).toBe('Apple'); + expect(listbox.inputs.value()).toEqual(['Apple']); inputEl.value = 'Apr'; combobox.onInput(new InputEvent('input')); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[1]); - expect(combobox.inputs.value()).toBe('Apricot'); + expect(listbox.inputs.value()).toEqual(['Apricot']); }); it('should commit the selected option on focusout', () => { @@ -460,7 +463,7 @@ describe('Combobox with Listbox Pattern', () => { const {combobox, listbox, inputEl} = getPatterns({filterMode: 'highlight'}); combobox.onPointerup(clickOption(listbox.inputs.items(), 3)); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[3]); - expect(combobox.inputs.value()).toBe('Blackberry'); + expect(listbox.inputs.value()).toEqual(['Blackberry']); expect(inputEl.value).toBe('Blackberry'); }); @@ -471,7 +474,7 @@ describe('Combobox with Listbox Pattern', () => { combobox.onKeydown(down()); combobox.onKeydown(enter()); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]); - expect(combobox.inputs.value()).toBe('Banana'); + expect(listbox.inputs.value()).toEqual(['Banana']); expect(inputEl.value).toBe('Banana'); }); @@ -479,7 +482,7 @@ describe('Combobox with Listbox Pattern', () => { const {combobox, listbox} = getPatterns({filterMode: 'highlight'}); combobox.onKeydown(down()); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); - expect(combobox.inputs.value()).toBe('Apple'); + expect(listbox.inputs.value()).toEqual(['Apple']); }); it('should select the last item on arrow up when collapsed', () => { @@ -488,7 +491,7 @@ describe('Combobox with Listbox Pattern', () => { expect(listbox.getSelectedItem()).toBe( listbox.inputs.items()[listbox.inputs.items().length - 1], ); - expect(combobox.inputs.value()).toBe('Cranberry'); + expect(listbox.inputs.value()).toEqual(['Cranberry']); }); it('should select on navigation', () => { @@ -496,7 +499,7 @@ describe('Combobox with Listbox Pattern', () => { combobox.onKeydown(down()); combobox.onKeydown(down()); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[1]); - expect(combobox.inputs.value()).toBe('Apricot'); + expect(listbox.inputs.value()).toEqual(['Apricot']); }); it('should select the first option on input', () => { @@ -505,13 +508,13 @@ describe('Combobox with Listbox Pattern', () => { combobox.onInput(new InputEvent('input')); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); - expect(combobox.inputs.value()).toBe('Apple'); + expect(listbox.inputs.value()).toEqual(['Apple']); inputEl.value = 'Apr'; combobox.onInput(new InputEvent('input')); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[1]); - expect(combobox.inputs.value()).toBe('Apricot'); + expect(listbox.inputs.value()).toEqual(['Apricot']); }); it('should commit the selected option on navigation', () => { @@ -669,7 +672,7 @@ describe('Combobox with Tree Pattern', () => { it('should select and commit on click', () => { const {combobox, tree, inputEl} = getPatterns(); combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 0)); - expect(combobox.inputs.value()).toBe('Fruit'); + expect(tree.inputs.value()).toEqual(['Fruit']); expect(inputEl.value).toBe('Fruit'); }); @@ -678,17 +681,17 @@ describe('Combobox with Tree Pattern', () => { combobox.onKeydown(down()); combobox.onKeydown(enter()); expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[0]); - expect(combobox.inputs.value()).toBe('Fruit'); + expect(tree.inputs.value()).toEqual(['Fruit']); expect(inputEl.value).toBe('Fruit'); }); it('should select on focusout if the input text exactly matches an item', () => { - const {combobox, inputEl} = getPatterns({filterMode: 'manual'}); + const {combobox, inputEl, tree} = getPatterns({filterMode: 'manual'}); combobox.onPointerup(clickInput(inputEl)); inputEl.value = 'Apple'; combobox.onInput(new InputEvent('input')); combobox.onFocusOut(new FocusEvent('focusout')); - expect(combobox.inputs.value()).toBe('Apple'); + expect(tree.inputs.value()).toEqual(['Apple']); }); it('should deselect on backspace', () => { @@ -700,14 +703,14 @@ describe('Combobox with Tree Pattern', () => { combobox.onInput(new InputEvent('input', {inputType: 'deleteContentBackward'})); expect(tree.getSelectedItem()).toBe(undefined); - expect(combobox.inputs.value()).toBe(undefined); + expect(tree.inputs.value()).toEqual([]); }); it('should not select on navigation', () => { const {combobox, tree} = getPatterns({filterMode: 'manual'}); combobox.onKeydown(down()); expect(tree.getSelectedItem()).toBe(undefined); - expect(combobox.inputs.value()).toBe(undefined); + expect(tree.inputs.value()).toEqual([]); }); it('should not select on input', () => { @@ -715,7 +718,7 @@ describe('Combobox with Tree Pattern', () => { inputEl.value = 'A'; combobox.onInput(new InputEvent('input')); expect(tree.getSelectedItem()).toBe(undefined); - expect(combobox.inputs.value()).toBe(undefined); + expect(tree.inputs.value()).toEqual([]); }); it('should not select on focusout if the input text does not match an item', () => { @@ -724,7 +727,7 @@ describe('Combobox with Tree Pattern', () => { combobox.onInput(new InputEvent('input')); combobox.onFocusOut(new FocusEvent('focusout')); expect(tree.getSelectedItem()).toBe(undefined); - expect(combobox.inputs.value()).toBe(undefined); + expect(tree.inputs.value()).toEqual([]); expect(inputEl.value).toBe('Appl'); }); }); @@ -734,17 +737,17 @@ describe('Combobox with Tree Pattern', () => { const {combobox, tree, inputEl} = getPatterns({filterMode: 'auto-select'}); combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 2)); expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[2]); - expect(combobox.inputs.value()).toBe('Banana'); + expect(tree.inputs.value()).toEqual(['Banana']); expect(inputEl.value).toBe('Banana'); }); it('should select and commit on Enter', () => { - const {combobox, inputEl} = getPatterns({filterMode: 'auto-select'}); + const {combobox, inputEl, tree} = getPatterns({filterMode: 'auto-select'}); combobox.onKeydown(down()); combobox.onKeydown(down()); combobox.onKeydown(down()); combobox.onKeydown(enter()); - expect(combobox.inputs.value()).toBe('Grains'); + expect(tree.inputs.value()).toEqual(['Grains']); expect(inputEl.value).toBe('Grains'); }); @@ -752,34 +755,34 @@ describe('Combobox with Tree Pattern', () => { const {combobox, tree} = getPatterns({filterMode: 'auto-select'}); combobox.onKeydown(down()); expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[0]); - expect(combobox.inputs.value()).toBe('Fruit'); + expect(tree.inputs.value()).toEqual(['Fruit']); }); it('should select the last focusable item on arrow up when collapsed', () => { - const {combobox} = getPatterns({filterMode: 'auto-select'}); + const {combobox, tree} = getPatterns({filterMode: 'auto-select'}); combobox.onKeydown(up()); - expect(combobox.inputs.value()).toBe('Grains'); + expect(tree.inputs.value()).toEqual(['Grains']); }); it('should select on navigation', () => { - const {combobox} = getPatterns({filterMode: 'auto-select'}); + const {combobox, tree} = getPatterns({filterMode: 'auto-select'}); combobox.onKeydown(down()); combobox.onKeydown(right()); combobox.onKeydown(right()); - expect(combobox.inputs.value()).toBe('Apple'); + expect(tree.inputs.value()).toEqual(['Apple']); }); it('should select the first option on input', () => { - const {combobox, inputEl} = getPatterns({filterMode: 'auto-select'}); + const {combobox, inputEl, tree} = getPatterns({filterMode: 'auto-select'}); inputEl.value = 'B'; combobox.onInput(new InputEvent('input')); - expect(combobox.inputs.value()).toBe('Banana'); + expect(tree.inputs.value()).toEqual(['Banana']); inputEl.value = 'Bro'; combobox.onInput(new InputEvent('input')); - expect(combobox.inputs.value()).toBe('Broccoli'); + expect(tree.inputs.value()).toEqual(['Broccoli']); }); it('should commit the selected option on focusout', () => { @@ -797,17 +800,17 @@ describe('Combobox with Tree Pattern', () => { const {combobox, tree, inputEl} = getPatterns({filterMode: 'highlight'}); combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 2)); expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[2]); - expect(combobox.inputs.value()).toBe('Banana'); + expect(tree.inputs.value()).toEqual(['Banana']); expect(inputEl.value).toBe('Banana'); }); it('should select and commit on Enter', () => { - const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); + const {combobox, inputEl, tree} = getPatterns({filterMode: 'highlight'}); combobox.onKeydown(down()); combobox.onKeydown(down()); combobox.onKeydown(down()); combobox.onKeydown(enter()); - expect(combobox.inputs.value()).toBe('Grains'); + expect(tree.inputs.value()).toEqual(['Grains']); expect(inputEl.value).toBe('Grains'); }); @@ -815,45 +818,45 @@ describe('Combobox with Tree Pattern', () => { const {combobox, tree} = getPatterns({filterMode: 'highlight'}); combobox.onKeydown(down()); expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[0]); - expect(combobox.inputs.value()).toBe('Fruit'); + expect(tree.inputs.value()).toEqual(['Fruit']); }); it('should select the last focusable item on arrow up when collapsed', () => { - const {combobox} = getPatterns({filterMode: 'highlight'}); + const {combobox, tree} = getPatterns({filterMode: 'highlight'}); combobox.onKeydown(up()); - expect(combobox.inputs.value()).toBe('Grains'); + expect(tree.inputs.value()).toEqual(['Grains']); }); it('should select on navigation', () => { - const {combobox} = getPatterns({filterMode: 'highlight'}); + const {combobox, tree} = getPatterns({filterMode: 'highlight'}); combobox.onKeydown(down()); combobox.onKeydown(right()); combobox.onKeydown(right()); - expect(combobox.inputs.value()).toBe('Apple'); + expect(tree.inputs.value()).toEqual(['Apple']); }); it('should select the first option on input', () => { - const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); + const {combobox, inputEl, tree} = getPatterns({filterMode: 'highlight'}); inputEl.value = 'B'; combobox.onInput(new InputEvent('input')); - expect(combobox.inputs.value()).toBe('Banana'); + expect(tree.inputs.value()).toEqual(['Banana']); inputEl.value = 'Bro'; combobox.onInput(new InputEvent('input')); - expect(combobox.inputs.value()).toBe('Broccoli'); + expect(tree.inputs.value()).toEqual(['Broccoli']); }); it('should commit the selected option on navigation', () => { - const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); + const {combobox, inputEl, tree} = getPatterns({filterMode: 'highlight'}); combobox.onKeydown(down()); expect(inputEl.value).toBe('Fruit'); combobox.onKeydown(right()); combobox.onKeydown(right()); expect(inputEl.value).toBe('Apple'); combobox.onKeydown(down()); - expect(combobox.inputs.value()).toBe('Banana'); + expect(tree.inputs.value()).toEqual(['Banana']); }); it('should commit the selected option on focusout', () => { diff --git a/src/cdk-experimental/ui-patterns/combobox/combobox.ts b/src/cdk-experimental/ui-patterns/combobox/combobox.ts index bfeb781fb9b9..050d1021c649 100644 --- a/src/cdk-experimental/ui-patterns/combobox/combobox.ts +++ b/src/cdk-experimental/ui-patterns/combobox/combobox.ts @@ -8,14 +8,11 @@ import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; import {computed, signal} from '@angular/core'; -import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; import {ListItem} from '../behaviors/list/list'; /** Represents the required inputs for a combobox. */ export type ComboboxInputs, V> = { - /** The current value of the combobox. */ - value: WritableSignalLike; - /** The controls for the popup associated with the combobox. */ popupControls: SignalLike | ComboboxTreeControls | undefined>; @@ -220,7 +217,6 @@ export class ComboboxPattern, V> { if (event instanceof InputEvent && event.inputType.match(/delete.*/)) { if (this.inputs.filterMode() === 'manual') { - this.inputs.value.set(undefined); this.inputs.popupControls()?.clearSelection(); } else { this.inputs.popupControls()?.select(); @@ -262,16 +258,14 @@ export class ComboboxPattern, V> { } setDefaultState() { - if (this.inputs.value() !== undefined) { - this.inputs.popupControls()?.setValue(this.inputs.value()); - - const inputEl = this.inputs.inputEl(); - const searchTerm = this.inputs.popupControls()?.getSelectedItem()?.searchTerm() ?? ''; - - if (inputEl) { - inputEl.value = searchTerm; - } - } + // if (this.inputs.value() !== undefined) { + // this.inputs.popupControls()?.setValue(this.inputs.value()); + // const inputEl = this.inputs.inputEl(); + // const searchTerm = this.inputs.popupControls()?.getSelectedItem()?.searchTerm() ?? ''; + // if (inputEl) { + // inputEl.value = searchTerm; + // } + // } } /** Closes the combobox. */ @@ -284,7 +278,6 @@ export class ComboboxPattern, V> { open(nav?: {first?: boolean; last?: boolean}) { this.expanded.set(true); this.inputs.popupControls()?.filter(this.inputs.inputEl()?.value ?? ''); - this.inputs.popupControls()?.setValue(this.inputs.value()); if (nav?.first) { this.first(); @@ -346,7 +339,6 @@ export class ComboboxPattern, V> { /** Selects an item in the combobox popup. */ select(opts: {item?: T; commit?: boolean; close?: boolean; highlight?: boolean} = {}) { this.inputs.popupControls()?.select(opts.item); - this.inputs.value.set(this.inputs.popupControls()?.getSelectedItem()?.value()); if (opts.commit) { this.commit(); diff --git a/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts b/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts index 462146378ec1..3b06f5724032 100644 --- a/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts +++ b/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts @@ -20,8 +20,6 @@ export class ComboboxTreePattern extends TreePattern implements ComboboxTreeControls, V> { - override id = computed(() => this.inputs.id()); - /** Whether the currently focused item is collapsible. */ isItemCollapsible = () => this.activeItem()?.parent() instanceof TreeItemPattern; diff --git a/src/cdk-experimental/ui-patterns/tree/tree.ts b/src/cdk-experimental/ui-patterns/tree/tree.ts index 619568a60560..2fd3f59bf994 100644 --- a/src/cdk-experimental/ui-patterns/tree/tree.ts +++ b/src/cdk-experimental/ui-patterns/tree/tree.ts @@ -299,6 +299,7 @@ export class TreePattern { }); constructor(readonly inputs: TreeInputs) { + this.id = inputs.id; this.nav = inputs.nav; this.currentType = inputs.currentType; this.allItems = inputs.allItems; From 4fe057b5bc6de9a6fafd6d61104812e5af1e82db Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Fri, 26 Sep 2025 15:28:46 -0400 Subject: [PATCH 17/23] fix(cdk-experimental/combobox): refactor combobox filtering --- .../combobox/combobox.spec.ts | 929 +++++++----------- src/cdk-experimental/combobox/combobox.ts | 22 +- src/cdk-experimental/listbox/listbox.ts | 1 - src/cdk-experimental/tree/tree.ts | 1 - .../behaviors/list-focus/list-focus.ts | 5 +- .../ui-patterns/combobox/combobox.spec.ts | 247 +++-- .../ui-patterns/combobox/combobox.ts | 182 ++-- .../ui-patterns/listbox/combobox-listbox.ts | 13 +- .../ui-patterns/listbox/listbox.ts | 8 - .../ui-patterns/listbox/option.ts | 3 - .../ui-patterns/tree/combobox-tree.ts | 52 +- src/cdk-experimental/ui-patterns/tree/tree.ts | 3 - .../cdk-experimental/combobox/BUILD.bazel | 1 + .../cdk-combobox-auto-select-example.html | 17 +- .../cdk-combobox-auto-select-example.ts | 112 ++- .../combobox/cdk-combobox-examples.css | 4 +- .../cdk-combobox-highlight-example.html | 22 +- .../cdk-combobox-highlight-example.ts | 114 ++- .../cdk-combobox-manual-example.html | 22 +- .../cdk-combobox-manual-example.ts | 114 ++- ...cdk-combobox-tree-auto-select-example.html | 18 +- .../cdk-combobox-tree-auto-select-example.ts | 38 +- .../cdk-combobox-tree-highlight-example.html | 18 +- .../cdk-combobox-tree-highlight-example.ts | 38 +- .../cdk-combobox-tree-manual-example.html | 18 +- .../cdk-combobox-tree-manual-example.ts | 38 +- .../cdk-experimental/combobox/data.ts | 31 +- 27 files changed, 1020 insertions(+), 1051 deletions(-) diff --git a/src/cdk-experimental/combobox/combobox.spec.ts b/src/cdk-experimental/combobox/combobox.spec.ts index bf2bbe3b6fc6..c3198de00b30 100644 --- a/src/cdk-experimental/combobox/combobox.spec.ts +++ b/src/cdk-experimental/combobox/combobox.spec.ts @@ -1,4 +1,4 @@ -import {Component, DebugElement, signal} from '@angular/core'; +import {Component, computed, DebugElement, signal} from '@angular/core'; import {ComponentFixture, TestBed, fakeAsync, tick} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import { @@ -138,12 +138,7 @@ describe('Combobox', () => { it('should set aria-selected on the selected option', () => { down(); enter(); - - const appleOption = getOption('Apple')!; - const apricotOption = getOption('Apricot')!; - - expect(appleOption.getAttribute('aria-selected')).toBe('true'); - expect(apricotOption.getAttribute('aria-selected')).toBe('false'); + expect(getOption('Alabama')!.getAttribute('aria-selected')).toBe('true'); }); it('should set aria-expanded to false by default', () => { @@ -163,7 +158,7 @@ describe('Combobox', () => { it('should set aria-activedescendant to the active option id', () => { down(); - const option = getOption('Apple')!; + const option = getOption('Alabama')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(option.id); }); }); @@ -253,7 +248,7 @@ describe('Combobox', () => { it('should not close on focusout if focus moves to an element inside the container', () => { down(); - blur(getOption('Apple')!); + blur(getOption('Alabama')!); expect(inputElement.getAttribute('aria-expanded')).toBe('true'); }); @@ -261,7 +256,7 @@ describe('Combobox', () => { fixture.componentInstance.filterMode.set('highlight'); focus(); input('A'); - expect(inputElement.value).toBe('Apple'); + expect(inputElement.value).toBe('Alabama'); expect(inputElement.getAttribute('aria-expanded')).toBe('true'); escape(); expect(inputElement.value).toBe('A'); @@ -279,7 +274,7 @@ describe('Combobox', () => { it('should close on click to select an item', () => { down(); - const fruitItem = getOption('Apple')!; + const fruitItem = getOption('Alabama')!; click(fruitItem); expect(inputElement.getAttribute('aria-expanded')).toBe('false'); }); @@ -295,16 +290,17 @@ describe('Combobox', () => { click(options[0]); fixture.detectChanges(); - expect(fixture.componentInstance.value()).toEqual(['Apple']); - expect(inputElement.value).toBe('Apple'); + expect(fixture.componentInstance.value()).toEqual(['Alabama']); + expect(inputElement.value).toBe('Alabama'); }); it('should select and commit to input on Enter', () => { + focus(); down(); enter(); - expect(fixture.componentInstance.value()).toEqual(['Apple']); - expect(inputElement.value).toBe('Apple'); + expect(fixture.componentInstance.value()).toEqual(['Alabama']); + expect(inputElement.value).toBe('Alabama'); }); it('should not select on navigation', () => { @@ -316,10 +312,10 @@ describe('Combobox', () => { it('should select on focusout if the input text exactly matches an item', () => { focus(); - input('Apple'); + input('Alabama'); blur(); - expect(fixture.componentInstance.value()).toEqual(['Apple']); + expect(fixture.componentInstance.value()).toEqual(['Alabama']); }); it('should not select on focusout if the input text does not match an item', () => { @@ -341,8 +337,8 @@ describe('Combobox', () => { click(options[1]); fixture.detectChanges(); - expect(fixture.componentInstance.value()).toEqual(['Apricot']); - expect(inputElement.value).toBe('Apricot'); + expect(fixture.componentInstance.value()).toEqual(['Alaska']); + expect(inputElement.value).toBe('Alaska'); }); it('should select and commit on Enter', () => { @@ -350,32 +346,35 @@ describe('Combobox', () => { down(); enter(); - expect(fixture.componentInstance.value()).toEqual(['Apricot']); - expect(inputElement.value).toBe('Apricot'); + expect(fixture.componentInstance.value()).toEqual(['Alaska']); + expect(inputElement.value).toBe('Alaska'); }); it('should select on navigation', () => { down(); - expect(fixture.componentInstance.value()).toEqual(['Apple']); + expect(fixture.componentInstance.value()).toEqual(['Alabama']); down(); - expect(fixture.componentInstance.value()).toEqual(['Apricot']); + expect(fixture.componentInstance.value()).toEqual(['Alaska']); + + down(); + expect(fixture.componentInstance.value()).toEqual(['Arizona']); }); it('should select the first option on input', () => { focus(); - input('B'); + input('W'); - expect(fixture.componentInstance.value()).toEqual(['Banana']); + expect(fixture.componentInstance.value()).toEqual(['Washington']); }); it('should commit the selected option on focusout', () => { focus(); - input('Apr'); + input('G'); blur(); - expect(inputElement.value).toBe('Apricot'); - expect(fixture.componentInstance.value()).toEqual(['Apricot']); + expect(inputElement.value).toBe('Georgia'); + expect(fixture.componentInstance.value()).toEqual(['Georgia']); }); }); @@ -388,8 +387,8 @@ describe('Combobox', () => { click(options[2]); fixture.detectChanges(); - expect(fixture.componentInstance.value()).toEqual(['Banana']); - expect(inputElement.value).toBe('Banana'); + expect(fixture.componentInstance.value()).toEqual(['Arizona']); + expect(inputElement.value).toBe('Arizona'); }); it('should select and commit on Enter', () => { @@ -398,31 +397,31 @@ describe('Combobox', () => { down(); enter(); - expect(fixture.componentInstance.value()).toEqual(['Banana']); - expect(inputElement.value).toBe('Banana'); + expect(fixture.componentInstance.value()).toEqual(['Arizona']); + expect(inputElement.value).toBe('Arizona'); }); it('should select on navigation', () => { down(); - expect(fixture.componentInstance.value()).toEqual(['Apple']); + expect(fixture.componentInstance.value()).toEqual(['Alabama']); down(); - expect(fixture.componentInstance.value()).toEqual(['Apricot']); + expect(fixture.componentInstance.value()).toEqual(['Alaska']); }); it('should update input value on navigation', () => { down(); - expect(inputElement.value).toBe('Apple'); + expect(inputElement.value).toBe('Alabama'); down(); - expect(inputElement.value).toBe('Apricot'); + expect(inputElement.value).toBe('Alaska'); }); it('should select the first option on input', () => { focus(); - input('Canta'); + input('Cali'); - expect(fixture.componentInstance.value()).toEqual(['Cantaloupe']); + expect(fixture.componentInstance.value()).toEqual(['California']); }); it('should insert a highlighted completion string on input', fakeAsync(() => { @@ -430,194 +429,75 @@ describe('Combobox', () => { input('A'); tick(); - expect(inputElement.value).toBe('Apple'); + expect(inputElement.value).toBe('Alabama'); expect(inputElement.selectionStart).toBe(1); - expect(inputElement.selectionEnd).toBe(5); + expect(inputElement.selectionEnd).toBe(7); })); it('should commit the selected option on focusout', () => { focus(); - input('Apr'); + input('Cali'); blur(); - expect(inputElement.value).toBe('Apricot'); - expect(fixture.componentInstance.value()).toEqual(['Apricot']); + expect(inputElement.value).toBe('California'); + expect(fixture.componentInstance.value()).toEqual(['California']); }); }); }); - describe('with disabled options', () => { - beforeEach(() => { - setupCombobox(); - fixture.componentInstance.options.set([ - {name: 'Apple'}, - {name: 'Apricot', disabled: true}, - {name: 'Banana'}, - {name: 'Blackberry', disabled: true}, - {name: 'Blueberry'}, - ]); - fixture.detectChanges(); - }); - - it('should not select a disabled option by clicking', () => { - click(inputElement); - const disabledOption = getOption('Apricot')!; - click(disabledOption); - - expect(fixture.componentInstance.value()).toEqual([]); - }); - - it('should skip disabled options during keyboard navigation', () => { - down(); // To Apple - expect(inputElement.getAttribute('aria-activedescendant')).toBe(getOption('Apple')!.id); - - down(); // Should skip Apricot and go to Banana - expect(inputElement.getAttribute('aria-activedescendant')).toBe(getOption('Banana')!.id); - - down(); // Should skip Blackberry and go to Blueberry - expect(inputElement.getAttribute('aria-activedescendant')).toBe(getOption('Blueberry')!.id); - - up(); // Back to Banana - expect(inputElement.getAttribute('aria-activedescendant')).toBe(getOption('Banana')!.id); - - up(); // Back to Apple - expect(inputElement.getAttribute('aria-activedescendant')).toBe(getOption('Apple')!.id); - }); - - it('should not select disabled option with auto-select on input', () => { - fixture.componentInstance.filterMode.set('auto-select'); - fixture.detectChanges(); - - input('Apr'); - - expect(fixture.componentInstance.value()).toEqual([]); - }); - - it('should not select disabled option with highlight on input', () => { - fixture.componentInstance.filterMode.set('highlight'); - fixture.detectChanges(); - - input('Apr'); - - expect(fixture.componentInstance.value()).toEqual([]); - expect(inputElement.value).toBe('Apr'); - }); - }); - - describe('with dynamic data', () => { - beforeEach(() => setupCombobox()); - - it('should update active item if an option is removed', () => { - down(); // -> Apple - down(); // -> Apricot - - const apricot = getOption('Apricot')!; - expect(inputElement.getAttribute('aria-activedescendant')).toBe(apricot.id); - - fixture.componentInstance.options.set( - fixture.componentInstance.options().filter(n => n.name !== 'Apricot'), - ); - fixture.detectChanges(); - expect(inputElement.getAttribute('aria-activedescendant')).toBe(null); - }); - - it('should update the combobox value if the selected item is removed', () => { - down(); // -> Apple - enter(); - expect(fixture.componentInstance.value()).toEqual(['Apple']); - expect(inputElement.value).toBe('Apple'); - - fixture.componentInstance.options.set( - fixture.componentInstance.options().filter(n => n.name !== 'Apple'), - ); - fixture.detectChanges(); - - expect(fixture.componentInstance.value()).toEqual([]); - }); - - it('should clear active item if listbox becomes empty', () => { - down(); // -> Apple - expect(inputElement.hasAttribute('aria-activedescendant')).toBe(true); - - fixture.componentInstance.options.set([]); - fixture.detectChanges(); - - expect(inputElement.hasAttribute('aria-activedescendant')).toBe(false); - expect(getOptions().length).toBe(0); - }); - }); + // TODO(wagnermaciel): Add unit tests for disabled options. describe('Filtering', () => { - const getVisibleOptions = () => getOptions().filter(o => !o.inert); - beforeEach(() => setupCombobox()); it('should lazily render options', () => { expect(getOptions().length).toBe(0); focus(); - expect(getOptions().length).toBe(9); + expect(getOptions().length).toBe(50); }); it('should filter the options based on the input value', () => { focus(); - input('ap'); - - let options = getVisibleOptions(); - expect(options.length).toBe(2); - expect(options[0].textContent?.trim()).toBe('Apple'); - expect(options[1].textContent?.trim()).toBe('Apricot'); + input('New'); - input('apple'); - options = getVisibleOptions(); - expect(options.length).toBe(1); - expect(options[0].textContent?.trim()).toBe('Apple'); + const options = getOptions(); + expect(options.length).toBe(4); + expect(options[0].textContent?.trim()).toBe('New Hampshire'); + expect(options[1].textContent?.trim()).toBe('New Jersey'); + expect(options[2].textContent?.trim()).toBe('New Mexico'); + expect(options[3].textContent?.trim()).toBe('New York'); }); it('should show no options if nothing matches', () => { focus(); input('xyz'); - const options = getVisibleOptions(); + const options = getOptions(); expect(options.length).toBe(0); }); it('should show all options when the input is cleared', () => { focus(); - input('Apple'); - expect(getVisibleOptions().length).toBe(1); + input('Alabama'); + expect(getOptions().length).toBe(1); input(''); - expect(getVisibleOptions().length).toBe(9); - }); - - it('should allow changing the filter function', () => { - fixture.componentInstance.filterFn.set( - (inputText, itemText) => itemText.includes(inputText), // Case sensitive filter. - ); - - focus(); - input('apple'); - expect(getVisibleOptions().length).toBe(0); - - input('Apple'); - const options = getVisibleOptions(); - expect(options.length).toBe(1); - expect(options[0].textContent?.trim()).toBe('Apple'); + expect(getOptions().length).toBe(50); }); }); - describe('with programmatic value changes', () => { - // TODO(wagnermaciel): Figure out if there's a way to automatically update the - // input value when the popup value signal is updated programmatically. - it('should update the selected item when the value is set programmatically', () => { - setupCombobox(); - focus(); - fixture.componentInstance.value.set(['Banana']); - fixture.detectChanges(); - expect(fixture.componentInstance.value()).toEqual(['Banana']); - const bananaOption = getOption('Banana')!; - expect(bananaOption.getAttribute('aria-selected')).toBe('true'); - }); - }); + // describe('with programmatic value changes', () => { + // // TODO(wagnermaciel): Figure out if there's a way to automatically update the + // // input value when the popup value signal is updated programmatically. + // it('should update the selected item when the value is set programmatically', () => { + // setupCombobox(); + // focus(); + // fixture.componentInstance.value.set(['Banana']); + // fixture.detectChanges(); + // expect(fixture.componentInstance.value()).toEqual(['Banana']); + // const bananaOption = getOption('Banana')!; + // expect(bananaOption.getAttribute('aria-selected')).toBe('true'); + // }); + // }); }); describe('with Tree', () => { @@ -636,10 +516,13 @@ describe('Combobox', () => { fixture.detectChanges(); }; - const input = (value: string) => { + const input = (value: string, opts: {backspace?: boolean} = {}) => { focus(); inputElement.value = value; - inputElement.dispatchEvent(new Event('input', {bubbles: true})); + const event = opts.backspace + ? new InputEvent('input', {inputType: 'deleteContentBackward', bubbles: true}) + : new InputEvent('input', {bubbles: true}); + inputElement.dispatchEvent(event); fixture.detectChanges(); }; @@ -701,7 +584,14 @@ describe('Combobox', () => { return fixture.debugElement .queryAll(By.directive(CdkTreeItem)) .map((debugEl: DebugElement) => debugEl.nativeElement as HTMLElement) - .filter(el => !el.parentElement?.hasAttribute('inert') && !el.hasAttribute('inert')); + .filter(el => { + if (el.parentElement?.role === 'group') { + return ( + el.parentElement.previousElementSibling?.getAttribute('aria-expanded') === 'true' + ); + } + return true; + }); } afterEach(async () => await runAccessibilityChecks(fixture.nativeElement)); @@ -721,23 +611,23 @@ describe('Combobox', () => { }); it('should set aria-selected on the selected tree item', () => { - down(); // -> Fruit + down(); enter(); - const fruitItem = getTreeItem('Fruit')!; - expect(fruitItem.getAttribute('aria-selected')).toBe('true'); + const item = getTreeItem('Winter')!; + expect(item.getAttribute('aria-selected')).toBe('true'); }); it('should toggle aria-expanded on parent nodes', () => { - down(); // -> Fruit - const fruitItem = getTreeItem('Fruit')!; - expect(fruitItem.getAttribute('aria-expanded')).toBe('false'); + down(); + const item = getTreeItem('Winter')!; + expect(item.getAttribute('aria-expanded')).toBe('false'); - right(); // Expand Fruit - expect(fruitItem.getAttribute('aria-expanded')).toBe('true'); + right(); + expect(item.getAttribute('aria-expanded')).toBe('true'); - left(); // Collapse Fruit - expect(fruitItem.getAttribute('aria-expanded')).toBe('false'); + left(); + expect(item.getAttribute('aria-expanded')).toBe('false'); }); }); @@ -746,82 +636,81 @@ describe('Combobox', () => { it('should navigate to the first focusable item on ArrowDown', () => { down(); - const fruitItem = getTreeItem('Fruit')!; - expect(inputElement.getAttribute('aria-activedescendant')).toBe(fruitItem.id); + const item = getTreeItem('Winter')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); it('should navigate to the last focusable item on ArrowUp', () => { up(); - const grainsItem = getTreeItem('Grains')!; - expect(inputElement.getAttribute('aria-activedescendant')).toBe(grainsItem.id); + const item = getTreeItem('Fall')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); it('should navigate to the next focusable item on ArrowDown when open', () => { down(); down(); - const vegetablesItem = getTreeItem('Vegetables')!; - expect(inputElement.getAttribute('aria-activedescendant')).toBe(vegetablesItem.id); + const item = getTreeItem('Spring')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); it('should navigate to the previous item on ArrowUp when open', () => { up(); up(); - const vegetablesItem = getTreeItem('Vegetables')!; - expect(inputElement.getAttribute('aria-activedescendant')).toBe(vegetablesItem.id); + const item = getTreeItem('Summer')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); it('should expand a closed node on ArrowRight', () => { - down(); // To Fruit - expect(getVisibleTreeItems().length).toBe(3); + down(); + expect(getVisibleTreeItems().length).toBe(4); right(); fixture.detectChanges(); - expect(getVisibleTreeItems().length).toBe(6); - const appleItem = getTreeItem('Apple')!; - expect(appleItem).not.toBeNull(); + expect(getVisibleTreeItems().length).toBe(7); + expect(getTreeItem('January')).not.toBeNull(); }); it('should navigate to the next item on ArrowRight when already expanded', () => { - down(); // To Fruit - right(); // Expand Fruit - right(); // To Apple - const appleItem = getTreeItem('Apple')!; - expect(inputElement.getAttribute('aria-activedescendant')).toBe(appleItem.id); + down(); + right(); + right(); + const item = getTreeItem('December')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); it('should collapse an open node on ArrowLeft', () => { - down(); // To Fruit - right(); // Expand Fruit + down(); + right(); fixture.detectChanges(); - expect(getVisibleTreeItems().length).toBe(6); - left(); // Collapse Fruit + expect(getVisibleTreeItems().length).toBe(7); + left(); fixture.detectChanges(); - expect(getVisibleTreeItems().length).toBe(3); - const fruitItem = getTreeItem('Fruit')!; - expect(inputElement.getAttribute('aria-activedescendant')).toBe(fruitItem.id); + expect(getVisibleTreeItems().length).toBe(4); + const item = getTreeItem('Winter')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); it('should navigate to the parent node on ArrowLeft when in a child node', () => { - down(); // To Fruit - right(); // Expand Fruit - right(); // To Apple - const appleItem = getTreeItem('Apple')!; - expect(inputElement.getAttribute('aria-activedescendant')).toBe(appleItem.id); - left(); // To Fruit - const fruitItem = getTreeItem('Fruit')!; - expect(inputElement.getAttribute('aria-activedescendant')).toBe(fruitItem.id); + down(); + right(); + right(); + const item1 = getTreeItem('December')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item1.id); + left(); + const item2 = getTreeItem('Winter')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item2.id); }); it('should navigate to the first focusable item on Home when open', () => { up(); keydown('Home'); - const fruitItem = getTreeItem('Fruit')!; - expect(inputElement.getAttribute('aria-activedescendant')).toBe(fruitItem.id); + const item = getTreeItem('Winter')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); }); it('should navigate to the last focusable item on End when open', () => { down(); keydown('End'); - const grainsItem = getTreeItem('Grains')!; + const grainsItem = getTreeItem('Fall')!; expect(inputElement.getAttribute('aria-activedescendant')).toBe(grainsItem.id); }); }); @@ -832,28 +721,28 @@ describe('Combobox', () => { it('should select and commit on click', () => { click(inputElement); - const fruitItem = getTreeItem('Fruit')!; - click(fruitItem); + const item = getTreeItem('April')!; + click(item); fixture.detectChanges(); - expect(fixture.componentInstance.value()).toEqual(['Fruit']); - expect(inputElement.value).toBe('Fruit'); + expect(fixture.componentInstance.value()).toEqual(['April']); + expect(inputElement.value).toBe('April'); }); it('should select and commit to input on Enter', () => { down(); enter(); - expect(fixture.componentInstance.value()).toEqual(['Fruit']); - expect(inputElement.value).toBe('Fruit'); + expect(fixture.componentInstance.value()).toEqual(['Winter']); + expect(inputElement.value).toBe('Winter'); }); it('should select on focusout if the input text exactly matches an item', () => { focus(); - input('Apple'); + input('November'); blur(); - expect(fixture.componentInstance.value()).toEqual(['Apple']); + expect(fixture.componentInstance.value()).toEqual(['November']); }); it('should not select on navigation', () => { @@ -880,12 +769,12 @@ describe('Combobox', () => { click(inputElement); down(); right(); - const appleItem = getTreeItem('Apple')!; - click(appleItem); + const item = getTreeItem('February')!; + click(item); fixture.detectChanges(); - expect(fixture.componentInstance.value()).toEqual(['Apple']); - expect(inputElement.value).toBe('Apple'); + expect(fixture.componentInstance.value()).toEqual(['February']); + expect(inputElement.value).toBe('February'); }); it('should select and commit on Enter', () => { @@ -893,32 +782,31 @@ describe('Combobox', () => { down(); enter(); - expect(fixture.componentInstance.value()).toEqual(['Vegetables']); - expect(inputElement.value).toBe('Vegetables'); + expect(fixture.componentInstance.value()).toEqual(['Spring']); + expect(inputElement.value).toBe('Spring'); }); it('should select on navigation', () => { down(); - expect(fixture.componentInstance.value()).toEqual(['Fruit']); + expect(fixture.componentInstance.value()).toEqual(['Winter']); down(); - expect(fixture.componentInstance.value()).toEqual(['Vegetables']); + expect(fixture.componentInstance.value()).toEqual(['Spring']); }); it('should select the first option on input', () => { focus(); - input('B'); - - expect(fixture.componentInstance.value()).toEqual(['Banana']); + input('Dec'); + expect(fixture.componentInstance.value()).toEqual(['December']); }); it('should commit the selected option on focusout', () => { focus(); - input('App'); + input('Jun'); blur(); - expect(inputElement.value).toBe('Apple'); - expect(fixture.componentInstance.value()).toEqual(['Apple']); + expect(inputElement.value).toBe('June'); + expect(fixture.componentInstance.value()).toEqual(['June']); }); }); @@ -929,12 +817,12 @@ describe('Combobox', () => { click(inputElement); down(); right(); - const bananaItem = getTreeItem('Banana')!; - click(bananaItem); + const item = getTreeItem('February')!; + click(item); fixture.detectChanges(); - expect(fixture.componentInstance.value()).toEqual(['Banana']); - expect(inputElement.value).toBe('Banana'); + expect(fixture.componentInstance.value()).toEqual(['February']); + expect(inputElement.value).toBe('February'); }); it('should select and commit on Enter', () => { @@ -942,50 +830,50 @@ describe('Combobox', () => { down(); enter(); - expect(fixture.componentInstance.value()).toEqual(['Vegetables']); - expect(inputElement.value).toBe('Vegetables'); + expect(fixture.componentInstance.value()).toEqual(['Spring']); + expect(inputElement.value).toBe('Spring'); }); it('should select on navigation', () => { down(); - expect(fixture.componentInstance.value()).toEqual(['Fruit']); + expect(fixture.componentInstance.value()).toEqual(['Winter']); down(); - expect(fixture.componentInstance.value()).toEqual(['Vegetables']); + expect(fixture.componentInstance.value()).toEqual(['Spring']); }); it('should update input value on navigation', () => { down(); - expect(inputElement.value).toBe('Fruit'); + expect(inputElement.value).toBe('Winter'); down(); - expect(inputElement.value).toBe('Vegetables'); + expect(inputElement.value).toBe('Spring'); }); it('should select the first option on input', () => { focus(); - input('Canta'); + input('Sept'); - expect(fixture.componentInstance.value()).toEqual(['Cantaloupe']); + expect(fixture.componentInstance.value()).toEqual(['September']); }); it('should insert a highlighted completion string on input', fakeAsync(() => { focus(); - input('A'); + input('Feb'); tick(); - expect(inputElement.value).toBe('Apple'); - expect(inputElement.selectionStart).toBe(1); - expect(inputElement.selectionEnd).toBe(5); + expect(inputElement.value).toBe('February'); + expect(inputElement.selectionStart).toBe(3); + expect(inputElement.selectionEnd).toBe(8); })); it('should commit the selected option on focusout', () => { focus(); - input('App'); + input('Jan'); blur(); - expect(inputElement.value).toBe('Apple'); - expect(fixture.componentInstance.value()).toEqual(['Apple']); + expect(inputElement.value).toBe('January'); + expect(fixture.componentInstance.value()).toEqual(['January']); }); }); }); @@ -1025,21 +913,21 @@ describe('Combobox', () => { it('should not close on focusout if focus moves to an element inside the container', () => { down(); - blur(getTreeItem('Fruit')!); + blur(getTreeItem('Spring')!); expect(inputElement.getAttribute('aria-expanded')).toBe('true'); }); it('should clear the completion string and not close on escape when a completion is present', () => { fixture.componentInstance.filterMode.set('highlight'); focus(); - input('A'); - expect(inputElement.value).toBe('Apple'); + input('Mar'); + expect(inputElement.value).toBe('March'); expect(inputElement.getAttribute('aria-expanded')).toBe('true'); escape(); - expect(inputElement.value).toBe('A'); + expect(inputElement.value).toBe('Mar'); expect(inputElement.getAttribute('aria-expanded')).toBe('true'); escape(); - expect(inputElement.value).toBe('A'); + expect(inputElement.value).toBe('Mar'); expect(inputElement.getAttribute('aria-expanded')).toBe('false'); }); @@ -1051,161 +939,12 @@ describe('Combobox', () => { it('should close on click to select an item', () => { down(); - const fruitItem = getTreeItem('Fruit')!; - click(fruitItem); + click(getTreeItem('Spring')!); expect(inputElement.getAttribute('aria-expanded')).toBe('false'); }); }); - describe('with disabled items', () => { - beforeEach(() => { - setupCombobox(); - fixture.componentInstance.nodes.set([ - { - name: 'Fruit', - value: 'Fruit', - children: [ - {name: 'Apple', value: 'Apple'}, - {name: 'Banana', value: 'Banana', disabled: true}, - {name: 'Cantaloupe', value: 'Cantaloupe'}, - ], - }, - { - name: 'Vegetables', - value: 'Vegetables', - disabled: true, - children: [ - {name: 'Broccoli', value: 'Broccoli'}, - {name: 'Carrot', value: 'Carrot'}, - ], - }, - { - name: 'Grains', - value: 'Grains', - }, - ]); - fixture.detectChanges(); - }); - - it('should not select a disabled item by clicking', () => { - click(inputElement); - const disabledItem = getTreeItem('Vegetables')!; - click(disabledItem); - - expect(fixture.componentInstance.value()).toEqual([]); - }); - - it('should skip disabled items during keyboard navigation', () => { - down(); // To Fruit - expect(inputElement.getAttribute('aria-activedescendant')).toBe(getTreeItem('Fruit')!.id); - - down(); // Should skip Vegetables and go to Grains - expect(inputElement.getAttribute('aria-activedescendant')).toBe(getTreeItem('Grains')!.id); - - up(); // Back to Fruit - expect(inputElement.getAttribute('aria-activedescendant')).toBe(getTreeItem('Fruit')!.id); - }); - - it('should skip disabled child items during keyboard navigation', () => { - down(); // To Fruit - right(); // Expand Fruit - down(); // To Apple - expect(inputElement.getAttribute('aria-activedescendant')).toBe(getTreeItem('Apple')!.id); - - down(); // Should skip Banana and go to Cantaloupe - expect(inputElement.getAttribute('aria-activedescendant')).toBe( - getTreeItem('Cantaloupe')!.id, - ); - }); - - it('should not select disabled item with auto-select on input', () => { - fixture.componentInstance.filterMode.set('auto-select'); - fixture.detectChanges(); - - input('Vege'); // Matches 'Vegetables', which is disabled. - - expect(fixture.componentInstance.value()).toEqual([]); - }); - - it('should not highlight disabled item with highlight on input', () => { - fixture.componentInstance.filterMode.set('highlight'); - fixture.detectChanges(); - - input('Vege'); // Matches 'Vegetables', which is disabled. - - expect(fixture.componentInstance.value()).toEqual([]); - expect(inputElement.value).toBe('Vege'); - }); - - it('should not select disabled child item with auto-select on input', () => { - fixture.componentInstance.filterMode.set('auto-select'); - fixture.detectChanges(); - - input('Bana'); // Matches 'Banana', which is disabled. - - expect(fixture.componentInstance.value()).toEqual([]); - }); - }); - - describe('with dynamic data', () => { - beforeEach(() => setupCombobox()); - - it('should update active item if a top-level node is removed', () => { - down(); // -> Fruit - down(); // -> Vegetables - - const vegetables = getTreeItem('Vegetables')!; - expect(inputElement.getAttribute('aria-activedescendant')).toBe(vegetables.id); - - fixture.componentInstance.nodes.set( - fixture.componentInstance.nodes().filter(n => n.name !== 'Vegetables'), - ); - fixture.detectChanges(); - expect(inputElement.getAttribute('aria-activedescendant')).toBe(null); - }); - - it('should update active item if a child node is removed', () => { - down(); // -> Fruit - right(); // Expand Fruit - down(); // -> Apple - down(); // -> Banana - - const banana = getTreeItem('Banana')!; - expect(inputElement.getAttribute('aria-activedescendant')).toBe(banana.id); - - const nodes = fixture.componentInstance.nodes(); - nodes[0].children = nodes[0].children!.filter(c => c.name !== 'Banana'); - fixture.componentInstance.nodes.set([...nodes]); - fixture.detectChanges(); - - expect(inputElement.getAttribute('aria-activedescendant')).toBe(null); - }); - - it('should update the combobox value if the selected item is removed', () => { - down(); // -> Fruit - enter(); - expect(fixture.componentInstance.value()).toEqual(['Fruit']); - expect(inputElement.value).toBe('Fruit'); - - fixture.componentInstance.nodes.set( - fixture.componentInstance.nodes().filter(n => n.name !== 'Fruit'), - ); - fixture.detectChanges(); - - expect(fixture.componentInstance.value()).toEqual([]); - }); - - it('should clear active item if tree becomes empty', () => { - down(); // -> Fruit - expect(inputElement.hasAttribute('aria-activedescendant')).toBe(true); - - fixture.componentInstance.nodes.set([]); - fixture.detectChanges(); - - expect(inputElement.hasAttribute('aria-activedescendant')).toBe(false); - expect(getVisibleTreeItems().length).toBe(0); - }); - }); + // TODO(wagnermaciel): Add unit tests for disabled options. describe('Filtering', () => { beforeEach(() => setupCombobox()); @@ -1213,26 +952,26 @@ describe('Combobox', () => { it('should lazily render options', () => { expect(getTreeItems().length).toBe(0); focus(); - expect(getTreeItems().length).toBe(11); + expect(getTreeItems().length).toBe(16); }); it('should filter the options based on the input value', () => { focus(); - input('vegetables'); + input('Summer'); let items = getVisibleTreeItems(); expect(items.length).toBe(1); - expect(items[0].textContent?.trim()).toBe('Vegetables'); + expect(items[0].textContent?.trim()).toBe('Summer'); }); it('should render parents if a child matches', () => { focus(); - input('broccoli'); + input('January'); let items = getVisibleTreeItems(); expect(items.length).toBe(2); - expect(items[0].textContent?.trim()).toBe('Vegetables'); - expect(items[1].textContent?.trim()).toBe('Broccoli'); + expect(items[0].textContent?.trim()).toBe('Winter'); + expect(items[1].textContent?.trim()).toBe('January'); }); it('should show no options if nothing matches', () => { @@ -1243,35 +982,21 @@ describe('Combobox', () => { it('should show all options when the input is cleared', () => { focus(); - input('Fruit'); + input('Winter'); expect(getVisibleTreeItems().length).toBe(1); - input(''); - expect(getVisibleTreeItems().length).toBe(3); + input('', {backspace: true}); + fixture.detectChanges(); + expect(getVisibleTreeItems().length).toBe(4); }); it('should expand all nodes when filtering', () => { focus(); - expect(getVisibleTreeItems().length).toBe(3); - - input('a'); - expect(getTreeItem('Fruit')!.getAttribute('aria-expanded')).toBe('true'); - expect(getTreeItem('Vegetables')!.getAttribute('aria-expanded')).toBe('true'); - expect(getTreeItem('Grains')!.getAttribute('aria-expanded')).toBe('true'); - }); - - it('should allow changing the filter function', () => { - focus(); - fixture.componentInstance.filterFn.set( - (inputText, itemText) => itemText.includes(inputText), // Case sensitive filter. - ); - input('fruit'); - expect(getVisibleTreeItems().length).toBe(0); + expect(getVisibleTreeItems().length).toBe(4); - input('Fruit'); - const options = getVisibleTreeItems(); - expect(options.length).toBe(1); - expect(options[0].textContent?.trim()).toBe('Fruit'); + input('J'); + expect(getTreeItem('Winter')!.getAttribute('aria-expanded')).toBe('true'); + expect(getTreeItem('Summer')!.getAttribute('aria-expanded')).toBe('true'); }); }); @@ -1281,10 +1006,10 @@ describe('Combobox', () => { it('should update the selected item when the value is set programmatically', () => { setupCombobox(); focus(); - fixture.componentInstance.value.set(['Fruit']); + fixture.componentInstance.value.set(['August']); fixture.detectChanges(); - expect(fixture.componentInstance.value()).toEqual(['Fruit']); - expect(getTreeItem('Fruit')!.getAttribute('aria-selected')).toBe('true'); + expect(fixture.componentInstance.value()).toEqual(['August']); + expect(getTreeItem('August')!.getAttribute('aria-selected')).toBe('true'); }); }); }); @@ -1292,17 +1017,31 @@ describe('Combobox', () => { @Component({ template: ` -
            - - - -
            - @for (option of options(); track option.name) { -
          • {{ option.name }}
          • - } +
            + + + +
            + @for (option of options(); track option) { +
            + {{option}}
            - + }
            +
            +
            `, imports: [ CdkCombobox, @@ -1315,63 +1054,63 @@ describe('Combobox', () => { }) class ComboboxListboxExample { value = signal([]); - filterFn = signal<(inputText: string, itemText: string) => boolean>((inputText, itemText) => - itemText.toLowerCase().includes(inputText.toLowerCase()), - ); + filterMode = signal<'manual' | 'auto-select' | 'highlight'>('manual'); - options = signal<{name: string; disabled?: boolean}[]>([ - {name: 'Apple'}, - {name: 'Apricot'}, - {name: 'Banana'}, - {name: 'Blackberry'}, - {name: 'Blueberry'}, - {name: 'Cantaloupe'}, - {name: 'Cherry'}, - {name: 'Clementine'}, - {name: 'Cranberry'}, - ]); + + searchString = signal(''); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); } @Component({ template: ` -
            - - - -
              +
              + + + +
                + +
              +
              +
              + + + @for (node of nodes; track node.name) { +
            • + {{ node.name }} +
            • + + @if (node.children) { +
                + -
              - - - @for (node of nodes; track node.value) { -
            • - {{ node.name }} -
            • - - @if (node.children) { -
                - - - -
              - } - }
              -
              -
            +
          + } + } + `, imports: [ CdkCombobox, @@ -1386,43 +1125,113 @@ class ComboboxListboxExample { }) class ComboboxTreeExample { value = signal([]); - filterFn = signal<(inputText: string, itemText: string) => boolean>((inputText, itemText) => - itemText.toLowerCase().includes(inputText.toLowerCase()), - ); + filterMode = signal<'manual' | 'auto-select' | 'highlight'>('manual'); - nodes = signal< - { - name: string; - value: string; - disabled?: boolean; - children?: {name: string; value: string; disabled?: boolean}[]; - }[] - >([ - { - name: 'Fruit', - value: 'Fruit', - children: [ - {name: 'Apple', value: 'Apple'}, - {name: 'Banana', value: 'Banana'}, - {name: 'Cantaloupe', value: 'Cantaloupe'}, - ], - }, - { - name: 'Vegetables', - value: 'Vegetables', - children: [ - {name: 'Broccoli', value: 'Broccoli'}, - {name: 'Carrot', value: 'Carrot'}, - {name: 'Lettuce', value: 'Lettuce'}, - ], - }, - { - name: 'Grains', - value: 'Grains', - children: [ - {name: 'Rice', value: 'Rice'}, - {name: 'Wheat', value: 'Wheat'}, - ], - }, - ]); + + searchString = signal(''); + + nodes = computed(() => this.filterTreeNodes(TREE_NODES)); + + firstMatch = computed(() => { + const flatNodes = this.flattenTreeNodes(this.nodes()); + const node = flatNodes.find(n => this.isMatch(n)); + return node?.name; + }); + + flattenTreeNodes(nodes: TreeNode[]): TreeNode[] { + return nodes.flatMap(node => { + return node.children ? [node, ...this.flattenTreeNodes(node.children)] : [node]; + }); + } + + filterTreeNodes(nodes: TreeNode[]): TreeNode[] { + return nodes.reduce((acc, node) => { + const children = node.children ? this.filterTreeNodes(node.children) : undefined; + if (this.isMatch(node) || (children && children.length > 0)) { + acc.push({...node, children}); + } + return acc; + }, [] as TreeNode[]); + } + + isMatch(node: TreeNode) { + return node.name.toLowerCase().includes(this.searchString().toLowerCase()); + } } + +export interface TreeNode { + name: string; + children?: TreeNode[]; +} + +export const TREE_NODES = [ + { + name: 'Winter', + children: [{name: 'December'}, {name: 'January'}, {name: 'February'}], + }, + { + name: 'Spring', + children: [{name: 'March'}, {name: 'April'}, {name: 'May'}], + }, + { + name: 'Summer', + children: [{name: 'June'}, {name: 'July'}, {name: 'August'}], + }, + { + name: 'Fall', + children: [{name: 'September'}, {name: 'October'}, {name: 'November'}], + }, +]; + +const states = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', + 'Delaware', + 'Florida', + 'Georgia', + 'Hawaii', + 'Idaho', + 'Illinois', + 'Indiana', + 'Iowa', + 'Kansas', + 'Kentucky', + 'Louisiana', + 'Maine', + 'Maryland', + 'Massachusetts', + 'Michigan', + 'Minnesota', + 'Mississippi', + 'Missouri', + 'Montana', + 'Nebraska', + 'Nevada', + 'New Hampshire', + 'New Jersey', + 'New Mexico', + 'New York', + 'North Carolina', + 'North Dakota', + 'Ohio', + 'Oklahoma', + 'Oregon', + 'Pennsylvania', + 'Rhode Island', + 'South Carolina', + 'South Dakota', + 'Tennessee', + 'Texas', + 'Utah', + 'Vermont', + 'Virginia', + 'Washington', + 'West Virginia', + 'Wisconsin', + 'Wyoming', +]; diff --git a/src/cdk-experimental/combobox/combobox.ts b/src/cdk-experimental/combobox/combobox.ts index fc782495e5d5..38d10e19bf48 100644 --- a/src/cdk-experimental/combobox/combobox.ts +++ b/src/cdk-experimental/combobox/combobox.ts @@ -13,7 +13,9 @@ import { ElementRef, inject, input, + model, signal, + untracked, WritableSignal, } from '@angular/core'; import {DeferredContent, DeferredContentAware} from '@angular/cdk-experimental/deferred-content'; @@ -53,10 +55,8 @@ export class CdkCombobox { /** Whether the combobox is focused. */ readonly isFocused = signal(false); - /** The function used to filter the options in the popup based on the input text. */ - filter = input<(inputText: string, itemText: string) => boolean>((inputText, itemText) => - itemText.toLowerCase().includes(inputText.toLowerCase()), - ); + /** The value of the first matching item in the popup. */ + firstMatch = input(undefined); /** Whether the listbox has received focus yet. */ private _hasBeenFocused = signal(false); @@ -64,6 +64,7 @@ export class CdkCombobox { /** The combobox ui pattern. */ readonly pattern = new ComboboxPattern({ ...this, + inputValue: signal(''), inputEl: signal(undefined), containerEl: () => this._elementRef.nativeElement, popupControls: () => this.popup()?.controls(), @@ -89,6 +90,7 @@ export class CdkCombobox { exportAs: 'cdkComboboxInput', host: { 'role': 'combobox', + '[value]': 'value()', '[attr.aria-expanded]': 'combobox.pattern.expanded()', '[attr.aria-activedescendant]': 'combobox.pattern.activedescendant()', '[attr.aria-controls]': 'combobox.pattern.popupId()', @@ -98,15 +100,25 @@ export class CdkCombobox { }) export class CdkComboboxInput { /** The element that the combobox is attached to. */ - private readonly _elementRef = inject(ElementRef); + private readonly _elementRef = inject>(ElementRef); /** The combobox that the input belongs to. */ readonly combobox = inject(CdkCombobox); + /** The value of the input. */ + value = model(''); + constructor() { (this.combobox.pattern.inputs.inputEl as WritableSignal).set( this._elementRef.nativeElement, ); + this.combobox.pattern.inputs.inputValue = this.value; + + /** Focuses & selects the first item in the combobox if the user changes the input value. */ + afterRenderEffect(() => { + this.combobox.popup()?.controls()?.items(); + untracked(() => this.combobox.pattern.onFilter()); + }); } } diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index 4d99730cc0cf..a8db327eebe3 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -197,7 +197,6 @@ export class CdkListbox { '[attr.tabindex]': 'pattern.tabindex()', '[attr.aria-selected]': 'pattern.selected()', '[attr.aria-disabled]': 'pattern.disabled()', - '[attr.inert]': 'pattern.inert()', }, }) export class CdkOption { diff --git a/src/cdk-experimental/tree/tree.ts b/src/cdk-experimental/tree/tree.ts index 948154771286..68096f476681 100644 --- a/src/cdk-experimental/tree/tree.ts +++ b/src/cdk-experimental/tree/tree.ts @@ -222,7 +222,6 @@ export class CdkTree { '[attr.aria-setsize]': 'pattern.setsize()', '[attr.aria-posinset]': 'pattern.posinset()', '[attr.tabindex]': 'pattern.tabindex()', - '[attr.inert]': 'pattern.inert()', }, }) export class CdkTreeItem implements OnInit, OnDestroy, HasElement { 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 index d5fb20070914..9515e29a30ee 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts @@ -22,9 +22,6 @@ export interface ListFocusItem { /** The index of the item in the list. */ index: SignalLike; - - /** Whether the item is currently focused. */ - inert?: SignalLike; } /** Represents the required inputs for a collection that contains focusable items. */ @@ -115,6 +112,6 @@ export class ListFocus { /** Returns true if the given item can be navigated to. */ isFocusable(item: T): boolean { - return (!item.disabled() || !this.inputs.skipDisabled()) && !item.inert?.(); + return !item.disabled() || !this.inputs.skipDisabled(); } } diff --git a/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts b/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts index e3d25fd3a619..c4b3b46ddce2 100644 --- a/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts +++ b/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts @@ -59,6 +59,31 @@ function clickInput(inputEl: HTMLInputElement) { return {target: inputEl} as unknown as PointerEvent; } +function _type( + text: string, + inputEl: HTMLInputElement, + combobox: ComboboxPattern, + allOptions: TestOption[] | TreeItemPattern[], + popup: ComboboxListboxPattern | ComboboxTreePattern, + firstMatch: WritableSignal, + backspace = false, +) { + inputEl.value = text; + combobox.onInput( + backspace + ? new InputEvent('input', {inputType: 'deleteContentBackward'}) + : new InputEvent('input'), + ); + const options = allOptions.filter(o => o.searchTerm().startsWith(text)); + if (popup instanceof ComboboxListboxPattern) { + (popup.inputs.items as WritableSignal).set(options); + } else if (popup instanceof ComboboxTreePattern) { + (popup.inputs.allItems as WritableSignal).set(options); + } + firstMatch.set(options[0]?.value()); + combobox.onFilter(); +} + function getComboboxPattern( inputs: Partial<{ [K in keyof TestInputs]: TestInputs[K] extends WritableSignal ? T : never; @@ -67,19 +92,19 @@ function getComboboxPattern( const containerEl = signal(document.createElement('div')); const inputEl = signal(document.createElement('input')); containerEl()?.appendChild(inputEl()!); + const firstMatch = signal(undefined); + const inputValue = signal(''); const combobox = new ComboboxPattern({ popupControls: signal(undefined), // will be set later inputEl, containerEl, filterMode: signal(inputs.filterMode ?? 'manual'), - filter: signal( - inputs.filter ?? - ((inputText, itemText) => itemText.toLowerCase().includes(inputText.toLowerCase())), - ), + firstMatch, + inputValue, }); - return {combobox, inputEl, containerEl}; + return {combobox, inputEl, containerEl, firstMatch, inputValue}; } function getListboxPattern( @@ -196,7 +221,7 @@ describe('Combobox with Listbox Pattern', () => { [K in keyof TestInputs]: TestInputs[K] extends WritableSignal ? T : never; }> = {}, ) { - const {combobox, inputEl, containerEl} = getComboboxPattern(inputs); + const {combobox, inputEl, containerEl, firstMatch, inputValue} = getComboboxPattern(inputs); const {listbox, options} = getListboxPattern(combobox, [ 'Apple', 'Apricot', @@ -214,9 +239,11 @@ describe('Combobox with Listbox Pattern', () => { return { combobox, listbox, - options: options(), + options, inputEl: inputEl()!, containerEl: containerEl()!, + firstMatch, + inputValue, }; } @@ -324,9 +351,24 @@ describe('Combobox with Listbox Pattern', () => { }); describe('Selection', () => { + let combobox: ComboboxPattern; + let listbox: ComboboxListboxPattern; + let inputEl: HTMLInputElement; + let options: () => TestOption[]; + let firstMatch: WritableSignal; + + function type(text: string, opts: {backspace?: boolean} = {}) { + _type(text, inputEl, combobox, options(), listbox, firstMatch, opts.backspace); + } + describe('when filterMode is "manual"', () => { + beforeEach(() => { + ({combobox, listbox, inputEl, options, firstMatch} = getPatterns({ + filterMode: 'manual', + })); + }); + it('should select and commit on click', () => { - const {combobox, listbox, inputEl} = getPatterns(); combobox.onPointerup(clickOption(listbox.inputs.items(), 0)); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); expect(listbox.inputs.value()).toEqual(['Apple']); @@ -334,7 +376,6 @@ describe('Combobox with Listbox Pattern', () => { }); it('should select and commit to input on Enter', () => { - const {combobox, listbox, inputEl} = getPatterns({filterMode: 'manual'}); combobox.onKeydown(down()); combobox.onKeydown(enter()); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); @@ -343,20 +384,17 @@ describe('Combobox with Listbox Pattern', () => { }); it('should select on focusout if the input text exactly matches an item', () => { - const {combobox, listbox, inputEl} = getPatterns({filterMode: 'manual'}); - inputEl.value = 'Apple'; - combobox.onInput(new InputEvent('input')); + type('Apple'); combobox.onFocusOut(new FocusEvent('focusout')); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); expect(listbox.inputs.value()).toEqual(['Apple']); }); it('should deselect on backspace', () => { - const {combobox, listbox, inputEl} = getPatterns({filterMode: 'manual'}); combobox.onKeydown(down()); combobox.onKeydown(enter()); - inputEl.value = 'Appl'; + type('Appl', {backspace: true}); combobox.onInput(new InputEvent('input', {inputType: 'deleteContentBackward'})); expect(listbox.getSelectedItem()).toBe(undefined); @@ -364,24 +402,19 @@ describe('Combobox with Listbox Pattern', () => { }); it('should not select on navigation', () => { - const {combobox, listbox} = getPatterns({filterMode: 'manual'}); combobox.onKeydown(down()); expect(listbox.getSelectedItem()).toBe(undefined); expect(listbox.inputs.value()).toEqual([]); }); it('should not select on input', () => { - const {combobox, listbox, inputEl} = getPatterns({filterMode: 'manual'}); - inputEl.value = 'A'; - combobox.onInput(new InputEvent('input')); + type('A'); expect(listbox.getSelectedItem()).toBe(undefined); expect(listbox.inputs.value()).toEqual([]); }); it('should not select on focusout if the input text does not match an item', () => { - const {combobox, listbox, inputEl} = getPatterns({filterMode: 'manual'}); - inputEl.value = 'Appl'; - combobox.onInput(new InputEvent('input')); + type('Appl'); combobox.onFocusOut(new FocusEvent('focusout')); expect(listbox.getSelectedItem()).toBe(undefined); expect(listbox.inputs.value()).toEqual([]); @@ -390,8 +423,13 @@ describe('Combobox with Listbox Pattern', () => { }); describe('when filterMode is "auto-select"', () => { + beforeEach(() => { + ({combobox, listbox, inputEl, options, firstMatch} = getPatterns({ + filterMode: 'auto-select', + })); + }); + it('should select and commit on click', () => { - const {combobox, listbox, inputEl} = getPatterns({filterMode: 'auto-select'}); combobox.onPointerup(clickOption(listbox.inputs.items(), 3)); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[3]); expect(listbox.inputs.value()).toEqual(['Blackberry']); @@ -399,7 +437,6 @@ describe('Combobox with Listbox Pattern', () => { }); it('should select and commit on Enter', () => { - const {combobox, listbox, inputEl} = getPatterns({filterMode: 'auto-select'}); combobox.onKeydown(down()); combobox.onKeydown(down()); combobox.onKeydown(down()); @@ -410,14 +447,12 @@ describe('Combobox with Listbox Pattern', () => { }); it('should select the first item on arrow down when collapsed', () => { - const {combobox, listbox} = getPatterns({filterMode: 'auto-select'}); combobox.onKeydown(down()); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); expect(listbox.inputs.value()).toEqual(['Apple']); }); it('should select the last item on arrow up when collapsed', () => { - const {combobox, listbox} = getPatterns({filterMode: 'auto-select'}); combobox.onKeydown(up()); expect(listbox.getSelectedItem()).toBe( listbox.inputs.items()[listbox.inputs.items().length - 1], @@ -426,7 +461,6 @@ describe('Combobox with Listbox Pattern', () => { }); it('should select on navigation', () => { - const {combobox, listbox} = getPatterns({filterMode: 'auto-select'}); combobox.onKeydown(down()); combobox.onKeydown(down()); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[1]); @@ -434,33 +468,29 @@ describe('Combobox with Listbox Pattern', () => { }); it('should select the first option on input', () => { - const {combobox, listbox, inputEl} = getPatterns({filterMode: 'auto-select'}); - inputEl.value = 'A'; - combobox.onInput(new InputEvent('input')); - - expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); + type('A'); expect(listbox.inputs.value()).toEqual(['Apple']); - inputEl.value = 'Apr'; - combobox.onInput(new InputEvent('input')); - - expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[1]); + type('Apr'); expect(listbox.inputs.value()).toEqual(['Apricot']); }); it('should commit the selected option on focusout', () => { - const {combobox, inputEl} = getPatterns({filterMode: 'auto-select'}); combobox.onKeydown(down()); - inputEl.value = 'App'; - combobox.onInput(new InputEvent('input')); + type('App'); combobox.onFocusOut(new FocusEvent('focusout')); expect(inputEl.value).toBe('Apple'); }); }); describe('when filterMode is "highlight"', () => { + beforeEach(() => { + ({combobox, listbox, inputEl, options, firstMatch} = getPatterns({ + filterMode: 'highlight', + })); + }); + it('should select and commit on click', () => { - const {combobox, listbox, inputEl} = getPatterns({filterMode: 'highlight'}); combobox.onPointerup(clickOption(listbox.inputs.items(), 3)); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[3]); expect(listbox.inputs.value()).toEqual(['Blackberry']); @@ -468,7 +498,6 @@ describe('Combobox with Listbox Pattern', () => { }); it('should select and commit on Enter', () => { - const {combobox, listbox, inputEl} = getPatterns({filterMode: 'highlight'}); combobox.onKeydown(down()); combobox.onKeydown(down()); combobox.onKeydown(down()); @@ -479,14 +508,12 @@ describe('Combobox with Listbox Pattern', () => { }); it('should select the first item on arrow down when collapsed', () => { - const {combobox, listbox} = getPatterns({filterMode: 'highlight'}); combobox.onKeydown(down()); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); expect(listbox.inputs.value()).toEqual(['Apple']); }); it('should select the last item on arrow up when collapsed', () => { - const {combobox, listbox} = getPatterns({filterMode: 'highlight'}); combobox.onKeydown(up()); expect(listbox.getSelectedItem()).toBe( listbox.inputs.items()[listbox.inputs.items().length - 1], @@ -495,7 +522,6 @@ describe('Combobox with Listbox Pattern', () => { }); it('should select on navigation', () => { - const {combobox, listbox} = getPatterns({filterMode: 'highlight'}); combobox.onKeydown(down()); combobox.onKeydown(down()); expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[1]); @@ -503,22 +529,14 @@ describe('Combobox with Listbox Pattern', () => { }); it('should select the first option on input', () => { - const {combobox, listbox, inputEl} = getPatterns({filterMode: 'highlight'}); - inputEl.value = 'A'; - combobox.onInput(new InputEvent('input')); - - expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); + type('A'); expect(listbox.inputs.value()).toEqual(['Apple']); - inputEl.value = 'Apr'; - combobox.onInput(new InputEvent('input')); - - expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[1]); + type('Apr'); expect(listbox.inputs.value()).toEqual(['Apricot']); }); it('should commit the selected option on navigation', () => { - const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); combobox.onKeydown(down()); expect(inputEl.value).toBe('Apple'); combobox.onKeydown(down()); @@ -526,27 +544,21 @@ describe('Combobox with Listbox Pattern', () => { }); it('should commit the selected option on focusout', () => { - const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); combobox.onKeydown(down()); - inputEl.value = 'App'; - combobox.onInput(new InputEvent('input')); + type('App'); combobox.onFocusOut(new FocusEvent('focusout')); expect(inputEl.value).toBe('Apple'); }); it('should insert a highlighted completion string on input', () => { - const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); - inputEl.value = 'A'; - combobox.onInput(new InputEvent('input')); + type('A'); expect(inputEl.value).toBe('Apple'); expect(inputEl.selectionStart).toBe(1); expect(inputEl.selectionEnd).toBe(5); }); it('should should remember which option was highlighted after navigating', () => { - const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); - inputEl.value = 'A'; - combobox.onInput(new InputEvent('input')); + type('A'); combobox.onKeydown(down()); expect(inputEl.value).toBe('Apricot'); @@ -569,7 +581,7 @@ describe('Combobox with Tree Pattern', () => { [K in keyof TestInputs]: TestInputs[K] extends WritableSignal ? T : never; }> = {}, ) { - const {combobox, inputEl, containerEl} = getComboboxPattern(inputs); + const {combobox, inputEl, containerEl, firstMatch, inputValue} = getComboboxPattern(inputs); const {tree, items} = getTreePattern(combobox, [ {value: 'Fruit', children: [{value: 'Apple'}, {value: 'Banana'}, {value: 'Cantaloupe'}]}, {value: 'Vegetables', children: [{value: 'Broccoli'}, {value: 'Carrot'}, {value: 'Lettuce'}]}, @@ -581,9 +593,11 @@ describe('Combobox with Tree Pattern', () => { return { combobox, tree, - items: items(), + items: items, inputEl: inputEl()!, containerEl: containerEl()!, + firstMatch, + inputValue, }; } @@ -668,16 +682,30 @@ describe('Combobox with Tree Pattern', () => { }); describe('Selection', () => { + let combobox: ComboboxPattern; + let tree: ComboboxTreePattern; + let inputEl: HTMLInputElement; + let items: () => TreeItemPattern[]; + let firstMatch: WritableSignal; + + function type(text: string, opts: {backspace?: boolean} = {}) { + _type(text, inputEl, combobox, items(), tree, firstMatch, opts.backspace); + } + describe('when filterMode is "manual"', () => { + beforeEach(() => { + ({combobox, tree, inputEl, items, firstMatch} = getPatterns({ + filterMode: 'manual', + })); + }); + it('should select and commit on click', () => { - const {combobox, tree, inputEl} = getPatterns(); combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 0)); expect(tree.inputs.value()).toEqual(['Fruit']); expect(inputEl.value).toBe('Fruit'); }); it('should select and commit to input on Enter', () => { - const {combobox, tree, inputEl} = getPatterns({filterMode: 'manual'}); combobox.onKeydown(down()); combobox.onKeydown(enter()); expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[0]); @@ -686,45 +714,36 @@ describe('Combobox with Tree Pattern', () => { }); it('should select on focusout if the input text exactly matches an item', () => { - const {combobox, inputEl, tree} = getPatterns({filterMode: 'manual'}); combobox.onPointerup(clickInput(inputEl)); - inputEl.value = 'Apple'; - combobox.onInput(new InputEvent('input')); + type('Apple'); combobox.onFocusOut(new FocusEvent('focusout')); expect(tree.inputs.value()).toEqual(['Apple']); }); it('should deselect on backspace', () => { - const {combobox, tree, inputEl} = getPatterns({filterMode: 'manual'}); combobox.onKeydown(down()); combobox.onKeydown(enter()); - inputEl.value = 'Appl'; - combobox.onInput(new InputEvent('input', {inputType: 'deleteContentBackward'})); + type('Appl', {backspace: true}); expect(tree.getSelectedItem()).toBe(undefined); expect(tree.inputs.value()).toEqual([]); }); it('should not select on navigation', () => { - const {combobox, tree} = getPatterns({filterMode: 'manual'}); combobox.onKeydown(down()); expect(tree.getSelectedItem()).toBe(undefined); expect(tree.inputs.value()).toEqual([]); }); it('should not select on input', () => { - const {combobox, tree, inputEl} = getPatterns({filterMode: 'manual'}); - inputEl.value = 'A'; - combobox.onInput(new InputEvent('input')); + type('A'); expect(tree.getSelectedItem()).toBe(undefined); expect(tree.inputs.value()).toEqual([]); }); it('should not select on focusout if the input text does not match an item', () => { - const {combobox, tree, inputEl} = getPatterns({filterMode: 'manual'}); - inputEl.value = 'Appl'; - combobox.onInput(new InputEvent('input')); + type('Appl'); combobox.onFocusOut(new FocusEvent('focusout')); expect(tree.getSelectedItem()).toBe(undefined); expect(tree.inputs.value()).toEqual([]); @@ -733,8 +752,13 @@ describe('Combobox with Tree Pattern', () => { }); describe('when filterMode is "auto-select"', () => { + beforeEach(() => { + ({combobox, tree, inputEl, items, firstMatch} = getPatterns({ + filterMode: 'auto-select', + })); + }); + it('should select and commit on click', () => { - const {combobox, tree, inputEl} = getPatterns({filterMode: 'auto-select'}); combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 2)); expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[2]); expect(tree.inputs.value()).toEqual(['Banana']); @@ -742,7 +766,6 @@ describe('Combobox with Tree Pattern', () => { }); it('should select and commit on Enter', () => { - const {combobox, inputEl, tree} = getPatterns({filterMode: 'auto-select'}); combobox.onKeydown(down()); combobox.onKeydown(down()); combobox.onKeydown(down()); @@ -752,20 +775,17 @@ describe('Combobox with Tree Pattern', () => { }); it('should select the first item on arrow down when collapsed', () => { - const {combobox, tree} = getPatterns({filterMode: 'auto-select'}); combobox.onKeydown(down()); expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[0]); expect(tree.inputs.value()).toEqual(['Fruit']); }); it('should select the last focusable item on arrow up when collapsed', () => { - const {combobox, tree} = getPatterns({filterMode: 'auto-select'}); combobox.onKeydown(up()); expect(tree.inputs.value()).toEqual(['Grains']); }); it('should select on navigation', () => { - const {combobox, tree} = getPatterns({filterMode: 'auto-select'}); combobox.onKeydown(down()); combobox.onKeydown(right()); combobox.onKeydown(right()); @@ -773,31 +793,29 @@ describe('Combobox with Tree Pattern', () => { }); it('should select the first option on input', () => { - const {combobox, inputEl, tree} = getPatterns({filterMode: 'auto-select'}); - inputEl.value = 'B'; - combobox.onInput(new InputEvent('input')); - + type('B'); expect(tree.inputs.value()).toEqual(['Banana']); - inputEl.value = 'Bro'; - combobox.onInput(new InputEvent('input')); - + type('Bro'); expect(tree.inputs.value()).toEqual(['Broccoli']); }); it('should commit the selected option on focusout', () => { - const {combobox, inputEl} = getPatterns({filterMode: 'auto-select'}); combobox.onKeydown(down()); - inputEl.value = 'App'; - combobox.onInput(new InputEvent('input')); + type('App'); combobox.onFocusOut(new FocusEvent('focusout')); expect(inputEl.value).toBe('Apple'); }); }); describe('when filterMode is "highlight"', () => { + beforeEach(() => { + ({combobox, tree, inputEl, items, firstMatch} = getPatterns({ + filterMode: 'highlight', + })); + }); + it('should select and commit on click', () => { - const {combobox, tree, inputEl} = getPatterns({filterMode: 'highlight'}); combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 2)); expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[2]); expect(tree.inputs.value()).toEqual(['Banana']); @@ -805,7 +823,6 @@ describe('Combobox with Tree Pattern', () => { }); it('should select and commit on Enter', () => { - const {combobox, inputEl, tree} = getPatterns({filterMode: 'highlight'}); combobox.onKeydown(down()); combobox.onKeydown(down()); combobox.onKeydown(down()); @@ -815,20 +832,17 @@ describe('Combobox with Tree Pattern', () => { }); it('should select the first item on arrow down when collapsed', () => { - const {combobox, tree} = getPatterns({filterMode: 'highlight'}); combobox.onKeydown(down()); expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[0]); expect(tree.inputs.value()).toEqual(['Fruit']); }); it('should select the last focusable item on arrow up when collapsed', () => { - const {combobox, tree} = getPatterns({filterMode: 'highlight'}); combobox.onKeydown(up()); expect(tree.inputs.value()).toEqual(['Grains']); }); it('should select on navigation', () => { - const {combobox, tree} = getPatterns({filterMode: 'highlight'}); combobox.onKeydown(down()); combobox.onKeydown(right()); combobox.onKeydown(right()); @@ -836,20 +850,14 @@ describe('Combobox with Tree Pattern', () => { }); it('should select the first option on input', () => { - const {combobox, inputEl, tree} = getPatterns({filterMode: 'highlight'}); - inputEl.value = 'B'; - combobox.onInput(new InputEvent('input')); - + type('B'); expect(tree.inputs.value()).toEqual(['Banana']); - inputEl.value = 'Bro'; - combobox.onInput(new InputEvent('input')); - + type('Bro'); expect(tree.inputs.value()).toEqual(['Broccoli']); }); it('should commit the selected option on navigation', () => { - const {combobox, inputEl, tree} = getPatterns({filterMode: 'highlight'}); combobox.onKeydown(down()); expect(inputEl.value).toBe('Fruit'); combobox.onKeydown(right()); @@ -860,39 +868,18 @@ describe('Combobox with Tree Pattern', () => { }); it('should commit the selected option on focusout', () => { - const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); combobox.onKeydown(down()); - inputEl.value = 'App'; - combobox.onInput(new InputEvent('input')); + type('App'); combobox.onFocusOut(new FocusEvent('focusout')); expect(inputEl.value).toBe('Apple'); }); it('should insert a highlighted completion string on input', () => { - const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); - inputEl.value = 'A'; - combobox.onInput(new InputEvent('input')); + type('A'); expect(inputEl.value).toBe('Apple'); expect(inputEl.selectionStart).toBe(1); expect(inputEl.selectionEnd).toBe(5); }); - - it('should should remember which option was highlighted after navigating', () => { - const {combobox, inputEl} = getPatterns({filterMode: 'highlight'}); - inputEl.value = 'B'; - combobox.onInput(new InputEvent('input')); - combobox.onKeydown(down()); - - expect(inputEl.value).toBe('Vegetables'); - expect(inputEl.selectionStart).toBe(10); - expect(inputEl.selectionEnd).toBe(10); - - combobox.onKeydown(up()); - - expect(inputEl.value).toBe('Banana'); - expect(inputEl.selectionStart).toBe(1); - expect(inputEl.selectionEnd).toBe(6); - }); }); }); }); diff --git a/src/cdk-experimental/ui-patterns/combobox/combobox.ts b/src/cdk-experimental/ui-patterns/combobox/combobox.ts index 050d1021c649..f5ff075ac8b1 100644 --- a/src/cdk-experimental/ui-patterns/combobox/combobox.ts +++ b/src/cdk-experimental/ui-patterns/combobox/combobox.ts @@ -8,7 +8,7 @@ import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; import {computed, signal} from '@angular/core'; -import {SignalLike} from '../behaviors/signal-like/signal-like'; +import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like'; import {ListItem} from '../behaviors/list/list'; /** Represents the required inputs for a combobox. */ @@ -25,8 +25,11 @@ export type ComboboxInputs, V> = { /** The filtering mode for the combobox. */ filterMode: SignalLike<'manual' | 'auto-select' | 'highlight'>; - /** The function used to filter items in the combobox. */ - filter: SignalLike<(inputText: string, itemText: string) => boolean>; + /** The current value of the combobox. */ + inputValue?: WritableSignalLike; + + /** The value of the first matching item in the popup. */ + firstMatch: SignalLike; }; /** An interface that allows combobox popups to expose the necessary controls for the combobox. */ @@ -43,14 +46,17 @@ export type ComboboxListboxControls, V> = { /** The list of items in the popup. */ items: SignalLike; + /** Navigates to the given item in the popup. */ + focus: (item: T) => void; + /** Navigates to the next item in the popup. */ next: () => void; /** Navigates to the previous item in the popup. */ prev: () => void; - /** Navigates to the first item in the popup, or the first match if filterText is provided. */ - first: (filterText?: string) => void; + /** Navigates to the first item in the popup. */ + first: () => void; /** Navigates to the last item in the popup. */ last: () => void; @@ -61,9 +67,6 @@ export type ComboboxListboxControls, V> = { /** Clears the selection state of the popup. */ clearSelection: () => void; - /** Filters the items in the popup. */ - filter: (text: string) => void; - /** Removes focus from any item in the popup. */ unfocus: () => void; @@ -89,6 +92,12 @@ export type ComboboxTreeControls, V> = ComboboxListboxCont /** Checks if the currently active item in the popup is expandable. */ isItemExpandable: () => boolean; + + /** Expands all nodes in the tree. */ + expandAll: () => void; + + /** Collapses all nodes in the tree. */ + collapseAll: () => void; }; /** Controls the state of a combobox. */ @@ -99,12 +108,12 @@ export class ComboboxPattern, V> { /** The ID of the active item in the combobox. */ activedescendant = computed(() => this.inputs.popupControls()?.activeId() ?? null); - /** The current search string for filtering. */ - searchString = signal(''); - /** The currently highlighted item in the combobox. */ highlightedItem = signal(undefined); + /** Whether the most recent input event was a deletion. */ + isDeleting = false; + /** Whether the combobox is focused. */ isFocused = signal(false); @@ -143,17 +152,18 @@ export class ComboboxPattern, V> { .on('Home', () => this.first()) .on('End', () => this.last()) .on('Escape', () => { + // TODO(wagnermaciel): We may want to fold this logic into the close() method. if (this.inputs.filterMode() === 'highlight' && popupControls.activeId()) { popupControls.unfocus(); popupControls.clearSelection(); const inputEl = this.inputs.inputEl(); - if (inputEl) { - inputEl.value = this.searchString(); + inputEl.value = this.inputs.inputValue!(); } } else { this.close(); + this.inputs.popupControls()?.clearSelection(); } }) // TODO: When filter mode is 'highlight', escape should revert to the last committed value. .on('Enter', () => this.select({commit: true, close: true})); @@ -209,24 +219,17 @@ export class ComboboxPattern, V> { return; } - this.searchString.set(inputEl.value); - this.inputs.popupControls()?.filter(inputEl.value); - this.open(); - this.inputs.popupControls()?.first(this.searchString() || undefined); + this.inputs.inputValue?.set(inputEl.value); + this.isDeleting = event instanceof InputEvent && !!event.inputType.match(/^delete/); + + if (this.inputs.filterMode() === 'manual') { + const searchTerm = this.inputs.popupControls()?.getSelectedItem()?.searchTerm(); - if (event instanceof InputEvent && event.inputType.match(/delete.*/)) { - if (this.inputs.filterMode() === 'manual') { + if (searchTerm && this.inputs.inputValue!() !== searchTerm) { this.inputs.popupControls()?.clearSelection(); - } else { - this.inputs.popupControls()?.select(); - return; } } - - if (this.inputs.filterMode() !== 'manual') { - this.select({highlight: this.inputs.filterMode() === 'highlight'}); - } } onFocusIn() { @@ -257,55 +260,94 @@ export class ComboboxPattern, V> { } } - setDefaultState() { - // if (this.inputs.value() !== undefined) { - // this.inputs.popupControls()?.setValue(this.inputs.value()); - // const inputEl = this.inputs.inputEl(); - // const searchTerm = this.inputs.popupControls()?.getSelectedItem()?.searchTerm() ?? ''; - // if (inputEl) { - // inputEl.value = searchTerm; - // } - // } - } + firstMatch = computed(() => { + // TODO(wagnermaciel): Consider whether we should not provide this default behavior for the + // listbox. Instead, we may want to allow users to have no match so that typing does not focus + // any option. + if (this.inputs.popupControls()?.role() === 'listbox') { + return this.inputs.popupControls()?.items()[0]; + } - /** Closes the combobox. */ - close() { - this.expanded.set(false); - this.inputs.popupControls()?.unfocus(); - } + return this.inputs + .popupControls() + ?.items() + .find(i => i.value() === this.inputs.firstMatch()); + }); - /** Opens the combobox. */ - open(nav?: {first?: boolean; last?: boolean}) { - this.expanded.set(true); - this.inputs.popupControls()?.filter(this.inputs.inputEl()?.value ?? ''); + onFilter() { + // TODO(wagnermaciel) + // When the user first interacts with the combobox, the popup will lazily render for the first + // time. This is a simple way to detect this and avoid auto-focus & selection logic, but this + // should probably be moved to the component layer instead. + const isInitialRender = !this.inputs.inputValue?.().length && !this.isDeleting; - if (nav?.first) { - this.first(); - } else if (nav?.last) { - this.last(); + if (isInitialRender) { + return; + } + + if (this.inputs.popupControls()?.role() === 'tree') { + const treeControls = this.inputs.popupControls() as ComboboxTreeControls; + this.inputs.inputValue?.().length ? treeControls.expandAll() : treeControls.collapseAll(); + } + + const item = this.firstMatch(); + + if (!item) { + this.inputs.popupControls()?.clearSelection(); + this.inputs.popupControls()?.unfocus(); + return; + } + + this.inputs.popupControls()?.focus(item); + + if (this.inputs.filterMode() !== 'manual') { + this.select({item}); + } + + if (this.inputs.filterMode() === 'highlight' && !this.isDeleting) { + this.highlight(); } } highlight() { - const element = this.inputs.inputEl(); + const inputEl = this.inputs.inputEl(); const item = this.inputs.popupControls()?.getSelectedItem(); - if (!item) { + if (!inputEl || !item) { return; } const isHighlightable = item .searchTerm() .toLowerCase() - .startsWith(this.searchString().toLowerCase()); + .startsWith(this.inputs.inputValue!().toLowerCase()); - if (element && isHighlightable) { - element.value = this.searchString() + item.searchTerm().slice(this.searchString().length); - element.setSelectionRange(this.searchString().length, item.searchTerm().length); + if (isHighlightable) { + inputEl.value = + this.inputs.inputValue!() + item.searchTerm().slice(this.inputs.inputValue!().length); + inputEl.setSelectionRange(this.inputs.inputValue!().length, item.searchTerm().length); this.highlightedItem.set(item); } } + /** Closes the combobox. */ + close() { + this.expanded.set(false); + this.inputs.popupControls()?.unfocus(); + } + + /** Opens the combobox. */ + open(nav?: {first?: boolean; last?: boolean}) { + this.expanded.set(true); + + if (nav?.first) { + this.first(); + } + if (nav?.last) { + this.last(); + } + } + /** Navigates to the next focusable item in the combobox popup. */ next() { this._navigate(() => this.inputs.popupControls()?.next()); @@ -337,7 +379,7 @@ export class ComboboxPattern, V> { } /** Selects an item in the combobox popup. */ - select(opts: {item?: T; commit?: boolean; close?: boolean; highlight?: boolean} = {}) { + select(opts: {item?: T; commit?: boolean; close?: boolean} = {}) { this.inputs.popupControls()?.select(opts.item); if (opts.commit) { @@ -346,22 +388,20 @@ export class ComboboxPattern, V> { if (opts.close) { this.close(); } - if (opts.highlight) { - this.highlight(); - } } /** Updates the value of the input based on the currently selected item. */ commit() { - const element = this.inputs.inputEl(); + const inputEl = this.inputs.inputEl(); const item = this.inputs.popupControls()?.getSelectedItem(); - if (element && item) { - element.value = item.searchTerm(); + if (inputEl && item) { + inputEl.value = item.searchTerm(); + this.inputs.inputValue?.set(item.searchTerm()); if (this.inputs.filterMode() === 'highlight') { - const length = element.value.length; - element.setSelectionRange(length, length); + const length = inputEl.value.length; + inputEl.setSelectionRange(length, length); } } } @@ -370,18 +410,24 @@ export class ComboboxPattern, V> { private _navigate(operation: () => void) { operation(); - if (this.inputs.filterMode() === 'auto-select') { + if (this.inputs.filterMode() !== 'manual') { this.select(); } if (this.inputs.filterMode() === 'highlight') { - this.select({commit: true}); - // This is to handle when the user navigates back to the originally highlighted item. // E.g. User types "Al", highlights "Alice", then navigates down and back up to "Alice". const selectedItem = this.inputs.popupControls()?.getSelectedItem(); - if (selectedItem && selectedItem === this.highlightedItem()) { + + if (!selectedItem) { + return; + } + + if (selectedItem === this.highlightedItem()) { this.highlight(); + } else { + const inputEl = this.inputs.inputEl()!; + inputEl.value = selectedItem?.searchTerm()!; } } } diff --git a/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts b/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts index 55a51c149cb9..b331de3c358e 100644 --- a/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts @@ -55,6 +55,9 @@ export class ComboboxListboxPattern /** Noop. The combobox controls the open state. */ override setDefaultState(): void {} + /** Navigates to the specified item in the listbox. */ + focus = (item: OptionPattern) => this.listBehavior.goto(item); + /** Navigates to the next focusable item in the listbox. */ next = () => this.listBehavior.next(); @@ -84,14 +87,4 @@ export class ComboboxListboxPattern /** Sets the value of the combobox listbox. */ setValue = (value: V | undefined) => this.inputs.value.set(value ? [value] : []); - - /** Filters the items in the listbox based on the provided text. */ - filter = (text: string) => { - const filterFn = this.inputs.combobox()!.inputs.filter(); - - this.inputs.items().forEach(i => { - const isMatch = filterFn(text, i.searchTerm()); - i.inert.set(isMatch ? null : true); - }); - }; } diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index e1e8be7e6390..77e12bfc46ca 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -205,14 +205,6 @@ export class ListboxPattern { ); } - const activeItem = this.inputs.activeItem(); - - if (activeItem && !this.inputs.items().includes(activeItem)) { - violations.push( - `The current active item does not exist in the list. Active item: ${activeItem.id()}.`, - ); - } - return violations; } diff --git a/src/cdk-experimental/ui-patterns/listbox/option.ts b/src/cdk-experimental/ui-patterns/listbox/option.ts index 35653dc8e33d..778fd8ca4563 100644 --- a/src/cdk-experimental/ui-patterns/listbox/option.ts +++ b/src/cdk-experimental/ui-patterns/listbox/option.ts @@ -32,9 +32,6 @@ export class OptionPattern { /** The value of the option. */ value: SignalLike; - /** Whether the option is inert. */ - inert = signal(null); - /** The position of the option in the list. */ index = computed(() => this.listbox()?.inputs.items().indexOf(this) ?? -1); diff --git a/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts b/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts index 3b06f5724032..ab15076684d8 100644 --- a/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts +++ b/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts @@ -54,6 +54,9 @@ export class ComboboxTreePattern /** Noop. The combobox controls the open state. */ override setDefaultState(): void {} + /** Navigates to the specified item in the tree. */ + focus = (item: TreeItemPattern) => this.listBehavior.goto(item); + /** Navigates to the next focusable item in the tree. */ next = () => this.listBehavior.next(); @@ -64,18 +67,7 @@ export class ComboboxTreePattern last = () => this.listBehavior.last(); /** Navigates to the first focusable item in the tree. */ - first = (filterText?: string) => { - if (!filterText) { - this.listBehavior.first(); - } else { - const filterFn = this.inputs.combobox()!.inputs.filter(); - const match = this.inputs.allItems().find(i => filterFn(filterText, i.searchTerm())); - - if (match) { - this.listBehavior.goto(match); - } - } - }; + first = () => this.listBehavior.first(); /** Unfocuses the currently focused item in the tree. */ unfocus = () => this.listBehavior.unfocus(); @@ -102,37 +94,13 @@ export class ComboboxTreePattern collapseItem = () => this.collapse(); /** Whether the specified item or the currently active item is expandable. */ - isItemExpandable = (item?: TreeItemPattern) => { - item = item || this.activeItem(); + isItemExpandable(item: TreeItemPattern | undefined = this.activeItem()) { return item ? item.expandable() : false; - }; - - /** Filters the items in the tree based on the provided text. */ - filter = (text: string) => { - if (!text) { - this.inputs.allItems().forEach(i => { - i.inert.set(null); - i.expansion.close(); - }); - return; - } - - const filterFn = this.inputs.combobox()!.inputs.filter(); - this.inputs.allItems().forEach(i => i.expansion.close()); - - this.inputs.allItems().forEach(i => { - const isMatch = filterFn(text, i.searchTerm()); + } - if (isMatch) { - let parent = i.parent(); - while (parent && parent instanceof TreeItemPattern) { - parent.inert.set(null); - parent.expansion.open(); - parent = parent.parent(); - } - } + /** Expands all of the tree items. */ + expandAll = () => this.items().forEach(item => item.expansion.open()); - i.inert.set(isMatch ? null : true); - }); - }; + /** Collapses all of the tree items. */ + collapseAll = () => this.items().forEach(item => item.expansion.close()); } diff --git a/src/cdk-experimental/ui-patterns/tree/tree.ts b/src/cdk-experimental/ui-patterns/tree/tree.ts index 2fd3f59bf994..7b7132d04b02 100644 --- a/src/cdk-experimental/ui-patterns/tree/tree.ts +++ b/src/cdk-experimental/ui-patterns/tree/tree.ts @@ -32,9 +32,6 @@ export interface TreeItemPattern extends TreeItemInputs {} * Represents an item in a Tree. */ export class TreeItemPattern implements ExpansionItem { - /** Whether the option is inert. */ - inert = signal(null); - /** The position of this item among its siblings. */ readonly index = computed(() => this.tree().visibleItems().indexOf(this)); diff --git a/src/components-examples/cdk-experimental/combobox/BUILD.bazel b/src/components-examples/cdk-experimental/combobox/BUILD.bazel index 0181eb24c384..f699b91a9d20 100644 --- a/src/components-examples/cdk-experimental/combobox/BUILD.bazel +++ b/src/components-examples/cdk-experimental/combobox/BUILD.bazel @@ -11,6 +11,7 @@ ng_project( ]), deps = [ "//:node_modules/@angular/core", + "//:node_modules/@angular/forms", "//src/cdk-experimental/listbox", "//src/cdk-experimental/tree", ], diff --git a/src/components-examples/cdk-experimental/combobox/cdk-combobox-auto-select/cdk-combobox-auto-select-example.html b/src/components-examples/cdk-experimental/combobox/cdk-combobox-auto-select/cdk-combobox-auto-select-example.html index a594ea8df8bb..e582263fd44a 100644 --- a/src/components-examples/cdk-experimental/combobox/cdk-combobox-auto-select/cdk-combobox-auto-select-example.html +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-auto-select/cdk-combobox-auto-select-example.html @@ -1,25 +1,30 @@
          search - +
          - @for (state of states; track state) { + @for (option of options(); track option) {
          - {{state}} + {{option}}