Skip to content

Commit 7c4377a

Browse files
committed
feat[frontend]: add file drag panel, improve accessibility
1 parent b411b1b commit 7c4377a

File tree

7 files changed

+132
-58
lines changed

7 files changed

+132
-58
lines changed

frontend/components/DarkModeToggle.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { JSX, useEffect } from "react"
2-
import { computerIcon, moonIcon, sunIcon } from "../icons.js"
2+
import { ComputerIcon, MoonIcon, SunIcon } from "../icons.js"
33
import { Tooltip, TooltipProps } from "@heroui/react"
44

55
export type DarkMode = "dark" | "light" | "system"
@@ -10,9 +10,9 @@ interface MyComponentProps extends TooltipProps {
1010
}
1111

1212
const icons: { name: DarkMode; icon: JSX.Element }[] = [
13-
{ name: "system", icon: computerIcon },
14-
{ name: "dark", icon: moonIcon },
15-
{ name: "light", icon: sunIcon },
13+
{ name: "system", icon: <ComputerIcon className="size-6 inline" /> },
14+
{ name: "dark", icon: <MoonIcon className="size-6 inline" /> },
15+
{ name: "light", icon: <SunIcon className="size-6 inline" /> },
1616
]
1717

1818
export function defaultDarkMode(): DarkMode {
@@ -37,10 +37,12 @@ export function DarkModeToggle({ mode, onModeChange, ...rest }: MyComponentProps
3737
}, [mode])
3838

3939
return (
40-
<Tooltip content="Toggle Dark Mode" {...rest}>
40+
<Tooltip content={`Toggle dark mode (${mode} mode now)`} {...rest}>
4141
<span
4242
className="absolute right-0"
4343
data-testid="pastebin-darkmode-toggle"
44+
role="button"
45+
aria-label="Toggle Dark Mode"
4446
onClick={() => {
4547
const curModeIdx = icons.findIndex(({ name }) => name === mode)
4648
onModeChange(icons[(curModeIdx + 1) % icons.length].name)

frontend/components/PasteEditor.tsx

Lines changed: 64 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Button, Card, CardBody, CardProps, Tab, Tabs, Textarea } from "@heroui/react"
2-
import React from "react"
2+
import React, { useRef, useState, DragEvent } from "react"
33
import { formatSize } from "../utils.js"
4+
import { XIcon } from "../icons.js"
45

56
export type EditKind = "edit" | "file"
67

@@ -16,28 +17,40 @@ interface PasteEditorProps extends CardProps {
1617
onStateChange: (state: PasteEditState) => void
1718
}
1819

19-
function displayFileInfo(file: File | null) {
20-
if (file === null) {
21-
return null
22-
} else {
23-
return (
24-
<span className="ml-4">
25-
<code>{file.name}</code> ({formatSize(file.size)})
26-
</span>
27-
)
20+
export function PasteEditor({ isPasteLoading, state, onStateChange, ...rest }: PasteEditorProps) {
21+
const fileInput = useRef<HTMLInputElement>(null)
22+
const [isDragged, setDragged] = useState<boolean>(false)
23+
24+
function setFile(file: File | null) {
25+
onStateChange({ ...state, editKind: "file", file })
26+
}
27+
28+
function onDrop(e: DragEvent) {
29+
e.preventDefault()
30+
const items = e.dataTransfer?.items
31+
if (items) {
32+
for (let i = 0; i < items.length; i++) {
33+
if (items[i].kind === "file") {
34+
console.log(items)
35+
const file = items[i].getAsFile()!
36+
setFile(file)
37+
break
38+
}
39+
}
40+
}
41+
setDragged(false)
2842
}
29-
}
3043

31-
export function PasteEditor({ isPasteLoading, state, onStateChange, ...rest }: PasteEditorProps) {
3244
return (
33-
<Card {...rest}>
45+
<Card aria-label="Pastebin editor panel" {...rest}>
3446
<CardBody>
3547
<Tabs
3648
variant="underlined"
3749
classNames={{
38-
tabList: "ml-4 gap-6 w-full p-0 border-divider",
39-
cursor: "w-full",
40-
tab: "max-w-fit px-0 h-8",
50+
tabList: "gap-2 w-full px-2 py-0 border-divider",
51+
cursor: "w-[80%]",
52+
tab: "max-w-fit px-2 h-8 px-2",
53+
panel: "pb-1",
4154
}}
4255
selectedKey={state.editKind}
4356
onSelectionChange={(k) => {
@@ -54,7 +67,7 @@ export function PasteEditor({ isPasteLoading, state, onStateChange, ...rest }: P
5467
classNames={{
5568
input: "resize-y min-h-[30em] font-mono",
5669
}}
57-
name="c"
70+
aria-label="paste-edit"
5871
disableAutosize
5972
disableAnimation
6073
value={state.editContent}
@@ -66,24 +79,47 @@ export function PasteEditor({ isPasteLoading, state, onStateChange, ...rest }: P
6679
></Textarea>
6780
</Tab>
6881
<Tab key="file" title="File">
69-
<Button radius="sm" color="primary" as="label">
82+
<div
83+
className={
84+
"w-full h-[20rem] rounded-xl flex flex-col items-center justify-center cursor-pointer relative" +
85+
(isDragged ? " bg-primary-100" : " bg-primary-50")
86+
}
87+
onDrop={onDrop}
88+
onDragEnter={() => setDragged(true)}
89+
onDragLeave={() => setDragged(false)}
90+
onDragOver={() => setDragged(true)}
91+
onClick={() => fileInput.current?.click()}
92+
>
7093
<input
7194
type="file"
95+
aria-label="paste-file"
96+
ref={fileInput}
7297
className="w-0 h-0 overflow-hidden absolute inline"
73-
onChange={(event) => {
74-
const files = event.target.files
98+
onChange={(e) => {
99+
const files = e.target.files
75100
if (files && files.length) {
76-
onStateChange({
77-
...state,
78-
editKind: "file",
79-
file: files[0],
80-
})
101+
setFile(files[0])
81102
}
82103
}}
83104
/>
84-
Upload
85-
</Button>
86-
{displayFileInfo(state.file)}
105+
<div className="text-2xl my-2 font-bold">Select File</div>
106+
<p className="text-1xl text-foreground-500 relative">
107+
<span>
108+
{state.file !== null
109+
? `${state.file.name} (${formatSize(state.file.size)})`
110+
: "Click or drag & drop file here"}
111+
</span>
112+
</p>
113+
{state.file && (
114+
<XIcon
115+
className="h-6 inline absolute top-2 right-2 text-red-400"
116+
onClick={(e) => {
117+
e.stopPropagation()
118+
setFile(null)
119+
}}
120+
/>
121+
)}
122+
</div>
87123
</Tab>
88124
</Tabs>
89125
</CardBody>

frontend/components/PasteSettingPanel.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,16 @@ interface PasteSettingPanelProps extends CardProps {
1919

2020
export function PanelSettingsPanel({ setting, onSettingChange, ...rest }: PasteSettingPanelProps) {
2121
return (
22-
<Card {...rest}>
22+
<Card aria-label="Pastebin setting panel" {...rest}>
2323
<CardHeader className="text-2xl">Settings</CardHeader>
2424
<Divider />
2525
<CardBody>
26-
<div className="gap-4 mb-6 flex flex-row">
26+
<div className="gap-4 mb-4 flex flex-row">
2727
<Input
2828
type="text"
2929
label="Expiration"
30+
// to avoid duplicated name, see https://github.com/adobe/react-spectrum/discussions/8037
31+
aria-labelledby=""
3032
className="basis-80"
3133
defaultValue="7d"
3234
value={setting.expiration}
@@ -39,6 +41,7 @@ export function PanelSettingsPanel({ setting, onSettingChange, ...rest }: PasteS
3941
<Input
4042
type="password"
4143
label="Password"
44+
aria-labelledby=""
4245
value={setting.password}
4346
onValueChange={(p) => onSettingChange({ ...setting, password: p })}
4447
placeholder={"Generated randomly"}

frontend/components/UploadedPanel.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ interface UploadedPanelProps extends CardProps {
88

99
export function UploadedPanel({ pasteResponse, ...rest }: UploadedPanelProps) {
1010
const snippetClassNames = {
11-
pre: "overflow-scroll leading-[2.5]",
12-
base: "w-full py-2/3",
11+
pre: "overflow-scroll leading-[2.5] font-sans",
12+
base: "w-full py-1/3",
1313
copyButton: "relative ml-[-12pt] left-[5pt]",
1414
}
15-
const firstColClassNames = "w-[7rem] whitespace-nowrap"
15+
const firstColClassNames = "w-[8rem] mr-4 whitespace-nowrap"
1616
return (
1717
<Card {...rest}>
1818
<CardHeader className="text-2xl">Uploaded Paste</CardHeader>
@@ -32,7 +32,7 @@ export function UploadedPanel({ pasteResponse, ...rest }: UploadedPanelProps) {
3232
</tr>
3333
<tr>
3434
<td className={firstColClassNames}>Manage URL</td>
35-
<td className="w-full overflow-hidden">
35+
<td className="w-full">
3636
<Skeleton isLoaded={pasteResponse !== null} className="rounded-2xl grow">
3737
<Snippet hideSymbol variant="bordered" classNames={snippetClassNames}>
3838
{pasteResponse?.manageUrl}

frontend/icons.tsx

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
export const moonIcon = (
1+
import { HTMLAttributes } from "react"
2+
3+
export const MoonIcon = (props: HTMLAttributes<SVGElement>) => (
24
<svg
35
xmlns="http://www.w3.org/2000/svg"
46
fill="none"
57
viewBox="0 0 24 24"
68
strokeWidth={1.5}
79
stroke="currentColor"
810
className="size-6 inline"
11+
{...props}
912
>
1013
<path
1114
strokeLinecap="round"
@@ -15,14 +18,14 @@ export const moonIcon = (
1518
</svg>
1619
)
1720

18-
export const sunIcon = (
21+
export const SunIcon = (props: HTMLAttributes<SVGElement>) => (
1922
<svg
2023
xmlns="http://www.w3.org/2000/svg"
2124
fill="none"
2225
viewBox="0 0 24 24"
2326
strokeWidth={1.5}
2427
stroke="currentColor"
25-
className="size-6 inline"
28+
{...props}
2629
>
2730
<path
2831
strokeLinecap="round"
@@ -32,14 +35,14 @@ export const sunIcon = (
3235
</svg>
3336
)
3437

35-
export const computerIcon = (
38+
export const ComputerIcon = (props: HTMLAttributes<SVGElement>) => (
3639
<svg
3740
xmlns="http://www.w3.org/2000/svg"
3841
fill="none"
3942
viewBox="0 0 24 24"
4043
strokeWidth={1.5}
4144
stroke="currentColor"
42-
className="size-6 inline"
45+
{...props}
4346
>
4447
<path
4548
strokeLinecap="round"
@@ -48,3 +51,20 @@ export const computerIcon = (
4851
/>
4952
</svg>
5053
)
54+
55+
export const XIcon = (props: HTMLAttributes<SVGElement>) => (
56+
<svg
57+
xmlns="http://www.w3.org/2000/svg"
58+
fill="none"
59+
viewBox="0 0 24 24"
60+
strokeWidth={1.5}
61+
stroke="currentColor"
62+
{...props}
63+
>
64+
<path
65+
strokeLinecap="round"
66+
strokeLinejoin="round"
67+
d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
68+
/>
69+
</svg>
70+
)

frontend/pb.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import React, { useEffect, useState } from "react"
22

3-
import { Button, Card, CardBody, Link, Tab, Tabs, Textarea } from "@heroui/react"
3+
import { Button, Link } from "@heroui/react"
44

55
import { PasteResponse, parsePath, parseFilenameFromContentDisposition } from "../src/shared.js"
66

77
import { DarkModeToggle, DarkMode, defaultDarkMode, shouldBeDark } from "./components/DarkModeToggle.js"
88
import { ErrorModal } from "./components/ErrorModal.js"
9-
import { PanelSettingsPanel, PasteSetting, UploadKind } from "./components/PasteSettingPanel.js"
9+
import { PanelSettingsPanel, PasteSetting } from "./components/PasteSettingPanel.js"
1010

1111
import { verifyExpiration, verifyManageUrl, verifyName, maxExpirationReadable, BaseUrl, APIUrl } from "./utils.js"
1212

@@ -174,9 +174,10 @@ export function PasteBin() {
174174

175175
const info = (
176176
<div className="mx-4 lg:mx-0">
177-
<h1 className="text-3xl mt-8 mb-4 relative">
178-
{INDEX_PAGE_TITLE} <DarkModeToggle mode={darkModeSelect} onModeChange={setDarkModeSelect} />
179-
</h1>
177+
<div className="mt-8 mb-4 relative">
178+
<h1 className="text-3xl inline">{INDEX_PAGE_TITLE}</h1>
179+
<DarkModeToggle mode={darkModeSelect} onModeChange={setDarkModeSelect} />
180+
</div>
180181
<p className="my-2">This is an open source pastebin deployed on Cloudflare Workers. </p>
181182
<p className="my-2">
182183
<b>Usage</b>: paste any text here, submit, then share it with URL. (

frontend/test/index.spec.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,30 @@ describe("Pastebin", () => {
2626
const title = screen.getByText("Pastebin Worker")
2727
expect(title).toBeInTheDocument()
2828

29-
const editor = screen.getByPlaceholderText("Edit your paste here")
29+
const editor = screen.getByRole("textbox", { name: "paste-edit" })
3030
expect(editor).toBeInTheDocument()
31-
await userEvent.type(editor, "something")
3231

33-
const submitter = screen.getByText("Upload")
32+
const submitter = screen.getByRole("button", { name: "Upload" })
3433
expect(submitter).toBeInTheDocument()
34+
expect(submitter).not.toBeEnabled()
35+
36+
await userEvent.type(editor, "something")
37+
3538
expect(submitter).toBeEnabled()
3639
await userEvent.click(submitter)
3740

3841
expect(screen.getByText(mockedPasteUpload.url)).toBeInTheDocument()
3942
expect(screen.getByText(mockedPasteUpload.manageUrl)).toBeInTheDocument()
4043
})
44+
45+
it("refuse illegal settings", async () => {
46+
render(<PasteBin />)
47+
// due to bugs https://github.com/adobe/react-spectrum/discussions/8037, we need to use duplicated name here
48+
const expire = screen.getByRole("textbox", { name: "Expiration" })
49+
expect(expire).toBeValid()
50+
await userEvent.type(expire, "xxx")
51+
expect(expire).toBeInvalid()
52+
})
4153
})
4254

4355
describe("Pastebin admin page", () => {
@@ -49,19 +61,19 @@ describe("Pastebin admin page", () => {
4961
})
5062
render(<PasteBin />)
5163

52-
const edit = screen.getByTestId("pastebin-edit")
53-
await userEvent.click(edit) // meaningless click, just ensure useEffect is done
54-
expect(edit).toBeInTheDocument()
55-
expect((edit as HTMLTextAreaElement).value).toStrictEqual(mockedPasteContent)
64+
const editor = screen.getByRole("textbox", { name: "paste-edit" })
65+
await userEvent.click(editor) // meaningless click, just ensure useEffect is done
66+
expect(editor).toBeInTheDocument()
67+
expect((editor as HTMLTextAreaElement).value).toStrictEqual(mockedPasteContent)
5668
})
5769
})
5870

5971
describe("Pastebin dark mode", () => {
6072
it("renders light mode", async () => {
6173
render(<PasteBin />)
6274

63-
const main = screen.getByTestId("pastebin-main")
64-
const toggler = screen.getByTestId("pastebin-darkmode-toggle")
75+
const main = screen.getByRole("main")
76+
const toggler = screen.getByRole("button", { name: "Toggle Dark Mode" })
6577
expect(main).toHaveClass("light")
6678
await userEvent.click(toggler)
6779
expect(main).toHaveClass("dark")

0 commit comments

Comments
 (0)