Skip to content

Commit dd72a5e

Browse files
authored
feat: add useTypingAnnounce hook for managing live regions triggered by user input (#34354)
1 parent 06d989b commit dd72a5e

17 files changed

+351
-3
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "feat: add useTypingAnnounce hook for managing live regions triggered by user input",
4+
"packageName": "@fluentui/react-aria",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "feat: add useTypingAnnounce hook",
4+
"packageName": "@fluentui/react-components",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}

packages/react-components/react-aria/library/etc/react-aria.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ export function useAriaLiveAnnouncerContextValues_unstable(state: AriaLiveAnnoun
128128
// @public (undocumented)
129129
export const useHasParentActiveDescendantContext: () => boolean;
130130

131+
// @public (undocumented)
132+
export function useTypingAnnounce<TInputElement extends HTMLElement = HTMLElement>(): TypingAnnounceReturn<TInputElement>;
133+
131134
// (No @packageDocumentation comment for this package)
132135

133136
```

packages/react-components/react-aria/library/src/AriaLiveAnnouncer/useAriaLiveAnnouncer.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ describe('useAriaLiveAnnouncer', () => {
5454
const { result } = renderHook(() => useAriaLiveAnnouncer({}), { wrapper: ContextWrapper });
5555

5656
result.current.announce('message loaded');
57+
jest.advanceTimersByTime(0);
5758
expect(innerNode?.innerText).toBe('message loaded' + ANNOUNCE_SUFFIX);
5859
});
5960

@@ -105,6 +106,19 @@ describe('useAriaLiveAnnouncer', () => {
105106
);
106107
});
107108

109+
it('should handle batched messages in the same tick, and announce only the last', () => {
110+
const appendChild = jest.spyOn(liveRegionNode!, 'appendChild');
111+
const { result } = renderHook(() => useAriaLiveAnnouncer({}), { wrapper: ContextWrapper });
112+
113+
result.current.announce('message loaded', { batchId: 'test' });
114+
result.current.announce('message reloaded', { batchId: 'test' });
115+
result.current.announce('message revolutions', { batchId: 'test' });
116+
jest.advanceTimersByTime(100);
117+
118+
expect(appendChild).toHaveBeenCalledTimes(1);
119+
expect(innerNode?.innerText).toBe('message revolutions' + ANNOUNCE_SUFFIX);
120+
});
121+
108122
it('should announce batched and unbatched messages', () => {
109123
const { result } = renderHook(() => useAriaLiveAnnouncer({}), { wrapper: ContextWrapper });
110124

@@ -123,6 +137,7 @@ describe('useAriaLiveAnnouncer', () => {
123137
const { result } = renderHook(() => useAriaLiveAnnouncer({}), { wrapper: ContextWrapper });
124138

125139
result.current.announce('message resurrections');
140+
jest.advanceTimersByTime(0);
126141
expect(innerNode?.innerText).toBe('message resurrections' + ANNOUNCE_SUFFIX);
127142

128143
jest.advanceTimersByTime(ANNOUNCE_TIMEOUT);

packages/react-components/react-aria/library/src/AriaLiveAnnouncer/useDomAnnounce.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,10 @@ export const useDomAnnounce_unstable = (): AriaLiveAnnounceFn => {
8686
}
8787
};
8888

89-
runCycle();
89+
// Run the first cycle with a 0 timeout to ensure multiple messages in the same tick are handled
90+
timeoutRef.current = setAnnounceTimeout(() => {
91+
runCycle();
92+
}, 0);
9093
}, [clearAnnounceTimeout, messageQueue, setAnnounceTimeout, targetDocument]);
9194

9295
const announce: AriaLiveAnnounceFn = React.useCallback(

packages/react-components/react-aria/library/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,5 @@ export {
3333
useAriaLiveAnnouncerContextValues_unstable,
3434
} from './AriaLiveAnnouncer/index';
3535
export type { AriaLiveAnnouncerProps, AriaLiveAnnouncerState } from './AriaLiveAnnouncer/index';
36+
37+
export { useTypingAnnounce } from './useTypingAnnounce/index';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useTypingAnnounce } from './useTypingAnnounce';
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import * as React from 'react';
2+
import { useTimeout } from '@fluentui/react-utilities';
3+
import { useAnnounce, useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';
4+
import type { AnnounceOptions } from '@fluentui/react-shared-contexts';
5+
import { AriaLiveAnnounceFn } from '../AriaLiveAnnouncer/AriaLiveAnnouncer.types';
6+
7+
type Message = {
8+
message: string;
9+
options: AnnounceOptions;
10+
};
11+
12+
const valueMutationOptions = {
13+
attributes: true,
14+
subtree: true,
15+
characterData: true,
16+
attributeFilter: ['value'],
17+
};
18+
19+
interface TypingAnnounceReturn<TInputElement extends HTMLElement = HTMLElement> {
20+
typingAnnounce: AriaLiveAnnounceFn;
21+
inputRef: React.RefObject<TInputElement>;
22+
}
23+
24+
export function useTypingAnnounce<
25+
TInputElement extends HTMLElement = HTMLElement,
26+
>(): TypingAnnounceReturn<TInputElement> {
27+
const { targetDocument } = useFluent();
28+
const { announce } = useAnnounce();
29+
30+
const inputRef = React.useRef<TInputElement>(null);
31+
const observer = React.useRef<MutationObserver>();
32+
const [setTypingTimeout, clearTypingTimeout] = useTimeout();
33+
const messageQueue = React.useRef<Message[]>([]);
34+
35+
const callback: MutationCallback = React.useCallback(
36+
(mutationList, mutationObserver) => {
37+
setTypingTimeout(() => {
38+
messageQueue.current.forEach(({ message, options }) => {
39+
announce(message, options);
40+
});
41+
messageQueue.current.length = 0;
42+
mutationObserver.disconnect();
43+
}, 500);
44+
},
45+
[announce, setTypingTimeout],
46+
);
47+
48+
const typingAnnounce: AriaLiveAnnounceFn = React.useCallback(
49+
(message: string, options: AnnounceOptions = {}) => {
50+
messageQueue.current.push({ message, options });
51+
52+
if (inputRef.current && observer.current) {
53+
observer.current.observe(inputRef.current, valueMutationOptions);
54+
}
55+
56+
setTypingTimeout(() => {
57+
observer.current && callback([], observer.current);
58+
}, 500);
59+
},
60+
[callback, inputRef, setTypingTimeout],
61+
);
62+
63+
React.useEffect(() => {
64+
const win = targetDocument?.defaultView;
65+
if (!win) {
66+
return;
67+
}
68+
69+
if (!observer.current) {
70+
observer.current = new win.MutationObserver(callback);
71+
}
72+
73+
return () => {
74+
// Clean up the observer when the component unmounts
75+
if (observer.current) {
76+
observer.current.disconnect();
77+
clearTypingTimeout();
78+
}
79+
};
80+
}, [callback, clearTypingTimeout, targetDocument]);
81+
82+
return { typingAnnounce, inputRef };
83+
}

packages/react-components/react-aria/stories/src/AriaLiveAnnouncer/AriaLiveAnnouncerDescription.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
`AriaLiveAnnouncer` provides a sample implementation of an `aria-live` region that can be used to announce messages to screen readers.
22

3-
It injects announcements into the DOM, and also exposes a function (to its children in a React tree) that can be used to announce messages. It's designed to be used with `useAnnounce()` hook.
3+
It injects announcements into the DOM, and also exposes a function (to its children in a React tree) that can be used to announce messages. It's designed to be used with `useAnnounce()` or `useTypingAnnounce()` hooks.
44

55
For debugging information, check our [Debugging Notifications](./?path=/docs/concepts-developer-accessibility-notification-debugging--docs) docs page.
66

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import * as React from 'react';
2+
import {
3+
AriaLiveAnnouncer,
4+
Field,
5+
JSXElement,
6+
makeStyles,
7+
tokens,
8+
useId,
9+
useTypingAnnounce,
10+
} from '@fluentui/react-components';
11+
12+
const useStyles = makeStyles({
13+
contentEditable: {
14+
border: `1px solid ${tokens.colorNeutralStroke1}`,
15+
borderRadius: '4px',
16+
padding: '0.5em',
17+
},
18+
});
19+
20+
const charCountMessage = (count: number) => {
21+
// the threshold for announcing character count updates is 10 characters
22+
const threshold = 10;
23+
24+
// the maxLength is 100
25+
const maxLength = 100;
26+
27+
if (count >= maxLength) {
28+
return `You have reached the maximum character limit, ${maxLength}`;
29+
} else if (maxLength - count === 1) {
30+
return `You have 1 character remaining`;
31+
} else if (maxLength - count <= threshold) {
32+
return `You have ${maxLength - count} characters remaining`;
33+
} else {
34+
return undefined;
35+
}
36+
};
37+
38+
export const ContentEditable = (): JSXElement => {
39+
const [count, setCount] = React.useState(0);
40+
const styles = useStyles();
41+
42+
const announceId = useId('charCount');
43+
44+
const { typingAnnounce, inputRef } = useTypingAnnounce();
45+
46+
const onInput = (ev: React.FormEvent<HTMLSpanElement>) => {
47+
const charCount = (ev.target as HTMLSpanElement).textContent?.length ?? 0;
48+
setCount(charCount);
49+
50+
const message = charCountMessage(charCount);
51+
if (message) {
52+
// pass typingAnnounce a batchId to ensure new charCount updates override old ones
53+
typingAnnounce(message, { batchId: announceId });
54+
}
55+
};
56+
57+
return (
58+
<AriaLiveAnnouncer>
59+
<Field label="A contenteditable div with a maxlength of 100" hint={`${count}/100`}>
60+
{fieldProps => (
61+
<span contentEditable ref={inputRef} onInput={onInput} className={styles.contentEditable} {...fieldProps} />
62+
)}
63+
</Field>
64+
</AriaLiveAnnouncer>
65+
);
66+
};
67+
68+
ContentEditable.parameters = {
69+
docs: {
70+
description: {
71+
story: `Updates on remaining characters will begin being announced once you are within 10 characters of the max length.`,
72+
},
73+
},
74+
};

0 commit comments

Comments
 (0)