/** Browser-owned callout behavior: focus, composition and a live editor lock. */ import { content, expect, test, written } from "./room"; import type { WebSocketRoute } from "@playwright/test"; let CALLOUT = ` Body text. `; test("a callout type menu has one keyboard path", async ({ join, seed }) => { await seed(CALLOUT); let page = await join("ana"); let callout = content(page).locator("aside[data-plan-type]"); let trigger = callout.getByRole("combobox", { name: "Change callout type: Note" }); await expect(page.getByRole("listbox", { name: "Callout type" })).toHaveCount(0); await trigger.click(); let menu = page.getByRole("listbox", { name: "Callout type" }); let options = menu.getByRole("option"); await expect(options).toHaveCount(5); await expect(menu.getByRole("option", { name: "Note" })).toHaveAttribute("aria-selected", "true"); await page.keyboard.press("Escape"); await expect(menu).toHaveCount(0); await expect(trigger).toBeFocused(); await page.keyboard.press("ArrowDown"); await expect(menu).toBeVisible(); let note = menu.getByRole("option", { name: "Note" }); await expect(note).toBeFocused(); // Radix queues arrow focus, so keep the second arrow and Enter in the same // browser task: this is the rapid keyboard path the picker has to honour. await page.evaluate(() => { for (let key of ["ArrowDown", "Enter"]) { document.activeElement?.dispatchEvent( new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key, }), ); } }); await expect(menu).toHaveCount(0); await expect(callout).toHaveAttribute("data-plan-type", "tip"); await expect(callout.getByRole("combobox", { name: "Change callout type: Tip" })).toBeFocused(); }); test("locking the plan closes an open callout type menu", async ({ join, page, seed }) => { let sockets: WebSocketRoute[] = []; let offline = false; await page.routeWebSocket("**/ws?**", route => { if (offline) return route.close(); route.connectToServer(); sockets.push(route); }); await seed(CALLOUT); await join("ana"); let callout = content(page).locator("aside[data-plan-type]"); let trigger = callout.getByRole("combobox", { name: "Change callout type: Note" }); await trigger.click(); await expect(page.getByRole("listbox", { name: "Callout type" })).toBeVisible(); // Held down past the grace period, which a blip would not outlast. offline = true; await sockets.at(-1)!.close(); await expect(content(page)).toHaveAttribute("contenteditable", "false"); await expect(page.getByRole("listbox", { name: "Callout type" })).toHaveCount(0); await expect(trigger).toBeDisabled(); await expect(callout).toHaveAttribute("data-plan-type", "note"); }); test("a callout title preserves the browser's edit and composition state", async ({ join, room, seed }) => { await seed(CALLOUT); let page = await join("ana"); let callout = content(page).locator("aside[data-plan-type]"); let title = callout.getByRole("textbox", { name: "Callout title" }); await title.fill("Worth knowing"); await title.evaluate(element => { let text = element.firstChild!; let range = document.createRange(); range.setStart(text, 6); range.collapse(true); let selection = window.getSelection()!; selection.removeAllRanges(); selection.addRange(range); }); await page.keyboard.type("really "); await expect(title).toHaveText("Worth really knowing"); await title.evaluate(element => { element.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); element.textContent = "One\nTwo"; element.dispatchEvent(new InputEvent("input", { bubbles: true, isComposing: true })); }); await expect(title).toHaveText("One\nTwo"); await title.evaluate(element => { element.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true })); }); await expect(title).toHaveText("One Two"); await written(page, room, /title="One Two"/); let maximum = "x".repeat(100); await title.fill(maximum + "extra"); await expect(title).toHaveText(maximum); await written(page, room, new RegExp(`title="${maximum}"`)); await title.blur(); }); test("a callout title pastes plain text at its caret", async ({ context, join, room, seed }) => { await seed(CALLOUT); await context.grantPermissions(["clipboard-read", "clipboard-write"]); let page = await join("ana"); let title = content(page).locator("aside[data-plan-type]").getByRole("textbox", { name: "Callout title", }); await title.fill("Worth very knowing"); await title.evaluate(element => { let range = document.createRange(); range.setStart(element.firstChild!, 6); range.setEnd(element.firstChild!, 10); let selection = window.getSelection()!; selection.removeAllRanges(); selection.addRange(range); }); await page.evaluate(async () => { await navigator.clipboard.write([ new ClipboardItem({ "text/html": new Blob(["really
important"], { type: "text/html", }), "text/plain": new Blob(["really\nimportant"], { type: "text/plain" }), }), ]); }); await page.keyboard.press("ControlOrMeta+V"); await expect(title).toHaveText("Worth really important knowing"); await page.keyboard.type("!"); await expect(title).toHaveText("Worth really important! knowing"); await written(page, room, /title="Worth really important! knowing"/); }); test("a focused callout title reconciles a peer edit on blur", async ({ join, seed }) => { await seed(CALLOUT); let page = await join("ana"); let callout = content(page).locator("aside[data-plan-type]"); let title = callout.getByRole("textbox", { name: "Callout title" }); await title.fill("Local draft"); let peer = await join("bo"); let peerCallout = content(peer).locator("aside[data-plan-type]"); let peerTitle = peerCallout.getByRole("textbox", { name: "Callout title", }); await peerTitle.fill("Changed elsewhere"); await peerCallout.getByRole("combobox", { name: "Change callout type: Note" }).click(); await peer.getByRole("option", { name: "Warning" }).click(); await expect(callout).toHaveAttribute("data-plan-type", "warning"); await expect(title).toBeFocused(); await expect(title).toHaveText("Local draft"); await title.blur(); await expect(title).toHaveText("Changed elsewhere"); });