+
{children}
) : (
diff --git a/app/src/components/PaneTabs/PaneTabs.css b/app/src/components/PaneTabs/PaneTabs.css
index e600e623..cffb57dd 100644
--- a/app/src/components/PaneTabs/PaneTabs.css
+++ b/app/src/components/PaneTabs/PaneTabs.css
@@ -17,7 +17,10 @@
padding: var(--cc-space-3) var(--cc-space-5) var(--cc-space-4); /* side padding runs the underline wider than the label */
border: 0;
border-bottom: 2px solid transparent;
- margin-bottom: -1px; /* overlap the strip's baseline */
+ /* No negative margin to overlap the divider: overflow-x:auto forces this strip
+ * into a scroll container, and a 1px vertical overflow makes focusing a tab
+ * scroll it 1px, shifting the underline off the divider permanently. The
+ * underline sits flush above the divider instead. */
background: transparent;
color: var(--cc-text-secondary); /* AA: muted fails 4.5:1 on the modal (bg-sidebar) surface */
font: inherit;
diff --git a/app/src/components/PaneTabs/PaneTabs.tsx b/app/src/components/PaneTabs/PaneTabs.tsx
index 944f0dac..fce5cff1 100644
--- a/app/src/components/PaneTabs/PaneTabs.tsx
+++ b/app/src/components/PaneTabs/PaneTabs.tsx
@@ -17,12 +17,45 @@ export interface PaneTabsProps {
onSelect: (id: string) => void;
/** Extra class on the strip root for context-specific spacing (e.g. modal). */
class?: string;
+ /** id of the tabpanel these tabs control. Wires aria-controls on every tab
+ * and gives each tab a stable id (`${panelId}-tab-${tabId}`) so the panel
+ * can point its aria-labelledby back at the active tab. */
+ panelId?: string;
}
-export function PaneTabs({ tabs, active, onSelect, class: className }: PaneTabsProps) {
+export function PaneTabs({ tabs, active, onSelect, class: className, panelId }: PaneTabsProps) {
+ // Arrow keys move selection within the tablist (automatic activation, like a
+ // click), wrapping at the ends; Home/End jump to the first/last. Focus follows
+ // to the newly selected tab, which becomes the strip's single tab stop.
+ function onKeyDown(e: KeyboardEvent, idx: number) {
+ let next: number;
+ switch (e.key) {
+ case 'ArrowRight':
+ case 'ArrowDown':
+ next = (idx + 1) % tabs.length;
+ break;
+ case 'ArrowLeft':
+ case 'ArrowUp':
+ next = (idx - 1 + tabs.length) % tabs.length;
+ break;
+ case 'Home':
+ next = 0;
+ break;
+ case 'End':
+ next = tabs.length - 1;
+ break;
+ default:
+ return;
+ }
+ e.preventDefault();
+ onSelect(tabs[next].id);
+ const strip = (e.currentTarget as HTMLElement).parentElement;
+ (strip?.children[next] as HTMLElement | undefined)?.focus();
+ }
+
return (
- {tabs.map((t) => {
+ {tabs.map((t, idx) => {
const Icon = t.icon;
const selected = t.id === active;
return (
@@ -30,9 +63,13 @@ export function PaneTabs({ tabs, active, onSelect, class: className }: PaneTabsP
key={t.id}
type="button"
role="tab"
+ id={panelId ? `${panelId}-tab-${t.id}` : undefined}
aria-selected={selected ? 'true' : 'false'}
+ aria-controls={panelId}
+ tabIndex={selected ? 0 : -1}
class={selected ? 'pane-tab pane-tab--active focus-inset' : 'pane-tab focus-inset'}
onClick={() => onSelect(t.id)}
+ onKeyDown={(e) => onKeyDown(e, idx)}
>
{Icon &&
}
{t.label}
diff --git a/app/src/components/PathBreadcrumbs/PathBreadcrumbs.tsx b/app/src/components/PathBreadcrumbs/PathBreadcrumbs.tsx
index 6736a508..5adfa572 100644
--- a/app/src/components/PathBreadcrumbs/PathBreadcrumbs.tsx
+++ b/app/src/components/PathBreadcrumbs/PathBreadcrumbs.tsx
@@ -69,7 +69,11 @@ export function PathBreadcrumbs({
const isLeaf = i === crumbs.length - 1;
return (
- {i > 0 && }
+ {i > 0 && (
+
+ )}