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) => ( + + ))}
{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) => ( + + ))}
{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 ( + + ); + })}