Skip to content

Add aria-live status messages for button actions to improve screen reader feedback (fixes issue #866) #905

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/components/CodeEmbed/index.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from "preact/hooks";
import { useLiveRegion } from '../hooks/useLiveRegion';
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
import { javascript } from "@codemirror/lang-javascript";
import { cdnLibraryUrl, cdnSoundUrl } from "@/src/globals/globals";
Expand All @@ -25,6 +26,7 @@ import { Icon } from "../Icon";
* }
*/
export const CodeEmbed = (props) => {
const { ref: liveRegionRef, announce } = useLiveRegion();
const [rendered, setRendered] = useState(false);
const initialCode = props.initialValue ?? "";
// Source code from Google Docs sometimes uses a unicode non-breaking space
Expand Down Expand Up @@ -59,6 +61,7 @@ export const CodeEmbed = (props) => {
} else {
setPreviewCodeString(codeString);
}
announce("Sketch is running");
};

const [previewCodeString, setPreviewCodeString] = useState(codeString);
Expand Down Expand Up @@ -108,6 +111,7 @@ export const CodeEmbed = (props) => {
className="bg-bg-gray-40"
onClick={() => {
setPreviewCodeString("");
announce("Sketch stopped");
}}
ariaLabel="Stop sketch"
>
Expand Down Expand Up @@ -148,6 +152,7 @@ export const CodeEmbed = (props) => {
onClick={() => {
setCodeString(initialCode);
setPreviewCodeString(initialCode);
announce("Code reset to initial value.");
}}
ariaLabel="Reset code to initial value"
className="bg-white text-black"
Expand All @@ -156,6 +161,7 @@ export const CodeEmbed = (props) => {
</CircleButton>
</div>
</div>
<span ref={liveRegionRef} aria-live="polite" class="sr-only" />
</div>
);
};
Expand Down
110 changes: 62 additions & 48 deletions src/components/CopyCodeButton/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { useState } from 'preact/hooks';
import { useLiveRegion } from '../hooks/useLiveRegion';
import CircleButton from "../CircleButton";

interface CopyCodeButtonProps {
textToCopy: string;
announceOnCopy?: string;
}

export const CopyCodeButton = ({ textToCopy }: CopyCodeButtonProps) => {
export const CopyCodeButton = ({
textToCopy,
announceOnCopy = 'Code copied to clipboard'
}: CopyCodeButtonProps) => {
const [isCopied, setIsCopied] = useState(false);

const { ref: liveRegionRef, announce } = useLiveRegion<HTMLSpanElement>();

const copyTextToClipboard = async () => {
console.log('Copy button clicked');
console.log('Text to copy:', textToCopy);
Expand All @@ -16,6 +23,9 @@ export const CopyCodeButton = ({ textToCopy }: CopyCodeButtonProps) => {
console.log('Using Clipboard API');
await navigator.clipboard.writeText(textToCopy);
console.log('Text copied successfully');

announce(announceOnCopy);

setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
Expand All @@ -29,52 +39,56 @@ export const CopyCodeButton = ({ textToCopy }: CopyCodeButtonProps) => {
console.log('Component rendered, isCopied:', isCopied);

return (
<CircleButton
onClick={() => {
console.log('CircleButton clicked');
copyTextToClipboard();
}}
ariaLabel="Copy code to clipboard"
className={`bg-white ${isCopied ? 'text-green-600' : 'text-black'} transition-colors duration-200`}
>
{isCopied ? (
<svg
width="18"
height="22"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20 6L9 17L4 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg
width="18"
height="22"
viewBox="4 7 18 23"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M 4.054 12.141 C 4.054 11.865 4.877 11.877 5.153 11.877 L 9.073 11.953 C 9.2 11.953 8.791 22.207 9.006 23.531 C 11.73 24.182 17.631 24.022 17.631 24.171 L 17.638 28.083 C 17.638 28.359 17.414 28.583 17.138 28.583 L 4.554 28.583 C 4.278 28.583 4.054 28.359 4.054 28.083 L 4.054 12.141 Z M 5.054 12.641 L 5.054 27.583 L 16.638 27.583 L 16.735 24.024 L 8.623 24.051 L 8.195 12.679 L 5.054 12.641 Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M 8.14 8.083 C 8.14 7.807 8.364 7.583 8.64 7.583 L 21.224 7.583 C 21.5 7.583 21.724 7.807 21.724 8.083 L 21.724 24.025 C 21.724 24.301 21.5 24.525 21.224 24.525 L 8.64 24.525 C 8.364 24.525 8.14 24.301 8.14 24.025 L 8.14 8.083 Z M 9.14 8.583 L 9.14 23.525 L 20.724 23.525 L 20.724 8.583 L 9.14 8.583 Z"
fill="currentColor"
/>
</svg>
)}
</CircleButton>
<>
<CircleButton
onClick={() => {
console.log('CircleButton clicked');
copyTextToClipboard();
}}
ariaLabel="Copy code to clipboard"
className={`bg-white ${isCopied ? 'text-green-600' : 'text-black'} transition-colors duration-200`}
>
{isCopied ? (
<svg
width="18"
height="22"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20 6L9 17L4 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg
width="18"
height="22"
viewBox="4 7 18 23"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M 4.054 12.141 C 4.054 11.865 4.877 11.877 5.153 11.877 L 9.073 11.953 C 9.2 11.953 8.791 22.207 9.006 23.531 C 11.73 24.182 17.631 24.022 17.631 24.171 L 17.638 28.083 C 17.638 28.359 17.414 28.583 17.138 28.583 L 4.554 28.583 C 4.278 28.583 4.054 28.359 4.054 28.083 L 4.054 12.141 Z M 5.054 12.641 L 5.054 27.583 L 16.638 27.583 L 16.735 24.024 L 8.623 24.051 L 8.195 12.679 L 5.054 12.641 Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M 8.14 8.083 C 8.14 7.807 8.364 7.583 8.64 7.583 L 21.224 7.583 C 21.5 7.583 21.724 7.807 21.724 8.083 L 21.724 24.025 C 21.724 24.301 21.5 24.525 21.224 24.525 L 8.64 24.525 C 8.364 24.525 8.14 24.301 8.14 24.025 L 8.14 8.083 Z M 9.14 8.583 L 9.14 23.525 L 20.724 23.525 L 20.724 8.583 L 9.14 8.583 Z"
fill="currentColor"
/>
</svg>
)}
</CircleButton>
{/* Visually hidden live region for accessibility announcements */}
<span ref={liveRegionRef} aria-live="polite" class="sr-only" />
</>
);
};
30 changes: 30 additions & 0 deletions src/components/hooks/useLiveRegion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useRef, useEffect } from 'preact/hooks';

export function useLiveRegion<T extends HTMLElement = HTMLElement>() {
const ref = useRef<T | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

/** Clear any existing timer */
const clearTimer = () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (ref.current) ref.current.textContent = '';
};

const announce = (message: string, clearMessage = 1000) => {
const node = ref.current;
if (!node) return;
clearTimer();
node.textContent = message;
timerRef.current = setTimeout(() => {
if (node) node.textContent = '';
timerRef.current = null;
}, clearMessage);
};

useEffect(() => clearTimer, []);

return { ref, announce };
}