diff --git a/frontend/src/components/tab-navigation.test.ts b/frontend/src/components/tab-navigation.test.ts
new file mode 100644
index 0000000..06f8dd3
--- /dev/null
+++ b/frontend/src/components/tab-navigation.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import { tabIndexForKey } from "./tab-navigation";
+
+describe("tab keyboard navigation", () => {
+ it("wraps through horizontal and vertical arrow keys", () => {
+ expect(tabIndexForKey("ArrowRight", 0, 3)).toBe(1);
+ expect(tabIndexForKey("ArrowDown", 2, 3)).toBe(0);
+ expect(tabIndexForKey("ArrowLeft", 0, 3)).toBe(2);
+ expect(tabIndexForKey("ArrowUp", 2, 3)).toBe(1);
+ });
+
+ it("jumps to the first or last tab with Home and End", () => {
+ expect(tabIndexForKey("Home", 2, 3)).toBe(0);
+ expect(tabIndexForKey("End", 0, 3)).toBe(2);
+ });
+
+ it("ignores unsupported keys and invalid tab positions", () => {
+ expect(tabIndexForKey("Enter", 1, 3)).toBeNull();
+ expect(tabIndexForKey("ArrowRight", -1, 3)).toBeNull();
+ expect(tabIndexForKey("ArrowRight", 0, 0)).toBeNull();
+ });
+});
diff --git a/frontend/src/components/tab-navigation.ts b/frontend/src/components/tab-navigation.ts
new file mode 100644
index 0000000..c3bc411
--- /dev/null
+++ b/frontend/src/components/tab-navigation.ts
@@ -0,0 +1,8 @@
+export function tabIndexForKey(key: string, currentIndex: number, tabCount: number): number | null {
+ if (tabCount <= 0 || currentIndex < 0 || currentIndex >= tabCount) return null;
+ if (key === "Home") return 0;
+ if (key === "End") return tabCount - 1;
+ if (key === "ArrowRight" || key === "ArrowDown") return (currentIndex + 1) % tabCount;
+ if (key === "ArrowLeft" || key === "ArrowUp") return (currentIndex - 1 + tabCount) % tabCount;
+ return null;
+}
diff --git a/frontend/src/topics/build/lab.tsx b/frontend/src/topics/build/lab.tsx
index 7fab29f..9009f69 100644
--- a/frontend/src/topics/build/lab.tsx
+++ b/frontend/src/topics/build/lab.tsx
@@ -1,4 +1,4 @@
-import { type FormEvent, useMemo, useState } from "react";
+import { type FormEvent, type KeyboardEvent, useMemo, useState } from "react";
import {
buildFileFixtures,
buildLabHappyPath,
@@ -10,6 +10,7 @@ import {
} from "./content";
import { createInitialBuildState, isBuildLabComplete, runBuildEvent } from "./simulator";
import { TopicCompletionCard, TopicLabShell, TopicStatusFeedback, type TopicStatusTone } from "../../components/TopicShell";
+import { tabIndexForKey } from "../../components/tab-navigation";
interface BuildHistoryEntry {
command?: string;
@@ -56,6 +57,18 @@ function buildFileLines(state: BuildLabState, fileId: BuildFileId): readonly str
return file.lines;
}
+function buildFileSlug(fileId: BuildFileId): string {
+ return fileId.replace(/[^a-z0-9]+/gi, "-");
+}
+
+function buildTabId(fileId: BuildFileId): string {
+ return `build-file-tab-${buildFileSlug(fileId)}`;
+}
+
+function buildPanelId(fileId: BuildFileId): string {
+ return `build-file-panel-${buildFileSlug(fileId)}`;
+}
+
export function buildLabProgress(state: BuildLabState): number {
return Math.round((state.completedStepIds.length / buildLessonSteps.length) * 100);
}
@@ -128,27 +141,49 @@ export function BuildLab({ onComplete }: { onComplete?: () => void }) {
workshop-build-lab
{state.phase}
-
- {buildFileFixtures.map((file) => (
+
+ {buildFileFixtures.map((file, index) => (
))}
-
- {buildFileLines(state, selectedFile).map((line, index) => (
-
- {String(index + 1).padStart(2, "0")}{line || " "}
-
- ))}
-
+ {buildFileFixtures.map((file) => (
+
+ {buildFileLines(state, file.id).map((line, index) => (
+
+ {String(index + 1).padStart(2, "0")}{line || " "}
+
+ ))}
+
+ ))}
{history.map((entry, index) => (
diff --git a/frontend/src/topics/package/lab.tsx b/frontend/src/topics/package/lab.tsx
index d04b54b..416470c 100644
--- a/frontend/src/topics/package/lab.tsx
+++ b/frontend/src/topics/package/lab.tsx
@@ -1,4 +1,4 @@
-import { type FormEvent, useMemo, useState } from "react";
+import { type FormEvent, type KeyboardEvent, useMemo, useState } from "react";
import {
packageLabHappyPath,
packageLessonSteps,
@@ -8,6 +8,7 @@ import {
} from "./content";
import { createInitialPackageState, isPackageLabComplete, runPackageEvent } from "./simulator";
import { TopicCompletionCard, TopicLabShell, TopicStatusFeedback, type TopicStatusTone } from "../../components/TopicShell";
+import { tabIndexForKey } from "../../components/tab-navigation";
interface PackageHistoryEntry {
command?: string;
@@ -59,6 +60,18 @@ function packageFileLines(state: PackageLabState, file: PackageFile): readonly s
: ["node_modules/", " // 尚未安裝任何依賴"];
}
+function packageFileSlug(file: PackageFile): string {
+ return file.replace(/[^a-z0-9]+/gi, "-");
+}
+
+function packageTabId(file: PackageFile): string {
+ return `package-file-tab-${packageFileSlug(file)}`;
+}
+
+function packagePanelId(file: PackageFile): string {
+ return `package-file-panel-${packageFileSlug(file)}`;
+}
+
export function packageLabProgress(state: PackageLabState): number {
return Math.round((state.completedStepIds.length / packageLessonSteps.length) * 100);
}
@@ -130,27 +143,49 @@ export function PackageLab({ onComplete }: { onComplete?: () => void }) {
workshop-package-lab
{state.phase}
-
- {PACKAGE_FILES.map((file) => (
+
+ {PACKAGE_FILES.map((file, index) => (
))}
-
- {packageFileLines(state, selectedFile).map((line, index) => (
-
- {String(index + 1).padStart(2, "0")}{line || " "}
-
- ))}
-
+ {PACKAGE_FILES.map((file) => (
+
+ {packageFileLines(state, file).map((line, index) => (
+
+ {String(index + 1).padStart(2, "0")}{line || " "}
+
+ ))}
+
+ ))}
{history.map((entry, index) => (
diff --git a/frontend/src/topics/rest/lab.tsx b/frontend/src/topics/rest/lab.tsx
index 05e4d40..6ef216e 100644
--- a/frontend/src/topics/rest/lab.tsx
+++ b/frontend/src/topics/rest/lab.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from "react";
+import { type KeyboardEvent, useState } from "react";
import { TopicCompletionCard, TopicLabShell, TopicStatusFeedback, type TopicStatusTone } from "../../components/TopicShell";
import {
findRestCodeFile,
@@ -15,6 +15,7 @@ import {
type RestTraceStageId,
} from "./content";
import { createInitialRestState, isRestLabComplete, isRestStageUnlocked, runRestEvent } from "./simulator";
+import { tabIndexForKey } from "../../components/tab-navigation";
type RestCodeMode = "annotated" | "source";
@@ -54,6 +55,18 @@ function firstLineId(fileId: RestCodeFileId): string {
return firstLine.id;
}
+function restFileSlug(fileId: RestCodeFileId): string {
+ return fileId.replace(/[^a-z0-9]+/gi, "-");
+}
+
+function restTabId(fileId: RestCodeFileId): string {
+ return `rest-file-tab-${restFileSlug(fileId)}`;
+}
+
+function restPanelId(fileId: RestCodeFileId): string {
+ return `rest-file-panel-${restFileSlug(fileId)}`;
+}
+
export function lineForFileSelection(fileId: RestCodeFileId, stageId: RestTraceStageId): string {
return relatedLine(fileId, stageId) ?? firstLineId(fileId);
}
@@ -76,11 +89,6 @@ export function RestLab({ onComplete }: { onComplete?: () => void }) {
const terminalIndex = stageIndex(scenario.terminalStageId);
const currentIndex = stageIndex(state.activeStageId);
const completed = isRestLabComplete(state);
- const activeLineIds = useMemo(
- () => selectedFile.lines.filter((line) => line.stages.includes(state.activeStageId)).map((line) => line.id),
- [selectedFile, state.activeStageId],
- );
-
function dispatch(event: RestLabEvent) {
const result = runRestEvent(state, event);
if (!isRestLabComplete(state) && isRestLabComplete(result.state)) onComplete?.();
@@ -181,9 +189,29 @@ export function RestLab({ onComplete }: { onComplete?: () => void }) {
-
- {restCodeFiles.map((file) => (
-
+
+ {restCodeFiles.map((file, index) => (
+
))}
@@ -193,23 +221,40 @@ export function RestLab({ onComplete }: { onComplete?: () => void }) {
{selectedFile.path}{selectedFile.language} · {selectedFile.role}
{fileSelectionNotice ?
{fileSelectionNotice}
: null}
-
- {selectedFile.lines.map((line, index) => {
- const isRelated = activeLineIds.includes(line.id);
- return (
-
- );
- })}
-
+ {restCodeFiles.map((file) => {
+ const isSelected = selectedFileId === file.id;
+ const activeLineIds = file.lines.filter((line) => line.stages.includes(state.activeStageId)).map((line) => line.id);
+ return (
+
+
+ {file.lines.map((line, index) => {
+ const isRelated = activeLineIds.includes(line.id);
+ return (
+
+ );
+ })}
+
+
+ );
+ })}