diff --git a/package-lock.json b/package-lock.json index a432e9ac..11afee31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1390,6 +1390,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 @@ -1812,6 +1821,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", @@ -3957,6 +3975,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.7.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", @@ -3985,6 +4009,18 @@ "dev": true, "license": "MIT" }, + "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.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -5504,9 +5540,11 @@ "@annotorious/core": "^3.0.14", "@annotorious/react": "^3.0.14", "@floating-ui/react": "^0.27.1", + "@react-aria/live-announcer": "^3.3.4", "@recogito/text-annotator": "3.0.0-rc.53", "@recogito/text-annotator-tei": "3.0.0-rc.53", - "CETEIcean": "^1.9.3" + "CETEIcean": "^1.9.3", + "unique-random": "^4.0.0" }, "devDependencies": { "@types/react-dom": "^18.3.5", diff --git a/packages/text-annotator-react/package.json b/packages/text-annotator-react/package.json index 9d56b3a2..02e8054b 100644 --- a/packages/text-annotator-react/package.json +++ b/packages/text-annotator-react/package.json @@ -47,8 +47,10 @@ "@annotorious/core": "^3.0.14", "@annotorious/react": "^3.0.14", "@floating-ui/react": "^0.27.1", + "@react-aria/live-announcer": "^3.3.4", "@recogito/text-annotator": "3.0.0-rc.53", "@recogito/text-annotator-tei": "3.0.0-rc.53", - "CETEIcean": "^1.9.3" + "CETEIcean": "^1.9.3", + "unique-random": "^4.0.0" } -} \ No newline at end of file +} diff --git a/packages/text-annotator-react/src/TextAnnotationPopup/TextAnnotationPopup.tsx b/packages/text-annotator-react/src/TextAnnotationPopup/TextAnnotationPopup.tsx index f0493149..e7deafc9 100644 --- a/packages/text-annotator-react/src/TextAnnotationPopup/TextAnnotationPopup.tsx +++ b/packages/text-annotator-react/src/TextAnnotationPopup/TextAnnotationPopup.tsx @@ -1,4 +1,5 @@ -import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import { FC, ReactNode, useCallback, useRef, useEffect, useMemo, useState } from 'react'; + import { useAnnotator, useSelection } from '@annotorious/react'; import { NOT_ANNOTATABLE_CLASS, @@ -7,7 +8,6 @@ import { type TextAnnotator, } from '@recogito/text-annotator'; -import { isMobile } from './isMobile'; import { arrow, autoUpdate, @@ -25,10 +25,15 @@ import { useRole } from '@floating-ui/react'; +import { isMobile } from './isMobile'; +import { useAnnouncePopupNavigation } from '../hooks'; + import './TextAnnotationPopup.css'; interface TextAnnotationPopupProps { + ariaNavigationMessage?: string; + ariaCloseWarning?: string; arrow?: boolean; @@ -55,7 +60,7 @@ const toViewportBounds = (annotationBounds: DOMRect, container: HTMLElement): DO return new DOMRect(left + containerBounds.left, top + containerBounds.top, right - left, bottom - top); } -export const TextAnnotationPopup = (props: TextAnnotationPopupProps) => { +export const TextAnnotationPopup: FC = (props) => { const r = useAnnotator(); @@ -64,6 +69,14 @@ export const TextAnnotationPopup = (props: TextAnnotationPopupProps) => { const annotation = selected[0]?.annotation; const [isOpen, setOpen] = useState(selected?.length > 0); + const handleClose = () => r?.cancelSelected(); + + const [isFocused, setFocused] = useState(false); + const handleFocus = useCallback(() => setFocused(true), []); + const handleBlur = useCallback(() => setFocused(false), []); + useEffect(() => { + if (!isOpen) handleBlur(); + }, [isOpen, handleBlur]); const arrowRef = useRef(null); @@ -73,7 +86,7 @@ export const TextAnnotationPopup = (props: TextAnnotationPopupProps) => { onOpenChange: (open, _event, reason) => { if (!open && (reason === 'escape-key' || reason === 'focus-out')) { setOpen(open); - r?.cancelSelected(); + handleClose(); } }, middleware: [ @@ -143,7 +156,15 @@ export const TextAnnotationPopup = (props: TextAnnotationPopupProps) => { return (event?.type === 'keyup' || event?.type === 'contextmenu' || isMobile()) ? -1 : 0; }, [event]); - const onClose = () => r?.cancelSelected(); + /** + * Announce the navigation hint only on the keyboard selection, + * because the focus isn't shifted to the popup automatically then + */ + useAnnouncePopupNavigation({ + disabled: isFocused, + floatingOpen: isOpen, + message: props.ariaNavigationMessage, + }); return isOpen && annotation ? ( @@ -157,13 +178,16 @@ export const TextAnnotationPopup = (props: TextAnnotationPopupProps) => { className={`a9s-popup r6o-popup annotation-popup r6o-text-popup ${NOT_ANNOTATABLE_CLASS}`} ref={refs.setFloating} style={floatingStyles} - {...getFloatingProps(getStopEventsPropagationProps())}> + {...getFloatingProps({ + onFocus: handleFocus, + onBlur: handleBlur, + ...getStopEventsPropagationProps() + })}> {props.popup({ annotation: selected[0].annotation, editable: selected[0].editable, event })} - {props.arrow && ( { {...(props.arrowProps || {})} /> )} - diff --git a/packages/text-annotator-react/src/hooks/index.ts b/packages/text-annotator-react/src/hooks/index.ts new file mode 100644 index 00000000..8a69a8fe --- /dev/null +++ b/packages/text-annotator-react/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './useAnnouncePopupNavigation'; diff --git a/packages/text-annotator-react/src/hooks/useAnnouncePopupNavigation.ts b/packages/text-annotator-react/src/hooks/useAnnouncePopupNavigation.ts new file mode 100644 index 00000000..314143ba --- /dev/null +++ b/packages/text-annotator-react/src/hooks/useAnnouncePopupNavigation.ts @@ -0,0 +1,75 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; + +import { Origin, useAnnotationStore, useSelection } from '@annotorious/react'; +import type { TextAnnotation } from '@recogito/text-annotator'; +import { announce, destroyAnnouncer } from '@react-aria/live-announcer'; +import { exhaustiveUniqueRandom } from 'unique-random'; + +interface AnnouncePopupOpeningArgs { + message?: string; + idle?: number; + floatingOpen: boolean; + floatingFocused?: boolean; + disabled?: boolean; +} + +export const useAnnouncePopupNavigation = (args: AnnouncePopupOpeningArgs) => { + const { + message = 'Press Tab to move to Notes Dialog', + idle = 700, + floatingOpen, + disabled = false + } = args; + + const store = useAnnotationStore(); + const { event } = useSelection(); + + // Generate random numbers that do not repeat until the entire 1-10000 range has appeared + const uniqueRandom = useCallback(exhaustiveUniqueRandom(1, 10000), []); + + /** + * Initialize the `LiveAnnouncer` class and + * its `polite` announcements live area + */ + useLayoutEffect(() => { + if (disabled || !message) return; + + announce('', 'polite'); + return () => destroyAnnouncer(); + }, [disabled, message]); + + /** + * 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(() => { + /** + * To imitate the uniqueness of the announced message + * w/o mutating it - we can append spaces at the end. + */ + const uniqueSpaces = Array.from({ length: announcementSeed }).map(() => ' ').join(''); + announce(`${message} ${uniqueSpaces}`, 'polite'); + }, [message, announcementSeed]); + + const idleTimeoutRef = useRef | null>(null); + + useEffect(() => { + if (disabled || !floatingOpen || !message) return; + + const scheduleIdleAnnouncement = () => { + clearTimeout(idleTimeoutRef.current); + idleTimeoutRef.current = setTimeout(announcePopupNavigation, idle); + }; + + scheduleIdleAnnouncement(); + store.observe(scheduleIdleAnnouncement, { origin: Origin.LOCAL }); + + return () => { + clearTimeout(idleTimeoutRef.current); + store.unobserve(scheduleIdleAnnouncement); + }; + }, [disabled, floatingOpen, event?.type, message, announcePopupNavigation, store]); + +}; diff --git a/packages/text-annotator-react/src/index.ts b/packages/text-annotator-react/src/index.ts index f67af5d3..8fb99d45 100644 --- a/packages/text-annotator-react/src/index.ts +++ b/packages/text-annotator-react/src/index.ts @@ -1,4 +1,5 @@ export * from './tei'; +export * from './hooks'; export * from './TextAnnotator'; export * from './TextAnnotationPopup'; export * from './TextAnnotatorPlugin';