feat(frontend): Spectrum UX port — Tabs + AlertBanner + BackLink + compact tables (sprint 11) #13
@@ -1,3 +1,5 @@
|
|||||||
|
import type { KeyboardEvent } from 'react';
|
||||||
|
|
||||||
interface TabItem {
|
interface TabItem {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -11,12 +13,22 @@ interface TabsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
role="tablist"
|
role="tablist"
|
||||||
className="flex items-end border-b border-hairline gap-xs"
|
className="flex items-end border-b border-hairline gap-xs"
|
||||||
>
|
>
|
||||||
{items.map((item) => {
|
{items.map((item, index) => {
|
||||||
const isActive = item.id === activeId;
|
const isActive = item.id === activeId;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -27,6 +39,7 @@ export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element {
|
|||||||
aria-controls={`tabpanel-${item.id}`}
|
aria-controls={`tabpanel-${item.id}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(item.id)}
|
onClick={() => onChange(item.id)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||||
className={`tab-underline${isActive ? ' tab-underline-active' : ''} pb-xs`}
|
className={`tab-underline${isActive ? ' tab-underline-active' : ''} pb-xs`}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export function useHashTab(defaultId: string): [string, (id: string) => void] {
|
|||||||
}, [defaultId]);
|
}, [defaultId]);
|
||||||
|
|
||||||
const navigate = useCallback((id: string) => {
|
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);
|
setActiveId(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { BackLink } from '@/components/BackLink';
|
|||||||
import { Tabs } from '@/components/Tabs';
|
import { Tabs } from '@/components/Tabs';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
type TabId = 'schedule' | 'description' | 'simulations';
|
||||||
|
|
||||||
export function EngagementDetailPage(): JSX.Element {
|
export function EngagementDetailPage(): JSX.Element {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const numericId = id ? Number(id) : undefined;
|
const numericId = id ? Number(id) : undefined;
|
||||||
@@ -21,7 +23,8 @@ export function EngagementDetailPage(): JSX.Element {
|
|||||||
const detail = useEngagement(numericId);
|
const detail = useEngagement(numericId);
|
||||||
const simsQuery = useEngagementSimulations(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.isLoading) return <LoadingState label="Loading engagement…" />;
|
||||||
if (detail.isError) {
|
if (detail.isError) {
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ export function UsersAdminPage(): JSX.Element {
|
|||||||
</tr>
|
</tr>
|
||||||
{resetOpen === u.id ? (
|
{resetOpen === u.id ? (
|
||||||
<tr className="border-b border-hairline last:border-0 bg-cloud">
|
<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">
|
<td colSpan={4} className="px-xl py-md">
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => onResetPassword(u, e)}
|
onSubmit={(e) => onResetPassword(u, e)}
|
||||||
|
|||||||
@@ -59,6 +59,20 @@ describe('Tabs', () => {
|
|||||||
expect(simsBtn).toHaveAttribute('aria-controls', 'tabpanel-simulations');
|
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)', () => {
|
it('has no rounded-md, transition-*, or shadow-* on tab buttons (brutalism)', () => {
|
||||||
render(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
|
render(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
|
||||||
for (const btn of screen.getAllByRole('tab')) {
|
for (const btn of screen.getAllByRole('tab')) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe('useHashTab', () => {
|
|||||||
expect(result.current[0]).toBe('simulations');
|
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'));
|
const { result } = renderHook(() => useHashTab('schedule'));
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current[1]('description');
|
result.current[1]('description');
|
||||||
|
|||||||
Reference in New Issue
Block a user