Skip to content

Commit

Permalink
feat(ui5-list, ui5-tree): support accessible description (#10131)
Browse files Browse the repository at this point in the history
Related to: #6445

Description
This PR adds support for the aria-describedby and the aria-description attribute to the ui5-list and ui5-tree components. These attributes allows developers to provide a reference to an element that describes the list or a string value, which can be read by screen readers.

Example
aria-description
A property accessibleDescription is added to the ui5-list and ui5-tree components. When set, the value of this property will be used as the accessible description of the list.

<ui5-list accessible-description="This is a list of items">...</ui5-list>

<ui5-tree accessible-description="This is a tree of items">...</ui5-tree>
aria-describedby
A property accessibleDescriptionRef is added to the ui5-list and ui5-tree components. When set, the value of this property will be used as the id of the element that describes the list.

<p id="description">This component has description</p>

<ui5-list accessible-description-ref="description">...</ui5-list>

<ui5-tree accessible-description-ref="description">...</ui5-tree>
Changes
ui5-list and ui5-tree components now support the accessibleDescription and accessibleDescriptionRef properties
An already existing utility named AriaLabelHelper was extended with a new methods getEffectiveAriaDescriptionText and getAllAccessibleDescriptionRefTexts to handle the new properties, similar to the ones for the aria-label attribute
the name of the utility was changed to AccessibleTextsHelper to better reflect its purpose
the ui5-list now subscribes for changes of the referenced elements using the AccessibleTextsHelper to update the aria-description and aria-label attribute of the list as well the
ui5-tree only forwards the values to the internal ui5-tree-list which handles the property to attribute transformation
  • Loading branch information
dobrinyonkov authored Nov 12, 2024
1 parent 4a591a8 commit 45f0ffe
Show file tree
Hide file tree
Showing 34 changed files with 298 additions and 40 deletions.
49 changes: 49 additions & 0 deletions docs/2-advanced/09-accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ The mapping of the accessibility APIs to ARIA attributes is described in the fol
| ------------------------------ | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `accessibleName` | `aria-label` | Defines the text alternative of the component. If not provided, a default text alternative is set, if present. |
| `accessibleNameRef` | `aria-label` | Alternative for `aria-labelledby`. Receives ID (or many IDs) of the elements that serve as labels of the component. Those labels are passed as a concatenated string to the `aria-label` attribute. |
| `accessibleDescription` | `aria-description` | Defines the description of the component. |
| `accessibleDescriptionRef` | `aria-description` | Alternative for `aria-describedby`. Receives ID (or many IDs) of the elements that serve as descriptions of the component. Those descriptions are passed as a concatenated string to the `aria-describedby` attribute. |
| `accessibleRole` | `role` | Sets the accessible aria role of the component. |
| `accessibilityAttributes` | `aria-expanded`, `aria-haspopup`, `aria-controls`, etc. | An object of strings that defines several additional accessibility attribute values for customization depending on the use case. <br/> For composite components the object provides a way to enrich the accessibility of the different elements inside the component (for example in the `ui5-shellbar`). | |
| `required` | `aria-required` | Defines whether the component is required. |
Expand Down Expand Up @@ -187,6 +189,53 @@ The `accessibleNameRef` property is currently supported in most of the available

---

### accessibleDescription

Setting the property on the custom element as:
```html
<ui5-list accessible-description="List of items">
<ui5-li>Item 1</ui5-li>
<ui5-li>Item 2</ui5-li>
</ui5-list>
```

Will result in the shadow DOM as:
```html
<ul role="list" aria-description="List of items" ... >
...
</ul>
```

The `accessibleDescription` property is currently supported in:
* [List](https://sap.github.io/ui5-webcomponents/nightly/components/List/)
* [Tree](https://sap.github.io/ui5-webcomponents/nightly/components/Tree/)

---

### accessibleDescriptionRef

Setting the property on the custom element as:
```html
<p id="description">List of items</p>
<ui5-list accessible-description-ref="description">
<ui5-li>Item 1</ui5-li>
<ui5-li>Item 2</ui5-li>
</ui5-list>
```

Will result in the shadow DOM as:
```html
<ul role="list" aria-description="List of items" ... >
...
</ul>
```

The `accessibleDescriptionRef` property is currently supported in:
* [List](https://sap.github.io/ui5-webcomponents/nightly/components/List/)
* [Tree](https://sap.github.io/ui5-webcomponents/nightly/components/Tree/)

---

### accessibleRole

Setting the property on the custom element as:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const registeredElements = new WeakMap<UI5Element, RegisteredElement>();
type AccessibleElement = HTMLElement & {
accessibleNameRef?: string;
accessibleName?: string;
accessibleDescriptionRef?: string;
accessibleDescription?: string;
};

const observerOptions = {
Expand Down Expand Up @@ -49,11 +51,10 @@ const getEffectiveAriaLabelText = (el: HTMLElement) => {
*/
const getAllAccessibleNameRefTexts = (el: HTMLElement) => {
const ids = (el as AccessibleElement).accessibleNameRef?.split(" ") ?? [];
const owner = el.getRootNode() as HTMLElement;
let result = "";

ids.forEach((elementId: string, index: number) => {
const element = owner.querySelector(`[id='${elementId}']`);
const element = _getReferencedElementById(el, elementId);
const text = `${element && element.textContent ? element.textContent : ""}`;
if (text) {
result += text;
Expand All @@ -74,8 +75,12 @@ const _getAllAssociatedElementsFromDOM = (el: UI5Element): Array<HTMLElement> =>
set.add(itm);
});
// adding other elements that id is the same as accessibleNameRef value
const value = el["accessibleNameRef" as keyof typeof el] as string;
const ids = value?.split(" ") ?? [];
const ariaLabelledBy = el["accessibleNameRef" as keyof typeof el] as string;
const ariaDescribedBy = el["accessibleDescriptionRef" as keyof typeof el] as string;

const value = [ariaLabelledBy, ariaDescribedBy].filter(Boolean).join(" ");

const ids = value ? value.split(" ") : [];
ids.forEach(id => {
const refEl = _getReferencedElementById(el, id);
if (refEl) {
Expand All @@ -91,7 +96,7 @@ const _getAssociatedLabels = (el: HTMLElement): Array<HTMLElement> => {
};

const _getReferencedElementById = (el: HTMLElement, elementId: string): HTMLElement | null => {
return (el.getRootNode() as HTMLElement).querySelector<HTMLElement>(`[id='${elementId}']`);
return (el.getRootNode() as HTMLElement).querySelector<HTMLElement>(`[id='${elementId}']`) || document.getElementById(elementId);
};

/**
Expand All @@ -115,7 +120,9 @@ const getAssociatedLabelForTexts = (el: HTMLElement) => {

const _createInvalidationCallback = (el: UI5Element) => {
const invalidationCallback = (changeInfo: ChangeInfo) => {
if (!(changeInfo && changeInfo.type === "property" && changeInfo.name === "accessibleNameRef")) {
const isAccessibleNameRefChange = changeInfo && changeInfo.type === "property" && changeInfo.name === "accessibleNameRef";
const isAccessibleDescriptionRefChange = changeInfo && changeInfo.type === "property" && changeInfo.name === "accessibleDescriptionRef";
if (!isAccessibleNameRefChange && !isAccessibleDescriptionRefChange) {
return;
}
const registeredElement = registeredElements.get(el);
Expand Down Expand Up @@ -210,10 +217,44 @@ const deregisterUI5Element = (el: UI5Element) => {
registeredElements.delete(el);
};

const getEffectiveAriaDescriptionText = (el: HTMLElement) => {
const accessibleEl = el as AccessibleElement;

if (!accessibleEl.accessibleDescriptionRef) {
if (accessibleEl.accessibleDescription) {
return accessibleEl.accessibleDescription;
}

return undefined;
}

return getAllAccessibleDescriptionRefTexts(el);
};

const getAllAccessibleDescriptionRefTexts = (el: HTMLElement) => {
const ids = (el as AccessibleElement).accessibleDescriptionRef?.split(" ") ?? [];
let result = "";

ids.forEach((elementId: string, index: number) => {
const element = _getReferencedElementById(el, elementId);
const text = `${element && element.textContent ? element.textContent : ""}`;
if (text) {
result += text;
if (index < ids.length - 1) {
result += " ";
}
}
});

return result;
};

export {
getEffectiveAriaLabelText,
getAssociatedLabelForTexts,
registerUI5Element,
deregisterUI5Element,
getAllAccessibleNameRefTexts,
getEffectiveAriaDescriptionText,
getAllAccessibleDescriptionRefTexts,
};
2 changes: 1 addition & 1 deletion packages/compat/src/Table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
import getNormalizedTarget from "@ui5/webcomponents-base/dist/util/getNormalizedTarget.js";
import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js";
import { getLastTabbableElement, getTabbableElements } from "@ui5/webcomponents-base/dist/util/TabbableElements.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AriaLabelHelper.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js";
import debounce from "@ui5/webcomponents-base/dist/util/debounce.js";
import BusyIndicator from "@ui5/webcomponents/dist/BusyIndicator.js";
import CheckBox from "@ui5/webcomponents/dist/CheckBox.js";
Expand Down
2 changes: 1 addition & 1 deletion packages/fiori/src/IllustratedMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js";
import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js";
import type { ResizeObserverCallback } from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js";
import { getIllustrationDataSync, getIllustrationData } from "@ui5/webcomponents-base/dist/asset-registries/Illustrations.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AriaLabelHelper.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js";
import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
import Title from "@ui5/webcomponents/dist/Title.js";
import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js";
Expand Down
29 changes: 29 additions & 0 deletions packages/main/cypress/specs/Tree.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { html } from "lit";
import "../../src/Tree.js";
import "../../src/TreeItem.js";

describe("Tree Tests", () => {
it("tests accessibility properties forwarded to the list", () => {
cy.mount(html`
<ui5-tree
accessible-name="Tree"
accessible-name-ref="lblDesc1"
accessible-description="Description"
accessible-description-ref="lblDesc2"
></ui5-tree>
<div id="lblDesc1">Tree</div>
<div id="lblDesc2">Description</div>
`);

cy.get("[ui5-tree]")
.as("tree");

cy.get("@tree")
.shadow()
.find(".ui5-tree-root")
.should("have.attr", "accessible-name", "Tree")
.and("have.attr", "accessible-name-ref", "lblDesc1")
.and("have.attr", "accessible-description", "Description")
.and("have.attr", "accessible-description-ref", "lblDesc2");
});
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { html } from "lit";
import "../../../src/Label.js";
import "../../../src/Input.js";
import "../../../src/List.js";

describe("AriaLabelHelper", () => {
describe("AccessibilityTextsHelper", () => {
it("Label-for tests", () => {
cy.mount(html`
<ui5-input id="myInput" placeholder="input placeholder" class="field"></ui5-input>
Expand Down Expand Up @@ -277,4 +278,39 @@ describe("AriaLabelHelper", () => {
cy.get("@input")
.should("have.attr", "aria-label", "Desc1X Desc4X");
});

it("Tests accessibleDescription and accessibleDescriptionRef with ui5-list", () => {
cy.mount(html`
<ui5-label id="lblDesc1">Desc1</ui5-label>
<ui5-label id="lblDesc2">Desc2</ui5-label>
<ui5-list id="list" accessible-description-ref="lblDesc1 lblDesc2" accessible-description="Desc3"></ui5-list>
`);

cy.get("#list")
.shadow()
.find("ul")
.as("list");

// assert
cy.get("@list")
.should("have.attr", "aria-description", "Desc1 Desc2");

// act - update text of referenced label
cy.get("#lblDesc1")
.then($el => {
$el.get(0).innerHTML = `${$el.get(0).innerHTML}X`;
});

// assert
cy.get("@list")
.should("have.attr", "aria-description", "Desc1X Desc2");

// act - update accessible-description-ref
cy.get("#list")
.invoke("removeAttr", "accessible-description-ref");

// assert
cy.get("@list")
.should("have.attr", "aria-description", "Desc3");
});
});
2 changes: 1 addition & 1 deletion packages/main/src/Button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
isEscape,
isShift,
} from "@ui5/webcomponents-base/dist/Keys.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AriaLabelHelper.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js";
import type { AccessibilityAttributes, PassiveEventListenerObject } from "@ui5/webcomponents-base/dist/types.js";
import type { ITabbable } from "@ui5/webcomponents-base/dist/delegate/ItemNavigation.js";
import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
Expand Down
2 changes: 1 addition & 1 deletion packages/main/src/Card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import slot from "@ui5/webcomponents-base/dist/decorators/slot.js";
import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js";
import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js";
import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AriaLabelHelper.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js";
import CardTemplate from "./generated/templates/CardTemplate.lit.js";
import Icon from "./Icon.js";
import BusyIndicator from "./BusyIndicator.js";
Expand Down
2 changes: 1 addition & 1 deletion packages/main/src/Carousel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { renderFinished } from "@ui5/webcomponents-base/dist/Render.js";
import { isDesktop } from "@ui5/webcomponents-base/dist/Device.js";
import AnimationMode from "@ui5/webcomponents-base/dist/types/AnimationMode.js";
import { getAnimationMode } from "@ui5/webcomponents-base/dist/config/AnimationMode.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AriaLabelHelper.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js";
import {
CAROUSEL_OF_TEXT,
CAROUSEL_DOT_TEXT,
Expand Down
2 changes: 1 addition & 1 deletion packages/main/src/CheckBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js";
import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js";
import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
import ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AriaLabelHelper.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js";
import { isSpace, isEnter } from "@ui5/webcomponents-base/dist/Keys.js";
import "@ui5/webcomponents-icons/dist/accept.js";
import "@ui5/webcomponents-icons/dist/complete.js";
Expand Down
2 changes: 1 addition & 1 deletion packages/main/src/ComboBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js";
import ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js";
import { isPhone, isAndroid } from "@ui5/webcomponents-base/dist/Device.js";
import InvisibleMessageMode from "@ui5/webcomponents-base/dist/types/InvisibleMessageMode.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AriaLabelHelper.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js";
import announce from "@ui5/webcomponents-base/dist/util/InvisibleMessage.js";
import { getScopedVarName } from "@ui5/webcomponents-base/dist/CustomElementsScope.js";
import "@ui5/webcomponents-icons/dist/slim-arrow-down.js";
Expand Down
2 changes: 1 addition & 1 deletion packages/main/src/DatePicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import modifyDateBy from "@ui5/webcomponents-localization/dist/dates/modifyDateB
import getRoundedTimestamp from "@ui5/webcomponents-localization/dist/dates/getRoundedTimestamp.js";
import getTodayUTCTimestamp from "@ui5/webcomponents-localization/dist/dates/getTodayUTCTimestamp.js";
import ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AriaLabelHelper.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js";
import { submitForm } from "@ui5/webcomponents-base/dist/features/InputElementsFormSupport.js";
import type { IFormInputElement } from "@ui5/webcomponents-base/dist/features/InputElementsFormSupport.js";
import {
Expand Down
2 changes: 1 addition & 1 deletion packages/main/src/Input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
getAllAccessibleNameRefTexts,
registerUI5Element,
deregisterUI5Element,
} from "@ui5/webcomponents-base/dist/util/AriaLabelHelper.js";
} from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js";
import { getCaretPosition, setCaretPosition } from "@ui5/webcomponents-base/dist/util/Caret.js";
import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js";
import "@ui5/webcomponents-icons/dist/decline.js";
Expand Down
2 changes: 1 addition & 1 deletion packages/main/src/Link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import property from "@ui5/webcomponents-base/dist/decorators/property.js";
import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js";
import type { AccessibilityAttributes } from "@ui5/webcomponents-base/dist/types.js";
import { isSpace, isEnter } from "@ui5/webcomponents-base/dist/Keys.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AriaLabelHelper.js";
import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js";
import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js";
import type { I18nText } from "@ui5/webcomponents-base/dist/i18nBundle.js";
import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
Expand Down
1 change: 1 addition & 0 deletions packages/main/src/List.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
role="{{listAccessibleRole}}"
aria-label="{{ariaLabelTxt}}"
aria-labelledby="{{ariaLabelledBy}}"
aria-description="{{ariaDescriptionText}}"
>
<slot></slot>

Expand Down
Loading

0 comments on commit 45f0ffe

Please sign in to comment.