Skip to content

Commit dfa43c8

Browse files
committed
feat(attachments): add Attachments and the file drop zone
Attachments renders files as a stack above the input, tiles in a message, or a list, with preview, info, remove and more parts. The host owns the list: useAttachments applies the count, size and duplicate limits, builds previews and revokes every object URL it made once its attachment leaves the list. useFileDrop and AttachmentDropZone accept files and whole folders, and a stray drop elsewhere never replaces the page.
1 parent 0fc6401 commit dfa43c8

11 files changed

Lines changed: 2802 additions & 1 deletion

‎e2e/attachments.spec.ts‎

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
import { expect, test, type Page } from "@playwright/test";
2+
3+
/**
4+
* Drops `count` text files named `<prefix>-<n>.txt` on the zone, the way a
5+
* browser delivers a drag from the desktop: dragenter, dragover, then drop,
6+
* all carrying one real DataTransfer.
7+
*/
8+
async function dropFiles(page: Page, prefix: string, count: number) {
9+
await page.getByTestId("zone").evaluate(
10+
(zone, { prefix, count }) => {
11+
const data = new DataTransfer();
12+
for (let index = 0; index < count; index += 1) {
13+
data.items.add(
14+
new File([`${prefix} ${index}`], `${prefix}-${index}.txt`, {
15+
type: "text/plain",
16+
}),
17+
);
18+
}
19+
for (const type of ["dragenter", "dragover", "drop"]) {
20+
zone.dispatchEvent(
21+
new DragEvent(type, {
22+
dataTransfer: data,
23+
bubbles: true,
24+
cancelable: true,
25+
}),
26+
);
27+
}
28+
},
29+
{ prefix, count },
30+
);
31+
}
32+
33+
/** Left edges of the stack's cards, from layout (so the tilt doesn't count). */
34+
function cardOffsets(page: Page): Promise<number[]> {
35+
return page
36+
.locator("[data-slot=attachment]")
37+
.evaluateAll((cards) =>
38+
cards.map((card) => (card instanceof HTMLElement ? card.offsetLeft : -1)),
39+
);
40+
}
41+
42+
async function gapBetweenFirstCards(page: Page): Promise<number> {
43+
const [first, second] = await cardOffsets(page);
44+
if (first === undefined || second === undefined) return Number.NaN;
45+
return second - first;
46+
}
47+
48+
const count = (page: Page) => page.getByRole("status", { name: "Count" });
49+
50+
test("a dropped picture previews, and removing it revokes its URL", async ({
51+
page,
52+
}) => {
53+
await page.goto("/attachments");
54+
await page.getByTestId("zone").evaluate(async (zone) => {
55+
const canvas = document.createElement("canvas");
56+
canvas.width = 8;
57+
canvas.height = 8;
58+
const blob = await new Promise<Blob | null>((resolve) =>
59+
canvas.toBlob(resolve, "image/png"),
60+
);
61+
if (blob === null) throw new Error("no PNG");
62+
const data = new DataTransfer();
63+
data.items.add(new File([blob], "dot.png", { type: "image/png" }));
64+
for (const type of ["dragenter", "dragover", "drop"]) {
65+
zone.dispatchEvent(
66+
new DragEvent(type, {
67+
dataTransfer: data,
68+
bubbles: true,
69+
cancelable: true,
70+
}),
71+
);
72+
}
73+
});
74+
75+
await expect(count(page)).toHaveText("1");
76+
const list = page.getByRole("list", { name: "1 attachment" });
77+
const picture = list.locator("img");
78+
await expect(picture).toHaveJSProperty("complete", true);
79+
expect(
80+
await picture.evaluate((image: HTMLImageElement) => image.naturalWidth),
81+
).toBe(8);
82+
const url = await picture.getAttribute("src");
83+
expect(url).toMatch(/^blob:/);
84+
const readable = (target: string) =>
85+
page.evaluate(
86+
(target) =>
87+
fetch(target).then(
88+
() => true,
89+
() => false,
90+
),
91+
target,
92+
);
93+
expect(await readable(url ?? "")).toBe(true);
94+
95+
await list.getByRole("button", { name: "Remove dot.png" }).click();
96+
await expect(count(page)).toHaveText("0");
97+
await expect.poll(() => readable(url ?? "")).toBe(false);
98+
// The list is gone with its last attachment; focus went to the message box.
99+
await expect(page.getByRole("textbox", { name: "Message" })).toBeFocused();
100+
expect(await page.evaluate(() => document.activeElement?.tagName)).not.toBe(
101+
"BODY",
102+
);
103+
});
104+
105+
test("25 files fold into a stack that fans out, expands and clears", async ({
106+
page,
107+
}) => {
108+
await page.goto("/attachments");
109+
await dropFiles(page, "single", 1);
110+
await expect(count(page)).toHaveText("1");
111+
await dropFiles(page, "batch", 25);
112+
await expect(count(page)).toHaveText("26");
113+
await expect(page.getByRole("status", { name: "Refused" })).toHaveText("");
114+
115+
const stack = page.getByRole("list", { name: "26 attachments" });
116+
await expect(stack.locator("[data-slot=attachment]")).toHaveCount(4);
117+
const more = stack.getByRole("button", { name: "Show 22 more" });
118+
await expect(more).toHaveText("+22");
119+
120+
// At rest the cards overlap: each shows a 10px sliver of the one behind.
121+
await page.getByTestId("outside").hover();
122+
await expect.poll(() => gapBetweenFirstCards(page)).toBe(10);
123+
await expect(more).toHaveCSS("opacity", "0");
124+
125+
// Pointed at, the deck fans out: a whole 52px card and an 8px gap apart.
126+
await stack.hover();
127+
await expect.poll(() => gapBetweenFirstCards(page)).toBe(60);
128+
await expect(more).toHaveCSS("opacity", "1");
129+
130+
await more.click();
131+
await expect(stack.locator("[data-slot=attachment]")).toHaveCount(26);
132+
// Focus lands on the first card "+22" was folding, not the page start.
133+
await expect(
134+
stack.getByRole("button", { name: "Remove batch-3.txt" }),
135+
).toBeFocused();
136+
await expect(stack.getByText("batch-24.txt")).toBeVisible();
137+
138+
await page.getByRole("button", { name: "Remove all" }).click();
139+
await expect(count(page)).toHaveText("0");
140+
await expect(page.locator("[data-slot=attachments]")).toHaveCount(0);
141+
await expect(page.getByRole("textbox", { name: "Message" })).toBeFocused();
142+
});
143+
144+
test("the stack fans out for the keyboard, and focus follows removal", async ({
145+
page,
146+
}) => {
147+
await page.goto("/attachments");
148+
await dropFiles(page, "key", 3);
149+
await expect(count(page)).toHaveText("3");
150+
await page.getByTestId("outside").hover();
151+
152+
const stack = page.getByRole("list", { name: "3 attachments" });
153+
await page.keyboard.press("Tab");
154+
const first = stack.getByRole("button", { name: "Remove key-0.txt" });
155+
await expect(first).toBeFocused();
156+
await expect.poll(() => gapBetweenFirstCards(page)).toBe(60);
157+
await expect(first).toHaveCSS("opacity", "1");
158+
159+
await page.keyboard.press("Enter");
160+
await expect(count(page)).toHaveText("2");
161+
await expect(
162+
page
163+
.getByRole("list", { name: "2 attachments" })
164+
.getByRole("button", { name: "Remove key-1.txt" }),
165+
).toBeFocused();
166+
167+
// The keyboard clears the rest; the last removal hands focus on.
168+
await page.keyboard.press("Enter");
169+
await page.keyboard.press("Enter");
170+
await expect(count(page)).toHaveText("0");
171+
await expect(page.locator("[data-slot=attachments]")).toHaveCount(0);
172+
await expect(page.getByRole("textbox", { name: "Message" })).toBeFocused();
173+
expect(await page.evaluate(() => document.activeElement?.tagName)).not.toBe(
174+
"BODY",
175+
);
176+
});
177+
178+
test("past the limit, 100 of 120 files join and the rest are refused", async ({
179+
page,
180+
}) => {
181+
await page.goto("/attachments");
182+
await dropFiles(page, "many", 120);
183+
await expect(count(page)).toHaveText("100");
184+
await expect(page.getByRole("status", { name: "Refused" })).toHaveText(
185+
"too-many 20",
186+
);
187+
await expect(
188+
page.getByRole("list", { name: "100 attachments" }).getByRole("button", {
189+
name: "Show 96 more",
190+
}),
191+
).toHaveText("+96");
192+
});
193+
194+
// A browser only builds file-system entries for a real drag from the
195+
// desktop, which Playwright cannot make. The drop's items are stubbed to
196+
// hand back entries of the same shape, so this covers the zone's walk of
197+
// them, not the browser's side.
198+
test("a dropped folder is walked to the files inside it", async ({ page }) => {
199+
await page.goto("/attachments");
200+
await page.getByTestId("zone").evaluate((zone) => {
201+
type Entry = {
202+
name: string;
203+
isFile: boolean;
204+
isDirectory: boolean;
205+
file?: (success: (file: File) => void) => void;
206+
createReader?: () => {
207+
readEntries: (success: (entries: Entry[]) => void) => void;
208+
};
209+
};
210+
const file = (name: string): Entry => ({
211+
name,
212+
isFile: true,
213+
isDirectory: false,
214+
file: (success) =>
215+
success(new File([name], name, { type: "text/plain" })),
216+
});
217+
const folder = (name: string, entries: Entry[]): Entry => ({
218+
name,
219+
isFile: false,
220+
isDirectory: true,
221+
createReader: () => {
222+
let read = false;
223+
return {
224+
readEntries: (success) => {
225+
success(read ? [] : entries);
226+
read = true;
227+
},
228+
};
229+
},
230+
});
231+
const project = folder("project", [
232+
file("README.md"),
233+
file(".DS_Store"),
234+
folder("src", [file("index.ts"), file("util.ts")]),
235+
folder("test", [file("index.ts")]),
236+
]);
237+
const items = [
238+
{ kind: "file", getAsFile: () => null, webkitGetAsEntry: () => project },
239+
];
240+
const data = { types: ["Files"], items, files: [], dropEffect: "none" };
241+
for (const type of ["dragenter", "dragover", "drop"]) {
242+
const event = new DragEvent(type, { bubbles: true, cancelable: true });
243+
Object.defineProperty(event, "dataTransfer", { value: data });
244+
zone.dispatchEvent(event);
245+
}
246+
});
247+
248+
await expect(count(page)).toHaveText("4");
249+
await expect(page.getByRole("status", { name: "Folders" })).toHaveText(
250+
"project",
251+
);
252+
const stack = page.getByRole("list", { name: "4 attachments" });
253+
for (const name of [
254+
"project/README.md",
255+
"project/src/index.ts",
256+
"project/src/util.ts",
257+
"project/test/index.ts",
258+
]) {
259+
await expect(
260+
stack.getByRole("button", { name: `Remove ${name}` }),
261+
).toHaveCount(1);
262+
}
263+
});
264+
265+
test("files dropped outside the zone do not open in place of the page", async ({
266+
page,
267+
}) => {
268+
await page.goto("/attachments");
269+
const handled = await page.getByTestId("outside").evaluate((outside) => {
270+
const data = new DataTransfer();
271+
data.items.add(new File(["stray"], "stray.txt", { type: "text/plain" }));
272+
return ["dragenter", "dragover", "drop"].map((type) => {
273+
const event = new DragEvent(type, {
274+
dataTransfer: data,
275+
bubbles: true,
276+
cancelable: true,
277+
});
278+
outside.dispatchEvent(event);
279+
return { type, prevented: event.defaultPrevented };
280+
});
281+
});
282+
expect(handled).toEqual([
283+
{ type: "dragenter", prevented: false },
284+
{ type: "dragover", prevented: true },
285+
{ type: "drop", prevented: true },
286+
]);
287+
await expect(page).toHaveURL(/\/attachments$/);
288+
await expect(count(page)).toHaveText("0");
289+
});

0 commit comments

Comments
 (0)