Skip to content

Commit 634c6c8

Browse files
correi-ffrancoisno
authored andcommitted
resolves theopenconversationkit#87 Improve web accessibility for screen reader
1 parent a008d02 commit 634c6c8

File tree

15 files changed

+153
-46
lines changed

15 files changed

+153
-46
lines changed

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,38 @@ A `TockTheme` can be used as a value of a `ThemeProvider` of [`emotion-theming`]
255255
| `timeoutBetweenMessage` | `number?` | Timeout between message |
256256
| `widgets` | `any?` | Custom display component |
257257
| `disableSse` | `boolean?` | Disable SSE (not even trying) |
258+
| `accessibility` | `TockAccessibility` | Object for overriding role and label accessibility attributes |
259+
260+
#### `TockAccessibility`
261+
262+
| Property name | Type | Description |
263+
|-----------------------------------|------------------------|---------------------------------------------------------------------------------------------------|
264+
| `carouselRoleDescription` | `string?` | Message of the carousel aria-roledescription attribute (overrides 'Carousel') |
265+
| `slideRoleDescription` | `string?` | Message of the slide carousel aria-roledescription attribute (overrides 'Slide') |
266+
| `previousCarouselButtonLabel` | `string?` | Message of the carousel previous button image aria-label attribute (overrides 'Previous slides') |
267+
| `nextCarouselButtonLabel` | `string?` | Message of the carousel next button image aria-label attribute (overrides 'Next slides') |
268+
| `sendButtonLabel` | `string?` | Message of the send message button image aria-label attribute (overrides 'Send a message') |
269+
| `clearButtonLabel` | `string?` | Message of the clear messages button image aria-label attribute (overrides 'Clear messages') |
270+
271+
#### Accessibility
272+
273+
The optional `accessibility` makes it possible to override default messages for some aria attributes.
274+
275+
Example :
276+
277+
```js
278+
renderChat(
279+
document.getElementById('chat'),
280+
'<TOCK_BOT_API_URL>',
281+
undefined,
282+
{},
283+
{ accessibility: {
284+
carouselRoleDescription: 'Carousel',
285+
slideRoleDescription: 'Resultat',
286+
},
287+
},
288+
);
289+
```
258290

259291
#### Opening message
260292

src/TockAccessibility.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export interface TockAccessibility {
2+
carouselRoleDescription?: string;
3+
slideRoleDescription?: string;
4+
previousCarouselButtonLabel?: string;
5+
nextCarouselButtonLabel?: string;
6+
sendButtonLabel?: string;
7+
clearButtonLabel?: string;
8+
}
9+
10+
export default TockAccessibility;

src/TockOptions.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import TockAccessibility from "TockAccessibility";
2+
13
export interface TockOptions {
24
// An initial message to send to the backend to trigger a welcome sequence
35
openingMessage?: string;
@@ -7,6 +9,7 @@ export interface TockOptions {
79
timeoutBetweenMessage?: number;
810
widgets?: any;
911
disableSse?: boolean;
12+
accessibility?: TockAccessibility;
1013
}
1114

1215
export default TockOptions;

src/components/Card/Card.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ import { prop } from 'styled-tools';
1010
import { Button } from '../../TockContext';
1111

1212
export const CardOuter: StyledComponent<
13-
DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>,
13+
DetailedHTMLProps<HTMLAttributes<HTMLLIElement>, HTMLLIElement>,
1414
unknown,
1515
TockTheme
16-
> = styled.div`
16+
> = styled.li`
1717
max-width: ${prop<any>('theme.sizing.conversation.width')};
1818
margin: 0.5em auto;
19+
list-style: none;
1920
`;
2021

2122
export const CardContainer: StyledComponent<
@@ -34,23 +35,27 @@ export const CardContainer: StyledComponent<
3435
`;
3536

3637
const CardTitle: StyledComponent<
37-
DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>,
38+
DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>,
3839
unknown,
3940
TockTheme
40-
> = styled.h3`
41+
> = styled.span`
4142
margin: 0.5em 0;
4243
font-size: 1.5em;
44+
font-weight: bold;
45+
display: block;
4346
4447
${prop<any>('theme.overrides.card.cardTitle', '')};
4548
`;
4649

4750
const CardSubTitle: StyledComponent<
48-
DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>,
51+
DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>,
4952
unknown,
5053
TockTheme
51-
> = styled.h4`
54+
> = styled.span`
5255
margin: 0.5em 0;
5356
font-size: 1em;
57+
font-weight: bold;
58+
display: block;
5459
5560
${prop<any>('theme.overrides.card.cardSubTitle', '')};
5661
`;
@@ -117,15 +122,16 @@ export interface CardProps {
117122
subTitle?: string;
118123
imageUrl?: string;
119124
buttons?: Button[];
125+
roleDescription?: string;
120126
onAction: (button: Button) => void;
121127
}
122128

123-
const Card = React.forwardRef<HTMLDivElement, CardProps>(function cardRender(
124-
{ title, subTitle, imageUrl, buttons, onAction }: CardProps,
129+
const Card = React.forwardRef<HTMLLIElement, CardProps>(function cardRender(
130+
{ title, subTitle, imageUrl, buttons, roleDescription, onAction }: CardProps,
125131
ref,
126132
) {
127133
return (
128-
<CardOuter ref={ref}>
134+
<CardOuter ref={ref} role={(ref == undefined) ? undefined : 'group'} aria-roledescription={(ref == undefined) ? undefined : ((roleDescription) ? roleDescription : 'Slide')}>
129135
<CardContainer>
130136
{imageUrl && <CardImage src={imageUrl} alt={title} />}
131137
<CardTitle>{title}</CardTitle>

src/components/Carousel/Carousel.tsx

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,35 +7,38 @@ import { useTheme } from 'emotion-theming';
77
import useCarousel from './hooks/useCarousel';
88
import useArrowVisibility from './hooks/useArrowVisibility';
99
import TockTheme from 'styles/theme';
10+
import TockAccessibility from 'TockAccessibility';
1011

1112
const ButtonContainer: StyledComponent<
12-
DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>,
13+
DetailedHTMLProps<HTMLAttributes<HTMLLIElement>, HTMLLIElement>,
1314
unknown,
1415
TockTheme
15-
> = styled.div`
16+
> = styled.li`
1617
margin: 0.4em 0;
1718
position: relative;
19+
list-style: none;
1820
`;
1921

2022
const ItemContainer: StyledComponent<
21-
DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>,
23+
DetailedHTMLProps<HTMLAttributes<HTMLUListElement>, HTMLUListElement>,
2224
unknown,
2325
TockTheme
24-
> = styled.div`
26+
> = styled.ul`
2527
display: flex;
2628
overflow: auto;
2729
-webkit-overflow-scrolling: touch;
2830
justify-content: start;
2931
scroll-behavior: smooth;
3032
touch-action: pan-x pan-y;
3133
position: relative;
34+
padding: 0;
3235
&::-webkit-scrollbar {
3336
display: none;
3437
}
3538
scrollbar-width: none;
3639
${prop<any>('theme.overrides.carouselContainer', '')}
3740
38-
& > div, & > * {
41+
& > li, & > * {
3942
margin-left: 1em;
4043
margin-right: 1em;
4144
@@ -111,13 +114,15 @@ const Next: StyledComponent<
111114
${prop<any>('theme.overrides.carouselArrow', '')};
112115
`;
113116

114-
const Carousel: (props: { children?: ReactElement[] }) => JSX.Element = ({
117+
const Carousel: (props: { children?: ReactElement[] , accessibility?: TockAccessibility}) => JSX.Element = ({
115118
children,
119+
accessibility,
116120
}: {
117121
children?: ReactElement[];
122+
accessibility?: TockAccessibility;
118123
}) => {
119124
const theme: TockTheme = useTheme<TockTheme>();
120-
const [ref, previous, next] = useCarousel<HTMLDivElement>(children?.length);
125+
const [ref, previous, next] = useCarousel<HTMLUListElement>(children?.length);
121126
const [leftVisible, rightVisible] = useArrowVisibility(
122127
ref.container,
123128
ref.items,
@@ -127,17 +132,17 @@ const Carousel: (props: { children?: ReactElement[] }) => JSX.Element = ({
127132
<ButtonContainer>
128133
{leftVisible && (
129134
<Previous onClick={previous}>
130-
<ArrowLeftCircle size={`calc(${theme.typography.fontSize} * 2)`} />
135+
<ArrowLeftCircle size={`calc(${theme.typography.fontSize} * 2)`} role='img' aria-label={accessibility?.previousCarouselButtonLabel || 'Previous slides'} focusable='false'/>
131136
</Previous>
132137
)}
133-
<ItemContainer ref={ref.container}>
138+
<ItemContainer ref={ref.container} role='group' aria-roledescription={accessibility?.carouselRoleDescription || 'Carousel'}>
134139
{children?.map((child, i) =>
135-
React.cloneElement(child, { ref: ref.items[i] }, undefined),
140+
React.cloneElement(child, { ref: ref.items[i], roleDescription: accessibility?.slideRoleDescription }, undefined),
136141
)}
137142
</ItemContainer>
138143
{rightVisible && (
139144
<Next onClick={next}>
140-
<ArrowRightCircle size={`calc(${theme.typography.fontSize} * 2)`} />
145+
<ArrowRightCircle size={`calc(${theme.typography.fontSize} * 2)`} role='img' aria-label={accessibility?.nextCarouselButtonLabel || 'Next slides'} focusable='false'/>
141146
</Next>
142147
)}
143148
</ButtonContainer>

src/components/Carousel/hooks/useCarousel.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { RefObject, useRef, useCallback } from 'react';
1+
import { RefObject, useRef, useCallback, useEffect } from 'react';
22
import useRefs from './useRefs';
33
import useMeasures, { Measure } from './useMeasures';
44

@@ -19,10 +19,32 @@ function getMeanX(
1919
return Math.round((previous.x + previous.width + target.x) / 2);
2020
}
2121

22+
function setAriaAttributes(
23+
measures: Measure[],
24+
itemRefs: RefObject<HTMLElement>[],
25+
targetIndex: number,
26+
width: number,
27+
) {
28+
if (width !== 0) {
29+
itemRefs.forEach(item => {
30+
const offsetLeftItem = item.current?.offsetLeft || 0;
31+
32+
if (offsetLeftItem < measures[targetIndex].x || (offsetLeftItem + (item.current?.offsetWidth || 0) > measures[targetIndex].x + width)) {
33+
item.current?.setAttribute('aria-hidden', 'true');
34+
item.current?.setAttribute('tabIndex', '-1');
35+
} else {
36+
item.current?.removeAttribute('aria-hidden');
37+
item.current?.removeAttribute('tabIndex');
38+
}
39+
});
40+
}
41+
}
42+
2243
function scrollStep(
2344
direction: 'NEXT' | 'PREVIOUS',
2445
container: HTMLElement | null,
2546
measures: Measure[],
47+
itemRefs: RefObject<HTMLElement>[],
2648
) {
2749
if (!container) return;
2850
const x = container.scrollLeft;
@@ -36,6 +58,7 @@ function scrollStep(
3658
measures[targetIndex],
3759
measures[targetIndex - 1],
3860
);
61+
setAriaAttributes(measures, itemRefs, targetIndex, width);
3962
} else {
4063
const firstLeftHidden = measures
4164
.slice()
@@ -51,6 +74,7 @@ function scrollStep(
5174
measures[targetIndex - 1],
5275
measures[targetIndex],
5376
);
77+
setAriaAttributes(measures, itemRefs, targetIndex, width);
5478
}
5579
}
5680

@@ -60,15 +84,21 @@ export default function useCarousel<T>(itemCount = 0): CarouselReturn<T> {
6084
const measures = useMeasures(itemRefs);
6185

6286
const previous = useCallback(
63-
() => scrollStep('PREVIOUS', containerRef.current, measures),
87+
() => scrollStep('PREVIOUS', containerRef.current, measures, itemRefs),
6488
[containerRef, measures],
6589
);
6690

6791
const next = useCallback(
68-
() => scrollStep('NEXT', containerRef.current, measures),
92+
() => scrollStep('NEXT', containerRef.current, measures, itemRefs),
6993
[containerRef, measures],
7094
);
7195

96+
useEffect(() => {
97+
if (measures !== undefined && measures.length !== 0) {
98+
setAriaAttributes(measures, itemRefs, 0, (containerRef.current as HTMLElement | null)?.clientWidth || 0);
99+
}
100+
}, [measures]);
101+
72102
return [
73103
{
74104
container: containerRef,

src/components/Chat/Chat.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import useTock, { UseTock } from '../../useTock';
33
import ChatInput from '../ChatInput';
44
import Container from '../Container';
55
import Conversation from '../Conversation';
6+
import TockAccessibility from "../../TockAccessibility";
67

78
export interface ChatProps {
89
endPoint: string;
@@ -12,6 +13,7 @@ export interface ChatProps {
1213
widgets?: any;
1314
extraHeadersProvider?: () => Promise<Record<string, string>>;
1415
disableSse?: boolean;
16+
accessibility?: TockAccessibility;
1517
}
1618

1719
const Chat: (props: ChatProps) => JSX.Element = ({
@@ -22,6 +24,7 @@ const Chat: (props: ChatProps) => JSX.Element = ({
2224
widgets = {},
2325
extraHeadersProvider = undefined,
2426
disableSse = false,
27+
accessibility = {},
2528
}: ChatProps) => {
2629
const {
2730
messages,
@@ -58,8 +61,9 @@ const Chat: (props: ChatProps) => JSX.Element = ({
5861
quickReplies={quickReplies}
5962
onAction={sendAction}
6063
onQuickReplyClick={sendQuickReply}
64+
accessibility={accessibility}
6165
/>
62-
<ChatInput disabled={sseInitializing} onSubmit={sendMessage} />
66+
<ChatInput disabled={sseInitializing} onSubmit={sendMessage} accessibility={accessibility}/>
6367
</Container>
6468
);
6569
};

src/components/ChatInput/ChatInput.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Send, Trash2 } from 'react-feather';
1111
import TockTheme from 'styles/theme';
1212
import { prop } from 'styled-tools';
1313
import useLocalTools, { UseLocalTools } from '../../useLocalTools';
14+
import TockAccessibility from 'TockAccessibility';
1415

1516
const InputOuterContainer: StyledComponent<
1617
DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>,
@@ -123,11 +124,13 @@ const ClearIcon: StyledComponent<
123124
export interface ChatInputProps {
124125
disabled?: boolean;
125126
onSubmit: (message: string) => void;
127+
accessibility?: TockAccessibility;
126128
}
127129

128130
const ChatInput: (props: ChatInputProps) => JSX.Element = ({
129131
disabled,
130132
onSubmit,
133+
accessibility,
131134
}: ChatInputProps): JSX.Element => {
132135
const { clearMessages }: UseLocalTools = useLocalTools();
133136
const [value, setValue] = useState('');
@@ -148,10 +151,10 @@ const ChatInput: (props: ChatInputProps) => JSX.Element = ({
148151
onChange={({ target: { value } }) => setValue(value)}
149152
/>
150153
<SubmitIcon>
151-
<Send size="100%" />
154+
<Send size="100%" role='img' aria-label={accessibility?.sendButtonLabel || 'Send a message'} focusable='false' />
152155
</SubmitIcon>
153156
<ClearIcon>
154-
<Trash2 size="25px" color={'white'} onClick={clearMessages} />
157+
<Trash2 size="25px" color={'white'} onClick={clearMessages} role='img' aria-label={accessibility?.clearButtonLabel || 'Clear messages'} focusable='false' />
155158
</ClearIcon>
156159
</InputOuterContainer>
157160
);

0 commit comments

Comments
 (0)