Skip to content

Commit 565c739

Browse files
committed
Localize teleprompter readouts
1 parent e785295 commit 565c739

17 files changed

Lines changed: 130 additions & 47 deletions

File tree

src/components/launch/NotesToolbar.browser.test.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ vi.mock("@/components/ui/tooltip", () => ({
1212
}));
1313

1414
vi.mock("@/contexts/I18nContext", () => ({
15-
useScopedT: () => (key: string) => {
15+
useI18n: () => ({ locale: "en" }),
16+
useScopedT: () => (key: string, vars?: Record<string, string | number>) => {
1617
const labels: Record<string, string> = {
1718
"tooltips.notesToolbar.play": "Play",
1819
"tooltips.notesToolbar.pause": "Pause",
@@ -23,8 +24,12 @@ vi.mock("@/contexts/I18nContext", () => ({
2324
"tooltips.notesToolbar.decreaseFontSize": "Decrease font size",
2425
"tooltips.notesToolbar.increaseFontSize": "Increase font size",
2526
"tooltips.notesToolbar.mirror": "Mirror",
27+
"units.pixelsPerSecond": "{{value}} px/s",
28+
"units.pixels": "{{value}} px",
2629
};
27-
return labels[key] ?? key;
30+
return (labels[key] ?? key).replace(/\{\{(\w+)\}\}/g, (_, name: string) =>
31+
String(vars?.[name] ?? `{{${name}}}`),
32+
);
2833
},
2934
}));
3035

src/components/launch/NotesToolbar.test.tsx

Lines changed: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,16 @@
11
import "@testing-library/jest-dom";
2-
import { render, screen } from "@testing-library/react";
2+
import { render, screen, within } from "@testing-library/react";
33
import userEvent from "@testing-library/user-event";
44
import type { Editor } from "@tiptap/react";
5+
import { type ReactNode, useLayoutEffect } from "react";
56
import { describe, expect, it, vi } from "vitest";
7+
import { I18nProvider, useI18n } from "@/contexts/I18nContext";
68
import { NotesToolbar, type NotesToolbarProps } from "./NotesToolbar";
79

810
vi.mock("@/components/ui/tooltip", () => ({
911
Tooltip: ({ children }: { children: React.ReactNode }) => children,
1012
}));
1113

12-
vi.mock("@/contexts/I18nContext", () => ({
13-
useScopedT: () => (key: string) => {
14-
const labels: Record<string, string> = {
15-
"tooltips.notesToolbar.play": "Play",
16-
"tooltips.notesToolbar.pause": "Pause",
17-
"tooltips.notesToolbar.speed": "Scroll speed",
18-
"tooltips.notesToolbar.decreaseSpeed": "Decrease scroll speed",
19-
"tooltips.notesToolbar.increaseSpeed": "Increase scroll speed",
20-
"tooltips.notesToolbar.fontSize": "Font size",
21-
"tooltips.notesToolbar.decreaseFontSize": "Decrease font size",
22-
"tooltips.notesToolbar.increaseFontSize": "Increase font size",
23-
"tooltips.notesToolbar.mirror": "Mirror",
24-
};
25-
return labels[key] ?? key;
26-
},
27-
}));
28-
2914
function createEditor(): Editor {
3015
const chain: Record<string, ReturnType<typeof vi.fn>> = {};
3116
for (const command of [
@@ -68,28 +53,55 @@ function createProps(overrides: Partial<NotesToolbarProps> = {}): NotesToolbarPr
6853
};
6954
}
7055

56+
function ActiveLocale({ children, locale }: { children: ReactNode; locale: string }) {
57+
const { setLocale } = useI18n();
58+
59+
useLayoutEffect(() => {
60+
setLocale(locale);
61+
}, [locale, setLocale]);
62+
63+
return children;
64+
}
65+
66+
function renderToolbar(props: NotesToolbarProps, locale = "en") {
67+
return render(
68+
<I18nProvider>
69+
<ActiveLocale locale={locale}>
70+
<NotesToolbar {...props} />
71+
</ActiveLocale>
72+
</I18nProvider>,
73+
);
74+
}
75+
7176
describe("NotesToolbar teleprompter controls", () => {
7277
it("exposes values and dispatches every manual control", async () => {
7378
const user = userEvent.setup();
7479
const props = createProps();
75-
render(<NotesToolbar {...props} />);
80+
renderToolbar(props);
7681

77-
expect(screen.getByRole("status", { name: "Scroll speed" })).toHaveTextContent("40 px/s");
78-
expect(screen.getByRole("status", { name: "Font size" })).toHaveTextContent("16 px");
79-
expect(screen.getByRole("button", { name: "Mirror" })).toHaveAttribute("aria-pressed", "false");
82+
const speed = within(screen.getByRole("group", { name: "Scroll speed" })).getByRole("status");
83+
const fontSize = within(screen.getByRole("group", { name: "Font size" })).getByRole("status");
84+
expect(speed).not.toHaveAccessibleName();
85+
expect(speed).toHaveTextContent("40 px/s");
86+
expect(fontSize).not.toHaveAccessibleName();
87+
expect(fontSize).toHaveTextContent("16 px");
88+
expect(screen.getByRole("button", { name: "Mirror horizontally" })).toHaveAttribute(
89+
"aria-pressed",
90+
"false",
91+
);
8092
expect(screen.getByRole("button", { name: "Decrease scroll speed" })).not.toHaveAttribute(
8193
"aria-pressed",
8294
);
8395
expect(screen.getByRole("button", { name: "Increase font size" })).not.toHaveAttribute(
8496
"aria-pressed",
8597
);
8698

87-
await user.click(screen.getByRole("button", { name: "Play" }));
99+
await user.click(screen.getByRole("button", { name: "Start auto-scroll" }));
88100
await user.click(screen.getByRole("button", { name: "Decrease scroll speed" }));
89101
await user.click(screen.getByRole("button", { name: "Increase scroll speed" }));
90102
await user.click(screen.getByRole("button", { name: "Decrease font size" }));
91103
await user.click(screen.getByRole("button", { name: "Increase font size" }));
92-
await user.click(screen.getByRole("button", { name: "Mirror" }));
104+
await user.click(screen.getByRole("button", { name: "Mirror horizontally" }));
93105

94106
expect(props.onTogglePlaying).toHaveBeenCalledOnce();
95107
expect(props.onDecreaseSpeed).toHaveBeenCalledOnce();
@@ -100,18 +112,33 @@ describe("NotesToolbar teleprompter controls", () => {
100112
});
101113

102114
it("disables controls at their bounds", () => {
103-
const { rerender } = render(<NotesToolbar {...createProps({ speed: 10, fontSize: 14 })} />);
115+
const { rerender } = renderToolbar(createProps({ speed: 10, fontSize: 14 }));
104116
expect(screen.getByRole("button", { name: "Decrease scroll speed" })).toBeDisabled();
105117
expect(screen.getByRole("button", { name: "Decrease font size" })).toBeDisabled();
106118

107-
rerender(<NotesToolbar {...createProps({ speed: 100, fontSize: 48 })} />);
119+
rerender(
120+
<I18nProvider>
121+
<NotesToolbar {...createProps({ speed: 100, fontSize: 48 })} />
122+
</I18nProvider>,
123+
);
108124
expect(screen.getByRole("button", { name: "Increase scroll speed" })).toBeDisabled();
109125
expect(screen.getByRole("button", { name: "Increase font size" })).toBeDisabled();
110126
});
111127

112128
it("keeps playback paused and disabled until the editor is ready", () => {
113-
render(<NotesToolbar {...createProps({ editor: null })} />);
114-
expect(screen.getByRole("button", { name: "Play" })).toBeDisabled();
115-
expect(screen.queryByRole("button", { name: "Pause" })).not.toBeInTheDocument();
129+
renderToolbar(createProps({ editor: null }));
130+
expect(screen.getByRole("button", { name: "Start auto-scroll" })).toBeDisabled();
131+
expect(screen.queryByRole("button", { name: "Pause auto-scroll" })).not.toBeInTheDocument();
132+
});
133+
134+
it("formats readout values for the active locale", () => {
135+
renderToolbar(createProps(), "ar");
136+
const speed = within(screen.getByRole("group", { name: "سرعة التمرير" })).getByRole("status");
137+
const fontSize = within(screen.getByRole("group", { name: "حجم الخط" })).getByRole("status");
138+
139+
expect(speed).not.toHaveAccessibleName();
140+
expect(speed).toHaveTextContent(`${new Intl.NumberFormat("ar").format(40)} بكسل/ثانية`);
141+
expect(fontSize).not.toHaveAccessibleName();
142+
expect(fontSize).toHaveTextContent(`${new Intl.NumberFormat("ar").format(16)} بكسل`);
116143
});
117144
});

src/components/launch/NotesToolbar.tsx

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ import {
1313
Quote,
1414
Strikethrough,
1515
} from "lucide-react";
16-
import { type ReactNode, useEffect, useReducer } from "react";
16+
import { type ReactNode, useEffect, useMemo, useReducer } from "react";
1717
import { Tooltip } from "@/components/ui/tooltip";
18-
import { useScopedT } from "@/contexts/I18nContext";
18+
import { useI18n, useScopedT } from "@/contexts/I18nContext";
1919
import { cn } from "@/lib/utils";
2020
import {
2121
MAX_NOTES_FONT_SIZE,
@@ -113,7 +113,9 @@ export function NotesToolbar({
113113
onToggleMirror,
114114
}: NotesToolbarProps) {
115115
useEditorRevision(editor);
116+
const { locale } = useI18n();
116117
const t = useScopedT("launch");
118+
const numberFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]);
117119

118120
return (
119121
<div className="flex w-full min-w-0 max-w-full flex-col gap-1.5 rounded-[0.625rem] border border-gray-200 bg-gray-50 p-1.5">
@@ -232,12 +234,8 @@ export function NotesToolbar({
232234
>
233235
<Minus size={16} />
234236
</ToolbarButton>
235-
<output
236-
aria-label={t("tooltips.notesToolbar.speed")}
237-
aria-live="polite"
238-
className="min-w-14 text-center text-xs tabular-nums text-gray-700"
239-
>
240-
{speed} px/s
237+
<output className="min-w-14 text-center text-xs tabular-nums text-gray-700">
238+
{t("units.pixelsPerSecond", { value: numberFormatter.format(speed) })}
241239
</output>
242240
<ToolbarButton
243241
aria-label={t("tooltips.notesToolbar.increaseSpeed")}
@@ -264,12 +262,8 @@ export function NotesToolbar({
264262
>
265263
<Minus size={16} />
266264
</ToolbarButton>
267-
<output
268-
aria-label={t("tooltips.notesToolbar.fontSize")}
269-
aria-live="polite"
270-
className="min-w-10 text-center text-xs tabular-nums text-gray-700"
271-
>
272-
{fontSize} px
265+
<output className="min-w-10 text-center text-xs tabular-nums text-gray-700">
266+
{t("units.pixels", { value: numberFormatter.format(fontSize) })}
273267
</output>
274268
<ToolbarButton
275269
aria-label={t("tooltips.notesToolbar.increaseFontSize")}

src/components/launch/NotesWindow.test.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ vi.mock("@/components/ui/tooltip", () => ({
3232
}));
3333

3434
vi.mock("@/contexts/I18nContext", () => ({
35-
useScopedT: () => (key: string) => {
35+
useI18n: () => ({ locale: "en" }),
36+
useScopedT: () => (key: string, vars?: Record<string, string | number>) => {
3637
const labels: Record<string, string> = {
3738
"tooltips.notesToolbar.play": "Play",
3839
"tooltips.notesToolbar.pause": "Pause",
@@ -43,8 +44,12 @@ vi.mock("@/contexts/I18nContext", () => ({
4344
"tooltips.notesToolbar.decreaseFontSize": "Decrease font size",
4445
"tooltips.notesToolbar.increaseFontSize": "Increase font size",
4546
"tooltips.notesToolbar.mirror": "Mirror",
47+
"units.pixelsPerSecond": "{{value}} px/s",
48+
"units.pixels": "{{value}} px",
4649
};
47-
return labels[key] ?? key;
50+
return (labels[key] ?? key).replace(/\{\{(\w+)\}\}/g, (_, name: string) =>
51+
String(vars?.[name] ?? `{{${name}}}`),
52+
);
4853
},
4954
}));
5055

src/i18n/locales/ar/launch.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
"mirror": "عكس أفقي"
3232
}
3333
},
34+
"units": {
35+
"pixelsPerSecond": "{{value}} بكسل/ثانية",
36+
"pixels": "{{value}} بكسل"
37+
},
3438
"audio": {
3539
"enableSystemAudio": "تفعيل صوت النظام",
3640
"disableSystemAudio": "تعطيل صوت النظام",

src/i18n/locales/en/launch.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@
3232
"mirror": "Mirror horizontally"
3333
}
3434
},
35+
"units": {
36+
"pixelsPerSecond": "{{value}} px/s",
37+
"pixels": "{{value}} px"
38+
},
3539
"audio": {
3640
"enableSystemAudio": "Enable system audio",
3741
"disableSystemAudio": "Disable system audio",

src/i18n/locales/es/launch.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
"mirror": "Reflejar horizontalmente"
3232
}
3333
},
34+
"units": {
35+
"pixelsPerSecond": "{{value}} píxeles/s",
36+
"pixels": "{{value}} píxeles"
37+
},
3438
"audio": {
3539
"enableSystemAudio": "Activar audio del sistema",
3640
"disableSystemAudio": "Desactivar audio del sistema",

src/i18n/locales/fr/launch.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
"mirror": "Miroir horizontal"
3232
}
3333
},
34+
"units": {
35+
"pixelsPerSecond": "{{value}} pixels/s",
36+
"pixels": "{{value}} pixels"
37+
},
3438
"audio": {
3539
"enableSystemAudio": "Activer l'audio système",
3640
"disableSystemAudio": "Désactiver l'audio système",

src/i18n/locales/it/launch.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
"mirror": "Specchia orizzontalmente"
3232
}
3333
},
34+
"units": {
35+
"pixelsPerSecond": "{{value}} pixel/s",
36+
"pixels": "{{value}} pixel"
37+
},
3438
"audio": {
3539
"enableSystemAudio": "Abilita audio di sistema",
3640
"disableSystemAudio": "Disabilita audio di sistema",

src/i18n/locales/ja-JP/launch.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
"mirror": "左右反転"
3232
}
3333
},
34+
"units": {
35+
"pixelsPerSecond": "{{value}} px/秒",
36+
"pixels": "{{value}} px"
37+
},
3438
"audio": {
3539
"enableSystemAudio": "システム音声を有効にする",
3640
"disableSystemAudio": "システム音声を無効にする",

0 commit comments

Comments
 (0)