Skip to content

Commit e785295

Browse files
committed
feat: add Notes teleprompter mode
1 parent f508a21 commit e785295

21 files changed

Lines changed: 1232 additions & 116 deletions
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import "@/index.css";
2+
import type { Editor } from "@tiptap/react";
3+
import { useState } from "react";
4+
import { flushSync } from "react-dom";
5+
import { createRoot, type Root } from "react-dom/client";
6+
import { afterEach, describe, expect, it, vi } from "vitest";
7+
import { userEvent } from "vitest/browser";
8+
import { NotesToolbar } from "./NotesToolbar";
9+
10+
vi.mock("@/components/ui/tooltip", () => ({
11+
Tooltip: ({ children }: { children: React.ReactNode }) => children,
12+
}));
13+
14+
vi.mock("@/contexts/I18nContext", () => ({
15+
useScopedT: () => (key: string) => {
16+
const labels: Record<string, string> = {
17+
"tooltips.notesToolbar.play": "Play",
18+
"tooltips.notesToolbar.pause": "Pause",
19+
"tooltips.notesToolbar.speed": "Scroll speed",
20+
"tooltips.notesToolbar.decreaseSpeed": "Decrease scroll speed",
21+
"tooltips.notesToolbar.increaseSpeed": "Increase scroll speed",
22+
"tooltips.notesToolbar.fontSize": "Font size",
23+
"tooltips.notesToolbar.decreaseFontSize": "Decrease font size",
24+
"tooltips.notesToolbar.increaseFontSize": "Increase font size",
25+
"tooltips.notesToolbar.mirror": "Mirror",
26+
};
27+
return labels[key] ?? key;
28+
},
29+
}));
30+
31+
function createEditor(): Editor {
32+
const chain: Record<string, ReturnType<typeof vi.fn>> = {};
33+
for (const command of [
34+
"focus",
35+
"toggleBold",
36+
"toggleItalic",
37+
"toggleStrike",
38+
"toggleBulletList",
39+
"toggleOrderedList",
40+
"toggleBlockquote",
41+
"toggleCodeBlock",
42+
]) {
43+
chain[command] = vi.fn(() => chain);
44+
}
45+
chain.run = vi.fn(() => true);
46+
47+
return {
48+
can: () => ({ chain: () => chain }),
49+
chain: () => chain,
50+
isActive: () => false,
51+
on: vi.fn(),
52+
off: vi.fn(),
53+
} as unknown as Editor;
54+
}
55+
56+
function ToolbarHarness() {
57+
const [isPlaying, setIsPlaying] = useState(false);
58+
const [speed, setSpeed] = useState(40);
59+
const [fontSize, setFontSize] = useState(16);
60+
const [mirrored, setMirrored] = useState(false);
61+
62+
return (
63+
<NotesToolbar
64+
editor={createEditor()}
65+
isPlaying={isPlaying}
66+
speed={speed}
67+
fontSize={fontSize}
68+
mirrored={mirrored}
69+
onTogglePlaying={() => setIsPlaying((current) => !current)}
70+
onDecreaseSpeed={() => setSpeed((current) => current - 10)}
71+
onIncreaseSpeed={() => setSpeed((current) => current + 10)}
72+
onDecreaseFontSize={() => setFontSize((current) => current - 2)}
73+
onIncreaseFontSize={() => setFontSize((current) => current + 2)}
74+
onToggleMirror={() => setMirrored((current) => !current)}
75+
/>
76+
);
77+
}
78+
79+
describe("NotesToolbar narrow-width reachability", () => {
80+
let root: Root | null = null;
81+
let host: HTMLDivElement | null = null;
82+
83+
afterEach(() => {
84+
root?.unmount();
85+
host?.remove();
86+
root = null;
87+
host = null;
88+
});
89+
90+
it("keeps every teleprompter control reachable at a severe 160px width", async () => {
91+
host = document.createElement("div");
92+
host.style.width = "160px";
93+
document.body.append(host);
94+
root = createRoot(host);
95+
flushSync(() => root?.render(<ToolbarHarness />));
96+
97+
const row = host.querySelector<HTMLElement>('[data-testid="notes-teleprompter-controls"]');
98+
const controls = Array.from(
99+
host.querySelectorAll<HTMLButtonElement>("[data-teleprompter-control]"),
100+
);
101+
if (!row || controls.length === 0) {
102+
throw new Error("Teleprompter controls did not render");
103+
}
104+
105+
expect(row.scrollWidth).toBeGreaterThan(row.clientWidth);
106+
await userEvent.wheel(row, { delta: { x: row.scrollWidth } });
107+
await new Promise(requestAnimationFrame);
108+
expect(row.scrollLeft).toBeGreaterThan(0);
109+
110+
const rowRect = row.getBoundingClientRect();
111+
const lastRect = controls.at(-1)?.getBoundingClientRect();
112+
expect(lastRect).toBeDefined();
113+
expect(lastRect!.left).toBeGreaterThanOrEqual(rowRect.left - 1);
114+
expect(lastRect!.right).toBeLessThanOrEqual(rowRect.right + 1);
115+
116+
controls[0]?.focus();
117+
expect(document.activeElement).toBe(controls[0]);
118+
for (let index = 1; index < controls.length; index++) {
119+
await userEvent.tab();
120+
expect(document.activeElement).toBe(controls[index]);
121+
}
122+
123+
controls[0]?.focus();
124+
await userEvent.keyboard("{Enter}");
125+
expect(controls[0]?.getAttribute("aria-label")).toBe("Pause");
126+
127+
const mirror = controls.at(-1)!;
128+
await userEvent.click(mirror);
129+
expect(mirror.getAttribute("aria-pressed")).toBe("true");
130+
});
131+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import "@testing-library/jest-dom";
2+
import { render, screen } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import type { Editor } from "@tiptap/react";
5+
import { describe, expect, it, vi } from "vitest";
6+
import { NotesToolbar, type NotesToolbarProps } from "./NotesToolbar";
7+
8+
vi.mock("@/components/ui/tooltip", () => ({
9+
Tooltip: ({ children }: { children: React.ReactNode }) => children,
10+
}));
11+
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+
29+
function createEditor(): Editor {
30+
const chain: Record<string, ReturnType<typeof vi.fn>> = {};
31+
for (const command of [
32+
"focus",
33+
"toggleBold",
34+
"toggleItalic",
35+
"toggleStrike",
36+
"toggleBulletList",
37+
"toggleOrderedList",
38+
"toggleBlockquote",
39+
"toggleCodeBlock",
40+
]) {
41+
chain[command] = vi.fn(() => chain);
42+
}
43+
chain.run = vi.fn(() => true);
44+
45+
return {
46+
can: () => ({ chain: () => chain }),
47+
chain: () => chain,
48+
isActive: () => false,
49+
on: vi.fn(),
50+
off: vi.fn(),
51+
} as unknown as Editor;
52+
}
53+
54+
function createProps(overrides: Partial<NotesToolbarProps> = {}): NotesToolbarProps {
55+
return {
56+
editor: createEditor(),
57+
isPlaying: false,
58+
speed: 40,
59+
fontSize: 16,
60+
mirrored: false,
61+
onTogglePlaying: vi.fn(),
62+
onDecreaseSpeed: vi.fn(),
63+
onIncreaseSpeed: vi.fn(),
64+
onDecreaseFontSize: vi.fn(),
65+
onIncreaseFontSize: vi.fn(),
66+
onToggleMirror: vi.fn(),
67+
...overrides,
68+
};
69+
}
70+
71+
describe("NotesToolbar teleprompter controls", () => {
72+
it("exposes values and dispatches every manual control", async () => {
73+
const user = userEvent.setup();
74+
const props = createProps();
75+
render(<NotesToolbar {...props} />);
76+
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");
80+
expect(screen.getByRole("button", { name: "Decrease scroll speed" })).not.toHaveAttribute(
81+
"aria-pressed",
82+
);
83+
expect(screen.getByRole("button", { name: "Increase font size" })).not.toHaveAttribute(
84+
"aria-pressed",
85+
);
86+
87+
await user.click(screen.getByRole("button", { name: "Play" }));
88+
await user.click(screen.getByRole("button", { name: "Decrease scroll speed" }));
89+
await user.click(screen.getByRole("button", { name: "Increase scroll speed" }));
90+
await user.click(screen.getByRole("button", { name: "Decrease font size" }));
91+
await user.click(screen.getByRole("button", { name: "Increase font size" }));
92+
await user.click(screen.getByRole("button", { name: "Mirror" }));
93+
94+
expect(props.onTogglePlaying).toHaveBeenCalledOnce();
95+
expect(props.onDecreaseSpeed).toHaveBeenCalledOnce();
96+
expect(props.onIncreaseSpeed).toHaveBeenCalledOnce();
97+
expect(props.onDecreaseFontSize).toHaveBeenCalledOnce();
98+
expect(props.onIncreaseFontSize).toHaveBeenCalledOnce();
99+
expect(props.onToggleMirror).toHaveBeenCalledOnce();
100+
});
101+
102+
it("disables controls at their bounds", () => {
103+
const { rerender } = render(<NotesToolbar {...createProps({ speed: 10, fontSize: 14 })} />);
104+
expect(screen.getByRole("button", { name: "Decrease scroll speed" })).toBeDisabled();
105+
expect(screen.getByRole("button", { name: "Decrease font size" })).toBeDisabled();
106+
107+
rerender(<NotesToolbar {...createProps({ speed: 100, fontSize: 48 })} />);
108+
expect(screen.getByRole("button", { name: "Increase scroll speed" })).toBeDisabled();
109+
expect(screen.getByRole("button", { name: "Increase font size" })).toBeDisabled();
110+
});
111+
112+
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();
116+
});
117+
});

0 commit comments

Comments
 (0)