diff --git a/src/cdk-experimental/combobox/BUILD.bazel b/src/cdk-experimental/combobox/BUILD.bazel index c4b52bf73e3c..8cef8563f576 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", "ng_web_test_suite", "ts_project") package(default_visibility = ["//visibility:public"]) @@ -9,17 +9,13 @@ 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", + "//src/cdk-experimental/deferred-content", + "//src/cdk-experimental/ui-patterns", ], ) -ng_project( +ts_project( name = "unit_test_sources", testonly = True, srcs = glob( @@ -28,9 +24,12 @@ ng_project( ), deps = [ ":combobox", + "//:node_modules/@angular/common", "//:node_modules/@angular/core", "//:node_modules/@angular/platform-browser", - "//src/cdk/keycodes", + "//:node_modules/axe-core", + "//src/cdk-experimental/listbox", + "//src/cdk-experimental/tree", "//src/cdk/testing/private", ], ) 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 index 0b6e0ea1ec01..c3198de00b30 100644 --- a/src/cdk-experimental/combobox/combobox.spec.ts +++ b/src/cdk-experimental/combobox/combobox.spec.ts @@ -1,380 +1,1237 @@ -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 {Component, computed, DebugElement, signal} from '@angular/core'; +import {ComponentFixture, TestBed, fakeAsync, tick} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; -import {CdkCombobox} from './combobox'; -import {CdkComboboxModule} from './combobox-module'; +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 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'); + describe('with Listbox', () => { + let fixture: ComponentFixture; + let inputElement: HTMLInputElement; + + const keydown = (key: string, modifierKeys: {} = {}) => { + focus(); + inputElement.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + ...modifierKeys, + }), + ); 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'); + const input = (value: string) => { + focus(); + inputElement.value = value; + inputElement.dispatchEvent(new Event('input', {bubbles: true})); fixture.detectChanges(); + }; - expect(comboboxElement.getAttribute('aria-expanded')).toBe('true'); - - comboboxInstance.close(); + const click = (element: HTMLElement, eventInit?: PointerEventInit) => { + focus(); + element.dispatchEvent(new PointerEvent('pointerup', {bubbles: true, ...eventInit})); fixture.detectChanges(); + }; - expect(comboboxElement.getAttribute('aria-expanded')).toBe('false'); - }); - - it('should toggle focus upon toggling the panel', () => { - comboboxElement.focus(); - testComponent.actions.set('toggle'); + const focus = () => { + inputElement.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); fixture.detectChanges(); + }; - expect(document.activeElement).toEqual(comboboxElement); - - dispatchMouseEvent(comboboxElement, 'click'); + const blur = (relatedTarget?: EventTarget) => { + inputElement.dispatchEvent(new FocusEvent('focusout', {bubbles: true, relatedTarget})); fixture.detectChanges(); + }; - expect(comboboxInstance.isOpen()).toBeTrue(); + const up = (modifierKeys?: {}) => keydown('ArrowUp', modifierKeys); + const down = (modifierKeys?: {}) => keydown('ArrowDown', modifierKeys); + const enter = (modifierKeys?: {}) => keydown('Enter', modifierKeys); + const escape = (modifierKeys?: {}) => keydown('Escape', modifierKeys); - dialog = fixture.debugElement.query(By.directive(CdkComboboxPopup)); - dialogElement = dialog.nativeElement; + function setupCombobox(opts: {filterMode?: 'manual' | 'auto-select' | 'highlight'} = {}) { + TestBed.configureTestingModule({}); + fixture = TestBed.createComponent(ComboboxListboxExample); + const testComponent = fixture.componentInstance; - expect(document.activeElement).toBe(dialogElement); + if (opts.filterMode) { + testComponent.filterMode.set(opts.filterMode); + } - dispatchMouseEvent(comboboxElement, 'click'); fixture.detectChanges(); + defineTestVariables(); + } + + function defineTestVariables() { + 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'); + }); - expect(document.activeElement).not.toEqual(dialogElement); + 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(); + expect(getOption('Alabama')!.getAttribute('aria-selected')).toBe('true'); + }); + + 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('Alabama')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(option.id); + }); }); - it('should have a panel that is closed by default', () => { - expect(comboboxInstance.hasPanel()).toBeTrue(); - expect(comboboxInstance.isOpen()).toBeFalse(); + 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, + ); + }); }); - it('should have an open action of click by default', () => { - expect(comboboxInstance.isOpen()).toBeFalse(); - - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeTrue(); + 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('Alabama')!); + 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('Alabama'); + 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('Alabama')!; + click(fruitItem); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); }); - 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(); + 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(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(['Alabama']); + expect(inputElement.value).toBe('Alabama'); + }); + + it('should not select on navigation', () => { + down(); + down(); + + expect(fixture.componentInstance.value()).toEqual([]); + }); + + it('should select on focusout if the input text exactly matches an item', () => { + focus(); + input('Alabama'); + blur(); + + expect(fixture.componentInstance.value()).toEqual(['Alabama']); + }); + + it('should not select on focusout if the input text does not match an item', () => { + focus(); + input('Appl'); + blur(); + + expect(fixture.componentInstance.value()).toEqual([]); + 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(fixture.componentInstance.value()).toEqual(['Alaska']); + expect(inputElement.value).toBe('Alaska'); + }); + + it('should select and commit on Enter', () => { + down(); + down(); + enter(); + + expect(fixture.componentInstance.value()).toEqual(['Alaska']); + expect(inputElement.value).toBe('Alaska'); + }); + + it('should select on navigation', () => { + down(); + expect(fixture.componentInstance.value()).toEqual(['Alabama']); + + down(); + expect(fixture.componentInstance.value()).toEqual(['Alaska']); + + down(); + expect(fixture.componentInstance.value()).toEqual(['Arizona']); + }); + + it('should select the first option on input', () => { + focus(); + input('W'); + + expect(fixture.componentInstance.value()).toEqual(['Washington']); + }); + + it('should commit the selected option on focusout', () => { + focus(); + input('G'); + blur(); + + expect(inputElement.value).toBe('Georgia'); + expect(fixture.componentInstance.value()).toEqual(['Georgia']); + }); + }); + + 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(fixture.componentInstance.value()).toEqual(['Arizona']); + expect(inputElement.value).toBe('Arizona'); + }); + + it('should select and commit on Enter', () => { + down(); + down(); + down(); + enter(); + + expect(fixture.componentInstance.value()).toEqual(['Arizona']); + expect(inputElement.value).toBe('Arizona'); + }); + + it('should select on navigation', () => { + down(); + expect(fixture.componentInstance.value()).toEqual(['Alabama']); + + down(); + expect(fixture.componentInstance.value()).toEqual(['Alaska']); + }); + + it('should update input value on navigation', () => { + down(); + expect(inputElement.value).toBe('Alabama'); + + down(); + expect(inputElement.value).toBe('Alaska'); + }); + + it('should select the first option on input', () => { + focus(); + input('Cali'); + + expect(fixture.componentInstance.value()).toEqual(['California']); + }); + + it('should insert a highlighted completion string on input', fakeAsync(() => { + focus(); + input('A'); + tick(); + + expect(inputElement.value).toBe('Alabama'); + expect(inputElement.selectionStart).toBe(1); + expect(inputElement.selectionEnd).toBe(7); + })); + + it('should commit the selected option on focusout', () => { + focus(); + input('Cali'); + blur(); + + expect(inputElement.value).toBe('California'); + expect(fixture.componentInstance.value()).toEqual(['California']); + }); + }); }); - 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'); + // TODO(wagnermaciel): Add unit tests for disabled options. + + describe('Filtering', () => { + beforeEach(() => setupCombobox()); + + it('should lazily render options', () => { + expect(getOptions().length).toBe(0); + focus(); + expect(getOptions().length).toBe(50); + }); + + it('should filter the options based on the input value', () => { + focus(); + input('New'); + + 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 = getOptions(); + expect(options.length).toBe(0); + }); + + it('should show all options when the input is cleared', () => { + focus(); + input('Alabama'); + expect(getOptions().length).toBe(1); + + input(''); + expect(getOptions().length).toBe(50); + }); }); - it('should close panel on outside click', () => { - expect(comboboxInstance.isOpen()).toBeFalse(); + // 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'); + // }); + // }); + }); - dispatchMouseEvent(comboboxElement, 'click'); + describe('with Tree', () => { + let fixture: ComponentFixture; + let inputElement: HTMLInputElement; + + const keydown = (key: string, modifierKeys: {} = {}) => { + focus(); + inputElement.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + ...modifierKeys, + }), + ); fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeTrue(); - - const otherDiv = fixture.debugElement.query(By.css('#other-content')); - const otherDivElement = otherDiv.nativeElement; - - dispatchMouseEvent(otherDivElement, 'click'); + }; + + const input = (value: string, opts: {backspace?: boolean} = {}) => { + focus(); + inputElement.value = value; + const event = opts.backspace + ? new InputEvent('input', {inputType: 'deleteContentBackward', bubbles: true}) + : new InputEvent('input', {bubbles: true}); + inputElement.dispatchEvent(event); 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'); + const click = (element: HTMLElement, eventInit?: PointerEventInit) => { + focus(); + element.dispatchEvent(new PointerEvent('pointerup', {bubbles: true, ...eventInit})); 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); + const focus = () => { + inputElement.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); 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'); + const blur = (relatedTarget?: EventTarget) => { + inputElement.dispatchEvent(new FocusEvent('focusout', {bubbles: true, relatedTarget})); fixture.detectChanges(); + }; - const openActions = comboboxInstance.openActions; - expect(openActions.length).toBe(2); - expect(openActions[0]).toBe('focus'); - expect(openActions[1]).toBe('click'); - }); + 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); - it('should coerce actions separated by comma', () => { - testComponent.actions.set('focus,click,downKey'); - fixture.detectChanges(); + function setupCombobox(opts: {filterMode?: 'manual' | 'auto-select' | 'highlight'} = {}) { + TestBed.configureTestingModule({}); + fixture = TestBed.createComponent(ComboboxTreeExample); + const testComponent = fixture.componentInstance; - const openActions = comboboxInstance.openActions; - expect(openActions.length).toBe(3); - expect(openActions[0]).toBe('focus'); - expect(openActions[1]).toBe('click'); - expect(openActions[2]).toBe('downKey'); - }); + if (opts.filterMode) { + testComponent.filterMode.set(opts.filterMode); + } - 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'); + defineTestVariables(); + } + + function defineTestVariables() { + 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 => { + if (el.parentElement?.role === 'group') { + return ( + el.parentElement.previousElementSibling?.getAttribute('aria-expanded') === 'true' + ); + } + return true; + }); + } + + 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(); + enter(); + + const item = getTreeItem('Winter')!; + expect(item.getAttribute('aria-selected')).toBe('true'); + }); + + it('should toggle aria-expanded on parent nodes', () => { + down(); + const item = getTreeItem('Winter')!; + expect(item.getAttribute('aria-expanded')).toBe('false'); + + right(); + expect(item.getAttribute('aria-expanded')).toBe('true'); + + left(); + expect(item.getAttribute('aria-expanded')).toBe('false'); + }); }); - it('should throw error when given invalid open action', () => { - expect(() => { - testComponent.actions.set('invalidAction'); + describe('Navigation', () => { + beforeEach(() => setupCombobox()); + + it('should navigate to the first focusable item on ArrowDown', () => { + down(); + const item = getTreeItem('Winter')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); + }); + + it('should navigate to the last focusable item on ArrowUp', () => { + up(); + 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 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 item = getTreeItem('Summer')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); + }); + + it('should expand a closed node on ArrowRight', () => { + down(); + expect(getVisibleTreeItems().length).toBe(4); + right(); + fixture.detectChanges(); + expect(getVisibleTreeItems().length).toBe(7); + expect(getTreeItem('January')).not.toBeNull(); + }); + + it('should navigate to the next item on ArrowRight when already expanded', () => { + down(); + right(); + right(); + const item = getTreeItem('December')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(item.id); + }); + + it('should collapse an open node on ArrowLeft', () => { + down(); + right(); fixture.detectChanges(); - }).toThrowError('invalidAction is not a support open action for CdkCombobox'); + expect(getVisibleTreeItems().length).toBe(7); + left(); + fixture.detectChanges(); + 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(); + 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 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('Fall')!; + expect(inputElement.getAttribute('aria-activedescendant')).toBe(grainsItem.id); + }); }); - }); - - 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; + describe('Selection', () => { + describe('when filterMode is "manual"', () => { + beforeEach(() => setupCombobox({filterMode: 'manual'})); + + it('should select and commit on click', () => { + click(inputElement); + const item = getTreeItem('April')!; + click(item); + fixture.detectChanges(); + + 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(['Winter']); + expect(inputElement.value).toBe('Winter'); + }); + + it('should select on focusout if the input text exactly matches an item', () => { + focus(); + input('November'); + blur(); + + expect(fixture.componentInstance.value()).toEqual(['November']); + }); + + it('should not select on navigation', () => { + down(); + down(); + + expect(fixture.componentInstance.value()).toEqual([]); + }); + + it('should not select on focusout if the input text does not match an item', () => { + focus(); + input('Appl'); + blur(); + + expect(fixture.componentInstance.value()).toEqual([]); + 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 item = getTreeItem('February')!; + click(item); + fixture.detectChanges(); + + expect(fixture.componentInstance.value()).toEqual(['February']); + expect(inputElement.value).toBe('February'); + }); + + it('should select and commit on Enter', () => { + down(); + down(); + enter(); + + expect(fixture.componentInstance.value()).toEqual(['Spring']); + expect(inputElement.value).toBe('Spring'); + }); + + it('should select on navigation', () => { + down(); + expect(fixture.componentInstance.value()).toEqual(['Winter']); + + down(); + expect(fixture.componentInstance.value()).toEqual(['Spring']); + }); + + it('should select the first option on input', () => { + focus(); + input('Dec'); + expect(fixture.componentInstance.value()).toEqual(['December']); + }); + + it('should commit the selected option on focusout', () => { + focus(); + input('Jun'); + blur(); + + expect(inputElement.value).toBe('June'); + expect(fixture.componentInstance.value()).toEqual(['June']); + }); + }); + + describe('when filterMode is "highlight"', () => { + beforeEach(() => setupCombobox({filterMode: 'highlight'})); + + it('should select and commit on click', () => { + click(inputElement); + down(); + right(); + const item = getTreeItem('February')!; + click(item); + fixture.detectChanges(); + + expect(fixture.componentInstance.value()).toEqual(['February']); + expect(inputElement.value).toBe('February'); + }); + + it('should select and commit on Enter', () => { + down(); + down(); + enter(); + + expect(fixture.componentInstance.value()).toEqual(['Spring']); + expect(inputElement.value).toBe('Spring'); + }); + + it('should select on navigation', () => { + down(); + expect(fixture.componentInstance.value()).toEqual(['Winter']); + + down(); + expect(fixture.componentInstance.value()).toEqual(['Spring']); + }); + + it('should update input value on navigation', () => { + down(); + expect(inputElement.value).toBe('Winter'); + + down(); + expect(inputElement.value).toBe('Spring'); + }); + + it('should select the first option on input', () => { + focus(); + input('Sept'); + + expect(fixture.componentInstance.value()).toEqual(['September']); + }); + + it('should insert a highlighted completion string on input', fakeAsync(() => { + focus(); + input('Feb'); + tick(); + + 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('Jan'); + blur(); + + expect(inputElement.value).toBe('January'); + expect(fixture.componentInstance.value()).toEqual(['January']); + }); + }); }); - 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(); + 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('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('Mar'); + expect(inputElement.value).toBe('March'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + escape(); + expect(inputElement.value).toBe('Mar'); + expect(inputElement.getAttribute('aria-expanded')).toBe('true'); + escape(); + expect(inputElement.value).toBe('Mar'); + 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(); + click(getTreeItem('Spring')!); + expect(inputElement.getAttribute('aria-expanded')).toBe('false'); + }); }); - it('should open panel with click open action', () => { - testComponent.actions.set('click'); - fixture.detectChanges(); + // TODO(wagnermaciel): Add unit tests for disabled options. - expect(comboboxInstance.isOpen()).toBeFalse(); + describe('Filtering', () => { + beforeEach(() => setupCombobox()); - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); + it('should lazily render options', () => { + expect(getTreeItems().length).toBe(0); + focus(); + expect(getTreeItems().length).toBe(16); + }); - expect(comboboxInstance.isOpen()).toBeTrue(); - }); + it('should filter the options based on the input value', () => { + focus(); + input('Summer'); - it('should open panel with downKey open action', () => { - testComponent.actions.set('downKey'); - fixture.detectChanges(); + let items = getVisibleTreeItems(); + expect(items.length).toBe(1); + expect(items[0].textContent?.trim()).toBe('Summer'); + }); - expect(comboboxInstance.isOpen()).toBeFalse(); + it('should render parents if a child matches', () => { + focus(); + input('January'); - dispatchKeyboardEvent(comboboxElement, 'keydown', DOWN_ARROW); - fixture.detectChanges(); + let items = getVisibleTreeItems(); + expect(items.length).toBe(2); + expect(items[0].textContent?.trim()).toBe('Winter'); + expect(items[1].textContent?.trim()).toBe('January'); + }); - expect(comboboxInstance.isOpen()).toBeTrue(); - }); + it('should show no options if nothing matches', () => { + focus(); + input('xyz'); + expect(getVisibleTreeItems().length).toBe(0); + }); - it('should toggle panel with toggle open action', () => { - testComponent.actions.set('toggle'); - fixture.detectChanges(); + it('should show all options when the input is cleared', () => { + focus(); + input('Winter'); + expect(getVisibleTreeItems().length).toBe(1); - expect(comboboxInstance.isOpen()).toBeFalse(); - - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); + input('', {backspace: true}); + fixture.detectChanges(); + expect(getVisibleTreeItems().length).toBe(4); + }); - expect(comboboxInstance.isOpen()).toBeTrue(); + it('should expand all nodes when filtering', () => { + focus(); + expect(getVisibleTreeItems().length).toBe(4); - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); - - expect(comboboxInstance.isOpen()).toBeFalse(); + input('J'); + expect(getTreeItem('Winter')!.getAttribute('aria-expanded')).toBe('true'); + expect(getTreeItem('Summer')!.getAttribute('aria-expanded')).toBe('true'); + }); }); - 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(); + 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(['August']); + fixture.detectChanges(); + expect(fixture.componentInstance.value()).toEqual(['August']); + expect(getTreeItem('August')!.getAttribute('aria-selected')).toBe('true'); + }); }); + }); +}); - it('should handle multiple open actions', () => { - testComponent.actions.set('click downKey'); - fixture.detectChanges(); +@Component({ + template: ` +
+ + + +
+ @for (option of options(); track option) { +
+ {{option}} +
+ } +
+
+
+ `, + imports: [ + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContainer, + CdkListbox, + CdkOption, + ], +}) +class ComboboxListboxExample { + value = signal([]); - expect(comboboxInstance.isOpen()).toBeFalse(); + filterMode = signal<'manual' | 'auto-select' | 'highlight'>('manual'); - dispatchMouseEvent(comboboxElement, 'click'); - fixture.detectChanges(); + searchString = signal(''); - expect(comboboxInstance.isOpen()).toBeTrue(); + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); +} - dispatchKeyboardEvent(comboboxElement, 'keydown', ESCAPE); - fixture.detectChanges(); +@Component({ + template: ` +
+ + + +
    + +
+
+
+ + + @for (node of nodes; track node.name) { +
  • + {{ node.name }} +
  • + + @if (node.children) { +
      + + + +
    + } + } +
    + `, + imports: [ + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopupContainer, + CdkTree, + CdkTreeItem, + CdkTreeItemGroup, + CdkTreeItemGroupContent, + NgTemplateOutlet, + ], +}) +class ComboboxTreeExample { + value = signal([]); - expect(comboboxInstance.isOpen()).toBeFalse(); + filterMode = signal<'manual' | 'auto-select' | 'highlight'>('manual'); - dispatchKeyboardEvent(comboboxElement, 'keydown', DOWN_ARROW); - fixture.detectChanges(); + searchString = signal(''); - expect(comboboxInstance.isOpen()).toBeTrue(); - }); + 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; }); -}); -@Component({ - template: ` - -
    - - -
    - - -
    -
    `, - imports: [CdkComboboxModule], -}) -class ComboboxToggle { - @ViewChild('input') inputElement: ElementRef; + 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()); + } +} - actions = signal('click'); +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 5044d08c802a..38d10e19bf48 100644 --- a/src/cdk-experimental/combobox/combobox.ts +++ b/src/cdk-experimental/combobox/combobox.ts @@ -5,317 +5,140 @@ * 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, + afterRenderEffect, + contentChild, Directive, ElementRef, - EventEmitter, - HOST_TAG_NAME, - InjectionToken, - Injector, - Input, - OnDestroy, - Output, - TemplateRef, - ViewContainerRef, inject, - DOCUMENT, + input, + model, + signal, + untracked, + WritableSignal, } 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 {DeferredContent, DeferredContentAware} from '@angular/cdk-experimental/deferred-content'; +import {ComboboxPattern, ComboboxListboxControls, ComboboxTreeControls} from '../ui-patterns'; @Directive({ selector: '[cdkCombobox]', exportAs: 'cdkCombobox', + hostDirectives: [ + { + directive: DeferredContentAware, + inputs: ['preserveContent'], + }, + ], 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()', + '[attr.data-expanded]': 'pattern.expanded()', + '(input)': 'pattern.onInput($event)', + '(keydown)': 'pattern.onKeydown($event)', + '(pointerup)': 'pattern.onPointerup($event)', + '(focusin)': 'pattern.onFocusIn()', + '(focusout)': 'pattern.onFocusOut($event)', }, - providers: [{provide: CDK_COMBOBOX, useExisting: CdkCombobox}], }) -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[] - >(); +export class CdkCombobox { + /** The element that the combobox is attached to. */ + private readonly _elementRef = inject(ElementRef); - contentId: string = ''; - contentType: AriaHasPopupValue; + /** The DeferredContentAware host directive. */ + private readonly _deferredContentAware = inject(DeferredContentAware, {optional: true}); - ngOnDestroy() { - if (this._overlayRef) { - this._overlayRef.dispose(); - } + /** The combobox popup. */ + readonly popup = contentChild>(CdkComboboxPopup); - this.opened.complete(); - this.closed.complete(); - this.panelValueChanged.complete(); - } + /** The filter mode for the combobox. */ + filterMode = input<'manual' | 'auto-select' | 'highlight'>('manual'); - _keydown(event: KeyboardEvent) { - const {keyCode} = event; + /** Whether the combobox is focused. */ + readonly isFocused = signal(false); - 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(); - } - } + /** The value of the first matching item in the popup. */ + firstMatch = input(undefined); - /** 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(); - } - } - } + /** Whether the listbox has received focus yet. */ + private _hasBeenFocused = signal(false); - /** 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; - } - } + /** The combobox ui pattern. */ + readonly pattern = new ComboboxPattern({ + ...this, + inputValue: signal(''), + inputEl: signal(undefined), + containerEl: () => this._elementRef.nativeElement, + popupControls: () => this.popup()?.controls(), + }); - 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(); + constructor() { + afterRenderEffect(() => { + if (!this._deferredContentAware?.contentVisible() && this.pattern.isFocused()) { + this._deferredContentAware?.contentVisible.set(true); } - } - } - - /** 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); + afterRenderEffect(() => { + if (!this._hasBeenFocused() && this.pattern.isFocused()) { + this._hasBeenFocused.set(true); } - } - } - - 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(), - ); - } +@Directive({ + selector: 'input[cdkComboboxInput]', + 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()', + '[attr.aria-haspopup]': 'combobox.pattern.hasPopup()', + '[attr.aria-autocomplete]': 'combobox.pattern.autocomplete()', + }, +}) +export class CdkComboboxInput { + /** The element that the combobox is attached to. */ + private readonly _elementRef = inject>(ElementRef); - 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'}, - ]; - } + /** The combobox that the input belongs to. */ + readonly combobox = inject(CdkCombobox); - private _getPanelInjector() { - return this._injector; - } + /** The value of the input. */ + value = model(''); - 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(), - ); - } + constructor() { + (this.combobox.pattern.inputs.inputEl as WritableSignal).set( + this._elementRef.nativeElement, + ); + this.combobox.pattern.inputs.inputValue = this.value; - return this._panelPortal; + /** 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()); + }); } +} - 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[]; - } +@Directive({ + selector: 'ng-template[cdkComboboxPopupContainer]', + exportAs: 'cdkComboboxPopupContainer', + hostDirectives: [DeferredContent], +}) +export class CdkComboboxPopupContainer {} - /** 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; - } +@Directive({ + selector: '[cdkComboboxPopup]', + exportAs: 'cdkComboboxPopup', +}) +export class CdkComboboxPopup { + /** The combobox that the popup belongs to. */ + readonly combobox = inject>(CdkCombobox, {optional: true}); + + /** The controls the popup exposes to the combobox. */ + readonly controls = signal< + ComboboxListboxControls | ComboboxTreeControls | undefined + >(undefined); } diff --git a/src/cdk-experimental/combobox/public-api.ts b/src/cdk-experimental/combobox/public-api.ts index 6f6b443c18bb..f54c5c49588f 100644 --- a/src/cdk-experimental/combobox/public-api.ts +++ b/src/cdk-experimental/combobox/public-api.ts @@ -6,6 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ -export * from './combobox-module'; -export * from './combobox'; -export * from './combobox-popup'; +export { + CdkCombobox, + CdkComboboxInput, + CdkComboboxPopup, + CdkComboboxPopupContainer, +} from './combobox'; diff --git a/src/cdk-experimental/deferred-content/deferred-content.ts b/src/cdk-experimental/deferred-content/deferred-content.ts index f98ef1cc9634..f105ffcbad5a 100644 --- a/src/cdk-experimental/deferred-content/deferred-content.ts +++ b/src/cdk-experimental/deferred-content/deferred-content.ts @@ -10,10 +10,10 @@ import { afterRenderEffect, Directive, inject, - input, TemplateRef, signal, ViewContainerRef, + model, } from '@angular/core'; /** @@ -22,7 +22,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/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..a8db327eebe3 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -17,11 +17,13 @@ import { input, model, signal, + untracked, } 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'; +import {CdkComboboxPopup} from '../combobox'; /** * A listbox container. @@ -43,6 +45,7 @@ import {_IdGenerator} from '@angular/cdk/a11y'; host: { 'role': 'listbox', 'class': 'cdk-listbox', + '[attr.id]': 'id()', '[attr.tabindex]': 'pattern.tabindex()', '[attr.aria-readonly]': 'pattern.readonly()', '[attr.aria-disabled]': 'pattern.disabled()', @@ -53,8 +56,21 @@ import {_IdGenerator} from '@angular/cdk/a11y'; '(pointerdown)': 'pattern.onPointerdown($event)', '(focusin)': 'onFocus()', }, + hostDirectives: [{directive: CdkComboboxPopup}], }) export class CdkListbox { + /** 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 listbox. */ + 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); @@ -103,18 +119,30 @@ 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, - }); + pattern: ListboxPattern; /** Whether the listbox has received focus yet. */ private _hasFocused = signal(false); constructor() { + const inputs = { + ...this, + id: this.id, + 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.controls.set(this.pattern as ComboboxListboxPattern); + } + afterRenderEffect(() => { if (typeof ngDevMode === 'undefined' || ngDevMode) { const violations = this.pattern.validate(); @@ -129,6 +157,27 @@ export class CdkListbox { this.pattern.setDefaultState(); } }); + + // 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()); + + if (!items.some(i => i === activeItem) && activeItem) { + this.pattern.listBehavior.unfocus(); + } + }); + + // 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))); + } + }); } onFocus() { diff --git a/src/cdk-experimental/tree/BUILD.bazel b/src/cdk-experimental/tree/BUILD.bazel index 2cfda0a3550f..eb097a7d557f 100644 --- a/src/cdk-experimental/tree/BUILD.bazel +++ b/src/cdk-experimental/tree/BUILD.bazel @@ -11,8 +11,9 @@ ng_project( ], deps = [ "//:node_modules/@angular/core", + "//src/cdk-experimental/combobox", "//src/cdk-experimental/deferred-content", - "//src/cdk-experimental/ui-patterns/tree", + "//src/cdk-experimental/ui-patterns", "//src/cdk/a11y", "//src/cdk/bidi", ], diff --git a/src/cdk-experimental/tree/tree.ts b/src/cdk-experimental/tree/tree.ts index 982809aea3e8..68096f476681 100644 --- a/src/cdk-experimental/tree/tree.ts +++ b/src/cdk-experimental/tree/tree.ts @@ -19,11 +19,13 @@ import { Signal, OnInit, OnDestroy, + untracked, } from '@angular/core'; 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 {CdkComboboxPopup} from '@angular/cdk-experimental/combobox'; interface HasElement { element: Signal; @@ -65,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()', @@ -74,8 +77,21 @@ function sortDirectives(a: HasElement, b: HasElement) { '(pointerdown)': 'pattern.onPointerdown($event)', '(focusin)': 'onFocus()', }, + 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, + }); + /** A reference to the tree element. */ private readonly _elementRef = inject(ElementRef); @@ -121,24 +137,54 @@ 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, + id: this.id, + allItems: computed(() => + [...this._unorderedItems()].sort(sortDirectives).map(item => item.pattern), + ), + activeItem: signal | undefined>(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(); } }); + + 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))); + } + }); } onFocus() { @@ -176,7 +222,6 @@ export class CdkTree { '[attr.aria-setsize]': 'pattern.setsize()', '[attr.aria-posinset]': 'pattern.posinset()', '[attr.tabindex]': 'pattern.tabindex()', - '[attr.inert]': 'pattern.visible() ? null : true', }, }) export class CdkTreeItem implements OnInit, OnDestroy, HasElement { @@ -313,9 +358,12 @@ export class CdkTreeItemGroup implements OnInit, OnDestroy, HasElement { readonly ownedBy = input.required>(); constructor() { + this._deferredContentAware.preserveContent.set(true); // Connect the group's hidden state to the DeferredContentAware's visibility. afterRenderEffect(() => { - 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/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/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/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..70f52d867d63 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/combobox/BUILD.bazel @@ -0,0 +1,37 @@ +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-experimental/ui-patterns/behaviors/signal-like", + "//src/cdk-experimental/ui-patterns/listbox", + "//src/cdk-experimental/ui-patterns/tree", + "//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.spec.ts b/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts new file mode 100644 index 000000000000..8a67d3f0ce70 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/combobox/combobox.spec.ts @@ -0,0 +1,886 @@ +/** + * @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 _type( + text: string, + inputEl: HTMLInputElement, + combobox: ComboboxPattern, + allOptions: TestOption[] | TreeItemPattern[], + popup: ComboboxListboxPattern | ComboboxTreePattern, + firstMatch: WritableSignal, + backspace = false, +) { + combobox.onFocusIn(); + 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; + }> = {}, +) { + 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'), + firstMatch, + inputValue, + }); + + return {combobox, inputEl, containerEl, firstMatch, inputValue}; +} + +function getListboxPattern( + combobox: ComboboxPattern, + values: string[], + initialValue?: string, +) { + const options = signal([]); + + const listbox = new ComboboxListboxPattern({ + id: signal('listbox-1'), + items: options, + value: signal(initialValue ? [initialValue] : []), + 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[], + initialValue?: string, +) { + const items = signal[]>([]); + + const tree = new ComboboxTreePattern({ + id: signal('tree-1'), + allItems: items, + value: signal(initialValue ? [initialValue] : []), + 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, firstMatch, inputValue} = 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, + inputEl: inputEl()!, + containerEl: containerEl()!, + firstMatch, + inputValue, + }; + } + + 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', () => { + 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', () => { + combobox.onPointerup(clickOption(listbox.inputs.items(), 0)); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); + expect(listbox.inputs.value()).toEqual(['Apple']); + expect(inputEl.value).toBe('Apple'); + }); + + it('should select and commit to input on Enter', () => { + combobox.onKeydown(down()); + combobox.onKeydown(enter()); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]); + expect(listbox.inputs.value()).toEqual(['Apple']); + expect(inputEl.value).toBe('Apple'); + }); + + it('should select on focusout if the input text exactly matches an item', () => { + 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', () => { + combobox.onKeydown(down()); + combobox.onKeydown(enter()); + + type('Appl', {backspace: true}); + combobox.onInput(new InputEvent('input', {inputType: 'deleteContentBackward'})); + + expect(listbox.getSelectedItem()).toBe(undefined); + expect(listbox.inputs.value()).toEqual([]); + }); + + it('should not select on navigation', () => { + combobox.onKeydown(down()); + expect(listbox.getSelectedItem()).toBe(undefined); + expect(listbox.inputs.value()).toEqual([]); + }); + + it('should not select on 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', () => { + type('Appl'); + combobox.onFocusOut(new FocusEvent('focusout')); + expect(listbox.getSelectedItem()).toBe(undefined); + expect(listbox.inputs.value()).toEqual([]); + expect(inputEl.value).toBe('Appl'); + }); + }); + + describe('when filterMode is "auto-select"', () => { + beforeEach(() => { + ({combobox, listbox, inputEl, options, firstMatch} = getPatterns({ + filterMode: 'auto-select', + })); + }); + + it('should select and commit on click', () => { + combobox.onPointerup(clickOption(listbox.inputs.items(), 3)); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[3]); + expect(listbox.inputs.value()).toEqual(['Blackberry']); + expect(inputEl.value).toBe('Blackberry'); + }); + + it('should select and commit on Enter', () => { + combobox.onKeydown(down()); + combobox.onKeydown(down()); + combobox.onKeydown(down()); + combobox.onKeydown(enter()); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]); + expect(listbox.inputs.value()).toEqual(['Banana']); + expect(inputEl.value).toBe('Banana'); + }); + + it('should select the first item on arrow down when collapsed', () => { + 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', () => { + combobox.onKeydown(up()); + expect(listbox.getSelectedItem()).toBe( + listbox.inputs.items()[listbox.inputs.items().length - 1], + ); + expect(listbox.inputs.value()).toEqual(['Cranberry']); + }); + + it('should select on navigation', () => { + combobox.onKeydown(down()); + combobox.onKeydown(down()); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[1]); + expect(listbox.inputs.value()).toEqual(['Apricot']); + }); + + it('should select the first option on input', () => { + type('A'); + expect(listbox.inputs.value()).toEqual(['Apple']); + + type('Apr'); + expect(listbox.inputs.value()).toEqual(['Apricot']); + }); + + it('should commit the selected option on focusout', () => { + combobox.onKeydown(down()); + 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', () => { + combobox.onPointerup(clickOption(listbox.inputs.items(), 3)); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[3]); + expect(listbox.inputs.value()).toEqual(['Blackberry']); + expect(inputEl.value).toBe('Blackberry'); + }); + + it('should select and commit on Enter', () => { + combobox.onKeydown(down()); + combobox.onKeydown(down()); + combobox.onKeydown(down()); + combobox.onKeydown(enter()); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]); + expect(listbox.inputs.value()).toEqual(['Banana']); + expect(inputEl.value).toBe('Banana'); + }); + + it('should select the first item on arrow down when collapsed', () => { + 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', () => { + combobox.onKeydown(up()); + expect(listbox.getSelectedItem()).toBe( + listbox.inputs.items()[listbox.inputs.items().length - 1], + ); + expect(listbox.inputs.value()).toEqual(['Cranberry']); + }); + + it('should select on navigation', () => { + combobox.onKeydown(down()); + combobox.onKeydown(down()); + expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[1]); + expect(listbox.inputs.value()).toEqual(['Apricot']); + }); + + it('should select the first option on input', () => { + type('A'); + expect(listbox.inputs.value()).toEqual(['Apple']); + + type('Apr'); + expect(listbox.inputs.value()).toEqual(['Apricot']); + }); + + it('should commit the selected option on navigation', () => { + combobox.onKeydown(down()); + expect(inputEl.value).toBe('Apple'); + combobox.onKeydown(down()); + expect(inputEl.value).toBe('Apricot'); + }); + + it('should commit the selected option on focusout', () => { + combobox.onKeydown(down()); + type('App'); + combobox.onFocusOut(new FocusEvent('focusout')); + expect(inputEl.value).toBe('Apple'); + }); + + it('should insert a highlighted completion string on 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', () => { + type('A'); + 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, 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'}]}, + {value: 'Grains', children: [{value: 'Rice'}, {value: 'Wheat'}]}, + ]); + + (combobox.inputs.popupControls as WritableSignal).set(tree); + + return { + combobox, + tree, + items: items, + inputEl: inputEl()!, + containerEl: containerEl()!, + firstMatch, + inputValue, + }; + } + + 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', () => { + 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', () => { + 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', () => { + combobox.onKeydown(down()); + combobox.onKeydown(enter()); + expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[0]); + expect(tree.inputs.value()).toEqual(['Fruit']); + expect(inputEl.value).toBe('Fruit'); + }); + + it('should select on focusout if the input text exactly matches an item', () => { + combobox.onPointerup(clickInput(inputEl)); + type('Apple'); + combobox.onFocusOut(new FocusEvent('focusout')); + expect(tree.inputs.value()).toEqual(['Apple']); + }); + + it('should deselect on backspace', () => { + combobox.onKeydown(down()); + combobox.onKeydown(enter()); + + type('Appl', {backspace: true}); + + expect(tree.getSelectedItem()).toBe(undefined); + expect(tree.inputs.value()).toEqual([]); + }); + + it('should not select on navigation', () => { + combobox.onKeydown(down()); + expect(tree.getSelectedItem()).toBe(undefined); + expect(tree.inputs.value()).toEqual([]); + }); + + it('should not select on 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', () => { + type('Appl'); + combobox.onFocusOut(new FocusEvent('focusout')); + expect(tree.getSelectedItem()).toBe(undefined); + expect(tree.inputs.value()).toEqual([]); + expect(inputEl.value).toBe('Appl'); + }); + }); + + describe('when filterMode is "auto-select"', () => { + beforeEach(() => { + ({combobox, tree, inputEl, items, firstMatch} = getPatterns({ + filterMode: 'auto-select', + })); + }); + + it('should select and commit on click', () => { + combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 2)); + expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[2]); + expect(tree.inputs.value()).toEqual(['Banana']); + expect(inputEl.value).toBe('Banana'); + }); + + it('should select and commit on Enter', () => { + combobox.onKeydown(down()); + combobox.onKeydown(down()); + combobox.onKeydown(down()); + combobox.onKeydown(enter()); + expect(tree.inputs.value()).toEqual(['Grains']); + expect(inputEl.value).toBe('Grains'); + }); + + it('should select the first item on arrow down when collapsed', () => { + 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', () => { + combobox.onKeydown(up()); + expect(tree.inputs.value()).toEqual(['Grains']); + }); + + it('should select on navigation', () => { + combobox.onKeydown(down()); + combobox.onKeydown(right()); + combobox.onKeydown(right()); + expect(tree.inputs.value()).toEqual(['Apple']); + }); + + it('should select the first option on input', () => { + type('B'); + expect(tree.inputs.value()).toEqual(['Banana']); + + type('Bro'); + expect(tree.inputs.value()).toEqual(['Broccoli']); + }); + + it('should commit the selected option on focusout', () => { + combobox.onKeydown(down()); + 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', () => { + combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 2)); + expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[2]); + expect(tree.inputs.value()).toEqual(['Banana']); + expect(inputEl.value).toBe('Banana'); + }); + + it('should select and commit on Enter', () => { + combobox.onKeydown(down()); + combobox.onKeydown(down()); + combobox.onKeydown(down()); + combobox.onKeydown(enter()); + expect(tree.inputs.value()).toEqual(['Grains']); + expect(inputEl.value).toBe('Grains'); + }); + + it('should select the first item on arrow down when collapsed', () => { + 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', () => { + combobox.onKeydown(up()); + expect(tree.inputs.value()).toEqual(['Grains']); + }); + + it('should select on navigation', () => { + combobox.onKeydown(down()); + combobox.onKeydown(right()); + combobox.onKeydown(right()); + expect(tree.inputs.value()).toEqual(['Apple']); + }); + + it('should select the first option on input', () => { + type('B'); + expect(tree.inputs.value()).toEqual(['Banana']); + + type('Bro'); + expect(tree.inputs.value()).toEqual(['Broccoli']); + }); + + it('should commit the selected option on navigation', () => { + combobox.onKeydown(down()); + expect(inputEl.value).toBe('Fruit'); + combobox.onKeydown(right()); + combobox.onKeydown(right()); + expect(inputEl.value).toBe('Apple'); + combobox.onKeydown(down()); + expect(tree.inputs.value()).toEqual(['Banana']); + }); + + it('should commit the selected option on focusout', () => { + combobox.onKeydown(down()); + type('App'); + combobox.onFocusOut(new FocusEvent('focusout')); + expect(inputEl.value).toBe('Apple'); + }); + + it('should insert a highlighted completion string on input', () => { + type('A'); + expect(inputEl.value).toBe('Apple'); + expect(inputEl.selectionStart).toBe(1); + expect(inputEl.selectionEnd).toBe(5); + }); + }); + }); +}); 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..cb2c356822cc --- /dev/null +++ b/src/cdk-experimental/ui-patterns/combobox/combobox.ts @@ -0,0 +1,440 @@ +/** + * @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 interface ComboboxInputs, V> { + /** The controls for the popup associated with the combobox. */ + popupControls: SignalLike | ComboboxTreeControls | 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'>; + + /** 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. */ +export interface ComboboxListboxControls, V> { + /** A unique identifier for the popup. */ + id: () => string; + + /** The ARIA role for the popup. */ + role: SignalLike<'listbox' | 'tree' | 'grid'>; + + /** The ID of the active item in the popup. */ + activeId: SignalLike; + + /** 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. */ + first: () => void; + + /** Navigates to the last item in the popup. */ + last: () => void; + + /** Selects the current item in the popup. */ + select: (item?: T) => void; + + /** Clears the selection state of the popup. */ + clearSelection: () => 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. +} + +export interface ComboboxTreeControls, V> + extends ComboboxListboxControls { + /** Whether the currently active item in the popup is collapsible. */ + isItemCollapsible: () => boolean; + + /** 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; + + /** Expands all nodes in the tree. */ + expandAll: () => void; + + /** Collapses all nodes in the tree. */ + collapseAll: () => void; +} + +/** 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 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); + + /** 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 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()) { + return new KeyboardEventManager() + .on('ArrowDown', () => this.open({first: true})) + .on('ArrowUp', () => this.open({last: true})); + } + + 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', () => { + // 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.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})); + + 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(), () => this.expandItem()); + } + } + + return manager; + }); + + /** 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.open(); + 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 (searchTerm && this.inputs.inputValue!() !== searchTerm) { + this.inputs.popupControls()?.clearSelection(); + } + } + } + + onFocusIn() { + this.isFocused.set(true); + } + + /** Handles focus out events for the combobox. */ + onFocusOut(event: FocusEvent) { + if ( + !(event.relatedTarget instanceof HTMLElement) || + !this.inputs.containerEl()?.contains(event.relatedTarget) + ) { + this.isFocused.set(false); + 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(); + } + } + + 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]; + } + + return this.inputs + .popupControls() + ?.items() + .find(i => i.value() === this.inputs.firstMatch()); + }); + + 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 (isInitialRender) { + return; + } + + // Avoid refocusing the input if a filter event occurs after focus has left the combobox. + if (!this.isFocused()) { + 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 inputEl = this.inputs.inputEl(); + const item = this.inputs.popupControls()?.getSelectedItem(); + + if (!inputEl || !item) { + return; + } + + const isHighlightable = item + .searchTerm() + .toLowerCase() + .startsWith(this.inputs.inputValue!().toLowerCase()); + + 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()); + } + + /** 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()); + } + + 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} = {}) { + this.inputs.popupControls()?.select(opts.item); + + if (opts.commit) { + this.commit(); + } + if (opts.close) { + this.close(); + } + } + + /** Updates the value of the input based on the currently selected item. */ + commit() { + const inputEl = this.inputs.inputEl(); + const item = this.inputs.popupControls()?.getSelectedItem(); + + if (inputEl && item) { + inputEl.value = item.searchTerm(); + this.inputs.inputValue?.set(item.searchTerm()); + + if (this.inputs.filterMode() === 'highlight') { + const length = inputEl.value.length; + inputEl.setSelectionRange(length, length); + } + } + } + + /** Navigates and handles additional actions based on filter mode. */ + private _navigate(operation: () => void) { + operation(); + + if (this.inputs.filterMode() !== 'manual') { + this.select(); + } + + if (this.inputs.filterMode() === 'highlight') { + // 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) { + 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/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/combobox-listbox.ts b/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts new file mode 100644 index 000000000000..b331de3c358e --- /dev/null +++ b/src/cdk-experimental/ui-patterns/listbox/combobox-listbox.ts @@ -0,0 +1,90 @@ +/** + * @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'; +import {OptionPattern} from './option'; +import {ComboboxPattern, ComboboxListboxControls} from '../combobox/combobox'; + +export type ComboboxListboxInputs = ListboxInputs & { + /** The combobox controlling the listbox. */ + combobox: SignalLike, V> | undefined>; +}; + +export class ComboboxListboxPattern + extends ListboxPattern + implements ComboboxListboxControls, V> +{ + /** 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()); + + /** 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; + + constructor(override readonly inputs: ComboboxListboxInputs) { + 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 {} + + /** 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(); + + /** 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] : []); +} 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/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index 893095f070af..77e12bfc46ca 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; }; @@ -31,7 +35,7 @@ export class ListboxPattern { readonly: SignalLike; /** The tabindex of the listbox. */ - tabindex = computed(() => this.listBehavior.tabindex()); + tabindex: SignalLike<-1 | 0> = computed(() => this.listBehavior.tabindex()); /** The id of the current active item. */ activedescendant = computed(() => this.listBehavior.activedescendant()); @@ -188,7 +192,6 @@ export class ListboxPattern { this.readonly = inputs.readonly; this.orientation = inputs.orientation; this.multi = inputs.multi; - this.listBehavior = new List(inputs); } @@ -202,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; } @@ -256,7 +251,7 @@ export class ListboxPattern { } } - private _getItem(e: PointerEvent) { + protected _getItem(e: PointerEvent) { if (!(e.target instanceof HTMLElement)) { return; } diff --git a/src/cdk-experimental/ui-patterns/public-api.ts b/src/cdk-experimental/ui-patterns/public-api.ts index 7260025d2930..55ae55da51a1 100644 --- a/src/cdk-experimental/ui-patterns/public-api.ts +++ b/src/cdk-experimental/ui-patterns/public-api.ts @@ -6,8 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ +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'; @@ -18,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..ab15076684d8 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/tree/combobox-tree.ts @@ -0,0 +1,106 @@ +/** + * @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'; +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> +{ + /** 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; + + 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 {} + + /** 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(); + + /** 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 | undefined = this.activeItem()) { + return item ? item.expandable() : false; + } + + /** Expands all of the tree items. */ + expandAll = () => this.items().forEach(item => item.expansion.open()); + + /** Collapses all of the tree items. */ + collapseAll = () => this.items().forEach(item => item.expansion.close()); +} 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'), diff --git a/src/cdk-experimental/ui-patterns/tree/tree.ts b/src/cdk-experimental/ui-patterns/tree/tree.ts index eed86e65434f..7b7132d04b02 100644 --- a/src/cdk-experimental/ui-patterns/tree/tree.ts +++ b/src/cdk-experimental/ui-patterns/tree/tree.ts @@ -122,6 +122,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[]>; @@ -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()); @@ -293,6 +296,7 @@ export class TreePattern { }); constructor(readonly inputs: TreeInputs) { + this.id = inputs.id; this.nav = inputs.nav; this.currentType = inputs.currentType; this.allItems = inputs.allItems; @@ -425,7 +429,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 new file mode 100644 index 000000000000..9e2dfcead864 --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/BUILD.bazel @@ -0,0 +1,28 @@ +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/common", + "//:node_modules/@angular/core", + "//:node_modules/@angular/forms", + "//src/cdk-experimental/listbox", + "//src/cdk-experimental/tree", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.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..e582263fd44a --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-auto-select/cdk-combobox-auto-select-example.html @@ -0,0 +1,38 @@ +
    +
    + search + +
    + +
    + +
    + @for (option of options(); track option) { +
    + {{option}} + +
    + } +
    +
    +
    +
    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..2fd3e2acea65 --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-auto-select/cdk-combobox-auto-select-example.ts @@ -0,0 +1,131 @@ +/** + * @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, + CdkComboboxPopupContainer, +} from '@angular/cdk-experimental/combobox'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + signal, + 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, + CdkComboboxPopupContainer, + CdkListbox, + CdkOption, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkComboboxAutoSelectExample { + popover = viewChild('popover'); + listbox = viewChild>(CdkListbox); + combobox = viewChild>(CdkCombobox); + + searchString = signal(''); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + 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(); + } +} + +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/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..ac10fdf13b13 --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-examples.css @@ -0,0 +1,149 @@ +.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], +.example-tree-item[inert] { + display: none; +} + +.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); +} + +.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; +} + +ul[role='group'] { + padding-inline-start: 1rem; +} + +.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-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..59430fda8cd7 --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-highlight/cdk-combobox-highlight-example.html @@ -0,0 +1,38 @@ +
    +
    + search + +
    + +
    + +
    + @for (option of options(); track option) { +
    + {{option}} + +
    + } +
    +
    +
    +
    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..611ab5ab004e --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-highlight/cdk-combobox-highlight-example.ts @@ -0,0 +1,133 @@ +/** + * @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, + CdkComboboxPopupContainer, +} from '@angular/cdk-experimental/combobox'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + signal, + viewChild, +} from '@angular/core'; +import {FormsModule} from '@angular/forms'; + +/** @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, + CdkComboboxPopupContainer, + CdkListbox, + CdkOption, + FormsModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkComboboxHighlightExample { + popover = viewChild('popover'); + listbox = viewChild>(CdkListbox); + combobox = viewChild>(CdkCombobox); + + searchString = signal(''); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + 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(); + } +} + +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/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..2d5ec27ae3cc --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-manual/cdk-combobox-manual-example.html @@ -0,0 +1,38 @@ +
    +
    + search + +
    + +
    + +
    + @for (option of options(); track option) { +
    + {{option}} + +
    + } +
    +
    +
    +
    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..94413a22225b --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-manual/cdk-combobox-manual-example.ts @@ -0,0 +1,133 @@ +/** + * @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, + CdkComboboxPopupContainer, +} from '@angular/cdk-experimental/combobox'; +import {CdkListbox, CdkOption} from '@angular/cdk-experimental/listbox'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + signal, + viewChild, +} from '@angular/core'; +import {FormsModule} from '@angular/forms'; + +/** @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, + CdkComboboxPopupContainer, + CdkListbox, + CdkOption, + FormsModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkComboboxManualExample { + popover = viewChild('popover'); + listbox = viewChild>(CdkListbox); + combobox = viewChild>(CdkCombobox); + + searchString = signal(''); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + 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(); + } +} + +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/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..535dc882091f --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-auto-select/cdk-combobox-tree-auto-select-example.html @@ -0,0 +1,62 @@ +
    +
    + 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..0ab4b24bb0be --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-auto-select/cdk-combobox-tree-auto-select-example.ts @@ -0,0 +1,112 @@ +/** + * @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, + CdkComboboxPopupContainer, +} from '@angular/cdk-experimental/combobox'; +import { + CdkTree, + CdkTreeItem, + CdkTreeItemGroup, + CdkTreeItemGroupContent, +} from '@angular/cdk-experimental/tree'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + signal, + viewChild, +} from '@angular/core'; +import {TREE_NODES, TreeNode} 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, + CdkComboboxPopupContainer, + CdkTree, + CdkTreeItem, + CdkTreeItemGroup, + CdkTreeItemGroupContent, + NgTemplateOutlet, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkComboboxTreeAutoSelectExample { + popover = viewChild('popover'); + tree = viewChild>(CdkTree); + combobox = viewChild>(CdkCombobox); + + 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()); + } + + 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..6849f55fd392 --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-highlight/cdk-combobox-tree-highlight-example.html @@ -0,0 +1,62 @@ +
    +
    + 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..52fc09353999 --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-highlight/cdk-combobox-tree-highlight-example.ts @@ -0,0 +1,112 @@ +/** + * @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, + CdkComboboxPopupContainer, +} from '@angular/cdk-experimental/combobox'; +import { + CdkTree, + CdkTreeItem, + CdkTreeItemGroup, + CdkTreeItemGroupContent, +} from '@angular/cdk-experimental/tree'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + signal, + viewChild, +} from '@angular/core'; +import {TREE_NODES, TreeNode} 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, + CdkComboboxPopupContainer, + CdkTree, + CdkTreeItem, + CdkTreeItemGroup, + CdkTreeItemGroupContent, + NgTemplateOutlet, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkComboboxTreeHighlightExample { + popover = viewChild('popover'); + tree = viewChild>(CdkTree); + combobox = viewChild>(CdkCombobox); + + 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()); + } + + 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..1b6301fb9604 --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-manual/cdk-combobox-tree-manual-example.html @@ -0,0 +1,62 @@ +
    +
    + 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..5196e9054a62 --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/cdk-combobox-tree-manual/cdk-combobox-tree-manual-example.ts @@ -0,0 +1,112 @@ +/** + * @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, + CdkComboboxPopupContainer, +} from '@angular/cdk-experimental/combobox'; +import { + CdkTree, + CdkTreeItem, + CdkTreeItemGroup, + CdkTreeItemGroupContent, +} from '@angular/cdk-experimental/tree'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + signal, + viewChild, +} from '@angular/core'; +import {TREE_NODES, TreeNode} 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, + CdkComboboxPopupContainer, + CdkTree, + CdkTreeItem, + CdkTreeItemGroup, + CdkTreeItemGroupContent, + NgTemplateOutlet, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkComboboxTreeManualExample { + popover = viewChild('popover'); + tree = viewChild>(CdkTree); + combobox = viewChild>(CdkCombobox); + + 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()); + } + + 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..ecc8db3bc4aa --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/data.ts @@ -0,0 +1,23 @@ +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'}], + }, +]; 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..b061c0017413 --- /dev/null +++ b/src/components-examples/cdk-experimental/combobox/index.ts @@ -0,0 +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/BUILD.bazel b/src/dev-app/cdk-experimental-combobox/BUILD.bazel index 7599efce5d7b..80a45cb5fc0f 100644 --- a/src/dev-app/cdk-experimental-combobox/BUILD.bazel +++ b/src/dev-app/cdk-experimental-combobox/BUILD.bazel @@ -7,9 +7,10 @@ ng_project( srcs = glob(["**/*.ts"]), assets = [ "cdk-combobox-demo.html", + "cdk-combobox-demo.css", ], deps = [ "//:node_modules/@angular/core", - "//src/cdk-experimental/combobox", + "//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 new file mode 100644 index 000000000000..8233ba47f19b --- /dev/null +++ b/src/dev-app/cdk-experimental-combobox/cdk-combobox-demo.css @@ -0,0 +1,24 @@ +.example-combobox-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); + gap: 20px; + justify-items: center; +} + +.example-combobox-container { + display: flex; + flex-direction: column; + justify-content: flex-start; + + /* stylelint-disable material/no-prefixes */ + width: fit-content; +} + +.example-configurable-combobox-container { + padding-top: 40px; +} + +h2 { + font-size: 1.1rem; +} + 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..2bc6cf0c0f45 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,33 @@ -Toggle Combobox! -
    - - - -
    - - -
    -
    +
    +
    +
    +

    Combobox with manual filtering

    + +
    + +
    +

    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 f138241fa601..3d94a23b107b 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,27 @@ * found in the LICENSE file at https://angular.dev/license */ -import {CdkComboboxModule} from '@angular/cdk-experimental/combobox'; +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', - imports: [CdkComboboxModule], + styleUrl: 'cdk-combobox-demo.css', + imports: [ + CdkComboboxManualExample, + CdkComboboxAutoSelectExample, + CdkComboboxHighlightExample, + CdkComboboxTreeManualExample, + CdkComboboxTreeAutoSelectExample, + CdkComboboxTreeHighlightExample, + ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class CdkComboboxDemo {}