feat(web): consolidate split-pane into list-detail-shell as canonical master-detail layout [SPRINT-030]

Extend ListDetailShellComponent with collapsible toggle button, detail panel
slide-in animation, and accessibility roles (complementary, aria-controls,
focus-visible). Adopt on signing-key-dashboard (trust-admin) for side-by-side
key list + detail browsing. Deprecate SplitPaneComponent. Add 15 focused
component tests covering rendering, toggle behavior, and accessibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
master
2026-03-08 22:58:06 +02:00
parent d7c3d5ad62
commit ce59f66e97
7 changed files with 362 additions and 38 deletions

View File

@@ -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: `
<div class="key-dashboard">
@@ -86,8 +87,14 @@ import { KeyRotationWizardComponent } from './key-rotation-wizard.component';
}
</div>
<!-- Keys Table -->
<div class="key-dashboard__table-container">
<!-- Keys Table + Detail Shell -->
<app-list-detail-shell
[detailVisible]="!!selectedKey()"
[collapsible]="true"
detailWidth="28rem"
(detailClosed)="selectedKey.set(null)"
>
<div shell-primary class="key-dashboard__table-container">
@if (loading()) {
<div class="key-dashboard__loading">Loading signing keys...</div>
} @else if (error()) {
@@ -242,17 +249,20 @@ import { KeyRotationWizardComponent } from './key-rotation-wizard.component';
}
</div>
<!-- Detail Panel -->
@if (selectedKey()) {
<app-key-detail-panel
[key]="selectedKey()!"
(close)="selectedKey.set(null)"
(rotateKey)="openRotationWizard($event)"
(revokeKey)="onRevokeKeyById($event)"
></app-key-detail-panel>
}
<!-- Detail Panel (projected into shell-detail slot) -->
@if (selectedKey()) {
<aside shell-detail>
<app-key-detail-panel
[key]="selectedKey()!"
(close)="selectedKey.set(null)"
(rotateKey)="openRotationWizard($event)"
(revokeKey)="onRevokeKeyById($event)"
></app-key-detail-panel>
</aside>
}
</app-list-detail-shell>
<!-- Rotation Wizard -->
<!-- Rotation Wizard (outside shell — overlays both panes) -->
@if (rotatingKey()) {
<app-key-rotation-wizard
[key]="rotatingKey()!"

View File

@@ -0,0 +1,176 @@
/**
* @file list-detail-shell.component.spec.ts
* @sprint SPRINT_20260308_030_FE_split_pane_list_detail_shell_consolidation (FE-SPL-004)
* @description Unit tests for the canonical ListDetailShellComponent
*/
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ListDetailShellComponent } from './list-detail-shell.component';
/**
* Test host that wraps ListDetailShellComponent with projected content.
*/
@Component({
standalone: true,
imports: [ListDetailShellComponent],
template: `
<app-list-detail-shell
[detailVisible]="detailVisible"
[detailWidth]="detailWidth"
[collapsible]="collapsible"
(detailClosed)="onDetailClosed()"
>
<div shell-primary data-testid="primary">Primary content</div>
<div shell-detail data-testid="detail">Detail content</div>
</app-list-detail-shell>
`,
})
class TestHostComponent {
detailVisible = false;
detailWidth = '24rem';
collapsible = false;
detailClosedCount = 0;
onDetailClosed(): void {
this.detailClosedCount++;
this.detailVisible = false;
}
}
describe('ListDetailShellComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
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');
});
});

View File

@@ -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';
<ng-content select="[shell-primary]"></ng-content>
</div>
@if (detailVisible && collapsible) {
<button
type="button"
class="list-detail-shell__toggle"
(click)="onToggleDetail()"
[attr.aria-label]="'Close detail panel'"
aria-controls="list-detail-shell-detail"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
}
@if (detailVisible) {
<div class="list-detail-shell__detail">
<div
class="list-detail-shell__detail"
id="list-detail-shell-detail"
role="complementary"
[attr.aria-label]="'Detail panel'"
>
<ng-content select="[shell-detail]"></ng-content>
</div>
}
@@ -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<void>();
onToggleDetail(): void {
this.detailClosed.emit();
}
}

View File

@@ -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';