From 291d8fed321454359bcb91c39b45924734885168 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Tue, 29 Apr 2025 15:04:22 -0700 Subject: [PATCH] fix(material/chips): provide ability to edit for all screen readers with a click on already focused chip --- goldens/material/chips/index.api.md | 4 ++ src/material/chips/chip-row.spec.ts | 85 +++++++++++++++++++++++++++++ src/material/chips/chip-row.ts | 33 +++++++++++ 3 files changed, 122 insertions(+) diff --git a/goldens/material/chips/index.api.md b/goldens/material/chips/index.api.md index 9ff35385222d..8c3c61b45309 100644 --- a/goldens/material/chips/index.api.md +++ b/goldens/material/chips/index.api.md @@ -423,6 +423,8 @@ export class MatChipRow extends MatChip implements AfterViewInit { editable: boolean; readonly edited: EventEmitter; // (undocumented) + _handleClick(event: MouseEvent): void; + // (undocumented) _handleDoubleclick(event: MouseEvent): void; _handleFocus(): void; // (undocumented) @@ -434,6 +436,8 @@ export class MatChipRow extends MatChip implements AfterViewInit { // (undocumented) _isRippleDisabled(): boolean; // (undocumented) + ngAfterViewInit(): void; + // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; diff --git a/src/material/chips/chip-row.spec.ts b/src/material/chips/chip-row.spec.ts index 08d957d65896..b58a4f65a0d6 100644 --- a/src/material/chips/chip-row.spec.ts +++ b/src/material/chips/chip-row.spec.ts @@ -4,6 +4,7 @@ import { dispatchEvent, dispatchFakeEvent, dispatchKeyboardEvent, + dispatchMouseEvent, provideFakeDirectionality, } from '@angular/cdk/testing/private'; import {Component, DebugElement, ElementRef, ViewChild} from '@angular/core'; @@ -234,6 +235,90 @@ describe('Row Chips', () => { fixture.detectChanges(); expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeTruthy(); }); + + it('should not begin editing on single click', () => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + dispatchMouseEvent(chipNativeElement, 'click'); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + }); + + it('should begin editing on single click when focused', fakeAsync(() => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + chipNativeElement.focus(); + + // Need to also simulate the mousedown as that sets the already focused flag. + dispatchMouseEvent(chipNativeElement, 'mousedown'); + dispatchMouseEvent(chipNativeElement, 'click'); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeTruthy(); + })); + + describe('when disabled', () => { + beforeEach(() => { + testComponent.disabled = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + }); + + it('should not begin editing on double click', () => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + dispatchFakeEvent(chipNativeElement, 'dblclick'); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + }); + + it('should not begin editing on ENTER', () => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + dispatchKeyboardEvent(chipNativeElement, 'keydown', ENTER); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + }); + + it('should not begin editing on single click when focused', fakeAsync(() => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + chipNativeElement.focus(); + + // Need to also simulate the mousedown as that sets the already focused flag. + dispatchMouseEvent(chipNativeElement, 'mousedown'); + dispatchMouseEvent(chipNativeElement, 'click'); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + })); + }); + + describe('when not editable', () => { + beforeEach(() => { + testComponent.editable = false; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + }); + + it('should not begin editing on double click', () => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + dispatchFakeEvent(chipNativeElement, 'dblclick'); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + }); + + it('should not begin editing on ENTER', () => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + dispatchKeyboardEvent(chipNativeElement, 'keydown', ENTER); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + }); + + it('should not begin editing on single click when focused', fakeAsync(() => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + chipNativeElement.focus(); + + // Need to also simulate the mousedown as that sets the already focused flag. + dispatchMouseEvent(chipNativeElement, 'mousedown'); + dispatchMouseEvent(chipNativeElement, 'click'); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + })); + }); }); describe('editing behavior', () => { diff --git a/src/material/chips/chip-row.ts b/src/material/chips/chip-row.ts index 8325255a5d41..a6b478664226 100644 --- a/src/material/chips/chip-row.ts +++ b/src/material/chips/chip-row.ts @@ -60,6 +60,7 @@ export interface MatChipEditedEvent extends MatChipEvent { '[attr.aria-description]': 'null', '[attr.role]': 'role', '(focus)': '_handleFocus()', + '(click)': '_handleClick($event)', '(dblclick)': '_handleDoubleclick($event)', }, providers: [ @@ -92,6 +93,15 @@ export class MatChipRow extends MatChip implements AfterViewInit { /** The projected chip edit input. */ @ContentChild(MatChipEditInput) contentEditInput?: MatChipEditInput; + /** + * Set on a mousedown when the chip is already focused via mouse or keyboard. + * + * This allows us to ensure chip is already focused when deciding whether to enter the + * edit mode on a subsequent click. Otherwise, the chip appears focused when handling the + * first click event. + */ + private _alreadyFocused = false; + _isEditing = false; constructor(...args: unknown[]); @@ -104,6 +114,19 @@ export class MatChipRow extends MatChip implements AfterViewInit { if (this._isEditing && !this._editStartPending) { this._onEditFinish(); } + this._alreadyFocused = false; + }); + } + + override ngAfterViewInit() { + super.ngAfterViewInit(); + + // Sets _alreadyFocused (ahead of click) when chip already has focus. + this._ngZone.runOutsideAngular(() => { + this._elementRef.nativeElement.addEventListener( + 'mousedown', + () => (this._alreadyFocused = this._hasFocus()), + ); }); } @@ -135,6 +158,16 @@ export class MatChipRow extends MatChip implements AfterViewInit { } } + _handleClick(event: MouseEvent) { + if (!this.disabled && this.editable && !this._isEditing && this._alreadyFocused) { + // Ensure click event not picked up unintentionally by other listeners, as + // once editing starts, the source element is detached from DOM. + event.preventDefault(); + event.stopPropagation(); + this._startEditing(event); + } + } + _handleDoubleclick(event: MouseEvent) { if (!this.disabled && this.editable) { this._startEditing(event);