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
151 changes: 151 additions & 0 deletions e2e/tab-authoring.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { content, expect, test, written } from "./room";

import type { WebSocketRoute } from "@playwright/test";

const TABS = "01K0N4TR8K7JGM4R1J7PW4R8YA";
const FIRST = "01K0N4V4E7Y6P4MJ5WD8XZF3BA";
const SECOND = "01K0N4V4E7Y6P4MJ5WD8XZF3BB";

const SOURCE = `Before the tabs.

<Tabs id="${TABS}">
<Tab id="${FIRST}" label="Mobile">

Queue in memory only.

</Tab>
<Tab id="${SECOND}" label="Desktop">

</Tab>
</Tabs>

After the tabs.
`;

test("tabs are renamed, added and removed in place, and the document keeps them", async ({ join, room, seed }) => {
await seed(SOURCE);
let page = await join("ana", { viewport: { width: 1440, height: 900 } });
let strip = content(page).getByRole("tablist");
let tabs = strip.getByRole("tab");
await expect(tabs).toHaveText(["Mobile", "Desktop"]);

// Rename by double-click. Typing in the field never reaches the document.
await strip.getByRole("tab", { name: "Desktop", exact: true }).dblclick();
let field = content(page).getByRole("textbox", { name: "Tab name", exact: true });
await expect(field).toBeFocused();
await page.keyboard.type("Wide screens");
await page.keyboard.press("Enter");
await expect(field).toHaveCount(0);
await expect(tabs).toHaveText(["Mobile", "Wide screens"]);
await expect(strip.getByRole("tab", { name: "Wide screens" })).toBeFocused();
await written(page, room, /<Tab id="[^"]+" label="Wide screens"/);
await expect(content(page)).toContainText("Before the tabs.");
await expect(content(page)).not.toContainText("Before the tabs.Wide");

// Enter renames from the keyboard; Escape and an empty label keep the old one.
await page.keyboard.press("Enter");
await expect(field).toBeFocused();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.press("Delete");
await page.keyboard.press("Enter");
await expect(tabs).toHaveText(["Mobile", "Wide screens"]);

// Add from the strip's end, which opens the new tab's name for editing.
await content(page).locator(".planTabs").hover();
await content(page).getByRole("button", { name: "Add tab", exact: true }).click();
await expect(field).toBeFocused();
await expect(field).toHaveValue("Tab 3");
await page.keyboard.type("Tablet");
await page.keyboard.press("Enter");
await expect(tabs).toHaveText(["Mobile", "Wide screens", "Tablet"]);
await expect(strip.getByRole("tab", { name: "Tablet" })).toHaveAttribute("aria-selected", "true");
await written(page, room, /label="Wide screens"[\s\S]*<Tab id="[A-Z0-9]{26}" label="Tablet"/);

// An empty tab goes at once from the keyboard.
await strip.getByRole("tab", { name: "Tablet" }).press("Delete");
await expect(tabs).toHaveText(["Mobile", "Wide screens"]);
await expect(strip.getByRole("tab", { name: "Wide screens" })).toBeFocused();

// A tab with content goes at once and can be recovered with Undo.
await strip.getByRole("tab", { name: "Mobile" }).hover();
await content(page).getByRole("button", { name: "Remove Mobile", exact: true }).click();
await expect(tabs).toHaveText(["Wide screens"]);
await written(page, room, /<Tabs id="[^"]+">\s*<Tab id="[^"]+" label="Wide screens"/);
await expect.poll(async () => (await content(page).textContent()) ?? "").not.toContain(
"Queue in memory only.",
);

// The last tab cannot be removed.
await expect(content(page).getByRole("button", { name: /^Remove / })).toHaveCount(0);
await strip.getByRole("tab", { name: "Wide screens" }).press("Delete");
await expect(tabs).toHaveText(["Wide screens"]);

await page.reload();
await expect(content(page).getByRole("tablist").getByRole("tab")).toHaveText(["Wide screens"]);
});

test("undo restores a removed tab with its content", async ({ join, room, seed }) => {
await seed(SOURCE);
let page = await join("ana");
let strip = content(page).getByRole("tablist");
let tabs = strip.getByRole("tab");

await strip.getByRole("tab", { name: "Mobile", exact: true }).press("Delete");
await expect(tabs).toHaveText(["Desktop"]);
await written(page, room, /<Tab id="[^"]+" label="Desktop"/);

await content(page).locator("p").filter({ hasText: "After the tabs." }).click();
await page.keyboard.press("ControlOrMeta+z");
await expect(tabs).toHaveText(["Mobile", "Desktop"]);
await strip.getByRole("tab", { name: "Mobile", exact: true }).click();
await expect(content(page).getByText("Queue in memory only.")).toBeVisible();
await written(page, room, /<Tab id="[^"]+" label="Mobile">[\s\S]*Queue in memory only\./);

await page.reload();
await expect(content(page).getByRole("tablist").getByRole("tab")).toHaveText([
"Mobile",
"Desktop",
]);
await expect(content(page).getByText("Queue in memory only.")).toBeVisible();
});

test("touch removes the selected tab with one tap", async ({ join, seed }) => {
await seed(SOURCE);
let page = await join("ana", {
viewport: { width: 390, height: 844 },
hasTouch: true,
isMobile: true,
});
let strip = content(page).getByRole("tablist");
let remove = content(page).getByRole("button", { name: "Remove Mobile", exact: true });
await expect(remove).toHaveCSS("opacity", "1");
await remove.tap();
await expect(strip.getByRole("tab")).toHaveText(["Desktop"]);
});

test("a locked document offers no tab authoring", 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(SOURCE);
await join("ana");
let strip = content(page).getByRole("tablist");
await strip.getByRole("tab", { name: "Desktop" }).hover();
await expect(content(page).getByRole("button", { name: "Remove Desktop" })).toBeAttached();

offline = true;
await sockets.at(-1)!.close();
await expect(content(page)).toHaveAttribute("contenteditable", "false");
await expect(content(page).getByRole("button", { name: "Add tab" })).toHaveCount(0);
await expect(content(page).getByRole("button", { name: /^Remove / })).toHaveCount(0);
await strip.getByRole("tab", { name: "Desktop" }).dblclick();
await expect(content(page).getByRole("textbox", { name: "Tab name" })).toHaveCount(0);
await expect(strip.getByRole("tab", { name: "Desktop" })).toHaveAttribute(
"aria-selected",
"true",
);
});
212 changes: 180 additions & 32 deletions packages/editor/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1458,47 +1458,35 @@
}

/*
* A table spans the prose measure like the paragraphs around it; a wide one
* scrolls inside it. The row and column rails are fixed overlays and sit in the
* gutter outside it.
*
* The rows live in an anonymous table box that shrink-wraps its columns, so a
* few short columns would stop halfway across. Each column of a table with up
* to six asks for an equal share of the measure (the container) instead; six
* columns at their minimum already fill it. Longer content still widens its own
* column and the table scrolls.
* A compact table shrink-wraps around its columns from the prose's start edge,
* like every other block; a wide one scrolls inside the prose measure. The row
* and column rails are fixed overlays and sit in the gutter outside it.
*/
.plan-content table {
container-type: inline-size;
}

.plan-content table tr > :is(th, td) {
/* Less one hairline, which the collapsed outer borders add to the sum. */
min-inline-size: max(7rem, (100cqi - 1px) / var(--plan-table-columns, 7));
}

.plan-content table tr:has(> :last-child:nth-child(1)) {
--plan-table-columns: 1;
}

.plan-content table tr:has(> :last-child:nth-child(2)) {
--plan-table-columns: 2;
.plan-content > table {
inline-size: max-content;
max-inline-size: 100%;
}

.plan-content table tr:has(> :last-child:nth-child(3)) {
--plan-table-columns: 3;
/* Name an empty header for what it holds. Counted per header cell, out of flow. */
.plan-content table tr:first-of-type {
counter-reset: plan-column;
}

.plan-content table tr:has(> :last-child:nth-child(4)) {
--plan-table-columns: 4;
.plan-content table tr:first-of-type > th {
counter-increment: plan-column;
}

.plan-content table tr:has(> :last-child:nth-child(5)) {
--plan-table-columns: 5;
.plan-content[contenteditable="true"] th > p:only-child:has(> br:only-child) {
position: relative;
}

.plan-content table tr:has(> :last-child:nth-child(6)) {
--plan-table-columns: 6;
.plan-content[contenteditable="true"] th > p:only-child:has(> br:only-child)::before {
content: "Column " counter(plan-column);
position: absolute;
inset-inline-start: 0;
color: var(--color-text-quaternary);
font-weight: 500;
pointer-events: none;
}

/* The paragraph owns the wide row; the image keeps its intrinsic size inside it. */
Expand Down Expand Up @@ -2558,10 +2546,170 @@
margin-block-end: 0.5rem;
}

.plan-content .plan-tab-strip {
display: flex;
align-items: center;
gap: calc(var(--spacing) * 1);
padding-block-end: calc(var(--spacing) * 1);
border-block-end: var(--edge-width) solid var(--color-edge);
}

.plan-content .plan-tab {
--plan-tab-surface: var(--color-page);
position: relative;
display: flex;
flex-shrink: 0;
/* Never wider than the strip, so a selected tab can always be shown whole. */
max-inline-size: 100%;
border-radius: var(--radius-md);
background: var(--plan-tab-surface);
color: var(--color-text-quaternary);
transition:
color var(--duration-fast) var(--ease-out),
background-color var(--duration-fast) var(--ease-out);
}

.plan-content .plan-tab:hover {
--plan-tab-surface: var(--color-hover);
color: var(--color-text-primary);
}

.plan-content .plan-tab[data-selected] {
--plan-tab-surface: var(--color-selected);
color: var(--color-text-primary);
}

/*
* A removable tab always keeps a slot at its end for remove, so showing the
* button never covers the label or moves the tabs beside it.
*/
.plan-content .plan-tab[data-removable] > [role="tab"] {
padding-inline-end: calc(var(--spacing) * 6);
}

.plan-content .plan-tab-remove {
position: absolute;
inset-block-start: calc(50% - var(--spacing) * 2);
inset-inline-end: calc(var(--spacing) * 1);
display: grid;
place-items: center;
inline-size: calc(var(--spacing) * 4);
block-size: calc(var(--spacing) * 4);
border-radius: var(--radius-sm);
color: var(--color-text-tertiary);
opacity: 0;
pointer-events: none;
transition: opacity var(--duration-fast) var(--ease-out);
}

@media (hover: hover) {
.plan-content .plan-tab:hover .plan-tab-remove {
opacity: 1;
pointer-events: auto;
}
}

/* A long press reveals the remove control for a tab that is not selected. */
.plan-content .plan-tab[data-revealed] .plan-tab-remove {
opacity: 1;
pointer-events: auto;
}

.plan-content .plan-tab-remove:hover {
color: var(--color-text-primary);
}

/* Touch shows removal on the selected tab; others stay compact until revealed. */
@media (hover: none) {
.plan-content .plan-tab[data-selected] .plan-tab-remove {
opacity: 1;
pointer-events: auto;
}

.plan-content .plan-tab[data-removable]:not([data-selected]):not([data-revealed]) > [role="tab"] {
padding-inline-end: calc(var(--spacing) * 2.5);
}
}

/* Coarse pointers: a 44px target around the small remove button. */
:root[data-plan-coarse-pointer] .plan-content .plan-tab-remove::after {
content: "";
position: absolute;
inset: calc(50% - 22px);
}

.plan-content .plan-tab-add {
flex-shrink: 0;
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-out);
}

.plan-content .planTabs:hover .plan-tab-add,
.plan-content .plan-tab-strip:focus-within .plan-tab-add {
opacity: 1;
}

@media (hover: none) {
.plan-content .plan-tab-add {
opacity: 1;
}
}

/* The field sizes to its text: the hidden copy sets the width the input fills. */
.plan-content .plan-tab-rename {
display: inline-grid;
flex-shrink: 0;
max-inline-size: 16rem;
}

.plan-content .plan-tab-rename > * {
grid-area: 1 / 1;
min-inline-size: 4rem;
padding: calc(var(--spacing) * 1) calc(var(--spacing) * 2.5);
}

.plan-content .plan-tab-rename > span {
visibility: hidden;
overflow: hidden;
white-space: pre;
}

.plan-content .plan-tab-rename > input {
inline-size: 100%;
font: inherit;
}

/* A rule closes the panel so the block after it reads as outside the tabs. */
.plan-content [data-plan-panels] {
padding-block-end: calc(var(--spacing) * 2);
border-block-end: var(--edge-width) solid var(--color-edge);
}

.plan-content [data-plan-panels] > [hidden] {
display: none;
}

/* Like the formula placeholder: out of flow, so the caret stays where it is. */
.plan-content[contenteditable="true"] .planTab > p:only-child:has(> br:only-child) {
position: relative;
}

/* A tab with no blocks at all, as the Planner or MDX can write one. */
.plan-content[contenteditable="true"] .planTab:not(:has(> :not(br)))::before {
content: "Write in this tab…";
display: block;
color: var(--color-text-quaternary);
pointer-events: none;
}

.plan-content[contenteditable="true"] .planTab > p:only-child:has(> br:only-child)::before {
content: "Write in this tab…";
position: absolute;
inset-inline-start: 0;
color: var(--color-text-quaternary);
pointer-events: none;
}

/* Status ----------------------------------------------------------------- */

/*
Expand Down
Loading
Loading