Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,6 @@
flex: none;
}

.navigation {
height: 0.875rem;
width: 0.875rem;
padding: 0 0.9375rem;
flex-shrink: 0;
}

.typeNegative {
.icon {
color: var(--sapNegativeElementColor);
Expand Down
15 changes: 12 additions & 3 deletions packages/main/src/components/MessageItem/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import IconMode from '@ui5/webcomponents/dist/types/IconMode.js';
import ListItemType from '@ui5/webcomponents/dist/types/ListItemType.js';
import WrappingType from '@ui5/webcomponents/dist/types/WrappingType.js';
import ValueState from '@ui5/webcomponents-base/dist/types/ValueState.js';
import iconArrowRight from '@ui5/webcomponents-icons/dist/slim-arrow-right.js';
import { useI18nBundle, useStylesheet } from '@ui5/webcomponents-react-base';
import { addCustomCSSWithScoping } from '@ui5/webcomponents-react-base/internal/utils';
import { clsx } from 'clsx';
import { Children, forwardRef, useContext, useEffect, useRef, useState } from 'react';
import type { ReactNode } from 'react';
Expand All @@ -22,6 +22,15 @@ import { FlexBox } from '../FlexBox/index.js';
import { getIconNameForType, getValueStateMap, resolveTitleTextStr } from '../MessageView/utils.js';
import { classNames, styleData } from './MessageItem.module.css.js';

// TODO: remove once https://github.com/UI5/webcomponents/issues/14221 is fixed (navigation arrow shrinks on long titles)
addCustomCSSWithScoping(
'ui5-li-custom',
`
:host([data-message-item]) [ui5-icon][name="slim-arrow-right"] {
flex-shrink: 0;
}`,
);

export interface MessageItemPropTypes
extends Pick<ListItemCustomPropTypes, 'accessibleName' | 'tooltip' | 'onClick'>, Omit<CommonProps, 'onClick'> {
/**
Expand Down Expand Up @@ -131,9 +140,10 @@ const MessageItem = forwardRef<ListItemCustomDomRef, MessageItemPropTypes>((prop
onKeyDown={handleKeyDown}
data-title={titleTextStr}
data-type={type}
type={hasDetails ? ListItemType.Active : ListItemType.Inactive}
type={hasDetails ? ListItemType.Navigation : ListItemType.Inactive}
{...rest}
className={listItemClasses}
data-message-item
ref={ref}
>
<FlexBox alignItems={FlexBoxAlignItems.Center} className={messageClasses}>
Expand All @@ -157,7 +167,6 @@ const MessageItem = forwardRef<ListItemCustomDomRef, MessageItemPropTypes>((prop
{counter}
</span>
)}
{hasDetails && <Icon className={classNames.navigation} name={iconArrowRight} mode={IconMode.Decorative} />}
{hasDetails && <span className={classNames.pseudoInvisibleText}>. {i18nBundle.getText(HAS_DETAILS)}</span>}
{type !== ValueState.None && type !== ValueState.Information && (
<span className={classNames.pseudoInvisibleText}>. {getValueStateMap(i18nBundle)[type]}</span>
Expand Down
10 changes: 10 additions & 0 deletions packages/main/src/components/MessageView/MessageView.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ When used in a `Dialog` or a `Popover`, we recommend not setting the `showDetail
to the `onItemSelect` event and add a back button to your Dialog or Popover header and use the `navigateBack()` method
to get back to the list view.

## Accessibility

When the details page is opened, `MessageView` announces the opened message (its value state and title) to screen readers and, with `showDetailsPageHeader={true}`, also handles focus for the details page.

With `showDetailsPageHeader={false}`, focus is not managed, so the details page needs to be made accessible manually — e.g. move focus to an interactive element (such as a back button) and provide any additional information for assistive technologies in the `onItemSelect` handler (see the example below).

## More Examples

### MessageView in a Dialog
Expand All @@ -50,6 +56,7 @@ Sample Code: _(Button and Dialog opening are omitted here)_
```tsx
function MyComponent() {
const messageViewRef = useRef<MessageViewDomRef>(null);
const backBtnRef = useRef<ButtonDomRef>(null);
const [isOnDetailsPage, setIsOnDetailsPage] = useState(false);
return (
<Dialog
Expand All @@ -65,6 +72,7 @@ function MyComponent() {
<FlexBox alignItems={FlexBoxAlignItems.Center}>
{isOnDetailsPage && (
<Button
ref={backBtnRef}
icon="slim-arrow-left"
design={ButtonDesign.Transparent}
onClick={() => {
Expand All @@ -85,6 +93,8 @@ function MyComponent() {
showDetailsPageHeader={false}
onItemSelect={() => {
setIsOnDetailsPage(true);
// move focus to the back button so keyboard/screen reader users are oriented
requestAnimationFrame(() => backBtnRef.current?.focus());
}}
>
{/* children */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import arrowLeftIcon from '@ui5/webcomponents-icons/dist/slim-arrow-left.js';
import { useRef, useState } from 'react';
import { FlexBoxAlignItems, FlexBoxJustifyContent } from '../../enums/index.js';
import { Bar } from '../../webComponents/Bar/index.js';
import type { ButtonDomRef } from '../../webComponents/Button/index.js';
import { Button } from '../../webComponents/Button/index.js';
import { Dialog } from '../../webComponents/Dialog/index.js';
import { Link } from '../../webComponents/Link/index.js';
Expand Down Expand Up @@ -108,6 +109,7 @@ export const MessageViewInDialog: Story = {
render(args) {
const [open, setOpen] = useState(false);
const messageViewRef = useRef<MessageViewDomRef>(null);
const backBtnRef = useRef<ButtonDomRef>(null);
const [isOnDetailsPage, setIsOnDetailsPage] = useState(false);
return (
<>
Expand All @@ -133,6 +135,7 @@ export const MessageViewInDialog: Story = {
<FlexBox alignItems={FlexBoxAlignItems.Center}>
{isOnDetailsPage && (
<Button
ref={backBtnRef}
icon={arrowLeftIcon}
design={ButtonDesign.Transparent}
onClick={() => {
Expand Down Expand Up @@ -173,6 +176,10 @@ export const MessageViewInDialog: Story = {
showDetailsPageHeader={false}
onItemSelect={() => {
setIsOnDetailsPage(true);
// no details-page header → the app ensures a11y by moving focus to its own back button
requestAnimationFrame(() => {
void backBtnRef.current?.focus();
});
}}
/>
</Dialog>
Expand All @@ -186,6 +193,7 @@ export const WithMessageViewButton: Story = {
render() {
const ref = useRef(null);
const messageViewRef = useRef<MessageViewDomRef>(null);
const backBtnRef = useRef<ButtonDomRef>(null);
const [isOnDetailsPage, setIsOnDetailsPage] = useState(false);
const [open, setOpen] = useState(false);
const numberOfItems = {
Expand Down Expand Up @@ -219,6 +227,7 @@ export const WithMessageViewButton: Story = {
<FlexBox alignItems={FlexBoxAlignItems.Center}>
{isOnDetailsPage && (
<Button
ref={backBtnRef}
design={ButtonDesign.Transparent}
icon="slim-arrow-left"
onClick={() => {
Expand Down Expand Up @@ -261,6 +270,10 @@ export const WithMessageViewButton: Story = {
groupItems
onItemSelect={() => {
setIsOnDetailsPage(true);
// no details-page header → the app ensures a11y by moving focus to its own back button
requestAnimationFrame(() => {
void backBtnRef.current?.focus();
});
}}
>
{generateMessageItems(numberOfItems)}
Expand Down
43 changes: 35 additions & 8 deletions packages/main/src/components/MessageView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export interface MessageViewPropTypes extends CommonProps {

/**
* Defines whether the header of the details page will be shown.
*
* __Note:__ When set to `false`, the accessibility of the details page has to be ensured manually (e.g. by moving focus to the back button and providing additional information for assistive technologies in the `onItemSelect` handler).
*/
showDetailsPageHeader?: boolean;

Expand Down Expand Up @@ -178,6 +180,36 @@ const MessageView = forwardRef<MessageViewDomRef, MessageViewPropTypes>((props,
}
}, [internalRef, navigateBack]);

// defer one frame so the live-region update isn't batched with the navigation re-render
const selectMessage = useCallback(
(message: SelectedMessage) => {
setSelectedMessage(message);
const valueStateText = getValueStateMap(i18nBundle)[message.type ?? ValueState.Negative];
const title = message.titleTextStr || i18nBundle.getText(MESSAGE_DETAILS);
requestAnimationFrame(() => {
announce(`${valueStateText} ${title}`.trim(), 'Assertive');
});
},
[i18nBundle],
);

// focus only after the slide finishes (see callers) — focusing mid-animation fights scroll-into-view
const focusDetails = useCallback(() => {
if (showDetailsPageHeader) {
requestAnimationFrame(() => {
void navBtnRef.current?.focus();
});
}
}, [showDetailsPageHeader]);

// no transitionend fires when animations are disabled, so focus directly
useEffect(() => {
if (!withAnimation && transitionTrigger.current === 'list' && effectiveSelectedMessage) {
focusDetails();
transitionTrigger.current = null;
}
}, [effectiveSelectedMessage, focusDetails]);

const handleListFilterChange: SegmentedButtonPropTypes['onSelectionChange'] = (e) => {
setListFilter(e.detail.selectedItems.at(0).dataset.key as never);
};
Expand All @@ -186,13 +218,8 @@ const MessageView = forwardRef<MessageViewDomRef, MessageViewPropTypes>((props,
if (typeof props?.onTransitionEnd === 'function') {
props.onTransitionEnd(e);
}
if (showDetailsPageHeader && transitionTrigger.current === 'list') {
requestAnimationFrame(() => {
void navBtnRef.current?.focus();
});
setTimeout(() => {
announce(i18nBundle.getText(MESSAGE_DETAILS), 'Polite');
}, 300);
if (transitionTrigger.current === 'list') {
focusDetails();
}
if (transitionTrigger.current === 'btn') {
requestAnimationFrame(() => {
Expand Down Expand Up @@ -226,7 +253,7 @@ const MessageView = forwardRef<MessageViewDomRef, MessageViewPropTypes>((props,
>
<MessageViewContext.Provider
value={{
selectMessage: setSelectedMessage,
selectMessage,
}}
>
<div
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import ValueState from '@ui5/webcomponents-base/dist/types/ValueState.js';
import { useRef } from 'react';
import { Dialog } from '../../../webComponents/Dialog/index.js';
import { MessageItem } from '../../MessageItem/index.js';
import type { MessageViewDomRef } from '../index.js';
import { MessageView } from '../index.js';

export const WithDetailsPageHeader = () => {
return (
<MessageView showDetailsPageHeader>
<MessageItem titleText="Password too short" type={ValueState.Negative}>
The password must contain at least two character types.
</MessageItem>
<MessageItem titleText="Check your input" type={ValueState.Critical}>
Please review the highlighted fields.
</MessageItem>
</MessageView>
);
};

export const InDialogWithoutHeader = () => {
const ref = useRef<MessageViewDomRef>(null);
const backBtnRef = useRef<HTMLButtonElement>(null);
return (
<Dialog open style={{ width: '400px' }}>
<button ref={backBtnRef} slot="header" data-testid="dialog-back" onClick={() => ref.current?.navigateBack()}>
nav back
</button>
<MessageView
ref={ref}
showDetailsPageHeader={false}
onItemSelect={() => {
// no header → app ensures a11y by moving focus to its own (interactive) back button
requestAnimationFrame(() => {
backBtnRef.current?.focus();
});
}}
>
<MessageItem titleText="Password too short" type={ValueState.Negative}>
The password must contain at least two character types.
</MessageItem>
<MessageItem titleText="Check your input" type={ValueState.Critical}>
Please review the highlighted fields.
</MessageItem>
</MessageView>
</Dialog>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { expect, test } from '../../../../../../playwright/fixtures/gallery-fixtures.js';

test.describe('MessageView - accessibility', () => {
test('showDetailsPageHeader: focuses the Navigate Back button and announces the message', async ({ mount, page }) => {
await mount('MessageView/WithDetailsPageHeader');

await page.locator('[data-title="Password too short"]').click();

// focus moves to the Navigate Back button
const navBack = page.locator('[data-component-name="MessageViewDetailsNavBackBtn"]');
await expect(navBack).toBeFocused();

// the message (value state + title) is announced, not a generic string
await expect(page.locator('.ui5-invisiblemessage-assertive')).toHaveText('Error Password too short');
});

test('showDetailsPageHeader={false}: app moves focus to its own back button via onItemSelect', async ({
mount,
page,
}) => {
await mount('MessageView/InDialogWithoutHeader');
const dialog = page.locator('[ui5-dialog]');
await expect(dialog).toBeVisible();

await page.locator('[data-title="Password too short"]').click();

// MessageView renders no details-page header and does not manage focus here
await expect(page.locator('[data-component-name="MessageViewDetailsNavBackBtn"]')).toHaveCount(0);

// the app's onItemSelect handler moves focus to its own (interactive) back button, keeping focus in the dialog
const backButton = page.getByTestId('dialog-back');
await expect(backButton).toBeFocused();
await expect(dialog.getByTestId('dialog-back')).toHaveCount(1);
});
});
Loading