From ae1506afd4c6753379898b7620b4cfd5f6031f98 Mon Sep 17 00:00:00 2001 From: aleksandar-terziev Date: Mon, 31 Aug 2026 22:44:05 +0300 Subject: [PATCH] feat(ui5-tokenizer): announce token deletion --- packages/main/cypress/specs/Tokenizer.cy.tsx | 71 +++++++++++++++++++ packages/main/src/Tokenizer.ts | 39 +++++++--- .../main/src/i18n/messagebundle.properties | 6 ++ 3 files changed, 108 insertions(+), 8 deletions(-) diff --git a/packages/main/cypress/specs/Tokenizer.cy.tsx b/packages/main/cypress/specs/Tokenizer.cy.tsx index 184490575e5eb..d6cc7e3d60fe0 100755 --- a/packages/main/cypress/specs/Tokenizer.cy.tsx +++ b/packages/main/cypress/specs/Tokenizer.cy.tsx @@ -2102,3 +2102,74 @@ describe("Tokenizer - getFocusDomRef Method", () => { .should("be.focused"); }); }); + +describe("Token deletion announcements", () => { + it("announces singular deletion on Backspace with a single token", () => { + cy.mount( + + + + + ); + + cy.get(".ui5-invisiblemessage-assertive") + .as("liveRegion") + .should("have.text", ""); + + cy.get("[ui5-token]") + .eq(0) + .realClick(); + + cy.realPress("Backspace"); + + cy.get("@liveRegion") + .should("have.text", "1 token deleted"); + }); + + it("announces plural deletion on Backspace with multiple selected tokens", () => { + cy.mount( + + + + + + ); + + cy.get(".ui5-invisiblemessage-assertive") + .as("liveRegion") + .should("have.text", ""); + + cy.get("[ui5-token]") + .eq(0) + .realClick(); + + cy.realPress(["Shift", "ArrowRight"]); + cy.realPress("Backspace"); + + cy.get("@liveRegion") + .should("have.text", "2 tokens deleted"); + }); + + it("announces plural deletion equal to token count on 'Clear All'", () => { + cy.mount( + + + + + + ); + + cy.get(".ui5-invisiblemessage-assertive") + .as("liveRegion") + .should("have.text", ""); + + cy.get("[ui5-tokenizer]") + .shadow() + .find(".ui5-tokenizer--clear-all") + .eq(0) + .realClick(); + + cy.get("@liveRegion") + .should("have.text", "3 tokens deleted"); + }); +}); diff --git a/packages/main/src/Tokenizer.ts b/packages/main/src/Tokenizer.ts index c567282e8d938..fa73fbe57cb83 100644 --- a/packages/main/src/Tokenizer.ts +++ b/packages/main/src/Tokenizer.ts @@ -10,6 +10,8 @@ import { renderFinished } from "@ui5/webcomponents-base/dist/Render.js"; import type { ResizeObserverCallback } from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; import ItemNavigation from "@ui5/webcomponents-base/dist/delegate/ItemNavigation.js"; import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js"; +import announce from "@ui5/webcomponents-base/dist/util/InvisibleMessage.js"; +import InvisibleMessageMode from "@ui5/webcomponents-base/dist/types/InvisibleMessageMode.js"; import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js"; import { getFocusedElement } from "@ui5/webcomponents-base/dist/util/PopupUtils.js"; import ScrollEnablement from "@ui5/webcomponents-base/dist/delegate/ScrollEnablement.js"; @@ -69,6 +71,8 @@ import { TOKENIZER_CLEAR_ALL, TOKENIZER_DIALOG_OK_BUTTON, TOKENIZER_DIALOG_CANCEL_BUTTON, + TOKENIZER_TOKEN_DELETED_SINGULAR, + TOKENIZER_TOKEN_DELETED_PLURAL, INPUT_SUGGESTIONS_TITLE, } from "./generated/i18n/i18n-defaults.js"; @@ -416,9 +420,23 @@ class Tokenizer extends UI5Element implements IFormInputElement { } handleClearAll() { + this._announceTokenDeletion(this._tokens.length); this.fireDecoratorEvent("token-delete", { tokens: this._tokens }); } + /** + * Announces the number of deleted tokens to screen readers. + * @private + * @param count The number of tokens being deleted + */ + _announceTokenDeletion(count: number) { + const text = count === 1 + ? Tokenizer.i18nBundle.getText(TOKENIZER_TOKEN_DELETED_SINGULAR) + : Tokenizer.i18nBundle.getText(TOKENIZER_TOKEN_DELETED_PLURAL, count); + + announce(text, InvisibleMessageMode.Assertive); + } + onBeforeRendering() { if (!this.multiLine) { this._scrollEnablement = new ScrollEnablement(this); @@ -590,11 +608,13 @@ class Tokenizer extends UI5Element implements IFormInputElement { const deletedTokenIndex = token ? tokens.indexOf(token) : tokens.indexOf(target); // The index of the token that just got deleted const nextTokenIndex = deletedTokenIndex === tokens.length - 1 ? deletedTokenIndex - 1 : deletedTokenIndex + 1; // The index of the next token that needs to be focused next due to the deletion const nextToken = tokens[nextTokenIndex]; // if the last item was deleted this will be undefined + const tokensToDelete = [token]; + this._announceTokenDeletion(tokensToDelete.length); this._handleCurrentItemAfterDeletion(nextToken); this._tokenDeleting = true; - this.fireDecoratorEvent("token-delete", { tokens: [token] }); + this.fireDecoratorEvent("token-delete", { tokens: tokensToDelete }); } _handleCurrentItemAfterDeletion(nextToken: Token) { @@ -643,28 +663,29 @@ class Tokenizer extends UI5Element implements IFormInputElement { nextToken = notSelectedTokens[0]; } + const tokensToDelete = this._selectedTokens.length ? this._selectedTokens : [token]; + this._announceTokenDeletion(tokensToDelete.length); + this._handleCurrentItemAfterDeletion(nextToken); this._tokenDeleting = true; - if (this._selectedTokens.length) { - this.fireDecoratorEvent("token-delete", { tokens: this._selectedTokens }); - } else { - this.fireDecoratorEvent("token-delete", { tokens: [token] }); - } + this.fireDecoratorEvent("token-delete", { tokens: tokensToDelete }); } async itemDelete(e: CustomEvent) { const token = this.getTokenByRefId(e.detail.item.getAttribute("data-ui5-token-ref-id")!); const tokensArray = this._tokens; + const tokensToDelete = [token]; // delay the token deletion in order to close the popover before removing token of the DOM if (tokensArray.length === 1) { const morePopover = this.getPopover(); morePopover.addEventListener("ui5-close", () => { - this.fireDecoratorEvent("token-delete", { tokens: [token] }); + this._announceTokenDeletion(tokensToDelete.length); + this.fireDecoratorEvent("token-delete", { tokens: tokensToDelete }); }, { once: true, }); @@ -673,7 +694,8 @@ class Tokenizer extends UI5Element implements IFormInputElement { if (isPhone()) { this._deletedDialogItems.push(token); } else { - this.fireDecoratorEvent("token-delete", { tokens: [token] }); + this._announceTokenDeletion(tokensToDelete.length); + this.fireDecoratorEvent("token-delete", { tokens: tokensToDelete }); } const currentListItem = e.detail.item as ListItem; const nextListItem = currentListItem.nextElementSibling as ListItem; @@ -717,6 +739,7 @@ class Tokenizer extends UI5Element implements IFormInputElement { const confirm = !!isOkButton; if (confirm && this._deletedDialogItems.length) { + this._announceTokenDeletion(this._deletedDialogItems.length); this.fireDecoratorEvent("token-delete", { tokens: this._deletedDialogItems }); } diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties index 8124b01bcbc93..b169885058ef3 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -670,6 +670,12 @@ TOKENIZER_DIALOG_CANCEL_BUTTON=Cancel #XBUT: Tokenizer's Dialog OK button on mobile devices TOKENIZER_DIALOG_OK_BUTTON=OK +#XACT: ARIA announcement for single token deletion +TOKENIZER_TOKEN_DELETED_SINGULAR=1 token deleted + +#XACT: ARIA announcement for multiple tokens deletion +TOKENIZER_TOKEN_DELETED_PLURAL={0} tokens deleted + #XACT: Label text for TreeListItem TREE_ITEM_ARIA_LABEL=Tree Item