Skip to content

Commit

Permalink
[UIE-82] Fix a11y issue with notification navigation "buttons" (#5123)
Browse files Browse the repository at this point in the history
  • Loading branch information
cahrens authored Oct 4, 2024
1 parent 3e1f96a commit ef5e3e5
Show file tree
Hide file tree
Showing 3 changed files with 147 additions and 117 deletions.
31 changes: 31 additions & 0 deletions src/libs/notifications.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<div>{notificationContent}</div>);

// 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', () => {
Expand Down
231 changes: 115 additions & 116 deletions src/libs/notifications.ts → src/libs/notifications.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
ButtonPrimary,
Clickable,
icon,
Icon,
IconId,
Link,
Modal,
Expand All @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -106,119 +105,115 @@ const NotificationDisplay = ({ id }) => {
const labelId = useUniqueId();
const descId = useUniqueId();

return div(
{
style: {
return (
<div
style={{
backgroundColor: baseColor(0.15),
borderRadius: '4px',
boxShadow: '0 0 4px 0 rgba(0,0,0,0.5)',
cursor: 'auto',
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}
>
<div style={{ display: 'flex', padding: '0.75rem 1rem' }}>
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'flex' }}>
{!!iconType && (
<Icon
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}
</div>
</div>
{!!message && (
<div id={descId} style={{ marginTop: '0.5rem', overflowWrap: 'break-word' }}>
{message}
</div>
)}
<div style={{ display: 'flex' }}>
{!!detail && (
<Clickable
style={{ marginTop: '0.25rem', marginRight: '0.5rem', textDecoration: 'underline' }}
onClick={() => setModal(true)}
>
Details
</Clickable>
)}
</div>
</div>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<Link
style={{ alignSelf: 'start' }}
aria-label={type ? `Dismiss ${type} notification` : 'Dismiss notification'}
title='Dismiss notification'
onClick={() => Store.removeNotification(id)}
>
<Icon icon='times' size={20} />
</Link>
</div>
{notifications.length > 1 && (
<div
style={{
alignItems: 'center',
borderTop: `1px solid ${baseColor()}`,
display: 'flex',
fontSize: 10,
padding: '0.75rem 1rem',
}}
>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<Link
disabled={onFirst}
onClick={() => setNotificationNumber(notificationNumber - 1)}
aria-label='Previous notification'
>
<Icon icon='angle-left' size={12} />
</Link>
<div
style={{
backgroundColor: colors.accent(),
color: 'white',
fontWeight: 600,
borderRadius: 10,
padding: '0.2rem 0.5rem',
}}
>
{notificationNumber + 1}/{notifications.length}
</div>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<Link
disabled={onLast}
onClick={() => setNotificationNumber(notificationNumber + 1)}
aria-label='Next notification'
>
<Icon icon='angle-right' size={12} />
</Link>
</div>
)}
{modal && (
<Modal
width={800}
title={title}
showCancel={false}
showX
onDismiss={() => setModal(false)}
okButton={<ButtonPrimary onClick={refreshPage}>Refresh Page</ButtonPrimary>}
>
<ErrorView error={detail} />
</Modal>
)}
</div>
);
};

Expand All @@ -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: (
<div style={{ width: '100%' }}>
<NotificationDisplay id={id} />
</div>
),
container: 'top-right',
dismiss: { duration: timeout || 0, click: false, touch: false },
animationIn: ['animate__animated', 'animate__fadeIn'],
Expand Down
2 changes: 1 addition & 1 deletion src/workspaces/common/state/useWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ export const useWorkspace = (namespace, name): WorkspaceDetails => {
{
onClick: () => {
refreshWorkspace();
clearNotification(accessNotificationId.current);
clearNotification(accessNotificationId.current!);
},
},
['Click to refresh now']
Expand Down

0 comments on commit ef5e3e5

Please sign in to comment.