feat(frontend): Spectrum UX port — Tabs + AlertBanner + BackLink + compact tables (sprint 11) #13
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user