Skip to content

Commit 9f542ae

Browse files
authored
Merge pull request #905 from coseeian/issue#866
Add aria-live status messages for button actions to improve screen reader feedback (fixes issue #866)
2 parents f059d72 + ffb65eb commit 9f542ae

File tree

3 files changed

+98
-48
lines changed

3 files changed

+98
-48
lines changed

src/components/CodeEmbed/index.jsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState, useEffect, useRef } from "preact/hooks";
2+
import { useLiveRegion } from '../hooks/useLiveRegion';
23
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
34
import { javascript } from "@codemirror/lang-javascript";
45
import { cdnLibraryUrl, cdnSoundUrl } from "@/src/globals/globals";
@@ -25,6 +26,7 @@ import { Icon } from "../Icon";
2526
* }
2627
*/
2728
export const CodeEmbed = (props) => {
29+
const { ref: liveRegionRef, announce } = useLiveRegion();
2830
const [rendered, setRendered] = useState(false);
2931
const initialCode = props.initialValue ?? "";
3032
// Source code from Google Docs sometimes uses a unicode non-breaking space
@@ -59,6 +61,7 @@ export const CodeEmbed = (props) => {
5961
} else {
6062
setPreviewCodeString(codeString);
6163
}
64+
announce("Sketch is running");
6265
};
6366

6467
const [previewCodeString, setPreviewCodeString] = useState(codeString);
@@ -108,6 +111,7 @@ export const CodeEmbed = (props) => {
108111
className="bg-bg-gray-40"
109112
onClick={() => {
110113
setPreviewCodeString("");
114+
announce("Sketch stopped");
111115
}}
112116
ariaLabel="Stop sketch"
113117
>
@@ -148,6 +152,7 @@ export const CodeEmbed = (props) => {
148152
onClick={() => {
149153
setCodeString(initialCode);
150154
setPreviewCodeString(initialCode);
155+
announce("Code reset to initial value.");
151156
}}
152157
ariaLabel="Reset code to initial value"
153158
className="bg-white text-black"
@@ -156,6 +161,7 @@ export const CodeEmbed = (props) => {
156161
</CircleButton>
157162
</div>
158163
</div>
164+
<span ref={liveRegionRef} aria-live="polite" class="sr-only" />
159165
</div>
160166
);
161167
};
Lines changed: 62 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import { useState } from 'preact/hooks';
2+
import { useLiveRegion } from '../hooks/useLiveRegion';
23
import CircleButton from "../CircleButton";
34

45
interface CopyCodeButtonProps {
56
textToCopy: string;
7+
announceOnCopy?: string;
68
}
79

8-
export const CopyCodeButton = ({ textToCopy }: CopyCodeButtonProps) => {
10+
export const CopyCodeButton = ({
11+
textToCopy,
12+
announceOnCopy = 'Code copied to clipboard'
13+
}: CopyCodeButtonProps) => {
914
const [isCopied, setIsCopied] = useState(false);
1015

16+
const { ref: liveRegionRef, announce } = useLiveRegion<HTMLSpanElement>();
17+
1118
const copyTextToClipboard = async () => {
1219
console.log('Copy button clicked');
1320
console.log('Text to copy:', textToCopy);
@@ -16,6 +23,9 @@ export const CopyCodeButton = ({ textToCopy }: CopyCodeButtonProps) => {
1623
console.log('Using Clipboard API');
1724
await navigator.clipboard.writeText(textToCopy);
1825
console.log('Text copied successfully');
26+
27+
announce(announceOnCopy);
28+
1929
setIsCopied(true);
2030
setTimeout(() => {
2131
setIsCopied(false);
@@ -29,52 +39,56 @@ export const CopyCodeButton = ({ textToCopy }: CopyCodeButtonProps) => {
2939
console.log('Component rendered, isCopied:', isCopied);
3040

3141
return (
32-
<CircleButton
33-
onClick={() => {
34-
console.log('CircleButton clicked');
35-
copyTextToClipboard();
36-
}}
37-
ariaLabel="Copy code to clipboard"
38-
className={`bg-white ${isCopied ? 'text-green-600' : 'text-black'} transition-colors duration-200`}
39-
>
40-
{isCopied ? (
41-
<svg
42-
width="18"
43-
height="22"
44-
viewBox="0 0 24 24"
45-
fill="none"
46-
xmlns="http://www.w3.org/2000/svg"
47-
>
48-
<path
49-
d="M20 6L9 17L4 12"
50-
stroke="currentColor"
51-
strokeWidth="2"
52-
strokeLinecap="round"
53-
strokeLinejoin="round"
54-
/>
55-
</svg>
56-
) : (
57-
<svg
58-
width="18"
59-
height="22"
60-
viewBox="4 7 18 23"
61-
fill="none"
62-
xmlns="http://www.w3.org/2000/svg"
63-
>
64-
<path
65-
fillRule="evenodd"
66-
clipRule="evenodd"
67-
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"
68-
fill="currentColor"
69-
/>
70-
<path
71-
fillRule="evenodd"
72-
clipRule="evenodd"
73-
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"
74-
fill="currentColor"
75-
/>
76-
</svg>
77-
)}
78-
</CircleButton>
42+
<>
43+
<CircleButton
44+
onClick={() => {
45+
console.log('CircleButton clicked');
46+
copyTextToClipboard();
47+
}}
48+
ariaLabel="Copy code to clipboard"
49+
className={`bg-white ${isCopied ? 'text-green-600' : 'text-black'} transition-colors duration-200`}
50+
>
51+
{isCopied ? (
52+
<svg
53+
width="18"
54+
height="22"
55+
viewBox="0 0 24 24"
56+
fill="none"
57+
xmlns="http://www.w3.org/2000/svg"
58+
>
59+
<path
60+
d="M20 6L9 17L4 12"
61+
stroke="currentColor"
62+
strokeWidth="2"
63+
strokeLinecap="round"
64+
strokeLinejoin="round"
65+
/>
66+
</svg>
67+
) : (
68+
<svg
69+
width="18"
70+
height="22"
71+
viewBox="4 7 18 23"
72+
fill="none"
73+
xmlns="http://www.w3.org/2000/svg"
74+
>
75+
<path
76+
fillRule="evenodd"
77+
clipRule="evenodd"
78+
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"
79+
fill="currentColor"
80+
/>
81+
<path
82+
fillRule="evenodd"
83+
clipRule="evenodd"
84+
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"
85+
fill="currentColor"
86+
/>
87+
</svg>
88+
)}
89+
</CircleButton>
90+
{/* Visually hidden live region for accessibility announcements */}
91+
<span ref={liveRegionRef} aria-live="polite" class="sr-only" />
92+
</>
7993
);
8094
};

src/components/hooks/useLiveRegion.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useRef, useEffect } from 'preact/hooks';
2+
3+
export function useLiveRegion<T extends HTMLElement = HTMLElement>() {
4+
const ref = useRef<T | null>(null);
5+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
6+
7+
/** Clear any existing timer */
8+
const clearTimer = () => {
9+
if (timerRef.current !== null) {
10+
clearTimeout(timerRef.current);
11+
timerRef.current = null;
12+
}
13+
if (ref.current) ref.current.textContent = '';
14+
};
15+
16+
const announce = (message: string, clearMessage = 1000) => {
17+
const node = ref.current;
18+
if (!node) return;
19+
clearTimer();
20+
node.textContent = message;
21+
timerRef.current = setTimeout(() => {
22+
if (node) node.textContent = '';
23+
timerRef.current = null;
24+
}, clearMessage);
25+
};
26+
27+
useEffect(() => clearTimer, []);
28+
29+
return { ref, announce };
30+
}

0 commit comments

Comments
 (0)