diff --git a/src/components/launch/NotesToolbar.test.tsx b/src/components/launch/NotesToolbar.test.tsx new file mode 100644 index 000000000..9ac483ccb --- /dev/null +++ b/src/components/launch/NotesToolbar.test.tsx @@ -0,0 +1,234 @@ +import "@testing-library/jest-dom"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Editor } from "@tiptap/react"; +import { type ReactNode, useLayoutEffect, useState } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider, useI18n } from "@/contexts/I18nContext"; +import { LOCALE_STORAGE_KEY } from "@/i18n/config"; +import { NotesToolbar, type NotesToolbarProps } from "./NotesToolbar"; + +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => children, +})); + +beforeEach(() => { + // `setLocale` persists its choice, and `I18nProvider` reads it back on mount — without + // this, the one test that switches language would leak into every test after it. + localStorage.removeItem(LOCALE_STORAGE_KEY); +}); + +function createEditor(): Editor { + const chain: Record> = {}; + for (const command of [ + "focus", + "toggleBold", + "toggleItalic", + "toggleStrike", + "toggleBulletList", + "toggleOrderedList", + "toggleBlockquote", + "toggleCodeBlock", + ]) { + chain[command] = vi.fn(() => chain); + } + chain.run = vi.fn(() => true); + + return { + can: () => ({ chain: () => chain }), + chain: () => chain, + isActive: () => false, + on: vi.fn(), + off: vi.fn(), + } as unknown as Editor; +} + +function createProps(overrides: Partial = {}): NotesToolbarProps { + return { + editor: createEditor(), + isPlaying: false, + speed: 40, + fontSize: 16, + mirrored: false, + onTogglePlaying: vi.fn(), + onDecreaseSpeed: vi.fn(), + onIncreaseSpeed: vi.fn(), + onDecreaseFontSize: vi.fn(), + onIncreaseFontSize: vi.fn(), + onToggleMirror: vi.fn(), + ...overrides, + }; +} + +function ActiveLocale({ children, locale }: { children: ReactNode; locale: string }) { + const { setLocale } = useI18n(); + + useLayoutEffect(() => { + setLocale(locale); + }, [locale, setLocale]); + + return children; +} + +function renderToolbar(props: NotesToolbarProps, locale = "en") { + return render( + + + + + , + ); +} + +describe("NotesToolbar teleprompter controls", () => { + it("exposes values and dispatches every manual control", async () => { + const user = userEvent.setup(); + const props = createProps(); + renderToolbar(props); + + const speed = within(screen.getByRole("group", { name: "Scroll speed" })).getByRole("status"); + const fontSize = within(screen.getByRole("group", { name: "Font size" })).getByRole("status"); + expect(speed).not.toHaveAccessibleName(); + expect(speed).toHaveTextContent("40 px/s"); + expect(fontSize).not.toHaveAccessibleName(); + expect(fontSize).toHaveTextContent("16 px"); + expect(screen.getByRole("button", { name: "Mirror horizontally" })).toHaveAttribute( + "aria-pressed", + "false", + ); + expect(screen.getByRole("button", { name: "Decrease scroll speed" })).not.toHaveAttribute( + "aria-pressed", + ); + expect(screen.getByRole("button", { name: "Increase font size" })).not.toHaveAttribute( + "aria-pressed", + ); + + await user.click(screen.getByRole("button", { name: "Start auto-scroll" })); + await user.click(screen.getByRole("button", { name: "Decrease scroll speed" })); + await user.click(screen.getByRole("button", { name: "Increase scroll speed" })); + await user.click(screen.getByRole("button", { name: "Decrease font size" })); + await user.click(screen.getByRole("button", { name: "Increase font size" })); + await user.click(screen.getByRole("button", { name: "Mirror horizontally" })); + + expect(props.onTogglePlaying).toHaveBeenCalledOnce(); + expect(props.onDecreaseSpeed).toHaveBeenCalledOnce(); + expect(props.onIncreaseSpeed).toHaveBeenCalledOnce(); + expect(props.onDecreaseFontSize).toHaveBeenCalledOnce(); + expect(props.onIncreaseFontSize).toHaveBeenCalledOnce(); + expect(props.onToggleMirror).toHaveBeenCalledOnce(); + }); + + it("disables controls at their bounds", () => { + const { rerender } = renderToolbar(createProps({ speed: 10, fontSize: 14 })); + expect(screen.getByRole("button", { name: "Decrease scroll speed" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Decrease font size" })).toBeDisabled(); + + rerender( + + + , + ); + expect(screen.getByRole("button", { name: "Increase scroll speed" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Increase font size" })).toBeDisabled(); + }); + + it("keeps playback paused and disabled until the editor is ready", () => { + renderToolbar(createProps({ editor: null })); + expect(screen.getByRole("button", { name: "Start auto-scroll" })).toBeDisabled(); + expect(screen.queryByRole("button", { name: "Pause auto-scroll" })).not.toBeInTheDocument(); + }); + + it("announces playback through its label rather than a second pressed state", () => { + const { rerender } = renderToolbar(createProps()); + expect(screen.getByRole("button", { name: "Start auto-scroll" })).not.toHaveAttribute( + "aria-pressed", + ); + + rerender( + + + , + ); + expect(screen.getByRole("button", { name: "Pause auto-scroll" })).not.toHaveAttribute( + "aria-pressed", + ); + }); + + it("locks formatting while the teleprompter scrolls", () => { + renderToolbar(createProps({ isPlaying: true })); + + // Sweep the whole row rather than a hand-written list, so a formatting button added + // later cannot quietly escape the lock. + const formatting = within(screen.getByTestId("notes-formatting-controls")).getAllByRole( + "button", + ); + expect(formatting).toHaveLength(7); + for (const button of formatting) { + expect(button).toBeDisabled(); + } + + // Teleprompter controls stay live so playback can always be stopped. + expect(screen.getByRole("button", { name: "Pause auto-scroll" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Mirror horizontally" })).toBeEnabled(); + }); + + it("formats readout values for the active locale", () => { + renderToolbar(createProps(), "ar"); + const speed = within(screen.getByRole("group", { name: "سرعة التمرير" })).getByRole("status"); + const fontSize = within(screen.getByRole("group", { name: "حجم الخط" })).getByRole("status"); + + expect(speed).not.toHaveAccessibleName(); + expect(speed).toHaveTextContent(`${new Intl.NumberFormat("ar").format(40)} بكسل/ثانية`); + expect(fontSize).not.toHaveAccessibleName(); + expect(fontSize).toHaveTextContent(`${new Intl.NumberFormat("ar").format(16)} بكسل`); + }); +}); + +function ToolbarHarness() { + const [isPlaying, setIsPlaying] = useState(false); + const [mirrored, setMirrored] = useState(false); + + return ( + setIsPlaying((current) => !current), + onToggleMirror: () => setMirrored((current) => !current), + })} + /> + ); +} + +describe("NotesToolbar keyboard reachability", () => { + it("walks every teleprompter control in order and toggles them from the keyboard", async () => { + const user = userEvent.setup(); + const { container } = render( + + + , + ); + + const row = container.querySelector('[data-testid="notes-teleprompter-controls"]'); + const controls = Array.from( + container.querySelectorAll("[data-teleprompter-control]"), + ); + expect(row).not.toBeNull(); + expect(controls).toHaveLength(6); + + controls[0]?.focus(); + expect(document.activeElement).toBe(controls[0]); + for (let index = 1; index < controls.length; index++) { + await user.tab(); + expect(document.activeElement).toBe(controls[index]); + } + + controls[0]?.focus(); + await user.keyboard("{Enter}"); + expect(controls[0]).toHaveAttribute("aria-label", "Pause auto-scroll"); + + const mirror = controls.at(-1); + await user.click(mirror as HTMLButtonElement); + expect(mirror).toHaveAttribute("aria-pressed", "true"); + }); +}); diff --git a/src/components/launch/NotesToolbar.tsx b/src/components/launch/NotesToolbar.tsx index c744b525c..c51c42e2a 100644 --- a/src/components/launch/NotesToolbar.tsx +++ b/src/components/launch/NotesToolbar.tsx @@ -1,19 +1,55 @@ import type { Editor } from "@tiptap/react"; -import { Bold, Code, Italic, List, ListOrdered, Quote, Strikethrough } from "lucide-react"; -import { type ReactNode, useEffect, useReducer } from "react"; +import { + Bold, + Code, + FlipHorizontal2, + Italic, + List, + ListOrdered, + Minus, + Pause, + Play, + Plus, + Quote, + Strikethrough, +} from "lucide-react"; +import { type ReactNode, useEffect, useMemo, useReducer } from "react"; import { Tooltip } from "@/components/ui/tooltip"; -import { useScopedT } from "@/contexts/I18nContext"; +import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { cn } from "@/lib/utils"; +import { + MAX_NOTES_FONT_SIZE, + MAX_TELEPROMPTER_SPEED, + MIN_NOTES_FONT_SIZE, + MIN_TELEPROMPTER_SPEED, +} from "./notesTeleprompter"; -type NotesToolbarProps = { +export type NotesToolbarProps = { editor: Editor | null; + isPlaying: boolean; + speed: number; + fontSize: number; + mirrored: boolean; + onTogglePlaying: () => void; + onDecreaseSpeed: () => void; + onIncreaseSpeed: () => void; + onDecreaseFontSize: () => void; + onIncreaseFontSize: () => void; + onToggleMirror: () => void; }; type ToolbarButtonProps = { "aria-label": string; tooltipContent: string; + /** Toggle state: drives both `aria-pressed` and the pressed styling. */ active?: boolean; + /** + * Pressed styling without `aria-pressed`, for buttons that already announce their state + * through a label that changes with it. Announcing both would say it twice. + */ + highlighted?: boolean; disabled?: boolean; + teleprompterControl?: boolean; onClick: () => void; children: ReactNode; }; @@ -21,8 +57,10 @@ type ToolbarButtonProps = { function ToolbarButton({ "aria-label": ariaLabel, tooltipContent, - active = false, + active, + highlighted = false, disabled = false, + teleprompterControl = false, onClick, children, }: ToolbarButtonProps) { @@ -33,10 +71,11 @@ function ToolbarButton({ aria-label={ariaLabel} aria-pressed={active} disabled={disabled} + data-teleprompter-control={teleprompterControl ? "" : undefined} onClick={onClick} className={cn( - "shrink-0 inline-flex h-8 w-8 items-center justify-center rounded-md border-0 bg-transparent text-gray-700 transition-colors hover:bg-gray-200 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-35", - active && "bg-gray-900 text-white hover:bg-gray-800 hover:text-white", + "shrink-0 inline-flex h-8 w-8 items-center justify-center rounded-md border-0 bg-transparent text-gray-700 transition-colors hover:bg-gray-200 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-violet-600 disabled:cursor-not-allowed disabled:opacity-35", + (active || highlighted) && "bg-gray-900 text-white hover:bg-gray-800 hover:text-white", )} > {children} @@ -67,86 +106,194 @@ function useEditorRevision(editor: Editor | null): void { }, [editor]); } -export function NotesToolbar({ editor }: NotesToolbarProps) { +export function NotesToolbar({ + editor, + isPlaying, + speed, + fontSize, + mirrored, + onTogglePlaying, + onDecreaseSpeed, + onIncreaseSpeed, + onDecreaseFontSize, + onIncreaseFontSize, + onToggleMirror, +}: NotesToolbarProps) { useEditorRevision(editor); + const { locale } = useI18n(); const t = useScopedT("launch"); + const tCommon = useScopedT("common"); + const numberFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]); return ( -
-
- editor?.chain().focus().toggleBold().run()} - > - - - editor?.chain().focus().toggleItalic().run()} - > - - - editor?.chain().focus().toggleStrike().run()} - > - - -
-
-
-