From ef5e3e5eef9d21931a57ca79e5b82d65287f649a Mon Sep 17 00:00:00 2001 From: Christina Ahrens Roberts Date: Fri, 4 Oct 2024 14:25:52 -0400 Subject: [PATCH] [UIE-82] Fix a11y issue with notification navigation "buttons" (#5123) --- src/libs/notifications.test.tsx | 31 +++ .../{notifications.ts => notifications.tsx} | 231 +++++++++--------- src/workspaces/common/state/useWorkspace.ts | 2 +- 3 files changed, 147 insertions(+), 117 deletions(-) rename src/libs/{notifications.ts => notifications.tsx} (51%) diff --git a/src/libs/notifications.test.tsx b/src/libs/notifications.test.tsx index 44e1427722..0a3e66a072 100644 --- a/src/libs/notifications.test.tsx +++ b/src/libs/notifications.test.tsx @@ -147,6 +147,37 @@ describe('notify', () => { // Assert screen.getByText('Things went BOOM!'); }); + + it('renders navigation buttons if needed', () => { + // Arrange + notify('error', 'Test notification', { + id: 'test-notification', + message: 'first message', + }); + notify('error', 'Test notification', { + id: 'test-notification', + message: 'second message', + }); + render(
{notificationContent}
); + + // Act and Assert + screen.getByText('first message'); + screen.getByText('1/2'); + const nextButton = screen.getByLabelText('Next notification'); + const previousButton = screen.getByLabelText('Previous notification'); + expect(nextButton).toHaveAttribute('aria-disabled', 'false'); + expect(previousButton).toHaveAttribute('aria-disabled', 'true'); + + fireEvent.click(nextButton); + screen.getByText('second message'); + screen.getByText('2/2'); + expect(nextButton).toHaveAttribute('aria-disabled', 'true'); + expect(previousButton).toHaveAttribute('aria-disabled', 'false'); + + fireEvent.click(previousButton); + screen.getByText('first message'); + screen.getByText('1/2'); + }); }); describe('clearNotification', () => { diff --git a/src/libs/notifications.ts b/src/libs/notifications.tsx similarity index 51% rename from src/libs/notifications.ts rename to src/libs/notifications.tsx index a540157249..656c7a9b56 100644 --- a/src/libs/notifications.ts +++ b/src/libs/notifications.tsx @@ -1,7 +1,7 @@ import { ButtonPrimary, Clickable, - icon, + Icon, IconId, Link, Modal, @@ -11,8 +11,7 @@ import { import { DEFAULT, switchCase } from '@terra-ui-packages/core-utils'; import { NotificationType } from '@terra-ui-packages/notifications'; import _ from 'lodash/fp'; -import { ReactNode, useState } from 'react'; -import { div, h } from 'react-hyperscript-helpers'; +import React, { ReactNode, useState } from 'react'; import { Store } from 'react-notifications-component'; import ErrorView from 'src/components/ErrorView'; import { getLocalPref, setLocalPref } from 'src/libs/prefs'; @@ -56,27 +55,27 @@ export const notify = (type: NotificationType, title: ReactNode, props?: Notific return notification.id; }; -export const clearNotification = (id) => Store.removeNotification(id); +export const clearNotification = (id: string) => Store.removeNotification(id); -export const clearMatchingNotifications = (idPrefix) => { +export const clearMatchingNotifications = (idPrefix: string) => { const matchingNotificationIds = _.flow(_.map(_.get('id')), _.filter(_.startsWith(idPrefix)))(notificationStore.get()); matchingNotificationIds.forEach((id) => { Store.removeNotification(id); }); }; -const muteNotificationPreferenceKey = (id) => `mute-notification/${id}`; +const muteNotificationPreferenceKey = (id: string) => `mute-notification/${id}`; -export const isNotificationMuted = (id) => { +export const isNotificationMuted = (id: string) => { const mutedUntil = getLocalPref(muteNotificationPreferenceKey(id)); return switchCase(mutedUntil, [undefined, () => false], [-1, () => true], [DEFAULT, () => mutedUntil > Date.now()]); }; -export const muteNotification = (id, until = -1) => { +export const muteNotification = (id: string, until = -1) => { setLocalPref(muteNotificationPreferenceKey(id), until); }; -const NotificationDisplay = ({ id }) => { +const NotificationDisplay = ({ id }: { id: string }) => { const { colors } = useThemeFromContext(); const notificationState = useStore(notificationStore); const [modal, setModal] = useState(false); @@ -106,9 +105,9 @@ const NotificationDisplay = ({ id }) => { const labelId = useUniqueId(); const descId = useUniqueId(); - return div( - { - style: { + return ( +
{ display: 'flex', flexDirection: 'column', fontSize: 12, - }, - role: 'alert', - 'aria-labelledby': labelId, - 'aria-describedby': message ? descId : undefined, - }, - [ - // content and close button - div({ style: { display: 'flex', padding: '0.75rem 1rem' } }, [ - // content - div({ style: { display: 'flex', flex: 1, flexDirection: 'column', overflow: 'hidden' } }, [ - // icon and title - div({ style: { display: 'flex' } }, [ - !!iconType && - icon(iconType, { - 'aria-hidden': false, - 'aria-label': ariaLabel, - size: 26, - style: { color: baseColor(), flexShrink: 0, marginRight: '0.5rem' }, - }), - div({ id: labelId, style: { fontWeight: 600, overflow: 'hidden', overflowWrap: 'break-word' } }, [title]), - ]), - !!message && div({ id: descId, style: { marginTop: '0.5rem', overflowWrap: 'break-word' } }, [message]), - div({ style: { display: 'flex' } }, [ - !!detail && - h( - Clickable, - { - style: { marginTop: '0.25rem', marginRight: '0.5rem', textDecoration: 'underline' }, - onClick: () => setModal(true), - }, - ['Details'] - ), - ]), - ]), - h( - Link, - { - style: { alignSelf: 'start' }, - 'aria-label': type ? `Dismiss ${type} notification` : 'Dismiss notification', - title: 'Dismiss notification', - onClick: () => { - Store.removeNotification(id); - }, - }, - [icon('times', { size: 20 })] - ), - ]), - notifications.length > 1 && - div( - { - style: { - alignItems: 'center', - borderTop: `1px solid ${baseColor()}`, - display: 'flex', - fontSize: 10, - padding: '0.75rem 1rem', - }, - }, - [ - h( - Link, - { - disabled: onFirst, - onClick: () => setNotificationNumber(notificationNumber - 1), - }, - [icon('angle-left', { size: 12 })] - ), - div( - { - style: { - backgroundColor: colors.accent(), - color: 'white', - fontWeight: 600, - borderRadius: 10, - padding: '0.2rem 0.5rem', - }, - }, - [notificationNumber + 1, '/', notifications.length] - ), - h( - Link, - { - disabled: onLast, - onClick: () => setNotificationNumber(notificationNumber + 1), - }, - [icon('angle-right', { size: 12 })] - ), - ] - ), - modal && - h( - Modal, - { - width: 800, - title, - showCancel: false, - showX: true, - onDismiss: () => setModal(false), - okButton: h(ButtonPrimary, { onClick: refreshPage }, ['Refresh Page']), - }, - [h(ErrorView, { error: detail })] - ), - ] + }} + role='alert' + aria-labelledby={labelId} + aria-describedby={message ? descId : undefined} + > +
+
+
+ {!!iconType && ( + + )} +
+ {title} +
+
+ {!!message && ( +
+ {message} +
+ )} +
+ {!!detail && ( + setModal(true)} + > + Details + + )} +
+
+ {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + Store.removeNotification(id)} + > + + +
+ {notifications.length > 1 && ( +
+ {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + setNotificationNumber(notificationNumber - 1)} + aria-label='Previous notification' + > + + +
+ {notificationNumber + 1}/{notifications.length} +
+ {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + setNotificationNumber(notificationNumber + 1)} + aria-label='Next notification' + > + + +
+ )} + {modal && ( + setModal(false)} + okButton={Refresh Page} + > + + + )} +
); }; @@ -231,7 +226,11 @@ const showNotification = ({ id, timeout }) => { Store.addNotification({ id, onRemoval: () => notificationStore.update(_.reject({ id })), - content: div({ style: { width: '100%' } }, [h(NotificationDisplay, { id })]), + content: ( +
+ +
+ ), container: 'top-right', dismiss: { duration: timeout || 0, click: false, touch: false }, animationIn: ['animate__animated', 'animate__fadeIn'], diff --git a/src/workspaces/common/state/useWorkspace.ts b/src/workspaces/common/state/useWorkspace.ts index 1333f56d28..d242cb4689 100644 --- a/src/workspaces/common/state/useWorkspace.ts +++ b/src/workspaces/common/state/useWorkspace.ts @@ -228,7 +228,7 @@ export const useWorkspace = (namespace, name): WorkspaceDetails => { { onClick: () => { refreshWorkspace(); - clearNotification(accessNotificationId.current); + clearNotification(accessNotificationId.current!); }, }, ['Click to refresh now']