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
10 changes: 9 additions & 1 deletion apps/web/src/hosted.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ export function HostedApp(
{ agent, user }: { agent: boolean; user: Api.User },
) {
let [route, setRoute] = useState(() => hostedRoute(location.pathname));
let [navigationRevision, setNavigationRevision] = useState(0);
let hostedRouteRef = useRef(route);
hostedRouteRef.current = route;
let childOpener = useRef<ResearchOpener | undefined>(undefined);
Expand Down Expand Up @@ -497,6 +498,7 @@ export function HostedApp(
let next = `${target.pathname}${target.search}${target.hash}`;
let current = `${location.pathname}${location.search}${location.hash}`;
if (next === current) return;
setNavigationRevision(value => value + 1);
childRouteChanged(target.pathname);
let nextRoute = hostedRoute(target.pathname);
if (options.replace) history.replaceState(history.state, "", next);
Expand Down Expand Up @@ -596,6 +598,7 @@ export function HostedApp(

useEffect(() => {
let changed = () => {
setNavigationRevision(value => value + 1);
childRouteChanged(location.pathname);
setRoute(hostedRoute(location.pathname));
};
Expand Down Expand Up @@ -632,7 +635,12 @@ export function HostedApp(
break;
}
return (
<NavigationShell navigate={navigate} route={route} user={user}>
<NavigationShell
navigate={navigate}
navigationRevision={navigationRevision}
route={route}
user={user}
>
{workspace}
</NavigationShell>
);
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/navigation-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,13 @@ export function NavigationShell(
{
children,
navigate,
navigationRevision,
route,
user,
}: {
children?: ReactNode;
navigate: Navigate;
navigationRevision: number;
route: NavigationRoute;
user: Api.User;
},
Expand Down Expand Up @@ -521,6 +523,7 @@ export function NavigationShell(

let creation = useDocumentCreation({
routeKey,
navigationRevision,
onCreated: upsertDocument,
onNavigate: navigateToDocument,
onAccessChanged: () => void refresh(),
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,11 @@ body {
outline: none;
}

/* A listbox holds the keyboard's place on its highlighted option instead. */
:where(.plan-language-menu[role="listbox"]):focus-visible {
outline: none;
}

/* The editable plan has a live caret, so a second focus marker is redundant. */
:where(.focus-caret):focus-visible {
outline: none;
Expand Down
19 changes: 12 additions & 7 deletions apps/web/src/tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,13 +616,6 @@ describe("migration", () => {
tag: "input",
utility: "choice-control",
},
{
file: "packages/editor/src/widgets/render-blocks.tsx",
marker: 'aria-label="Code language"',
name: "code language",
tag: "select",
utility: "field-ghost",
},
];
let offenders = controls.flatMap(control =>
controlOffenders(
Expand Down Expand Up @@ -703,6 +696,18 @@ describe("migration", () => {
tiers: ["btn-ghost"],
},
],
["packages/editor/src/widgets/render-blocks.tsx", {
action: "code source toggle",
marker: 'aria-label={collapsed ? "Show source" : "Hide source"}',
size: "btn-icon",
tiers: ["btn-ghost"],
}],
["packages/editor/src/widgets/language-menu.tsx", {
action: "code language trigger",
marker: 'aria-haspopup="listbox"',
size: "btn-sm",
tiers: ["btn-ghost"],
}],
["apps/web/src/chat/transcript.tsx", {
action: "Withdraw",
marker: 'title="Withdraw"',
Expand Down
12 changes: 8 additions & 4 deletions apps/web/src/use-document-creation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ type Attempt = {
};

export function useDocumentCreation(
{ routeKey, onCreated, onNavigate, onAccessChanged }: {
{ routeKey, navigationRevision, onCreated, onNavigate, onAccessChanged }: {
routeKey: string;
navigationRevision: number;
onCreated: (channel: Api.Channel) => void;
onNavigate: (documentId: string, path: string) => void;
onAccessChanged: () => void;
Expand All @@ -24,8 +25,11 @@ export function useDocumentCreation(
let [pending, setPending] = useState<ReadonlyMap<string, DocumentCreationPhase>>(() => new Map());
let [error, setError] = useState<{ project: Api.NavigationProject; message: string }>();
let latest = useRef<Attempt | undefined>(undefined);
let location = useRef({ routeKey });
if (location.current.routeKey !== routeKey) location.current = { routeKey };
// Canonicalizing the same document changes its route key without a navigation.
let location = useRef({ routeKey, navigationRevision });
if (location.current.navigationRevision !== navigationRevision) {
location.current = { routeKey, navigationRevision };
} else location.current.routeKey = routeKey;
let publish = useCallback(() => {
setPending(new Map([...attempts.current].map(([id, attempt]) => [id, attempt.phase])));
}, []);
Expand All @@ -37,7 +41,7 @@ export function useDocumentCreation(

useEffect(() => () => {
latest.current = undefined;
location.current = { routeKey: "" };
location.current = { routeKey: "", navigationRevision: -1 };
}, []);

useEffect(() => {
Expand Down
80 changes: 75 additions & 5 deletions e2e/code.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@
import { content, expect, test, written } from "./room";
import { expectNoHorizontalOverflow } from "./responsive";

import type { Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";

let MENU = { name: "Insert block" };

/** The language control is a button that opens a listbox. */
async function chooseLanguage(scope: Locator, from: string, to: string) {
await scope.getByRole("button", { name: `Code language: ${from}` }).click();
await scope.page().getByRole("option", { name: to, exact: true }).click();
}

/** A fence with the counts a person or a model actually writes. */
const PATCH = `\`\`\`diff
--- a/apps/server/src/plan/room.ts
Expand Down Expand Up @@ -183,7 +189,7 @@ test("naming a fence colours it, and the name reaches the file", async ({ join,
// an uncoloured original is two of the same thing.
await expect(content(page).locator("[data-file]")).toHaveCount(0);

await content(page).getByRole("combobox", { name: "Code language" }).selectOption("typescript");
await chooseLanguage(content(page), "Plain text", "TypeScript");

await expect(content(page).locator("[data-file]")).toBeVisible();
await expect.poll(() => colours(page)).toBeGreaterThan(1);
Expand Down Expand Up @@ -226,6 +232,15 @@ test("a fence that is not a patch is drawn as the text it is", async ({ join, se
await expect(content(page).locator("[data-diff]")).toHaveCount(0);
});

test("an invalid diff keeps its authored filename", async ({ join, seed }) => {
await seed('```diff title="broken.patch"\nnot a patch\n```\n');
let page = await join("ana");

await expect(content(page).locator("[data-file]")).toBeVisible();
await expect(content(page).getByText("broken.patch", { exact: true })).toBeVisible();
await expect(content(page).locator("[data-diff]")).toHaveCount(0);
});

test("enter is a newline in a fence, and twice over is the way out", async ({ join, room }) => {
let page = await join("ana");

Expand Down Expand Up @@ -321,13 +336,13 @@ test("a language chosen by one is a change for everyone", async ({ join, room, s

await expect(content(bo).locator("[data-file]")).toHaveCount(0);

await content(ana).getByRole("combobox", { name: "Code language" }).selectOption("typescript");
await chooseLanguage(content(ana), "Plain text", "TypeScript");

// The language is a property of the fence rather than a way of looking at
// it, so it travels: the other reader's copy is coloured too, and their
// control says what it now is.
await expect(content(bo).getByRole("combobox", { name: "Code language" }))
.toHaveValue("typescript");
await expect(content(bo).getByRole("button", { name: "Code language: TypeScript" }))
.toBeVisible();
await expect(content(bo).locator("[data-file]")).toBeVisible();
await expect.poll(() => colours(bo)).toBeGreaterThan(1);

Expand All @@ -349,3 +364,58 @@ test("showing the source leaves everybody else's hidden", async ({ join, seed })
await expect(content(bo).locator("[data-plan-source]")).toBeHidden();
await expect(content(bo).getByRole("button", { name: "Show source" })).toBeVisible();
});

test("the language menu is a keyboard-operable listbox", async ({ join, room, seed }) => {
await seed("```typescript\nlet total = 1;\n```\n");
let page = await join("ana");
let trigger = content(page).getByRole("button", { name: "Code language: TypeScript" });
let list = page.getByRole("listbox", { name: "Code language" });

await trigger.focus();
await page.keyboard.press("ArrowDown");
await expect(list).toBeVisible();

await page.keyboard.press("Escape");
await expect(list).toBeHidden();
await expect(trigger).toBeFocused();

await trigger.click();
await expect(list).toBeVisible();
await page.mouse.click(5, 5);
await expect(list).toBeHidden();

// Tab from the open menu continues from the trigger, not from the end of the page.
await trigger.focus();
await page.keyboard.press("ArrowDown");
await expect(list).toBeVisible();
await page.keyboard.press("Tab");
await expect(list).toBeHidden();
await expect(content(page).getByRole("button", { name: "Show source" })).toBeFocused();

await trigger.focus();
await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
await expect(list).toBeHidden();
await expect(content(page).getByRole("button", { name: /^Code language: (?!TypeScript)/ }))
.toBeVisible();
await written(page, room, /^```(?!typescript$)\S+$/m);
});

test("the language menu takes focus before the next animation frame", async ({ join, seed }) => {
await seed("```typescript\nlet total = 1;\n```\n");
let page = await join("ana");
let trigger = content(page).getByRole("button", { name: "Code language: TypeScript" });
let list = page.getByRole("listbox", { name: "Code language" });

await page.clock.install();
await page.clock.pauseAt(new Date());
await trigger.focus();
await page.keyboard.press("ArrowDown");
await expect(list).toBeFocused();
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
await page.clock.resume();
await expect(content(page).getByRole("button", { name: "Code language: XML", exact: true }))
.toBeVisible();
});
12 changes: 6 additions & 6 deletions e2e/design/approved-contrast/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"finding": {
"rule": "color-contrast",
"target": [
".gap-3.flex[data-chat-entry=\"true\"]:nth-child(1) > .-mt-0\\.5.gap-1.flex-1 > .items-baseline.gap-1\\.5.text-sm > .tabular-nums.text-text-quaternary.text-sm"
".gap-3.flex[data-chat-entry=\"true\"]:nth-child(1) > .-mt-0\\.5.gap-1.flex-1 > .items-baseline.gap-1\\.5.text-sm > .text-text-quaternary.tabular-nums.text-sm"
],
"html": "<span class=\"text-sm text-text-quaternary tabular-nums\">10:40</span>",
"evidence": "Fix any of the following:\n Element has insufficient color contrast of 4.43 (foreground color: #78766e, background color: #fcfcfb, font size: 10.5pt (14.0351px), font weight: normal). Expected contrast ratio of 4.5:1"
Expand All @@ -27,7 +27,7 @@
"finding": {
"rule": "color-contrast",
"target": [
".gap-3.flex[data-chat-entry=\"true\"]:nth-child(2) > .-mt-0\\.5.gap-1.flex-1 > .items-baseline.gap-1\\.5.text-sm > .tabular-nums.text-text-quaternary.text-sm"
".gap-3.flex[data-chat-entry=\"true\"]:nth-child(2) > .-mt-0\\.5.gap-1.flex-1 > .items-baseline.gap-1\\.5.text-sm > .text-text-quaternary.tabular-nums.text-sm"
],
"html": "<span class=\"text-sm text-text-quaternary tabular-nums\">10:41</span>",
"evidence": "Fix any of the following:\n Element has insufficient color contrast of 4.43 (foreground color: #78766e, background color: #fcfcfb, font size: 10.5pt (14.0351px), font weight: normal). Expected contrast ratio of 4.5:1"
Expand Down Expand Up @@ -71,7 +71,7 @@
"finding": {
"rule": "color-contrast",
"target": [
".opacity-60 > .items-baseline.gap-1\\.5.text-sm > .tabular-nums.text-text-quaternary.text-sm"
".opacity-60 > .items-baseline.gap-1\\.5.text-sm > .text-text-quaternary.tabular-nums.text-sm"
],
"html": "<span class=\"text-sm text-text-quaternary tabular-nums\">queued</span>",
"evidence": "Fix any of the following:\n Element has insufficient color contrast of 2.21 (foreground color: #adaca6, background color: #fcfcfb, font size: 10.5pt (14.0351px), font weight: normal). Expected contrast ratio of 4.5:1"
Expand Down Expand Up @@ -106,7 +106,7 @@
"finding": {
"rule": "color-contrast",
"target": [
".gap-3.flex[data-chat-entry=\"true\"]:nth-child(1) > .-mt-0\\.5.gap-1.flex-1 > .items-baseline.gap-1\\.5.text-sm > .tabular-nums.text-text-quaternary.text-sm"
".gap-3.flex[data-chat-entry=\"true\"]:nth-child(1) > .-mt-0\\.5.gap-1.flex-1 > .items-baseline.gap-1\\.5.text-sm > .text-text-quaternary.tabular-nums.text-sm"
],
"html": "<span class=\"text-sm text-text-quaternary tabular-nums\">10:40</span>",
"evidence": "Fix any of the following:\n Element has insufficient color contrast of 4.43 (foreground color: #78766e, background color: #fcfcfb, font size: 10.1pt (13.4107px), font weight: normal). Expected contrast ratio of 4.5:1"
Expand All @@ -117,7 +117,7 @@
"finding": {
"rule": "color-contrast",
"target": [
".gap-3.flex[data-chat-entry=\"true\"]:nth-child(2) > .-mt-0\\.5.gap-1.flex-1 > .items-baseline.gap-1\\.5.text-sm > .tabular-nums.text-text-quaternary.text-sm"
".gap-3.flex[data-chat-entry=\"true\"]:nth-child(2) > .-mt-0\\.5.gap-1.flex-1 > .items-baseline.gap-1\\.5.text-sm > .text-text-quaternary.tabular-nums.text-sm"
],
"html": "<span class=\"text-sm text-text-quaternary tabular-nums\">10:41</span>",
"evidence": "Fix any of the following:\n Element has insufficient color contrast of 4.43 (foreground color: #78766e, background color: #fcfcfb, font size: 10.1pt (13.4107px), font weight: normal). Expected contrast ratio of 4.5:1"
Expand Down Expand Up @@ -161,7 +161,7 @@
"finding": {
"rule": "color-contrast",
"target": [
".opacity-60 > .items-baseline.gap-1\\.5.text-sm > .tabular-nums.text-text-quaternary.text-sm"
".opacity-60 > .items-baseline.gap-1\\.5.text-sm > .text-text-quaternary.tabular-nums.text-sm"
],
"html": "<span class=\"text-sm text-text-quaternary tabular-nums\">queued</span>",
"evidence": "Fix any of the following:\n Element has insufficient color contrast of 2.21 (foreground color: #adaca6, background color: #fcfcfb, font size: 10.1pt (13.4107px), font weight: normal). Expected contrast ratio of 4.5:1"
Expand Down
Loading
Loading