Skip to content

Commit 1f3d4e3

Browse files
committed
feat(a11y): audit and apply dialog-label wiring to high-impact Prompt/Viewer call sites
1 parent cc4b6b5 commit 1f3d4e3

40 files changed

Lines changed: 516 additions & 38 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useMemo } from 'react';
2+
3+
type AriaIdentifierDescriptor = 'description' | 'title';
4+
5+
const sanitizeAriaRootId = (rootId?: string) =>
6+
rootId?.trim().replace(/[^A-Za-z0-9:_-]/g, '-') ?? '';
7+
8+
const buildAriaIdentifier = (
9+
sanitizedRootId: string,
10+
descriptor: AriaIdentifierDescriptor,
11+
) => (sanitizedRootId ? `${sanitizedRootId}-${descriptor}` : undefined);
12+
13+
/**
14+
* Derives stable ARIA identifier IDs from a single root ID.
15+
*
16+
* Use this to keep dialog/component labeling conventions consistent without
17+
* manually building `*-title` and `*-description` IDs at each call site.
18+
*
19+
* Behavior:
20+
* - Root ID is trimmed and sanitized to `[A-Za-z0-9:_-]` before use.
21+
* - Returns `undefined` IDs when root ID is missing/empty after sanitization.
22+
*/
23+
export const useAriaIdentifiers = (rootId?: string) => {
24+
const sanitizedRootId = sanitizeAriaRootId(rootId);
25+
26+
return useMemo(
27+
() => ({
28+
descriptionId: buildAriaIdentifier(sanitizedRootId, 'description'),
29+
titleId: buildAriaIdentifier(sanitizedRootId, 'title'),
30+
}),
31+
[sanitizedRootId],
32+
);
33+
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { useMemo } from 'react';
2+
import { useAriaIdentifiers } from './useAriaIdentifiers';
3+
4+
export type ResolvedModalAriaProps = {
5+
'aria-describedby'?: string;
6+
'aria-label'?: string;
7+
'aria-labelledby'?: string;
8+
};
9+
10+
type UseResolvedModalAriaPropsParams = {
11+
ariaDescribedby?: string;
12+
ariaLabel?: string;
13+
ariaLabelledby?: string;
14+
dialogId?: string;
15+
};
16+
17+
/**
18+
* Resolves modal labeling/description attributes from explicit props first,
19+
* then from the modal dialog id convention (`${dialogId}-title|description`).
20+
*
21+
* Rules:
22+
* - `aria-labelledby` wins over `aria-label`.
23+
* - `aria-describedby` defaults to inferred id when explicit value is absent.
24+
*/
25+
export const useResolvedModalAriaProps = ({
26+
ariaDescribedby,
27+
ariaLabel,
28+
ariaLabelledby,
29+
dialogId,
30+
}: UseResolvedModalAriaPropsParams): ResolvedModalAriaProps => {
31+
const { descriptionId, titleId } = useAriaIdentifiers(dialogId);
32+
33+
return useMemo(() => {
34+
const resolvedAriaLabelledby = ariaLabel
35+
? ariaLabelledby
36+
: (ariaLabelledby ?? titleId);
37+
const resolvedAriaDescribedby = ariaDescribedby ?? descriptionId;
38+
const resolvedAriaLabel = resolvedAriaLabelledby ? undefined : ariaLabel;
39+
40+
return {
41+
'aria-describedby': resolvedAriaDescribedby,
42+
'aria-label': resolvedAriaLabel,
43+
'aria-labelledby': resolvedAriaLabelledby,
44+
};
45+
}, [ariaDescribedby, ariaLabel, ariaLabelledby, descriptionId, titleId]);
46+
};

src/components/ChannelListItem/ChannelListItemActionButtons.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { type ComponentProps, type ComponentType, type ReactNode } from '
22

33
import clsx from 'clsx';
44
import { ContextMenu, useDialogIsOpen, useDialogOnNearestManager } from '../Dialog';
5-
import { useComponentContext } from '../../context';
5+
import { useComponentContext, useTranslationContext } from '../../context';
66
import {
77
defaultChannelActionSet,
88
useBaseChannelActionSetFilter,
@@ -20,6 +20,7 @@ interface ChannelListItemActionButtonsInterface {
2020

2121
export const ChannelListItemActionButtons: ChannelListItemActionButtonsInterface = () => {
2222
const { ContextMenu: ContextMenuComponent = ContextMenu } = useComponentContext();
23+
const { t } = useTranslationContext();
2324
const { channel } = useChannelListItemContext();
2425
const [referenceElement, setReferenceElement] =
2526
React.useState<HTMLButtonElement | null>(null);
@@ -52,6 +53,7 @@ export const ChannelListItemActionButtons: ChannelListItemActionButtonsInterface
5253
<Component key={type} />
5354
))}
5455
<ContextMenuComponent
56+
aria-label={t('aria/Channel Actions')}
5557
className='str-chat__channel-list-item__action-buttons-context-menu'
5658
dialogManagerId={dialogManager?.id}
5759
id={dialog.id}

src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ describe('ChannelListItemActionButtons defaults', () => {
127127
});
128128

129129
const menu = document.querySelector('.str-chat__context-menu') as HTMLElement;
130+
expect(menu).toHaveAttribute('aria-label');
131+
expect(menu.getAttribute('aria-label')).toBeTruthy();
130132
act(() => {
131133
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Block User' }));
132134
});

src/components/Dialog/components/Alert.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { type ComponentProps, type ComponentType, forwardRef } from 'react';
22
import clsx from 'clsx';
33
import { useModalContext } from '../../../context';
4-
import { useAriaIdentifiers } from '../../../hooks/useAriaIdentifiers';
4+
import { useAriaIdentifiers } from '../../../a11y/hooks/useAriaIdentifiers';
55

66
export const Root = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function AlertRoot(
77
{ children, className, ...props }: ComponentProps<'div'>,

src/components/Dialog/components/Prompt.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import clsx from 'clsx';
33
import { Button, type ButtonProps } from '../../Button';
44
import { IconXmark } from '../../Icons';
55
import { useModalContext, useTranslationContext } from '../../../context';
6-
import { useAriaIdentifiers } from '../../../hooks/useAriaIdentifiers';
6+
import { useAriaIdentifiers } from '../../../a11y/hooks/useAriaIdentifiers';
77

88
const PromptRoot = ({ children, className, ...props }: ComponentProps<'div'>) => (
99
<div {...props} className={clsx('str-chat__prompt', className)}>
@@ -50,6 +50,9 @@ const PromptHeader = ({
5050
{close && (
5151
<Button
5252
appearance='ghost'
53+
aria-describedby={
54+
description != null && description !== '' ? resolvedDescriptionId : undefined
55+
}
5356
aria-label={t('Close prompt: {{ title }}', { title })}
5457
circular
5558
className='str-chat__prompt__header__close-button'

src/components/Dialog/components/Viewer.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import clsx from 'clsx';
33
import { Button, type ButtonProps } from '../../Button';
44
import { IconArrowLeft, IconXmark } from '../../Icons';
55
import { useModalContext, useTranslationContext } from '../../../context';
6-
import { useAriaIdentifiers } from '../../../hooks/useAriaIdentifiers';
6+
import { useAriaIdentifiers } from '../../../a11y/hooks/useAriaIdentifiers';
77

88
const ViewerRoot = ({ children, className, ...props }: ComponentProps<'div'>) => (
99
<div {...props} className={clsx('str-chat__viewer', className)}>
@@ -42,6 +42,9 @@ const ViewerHeader = ({
4242
{goBack && (
4343
<Button
4444
appearance='ghost'
45+
aria-describedby={
46+
description != null && description !== '' ? resolvedDescriptionId : undefined
47+
}
4548
aria-label={t('Back')}
4649
circular
4750
className='str-chat__viewer__header__go-back-button'
@@ -65,7 +68,10 @@ const ViewerHeader = ({
6568
{close && (
6669
<Button
6770
appearance='ghost'
68-
aria-label={t('Close')}
71+
aria-describedby={
72+
description != null && description !== '' ? resolvedDescriptionId : undefined
73+
}
74+
aria-label={t('Close dialog')}
6975
circular
7076
className='str-chat__viewer__header__close-button'
7177
onClick={close}

src/components/Dialog/styling/Prompt.scss

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
}
2121

2222
.str-chat__prompt__header__title {
23+
margin: 0;
2324
font: var(--str-chat__font-heading-sm);
2425
color: var(--text-primary);
2526
}
@@ -30,8 +31,10 @@
3031
}
3132

3233
.str-chat__prompt__header__close-button {
34+
align-self: flex-start;
3335
flex-shrink: 0;
3436
color: var(--text-primary);
37+
3538
.str-chat__icon {
3639
width: var(--icon-size-md);
3740
height: var(--icon-size-md);

src/components/Dialog/styling/Viewer.scss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
}
2626

2727
.str-chat__viewer__header__title {
28+
margin: 0;
2829
font: var(--str-chat__font-heading-sm);
2930
color: var(--text-primary);
3031
}
@@ -43,6 +44,7 @@
4344
}
4445

4546
.str-chat__viewer__header__close-button {
47+
align-self: flex-start;
4648
flex-shrink: 0;
4749
color: var(--text-primary);
4850
}

src/components/Form/NumericInput.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,13 +132,17 @@ export const NumericInput = forwardRef<HTMLInputElement, NumericInputProps>(
132132
<IconMinus className='str-chat__form-numeric-input__stepper-icon' />
133133
</Button>
134134
<input
135+
aria-valuemax={Number.isFinite(maxDef) ? maxDef : undefined}
136+
aria-valuemin={Number.isFinite(minDef) ? minDef : undefined}
137+
aria-valuenow={num ?? undefined}
135138
className='str-chat__form-numeric-input__input'
136139
disabled={disabled}
137140
id={id}
138141
inputMode='numeric'
139142
onChange={handleInputChange}
140143
onKeyDown={handleKeyDown}
141144
ref={ref}
145+
role='spinbutton'
142146
type='text'
143147
value={value}
144148
{...inputProps}

0 commit comments

Comments
 (0)