Skip to content

Commit 07c656f

Browse files
authored
Fix possible role relation a11y issues in transcript display (#821)
1 parent daf68f8 commit 07c656f

File tree

5 files changed

+146
-44
lines changed

5 files changed

+146
-44
lines changed

src/components/Transcript/Transcript.js

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
useSearchCounts
1212
} from '@Services/search';
1313
import { useTranscripts } from '@Services/ramp-hooks';
14-
import { autoScroll, screenReaderFriendlyTime, timeToHHmmss } from '@Services/utility-helpers';
14+
import { autoScroll, screenReaderFriendlyText, screenReaderFriendlyTime, timeToHHmmss } from '@Services/utility-helpers';
1515
import Spinner from '@Components/Spinner';
1616
import './Transcript.scss';
1717

@@ -114,6 +114,20 @@ const TranscriptLine = memo(({
114114
const onClick = (e) => {
115115
e.preventDefault();
116116
e.stopPropagation();
117+
118+
// Handle click on a link in the cue text in the same tab
119+
if (e.target.tagName == 'A') {
120+
// Check if the href value is a valid URL before navigation
121+
const urlRegex = /https?:\/\/[^\s/$.?#].[^\s]*/gi;
122+
const href = e.target.getAttribute('href');
123+
if (!href?.match(urlRegex)) {
124+
e.preventDefault();
125+
} else {
126+
window.open(href, '_self');
127+
return;
128+
}
129+
}
130+
117131
if (item.match && focusedMatchId !== item.id) {
118132
setFocusedMatchId(item.id);
119133
} else if (focusedMatchId !== null && item.tag === TRANSCRIPT_CUE_TYPES.timedCue) {
@@ -138,7 +152,7 @@ const TranscriptLine = memo(({
138152

139153
const cueText = useMemo(() => {
140154
return buildSpeakerText(item, item.tag === TRANSCRIPT_CUE_TYPES.nonTimedLine);
141-
}, [item?.tag]);
155+
}, [item]);
142156

143157
/** Build text portion of the transcript cue element */
144158
const cueTextElement = useMemo(() => {
@@ -171,22 +185,30 @@ const TranscriptLine = memo(({
171185

172186
return (
173187
<span
174-
role="radio"
175-
tabIndex={isFirstItem ? 0 : -1}
176-
aria-checked={isActive}
177188
ref={itemRef}
178-
onClick={onClick}
179-
onKeyDown={handleKeyDown}
180189
className={cx(
181190
'ramp--transcript_item',
182191
isActive && 'active',
183-
isFocused && 'focused'
192+
isFocused && 'focused',
193+
item.tag != TRANSCRIPT_CUE_TYPES.timedCue && 'untimed',
184194
)}
185195
data-testid={testId}
186-
aria-label={`${screenReaderFriendlyTime(item.begin)}, ${cueText}`}
196+
/* For untimed cues,
197+
- set tabIndex for keyboard navigation
198+
- onClick handler to scroll them to top on click
199+
- set aria-label with full cue text */
200+
tabIndex={isFirstItem && item.begin == undefined ? 0 : -1}
201+
onClick={item.begin == undefined ? onClick : null}
202+
aria-label={item.begin == undefined && screenReaderFriendlyText(cueText)}
187203
>
188204
{item.tag === TRANSCRIPT_CUE_TYPES.timedCue && typeof item.begin === 'number' && (
189-
<span className="ramp--transcript_time" data-testid="transcript_time">
205+
<span className='ramp--transcript_time' data-testid='transcript_time'
206+
role='button'
207+
onClick={onClick}
208+
onKeyDown={handleKeyDown}
209+
tabIndex={isFirstItem ? 0 : -1}
210+
aria-label={`${screenReaderFriendlyTime(item.begin)}, ${screenReaderFriendlyText(cueText)}`}
211+
>
190212
[{timeToHHmmss(item.begin, true)}]
191213
</span>
192214
)}
@@ -247,19 +269,24 @@ const TranscriptList = memo(({
247269
* @param {Event} e keyboard event
248270
*/
249271
const handleKeyDown = (e) => {
250-
const cues = transcriptListRef.current.children;
251-
if (cues?.length > 0) {
272+
// Get the timestamp for each cue for timed transcript, as these are focusable
273+
const cueTimes = transcriptListRef.current.querySelectorAll('.ramp--transcript_time');
274+
// Get the non-empty cues for untimed transcript
275+
const cueList = Array.from(transcriptListRef.current.children).filter((c) => c.textContent?.length > 0);
276+
277+
const cueLength = cueTimes?.length || cueList?.length || 0;
278+
if (cueLength > 0) {
252279
let nextIndex = currentIndex.current;
253280
/**
254281
* Default behavior is prevented (e.preventDefault()) only for the handled
255282
* key combinations to allow other keyboard shortcuts to work as expected.
256283
*/
257284
if (e.key === 'ArrowDown') {
258285
// Wraps focus back to first cue when the end of transcript is reached
259-
nextIndex = (currentIndex.current + 1) % cues.length;
286+
nextIndex = (currentIndex.current + 1) % cueLength;
260287
e.preventDefault();
261288
} else if (e.key === 'ArrowUp') {
262-
nextIndex = (currentIndex.current - 1 + cues.length) % cues.length;
289+
nextIndex = (currentIndex.current - 1 + cueLength) % cueLength;
263290
e.preventDefault();
264291
} else if (e.key === 'Tab' && e.shiftKey) {
265292
// Returns focus to parent container on (Shift + Tab) key combination press
@@ -268,11 +295,21 @@ const TranscriptList = memo(({
268295
return;
269296
}
270297
if (nextIndex !== currentIndex.current) {
271-
cues[currentIndex.current].tabIndex = -1;
272-
cues[nextIndex].tabIndex = 0;
273-
cues[nextIndex].focus();
274-
// Scroll the cue into view
275-
autoScroll(cues[nextIndex], transcriptContainerRef);
298+
if (cueTimes?.length > 0) {
299+
// Use timestamps of timed cues for navigation
300+
cueTimes[currentIndex.current].tabIndex = -1;
301+
cueTimes[nextIndex].tabIndex = 0;
302+
cueTimes[nextIndex].focus();
303+
// Scroll the cue into view
304+
autoScroll(cueTimes[nextIndex], transcriptContainerRef);
305+
} else if (cueList?.length > 0) {
306+
// Use whole cues for navigation for untimed cues
307+
cueList[currentIndex.current].tabIndex = -1;
308+
cueList[nextIndex].tabIndex = 0;
309+
cueList[nextIndex].focus();
310+
// Scroll the cue to the top of container
311+
autoScroll(cueList[nextIndex], transcriptContainerRef, true);
312+
}
276313
setCurrentIndex(nextIndex);
277314
}
278315
}
@@ -292,7 +329,6 @@ const TranscriptList = memo(({
292329
return (
293330
<div
294331
data-testid={`transcript_${testId}`}
295-
role="radiogroup"
296332
onKeyDown={handleKeyDown}
297333
ref={transcriptListRef}
298334
aria-label='Scrollable transcript cues'

src/components/Transcript/Transcript.scss

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,24 @@ p.ramp--transcript_untimed_item {
4040
span.ramp--transcript_item {
4141
display: flex;
4242
margin: 10px 10px 10px 10px;
43-
cursor: pointer;
4443
text-decoration: none;
4544
transition: background-color 0.2s ease-in;
45+
align-items: center;
46+
47+
&.untimed {
48+
// Enable pointer events for untimed items to allow scrolling on click
49+
cursor: pointer;
50+
// Highlight entire cue on hover
51+
&:focus,
52+
&:hover {
53+
background-color: $primaryGreenLight;
54+
}
55+
}
4656

4757
&.active {
4858
background-color: $primaryLighter;
4959
}
5060

51-
&:hover,
52-
&:focus {
53-
background-color: $primaryGreenLight;
54-
}
55-
5661
&.disabled {
5762
cursor: default;
5863
}
@@ -71,8 +76,13 @@ span.ramp--transcript_item {
7176
}
7277

7378
.ramp--transcript_time {
74-
margin-right: 15px;
79+
cursor: pointer;
80+
margin-right: 1em;
7581
color: $primaryGreenDark;
82+
83+
&:hover {
84+
text-decoration: underline;
85+
}
7686
}
7787

7888
.ramp--transcript_text {

src/components/Transcript/Transcript.test.js

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,26 @@ describe('Transcript component', () => {
110110
});
111111
});
112112

113-
test('highlights transcript item on click', async () => {
113+
test('highlights cue when clicking on cue\'s timestamp', async () => {
114114
await waitFor(() => {
115115
const transcriptItem = screen.queryAllByTestId('transcript_item')[0];
116-
fireEvent.click(transcriptItem);
116+
expect(transcriptItem.children).toHaveLength(2);
117+
expect(transcriptItem.children[0].textContent).toEqual('[00:00:01]');
118+
expect(transcriptItem.children[1].textContent).toEqual('[music]');
119+
expect(transcriptItem.classList.contains('active')).toBeFalsy();
120+
// Click on the cue's timestamp
121+
fireEvent.click(transcriptItem.children[0]);
117122
expect(transcriptItem.classList.contains('active')).toBeTruthy();
118123
});
119124
});
125+
126+
test('does nothing when clicking on the cue\'s text', async () => {
127+
await waitFor(() => {
128+
const transcriptItem = screen.queryAllByTestId('transcript_item')[0];
129+
fireEvent.click(transcriptItem.children[1]);
130+
expect(transcriptItem.classList.contains('active')).toBeFalsy();
131+
});
132+
});
120133
});
121134

122135
describe('with WebVTT including a header block', () => {
@@ -190,13 +203,41 @@ describe('Transcript component', () => {
190203
});
191204
});
192205

193-
test('renders the rest of the cue with timestamp', async () => {
206+
test('renders the rest of the cues with timestamps', async () => {
207+
await waitFor(() => {
208+
const transcriptItem0 = screen.queryAllByTestId('transcript_item')[0];
209+
expect(transcriptItem0.children).toHaveLength(2);
210+
expect(transcriptItem0.children[0].textContent).toEqual('[00:00:01]');
211+
expect(transcriptItem0.children[1].textContent).toEqual('[music]');
212+
expect(transcriptItem0.classList.contains('active')).toBeFalsy();
213+
214+
const transcriptItem1 = screen.queryAllByTestId('transcript_item')[1];
215+
expect(transcriptItem1.children).toHaveLength(2);
216+
expect(transcriptItem1.children[0].textContent).toEqual('[00:00:22]');
217+
expect(transcriptItem1.children[1].textContent).toEqual('transcript text 1');
218+
expect(transcriptItem1.classList.contains('active')).toBeFalsy();
219+
});
220+
});
221+
222+
test('highlights cue when clicking on the cue\'s timestamp', async () => {
194223
await waitFor(() => {
195224
const transcriptItem = screen.queryAllByTestId('transcript_item')[1];
196-
fireEvent.click(transcriptItem);
225+
expect(transcriptItem.children).toHaveLength(2);
226+
expect(transcriptItem.children[0].textContent).toEqual('[00:00:22]');
227+
expect(transcriptItem.children[1].textContent).toEqual('transcript text 1');
228+
// Click on the cue's timestamp
229+
fireEvent.click(transcriptItem.children[0]);
197230
expect(transcriptItem.classList.contains('active')).toBeTruthy();
198231
});
199232
});
233+
234+
test('does nothing when clicking on the cue\'s text', async () => {
235+
await waitFor(() => {
236+
const transcriptItem = screen.queryAllByTestId('transcript_item')[1];
237+
fireEvent.click(transcriptItem.children[1]);
238+
expect(transcriptItem.classList.contains('active')).toBeFalsy();
239+
});
240+
});
200241
});
201242

202243
describe('with WebVTT with NOTE comment', () => {

src/services/annotations-parser.js

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -365,16 +365,18 @@ function parseAnnotationBody(annotationBody, motivations) {
365365
export async function parseExternalAnnotationResource(annotation) {
366366
const { canvasId, format, id, motivation, url } = annotation;
367367
const { tData } = await parseTranscriptData(url, format);
368-
return tData.map((data) => {
369-
const { begin, end, text } = data;
370-
return {
371-
canvasId,
372-
id,
373-
motivation,
374-
time: { start: begin, end },
375-
value: [{ format: 'text/plain', purpose: motivation, value: text }],
376-
};
377-
});
368+
if (tData) {
369+
return tData.map((data) => {
370+
const { begin, end, text } = data;
371+
return {
372+
canvasId,
373+
id,
374+
motivation,
375+
time: { start: begin, end },
376+
value: [{ format: 'text/plain', purpose: motivation, value: text }],
377+
};
378+
});
379+
}
378380
}
379381

380382
/**

src/services/utility-helpers.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,17 @@ export function screenReaderFriendlyTime(time) {
109109
}
110110
};
111111

112+
/**
113+
* Convert a given text with HTML tags to a string read as a human
114+
* @param {String} html text with HTML tags
115+
* @returns {String} text without HTML tags
116+
*/
117+
export function screenReaderFriendlyText(html) {
118+
const tempElement = document.createElement('div');
119+
tempElement.innerHTML = html;
120+
return tempElement.textContent || tempElement.innerText || "";
121+
}
122+
112123
/**
113124
* Convert time from hh:mm:ss.ms/mm:ss.ms string format to int
114125
* @function Utils#timeToS
@@ -627,14 +638,14 @@ export function playerHotKeys(event, player, canvasIsEmpty) {
627638
let isCombKeyPress = event.ctrlKey || event.metaKey || event.altKey || event.shiftKey;
628639

629640
// CSS classes of active buttons to skip
630-
let buttonClassesToCheck = ['ramp--transcript_item', 'ramp--structured-nav__section-title',
641+
let buttonClassesToCheck = ['ramp--transcript_time', 'ramp--structured-nav__section-title',
631642
'ramp--structured-nav__item-link', 'ramp--structured-nav__collapse-all-btn',
632643
'ramp--annotations__multi-select-header', 'ramp--annotations__show-more-tags',
633644
'ramp--annotations__show-more-less'
634645
];
635646

636647
// Determine the focused element and pressed key combination needs to be skipped
637-
let skipActionOnFocus = (
648+
let skipActionWithButtonFocus = (
638649
activeElement?.role === 'button'
639650
&& (
640651
(
@@ -679,7 +690,9 @@ export function playerHotKeys(event, player, canvasIsEmpty) {
679690
inputs.indexOf(activeElement.tagName.toLowerCase()) !== -1
680691
|| (activeElement.role === 'tab' && (pressedKey === 37 || pressedKey === 39))
681692
|| (activeElement.role === 'switch' && (pressedKey === 13 || pressedKey === 32))
682-
|| skipActionOnFocus
693+
|| (activeElement?.classList?.contains('transcript_content') && (pressedKey === 38 || pressedKey === 40))
694+
|| (activeElement?.classList?.contains('ramp--transcript_item')) && (pressedKey === 38 || pressedKey === 40)
695+
|| skipActionWithButtonFocus
683696
)
684697
&& !focusedWithinPlayer)
685698
|| isCombKeyPress || canvasIsEmpty

0 commit comments

Comments
 (0)