Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions frontend/src/components/tab-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
8 changes: 8 additions & 0 deletions frontend/src/components/tab-navigation.ts
Original file line number Diff line number Diff line change
@@ -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;
}
55 changes: 45 additions & 10 deletions frontend/src/topics/build/lab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type FormEvent, useMemo, useState } from "react";
import { type FormEvent, type KeyboardEvent, useMemo, useState } from "react";
import {
buildFileFixtures,
buildLabHappyPath,
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -128,27 +141,49 @@ export function BuildLab({ onComplete }: { onComplete?: () => void }) {
<b>workshop-build-lab</b>
<span className="build-phase">{state.phase}</span>
</div>
<div className="build-file-tabs" role="tablist" aria-label="Build fixture files">
{buildFileFixtures.map((file) => (
<div className="build-file-tabs" role="tablist" aria-orientation="horizontal" aria-label="Build fixture files">
{buildFileFixtures.map((file, index) => (
<button
className={selectedFile === file.id ? "active" : ""}
key={file.id}
type="button"
role="tab"
id={buildTabId(file.id)}
aria-controls={buildPanelId(file.id)}
aria-selected={selectedFile === file.id}
tabIndex={selectedFile === file.id ? 0 : -1}
onKeyDown={(event: KeyboardEvent<HTMLButtonElement>) => {
const nextIndex = tabIndexForKey(event.key, index, buildFileFixtures.length);
if (nextIndex === null) return;
event.preventDefault();
const nextFile = buildFileFixtures[nextIndex];
setSelectedFile(nextFile.id);
document.getElementById(buildTabId(nextFile.id))?.focus();
}}
onClick={() => setSelectedFile(file.id)}
>
{file.name}
</button>
))}
</div>
<div className="build-editor" role="region" aria-label={`${selectedFile} fixture`}>
{buildFileLines(state, selectedFile).map((line, index) => (
<div className="build-code-line" key={`${selectedFile}-${index}`}>
<span>{String(index + 1).padStart(2, "0")}</span><code>{line || " "}</code>
</div>
))}
</div>
{buildFileFixtures.map((file) => (
<div
className="build-editor"
id={buildPanelId(file.id)}
key={file.id}
role="tabpanel"
aria-labelledby={buildTabId(file.id)}
aria-label={`${file.id} fixture`}
tabIndex={0}
hidden={selectedFile !== file.id}
>
{buildFileLines(state, file.id).map((line, index) => (
<div className="build-code-line" key={`${file.id}-${index}`}>
<span>{String(index + 1).padStart(2, "0")}</span><code>{line || " "}</code>
</div>
))}
</div>
))}
<div className="build-terminal-output" role="log" aria-live="polite" aria-label="Build command output">
{history.map((entry, index) => (
<div className={`terminal-entry ${entry.accepted === false ? "error" : ""}`} key={`${index}-${entry.command ?? "system"}`}>
Expand Down
55 changes: 45 additions & 10 deletions frontend/src/topics/package/lab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type FormEvent, useMemo, useState } from "react";
import { type FormEvent, type KeyboardEvent, useMemo, useState } from "react";
import {
packageLabHappyPath,
packageLessonSteps,
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -130,27 +143,49 @@ export function PackageLab({ onComplete }: { onComplete?: () => void }) {
<b>workshop-package-lab</b>
<span className="package-phase">{state.phase}</span>
</div>
<div className="package-file-tabs" role="tablist" aria-label="Package fixture files">
{PACKAGE_FILES.map((file) => (
<div className="package-file-tabs" role="tablist" aria-orientation="horizontal" aria-label="Package fixture files">
{PACKAGE_FILES.map((file, index) => (
<button
className={selectedFile === file ? "active" : ""}
key={file}
type="button"
role="tab"
id={packageTabId(file)}
aria-controls={packagePanelId(file)}
aria-selected={selectedFile === file}
tabIndex={selectedFile === file ? 0 : -1}
onKeyDown={(event: KeyboardEvent<HTMLButtonElement>) => {
const nextIndex = tabIndexForKey(event.key, index, PACKAGE_FILES.length);
if (nextIndex === null) return;
event.preventDefault();
const nextFile = PACKAGE_FILES[nextIndex];
setSelectedFile(nextFile);
document.getElementById(packageTabId(nextFile))?.focus();
}}
onClick={() => setSelectedFile(file)}
>
{file}
</button>
))}
</div>
<div className="package-editor" role="region" aria-label={`${selectedFile} fixture`}>
{packageFileLines(state, selectedFile).map((line, index) => (
<div className="package-code-line" key={`${selectedFile}-${index}`}>
<span>{String(index + 1).padStart(2, "0")}</span><code>{line || " "}</code>
</div>
))}
</div>
{PACKAGE_FILES.map((file) => (
<div
className="package-editor"
id={packagePanelId(file)}
key={file}
role="tabpanel"
aria-labelledby={packageTabId(file)}
aria-label={`${file} fixture`}
tabIndex={0}
hidden={selectedFile !== file}
>
{packageFileLines(state, file).map((line, index) => (
<div className="package-code-line" key={`${file}-${index}`}>
<span>{String(index + 1).padStart(2, "0")}</span><code>{line || " "}</code>
</div>
))}
</div>
))}
<div className="package-terminal-output" role="log" aria-live="polite" aria-label="Package command output">
{history.map((entry, index) => (
<div className={`terminal-entry ${entry.accepted === false ? "error" : ""}`} key={`${index}-${entry.command ?? "system"}`}>
Expand Down
97 changes: 71 additions & 26 deletions frontend/src/topics/rest/lab.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";

Expand Down Expand Up @@ -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);
}
Expand All @@ -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?.();
Expand Down Expand Up @@ -181,9 +189,29 @@ export function RestLab({ onComplete }: { onComplete?: () => void }) {
<div className="rest-workbench">
<section className="rest-code-panel" aria-label="Full stack source code">
<header className="rest-code-toolbar">
<div className="rest-file-tabs" role="tablist" aria-label="程式檔案">
{restCodeFiles.map((file) => (
<button key={file.id} type="button" role="tab" aria-selected={selectedFileId === file.id} className={selectedFileId === file.id ? "active" : ""} onClick={() => changeFile(file.id)}>{file.id}</button>
<div className="rest-file-tabs" role="tablist" aria-orientation="horizontal" aria-label="程式檔案">
{restCodeFiles.map((file, index) => (
<button
key={file.id}
id={restTabId(file.id)}
type="button"
role="tab"
aria-controls={restPanelId(file.id)}
aria-selected={selectedFileId === file.id}
tabIndex={selectedFileId === file.id ? 0 : -1}
className={selectedFileId === file.id ? "active" : ""}
onKeyDown={(event: KeyboardEvent<HTMLButtonElement>) => {
const nextIndex = tabIndexForKey(event.key, index, restCodeFiles.length);
if (nextIndex === null) return;
event.preventDefault();
const nextFile = restCodeFiles[nextIndex];
changeFile(nextFile.id);
document.getElementById(restTabId(nextFile.id))?.focus();
}}
onClick={() => changeFile(file.id)}
>
{file.id}
</button>
))}
</div>
<div className="rest-mode-switch" aria-label="Code display mode">
Expand All @@ -193,23 +221,40 @@ export function RestLab({ onComplete }: { onComplete?: () => void }) {
</header>
<div className="rest-code-meta"><span>{selectedFile.path}</span><small>{selectedFile.language} · {selectedFile.role}</small></div>
{fileSelectionNotice ? <p className="rest-code-notice" role="status">{fileSelectionNotice}</p> : null}
<div className="rest-code-lines" role="listbox" aria-label={`${selectedFile.id} 逐行程式碼`}>
{selectedFile.lines.map((line, index) => {
const isRelated = activeLineIds.includes(line.id);
return (
<button
type="button"
role="option"
aria-selected={selectedLineId === line.id}
className={`${selectedLineId === line.id ? "selected" : ""} ${isRelated ? "related" : ""}`}
key={line.id}
onClick={() => setSelectedLineId(line.id)}
>
<span>{String(index + 1).padStart(2, "0")}</span><code>{line.code}</code>{codeMode === "annotated" ? <small>{line.explanation}</small> : null}
</button>
);
})}
</div>
{restCodeFiles.map((file) => {
const isSelected = selectedFileId === file.id;
const activeLineIds = file.lines.filter((line) => line.stages.includes(state.activeStageId)).map((line) => line.id);
return (
<div
className="rest-code-tabpanel"
id={restPanelId(file.id)}
key={file.id}
role="tabpanel"
aria-labelledby={restTabId(file.id)}
aria-label={`${file.id} 逐行程式碼`}
tabIndex={0}
hidden={!isSelected}
>
<div className="rest-code-lines" role="listbox" aria-label={`${file.id} 逐行程式碼`}>
{file.lines.map((line, index) => {
const isRelated = activeLineIds.includes(line.id);
return (
<button
type="button"
role="option"
aria-selected={selectedLineId === line.id}
className={`${selectedLineId === line.id ? "selected" : ""} ${isRelated ? "related" : ""}`}
key={line.id}
onClick={() => setSelectedLineId(line.id)}
>
<span>{String(index + 1).padStart(2, "0")}</span><code>{line.code}</code>{codeMode === "annotated" ? <small>{line.explanation}</small> : null}
</button>
);
})}
</div>
</div>
);
})}
</section>

<aside className="rest-explanation-panel" aria-live="polite">
Expand Down
Loading