105 lines
2.6 KiB
TypeScript
105 lines
2.6 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[];
|
|
}
|
|
|
|
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 }: Props) {
|
|
return (
|
|
<AriaTabs 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>
|
|
);
|
|
}
|