From 52539154eacfb7111040611d631e372ed7e33942 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Thu, 22 Aug 2024 15:26:28 +0300 Subject: [PATCH 01/19] Added navigation hint announcement on store's idling --- package-lock.json | 40 +++++++++++++- packages/text-annotator-react/package.json | 4 +- .../TextAnnotatorPopup/TextAnnotatorPopup.tsx | 3 +- .../text-annotator-react/src/hooks/index.ts | 3 +- .../src/hooks/useAnnouncePopupOpening.ts | 55 +++++++++++++++++++ 5 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 packages/text-annotator-react/src/hooks/useAnnouncePopupOpening.ts diff --git a/package-lock.json b/package-lock.json index 4f2222bb..e64a9570 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1286,6 +1286,15 @@ "url": "^0.11.0" } }, + "node_modules/@react-aria/live-announcer": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@react-aria/live-announcer/-/live-announcer-3.3.4.tgz", + "integrity": "sha512-w8lxs35QrRrn6pBNzVfyGOeqWdxeVKf9U6bXIVwhq7rrTqRULL8jqy8RJIMfIs1s8G5FpwWYjyBOjl2g5Cu1iA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@recogito/react-text-annotator": { "resolved": "packages/text-annotator-react", "link": true @@ -1653,6 +1662,15 @@ "string-argv": "~0.3.1" } }, + "node_modules/@swc/helpers": { + "version": "0.5.12", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.12.tgz", + "integrity": "sha512-KMZNXiGibsW9kvZAO1Pam2JPTDBm+KSHMMHWdsyI/1DbIZjT2A6Gy3hblVXUMEDvUAKq+e0vL0X0o54owWji7g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/argparse": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", @@ -3868,6 +3886,12 @@ } } }, + "node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", @@ -3887,6 +3911,18 @@ "integrity": "sha512-mIDEX2ek50x0OlRgxryxsenE5XaQD4on5U2inY7RApK3SOJpofyw7uW2AyfMKkhAxXIceo2DeWGVGwyvng1GNQ==", "dev": true }, + "node_modules/unique-random": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-random/-/unique-random-4.0.0.tgz", + "integrity": "sha512-lrIvMDF5M9DOCy6j1YdNkMkAZkQAd0Bk7PWazA/Kr3cy2L0NhanxdeFmBFg+tDUirNHrIcQf3K7oIeBTCXpIFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", @@ -4404,9 +4440,11 @@ "@annotorious/core": "^3.0.0-rc.31", "@annotorious/react": "^3.0.0-rc.31", "@floating-ui/react": "^0.26.20", + "@react-aria/live-announcer": "^3.3.4", "@recogito/text-annotator": "3.0.0-rc.39", "@recogito/text-annotator-tei": "3.0.0-rc.39", - "CETEIcean": "^1.9.3" + "CETEIcean": "^1.9.3", + "unique-random": "^4.0.0" }, "devDependencies": { "@types/react-dom": "^18.3.0", diff --git a/packages/text-annotator-react/package.json b/packages/text-annotator-react/package.json index 632db0bc..16de603b 100644 --- a/packages/text-annotator-react/package.json +++ b/packages/text-annotator-react/package.json @@ -48,8 +48,10 @@ "@annotorious/core": "^3.0.0-rc.31", "@annotorious/react": "^3.0.0-rc.31", "@floating-ui/react": "^0.26.20", + "@react-aria/live-announcer": "^3.3.4", "@recogito/text-annotator": "3.0.0-rc.39", "@recogito/text-annotator-tei": "3.0.0-rc.39", - "CETEIcean": "^1.9.3" + "CETEIcean": "^1.9.3", + "unique-random": "^4.0.0" } } diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx index 98f37570..5eb8da09 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx @@ -16,7 +16,7 @@ import { import { useAnnotator, useSelection } from '@annotorious/react'; import type { TextAnnotation, TextAnnotator } from '@recogito/text-annotator'; -import { useRestoreSelectionCaret } from '../hooks'; +import { useAnnouncePopupOpening, useRestoreSelectionCaret } from '../hooks'; import './TextAnnotatorPopup.css'; interface TextAnnotationPopupProps { @@ -128,6 +128,7 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { }, [update]); useRestoreSelectionCaret({ floatingOpen: isOpen }); + useAnnouncePopupOpening({ floatingOpen: isOpen }); return isOpen && selected.length > 0 ? ( diff --git a/packages/text-annotator-react/src/hooks/index.ts b/packages/text-annotator-react/src/hooks/index.ts index 61a73d75..8442845c 100644 --- a/packages/text-annotator-react/src/hooks/index.ts +++ b/packages/text-annotator-react/src/hooks/index.ts @@ -1 +1,2 @@ -export { useRestoreSelectionCaret } from './useRestoreSelectionCaret'; +export * from './useRestoreSelectionCaret'; +export * from './useAnnouncePopupOpening'; diff --git a/packages/text-annotator-react/src/hooks/useAnnouncePopupOpening.ts b/packages/text-annotator-react/src/hooks/useAnnouncePopupOpening.ts new file mode 100644 index 00000000..3611e1c6 --- /dev/null +++ b/packages/text-annotator-react/src/hooks/useAnnouncePopupOpening.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; + +import { Origin, useAnnotationStore, useSelection } from '@annotorious/react'; +import type { TextAnnotation } from '@recogito/text-annotator'; +import { announce, clearAnnouncer, destroyAnnouncer } from '@react-aria/live-announcer'; +import { exhaustiveUniqueRandom } from 'unique-random'; + +// Generate random numbers that do not repeat until the entire range has appeared +const uniqueRandom = exhaustiveUniqueRandom(1, 300); + +export const useAnnouncePopupOpening = (args: { floatingOpen: boolean }) => { + const { floatingOpen } = args; + + const store = useAnnotationStore() + const { event } = useSelection(); + + /** + * Initialize the `LiveAnnouncer` helper + * and append live areas to the DOM + */ + useLayoutEffect(() => { + announce('', 'polite'); + return () => destroyAnnouncer() + }, []) + + + const announcePopupNavigation = useCallback(() => { + /** + * Screen reader requires messages to always be unique! + * Otherwise, the hint will be announced only a single time. + * To imitate the uniqueness w/o mutating the message - we can append spaces at the end. + */ + const spaces = Array.from({ length: uniqueRandom() }).map(() => ' ').join(''); + announce(`Press Tab to move to Highlights and Comments Dialog ${spaces}`, 'polite'); + }, []); + + const idleTimeoutRef = useRef | null>(null); + + useEffect(() => { + if (!floatingOpen || event?.type !== 'keydown') return; + + const scheduleIdleAnnouncement = () => { + const { current: idleTimeout } = idleTimeoutRef; + if (idleTimeout) + clearTimeout(idleTimeout); + + idleTimeoutRef.current = setTimeout(announcePopupNavigation, 1000); + } + + scheduleIdleAnnouncement() + store.observe(scheduleIdleAnnouncement, { origin: Origin.LOCAL }); + + return () => store.unobserve(scheduleIdleAnnouncement) + }, [store, announcePopupNavigation, floatingOpen, event?.type]); +}; From c3e469880a479c424da430a421cfe07eacc7b27d Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Thu, 22 Aug 2024 15:53:55 +0300 Subject: [PATCH 02/19] Removed message repetition on subsequent floating openings --- .../src/hooks/useAnnouncePopupOpening.ts | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/text-annotator-react/src/hooks/useAnnouncePopupOpening.ts b/packages/text-annotator-react/src/hooks/useAnnouncePopupOpening.ts index 3611e1c6..37c4eb0c 100644 --- a/packages/text-annotator-react/src/hooks/useAnnouncePopupOpening.ts +++ b/packages/text-annotator-react/src/hooks/useAnnouncePopupOpening.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import { Origin, useAnnotationStore, useSelection } from '@annotorious/react'; import type { TextAnnotation } from '@recogito/text-annotator'; @@ -15,41 +15,46 @@ export const useAnnouncePopupOpening = (args: { floatingOpen: boolean }) => { const { event } = useSelection(); /** - * Initialize the `LiveAnnouncer` helper - * and append live areas to the DOM + * Initialize the `LiveAnnouncer` class and + * its `polite` announcements live area */ useLayoutEffect(() => { announce('', 'polite'); return () => destroyAnnouncer() }, []) + /** + * Screen reader requires messages to always be unique! + * Otherwise, the hint will be announced once per page. + */ + const announcementSeed = useMemo(() => floatingOpen ? uniqueRandom() : 0, [floatingOpen]) const announcePopupNavigation = useCallback(() => { /** - * Screen reader requires messages to always be unique! - * Otherwise, the hint will be announced only a single time. - * To imitate the uniqueness w/o mutating the message - we can append spaces at the end. + * To imitate the uniqueness of the announced message + * w/o mutating it - we can append spaces at the end. */ - const spaces = Array.from({ length: uniqueRandom() }).map(() => ' ').join(''); - announce(`Press Tab to move to Highlights and Comments Dialog ${spaces}`, 'polite'); - }, []); + const uniqueSpaces = Array.from({ length: announcementSeed }).map(() => ' ').join(''); + announce(`Press Tab to move to Highlights and Comments Dialog ${uniqueSpaces}`, 'polite'); + }, [announcementSeed]); + const idleTimeoutMs = 700; const idleTimeoutRef = useRef | null>(null); useEffect(() => { if (!floatingOpen || event?.type !== 'keydown') return; const scheduleIdleAnnouncement = () => { - const { current: idleTimeout } = idleTimeoutRef; - if (idleTimeout) - clearTimeout(idleTimeout); - - idleTimeoutRef.current = setTimeout(announcePopupNavigation, 1000); + clearTimeout(idleTimeoutRef.current); + idleTimeoutRef.current = setTimeout(announcePopupNavigation, idleTimeoutMs); } scheduleIdleAnnouncement() store.observe(scheduleIdleAnnouncement, { origin: Origin.LOCAL }); - return () => store.unobserve(scheduleIdleAnnouncement) + return () => { + clearTimeout(idleTimeoutRef.current); + store.unobserve(scheduleIdleAnnouncement); + } }, [store, announcePopupNavigation, floatingOpen, event?.type]); }; From ce63a24d0ff6060ae7c4cf78f937a4b2efc83f16 Mon Sep 17 00:00:00 2001 From: Oleksandr Danylchenko Date: Thu, 22 Aug 2024 16:02:06 +0300 Subject: [PATCH 03/19] Made navigation message configurable --- .../TextAnnotatorPopup/TextAnnotatorPopup.tsx | 12 +++++--- .../src/hooks/useAnnouncePopupOpening.ts | 29 ++++++++++--------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx index 5eb8da09..c10b878c 100644 --- a/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotatorPopup/TextAnnotatorPopup.tsx @@ -1,4 +1,4 @@ -import React, { PointerEvent, ReactNode, useCallback, useEffect, useState } from 'react'; +import React, { FC, PointerEvent, ReactNode, useCallback, useEffect, useState } from 'react'; import { autoUpdate, flip, @@ -21,6 +21,8 @@ import './TextAnnotatorPopup.css'; interface TextAnnotationPopupProps { + popupNavigationMessage?: string; + popup(props: TextAnnotatorPopupProps): ReactNode; } @@ -31,7 +33,9 @@ export interface TextAnnotatorPopupProps { } -export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { +export const TextAnnotatorPopup: FC = (props) => { + + const { popup, popupNavigationMessage } = props; const r = useAnnotator(); @@ -128,7 +132,7 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { }, [update]); useRestoreSelectionCaret({ floatingOpen: isOpen }); - useAnnouncePopupOpening({ floatingOpen: isOpen }); + useAnnouncePopupOpening({ message: popupNavigationMessage, floatingOpen: isOpen }); return isOpen && selected.length > 0 ? ( @@ -151,7 +155,7 @@ export const TextAnnotatorPopup = (props: TextAnnotationPopupProps) => { style={floatingStyles} {...getFloatingProps()} {...getStopEventsPropagationProps()}> - {props.popup({ selected })} + {popup({ selected })} {/* It lets keyboard/sr users to know that the dialog closes when they focus out of its */}