From 718ab9e4ea991057476dca21a01dd3996b89e387 Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 26 Nov 2025 11:23:30 +0000 Subject: [PATCH 01/48] Build a blueprint --- .../html2/accessibility/scanMode/ideal.html | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 __tests__/html2/accessibility/scanMode/ideal.html diff --git a/__tests__/html2/accessibility/scanMode/ideal.html b/__tests__/html2/accessibility/scanMode/ideal.html new file mode 100644 index 0000000000..c6f2add8d2 --- /dev/null +++ b/__tests__/html2/accessibility/scanMode/ideal.html @@ -0,0 +1,219 @@ + + + + + + + +
+ + + + + From b98f4ff6ffa2e4dfafaf18a5daffcc03428576d4 Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 26 Nov 2025 11:59:40 +0000 Subject: [PATCH 02/48] Add focus trap --- .../html2/accessibility/scanMode/ideal.html | 102 +++++++++++++++++- 1 file changed, 97 insertions(+), 5 deletions(-) diff --git a/__tests__/html2/accessibility/scanMode/ideal.html b/__tests__/html2/accessibility/scanMode/ideal.html index c6f2add8d2..48bf4c265e 100644 --- a/__tests__/html2/accessibility/scanMode/ideal.html +++ b/__tests__/html2/accessibility/scanMode/ideal.html @@ -11,7 +11,7 @@ padding: 4px; } - .chat-message__is-active { + .chat-history:focus .chat-message__is-active { outline: dashed 2px black; } @@ -36,6 +36,15 @@ import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; + const FOCUSABLE_SELECTOR_QUERY = [ + 'a[href]', + 'button:not([disabled])', + 'textarea:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + '[tabindex]:not([tabindex="-1"])' + ].join(','); + function usePrevious(value) { const previousRef = useRef(); @@ -46,12 +55,71 @@ return previousRef.current; } - function ChatMessage({ abstract, activeMode, children, index, onLeave }) { + // TODO: Use our own implementation of , we have better UX: + // - Save last focus + // - When an element become non-focusable + // However, this implementation is better at: + // - Handle "inert" attribute + // - Handle invisible element (element without `offsetParent`) + function FocusTrap({ children, onLeave }) { + const onLeaveRef = useRefFrom(onLeave); + const rootRef = useRef(); + + const handleKeyDown = useCallback( + event => { + const container = rootRef.current; + + if (!container) { + return; + } + + if (event.key === 'Tab') { + const focusables = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR_QUERY)).filter( + element => !element.closest('[inert]') && element.offsetParent + ); + + if (focusables.length === 0) { + return; + } + + const firstElement = focusables[0]; + const lastElement = focusables.at(-1); + + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + event.stopPropagation(); + + lastElement.focus(); + } else if (!event.shiftKey && document.activeElement === lastElement) { + event.preventDefault(); + event.stopPropagation(); + + firstElement.focus(); + } + } else if (event.key === 'Escape') { + event.stopPropagation(); + + onLeaveRef.current?.(); + } + }, + [onLeaveRef] + ); + + return ( +
+ {children} +
+ ); + } + + function ChatMessage({ abstract, activeMode, children, index, onLeave, onRequestFocus }) { const bodyRef = useRef(); const bodyId = useMemo(() => crypto.randomUUID(), []); const contentId = useMemo(() => crypto.randomUUID(), []); const headerId = useMemo(() => crypto.randomUUID(), []); + const indexRef = useRefFrom(index); const onLeaveRef = useRefFrom(onLeave); + const onRequestFocusRef = useRefFrom(onRequestFocus); const isFocused = activeMode === 'focus'; const wasFocused = usePrevious(isFocused); @@ -80,27 +148,38 @@ } }, [becomingFocused]); + const handleHeaderClick = useCallback( + event => onRequestFocusRef.current?.(indexRef.current), + [indexRef, onRequestFocusRef] + ); + return (
-

+

- {children} + {children}
@@ -128,6 +207,8 @@

setIsFocused(true); } else if (event.key === 'Escape') { + setIsFocused(false); + onLeaveRef.current?.(); } }, @@ -136,9 +217,18 @@

const handleMessageLeave = useCallback(() => { rootRef.current?.focus(); + setIsFocused(false); }, [rootRef, setIsFocused]); + const handleMessageRequestFocus = useCallback( + index => { + setActiveMessageIndex(index); + setIsFocused(true); + }, + [setActiveMessageIndex, setIsFocused] + ); + return (
activeMode={activeMessageIndex === 0 ? (isFocused ? 'focus' : 'active') : undefined} index={0} onLeave={activeMessageIndex === 0 ? handleMessageLeave : undefined} + onRequestFocus={handleMessageRequestFocus} >

Hello, World!

@@ -168,6 +259,7 @@

activeMode={activeMessageIndex === 1 ? (isFocused ? 'focus' : 'active') : undefined} index={1} onLeave={activeMessageIndex === 1 ? handleMessageLeave : undefined} + onRequestFocus={handleMessageRequestFocus} >

Aloha!

From 8ab43103e667688e7c14eb11bd8a480aa58a66f2 Mon Sep 17 00:00:00 2001 From: William Wong Date: Wed, 26 Nov 2025 12:42:43 +0000 Subject: [PATCH 03/48] Simple styling --- .../html2/accessibility/scanMode/ideal.html | 51 ++++++++++++++++--- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/__tests__/html2/accessibility/scanMode/ideal.html b/__tests__/html2/accessibility/scanMode/ideal.html index 48bf4c265e..41e9d8a97c 100644 --- a/__tests__/html2/accessibility/scanMode/ideal.html +++ b/__tests__/html2/accessibility/scanMode/ideal.html @@ -3,16 +3,53 @@ @@ -274,8 +311,8 @@ }, []); return ( -
-