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
11 changes: 8 additions & 3 deletions web/src/app/pattern-lab/HardwareModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
// on up to this backdrop and closed To hardware as well. The header the model
// translated lives only in this modal's state, so it went with it.
//
// Esc goes the same way. Send and Publish listen for no keys, so this modal
// hears the Esc meant for them, and it closes the one on top.
// Esc goes the same way. This modal hears the Esc meant for the window on
// top, Send or Publish, and closes that one alone. Publish hears the same
// Esc itself and closes itself, which leaves this modal open all the same.
//
// The modal is mounted the way the lab mounts it, behind an open flag that
// onClose clears, so a stray close really unmounts it. The pasted header is
Expand Down Expand Up @@ -403,12 +404,16 @@ describe("HardwareModal", () => {
it("Esc over the Publish modal closes Publish alone and keeps the header", async () => {
render(<Lab />);
pasteHeaderAndContinue();
fireEvent.click(screen.getByText("Upload to the community"));
const upload = screen.getByText("Upload to the community");
upload.focus();
fireEvent.click(upload);
await screen.findByText("Share to Community");
expect(screen.getByRole("textbox", { name: "Title" })).toHaveFocus();

fireEvent.keyDown(document.activeElement!, { key: "Escape" });

await waitFor(() => expect(screen.queryByText("Share to Community")).toBeNull());
expect(upload).toHaveFocus();
expectHeaderKept();
});

Expand Down
6 changes: 3 additions & 3 deletions web/src/app/pattern-lab/HardwareModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,9 @@ export default function HardwareModal({
// here as well, and that one began in the card.
const closeOnBackdrop = useBackdropClose(onClose);

// Esc closes the window on top. Send and Publish open over this one and
// hear no keys of their own, so the first Esc is theirs and the header
// stays.
// Esc closes the window on top and the header stays. Send opens over this
// one and hears no keys of its own, so this listener closes it. Publish
// hears Esc itself, and both listeners close Publish and nothing else.
const cardRef = useDialog<HTMLDivElement>(() => {
if (sendOpen) setSendOpen(false);
else if (publishCode !== null) setPublishCode(null);
Expand Down
23 changes: 20 additions & 3 deletions web/src/components/community/PublishModal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useDialog } from "@/lib/hooks/useDialog";
import { authClient } from "@/lib/community/auth-client";
import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase";
import { useSubmitLatch } from "@/lib/community/submitLatch";
Expand Down Expand Up @@ -121,6 +122,17 @@ export default function PublishModal({
const latch = useSubmitLatch();
const [includeShow, setIncludeShow] = useState(true);

// The card takes the focus first, so the hook sees the Share button that
// opened the window and hands the focus back to it on close. The title
// field's autoFocus used to move it before the hook could look, and the
// field is not there yet while the session is being checked.
const cardRef = useDialog<HTMLDivElement>(onClose);
const titleRef = useRef<HTMLInputElement>(null);
const formShown = !isPending && Boolean(session);
useEffect(() => {
if (formShown) titleRef.current?.focus({ preventScroll: true });
}, [formShown]);

const licenseChoices = parentLicense ? forkLicenseOptions(parentLicense) : LICENSE_OPTIONS;
// Default to the recommended licence, unless the parent's terms rule it out.
const [licenseId, setLicenseId] = useState(
Expand Down Expand Up @@ -252,7 +264,12 @@ export default function PublishModal({

return (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" onClick={onClose}>
<div className={styles.modalCard} onClick={(event) => event.stopPropagation()}>
<div
ref={cardRef}
tabIndex={-1}
className={styles.modalCard}
onClick={(event) => event.stopPropagation()}
>
<div className={styles.modalHeader}>
<span>{editOf ? "Update your pattern" : "Share to Community"}</span>
<button type="button" onClick={onClose} aria-label="Close">
Expand Down Expand Up @@ -317,10 +334,10 @@ export default function PublishModal({
<label className={styles.field}>
<span className={styles.fieldLabel}>Title</span>
<input
ref={titleRef}
className={styles.textInput}
value={title}
maxLength={TITLE_MAX}
autoFocus
onChange={(event) => setTitle(event.target.value)}
/>
</label>
Expand Down
58 changes: 57 additions & 1 deletion web/src/components/community/dialogKeys.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
// 1440px each stayed open on Esc, and four left the focus on the button
// behind them. Each case opens its window from a real button, so the focus
// has somewhere to come back to.
//
// Share to Community, the window Pattern Lab's Share opens, came after them.
// It closed on a press outside alone, and its title field took the focus
// before anything could note the Share button.

import { useState, type ReactNode } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
Expand All @@ -11,21 +15,24 @@ import AddHeaderModal from "./AddHeaderModal";
import AddPerformanceModal from "./AddPerformanceModal";
import AuthModal from "./AuthModal";
import EditDetailsModal from "./EditDetailsModal";
import PublishModal from "./PublishModal";
import ReportModal from "./ReportModal";

const auth = vi.hoisted(() => ({
session: { user: { id: "u1", name: "Tester" } } as { user: { id: string; name: string } } | null,
pending: false,
}));

vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));
vi.mock("@/lib/community/auth-client", () => ({
authClient: { useSession: () => ({ data: auth.session, isPending: false }) },
authClient: { useSession: () => ({ data: auth.session, isPending: auth.pending }) },
dummyEmailFor: (name: string) => `${name}@example.invalid`,
}));
vi.mock("@/lib/posthogEvents", () => ({ captureEvent: vi.fn() }));

afterEach(() => {
auth.session = { user: { id: "u1", name: "Tester" } };
auth.pending = false;
});

function Page({
Expand Down Expand Up @@ -86,6 +93,18 @@ const WINDOWS: [string, (close: () => void) => ReactNode][] = [
],
["Add firmware header", (close) => <AddHeaderModal patternId="p1" initialCpp={null} onClose={close} />],
["Publish a performance", (close) => <AddPerformanceModal patternId="p1" onClose={close} />],
[
"Share to Community",
(close) => (
<PublishModal
code="export function draw(display) {}"
parentId={null}
parentTitle={null}
initialTitle="Wave"
onClose={close}
/>
),
],
];

describe.each(WINDOWS)("the %s window", (_name, dialog) => {
Expand All @@ -112,6 +131,43 @@ describe("community windows", () => {
expect(screen.getByRole("textbox", { name: "Title" })).toHaveFocus();
});

it("Share to Community still starts on the title field", () => {
render(<Page dialog={WINDOWS[5][1]} />);
open();
expect(screen.getByRole("textbox", { name: "Title" })).toHaveFocus();
});

// The lab's first Share can open before the session is known. The card
// holds the focus until then, and the title takes it when the form shows.
it("Share to Community moves to the title field once the session is known", () => {
auth.pending = true;
const onClose = vi.fn();
const { rerender } = render(<Page dialog={WINDOWS[5][1]} onClose={onClose} />);
const opener = open();
expect(screen.getByText("Checking session…")).toBeTruthy();
expect(screen.getByRole("dialog")).toContainElement(document.activeElement as HTMLElement);

auth.pending = false;
rerender(<Page dialog={WINDOWS[5][1]} onClose={onClose} />);
expect(screen.getByRole("textbox", { name: "Title" })).toHaveFocus();

pressEsc();
expect(onClose).toHaveBeenCalledTimes(1);
expect(opener).toHaveFocus();
});

it("a sign-in form inside Share to Community lets one Esc close it", () => {
auth.session = null;
const onClose = vi.fn();
render(<Page dialog={WINDOWS[5][1]} onClose={onClose} />);
const opener = open();
expect(screen.getByRole("button", { name: "Create account" })).toBeTruthy();
fireEvent.keyDown(screen.getByLabelText("Password"), { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("dialog")).toBeNull();
expect(opener).toHaveFocus();
});

it("a sign-in form inside the report window lets one Esc close the report", () => {
auth.session = null;
const onClose = vi.fn();
Expand Down
Loading