Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 52 additions & 15 deletions apps/web/src/phone-workspace.css
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,12 @@

/*
* While formatting docks above the keyboard, the caret's block stops short of the dock. The
* workspace already ends at the keyboard (`--app-height`), so only the dock's row is added.
* workspace already ends at the keyboard (`--app-height`), so only the dock's bar is added.
*/
.workspace-root[data-workspace-phone]:has(.phone-tab-row[data-phone-tab-toolbar])
.plan
[data-plan-scroll] {
scroll-padding-bottom: calc(
var(--phone-tab-bar-height) + var(--phone-tab-bar-offset) + var(--spacing) * 3
);
scroll-padding-bottom: calc(var(--phone-dock-height) + var(--spacing) * 3);
}

/* Focus and find-in-page scrolling stop below the top bar, never under it. */
Expand Down Expand Up @@ -266,7 +264,6 @@
}

.phone-tab-bar,
.phone-tab-toolbar,
.phone-tab-action {
background: var(--color-glass);
-webkit-backdrop-filter: var(--glass-filter);
Expand All @@ -290,25 +287,65 @@
display: none;
}

/* Editing on a phone: formatting takes the tabs' place and rides above the keyboard. */
/*
* Editing on a phone: formatting takes the tabs' place as one bar across the top of the
* keyboard, edge to edge like a native keyboard accessory, with Done at its trailing end. An
* Insert or Text style panel stands below the bar where the keyboard was.
*/
:root[data-keyboard-open] .phone-tab-row[data-phone-tab-toolbar] {
visibility: visible;
transform: none;
}

.phone-tab-toolbar {
flex: 1;
min-width: 0;
height: var(--phone-tab-bar-height);
border-radius: var(--radius-full);
overflow: hidden;
.phone-tab-row[data-phone-tab-toolbar] {
bottom: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
width: 100%;
gap: 0;
padding-inline: env(safe-area-inset-left) calc(var(--spacing) + env(safe-area-inset-right));
/* Opaque, as a keyboard's own bar is: prose sliding under the icons would read as clutter. */
background: var(--color-page);
border-top: var(--edge-width) solid var(--color-edge);
animation: phone-tab-toolbar-in var(--tab-slide-dur) var(--tab-slide-ease);
}

/* The host and the editor's portal add no boxes: the bar, its panel and Done share one grid. */
.phone-tab-toolbar {
display: contents;
}

.phone-tab-row[data-phone-tab-toolbar] .plan-format-panel {
grid-row: 2;
grid-column: 1 / -1;
margin-inline: calc(-1 * env(safe-area-inset-left))
calc(-1 * (var(--spacing) + env(safe-area-inset-right)));
}

/* A filled check inside a full-size target: the same Done as the bar's round button. */
.phone-tab-row[data-phone-tab-toolbar] > .phone-tab-action {
grid-row: 1;
grid-column: 2;
width: var(--control-target-coarse);
height: var(--control-target-coarse);
padding: var(--spacing);
background-clip: content-box;
/* The bar's edge hugs the smaller fill, where its brand colour disappears into it. */
outline-offset: calc(-1 * (var(--spacing) + var(--edge-width)));
-webkit-backdrop-filter: none;
backdrop-filter: none;
box-shadow: none;
}

.phone-tab-row[data-phone-tab-toolbar] > .phone-tab-action > svg {
width: calc(var(--spacing) * 5);
height: calc(var(--spacing) * 5);
}

@keyframes phone-tab-toolbar-in {
from {
opacity: 0;
transform: translateY(calc(var(--spacing) * 3)) scale(0.98);
transform: translateY(100%);
}
}

Expand Down Expand Up @@ -528,7 +565,7 @@
transition: none;
}

.phone-tab-toolbar {
.phone-tab-row[data-phone-tab-toolbar] {
animation: none;
}
}
9 changes: 7 additions & 2 deletions apps/web/src/room-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,8 @@ export function RoomWorkspace(
// survives tab changes and reconnects, and the next document opens reading again.
let [editing, setEditing] = useState(() => opensEditing({ renaming: titleEdit === "new" }));
let [formatDock, setFormatDock] = useState<HTMLElement | null>(null);
// The dock's Insert or Text style panel stands where the keyboard was.
let [formatPanel, setFormatPanel] = useState(false);
let keyboardOpen = useKeyboardOpen();
useEffect(() => {
let listen = (event: Event) => {
Expand Down Expand Up @@ -992,7 +994,9 @@ export function RoomWorkspace(
// What this room's own editor shows: a child is on screen only while its parent's Document is.
let shown = childRoom ? !!childChrome?.visible : planVisible && !childOpen;
// Only the document's own keyboard gets formatting: never Chat, a comment reply, or the title.
let docked = editMode === "editing" && keyboardOpen && shown && editorFocused;
// A panel lets the keyboard go on purpose, so it keeps the dock up in the keyboard's place.
let docked = editMode === "editing" && shown
&& (formatPanel || (keyboardOpen && editorFocused));
let toggleEditing = () => {
// Synchronous, so the editor takes focus inside the tap and iOS raises the keyboard.
flushSync(() => {
Expand Down Expand Up @@ -1174,6 +1178,7 @@ export function RoomWorkspace(
disclosureMotion={motionContract("collapse")}
editMode={editMode}
formatDock={docked ? childRoom ? childChrome?.formatDock : formatDock : undefined}
onFormatPanel={setFormatPanel}
motionImmediately={settleMotionImmediately}
onScrollTop={setPlanScrollTop}
onState={setPlanState}
Expand Down Expand Up @@ -1361,7 +1366,7 @@ export function RoomWorkspace(
)}
// Formatting rides above the keyboard while typing. With the keyboard down the tabs
// return, and a selection gets the ordinary bubble instead.
phoneToolbar={barEdit?.docked && <div className="h-full" ref={setFormatDock} />}
phoneToolbar={barEdit?.docked && <div className="contents" ref={setFormatDock} />}
identity={room}
mode={mode}
presence={<RoomMembers members={members} ring="page" />}
Expand Down
212 changes: 183 additions & 29 deletions e2e/phone-read-mode.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,24 +143,66 @@ test("formatting docks above the keyboard while editing", async ({ baseURL, brow
await expect(dock).toBeVisible();
await expect(page.getByRole("navigation", { name: "Workspace view" })).toBeHidden();
await expect(done(page)).toBeVisible();
// Layout size, not the box: the dock rises in with a brief scale.
for (let control of await dock.getByRole("button").all()) {
// One bar across the top of the keyboard, edge to edge, with Done at its trailing end.
let row = page.locator("[data-phone-tab-row]");
await row.evaluate(element =>
Promise.all(element.getAnimations({ subtree: true }).map(animation => animation.finished))
);
let bar = (await row.boundingBox())!;
expect(bar.x).toBe(0);
expect(bar.width).toBe(390);
expect(Math.round(bar.y + bar.height)).toBe(500);
let doneBox = (await done(page).boundingBox())!;
expect(doneBox.x + doneBox.width).toBeGreaterThan(390 - 12);
expect(doneBox.y).toBeGreaterThanOrEqual(bar.y);
// Layout size, not the box.
for (let control of await row.getByRole("button").all()) {
let size = await control.evaluate(element => {
let html = element as HTMLElement;
return Math.min(html.offsetWidth, html.offsetHeight);
});
expect(size).toBeGreaterThanOrEqual(44);
}
let bottom = await dock.evaluate(element => element.getBoundingClientRect().bottom);
expect(bottom).toBeLessThanOrEqual(500);

// A caret gets blocks, styles and history.
for (let name of ["Insert block", "Text style", "Undo", "Redo", "Hide keyboard"]) {
await expect(dock.getByRole("button", { name, exact: true })).toBeVisible();
}
await expect(dock.getByRole("button", { name: "Bold" })).toHaveCount(0);

await page.keyboard.type("loud ");
await page.keyboard.press("ArrowLeft");
for (let step = 0; step < 4; step++) await page.keyboard.press("Shift+ArrowLeft");
// A selection gets marks, a link and a comment instead.
for (
let name of [
"Bold",
"Italic",
"Strikethrough",
"Inline code",
"Link",
"Comment on this passage",
"Hide keyboard",
]
) {
await expect(dock.getByRole("button", { name, exact: true })).toBeVisible();
}
await expect(dock.getByRole("button", { name: "Undo" })).toHaveCount(0);
await dock.getByRole("button", { name: "Bold" }).tap();
await page.keyboard.type("loud");
await expect(content(page).locator("strong, b").filter({ hasText: "loud" })).toHaveCount(1);
let loud = content(page).locator("strong, b").filter({ hasText: "loud" });
await expect(loud).toHaveCount(1);
await expect(content(page)).toBeFocused();
// One formatting surface at a time.
await expect(page.getByRole("toolbar", { name: "Text formatting" })).toHaveCount(0);

// Undo and redo act on this writer's edits without taking the keyboard down.
await page.keyboard.press("ArrowRight");
await dock.getByRole("button", { name: "Undo" }).tap();
await expect(loud).toHaveCount(0);
await expect(content(page)).toBeFocused();
await dock.getByRole("button", { name: "Redo" }).tap();
await expect(loud).toHaveCount(1);

// The way out is pinned, never scrolled off the row.
let hide = dock.getByRole("button", { name: "Hide keyboard" });
let pinned = await hide.evaluate(element => {
Expand All @@ -170,20 +212,6 @@ test("formatting docks above the keyboard while editing", async ({ baseURL, brow
});
expect(pinned).toBe(true);

// A writer can comment while the keyboard is up.
await content(page).getByText("Members lose").evaluate(element => {
let text = document.createTreeWalker(element, NodeFilter.SHOW_TEXT).nextNode()!;
let range = document.createRange();
range.setStart(text, 0);
range.setEnd(text, 7);
getSelection()!.removeAllRanges();
getSelection()!.addRange(range);
});
await expect(dock.getByRole("button", { name: "Comment on this passage" })).toBeVisible();
// Collapse the selection; the typing above may have split the second paragraph's words.
await page.keyboard.press("ArrowRight");
await expect(dock.getByRole("button", { name: "Comment on this passage" })).toHaveCount(0);

await hide.tap();
await expect(content(page)).not.toBeFocused();
await setVisualViewport(page, { event: "resize", height: 844 });
Expand Down Expand Up @@ -299,23 +327,149 @@ test("only the document's own keyboard gets the formatting dock", async ({ baseU
}
});

test("Insert block starts a new paragraph and keeps its menu above the dock", async ({ baseURL, browser, room, seed }) => {
test("Insert stands in for the keyboard and gives the caret back", async ({ baseURL, browser, room, seed }) => {
await seed(SOURCE);
let { close, page } = await openIsolatedRoom(browser, baseURL!, room, "ana", PHONE, fakeKeyboard);
try {
await page.getByText("Second paragraph").tap();
await pencil(page).tap();
await setVisualViewport(page, { event: "resize", height: 500 });
let dock = page.getByRole("toolbar", { name: "Formatting" });
await dock.getByRole("button", { name: "Insert block" }).tap();
let insert = dock.getByRole("button", { name: "Insert block" });
let row = page.locator("[data-phone-tab-row]");
await row.evaluate(element =>
Promise.all(element.getAnimations({ subtree: true }).map(animation => animation.finished))
);
let resting = (await row.boundingBox())!;
expect(Math.round(resting.y + resting.height)).toBe(500);
await insert.tap();
let panel = page.getByRole("group", { name: "Insert block" });
await expect(panel).toBeFocused();
await expect(insert).toHaveAttribute("aria-expanded", "true");
// Until the keyboard has gone, the panel takes no space: the bar stays on the keyboard
// rather than growing up the screen over the document.
expect(await row.boundingBox()).toEqual(resting);
// Focus left the editor, so the keyboard goes; the panel takes its place at its height.
await setVisualViewport(page, { event: "resize", height: 844 });
await expect(dock).toBeVisible();
await expect(page.getByRole("navigation", { name: "Workspace view" })).toBeHidden();
let [panelBox, dockBox] = await Promise.all([panel.boundingBox(), dock.boundingBox()]);
expect(Math.round(panelBox!.y + panelBox!.height)).toBe(844);
expect(Math.round(panelBox!.height)).toBe(344);
expect(Math.round(dockBox!.y + dockBox!.height)).toBeLessThanOrEqual(Math.round(panelBox!.y));
for (let choice of await panel.getByRole("button").all()) {
expect((await choice.boundingBox())!.height).toBeGreaterThanOrEqual(44);
}
// The caret's paragraph stays in sight above the panel.
let paragraph = content(page).locator("p").filter({ hasText: "Second paragraph" });
expect((await paragraph.boundingBox())!.y + 12).toBeLessThanOrEqual(dockBox!.y);

await panel.getByRole("button", { name: "Table" }).tap();
await expect(content(page)).toBeFocused();
await expect(content(page).locator("table")).toHaveCount(1);
// The paragraph keeps its words: the table goes after it, not into it.
await expect(content(page)).toContainText("Second paragraph for the caret.");
let menu = page.getByRole("listbox", { name: "Insert block" });
await expect(menu).toBeVisible();
let [menuBottom, dockTop] = await Promise.all([
menu.evaluate(element => element.getBoundingClientRect().bottom),
dock.evaluate(element => element.getBoundingClientRect().top),
]);
expect(menuBottom).toBeLessThanOrEqual(dockTop);
await setVisualViewport(page, { event: "resize", height: 500 });
await expect(panel).toHaveCount(0);
await expect(dock).toBeVisible();
// The table's own actions sit above the bar, so Undo stays in reach.
let actions = page.getByRole("toolbar", { name: "Table actions" });
await expect(actions).toBeVisible();
let [actionsBox, barBox] = await Promise.all([actions.boundingBox(), dock.boundingBox()]);
expect(actionsBox!.y + actionsBox!.height).toBeLessThanOrEqual(barBox!.y);
} finally {
await close();
}
});

test("Text style turns the caret's block into a heading", async ({ baseURL, browser, room, seed }) => {
await seed(SOURCE);
let { close, page } = await openIsolatedRoom(browser, baseURL!, room, "ana", PHONE, fakeKeyboard);
try {
await page.getByText("Second paragraph").tap();
await pencil(page).tap();
await setVisualViewport(page, { event: "resize", height: 500 });
let dock = page.getByRole("toolbar", { name: "Formatting" });
await dock.getByRole("button", { name: "Text style" }).tap();
let panel = page.getByRole("group", { name: "Text style" });
await expect(panel.getByRole("button", { name: "Text", exact: true }))
.toHaveAttribute("aria-pressed", "true");
// Escape, or the same button again, returns to the keyboard unchanged.
await page.keyboard.press("Escape");
await expect(content(page)).toBeFocused();
await expect(panel).toHaveCount(0);

await dock.getByRole("button", { name: "Text style" }).tap();
await setVisualViewport(page, { event: "resize", height: 844 });
await panel.getByRole("button", { name: "Heading 2" }).tap();
await expect(content(page)).toBeFocused();
await expect(content(page).locator("h2")).toHaveText("Second paragraph for the caret.");
await page.keyboard.type("!");
await expect(content(page).locator("h2")).toContainText("!");

// A tap in the prose takes the caret there and the keyboard back, with no tab bar between.
await dock.getByRole("button", { name: "Text style" }).tap();
await setVisualViewport(page, { event: "resize", height: 844 });
await expect(panel).toBeVisible();
await content(page).getByText("Members lose").tap();
await expect(content(page)).toBeFocused();
await expect(dock).toBeVisible();
await expect(page.getByRole("navigation", { name: "Workspace view" })).toBeHidden();
await setVisualViewport(page, { event: "resize", height: 500 });
await expect(panel).toHaveCount(0);
await expect(dock).toBeVisible();

// Leaving the panel for anywhere else closes it and the dock with it.
await dock.getByRole("button", { name: "Insert block" }).tap();
await dock.getByRole("button", { name: "Hide keyboard" }).tap();
await expect(dock).toHaveCount(0);
await expect(page.getByRole("navigation", { name: "Workspace view" })).toBeVisible();
} finally {
await close();
}
});

test("the bar holds still while the keyboard travels frame by frame", async ({ baseURL, browser, room, seed }) => {
await seed(SOURCE);
let { close, page } = await openIsolatedRoom(browser, baseURL!, room, "ana", PHONE, fakeKeyboard);
try {
await page.getByText("Second paragraph").tap();
await pencil(page).tap();
await setVisualViewport(page, { event: "resize", height: 500 });
let dock = page.getByRole("toolbar", { name: "Formatting" });
await dock.evaluate(element =>
Promise.all(
element.closest("[data-phone-tab-row]")!.getAnimations({ subtree: true })
.map(animation => animation.finished),
)
);
let top = () =>
dock.evaluate(element =>
new Promise<number>(resolve =>
requestAnimationFrame(() => resolve(element.getBoundingClientRect().top))
)
);
let resting = await top();
// Some browsers, Android Chrome among them, report the keyboard's travel every frame.
let travel = async (heights: number[]) => {
for (let height of heights) {
await setVisualViewport(page, { event: "resize", height });
expect(Math.abs((await top()) - resting)).toBeLessThanOrEqual(1);
}
};
let down = [500, 520, 560, 612, 680, 732, 776, 810, 832, 844];

await dock.getByRole("button", { name: "Insert block" }).tap();
expect(Math.abs((await top()) - resting)).toBeLessThanOrEqual(1);
await travel(down);
let panel = page.getByRole("group", { name: "Insert block" });
expect(Math.round((await panel.boundingBox())!.height)).toBe(344);

await page.keyboard.press("Escape");
await expect(content(page)).toBeFocused();
await travel(down.toReversed());
await expect(panel).toHaveCount(0);
expect(Math.abs((await top()) - resting)).toBeLessThanOrEqual(1);
} finally {
await close();
}
Expand Down
Loading
Loading