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 (`
`). 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}
+ )}
+
+ );
+}
+
+// Keeps the chevron a fixed 14px (Figma sort icon) with the muted icon color.
+function ChevronGlyphSlot({ Glyph }: { Glyph: typeof ChevronsUpDown }) {
+ return ;
+}
+
+// Shared `
` 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 (
+
+ );
+}
+
+// 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 (`
`). 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}
+
+
+ );
+}
+
+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 (`
`) — 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}
+
+
+ );
+}
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) => (
+
+ );
+ },
+ 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.
+
+ );
+ },
+ 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) => (
+
+ ),
+ 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 (
+
+ );
+ },
+ 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 (
+