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
2 changes: 1 addition & 1 deletion flashcards/public/student-ui.css

Large diffs are not rendered by default.

3,963 changes: 2,003 additions & 1,960 deletions flashcards/public/student-ui.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion flashcards/public/studio-ui.css

Large diffs are not rendered by default.

2,178 changes: 1,097 additions & 1,081 deletions flashcards/public/studio-ui.js

Large diffs are not rendered by default.

595 changes: 264 additions & 331 deletions frontend/package-lock.json

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions frontend/src/react-inert.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/// <reference types="react" />

declare module 'react' {
interface HTMLAttributes<T> extends AriaAttributes, DOMAttributes<T> {
inert?: string;
}
}

export {};
43 changes: 38 additions & 5 deletions frontend/src/student-ui/student-ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import {
beforeEach, describe, expect, it, vi,
} from 'vitest';
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import StudentUi from './student-ui';

Expand Down Expand Up @@ -70,7 +70,7 @@ describe('StudentUi', () => {
await userEvent.click(screen.getByRole('button', { name: 'Start flashcard deck' }));

screen.getByText('Question 1');
const counter1 = screen.getByRole('status', { name: 'Flashcard counter' });
const counter1 = screen.getByLabelText('Flashcard counter');
expect(counter1?.textContent).toBe('1 / 3');

const prevBtn = screen.getByRole('button', { name: 'Previous card' });
Expand All @@ -82,7 +82,7 @@ describe('StudentUi', () => {

await userEvent.click(nextBtn);
expect(screen.getByText('Question 2')).toBeInTheDocument();
const counter2 = screen.getByRole('status', { name: 'Flashcard counter' });
const counter2 = screen.getByLabelText('Flashcard counter');
expect(counter2?.textContent).toBe('2 / 3');

// Both buttons should be enabled on middle card
Expand All @@ -91,7 +91,7 @@ describe('StudentUi', () => {

await userEvent.click(nextBtn);
expect(screen.getByText('Question 3')).toBeInTheDocument();
const counter3 = screen.getByRole('status', { name: 'Flashcard counter' });
const counter3 = screen.getByLabelText('Flashcard counter');
expect(counter3?.textContent).toBe('3 / 3');

// Next button should be disabled on last card
Expand All @@ -100,7 +100,7 @@ describe('StudentUi', () => {

await userEvent.click(prevBtn);
expect(screen.getByText('Question 2')).toBeInTheDocument();
const counter4 = screen.getByRole('status', { name: 'Flashcard counter' });
const counter4 = screen.getByLabelText('Flashcard counter');
expect(counter4?.textContent).toBe('2 / 3');
});

Expand All @@ -111,12 +111,15 @@ describe('StudentUi', () => {
const flashcardContainer = screen.getByRole('button', { name: /Flashcard/ });
const flashcard = flashcardContainer.querySelector('.fc-card-front');
const content = flashcardContainer.querySelector('.card-content');
const label = flashcardContainer.querySelector('.fc-card-front .label');

expect(flashcard).toHaveStyle({ borderColor: props.styling.borderColor });
expect(flashcard).toHaveStyle({ backgroundColor: props.styling.backgroundColor });

expect(content).toHaveStyle({ fontSize: props.styling.fontSize });
expect(content).toHaveStyle({ color: props.styling.textColor });
expect(label).not.toBeNull();
expect(label as HTMLElement).toHaveStyle({ color: props.styling.textColor });
});

it('shuffles the flashcards', async () => {
Expand Down Expand Up @@ -146,4 +149,34 @@ describe('StudentUi', () => {
// Restore the original Math.random.
Math.random = originalRandom;
});

it('moves focus to the card after start and navigation', async () => {
render(<StudentUi {...props} />);

await userEvent.click(screen.getByRole('button', { name: 'Start flashcard deck' }));
expect(screen.getByRole('button', { name: /Flashcard 1 of/i })).toHaveFocus();

await userEvent.click(screen.getByRole('button', { name: 'Next card' }));
expect(screen.getByRole('button', { name: /Flashcard 2 of/i })).toHaveFocus();

await userEvent.click(screen.getByRole('button', { name: 'Previous card' }));
expect(screen.getByRole('button', { name: /Flashcard 1 of/i })).toHaveFocus();
});

it('announces the current card content', async () => {
render(<StudentUi {...props} />);
await userEvent.click(screen.getByRole('button', { name: 'Start flashcard deck' }));

const announcement = screen.getByRole('status', { name: 'Flashcard announcement' });
await waitFor(() => expect(announcement).toHaveTextContent(/Card 1 of 3\. Question\./i));
expect(announcement).toHaveTextContent(/Question 1/i);

await userEvent.click(screen.getByRole('button', { name: /Flashcard 1 of/i }));
await waitFor(() => expect(announcement).toHaveTextContent(/Card 1 of 3\. Answer\./i));
expect(announcement).toHaveTextContent(/Answer 1/i);

await userEvent.click(screen.getByRole('button', { name: 'Next card' }));
await waitFor(() => expect(announcement).toHaveTextContent(/Card 2 of 3\. Question\./i));
expect(announcement).toHaveTextContent(/Question 2/i);
});
});
97 changes: 92 additions & 5 deletions frontend/src/student-ui/student-ui.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as React from 'react';
import { useState } from 'react';
import {
useEffect, useMemo, useRef, useState,
} from 'react';
import { Button, Icon } from '@openedx/paragon';
import {
FlipToBack, ChevronLeft, ChevronRight, Shuffle,
Expand All @@ -11,12 +13,44 @@ interface StudentUiProps {
styling: FlashcardStyling,
}

function htmlToText(html: string): string {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should be stripping html for the screen reader; see next comment about the recommendations.

if (!html) {
return '';
}
if (typeof document === 'undefined') {
return html;
}
const container = document.createElement('div');
container.innerHTML = html;
return (container.textContent || '').replace(/\s+/g, ' ').trim();
}

function StudentUi({ title, flashcards, styling }: StudentUiProps) {
const [currentIndex, setCurrentIndex] = useState(-1);
const [isFlipped, setIsFlipped] = useState(false);
const [isStarted, setIsStarted] = useState(false);
const [isNavigating, setIsNavigating] = useState(false);
const [shouldFocusCard, setShouldFocusCard] = useState(false);
const [shuffledFlashcards, setShuffledFlashcards] = useState(flashcards);
const [liveAnnouncement, setLiveAnnouncement] = useState('');
const cardRef = useRef<HTMLDivElement | null>(null);
const announcementTimeoutRef = useRef<number | null>(null);
const announcementText = useMemo(() => {
if (!isStarted || currentIndex < 0 || currentIndex >= shuffledFlashcards.length) {
return '';
}

const currentFlashcard = shuffledFlashcards[currentIndex];
if (!currentFlashcard) {
return '';
}

const total = shuffledFlashcards.length;
const sideLabel = isFlipped ? 'Answer' : 'Question';
const visibleHtml = isFlipped ? currentFlashcard.back : currentFlashcard.front;
const visibleText = htmlToText(visibleHtml);
return `Card ${currentIndex + 1} of ${total}. ${sideLabel}.${visibleText ? ` ${visibleText}` : ''}`;
}, [currentIndex, isFlipped, isStarted, shuffledFlashcards]);

const shuffleArray = (array: Flashcard[]) => {
const shuffled = [...array];
Expand All @@ -28,6 +62,7 @@ function StudentUi({ title, flashcards, styling }: StudentUiProps) {
};

const handleStart = () => {
setShouldFocusCard(true);
setIsStarted(true);
setCurrentIndex(0);
setIsFlipped(false);
Expand All @@ -37,12 +72,14 @@ function StudentUi({ title, flashcards, styling }: StudentUiProps) {
const shuffled = shuffleArray(flashcards);
setShuffledFlashcards(shuffled);
if (isStarted) {
setShouldFocusCard(true);
setCurrentIndex(0);
setIsFlipped(false);
}
};

const navigateTo = (newIndex: number) => {
setShouldFocusCard(true);
// Prevent flipping the card while navigating.
setIsNavigating(true);
setIsFlipped(false);
Expand All @@ -67,10 +104,49 @@ function StudentUi({ title, flashcards, styling }: StudentUiProps) {
setIsFlipped(!isFlipped);
};

useEffect(() => {
if (!isStarted || !shouldFocusCard) {
return;
}

const el = cardRef.current;
if (!el) {
return;
}

try {
el.focus({ preventScroll: true });
} catch (e) {
el.focus();
} finally {
setShouldFocusCard(false);
}
}, [currentIndex, isStarted, shouldFocusCard]);

useEffect(() => {
if (announcementTimeoutRef.current !== null) {
window.clearTimeout(announcementTimeoutRef.current);
announcementTimeoutRef.current = null;
}

if (!announcementText) {
setLiveAnnouncement('');
return;
}

// Clear then set on next tick so screen readers reliably announce updates,
// especially for the initial announcement right after clicking "Start".
setLiveAnnouncement('');
announcementTimeoutRef.current = window.setTimeout(() => {
setLiveAnnouncement(announcementText);
announcementTimeoutRef.current = null;
}, 0);
}, [announcementText]);

if (!isStarted) {
return (
<div className="flashcards_block">
<div className="fc-number" aria-label="Flashcard counter" role="status">
<div className="fc-number" aria-label="Flashcard counter">
0 / <span className="fc-total">{shuffledFlashcards.length}</span>
</div>
<div
Expand All @@ -80,6 +156,9 @@ function StudentUi({ title, flashcards, styling }: StudentUiProps) {
{title}
</div>
<hr />
<div className="visually-hidden" role="status" aria-atomic="true" aria-label="Flashcard announcement">
{liveAnnouncement}
</div>
<div className="fc-start-controls">
<Button
className="shuffle-btn"
Expand All @@ -105,7 +184,7 @@ function StudentUi({ title, flashcards, styling }: StudentUiProps) {

return (
<div className="flashcards_block">
<div className="fc-number" aria-label="Flashcard counter" role="status">
<div className="fc-number" aria-label="Flashcard counter">
<span className="current-fc">{currentIndex + 1}</span> / <span className="fc-total">{shuffledFlashcards.length}</span>
</div>
<div
Expand All @@ -115,6 +194,9 @@ function StudentUi({ title, flashcards, styling }: StudentUiProps) {
{title}
</div>
<hr />
<div className="visually-hidden" role="status" aria-atomic="true" aria-label="Flashcard announcement">
{liveAnnouncement}
</div>
<div className="fc-container">
<Button
className="nav-btn prev-btn"
Expand All @@ -127,6 +209,7 @@ function StudentUi({ title, flashcards, styling }: StudentUiProps) {
</Button>
<div
className={`fc-card ${isFlipped ? 'is-flipped' : ''} ${isNavigating ? 'is-navigating' : ''}`}
ref={cardRef}
onClick={handleFlip}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
Expand All @@ -140,6 +223,8 @@ function StudentUi({ title, flashcards, styling }: StudentUiProps) {
>
<div
className="fc-card-front"
aria-hidden={isFlipped}
inert={isFlipped ? '' : undefined}
style={{
borderColor: styling.borderColor,
backgroundColor: styling.backgroundColor,
Expand All @@ -148,7 +233,7 @@ function StudentUi({ title, flashcards, styling }: StudentUiProps) {
<div className="fc-flip-icon">
<Icon src={FlipToBack} size="sm" />
</div>
<p className="label">Question</p>
<p className="label" style={{ color: styling.textColor }}>Question</p>
<div
className="card-content"
style={{
Expand All @@ -161,6 +246,8 @@ function StudentUi({ title, flashcards, styling }: StudentUiProps) {
</div>
<div
className="fc-card-back"
aria-hidden={!isFlipped}
inert={!isFlipped ? '' : undefined}
style={{
borderColor: styling.borderColor,
backgroundColor: styling.backgroundColor,
Expand All @@ -169,7 +256,7 @@ function StudentUi({ title, flashcards, styling }: StudentUiProps) {
<div className="fc-flip-icon">
<Icon src={FlipToBack} size="sm" />
</div>
<p className="label">Answer</p>
<p className="label" style={{ color: styling.textColor }}>Answer</p>
<div
className="card-content"
style={{
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/student-ui/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@
display: inline-block;
text-align: center;

.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}

.fc-number {
text-align: center;
margin-bottom: 10px;
Expand Down Expand Up @@ -104,6 +116,10 @@
.card-content {
font-weight: 600;
line-height: 1.4;

h1, h2, h3, h4, h5, h6 {
color: inherit;
}
}
}

Expand Down
Loading