import { debugFlags, defineMessages, Editor, TLShapeId, unsafe__withoutCapture, useContainer, useEditor, useMaybeEditor, useReactor, useValue, } from '@tldraw/editor' import { memo, MouseEvent, useCallback, useEffect, useRef } from 'react' import { useA11y } from '../context/a11y' import { useTranslation } from '../hooks/useTranslation/useTranslation' import { suppressBackToContent } from './HelperButtons/BackToContent' import { TldrawUiButton } from './primitives/Button/TldrawUiButton' const messages = defineMessages({ a11yMultipleShapes: { id: 'a11y.multiple-shapes', // Only ever read with two or more shapes, so English shows one form. Still a plural, because // other languages distinguish within "many" — see the gate in `internal/scripts/i18n-extract.ts`. defaultMessage: '{num, plural, one {# shape selected} other {# shapes selected}}', }, a11yShapeIndex: { id: 'a11y.shape-index', defaultMessage: '{num} of {total}' }, a11ySkipToMainContent: { id: 'a11y.skip-to-main-content', defaultMessage: 'Move focus to canvas', }, a11yStatus: { id: 'a11y.status', defaultMessage: 'Status' }, }) export function SkipToMainContent() { const editor = useEditor() const msg = useTranslation() const button = useRef(null) const handleNavigateToFirstShape = useCallback( (e: MouseEvent | KeyboardEvent) => { editor.markEventAsHandled(e) button.current?.blur() const shapes = editor.getCurrentPageShapesInReadingOrder() if (!shapes.length) return editor.setSelectedShapes([shapes[0].id]) suppressBackToContent(editor, editor.options.animationMediumMs) editor.zoomToSelectionIfOffscreen(256, { animation: { duration: editor.options.animationMediumMs, }, inset: 0, }) // N.B. If we don't do this, then we go into editing mode for some reason... // Not sure of a better solution at the moment... editor.timers.setTimeout(() => editor.getContainer().focus(), 100) }, [editor] ) return ( {msg(messages.a11ySkipToMainContent)} ) } /** @public @react */ export const DefaultA11yAnnouncer = memo(function TldrawUiA11yAnnouncer() { const a11y = useA11y() const translation = useTranslation() const msg = useValue('a11y-msg', () => a11y.currentMsg.get(), []) useA11yDebug(msg.msg) useSelectedShapesAnnouncer() return ( msg.msg && (
{msg.msg}
) ) }) /** * Core function to generate accessibility announcements for selected shapes * @public */ export function generateShapeAnnouncementMessage(args: { editor: Editor selectedShapeIds: TLShapeId[] msg(id: string, values?: Record): string }) { const { editor, selectedShapeIds, msg } = args const numShapes = selectedShapeIds.length if (numShapes > 1) { return msg(messages.a11yMultipleShapes.id, { num: numShapes }) } if (numShapes !== 1) return '' const shapeId = selectedShapeIds[0] const shape = editor.getShape(shapeId) if (!shape) return '' const shapeUtil = editor.getShapeUtil(shape.type) // Each shape names itself. Only the convention is left here: a shape that doesn't override // `getShapeName` is announced by its tool's label, which is how every built-in but geo, the // two media shapes and bookmark gets its name. const shapeType = shapeUtil.getShapeName(shape) ?? msg(`tool.${shape.type}`) // A selected shape outside the page's reading order (a container's child, or canTabTo // false) has no position to announce, and "0 of N" would be wrong const readingOrderShapes = editor.getCurrentPageShapesInReadingOrder() const indexInReadingOrder = readingOrderShapes.findIndex((s) => s.id === shapeId) const shapeIndex = indexInReadingOrder === -1 ? '' : ' ' + msg(messages.a11yShapeIndex.id, { num: indexInReadingOrder + 1, total: readingOrderShapes.length, }) // Get describing text (alt text or shape text) const describingText = shapeUtil.getAriaDescriptor(shape) || shapeUtil.getText(shape) || '' // Build the full announcement return (describingText ? `${describingText}, ` : '') + `${shapeType}.${shapeIndex}` } /** @public */ export function useSelectedShapesAnnouncer() { const editor = useMaybeEditor() const a11y = useA11y() const msg = useTranslation() const rPrevSelectedShapeIds = useRef([]) useReactor( 'announce selection', () => { if (!editor) return const isInSelecting = editor.isIn('select.idle') if (isInSelecting) { const selectedShapeIds = editor.getSelectedShapeIds() if (selectedShapeIds !== rPrevSelectedShapeIds.current) { rPrevSelectedShapeIds.current = selectedShapeIds unsafe__withoutCapture(() => { const a11yLive = generateShapeAnnouncementMessage({ editor, selectedShapeIds, msg, }) if (a11yLive) { a11y.announce({ msg: a11yLive }) } }) } } }, [editor, a11y, msg] ) } const useA11yDebug = (msg: string | undefined) => { const container = useContainer() useEffect(() => { if (debugFlags.a11y.get()) { const log = (msg: string) => { // eslint-disable-next-line no-console console.debug( `%ca11y%c: ${msg}`, `color: white; background: #40C057; padding: 2px;border-radius: 3px;`, 'font-weight: normal' ) } const doc = container.ownerDocument const handleKeyUp = (e: KeyboardEvent) => { const el = doc.activeElement if (e.key === 'Tab' && el && el !== doc.body && !el.classList.contains('tl-container')) { const label = el.getAttribute('aria-label') || el.getAttribute('title') || el.textContent if (label) { log(label) } } } if (msg) { log(msg) } doc.addEventListener('keyup', handleKeyUp) return () => doc.removeEventListener('keyup', handleKeyUp) } return undefined }, [container, msg]) }