milestone 23 s2: react aria primitives and their cluster
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user