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:
@@ -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()!"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user