Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Desktop: Improve notification accessibility #11752

Open
wants to merge 22 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
405bf70
Desktop: Make trash notification more accessible
personalizedrefrigerator Jan 29, 2025
f2dcd14
Make the fade-out animation more closely match the original Notyf
personalizedrefrigerator Jan 29, 2025
441ab40
Use new notification component for plugin notifications
personalizedrefrigerator Jan 29, 2025
0beb2db
Use custom notifications for updates
personalizedrefrigerator Jan 29, 2025
fe42ddd
Refactoring
personalizedrefrigerator Jan 29, 2025
e49eb9c
Better RTL support
personalizedrefrigerator Jan 29, 2025
1bc85bb
Remove notyf
personalizedrefrigerator Jan 29, 2025
b38db86
Fix notifications not shown in secondary windows
personalizedrefrigerator Jan 30, 2025
adeb4ca
Commenting and a test
personalizedrefrigerator Jan 30, 2025
9b27501
Reset changes to licenses.md
personalizedrefrigerator Jan 30, 2025
94b5f76
Fix low contrast in dark mode
personalizedrefrigerator Jan 30, 2025
c9b1826
Accessibility: Allow showing historical notifications
personalizedrefrigerator Jan 30, 2025
b812f7d
Code cleanup
personalizedrefrigerator Jan 30, 2025
57fdb11
Fix missing icon labels, update roles
personalizedrefrigerator Jan 30, 2025
c9a99b3
Adjust ARIA for the notifications group
personalizedrefrigerator Jan 31, 2025
d26cc44
Use overflow:clip to prevent accessibility tools from scrolling popup
personalizedrefrigerator Jan 31, 2025
86209f4
Fix popups still in the DOM after dismissal
personalizedrefrigerator Jan 31, 2025
a309ce1
Merge remote-tracking branch 'upstream/dev' into pr/desktop/accessibi…
personalizedrefrigerator Feb 10, 2025
9d7fa03
Remove notification history dialog for now
personalizedrefrigerator Feb 10, 2025
0e16377
Merge remote-tracking branch 'upstream/dev' into pr/desktop/accessibi…
personalizedrefrigerator Feb 19, 2025
4d7f91e
Adjust padding
personalizedrefrigerator Feb 19, 2025
d181cd9
Merge branch 'dev' into pr/desktop/accessibility/popup-accessibility
personalizedrefrigerator Feb 19, 2025
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
6 changes: 5 additions & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -353,13 +353,16 @@ packages/app-desktop/gui/NoteSearchBar.js
packages/app-desktop/gui/NoteStatusBar.js
packages/app-desktop/gui/NoteTextViewer.js
packages/app-desktop/gui/NoteToolbar/NoteToolbar.js
packages/app-desktop/gui/NotyfContext.js
packages/app-desktop/gui/OneDriveLoginScreen.js
packages/app-desktop/gui/PasswordInput/LabelledPasswordInput.js
packages/app-desktop/gui/PasswordInput/PasswordInput.js
packages/app-desktop/gui/PasswordInput/types.js
packages/app-desktop/gui/PdfViewer.js
packages/app-desktop/gui/PluginNotification/PluginNotification.js
packages/app-desktop/gui/PopupNotification/NotificationItem.js
packages/app-desktop/gui/PopupNotification/PopupNotificationList.js
packages/app-desktop/gui/PopupNotification/PopupNotificationProvider.js
packages/app-desktop/gui/PopupNotification/types.js
packages/app-desktop/gui/PromptDialog.js
packages/app-desktop/gui/ResizableLayout/LayoutItemContainer.js
packages/app-desktop/gui/ResizableLayout/MoveButtons.js
Expand Down Expand Up @@ -421,6 +424,7 @@ packages/app-desktop/gui/ToolbarBase.js
packages/app-desktop/gui/ToolbarButton/ToolbarButton.js
packages/app-desktop/gui/ToolbarSpace.js
packages/app-desktop/gui/TrashNotification/TrashNotification.js
packages/app-desktop/gui/TrashNotification/TrashNotificationMessage.js
packages/app-desktop/gui/UpdateNotification/UpdateNotification.js
packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js
Expand Down
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -328,13 +328,16 @@ packages/app-desktop/gui/NoteSearchBar.js
packages/app-desktop/gui/NoteStatusBar.js
packages/app-desktop/gui/NoteTextViewer.js
packages/app-desktop/gui/NoteToolbar/NoteToolbar.js
packages/app-desktop/gui/NotyfContext.js
packages/app-desktop/gui/OneDriveLoginScreen.js
packages/app-desktop/gui/PasswordInput/LabelledPasswordInput.js
packages/app-desktop/gui/PasswordInput/PasswordInput.js
packages/app-desktop/gui/PasswordInput/types.js
packages/app-desktop/gui/PdfViewer.js
packages/app-desktop/gui/PluginNotification/PluginNotification.js
packages/app-desktop/gui/PopupNotification/NotificationItem.js
packages/app-desktop/gui/PopupNotification/PopupNotificationList.js
packages/app-desktop/gui/PopupNotification/PopupNotificationProvider.js
packages/app-desktop/gui/PopupNotification/types.js
packages/app-desktop/gui/PromptDialog.js
packages/app-desktop/gui/ResizableLayout/LayoutItemContainer.js
packages/app-desktop/gui/ResizableLayout/MoveButtons.js
Expand Down Expand Up @@ -396,6 +399,7 @@ packages/app-desktop/gui/ToolbarBase.js
packages/app-desktop/gui/ToolbarButton/ToolbarButton.js
packages/app-desktop/gui/ToolbarSpace.js
packages/app-desktop/gui/TrashNotification/TrashNotification.js
packages/app-desktop/gui/TrashNotification/TrashNotificationMessage.js
packages/app-desktop/gui/UpdateNotification/UpdateNotification.js
packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js
Expand Down
2 changes: 1 addition & 1 deletion packages/app-desktop/gui/MainScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -785,7 +785,7 @@ class MainScreenComponent extends React.Component<Props, State> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
dispatch={this.props.dispatch as any}
/>
<UpdateNotification themeId={this.props.themeId} />
<UpdateNotification />
<PluginNotification
themeId={this.props.themeId}
toast={this.props.toast}
Expand Down
20 changes: 0 additions & 20 deletions packages/app-desktop/gui/NotyfContext.tsx

This file was deleted.

25 changes: 11 additions & 14 deletions packages/app-desktop/gui/PluginNotification/PluginNotification.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useContext, useMemo } from 'react';
import NotyfContext from '../NotyfContext';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import * as React from 'react';
import { useContext, useEffect, useMemo } from 'react';
import { Toast, ToastType } from '@joplin/lib/services/plugins/api/types';
import { INotyfNotificationOptions } from 'notyf';
import { PopupNotificationContext } from '../PopupNotification/PopupNotificationProvider';
import { NotificationType } from '../PopupNotification/types';

const emptyToast = (): Toast => {
return {
Expand All @@ -19,26 +19,23 @@ interface Props {
}

export default (props: Props) => {
const notyfContext = useContext(NotyfContext);
const popupManager = useContext(PopupNotificationContext);

const toast = useMemo(() => {
const toast: Toast = props.toast ? props.toast : emptyToast();
return toast;
}, [props.toast]);

useAsyncEffect(async () => {
useEffect(() => {
if (!toast.message) return;

const options: Partial<INotyfNotificationOptions> = {
type: toast.type,
message: toast.message,
duration: toast.duration,
};

notyfContext.open(options);
popupManager.createPopup(() => toast.message, {
type: toast.type as string as NotificationType,
}).scheduleDismiss(toast.duration);
// toast.timestamp needs to be included in the dependency list to allow
// showing multiple toasts with the same message, one after another.
// See https://github.com/laurent22/joplin/issues/11783
}, [toast.message, toast.duration, toast.type, toast.timestamp, notyfContext]);
}, [toast.message, toast.duration, toast.type, toast.timestamp, popupManager]);

return <div style={{ display: 'none' }}/>;
};
52 changes: 52 additions & 0 deletions packages/app-desktop/gui/PopupNotification/NotificationItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as React from 'react';
import { NotificationType } from './types';
import { _ } from '@joplin/lib/locale';

interface Props {
children: React.ReactNode;
key: string;
type: NotificationType;
dismissing: boolean;
popup: boolean;
}

const NotificationItem: React.FC<Props> = props => {
const [iconClassName, iconLabel] = (() => {
if (props.type === NotificationType.Success) {
return ['fas fa-check', _('Success')];
}
if (props.type === NotificationType.Error) {
return ['fas fa-times', _('Error')];
}
if (props.type === NotificationType.Info) {
return ['fas fa-info', _('Info')];
}
return ['', ''];
})();

const containerModifier = (() => {
if (props.type === NotificationType.Success) return '-success';
if (props.type === NotificationType.Error) return '-error';
if (props.type === NotificationType.Info) return '-info';
return '';
})();

const icon = <i
role='img'
aria-label={iconLabel}
className={`icon ${iconClassName}`}
/>;

return <li
role={props.popup ? 'alert' : undefined}
className={`popup-notification-item ${containerModifier} ${props.dismissing ? '-dismissing' : ''}`}
>
{iconClassName ? icon : null}
<div className='ripple'/>
<div className='content'>
{props.children}
</div>
</li>;
};

export default NotificationItem;
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as React from 'react';
import { VisibleNotificationsContext } from './PopupNotificationProvider';
import NotificationItem from './NotificationItem';
import { useContext } from 'react';
import { _ } from '@joplin/lib/locale';

interface Props {}

// This component displays the popups managed by PopupNotificationContext.
// This allows popups to be shown in multiple windows at the same time.
const PopupNotificationList: React.FC<Props> = () => {
const popupSpecs = useContext(VisibleNotificationsContext);
const popups = [];
for (const spec of popupSpecs) {
if (spec.dismissed) continue;

popups.push(
<NotificationItem
key={spec.key}
type={spec.type}
dismissing={!!spec.dismissAt}
popup={true}
>{spec.content()}</NotificationItem>,
);
}
popups.reverse();

if (popups.length) {
return <ul
className='popup-notification-list -overlay'
role='group'
aria-label={_('Notifications')}
>
{popups}
</ul>;
} else {
return null;
}
};

export default PopupNotificationList;
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import * as React from 'react';
import { createContext, useMemo, useRef, useState } from 'react';
import { NotificationType, PopupHandle, PopupControl as PopupManager } from './types';
import { Hour, msleep } from '@joplin/utils/time';

export const PopupNotificationContext = createContext<PopupManager|null>(null);
export const VisibleNotificationsContext = createContext<PopupSpec[]>([]);

interface Props {
children: React.ReactNode;
}

interface PopupSpec {
key: string;
dismissAt?: number;
dismissed: boolean;
type: NotificationType;
content: ()=> React.ReactNode;
}

const PopupNotificationProvider: React.FC<Props> = props => {
const [popupSpecs, setPopupSpecs] = useState<PopupSpec[]>([]);
const nextPopupKey = useRef(0);

const popupManager = useMemo((): PopupManager => {
const removeOldPopups = () => {
// The WCAG allows dismissing notifications older than 20 hours.
setPopupSpecs(popups => popups.filter(popup => {
if (!popup.dismissed) {
return true;
}

const dismissedRecently = popup.dismissAt > performance.now() - Hour * 20;
return dismissedRecently;
}));
};

const removePopupWithKey = (key: string) => {
setPopupSpecs(popups => popups.filter(p => p.key !== key));
};

type UpdatePopupCallback = (popup: PopupSpec)=> PopupSpec;
const updatePopupWithKey = (key: string, updateCallback: UpdatePopupCallback) => {
setPopupSpecs(popups => popups.map(p => {
if (p.key === key) {
return updateCallback(p);
} else {
return p;
}
}));
};

const dismissAnimationDelay = 600;
const dismissPopup = async (key: string) => {
// Start the dismiss animation
updatePopupWithKey(key, popup => ({
...popup,
dismissAt: performance.now() + dismissAnimationDelay,
}));

await msleep(dismissAnimationDelay);

updatePopupWithKey(key, popup => ({
...popup,
dismissed: true,
}));
removeOldPopups();
};

const dismissAndRemovePopup = async (key: string) => {
await dismissPopup(key);
removePopupWithKey(key);
};

const manager: PopupManager = {
createPopup: (content, { type } = {}): PopupHandle => {
const key = `popup-${nextPopupKey.current++}`;
const newPopup: PopupSpec = {
key,
content,
type,
dismissed: false,
};

setPopupSpecs(popups => {
const newPopups = [...popups];

// Replace the existing popup, if it exists
const insertIndex = newPopups.findIndex(p => p.key === key);
if (insertIndex === -1) {
newPopups.push(newPopup);
} else {
newPopups.splice(insertIndex, 1, newPopup);
}

return newPopups;
});

const handle: PopupHandle = {
remove() {
void dismissAndRemovePopup(key);
},
scheduleDismiss(delay = 5_500) {
setTimeout(() => {
void dismissPopup(key);
}, delay);
},
};
return handle;
},
};
return manager;
}, []);

return <PopupNotificationContext.Provider value={popupManager}>
<VisibleNotificationsContext.Provider value={popupSpecs}>
{props.children}
</VisibleNotificationsContext.Provider>
</PopupNotificationContext.Provider>;
};

export default PopupNotificationProvider;
22 changes: 22 additions & 0 deletions packages/app-desktop/gui/PopupNotification/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import * as React from 'react';

export type PopupHandle = {
remove(): void;
scheduleDismiss(delay?: number): void;
};

export enum NotificationType {
Info = 'info',
Success = 'success',
Error = 'error',
}

export type NotificationContentCallback = ()=> React.ReactNode;

export interface PopupOptions {
type?: NotificationType;
}

export interface PopupControl {
createPopup(content: NotificationContentCallback, props?: PopupOptions): PopupHandle;
}
Loading
Loading