Files
nxdns/admin/src/ui/Tabs.tsx
T
mokhtar 7e0df5fd94
Gates / frontend (push) Successful in 1m22s
Gates / test (push) Successful in 1m54s
Gates / test-aarch64 (push) Successful in 7m57s
Gates / package (push) Successful in 5m29s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 32m51s
milestone 32: task-shaped configuration, file mode as a rendering, config status api
2026-08-22 22:42:50 +02:00

116 lines
3.1 KiB
TypeScript

/**
* The tab switcher (milestone 23, ruling 4).
*
* The hand-rolled version spelled the ARIA attributes by hand but had no
* keyboard navigation; React Aria brings arrow-key movement and roving
* tabindex with the same roles. State stays inside RAC — no caller needs to
* read which tab is open.
*
* RAC exposes state as render-prop booleans, so every rule below is a
* boolean-guarded style object: StyleX cannot express `[data-selected]`.
*/
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Tab, TabList, TabPanel, Tabs as AriaTabs } from "react-aria-components";
import { colors } from "./tokens.stylex";
export interface TabSpec {
id: string;
label: string;
content: ReactNode;
}
interface Props {
/** The tab list's accessible name. */
label: string;
tabs: readonly TabSpec[];
/**
* The open tab, when a caller owns the selection — the configuration pages
* keep it in the URL. Omit both of these to let RAC hold the state, which
* is what a tab set with no linkable identity wants.
*/
selectedKey?: string;
onSelectionChange?: (key: string) => void;
}
const styles = stylex.create({
list: {
display: "flex",
gap: "0.5rem",
marginTop: "1rem",
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
tab: {
marginBottom: -1,
borderBottomWidth: 2,
borderBottomStyle: "solid",
borderBottomColor: "transparent",
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontWeight: 500,
color: colors.textMuted,
cursor: "pointer",
},
tabSelected: {
borderBottomColor: colors.primary,
color: colors.primaryOnSurface,
},
tabHovered: {
color: colors.text,
},
/**
* The milestone-9 focus floor. A Tab is a `div` with a roving tabindex, so
* the ring is driven by RAC's `isFocusVisible` rather than `:focus-visible`.
*/
tabFocusVisible: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
panel: {
outlineStyle: "none",
},
/** Explicit, so RAC's default `react-aria-Tabs` class does not land instead. */
root: {
display: "block",
},
});
export default function Tabs({ label, tabs, selectedKey, onSelectionChange }: Props) {
return (
<AriaTabs
selectedKey={selectedKey}
onSelectionChange={onSelectionChange === undefined ? undefined : (key) => onSelectionChange(String(key))}
className={() => stylex.props(styles.root).className ?? ""}
>
<TabList aria-label={label} className={() => stylex.props(styles.list).className ?? ""}>
{tabs.map((tab) => (
<Tab
key={tab.id}
id={tab.id}
className={({ isSelected, isHovered, isFocusVisible }) =>
stylex.props(
styles.tab,
isHovered && !isSelected && styles.tabHovered,
isSelected && styles.tabSelected,
isFocusVisible && styles.tabFocusVisible,
).className ?? ""
}
>
{tab.label}
</Tab>
))}
</TabList>
{tabs.map((tab) => (
<TabPanel key={tab.id} id={tab.id} className={() => stylex.props(styles.panel).className ?? ""}>
{tab.content}
</TabPanel>
))}
</AriaTabs>
);
}