diff --git a/packages/propel/src/components/table/index.tsx b/packages/propel/src/components/table/index.tsx new file mode 100644 index 00000000..dc645804 --- /dev/null +++ b/packages/propel/src/components/table/index.tsx @@ -0,0 +1,482 @@ +import { ScrollArea as BaseScrollArea } from "@base-ui/react/scroll-area"; +import { cva, cx, type VariantProps } from "class-variance-authority"; +import { ChevronDown, ChevronsUpDown, ChevronUp, Ellipsis } from "lucide-react"; +import * as React from "react"; +import { scrollbarClass, scrollbarThumbClass } from "../../internal/scrollbar"; +import { Dropdown, DropdownTrigger } from "../dropdown/index"; + +// Figma "Table" node 5196-4084 ships two layouts that share the same cell metrics +// (38px header / 44px body, px-4 py-2, header on `background/layer/1`, rows on +// `background/layer/2`) and differ only in their dividers: +// • `table` — row dividers only (header underline + a hairline under each +// body row); no vertical rules. +// • `spreadsheet` — a full grid: every header and body cell draws a 0.5px +// `border/subtle` on all sides. +// The whole table sits inside a rounded, `border/subtle`-bordered, scrollable frame. +// The variant is owned by the root and read by the cells through context, so a +// consumer only sets it once on ``. + +export type TableVariant = "table" | "spreadsheet"; + +// Which edge a cell is pinned to while the table scrolls horizontally (the "make the +// first / last column sticky" option). `start` sticks to the inline-start edge, `end` +// to the inline-end edge. Set it on a column's header AND its body cells. +export type TablePinned = "start" | "end"; + +const TableVariantContext = React.createContext("table"); + +export type TableProps = Omit, "className" | "style"> & { + /** + * Layout (required). `table` draws row dividers only; `spreadsheet` draws a full + * grid (every cell bordered on all sides). Both share the same cell metrics. + */ + variant: TableVariant; +}; + +/** + * Root `
`, wrapped in a rounded, hairline-bordered scroll frame. Compose with + * `TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell`, + * `TableEditableCell`, and `TableActionCell`. The frame scrolls when the table + * overflows it (constrain its parent's height to scroll vertically); the header stays + * pinned to the top, and columns can be pinned with each cell's `pinned` prop. + * + * Pass `variant="table"` for row dividers or `variant="spreadsheet"` for a full grid; + * the cells read it via context. + */ +export function Table({ variant, ...props }: TableProps) { + return ( + + {/* The scroll frame is a Base UI ScrollArea: its Viewport is a real scroll + container (so sticky headers + pinned columns work relative to it) and its + scrollbar is an OVERLAY thumb positioned absolutely — it never reserves a + gutter, so the header isn't clipped and the table width stays constant whether + or not the scrollbar shows. The Root owns the outer `border/subtle`, the + `radius/lg` corners, and clips the overflow (a `
` can't clip its own + rounded corners). `max-h-full` caps it at a height-constrained parent so it + scrolls instead of growing. Base UI gives the Viewport `tabIndex=0` only while + it overflows, satisfying axe `scrollable-region-focusable` without a stray tab + stop when the table fits. */} + + +
+ + + + + + + + + + + ); +} + +export type TableHeaderProps = Omit, "className" | "style">; + +/** Header section (``). Holds a single `TableRow` of `TableHead` cells. */ +export function TableHeader(props: TableHeaderProps) { + return ; +} + +export type TableBodyProps = Omit, "className" | "style">; + +/** Body section (``). Holds the data `TableRow`s. */ +export function TableBody(props: TableBodyProps) { + return ; +} + +export type TableRowProps = Omit, "className" | "style">; + +/** + * A table row (``). Body rows sit on `layer-2` and tint to `layer-2-hover` on + * hover. Dividers are drawn per-cell (so the `spreadsheet` grid works), so the row + * itself carries only the background + hover. + */ +export function TableRow(props: TableRowProps) { + // `group/body-row` so a `table`-variant cell can drop its bottom divider on the last + // row (`group-last/body-row:border-b-0`) and a pinned cell can follow the row's hover. + return ; +} + +// Per-variant cell borders. The scroll frame already draws the single outer +// `border/subtle` ring (and rounds the corners), so cells only ever draw INTERIOR +// dividers — drawing a full border on the edge cells too would double the frame's ring +// (a visible 1px-ish double line that squared off the rounded corners). +// +// In `table`, only a bottom hairline divides rows (header uses the 1px `border/sm`, +// body cells the 0.5px `border/xs`). In `spreadsheet`, a full grid: each cell adds an +// inline-end divider (logical `border-e`, RTL-safe) plus the bottom hairline; the last +// column drops its end divider (`last:border-e-0`) and the last row drops its bottom +// divider so the frame's ring stays single all the way around. +const headBorder: Record = { + table: "border-b border-subtle", + spreadsheet: "border-b-[0.5px] border-e-[0.5px] border-subtle last:border-e-0", +}; +const cellBorder: Record = { + // Last body row drops its bottom divider so the rounded table closes cleanly. + table: "border-b-[0.5px] border-subtle group-last/body-row:border-b-0", + spreadsheet: + "border-b-[0.5px] border-e-[0.5px] border-subtle last:border-e-0 group-last/body-row:border-b-0", +}; + +// Sticky-column pinning. A pinned cell sticks to the inline-start/end edge while the +// table scrolls sideways. A `start` column draws a hairline on its inline-end edge and +// an `end` column on its inline-start edge (logical, RTL-safe) so the pinned column +// reads as separated from the content scrolling under it. Body cells get an opaque +// background (matching the row, incl. its hover) so scrolled columns slide beneath them; +// the header's pinned cell sits above both the sticky header row (z-20) and the pinned +// body column (z-10). +function pinnedEdgeBorder(pinned: TablePinned) { + return pinned === "start" ? "border-e-[0.5px] border-subtle" : "border-s-[0.5px] border-subtle"; +} +function pinnedHeadClass(pinned: TablePinned | undefined) { + if (!pinned) return "z-20"; + return cx("sticky z-30", pinned === "start" ? "start-0" : "end-0", pinnedEdgeBorder(pinned)); +} +function pinnedCellClass(pinned: TablePinned | undefined) { + if (!pinned) return ""; + return cx( + "sticky z-10 bg-layer-2 group-hover/body-row:bg-layer-2-hover", + pinned === "start" ? "start-0" : "end-0", + pinnedEdgeBorder(pinned), + ); +} + +// Header cells follow the Figma "Table header" component: 38px tall, `px-4 py-2`, +// `text-12` semibold on `background/layer/1` in `text/tertiary` (the muted header +// token from Figma). `sticky top-0` keeps the header in view while the body scrolls +// (the `layer-1` fill is opaque, so rows pass under it). +const tableHeadVariants = cva( + "sticky top-0 h-[38px] px-4 py-2 text-start align-middle text-12 font-semibold text-tertiary", + { + variants: { + variant: { + default: "bg-layer-1", + sortable: "bg-layer-1", + }, + }, + }, +); + +/** Sort direction of a sortable `TableHead`. `none` shows the neutral affordance. */ +export type TableHeadSort = "asc" | "desc" | "none"; + +const sortIcon: Record = { + asc: ChevronUp, + desc: ChevronDown, + none: ChevronsUpDown, +}; + +const ariaSort: Record = { + asc: "ascending", + desc: "descending", + none: "none", +}; + +export type TableHeadProps = Omit, "className" | "style" | "aria-sort"> & + VariantProps & { + /** + * Visual treatment. `sortable` renders a clickable button with a sort chevron; + * set it whenever you pass `sort`. + */ + variant: NonNullable["variant"]>; + /** + * Current sort state for a sortable header. Drives both the chevron icon + * (`asc`→up, `desc`→down, `none`→up/down) and the cell's `aria-sort`. + */ + sort?: TableHeadSort; + /** Click handler for the sort control; only used when the header is sortable. */ + onSort?: () => void; + /** Pin this header to the inline-start/end edge when the table scrolls sideways. */ + pinned?: TablePinned; + }; + +/** + * A header cell (` + ); +} + +// Keeps the chevron a fixed 14px (Figma sort icon) with the muted icon color. +function ChevronGlyphSlot({ Glyph }: { Glyph: typeof ChevronsUpDown }) { + return ; +} + +// Shared ` + ); +} + +// Shared chrome for the interactive cell triggers (editable + action). An actionable +// cell is transparent so the row shows through, and tints with a `layer-transparent` +// overlay on hover / keyboard focus / while its menu is open — distinct from the row's +// own hover, matching the Figma "actionable cell" treatment. +const actionableTriggerClass = cx( + "flex h-11 w-full items-center outline-none", + "bg-layer-transparent hover:bg-layer-transparent-hover focus-visible:bg-layer-transparent-hover", + "data-[popup-open]:bg-layer-transparent-active", + "disabled:pointer-events-none disabled:text-disabled", +); + +// The persistent "this cell is the selected one" tint, a step stronger than hover, on +// the `layer-transparent-selected` token. It stays under the open/hover overlays so an +// open or hovered selected cell still reads its interaction state on top. +const selectedTriggerClass = "bg-layer-transparent-selected"; + +export type TableEditableCellProps = Omit< + React.ComponentProps<"td">, + "className" | "style" | "children" +> & { + /** + * The current value shown in the cell (e.g. `"Admin"`). It sits before the + * trailing chevron and labels the trigger button. + */ + value: React.ReactNode; + /** + * The dropdown menu shown when the cell is clicked. Pass a propel `Dropdown` + * composition — typically a `DropdownContent` with `DropdownItem`s. The cell + * owns the `Dropdown` root + the trigger; you only supply the menu surface. + */ + children: React.ReactNode; + /** + * Whether the menu is open (controlled). Pair with `onOpenChange`; omit for an + * uncontrolled cell that manages its own open state. + */ + open?: boolean; + /** Default open state for an uncontrolled cell. @default false */ + defaultOpen?: boolean; + /** Called when the menu requests to open or close. */ + onOpenChange?: (open: boolean) => void; + /** Disables the trigger so the cell can't be edited. */ + disabled?: boolean; + /** + * Marks this as the actively-selected cell (e.g. the focused cell in a spreadsheet), + * giving it a persistent `layer-transparent-selected` tint a step stronger than hover. + */ + selected?: boolean; + /** Pin this cell to the inline-start/end edge when the table scrolls sideways. */ + pinned?: TablePinned; + /** Accessible name for the trigger when the value alone isn't descriptive. */ + "aria-label"?: string; +}; + +/** + * An editable data cell (` + ); +} + +export type TableActionCellProps = Omit< + React.ComponentProps<"td">, + "className" | "style" | "children" +> & { + /** + * The dropdown menu of row actions. Pass a propel `Dropdown` composition (a + * `DropdownContent` with `DropdownItem`s). The cell owns the `Dropdown` root + the + * icon trigger; you only supply the menu surface. + */ + children: React.ReactNode; + /** Accessible name for the trigger (e.g. "Row options"). Required (icon-only). */ + "aria-label": string; + /** Trigger glyph. @default an ellipsis (`⋯`). */ + icon?: React.ReactNode; + /** Whether the menu is open (controlled). Pair with `onOpenChange`. */ + open?: boolean; + /** Default open state for an uncontrolled cell. @default false */ + defaultOpen?: boolean; + /** Called when the menu requests to open or close. */ + onOpenChange?: (open: boolean) => void; + /** Disables the trigger. */ + disabled?: boolean; + /** Pin this cell to the inline-start/end edge when the table scrolls sideways. */ + pinned?: TablePinned; +}; + +/** + * An icon-only action cell (` + ); +} diff --git a/packages/propel/src/components/table/table.stories.tsx b/packages/propel/src/components/table/table.stories.tsx new file mode 100644 index 00000000..119babd8 --- /dev/null +++ b/packages/propel/src/components/table/table.stories.tsx @@ -0,0 +1,636 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Pencil, Trash2 } from "lucide-react"; +import * as React from "react"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { Avatar } from "../avatar/index"; +import { DropdownContent, DropdownItem } from "../dropdown/index"; +import { Pagination } from "../pagination/index"; +import { + Table, + TableActionCell, + TableBody, + TableCell, + TableEditableCell, + TableHead, + TableHeader, + TableRow, + type TableHeadSort, +} from "./index"; + +const meta = { + title: "Components/Table", + component: Table, + // Table is a compound component; document its parts alongside the root so the + // args table gets a tab per part and the manifest records the relationship. + subcomponents: { + TableHeader, + TableBody, + TableRow, + TableHead, + TableCell, + TableEditableCell, + TableActionCell, + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/ioN74zM1xMGbcPemsxs4J1/Global-components?node-id=5196-4084", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +type Person = { name: string; display: string; email: string; role: string; billing: string }; + +const PEOPLE: Person[] = [ + { + name: "Chargers", + display: "astra", + email: "astra.terra@example.com", + role: "Admin", + billing: "Active", + }, + { + name: "Alpha", + display: "nova", + email: "nova.star@example.com", + role: "Member", + billing: "Inactive", + }, + { + name: "Beta", + display: "lyra", + email: "lyra.constellation@example.com", + role: "Member", + billing: "Active", + }, + { + name: "Gamma", + display: "pulsar", + email: "vega.pulsar@example.com", + role: "Guest", + billing: "Inactive", + }, +]; + +const COLUMNS = ["Name", "Display name", "Email", "Account type", "Billing status"]; + +/** + * The standard **Table** (`variant="table"`): a rounded outer border with row + * dividers only (no vertical rules). Header on `layer-1`, body on `surface-1`. + */ +export const Default: Story = { + args: { variant: "table" }, + render: (args) => ( +
`). Pass `variant="sortable"` (with `sort`/`onSort`) to turn + * the label into an interactive sort control: it renders a lucide chevron + * (aria-hidden) and reflects the order via `aria-sort` for assistive tech. Pass + * `pinned` to make the column sticky on horizontal scroll (set it on the body cells too). + */ +export function TableHead({ + variant, + sort = "none", + onSort, + pinned, + children, + ...props +}: TableHeadProps) { + const tableVariant = React.useContext(TableVariantContext); + const isSortable = variant === "sortable"; + // Only render the interactive sort control when there's a handler to drive it; + // a sortable-styled header without `onSort` falls back to a plain label so we + // never expose a focusable button that does nothing. + const hasSortControl = isSortable && onSort != null; + const SortGlyph = sortIcon[sort]; + return ( + + {hasSortControl ? ( + + ) : ( +
{children}
+ )} +
` chrome for the plain, editable, and action cells: 44px tall, +// `align-middle`, plus the per-variant border read from context. Padding is +// intentionally NOT included here: the plain cell adds `px-4 py-2`, while the editable +// and action cells stay `p-0` so their full-bleed button supplies the inset. (Baking +// padding in and overriding it with `p-0` is order-fragile under `cx`, which is what +// made editable rows render too tall.) +function useTableCellClass() { + const tableVariant = React.useContext(TableVariantContext); + return cx("h-11 align-middle", cellBorder[tableVariant]); +} + +// A leading/trailing slot inside a cell — an icon or an avatar that sits beside the +// cell's content. It never shrinks and centers its node; the node sizes itself. +function TableCellSlot({ children }: { children: React.ReactNode }) { + return {children}; +} + +export type TableCellProps = Omit, "className" | "style"> & { + /** Leading content beside the cell text — an icon or an `Avatar`. */ + inlineStartNode?: React.ReactNode; + /** Trailing content beside the cell text — an icon or an `Avatar`. */ + inlineEndNode?: React.ReactNode; + /** Pin this cell to the inline-start/end edge when the table scrolls sideways. */ + pinned?: TablePinned; +}; + +/** + * A data cell (``). 44px tall with `px-4 py-2` and `text-13` body text. Optional + * `inlineStartNode` / `inlineEndNode` slots hold a leading/trailing icon or `Avatar` + * (e.g. an avatar before a name). `pinned` keeps the column sticky on horizontal scroll. + */ +export function TableCell({ + inlineStartNode, + inlineEndNode, + pinned, + children, + ...props +}: TableCellProps) { + const className = useTableCellClass(); + return ( + +
+ {inlineStartNode != null ? {inlineStartNode} : null} +
{children}
+ {inlineEndNode != null ? {inlineEndNode} : null} +
+
`). Renders the current `value` plus a trailing + * chevron as a single button that opens the propel `Dropdown` passed as `children` + * to pick a new value (Figma "Account type" cell). It owns the `Dropdown` root and + * trigger, so you only pass the menu surface: + * + * ```tsx + * + * + * setRole("Admin")} /> + * setRole("Member")} /> + * + * + * ``` + */ +export function TableEditableCell({ + value, + children, + open, + defaultOpen, + onOpenChange, + disabled, + selected, + pinned, + "aria-label": ariaLabel, + ...props +}: TableEditableCellProps) { + const className = useTableCellClass(); + return ( + // The `` keeps no padding so the full-bleed trigger button fills the cell + // (the button re-applies the px-4/py-2 inset). This keeps the click target the + // whole cell, matching the Figma editable cell. + + + + } + > + {value} + {/* 14px chevron in a 20px icon slot, mirroring the Figma trigger. */} + + + + + {children} + + `) — the trailing "⋯" that opens a menu of actions + * for the row (Figma's row-options cell). Like `TableEditableCell` it owns the + * `Dropdown` root + trigger and you pass only the menu surface; the trigger is an + * icon button with the actionable-cell hover treatment. + */ +export function TableActionCell({ + children, + "aria-label": ariaLabel, + icon, + open, + defaultOpen, + onOpenChange, + disabled, + pinned, + ...props +}: TableActionCellProps) { + const className = useTableCellClass(); + return ( + + + + } + > + + {icon ?? } + + + {children} + +
+ + + {COLUMNS.map((c) => ( + + {c} + + ))} + + + + {PEOPLE.map((person) => ( + + + } + > + {person.name} + + {person.display} + {person.email} + {person.role} + {person.billing} + + ))} + +
+ ), + play: async ({ canvas }) => { + await expect(canvas.getAllByRole("columnheader")).toHaveLength(5); + // 1 header row + 4 data rows. + await expect(canvas.getAllByRole("row")).toHaveLength(5); + await expect(canvas.getAllByRole("cell")).toHaveLength(20); + }, +}; + +/** + * The denser **Spreadsheet** (`variant="spreadsheet"`): the same metrics, but every + * cell is fully bordered to form a grid (Figma "Spreadsheet"). + */ +export const Spreadsheet: Story = { + args: { variant: "spreadsheet" }, + render: (args) => ( + + + + {COLUMNS.map((c) => ( + + {c} + + ))} + + + + {PEOPLE.map((person) => ( + + + } + > + {person.name} + + {person.display} + {person.email} + {person.role} + {person.billing} + + ))} + +
+ ), + play: async ({ canvas }) => { + await expect(canvas.getAllByRole("columnheader")).toHaveLength(5); + await expect(canvas.getAllByRole("row")).toHaveLength(5); + }, +}; + +/** + * A sortable header: `variant="sortable"` renders the label as a button with a + * sort chevron and reflects the order through `aria-sort`. Clicking cycles + * none → asc → desc. + */ +export const Sortable: Story = { + args: { variant: "table" }, + render: (args) => { + const [sort, setSort] = React.useState("none"); + const cycle = () => setSort((s) => (s === "none" ? "asc" : s === "asc" ? "desc" : "none")); + return ( + + + + + Name + + Account type + Email + + + + {PEOPLE.map((person) => ( + + + } + > + {person.name} + + {person.role} + {person.email} + + ))} + +
+ ); + }, + play: async ({ canvas }) => { + const header = canvas.getAllByRole("columnheader")[0]; + await expect(header).toHaveAttribute("aria-sort", "none"); + const button = canvas.getByRole("button", { name: "Name" }); + await userEvent.click(button); + await expect(header).toHaveAttribute("aria-sort", "ascending"); + await userEvent.click(button); + await expect(header).toHaveAttribute("aria-sort", "descending"); + }, +}; + +const ROLES = ["Admin", "Member", "Guest"]; + +/** + * **Editable cells.** Each "Account type" cell is a `TableEditableCell`: clicking it + * opens a propel `Dropdown` to pick a new value, which updates the row in place + * (Figma "Account type" editable cell). The cell tints on hover and while its menu is + * open; the last-clicked cell keeps a stronger `selected` tint to mark the active cell. + * Works in both table variants. + */ +export const EditableCells: Story = { + args: { variant: "table" }, + render: function EditableCellsStory(args) { + const [people, setPeople] = React.useState(PEOPLE); + const [selectedEmail, setSelectedEmail] = React.useState(null); + const setRole = (email: string, role: string) => + setPeople((rows) => rows.map((r) => (r.email === email ? { ...r, role } : r))); + return ( + + + + Name + Email + Account type + + + + {people.map((person) => ( + + + } + > + {person.name} + + {person.email} + { + if (next) setSelectedEmail(person.email); + }} + > + + {ROLES.map((role) => ( + setRole(person.email, role)} + /> + ))} + + + + ))} + +
+ ); + }, + play: async ({ canvas }) => { + // The first editable cell opens a menu; picking a role updates the cell value. + const trigger = canvas.getByRole("button", { name: "Account type for Chargers" }); + await expect(trigger).toHaveTextContent("Admin"); + await userEvent.click(trigger); + // The menu renders in a portal — query the document body. + const menu = within(document.body); + const member = await menu.findByRole("menuitem", { name: "Member" }); + await userEvent.click(member); + await expect(trigger).toHaveTextContent("Member"); + }, +}; + +// A larger directory so the table has enough rows to page through. +const DIRECTORY: Person[] = Array.from({ length: 23 }, (_, i) => { + const base = PEOPLE[i % PEOPLE.length]; + return { + ...base, + name: `${base.name} ${i + 1}`, + email: `user${i + 1}@example.com`, + }; +}); + +/** + * **With Pagination.** The Table and `Pagination` are separate components: the table + * renders only the current page of rows, and the pagination below drives `page` and + * `pageSize`. Changing the page size resets to the first page and the range label + * updates. This is the designer follow-up on composing the two. + */ +export const WithPagination: Story = { + args: { variant: "table" }, + render: (args) => { + const [page, setPage] = React.useState(1); + const [pageSize, setPageSize] = React.useState(5); + const pageCount = Math.ceil(DIRECTORY.length / pageSize); + const start = (page - 1) * pageSize; + const rows = DIRECTORY.slice(start, start + pageSize); + return ( + // A fixed width so the table does not resize as pages change: the table is + // `w-full`, so without a constrained container it would shrink-to-fit each page's + // content and jump in width when paginating. +
+ + + + Name + Email + Account type + Billing status + + + + {rows.map((person) => ( + + + } + > + {person.name} + + {person.email} + {person.role} + {person.billing} + + ))} + +
+ { + setPageSize(next); + setPage(1); + }, + }} + range={{ + current: `${start + 1}-${Math.min(start + pageSize, DIRECTORY.length)}`, + total: DIRECTORY.length, + }} + /> +
+ ); + }, + play: async ({ canvas }) => { + // Page 1 shows the first slice; advancing the pagination swaps the table's rows. + await expect(canvas.getByText("user1@example.com")).toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button", { name: "Go to next page" })); + await waitFor(() => expect(canvas.queryByText("user1@example.com")).not.toBeInTheDocument()); + await expect(canvas.getByText("user6@example.com")).toBeInTheDocument(); + }, +}; + +/** + * **Cell slots + an action cell.** Cells carry a leading `Avatar` (the Name column), + * an inline editable cell (Account type), and a trailing icon-only `TableActionCell` + * (the "⋯" that opens a menu of row actions). Actionable cells tint with a + * `layer-transparent` overlay on hover, distinct from the row's own hover. + */ +export const RichRows: Story = { + args: { variant: "table" }, + parameters: { controls: { disable: true } }, + render: function RichRowsStory(args) { + const [people, setPeople] = React.useState(PEOPLE); + const setRole = (email: string, role: string) => + setPeople((rows) => rows.map((r) => (r.email === email ? { ...r, role } : r))); + return ( + + + + Name + Email + Account type + + Actions + + + + + {people.map((person) => ( + + + } + > + {person.name} + + {person.email} + + + {ROLES.map((role) => ( + setRole(person.email, role)} + /> + ))} + + + + + } label="Edit" /> + } label="Delete" /> + + + + ))} + +
+ ); + }, + play: async ({ canvas }) => { + // The trailing action cell opens a row-options menu. + const trigger = canvas.getByRole("button", { name: "Options for Chargers" }); + await userEvent.click(trigger); + const menu = within(document.body); + // The menu opens with a scale-fade, so wait for the items to settle visible + // (find resolves on mount, which can be mid-animation). + await waitFor(() => expect(menu.getByRole("menuitem", { name: "Edit" })).toBeVisible()); + await expect(menu.getByRole("menuitem", { name: "Delete" })).toBeVisible(); + }, +}; + +/** + * **Sticky header + pinned column.** In a height- and width-constrained frame, the + * header stays pinned to the top on vertical scroll, and the first column (`pinned="start"` + * on its header + cells) stays put on horizontal scroll. + */ +export const StickyHeaderAndColumns: Story = { + args: { variant: "table" }, + parameters: { controls: { disable: true } }, + render: (args) => ( +
+ + + + + Name + + Display name + Email + Account type + Billing status + + + + {DIRECTORY.map((person) => ( + + + } + > + {person.name} + + {person.display} + {person.email} + {person.role} + {person.billing} + + ))} + +
+
+ ), + play: async ({ canvas }) => { + // The pinned header is the sticky-column corner: sticky to both the top (header) + // and the inline-start edge (pinned column). + const nameHeader = canvas.getByRole("columnheader", { name: "Name" }); + await expect(nameHeader).toHaveClass("sticky"); + await expect(nameHeader).toHaveClass("start-0"); + await expect(nameHeader).toHaveClass("top-0"); + }, +}; + +/** + * **Keyboard: sortable header.** Tab to the sortable column-header button and toggle + * the sort with the keyboard: each Enter/Space advances the cycle + * (none → ascending → descending → none) and the ``'s `aria-sort` follows along, + * while `onSort` fires on every activation. Tagged so it stays out of the sidebar, + * docs, and AI manifest while still running under the default `test` tag. + */ +export const SortableKeyboard: Story = { + tags: ["!dev", "!autodocs", "!manifest"], + args: { variant: "table" }, + render: function SortableKeyboardStory(args) { + const [sort, setSort] = React.useState("none"); + const cycle = () => setSort((s) => (s === "none" ? "asc" : s === "asc" ? "desc" : "none")); + return ( + + + + + Name + + Email + + + + {PEOPLE.map((person) => ( + + + } + > + {person.name} + + {person.email} + + ))} + +
+ ); + }, + play: async ({ canvas }) => { + const header = canvas.getAllByRole("columnheader")[0]; + if (!header) throw new Error("expected a sortable column header"); + // Table semantics: the header is a proper `` that starts unsorted. + await expect(header.tagName).toBe("TH"); + await expect(header).toHaveAttribute("scope", "col"); + await expect(header).toHaveAttribute("aria-sort", "none"); + + const button = canvas.getByRole("button", { name: "Name" }); + // Tab moves focus onto the sort-control button (it's the first focusable element). + await userEvent.tab(); + await expect(button).toHaveFocus(); + + // Enter advances the cycle: none → ascending. + await userEvent.keyboard("{Enter}"); + await expect(header).toHaveAttribute("aria-sort", "ascending"); + + // Space advances again: ascending → descending. + await userEvent.keyboard(" "); + await expect(header).toHaveAttribute("aria-sort", "descending"); + + // Enter once more wraps back to none. + await userEvent.keyboard("{Enter}"); + await expect(header).toHaveAttribute("aria-sort", "none"); + }, +}; + +/** + * **Keyboard: editable cell.** Tab/Enter on the editable cell's trigger opens the + * portaled `Dropdown`; Arrow Down moves the highlight onto the first item and Enter + * selects it, updating the cell value in place; Escape closes the menu and returns + * focus to the cell trigger. The menu is portaled, so it's queried from the document + * body by its unique item text. Tagged so it stays out of the sidebar, docs, and AI + * manifest while still running under the default `test` tag. + */ +export const EditableCellKeyboard: Story = { + tags: ["!dev", "!autodocs", "!manifest"], + args: { variant: "table" }, + render: function EditableCellKeyboardStory(args) { + const [role, setRole] = React.useState("Admin"); + return ( + + + + Name + Account type + + + + + Chargers + + + {ROLES.map((r) => ( + setRole(r)} + /> + ))} + + + + +
+ ); + }, + play: async ({ canvas }) => { + const menu = within(document.body); + const trigger = canvas.getByRole("button", { name: "Account type for Chargers" }); + await expect(trigger).toHaveTextContent("Admin"); + + // Tab focuses the cell trigger; Enter opens the portaled menu. + await userEvent.tab(); + await expect(trigger).toHaveFocus(); + await userEvent.keyboard("{Enter}"); + // The menu is portaled — find it by its unique item text, not a bare role. + await waitFor(() => expect(menu.getByRole("menuitem", { name: "Member" })).toBeVisible()); + + // Opening with Enter already highlights the first item ("Admin"); one Arrow Down + // moves the highlight onto "Member", then Enter selects it and the cell updates. + await userEvent.keyboard("{ArrowDown}{Enter}"); + await expect(trigger).toHaveTextContent("Member"); + // Selecting closed the menu. + await waitFor(() => expect(menu.queryByRole("menuitem", { name: "Member" })).toBeNull()); + + // Reopen with the keyboard, then Escape closes it and restores focus to the trigger. + await userEvent.keyboard("{Enter}"); + await waitFor(() => expect(menu.getByRole("menuitem", { name: "Guest" })).toBeVisible()); + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(menu.queryByRole("menuitem", { name: "Guest" })).toBeNull()); + await expect(trigger).toHaveFocus(); + }, +};