feat(frontend): Spectrum UX port — Tabs + AlertBanner + BackLink + compact tables (sprint 11) #13

Merged
knacky merged 11 commits from sprint/11-spectrum-ux into main 2026-06-22 08:25:38 +00:00
6 changed files with 36 additions and 4 deletions
Showing only changes of commit 11ce3cfb86 - Show all commits

View File

@@ -1,3 +1,5 @@
import type { KeyboardEvent } from 'react';
interface TabItem {
id: string;
label: string;
@@ -11,12 +13,22 @@ interface TabsProps {
}
export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element {
function handleKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
if (e.key === 'ArrowRight') {
e.preventDefault();
onChange(items[(index + 1) % items.length].id);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
onChange(items[(index - 1 + items.length) % items.length].id);
}
}
return (
<div
role="tablist"
className="flex items-end border-b border-hairline gap-xs"
>
{items.map((item) => {
{items.map((item, index) => {
const isActive = item.id === activeId;
return (
<button
@@ -27,6 +39,7 @@ export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element {
aria-controls={`tabpanel-${item.id}`}
type="button"
onClick={() => onChange(item.id)}
onKeyDown={(e) => handleKeyDown(e, index)}
className={`tab-underline${isActive ? ' tab-underline-active' : ''} pb-xs`}
>
{item.label}

View File

@@ -17,7 +17,8 @@ export function useHashTab(defaultId: string): [string, (id: string) => void] {
}, [defaultId]);
const navigate = useCallback((id: string) => {
window.location.hash = id;
// replaceState: no history entry (Back button unaffected), no anchor-jump scroll
history.replaceState(null, '', '#' + id);
setActiveId(id);
}, []);

View File

@@ -13,6 +13,8 @@ import { BackLink } from '@/components/BackLink';
import { Tabs } from '@/components/Tabs';
import { Link } from 'react-router-dom';
type TabId = 'schedule' | 'description' | 'simulations';
export function EngagementDetailPage(): JSX.Element {
const { id } = useParams<{ id: string }>();
const numericId = id ? Number(id) : undefined;
@@ -21,7 +23,8 @@ export function EngagementDetailPage(): JSX.Element {
const detail = useEngagement(numericId);
const simsQuery = useEngagementSimulations(numericId);
const [activeTab, setActiveTab] = useHashTab('schedule');
const [activeTabRaw, setActiveTab] = useHashTab('schedule');
const activeTab = activeTabRaw as TabId;
if (detail.isLoading) return <LoadingState label="Loading engagement…" />;
if (detail.isError) {

View File

@@ -248,6 +248,7 @@ export function UsersAdminPage(): JSX.Element {
</tr>
{resetOpen === u.id ? (
<tr className="border-b border-hairline last:border-0 bg-cloud">
{/* Aerated row — inline form needs room; compact-density tradeoff intentional */}
<td colSpan={4} className="px-xl py-md">
<form
onSubmit={(e) => onResetPassword(u, e)}

View File

@@ -59,6 +59,20 @@ describe('Tabs', () => {
expect(simsBtn).toHaveAttribute('aria-controls', 'tabpanel-simulations');
});
it('ArrowRight moves focus to the next tab', () => {
const onChange = vi.fn();
render(<Tabs items={ITEMS} activeId="schedule" onChange={onChange} />);
fireEvent.keyDown(screen.getByRole('tab', { name: /Schedule/i }), { key: 'ArrowRight' });
expect(onChange).toHaveBeenCalledWith('description');
});
it('ArrowLeft wraps around to the last tab', () => {
const onChange = vi.fn();
render(<Tabs items={ITEMS} activeId="schedule" onChange={onChange} />);
fireEvent.keyDown(screen.getByRole('tab', { name: /Schedule/i }), { key: 'ArrowLeft' });
expect(onChange).toHaveBeenCalledWith('simulations');
});
it('has no rounded-md, transition-*, or shadow-* on tab buttons (brutalism)', () => {
render(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
for (const btn of screen.getAllByRole('tab')) {

View File

@@ -22,7 +22,7 @@ describe('useHashTab', () => {
expect(result.current[0]).toBe('simulations');
});
it('navigate() updates activeId and sets location.hash', () => {
it('navigate() updates activeId and sets hash via replaceState (no history entry)', () => {
const { result } = renderHook(() => useHashTab('schedule'));
act(() => {
result.current[1]('description');