diff --git a/CHANGELOG.md b/CHANGELOG.md index 88fa1ce..5b5dff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] +### Changed — Sprint 13 (SimulationFormPage 3-tab layout) + +**Frontend only** (253 vitest passing) + +- `frontend/src/components/Tabs.tsx` — Added `disabled?: boolean` per-item: `aria-disabled`, `aria-controls`, native `disabled`, `.tab-underline-disabled` CSS, arrow-key skip logic. +- `frontend/src/styles/index.css` — New `.tab-underline-disabled` recipe (`text-graphite opacity-50 cursor-not-allowed`). +- `frontend/src/hooks/useHashTab.ts` — Added optional `validIds?: string[]` for hash fallback to defaultId when hash not in valid set. +- `frontend/src/pages/SimulationFormPage.tsx` — Full 3-tab refactor: Red Team / SOC / Tâche C2. RT fields in RT tab, SOC fields in SOC tab, `C2TasksPanel` in C2 tab. SOC tab disabled when `status ∉ {review_required, done}`. C2 tab disabled in create mode. Contextual sticky bar hides entirely on C2 tab. SOC-blocked `AlertBanner` removed; tab disabled state replaces it. `safeTab` guards against stale hash from previous navigation. +- `frontend/src/i18n/fr.json` — Added `simulation.form.tab.{redTeam,soc,c2}` keys. +- `DESIGN.md` — Documented disabled tab variant and `aria-controls` contract. +- `frontend/tests/SimulationFormPage.test.tsx` — Rewritten for tab-based layout: SOC-blocked-banner tests removed; tab disabled-state tests, SOC-only field tests, C2 tab panel test, count pill test added. +- `frontend/tests/components/Tabs.test.tsx` — Added 5 disabled-state tests. + ### Changed — Sprint 12 (EngagementDetailPage 2-tab merge + full FR i18n) **Frontend only** (245 vitest passing — baseline 233 + 12 new i18n smoke tests) diff --git a/DESIGN.md b/DESIGN.md index bb99a46..e60c20e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -225,7 +225,8 @@ Used inside pages that need content partitioning without a URL change (e.g. Enga - **`.tab-underline-active`**: `text-primary border-primary` — 2px primary underline, primary text. - **Count pill** (optional, e.g. simulation count): `.tab-count-pill` — `rounded-pill bg-cloud text-graphite text-[11px] px-xs py-0 font-mono`. `rounded-pill` is the documented exception. - **Active count pill**: `.tab-count-pill-active` — `bg-primary-soft text-primary`. -- ARIA: `role="tablist"` on container; `role="tab"` + `aria-selected` on each button. +- **Disabled variant**: `.tab-underline-disabled` — `text-steel cursor-not-allowed`. Uses the same `text-steel` token as `btn-outline:disabled` for system-wide consistency. Applied when `disabled?: boolean` is set on a `TabItem`. Overrides `.tab-underline` hover. ARIA: `aria-disabled="true"` + native `disabled` attribute. Arrow-key navigation skips disabled items. +- ARIA: `role="tablist"` on container; `role="tab"` + `aria-selected` + `aria-controls="tabpanel-{id}"` on each button. ### Data Tables diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx index 55d70f8..6cac399 100644 --- a/frontend/src/components/Tabs.tsx +++ b/frontend/src/components/Tabs.tsx @@ -4,6 +4,7 @@ interface TabItem { id: string; label: string; count?: number; + disabled?: boolean; } interface TabsProps { @@ -13,13 +14,23 @@ interface TabsProps { } export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element { + function nextEnabled(from: number, direction: 1 | -1): string { + const len = items.length; + let i = (from + direction + len) % len; + while (i !== from) { + if (!items[i].disabled) return items[i].id; + i = (i + direction + len) % len; + } + return items[from].id; + } + function handleKeyDown(e: KeyboardEvent, index: number) { if (e.key === 'ArrowRight') { e.preventDefault(); - onChange(items[(index + 1) % items.length].id); + onChange(nextEnabled(index, 1)); } else if (e.key === 'ArrowLeft') { e.preventDefault(); - onChange(items[(index - 1 + items.length) % items.length].id); + onChange(nextEnabled(index, -1)); } } @@ -30,6 +41,7 @@ export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element { > {items.map((item, index) => { const isActive = item.id === activeId; + const isDisabled = item.disabled ?? false; return ( - - {t('simulation.form.btn.cancel')} - - + +
+ + + {t('simulation.form.btn.cancel')} + +
); } - const submitting = - updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending; - return (
@@ -297,256 +348,257 @@ export function SimulationFormPage(): JSX.Element {
- {/* Done banner */} + {/* Done banner — above tabs, global status info */} {isDone && ( {t('simulation.form.banner.done')} )} - {/* SOC banner */} - {socBlocked && ( -
- - {t('simulation.form.banner.socNotReady')} - -
- )} + - {/* 2-column grid: RT+tasks left, SOC right. Stacks vertically below lg. */} -
- {/* Left column: RT card + C2 tasks panel */} -
- {/* Red Team card */} -
e.preventDefault()} - noValidate - className="card-product flex flex-col gap-md" - > -

{t('simulation.form.header.redTeam')}

+
+ {/* ── Red Team tab ──────────────────────────────────────── */} + {safeTab === 'red-team' && ( + e.preventDefault()} + noValidate + className="card-product flex flex-col gap-md" + > + + setRt({ ...rt, name: e.target.value })} + disabled={rtDisabled} + required + /> + - - setRt({ ...rt, name: e.target.value })} - disabled={rtDisabled} - required - /> - - -
- {t('simulation.form.field.mitre')} - -
- - -