diff --git a/packages/main/src/components/MessageView/MessageItem.module.css b/packages/main/src/components/MessageView/MessageItem.module.css index 0544ae8b851..8d07fa1ca7e 100644 --- a/packages/main/src/components/MessageView/MessageItem.module.css +++ b/packages/main/src/components/MessageView/MessageItem.module.css @@ -89,3 +89,11 @@ color: var(--sapInformativeElementColor); } } + +.pseudoInvisibleText { + font-size: 0; + left: 0; + position: absolute; + top: 0; + user-select: none; +} diff --git a/packages/main/src/components/MessageView/MessageItem.tsx b/packages/main/src/components/MessageView/MessageItem.tsx index 0999d6a1855..7b2676a9296 100644 --- a/packages/main/src/components/MessageView/MessageItem.tsx +++ b/packages/main/src/components/MessageView/MessageItem.tsx @@ -1,22 +1,26 @@ 'use client'; +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 { useStylesheet } from '@ui5/webcomponents-react-base'; +import { useI18nBundle, useStylesheet } from '@ui5/webcomponents-react-base'; import { clsx } from 'clsx'; import type { ReactNode } from 'react'; -import { Children, forwardRef, useContext, useEffect, useRef, useState } from 'react'; +import { Children, isValidElement, forwardRef, useContext, useEffect, useRef, useState } from 'react'; import { FlexBoxAlignItems, FlexBoxDirection } from '../../enums/index.js'; +import { COUNTER, HAS_DETAILS } from '../../i18n/i18n-defaults.js'; import { MessageViewContext } from '../../internal/MessageViewContext.js'; import type { CommonProps } from '../../types/index.js'; import { Icon } from '../../webComponents/Icon/index.js'; import { Label } from '../../webComponents/Label/index.js'; -import type { ListItemCustomDomRef } from '../../webComponents/ListItemCustom/index.js'; +import type { LinkPropTypes } from '../../webComponents/Link/index.js'; +import type { ListItemCustomDomRef, ListItemCustomPropTypes } from '../../webComponents/ListItemCustom/index.js'; import { ListItemCustom } from '../../webComponents/ListItemCustom/index.js'; import { FlexBox } from '../FlexBox/index.js'; import { classNames, styleData } from './MessageItem.module.css.js'; -import { getIconNameForType } from './utils.js'; +import { getIconNameForType, getValueStateMap } from './utils.js'; export interface MessageItemPropTypes extends CommonProps { /** @@ -60,8 +64,10 @@ export interface MessageItemPropTypes extends CommonProps { const MessageItem = forwardRef((props, ref) => { const { titleText, subtitleText, counter, type = ValueState.Negative, children, className, ...rest } = props; const [isTitleTextOverflowing, setIsTitleTextIsOverflowing] = useState(false); + const [titleTextStr, setTitleTextStr] = useState(''); const titleTextRef = useRef(null); const hasDetails = !!(children || isTitleTextOverflowing); + const i18nBundle = useI18nBundle('@ui5/webcomponents-react'); useStylesheet(styleData, MessageItem.displayName); @@ -78,14 +84,14 @@ const MessageItem = forwardRef((prop const handleListItemClick = (e) => { if (hasDetails) { - selectMessage(props); + selectMessage({ ...props, titleTextStr }); if (typeof rest.onClick === 'function') { rest.onClick(e); } } }; - const handleKeyDown = (e) => { + const handleKeyDown: ListItemCustomPropTypes['onKeyDown'] = (e) => { if (typeof rest.onKeyDown === 'function') { rest.onKeyDown(e); } @@ -108,7 +114,6 @@ const MessageItem = forwardRef((prop isChildOverflowing = firstChild.scrollWidth > firstChild.clientWidth; } } - setIsTitleTextIsOverflowing(isTargetOverflowing || isChildOverflowing); }); if (!hasChildren && titleTextRef.current) { @@ -119,11 +124,20 @@ const MessageItem = forwardRef((prop }; }, [hasChildren]); + useEffect(() => { + if (typeof titleText === 'string') { + setTitleTextStr(titleText); + } else if (isValidElement(titleText) && typeof (titleText.props as LinkPropTypes)?.children === 'string') { + // @ts-expect-error: props.children is available and a string + setTitleTextStr(titleText.props.children); + } + }, [titleText]); + return ( ((prop >
- +
((prop {titleText} )} - {titleText && subtitleText && } + {titleText && subtitleText && ( + + )} - {counter != null && {counter}} - {hasDetails && } + {counter != null && ( + + {counter} + + )} + {hasDetails && } + {hasDetails && . {i18nBundle.getText(HAS_DETAILS)}} + {type !== ValueState.None && type !== ValueState.Information && ( + . {getValueStateMap(i18nBundle)[type]} + )}
); diff --git a/packages/main/src/components/MessageView/MessageView.cy.tsx b/packages/main/src/components/MessageView/MessageView.cy.tsx index a0b4d3a2465..4772bbf0a6b 100644 --- a/packages/main/src/components/MessageView/MessageView.cy.tsx +++ b/packages/main/src/components/MessageView/MessageView.cy.tsx @@ -48,20 +48,21 @@ describe('MessageView', () => { }); cy.get('[data-title="Success"]') .next() - .should('have.text', 'Information') + // Information and None don't have a status screen reader announcement + .should('have.text', 'Information. Has Details') .next() .should('have.attr', 'header-text', 'Group1') .children() .first() - .should('have.text', 'Error') + .should('have.text', 'Error. Has Details. Error') .next() - .should('have.text', 'Warning') + .should('have.text', 'Warning. Has Details. Warning') .parent() .next() .should('have.attr', 'header-text', 'Group2') .children() .first() - .should('have.text', 'None'); + .should('have.text', 'None. Has Details'); ['error', 'alert', 'sys-enter-2', 'information'].forEach((btn, index, arr) => { cy.log(`SegmentedButton click - ${btn}`); @@ -181,7 +182,7 @@ describe('MessageView', () => { titleText={ 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. + wrappingType='None' applied. - The details view is only available if the `titleText` is not fully visible. It is NOT recommended to use long titles! } @@ -200,14 +201,23 @@ describe('MessageView', () => { ); cy.get('[name="slim-arrow-right"]').should('be.visible').and('have.length', 2); - cy.findByTestId('item1').click(); cy.get('@select').should('have.been.calledOnce'); - cy.get('[name="slim-arrow-left"]').should('be.visible').and('have.length', 1).click(); + cy.get('[name="slim-arrow-left"]').should('be.visible').and('have.length', 1); + cy.focused().should('have.attr', 'aria-label', 'Navigate Back').click(); + + cy.focused() + .parent() + .should( + 'have.attr', + 'data-title', + `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!` + ); cy.findByTestId('item2').click(); cy.get('@select').should('have.been.calledTwice'); - cy.get('[name="slim-arrow-left"]').should('be.visible').and('have.length', 1).click(); + cy.get('[name="slim-arrow-left"]').should('be.visible').and('have.length', 1); + cy.get('[accessible-name="Navigate Back"]').should('be.focused').click(); cy.findByTestId('item3').click(); cy.get('@select').should('have.been.calledTwice'); diff --git a/packages/main/src/components/MessageView/MessageView.stories.tsx b/packages/main/src/components/MessageView/MessageView.stories.tsx index 5e528bddcea..6397e412085 100644 --- a/packages/main/src/components/MessageView/MessageView.stories.tsx +++ b/packages/main/src/components/MessageView/MessageView.stories.tsx @@ -38,7 +38,7 @@ const meta = { children: [ Informative message , - , + , + { + e.stopPropagation(); + }} + > 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! diff --git a/packages/main/src/components/MessageView/index.tsx b/packages/main/src/components/MessageView/index.tsx index f9dae775f1b..20787a8c3df 100644 --- a/packages/main/src/components/MessageView/index.tsx +++ b/packages/main/src/components/MessageView/index.tsx @@ -5,29 +5,33 @@ import ListSeparator from '@ui5/webcomponents/dist/types/ListSeparator.js'; import TitleLevel from '@ui5/webcomponents/dist/types/TitleLevel.js'; import WrappingType from '@ui5/webcomponents/dist/types/WrappingType.js'; import ValueState from '@ui5/webcomponents-base/dist/types/ValueState.js'; +import announce from '@ui5/webcomponents-base/dist/util/InvisibleMessage.js'; import iconSlimArrowLeft from '@ui5/webcomponents-icons/dist/slim-arrow-left.js'; +import type { Ui5DomRef } from '@ui5/webcomponents-react-base'; import { useI18nBundle, useStylesheet, useSyncRef } from '@ui5/webcomponents-react-base'; import { clsx } from 'clsx'; import type { ReactElement, ReactNode } from 'react'; -import { Children, forwardRef, Fragment, isValidElement, useCallback, useEffect, useState } from 'react'; +import { useRef, Children, forwardRef, Fragment, isValidElement, useCallback, useEffect, useState } from 'react'; import { FlexBoxDirection } from '../../enums/index.js'; -import { ALL, LIST_NO_DATA } from '../../i18n/i18n-defaults.js'; +import { ALL, LIST_NO_DATA, NAVIGATE_BACK, MESSAGE_DETAILS, MESSAGE_TYPES } from '../../i18n/i18n-defaults.js'; +import type { SelectedMessage } from '../../internal/MessageViewContext.js'; import { MessageViewContext } from '../../internal/MessageViewContext.js'; import type { CommonProps } from '../../types/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 { Icon } from '../../webComponents/Icon/index.js'; -import type { ListPropTypes } from '../../webComponents/List/index.js'; +import type { ListDomRef, ListPropTypes } from '../../webComponents/List/index.js'; import { List } from '../../webComponents/List/index.js'; import { ListItemGroup } from '../../webComponents/ListItemGroup/index.js'; -import type { SegmentedButtonPropTypes } from '../../webComponents/SegmentedButton/index.js'; import { SegmentedButton } from '../../webComponents/SegmentedButton/index.js'; +import type { SegmentedButtonPropTypes } from '../../webComponents/SegmentedButton/index.js'; import { SegmentedButtonItem } from '../../webComponents/SegmentedButtonItem/index.js'; import { Title } from '../../webComponents/Title/index.js'; import { FlexBox } from '../FlexBox/index.js'; import type { MessageItemPropTypes } from './MessageItem.js'; import { classNames, styleData } from './MessageView.module.css.js'; -import { getIconNameForType } from './utils.js'; +import { getIconNameForType, getValueStateMap } from './utils.js'; export interface MessageViewDomRef extends HTMLDivElement { /** @@ -107,6 +111,10 @@ export const resolveMessageGroups = (children: ReactElement((props, ref) => { const { children, groupItems, showDetailsPageHeader, className, onItemSelect, ...rest } = props; + const navBtnRef = useRef(null); + const listRef = useRef(null); + const transitionTrigger = useRef<'btn' | 'list' | null>(null); + const prevSelectedMessage = useRef(null); useStylesheet(styleData, MessageView.displayName); @@ -115,7 +123,7 @@ const MessageView = forwardRef((props, const i18nBundle = useI18nBundle('@ui5/webcomponents-react'); const [listFilter, setListFilter] = useState('All'); - const [selectedMessage, setSelectedMessage] = useState(null); + const [selectedMessage, setSelectedMessage] = useState(null); const childrenArray = Children.toArray(children); const messageTypes = resolveMessageTypes(childrenArray as ReactElement[]); @@ -138,8 +146,10 @@ const MessageView = forwardRef((props, const groupedMessages = resolveMessageGroups(filteredChildren as ReactElement[]); const navigateBack = useCallback(() => { + transitionTrigger.current = 'btn'; + prevSelectedMessage.current = selectedMessage; setSelectedMessage(null); - }, [setSelectedMessage]); + }, [setSelectedMessage, selectedMessage]); useEffect(() => { if (internalRef.current) { @@ -151,10 +161,37 @@ const MessageView = forwardRef((props, setListFilter(e.detail.selectedItems.at(0).dataset.key as never); }; - const outerClasses = clsx(classNames.container, className, selectedMessage && classNames.showDetails); + const handleTransitionEnd: MessageViewPropTypes['onTransitionEnd'] = (e) => { + 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 === 'btn') { + requestAnimationFrame(() => { + const selectedItem = listRef.current.querySelector( + `[data-title="${CSS.escape(prevSelectedMessage.current.titleTextStr)}"]` + ); + void selectedItem.focus(); + }); + } + transitionTrigger.current = null; + }; + const handleListItemClick: ListPropTypes['onItemClick'] = (e) => { + transitionTrigger.current = 'list'; + onItemSelect(e); + }; + + const outerClasses = clsx(classNames.container, className, selectedMessage && classNames.showDetails); return ( -
+
((props, {filledTypes > 1 && ( + {i18nBundle.getText(ALL)} @@ -182,6 +222,8 @@ const MessageView = forwardRef((props, selected={listFilter === valueState} icon={getIconNameForType(valueState)} className={classNames.button} + tooltip={getValueStateMap(i18nBundle)[valueState]} + accessibleName={getValueStateMap(i18nBundle)[valueState]} > {count} @@ -192,7 +234,8 @@ const MessageView = forwardRef((props, /> )} @@ -212,13 +255,21 @@ const MessageView = forwardRef((props, )}
-
+
{childrenArray.length > 0 ? ( <> {showDetailsPageHeader && selectedMessage && ( +