Skip to content
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: add AriaLiveAnnouncer component",
"packageName": "@fluentui/react-aria",
"email": "olfedias@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: add AriaLiveAnnouncer component",
"packageName": "@fluentui/react-components",
"email": "olfedias@microsoft.com",
"dependentChangeType": "patch"
}
24 changes: 24 additions & 0 deletions packages/react-components/react-aria/etc/react-aria.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

```ts

import type { AnnounceContextValue } from '@fluentui/react-shared-contexts';
import type { ExtractSlotProps } from '@fluentui/react-utilities';
import * as React_2 from 'react';
import type { ResolveShorthandFunction } from '@fluentui/react-utilities';
Expand Down Expand Up @@ -63,6 +64,23 @@ export type ARIAButtonSlotProps<AlternateAs extends 'a' | 'div' = 'a' | 'div'> =
// @public (undocumented)
export type ARIAButtonType = 'button' | 'a' | 'div';

// @public
export const AriaLiveAnnouncer: React_2.FC<AriaLiveAnnouncerProps>;

// @public (undocumented)
export type AriaLiveAnnouncerProps = {
children?: React_2.ReactNode;
};

// @public (undocumented)
export type AriaLiveAnnouncerState = {
announce: AriaLiveAnnounceFn;
children?: React_2.ReactNode;
};

// @public (undocumented)
export const renderAriaLiveAnnouncer_unstable: (state: AriaLiveAnnouncerState, contextValues: AriaLiveAnnouncerContextValues) => JSX.Element;

// @public (undocumented)
export function useActiveDescendant<TActiveParentElement extends HTMLElement, TListboxElement extends HTMLElement>(options: ActiveDescendantOptions): UseActiveDescendantReturn<TActiveParentElement, TListboxElement>;

Expand All @@ -72,6 +90,12 @@ export function useARIAButtonProps<Type extends ARIAButtonType, Props extends AR
// @internal @deprecated (undocumented)
export const useARIAButtonShorthand: ResolveShorthandFunction<ARIAButtonSlotProps>;

// @public (undocumented)
export const useAriaLiveAnnouncer_unstable: (props: AriaLiveAnnouncerProps) => AriaLiveAnnouncerState;

// @public (undocumented)
export function useAriaLiveAnnouncerContextValues_unstable(state: AriaLiveAnnouncerState): AriaLiveAnnouncerContextValues;

// (No @packageDocumentation comment for this package)

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { render } from '@testing-library/react';
import * as React from 'react';

import { AriaLiveAnnouncer } from './AriaLiveAnnouncer';

describe('AriaLiveAnnouncer', () => {
it('renders a default state', () => {
const result = render(<AriaLiveAnnouncer>Default AriaLive</AriaLiveAnnouncer>);

expect(result.container).toMatchSnapshot();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as React from 'react';

import type { AriaLiveAnnouncerProps } from './AriaLiveAnnouncer.types';
import { renderAriaLiveAnnouncer_unstable } from './renderAriaLiveAnnouncer';
import { useAriaLiveAnnouncer_unstable } from './useAriaLiveAnnouncer';
import { useAriaLiveAnnouncerContextValues_unstable } from './useAriaLiveAnnouncerContextValues';

/**
* A sample implementation of a component that manages aria live announcements.
*/
export const AriaLiveAnnouncer: React.FC<AriaLiveAnnouncerProps> = props => {
const state = useAriaLiveAnnouncer_unstable(props);
const contextValues = useAriaLiveAnnouncerContextValues_unstable(state);

return renderAriaLiveAnnouncer_unstable(state, contextValues);
};

AriaLiveAnnouncer.displayName = 'AriaLiveAnnouncer';
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { AnnounceContextValue } from '@fluentui/react-shared-contexts';
import * as React from 'react';

export type AriaLiveAnnounceFn = AnnounceContextValue['announce'];

export type AriaLiveMessage = {
message: string;

createdAt: number;

priority: number;
batchId?: string;
};

export type AriaLiveAnnouncerProps = {
children?: React.ReactNode;
};

export type AriaLiveAnnouncerState = {
announce: AriaLiveAnnounceFn;
children?: React.ReactNode;
};

export type AriaLiveAnnouncerContextValues = {
announce: { announce: AriaLiveAnnounceFn };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`AriaLiveAnnouncer renders a default state 1`] = `
<div>
Default AriaLive
</div>
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { AriaLiveAnnouncer } from './AriaLiveAnnouncer';
export type { AriaLiveAnnouncerProps, AriaLiveAnnouncerState } from './AriaLiveAnnouncer.types';
export { renderAriaLiveAnnouncer_unstable } from './renderAriaLiveAnnouncer';
export { useAriaLiveAnnouncer_unstable } from './useAriaLiveAnnouncer';
export { useAriaLiveAnnouncerContextValues_unstable } from './useAriaLiveAnnouncerContextValues';
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** @jsxRuntime automatic */
/** @jsxImportSource @fluentui/react-jsx-runtime */

import { AnnounceProvider } from '@fluentui/react-shared-contexts';

import type { AriaLiveAnnouncerContextValues, AriaLiveAnnouncerState } from './AriaLiveAnnouncer.types';

export const renderAriaLiveAnnouncer_unstable = (
state: AriaLiveAnnouncerState,
contextValues: AriaLiveAnnouncerContextValues,
) => {
return <AnnounceProvider value={contextValues.announce}>{state.children}</AnnounceProvider>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { Provider_unstable as Provider } from '@fluentui/react-shared-contexts';
import { renderHook } from '@testing-library/react-hooks';
import * as React from 'react';

import { useAriaLiveAnnouncer_unstable as useAriaLiveAnnouncer } from './useAriaLiveAnnouncer';

const ANNOUNCE_TIMEOUT = 500;
const ANNOUNCE_SUFFIX = '. ';

describe('useAriaLiveAnnouncer', () => {
describe('announce', () => {
let liveRegionNode: HTMLDivElement | null;
let innerNode: HTMLSpanElement | null;

const targetDocument = {
createElement: (type: string) => (type === 'div' ? liveRegionNode : innerNode),
body: {
append: jest.fn(),
},
} as unknown as Document;
const ContextWrapper: React.FC = props => {
return <Provider value={{ dir: 'ltr', targetDocument }}>{props.children}</Provider>;
};

beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();

liveRegionNode = document.createElement('div');
innerNode = document.createElement('span');
});

afterEach(() => {
jest.useRealTimers();

liveRegionNode = null;
innerNode = null;
});

it('should append a "div" to <body>', () => {
const append = jest.spyOn(document.body, 'append');
const { rerender } = renderHook(() => useAriaLiveAnnouncer({}));

expect(append).toBeCalledTimes(1);
expect(append).toBeCalledWith(expect.any(HTMLDivElement));

// Ensure that the same element is not appended again
rerender();
expect(append).toBeCalledTimes(1);
});

it('should update the announcement message', () => {
const { result } = renderHook(() => useAriaLiveAnnouncer({}), { wrapper: ContextWrapper });

result.current.announce('message loaded');
expect(innerNode?.innerText).toBe('message loaded' + ANNOUNCE_SUFFIX);
});

it('should announce frequent messages in batches', () => {
const { result } = renderHook(() => useAriaLiveAnnouncer({}), { wrapper: ContextWrapper });

result.current.announce('message loaded');
jest.advanceTimersByTime(100);

result.current.announce('message unloaded');
result.current.announce('message reloaded');
expect(innerNode?.innerText).toBe('message loaded' + ANNOUNCE_SUFFIX);

jest.advanceTimersByTime(ANNOUNCE_TIMEOUT);
expect(innerNode?.innerText).toBe('message unloaded' + ANNOUNCE_SUFFIX + 'message reloaded' + ANNOUNCE_SUFFIX);
});

it('should only update the last of batched messages', () => {
const { result } = renderHook(() => useAriaLiveAnnouncer({}), { wrapper: ContextWrapper });

result.current.announce('message loaded', { batchId: 'test' });
jest.advanceTimersByTime(100);

result.current.announce('message reloaded', { batchId: 'test' });
jest.advanceTimersByTime(100);

result.current.announce('message revolutions', { batchId: 'test' });
jest.advanceTimersByTime(ANNOUNCE_TIMEOUT);
expect(innerNode?.innerText).toBe('message revolutions' + ANNOUNCE_SUFFIX);
});

it('should handle multiple batches', () => {
const { result } = renderHook(() => useAriaLiveAnnouncer({}), { wrapper: ContextWrapper });

result.current.announce('message loaded', { batchId: 'test' });
jest.advanceTimersByTime(100);

result.current.announce('message reloaded', { batchId: 'test' });
jest.advanceTimersByTime(100);

result.current.announce('message revolutions', { batchId: 'test2' });
jest.advanceTimersByTime(100);

result.current.announce('message resurrections', { batchId: 'test2' });
jest.advanceTimersByTime(ANNOUNCE_TIMEOUT);

expect(innerNode?.innerText).toBe(
'message reloaded' + ANNOUNCE_SUFFIX + 'message resurrections' + ANNOUNCE_SUFFIX,
);
});

it('should announce batched and unbatched messages', () => {
const { result } = renderHook(() => useAriaLiveAnnouncer({}), { wrapper: ContextWrapper });

result.current.announce('message loaded', { batchId: 'test' });
jest.advanceTimersByTime(100);

result.current.announce('message reloaded', { batchId: 'test' });
jest.advanceTimersByTime(100);

result.current.announce('message revolutions');
jest.advanceTimersByTime(ANNOUNCE_TIMEOUT);
expect(innerNode?.innerText).toBe('message reloaded' + ANNOUNCE_SUFFIX + 'message revolutions' + ANNOUNCE_SUFFIX);
});

it('should clear the announcement message after a delay', async () => {
const { result } = renderHook(() => useAriaLiveAnnouncer({}), { wrapper: ContextWrapper });

result.current.announce('message resurrections');
expect(innerNode?.innerText).toBe('message resurrections' + ANNOUNCE_SUFFIX);

jest.advanceTimersByTime(ANNOUNCE_TIMEOUT);
expect(liveRegionNode?.innerText).toBe('');
});
});
});
Loading