Skip to content

Commit a2d4b7f

Browse files
authored
fix(Form): improve a11y of ui5 web component inputs (#5846)
Fixes #5820
1 parent a5c0f6d commit a2d4b7f

File tree

9 files changed

+197
-36
lines changed

9 files changed

+197
-36
lines changed

.storybook/preview-head.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@
5555
padding: 0;
5656
}
5757

58+
.pseudoInvisibleText {
59+
font-size: 0;
60+
left: 0;
61+
position: absolute;
62+
top: 0;
63+
user-select: none;
64+
}
65+
5866
/* TODO remove this workaround as soon as https://github.com/storybookjs/storybook/issues/20497 is fixed */
5967
.docs-story > div > div[scale] {
6068
min-height: 20px;

packages/main/src/components/Form/Form.cy.tsx

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,35 @@ import { Form } from './index.js';
1010
import { cypressPassThroughTestsFactory, mountWithCustomTagName } from '@/cypress/support/utils';
1111

1212
const component = (
13-
<Form titleText={'Test form'}>
14-
{false}
15-
{null}
16-
{undefined}
17-
<FormGroup titleText={'Group 1'}>
18-
<FormItem label={'item 1'}>
19-
<Input data-testid="formInput" type={InputType.Text} />
20-
</FormItem>
21-
<FormItem label={'item 2'}>
22-
<Input type={InputType.Number} />
23-
</FormItem>
24-
</FormGroup>
25-
<FormGroup titleText={'Group 2'}>
26-
<FormItem label={'item 3'}>
27-
<Input data-testid="formInput2" type={InputType.Text} />
28-
</FormItem>
29-
<FormItem label={<Label>item 4</Label>}>
30-
<Input type={InputType.Number} id="test-id" />
31-
</FormItem>
32-
</FormGroup>
33-
</Form>
13+
<>
14+
<Form titleText={'Test form'}>
15+
{false}
16+
{null}
17+
{undefined}
18+
<FormGroup titleText={'Group 1'}>
19+
<FormItem label={'item 1'}>
20+
<Input data-testid="formInput" type={InputType.Text} />
21+
</FormItem>
22+
<FormItem label={'item 2'}>
23+
<Input type={InputType.Number} />
24+
</FormItem>
25+
</FormGroup>
26+
<FormGroup titleText={'Group 2'}>
27+
<FormItem label={'item 3'}>
28+
<Input data-testid="formInput2" type={InputType.Text} />
29+
</FormItem>
30+
<FormItem label={<Label>item 4</Label>}>
31+
<Input type={InputType.Number} accessibleNameRef="test-id" />
32+
</FormItem>
33+
<FormItem label={<Label>item 4</Label>}>
34+
<Input type={InputType.Number} accessibleName="custom label" />
35+
</FormItem>
36+
</FormGroup>
37+
</Form>
38+
<span id="test-id" style={{ fontSize: 0, left: 0, position: 'absolute', top: 0, userSelect: 'none' }}>
39+
custom label
40+
</span>
41+
</>
3442
);
3543

3644
const ConditionRenderingExample = () => {
@@ -119,16 +127,22 @@ describe('Form', () => {
119127
it('a11y labels', () => {
120128
cy.mount(component);
121129
for (let i = 1; i <= 3; i++) {
122-
cy.get('label').contains(`item ${i}`).should('exist').should('not.be.visible');
130+
cy.get('span').contains(`item ${i}`).should('exist').should('not.be.visible');
123131
cy.get('[ui5-label]').contains(`item ${i}`).should('be.visible');
124132
}
125133
// custom `Label`
126134
cy.findAllByText(`item 4`).eq(0).should('be.visible');
127135
cy.findAllByText(`item 4`).eq(1).should('exist').should('not.be.visible');
128136

129-
// custom id child of FormItem
130-
cy.get('#test-id').should('have.length', 1).should('be.visible');
131-
cy.get('[for="test-id"]').should('have.length', 1).should('not.be.visible');
137+
// custom accessibleNameRef of FormItem input
138+
cy.get('#test-id').should('have.length', 1).should('not.be.visible');
139+
cy.get('[accessible-name-ref="test-id"]').should('have.length', 1).should('be.visible');
140+
141+
// custom accessibleName of FormItem input
142+
cy.get('[accessible-name="custom label"]')
143+
.should('have.length', 1)
144+
.should('be.visible')
145+
.should('not.have.attr', 'accessible-name-ref');
132146
});
133147

134148
it('FilterItem: doesnt crash with portal as child', () => {

packages/main/src/components/Form/Form.mdx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,62 @@ const FormComponent = (props) => {
253253

254254
</details>
255255

256+
## Custom Screen Reader Announcements
257+
258+
In general we recommend using the default announcements of the Form, but if you need to adjust them anyway, then this example might help avoiding some of the typical pitfalls.
259+
260+
<Canvas of={ComponentStories.CustomLabel} sourceState="none" />
261+
262+
### Code
263+
264+
<details>
265+
266+
<summary>Show Code</summary>
267+
268+
```jsx
269+
function FormComponent() {
270+
const uniqueId = useId();
271+
return (
272+
<Form
273+
titleText="Not announced (because of `aria-label` of the `Form`)"
274+
aria-label="Custom announcement of the form title via aria-label"
275+
>
276+
<FormGroup titleText="Default Group Announcement">
277+
<FormItem label={<Label>Default announcement with custom Label</Label>}>
278+
<Input />
279+
</FormItem>
280+
</FormGroup>
281+
<FormGroup titleText="Not announced (because of `accessibleName` of the `Input`)">
282+
<FormItem label={<Label>Not announced (because of `accessibleName` of the `Input`)</Label>}>
283+
<Input accessibleName="Custom announcement via accessibleName prop" />
284+
</FormItem>
285+
</FormGroup>
286+
<FormGroup titleText="Not announced (because of `accessibleNameRef` of the `Input`)">
287+
<FormItem label={<Label>Not announced (because of `accessibleNameRef` of the `Input`)</Label>}>
288+
<Input accessibleNameRef={`${uniqueId}-input1`} />
289+
<span id={`${uniqueId}-input1`} className="pseudoInvisibleText">
290+
Custom announcement via accessibleNameRef prop
291+
</span>
292+
</FormItem>
293+
</FormGroup>
294+
<FormGroup
295+
titleText="Announced (because of `accessibleNameRef` of the `Input` and linking id)"
296+
id={`${uniqueId}-group`}
297+
>
298+
<FormItem label={<Label>Not announced (because of `accessibleNameRef` of the `Input`)</Label>}>
299+
<Input accessibleNameRef={`${uniqueId}-group ${uniqueId}-input2`} />
300+
<span id={`${uniqueId}-input2`} className="pseudoInvisibleText">
301+
Custom announcement via accessibleNameRef prop
302+
</span>
303+
</FormItem>
304+
</FormGroup>
305+
</Form>
306+
);
307+
}
308+
```
309+
310+
</details>
311+
256312
<Markdown>{SubcomponentsSection}</Markdown>
257313

258314
## Form Group

packages/main/src/components/Form/Form.stories.tsx

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Meta, StoryObj } from '@storybook/react';
2-
import { useReducer } from 'react';
2+
import { useId, useReducer } from 'react';
33
import {
44
Button,
55
CheckBox,
@@ -309,7 +309,7 @@ export const DisplayEditMode: Story = {
309309
}
310310
};
311311

312-
export const FormWithOneGroup: Story = {
312+
export const FormItemsWithoutGroup: Story = {
313313
args: {
314314
titleText: 'Address',
315315
columnsM: 2,
@@ -376,3 +376,46 @@ export const FormWithOneGroup: Story = {
376376
);
377377
}
378378
};
379+
380+
export const CustomLabel: Story = {
381+
name: 'Custom Label (a11y)',
382+
render() {
383+
const uniqueId = useId();
384+
return (
385+
<Form
386+
titleText="Not announced (because of `aria-label` of the `Form`)"
387+
aria-label="Custom announcement of the form title via aria-label"
388+
>
389+
<FormGroup titleText="Default Group Announcement">
390+
<FormItem label={<Label>Default announcement with custom Label</Label>}>
391+
<Input />
392+
</FormItem>
393+
</FormGroup>
394+
<FormGroup titleText="Not announced (because of `accessibleName` of the `Input`)">
395+
<FormItem label={<Label>Not announced (because of `accessibleName` of the `Input`)</Label>}>
396+
<Input accessibleName="Custom announcement via accessibleName prop" />
397+
</FormItem>
398+
</FormGroup>
399+
<FormGroup titleText="Not announced (because of `accessibleNameRef` of the `Input`)">
400+
<FormItem label={<Label>Not announced (because of `accessibleNameRef` of the `Input`)</Label>}>
401+
<Input accessibleNameRef={`${uniqueId}-input1`} />
402+
<span id={`${uniqueId}-input1`} className="pseudoInvisibleText">
403+
Custom announcement via accessibleNameRef prop
404+
</span>
405+
</FormItem>
406+
</FormGroup>
407+
<FormGroup
408+
titleText="Announced (because of `accessibleNameRef` of the `Input` and linking id)"
409+
id={`${uniqueId}-group`}
410+
>
411+
<FormItem label={<Label>Not announced (because of `accessibleNameRef` of the `Input`)</Label>}>
412+
<Input accessibleNameRef={`${uniqueId}-group ${uniqueId}-input2`} />
413+
<span id={`${uniqueId}-input2`} className="pseudoInvisibleText">
414+
Custom announcement via accessibleNameRef prop
415+
</span>
416+
</FormItem>
417+
</FormGroup>
418+
</Form>
419+
);
420+
}
421+
};

packages/main/src/components/Form/index.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,16 @@ export interface FormPropTypes extends CommonProps {
105105

106106
/**
107107
* The `Form` component arranges labels and fields into groups and rows. There are different ways to visualize forms for different screen sizes.
108-
* It is possible to change the alignment of all labels by setting the CSS `align-items` property, per default all labels are centered.
108+
* It is possible to change the alignment of all labels by setting the CSS `align-items` property. By default, labels are centered when `labelSpan` is less than 12 and aligned to the start when `labelSpan` is equal to 12."
109+
*
110+
* __Accessibility features:__
111+
*
112+
* The Form only supports announcing labels and groups by screen readers for UI5 Web Components inputs like `Input (ui5-input)`, `CheckBox (ui5-checkbox)`,`DatePicker (ui5-date-picker)`, etc.
113+
* For other inputs, this behavior must be implemented manually. Also, please note that when passing custom React components to the `FilterItem`, it's mandatory to pass through the `accessibleNameRef` prop, as otherwise the label won't be announced.
109114
*
110115
* __Note:__ The `Form` calculates its width based on the available space of its container. If the container also dynamically adjusts its width to its contents, you must ensure that you specify a fixed width, either for the container or for the `Form` itself. (e.g. when used inside a 'popover').
116+
*
117+
* __Note:__ It's not recommended mixing `FormGroup`s with standalone `FormItem`s, either only use FormItems inside a Form, or wrap all items in one or more groups.
111118
*/
112119
const Form = forwardRef<HTMLFormElement, FormPropTypes>((props, ref) => {
113120
const {
@@ -417,6 +424,7 @@ const Form = forwardRef<HTMLFormElement, FormPropTypes>((props, ref) => {
417424
'--_ui5wcr_form_columns_l': columnsL,
418425
'--_ui5wcr_form_columns_xl': columnsXL
419426
}}
427+
aria-label={titleText}
420428
{...rest}
421429
>
422430
<div className={formClassNames}>

packages/main/src/components/FormGroup/FormGroupTitle.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,22 @@ import React, { type ElementType } from 'react';
44
import { classNames, styleData } from './FormGroupTitle.module.css.js';
55
import type { FormGroupPropTypes } from './index.js';
66

7-
export function FormGroupTitle({ as, className, titleText, style, ...rest }: Omit<FormGroupPropTypes, 'children'>) {
7+
export function FormGroupTitle({
8+
as,
9+
className,
10+
titleText,
11+
style,
12+
uniqueId,
13+
...rest
14+
}: Omit<FormGroupPropTypes, 'children'> & { uniqueId: string }) {
815
useStylesheet(styleData, FormGroupTitle.displayName);
916
const CustomTag = as as ElementType;
1017

1118
return (
1219
<CustomTag
20+
id={`${uniqueId}-group`}
1321
{...rest}
1422
className={clsx(classNames.title, className)}
15-
title={titleText}
16-
aria-label={titleText}
1723
data-component-name="FormGroupTitle"
1824
style={style}
1925
>

packages/main/src/components/FormGroup/index.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ export interface FormGroupPropTypes extends CommonProps<HTMLHeadingElement> {
2424
* @default "h5"
2525
*/
2626
as?: keyof HTMLElementTagNameMap;
27+
/**
28+
* Defines the [global `id` attribute](https://developer.mozilla.org/docs/Web/HTML/Global_attributes/id).
29+
*
30+
* __Note:__ Only override this prop if you want to implement screen reader announcements for groups manually.
31+
*/
32+
id?: HTMLHeadingElement['id'];
2733
}
2834

2935
/**
@@ -57,6 +63,7 @@ const FormGroup = (props: FormGroupPropTypes) => {
5763
<FormGroupTitle
5864
{...rest}
5965
titleText={titleText}
66+
uniqueId={uniqueId}
6067
style={{
6168
...style,
6269
display: titleText ? 'unset' : 'none',

packages/main/src/components/FormItem/FormItem.module.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,11 @@
2525
.lastGroupItem {
2626
margin-block-end: 1rem;
2727
}
28+
29+
.pseudoInvisibleText {
30+
font-size: 0;
31+
left: 0;
32+
position: absolute;
33+
top: 0;
34+
user-select: none;
35+
}

packages/main/src/components/FormItem/index.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export interface FormItemPropTypes {
2525
/**
2626
* Content of the FormItem.
2727
*
28+
* __Note:__ Only ui5 web component inputs such as `Input (ui5-input)`, `CheckBox (ui5-checkbox)`,`DatePicker (ui5-date-picker)`, etc. are supporting screen readers. For all other inputs the labels have to be set manually.
29+
*
2830
* __Note:__ Text, numbers and React portals are ignored.
2931
*/
3032
children: FormItemContent;
@@ -173,14 +175,23 @@ const FormItem = (props: FormItemPropTypes) => {
173175
// @ts-expect-error: type can't be string because of `isValidElement`
174176
if (isValidElement(child) && child.type && child.type.$$typeof !== Symbol.for('react.portal')) {
175177
const content = getContentForHtmlLabel(label);
176-
const childId = child?.props?.id;
178+
let accessibleNameRef: string | undefined;
179+
if (!child?.props.accessibleName) {
180+
accessibleNameRef =
181+
child?.props?.accessibleNameRef ?? `${layoutInfo.groupId}-group ${uniqueId}-${index}-label`;
182+
}
183+
177184
return (
178185
<Fragment key={`${content}-${uniqueId}-${index}`}>
179-
{/*@ts-expect-error: child is ReactElement*/}
180-
{cloneElement(child, { id: childId ?? `${uniqueId}-${index}` })}
181-
<label htmlFor={childId ?? `${uniqueId}-${index}`} style={{ display: 'none' }} aria-hidden={true}>
186+
{accessibleNameRef
187+
? cloneElement(child, {
188+
//@ts-expect-error: child is ReactElement
189+
accessibleNameRef
190+
})
191+
: child}
192+
<span className={classNames.pseudoInvisibleText} id={`${uniqueId}-${index}-label`}>
182193
{content}
183-
</label>
194+
</span>
184195
</Fragment>
185196
);
186197
}

0 commit comments

Comments
 (0)