import { debugFlags, Editor, TLGeoShape, 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 { TldrawUiButton } from './primitives/Button/TldrawUiButton' 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]) 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('a11y.skip-to-main-content')} ) } /** @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 let a11yLive = '' const numShapes = selectedShapeIds.length if (numShapes > 1) { a11yLive = msg('a11y.multiple-shapes').replace('{num}', numShapes.toString()) } else if (numShapes === 1) { const shapeId = selectedShapeIds[0] const shape = editor.getShape(shapeId) if (!shape) return '' const shapeUtil = editor.getShapeUtil(shape.type) const isMedia = ['image', 'video'].includes(shape.type) // Yeah, yeah this is a bit of a hack, we should get better translations. let shapeType = '' if (shape.type === 'geo') { shapeType = msg(`geo-style.${(shape as TLGeoShape).props.geo}`) } else if (isMedia) { shapeType = msg(`a11y.shape-${shape.type}`) } else { shapeType = msg(`tool.${shape.type}`) } // Get shape index in reading order const readingOrderShapes = editor.getCurrentPageShapesInReadingOrder() const currentShapeIndex = (readingOrderShapes.findIndex((s) => s.id === shapeId) + 1).toString() const totalShapes = readingOrderShapes.length.toString() const shapeIndex = msg('a11y.shape-index') .replace('{num}', currentShapeIndex) .replace('{total}', totalShapes) // Get describing text (alt text or shape text) const describingText = shapeUtil.getAriaDescriptor(shape) || shapeUtil.getText(shape) || '' // Build the full announcement a11yLive = (describingText ? `${describingText}, ` : '') + `${shapeType}. ${shapeIndex}` } return a11yLive } /** @public */ export const 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]) }