feat(frontend): Spectrum UX port — Tabs + AlertBanner + BackLink + compact tables (sprint 11) #13
43
frontend/src/components/Tabs.tsx
Normal file
43
frontend/src/components/Tabs.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
interface TabItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabsProps {
|
||||||
|
items: TabItem[];
|
||||||
|
activeId: string;
|
||||||
|
onChange: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
className="flex items-end border-b border-hairline gap-xs"
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const isActive = item.id === activeId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(item.id)}
|
||||||
|
className={`tab-underline${isActive ? ' tab-underline-active' : ''} pb-xs`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
{item.count !== undefined ? (
|
||||||
|
<span
|
||||||
|
className={`ml-xxs tab-count-pill${isActive ? ' tab-count-pill-active' : ''}`}
|
||||||
|
>
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/src/hooks/useHashTab.ts
Normal file
25
frontend/src/hooks/useHashTab.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
function readHash(defaultId: string): string {
|
||||||
|
const hash = window.location.hash.slice(1); // strip leading '#'
|
||||||
|
return hash || defaultId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHashTab(defaultId: string): [string, (id: string) => void] {
|
||||||
|
const [activeId, setActiveId] = useState<string>(() => readHash(defaultId));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onHashChange() {
|
||||||
|
setActiveId(readHash(defaultId));
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', onHashChange);
|
||||||
|
return () => window.removeEventListener('hashchange', onHashChange);
|
||||||
|
}, [defaultId]);
|
||||||
|
|
||||||
|
const navigate = useCallback((id: string) => {
|
||||||
|
window.location.hash = id;
|
||||||
|
setActiveId(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [activeId, navigate];
|
||||||
|
}
|
||||||
@@ -147,4 +147,48 @@
|
|||||||
.tag-mitre {
|
.tag-mitre {
|
||||||
@apply inline-flex items-center bg-cloud text-ink border border-hairline rounded-none px-2 py-[2px] font-mono text-[12px] leading-[1.33];
|
@apply inline-flex items-center bg-cloud text-ink border border-hairline rounded-none px-2 py-[2px] font-mono text-[12px] leading-[1.33];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Sub-page tabs (L1) ─────────────────────────────────────────────── */
|
||||||
|
.tab-underline {
|
||||||
|
@apply text-graphite caption-bold cursor-pointer border-b-2 border-transparent hover:text-ink px-xs;
|
||||||
|
}
|
||||||
|
.tab-underline-active {
|
||||||
|
@apply text-primary border-primary;
|
||||||
|
}
|
||||||
|
/* Count pill — rounded-pill is the allowed exception per DESIGN.md */
|
||||||
|
.tab-count-pill {
|
||||||
|
@apply inline-flex items-center rounded-pill bg-cloud text-graphite text-[11px] px-xs py-0 font-mono;
|
||||||
|
}
|
||||||
|
.tab-count-pill-active {
|
||||||
|
@apply bg-primary-soft text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Inline alert banners (L2) ─────────────────────────────────────── */
|
||||||
|
.alert-error {
|
||||||
|
@apply bg-paper border border-hairline border-l-4 border-l-bloom-deep rounded-none px-md py-sm flex items-start gap-sm;
|
||||||
|
}
|
||||||
|
.alert-warn {
|
||||||
|
@apply bg-paper border border-hairline border-l-4 border-l-warn rounded-none px-md py-sm flex items-start gap-sm;
|
||||||
|
}
|
||||||
|
.alert-success {
|
||||||
|
@apply bg-paper border border-hairline border-l-4 border-l-success rounded-none px-md py-sm flex items-start gap-sm;
|
||||||
|
}
|
||||||
|
.alert-info {
|
||||||
|
@apply bg-paper border border-hairline border-l-4 border-l-primary rounded-none px-md py-sm flex items-start gap-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Compact table density (L4) — 32px row height, global ──────────── */
|
||||||
|
.table-compact thead tr {
|
||||||
|
@apply text-[11px] uppercase tracking-[0.5px] text-graphite;
|
||||||
|
}
|
||||||
|
.table-compact th {
|
||||||
|
@apply px-md py-xxs;
|
||||||
|
}
|
||||||
|
.table-compact td {
|
||||||
|
@apply px-md py-xxs caption-md;
|
||||||
|
}
|
||||||
|
.table-compact tbody tr {
|
||||||
|
@apply border-b border-hairline last:border-0;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user