import type { ReactNode } from 'react' import { Tabs as BaseTabs } from '@base-ui/react/tabs' import { cn } from './cn' import { UnreadDot } from './UnreadDot' /** * A row of tabs. Ship's Tabs (2026-09-01), which was Peek's TopicTabs with * the topic-specific ids taken out; on Base UI Tabs since stage 1 of the * migration (2026-09-06), which is where the keyboard comes from: one Tab * stop for the row, arrow keys between the tabs. * * A selected tab is a neutral fill (bg-active), not the accent tint — * Katerina's ruling (2026-08-27), extended to every app (2026-09-01). Two * sizes: `default` is 14px, a little more room; `small` is Peek's own * geometry (its inline `fontSize: 12` was the caption token spelled by hand). * * A tab's count is the sidebar's number, not a chip (Katerina, 2026-09-01, * superseding the chip ruling of 2026-08-26): mono, caption size, muted, * tabular — the one way a number sits beside a label everywhere. A count of * `0` is still drawn ("Assigned to me 0" is an answer); an absent count draws * nothing. */ export interface TabDef { id: T label: string /** Shown as a muted mono number after the label. Absent draws nothing; 0 is drawn. */ count?: number /** 16px, stroke 1.5. */ icon?: ReactNode /** Something new behind this tab: the `UnreadDot` after the label, and "new" in the tab's accessible name. */ unread?: boolean } export interface TabsProps { tabs: TabDef[] active: T onChange: (id: T) => void /** `default` 14px; `small` 12px, the denser geometry. */ size?: 'default' | 'small' /** * Names the row for assistive tech — "Views", "Filters". * * A `tablist` with no name is announced as a bare "tab list", which says * nothing and says the same nothing twice on a page with two of them * (measured 2026-09-08: no name, and no way to give one). Where a visible * heading already names it, point at that with `aria-labelledby` instead. */ 'aria-label'?: string 'aria-labelledby'?: string /** Lands on the outer box, around the row. */ className?: string } export function Tabs({ tabs, active, onChange, size = 'default', className, ...aria }: TabsProps) { return ( { // Only a person's choice reaches the caller. Base UI also reports its // own fallbacks (an `active` that matches no tab), and those carry // `null`, which is not a T. if (details.reason === 'none') onChange(value as T) }} className={className} > {tabs.map((tab) => ( cn( 'flex cursor-pointer items-center transition-colors', // gap: default is Ship's 6px; small keeps Peek's original 4px, or // "small is Peek's geometry" stops being true. size === 'default' ? 'gap-1.5 rounded-md px-2 py-1 text-body-2' : 'gap-1 rounded px-1.5 py-0.5 text-caption', state.active ? 'bg-bg-active text-text-primary' : 'text-text-secondary hover:bg-bg-hover', ) } > {tab.icon} {/* Label and count share a baseline: a smaller text centred as a box (items-center) floats above the label's baseline — the digits read as riding high. Baseline alignment is what makes two sizes sit on one line. */} {tab.label} {tab.count !== undefined ? ( {tab.count} ) : null} {/* The dot without its 24px slot: a tab is a line of text, and the slot would make an unread tab taller than its neighbours. The disc says nothing to a screen reader, so the name says "new". */} {tab.unread ? ( <> new ) : null} ))} ) }