From 74d67c39a0b7e807fc2d9ada293a1e8f15e43e38 Mon Sep 17 00:00:00 2001 From: Mykyta Maliarchuk <84377976+nikita-web-ua@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:23:16 +0200 Subject: [PATCH] [ACS-11317] a11y Fix: Tags interactive controls are not manageable via keyboard (#11845) * [ACS-11317] Fix a11y: Delete button in create tags popup is not focusable via keyboard * [ACS-11317] use UnitTestingUtils * [ACS-11317] cr fixes --- .../tags-creator/tags-creator.component.scss | 3 +- .../tags-creator.component.spec.ts | 112 +++++++++++------- .../tags-creator/tags-creator.component.ts | 14 ++- .../dynamic-chip-list.component.html | 29 +++-- .../dynamic-chip-list.component.scss | 29 +++++ .../dynamic-chip-list.component.spec.ts | 4 +- .../dynamic-chip-list.component.ts | 6 + lib/core/src/lib/i18n/en.json | 3 +- 8 files changed, 141 insertions(+), 59 deletions(-) diff --git a/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.scss b/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.scss index 9742f74443..e7de7bd783 100644 --- a/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.scss +++ b/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.scss @@ -82,8 +82,7 @@ adf-tags-creator { /* stylelint-disable selector-class-pattern */ .mdc-evolution-chip-set .mat-mdc-standard-chip { .mdc-evolution-chip__cell--primary, - .mdc-evolution-chip__action--primary, - .mat-mdc-chip-action-label { + .mdc-evolution-chip__action--primary { overflow: hidden; word-break: break-all; } diff --git a/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.spec.ts b/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.spec.ts index f26a26b79e..95ae88dc66 100644 --- a/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.spec.ts +++ b/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.spec.ts @@ -16,7 +16,7 @@ */ import { TagsCreatorMode, TagService } from '@alfresco/adf-content-services'; -import { NotificationService } from '@alfresco/adf-core'; +import { NotificationService, UnitTestingUtils } from '@alfresco/adf-core'; import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { DebugElement } from '@angular/core'; @@ -24,7 +24,6 @@ import { ComponentFixture, discardPeriodicTasks, fakeAsync, flush, TestBed, tick import { MatChipHarness } from '@angular/material/chips/testing'; import { MatError } from '@angular/material/form-field'; import { MatProgressSpinnerHarness } from '@angular/material/progress-spinner/testing'; -import { By } from '@angular/platform-browser'; import { EMPTY, of, throwError } from 'rxjs'; import { TagsCreatorComponent } from './tags-creator.component'; @@ -34,6 +33,7 @@ describe('TagsCreatorComponent', () => { let tagService: TagService; let notificationService: NotificationService; let loader: HarnessLoader; + let testingUtils: UnitTestingUtils; beforeEach(() => { TestBed.configureTestingModule({ @@ -65,6 +65,7 @@ describe('TagsCreatorComponent', () => { component = fixture.componentInstance; tagService = TestBed.inject(TagService); notificationService = TestBed.inject(NotificationService); + testingUtils = new UnitTestingUtils(fixture.debugElement, loader); fixture.detectChanges(); }); @@ -75,7 +76,7 @@ describe('TagsCreatorComponent', () => { * @returns native element */ function getNameInput(): HTMLInputElement { - return fixture.debugElement.query(By.css(`.adf-tag-name-field input`))?.nativeElement; + return testingUtils.getInputByCSS('.adf-tag-name-field input'); } /** @@ -84,7 +85,7 @@ describe('TagsCreatorComponent', () => { * @returns native element */ function getCreateTagLabel(): HTMLSpanElement { - return fixture.debugElement.query(By.css('.adf-create-tag-label'))?.nativeElement; + return testingUtils.getByCSS('.adf-create-tag-label')?.nativeElement; } /** @@ -93,8 +94,7 @@ describe('TagsCreatorComponent', () => { * @returns list of native elements */ function getRemoveTagButtons(): HTMLButtonElement[] { - const elements = fixture.debugElement.queryAll(By.css(`.adf-dynamic-chip-list-delete-icon`)); - return elements.map((el) => el.nativeElement); + return testingUtils.getAllByCSS('.adf-dynamic-chip-list-delete-btn').map((el) => el.nativeElement); } /** @@ -122,7 +122,7 @@ describe('TagsCreatorComponent', () => { typeTag(tagName, typingTimeout); if (addUsingEnter) { - getNameInput().dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + testingUtils.keyBoardEventByCSS('.adf-tag-name-field input', 'keyup', 'Enter', 'Enter'); } else { getCreateTagLabel().click(); } @@ -141,9 +141,7 @@ describe('TagsCreatorComponent', () => { component.tagNameControlVisible = true; fixture.detectChanges(); - const tagNameInput = getNameInput(); - tagNameInput.value = tagName; - tagNameInput.dispatchEvent(new InputEvent('input')); + testingUtils.fillInputByCSS('.adf-tag-name-field input', tagName); tick(timeout); fixture.detectChanges(); @@ -155,12 +153,12 @@ describe('TagsCreatorComponent', () => { * @returns label */ function getExistingTagsLabel(): string { - return fixture.debugElement.query(By.css('.adf-existing-tags-label')).nativeElement.textContent.trim(); + return testingUtils.getByCSS('.adf-existing-tags-label').nativeElement.textContent.trim(); } describe('Created tags list', () => { it('should display no tags created message after initialization', () => { - const message = fixture.debugElement.query(By.css('.adf-no-tags-message')).nativeElement.textContent.trim(); + const message = testingUtils.getByCSS('.adf-no-tags-message').nativeElement.textContent.trim(); expect(message).toBe('TAG.TAGS_CREATOR.NO_TAGS_CREATED'); }); @@ -271,6 +269,51 @@ describe('TagsCreatorComponent', () => { fixture.detectChanges(); expect(await getAddedTags()).toEqual(component.tags); }); + + it('should focus input when last tag is removed', fakeAsync(() => { + addTagToAddedList('Tag 1'); + + const input = getNameInput(); + spyOn(input, 'focus'); + + getRemoveTagButtons()[0].click(); + tick(); + fixture.detectChanges(); + + expect(input.focus).toHaveBeenCalled(); + })); + + it('should focus button at same index when a non-last tag is removed', fakeAsync(() => { + addTagToAddedList('Tag 1'); + addTagToAddedList('Tag 2'); + + getRemoveTagButtons()[0].click(); + fixture.detectChanges(); + + const remainingButton = getRemoveTagButtons()[0]; + spyOn(remainingButton, 'focus'); + + tick(); + fixture.detectChanges(); + + expect(remainingButton.focus).toHaveBeenCalled(); + })); + + it('should focus previous button when last tag in list is removed', fakeAsync(() => { + addTagToAddedList('Tag 1'); + addTagToAddedList('Tag 2'); + + getRemoveTagButtons()[1].click(); + fixture.detectChanges(); + + const remainingButton = getRemoveTagButtons()[0]; + spyOn(remainingButton, 'focus'); + + tick(); + fixture.detectChanges(); + + expect(remainingButton.focus).toHaveBeenCalled(); + })); }); describe('Tag name field', () => { @@ -316,16 +359,14 @@ describe('TagsCreatorComponent', () => { * @returns error text */ function getFirstError(): string { - const error = fixture.debugElement.query(By.directive(MatError)); - return error?.nativeElement.textContent.trim(); + return testingUtils.getByDirective(MatError)?.nativeElement.textContent.trim(); } it('should show error for only spaces', fakeAsync(() => { typeTag(' '); component.tagNameControl.markAsTouched(); fixture.detectChanges(); - const error = getFirstError(); - expect(error).toBe('TAG.TAGS_CREATOR.ERRORS.EMPTY_TAG'); + expect(getFirstError()).toBe('TAG.TAGS_CREATOR.ERRORS.EMPTY_TAG'); })); it('should show error for only spaces if tags are changed', fakeAsync(() => { @@ -342,8 +383,7 @@ describe('TagsCreatorComponent', () => { addTagToAddedList(tag); typeTag(tag); - const error = getFirstError(); - expect(error).toBe('TAG.TAGS_CREATOR.ERRORS.ALREADY_ADDED_TAG'); + expect(getFirstError()).toBe('TAG.TAGS_CREATOR.ERRORS.ALREADY_ADDED_TAG'); })); it('should show error when duplicated already added tag if tags are changed', fakeAsync(() => { @@ -361,8 +401,7 @@ describe('TagsCreatorComponent', () => { typeTag('tag*"<>\\/?:|{}()^.'); component.tagNameControl.markAsTouched(); fixture.detectChanges(); - const error = getFirstError(); - expect(error).toBe('TAG.TAGS_CREATOR.ERRORS.SPECIAL_CHARACTERS'); + expect(getFirstError()).toBe('TAG.TAGS_CREATOR.ERRORS.SPECIAL_CHARACTERS'); })); it('should show error when duplicated already existing tag', fakeAsync(() => { @@ -378,8 +417,7 @@ describe('TagsCreatorComponent', () => { ); typeTag(tag); - const error = getFirstError(); - expect(error).toBe('TAG.TAGS_CREATOR.ERRORS.EXISTING_TAG'); + expect(getFirstError()).toBe('TAG.TAGS_CREATOR.ERRORS.EXISTING_TAG'); })); it('should show error when duplicated already existing tag with spaces', fakeAsync(() => { @@ -395,8 +433,7 @@ describe('TagsCreatorComponent', () => { ); typeTag(tag + ' '); - const error = getFirstError(); - expect(error).toBe('TAG.TAGS_CREATOR.ERRORS.EXISTING_TAG'); + expect(getFirstError()).toBe('TAG.TAGS_CREATOR.ERRORS.EXISTING_TAG'); })); it('should show error when deleting other Tag1 and Tag2 is typed and already existing tag', fakeAsync(() => { @@ -418,8 +455,7 @@ describe('TagsCreatorComponent', () => { component.removeTag(tag1); tick(); fixture.detectChanges(); - const error = getFirstError(); - expect(error).toBe('TAG.TAGS_CREATOR.ERRORS.EXISTING_TAG'); + expect(getFirstError()).toBe('TAG.TAGS_CREATOR.ERRORS.EXISTING_TAG'); })); }); }); @@ -431,7 +467,7 @@ describe('TagsCreatorComponent', () => { * @returns debug element */ function getPanel(): DebugElement { - return fixture.debugElement.query(By.css(`.adf-existing-tags-panel`)); + return testingUtils.getByCSS('.adf-existing-tags-panel'); } it('should be visible when input is visible and something is typed in input', fakeAsync(() => { @@ -521,8 +557,7 @@ describe('TagsCreatorComponent', () => { * @returns list of tags */ function getExistingTags(): string[] { - const tagElements = fixture.debugElement.queryAll(By.css(`.adf-existing-tags-panel .adf-tag`)); - return tagElements.map((el) => el.nativeElement.textContent.trim()); + return testingUtils.getAllByCSS('.adf-existing-tags-panel .adf-tag').map((el) => el.nativeElement.textContent.trim()); } it('should call findTagByName on tagService using name set in input', fakeAsync(() => { @@ -571,8 +606,7 @@ describe('TagsCreatorComponent', () => { component.tagNameControl.markAsTouched(); fixture.detectChanges(); - const tagElements = getExistingTags(); - expect(tagElements).toEqual([tag1, tag2]); + expect(getExistingTags()).toEqual([tag1, tag2]); })); it('should exclude tags passed through tags input from loaded existing tags', fakeAsync(() => { @@ -617,8 +651,7 @@ describe('TagsCreatorComponent', () => { typeTag(tag); - const tagElements = getExistingTags(); - expect(tagElements).toEqual([tag]); + expect(getExistingTags()).toEqual([tag]); })); it('should not display exact tag if that tag was passed through tags input', fakeAsync(() => { @@ -671,8 +704,7 @@ describe('TagsCreatorComponent', () => { ); typeTag(tag); - const tagElements = getExistingTags(); - expect(tagElements).toEqual([tag, tag1, tag2]); + expect(getExistingTags()).toEqual([tag, tag1, tag2]); })); it('should selection be disabled if mode is Create', fakeAsync(() => { @@ -715,9 +747,7 @@ describe('TagsCreatorComponent', () => { * @returns debug element */ async function getSpinner(): Promise { - const progressSpinner = await loader.getHarnessOrNull(MatProgressSpinnerHarness); - - return progressSpinner; + return loader.getHarnessOrNull(MatProgressSpinnerHarness); } it('should be displayed when existing tags are loading', fakeAsync(async () => { @@ -725,8 +755,7 @@ describe('TagsCreatorComponent', () => { component.tagNameControl.markAsTouched(); fixture.detectChanges(); - const spinner = await getSpinner(); - expect(spinner).toBeTruthy(); + expect(await getSpinner()).toBeTruthy(); discardPeriodicTasks(); flush(); @@ -735,8 +764,7 @@ describe('TagsCreatorComponent', () => { it('should not be displayed when existing tags stopped loading', fakeAsync(async () => { typeTag('tag'); - const spinner = await getSpinner(); - expect(spinner).toBeFalsy(); + expect(await getSpinner()).toBeFalsy(); })); it('should have correct diameter', fakeAsync(async () => { diff --git a/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.ts b/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.ts index 7f46e77483..14994f4d0e 100644 --- a/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.ts +++ b/lib/content-services/src/lib/tag/tags-creator/tags-creator.component.ts @@ -186,6 +186,8 @@ export class TagsCreatorComponent implements OnInit, OnDestroy { private readonly tagsListElement: ElementRef; @ViewChild('tagNameInput') private readonly tagNameInputElement: ElementRef; + @ViewChild(DynamicChipListComponent) + private readonly dynamicChipList: DynamicChipListComponent; private readonly destroyRef = inject(DestroyRef); @@ -271,7 +273,7 @@ export class TagsCreatorComponent implements OnInit, OnDestroy { * or if user is still typing what means that validation for input is not called yet. */ addTag(): void { - if (!this._typing && !this.tagNameControl.invalid) { + if (!this._typing && !this.tagNameControl.invalid && this.tagNameControl.value.trim()) { this.tags = [...this.tags, this.tagNameControl.value.trim()]; this.clearTagNameInput(); this.checkScrollbarVisibility(); @@ -286,6 +288,7 @@ export class TagsCreatorComponent implements OnInit, OnDestroy { * @param tag tag's name which should be removed from top list. */ removeTag(tag: string): void { + const removedIndex = this.tagsToDisplay.findIndex((chip) => chip.id === tag); this.removeTagFromArray(this.tags, tag); this.tags = [...this.tags]; this.tagNameControl.updateValueAndValidity(); @@ -293,6 +296,7 @@ export class TagsCreatorComponent implements OnInit, OnDestroy { this.exactTagSet$.next(); this.checkScrollbarVisibility(); this.tagsChange.emit(this.tags); + setTimeout(() => this.focusAfterRemoval(removedIndex)); } /** @@ -442,4 +446,12 @@ export class TagsCreatorComponent implements OnInit, OnDestroy { this.tagNameControl.setValue(''); this.tagNameControl.markAsUntouched(); } + + private focusAfterRemoval(removedIndex: number): void { + if (this.tags.length === 0) { + this.tagNameInputElement?.nativeElement?.focus(); + } else { + this.dynamicChipList?.focusDeleteButton(removedIndex); + } + } } diff --git a/lib/core/src/lib/dynamic-chip-list/dynamic-chip-list.component.html b/lib/core/src/lib/dynamic-chip-list/dynamic-chip-list.component.html index c79610b574..76ee31d7ab 100644 --- a/lib/core/src/lib/dynamic-chip-list/dynamic-chip-list.component.html +++ b/lib/core/src/lib/dynamic-chip-list/dynamic-chip-list.component.html @@ -14,17 +14,24 @@ [style.border-radius]="roundUpChips ? '20px' : '10px'" [style.font-weight]="'bold'" role="listitem" - [attr.aria-label]="chip.name" - (removed)="removedChip.emit(chip.id)"> - {{ chip.name }} - + [attr.aria-label]="chip.name"> +
+ {{ chip.name }} + +