Skip to content

Commit ee114c5

Browse files
authored
fix(MessageView): improve accessibility (#7098)
Fixes #7076 Fixes #7087 Fixes #7059
1 parent b923669 commit ee114c5

File tree

8 files changed

+172
-38
lines changed

8 files changed

+172
-38
lines changed

packages/main/src/components/MessageView/MessageItem.module.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,11 @@
8989
color: var(--sapInformativeElementColor);
9090
}
9191
}
92+
93+
.pseudoInvisibleText {
94+
font-size: 0;
95+
left: 0;
96+
position: absolute;
97+
top: 0;
98+
user-select: none;
99+
}

packages/main/src/components/MessageView/MessageItem.tsx

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
11
'use client';
22

3+
import IconMode from '@ui5/webcomponents/dist/types/IconMode.js';
34
import ListItemType from '@ui5/webcomponents/dist/types/ListItemType.js';
5+
import WrappingType from '@ui5/webcomponents/dist/types/WrappingType.js';
46
import ValueState from '@ui5/webcomponents-base/dist/types/ValueState.js';
57
import iconArrowRight from '@ui5/webcomponents-icons/dist/slim-arrow-right.js';
6-
import { useStylesheet } from '@ui5/webcomponents-react-base';
8+
import { useI18nBundle, useStylesheet } from '@ui5/webcomponents-react-base';
79
import { clsx } from 'clsx';
810
import type { ReactNode } from 'react';
9-
import { Children, forwardRef, useContext, useEffect, useRef, useState } from 'react';
11+
import { Children, isValidElement, forwardRef, useContext, useEffect, useRef, useState } from 'react';
1012
import { FlexBoxAlignItems, FlexBoxDirection } from '../../enums/index.js';
13+
import { COUNTER, HAS_DETAILS } from '../../i18n/i18n-defaults.js';
1114
import { MessageViewContext } from '../../internal/MessageViewContext.js';
1215
import type { CommonProps } from '../../types/index.js';
1316
import { Icon } from '../../webComponents/Icon/index.js';
1417
import { Label } from '../../webComponents/Label/index.js';
15-
import type { ListItemCustomDomRef } from '../../webComponents/ListItemCustom/index.js';
18+
import type { LinkPropTypes } from '../../webComponents/Link/index.js';
19+
import type { ListItemCustomDomRef, ListItemCustomPropTypes } from '../../webComponents/ListItemCustom/index.js';
1620
import { ListItemCustom } from '../../webComponents/ListItemCustom/index.js';
1721
import { FlexBox } from '../FlexBox/index.js';
1822
import { classNames, styleData } from './MessageItem.module.css.js';
19-
import { getIconNameForType } from './utils.js';
23+
import { getIconNameForType, getValueStateMap } from './utils.js';
2024

2125
export interface MessageItemPropTypes extends CommonProps {
2226
/**
@@ -60,8 +64,10 @@ export interface MessageItemPropTypes extends CommonProps {
6064
const MessageItem = forwardRef<ListItemCustomDomRef, MessageItemPropTypes>((props, ref) => {
6165
const { titleText, subtitleText, counter, type = ValueState.Negative, children, className, ...rest } = props;
6266
const [isTitleTextOverflowing, setIsTitleTextIsOverflowing] = useState(false);
67+
const [titleTextStr, setTitleTextStr] = useState('');
6368
const titleTextRef = useRef<HTMLSpanElement>(null);
6469
const hasDetails = !!(children || isTitleTextOverflowing);
70+
const i18nBundle = useI18nBundle('@ui5/webcomponents-react');
6571

6672
useStylesheet(styleData, MessageItem.displayName);
6773

@@ -78,14 +84,14 @@ const MessageItem = forwardRef<ListItemCustomDomRef, MessageItemPropTypes>((prop
7884

7985
const handleListItemClick = (e) => {
8086
if (hasDetails) {
81-
selectMessage(props);
87+
selectMessage({ ...props, titleTextStr });
8288
if (typeof rest.onClick === 'function') {
8389
rest.onClick(e);
8490
}
8591
}
8692
};
8793

88-
const handleKeyDown = (e) => {
94+
const handleKeyDown: ListItemCustomPropTypes['onKeyDown'] = (e) => {
8995
if (typeof rest.onKeyDown === 'function') {
9096
rest.onKeyDown(e);
9197
}
@@ -108,7 +114,6 @@ const MessageItem = forwardRef<ListItemCustomDomRef, MessageItemPropTypes>((prop
108114
isChildOverflowing = firstChild.scrollWidth > firstChild.clientWidth;
109115
}
110116
}
111-
112117
setIsTitleTextIsOverflowing(isTargetOverflowing || isChildOverflowing);
113118
});
114119
if (!hasChildren && titleTextRef.current) {
@@ -119,11 +124,20 @@ const MessageItem = forwardRef<ListItemCustomDomRef, MessageItemPropTypes>((prop
119124
};
120125
}, [hasChildren]);
121126

127+
useEffect(() => {
128+
if (typeof titleText === 'string') {
129+
setTitleTextStr(titleText);
130+
} else if (isValidElement(titleText) && typeof (titleText.props as LinkPropTypes)?.children === 'string') {
131+
// @ts-expect-error: props.children is available and a string
132+
setTitleTextStr(titleText.props.children);
133+
}
134+
}, [titleText]);
135+
122136
return (
123137
<ListItemCustom
124138
onClick={handleListItemClick}
125139
onKeyDown={handleKeyDown}
126-
data-title={titleText}
140+
data-title={titleTextStr}
127141
data-type={type}
128142
type={hasDetails ? ListItemType.Active : ListItemType.Inactive}
129143
{...rest}
@@ -132,7 +146,7 @@ const MessageItem = forwardRef<ListItemCustomDomRef, MessageItemPropTypes>((prop
132146
>
133147
<FlexBox alignItems={FlexBoxAlignItems.Center} className={messageClasses}>
134148
<div className={classNames.iconContainer}>
135-
<Icon name={getIconNameForType(type as ValueState)} className={classNames.icon} />
149+
<Icon name={getIconNameForType(type as ValueState)} className={classNames.icon} mode={IconMode.Decorative} />
136150
</div>
137151
<FlexBox
138152
direction={FlexBoxDirection.Column}
@@ -143,10 +157,22 @@ const MessageItem = forwardRef<ListItemCustomDomRef, MessageItemPropTypes>((prop
143157
{titleText}
144158
</span>
145159
)}
146-
{titleText && subtitleText && <Label className={classNames.subtitle}>{subtitleText}</Label>}
160+
{titleText && subtitleText && (
161+
<Label className={classNames.subtitle} wrappingType={WrappingType.None}>
162+
{subtitleText}
163+
</Label>
164+
)}
147165
</FlexBox>
148-
{counter != null && <span className={classNames.counter}>{counter}</span>}
149-
{hasDetails && <Icon className={classNames.navigation} name={iconArrowRight} />}
166+
{counter != null && (
167+
<span className={classNames.counter} aria-label={`. ${i18nBundle.getText(COUNTER)} ${counter}`}>
168+
{counter}
169+
</span>
170+
)}
171+
{hasDetails && <Icon className={classNames.navigation} name={iconArrowRight} mode={IconMode.Decorative} />}
172+
{hasDetails && <span className={classNames.pseudoInvisibleText}>. {i18nBundle.getText(HAS_DETAILS)}</span>}
173+
{type !== ValueState.None && type !== ValueState.Information && (
174+
<span className={classNames.pseudoInvisibleText}>. {getValueStateMap(i18nBundle)[type]}</span>
175+
)}
150176
</FlexBox>
151177
</ListItemCustom>
152178
);

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

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,20 +48,21 @@ describe('MessageView', () => {
4848
});
4949
cy.get('[data-title="Success"]')
5050
.next()
51-
.should('have.text', 'Information')
51+
// Information and None don't have a status screen reader announcement
52+
.should('have.text', 'Information. Has Details')
5253
.next()
5354
.should('have.attr', 'header-text', 'Group1')
5455
.children()
5556
.first()
56-
.should('have.text', 'Error')
57+
.should('have.text', 'Error. Has Details. Error')
5758
.next()
58-
.should('have.text', 'Warning')
59+
.should('have.text', 'Warning. Has Details. Warning')
5960
.parent()
6061
.next()
6162
.should('have.attr', 'header-text', 'Group2')
6263
.children()
6364
.first()
64-
.should('have.text', 'None');
65+
.should('have.text', 'None. Has Details');
6566

6667
['error', 'alert', 'sys-enter-2', 'information'].forEach((btn, index, arr) => {
6768
cy.log(`SegmentedButton click - ${btn}`);
@@ -181,7 +182,7 @@ describe('MessageView', () => {
181182
titleText={
182183
<Link wrappingType={WrappingType.None}>
183184
Long Error Message Type without children/details including a Link as `titleText` which has
184-
wrappingType="None" applied. - The details view is only available if the `titleText` is not fully visible.
185+
wrappingType='None' applied. - The details view is only available if the `titleText` is not fully visible.
185186
It is NOT recommended to use long titles!
186187
</Link>
187188
}
@@ -200,14 +201,23 @@ describe('MessageView', () => {
200201
);
201202

202203
cy.get('[name="slim-arrow-right"]').should('be.visible').and('have.length', 2);
203-
204204
cy.findByTestId('item1').click();
205205
cy.get('@select').should('have.been.calledOnce');
206-
cy.get('[name="slim-arrow-left"]').should('be.visible').and('have.length', 1).click();
207206

207+
cy.get('[name="slim-arrow-left"]').should('be.visible').and('have.length', 1);
208+
cy.focused().should('have.attr', 'aria-label', 'Navigate Back').click();
209+
210+
cy.focused()
211+
.parent()
212+
.should(
213+
'have.attr',
214+
'data-title',
215+
`Long Error Message Type without children/details including a Link as \`titleText\` which has wrappingType='None' applied. - The details view is only available if the \`titleText\` is not fully visible. It is NOT recommended to use long titles!`
216+
);
208217
cy.findByTestId('item2').click();
209218
cy.get('@select').should('have.been.calledTwice');
210-
cy.get('[name="slim-arrow-left"]').should('be.visible').and('have.length', 1).click();
219+
cy.get('[name="slim-arrow-left"]').should('be.visible').and('have.length', 1);
220+
cy.get('[accessible-name="Navigate Back"]').should('be.focused').click();
211221

212222
cy.findByTestId('item3').click();
213223
cy.get('@select').should('have.been.calledTwice');

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const meta = {
3838
children: [
3939
<MessageItem
4040
key={1}
41-
titleText={'Error Message Type'}
41+
titleText={'Error Message Type (1)'}
4242
subtitleText={'Some bad error occurred'}
4343
type={ValueState.Negative}
4444
counter={1}
@@ -75,11 +75,16 @@ const meta = {
7575
>
7676
Informative message
7777
</MessageItem>,
78-
<MessageItem key={7} titleText={'Error Message Type'} type={ValueState.Negative} counter={3} />,
78+
<MessageItem key={7} titleText={'Error Message Type (2)'} type={ValueState.Negative} counter={3} />,
7979
<MessageItem
8080
key={8}
8181
titleText={
82-
<Link wrappingType={WrappingType.None}>
82+
<Link
83+
wrappingType={WrappingType.None}
84+
onClick={(e) => {
85+
e.stopPropagation();
86+
}}
87+
>
8388
Long Error Message Type without children/details including a Link as `titleText` which has
8489
wrappingType="None" applied. - The details view is only available if the `titleText` is not fully visible.
8590
It is NOT recommended to use long titles!

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

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,33 @@ import ListSeparator from '@ui5/webcomponents/dist/types/ListSeparator.js';
55
import TitleLevel from '@ui5/webcomponents/dist/types/TitleLevel.js';
66
import WrappingType from '@ui5/webcomponents/dist/types/WrappingType.js';
77
import ValueState from '@ui5/webcomponents-base/dist/types/ValueState.js';
8+
import announce from '@ui5/webcomponents-base/dist/util/InvisibleMessage.js';
89
import iconSlimArrowLeft from '@ui5/webcomponents-icons/dist/slim-arrow-left.js';
10+
import type { Ui5DomRef } from '@ui5/webcomponents-react-base';
911
import { useI18nBundle, useStylesheet, useSyncRef } from '@ui5/webcomponents-react-base';
1012
import { clsx } from 'clsx';
1113
import type { ReactElement, ReactNode } from 'react';
12-
import { Children, forwardRef, Fragment, isValidElement, useCallback, useEffect, useState } from 'react';
14+
import { useRef, Children, forwardRef, Fragment, isValidElement, useCallback, useEffect, useState } from 'react';
1315
import { FlexBoxDirection } from '../../enums/index.js';
14-
import { ALL, LIST_NO_DATA } from '../../i18n/i18n-defaults.js';
16+
import { ALL, LIST_NO_DATA, NAVIGATE_BACK, MESSAGE_DETAILS, MESSAGE_TYPES } from '../../i18n/i18n-defaults.js';
17+
import type { SelectedMessage } from '../../internal/MessageViewContext.js';
1518
import { MessageViewContext } from '../../internal/MessageViewContext.js';
1619
import type { CommonProps } from '../../types/index.js';
1720
import { Bar } from '../../webComponents/Bar/index.js';
21+
import type { ButtonDomRef } from '../../webComponents/Button/index.js';
1822
import { Button } from '../../webComponents/Button/index.js';
1923
import { Icon } from '../../webComponents/Icon/index.js';
20-
import type { ListPropTypes } from '../../webComponents/List/index.js';
24+
import type { ListDomRef, ListPropTypes } from '../../webComponents/List/index.js';
2125
import { List } from '../../webComponents/List/index.js';
2226
import { ListItemGroup } from '../../webComponents/ListItemGroup/index.js';
23-
import type { SegmentedButtonPropTypes } from '../../webComponents/SegmentedButton/index.js';
2427
import { SegmentedButton } from '../../webComponents/SegmentedButton/index.js';
28+
import type { SegmentedButtonPropTypes } from '../../webComponents/SegmentedButton/index.js';
2529
import { SegmentedButtonItem } from '../../webComponents/SegmentedButtonItem/index.js';
2630
import { Title } from '../../webComponents/Title/index.js';
2731
import { FlexBox } from '../FlexBox/index.js';
2832
import type { MessageItemPropTypes } from './MessageItem.js';
2933
import { classNames, styleData } from './MessageView.module.css.js';
30-
import { getIconNameForType } from './utils.js';
34+
import { getIconNameForType, getValueStateMap } from './utils.js';
3135

3236
export interface MessageViewDomRef extends HTMLDivElement {
3337
/**
@@ -107,6 +111,10 @@ export const resolveMessageGroups = (children: ReactElement<MessageItemPropTypes
107111
*/
108112
const MessageView = forwardRef<MessageViewDomRef, MessageViewPropTypes>((props, ref) => {
109113
const { children, groupItems, showDetailsPageHeader, className, onItemSelect, ...rest } = props;
114+
const navBtnRef = useRef<ButtonDomRef>(null);
115+
const listRef = useRef<ListDomRef>(null);
116+
const transitionTrigger = useRef<'btn' | 'list' | null>(null);
117+
const prevSelectedMessage = useRef<SelectedMessage>(null);
110118

111119
useStylesheet(styleData, MessageView.displayName);
112120

@@ -115,7 +123,7 @@ const MessageView = forwardRef<MessageViewDomRef, MessageViewPropTypes>((props,
115123
const i18nBundle = useI18nBundle('@ui5/webcomponents-react');
116124

117125
const [listFilter, setListFilter] = useState<ValueState | 'All'>('All');
118-
const [selectedMessage, setSelectedMessage] = useState<MessageItemPropTypes>(null);
126+
const [selectedMessage, setSelectedMessage] = useState<SelectedMessage>(null);
119127

120128
const childrenArray = Children.toArray(children);
121129
const messageTypes = resolveMessageTypes(childrenArray as ReactElement<MessageItemPropTypes>[]);
@@ -138,8 +146,10 @@ const MessageView = forwardRef<MessageViewDomRef, MessageViewPropTypes>((props,
138146
const groupedMessages = resolveMessageGroups(filteredChildren as ReactElement<MessageItemPropTypes>[]);
139147

140148
const navigateBack = useCallback(() => {
149+
transitionTrigger.current = 'btn';
150+
prevSelectedMessage.current = selectedMessage;
141151
setSelectedMessage(null);
142-
}, [setSelectedMessage]);
152+
}, [setSelectedMessage, selectedMessage]);
143153

144154
useEffect(() => {
145155
if (internalRef.current) {
@@ -151,10 +161,37 @@ const MessageView = forwardRef<MessageViewDomRef, MessageViewPropTypes>((props,
151161
setListFilter(e.detail.selectedItems.at(0).dataset.key as never);
152162
};
153163

154-
const outerClasses = clsx(classNames.container, className, selectedMessage && classNames.showDetails);
164+
const handleTransitionEnd: MessageViewPropTypes['onTransitionEnd'] = (e) => {
165+
if (typeof props?.onTransitionEnd === 'function') {
166+
props.onTransitionEnd(e);
167+
}
168+
if (showDetailsPageHeader && transitionTrigger.current === 'list') {
169+
requestAnimationFrame(() => {
170+
void navBtnRef.current?.focus();
171+
});
172+
setTimeout(() => {
173+
announce(i18nBundle.getText(MESSAGE_DETAILS), 'Polite');
174+
}, 300);
175+
}
176+
if (transitionTrigger.current === 'btn') {
177+
requestAnimationFrame(() => {
178+
const selectedItem = listRef.current.querySelector<Ui5DomRef>(
179+
`[data-title="${CSS.escape(prevSelectedMessage.current.titleTextStr)}"]`
180+
);
181+
void selectedItem.focus();
182+
});
183+
}
184+
transitionTrigger.current = null;
185+
};
155186

187+
const handleListItemClick: ListPropTypes['onItemClick'] = (e) => {
188+
transitionTrigger.current = 'list';
189+
onItemSelect(e);
190+
};
191+
192+
const outerClasses = clsx(classNames.container, className, selectedMessage && classNames.showDetails);
156193
return (
157-
<div ref={componentRef} {...rest} className={outerClasses}>
194+
<div ref={componentRef} {...rest} className={outerClasses} onTransitionEnd={handleTransitionEnd}>
158195
<MessageViewContext.Provider
159196
value={{
160197
selectMessage: setSelectedMessage
@@ -166,7 +203,10 @@ const MessageView = forwardRef<MessageViewDomRef, MessageViewPropTypes>((props,
166203
{filledTypes > 1 && (
167204
<Bar
168205
startContent={
169-
<SegmentedButton onSelectionChange={handleListFilterChange}>
206+
<SegmentedButton
207+
onSelectionChange={handleListFilterChange}
208+
accessibleName={i18nBundle.getText(MESSAGE_TYPES)}
209+
>
170210
<SegmentedButtonItem data-key="All" selected={listFilter === 'All'}>
171211
{i18nBundle.getText(ALL)}
172212
</SegmentedButtonItem>
@@ -182,6 +222,8 @@ const MessageView = forwardRef<MessageViewDomRef, MessageViewPropTypes>((props,
182222
selected={listFilter === valueState}
183223
icon={getIconNameForType(valueState)}
184224
className={classNames.button}
225+
tooltip={getValueStateMap(i18nBundle)[valueState]}
226+
accessibleName={getValueStateMap(i18nBundle)[valueState]}
185227
>
186228
{count}
187229
</SegmentedButtonItem>
@@ -192,7 +234,8 @@ const MessageView = forwardRef<MessageViewDomRef, MessageViewPropTypes>((props,
192234
/>
193235
)}
194236
<List
195-
onItemClick={onItemSelect}
237+
ref={listRef}
238+
onItemClick={handleListItemClick}
196239
noDataText={i18nBundle.getText(LIST_NO_DATA)}
197240
separators={ListSeparator.Inner}
198241
>
@@ -212,13 +255,21 @@ const MessageView = forwardRef<MessageViewDomRef, MessageViewPropTypes>((props,
212255
</>
213256
)}
214257
</div>
215-
<div className={classNames.detailsContainer}>
258+
<div className={classNames.detailsContainer} data-component-name="MessageViewDetailsContainer">
216259
{childrenArray.length > 0 ? (
217260
<>
218261
{showDetailsPageHeader && selectedMessage && (
219262
<Bar
220263
startContent={
221-
<Button design={ButtonDesign.Transparent} icon={iconSlimArrowLeft} onClick={navigateBack} />
264+
<Button
265+
ref={navBtnRef}
266+
design={ButtonDesign.Transparent}
267+
icon={iconSlimArrowLeft}
268+
onClick={navigateBack}
269+
tooltip={i18nBundle.getText(NAVIGATE_BACK)}
270+
accessibleName={i18nBundle.getText(NAVIGATE_BACK)}
271+
data-component-name="MessageViewDetailsNavBackBtn"
272+
/>
222273
}
223274
/>
224275
)}

0 commit comments

Comments
 (0)