fix(files): 文件行的名字做成按钮,键盘和读屏都能打开文件和文件夹
This commit is contained in:
4 files changed
+68
-10
No files matched your search
@@ -4,6 +4,8 @@
|
||||
"root": "World root",
|
||||
"up": "Up",
|
||||
"refresh": "Refresh",
|
||||
"open_folder": "Open folder {{name}}",
|
||||
"open_file": "Open file {{name}}",
|
||||
"col_name": "Name",
|
||||
"col_size": "Size",
|
||||
"col_modified": "Modified",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"root": "世界根目录",
|
||||
"up": "上级目录",
|
||||
"refresh": "刷新",
|
||||
"open_folder": "打开文件夹 {{name}}",
|
||||
"open_file": "打开文件 {{name}}",
|
||||
"col_name": "名称",
|
||||
"col_size": "大小",
|
||||
"col_modified": "修改时间",
|
||||
|
||||
@@ -7,7 +7,13 @@ import i18next from "i18next";
|
||||
import { ServerFiles } from "./ServerFiles";
|
||||
import { STATUS_POLL_FAST_MS } from "@/lib/hooks";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ writeServerFile: vi.fn(), status: vi.fn(), stop: vi.fn() }));
|
||||
const mocks = vi.hoisted(() => ({
|
||||
writeServerFile: vi.fn(),
|
||||
status: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
listServerFiles: vi.fn(),
|
||||
readServerFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||
@@ -17,13 +23,8 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
||||
...actual.api,
|
||||
status: mocks.status,
|
||||
stop: mocks.stop,
|
||||
listServerFiles: () =>
|
||||
Promise.resolve({
|
||||
path: "",
|
||||
truncated: false,
|
||||
entries: [{ name: "server.properties", size: 8, is_dir: false, mod_time: "2026-09-01T00:00:00Z" }],
|
||||
}),
|
||||
readServerFile: () => Promise.resolve({ path: "server.properties", content: btoa("motd=hi\n"), sha256: "abc" }),
|
||||
listServerFiles: mocks.listServerFiles,
|
||||
readServerFile: mocks.readServerFile,
|
||||
writeServerFile: mocks.writeServerFile,
|
||||
},
|
||||
};
|
||||
@@ -53,6 +54,21 @@ const stopped = { name: "lobby", subdomain: "lobby", phase: "Stopped", desiredSt
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.writeServerFile.mockReset();
|
||||
mocks.listServerFiles.mockReset();
|
||||
mocks.listServerFiles.mockImplementation((_name: string, path: string) =>
|
||||
Promise.resolve({
|
||||
path,
|
||||
truncated: false,
|
||||
entries: [
|
||||
{ name: "server.properties", size: 8, is_dir: false, mod_time: "2026-09-01T00:00:00Z" },
|
||||
{ name: "world", size: 0, is_dir: true, mod_time: "2026-09-01T00:00:00Z" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
mocks.readServerFile.mockReset();
|
||||
mocks.readServerFile.mockImplementation((_name: string, path: string) =>
|
||||
Promise.resolve({ path, content: btoa("motd=hi\n"), sha256: "abc" }),
|
||||
);
|
||||
mocks.stop.mockReset();
|
||||
mocks.status.mockReset();
|
||||
mocks.status.mockResolvedValue(stopped);
|
||||
@@ -180,3 +196,34 @@ describe("ServerFiles on a running server", () => {
|
||||
expect(mocks.stop).toHaveBeenCalledWith("lobby");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerFiles from the keyboard", () => {
|
||||
it("opens a folder and then a file without a mouse", async () => {
|
||||
renderFiles();
|
||||
const folder = await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "world" }) });
|
||||
folder.focus();
|
||||
expect(document.activeElement).toBe(folder);
|
||||
|
||||
await userEvent.keyboard(" ");
|
||||
expect(mocks.listServerFiles).toHaveBeenLastCalledWith("lobby", "world");
|
||||
|
||||
const file = await screen.findByRole("button", { name: i18next.t("files:open_file", { name: "server.properties" }) });
|
||||
file.focus();
|
||||
expect(document.activeElement).toBe(file);
|
||||
await userEvent.keyboard("{Enter}");
|
||||
|
||||
expect(await screen.findByRole("dialog")).toBeTruthy();
|
||||
// One press reads the file once: the button's click and the row's are one handler.
|
||||
expect(mocks.readServerFile.mock.calls).toEqual([["lobby", "world/server.properties"]]);
|
||||
});
|
||||
|
||||
it("still opens from a click anywhere on the row", async () => {
|
||||
renderFiles();
|
||||
const size = await screen.findByText("8 B");
|
||||
|
||||
await userEvent.click(size);
|
||||
|
||||
expect(await screen.findByRole("dialog")).toBeTruthy();
|
||||
expect(mocks.readServerFile.mock.calls).toEqual([["lobby", "server.properties"]]);
|
||||
});
|
||||
});
|
||||
@@ -411,7 +411,14 @@ export function ServerFiles() {
|
||||
className="cursor-pointer transition-colors hover:bg-muted/30"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{/* The name is a button so the keyboard and a screen
|
||||
reader reach every row; its click bubbles to the
|
||||
row, which opens it from anywhere on the row. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(e.is_dir ? "open_folder" : "open_file", { name: e.name })}
|
||||
className="flex min-w-0 max-w-full items-center gap-2 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{e.is_dir ? (
|
||||
<Folder className="h-4 w-4 shrink-0 text-primary" />
|
||||
) : (
|
||||
@@ -421,7 +428,7 @@ export function ServerFiles() {
|
||||
{opening === joinPath(dir, e.name) && (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">
|
||||
{e.is_dir ? "—" : formatBytes(e.size)}
|
||||
|
||||
Reference in new issue
Block a user