diff --git a/src/components/launch/NotesToolbar.browser.test.tsx b/src/components/launch/NotesToolbar.browser.test.tsx new file mode 100644 index 000000000..5861a0671 --- /dev/null +++ b/src/components/launch/NotesToolbar.browser.test.tsx @@ -0,0 +1,136 @@ +import "@/index.css"; +import type { Editor } from "@tiptap/react"; +import { useState } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { userEvent } from "vitest/browser"; +import { NotesToolbar } from "./NotesToolbar"; + +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock("@/contexts/I18nContext", () => ({ + useI18n: () => ({ locale: "en" }), + useScopedT: () => (key: string, vars?: Record) => { + const labels: Record = { + "tooltips.notesToolbar.play": "Play", + "tooltips.notesToolbar.pause": "Pause", + "tooltips.notesToolbar.speed": "Scroll speed", + "tooltips.notesToolbar.decreaseSpeed": "Decrease scroll speed", + "tooltips.notesToolbar.increaseSpeed": "Increase scroll speed", + "tooltips.notesToolbar.fontSize": "Font size", + "tooltips.notesToolbar.decreaseFontSize": "Decrease font size", + "tooltips.notesToolbar.increaseFontSize": "Increase font size", + "tooltips.notesToolbar.mirror": "Mirror", + "units.pixelsPerSecond": "{{value}} px/s", + "units.pixels": "{{value}} px", + }; + return (labels[key] ?? key).replace(/\{\{(\w+)\}\}/g, (_, name: string) => + String(vars?.[name] ?? `{{${name}}}`), + ); + }, +})); + +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 ToolbarHarness() { + const [isPlaying, setIsPlaying] = useState(false); + const [speed, setSpeed] = useState(40); + const [fontSize, setFontSize] = useState(16); + const [mirrored, setMirrored] = useState(false); + + return ( + setIsPlaying((current) => !current)} + onDecreaseSpeed={() => setSpeed((current) => current - 10)} + onIncreaseSpeed={() => setSpeed((current) => current + 10)} + onDecreaseFontSize={() => setFontSize((current) => current - 2)} + onIncreaseFontSize={() => setFontSize((current) => current + 2)} + onToggleMirror={() => setMirrored((current) => !current)} + /> + ); +} + +describe("NotesToolbar narrow-width reachability", () => { + let root: Root | null = null; + let host: HTMLDivElement | null = null; + + afterEach(() => { + root?.unmount(); + host?.remove(); + root = null; + host = null; + }); + + it("keeps every teleprompter control reachable at a severe 160px width", async () => { + host = document.createElement("div"); + host.style.width = "160px"; + document.body.append(host); + root = createRoot(host); + flushSync(() => root?.render()); + + const row = host.querySelector('[data-testid="notes-teleprompter-controls"]'); + const controls = Array.from( + host.querySelectorAll("[data-teleprompter-control]"), + ); + if (!row || controls.length === 0) { + throw new Error("Teleprompter controls did not render"); + } + + expect(row.scrollWidth).toBeGreaterThan(row.clientWidth); + await userEvent.wheel(row, { delta: { x: row.scrollWidth } }); + await new Promise(requestAnimationFrame); + expect(row.scrollLeft).toBeGreaterThan(0); + + const rowRect = row.getBoundingClientRect(); + const lastRect = controls.at(-1)?.getBoundingClientRect(); + expect(lastRect).toBeDefined(); + expect(lastRect!.left).toBeGreaterThanOrEqual(rowRect.left - 1); + expect(lastRect!.right).toBeLessThanOrEqual(rowRect.right + 1); + + controls[0]?.focus(); + expect(document.activeElement).toBe(controls[0]); + for (let index = 1; index < controls.length; index++) { + await userEvent.tab(); + expect(document.activeElement).toBe(controls[index]); + } + + controls[0]?.focus(); + await userEvent.keyboard("{Enter}"); + expect(controls[0]?.getAttribute("aria-label")).toBe("Pause"); + + const mirror = controls.at(-1)!; + await userEvent.click(mirror); + expect(mirror.getAttribute("aria-pressed")).toBe("true"); + }); +}); diff --git a/src/components/launch/NotesToolbar.test.tsx b/src/components/launch/NotesToolbar.test.tsx new file mode 100644 index 000000000..6564b60d2 --- /dev/null +++ b/src/components/launch/NotesToolbar.test.tsx @@ -0,0 +1,144 @@ +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 } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { I18nProvider, useI18n } from "@/contexts/I18nContext"; +import { NotesToolbar, type NotesToolbarProps } from "./NotesToolbar"; + +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => children, +})); + +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("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)} بكسل`); + }); +}); diff --git a/src/components/launch/NotesToolbar.tsx b/src/components/launch/NotesToolbar.tsx index c744b525c..0fd14e3e0 100644 --- a/src/components/launch/NotesToolbar.tsx +++ b/src/components/launch/NotesToolbar.tsx @@ -1,12 +1,41 @@ 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 = { @@ -14,6 +43,7 @@ type ToolbarButtonProps = { tooltipContent: string; active?: boolean; disabled?: boolean; + teleprompterControl?: boolean; onClick: () => void; children: ReactNode; }; @@ -21,8 +51,9 @@ type ToolbarButtonProps = { function ToolbarButton({ "aria-label": ariaLabel, tooltipContent, - active = false, + active, disabled = false, + teleprompterControl = false, onClick, children, }: ToolbarButtonProps) { @@ -33,9 +64,10 @@ 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", + "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 && "bg-gray-900 text-white hover:bg-gray-800 hover:text-white", )} > @@ -67,86 +99,193 @@ 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 numberFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]); return ( -
-
- editor?.chain().focus().toggleBold().run()} - > - - - editor?.chain().focus().toggleItalic().run()} - > - - - editor?.chain().focus().toggleStrike().run()} - > - - -
-
-
-