From c5ee5ba5ee2a72e51b79c0a41afbe9deb831e8ff Mon Sep 17 00:00:00 2001 From: Cahit Guerguec Date: Tue, 12 Aug 2025 21:23:39 +0200 Subject: [PATCH 1/3] fix(ui5-table): custom row announcements are set --- packages/base/src/UI5Element.ts | 4 +- packages/base/src/types.ts | 17 +-- packages/main/src/Table.ts | 5 + packages/main/src/TableCell.ts | 7 +- packages/main/src/TableCellTemplate.tsx | 2 +- packages/main/src/TableHeaderRow.ts | 33 ++++ packages/main/src/TableHeaderRowTemplate.tsx | 3 +- packages/main/src/TableNavigation.ts | 1 - packages/main/src/TableRow.ts | 79 +++++++++- packages/main/src/TableRowTemplate.tsx | 17 ++- packages/main/src/TableUtils.ts | 143 ++++++++++++++++++ .../main/src/i18n/messagebundle.properties | 36 ++++- packages/main/test/pages/Table.html | 16 +- vite.config.js | 3 + 14 files changed, 325 insertions(+), 41 deletions(-) diff --git a/packages/base/src/UI5Element.ts b/packages/base/src/UI5Element.ts index c73a0f957c34..fa49cfb702f8 100644 --- a/packages/base/src/UI5Element.ts +++ b/packages/base/src/UI5Element.ts @@ -1115,8 +1115,8 @@ abstract class UI5Element extends HTMLElement { * Returns the component accessibility info. * @private */ - get accessibilityInfo(): AccessibilityInfo { - return {}; + get accessibilityInfo(): AccessibilityInfo | undefined { + return undefined; } /** diff --git a/packages/base/src/types.ts b/packages/base/src/types.ts index ad2d7bd4f377..c21f65db0e62 100644 --- a/packages/base/src/types.ts +++ b/packages/base/src/types.ts @@ -31,25 +31,24 @@ export type AccessibilityInfo = { // The WAI-ARIA role of the component. role?: AriaRole, - // A translated text that represents the component type. Used when several components share same role, - // f.e. Select and ComboBox both have role="combobox". - type?: LowercaseString, + // A translated text that represents the component type. + type?: string, // A translated text that represents relevant component description/state - value, placeholder, label, etc. description?: string, - // The component disabled state. + // Disabled state of the component. disabled?: boolean, - // The component readonly state. + // Readonly state of the component. readonly?: boolean, - // The component required state. + // Required state of the component. required?: boolean, - // An array of elements, aggregated by the component - // Note: Children should only be provided when it is helpful to understand the accessibility context. - children?: Array, + // An array of nodes, aggregated by the component + // **Note:** Children should only be provided when it is helpful to understand the accessibility context. + children?: Array, } export type AccessibilityAttributes = { diff --git a/packages/main/src/Table.ts b/packages/main/src/Table.ts index 639178f8c095..d360a3a4c2a9 100644 --- a/packages/main/src/Table.ts +++ b/packages/main/src/Table.ts @@ -561,11 +561,13 @@ class Table extends UI5Element { const headerIndex = this.headerRow[0].cells.indexOf(headerCell); headerCell._popin = inPopin && this.overflowMode === TableOverflowMode.Popin; headerCell._popinWidth = popinWidth; + headerCell.ariaColIndex = null; this.rows.forEach(row => { const cell = row.cells[headerIndex]; if (cell) { row.cells[headerIndex]._popinHidden = headerCell.popinHidden; row.cells[headerIndex]._popin = headerCell._popin; + row.cells[headerIndex].ariaColIndex = null; } }); } @@ -682,6 +684,9 @@ class Table extends UI5Element { if (this.rowActionCount > 0) { ariaColCount++; } + if (this.headerRow[0]._popinCells.length > 0) { + ariaColCount++; + } return ariaColCount; } diff --git a/packages/main/src/TableCell.ts b/packages/main/src/TableCell.ts index fc511406a707..696949f83ae6 100644 --- a/packages/main/src/TableCell.ts +++ b/packages/main/src/TableCell.ts @@ -2,6 +2,7 @@ import customElement from "@ui5/webcomponents-base/dist/decorators/customElement import TableCellTemplate from "./TableCellTemplate.js"; import TableCellStyles from "./generated/themes/TableCell.css.js"; import TableCellBase from "./TableCellBase.js"; +import { getAccessibilityDescription, updateInvisibleText } from "./TableUtils.js"; import type TableRow from "./TableRow.js"; import type Table from "./Table.js"; import { LABEL_COLON } from "./generated/i18n/i18n-defaults.js"; @@ -38,7 +39,7 @@ class TableCell extends TableCellBase { } } - injectHeaderNodes(ref: HTMLElement | null) { + _injectHeaderNodes(ref: HTMLElement | null) { if (ref && !ref.hasChildNodes()) { ref.replaceChildren(...this._popinHeaderNodes); } @@ -52,10 +53,10 @@ class TableCell extends TableCellBase { } get _popinHeaderNodes() { - const nodes = []; + const nodes: Node[] = []; const headerCell = this._headerCell; if (headerCell.popinText) { - nodes.push(headerCell.popinText); + nodes.push(document.createTextNode(headerCell.popinText)); } else { nodes.push(...this._headerCell.content.map(node => node.cloneNode(true))); } diff --git a/packages/main/src/TableCellTemplate.tsx b/packages/main/src/TableCellTemplate.tsx index ebbddbdb23ef..467392398f85 100644 --- a/packages/main/src/TableCellTemplate.tsx +++ b/packages/main/src/TableCellTemplate.tsx @@ -5,7 +5,7 @@ export default function TableCellTemplate(this: TableCell) { <> { this._popin && <> -
+
{this._i18nPopinColon} } diff --git a/packages/main/src/TableHeaderRow.ts b/packages/main/src/TableHeaderRow.ts index 06fa9f9f5466..6bc61282ad2b 100644 --- a/packages/main/src/TableHeaderRow.ts +++ b/packages/main/src/TableHeaderRow.ts @@ -4,6 +4,7 @@ import TableHeaderRowTemplate from "./TableHeaderRowTemplate.js"; import TableHeaderRowStyles from "./generated/themes/TableHeaderRow.css.js"; import type TableHeaderCell from "./TableHeaderCell.js"; import type TableSelectionMulti from "./TableSelectionMulti.js"; +import { getAccessibilityDescription, updateInvisibleText } from "./TableUtils.js"; import { TABLE_SELECTION, TABLE_ROW_POPIN, @@ -92,6 +93,38 @@ class TableHeaderRow extends TableRowBase { return true; } + _onfocusin(e: FocusEvent, eventOrigin: HTMLElement) { + if (eventOrigin !== this) { + return; + } + + const descriptions = [ + TableRowBase.i18nBundle.getText(TABLE_COLUMN_HEADER_ROW), + ]; + + const selectionDescription = this._selectionCellAriaDescription; + if (selectionDescription) { + descriptions.push(selectionDescription); + } + + this._visibleCells.forEach(cell => { + const cellDescription = getAccessibilityDescription(cell, true); + descriptions.push(cellDescription); + }); + + if (this._rowActionCount > 0) { + descriptions.push(TableRowBase.i18nBundle.getText(TABLE_ROW_ACTIONS)); + } + + updateInvisibleText(this, descriptions); + } + + _onfocusout(e: FocusEvent, eventOrigin: HTMLElement) { + if (eventOrigin !== this) { + updateInvisibleText(this); + } + } + get _isSelectable() { return this._isMultiSelect; } diff --git a/packages/main/src/TableHeaderRowTemplate.tsx b/packages/main/src/TableHeaderRowTemplate.tsx index ec724ba6bff5..f8cea97b98a3 100644 --- a/packages/main/src/TableHeaderRowTemplate.tsx +++ b/packages/main/src/TableHeaderRowTemplate.tsx @@ -44,7 +44,7 @@ export default function TableHeaderRowTemplate(this: TableHeaderRow, ariaColInde { this._visibleCells.map(cell => { cell.ariaColIndex = `${ariaColIndex++}`; - return ; + return ; })} { this._rowActionCount > 0 && @@ -56,6 +56,7 @@ export default function TableHeaderRowTemplate(this: TableHeaderRow, ariaColInde { this._popinCells.length > 0 && diff --git a/packages/main/src/TableNavigation.ts b/packages/main/src/TableNavigation.ts index e510f48c4ad7..85f0d1937914 100644 --- a/packages/main/src/TableNavigation.ts +++ b/packages/main/src/TableNavigation.ts @@ -28,7 +28,6 @@ class TableNavigation extends TableExtension { super(); this._table = table; this._gridWalker = new GridWalker(); - this._gridWalker.setGrid(this._getNavigationItemsOfGrid()); this._onKeyDownCaptureBound = this._onKeyDownCapture.bind(this); // we register the keydown handler on the table element at the capturing phase since the diff --git a/packages/main/src/TableRow.ts b/packages/main/src/TableRow.ts index d93a36d0a796..43744f2d9d39 100644 --- a/packages/main/src/TableRow.ts +++ b/packages/main/src/TableRow.ts @@ -2,7 +2,7 @@ import { customElement, slot, property } from "@ui5/webcomponents-base/dist/deco import { isEnter } from "@ui5/webcomponents-base/dist/Keys.js"; import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js"; import type { UI5CustomEvent } from "@ui5/webcomponents-base"; -import { toggleAttribute } from "./TableUtils.js"; +import { toggleAttribute, updateInvisibleText, getAccessibilityDescription } from "./TableUtils.js"; import TableRowTemplate from "./TableRowTemplate.js"; import TableRowBase from "./TableRowBase.js"; import TableRowCss from "./generated/themes/TableRow.css.js"; @@ -10,6 +10,15 @@ import type TableCell from "./TableCell.js"; import type TableRowActionBase from "./TableRowActionBase.js"; import type Button from "./Button.js"; import "@ui5/webcomponents-icons/dist/overflow.js"; +import { + TABLE_ROW, + TABLE_ROW_INDEX, + TABLE_ROW_SELECTED, + TABLE_ROW_ACTIVE, + TABLE_ROW_NAVIGABLE, + TABLE_ROW_SINGLE_ACTION, + TABLE_ROW_MULTIPLE_ACTIONS, +} from "./generated/i18n/i18n-defaults.js"; /** * @class @@ -144,7 +153,7 @@ class TableRow extends TableRowBase { if (this === getActiveElement()) { if (this._isSelectable && !this._hasSelector) { this._onSelectionChange(); - } else if (this.interactive) { + } else if (this.interactive || this._isNavigable) { this._table?._onRowClick(this); } } @@ -154,8 +163,48 @@ class TableRow extends TableRowBase { this.removeAttribute("_active"); } - _onfocusout() { + _onfocusin(e: FocusEvent, eventOrigin: HTMLElement) { + if (eventOrigin !== this) { + return; + } + + const descriptions = [ + TableRowBase.i18nBundle.getText(TABLE_ROW), + TableRowBase.i18nBundle.getText(TABLE_ROW_INDEX, this.ariaRowIndex!, this._table!._ariaRowCount), + ]; + + if (this._isSelected) { + descriptions.push(TableRowBase.i18nBundle.getText(TABLE_ROW_SELECTED)); + } + + if (this._isNavigable) { + descriptions.push(TableRowBase.i18nBundle.getText(TABLE_ROW_NAVIGABLE)); + } else if (this.interactive) { + descriptions.push(TableRowBase.i18nBundle.getText(TABLE_ROW_ACTIVE)); + } + + [...this._visibleCells, ...this._popinCells].forEach(cell => { + const headerCell = cell._popin ? cell.getDomRef()! : (cell as TableCell)._headerCell; + const headerCellDescription = getAccessibilityDescription(headerCell, false); + const cellDescription = getAccessibilityDescription(cell, false); + descriptions.push(headerCellDescription); + descriptions.push(cellDescription); + }); + + const availableActionsCount = this._availableActionsCount; + if (availableActionsCount > 0) { + const rowActionBundleKey = availableActionsCount === 1 ? TABLE_ROW_SINGLE_ACTION : TABLE_ROW_MULTIPLE_ACTIONS; + descriptions.push(TableRowBase.i18nBundle.getText(rowActionBundleKey, availableActionsCount)); + } + + updateInvisibleText(this, descriptions); + } + + _onfocusout(e: FocusEvent, eventOrigin: HTMLElement) { this.removeAttribute("_active"); + if (eventOrigin === this) { + updateInvisibleText(this); + } } _onOverflowButtonClick(e: UI5CustomEvent) { @@ -165,7 +214,13 @@ class TableRow extends TableRowBase { } get _isInteractive() { - return this.interactive || (this._isSelectable && !this._hasSelector); + return this.interactive || (this._isSelectable && !this._hasSelector) || this._isNavigable; + } + + get _isNavigable() { + return this._fixedActions.find(action => { + return action.hasAttribute("ui5-table-row-action-navigation") && !action._isInteractive; + }) !== undefined; } get _rowIndex() { @@ -179,12 +234,12 @@ class TableRow extends TableRowBase { } get _hasOverflowActions() { - let renderedActionsCount = 0; + let renderableActionsCount = 0; return this.actions.some(action => { if (action.isFixedAction() || !action.invisible) { - renderedActionsCount++; + renderableActionsCount++; } - return renderedActionsCount > this._rowActionCount; + return renderableActionsCount > this._rowActionCount; }); } @@ -229,6 +284,16 @@ class TableRow extends TableRowBase { return overflowActions; } + + get _availableActionsCount() { + if (this._rowActionCount < 1) { + return 0; + } + + return [...this._flexibleActions, ...this._fixedActions].filter(action => { + return !action.invisible && action._isInteractive; + }).length + (this._hasOverflowActions ? 1 : 0); + } } TableRow.define(); diff --git a/packages/main/src/TableRowTemplate.tsx b/packages/main/src/TableRowTemplate.tsx index 91c6af2c69e7..61679307fd16 100644 --- a/packages/main/src/TableRowTemplate.tsx +++ b/packages/main/src/TableRowTemplate.tsx @@ -5,13 +5,13 @@ import Button from "./Button.js"; import ButtonDesign from "./types/ButtonDesign.js"; import type TableRow from "./TableRow.js"; -export default function TableRowTemplate(this: TableRow) { +export default function TableRowTemplate(this: TableRow, ariaColIndex: number = 1) { return ( <> { this._hasSelector && - @@ -34,12 +34,13 @@ export default function TableRowTemplate(this: TableRow) { } - { this._visibleCells.map(cell => ( - - ))} + { this._visibleCells.map(cell => { + cell.ariaColIndex = `${ariaColIndex++}`; + return ; + })} { this._rowActionCount > 0 && - + { this._flexibleActions.map(action => ( ))} @@ -66,7 +67,7 @@ export default function TableRowTemplate(this: TableRow) { } { this._popinCells.length > 0 && - + { this._popinCells.map(cell => ( ))} diff --git a/packages/main/src/TableUtils.ts b/packages/main/src/TableUtils.ts index 932a5f725ede..ba9099a00665 100644 --- a/packages/main/src/TableUtils.ts +++ b/packages/main/src/TableUtils.ts @@ -1,5 +1,21 @@ import type Table from "./Table.js"; import type TableRow from "./TableRow.js"; +import type { AccessibilityInfo } from "@ui5/webcomponents-base"; +import I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; +import { getTabbableElements } from "@ui5/webcomponents-base/dist/util/TabbableElements.js"; + +import { + TABLE_ACC_STATE_EMPTY, + TABLE_ACC_STATE_REQUIRED, + TABLE_ACC_STATE_DISABLED, + TABLE_ACC_STATE_READONLY, + TABLE_CELL_CONTAINS, + TABLE_CELL_SINGLE_CONTROL, + TABLE_CELL_MULTIPLE_CONTROLS, +} from "./generated/i18n/i18n-defaults.js"; + +let invisibleText: HTMLElement; +const i18nBundle = new I18nBundle("@ui5/webcomponents/main"); const isInstanceOfTable = (obj: any): obj is Table => { return !!obj && "isTable" in obj && !!obj.isTable; @@ -111,6 +127,131 @@ const isValidColumnWidth = (width: string | undefined): width is string => { return element.style.width !== ""; }; +/** + * Manages an invisible text element for accessibility and associates it with the given element via `aria-labelledby`. + * + * - Ensures a single invisible text element with a specific ID exists in the DOM. + * - Updates the text content of the invisible text element to the provided `texts`. + * - Adds or removes the invisible text element's ID from the target element's `aria-labelledby` attribute. + * - If no text is provided, disassociates the invisible text element from the target element. + * + * @param element The target HTMLElement to associate with the invisible text for accessibility. + * @param texts An optional array of strings to be joined and set as the invisible text content. + */ +const updateInvisibleText = (element: HTMLElement, texts: string[] = [], joiner: string = " . ") => { + const invisibleTextId = "ui5-table-invisible-text"; + if (!invisibleText || !invisibleText.isConnected) { + invisibleText = document.createElement("span"); + invisibleText.id = invisibleTextId; + invisibleText.ariaHidden = "true"; + invisibleText.style.display = "none"; + document.body.appendChild(invisibleText); + } + + let ariaLabelledBy = (element.getAttribute("aria-labelledby") || "").split(" ").filter(Boolean); + const invisibleTextAssociated = ariaLabelledBy.includes(invisibleTextId); + + const text = texts.filter(Boolean).join(joiner).trim(); + if (text && !invisibleTextAssociated) { + ariaLabelledBy.push(invisibleTextId); + } else if (!text && invisibleTextAssociated) { + ariaLabelledBy = ariaLabelledBy.filter(id => id !== invisibleTextId); + } + + invisibleText.textContent = text; + if (ariaLabelledBy.length > 0) { + element.setAttribute("aria-labelledby", ariaLabelledBy.join(" ")); + } else { + element.removeAttribute("aria-labelledby"); + } +}; + +const checkVisibility = (element: HTMLElement): boolean => { + return element.checkVisibility() || getComputedStyle(element).display === "contents"; +}; + +const getDefaultAccessibilityChildren = (element: Node, _nodes: Node[] = []): Node[] => { + element.childNodes.forEach(child => { + if (child.nodeType === Node.TEXT_NODE) { + _nodes.push(child); + } else if (child instanceof HTMLElement) { + if (child.localName === "slot") { + const assignedNodes = (child as HTMLSlotElement).assignedNodes(); + _nodes.push(...assignedNodes); + return; + } + if (!checkVisibility(child)) { + return; + } + if (child.hasAttribute("data-ui5-acc-text") || "accessibilityInfo" in child) { + _nodes.push(child); + } else { + getDefaultAccessibilityChildren(child, _nodes); + } + } + }); + + return _nodes; +}; + +const getAccessibilityDescription = (element: Node, details: boolean = true, _isRootElement: boolean = true): string => { + if (element.nodeType === Node.TEXT_NODE) { + return (element as Text).data.trim(); + } + + if (!(element instanceof HTMLElement)) { + return ""; + } + + if (!_isRootElement && !checkVisibility(element)) { + return ""; + } + + if (element.dataset.ui5AccText) { + return element.dataset.ui5AccText; + } + + const parts = { self: [] as string[], children: [] as string[] }; + const accessibilityInfo = ((element as any).accessibilityInfo) as AccessibilityInfo | undefined; + + const type = accessibilityInfo ? accessibilityInfo.type : element.ariaRoleDescription; + type && parts.self.push(type); + + const description = accessibilityInfo ? accessibilityInfo.description : element.ariaLabel; + description && parts.self.push(description); + + if (details) { + const required = accessibilityInfo ? accessibilityInfo.required : element.ariaRequired; + required && parts.self.push(i18nBundle.getText(TABLE_ACC_STATE_REQUIRED)); + + const disabled = accessibilityInfo ? accessibilityInfo.disabled : element.ariaDisabled; + disabled && parts.self.push(i18nBundle.getText(TABLE_ACC_STATE_DISABLED)); + + const readOnly = accessibilityInfo ? accessibilityInfo.readonly : element.ariaReadOnly; + readOnly && parts.self.push(i18nBundle.getText(TABLE_ACC_STATE_READONLY)); + } + + const children = accessibilityInfo ? accessibilityInfo.children || [] : getDefaultAccessibilityChildren(element); + children.forEach(child => { + const childDescription = getAccessibilityDescription(child, details, false); + childDescription && parts.children.push(childDescription); + }); + + if (_isRootElement && details && parts.children.length > 0 && getTabbableElements(element).length > 0) { + const childrenDescription = parts.children.join(" "); + parts.children = [i18nBundle.getText(TABLE_CELL_CONTAINS, childrenDescription)]; + } + + const fullDescription = [...parts.self, ...parts.children].join(" ").trim(); + if (_isRootElement && fullDescription === "") { + const tabbables = getTabbableElements(element); + const emptyTextBundleKey = [TABLE_ACC_STATE_EMPTY, TABLE_CELL_SINGLE_CONTROL, TABLE_CELL_MULTIPLE_CONTROLS][Math.min(tabbables.length, 2)]; + return i18nBundle.getText(emptyTextBundleKey); + } + + return fullDescription; +}; + export { isInstanceOfTable, isSelectionCheckbox, @@ -122,4 +263,6 @@ export { throttle, toggleAttribute, isValidColumnWidth, + getAccessibilityDescription, + updateInvisibleText, }; diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties index ccb2276f7b65..49297d5d9b9d 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -729,24 +729,46 @@ TABLE_NO_DATA=No Data TABLE_SINGLE_SELECTABLE=Single Selection Table #XACT: ARIA announcement for the table that allows multi selection TABLE_MULTI_SELECTABLE=Multi Selection Table +#XACT: accessibility text for announcing the cell content +TABLE_CELL_CONTAINS=Contains {0} +#XACT: accessibility text for the cell that contains a single interactive element +TABLE_CELL_SINGLE_CONTROL=Contains Control +#XACT: accessibility text for the cell that contains multiple interactive elements +TABLE_CELL_MULTIPLE_CONTROLS=Contains Controls #XACT: ARIA description for the selection column header when select all checkbox is shown -TABLE_COLUMNHEADER_SELECTALL_DESCRIPTION=Contains Select All Checkbox +TABLE_COLUMNHEADER_SELECTALL_DESCRIPTION=Select All Checkbox #XACT: ARIA description for the selection column header when select all checkbox is checked TABLE_COLUMNHEADER_SELECTALL_CHECKED=Checked #XACT: ARIA description for the selection column header when select all checkbox is not checked TABLE_COLUMNHEADER_SELECTALL_NOT_CHECKED=Not Checked #XACT: ARIA description for the selection column header when clear all button is shown -TABLE_COLUMNHEADER_CLEARALL_DESCRIPTION=Contains Clear All Button +TABLE_COLUMNHEADER_CLEARALL_DESCRIPTION=Clear All Button #XACT: ARIA description for the selection column header when clear all button is disabled TABLE_COLUMNHEADER_CLEARALL_DISABLED=Disabled +#XACT: ARIA announcement of a table row +TABLE_ROW=Row #XACT: Description for the popin containing column header TABLE_ROW_POPIN=Row Popin +#XACT: ARIA announcement for the position of a row among all table rows +TABLE_ROW_INDEX={0} of {1} +#XACT: ARIA announcement for the selected table row +TABLE_ROW_SELECTED=Selected +#XACT: ARIA announcement for the active clickable table rows +TABLE_ROW_ACTIVE=Is Active +#XACT: ARIA announcement for the navigable table rows +TABLE_ROW_NAVIGABLE=Has Details +#XACT: ARIA description for the column header row of the table +TABLE_COLUMN_HEADER_ROW=Column Header Row #XTXT: Text for the growing button TABLE_MORE=More #XACT: ARIA description for the growing button TABLE_MORE_DESCRIPTION=To load more rows, press Enter or Space #XACT: ARIA description for the row action of the header cell TABLE_ROW_ACTIONS=Row Actions +#XACT: Screenreader announcement when a single action is available +TABLE_ROW_SINGLE_ACTION=1 row action available +#XACT: Screenreader announcement when several actions are available +TABLE_ROW_MULTIPLE_ACTIONS={0} row actions available #XACT: ARIA description for the row action navigation TABLE_NAVIGATION=Navigation #XTOL: Tooltip for the AI button in the column header to indicate that the column is generated by AI @@ -755,6 +777,14 @@ TABLE_GENERATED_BY_AI=Generated by AI TABLE_SELECT_ALL_ROWS=Select All Rows #XTOL: Tooltip of the header row checkbox to deselect all rows in the table TABLE_DESELECT_ALL_ROWS=Deselect All Rows +#XACT: Accessibility state which should be announced by screenreaders if the element in a table cell is disabled +TABLE_ACC_STATE_DISABLED=Disabled +#XACT: state which should be announced by screenreaders if the element in a table cell is readonly +TABLE_ACC_STATE_READONLY=Read Only +#XACT: state which should be announced by screenreaders if the element in a table cell is required +TABLE_ACC_STATE_REQUIRED=Required +#XACT: state which should be announced by screenreaders if the table cell is empty +TABLE_ACC_STATE_EMPTY=Empty #XFLD: Text for the "Yesterday" option in the DynamicDateRange component. DYNAMIC_DATE_RANGE_YESTERDAY_TEXT=Yesterday @@ -779,8 +809,6 @@ DYNAMIC_DATE_RANGE_EMPTY_SELECTED_TEXT=Choose Dates #XFLD: Text for icon that navigates back to the previous page in the DynamicDateRange component. DYNAMIC_DATE_RANGE_NAVIGATION_ICON_TOOLTIP=Navigate back -#XACT: ARIA description for the column header row of the table -TABLE_COLUMN_HEADER_ROW=Column Header Row #XFLD: Text for the "Last Days" option in the DynamicDateRange component. DYNAMIC_DATE_RANGE_LAST_DAYS_TEXT=Last X Days diff --git a/packages/main/test/pages/Table.html b/packages/main/test/pages/Table.html index fb354678ef20..a0f097e4f538 100644 --- a/packages/main/test/pages/Table.html +++ b/packages/main/test/pages/Table.html @@ -31,13 +31,13 @@ - + - Text + Product Supplier @@ -48,19 +48,25 @@ - + Notebook Basic 15
HT-1000
Very Best Screens - 30 x 18 x 3 cm +
30 x 18 x 3 cm
4.2 KG 956 EUR
- + Notebook Basic 16
HT-1001
Smartcards 4.5 KG 1249 EUR + + + + + +
diff --git a/vite.config.js b/vite.config.js index f30d0453b158..1fbd4caf93bc 100644 --- a/vite.config.js +++ b/vite.config.js @@ -107,6 +107,9 @@ export default defineConfig(async () => { build: { emptyOutDir: false, }, + server: { + host: true, + }, plugins: [ await virtualIndex(), tsconfigPaths(), From 450d0a9eb01df7b3de2486063fa6c8182e482e14 Mon Sep 17 00:00:00 2001 From: Cahit Guerguec Date: Thu, 2 Oct 2025 06:54:59 +0200 Subject: [PATCH 2/3] refactor(ui5-table): enhance with custom announcements - Introduce TableCustomAnnouncement extension for accessibility announcements. - Refactor Table elements to improve accessibility descriptions and focus handling. - Modify TableRowTemplate to include accessibility attributes and improve structure. - Adjust CSS for TableCell and TableHeaderRow to support new accessibility features. - Update i18n message bundles for clearer accessibility text. - Add Cypress tests for custom announcements. --- packages/base/src/UI5Element.ts | 2 +- packages/base/src/util/TabbableElements.ts | 4 +- .../specs/TableCustomAnnouncement.cy.tsx | 294 ++++++++++++++++++ .../main/cypress/specs/TableSelections.cy.tsx | 8 +- packages/main/src/Table.ts | 7 +- packages/main/src/TableCell.ts | 8 +- packages/main/src/TableCellTemplate.tsx | 10 +- packages/main/src/TableCustomAnnouncement.ts | 239 ++++++++++++++ packages/main/src/TableHeaderCell.ts | 13 + .../main/src/TableHeaderCellActionBase.ts | 9 +- packages/main/src/TableHeaderRow.ts | 33 -- packages/main/src/TableHeaderRowTemplate.tsx | 16 +- packages/main/src/TableRow.ts | 70 ++--- packages/main/src/TableRowTemplate.tsx | 32 +- packages/main/src/TableSelection.ts | 6 +- packages/main/src/TableSelectionMulti.ts | 8 +- packages/main/src/TableUtils.ts | 155 +-------- .../main/src/i18n/messagebundle.properties | 2 - .../main/src/i18n/messagebundle_en.properties | 4 +- packages/main/src/themes/TableCell.css | 7 +- packages/main/src/themes/TableHeaderRow.css | 5 + packages/main/src/types/TableGrowingMode.ts | 1 - packages/main/test/pages/Table.html | 4 +- vite.config.js | 3 - 24 files changed, 657 insertions(+), 283 deletions(-) create mode 100644 packages/main/cypress/specs/TableCustomAnnouncement.cy.tsx create mode 100644 packages/main/src/TableCustomAnnouncement.ts diff --git a/packages/base/src/UI5Element.ts b/packages/base/src/UI5Element.ts index fa49cfb702f8..09c2c192842e 100644 --- a/packages/base/src/UI5Element.ts +++ b/packages/base/src/UI5Element.ts @@ -159,7 +159,7 @@ type KebabToCamel = T extends `${infer H}-${infer J}${infer K} : T; type KebabToPascal = Capitalize>; -type GlobalHTMLAttributeNames = "accesskey" | "autocapitalize" | "autofocus" | "autocomplete" | "contenteditable" | "contextmenu" | "class" | "dir" | "draggable" | "enterkeyhint" | "hidden" | "id" | "inputmode" | "lang" | "nonce" | "part" | "exportparts" | "pattern" | "slot" | "spellcheck" | "style" | "tabIndex" | "tabindex" | "title" | "translate" | "ref" | "inert"; +type GlobalHTMLAttributeNames = "accesskey" | "autocapitalize" | "autofocus" | "autocomplete" | "contenteditable" | "contextmenu" | "class" | "dir" | "draggable" | "enterkeyhint" | "hidden" | "id" | "inputmode" | "lang" | "nonce" | "part" | "exportparts" | "pattern" | "slot" | "spellcheck" | "style" | "tabIndex" | "tabindex" | "title" | "translate" | "ref" | "inert" | "role"; type ElementProps = Partial>; type TargetedCustomEvent = Omit, "currentTarget"> & { currentTarget: T }; // define as method and extract the function signature from the method to make it bivariant so that inheritance of event handlers is not checked via strictFunctionTypes diff --git a/packages/base/src/util/TabbableElements.ts b/packages/base/src/util/TabbableElements.ts index 6b8222b3fc9c..fe7a77307d42 100644 --- a/packages/base/src/util/TabbableElements.ts +++ b/packages/base/src/util/TabbableElements.ts @@ -8,7 +8,7 @@ import isElementTabbable from "./isElementTabbable.js"; * @returns { Array } the tabbable elements */ const getTabbableElements = (el: HTMLElement): Array => { - return getTabbables([...el.children]); + return getTabbables(el.tagName === "SLOT" ? [el] : [...el.children]); }; /** @@ -45,7 +45,7 @@ const getTabbables = (nodes: Array, tabbables?: Array): Array } if (currentElement.tagName === "SLOT") { - getTabbables((currentElement as HTMLSlotElement).assignedNodes() as Array, tabbableElements); + getTabbables((currentElement as HTMLSlotElement).assignedElements(), tabbableElements); } else { const children = currentElement.shadowRoot ? currentElement.shadowRoot.children : currentElement.children; getTabbables([...children], tabbableElements); diff --git a/packages/main/cypress/specs/TableCustomAnnouncement.cy.tsx b/packages/main/cypress/specs/TableCustomAnnouncement.cy.tsx new file mode 100644 index 000000000000..615a1dc9a547 --- /dev/null +++ b/packages/main/cypress/specs/TableCustomAnnouncement.cy.tsx @@ -0,0 +1,294 @@ +import Table from "../../src/Table.js"; +import TableHeaderRow from "../../src/TableHeaderRow.js"; +import TableHeaderCell from "../../src/TableHeaderCell.js"; +import TableRow from "../../src/TableRow.js"; +import TableCell from "../../src/TableCell.js"; +import TableSelectionMulti from "../../src/TableSelectionMulti.js"; +import TableRowAction from "../../src/TableRowAction.js"; +import TableRowActionNavigation from "../../src/TableRowActionNavigation.js"; +import TableHeaderCellActionAI from "../../src/TableHeaderCellActionAI.js"; +import Label from "../../src/Label.js"; +import Button from "../../src/Button.js"; +import add from "@ui5/webcomponents-icons/dist/add.js"; +import edit from "@ui5/webcomponents-icons/dist/edit.js"; +import "../../src/TableSelectionSingle.js"; + +describe("Cell Custom Announcement - More details", () => { + beforeEach(() => { + cy.mount( + + + + + + + + +
Header3
+ +
+ + + Row1Cell1 +
" }} /> + + +
+ + + + + +
+ ); + + cy.get("#table0").children("ui5-table-row").as("rows"); + cy.get("#table0").children("ui5-table-header-row").first().as("headerRow"); + cy.get("#table0").shadow().find("#table").as("innerTable"); + cy.get("@rows").first().as("row1"); + cy.get("@row1").find("#row1-button").as("row1Button"); + cy.get("@row1").find("#row1-input1").as("row1Input1"); + cy.get("@row1").find("#row1-input2").as("row1Input2"); + cy.get("@row1").find("#row1-div").as("row1Div"); + }); + + function checkAnnouncement(expectedText: string, focusAgain = false) { + if (focusAgain) { + cy.realPress("ArrowUp"); + cy.realPress("ArrowDown"); + } + + cy.get("body").then($body => { + expect($body.find("#ui5-table-invisible-text").text()).to.equal(expectedText); + }); + } + + it("should announce table cells", () => { + cy.get("@row1").realClick(); // row focused + cy.realPress("ArrowRight"); // selection cell focused + checkAnnouncement(""); + + cy.realPress("ArrowRight"); // first cell focused + checkAnnouncement("Row1Cell1"); + + cy.realPress("ArrowRight"); // second cell focused + checkAnnouncement("Contains Control"); + + cy.get("@row1Input2").invoke("removeAttr", "hidden"); + checkAnnouncement("Contains Controls", true); + + cy.get("@row1Input1").invoke("attr", "data-ui5-table-acc-text", "Input with custom accessibility text"); + checkAnnouncement("Input with custom accessibility text . Contains Controls", true); + + cy.realPress("ArrowRight"); // third cell focused + checkAnnouncement("Empty"); + + cy.get("@row1Div").invoke("attr", "tabindex", "0"); + cy.get("@row1Div").invoke("css", "width", "150px"); + cy.get("@row1Div").focus(); + cy.realPress("F2"); + checkAnnouncement("Contains Control", true); + + cy.realPress("ArrowRight"); // fourth cell focused + checkAnnouncement("Row1Cell3 . Contains Control"); + + cy.document().then((doc) => { + const row1Button = doc.getElementById("row1-button") as Button; + cy.stub(row1Button, "accessibilityInfo").get(() => ({ + type: "Button", + description: "Row1Cell4", + required: true, + disabled: true, + readonly: true, + })); + }); + checkAnnouncement("Button Row1Cell4 Required Disabled Read Only . Contains Control", true); + + cy.get("@row1Button").invoke("attr", "data-ui5-table-acc-text", "Button with custom accessibility text"); + checkAnnouncement("Button with custom accessibility text . Contains Control", true); + + cy.realPress("ArrowRight"); // row actions cell + checkAnnouncement("2 row actions available"); + cy.get("#row1-edit-action").invoke("remove"); + checkAnnouncement("1 row action available", true); + + cy.get("#row1-add-action").invoke("remove"); + checkAnnouncement("Empty", true); + + cy.realPress("Home"); // selection cell focused + checkAnnouncement(""); + }); + + it("should announce table header cells", () => { + cy.get("@headerRow").realClick(); // header row focused + cy.realPress("ArrowRight"); // selection cell focused + checkAnnouncement(""); + + cy.realPress("ArrowRight"); // first cell focused + checkAnnouncement("Header1 Generated by AI . Contains Control"); + + cy.realPress("ArrowRight"); // second cell focused + checkAnnouncement("Header2"); + + cy.realPress("ArrowRight"); // third cell focused + checkAnnouncement("Header3"); + + cy.realPress("ArrowRight"); // forth cell focused + checkAnnouncement("Empty"); + + cy.realPress("ArrowRight"); // forth cell focused + checkAnnouncement("Row Actions"); + + cy.realPress("Home"); // selection cell focused + checkAnnouncement(""); + }); +}); + +describe("Row Custom Announcement - Less details", () => { + beforeEach(() => { + cy.mount( + + + + + +
H1DisplayNone
+
+ +
H2 Custom Text
+
+ +
H3
+
+ + H4 + + +
+ + R1C1 + + + + + +
+ + + R1C3DisplayNone +
+
+ + C4 + + + + +
+
+ ); + + cy.get("#table0").children("ui5-table-row").as("rows"); + cy.get("#table0").children("ui5-table-header-row").first().as("headerRow"); + cy.get("#table0").shadow().find("#table").as("innerTable"); + cy.get("@rows").first().as("row1"); + cy.get("@row1").find("#row1-button").as("row1Button"); + + cy.document().then((doc) => { + const header1Label = doc.getElementById("Header1Label") as Label; + cy.stub(header1Label, "accessibilityInfo").get(() => ({ + description: "H1", + required: true, + })); + + const row1Button = doc.getElementById("row1-button") as Button; + cy.stub(row1Button, "accessibilityInfo").get(() => ({ + type: "Button", + description: "C4Button", + required: true, + disabled: true, + readonly: true, + })); + }); + }); + + function checkAnnouncement(expectedText: string, focusAgain = false, check = "contains") { + focusAgain && cy.focused().then($el => { + if ($el.attr("ui5-table-header-row") !== undefined) { + cy.realPress("ArrowDown"); + cy.realPress("ArrowUp"); + } else { + cy.realPress("ArrowUp"); + cy.realPress("ArrowDown"); + } + }); + + cy.get("body").then($body => { + expect($body.find("#ui5-table-invisible-text").text())[check](expectedText); + }); + } + + it("should announce table rows", () => { + cy.get("@row1").realClick(); + checkAnnouncement("Row . 2 of 2 . Selected . Has Details . H1"); + checkAnnouncement("H1 . R1C1 . H2 . Contains Controls . H3 . Empty . H4 . C4 Button C4Button"); + checkAnnouncement("1 row action available"); + + cy.get("#selection").invoke("attr", "selected", ""); + checkAnnouncement("Row . 2 of 2 . Has Details", true); + + cy.get("#row1-nav-action").invoke("prop", "interactive", true); + checkAnnouncement("Row . 2 of 2 . Is Active . H1", true); + checkAnnouncement("2 row actions available"); + + cy.get("@row1").invoke("prop", "interactive", false); + checkAnnouncement("Row . 2 of 2 . H1", true); + + cy.get("#table0").invoke("css", "width", "301px"); + checkAnnouncement("Row . 2 of 2 . H1", true); + checkAnnouncement("H1 . R1C1 . H2 . Contains Controls . H3 . Empty . H4Popin . C4 Button C4Button"); + checkAnnouncement("2 row actions available"); + + cy.get("#Header3").invoke("prop", "popinHidden", true); + checkAnnouncement("H1 . R1C1 . H2 . Contains Controls . H4Popin . C4 Button C4Button", true); + + cy.get("#row1-nav-action").invoke("remove"); + cy.get("#row1-add-action").invoke("remove"); + checkAnnouncement("Row . 2 of 2 . H1 . R1C1 . H2 . Contains Controls . H4Popin . C4 Button C4Button", true, "equal"); + + cy.realPress("ArrowRight"); // selection cell focused + checkAnnouncement(""); + + cy.realPress("End"); // popin cell focused we need details + checkAnnouncement("H2 . Contains Controls . H4Popin Generated by AI . Contains Control . C4 Button C4Button Required Disabled Read Only . Contains Control"); + + cy.realPress("Home"); // selection cell focused + cy.realPress("Home"); // row focused + cy.get("#table0").invoke("css", "width", "1000px"); + checkAnnouncement("Row . 2 of 2 . H1 . R1C1 . H2 . Contains Controls . H3 . Empty . H4 . C4 Button C4Button", true, "equal"); + + cy.realPress("ArrowUp"); // header row focused + cy.get("@row1").invoke("remove"); + cy.focused().click(); + cy.realPress("ArrowDown"); // nodata row focused + checkAnnouncement(""); + }); + + it("should announce table header row", () => { + cy.get("@row1").realClick(); + cy.realPress("ArrowUp"); + checkAnnouncement("Column Header Row . Select All Checkbox Checked . H1 . H2 . H3 . H4 . Row Actions"); + + cy.get("#table0").invoke("attr", "row-action-count", "0"); + checkAnnouncement("Column Header Row . Select All Checkbox Checked . H1 . H2 . H3 . H4", true, "equal"); + + cy.get("#selection").invoke("attr", "selected", ""); + checkAnnouncement("Column Header Row . Select All Checkbox Not Checked . H1 . H2 . H3 . H4", true, "equal"); + + cy.get("#selection").invoke("remove"); + checkAnnouncement("Column Header Row . H1 . H2 . H3 . H4", true, "equal"); + + cy.get("#table0").invoke("append", ''); + checkAnnouncement("Column Header Row . Selection . H1 . H2 . H3 . H4", true, "equal"); + }); +}); diff --git a/packages/main/cypress/specs/TableSelections.cy.tsx b/packages/main/cypress/specs/TableSelections.cy.tsx index f142a4c78894..4e1a3981606b 100644 --- a/packages/main/cypress/specs/TableSelections.cy.tsx +++ b/packages/main/cypress/specs/TableSelections.cy.tsx @@ -377,7 +377,7 @@ describe("TableSelectionMulti", () => { }); it("updates the header row checkbox when rows are added or removed", () => { - cy.get("@headerRowSelectionCell").should("have.attr", "aria-description", "Contains Select All Checkbox . Checked"); + cy.get("@headerRowSelectionCell").should("have.attr", "aria-description", "Select All Checkbox Checked"); cy.get("@headerRowSelectionCell").children().first().as("headerRowCheckBox"); cy.get("@headerRowCheckBox").should("have.attr", "checked"); cy.get("@headerRowCheckBox").should("have.attr", "title", "Deselect All Rows"); @@ -389,11 +389,11 @@ describe("TableSelectionMulti", () => { ` ); }); - cy.get("@headerRowSelectionCell").should("have.attr", "aria-description", "Contains Select All Checkbox . Not Checked"); + cy.get("@headerRowSelectionCell").should("have.attr", "aria-description", "Select All Checkbox Not Checked"); cy.get("@headerRowCheckBox").should("not.have.attr", "checked"); cy.get("@headerRowCheckBox").should("have.attr", "title", "Select All Rows"); cy.get("#row3").invoke("remove"); - cy.get("@headerRowSelectionCell").should("have.attr", "aria-description", "Contains Select All Checkbox . Checked"); + cy.get("@headerRowSelectionCell").should("have.attr", "aria-description", "Select All Checkbox Checked"); cy.get("@headerRowCheckBox").should("have.attr", "checked"); cy.get("@headerRowCheckBox").should("have.attr", "title", "Deselect All Rows"); cy.get("#row2").invoke("remove"); @@ -410,7 +410,7 @@ describe("TableSelectionMulti", () => { cy.get("@headerRowIcon").should("have.attr", "show-tooltip"); cy.get("@headerRowIcon").should("have.attr", "accessible-name", "Deselect All Rows"); cy.get("@headerRowIcon").should("have.attr", "design", hasSelection ? "Default" : "NonInteractive"); - cy.get("@headerRowSelectionCell").should("have.attr", "aria-description", hasSelection ? "Contains Clear All Button" : "Contains Clear All Button . Disabled"); + cy.get("@headerRowSelectionCell").should("have.attr", "aria-description", hasSelection ? "Clear All Button" : "Clear All Button Disabled"); } cy.get("#selection").invoke("attr", "header-selector", "ClearAll"); diff --git a/packages/main/src/Table.ts b/packages/main/src/Table.ts index d360a3a4c2a9..3167e0915618 100644 --- a/packages/main/src/Table.ts +++ b/packages/main/src/Table.ts @@ -10,6 +10,7 @@ import TableExtension from "./TableExtension.js"; import TableNavigation from "./TableNavigation.js"; import TableOverflowMode from "./types/TableOverflowMode.js"; import TableDragAndDrop from "./TableDragAndDrop.js"; +import TableCustomAnnouncement from "./TableCustomAnnouncement.js"; import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; import { findVerticalScrollContainer, scrollElementIntoView, isFeature, isValidColumnWidth, @@ -409,6 +410,7 @@ class Table extends UI5Element { _onResizeBound: ResizeObserverCallback; _tableNavigation?: TableNavigation; _tableDragAndDrop?: TableDragAndDrop; + _tableCustomAnnouncement?: TableCustomAnnouncement; _poppedIn: Array<{col: TableHeaderCell, width: number}> = []; _containerWidth = 0; @@ -423,6 +425,7 @@ class Table extends UI5Element { this.features.forEach(feature => feature.onTableActivate?.(this)); this._tableNavigation = new TableNavigation(this); this._tableDragAndDrop = new TableDragAndDrop(this); + this._tableCustomAnnouncement = new TableCustomAnnouncement(this); } onExitDOM() { @@ -473,7 +476,7 @@ class Table extends UI5Element { _onEvent(e: Event) { const composedPath = e.composedPath(); const eventOrigin = composedPath[0] as HTMLElement; - const elements = [this._tableNavigation, this._tableDragAndDrop, ...composedPath, ...this.features]; + const elements = [this._tableCustomAnnouncement, this._tableNavigation, this._tableDragAndDrop, ...composedPath, ...this.features].filter(Boolean) as Array; elements.forEach(element => { if (element instanceof TableExtension || (element instanceof HTMLElement && element.localName.includes("ui5-table"))) { const eventHandlerName = `_on${e.type}` as keyof typeof element; @@ -561,13 +564,11 @@ class Table extends UI5Element { const headerIndex = this.headerRow[0].cells.indexOf(headerCell); headerCell._popin = inPopin && this.overflowMode === TableOverflowMode.Popin; headerCell._popinWidth = popinWidth; - headerCell.ariaColIndex = null; this.rows.forEach(row => { const cell = row.cells[headerIndex]; if (cell) { row.cells[headerIndex]._popinHidden = headerCell.popinHidden; row.cells[headerIndex]._popin = headerCell._popin; - row.cells[headerIndex].ariaColIndex = null; } }); } diff --git a/packages/main/src/TableCell.ts b/packages/main/src/TableCell.ts index 696949f83ae6..1daa5361002a 100644 --- a/packages/main/src/TableCell.ts +++ b/packages/main/src/TableCell.ts @@ -1,8 +1,8 @@ import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; +import query from "@ui5/webcomponents-base/dist/decorators/query.js"; import TableCellTemplate from "./TableCellTemplate.js"; import TableCellStyles from "./generated/themes/TableCell.css.js"; import TableCellBase from "./TableCellBase.js"; -import { getAccessibilityDescription, updateInvisibleText } from "./TableUtils.js"; import type TableRow from "./TableRow.js"; import type Table from "./Table.js"; import { LABEL_COLON } from "./generated/i18n/i18n-defaults.js"; @@ -30,6 +30,12 @@ import { LABEL_COLON } from "./generated/i18n/i18n-defaults.js"; template: TableCellTemplate, }) class TableCell extends TableCellBase { + @query("#popin-header") + _popinHeader?: HTMLElement; + + @query("#popin-content") + _popinContent?: HTMLElement; + onBeforeRendering() { super.onBeforeRendering(); if (this.horizontalAlign) { diff --git a/packages/main/src/TableCellTemplate.tsx b/packages/main/src/TableCellTemplate.tsx index 467392398f85..8babb48ae570 100644 --- a/packages/main/src/TableCellTemplate.tsx +++ b/packages/main/src/TableCellTemplate.tsx @@ -3,13 +3,15 @@ import type TableCell from "./TableCell.js"; export default function TableCellTemplate(this: TableCell) { return ( <> - { this._popin && + { this._popin ? <> -
- {this._i18nPopinColon} +
+ + + : + } - ); } diff --git a/packages/main/src/TableCustomAnnouncement.ts b/packages/main/src/TableCustomAnnouncement.ts new file mode 100644 index 000000000000..c65a13d6922c --- /dev/null +++ b/packages/main/src/TableCustomAnnouncement.ts @@ -0,0 +1,239 @@ +import TableExtension from "./TableExtension.js"; +import I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; +import { getTabbableElements } from "@ui5/webcomponents-base/dist/util/TabbableElements.js"; +import type { AccessibilityInfo } from "@ui5/webcomponents-base"; +import type Table from "./Table.js"; +import type TableRow from "./TableRow.js"; +import type TableCell from "./TableCell.js"; +import type TableHeaderRow from "./TableHeaderRow.js"; +import { + TABLE_ROW, + TABLE_ROW_INDEX, + TABLE_ROW_SELECTED, + TABLE_ROW_ACTIVE, + TABLE_ROW_NAVIGABLE, + TABLE_COLUMN_HEADER_ROW, + TABLE_CELL_SINGLE_CONTROL, + TABLE_CELL_MULTIPLE_CONTROLS, + TABLE_ACC_STATE_EMPTY, + TABLE_ACC_STATE_REQUIRED, + TABLE_ACC_STATE_DISABLED, + TABLE_ACC_STATE_READONLY, +} from "./generated/i18n/i18n-defaults.js"; + +let invisibleText: HTMLElement; +const i18nBundle = new I18nBundle("@ui5/webcomponents/main"); + +const checkVisibility = (element: HTMLElement): boolean => { + return element.checkVisibility() || getComputedStyle(element).display === "contents"; +}; + +const updateInvisibleText = (element: HTMLElement, text: string | string[] = []) => { + const invisibleTextId = "ui5-table-invisible-text"; + if (!invisibleText || !invisibleText.isConnected) { + invisibleText = document.createElement("span"); + invisibleText.id = invisibleTextId; + invisibleText.ariaHidden = "true"; + invisibleText.style.display = "none"; + document.body.appendChild(invisibleText); + } + + let ariaLabelledBy = (element.getAttribute("aria-labelledby") || "").split(" ").filter(Boolean); + const invisibleTextAssociated = ariaLabelledBy.includes(invisibleTextId); + + text = Array.isArray(text) ? text.filter(Boolean).join(" . ").trim() : text.trim(); + if (text && !invisibleTextAssociated) { + ariaLabelledBy.push(invisibleTextId); + } else if (!text && invisibleTextAssociated) { + ariaLabelledBy = ariaLabelledBy.filter(id => id !== invisibleTextId); + } + + invisibleText.textContent = text; + if (ariaLabelledBy.length > 0) { + element.setAttribute("aria-labelledby", ariaLabelledBy.join(" ")); + } else { + element.removeAttribute("aria-labelledby"); + } +}; + +const getAccessibilityDescription = (element: Node, lessDetails: boolean = false, _isRootElement: boolean = true): string => { + if (!element) { + return ""; + } + + if (element.nodeType === Node.TEXT_NODE) { + return (element as Text).data.trim(); + } + + if (!(element instanceof HTMLElement)) { + return ""; + } + + if (element.hasAttribute("data-ui5-table-acc-text")) { + return element.getAttribute("data-ui5-table-acc-text") || ""; + } + + if (element.ariaHidden === "true" || !checkVisibility(element)) { + return _isRootElement ? i18nBundle.getText(TABLE_ACC_STATE_EMPTY) : ""; + } + + let childNodes = [] as Array; + const descriptions = [] as Array; + const accessibilityInfo = (element as any).accessibilityInfo as AccessibilityInfo | undefined; + if (accessibilityInfo) { + const { + type, description, required, disabled, readonly, children, + } = accessibilityInfo; + + childNodes = children || []; + type && descriptions.push(type); + description && descriptions.push(description); + + if (!lessDetails) { + required && descriptions.push(i18nBundle.getText(TABLE_ACC_STATE_REQUIRED)); + disabled && descriptions.push(i18nBundle.getText(TABLE_ACC_STATE_DISABLED)); + readonly && descriptions.push(i18nBundle.getText(TABLE_ACC_STATE_READONLY)); + } + } else if (element.localName === "slot") { + childNodes = (element as HTMLSlotElement).assignedNodes({ flatten: true }); + } else { + childNodes = element.shadowRoot ? [...element.shadowRoot.childNodes] : [...element.childNodes]; + } + + childNodes.forEach(child => { + const childDescription = getAccessibilityDescription(child, lessDetails, false); + childDescription && descriptions.push(childDescription); + }); + + if (_isRootElement) { + const hasDescription = descriptions.length > 0; + if (!hasDescription || !lessDetails) { + const tabbables = getTabbableElements(element); + const bundleKey = [ + hasDescription ? "" : TABLE_ACC_STATE_EMPTY, + TABLE_CELL_SINGLE_CONTROL, + TABLE_CELL_MULTIPLE_CONTROLS, + ][Math.min(tabbables.length, 2)]; + if (bundleKey) { + hasDescription && descriptions.push("."); + descriptions.push(i18nBundle.getText(bundleKey)); + } + } + } + + return descriptions.join(" ").trim(); +}; + +/** + * Handles the custom announcement for the ui5-table. + * + * @class + * @private + */ +class TableCustomAnnouncement extends TableExtension { + _table: Table; + _tableAttributes = ["ui5-table-header-row", "ui5-table-header-cell", "ui5-table-row", "ui5-table-cell"]; + + constructor(table: Table) { + super(); + this._table = table; + } + + _onfocusin(e: FocusEvent, eventOrigin: HTMLElement) { + const tableAttribute = this._tableAttributes.find(attr => eventOrigin.hasAttribute(attr)); + if (!tableAttribute) { + return; + } + + const tableElementName = tableAttribute.replace("ui5-table", "Table").replace(/-([a-z])/g, g => g[1].toUpperCase()); + const eventHandlerName = `_handle${tableElementName}Focusin` as keyof TableCustomAnnouncement; + const eventHandler = this[eventHandlerName] as (target: HTMLElement, e?: FocusEvent) => void; + if (typeof eventHandler === "function") { + eventHandler.call(this, eventOrigin, e); + } else { + this._handleTableElementFocusin(eventOrigin); + } + } + + _onfocusout(e: FocusEvent, eventOrigin: HTMLElement) { + const isTableElement = this._tableAttributes.some(attr => eventOrigin.hasAttribute(attr)); + isTableElement && updateInvisibleText(eventOrigin); + } + + _handleTableElementFocusin(element: HTMLElement) { + const description = getAccessibilityDescription(element); + updateInvisibleText(element, description); + } + + _handleTableHeaderRowFocusin(headerRow: TableHeaderRow) { + const descriptions = [ + i18nBundle.getText(TABLE_COLUMN_HEADER_ROW), + ]; + + if (headerRow._hasSelector) { + descriptions.push(headerRow._isMultiSelect ? headerRow._selectionCellAriaDescription! : headerRow._i18nSelection); + } + + headerRow._visibleCells.forEach(headerCell => { + const cellDescription = getAccessibilityDescription(headerCell, true); + descriptions.push(cellDescription); + }); + + if (headerRow._rowActionCount > 0) { + descriptions.push(headerRow._i18nRowActions); + } + + updateInvisibleText(headerRow, descriptions); + } + + _handleTableRowFocusin(row: TableRow) { + if (!row._table) { + return; + } + + const descriptions = [ + i18nBundle.getText(TABLE_ROW), + i18nBundle.getText(TABLE_ROW_INDEX, row.ariaRowIndex!, this._table._ariaRowCount), + ]; + + if (row._isSelected) { + descriptions.push(i18nBundle.getText(TABLE_ROW_SELECTED)); + } + + if (row._isNavigable) { + descriptions.push(i18nBundle.getText(TABLE_ROW_NAVIGABLE)); + } else if (row.interactive) { + descriptions.push(i18nBundle.getText(TABLE_ROW_ACTIVE)); + } + + const cells = [...row._visibleCells, ...row._popinCells] as TableCell[]; + cells.flatMap(cell => { + return cell._popin ? [cell._popinHeader!, cell._popinContent!] : [cell._headerCell, cell]; + }).forEach(node => { + const nodeDescription = getAccessibilityDescription(node, true); + descriptions.push(nodeDescription); + }); + + if (row._availableActionsCount > 0) { + descriptions.push(row._actionCellAccText!); + } + + updateInvisibleText(row, descriptions); + } + + _handleTableCellFocusin(cell: TableCell) { + if (cell.hasAttribute("data-ui5-table-popin-cell")) { + const popinCells = (cell.getDomRef() as HTMLSlotElement).assignedNodes({ flatten: true }) as TableCell[]; + const descriptions = popinCells.flatMap(popinCell => { + const headerDescription = getAccessibilityDescription(popinCell._popinHeader!); + const contentDescription = getAccessibilityDescription(popinCell._popinContent!); + return [headerDescription, contentDescription]; + }); + updateInvisibleText(cell, descriptions); + } else { + this._handleTableElementFocusin(cell); + } + } +} + +export default TableCustomAnnouncement; diff --git a/packages/main/src/TableHeaderCell.ts b/packages/main/src/TableHeaderCell.ts index 886e9b078453..5b14efd2bcc6 100644 --- a/packages/main/src/TableHeaderCell.ts +++ b/packages/main/src/TableHeaderCell.ts @@ -4,6 +4,7 @@ import TableCellBase from "./TableCellBase.js"; import TableHeaderCellTemplate from "./TableHeaderCellTemplate.js"; import TableHeaderCellStyles from "./generated/themes/TableHeaderCell.css.js"; import SortOrder from "@ui5/webcomponents-base/dist/types/SortOrder.js"; +import query from "@ui5/webcomponents-base/dist/decorators/query.js"; import type TableHeaderCellActionBase from "./TableHeaderCellActionBase.js"; /** @@ -124,6 +125,12 @@ class TableHeaderCell extends TableCellBase { @property({ type: Boolean, noAttribute: true }) _popin = false; + @query("slot:not([name])") + _defaultSlot!: HTMLSlotElement; + + @query("slot[name=action]") + _actionSlot!: HTMLSlotElement; + protected ariaRole: string = "columnheader"; _popinWidth: number = 0; @@ -135,6 +142,12 @@ class TableHeaderCell extends TableCellBase { } toggleAttribute(this, "aria-sort", this.sortIndicator !== SortOrder.None, this.sortIndicator.toLowerCase()); } + + get accessibilityInfo() { + return { + children: [this._defaultSlot, this._actionSlot], + }; + } } TableHeaderCell.define(); diff --git a/packages/main/src/TableHeaderCellActionBase.ts b/packages/main/src/TableHeaderCellActionBase.ts index 41b6b428c3b3..c40045b2b728 100644 --- a/packages/main/src/TableHeaderCellActionBase.ts +++ b/packages/main/src/TableHeaderCellActionBase.ts @@ -1,9 +1,10 @@ import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; import { customElement, eventStrict } from "@ui5/webcomponents-base/dist/decorators.js"; import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; -import type { UI5CustomEvent } from "@ui5/webcomponents-base"; import TableHeaderCellActionBaseTemplate from "./TableHeaderCellActionBaseTemplate.js"; import TableHeaderCellActionBaseStyles from "./generated/themes/TableHeaderCellActionBase.css.js"; +import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js"; +import type { UI5CustomEvent } from "@ui5/webcomponents-base"; import type TableCell from "./TableCell.js"; import type Button from "./Button.js"; @@ -64,6 +65,12 @@ abstract class TableHeaderCellActionBase extends UI5Element { e.stopPropagation(); } + get accessibilityInfo() { + return { + description: getActiveElement()?.hasAttribute("ui5-table-cell-base") ? this._tooltip : "", + }; + } + get _tooltip() { return this.getRenderInfo().tooltip; } diff --git a/packages/main/src/TableHeaderRow.ts b/packages/main/src/TableHeaderRow.ts index 6bc61282ad2b..06fa9f9f5466 100644 --- a/packages/main/src/TableHeaderRow.ts +++ b/packages/main/src/TableHeaderRow.ts @@ -4,7 +4,6 @@ import TableHeaderRowTemplate from "./TableHeaderRowTemplate.js"; import TableHeaderRowStyles from "./generated/themes/TableHeaderRow.css.js"; import type TableHeaderCell from "./TableHeaderCell.js"; import type TableSelectionMulti from "./TableSelectionMulti.js"; -import { getAccessibilityDescription, updateInvisibleText } from "./TableUtils.js"; import { TABLE_SELECTION, TABLE_ROW_POPIN, @@ -93,38 +92,6 @@ class TableHeaderRow extends TableRowBase { return true; } - _onfocusin(e: FocusEvent, eventOrigin: HTMLElement) { - if (eventOrigin !== this) { - return; - } - - const descriptions = [ - TableRowBase.i18nBundle.getText(TABLE_COLUMN_HEADER_ROW), - ]; - - const selectionDescription = this._selectionCellAriaDescription; - if (selectionDescription) { - descriptions.push(selectionDescription); - } - - this._visibleCells.forEach(cell => { - const cellDescription = getAccessibilityDescription(cell, true); - descriptions.push(cellDescription); - }); - - if (this._rowActionCount > 0) { - descriptions.push(TableRowBase.i18nBundle.getText(TABLE_ROW_ACTIONS)); - } - - updateInvisibleText(this, descriptions); - } - - _onfocusout(e: FocusEvent, eventOrigin: HTMLElement) { - if (eventOrigin !== this) { - updateInvisibleText(this); - } - } - get _isSelectable() { return this._isMultiSelect; } diff --git a/packages/main/src/TableHeaderRowTemplate.tsx b/packages/main/src/TableHeaderRowTemplate.tsx index f8cea97b98a3..5d68ca6e639e 100644 --- a/packages/main/src/TableHeaderRowTemplate.tsx +++ b/packages/main/src/TableHeaderRowTemplate.tsx @@ -15,8 +15,9 @@ export default function TableHeaderRowTemplate(this: TableHeaderRow, ariaColInde aria-label={this._i18nSelection} aria-description={this._selectionCellAriaDescription} aria-colindex={ariaColIndex++} + data-ui5-table-selection-cell data-ui5-table-cell-fixed - data-ui5-table-selection-component + data-ui5-table-acc-text="" > { !this._isMultiSelect ? <> @@ -42,16 +43,21 @@ export default function TableHeaderRowTemplate(this: TableHeaderRow, ariaColInde } - { this._visibleCells.map(cell => { + { this.cells.flatMap(cell => { + if (cell._popin) { + cell.ariaColIndex = null; + return []; + } cell.ariaColIndex = `${ariaColIndex++}`; - return ; + return []; })} { this._rowActionCount > 0 && + > +
{this._i18nRowActions}
+ } { this._popinCells.length > 0 && diff --git a/packages/main/src/TableRow.ts b/packages/main/src/TableRow.ts index 43744f2d9d39..a6ae9c9c896e 100644 --- a/packages/main/src/TableRow.ts +++ b/packages/main/src/TableRow.ts @@ -1,23 +1,17 @@ import { customElement, slot, property } from "@ui5/webcomponents-base/dist/decorators.js"; import { isEnter } from "@ui5/webcomponents-base/dist/Keys.js"; import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js"; -import type { UI5CustomEvent } from "@ui5/webcomponents-base"; -import { toggleAttribute, updateInvisibleText, getAccessibilityDescription } from "./TableUtils.js"; +import query from "@ui5/webcomponents-base/dist/decorators/query.js"; +import { toggleAttribute } from "./TableUtils.js"; import TableRowTemplate from "./TableRowTemplate.js"; import TableRowBase from "./TableRowBase.js"; import TableRowCss from "./generated/themes/TableRow.css.js"; import type TableCell from "./TableCell.js"; import type TableRowActionBase from "./TableRowActionBase.js"; import type Button from "./Button.js"; -import "@ui5/webcomponents-icons/dist/overflow.js"; +import type { UI5CustomEvent } from "@ui5/webcomponents-base"; import { - TABLE_ROW, - TABLE_ROW_INDEX, - TABLE_ROW_SELECTED, - TABLE_ROW_ACTIVE, - TABLE_ROW_NAVIGABLE, - TABLE_ROW_SINGLE_ACTION, - TABLE_ROW_MULTIPLE_ACTIONS, + TABLE_ROW_MULTIPLE_ACTIONS, TABLE_ROW_SINGLE_ACTION, } from "./generated/i18n/i18n-defaults.js"; /** @@ -123,6 +117,12 @@ class TableRow extends TableRowBase { @property({ type: Boolean }) movable = false; + @query("#popin-cell") + _popinCell?: TableCell; + + @query("#actions-cell") + _actionsCell?: TableCell; + onBeforeRendering() { super.onBeforeRendering(); toggleAttribute(this, "aria-current", this._renderNavigated && this.navigated, "true"); @@ -163,48 +163,8 @@ class TableRow extends TableRowBase { this.removeAttribute("_active"); } - _onfocusin(e: FocusEvent, eventOrigin: HTMLElement) { - if (eventOrigin !== this) { - return; - } - - const descriptions = [ - TableRowBase.i18nBundle.getText(TABLE_ROW), - TableRowBase.i18nBundle.getText(TABLE_ROW_INDEX, this.ariaRowIndex!, this._table!._ariaRowCount), - ]; - - if (this._isSelected) { - descriptions.push(TableRowBase.i18nBundle.getText(TABLE_ROW_SELECTED)); - } - - if (this._isNavigable) { - descriptions.push(TableRowBase.i18nBundle.getText(TABLE_ROW_NAVIGABLE)); - } else if (this.interactive) { - descriptions.push(TableRowBase.i18nBundle.getText(TABLE_ROW_ACTIVE)); - } - - [...this._visibleCells, ...this._popinCells].forEach(cell => { - const headerCell = cell._popin ? cell.getDomRef()! : (cell as TableCell)._headerCell; - const headerCellDescription = getAccessibilityDescription(headerCell, false); - const cellDescription = getAccessibilityDescription(cell, false); - descriptions.push(headerCellDescription); - descriptions.push(cellDescription); - }); - - const availableActionsCount = this._availableActionsCount; - if (availableActionsCount > 0) { - const rowActionBundleKey = availableActionsCount === 1 ? TABLE_ROW_SINGLE_ACTION : TABLE_ROW_MULTIPLE_ACTIONS; - descriptions.push(TableRowBase.i18nBundle.getText(rowActionBundleKey, availableActionsCount)); - } - - updateInvisibleText(this, descriptions); - } - - _onfocusout(e: FocusEvent, eventOrigin: HTMLElement) { + _onfocusout() { this.removeAttribute("_active"); - if (eventOrigin === this) { - updateInvisibleText(this); - } } _onOverflowButtonClick(e: UI5CustomEvent) { @@ -294,6 +254,14 @@ class TableRow extends TableRowBase { return !action.invisible && action._isInteractive; }).length + (this._hasOverflowActions ? 1 : 0); } + + get _actionCellAccText() { + const availableActionsCount = this._availableActionsCount; + if (availableActionsCount > 0) { + const bundleKey = availableActionsCount === 1 ? TABLE_ROW_SINGLE_ACTION : TABLE_ROW_MULTIPLE_ACTIONS; + return TableRowBase.i18nBundle.getText(bundleKey, availableActionsCount); + } + } } TableRow.define(); diff --git a/packages/main/src/TableRowTemplate.tsx b/packages/main/src/TableRowTemplate.tsx index 61679307fd16..cfb6ec3b1957 100644 --- a/packages/main/src/TableRowTemplate.tsx +++ b/packages/main/src/TableRowTemplate.tsx @@ -3,6 +3,7 @@ import CheckBox from "./CheckBox.js"; import RadioButton from "./RadioButton.js"; import Button from "./Button.js"; import ButtonDesign from "./types/ButtonDesign.js"; +import iconOverflow from "@ui5/webcomponents-icons/dist/overflow.js"; import type TableRow from "./TableRow.js"; export default function TableRowTemplate(this: TableRow, ariaColIndex: number = 1) { @@ -12,8 +13,9 @@ export default function TableRowTemplate(this: TableRow, ariaColIndex: number = { this._isMultiSelect ? } - { this._visibleCells.map(cell => { + { this.cells.flatMap(cell => { + if (cell._popin) { + cell.ariaColIndex = null; + return []; + } cell.ariaColIndex = `${ariaColIndex++}`; - return ; + return []; })} { this._rowActionCount > 0 && - + { this._flexibleActions.map(action => ( ))} { this._hasOverflowActions && - @@ -61,13 +69,19 @@ export default function TableRowTemplate(this: TableRow, ariaColIndex: number = } { this._renderNavigated && - + } { this._popinCells.length > 0 && - + { this._popinCells.map(cell => ( ))} diff --git a/packages/main/src/TableSelection.ts b/packages/main/src/TableSelection.ts index ab196e1126ff..9528a424b441 100644 --- a/packages/main/src/TableSelection.ts +++ b/packages/main/src/TableSelection.ts @@ -7,11 +7,11 @@ import customElement from "@ui5/webcomponents-base/dist/decorators/customElement import property from "@ui5/webcomponents-base/dist/decorators/property.js"; import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js"; import TableSelectionMode from "./types/TableSelectionMode.js"; +import { isSelectionCell, isHeaderSelectionCell, findRowInPath } from "./TableUtils.js"; import type Table from "./Table.js"; import type { ITableFeature } from "./Table.js"; import type TableRow from "./TableRow.js"; import type TableRowBase from "./TableRowBase.js"; -import { isSelectionCheckbox, isHeaderSelector, findRowInPath } from "./TableUtils.js"; /** * @class @@ -298,12 +298,12 @@ class TableSelection extends UI5Element implements ITableFeature { return; } - if (isHeaderSelector(e)) { + if (isHeaderSelectionCell(e)) { this._stopRangeSelection(); return; } - if (!isSelectionCheckbox(e)) { + if (!isSelectionCell(e)) { this._stopRangeSelection(); return; } diff --git a/packages/main/src/TableSelectionMulti.ts b/packages/main/src/TableSelectionMulti.ts index 64e46c7ecc78..7fe41f7812c4 100644 --- a/packages/main/src/TableSelectionMulti.ts +++ b/packages/main/src/TableSelectionMulti.ts @@ -1,7 +1,7 @@ import { customElement, property } from "@ui5/webcomponents-base/dist/decorators.js"; import TableSelectionBase from "./TableSelectionBase.js"; import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js"; -import { isSelectionCheckbox, isHeaderSelector, findRowInPath } from "./TableUtils.js"; +import { isSelectionCell, isHeaderSelectionCell, findRowInPath } from "./TableUtils.js"; import { isUpShift } from "@ui5/webcomponents-base/dist/Keys.js"; import type Table from "./Table.js"; import type TableRow from "./TableRow.js"; @@ -186,7 +186,7 @@ class TableSelectionMulti extends TableSelectionBase { } let description = ""; - const seperator = " . "; + const seperator = " "; const i18nBundle = (this._table.constructor as typeof Table).i18nBundle; if (this.headerSelector === "SelectAll") { description = i18nBundle.getText(TABLE_COLUMNHEADER_SELECTALL_DESCRIPTION); @@ -244,12 +244,12 @@ class TableSelectionMulti extends TableSelectionBase { return; } - if (isHeaderSelector(e)) { + if (isHeaderSelectionCell(e)) { this._stopRangeSelection(); return; } - if (!isSelectionCheckbox(e)) { + if (!isSelectionCell(e)) { this._stopRangeSelection(); return; } diff --git a/packages/main/src/TableUtils.ts b/packages/main/src/TableUtils.ts index ba9099a00665..a6386d39651f 100644 --- a/packages/main/src/TableUtils.ts +++ b/packages/main/src/TableUtils.ts @@ -1,32 +1,16 @@ import type Table from "./Table.js"; import type TableRow from "./TableRow.js"; -import type { AccessibilityInfo } from "@ui5/webcomponents-base"; -import I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; -import { getTabbableElements } from "@ui5/webcomponents-base/dist/util/TabbableElements.js"; - -import { - TABLE_ACC_STATE_EMPTY, - TABLE_ACC_STATE_REQUIRED, - TABLE_ACC_STATE_DISABLED, - TABLE_ACC_STATE_READONLY, - TABLE_CELL_CONTAINS, - TABLE_CELL_SINGLE_CONTROL, - TABLE_CELL_MULTIPLE_CONTROLS, -} from "./generated/i18n/i18n-defaults.js"; - -let invisibleText: HTMLElement; -const i18nBundle = new I18nBundle("@ui5/webcomponents/main"); const isInstanceOfTable = (obj: any): obj is Table => { return !!obj && "isTable" in obj && !!obj.isTable; }; -const isSelectionCheckbox = (e: Event) => { - return e.composedPath().some((el: EventTarget) => (el as HTMLElement).hasAttribute?.("data-ui5-table-selection-component")); +const isSelectionCell = (e: Event) => { + return e.composedPath().some((el: EventTarget) => (el as HTMLElement).hasAttribute?.("data-ui5-table-selection-cell")); }; -const isHeaderSelector = (e: Event) => { - return isSelectionCheckbox(e) && e.composedPath().some((el: EventTarget) => el instanceof HTMLElement && el.hasAttribute("ui5-table-header-row")); +const isHeaderSelectionCell = (e: Event) => { + return isSelectionCell(e) && e.composedPath().some((el: EventTarget) => el instanceof HTMLElement && el.hasAttribute("ui5-table-header-row")); }; const findRowInPath = (composedPath: Array) => { @@ -127,135 +111,10 @@ const isValidColumnWidth = (width: string | undefined): width is string => { return element.style.width !== ""; }; -/** - * Manages an invisible text element for accessibility and associates it with the given element via `aria-labelledby`. - * - * - Ensures a single invisible text element with a specific ID exists in the DOM. - * - Updates the text content of the invisible text element to the provided `texts`. - * - Adds or removes the invisible text element's ID from the target element's `aria-labelledby` attribute. - * - If no text is provided, disassociates the invisible text element from the target element. - * - * @param element The target HTMLElement to associate with the invisible text for accessibility. - * @param texts An optional array of strings to be joined and set as the invisible text content. - */ -const updateInvisibleText = (element: HTMLElement, texts: string[] = [], joiner: string = " . ") => { - const invisibleTextId = "ui5-table-invisible-text"; - if (!invisibleText || !invisibleText.isConnected) { - invisibleText = document.createElement("span"); - invisibleText.id = invisibleTextId; - invisibleText.ariaHidden = "true"; - invisibleText.style.display = "none"; - document.body.appendChild(invisibleText); - } - - let ariaLabelledBy = (element.getAttribute("aria-labelledby") || "").split(" ").filter(Boolean); - const invisibleTextAssociated = ariaLabelledBy.includes(invisibleTextId); - - const text = texts.filter(Boolean).join(joiner).trim(); - if (text && !invisibleTextAssociated) { - ariaLabelledBy.push(invisibleTextId); - } else if (!text && invisibleTextAssociated) { - ariaLabelledBy = ariaLabelledBy.filter(id => id !== invisibleTextId); - } - - invisibleText.textContent = text; - if (ariaLabelledBy.length > 0) { - element.setAttribute("aria-labelledby", ariaLabelledBy.join(" ")); - } else { - element.removeAttribute("aria-labelledby"); - } -}; - -const checkVisibility = (element: HTMLElement): boolean => { - return element.checkVisibility() || getComputedStyle(element).display === "contents"; -}; - -const getDefaultAccessibilityChildren = (element: Node, _nodes: Node[] = []): Node[] => { - element.childNodes.forEach(child => { - if (child.nodeType === Node.TEXT_NODE) { - _nodes.push(child); - } else if (child instanceof HTMLElement) { - if (child.localName === "slot") { - const assignedNodes = (child as HTMLSlotElement).assignedNodes(); - _nodes.push(...assignedNodes); - return; - } - if (!checkVisibility(child)) { - return; - } - if (child.hasAttribute("data-ui5-acc-text") || "accessibilityInfo" in child) { - _nodes.push(child); - } else { - getDefaultAccessibilityChildren(child, _nodes); - } - } - }); - - return _nodes; -}; - -const getAccessibilityDescription = (element: Node, details: boolean = true, _isRootElement: boolean = true): string => { - if (element.nodeType === Node.TEXT_NODE) { - return (element as Text).data.trim(); - } - - if (!(element instanceof HTMLElement)) { - return ""; - } - - if (!_isRootElement && !checkVisibility(element)) { - return ""; - } - - if (element.dataset.ui5AccText) { - return element.dataset.ui5AccText; - } - - const parts = { self: [] as string[], children: [] as string[] }; - const accessibilityInfo = ((element as any).accessibilityInfo) as AccessibilityInfo | undefined; - - const type = accessibilityInfo ? accessibilityInfo.type : element.ariaRoleDescription; - type && parts.self.push(type); - - const description = accessibilityInfo ? accessibilityInfo.description : element.ariaLabel; - description && parts.self.push(description); - - if (details) { - const required = accessibilityInfo ? accessibilityInfo.required : element.ariaRequired; - required && parts.self.push(i18nBundle.getText(TABLE_ACC_STATE_REQUIRED)); - - const disabled = accessibilityInfo ? accessibilityInfo.disabled : element.ariaDisabled; - disabled && parts.self.push(i18nBundle.getText(TABLE_ACC_STATE_DISABLED)); - - const readOnly = accessibilityInfo ? accessibilityInfo.readonly : element.ariaReadOnly; - readOnly && parts.self.push(i18nBundle.getText(TABLE_ACC_STATE_READONLY)); - } - - const children = accessibilityInfo ? accessibilityInfo.children || [] : getDefaultAccessibilityChildren(element); - children.forEach(child => { - const childDescription = getAccessibilityDescription(child, details, false); - childDescription && parts.children.push(childDescription); - }); - - if (_isRootElement && details && parts.children.length > 0 && getTabbableElements(element).length > 0) { - const childrenDescription = parts.children.join(" "); - parts.children = [i18nBundle.getText(TABLE_CELL_CONTAINS, childrenDescription)]; - } - - const fullDescription = [...parts.self, ...parts.children].join(" ").trim(); - if (_isRootElement && fullDescription === "") { - const tabbables = getTabbableElements(element); - const emptyTextBundleKey = [TABLE_ACC_STATE_EMPTY, TABLE_CELL_SINGLE_CONTROL, TABLE_CELL_MULTIPLE_CONTROLS][Math.min(tabbables.length, 2)]; - return i18nBundle.getText(emptyTextBundleKey); - } - - return fullDescription; -}; - export { isInstanceOfTable, - isSelectionCheckbox, - isHeaderSelector, + isSelectionCell, + isHeaderSelectionCell, findRowInPath, findVerticalScrollContainer, scrollElementIntoView, @@ -263,6 +122,4 @@ export { throttle, toggleAttribute, isValidColumnWidth, - getAccessibilityDescription, - updateInvisibleText, }; diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties index 49297d5d9b9d..039460d82b0d 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -729,8 +729,6 @@ TABLE_NO_DATA=No Data TABLE_SINGLE_SELECTABLE=Single Selection Table #XACT: ARIA announcement for the table that allows multi selection TABLE_MULTI_SELECTABLE=Multi Selection Table -#XACT: accessibility text for announcing the cell content -TABLE_CELL_CONTAINS=Contains {0} #XACT: accessibility text for the cell that contains a single interactive element TABLE_CELL_SINGLE_CONTROL=Contains Control #XACT: accessibility text for the cell that contains multiple interactive elements diff --git a/packages/main/src/i18n/messagebundle_en.properties b/packages/main/src/i18n/messagebundle_en.properties index d53fa4879ca8..ad80fb7d4dac 100644 --- a/packages/main/src/i18n/messagebundle_en.properties +++ b/packages/main/src/i18n/messagebundle_en.properties @@ -480,10 +480,10 @@ TABLE_ROW_SELECTOR=Row Selector TABLE_NO_DATA=No Data TABLE_SINGLE_SELECTABLE=Single Selection Table TABLE_MULTI_SELECTABLE=Multi Selection Table -TABLE_COLUMNHEADER_SELECTALL_DESCRIPTION=Contains Select All Checkbox +TABLE_COLUMNHEADER_SELECTALL_DESCRIPTION=Select All Checkbox TABLE_COLUMNHEADER_SELECTALL_CHECKED=Checked TABLE_COLUMNHEADER_SELECTALL_NOT_CHECKED=Not Checked -TABLE_COLUMNHEADER_CLEARALL_DESCRIPTION=Contains Clear All Button +TABLE_COLUMNHEADER_CLEARALL_DESCRIPTION=Clear All Button TABLE_COLUMNHEADER_CLEARALL_DISABLED=Disabled TABLE_ROW_POPIN=Row Popin TABLE_MORE=More diff --git a/packages/main/src/themes/TableCell.css b/packages/main/src/themes/TableCell.css index 14c92c7a036b..ba513ae7aad2 100644 --- a/packages/main/src/themes/TableCell.css +++ b/packages/main/src/themes/TableCell.css @@ -4,14 +4,15 @@ align-items: center; } -:host([_popin]) .popin-header { +:host([_popin]) #popin-header { color: var(--sapContent_LabelColor); } -.popin-colon { +#popin-colon { padding-inline-end: 0.5rem; + white-space: pre; } -.popin-header { +#popin-header { display: contents; } diff --git a/packages/main/src/themes/TableHeaderRow.css b/packages/main/src/themes/TableHeaderRow.css index 2bd388c6ad36..2cd7a4615717 100644 --- a/packages/main/src/themes/TableHeaderRow.css +++ b/packages/main/src/themes/TableHeaderRow.css @@ -25,3 +25,8 @@ width: var(--_ui5_checkbox_inner_width_height); height: var(--_ui5_checkbox_inner_width_height); } + +#actions-cell-content { + position: absolute; + clip: rect(0, 0, 0, 0); +} diff --git a/packages/main/src/types/TableGrowingMode.ts b/packages/main/src/types/TableGrowingMode.ts index 8ac3f94b5c7b..f07539c016ea 100644 --- a/packages/main/src/types/TableGrowingMode.ts +++ b/packages/main/src/types/TableGrowingMode.ts @@ -2,7 +2,6 @@ * Growing mode of the <ui5-table> component. * * @public - * @experimental */ enum TableGrowingMode { /** diff --git a/packages/main/test/pages/Table.html b/packages/main/test/pages/Table.html index a0f097e4f538..ddba687a662f 100644 --- a/packages/main/test/pages/Table.html +++ b/packages/main/test/pages/Table.html @@ -51,9 +51,9 @@ Notebook Basic 15
HT-1000
Very Best Screens -
30 x 18 x 3 cm
+
30 x 18 x 3 cmButton
4.2 KG - 956 EUR + 956 EUR
Notebook Basic 16
HT-1001
diff --git a/vite.config.js b/vite.config.js index 1fbd4caf93bc..f30d0453b158 100644 --- a/vite.config.js +++ b/vite.config.js @@ -107,9 +107,6 @@ export default defineConfig(async () => { build: { emptyOutDir: false, }, - server: { - host: true, - }, plugins: [ await virtualIndex(), tsconfigPaths(), From 2555e65fc4074e9b2bb304fdd8b88150bfdb69f9 Mon Sep 17 00:00:00 2001 From: Cahit Guerguec Date: Thu, 2 Oct 2025 14:32:55 +0200 Subject: [PATCH 3/3] refactor(ui5-table): enhance with custom announcements - Introduce TableCustomAnnouncement extension for accessibility announcements. - Refactor Table elements to improve accessibility descriptions and focus handling. - Modify TableRowTemplate to include accessibility attributes and improve structure. - Adjust CSS for TableCell and TableHeaderRow to support new accessibility features. - Update i18n message bundles for clearer accessibility text. - Add Cypress tests for custom announcements. --- .../specs/TableCustomAnnouncement.cy.tsx | 101 +++++++++++------- .../main/cypress/specs/TableSelections.cy.tsx | 27 +++-- packages/main/src/TableSelectionMulti.ts | 4 +- .../main/src/i18n/messagebundle.properties | 2 - .../main/src/i18n/messagebundle_en.properties | 2 +- 5 files changed, 87 insertions(+), 49 deletions(-) diff --git a/packages/main/cypress/specs/TableCustomAnnouncement.cy.tsx b/packages/main/cypress/specs/TableCustomAnnouncement.cy.tsx index 615a1dc9a547..55d6aa98edd4 100644 --- a/packages/main/cypress/specs/TableCustomAnnouncement.cy.tsx +++ b/packages/main/cypress/specs/TableCustomAnnouncement.cy.tsx @@ -12,6 +12,29 @@ import Button from "../../src/Button.js"; import add from "@ui5/webcomponents-icons/dist/add.js"; import edit from "@ui5/webcomponents-icons/dist/edit.js"; import "../../src/TableSelectionSingle.js"; +import * as Translations from "../../src/generated/i18n/i18n-defaults.js"; + +const { + TABLE_CELL_MULTIPLE_CONTROLS: { defaultText: CONTAINS_CONTROLS }, + TABLE_CELL_SINGLE_CONTROL: { defaultText: CONTAINS_CONTROL }, + TABLE_ACC_STATE_READONLY: { defaultText: READONLY }, + TABLE_ACC_STATE_DISABLED: { defaultText: DISABLED }, + TABLE_ACC_STATE_REQUIRED: { defaultText: REQUIRED }, + TABLE_ROW_SINGLE_ACTION: { defaultText: ONE_ROW_ACTION }, + TABLE_ROW_MULTIPLE_ACTIONS: { defaultText: MULTIPLE_ACTIONS }, + TABLE_ACC_STATE_EMPTY: { defaultText: EMPTY }, + TABLE_GENERATED_BY_AI: { defaultText: GENERATED_BY_AI }, + TABLE_ROW_ACTIONS: { defaultText: ROW_ACTIONS }, + TABLE_COLUMNHEADER_SELECTALL_DESCRIPTION: { defaultText: SELECT_ALL_CHECKBOX }, + TABLE_COLUMNHEADER_SELECTALL_CHECKED: { defaultText: CHECKED }, + TABLE_COLUMNHEADER_SELECTALL_NOT_CHECKED: { defaultText: NOT_CHECKED }, + TABLE_COLUMNHEADER_CLEARALL_DESCRIPTION: { defaultText: CLEAR_ALL_BUTTON }, + TABLE_SELECTION: { defaultText: SELECTION }, + TABLE_COLUMN_HEADER_ROW: { defaultText: COLUMN_HEADER_ROW }, + TABLE_ROW_SELECTED: { defaultText: SELECTED }, + TABLE_ROW_NAVIGABLE: { defaultText: NAVIGABLE }, + TABLE_ROW_ACTIVE: { defaultText: ACTIVE }, +} = Translations; describe("Cell Custom Announcement - More details", () => { beforeEach(() => { @@ -72,48 +95,49 @@ describe("Cell Custom Announcement - More details", () => { checkAnnouncement("Row1Cell1"); cy.realPress("ArrowRight"); // second cell focused - checkAnnouncement("Contains Control"); + checkAnnouncement(CONTAINS_CONTROL); cy.get("@row1Input2").invoke("removeAttr", "hidden"); - checkAnnouncement("Contains Controls", true); + checkAnnouncement(CONTAINS_CONTROLS, true); cy.get("@row1Input1").invoke("attr", "data-ui5-table-acc-text", "Input with custom accessibility text"); - checkAnnouncement("Input with custom accessibility text . Contains Controls", true); + checkAnnouncement(`Input with custom accessibility text . ${CONTAINS_CONTROLS}`, true); cy.realPress("ArrowRight"); // third cell focused - checkAnnouncement("Empty"); + checkAnnouncement(EMPTY); cy.get("@row1Div").invoke("attr", "tabindex", "0"); cy.get("@row1Div").invoke("css", "width", "150px"); cy.get("@row1Div").focus(); cy.realPress("F2"); - checkAnnouncement("Contains Control", true); + checkAnnouncement(CONTAINS_CONTROL, true); cy.realPress("ArrowRight"); // fourth cell focused - checkAnnouncement("Row1Cell3 . Contains Control"); + checkAnnouncement(`Row1Cell3 . ${CONTAINS_CONTROL}`); cy.document().then((doc) => { const row1Button = doc.getElementById("row1-button") as Button; cy.stub(row1Button, "accessibilityInfo").get(() => ({ type: "Button", - description: "Row1Cell4", + description: "Row1Cell3Button", required: true, disabled: true, readonly: true, })); }); - checkAnnouncement("Button Row1Cell4 Required Disabled Read Only . Contains Control", true); + checkAnnouncement(`Button Row1Cell3Button ${REQUIRED} ${DISABLED} ${READONLY} . ${CONTAINS_CONTROL}`, true); cy.get("@row1Button").invoke("attr", "data-ui5-table-acc-text", "Button with custom accessibility text"); - checkAnnouncement("Button with custom accessibility text . Contains Control", true); + checkAnnouncement(`Button with custom accessibility text . ${CONTAINS_CONTROL}`, true); + + cy.realPress("ArrowRight"); // ROW_ACTIONS cell + checkAnnouncement(Table.i18nBundle.getText(MULTIPLE_ACTIONS, 2)); - cy.realPress("ArrowRight"); // row actions cell - checkAnnouncement("2 row actions available"); cy.get("#row1-edit-action").invoke("remove"); - checkAnnouncement("1 row action available", true); + checkAnnouncement(ONE_ROW_ACTION, true); cy.get("#row1-add-action").invoke("remove"); - checkAnnouncement("Empty", true); + checkAnnouncement(EMPTY, true); cy.realPress("Home"); // selection cell focused checkAnnouncement(""); @@ -125,7 +149,7 @@ describe("Cell Custom Announcement - More details", () => { checkAnnouncement(""); cy.realPress("ArrowRight"); // first cell focused - checkAnnouncement("Header1 Generated by AI . Contains Control"); + checkAnnouncement(`Header1 ${GENERATED_BY_AI} . ${CONTAINS_CONTROL}`); cy.realPress("ArrowRight"); // second cell focused checkAnnouncement("Header2"); @@ -134,10 +158,10 @@ describe("Cell Custom Announcement - More details", () => { checkAnnouncement("Header3"); cy.realPress("ArrowRight"); // forth cell focused - checkAnnouncement("Empty"); + checkAnnouncement(EMPTY); cy.realPress("ArrowRight"); // forth cell focused - checkAnnouncement("Row Actions"); + checkAnnouncement(ROW_ACTIONS); cy.realPress("Home"); // selection cell focused checkAnnouncement(""); @@ -230,42 +254,41 @@ describe("Row Custom Announcement - Less details", () => { it("should announce table rows", () => { cy.get("@row1").realClick(); - checkAnnouncement("Row . 2 of 2 . Selected . Has Details . H1"); - checkAnnouncement("H1 . R1C1 . H2 . Contains Controls . H3 . Empty . H4 . C4 Button C4Button"); - checkAnnouncement("1 row action available"); + checkAnnouncement(`Row . 2 of 2 . ${SELECTED} . ${NAVIGABLE} . H1`); + checkAnnouncement(`H1 . R1C1 . H2 . ${CONTAINS_CONTROLS} . H3 . ${EMPTY} . H4 . C4 Button C4Button`); + checkAnnouncement(ONE_ROW_ACTION); cy.get("#selection").invoke("attr", "selected", ""); - checkAnnouncement("Row . 2 of 2 . Has Details", true); + checkAnnouncement(`Row . 2 of 2 . ${NAVIGABLE}`, true); cy.get("#row1-nav-action").invoke("prop", "interactive", true); - checkAnnouncement("Row . 2 of 2 . Is Active . H1", true); - checkAnnouncement("2 row actions available"); + checkAnnouncement(`Row . 2 of 2 . ${ACTIVE} . H1`, true); + checkAnnouncement(Table.i18nBundle.getText(MULTIPLE_ACTIONS, 2)); cy.get("@row1").invoke("prop", "interactive", false); - checkAnnouncement("Row . 2 of 2 . H1", true); + checkAnnouncement(`Row . 2 of 2 . H1`, true); cy.get("#table0").invoke("css", "width", "301px"); - checkAnnouncement("Row . 2 of 2 . H1", true); - checkAnnouncement("H1 . R1C1 . H2 . Contains Controls . H3 . Empty . H4Popin . C4 Button C4Button"); - checkAnnouncement("2 row actions available"); + checkAnnouncement(`Row . 2 of 2 . H1`, true); + checkAnnouncement(`H1 . R1C1 . H2 . ${CONTAINS_CONTROLS} . H3 . ${EMPTY} . H4Popin . C4 Button C4Button`); cy.get("#Header3").invoke("prop", "popinHidden", true); - checkAnnouncement("H1 . R1C1 . H2 . Contains Controls . H4Popin . C4 Button C4Button", true); + checkAnnouncement(`H1 . R1C1 . H2 . ${CONTAINS_CONTROLS} . H4Popin . C4 Button C4Button`, true); cy.get("#row1-nav-action").invoke("remove"); cy.get("#row1-add-action").invoke("remove"); - checkAnnouncement("Row . 2 of 2 . H1 . R1C1 . H2 . Contains Controls . H4Popin . C4 Button C4Button", true, "equal"); + checkAnnouncement(`Row . 2 of 2 . H1 . R1C1 . H2 . ${CONTAINS_CONTROLS} . H4Popin . C4 Button C4Button`, true, "equal"); cy.realPress("ArrowRight"); // selection cell focused checkAnnouncement(""); cy.realPress("End"); // popin cell focused we need details - checkAnnouncement("H2 . Contains Controls . H4Popin Generated by AI . Contains Control . C4 Button C4Button Required Disabled Read Only . Contains Control"); + checkAnnouncement(`H2 . ${CONTAINS_CONTROLS} . H4Popin ${GENERATED_BY_AI} . ${CONTAINS_CONTROL} . C4 Button C4Button ${REQUIRED} ${DISABLED} ${READONLY} . ${CONTAINS_CONTROL}`); cy.realPress("Home"); // selection cell focused cy.realPress("Home"); // row focused cy.get("#table0").invoke("css", "width", "1000px"); - checkAnnouncement("Row . 2 of 2 . H1 . R1C1 . H2 . Contains Controls . H3 . Empty . H4 . C4 Button C4Button", true, "equal"); + checkAnnouncement(`Row . 2 of 2 . H1 . R1C1 . H2 . ${CONTAINS_CONTROLS} . H3 . ${EMPTY} . H4 . C4 Button C4Button`, true, "equal"); cy.realPress("ArrowUp"); // header row focused cy.get("@row1").invoke("remove"); @@ -277,18 +300,24 @@ describe("Row Custom Announcement - Less details", () => { it("should announce table header row", () => { cy.get("@row1").realClick(); cy.realPress("ArrowUp"); - checkAnnouncement("Column Header Row . Select All Checkbox Checked . H1 . H2 . H3 . H4 . Row Actions"); + checkAnnouncement(`${COLUMN_HEADER_ROW} . ${SELECT_ALL_CHECKBOX} ${CHECKED} . H1 . H2 . H3 . H4 . ${ROW_ACTIONS}`); cy.get("#table0").invoke("attr", "row-action-count", "0"); - checkAnnouncement("Column Header Row . Select All Checkbox Checked . H1 . H2 . H3 . H4", true, "equal"); + checkAnnouncement(`${COLUMN_HEADER_ROW} . ${SELECT_ALL_CHECKBOX} ${CHECKED} . H1 . H2 . H3 . H4`, true, "equal"); - cy.get("#selection").invoke("attr", "selected", ""); - checkAnnouncement("Column Header Row . Select All Checkbox Not Checked . H1 . H2 . H3 . H4", true, "equal"); + cy.get("#selection").invoke("prop", "selected", ""); + checkAnnouncement(`${COLUMN_HEADER_ROW} . ${SELECT_ALL_CHECKBOX} ${NOT_CHECKED} . H1 . H2 . H3 . H4`, true, "equal"); + + cy.get("#selection").invoke("attr", "header-selector", "ClearAll"); + checkAnnouncement(`${COLUMN_HEADER_ROW} . ${CLEAR_ALL_BUTTON} ${DISABLED} . H1 . H2 . H3 . H4`, true, "equal"); + + cy.get("#selection").invoke("prop", "selected", "Row1"); + checkAnnouncement(`${COLUMN_HEADER_ROW} . ${CLEAR_ALL_BUTTON} . H1 . H2 . H3 . H4`, true, "equal"); cy.get("#selection").invoke("remove"); - checkAnnouncement("Column Header Row . H1 . H2 . H3 . H4", true, "equal"); + checkAnnouncement(`${COLUMN_HEADER_ROW} . H1 . H2 . H3 . H4`, true, "equal"); cy.get("#table0").invoke("append", ''); - checkAnnouncement("Column Header Row . Selection . H1 . H2 . H3 . H4", true, "equal"); + checkAnnouncement(`${COLUMN_HEADER_ROW} . ${SELECTION} . H1 . H2 . H3 . H4`, true, "equal"); }); }); diff --git a/packages/main/cypress/specs/TableSelections.cy.tsx b/packages/main/cypress/specs/TableSelections.cy.tsx index 4e1a3981606b..d2c237b42d38 100644 --- a/packages/main/cypress/specs/TableSelections.cy.tsx +++ b/packages/main/cypress/specs/TableSelections.cy.tsx @@ -7,6 +7,17 @@ import TableCell from "../../src/TableCell.js"; import TableSelectionSingle from "../../src/TableSelectionSingle.js"; import TableSelectionMulti from "../../src/TableSelectionMulti.js"; import type TableSelectionBase from "../../src/TableSelectionBase.js"; +import * as Translations from "../../src/generated/i18n/i18n-defaults.js"; + +const { + TABLE_COLUMNHEADER_SELECTALL_DESCRIPTION: { defaultText: SELECT_ALL_CHECKBOX }, + TABLE_COLUMNHEADER_SELECTALL_CHECKED: { defaultText: CHECKED }, + TABLE_COLUMNHEADER_SELECTALL_NOT_CHECKED: { defaultText: NOT_CHECKED }, + TABLE_SELECT_ALL_ROWS: { defaultText: SELECT_ALL_ROWS }, + TABLE_DESELECT_ALL_ROWS: { defaultText: DESELECT_ALL_ROWS }, + TABLE_COLUMNHEADER_CLEARALL_DESCRIPTION: { defaultText: CLEAR_ALL_BUTTON }, + TABLE_ACC_STATE_DISABLED: { defaultText: DISABLED } +} = Translations; function mountTestpage(selectionMode: string) { cy.mount( @@ -377,10 +388,10 @@ describe("TableSelectionMulti", () => { }); it("updates the header row checkbox when rows are added or removed", () => { - cy.get("@headerRowSelectionCell").should("have.attr", "aria-description", "Select All Checkbox Checked"); + cy.get("@headerRowSelectionCell").should("have.attr", "aria-description", `${SELECT_ALL_CHECKBOX} ${CHECKED}`); cy.get("@headerRowSelectionCell").children().first().as("headerRowCheckBox"); cy.get("@headerRowCheckBox").should("have.attr", "checked"); - cy.get("@headerRowCheckBox").should("have.attr", "title", "Deselect All Rows"); + cy.get("@headerRowCheckBox").should("have.attr", "title", DESELECT_ALL_ROWS); cy.get("#table1").then($table => { $table.append( ` @@ -389,13 +400,13 @@ describe("TableSelectionMulti", () => { ` ); }); - cy.get("@headerRowSelectionCell").should("have.attr", "aria-description", "Select All Checkbox Not Checked"); + cy.get("@headerRowSelectionCell").should("have.attr", "aria-description", `${SELECT_ALL_CHECKBOX} ${NOT_CHECKED}`); cy.get("@headerRowCheckBox").should("not.have.attr", "checked"); - cy.get("@headerRowCheckBox").should("have.attr", "title", "Select All Rows"); + cy.get("@headerRowCheckBox").should("have.attr", "title", SELECT_ALL_ROWS); cy.get("#row3").invoke("remove"); - cy.get("@headerRowSelectionCell").should("have.attr", "aria-description", "Select All Checkbox Checked"); + cy.get("@headerRowSelectionCell").should("have.attr", "aria-description", `${SELECT_ALL_CHECKBOX} ${CHECKED}`); cy.get("@headerRowCheckBox").should("have.attr", "checked"); - cy.get("@headerRowCheckBox").should("have.attr", "title", "Deselect All Rows"); + cy.get("@headerRowCheckBox").should("have.attr", "title", DESELECT_ALL_ROWS); cy.get("#row2").invoke("remove"); cy.get("@headerRowCheckBox").should("have.attr", "checked"); cy.get("#row1").invoke("remove"); @@ -408,9 +419,9 @@ describe("TableSelectionMulti", () => { cy.get("@headerRowIcon").should("have.attr", "name", "clear-all"); cy.get("@headerRowIcon").should("have.attr", "mode", "Decorative"); cy.get("@headerRowIcon").should("have.attr", "show-tooltip"); - cy.get("@headerRowIcon").should("have.attr", "accessible-name", "Deselect All Rows"); + cy.get("@headerRowIcon").should("have.attr", "accessible-name", DESELECT_ALL_ROWS); cy.get("@headerRowIcon").should("have.attr", "design", hasSelection ? "Default" : "NonInteractive"); - cy.get("@headerRowSelectionCell").should("have.attr", "aria-description", hasSelection ? "Clear All Button" : "Clear All Button Disabled"); + cy.get("@headerRowSelectionCell").should("have.attr", "aria-description", hasSelection ? CLEAR_ALL_BUTTON : `${CLEAR_ALL_BUTTON} ${DISABLED}`); } cy.get("#selection").invoke("attr", "header-selector", "ClearAll"); diff --git a/packages/main/src/TableSelectionMulti.ts b/packages/main/src/TableSelectionMulti.ts index 7fe41f7812c4..dc60eaa9b302 100644 --- a/packages/main/src/TableSelectionMulti.ts +++ b/packages/main/src/TableSelectionMulti.ts @@ -12,7 +12,7 @@ import { TABLE_COLUMNHEADER_SELECTALL_CHECKED, TABLE_COLUMNHEADER_SELECTALL_NOT_CHECKED, TABLE_COLUMNHEADER_CLEARALL_DESCRIPTION, - TABLE_COLUMNHEADER_CLEARALL_DISABLED, + TABLE_ACC_STATE_DISABLED, } from "./generated/i18n/i18n-defaults.js"; /** @@ -193,7 +193,7 @@ class TableSelectionMulti extends TableSelectionBase { description += seperator + i18nBundle.getText(this.areAllRowsSelected() ? TABLE_COLUMNHEADER_SELECTALL_CHECKED : TABLE_COLUMNHEADER_SELECTALL_NOT_CHECKED); } else { description = i18nBundle.getText(TABLE_COLUMNHEADER_CLEARALL_DESCRIPTION); - description += this.getSelectedRows().length === 0 ? seperator + i18nBundle.getText(TABLE_COLUMNHEADER_CLEARALL_DISABLED) : ""; + description += this.getSelectedRows().length === 0 ? seperator + i18nBundle.getText(TABLE_ACC_STATE_DISABLED) : ""; } return description; } diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties index 039460d82b0d..9e2b48e6f5b1 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -741,8 +741,6 @@ TABLE_COLUMNHEADER_SELECTALL_CHECKED=Checked TABLE_COLUMNHEADER_SELECTALL_NOT_CHECKED=Not Checked #XACT: ARIA description for the selection column header when clear all button is shown TABLE_COLUMNHEADER_CLEARALL_DESCRIPTION=Clear All Button -#XACT: ARIA description for the selection column header when clear all button is disabled -TABLE_COLUMNHEADER_CLEARALL_DISABLED=Disabled #XACT: ARIA announcement of a table row TABLE_ROW=Row #XACT: Description for the popin containing column header diff --git a/packages/main/src/i18n/messagebundle_en.properties b/packages/main/src/i18n/messagebundle_en.properties index ad80fb7d4dac..9ed48550c196 100644 --- a/packages/main/src/i18n/messagebundle_en.properties +++ b/packages/main/src/i18n/messagebundle_en.properties @@ -480,7 +480,7 @@ TABLE_ROW_SELECTOR=Row Selector TABLE_NO_DATA=No Data TABLE_SINGLE_SELECTABLE=Single Selection Table TABLE_MULTI_SELECTABLE=Multi Selection Table -TABLE_COLUMNHEADER_SELECTALL_DESCRIPTION=Select All Checkbox +TABLE_COLUMNHEADER_SELECTALL_DESCRIPTION=Contains Select All Checkbox TABLE_COLUMNHEADER_SELECTALL_CHECKED=Checked TABLE_COLUMNHEADER_SELECTALL_NOT_CHECKED=Not Checked TABLE_COLUMNHEADER_CLEARALL_DESCRIPTION=Clear All Button