[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
This commit is contained in:
Mykyta Maliarchuk
2026-06-25 14:23:16 +02:00
committed by GitHub
parent 20c20f2749
commit 74d67c39a0
8 changed files with 141 additions and 59 deletions
@@ -82,8 +82,7 @@ adf-tags-creator {
/* stylelint-disable selector-class-pattern */ /* stylelint-disable selector-class-pattern */
.mdc-evolution-chip-set .mat-mdc-standard-chip { .mdc-evolution-chip-set .mat-mdc-standard-chip {
.mdc-evolution-chip__cell--primary, .mdc-evolution-chip__cell--primary,
.mdc-evolution-chip__action--primary, .mdc-evolution-chip__action--primary {
.mat-mdc-chip-action-label {
overflow: hidden; overflow: hidden;
word-break: break-all; word-break: break-all;
} }
@@ -16,7 +16,7 @@
*/ */
import { TagsCreatorMode, TagService } from '@alfresco/adf-content-services'; 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 { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { DebugElement } from '@angular/core'; import { DebugElement } from '@angular/core';
@@ -24,7 +24,6 @@ import { ComponentFixture, discardPeriodicTasks, fakeAsync, flush, TestBed, tick
import { MatChipHarness } from '@angular/material/chips/testing'; import { MatChipHarness } from '@angular/material/chips/testing';
import { MatError } from '@angular/material/form-field'; import { MatError } from '@angular/material/form-field';
import { MatProgressSpinnerHarness } from '@angular/material/progress-spinner/testing'; import { MatProgressSpinnerHarness } from '@angular/material/progress-spinner/testing';
import { By } from '@angular/platform-browser';
import { EMPTY, of, throwError } from 'rxjs'; import { EMPTY, of, throwError } from 'rxjs';
import { TagsCreatorComponent } from './tags-creator.component'; import { TagsCreatorComponent } from './tags-creator.component';
@@ -34,6 +33,7 @@ describe('TagsCreatorComponent', () => {
let tagService: TagService; let tagService: TagService;
let notificationService: NotificationService; let notificationService: NotificationService;
let loader: HarnessLoader; let loader: HarnessLoader;
let testingUtils: UnitTestingUtils;
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -65,6 +65,7 @@ describe('TagsCreatorComponent', () => {
component = fixture.componentInstance; component = fixture.componentInstance;
tagService = TestBed.inject(TagService); tagService = TestBed.inject(TagService);
notificationService = TestBed.inject(NotificationService); notificationService = TestBed.inject(NotificationService);
testingUtils = new UnitTestingUtils(fixture.debugElement, loader);
fixture.detectChanges(); fixture.detectChanges();
}); });
@@ -75,7 +76,7 @@ describe('TagsCreatorComponent', () => {
* @returns native element * @returns native element
*/ */
function getNameInput(): HTMLInputElement { 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 * @returns native element
*/ */
function getCreateTagLabel(): HTMLSpanElement { 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 * @returns list of native elements
*/ */
function getRemoveTagButtons(): HTMLButtonElement[] { function getRemoveTagButtons(): HTMLButtonElement[] {
const elements = fixture.debugElement.queryAll(By.css(`.adf-dynamic-chip-list-delete-icon`)); return testingUtils.getAllByCSS('.adf-dynamic-chip-list-delete-btn').map((el) => el.nativeElement);
return elements.map((el) => el.nativeElement);
} }
/** /**
@@ -122,7 +122,7 @@ describe('TagsCreatorComponent', () => {
typeTag(tagName, typingTimeout); typeTag(tagName, typingTimeout);
if (addUsingEnter) { if (addUsingEnter) {
getNameInput().dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); testingUtils.keyBoardEventByCSS('.adf-tag-name-field input', 'keyup', 'Enter', 'Enter');
} else { } else {
getCreateTagLabel().click(); getCreateTagLabel().click();
} }
@@ -141,9 +141,7 @@ describe('TagsCreatorComponent', () => {
component.tagNameControlVisible = true; component.tagNameControlVisible = true;
fixture.detectChanges(); fixture.detectChanges();
const tagNameInput = getNameInput(); testingUtils.fillInputByCSS('.adf-tag-name-field input', tagName);
tagNameInput.value = tagName;
tagNameInput.dispatchEvent(new InputEvent('input'));
tick(timeout); tick(timeout);
fixture.detectChanges(); fixture.detectChanges();
@@ -155,12 +153,12 @@ describe('TagsCreatorComponent', () => {
* @returns label * @returns label
*/ */
function getExistingTagsLabel(): string { 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', () => { describe('Created tags list', () => {
it('should display no tags created message after initialization', () => { 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'); expect(message).toBe('TAG.TAGS_CREATOR.NO_TAGS_CREATED');
}); });
@@ -271,6 +269,51 @@ describe('TagsCreatorComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
expect(await getAddedTags()).toEqual(component.tags); 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', () => { describe('Tag name field', () => {
@@ -316,16 +359,14 @@ describe('TagsCreatorComponent', () => {
* @returns error text * @returns error text
*/ */
function getFirstError(): string { function getFirstError(): string {
const error = fixture.debugElement.query(By.directive(MatError)); return testingUtils.getByDirective(MatError)?.nativeElement.textContent.trim();
return error?.nativeElement.textContent.trim();
} }
it('should show error for only spaces', fakeAsync(() => { it('should show error for only spaces', fakeAsync(() => {
typeTag(' '); typeTag(' ');
component.tagNameControl.markAsTouched(); component.tagNameControl.markAsTouched();
fixture.detectChanges(); fixture.detectChanges();
const error = getFirstError(); expect(getFirstError()).toBe('TAG.TAGS_CREATOR.ERRORS.EMPTY_TAG');
expect(error).toBe('TAG.TAGS_CREATOR.ERRORS.EMPTY_TAG');
})); }));
it('should show error for only spaces if tags are changed', fakeAsync(() => { it('should show error for only spaces if tags are changed', fakeAsync(() => {
@@ -342,8 +383,7 @@ describe('TagsCreatorComponent', () => {
addTagToAddedList(tag); addTagToAddedList(tag);
typeTag(tag); typeTag(tag);
const error = getFirstError(); expect(getFirstError()).toBe('TAG.TAGS_CREATOR.ERRORS.ALREADY_ADDED_TAG');
expect(error).toBe('TAG.TAGS_CREATOR.ERRORS.ALREADY_ADDED_TAG');
})); }));
it('should show error when duplicated already added tag if tags are changed', fakeAsync(() => { it('should show error when duplicated already added tag if tags are changed', fakeAsync(() => {
@@ -361,8 +401,7 @@ describe('TagsCreatorComponent', () => {
typeTag('tag*"<>\\/?:|{}()^.'); typeTag('tag*"<>\\/?:|{}()^.');
component.tagNameControl.markAsTouched(); component.tagNameControl.markAsTouched();
fixture.detectChanges(); fixture.detectChanges();
const error = getFirstError(); expect(getFirstError()).toBe('TAG.TAGS_CREATOR.ERRORS.SPECIAL_CHARACTERS');
expect(error).toBe('TAG.TAGS_CREATOR.ERRORS.SPECIAL_CHARACTERS');
})); }));
it('should show error when duplicated already existing tag', fakeAsync(() => { it('should show error when duplicated already existing tag', fakeAsync(() => {
@@ -378,8 +417,7 @@ describe('TagsCreatorComponent', () => {
); );
typeTag(tag); typeTag(tag);
const error = getFirstError(); expect(getFirstError()).toBe('TAG.TAGS_CREATOR.ERRORS.EXISTING_TAG');
expect(error).toBe('TAG.TAGS_CREATOR.ERRORS.EXISTING_TAG');
})); }));
it('should show error when duplicated already existing tag with spaces', fakeAsync(() => { it('should show error when duplicated already existing tag with spaces', fakeAsync(() => {
@@ -395,8 +433,7 @@ describe('TagsCreatorComponent', () => {
); );
typeTag(tag + ' '); typeTag(tag + ' ');
const error = getFirstError(); expect(getFirstError()).toBe('TAG.TAGS_CREATOR.ERRORS.EXISTING_TAG');
expect(error).toBe('TAG.TAGS_CREATOR.ERRORS.EXISTING_TAG');
})); }));
it('should show error when deleting other Tag1 and Tag2 is typed and already existing tag', fakeAsync(() => { 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); component.removeTag(tag1);
tick(); tick();
fixture.detectChanges(); fixture.detectChanges();
const error = getFirstError(); expect(getFirstError()).toBe('TAG.TAGS_CREATOR.ERRORS.EXISTING_TAG');
expect(error).toBe('TAG.TAGS_CREATOR.ERRORS.EXISTING_TAG');
})); }));
}); });
}); });
@@ -431,7 +467,7 @@ describe('TagsCreatorComponent', () => {
* @returns debug element * @returns debug element
*/ */
function getPanel(): DebugElement { 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(() => { 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 * @returns list of tags
*/ */
function getExistingTags(): string[] { function getExistingTags(): string[] {
const tagElements = fixture.debugElement.queryAll(By.css(`.adf-existing-tags-panel .adf-tag`)); return testingUtils.getAllByCSS('.adf-existing-tags-panel .adf-tag').map((el) => el.nativeElement.textContent.trim());
return tagElements.map((el) => el.nativeElement.textContent.trim());
} }
it('should call findTagByName on tagService using name set in input', fakeAsync(() => { it('should call findTagByName on tagService using name set in input', fakeAsync(() => {
@@ -571,8 +606,7 @@ describe('TagsCreatorComponent', () => {
component.tagNameControl.markAsTouched(); component.tagNameControl.markAsTouched();
fixture.detectChanges(); fixture.detectChanges();
const tagElements = getExistingTags(); expect(getExistingTags()).toEqual([tag1, tag2]);
expect(tagElements).toEqual([tag1, tag2]);
})); }));
it('should exclude tags passed through tags input from loaded existing tags', fakeAsync(() => { it('should exclude tags passed through tags input from loaded existing tags', fakeAsync(() => {
@@ -617,8 +651,7 @@ describe('TagsCreatorComponent', () => {
typeTag(tag); typeTag(tag);
const tagElements = getExistingTags(); expect(getExistingTags()).toEqual([tag]);
expect(tagElements).toEqual([tag]);
})); }));
it('should not display exact tag if that tag was passed through tags input', fakeAsync(() => { it('should not display exact tag if that tag was passed through tags input', fakeAsync(() => {
@@ -671,8 +704,7 @@ describe('TagsCreatorComponent', () => {
); );
typeTag(tag); typeTag(tag);
const tagElements = getExistingTags(); expect(getExistingTags()).toEqual([tag, tag1, tag2]);
expect(tagElements).toEqual([tag, tag1, tag2]);
})); }));
it('should selection be disabled if mode is Create', fakeAsync(() => { it('should selection be disabled if mode is Create', fakeAsync(() => {
@@ -715,9 +747,7 @@ describe('TagsCreatorComponent', () => {
* @returns debug element * @returns debug element
*/ */
async function getSpinner(): Promise<MatProgressSpinnerHarness> { async function getSpinner(): Promise<MatProgressSpinnerHarness> {
const progressSpinner = await loader.getHarnessOrNull(MatProgressSpinnerHarness); return loader.getHarnessOrNull(MatProgressSpinnerHarness);
return progressSpinner;
} }
it('should be displayed when existing tags are loading', fakeAsync(async () => { it('should be displayed when existing tags are loading', fakeAsync(async () => {
@@ -725,8 +755,7 @@ describe('TagsCreatorComponent', () => {
component.tagNameControl.markAsTouched(); component.tagNameControl.markAsTouched();
fixture.detectChanges(); fixture.detectChanges();
const spinner = await getSpinner(); expect(await getSpinner()).toBeTruthy();
expect(spinner).toBeTruthy();
discardPeriodicTasks(); discardPeriodicTasks();
flush(); flush();
@@ -735,8 +764,7 @@ describe('TagsCreatorComponent', () => {
it('should not be displayed when existing tags stopped loading', fakeAsync(async () => { it('should not be displayed when existing tags stopped loading', fakeAsync(async () => {
typeTag('tag'); typeTag('tag');
const spinner = await getSpinner(); expect(await getSpinner()).toBeFalsy();
expect(spinner).toBeFalsy();
})); }));
it('should have correct diameter', fakeAsync(async () => { it('should have correct diameter', fakeAsync(async () => {
@@ -186,6 +186,8 @@ export class TagsCreatorComponent implements OnInit, OnDestroy {
private readonly tagsListElement: ElementRef; private readonly tagsListElement: ElementRef;
@ViewChild('tagNameInput') @ViewChild('tagNameInput')
private readonly tagNameInputElement: ElementRef; private readonly tagNameInputElement: ElementRef;
@ViewChild(DynamicChipListComponent)
private readonly dynamicChipList: DynamicChipListComponent;
private readonly destroyRef = inject(DestroyRef); 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. * or if user is still typing what means that validation for input is not called yet.
*/ */
addTag(): void { 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.tags = [...this.tags, this.tagNameControl.value.trim()];
this.clearTagNameInput(); this.clearTagNameInput();
this.checkScrollbarVisibility(); this.checkScrollbarVisibility();
@@ -286,6 +288,7 @@ export class TagsCreatorComponent implements OnInit, OnDestroy {
* @param tag tag's name which should be removed from top list. * @param tag tag's name which should be removed from top list.
*/ */
removeTag(tag: string): void { removeTag(tag: string): void {
const removedIndex = this.tagsToDisplay.findIndex((chip) => chip.id === tag);
this.removeTagFromArray(this.tags, tag); this.removeTagFromArray(this.tags, tag);
this.tags = [...this.tags]; this.tags = [...this.tags];
this.tagNameControl.updateValueAndValidity(); this.tagNameControl.updateValueAndValidity();
@@ -293,6 +296,7 @@ export class TagsCreatorComponent implements OnInit, OnDestroy {
this.exactTagSet$.next(); this.exactTagSet$.next();
this.checkScrollbarVisibility(); this.checkScrollbarVisibility();
this.tagsChange.emit(this.tags); this.tagsChange.emit(this.tags);
setTimeout(() => this.focusAfterRemoval(removedIndex));
} }
/** /**
@@ -442,4 +446,12 @@ export class TagsCreatorComponent implements OnInit, OnDestroy {
this.tagNameControl.setValue(''); this.tagNameControl.setValue('');
this.tagNameControl.markAsUntouched(); this.tagNameControl.markAsUntouched();
} }
private focusAfterRemoval(removedIndex: number): void {
if (this.tags.length === 0) {
this.tagNameInputElement?.nativeElement?.focus();
} else {
this.dynamicChipList?.focusDeleteButton(removedIndex);
}
}
} }
@@ -14,17 +14,24 @@
[style.border-radius]="roundUpChips ? '20px' : '10px'" [style.border-radius]="roundUpChips ? '20px' : '10px'"
[style.font-weight]="'bold'" [style.font-weight]="'bold'"
role="listitem" role="listitem"
[attr.aria-label]="chip.name" [attr.aria-label]="chip.name">
(removed)="removedChip.emit(chip.id)"> <div class="adf-dynamic-chip-list-content">
<span id="adf-dynamic-chip-list-chip-name-{{ idx }}">{{ chip.name }}</span> <span id="adf-dynamic-chip-list-chip-name-{{ idx }}">{{ chip.name }}</span>
<mat-icon <button type="button"
*ngIf="showDelete" *ngIf="showDelete"
id="adf-dynamic-chip-list-delete-{{ chip.name }}" [disabled]="disableDelete"
class="adf-dynamic-chip-list-delete-icon" (click)="removedChip.emit(chip.id)"
[disabled]="disableDelete" [attr.data-automation-id]="'adf-dynamic-chip-list-delete-btn-' + chip.id"
adf-icon="close" class="adf-dynamic-chip-list-delete-btn"
matChipRemove [attr.aria-label]="'DYNAMIC_CHIP_LIST.DELETE' | translate: { name: chip.name }"
/> [title]="'DYNAMIC_CHIP_LIST.DELETE' | translate: { name: chip.name }">
<mat-icon
id="adf-dynamic-chip-list-delete-{{ chip.name }}"
class="adf-dynamic-chip-list-delete-icon"
adf-icon="close"
/>
</button>
</div>
</mat-chip> </mat-chip>
</mat-chip-set> </mat-chip-set>
<button <button
@@ -7,6 +7,35 @@
padding-top: 12px; padding-top: 12px;
padding-bottom: 12px; padding-bottom: 12px;
.adf-dynamic-chip-list-content {
display: flex;
flex-direction: row;
align-items: center;
}
.adf-dynamic-chip-list-delete-btn {
display: inline-flex;
margin: -10px -14px -10px -2px;
padding: 10px;
border: none;
cursor: pointer;
background: none;
&:disabled {
cursor: default;
}
&:focus-visible {
outline-offset: -5px;
}
.adf-dynamic-chip-list-delete-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
.adf-dynamic-chip-list-view-more-button { .adf-dynamic-chip-list-view-more-button {
margin-left: 5px; margin-left: 5px;
position: absolute; position: absolute;
@@ -98,7 +98,7 @@ describe('DynamicChipListComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
testingUtils.clickByCSS('#adf-dynamic-chip-list-delete-test1'); testingUtils.clickByDataAutomationId('adf-dynamic-chip-list-delete-btn-0ee933fa-57fc-4587-8a77-b787e814f1d2');
expect(component.removedChip.emit).toHaveBeenCalledWith('0ee933fa-57fc-4587-8a77-b787e814f1d2'); expect(component.removedChip.emit).toHaveBeenCalledWith('0ee933fa-57fc-4587-8a77-b787e814f1d2');
}); });
@@ -149,7 +149,7 @@ describe('DynamicChipListComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
const chip = testingUtils.getByCSS('.adf-dynamic-chip-list-delete-icon'); const chip = testingUtils.getByCSS('.adf-dynamic-chip-list-delete-btn');
expect(Object.keys(chip.attributes)).toContain('disabled'); expect(Object.keys(chip.attributes)).toContain('disabled');
}); });
@@ -159,6 +159,12 @@ export class DynamicChipListComponent implements OnChanges, OnInit, AfterViewIni
this.displayNext.emit(); this.displayNext.emit();
} }
focusDeleteButton(index: number): void {
const buttons: NodeListOf<HTMLButtonElement> = this.containerView.nativeElement.querySelectorAll('.adf-dynamic-chip-list-delete-btn');
const target: HTMLButtonElement = buttons[index] ?? buttons[index - 1];
target?.focus();
}
private calculateChipsToDisplay(): void { private calculateChipsToDisplay(): void {
if (this.requestedDisplayingAllChips || !this.chips.length) { if (this.requestedDisplayingAllChips || !this.chips.length) {
return; return;
+2 -1
View File
@@ -659,7 +659,8 @@
"ICON": "Node Icon" "ICON": "Node Icon"
}, },
"DYNAMIC_CHIP_LIST": { "DYNAMIC_CHIP_LIST": {
"LOAD_MORE": "Load more" "LOAD_MORE": "Load more",
"DELETE": "Remove {{ name }}"
}, },
"ADF_CONFIRM_DIALOG": { "ADF_CONFIRM_DIALOG": {
"TITLE": "Confirm", "TITLE": "Confirm",