Skip to content

Commit cc4b6b5

Browse files
committed
feat(a11y): add reusable header ID plumbing for Prompt, Alert, and Viewer primitives
1 parent 439e1d5 commit cc4b6b5

10 files changed

Lines changed: 183 additions & 25 deletions

File tree

examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
1+
import {
2+
createContext,
3+
useCallback,
4+
useContext,
5+
useEffect,
6+
useMemo,
7+
useState,
8+
} from 'react';
29
import type { DeleteMessageOptions, LocalMessage } from 'stream-chat';
310
import {
411
Alert,
@@ -281,6 +288,7 @@ export const ConfigurableMessageActions = (
281288
const { customMessageActions } = useAppSettingsSelector(
282289
(state) => state.messageActions,
283290
);
291+
const customDeleteEnabled = customMessageActions.delete.enableOptionConfiguration;
284292
const configurableActionSet = useMemo(() => {
285293
const actionSet = props.messageActionSet ?? defaultMessageActionSet;
286294
const actionOverrides: Record<
@@ -309,7 +317,7 @@ export const ConfigurableMessageActions = (
309317
const overrides: CustomMessageActionOverrideSpec[] = [
310318
{
311319
...actionOverrides.delete,
312-
enabled: true,
320+
enabled: customDeleteEnabled,
313321
},
314322
{
315323
...actionOverrides.markOwnUnread,
@@ -318,21 +326,27 @@ export const ConfigurableMessageActions = (
318326
];
319327

320328
return applyCustomMessageActionOverrides({ messageActionSet: actionSet, overrides });
321-
}, [customMessageActions, props.messageActionSet]);
329+
}, [customDeleteEnabled, customMessageActions.markOwnUnread, props.messageActionSet]);
322330
const openDeleteDialog = useCallback((params: OpenDeleteDialogParams) => {
323331
setDeleteDialogTarget(params);
324332
}, []);
325333

334+
useEffect(() => {
335+
if (!customDeleteEnabled && deleteDialogTarget) {
336+
setDeleteDialogTarget(null);
337+
}
338+
}, [customDeleteEnabled, deleteDialogTarget]);
339+
326340
return (
327341
<CustomDeleteActionContext.Provider value={{ openDeleteDialog }}>
328342
<MessageActions {...props} messageActionSet={configurableActionSet} />
329343
<Modal
330-
open={Boolean(deleteDialogTarget)}
344+
open={customDeleteEnabled && Boolean(deleteDialogTarget)}
331345
onClose={() => {
332346
setDeleteDialogTarget(null);
333347
}}
334348
>
335-
{deleteDialogTarget && (
349+
{customDeleteEnabled && deleteDialogTarget && (
336350
<CustomDeleteMessageAlert
337351
enableOptionConfiguration={
338352
customMessageActions.delete.enableOptionConfiguration

src/components/Dialog/components/Alert.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import React, { type ComponentProps, type ComponentType, forwardRef } from 'react';
22
import clsx from 'clsx';
3+
import { useModalContext } from '../../../context';
4+
import { useAriaIdentifiers } from '../../../hooks/useAriaIdentifiers';
35

46
export const Root = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function AlertRoot(
57
{ children, className, ...props }: ComponentProps<'div'>,
@@ -13,24 +15,39 @@ export const Root = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function A
1315
});
1416

1517
export type AlertHeaderProps = ComponentProps<'div'> & {
16-
title?: string;
1718
description?: string;
19+
descriptionId?: string;
1820
Icon?: ComponentType;
21+
title?: string;
22+
titleId?: string;
1923
};
2024

2125
export const Header = forwardRef<HTMLDivElement, AlertHeaderProps>(function AlertHeader(
22-
{ children, className, description, Icon, title, ...props },
26+
{ children, className, description, descriptionId, Icon, title, titleId, ...props },
2327
ref,
2428
) {
29+
const { dialogId } = useModalContext();
30+
const { descriptionId: derivedDescriptionId, titleId: derivedTitleId } =
31+
useAriaIdentifiers(dialogId);
32+
const resolvedTitleId = titleId ?? derivedTitleId;
33+
const resolvedDescriptionId = descriptionId ?? derivedDescriptionId;
34+
2535
return (
2636
<div {...props} className={clsx('str-chat__alert-header', className)} ref={ref}>
2737
{title ? (
2838
<>
2939
{Icon && <Icon />}
3040
<div className='str-chat__alert-header__copy'>
31-
<div className='str-chat__alert-header__title'>{title}</div>
41+
<h2 className='str-chat__alert-header__title' id={resolvedTitleId}>
42+
{title}
43+
</h2>
3244
{description && (
33-
<div className='str-chat__alert-header__description'>{description}</div>
45+
<p
46+
className='str-chat__alert-header__description'
47+
id={resolvedDescriptionId}
48+
>
49+
{description}
50+
</p>
3451
)}
3552
</div>
3653
</>

src/components/Dialog/components/Prompt.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import React, { type ComponentProps, type PropsWithChildren } from 'react';
22
import clsx from 'clsx';
33
import { Button, type ButtonProps } from '../../Button';
44
import { IconXmark } from '../../Icons';
5-
import { useTranslationContext } from '../../../context';
5+
import { useModalContext, useTranslationContext } from '../../../context';
6+
import { useAriaIdentifiers } from '../../../hooks/useAriaIdentifiers';
67

78
const PromptRoot = ({ children, className, ...props }: ComponentProps<'div'>) => (
89
<div {...props} className={clsx('str-chat__prompt', className)}>
@@ -15,17 +16,35 @@ export type PromptHeaderProps = {
1516
description?: string;
1617
className?: string;
1718
close?: () => void;
19+
descriptionId?: string;
20+
titleId?: string;
1821
};
1922

20-
const PromptHeader = ({ className, close, description, title }: PromptHeaderProps) => {
23+
const PromptHeader = ({
24+
className,
25+
close,
26+
description,
27+
descriptionId,
28+
title,
29+
titleId,
30+
}: PromptHeaderProps) => {
2131
const { t } = useTranslationContext();
32+
const { dialogId } = useModalContext();
33+
const { descriptionId: derivedDescriptionId, titleId: derivedTitleId } =
34+
useAriaIdentifiers(dialogId);
35+
const resolvedTitleId = titleId ?? derivedTitleId;
36+
const resolvedDescriptionId = descriptionId ?? derivedDescriptionId;
2237

2338
return (
2439
<div className={clsx('str-chat__prompt__header', className)}>
2540
<div className='str-chat__prompt__header__title-group'>
26-
<div className='str-chat__prompt__header__title'>{title}</div>
41+
<h2 className='str-chat__prompt__header__title' id={resolvedTitleId}>
42+
{title}
43+
</h2>
2744
{description != null && description !== '' && (
28-
<div className='str-chat__prompt__header__description'>{description}</div>
45+
<p className='str-chat__prompt__header__description' id={resolvedDescriptionId}>
46+
{description}
47+
</p>
2948
)}
3049
</div>
3150
{close && (

src/components/Dialog/components/Viewer.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import React, { type ComponentProps, type PropsWithChildren } from 'react';
22
import clsx from 'clsx';
33
import { Button, type ButtonProps } from '../../Button';
44
import { IconArrowLeft, IconXmark } from '../../Icons';
5-
import { useTranslationContext } from '../../../context';
5+
import { useModalContext, useTranslationContext } from '../../../context';
6+
import { useAriaIdentifiers } from '../../../hooks/useAriaIdentifiers';
67

78
const ViewerRoot = ({ children, className, ...props }: ComponentProps<'div'>) => (
89
<div {...props} className={clsx('str-chat__viewer', className)}>
@@ -16,16 +17,25 @@ export type ViewerHeaderProps = {
1617
className?: string;
1718
close?: () => void;
1819
goBack?: () => void;
20+
descriptionId?: string;
21+
titleId?: string;
1922
};
2023

2124
const ViewerHeader = ({
2225
className,
2326
close,
2427
description,
28+
descriptionId,
2529
goBack,
2630
title,
31+
titleId,
2732
}: ViewerHeaderProps) => {
2833
const { t } = useTranslationContext();
34+
const { dialogId } = useModalContext();
35+
const { descriptionId: derivedDescriptionId, titleId: derivedTitleId } =
36+
useAriaIdentifiers(dialogId);
37+
const resolvedTitleId = titleId ?? derivedTitleId;
38+
const resolvedDescriptionId = descriptionId ?? derivedDescriptionId;
2939

3040
return (
3141
<div className={clsx('str-chat__viewer__header', className)}>
@@ -43,9 +53,13 @@ const ViewerHeader = ({
4353
</Button>
4454
)}
4555
<div className='str-chat__viewer__header__title-group'>
46-
<div className='str-chat__viewer__header__title'>{title}</div>
56+
<h2 className='str-chat__viewer__header__title' id={resolvedTitleId}>
57+
{title}
58+
</h2>
4759
{description != null && description !== '' && (
48-
<div className='str-chat__viewer__header__description'>{description}</div>
60+
<p className='str-chat__viewer__header__description' id={resolvedDescriptionId}>
61+
{description}
62+
</p>
4963
)}
5064
</div>
5165
{close && (

src/components/MessageActions/DeleteMessageAlert.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type DeleteMessageAlertProps = {
1111
export const DeleteMessageAlert = ({ onCancel, onDelete }: DeleteMessageAlertProps) => {
1212
const { t } = useTranslationContext();
1313
const { close } = useModalContext();
14+
1415
return (
1516
<Alert.Root
1617
className='str-chat__delete-message-alert'
@@ -33,6 +34,7 @@ export const DeleteMessageAlert = ({ onCancel, onDelete }: DeleteMessageAlertPro
3334
</Button>
3435
<Button
3536
appearance='outline'
37+
autoFocus
3638
className='str-chat__delete-message-alert__cancel-button'
3739
data-testid='delete-message-alert-cancel-button'
3840
onClick={() => {

src/components/MessageActions/MessageActions.defaults.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -678,7 +678,7 @@ const DefaultMessageActionComponents = {
678678
>
679679
{t('Delete message')}
680680
</MessageActionsMenuItemButton>
681-
<Modal open={openModal}>
681+
<Modal open={openModal} role='alertdialog'>
682682
<DeleteMessageAlert
683683
onCancel={() => {
684684
setOpenModal(false);

src/components/MessageActions/__tests__/MessageActions.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,28 @@ describe('<MessageActions />', () => {
329329
expect(screen.queryByText('Delete message')).not.toBeInTheDocument();
330330
});
331331

332+
it('should render delete confirmation as labeled alertdialog with description', async () => {
333+
const message = generateMessage({ user: alice });
334+
await renderMessageActions({
335+
channelStateOpts: {
336+
channelCapabilities: { 'delete-own-message': true },
337+
},
338+
customMessageContext: { message },
339+
});
340+
await toggleOpenMessageActions();
341+
342+
await act(async () => {
343+
await fireEvent.click(screen.getByText('Delete message'));
344+
});
345+
346+
const dialog = screen.getByRole('alertdialog', { name: 'Delete message' });
347+
expect(dialog).toHaveAttribute('aria-labelledby', 'modal-dialog-title');
348+
expect(dialog).toHaveAttribute('aria-describedby', 'modal-dialog-description');
349+
expect(
350+
screen.getByText('Are you sure you want to delete this message?'),
351+
).toHaveAttribute('id', 'modal-dialog-description');
352+
});
353+
332354
it('should include Edit in dropdown actions when user has edit capability', async () => {
333355
const message = generateMessage({ user: alice });
334356
const { container } = await renderMessageActions({

src/components/Modal/GlobalModal.tsx

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
useModalDialog,
2222
useModalDialogIsOpen,
2323
} from '../Dialog';
24+
import { useAriaIdentifiers } from '../../hooks/useAriaIdentifiers';
2425

2526
export type ModalCloseEvent =
2627
| KeyboardEvent
@@ -40,6 +41,8 @@ export type ModalProps = {
4041
'aria-labelledby'?: string;
4142
/** ID of the element that describes the modal dialog. */
4243
'aria-describedby'?: string;
44+
/** ARIA role for the modal dialog surface. */
45+
role?: 'alertdialog' | 'dialog';
4346
/** If provided, the close button is rendered on overlay */
4447
CloseButtonOnOverlay?: ComponentType<ComponentProps<'button'>>;
4548
/** Callback handler for closing of modal. */
@@ -58,13 +61,27 @@ export const GlobalModal = ({
5861
onClose,
5962
onCloseAttempt,
6063
open,
64+
role = 'dialog',
6165
}: PropsWithChildren<ModalProps>) => {
6266
const dialog = useModalDialog();
6367
const isOpen = useModalDialogIsOpen();
6468
const overlayRef = useRef<HTMLDivElement | null>(null);
6569
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
6670
const closingRef = useRef(false);
67-
const { theme } = useChatContext('GlobalModal');
71+
const { theme } = useChatContext();
72+
const dialogLabelingBaseId = dialog.id;
73+
const {
74+
descriptionId: derivedAriaDescribedby = ariaDescribedby,
75+
titleId: derivedAriaLabelledby = ariaLabelledby,
76+
} = useAriaIdentifiers(dialogLabelingBaseId);
77+
const resolvedAriaLabelledby = ariaLabel
78+
? ariaLabelledby
79+
: (ariaLabelledby ?? derivedAriaLabelledby);
80+
const resolvedAriaDescribedby =
81+
role === 'alertdialog'
82+
? (ariaDescribedby ?? derivedAriaDescribedby)
83+
: ariaDescribedby;
84+
const resolvedAriaLabel = resolvedAriaLabelledby ? undefined : ariaLabel;
6885

6986
const maybeClose = useCallback(
7087
(source: ModalCloseSource, event: ModalCloseEvent) => {
@@ -78,11 +95,12 @@ export const GlobalModal = ({
7895
[dialog, onClose, onCloseAttempt],
7996
);
8097

81-
const modalContextValue = useMemo<{ close: () => void }>(
98+
const modalContextValue = useMemo<{ close: () => void; dialogId?: string }>(
8299
() => ({
83100
close: () => maybeClose('button', {} as ModalCloseEvent),
101+
dialogId: dialogLabelingBaseId,
84102
}),
85-
[maybeClose],
103+
[dialogLabelingBaseId, maybeClose],
86104
);
87105

88106
const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -129,13 +147,13 @@ export const GlobalModal = ({
129147
>
130148
<FocusScope autoFocus contain>
131149
<div
132-
aria-describedby={ariaDescribedby}
133-
aria-label={ariaLabelledby ? undefined : ariaLabel}
134-
aria-labelledby={ariaLabelledby}
150+
aria-describedby={resolvedAriaDescribedby}
151+
aria-label={resolvedAriaLabel}
152+
aria-labelledby={resolvedAriaLabelledby}
135153
aria-modal='true'
136154
className='str-chat__modal__dialog'
137155
onKeyDown={handleDialogKeyDown}
138-
role='dialog'
156+
role={role}
139157
>
140158
{children}
141159
</div>

0 commit comments

Comments
 (0)