diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json
index 70fb537928a..3981b73989e 100644
--- a/client/i18n/locales/english/translations.json
+++ b/client/i18n/locales/english/translations.json
@@ -965,7 +965,8 @@
"terminal-output": "Terminal output",
"not-available": "Not available",
"interactive-editor-desc": "Turn static code examples into interactive editors. This allows you to edit and run the code directly on the page.",
- "pinyin-to-hanzi-input-desc": "This task uses Pinyin-to-Hanzi inputs. Type pinyin with tone numbers (1 to 5). When you enter a correct syllable, it will turn into a Chinese character. If you press backspace after a Chinese character, it will change back to pinyin and remove the last thing you typed: if it's a tone number, the tone is removed; if it's a letter, the letter is removed."
+ "pinyin-to-hanzi-input-desc": "This task uses Pinyin-to-Hanzi inputs. Type pinyin with tone numbers (1 to 5). When you enter a correct syllable, it will turn into a Chinese character. If you press backspace after a Chinese character, it will change back to pinyin and remove the last thing you typed: if it's a tone number, the tone is removed; if it's a letter, the letter is removed.",
+ "pinyin-tone-input-desc": "This task uses Pinyin Tone inputs. Type pinyin with tone numbers (1 to 5). When you enter a tone number, it will be converted to a tone mark. If you press backspace, the last thing you typed is removed: if it's a tone number, the tone is removed; if it's a letter, the letter is removed."
},
"flash": {
"no-email-in-userinfo": "We could not retrieve an email from your chosen provider. Please try another provider or use the 'Continue with Email' option.",
diff --git a/client/src/templates/Challenges/components/fill-in-the-blanks.tsx b/client/src/templates/Challenges/components/fill-in-the-blanks.tsx
index 23295877cf3..ee315d9dd31 100644
--- a/client/src/templates/Challenges/components/fill-in-the-blanks.tsx
+++ b/client/src/templates/Challenges/components/fill-in-the-blanks.tsx
@@ -7,6 +7,7 @@ import PrismFormatted from '../components/prism-formatted';
import { FillInTheBlank } from '../../../redux/prop-types';
import ChallengeHeading from './challenge-heading';
import PinyinToHanziInput from './pinyin-to-hanzi-input';
+import PinyinToneInput from './pinyin-tone-input';
type FillInTheBlankProps = {
fillInTheBlank: FillInTheBlank;
@@ -72,6 +73,18 @@ const BlankInput = ({
ariaLabel={ariaLabel}
/>
);
+ } else if (inputType === 'pinyin-tone' && typeof parsedAnswer === 'string') {
+ return (
+
+ );
}
// Default text input
@@ -113,13 +126,17 @@ function FillInTheBlanks({
const blankAnswers = blanks.map(b => b.answer);
const ariaInputDescription =
- inputType === 'pinyin-to-hanzi' ? t('aria.pinyin-to-hanzi-input-desc') : '';
+ inputType === 'pinyin-to-hanzi'
+ ? t('aria.pinyin-to-hanzi-input-desc')
+ : inputType === 'pinyin-tone'
+ ? t('aria.pinyin-tone-input-desc')
+ : '';
return (
<>
-
{t(ariaInputDescription)}
+
{ariaInputDescription}
{paragraphs.map((p, i) => (
// both keys, i and j, are stable between renders, since
diff --git a/client/src/templates/Challenges/components/pinyin-to-hanzi-input.tsx b/client/src/templates/Challenges/components/pinyin-to-hanzi-input.tsx
index ec8d08d3cbc..c220bc4d60c 100644
--- a/client/src/templates/Challenges/components/pinyin-to-hanzi-input.tsx
+++ b/client/src/templates/Challenges/components/pinyin-to-hanzi-input.tsx
@@ -226,7 +226,6 @@ function PinyinToHanziInput({
size={size}
autoComplete='off'
aria-label={ariaLabel}
- aria-describedby={`pinyin-description-${index}`}
{...(isCorrect === false ? { 'aria-invalid': 'true' } : {})}
/>
);
diff --git a/client/src/templates/Challenges/components/pinyin-tone-input.test.tsx b/client/src/templates/Challenges/components/pinyin-tone-input.test.tsx
new file mode 100644
index 00000000000..989bbd7836b
--- /dev/null
+++ b/client/src/templates/Challenges/components/pinyin-tone-input.test.tsx
@@ -0,0 +1,284 @@
+import React from 'react';
+import { describe, test, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import PinyinToneInput, { convertToPinyinWithTones } from './pinyin-tone-input';
+
+describe('convertToPinyinWithTones', () => {
+ test('should convert single syllable with tone number to pinyin with tone mark', () => {
+ expect(convertToPinyinWithTones('ni3')).toBe('nǐ');
+ expect(convertToPinyinWithTones('hao3')).toBe('hǎo');
+ });
+
+ test('should handle all five tones correctly', () => {
+ expect(convertToPinyinWithTones('ma1')).toBe('mā');
+ expect(convertToPinyinWithTones('ma2')).toBe('má');
+ expect(convertToPinyinWithTones('ma3')).toBe('mǎ');
+ expect(convertToPinyinWithTones('ma4')).toBe('mà');
+ expect(convertToPinyinWithTones('ma5')).toBe('ma');
+
+ expect(convertToPinyinWithTones('v1')).toBe('ǖ');
+ expect(convertToPinyinWithTones('v2')).toBe('ǘ');
+ expect(convertToPinyinWithTones('v3')).toBe('ǚ');
+ expect(convertToPinyinWithTones('v4')).toBe('ǜ');
+ expect(convertToPinyinWithTones('v5')).toBe('ü');
+ });
+
+ test('should convert tone number before final letter', () => {
+ expect(convertToPinyinWithTones('she2n')).toBe('shén');
+ });
+
+ test('should convert tone number after final letter', () => {
+ expect(convertToPinyinWithTones('shen2')).toBe('shén');
+ expect(convertToPinyinWithTones('dian3')).toBe('diǎn');
+ });
+
+ test('should chain multiple syllables without spaces', () => {
+ expect(convertToPinyinWithTones('qing3wen4ni3jiao4shen2me5ming2zi5')).toBe(
+ 'qǐngwènnǐjiàoshénmemíngzi'
+ );
+ });
+
+ test('should preserve spaces between syllables', () => {
+ expect(
+ convertToPinyinWithTones('qing3 wen4 ni3 jiao4 shen2 me5 ming2 zi5')
+ ).toBe('qǐng wèn nǐ jiào shén me míng zi');
+ expect(convertToPinyinWithTones('ni3 hao3')).toBe('nǐ hǎo');
+ });
+
+ test('should handle uppercase input case-insensitively', () => {
+ expect(convertToPinyinWithTones('NI3HAO3')).toBe('nǐhǎo');
+ expect(convertToPinyinWithTones('Ni3hAO3')).toBe('nǐhǎo');
+ });
+
+ test('should handle incomplete syllables without tone numbers', () => {
+ expect(convertToPinyinWithTones('ni')).toBe('ni');
+ expect(convertToPinyinWithTones('niha')).toBe('niha');
+ });
+
+ test('should handle mixed complete and incomplete syllables', () => {
+ expect(convertToPinyinWithTones('ni3ha')).toBe('nǐha');
+ expect(convertToPinyinWithTones('ni3hao')).toBe('nǐhao');
+ });
+});
+
+describe('PinyinToneInput component', () => {
+ test('should convert syllable when tone number is typed', async () => {
+ const mockOnChange = vi.fn();
+
+ render(
+
+ );
+
+ const input = screen.getByLabelText('blank');
+
+ await userEvent.type(input, 'ni3');
+ expect(input.value).toBe('nǐ');
+ });
+
+ test('should chain multiple syllables without spaces', async () => {
+ const mockOnChange = vi.fn();
+
+ render(
+
+ );
+
+ const input = screen.getByLabelText('blank');
+
+ await userEvent.type(input, 'ni3hao3');
+ expect(input.value).toBe('nǐhǎo');
+ });
+
+ test('should convert syllables with spaces', async () => {
+ const mockOnChange = vi.fn();
+
+ render(
+
+ );
+
+ const input = screen.getByLabelText('blank');
+
+ await userEvent.type(input, 'ni3 hao3');
+ expect(input.value).toBe('nǐ hǎo');
+ });
+
+ test('should allow changing the tone digit for a syllable', async () => {
+ const mockOnChange = vi.fn();
+
+ render(
+
+ );
+
+ const input = screen.getByLabelText('blank');
+
+ await userEvent.type(input, 'ni3');
+ expect(input.value).toBe('nǐ');
+
+ // Replace tone 3 with tone 4
+ await userEvent.type(input, '4');
+ expect(input.value).toBe('nì');
+ });
+
+ test('should clear the input when selecting all and pressing backspace', async () => {
+ const mockOnChange = vi.fn();
+
+ render(
+
+ );
+
+ const input = screen.getByLabelText('blank');
+
+ await userEvent.type(input, 'ni3hao3');
+ expect(input.value).toBe('nǐhǎo');
+
+ await userEvent.clear(input);
+ expect(input.value).toBe('');
+ });
+
+ test('should handle tone number before final letter', async () => {
+ const mockOnChange = vi.fn();
+
+ render(
+
+ );
+
+ const input = screen.getByLabelText('blank');
+
+ await userEvent.type(input, 'she2n');
+ expect(input.value).toBe('shén');
+ });
+
+ test('should remove tone mark when backspacing', async () => {
+ const mockOnChange = vi.fn();
+ const expectedMap: Record = {
+ ni3hao: 'nǐhao',
+ ni3ha: 'nǐha',
+ ni3h: 'nǐh',
+ ni3: 'nǐ',
+ ni: 'ni',
+ n: 'n'
+ };
+
+ render(
+
+ );
+
+ const input = screen.getByLabelText('blank');
+
+ await userEvent.type(input, 'ni3hao3');
+ expect(input.value).toBe('nǐhǎo');
+
+ const rawSteps = ['ni3hao', 'ni3ha', 'ni3h', 'ni3', 'ni', 'n'];
+ for (const step of rawSteps) {
+ await userEvent.type(input, '{Backspace}');
+ expect(input.value).toBe(expectedMap[step]);
+ }
+ });
+
+ test('should remove final letter before tone mark when backspacing syllables with tone before final letter', async () => {
+ const mockOnChange = vi.fn();
+
+ render(
+
+ );
+
+ const input = screen.getByLabelText('blank');
+
+ // Type 'she2n' - tone digit before final letter
+ await userEvent.type(input, 'she2n');
+ expect(input.value).toBe('shén');
+
+ // First backspace removes the final letter 'n'
+ await userEvent.type(input, '{Backspace}');
+ expect(input.value).toBe('shé');
+
+ // Second backspace removes the tone mark
+ await userEvent.type(input, '{Backspace}');
+ expect(input.value).toBe('she');
+ });
+
+ test('should allow inserting mid-string', async () => {
+ const mockOnChange = vi.fn();
+
+ render(
+
+ );
+
+ const input = screen.getByLabelText('blank');
+
+ await userEvent.type(input, 'ni3hao3');
+ expect(input.value).toBe('nǐhǎo');
+
+ // Simulate mid-string edit: insert 'x' between the syllables
+ await userEvent.type(input, 'x', {
+ initialSelectionStart: 1,
+ initialSelectionEnd: 1
+ });
+
+ expect(input.value).toBe('nxǐhǎo');
+ });
+});
diff --git a/client/src/templates/Challenges/components/pinyin-tone-input.tsx b/client/src/templates/Challenges/components/pinyin-tone-input.tsx
new file mode 100644
index 00000000000..1847445c72a
--- /dev/null
+++ b/client/src/templates/Challenges/components/pinyin-tone-input.tsx
@@ -0,0 +1,133 @@
+import React, { useState } from 'react';
+import { convertUnspacedPinyin } from 'pinyin-tone/v2';
+
+/**
+ * Converts raw pinyin input (with tone numbers) to pinyin with tone marks.
+ *
+ * Key behaviors:
+ * 1. When a tone digit (1-5) is encountered -> convert syllable to toned pinyin
+ * 2. Spaces are preserved in the output
+ * 3. Incomplete syllables (without tone numbers) remain as-is
+ */
+export function convertToPinyinWithTones(raw: string): string {
+ if (!raw.trim()) return raw;
+
+ let displayOutput = '';
+ let currentSyllable = '';
+
+ // Process each character in the raw input
+ for (const character of raw) {
+ // Handle spaces: flush current syllable and preserve space
+ if (character === ' ') {
+ displayOutput += currentSyllable + ' ';
+ currentSyllable = '';
+ continue;
+ }
+
+ // Add character to current syllable
+ currentSyllable += character;
+
+ // When a tone digit is encountered, convert the syllable
+ if (/[1-5]/.test(character)) {
+ // Normalize to lowercase for conversion
+ const normalizedSyllable = currentSyllable.toLowerCase();
+ const convertedPinyin = convertUnspacedPinyin(normalizedSyllable);
+
+ displayOutput += convertedPinyin;
+ currentSyllable = '';
+ }
+ }
+
+ // Append any unfinished syllable at the end
+ return displayOutput + currentSyllable;
+}
+
+interface PinyinToneInputProps {
+ index: number;
+ isCorrect: boolean | null;
+ onChange: (index: number, value: string) => void;
+ className?: string;
+ maxLength: number;
+ size: number;
+ ariaLabel: string;
+}
+
+function PinyinToneInput({
+ index,
+ isCorrect,
+ onChange,
+ className,
+ maxLength,
+ size,
+ ariaLabel
+}: PinyinToneInputProps): JSX.Element {
+ const [rawInput, setRawInput] = useState('');
+ const [displayValue, setDisplayValue] = useState('');
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const inputValue = e.target.value;
+ const prevLength = displayValue.length;
+ const inputLength = inputValue.length;
+
+ const isAppendingAtEnd =
+ inputLength > prevLength && inputValue.startsWith(displayValue);
+ const isDeletingFromEnd =
+ inputLength < prevLength && displayValue.startsWith(inputValue);
+
+ let newRawInput: string;
+
+ if (isAppendingAtEnd) {
+ const added = inputValue.substring(prevLength);
+
+ // Handle tone digit replacement
+ if (
+ added.length === 1 &&
+ /[1-5]/.test(added) &&
+ /[1-5]$/.test(rawInput)
+ ) {
+ newRawInput = rawInput.slice(0, -1) + added;
+ } else {
+ newRawInput = rawInput + added;
+ }
+ } else if (isDeletingFromEnd) {
+ if (inputLength === 0) {
+ // When clearing the entire input:
+ // - If the previous display was a single character,
+ // assume the user wants to remove the last character from raw input
+ // (e.g., undo the tone digit that converted it, like 'ni3' -> 'ni').
+ // - Otherwise, fully clear raw input to an empty string.
+ newRawInput = prevLength === 1 ? rawInput.slice(0, -1) : '';
+ } else {
+ // Remove characters from raw input
+ const charsToRemove = prevLength - inputLength;
+ newRawInput = rawInput.slice(0, -charsToRemove);
+ }
+ } else {
+ // Mid-string edit - update new raw input directly
+ newRawInput = inputValue;
+ }
+
+ setRawInput(newRawInput);
+ const newDisplayValue = convertToPinyinWithTones(newRawInput);
+ setDisplayValue(newDisplayValue);
+ onChange(index, newDisplayValue);
+ };
+
+ return (
+
+ );
+}
+
+PinyinToneInput.displayName = 'PinyinToneInput';
+
+export default PinyinToneInput;
diff --git a/client/src/templates/Challenges/fill-in-the-blank/show.tsx b/client/src/templates/Challenges/fill-in-the-blank/show.tsx
index 5b1cc32ff76..f761487ebe2 100644
--- a/client/src/templates/Challenges/fill-in-the-blank/show.tsx
+++ b/client/src/templates/Challenges/fill-in-the-blank/show.tsx
@@ -187,6 +187,13 @@ const ShowFillInTheBlank = ({
hanzi.replace(/\s+/g, '')
);
}
+ } else if (fillInTheBlank.inputType === 'pinyin-tone') {
+ // Ignore spaces to allow both syllable formats:
+ // spaced (e.g., 'nǐ hǎo') and unspaced (e.g., 'nǐhǎo').
+ return (
+ normalizedUserAnswer.replace(/\s+/g, '') ===
+ answer.toLowerCase().replace(/\s+/g, '')
+ );
}
return normalizedUserAnswer === answer.toLowerCase();