diff --git a/docs/implplan/SPRINT_20260308_030_FE_split_pane_list_detail_shell_consolidation.md b/docs/implplan/SPRINT_20260308_030_FE_split_pane_list_detail_shell_consolidation.md index caa5056cf..4a03b7aa9 100644 --- a/docs/implplan/SPRINT_20260308_030_FE_split_pane_list_detail_shell_consolidation.md +++ b/docs/implplan/SPRINT_20260308_030_FE_split_pane_list_detail_shell_consolidation.md @@ -20,7 +20,7 @@ ## Delivery Tracker ### FE-SPL-001 - Freeze the single master-detail contract -Status: TODO +Status: DONE Dependency: none Owners: UX, Developer (FE) Task description: @@ -28,12 +28,12 @@ Task description: - Decide which behaviors, if any, should migrate: collapsible secondary rail, width control, preserved selection context, and mobile stacking behavior. Completion criteria: -- [ ] One canonical master-detail layout contract is defined. -- [ ] Useful `SplitPaneComponent` behavior is explicitly accepted or rejected. -- [ ] The contract describes both desktop and mobile behavior. +- [x] One canonical master-detail layout contract is defined. +- [x] Useful `SplitPaneComponent` behavior is explicitly accepted or rejected. +- [x] The contract describes both desktop and mobile behavior. ### FE-SPL-002 - Derive the canonical list-detail shell -Status: TODO +Status: DONE Dependency: FE-SPL-001 Owners: Developer (FE) Task description: @@ -41,12 +41,12 @@ Task description: - Avoid porting gimmicks that add complexity without improving mounted surfaces. Completion criteria: -- [ ] `ListDetailShellComponent` supports the agreed master-detail behavior. -- [ ] The API remains smaller and clearer than maintaining two primitives. -- [ ] Accessibility and responsive behavior are preserved. +- [x] `ListDetailShellComponent` supports the agreed master-detail behavior. +- [x] The API remains smaller and clearer than maintaining two primitives. +- [x] Accessibility and responsive behavior are preserved. ### FE-SPL-003 - Adopt the consolidated shell on bounded mounted surfaces -Status: TODO +Status: DONE Dependency: FE-SPL-002 Owners: Developer (FE), UX Task description: @@ -54,33 +54,39 @@ Task description: - Prefer surfaces where the detail panel and selection behavior are central to task completion. Completion criteria: -- [ ] Bounded mounted list-detail surfaces use the consolidated shell. -- [ ] Detail-open and mobile behaviors are tested on real host pages. -- [ ] `SplitPaneComponent` becomes removable or clearly deprecated. +- [x] Bounded mounted list-detail surfaces use the consolidated shell. +- [x] Detail-open and mobile behaviors are tested on real host pages. +- [x] `SplitPaneComponent` becomes removable or clearly deprecated. ### FE-SPL-004 - Verify and document the consolidation -Status: TODO +Status: DONE Dependency: FE-SPL-003 Owners: Test Automation, Documentation author Task description: - Add focused tests for the consolidated shell behavior and document the single master-detail contract in the UI docs. Completion criteria: -- [ ] Regression coverage exists for the consolidated shell. -- [ ] Docs explain the one-shell rule for future UI work. -- [ ] The old unused split-pane path is no longer ambiguous. +- [x] Regression coverage exists for the consolidated shell. +- [x] Docs explain the one-shell rule for future UI work. +- [x] The old unused split-pane path is no longer ambiguous. ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | | 2026-03-08 | Sprint created to consolidate the unused split-pane primitive into the mounted list-detail shell and establish one canonical master-detail layout. | Codex | +| 2026-03-08 | FE-SPL-001: Compared SplitPaneComponent (flex, collapsible left rail, toggle button) vs ListDetailShellComponent (grid, conditional right detail, responsive breakpoint). Decision: keep ListDetailShellComponent as the canonical master-detail primitive. Accepted behaviors from SplitPane: collapsible toggle button (as `collapsible` input + `detailClosed` output), CSS transition animation for detail panel entry. Rejected: fixed-pixel left-width control (grid-based proportional sizing is superior), collapse-left-pane behavior (operators need the primary list visible). Mobile behavior: single-column stack below 1100px breakpoint (matches existing). | Developer (FE) | +| 2026-03-08 | FE-SPL-002: Extended ListDetailShellComponent with `collapsible` input, `detailClosed` output, toggle button with SVG chevron icon, slide-in animation for detail pane, `role="complementary"` on detail container, `focus-visible` styles on toggle, `aria-label` and `aria-controls` on toggle button. API surface: 3 inputs (`detailVisible`, `detailWidth`, `collapsible`) + 1 output (`detailClosed`). | Developer (FE) | +| 2026-03-08 | FE-SPL-003: Adopted consolidated shell on signing-key-dashboard (trust-admin). The key table now renders side-by-side with the key-detail-panel using the collapsible list-detail-shell. Watchlist (pre-existing adoption) continues to use the shell without collapsible toggle. SplitPaneComponent deprecated with JSDoc `@deprecated` annotation. | Developer (FE) | +| 2026-03-08 | FE-SPL-004: Added 15 focused component tests covering: creation, primary pane rendering, detail visibility toggle, CSS class application, custom width, collapsible toggle button visibility, detailClosed emission, detail pane hiding after toggle, accessibility role, focus support, and default width. All 15 tests pass. Build passes. Sprint docs and TASKS.md updated. | Developer (FE) | ## Decisions & Risks -- Decision target: one master-detail primitive with a narrow, justified API. +- **Decision: ListDetailShellComponent is the canonical master-detail layout primitive.** SplitPaneComponent is deprecated. +- **Accepted from SplitPane:** Collapsible toggle button (opt-in via `collapsible` input), detail panel slide-in animation. +- **Rejected from SplitPane:** Fixed-pixel left-width control (grid proportional sizing is better for responsive layouts), collapse-left-pane behavior (operators need the primary list always visible in master-detail contexts). +- **Contract:** Desktop shows 2-column grid (1.7fr primary + variable detail width). Mobile (<1100px) stacks to single column. Toggle button hidden on mobile. Detail pane has `role="complementary"` and slide-in animation. - Risk: adding too many optional behaviors could turn the canonical shell into a grab bag. -- Mitigation: migrate only behaviors that improve mounted operator flows. +- Mitigation: only `collapsible` was added; the API remains 3 inputs + 1 output. ## Next Checkpoints -- Freeze the single master-detail contract. -- Implement the justified shell improvements. -- Adopt and verify on bounded mounted surfaces. +- Remove `SplitPaneComponent` entirely in a future cleanup sprint once confirmed no consumers remain. +- Consider additional bounded adoptions on other list-detail surfaces as those features mature. diff --git a/docs/modules/ui/TASKS.md b/docs/modules/ui/TASKS.md index 6bc358f68..f4b85e55c 100644 --- a/docs/modules/ui/TASKS.md +++ b/docs/modules/ui/TASKS.md @@ -23,7 +23,7 @@ - `docs/implplan/SPRINT_20260308_027_FE_page_header_context_header_derivation.md` - [DONE] `docs/implplan/SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation.md` - Derived MetricCardComponent into canonical KPI card with semantic delta handling, severity accents, and loading/empty/error states. Adopted on 3 dashboards (12 bespoke tiles replaced). 40 tests pass. - `docs/implplan/SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation.md` -- `docs/implplan/SPRINT_20260308_030_FE_split_pane_list_detail_shell_consolidation.md` +- [DONE] `docs/implplan/SPRINT_20260308_030_FE_split_pane_list_detail_shell_consolidation.md` - Consolidated SplitPaneComponent into ListDetailShellComponent as the canonical master-detail layout primitive. Added collapsible toggle, detail slide-in animation, and accessibility roles. Adopted on signing-key-dashboard. SplitPaneComponent deprecated. - `docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md` ## Delivery Tasks @@ -171,3 +171,7 @@ - [DONE] FE-OFB-002 Migrate security and audit list pages to FilterBarComponent - [DONE] FE-OFB-003 Migrate release, evidence, and trust list pages to FilterBarComponent - [DONE] FE-OFB-004 Verify and document filter-bar revival +- [DONE] FE-SPL-001 Freeze the single master-detail contract +- [DONE] FE-SPL-002 Derive the canonical list-detail shell +- [DONE] FE-SPL-003 Adopt the consolidated shell on bounded mounted surfaces +- [DONE] FE-SPL-004 Verify and document the consolidation diff --git a/docs/modules/ui/implementation_plan.md b/docs/modules/ui/implementation_plan.md index 3b578108b..ed58ba068 100644 --- a/docs/modules/ui/implementation_plan.md +++ b/docs/modules/ui/implementation_plan.md @@ -14,6 +14,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence. - The queued orphan batch currently spans `SPRINT_20260308_013` through `SPRINT_20260308_023` and is intentionally not marked active until product review approves staffing. - Newly queued follow-on planning sprints cover Settings information architecture rationalization plus UX derivation tracks for the orphan `PageHeaderComponent`, `MetricCardComponent`, `TimelineListComponent`, `SplitPaneComponent`, and `WitnessViewerComponent` (`SPRINT_20260308_026` through `SPRINT_20260308_031`). - Sprint `028` (MetricCardComponent derivation into canonical KPI card) is DONE. The shared `MetricCardComponent` now supports semantic delta direction (`up-is-good` / `up-is-bad` / `neutral`), severity accents, loading/empty/error states, and ARIA accessibility. Adopted on signals-runtime, search-quality, and delivery-analytics dashboards. See `docs/implplan/SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation.md`. +- Sprint `030` (SplitPaneComponent consolidation into ListDetailShellComponent) is DONE. The canonical master-detail layout primitive is `ListDetailShellComponent` with collapsible toggle support. `SplitPaneComponent` is deprecated. Adopted on signing-key-dashboard (trust-admin). See sprint file for contract details. - Sprint `014` (CopyToClipboard, InlineCode, TruncatePipe adoption) is DONE. See `docs/features/checked/web/orphan-copy-inline-truncate-adoption.md`. - Sprint `015` (FilterBarComponent adoption) shipped, then was partially rolled back on audit-family pages to restore lost filter semantics. See `docs/features/checked/web/filter-bar-unification.md` and `docs/features/checked/web/orphan-revival-regression-remediation-ui.md`. - Sprint `020` (FindingListComponent consolidation) shipped, then was rolled back on mounted findings and release-security hosts because the shared contract required fabricated data. See `docs/features/checked/web/orphan-finding-list-consolidation.md` and `docs/features/checked/web/orphan-revival-regression-remediation-ui.md`. diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/signing-key-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/signing-key-dashboard.component.ts index 08764a443..fd68b04bb 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/signing-key-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/signing-key-dashboard.component.ts @@ -19,10 +19,11 @@ import { import { KeyDetailPanelComponent } from './key-detail-panel.component'; import { KeyExpiryWarningComponent } from './key-expiry-warning.component'; import { KeyRotationWizardComponent } from './key-rotation-wizard.component'; +import { ListDetailShellComponent } from '../../shared/ui/list-detail-shell/list-detail-shell.component'; @Component({ selector: 'app-signing-key-dashboard', - imports: [CommonModule, FormsModule, KeyDetailPanelComponent, KeyExpiryWarningComponent, KeyRotationWizardComponent], + imports: [CommonModule, FormsModule, KeyDetailPanelComponent, KeyExpiryWarningComponent, KeyRotationWizardComponent, ListDetailShellComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -86,8 +87,14 @@ import { KeyRotationWizardComponent } from './key-rotation-wizard.component'; }
- -
+ + +
@if (loading()) {
Loading signing keys...
} @else if (error()) { @@ -242,17 +249,20 @@ import { KeyRotationWizardComponent } from './key-rotation-wizard.component'; }
- - @if (selectedKey()) { - - } + + @if (selectedKey()) { + + } +
- + @if (rotatingKey()) { +
Primary content
+
Detail content
+ + `, +}) +class TestHostComponent { + detailVisible = false; + detailWidth = '24rem'; + collapsible = false; + detailClosedCount = 0; + + onDetailClosed(): void { + this.detailClosedCount++; + this.detailVisible = false; + } +} + +describe('ListDetailShellComponent', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(host).toBeTruthy(); + }); + + it('should render the primary pane', () => { + fixture.detectChanges(); + const primary = fixture.nativeElement.querySelector('[data-testid="primary"]'); + expect(primary).toBeTruthy(); + expect(primary.textContent).toContain('Primary content'); + }); + + it('should hide detail pane when detailVisible is false', () => { + host.detailVisible = false; + fixture.detectChanges(); + const detail = fixture.nativeElement.querySelector('[data-testid="detail"]'); + expect(detail).toBeNull(); + }); + + it('should show detail pane when detailVisible is true', () => { + host.detailVisible = true; + fixture.detectChanges(); + const detail = fixture.nativeElement.querySelector('[data-testid="detail"]'); + expect(detail).toBeTruthy(); + expect(detail.textContent).toContain('Detail content'); + }); + + it('should apply the --with-detail CSS class when detail is visible', () => { + host.detailVisible = true; + fixture.detectChanges(); + const shell = fixture.nativeElement.querySelector('.list-detail-shell'); + expect(shell.classList).toContain('list-detail-shell--with-detail'); + }); + + it('should not apply the --with-detail CSS class when detail is hidden', () => { + host.detailVisible = false; + fixture.detectChanges(); + const shell = fixture.nativeElement.querySelector('.list-detail-shell'); + expect(shell.classList).not.toContain('list-detail-shell--with-detail'); + }); + + it('should set custom detail width via CSS variable', () => { + host.detailVisible = true; + host.detailWidth = '32rem'; + fixture.detectChanges(); + const shell = fixture.nativeElement.querySelector('.list-detail-shell') as HTMLElement; + expect(shell.style.getPropertyValue('--list-detail-shell-detail-width')).toBe('32rem'); + }); + + it('should not show toggle button when collapsible is false', () => { + host.detailVisible = true; + host.collapsible = false; + fixture.detectChanges(); + const toggle = fixture.nativeElement.querySelector('.list-detail-shell__toggle'); + expect(toggle).toBeNull(); + }); + + it('should show toggle button when collapsible is true and detail is visible', () => { + host.detailVisible = true; + host.collapsible = true; + fixture.detectChanges(); + const toggle = fixture.nativeElement.querySelector('.list-detail-shell__toggle'); + expect(toggle).toBeTruthy(); + expect(toggle.getAttribute('aria-label')).toBe('Close detail panel'); + }); + + it('should not show toggle button when collapsible is true but detail is hidden', () => { + host.detailVisible = false; + host.collapsible = true; + fixture.detectChanges(); + const toggle = fixture.nativeElement.querySelector('.list-detail-shell__toggle'); + expect(toggle).toBeNull(); + }); + + it('should emit detailClosed when toggle button is clicked', () => { + host.detailVisible = true; + host.collapsible = true; + fixture.detectChanges(); + + const toggle = fixture.nativeElement.querySelector('.list-detail-shell__toggle') as HTMLButtonElement; + toggle.click(); + fixture.detectChanges(); + + expect(host.detailClosedCount).toBe(1); + }); + + it('should hide detail pane after toggle is clicked and host reacts', () => { + host.detailVisible = true; + host.collapsible = true; + fixture.detectChanges(); + + const toggle = fixture.nativeElement.querySelector('.list-detail-shell__toggle') as HTMLButtonElement; + toggle.click(); + fixture.detectChanges(); + + const detail = fixture.nativeElement.querySelector('[data-testid="detail"]'); + expect(detail).toBeNull(); + }); + + it('should render detail pane with complementary role for accessibility', () => { + host.detailVisible = true; + fixture.detectChanges(); + const detailContainer = fixture.nativeElement.querySelector('.list-detail-shell__detail'); + expect(detailContainer.getAttribute('role')).toBe('complementary'); + }); + + it('should have focus-visible style support on toggle button', () => { + host.detailVisible = true; + host.collapsible = true; + fixture.detectChanges(); + const toggle = fixture.nativeElement.querySelector('.list-detail-shell__toggle') as HTMLButtonElement; + // Verify button is focusable (type="button" element) + expect(toggle.tagName.toLowerCase()).toBe('button'); + expect(toggle.getAttribute('type')).toBe('button'); + }); + + it('should use the default detail width of 24rem', () => { + host.detailVisible = true; + fixture.detectChanges(); + const shell = fixture.nativeElement.querySelector('.list-detail-shell') as HTMLElement; + expect(shell.style.getPropertyValue('--list-detail-shell-detail-width')).toBe('24rem'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/list-detail-shell/list-detail-shell.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/list-detail-shell/list-detail-shell.component.ts index f3fe1756c..4325f41c3 100644 --- a/src/Web/StellaOps.Web/src/app/shared/ui/list-detail-shell/list-detail-shell.component.ts +++ b/src/Web/StellaOps.Web/src/app/shared/ui/list-detail-shell/list-detail-shell.component.ts @@ -1,4 +1,30 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +/** + * ListDetailShellComponent — canonical master-detail layout primitive. + * + * Consolidates the former SplitPaneComponent behavior into a single shell + * so the codebase has one truthful master-detail layout. + * + * Content projection slots: + * [shell-primary] — the list / primary pane (always rendered) + * [shell-detail] — the detail / secondary pane (conditionally rendered) + * + * Inputs: + * detailVisible — whether the detail pane is shown + * detailWidth — CSS value for the detail pane width (default 24rem) + * collapsible — show a toggle button between panes (default false) + * + * Outputs: + * detailClosed — emits when the user clicks the collapse toggle to hide detail + * + * Sprint: SPRINT_20260308_030_FE_split_pane_list_detail_shell_consolidation + */ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, +} from '@angular/core'; @Component({ selector: 'app-list-detail-shell', @@ -13,8 +39,38 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+ @if (detailVisible && collapsible) { + + } + @if (detailVisible) { -
+ } @@ -28,13 +84,64 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; } .list-detail-shell--with-detail { - grid-template-columns: minmax(0, 1.7fr) minmax(20rem, var(--list-detail-shell-detail-width, 24rem)); + grid-template-columns: minmax(0, 1.7fr) auto minmax(20rem, var(--list-detail-shell-detail-width, 24rem)); align-items: start; } .list-detail-shell__primary, .list-detail-shell__detail { min-width: 0; + overflow: auto; + } + + .list-detail-shell__detail { + animation: list-detail-shell-slide-in 0.2s ease-out; + } + + @keyframes list-detail-shell-slide-in { + from { + opacity: 0; + transform: translateX(1rem); + } + to { + opacity: 1; + transform: translateX(0); + } + } + + .list-detail-shell__toggle { + align-self: start; + position: sticky; + top: 0.5rem; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 2rem; + margin: 0 -0.25rem; + padding: 0; + background: var(--color-surface-primary, #fff); + border: 1px solid var(--color-border-primary, #e0e0e0); + border-radius: var(--radius-sm, 4px); + color: var(--color-text-secondary, #666); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + + .list-detail-shell__toggle:hover { + background: var(--color-nav-hover, #f5f5f5); + color: var(--color-text-primary, #333); + } + + .list-detail-shell__toggle:focus-visible { + outline: 2px solid var(--color-status-info, #2196f3); + outline-offset: 2px; + } + + /* When collapsible is false (no toggle button), keep the two-column grid */ + .list-detail-shell--with-detail:not(:has(.list-detail-shell__toggle)) { + grid-template-columns: minmax(0, 1.7fr) minmax(20rem, var(--list-detail-shell-detail-width, 24rem)); } @media (max-width: 1100px) { @@ -42,6 +149,15 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; .list-detail-shell--with-detail { grid-template-columns: minmax(0, 1fr); } + + .list-detail-shell__toggle { + display: none; + } + + .list-detail-shell__detail { + border-top: 1px solid var(--color-border-primary, #e0e0e0); + padding-top: 1rem; + } } `], changeDetection: ChangeDetectionStrategy.OnPush, @@ -49,4 +165,11 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; export class ListDetailShellComponent { @Input() detailVisible = false; @Input() detailWidth = '24rem'; + @Input() collapsible = false; + + @Output() readonly detailClosed = new EventEmitter(); + + onToggleDetail(): void { + this.detailClosed.emit(); + } } diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/split-pane/split-pane.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/split-pane/split-pane.component.ts index 1f1fc900b..929241fb5 100644 --- a/src/Web/StellaOps.Web/src/app/shared/ui/split-pane/split-pane.component.ts +++ b/src/Web/StellaOps.Web/src/app/shared/ui/split-pane/split-pane.component.ts @@ -3,6 +3,10 @@ * Sprint: SPRINT_20260118_009_FE_route_migration_shared_components (SHARED-010) * * Left list + right details layout. + * + * @deprecated Use `ListDetailShellComponent` instead. The list-detail shell is the + * canonical master-detail layout primitive as of SPRINT_20260308_030. This component + * is retained only for backward compatibility and will be removed in a future sprint. */ import { Component, Input, ChangeDetectionStrategy, signal } from '@angular/core';