> ## Documentation Index
> Fetch the complete documentation index at: https://anvil.servicetitan.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Data Table – Code

> Data table component for complex data grid layouts with sorting, filtering, pagination, and row operations.

export const LiveCode = ({children, customHeight, clickToLoad, example, fullWidth, fullHeight, hideCodeInLiveCode, screenshot, screenshotOnly, showCode: showCodeProp}) => {
  const SCREENSHOTS_BASE = "https://servicetitan.github.io/anvil2-docs-live-code/screenshots";
  const STACKBLITZ_BASE = "https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples";
  const [showCodeBlock, setShowCodeBlock] = useState(showCodeProp ?? false);
  const [isLocalOverride, setIsLocalOverride] = useState(false);
  useEffect(() => {
    const examplePath = `/images/live-code-screenshots-tmp/${example}.png`;
    fetch(examplePath, {
      method: "HEAD"
    }).then(r => {
      if (r.ok) setIsLocalOverride(true);
    }).catch(() => {});
  }, [example]);
  const screenshotBase = isLocalOverride ? "/images/live-code-screenshots-tmp" : SCREENSHOTS_BASE;
  if (screenshotOnly) {
    return <Frame className="flex flex-col">
        <div className="flex dark:hidden" style={{
      justifyContent: "center",
      alignItems: "center",
      width: fullWidth ? "100%" : "50%",
      minHeight: fullHeight ? "284px" : undefined,
      background: "#FFFFFF"
    }}>
          <img srcset={`${screenshotBase}/${example}.png, ${screenshotBase}/${example}-2x.png 2x`} src={`${screenshotBase}/${example}.png`} alt={example} noZoom />
        </div>
        <div className="hidden dark:flex" style={{
      justifyContent: "center",
      alignItems: "center",
      width: fullWidth ? "100%" : "50%",
      minHeight: fullHeight ? "284px" : undefined,
      background: "#141414"
    }}>
          <img srcset={`${screenshotBase}/${example}-dark.png, ${screenshotBase}/${example}-dark-2x.png 2x`} src={`${screenshotBase}/${example}-dark.png`} alt={example} noZoom />
        </div>
      </Frame>;
  }
  if (screenshot) {
    return <Frame className="flex flex-col -mb-2">
        <div className="flex dark:hidden bg-white dark:bg-codeblock border border-gray-950/10 dark:border-white/10 dark:twoslash-dark rounded-2xl overflow-hidden" style={{
      justifyContent: "center",
      alignItems: "center",
      width: fullWidth ? "100%" : "50%",
      minHeight: fullHeight ? "284px" : undefined
    }}>
          <img srcset={`${screenshotBase}/${example}.png, ${screenshotBase}/${example}-2x.png 2x`} src={`${screenshotBase}/${example}.png`} alt={example} noZoom />
        </div>

        <div className="hidden dark:flex bg-white dark:bg-codeblock border border-gray-950/10 dark:border-white/10 dark:twoslash-dark rounded-2xl overflow-hidden" style={{
      background: "#141414",
      justifyContent: "center",
      alignItems: "center",
      width: fullWidth ? "100%" : "50%",
      minHeight: fullHeight ? "284px" : undefined
    }}>
          <img srcset={`${screenshotBase}/${example}-dark.png, ${screenshotBase}/${example}-dark-2x.png 2x`} src={`${screenshotBase}/${example}-dark.png`} alt={example} noZoom />
        </div>

        <div className="flex justify-end items-center text-xs py-2 px-1 gap-4">
          {!showCodeProp ? <button className="inline-flex justify-end items-center text-gray-700 dark:text-gray-50 hover:text-blue-500 dark:hover:text-blue-300 transition-colors group self-end gap-1 cursor-pointer" onClick={() => setShowCodeBlock(!showCodeBlock)} style={{
      appearance: "none"
    }}>
              <Icon icon="code" size="12px" className="group-hover:bg-blue-500 dark:group-hover:bg-blue-300" />
              <span>{showCodeBlock ? "Hide code" : "Show code"}</span>
            </button> : null}

          <a className="inline-flex justify-end items-center hover:text-blue-500 dark:hover:text-blue-300 transition-colors group self-end gap-1" href={`${STACKBLITZ_BASE}/${example}?file=src/App.tsx`} target="_blank" rel="noreferrer">
            <Icon icon="bolt" size="12px" className="group-hover:bg-blue-500 dark:group-hover:bg-blue-300" />
            <span>StackBlitz demo</span>
          </a>
        </div>

        <div className="grid transition-[grid-template-rows] duration-300 ease-in-out overflow-auto overflow-y-hidden overflow-x-auto" style={showCodeBlock ? {
      gridTemplateRows: "1fr"
    } : {
      gridTemplateRows: "0fr"
    }}>
          <div style={{
      minHeight: 0,
      overflowX: "auto",
      overflowY: "hidden",
      marginBlockStart: "-1.25rem",
      marginBlockEnd: "-1.5rem"
    }}>
            {children}
          </div>
        </div>
      </Frame>;
  } else {
    return <div style={{
      display: "flex",
      width: fullWidth ? "100%" : "50%",
      minHeight: customHeight ? customHeight : "316px",
      resize: "vertical",
      overflow: "auto"
    }}>
        <iframe title={example} style={{
      flex: 1,
      width: fullWidth ? "100%" : "50%",
      minHeight: customHeight ? customHeight : "316px"
    }} src={`${STACKBLITZ_BASE}/${example}?embed=1&hideNavigation=1&hideExplorer=1&terminalHeight=0&file=src/App.tsx${clickToLoad ? "&ctl=1" : ""}${hideCodeInLiveCode ? "&view=preview" : ""}`} allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking" sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts" />
      </div>;
  }
};

<Note>
  **Beta Feature**

  This feature is currently in beta, and needs to be imported from `@servicetitan/anvil2/beta`.

  While we hope to minimize breaking changes, they may occur due to feedback we receive or other improvements. These will always be documented in the changelog and communicated in Slack.

  Please reach out in the [#ask-designsystem](https://servicetitan.enterprise.slack.com/archives/CBSRGHTRS) channel with any questions or feedback!
</Note>

<Info>
  **Known limitations:**

  * Toolbars are not integrated with the `DataTable` yet
  * Row grouping is not yet supported
  * Limited default formatting and editable cell options are available
</Info>

<Tabs>
  <Tab title="Implementation">
    <LiveCode showCode example="data-table-playground" fullWidth screenshot>
      ```tsx lines expandable theme={null}
      import {
        DataTable,
        createColumnHelper,
        chipsFormatter,
        currencyFormatter,
        type TableRow,
      } from "@servicetitan/anvil2/beta";

      type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
      type PaymentType =
        | "credit_card"
        | "cash"
        | "bank_transfer"
        | "check"
        | "paypal";

      type StatusValue = OrderData["status"];

      type OrderData = {
        id: string;
        amount: number;
        status?: Status[];
        order_date: string;
        payment_type: PaymentType;
      };

      const data: TableRow<OrderData>[] = [
        {
          id: "ORD-2024-001",
          amount: 2450.75,
          status: ["completed"],
          order_date: "09/15/2024",
          payment_type: "credit_card",
        },
        {
          id: "ORD-2024-002",
          amount: 15750.0,
          status: ["shipped"],
          order_date: "09/18/2024",
          payment_type: "bank_transfer",
        },
        {
          id: "ORD-2024-003",
          amount: 89.99,
          status: ["pending"],
          order_date: "09/20/2024",
          payment_type: "paypal",
        },
        {
          id: "ORD-2024-004",
          amount: 1200.5,
          status: ["processing"],
          order_date: "09/12/2024",
          payment_type: "credit_card",
        },
        {
          id: "ORD-2024-005",
          amount: 0.0,
          status: ["cancelled"],
          order_date: "09/10/2024",
          payment_type: "credit_card",
        },
        {
          id: "ORD-2024-006",
          amount: 8925.0,
          status: ["completed"],
          order_date: "08/28/2024",
          payment_type: "check",
        },
        {
          id: "ORD-2024-007",
          amount: 45.99,
          status: ["shipped"],
          order_date: "09/21/2024",
          payment_type: "cash",
        },
      ];

      const createColumn = createColumnHelper<OrderData>();

      const statusOrder: Record<Status, number> = {
        pending: 1,
        processing: 2,
        shipped: 3,
        completed: 4,
        cancelled: 5,
      };

      const formatStatus = (value: StatusValue) =>
        chipsFormatter(
          value?.map((status) => ({
            label: status.charAt(0).toUpperCase() + status.slice(1),
            color:
              status === "pending"
                ? "#f59e0b"
                : status === "shipped"
                  ? "#8b5cf6"
                  : status === "processing"
                    ? "#3b82f6"
                    : status === "completed"
                      ? "#10b981"
                      : "#ef4444",
          })),
        );

      const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
        const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
        const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

        return statusA - statusB;
      };

      const formatPaymentType = (value: PaymentType) => (
        <span>
          {value === "credit_card"
            ? "Credit Card"
            : value === "cash"
              ? "Cash"
              : value === "bank_transfer"
                ? "Bank Transfer"
                : value === "check"
                  ? "Check"
                  : value === "paypal"
                    ? "PayPal"
                    : value}
        </span>
      );

      const columns = [
        createColumn("id", {
          header: { label: "Order ID" },
          sortable: true,
          resizable: true,
          minWidth: 130,
          footerContent: (
            <span>
              <b>Total:</b> {data.length}
            </span>
          ),
        }),
        createColumn("amount", {
          header: { label: "Amount" },
          renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
          sortable: true,
          footerContent: [
            <span key="total">
              {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
            </span>,
            <span key="average">
              {currencyFormatter(
                data.reduce((acc, row) => acc + row.amount, 0) / data.length,
              )}{" "}
              (avg)
            </span>,
          ],
        }),
        createColumn("status", {
          header: { label: "Status" },
          resizable: true,
          renderCell: (value: StatusValue) => formatStatus(value),
          sortable: sortStatus,
        }),
        createColumn("order_date", {
          header: { label: "Order Date" },
          sortable: true,
        }),
        createColumn("payment_type", {
          header: { label: "Payment Type" },
          renderCell: (value: PaymentType) => formatPaymentType(value),
          sortable: true,
          minWidth: 150,
        }),
      ];

      function App() {
        return <DataTable data={data} columns={columns} />;
      }

      export default App;
      ```
    </LiveCode>

    ## Common Examples

    ```tsx theme={null}
    import { DataTable, createColumnHelper, currencyFormatter, type TableRow } from "@servicetitan/anvil2/beta";

    type OrderData = {
      id: string;
      customer: string;
      amount: number;
      status: "pending" | "shipped" | "completed";
      date: string;
    };

    const columnHelper = createColumnHelper<OrderData>();

    const columns = [
      columnHelper("customer", {
        header: { label: "Customer" },
        sortable: true,
      }),
      columnHelper("amount", {
        header: { label: "Amount" },
        sortable: true,
        renderCell: currencyFormatter,
      }),
      columnHelper("status", {
        header: { label: "Status" },
        sortable: true,
        editConfig: {
          mode: "select",
          options: [
            { id: "pending", label: "Pending" },
            { id: "shipped", label: "Shipped" },
            { id: "completed", label: "Completed" },
          ],
          onChange: (option, rowId) => {
            if (option) {
              console.log(String(option.id), rowId);
            }
          },
        },
      }),
      columnHelper("date", {
        header: { label: "Order Date" },
        sortable: true,
      }),
    ];

    const data: TableRow<OrderData>[] = [
      { id: "1", customer: "Acme Corp", amount: 1250.0, status: "completed", date: "2024-01-15" },
      { id: "2", customer: "TechStart", amount: 890.5, status: "shipped", date: "2024-01-16" },
      // ... more data
    ];

    function ExampleComponent() {
      return <DataTable data={data} columns={columns} />;
    }
    ```

    The `DataTable` component provides a comprehensive solution for displaying complex tabular data with built-in support for sorting, pagination, row selection, and expansion.

    ## Basic Usage

    The `DataTable` component requires two props: `data` (row data) and `columns` (column definitions).

    ```tsx theme={null}
    <DataTable data={rowData} columns={columnDefs} />
    ```

    Type safety is built-in using TypeScript generics and a factory function used to create column definitions: `createColumnHelper` (below).

    ## Working with columns

    ### Defining data interface

    To ensure type safety, a type or interface should be used that defines the columns in the table. The `id` property should always be included, as it is used internally for sorting, selection, etc.

    ```ts theme={null}
    interface TableColumns {
      id: string;
      customer_name: string;
      address: string;
      status: "active" | "inactive";
      amount_due: number;
      paid_percent: number;
    }
    ```

    ### Defining columns

    To create column definitions, use the `createColumnHelper` factory function. This returns a function, which can then be used to create type-safe column definitions.

    ```ts theme={null}
    const createColumn = createColumnHelper<TableColumns>();
    ```

    The `createColumn` function created above accepts two parameters:

    1. `id`: `string` | `{ group: string }`. The `string` value must match one of the properties of the type interface provided to the factory function (`id`, `customer_name`, etc.). The object with the `group` parameter can accept any string, and is used to group column headers.
    2. `column`: configuration for column (see below for examples)

    The `DataTable.columns` prop takes an array of column definitions, each created using the `createColumn` function created above.

    <Note>
      `header.label` supports the same limited inline Markdown as [FieldLabel](/docs/web/components/field-label/code).
    </Note>

    #### Basic columns

    <LiveCode showCode example="data-table-basic-columns" fullWidth screenshot>
      ```tsx lines expandable theme={null}
      import {
        DataTable,
        createColumnHelper,
        currencyFormatter,
        percentFormatter,
        type TableRow,
      } from "@servicetitan/anvil2/beta";

      type CustomerRow = {
        id: string;
        customer_name: string;
        address: string;
        status: string;
        amount_due: number;
        paid_percent: number;
      };

      const data: TableRow<CustomerRow>[] = [
        {
          id: "CUST-001",
          customer_name: "Jose Ramirez",
          address: "2401 Ontario Street, Cleveland, OH",
          status: "Active",
          amount_due: 1276.43,
          paid_percent: 0.61,
        },
        {
          id: "CUST-002",
          customer_name: "Sophia Rodriguez",
          address: "742 Evergreen Terrace, Springfield, IL",
          status: "Active",
          amount_due: 3450.0,
          paid_percent: 0.85,
        },
        {
          id: "CUST-003",
          customer_name: "TechCorp Solutions",
          address: "100 Innovation Drive, Austin, TX",
          status: "Inactive",
          amount_due: 890.5,
          paid_percent: 1.0,
        },
        {
          id: "CUST-004",
          customer_name: "Ahmed Hassan",
          address: "55 Market Street, San Francisco, CA",
          status: "Active",
          amount_due: 5120.0,
          paid_percent: 0.23,
        },
        {
          id: "CUST-005",
          customer_name: "Green Valley Farms",
          address: "800 Rural Route 3, Des Moines, IA",
          status: "Active",
          amount_due: 2100.75,
          paid_percent: 0.5,
        },
      ];

      const createColumn = createColumnHelper<CustomerRow>();

      const columns = [
        createColumn("id", {
          header: {
            label: "Customer ID",
            required: true,
            moreInfo: "Used by support and billing to identify the account.",
          },
          sortable: true,
          resizable: true,
          minWidth: 100,
          maxWidth: 400,
        }),
        createColumn("customer_name", {
          header: { label: "Name" },
          sortable: true,
          resizable: true,
          minWidth: 150,
        }),
        createColumn("address", {
          header: { label: "Address" },
          resizable: true,
          minWidth: 200,
        }),
        createColumn("status", {
          header: { label: "Status" },
          resizable: true,
        }),
        createColumn("amount_due", {
          header: { label: "Amount Due" },
          renderCell: (value: CustomerRow["amount_due"]) => currencyFormatter(value),
          minWidth: 100,
          maxWidth: 100,
        }),
        createColumn("paid_percent", {
          header: { label: "Paid %" },
          renderCell: (value: CustomerRow["paid_percent"]) => percentFormatter(value),
          minWidth: 100,
          maxWidth: 100,
        }),
      ];

      function App() {
        return <DataTable data={data} columns={columns} />;
      }

      export default App;
      ```
    </LiveCode>

    #### Grouped columns

    <LiveCode showCode example="data-table-grouped-columns" fullWidth screenshot>
      ```tsx lines expandable theme={null}
      import {
        DataTable,
        createColumnHelper,
        chipsFormatter,
        currencyFormatter,
        type TableRow,
      } from "@servicetitan/anvil2/beta";

      type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
      type PaymentType =
        | "credit_card"
        | "cash"
        | "bank_transfer"
        | "check"
        | "paypal";

      type StatusValue = OrderData["status"];

      type OrderData = {
        id: string;
        amount: number;
        status?: Status[];
        order_date: string;
        payment_type: PaymentType;
      };

      const data: TableRow<OrderData>[] = [
        {
          id: "ORD-2024-001",
          amount: 2450.75,
          status: ["completed"],
          order_date: "09/15/2024",
          payment_type: "credit_card",
        },
        {
          id: "ORD-2024-002",
          amount: 15750.0,
          status: ["shipped"],
          order_date: "09/18/2024",
          payment_type: "bank_transfer",
        },
        {
          id: "ORD-2024-003",
          amount: 89.99,
          status: ["pending"],
          order_date: "09/20/2024",
          payment_type: "paypal",
        },
        {
          id: "ORD-2024-004",
          amount: 1200.5,
          status: ["processing"],
          order_date: "09/12/2024",
          payment_type: "credit_card",
        },
        {
          id: "ORD-2024-005",
          amount: 0.0,
          status: ["cancelled"],
          order_date: "09/10/2024",
          payment_type: "credit_card",
        },
        {
          id: "ORD-2024-006",
          amount: 8925.0,
          status: ["completed"],
          order_date: "08/28/2024",
          payment_type: "check",
        },
        {
          id: "ORD-2024-007",
          amount: 45.99,
          status: ["shipped"],
          order_date: "09/21/2024",
          payment_type: "cash",
        },
      ];

      const createColumn = createColumnHelper<OrderData>();

      const statusOrder: Record<Status, number> = {
        pending: 1,
        processing: 2,
        shipped: 3,
        completed: 4,
        cancelled: 5,
      };

      const formatStatus = (value: StatusValue) =>
        chipsFormatter(
          value?.map((status) => ({
            label: status.charAt(0).toUpperCase() + status.slice(1),
            color:
              status === "pending"
                ? "#f59e0b"
                : status === "shipped"
                  ? "#8b5cf6"
                  : status === "processing"
                    ? "#3b82f6"
                    : status === "completed"
                      ? "#10b981"
                      : "#ef4444",
          })),
        );

      const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
        const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
        const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

        return statusA - statusB;
      };

      const formatPaymentType = (value: PaymentType) => (
        <span>
          {value === "credit_card"
            ? "Credit Card"
            : value === "cash"
              ? "Cash"
              : value === "bank_transfer"
                ? "Bank Transfer"
                : value === "check"
                  ? "Check"
                  : value === "paypal"
                    ? "PayPal"
                    : value}
        </span>
      );

      // Define individual columns that will be grouped
      const orderInfoColumns = [
        createColumn("id", {
          header: {
            label: "Order ID",
            required: true,
            moreInfo: "Unique order identifier used in downstream workflows.",
          },
          sortable: true,
          resizable: true,
          minWidth: 130,
          footerContent: (
            <span>
              <b>Total:</b> {data.length}
            </span>
          ),
        }),
        createColumn("order_date", {
          header: { label: "Order Date" },
          sortable: true,
        }),
        createColumn("status", {
          header: { label: "Status" },
          resizable: true,
          renderCell: (value: StatusValue) => formatStatus(value),
          sortable: sortStatus,
        }),
      ];

      const paymentColumns = [
        createColumn("amount", {
          header: { label: "Amount" },
          renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
          sortable: true,
          footerContent: [
            <span key="total">
              {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
            </span>,
            <span key="average">
              {currencyFormatter(
                data.reduce((acc, row) => acc + row.amount, 0) / data.length,
              )}{" "}
              (avg)
            </span>,
          ],
        }),
        createColumn("payment_type", {
          header: { label: "Payment Type" },
          renderCell: (value: PaymentType) => formatPaymentType(value),
          sortable: true,
          minWidth: 150,
        }),
      ];

      // Create grouped columns using the group syntax
      const groupedColumns = [
        createColumn(
          { group: "order_info" },
          {
            header: {
              label: "Order Info",
              required: true,
              moreInfo: "Columns describing the order record.",
            },
            columns: orderInfoColumns,
          },
        ),
        createColumn(
          { group: "payment_details" },
          {
            header: { label: "Payment Details" },
            columns: paymentColumns,
          },
        ),
      ];

      function App() {
        return <DataTable data={data} columns={groupedColumns} />;
      }

      export default App;
      ```
    </LiveCode>

    #### Pinned columns

    Columns can be pinned to the left or right side of the table using the `pinned` property in the column options. Pinned columns have sticky position when the table is scrolled horizontally.

    <LiveCode showCode example="data-table-pinned-columns" fullWidth screenshot>
      ```tsx lines expandable theme={null}
      import {
        DataTable,
        createColumnHelper,
        chipsFormatter,
        currencyFormatter,
        type TableRow,
      } from "@servicetitan/anvil2/beta";

      type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
      type PaymentType =
        | "credit_card"
        | "cash"
        | "bank_transfer"
        | "check"
        | "paypal";

      type StatusValue = OrderData["status"];

      type OrderData = {
        id: string;
        amount: number;
        status?: Status[];
        order_date: string;
        payment_type: PaymentType;
      };

      const data: TableRow<OrderData>[] = [
        {
          id: "ORD-2024-001",
          amount: 2450.75,
          status: ["completed"],
          order_date: "09/15/2024",
          payment_type: "credit_card",
        },
        {
          id: "ORD-2024-002",
          amount: 15750.0,
          status: ["shipped"],
          order_date: "09/18/2024",
          payment_type: "bank_transfer",
        },
        {
          id: "ORD-2024-003",
          amount: 89.99,
          status: ["pending"],
          order_date: "09/20/2024",
          payment_type: "paypal",
        },
        {
          id: "ORD-2024-004",
          amount: 1200.5,
          status: ["processing"],
          order_date: "09/12/2024",
          payment_type: "credit_card",
        },
        {
          id: "ORD-2024-005",
          amount: 0.0,
          status: ["cancelled"],
          order_date: "09/10/2024",
          payment_type: "credit_card",
        },
        {
          id: "ORD-2024-006",
          amount: 8925.0,
          status: ["completed"],
          order_date: "08/28/2024",
          payment_type: "check",
        },
        {
          id: "ORD-2024-007",
          amount: 45.99,
          status: ["shipped"],
          order_date: "09/21/2024",
          payment_type: "cash",
        },
      ];

      const createColumn = createColumnHelper<OrderData>();

      const statusOrder: Record<Status, number> = {
        pending: 1,
        processing: 2,
        shipped: 3,
        completed: 4,
        cancelled: 5,
      };

      const formatStatus = (value: StatusValue) =>
        chipsFormatter(
          value?.map((status) => ({
            label: status.charAt(0).toUpperCase() + status.slice(1),
            color:
              status === "pending"
                ? "#f59e0b"
                : status === "shipped"
                  ? "#8b5cf6"
                  : status === "processing"
                    ? "#3b82f6"
                    : status === "completed"
                      ? "#10b981"
                      : "#ef4444",
          })),
        );

      const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
        const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
        const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

        return statusA - statusB;
      };

      const formatPaymentType = (value: PaymentType) => (
        <span>
          {value === "credit_card"
            ? "Credit Card"
            : value === "cash"
              ? "Cash"
              : value === "bank_transfer"
                ? "Bank Transfer"
                : value === "check"
                  ? "Check"
                  : value === "paypal"
                    ? "PayPal"
                    : value}
        </span>
      );

      const columns = [
        createColumn("id", {
          header: { label: "Order ID" },
          sortable: true,
          resizable: true,
          minWidth: 130,
          footerContent: (
            <span>
              <b>Total:</b> {data.length}
            </span>
          ),
        }),
        createColumn("amount", {
          header: { label: "Amount" },
          renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
          sortable: true,
          footerContent: [
            <span key="total">
              {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
            </span>,
            <span key="average">
              {currencyFormatter(
                data.reduce((acc, row) => acc + row.amount, 0) / data.length,
              )}{" "}
              (avg)
            </span>,
          ],
        }),
        createColumn("status", {
          header: { label: "Status" },
          resizable: true,
          renderCell: (value: StatusValue) => formatStatus(value),
          sortable: sortStatus,
        }),
        createColumn("order_date", {
          header: { label: "Order Date" },
          sortable: true,
        }),
        createColumn("payment_type", {
          header: { label: "Payment Type" },
          renderCell: (value: PaymentType) => formatPaymentType(value),
          sortable: true,
          minWidth: 150,
        }),
      ];

      function App() {
        return (
          <DataTable
            data={data}
            columns={columns.map((column, index) => ({
              ...column,
              pinned: index === 0 ? "left" : index === 4 ? "right" : undefined,
            }))}
          />
        );
      }

      export default App;
      ```
    </LiveCode>

    <Warning>
      Grouped columns cannot be pinned.
    </Warning>

    ### Sorting columns

    If the `sortable` property is used in a column definition, it will attempt to sort the rows of the table when the column header is clicked.

    #### Default sorting (`boolean`)

    If the `sortable` property is set to `true`, clicking on the column header will attempt to sort the rows alphabetically based on the column data—first ascending, then descending, then unsorted. The sorting applies to the text or numeric value of the cell, regardless of what is rendered by the `renderCell` function, if provided.

    ```ts theme={null}
    createColumn("customer_name", {
      header: { label: "Name" },
      sortable: true,
    }),
    ```

    #### Custom sorting (function)

    In some cases, especially with array values, it may be easier to control the sorting logic for a column. To do this, pass a function to the `sortable` property in the column definition:

    ```ts theme={null}
    createColumn("customer_name", {
      header: { label: "Name" },
      // sortable: (a, b) => number
      sortable: (nameA, nameB) => {
        // sort by last name
        const nameAWords = nameA.split(/\s+/);
        const lastNameA = nameAWords[nameAWords.length - 1];

        const nameBWords = nameB.split(/\s+/);
        const lastNameB = nameBWords[nameBWords.length - 1];

        return lastNameA.localeCompare(lastNameB);
      },
    }),
    ```

    #### Controlling sort (props)

    ##### Default sorted column

    A column can be sorted by default using the `DataTable.defaultSortedColumn` prop. The `onSort` callback is called when the user clicks on a sortable column header cell, and passes the column `id` and whether the column is sorted descending. If the new sort state is unsorted, it will pass `undefined` instead.

    <LiveCode showCode example="data-table-sorting" fullWidth screenshot>
      ```tsx lines expandable theme={null}
      import {
        DataTable,
        createColumnHelper,
        chipsFormatter,
        currencyFormatter,
        type TableRow,
      } from "@servicetitan/anvil2/beta";

      type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
      type PaymentType =
        | "credit_card"
        | "cash"
        | "bank_transfer"
        | "check"
        | "paypal";

      type StatusValue = OrderData["status"];

      type OrderData = {
        id: string;
        amount: number;
        status?: Status[];
        order_date: string;
        payment_type: PaymentType;
      };

      const data: TableRow<OrderData>[] = [
        {
          id: "ORD-2024-001",
          amount: 2450.75,
          status: ["completed"],
          order_date: "09/15/2024",
          payment_type: "credit_card",
        },
        {
          id: "ORD-2024-002",
          amount: 15750.0,
          status: ["shipped"],
          order_date: "09/18/2024",
          payment_type: "bank_transfer",
        },
        {
          id: "ORD-2024-003",
          amount: 89.99,
          status: ["pending"],
          order_date: "09/20/2024",
          payment_type: "paypal",
        },
        {
          id: "ORD-2024-004",
          amount: 1200.5,
          status: ["processing"],
          order_date: "09/12/2024",
          payment_type: "credit_card",
        },
        {
          id: "ORD-2024-005",
          amount: 0.0,
          status: ["cancelled"],
          order_date: "09/10/2024",
          payment_type: "credit_card",
        },
        {
          id: "ORD-2024-006",
          amount: 8925.0,
          status: ["completed"],
          order_date: "08/28/2024",
          payment_type: "check",
        },
        {
          id: "ORD-2024-007",
          amount: 45.99,
          status: ["shipped"],
          order_date: "09/21/2024",
          payment_type: "cash",
        },
        {
          id: "ORD-2024-008",
          amount: 3780.25,
          status: ["processing"],
          order_date: "09/19/2024",
          payment_type: "bank_transfer",
        },
        {
          id: "ORD-2024-009",
          amount: 156.78,
          status: ["completed"],
          order_date: "09/14/2024",
          payment_type: "paypal",
        },
        {
          id: "ORD-2024-010",
          amount: 2100.0,
          status: ["shipped", "cancelled"],
          order_date: "09/22/2024",
          payment_type: "credit_card",
        },
      ];

      const createColumn = createColumnHelper<OrderData>();

      const statusOrder: Record<Status, number> = {
        pending: 1,
        processing: 2,
        shipped: 3,
        completed: 4,
        cancelled: 5,
      };

      const formatStatus = (value: StatusValue) =>
        chipsFormatter(
          value?.map((status) => ({
            label: status.charAt(0).toUpperCase() + status.slice(1),
            color:
              status === "pending"
                ? "#f59e0b"
                : status === "shipped"
                  ? "#8b5cf6"
                  : status === "processing"
                    ? "#3b82f6"
                    : status === "completed"
                      ? "#10b981"
                      : "#ef4444",
          })),
        );

      const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
        const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
        const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

        return statusA - statusB;
      };

      const formatPaymentType = (value: PaymentType) => (
        <span>
          {value === "credit_card"
            ? "Credit Card"
            : value === "cash"
              ? "Cash"
              : value === "bank_transfer"
                ? "Bank Transfer"
                : value === "check"
                  ? "Check"
                  : value === "paypal"
                    ? "PayPal"
                    : value}
        </span>
      );

      const columns = [
        createColumn("id", {
          header: { label: "Order ID" },
          sortable: true,
          resizable: true,
          minWidth: 130,
          footerContent: (
            <span>
              <b>Total:</b> {data.length}
            </span>
          ),
        }),
        createColumn("amount", {
          header: { label: "Amount" },
          renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
          sortable: true,
          footerContent: [
            <span key="total">
              {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
            </span>,
            <span key="average">
              {currencyFormatter(
                data.reduce((acc, row) => acc + row.amount, 0) / data.length,
              )}{" "}
              (avg)
            </span>,
          ],
        }),
        createColumn("status", {
          header: { label: "Status" },
          resizable: true,
          renderCell: (value: StatusValue) => formatStatus(value),
          sortable: sortStatus,
        }),
        createColumn("order_date", {
          header: { label: "Order Date" },
          sortable: true,
        }),
        createColumn("payment_type", {
          header: { label: "Payment Type" },
          renderCell: (value: PaymentType) => formatPaymentType(value),
          sortable: true,
          minWidth: 150,
        }),
      ];

      function App() {
        return (
          <DataTable
            data={data}
            columns={columns}
            defaultSortedColumn={{ id: "status", desc: false }}
          />
        );
      }

      export default App;
      ```
    </LiveCode>

    ##### Controlled sort state

    It is also possible to fully control the sorting state using the `sortedColumn` and `onSort` props of the `DataTable`.

    ```tsx theme={null}
    import { useState } from "react";
    import { DataTable, SortedColumn } from "@servicetitan/anvil2/beta";

    export const SortedDataTable () => {
      const [sortedColumn, setSortedColumn] = useState<SortedColumn | undefined>({
        id: "customer_name",
        desc: false,
      });

      return (
        <DataTable
          data={data}
          columns={primaryColumns}
          onSort={setSortedColumn}
          sortedColumn={sortedColumn}
        />
      );
    };
    ```

    ## Working with cells

    ### Formatting data in cells

    The `renderCell` property in the `createColumn` options parameter enables custom rendering of cell data. If no `renderCell` function is provided, the cell will attempt to render the current value as plain text. When your read view returns JSX or multiline content but default sorting should still use plain text, add `getCellText` for the simple case, or `getReadRenderResult` when you want one hook to return both rendered content and raw sortable text.

    Anvil2 provides utility functions for common render patterns. In most cases, **these formatters should be used**, but there may be edge cases that aren't supported.

    #### Default formatters

    Currently, the following formatter functions are available:

    * `booleanFormatter`: show boolean as customizable text (e.g., "True"/"False", "Yes"/"No", "On"/"Off")
    * `chipsFormatter`: show chips in cell, with optional truncation
    * `currencyFormatter`: show number as currency, with i18n options
    * `dateFormatter`: show date with i18n options (accepts ISO string)
    * `dateTimeFormatter`: show date and time with i18n options (accepts ISO string or Date object)
    * `numberFormatter`: show number with i18n options, grouping separators, and notation styles
    * `percentFormatter`: show number as percentage, with i18n options
    * `timeFormatter`: show time with i18n options (accepts ISO time string)
    * `yearlessDateFormatter`: show month and day without year (accepts Temporal PlainMonthDay string or YearlessDate object)

    <Note>
      In the future, we plan to have a robust set of formatters available for common cell formats to avoid inconsistencies. Feel free to contribute formatters that cover common use cases, and we would be happy to review! For complex or unproven use cases, we may suggest adding it to the `anvil2-ext-common` library instead. [Learn more about this extended library here](https://github.com/servicetitan/hammer/tree/main/packages/ext-common/README.md).
    </Note>

    Import these functions from Anvil2 and apply them when defining the columns:

    <LiveCode showCode example="data-table-formatters" fullWidth screenshot>
      ```tsx lines expandable theme={null}
      import {
        DataTable,
        createColumnHelper,
        booleanFormatter,
        currencyFormatter,
        dateFormatter,
        numberFormatter,
        percentFormatter,
        timeFormatter,
        type TableRow,
      } from "@servicetitan/anvil2/beta";

      type ProductData = {
        id: string;
        is_active: boolean;
        quantity: number;
        amount_due: number;
        paid_percent: number;
        order_date: string;
        start_time: string;
      };

      const data: TableRow<ProductData>[] = [
        {
          id: "PRD-001",
          is_active: true,
          quantity: 1250,
          amount_due: 2450.75,
          paid_percent: 0.85,
          order_date: "2024-09-15",
          start_time: "08:30:00",
        },
        {
          id: "PRD-002",
          is_active: false,
          quantity: 340,
          amount_due: 15750.0,
          paid_percent: 0.42,
          order_date: "2024-09-18",
          start_time: "14:15:00",
        },
        {
          id: "PRD-003",
          is_active: true,
          quantity: 89,
          amount_due: 89.99,
          paid_percent: 1.0,
          order_date: "2024-09-20",
          start_time: "09:00:00",
        },
        {
          id: "PRD-004",
          is_active: true,
          quantity: 5600,
          amount_due: 1200.5,
          paid_percent: 0.0,
          order_date: "2024-09-12",
          start_time: "17:45:00",
        },
      ];

      const createColumn = createColumnHelper<ProductData>();

      const columns = [
        createColumn("id", {
          header: { label: "Product ID" },
          minWidth: 100,
        }),
        createColumn("is_active", {
          header: { label: "Active" },
          renderCell: (value: ProductData["is_active"]) => booleanFormatter(value),
          minWidth: 80,
        }),
        createColumn("quantity", {
          header: { label: "Quantity" },
          renderCell: (value: ProductData["quantity"]) => numberFormatter(value),
          minWidth: 100,
        }),
        createColumn("amount_due", {
          header: { label: "Amount Due" },
          renderCell: (value: ProductData["amount_due"]) => currencyFormatter(value),
          minWidth: 120,
        }),
        createColumn("paid_percent", {
          header: { label: "Paid %" },
          renderCell: (value: ProductData["paid_percent"]) => percentFormatter(value),
          minWidth: 80,
        }),
        createColumn("order_date", {
          header: { label: "Order Date" },
          renderCell: (value: string) => dateFormatter(value),
          minWidth: 130,
        }),
        createColumn("start_time", {
          header: { label: "Start Time" },
          renderCell: (value: string) => timeFormatter(value),
          minWidth: 110,
        }),
      ];

      function App() {
        return <DataTable data={data} columns={columns} />;
      }

      export default App;
      ```
    </LiveCode>

    To change the internationalization options for the `currencyFormatter` and `percentFormatter`, pass an object as the second parameter of the functions:

    ```tsx theme={null}
    createColumn("amount_due", {
      header: { label: "Amount Due" },
      renderCell: (value) => currencyFormatter(value, {
        locale: "en-AU",
        currency: "AUD",
      }),
    }),
    createColumn("paid_percent", {
      header: { label: "Paid %" },
      renderCell: (value) => percentFormatter(value, {
        locale: "en-AU",
      }),
    }),
    ```

    #### Boolean formatter

    The `booleanFormatter` displays `true` as "True" and `false` as "False" by default. Customize the labels for i18n or different use cases:

    ```tsx theme={null}
    // Default: "True" / "False"
    createColumn("is_active", {
      header: { label: "Active" },
      renderCell: booleanFormatter,
    }),

    // Custom labels: "Yes" / "No"
    createColumn("is_enabled", {
      header: { label: "Enabled" },
      renderCell: (value) => booleanFormatter(value, {
        trueLabel: "Yes",
        falseLabel: "No",
      }),
    }),

    // Custom labels: "On" / "Off"
    createColumn("is_on", {
      header: { label: "Status" },
      renderCell: (value) => booleanFormatter(value, {
        trueLabel: "On",
        falseLabel: "Off",
      }),
    }),
    ```

    #### Number formatter

    The `numberFormatter` provides flexible number formatting with full `Intl.NumberFormat` support:

    ```tsx theme={null}
    // Default formatting with grouping separators
    createColumn("quantity", {
      header: { label: "Quantity" },
      renderCell: numberFormatter,
    }),

    // Compact notation for large numbers (e.g., 1.2M, 45K)
    createColumn("views", {
      header: { label: "Views" },
      renderCell: (value) => numberFormatter(value, {
        notation: "compact",
      }),
    }),

    // Scientific notation
    createColumn("measurement", {
      header: { label: "Measurement" },
      renderCell: (value) => numberFormatter(value, {
        notation: "scientific",
      }),
    }),

    // Always show sign (+ or -)
    createColumn("change", {
      header: { label: "Change" },
      renderCell: (value) => numberFormatter(value, {
        signDisplay: "always",
      }),
    }),

    // Fixed decimal places
    createColumn("rating", {
      header: { label: "Rating" },
      renderCell: (value) => numberFormatter(value, {
        minimumFractionDigits: 1,
        maximumFractionDigits: 1,
      }),
    }),
    ```

    #### Date formatter

    The `dateFormatter` displays plain dates with locale-aware formatting. It accepts ISO date strings in `"YYYY-MM-DD"` format (like Temporal.PlainDate).

    For JavaScript `Date` objects or date-times with timezone information, use `dateTimeFormatter` instead.

    ```tsx theme={null}
    // Default format: "medium" (Jan 15, 2024)
    createColumn("order_date", {
      header: { label: "Order Date" },
      renderCell: dateFormatter,
    }),

    // Short format (01/15/2024)
    createColumn("created_at", {
      header: { label: "Created" },
      renderCell: (value) => dateFormatter(value, { format: "short" }),
    }),

    // Long format (January 15, 2024)
    createColumn("due_date", {
      header: { label: "Due Date" },
      renderCell: (value) => dateFormatter(value, { format: "long" }),
    }),

    // Full format (Monday, January 15, 2024)
    createColumn("event_date", {
      header: { label: "Event Date" },
      renderCell: (value) => dateFormatter(value, { format: "full" }),
    }),

    // Custom format using Intl.DateTimeFormatOptions
    createColumn("custom_date", {
      header: { label: "Custom" },
      renderCell: (value) => dateFormatter(value, {
        format: { weekday: "short", month: "short", day: "numeric" },
      }),
    }),
    ```

    #### Time formatter

    The `timeFormatter` displays times with locale-aware formatting. It accepts ISO time strings (`"HH:mm:ss"` or `"HH:mm"`):

    ```tsx theme={null}
    // Default format: "short" (08:30 AM)
    createColumn("start_time", {
      header: { label: "Start Time" },
      renderCell: timeFormatter,
    }),

    // Medium format with seconds (08:30:00 AM)
    createColumn("timestamp", {
      header: { label: "Timestamp" },
      renderCell: (value) => timeFormatter(value, { format: "medium" }),
    }),

    // Custom 24-hour format using Intl.DateTimeFormatOptions
    createColumn("military_time", {
      header: { label: "Time (24h)" },
      renderCell: (value) => timeFormatter(value, {
        format: { hour: "2-digit", minute: "2-digit", hour12: false },
      }),
    }),
    ```

    #### Date-time formatter

    The `dateTimeFormatter` displays both date and time together. It accepts ISO date-time strings (`"YYYY-MM-DDTHH:mm:ss"`) or JavaScript `Date` objects.

    **Timezone handling:**

    * Strings without timezone info are treated as "plain date-times" - no timezone conversion or display occurs
    * Strings with timezone info honor the provided timezone and display the timezone abbreviation (e.g., "PST", "UTC")
    * `Date` objects always display their timezone: local timezone by default, or use the `timeZone` option to specify an IANA timezone (e.g., `"America/New_York"`, `"Europe/London"`, `"UTC"`)

    ```tsx theme={null}
    // Plain date-time string (no timezone info)
    // "2024-01-15T09:30:00" → "Jan 15, 2024, 09:30 AM"
    createColumn("created_at", {
      header: { label: "Created At" },
      renderCell: dateTimeFormatter,
    }),

    // String with UTC timezone
    // "2024-01-15T09:30:00Z" → "Jan 15, 2024, 09:30 AM UTC"
    createColumn("utc_timestamp", {
      header: { label: "UTC Timestamp" },
      renderCell: dateTimeFormatter,
    }),

    // String with timezone offset
    // "2024-01-15T09:30:00-05:00" → "Jan 15, 2024, 09:30 AM EST"
    createColumn("eastern_time", {
      header: { label: "Eastern Time" },
      renderCell: dateTimeFormatter,
    }),

    // Date object with specific timezone
    createColumn("event_time", {
      header: { label: "Event (Tokyo)" },
      renderCell: (value) => dateTimeFormatter(value, {
        timeZone: "Asia/Tokyo",
      }),
    }),

    // Short date + short time
    createColumn("modified_at", {
      header: { label: "Modified" },
      renderCell: (value) => dateTimeFormatter(value, {
        dateFormat: "short",
        timeFormat: "short",
      }),
    }),

    // Long date + medium time
    createColumn("scheduled_at", {
      header: { label: "Scheduled" },
      renderCell: (value) => dateTimeFormatter(value, {
        dateFormat: "long",
        timeFormat: "medium",
      }),
    }),

    ```

    #### Yearless date formatter

    The `yearlessDateFormatter` displays month and day without the year. It accepts:

    * Temporal PlainMonthDay string format (`"--MM-DD"`)
    * `YearlessDate` object (`{ month: number, day: number }`) from Anvil2's `DateFieldYearless` component

    ```tsx theme={null}
    // Using string format (Temporal PlainMonthDay)
    // Default format: "long" (January 15)
    createColumn("birthday", {
      header: { label: "Birthday" },
      renderCell: yearlessDateFormatter,
    }),

    // Short format (Jan 05)
    createColumn("anniversary", {
      header: { label: "Anniversary" },
      renderCell: (value) => yearlessDateFormatter(value, { format: "short" }),
    }),

    // Custom format using Intl.DateTimeFormatOptions
    createColumn("recurring_date", {
      header: { label: "Recurring" },
      renderCell: (value) => yearlessDateFormatter(value, {
        format: { month: "2-digit", day: "2-digit" },
      }),
    }),
    ```

    When using with `DateFieldYearless` component values (which return `YearlessDate` objects):

    ```tsx theme={null}
    // YearlessDate object format: { month: 7, day: 4 } → "July 4"
    createColumn("holiday", {
      header: { label: "Holiday" },
      renderCell: (value) => yearlessDateFormatter(value),
    }),
    ```

    Other options are available for specific formatters:

    | **Formatter**           | **Options**                                                                                                                                                                                                                                    |
    | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `booleanFormatter`      | `trueLabel: string`, `falseLabel: string`                                                                                                                                                                                                      |
    | `chipsFormatter`        | `truncateChips: boolean`                                                                                                                                                                                                                       |
    | `currencyFormatter`     | `locale: string`, `currency: string`                                                                                                                                                                                                           |
    | `dateFormatter`         | `locale: string`, `format: "short" \| "medium" \| "long" \| "full" \| Intl.DateTimeFormatOptions`                                                                                                                                              |
    | `dateTimeFormatter`     | `locale: string`, `dateFormat: "short" \| "medium" \| "long" \| "full" \| Intl.DateTimeFormatOptions`, `timeFormat: "short" \| "medium" \| "none" \| Intl.DateTimeFormatOptions`, `timeZone: IanaZone`                                         |
    | `numberFormatter`       | `locale: string`, `minimumFractionDigits: number`, `maximumFractionDigits: number`, `useGrouping: boolean`, `notation: "standard" \| "scientific" \| "engineering" \| "compact"`, `signDisplay: "auto" \| "never" \| "always" \| "exceptZero"` |
    | `percentFormatter`      | `locale: string`, `decimals: number`                                                                                                                                                                                                           |
    | `timeFormatter`         | `locale: string`, `format: "short" \| "medium" \| Intl.DateTimeFormatOptions`                                                                                                                                                                  |
    | `yearlessDateFormatter` | `locale: string`, `format: "short" \| "long" \| Intl.DateTimeFormatOptions`                                                                                                                                                                    |

    #### Chips formatter

    The `chipsFormatter` works for both `string` and `string[]` values. The `value` parameter passed from the `renderCell` option will use the type based on the column it maps to in the interface used in the `createColumnHelper` function. In our example, since `status` has type `"active" | "inactive`, it will assume a `string[]`, but in other cases it can be used with a single `string`.

    The `chipsFormatter` can be customized in two ways:

    1. Set the `truncateChips` option to `true` to automatically truncate chips when they overflow the cell boundaries.
    2. Map the `value` from the `renderCell` to an object that includes `ChipProps` (for adjusting the color, icon, etc.).

    ```tsx theme={null}
    createColumn("status", {
      header: { label: "Status" },
      renderCell: (value) =>
        chipsFormatter(
          value?.map((status) => ({
            label: status.charAt(0).toUpperCase() + status.slice(1), // capitalize
            color: status === "active" ? "#007A4D" : "#e13212", // change chip color
          })),
          {
            truncateChips: true,
          }
        ),
    }),
    ```

    #### Custom formats

    To customize the format of a cell in a way that isn't supported by the default formatters, use the `renderCell` property, and return a `ReactNode`.

    ```tsx theme={null}
    createColumn("status", {
      header: { label: "Status" },
      renderCell: (value) => value.map((status) => (
        <Text variant="body" size="small" subdued={status === "inactive"}>
          {status === "active" ? "Active" : "Inactive" }
        </Text>
      )),
    }),
    ```

    <Warning>
      We cannot guarantee accessibility or proper keyboard navigation for custom rendered cells, so it is up to the implementor to build with caution and test screen readers and keyboard navigation.
    </Warning>

    ### Overflow surface

    Read-only leaf columns can opt into an overflow disclosure surface. Use `overflow: { mode: "surface" }` when the mounted cell content may be clipped by a constrained column width or height and users need access to the full read-only value.

    ```tsx theme={null}
    createColumn("description", {
      header: { label: "Description" },
      defaultWidth: 220,
      overflow: { mode: "surface" },
    }),
    ```

    When the cell content is clipped, DataTable shows a trailing affordance on hover or focus. Users can open the Surface by clicking the cell, or by focusing the cell and pressing `Enter`, `Space`, or `F2`. Short cells that are not clipped do not show the affordance.

    <Note>
      `overflow` is only available for read-only leaf columns. It is not available on grouped columns or columns with `editConfig`.
    </Note>

    ### Editable cells

    The `editConfig` property can be used when defining columns to make data table cells editable. The primary modes are `"text"`, `"number"`, `"boolean"`, `"select"`, `"multiselect"`, and `"custom"`.

    Custom edit mode is for object-backed cells that keep a formatted read view while opening a custom multi-field editor surface.

    * `renderCell` stays on the read path.
    * `getCellText` is the preferred way to supply sortable plain text when the read UI is rich or multiline.
    * `getReadRenderResult` remains available for advanced cases where one hook should return both rendered read content and its sortable raw string.
    * `renderEditor` receives a typed `controller` with draft state, validation, focus, and close helpers.
    * `onCommit` persists the final committed object value, while `onDraftUpdate` remains available for per-keystroke draft updates.
    * Surface title, width, height, and close-button copy live under `editConfig.surface`.
    * If you want Enter-to-submit behavior in a custom editor, wrap the editable contents in a `<form onSubmit={controller.handleSubmit}>`; Enter only submits through that form contract.
    * Validation remains informational by default; set `blockOnValidationError: true` if submit-style close requests should stay open while validation errors exist.

    For the full custom-mode guide, see [Custom Edit Mode](/docs/web/components/data-table/beta-changes/custom-edit-mode).

    The appearance of the cell content still respects the read configuration. Each mode behaves differently when editing:

    * The **text** cell changes to a text input.
    * The **number** cell changes to a formatted number input with keyboard increment/decrement support.
    * The **boolean** cell opens a constrained true/false selector.
    * The **select** cell triggers a menu dropdown.
    * The **multiselect** cell triggers a popover with a search field and list view.
    * The **custom editable cell** opens an anchored custom editor surface from `renderEditor`.

    Editable cells open when the user clicks a cell, or focuses it and presses "Enter", "F2", or "Space". Simple modes commit through their normal close behavior, such as blur, selection, or confirming the inline field. In custom mode, submit-style close requests commit the current draft, while `Escape` discards it and returns focus to the originating cell. Enter submits a custom editor only when the focused control lives inside a form wired with `onSubmit={controller.handleSubmit}`. Validation errors stay informational unless `blockOnValidationError` is set to `true`.

    <Note>
      In `renderEditor`, wrap custom editor contents in a `<form onSubmit={controller.handleSubmit}>` if you want Enter to submit the draft.
    </Note>

    <LiveCode showCode example="data-table-editable" fullWidth screenshot>
      ```tsx lines expandable theme={null}
      import {
        DataTable,
        createColumnHelper,
        chipsFormatter,
        type TableRow,
      } from "@servicetitan/anvil2/beta";

      import { useState } from "react";

      type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
      type PaymentType =
        | "credit_card"
        | "cash"
        | "bank_transfer"
        | "check"
        | "paypal";
      type Category = "electronics" | "clothing" | "home" | "garden" | "other";

      type OrderData = {
        id: string;
        customer_name: string;
        status?: Status[];
        payment_type: PaymentType;
        categories: Category[];
      };

      const initialData: TableRow<OrderData>[] = [
        {
          id: "ORD-2024-001",
          customer_name: "Sophia Rodriguez",
          status: ["completed"],
          payment_type: "credit_card",
          categories: ["electronics", "home"],
        },
        {
          id: "ORD-2024-002",
          customer_name: "TechCorp Solutions",
          status: ["shipped"],
          payment_type: "bank_transfer",
          categories: ["electronics"],
        },
        {
          id: "ORD-2024-003",
          customer_name: "Ahmed Hassan",
          status: ["pending"],
          payment_type: "paypal",
          categories: ["clothing", "other"],
        },
        {
          id: "ORD-2024-004",
          customer_name: "Maria Gonzalez",
          status: ["processing"],
          payment_type: "credit_card",
          categories: ["home", "garden"],
        },
        {
          id: "ORD-2024-005",
          customer_name: "Chen Wei",
          status: ["cancelled"],
          payment_type: "credit_card",
          categories: ["electronics", "clothing"],
        },
        {
          id: "ORD-2024-006",
          customer_name: "Green Valley Farms",
          status: ["completed"],
          payment_type: "check",
          categories: ["garden"],
        },
        {
          id: "ORD-2024-007",
          customer_name: "Jennifer Kim",
          status: ["shipped"],
          payment_type: "cash",
          categories: ["clothing", "home"],
        },
      ];

      function App() {
        const [data, setData] = useState(initialData);

        const createColumn = createColumnHelper<OrderData>();

        const columns = [
          createColumn("id", {
            header: { label: "Order ID" },
            sortable: true,
            resizable: true,
            minWidth: 130,
            editConfig: {
              mode: "text",
              onChange: (value, rowId) => {
                setData((prev) =>
                  prev.map((row) =>
                    row.id === rowId ? { ...row, id: value as string } : row,
                  ),
                );
              },
            },
          }),
          createColumn("customer_name", {
            header: { label: "Customer" },
            sortable: true,
            resizable: true,
            editConfig: {
              mode: "text",
              onChange: (value, rowId) => {
                setData((prev) =>
                  prev.map((row) =>
                    row.id === rowId
                      ? { ...row, customer_name: value as string }
                      : row,
                  ),
                );
              },
            },
          }),
          createColumn("categories", {
            header: { label: "Categories" },
            minWidth: 180,
            resizable: true,
            renderCell: (value: Category[]) =>
              chipsFormatter(
                value?.map((val: Category) => ({
                  label: val.charAt(0).toUpperCase() + val.slice(1),
                })),
                { truncateChips: true },
              ),
            editConfig: {
              mode: "multiselect",
              options: [
                { id: "electronics", label: "Electronics" },
                { id: "clothing", label: "Clothing" },
                { id: "home", label: "Home" },
                { id: "garden", label: "Garden" },
                { id: "other", label: "Other" },
              ],
              onChange: (options, rowId) => {
                const value = options.map((o) => String(o.id)) as Category[];
                setData((prev) =>
                  prev.map((row) =>
                    row.id === rowId ? { ...row, categories: value } : row,
                  ),
                );
              },
            },
          }),
          createColumn("status", {
            header: { label: "Status" },
            resizable: true,
            renderCell: (value: Status[] | undefined) =>
              chipsFormatter(
                value?.map((val: Status) => ({
                  label: val.charAt(0).toUpperCase() + val.slice(1),
                  color:
                    val === "pending"
                      ? "#f59e0b"
                      : val === "shipped"
                        ? "#8b5cf6"
                        : val === "processing"
                          ? "#3b82f6"
                          : val === "completed"
                            ? "#10b981"
                            : "#ef4444",
                })),
              ),
            sortable: (
              valueA: Status[] | undefined,
              valueB: Status[] | undefined,
            ) => {
              const statusOrder = {
                pending: 1,
                processing: 2,
                shipped: 3,
                completed: 4,
                cancelled: 5,
              } as const;

              const statusA =
                valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;

              const statusB =
                valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

              return statusA - statusB;
            },
            editConfig: {
              mode: "multiselect",
              options: [
                { id: "pending", label: "Pending" },
                { id: "shipped", label: "Shipped" },
                { id: "processing", label: "Processing" },
                { id: "completed", label: "Completed" },
                { id: "cancelled", label: "Cancelled" },
              ],
              onChange: (options, rowId) => {
                const value = options.map((o) => String(o.id)) as Status[];
                setData((prev) =>
                  prev.map((row) =>
                    row.id === rowId ? { ...row, status: value } : row,
                  ),
                );
              },
            },
          }),
          createColumn("payment_type", {
            header: { label: "Payment Type" },
            renderCell: (value: PaymentType) => (
              <span>
                {value === "credit_card"
                  ? "Credit Card"
                  : value === "cash"
                    ? "Cash"
                    : value === "bank_transfer"
                      ? "Bank Transfer"
                      : value === "check"
                        ? "Check"
                        : value === "paypal"
                          ? "PayPal"
                          : value}
              </span>
            ),
            sortable: true,
            minWidth: 180,
            editConfig: {
              mode: "select",
              options: [
                { id: "credit_card", label: "Credit Card" },
                { id: "cash", label: "Cash" },
                { id: "bank_transfer", label: "Bank Transfer" },
                { id: "check", label: "Check" },
                { id: "paypal", label: "PayPal" },
              ],
              onChange: (option, rowId) => {
                if (option) {
                  setData((prev) =>
                    prev.map((row) =>
                      row.id === rowId
                        ? { ...row, payment_type: String(option.id) as PaymentType }
                        : row,
                    ),
                  );
                }
              },
            },
          }),
        ];

        return <DataTable data={data} columns={columns} />;
      }

      export default App;
      ```
    </LiveCode>

    #### Custom edit mode

    Use custom mode when a cell's committed value is an object and the read view should stay separate from the edit UI. This example keeps an address object intact, formats the read state as multiline text, uses `validateDraft` plus `onRequestClose` to keep invalid submit-style closes open, and commits the full object through `editConfig.onChange`. Because the editor is wrapped in `<form onSubmit={controller.handleSubmit}>`, pressing Enter inside a single-line field submits through that form.

    ```tsx theme={null}
    import { Button } from "@servicetitan/anvil2";
    import {
      DataTable,
      Flex,
      TextField,
      Textarea,
      createColumnHelper,
      type TableRow,
    } from "@servicetitan/anvil2/beta";
    import { useState } from "react";

    type AddressValue = {
      street1: string;
      street2: string;
      city: string;
      state: string;
      postalCode: string;
      deliveryNotes: string;
    };

    type CustomerRow = {
      id: string;
      customer: string;
      address: AddressValue;
    };

    const formatAddress = (value: AddressValue) =>
      [
        [value.street1, value.street2].filter(Boolean).join(", "),
        [value.city, [value.state, value.postalCode].filter(Boolean).join(" ")]
          .filter(Boolean)
          .join(", "),
        value.deliveryNotes ? `Notes: ${value.deliveryNotes}` : undefined,
      ]
        .filter(Boolean)
        .join("\n");

    const initialData: TableRow<CustomerRow>[] = [
      {
        id: "1",
        customer: "Bayside Coffee",
        address: {
          street1: "188 Market Street",
          street2: "Suite 400",
          city: "San Francisco",
          state: "CA",
          postalCode: "94105",
          deliveryNotes: "Leave with front desk before 3 PM.",
        },
      },
    ];

    function App() {
      const [data, setData] = useState(initialData);
      const createColumn = createColumnHelper<CustomerRow>();

      const columns = [
        createColumn("customer", {
          headerLabel: "Customer",
          sortable: true,
        }),
        createColumn("address", {
          headerLabel: "Address",
          minWidth: 280,
          sortable: true,
          renderCell: (value) => (
            <span style={{ whiteSpace: "pre-line" }}>{formatAddress(value)}</span>
          ),
          getReadRenderResult: (value) => ({
            content: (
              <span style={{ whiteSpace: "pre-line" }}>{formatAddress(value)}</span>
            ),
            rawString: formatAddress(value),
          }),
          editConfig: {
            mode: "custom",
            surface: {
              title: "Edit address",
              width: 360,
              maxHeight: 480,
              closeButtonLabel: "Save address changes",
            },
            validateDraft: (draftValue) => ({
              formError: draftValue.street1
                ? undefined
                : "Street address is required.",
              fieldErrors: draftValue.street1
                ? undefined
                : { street1: "Enter a street address." },
            }),
            onRequestClose: (request) => {
              if (request.reason !== "escape" && request.validation.formError) {
                return false;
              }
            },
            onChange: (value, rowId) => {
              setData((prev) =>
                prev.map((row) =>
                  row.id === rowId ? { ...row, address: value } : row,
                ),
              );
            },
            renderEditor: ({ controller, rowId }) => {
              const changedFieldsLabel = controller.changedFields
                .map(String)
                .join(", ");

              return (
                <form onSubmit={controller.handleSubmit}>
                  <Flex direction="column" gap={12}>
                    <TextField
                      ref={(element) => {
                        controller.setInitialFocus(
                          element ? () => element.focus() : null,
                        );
                      }}
                      label="Street address"
                      value={controller.draftValue.street1}
                      onChange={(event) =>
                        controller.updateDraftValue("street1", event.target.value)
                      }
                      error={controller.validation.fieldErrors?.street1}
                    />
                    <TextField
                      label="City"
                      value={controller.draftValue.city}
                      onChange={(event) =>
                        controller.updateDraftValue("city", event.target.value)
                      }
                    />
                    <Flex gap={12}>
                      <TextField
                        label="State"
                        maxLength={2}
                        value={controller.draftValue.state}
                        onChange={(event) =>
                          controller.updateDraftValue(
                            "state",
                            event.target.value.toUpperCase(),
                          )
                        }
                      />
                      <TextField
                        label="Postal code"
                        inputMode="numeric"
                        value={controller.draftValue.postalCode}
                        onChange={(event) =>
                          controller.updateDraftValue(
                            "postalCode",
                            event.target.value.replace(/[^\d]/g, "").slice(0, 5),
                          )
                        }
                      />
                    </Flex>
                    <Textarea
                      label="Delivery notes"
                      autoHeight
                      minRows={3}
                      value={controller.draftValue.deliveryNotes}
                      onChange={(event) =>
                        controller.updateDraftValue(
                          "deliveryNotes",
                          event.target.value,
                        )
                      }
                    />

                    {controller.validation.formError ? (
                      <div>{controller.validation.formError}</div>
                    ) : null}

                    {controller.isDirty ? (
                      <div>
                        Row {rowId} changed fields: {changedFieldsLabel || "none"}
                      </div>
                    ) : null}

                    <Flex gap={8}>
                      <Button type="submit">Save</Button>
                      <Button type="button" onClick={controller.discard}>
                        Discard
                      </Button>
                    </Flex>
                  </Flex>
                </form>
              );
            },
          },
        }),
      ];

      return <DataTable data={data} columns={columns} />;
    }
    ```

    For draft callbacks, close interception, and focus details, see [Custom Edit Mode](/docs/web/components/data-table/beta-changes/custom-edit-mode).

    ### Empty cell content

    By default, cells with empty values (`null`, `undefined`, or empty string) display an em dash (—). Customize this at the table or column level using the `emptyCellContent` prop.

    #### Table-wide default

    Set `emptyCellContent` on the `DataTable` to change the empty cell display for all columns:

    ```tsx theme={null}
    <DataTable
      data={rowData}
      columns={columns}
      emptyCellContent="N/A"
    />
    ```

    #### Per-column override

    Set `emptyCellContent` on individual column definitions to override the table default for specific columns:

    ```tsx theme={null}
    const columns = [
      createColumn("customer_name", {
        header: { label: "Customer" },
      }),
      createColumn("scheduled_date", {
        header: { label: "Scheduled" },
        type: "date",
        emptyCellContent: "No date",
      }),
      createColumn("notes", {
        header: { label: "Notes" },
        emptyCellContent: "No notes",
      }),
    ];
    ```

    Column-level values take precedence over the table-level default. When neither is set, the default em dash (—) is displayed.

    ## Working with rows

    ### Defining row data

    To show content within the data table body cells, the `DataTable.data` prop should be passed an array of objects. For type safety, use the `TableRow` type from Anvil2, and pass the same type interface that was used for the `createColumnHelper` function as the generic parameter:

    ```ts theme={null}
    import { type TableRow } from "@servicetitan/anvil2/beta";

    const rowData: TableRow<TableColumns>[] = [
      {
        id: "row-1",
        customer_name: "Jose Ramirez";
        address: "2401 Ontario Street, Cleveland, OH";
        status: "active";
        amount_due: 1276.43;
        paid_percent: 0.61;
      }
    ]
    ```

    ```tsx theme={null}
    <DataTable data={rowData} columns={columns} />
    ```

    <Note>
      Row data for data tables with grouped columns do not need to include the `group` id in the data structure. The parameters of the objects in the array passed to the `data` prop should map to the lowest-level column `id`, rather than the top-level groups.
    </Note>

    ### Loading `data` async

    The `DataTable.data` prop can also accept a `Promise` that resolves `TableRow<T>[]`. While the `Promise` is pending, a loading spinner will be rendered in the data table.

    ```tsx theme={null}
    async function fetchData() {
      // fetch data from the server
      // this should return TableRow<TableColumns>[]
    }
    return <DataTable data={fetchData()} columns={columns} />;
    ```

    ### Empty states

    Use the `emptyState` prop to display a customizable empty state when the table has no data. The prop accepts an object with an optional `svg` illustration component and optional `content` (any ReactNode).

    <LiveCode showCode example="data-table-empty-state" fullWidth screenshot>
      ```tsx lines expandable theme={null}
      import { Text, Flex, Link } from "@servicetitan/anvil2";
      import { DataTable, createColumnHelper } from "@servicetitan/anvil2/beta";
      import NoSearchResultsLight from "@servicetitan/anvil2-illustrations/illustrations/empty-state-no-search-results-light.svg";
      import NoSearchResultsDark from "@servicetitan/anvil2-illustrations/illustrations/empty-state-no-search-results-dark.svg";

      type OrderData = {
        id: string;
        customer: string;
        amount: number;
        status: string;
      };

      const createColumn = createColumnHelper<OrderData>();

      const columns = [
        createColumn("id", {
          header: { label: "Order ID" },
        }),
        createColumn("customer", {
          header: { label: "Customer" },
        }),
        createColumn("amount", {
          header: { label: "Amount" },
        }),
        createColumn("status", {
          header: { label: "Status" },
        }),
      ];

      function App() {
        return (
          <DataTable
            data={[]}
            columns={columns}
            emptyState={{
              svg: { light: NoSearchResultsLight, dark: NoSearchResultsDark },
              content: (
                <Flex
                  alignItems="center"
                  direction="column"
                  gap={2}
                  style={{ maxWidth: "420px" }}
                >
                  <Text subdued size="small" style={{ textAlign: "center" }}>
                    No results found. Try another search or{" "}
                    <Link href="#">create a new report</Link>.
                  </Text>
                </Flex>
              ),
            }}
          />
        );
      }

      export default App;
      ```
    </LiveCode>

    ### Indicating errors on rows and cells

    DataTable supports displaying error states on individual cells or entire rows. Errors are indicated with a red border, red text, and an error icon. Use the `meta` property on row data to set errors.

    <Note>
      When setting errors, prefer using descriptive string messages instead of boolean values. String messages provide accessible context that screen readers announce, helping users understand why a cell or row is in an error state.
    </Note>

    #### Cell-level errors

    <LiveCode showCode example="data-table-cell-errors" fullWidth screenshot>
      ```tsx lines expandable theme={null}
      import {
        DataTable,
        createColumnHelper,
        currencyFormatter,
        type TableRow,
      } from "@servicetitan/anvil2/beta";

      type OrderData = {
        id: string;
        customer_name: string;
        amount: number;
        status: string;
        note: string;
      };

      const createColumn = createColumnHelper<OrderData>();

      // Data with cell-level errors
      // Use string error messages to provide context about what's wrong
      const data: TableRow<OrderData>[] = [
        {
          id: "ORD-001",
          customer_name: "",
          amount: 2450.75,
          status: "completed",
          note: "Regular order",
          // String error message provides accessible context
          meta: { errors: { customer_name: "Customer name is required" } },
        } as TableRow<OrderData>,
        {
          id: "ORD-002",
          customer_name: "TechCorp Solutions",
          amount: 15750.0,
          status: "shipped",
          note: "Priority shipment",
          // No errors on this row
        },
        {
          id: "ORD-003",
          customer_name: "Ahmed Hassan",
          amount: -50.0,
          status: "pending",
          note: "",
          // Multiple cells can have errors
          meta: {
            errors: {
              amount: "Amount must be positive",
              note: "Note is required for pending orders",
            },
          },
        } as TableRow<OrderData>,
        {
          id: "ORD-004",
          customer_name: "Maria Gonzalez",
          amount: 1200.5,
          status: "processing",
          note: "This is an extremely long note that exceeds the maximum allowed character limit for this field",
          meta: { errors: { note: "Note exceeds 100 character limit" } },
        } as TableRow<OrderData>,
      ];

      const columns = [
        createColumn("id", {
          header: { label: "Order ID" },
          minWidth: 100,
        }),
        createColumn("customer_name", {
          header: { label: "Customer Name" },
          minWidth: 150,
        }),
        createColumn("amount", {
          header: { label: "Amount" },
          renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
          minWidth: 100,
        }),
        createColumn("status", {
          header: { label: "Status" },
          minWidth: 100,
        }),
        createColumn("note", {
          header: { label: "Note" },
          minWidth: 200,
        }),
      ];

      function App() {
        return <DataTable data={data} columns={columns} />;
      }

      export default App;
      ```
    </LiveCode>

    Set cell-level errors using the `meta.errors` property. This is an object keyed by column `id`, where the value is either a string message or `true`:

    ```tsx theme={null}
    import { type TableRow } from "@servicetitan/anvil2/beta";

    const rowData: TableRow<TableColumns>[] = [
      {
        id: "row-1",
        customer_name: "",
        amount: 1276.43,
        status: "active",
        meta: {
          errors: {
            // Preferred: string message provides accessible context
            customer_name: "Customer name is required",
          },
        },
      },
      {
        id: "row-2",
        customer_name: "Jose Ramirez",
        amount: -50,
        status: "pending",
        meta: {
          errors: {
            // Multiple cells can have errors
            amount: "Amount must be positive",
            status: "Status is invalid for this order type",
          },
        },
      },
    ];
    ```

    The error icon displays in the cell. When a string message is provided, hovering or focusing the icon shows a tooltip with the error message, and screen readers announce the error.

    #### Row-level errors

    <LiveCode showCode example="data-table-row-errors" fullWidth screenshot>
      ```tsx lines expandable theme={null}
      import {
        DataTable,
        createColumnHelper,
        currencyFormatter,
        type TableRow,
      } from "@servicetitan/anvil2/beta";

      type OrderData = {
        id: string;
        customer_name: string;
        amount: number;
        status: string;
      };

      const createColumn = createColumnHelper<OrderData>();

      // Data with row-level errors
      // Use string error messages to provide context about what's wrong with the entire row
      const data: TableRow<OrderData>[] = [
        {
          id: "ORD-001",
          customer_name: "Sophia Rodriguez",
          amount: 2450.75,
          status: "completed",
          // String error message provides accessible context
          meta: { rowError: "This order has validation issues that need review" },
        } as TableRow<OrderData>,
        {
          id: "ORD-002",
          customer_name: "TechCorp Solutions",
          amount: 15750.0,
          status: "shipped",
          // No error on this row
        },
        {
          id: "ORD-003",
          customer_name: "Ahmed Hassan",
          amount: 89.99,
          status: "pending",
          meta: { rowError: "Missing required shipping information" },
        } as TableRow<OrderData>,
        {
          id: "ORD-004",
          customer_name: "Maria Gonzalez",
          amount: 1200.5,
          status: "processing",
          // No error on this row
        },
        {
          id: "ORD-005",
          customer_name: "Chen Wei",
          amount: 0.0,
          status: "cancelled",
          meta: { rowError: "Order was cancelled due to payment failure" },
        } as TableRow<OrderData>,
      ];

      const columns = [
        createColumn("id", {
          header: { label: "Order ID" },
          minWidth: 120,
        }),
        createColumn("customer_name", {
          header: { label: "Customer Name" },
          minWidth: 180,
        }),
        createColumn("amount", {
          header: { label: "Amount" },
          renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
          minWidth: 100,
        }),
        createColumn("status", {
          header: { label: "Status" },
          minWidth: 100,
        }),
      ];

      function App() {
        return <DataTable data={data} columns={columns} />;
      }

      export default App;
      ```
    </LiveCode>

    Set row-level errors using the `meta.rowError` property. This displays an error icon in a dedicated column at the start of the row:

    ```tsx theme={null}
    import { type TableRow } from "@servicetitan/anvil2/beta";

    const rowData: TableRow<TableColumns>[] = [
      {
        id: "row-1",
        customer_name: "Sophia Rodriguez",
        amount: 2450.75,
        status: "completed",
        meta: {
          // Preferred: string message provides accessible context
          rowError: "This order has validation issues that need review",
        },
      },
      {
        id: "row-2",
        customer_name: "Chen Wei",
        amount: 0,
        status: "cancelled",
        meta: {
          rowError: "Missing required shipping information",
        },
      },
    ];
    ```

    The row error column automatically appears when any row has a `rowError` set, and is pinned to the left side of the table along with other internal columns (selection, expansion).

    #### Combining cell and row errors

    Cell-level and row-level errors can be used together. Use row-level errors for general validation issues that affect the entire row, and cell-level errors for specific field validation:

    ```tsx theme={null}
    const rowData: TableRow<TableColumns>[] = [
      {
        id: "row-1",
        customer_name: "",
        amount: -50,
        status: "pending",
        meta: {
          // Row-level error for the overall issue
          rowError: "Order cannot be processed due to validation errors",
          // Cell-level errors for specific fields
          errors: {
            customer_name: "Customer name is required",
            amount: "Amount must be positive",
          },
        },
      },
    ];
    ```

    ### Indicating warnings on rows and cells

    DataTable supports displaying warning states on individual cells or entire rows. Warnings are indicated with a yellow border and a warning icon, and are used for non-blocking advisory issues. Use the `meta` property on row data to set warnings.

    <Note>
      When setting warnings, prefer using descriptive string messages instead of boolean values. String messages provide accessible context that screen readers announce, helping users understand why a cell or row is in a warning state.
    </Note>

    #### Cell-level warnings

    <LiveCode showCode example="data-table-cell-warnings" fullWidth screenshot>
      ```tsx lines expandable theme={null}
      import {
        DataTable,
        createColumnHelper,
        currencyFormatter,
        type TableRow,
      } from "@servicetitan/anvil2/beta";

      type OrderData = {
        id: string;
        customer_name: string;
        amount: number;
        status: string;
        note: string;
      };

      const createColumn = createColumnHelper<OrderData>();

      // Data with cell-level warnings
      // Use string warning messages to provide context about advisory issues
      const data: TableRow<OrderData>[] = [
        {
          id: "ORD-001",
          customer_name: "Sophia Rodriguez",
          amount: 2450.75,
          status: "completed",
          note: "Regular order",
          // String warning message provides accessible context
          meta: { warnings: { customer_name: "Customer name may be incomplete" } },
        } as TableRow<OrderData>,
        {
          id: "ORD-002",
          customer_name: "TechCorp Solutions",
          amount: 15750.0,
          status: "shipped",
          note: "Priority shipment",
          // No warnings on this row
        },
        {
          id: "ORD-003",
          customer_name: "Ahmed Hassan",
          amount: 89.99,
          status: "pending",
          note: "",
          // Multiple cells can have warnings
          meta: {
            warnings: {
              amount: "Amount is below the typical order minimum",
              note: "Consider adding a note for pending orders",
            },
          },
        } as TableRow<OrderData>,
        {
          id: "ORD-004",
          customer_name: "Maria Gonzalez",
          amount: 1200.5,
          status: "processing",
          note: "This is an extremely long note that may exceed the recommended character limit for this field",
          meta: { warnings: { note: "Note is approaching the 100 character limit" } },
        } as TableRow<OrderData>,
      ];

      const columns = [
        createColumn("id", {
          header: { label: "Order ID" },
          minWidth: 100,
        }),
        createColumn("customer_name", {
          header: { label: "Customer Name" },
          minWidth: 150,
        }),
        createColumn("amount", {
          header: { label: "Amount" },
          renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
          minWidth: 100,
        }),
        createColumn("status", {
          header: { label: "Status" },
          minWidth: 100,
        }),
        createColumn("note", {
          header: { label: "Note" },
          minWidth: 200,
        }),
      ];

      function App() {
        return <DataTable data={data} columns={columns} />;
      }

      export default App;
      ```
    </LiveCode>

    Set cell-level warnings using the `meta.warnings` property. This is an object keyed by column `id`, where the value is either a string message or `true`:

    ```tsx theme={null}
    import { type TableRow } from "@servicetitan/anvil2/beta";

    const rowData: TableRow<TableColumns>[] = [
      {
        id: "row-1",
        customer_name: "Sophia Rodriguez",
        amount: 89.99,
        status: "pending",
        meta: {
          warnings: {
            // Preferred: string message provides accessible context
            amount: "Amount is below the typical order minimum",
          },
        },
      },
      {
        id: "row-2",
        customer_name: "Jose Ramirez",
        amount: 1200.5,
        status: "processing",
        meta: {
          warnings: {
            // Multiple cells can have warnings
            amount: "Amount exceeds the standard discount threshold",
            status: "Processing orders may experience delays",
          },
        },
      },
    ];
    ```

    The warning icon displays in the cell. When a string message is provided, hovering or focusing the icon shows a tooltip with the warning message, and screen readers announce the warning.

    #### Row-level warnings

    <LiveCode showCode example="data-table-row-warnings" fullWidth screenshot>
      ```tsx lines expandable theme={null}
      import {
        DataTable,
        createColumnHelper,
        currencyFormatter,
        type TableRow,
      } from "@servicetitan/anvil2/beta";

      type OrderData = {
        id: string;
        customer_name: string;
        amount: number;
        status: string;
      };

      const createColumn = createColumnHelper<OrderData>();

      // Data with row-level warnings
      // Use string warning messages to provide context about advisory issues for the entire row
      const data: TableRow<OrderData>[] = [
        {
          id: "ORD-001",
          customer_name: "Sophia Rodriguez",
          amount: 2450.75,
          status: "completed",
          // String warning message provides accessible context
          meta: { rowWarning: "This order has unusual pricing that may need review" },
        } as TableRow<OrderData>,
        {
          id: "ORD-002",
          customer_name: "TechCorp Solutions",
          amount: 15750.0,
          status: "shipped",
          // No warning on this row
        },
        {
          id: "ORD-003",
          customer_name: "Ahmed Hassan",
          amount: 89.99,
          status: "pending",
          meta: { rowWarning: "Shipping address has not been verified" },
        } as TableRow<OrderData>,
        {
          id: "ORD-004",
          customer_name: "Maria Gonzalez",
          amount: 1200.5,
          status: "processing",
          // No warning on this row
        },
        {
          id: "ORD-005",
          customer_name: "Chen Wei",
          amount: 0.0,
          status: "cancelled",
          meta: {
            rowWarning: "Order was cancelled but payment refund is still pending",
          },
        } as TableRow<OrderData>,
      ];

      const columns = [
        createColumn("id", {
          header: { label: "Order ID" },
          minWidth: 120,
        }),
        createColumn("customer_name", {
          header: { label: "Customer Name" },
          minWidth: 180,
        }),
        createColumn("amount", {
          header: { label: "Amount" },
          renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
          minWidth: 100,
        }),
        createColumn("status", {
          header: { label: "Status" },
          minWidth: 100,
        }),
      ];

      function App() {
        return <DataTable data={data} columns={columns} />;
      }

      export default App;
      ```
    </LiveCode>

    Set row-level warnings using the `meta.rowWarning` property. This displays a warning icon in a dedicated column at the start of the row:

    ```tsx theme={null}
    import { type TableRow } from "@servicetitan/anvil2/beta";

    const rowData: TableRow<TableColumns>[] = [
      {
        id: "row-1",
        customer_name: "Sophia Rodriguez",
        amount: 2450.75,
        status: "completed",
        meta: {
          // Preferred: string message provides accessible context
          rowWarning: "This order has unusual pricing that may need review",
        },
      },
      {
        id: "row-2",
        customer_name: "Chen Wei",
        amount: 0,
        status: "cancelled",
        meta: {
          rowWarning: "Shipping address has not been verified",
        },
      },
    ];
    ```

    The row warning column automatically appears when any row has a `rowWarning` set, and is pinned to the left side of the table along with other internal columns (selection, expansion).

    #### Combining cell and row warnings

    Cell-level and row-level warnings can be used together. Use row-level warnings for general advisory issues that affect the entire row, and cell-level warnings for specific field-level advisories:

    ```tsx theme={null}
    const rowData: TableRow<TableColumns>[] = [
      {
        id: "row-1",
        customer_name: "Sophia Rodriguez",
        amount: 89.99,
        status: "pending",
        meta: {
          // Row-level warning for the overall advisory
          rowWarning: "This order may require additional review",
          // Cell-level warnings for specific fields
          warnings: {
            amount: "Amount is below the typical order minimum",
            status: "Pending orders older than 7 days are auto-cancelled",
          },
        },
      },
    ];
    ```

    #### Error and warning priority

    When both errors and warnings exist on the same cell or row, errors take priority. The error styling and icon are displayed, and the warning is hidden. This ensures that blocking validation issues are always surfaced over advisory messages:

    ```tsx theme={null}
    const rowData: TableRow<TableColumns>[] = [
      {
        id: "row-1",
        customer_name: "",
        amount: -50,
        status: "pending",
        meta: {
          // Error takes priority over warning for the row indicator
          rowError: "Order cannot be processed due to validation errors",
          rowWarning: "This order may require additional review",
          // Error takes priority over warning on the customer_name cell
          errors: { customer_name: "Customer name is required" },
          // Warning on customer_name is hidden because the error takes priority
          // Warning on amount displays since there is no error on that cell
          warnings: {
            customer_name: "Customer name format is unusual",
            amount: "Amount is below the typical order minimum",
          },
        },
      },
    ];
    ```

    ### Expanding rows

    There are two methods for expanding rows to show additional content:

    1. **Sub-rows**: the expanded content includes additional rows of data the have the same columns of the parent row.
    2. **Sub-components**: the expanded content includes arbitrary content that is considered "off-grid", or not part of the table data itself.

    Both of these are defined in the `DataTable.data` array, using the `subRows` or `subComponent` properties.

    #### Sub-rows

    The `subRows` property will have the same type requirements as the top-level rows to ensure the data maps to the same column structure.

    <LiveCode showCode example="data-table-expanded-rows" fullWidth screenshot>
      ```tsx lines expandable theme={null}
      import {
        DataTable,
        createColumnHelper,
        chipsFormatter,
        currencyFormatter,
        type TableRow,
      } from "@servicetitan/anvil2/beta";

      import { useState } from "react";

      type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
      type PaymentType =
        | "credit_card"
        | "cash"
        | "bank_transfer"
        | "check"
        | "paypal";

      type StatusValue = OrderData["status"];

      type OrderData = {
        id: string;
        amount: number;
        status?: Status[];
        order_date: string;
        customer_name: string;
        items: number;
        payment_type: PaymentType;
        note?: string;
      };

      const data: TableRow<OrderData>[] = [
        {
          id: "ORD-2024-001",
          amount: 2450.75,
          status: ["completed"],
          order_date: "09/15/2024",
          payment_type: "credit_card",
          items: 12,
          customer_name: "Sophia Rodriguez",
        },
        {
          id: "ORD-2024-002",
          amount: 15750.0,
          status: ["shipped"],
          order_date: "09/18/2024",
          payment_type: "bank_transfer",
          items: 45,
          customer_name: "TechCorp Solutions",
        },
        {
          id: "ORD-2024-003",
          amount: 89.99,
          status: ["pending"],
          items: 2,
          customer_name: "Ahmed Hassan",
          order_date: "09/20/2024",
          payment_type: "paypal",
        },
        {
          id: "ORD-2024-004",
          amount: 1200.5,
          status: ["processing"],
          order_date: "09/12/2024",
          payment_type: "credit_card",
          customer_name: "Maria Gonzalez",
          items: 8,
        },
        {
          id: "ORD-2024-005",
          amount: 0.0,
          status: ["cancelled"],
          order_date: "09/10/2024",
          payment_type: "credit_card",
          customer_name: "Chen Wei",
          items: 1,
        },
        {
          id: "ORD-2024-006",
          amount: 8925.0,
          status: ["completed"],
          order_date: "08/28/2024",
          payment_type: "check",
          customer_name: "Green Valley Farms",
          items: 156,
        },
        {
          id: "ORD-2024-007",
          amount: 45.99,
          customer_name: "Jennifer Kim",
          items: 1,
          status: ["shipped"],
          order_date: "09/21/2024",
          payment_type: "cash",
        },
      ];

      const createColumn = createColumnHelper<OrderData>();

      const statusOrder: Record<Status, number> = {
        pending: 1,
        processing: 2,
        shipped: 3,
        completed: 4,
        cancelled: 5,
      };

      const formatStatus = (value: StatusValue) =>
        chipsFormatter(
          value?.map((status) => ({
            label: status.charAt(0).toUpperCase() + status.slice(1),
            color:
              status === "pending"
                ? "#f59e0b"
                : status === "shipped"
                  ? "#8b5cf6"
                  : status === "processing"
                    ? "#3b82f6"
                    : status === "completed"
                      ? "#10b981"
                      : "#ef4444",
          })),
        );

      const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
        const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
        const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

        return statusA - statusB;
      };

      const formatPaymentType = (value: PaymentType) => (
        <span>
          {value === "credit_card"
            ? "Credit Card"
            : value === "cash"
              ? "Cash"
              : value === "bank_transfer"
                ? "Bank Transfer"
                : value === "check"
                  ? "Check"
                  : value === "paypal"
                    ? "PayPal"
                    : value}
        </span>
      );

      const columns = [
        createColumn("id", {
          header: { label: "Order ID" },
          sortable: true,
          resizable: true,
          minWidth: 130,
          footerContent: (
            <span>
              <b>Total:</b> {data.length}
            </span>
          ),
        }),
        createColumn("amount", {
          header: { label: "Amount" },
          renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
          sortable: true,
          footerContent: [
            <span key="total">
              {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
            </span>,
            <span key="average">
              {currencyFormatter(
                data.reduce((acc, row) => acc + row.amount, 0) / data.length,
              )}{" "}
              (avg)
            </span>,
          ],
        }),
        createColumn("status", {
          header: { label: "Status" },
          resizable: true,
          renderCell: (value: StatusValue) => formatStatus(value),
          sortable: sortStatus,
        }),
        createColumn("order_date", {
          header: { label: "Order Date" },
          sortable: true,
        }),
        createColumn("payment_type", {
          header: { label: "Payment Type" },
          renderCell: (value: PaymentType) => formatPaymentType(value),
          sortable: true,
          minWidth: 150,
        }),
      ];

      const expandedRowData: TableRow<OrderData>[] = data.map((row) => ({
        ...row,
        items: row.items * 2,
        subRows: [
          {
            ...row,
            id: `${row.id}-0`,
            items: row.items + 1,
            amount: row.amount / 2,
            status: undefined,
            note: undefined,
          },
          {
            ...row,
            id: `${row.id}-1`,
            items: row.items - 1,
            amount: row.amount / 2,
            status: undefined,
            note: undefined,
          },
        ],
      }));

      function App() {
        const [selectedRowIds, setSelectedRowIds] = useState<
          TableRow<typeof data>[0]["id"][]
        >(["ORD-2024-002", "ORD-2024-002-0"]);

        const handleSelection = (
          newSelectedRowIds: TableRow<typeof data>[0]["id"][],
        ) => {
          setSelectedRowIds(newSelectedRowIds);
        };

        return (
          <DataTable
            data={expandedRowData}
            columns={columns}
            isSelectable
            onSelectRow={handleSelection}
            selectedRowIds={selectedRowIds}
            defaultExpandedRowIds={["ORD-2024-002"]}
          />
        );
      }

      export default App;
      ```
    </LiveCode>

    #### Sub-components

    The `subComponent` property accepts a `ReactNode`, which is rendered in the expanded area. Some padding is added around the component, and the direct parent is a flex container.

    <LiveCode showCode example="data-table-sub-component" fullWidth screenshot>
      ```tsx lines expandable theme={null}
      import {
        DataTable,
        createColumnHelper,
        chipsFormatter,
        currencyFormatter,
        type TableRow,
      } from "@servicetitan/anvil2/beta";
      import { Card, Text, Button } from "@servicetitan/anvil2";

      type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
      type PaymentType =
        | "credit_card"
        | "cash"
        | "bank_transfer"
        | "check"
        | "paypal";

      type StatusValue = OrderData["status"];

      type OrderData = {
        id: string;
        amount: number;
        status?: Status[];
        order_date: string;
        customer_name: string;
        items: number;
        payment_type: PaymentType;
        note?: string;
      };

      const data: OrderData[] = [
        {
          id: "ORD-2024-001",
          amount: 2450.75,
          status: ["completed"],
          order_date: "09/15/2024",
          payment_type: "credit_card",
          items: 12,
          customer_name: "Sophia Rodriguez",
        },
        {
          id: "ORD-2024-002",
          amount: 15750.0,
          status: ["shipped"],
          order_date: "09/18/2024",
          payment_type: "bank_transfer",
          items: 45,
          customer_name: "TechCorp Solutions",
        },
        {
          id: "ORD-2024-003",
          amount: 89.99,
          status: ["pending"],
          items: 2,
          customer_name: "Ahmed Hassan",
          order_date: "09/20/2024",
          payment_type: "paypal",
        },
        {
          id: "ORD-2024-004",
          amount: 1200.5,
          status: ["processing"],
          order_date: "09/12/2024",
          payment_type: "credit_card",
          customer_name: "Maria Gonzalez",
          items: 8,
        },
        {
          id: "ORD-2024-005",
          amount: 0.0,
          status: ["cancelled"],
          order_date: "09/10/2024",
          payment_type: "credit_card",
          customer_name: "Chen Wei",
          items: 1,
        },
        {
          id: "ORD-2024-006",
          amount: 8925.0,
          status: ["completed"],
          order_date: "08/28/2024",
          payment_type: "check",
          customer_name: "Green Valley Farms",
          items: 156,
        },
        {
          id: "ORD-2024-007",
          amount: 45.99,
          customer_name: "Jennifer Kim",
          items: 1,
          status: ["shipped"],
          order_date: "09/21/2024",
          payment_type: "cash",
        },
        {
          id: "ORD-2024-008",
          amount: 3780.25,
          customer_name: "Roberto Silva",
          items: 23,
          status: ["processing"],
          order_date: "09/19/2024",
          payment_type: "bank_transfer",
        },
        {
          id: "ORD-2024-009",
          amount: 156.78,
          customer_name: "Emily Watson",
          items: 4,
          status: ["completed"],
          order_date: "09/14/2024",
          payment_type: "paypal",
        },
        {
          id: "ORD-2024-010",
          amount: 2100.0,
          customer_name: "Moonlight Café",
          items: 35,
          status: ["shipped", "cancelled"],
          order_date: "09/22/2024",
          payment_type: "credit_card",
        },
      ];

      const subComponentsData: TableRow<OrderData>[] = data.map((row) => ({
        ...row,
        subComponent: (
          <Card flexDirection="column" gap="2" style={{ width: "500px" }}>
            <Text variant="headline" size="small" el="h2">
              Sub Component
            </Text>
            <Text variant="body">
              Tab press should focus the button. Second Tab press should focus the
              next cell in the table. Shift+Tab press should focus the previous cell
              in the table.
            </Text>
            <Button size="small" appearance="primary">
              Focus Test
            </Button>
          </Card>
        ),
      }));

      const createColumn = createColumnHelper<OrderData>();

      const statusOrder: Record<Status, number> = {
        pending: 1,
        processing: 2,
        shipped: 3,
        completed: 4,
        cancelled: 5,
      };

      const formatStatus = (value: StatusValue) =>
        chipsFormatter(
          value?.map((status) => ({
            label: status.charAt(0).toUpperCase() + status.slice(1),
            color:
              status === "pending"
                ? "#f59e0b"
                : status === "shipped"
                  ? "#8b5cf6"
                  : status === "processing"
                    ? "#3b82f6"
                    : status === "completed"
                      ? "#10b981"
                      : "#ef4444",
          })),
        );

      const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
        const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
        const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

        return statusA - statusB;
      };

      const formatPaymentType = (value: PaymentType) => (
        <span>
          {value === "credit_card"
            ? "Credit Card"
            : value === "cash"
              ? "Cash"
              : value === "bank_transfer"
                ? "Bank Transfer"
                : value === "check"
                  ? "Check"
                  : value === "paypal"
                    ? "PayPal"
                    : value}
        </span>
      );

      const columns = [
        createColumn("id", {
          header: { label: "Order ID" },
          sortable: true,
          resizable: true,
          minWidth: 130,
          footerContent: (
            <span>
              <b>Total:</b> {data.length}
            </span>
          ),
        }),
        createColumn("amount", {
          header: { label: "Amount" },
          renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
          sortable: true,
          footerContent: [
            <span key="total">
              {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
            </span>,
            <span key="average">
              {currencyFormatter(
                data.reduce((acc, row) => acc + row.amount, 0) / data.length,
              )}{" "}
              (avg)
            </span>,
          ],
        }),
        createColumn("status", {
          header: { label: "Status" },
          resizable: true,
          renderCell: (value: StatusValue) => formatStatus(value),
          sortable: sortStatus,
        }),
        createColumn("order_date", {
          header: { label: "Order Date" },
          sortable: true,
        }),
        createColumn("payment_type", {
          header: { label: "Payment Type" },
          renderCell: (value: PaymentType) => formatPaymentType(value),
          sortable: true,
          minWidth: 150,
        }),
      ];

      function App() {
        return (
          <DataTable
            data={subComponentsData}
            columns={columns}
            defaultExpandedRowIds={["ORD-2024-001"]}
          />
        );
      }

      export default App;
      ```
    </LiveCode>

    <Note>
      When a user tabs to a sub-component, they exit the keyboard navigation of the data table, and can interact with the elements of the sub-component until they tab back to the data table cell.
    </Note>

    #### Controlling expanded state

    By default, the expanded state of rows is uncontrolled. The `DataGrid.defaultExpandedRowIds` prop can be used to expand particular rows by default.

    To control the expanded state of rows, use `expandedRowIds` with `onExpandRow`:

    ```tsx theme={null}
    const [expandedRowIds, setExpandedRowIds] = useState<string[]>(["row-1"]);
    return (
      <DataTable
        data={rowData}
        columns={columns}
        expandedRowIds={expandedRowIds}
        onExpandRow={setExpandedRowIds}
      />
    );
    ```

    <Note>
      The `expandedRowIds` should correspond to the parent row that expands, not the sub-rows.
    </Note>

    ### Selecting rows

    Row selection can be enabled using the `DataTable.isSelectable` prop. When `true`, a new column is added as the first column with a checkbox to select the row.

    #### Uncontrolled selection

    The `defaultSelectedRowIds` can be used to select specific rows on load, but keep the selection state uncontrolled.

    ```tsx theme={null}
    <DataTable
      data={rowData}
      columns={columns}
      isSelectable
      defaultSelectedRowIds={["row-1"]}
      onSelectRow={(selectedIds: string[]) => {
        // do something with the selected rows
      }}
    />
    ```

    #### Controlled selection

    The `selectedRowIds` and `onSelectRow` can be used to control selection state.

    <LiveCode showCode example="data-table-controlled-selection" fullWidth screenshot>
      ```tsx lines expandable theme={null}
      import {
        DataTable,
        createColumnHelper,
        chipsFormatter,
        currencyFormatter,
        type TableRow,
      } from "@servicetitan/anvil2/beta";

      import { useState } from "react";

      type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
      type PaymentType =
        | "credit_card"
        | "cash"
        | "bank_transfer"
        | "check"
        | "paypal";

      type StatusValue = OrderData["status"];

      type OrderData = {
        id: string;
        amount: number;
        status?: Status[];
        order_date: string;
        payment_type: PaymentType;
      };

      const data: TableRow<OrderData>[] = [
        {
          id: "ORD-2024-001",
          amount: 2450.75,
          status: ["completed"],
          order_date: "09/15/2024",
          payment_type: "credit_card",
        },
        {
          id: "ORD-2024-002",
          amount: 15750.0,
          status: ["shipped"],
          order_date: "09/18/2024",
          payment_type: "bank_transfer",
        },
        {
          id: "ORD-2024-003",
          amount: 89.99,
          status: ["pending"],
          order_date: "09/20/2024",
          payment_type: "paypal",
        },
        {
          id: "ORD-2024-004",
          amount: 1200.5,
          status: ["processing"],
          order_date: "09/12/2024",
          payment_type: "credit_card",
        },
        {
          id: "ORD-2024-005",
          amount: 0.0,
          status: ["cancelled"],
          order_date: "09/10/2024",
          payment_type: "credit_card",
        },
        {
          id: "ORD-2024-006",
          amount: 8925.0,
          status: ["completed"],
          order_date: "08/28/2024",
          payment_type: "check",
        },
        {
          id: "ORD-2024-007",
          amount: 45.99,
          status: ["shipped"],
          order_date: "09/21/2024",
          payment_type: "cash",
        },
      ];

      const createColumn = createColumnHelper<OrderData>();

      const statusOrder: Record<Status, number> = {
        pending: 1,
        processing: 2,
        shipped: 3,
        completed: 4,
        cancelled: 5,
      };

      const formatStatus = (value: StatusValue) =>
        chipsFormatter(
          value?.map((status) => ({
            label: status.charAt(0).toUpperCase() + status.slice(1),
            color:
              status === "pending"
                ? "#f59e0b"
                : status === "shipped"
                  ? "#8b5cf6"
                  : status === "processing"
                    ? "#3b82f6"
                    : status === "completed"
                      ? "#10b981"
                      : "#ef4444",
          })),
        );

      const sortStatus = (valueA: StatusValue, valueB: StatusValue) => {
        const statusA = valueA && valueA.length > 0 ? statusOrder[valueA[0]] : 999;
        const statusB = valueB && valueB.length > 0 ? statusOrder[valueB[0]] : 999;

        return statusA - statusB;
      };

      const formatPaymentType = (value: PaymentType) => (
        <span>
          {value === "credit_card"
            ? "Credit Card"
            : value === "cash"
              ? "Cash"
              : value === "bank_transfer"
                ? "Bank Transfer"
                : value === "check"
                  ? "Check"
                  : value === "paypal"
                    ? "PayPal"
                    : value}
        </span>
      );

      const columns = [
        createColumn("id", {
          header: { label: "Order ID" },
          sortable: true,
          resizable: true,
          minWidth: 130,
          footerContent: (
            <span>
              <b>Total:</b> {data.length}
            </span>
          ),
        }),
        createColumn("amount", {
          header: { label: "Amount" },
          renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
          sortable: true,
          footerContent: [
            <span key="total">
              {currencyFormatter(data.reduce((acc, row) => acc + row.amount, 0))}
            </span>,
            <span key="average">
              {currencyFormatter(
                data.reduce((acc, row) => acc + row.amount, 0) / data.length,
              )}{" "}
              (avg)
            </span>,
          ],
        }),
        createColumn("status", {
          header: { label: "Status" },
          resizable: true,
          renderCell: (value: StatusValue) => formatStatus(value),
          sortable: sortStatus,
        }),
        createColumn("order_date", {
          header: { label: "Order Date" },
          sortable: true,
        }),
        createColumn("payment_type", {
          header: { label: "Payment Type" },
          renderCell: (value: PaymentType) => formatPaymentType(value),
          sortable: true,
          minWidth: 150,
        }),
      ];

      function App() {
        const [selectedRowIds, setSelectedRowIds] = useState<
          TableRow<typeof data>[0]["id"][]
        >(["ORD-2024-002", "ORD-2024-002-0"]);

        const handleSelection = (
          newSelectedRowIds: TableRow<typeof data>[0]["id"][],
        ) => {
          setSelectedRowIds(newSelectedRowIds);
        };

        return (
          <DataTable
            data={data}
            columns={columns}
            isSelectable
            onSelectRow={handleSelection}
            selectedRowIds={selectedRowIds}
            defaultExpandedRowIds={["ORD-2024-002"]}
          />
        );
      }

      export default App;
      ```
    </LiveCode>

    ### Activating rows

    Row activation is a click-driven highlight state independent of selection. Enable it with the `DataTable.isActivatable` prop, passed as a boolean to opt every row in, or as a function to opt in per-row. Clicking an activatable row toggles it into the active set; when a parent row is activated, the active state cascades to every descendant.

    #### Uncontrolled activation

    The `defaultActiveRowIds` can be used to activate specific rows on load, but keep the activation state uncontrolled.

    ```tsx theme={null}
    <DataTable
      data={rowData}
      columns={columns}
      isActivatable
      defaultActiveRowIds={["row-1"]}
      onActivateRow={(activeIds: string[]) => {
        // respond to activation changes
      }}
    />
    ```

    #### Controlled activation

    The `activeRowIds` and `onActivateRow` can be used to control activation state.

    ```tsx theme={null}
    <DataTable
      data={rowData}
      columns={columns}
      isActivatable
      activeRowIds={activeRowIds}
      onActivateRow={setActiveRowIds}
    />
    ```

    #### Conditional activation

    Pass a function to `isActivatable` to opt specific rows in based on row data.

    ```tsx theme={null}
    <DataTable
      data={rowData}
      columns={columns}
      isActivatable={(row) => row.status !== "archived"}
      onActivateRow={setActiveRowIds}
    />
    ```

    ### Read-only rows

    Pass row IDs to `readOnlyRowIds` to render those rows as read-only. Read-only rows gray their plain-text cell content, show a `not-allowed` cursor when the row is also activatable, and disable the selection checkbox. Links, chips, and custom cell content keep their own color.

    Pre-seeded `selectedRowIds` and `activeRowIds` are still honored — `readOnlyRowIds` only gates user interaction, not the visual state.

    ```tsx theme={null}
    <DataTable
      data={rowData}
      columns={columns}
      isSelectable
      isActivatable
      readOnlyRowIds={["row-3", "row-4"]}
    />
    ```

    ## Pagination

    For larger data sets, pagination is the preferred method for only rendering a maximum number of rows at a time. This is done with the `DataTable.pagination` prop, which accepts either a `boolean`, or a config object with the following shape:

    ```ts theme={null}
    export type DataTablePaginationConfig<T> = {
      /**
       * Number of rows displayed per page
       * @default 25
       */
      rowsPerPage?: number;
      /**
       * Available options for rows per page selection
       */
      rowsPerPageOptions?: number[];
      /**
       * Callback when rows per page changes
       */
      onRowsPerPageChange?: (rowsPerPage: number) => void;
      /**
       * Current page index (0-based)
       */
      currentPageIndex?: number;
      /**
       * Function to get the data for the current page. When used, the data displayed will be
       * fully controlled by the implementor. This is useful for server-side pagination scenarios where
       * the total number of rows may differ from the data array length.
       */
      loadPageData?: ({
        pageIndex,
        pageSize,
        sorting,
      }: {
        pageIndex: number;
        pageSize: number;
        sorting?: SortedColumn;
      }) => TableRow<T>[] | Promise<TableRow<T>[]>;
      /**
       * Default page index for uncontrolled mode (0-based)
       * @default 0
       */
      defaultPageIndex?: number;
      /**
       * Callback when page changes. Receives the new page index (0-based)
       */
      onPageChange?: (pageIndex: number) => void;
      /**
       * Whether to display the item count information in pagination
       * @default true
       */
      showCount?: boolean;
      /**
       * Total number of rows across all pages. Falls back to data.length if not provided.
       * Useful for server-side pagination scenarios where totalRowCount may differ from data array length.
       */
      totalRowCount?: number;
      /**
       * Key that triggers data refetch when changed.
       * Useful for forcing refetch when external filters change.
       * When changed, the page index automatically resets to 0.
       */
      refreshKey?: string | number;
      /**
       * Configuration for caching loadPageData results.
       * Caching is enabled by default. Set `{ enabled: false }` to disable.
       */
      cache?: DataTableCacheOptions;
    };
    ```

    ### Uncontrolled pagination

    <LiveCode showCode example="data-table-pagination" fullWidth screenshot>
      ```tsx lines expandable theme={null}
      import {
        DataTable,
        createColumnHelper,
        chipsFormatter,
        currencyFormatter,
        type TableRow,
      } from "@servicetitan/anvil2/beta";

      type Status = "pending" | "shipped" | "processing" | "completed" | "cancelled";
      type PaymentType =
        | "credit_card"
        | "cash"
        | "bank_transfer"
        | "check"
        | "paypal";

      type StatusValue = OrderData["status"];

      type OrderData = {
        id: string;
        amount: number;
        status?: Status[];
        order_date: string;
        customer_name: string;
        payment_type: PaymentType;
      };

      const data: TableRow<OrderData>[] = [
        {
          id: "ORD-2024-001",
          amount: 2450.75,
          status: ["completed"],
          order_date: "09/15/2024",
          customer_name: "Sophia Rodriguez",
          payment_type: "credit_card",
        },
        {
          id: "ORD-2024-002",
          amount: 15750.0,
          status: ["shipped"],
          order_date: "09/18/2024",
          customer_name: "TechCorp Solutions",
          payment_type: "bank_transfer",
        },
        {
          id: "ORD-2024-003",
          amount: 89.99,
          status: ["pending"],
          order_date: "09/20/2024",
          customer_name: "Ahmed Hassan",
          payment_type: "paypal",
        },
        {
          id: "ORD-2024-004",
          amount: 1200.5,
          status: ["processing"],
          order_date: "09/12/2024",
          customer_name: "Maria Gonzalez",
          payment_type: "credit_card",
        },
        {
          id: "ORD-2024-005",
          amount: 0.0,
          status: ["cancelled"],
          order_date: "09/10/2024",
          customer_name: "Chen Wei",
          payment_type: "credit_card",
        },
        {
          id: "ORD-2024-006",
          amount: 8925.0,
          status: ["completed"],
          order_date: "08/28/2024",
          customer_name: "Green Valley Farms",
          payment_type: "check",
        },
        {
          id: "ORD-2024-007",
          amount: 45.99,
          status: ["shipped"],
          order_date: "09/21/2024",
          customer_name: "Jennifer Kim",
          payment_type: "cash",
        },
      ];

      const createColumn = createColumnHelper<OrderData>();

      const formatStatus = (value: StatusValue) =>
        chipsFormatter(
          value?.map((status) => ({
            label: status.charAt(0).toUpperCase() + status.slice(1),
            color:
              status === "pending"
                ? "#f59e0b"
                : status === "shipped"
                  ? "#8b5cf6"
                  : status === "processing"
                    ? "#3b82f6"
                    : status === "completed"
                      ? "#10b981"
                      : "#ef4444",
          })),
        );

      const formatPaymentType = (value: PaymentType) => (
        <span>
          {value === "credit_card"
            ? "Credit Card"
            : value === "cash"
              ? "Cash"
              : value === "bank_transfer"
                ? "Bank Transfer"
                : value === "check"
                  ? "Check"
                  : value === "paypal"
                    ? "PayPal"
                    : value}
        </span>
      );

      const columns = [
        createColumn("id", {
          header: { label: "Order ID" },
          sortable: true,
          resizable: true,
          minWidth: 130,
        }),
        createColumn("customer_name", {
          header: { label: "Customer" },
          sortable: true,
          resizable: true,
        }),
        createColumn("amount", {
          header: { label: "Amount" },
          renderCell: (value: OrderData["amount"]) => currencyFormatter(value),
          sortable: true,
        }),
        createColumn("status", {
          header: { label: "Status" },
          resizable: true,
          renderCell: (value: StatusValue) => formatStatus(value),
        }),
        createColumn("order_date", {
          header: { label: "Order Date" },
          sortable: true,
        }),
        createColumn("payment_type", {
          header: { label: "Payment Type" },
          renderCell: (value: PaymentType) => formatPaymentType(value),
          sortable: true,
          minWidth: 150,
        }),
      ];

      function App() {
        return (
          <DataTable
            data={data}
            columns={columns}
            pagination={{
              rowsPerPage: 5,
              defaultPageIndex: 0,
              showCount: true,
            }}
          />
        );
      }

      export default App;
      ```
    </LiveCode>

    To add uncontrolled (client-side) pagination to a Data Table, set `pagination` to `true`:

    ```tsx theme={null}
    <DataTable data={rowData} columns={columns} pagination />
    ```

    Pass a config object for more fine-tuned control over the pagination:

    ```tsx theme={null}
    <DataTable
      data={rowData}
      columns={columns}
      pagination={{
        rowsPerPage: 5,
        defaultPageIndex: 1,
        onPageChange: (pageIndex) =>
          console.log(`do something with the ${pageIndex}`),
        showCount: false,
      }}
    />
    ```

    ### Controlled pagination

    To manually control the pagination state, use the `onPageChange` and `currentPageIndex` properties:

    ```tsx theme={null}
    const [currentPageIndex, setCurrentPageIndex] = useState<number>(0);
    return (
      <DataTable
        data={rowData}
        columns={columns}
        pagination={{
          currentPageIndex: 0,
          onPageChange: setCurrentPageIndex,
        }}
      />
    );
    ```

    ### Rows per page selection

    The pagination controls include a dropdown selector that allows users to change the number of rows displayed per page. This is enabled by default with options for 25, 50, and 100 rows per page.

    To show the dropdown selector and the available options, use the `rowsPerPageOptions` and `onRowsPerPageChange` props to control the `rowsPerPage` state:

    ```tsx theme={null}
    const [rowsPerPage, setRowsPerPage] = useState<number>(25);
    return (
      <DataTable
        data={rowData}
        columns={columns}
        pagination={{
          rowsPerPage,
          onRowsPerPageChange: setRowsPerPage,
          rowsPerPageOptions: [5,10,15,20,25],
        }}
      />
    );
    ```

    <Note>
      When the user changes the rows per page selection, the page automatically resets to the first page if the current page index no longer exists.
    </Note>

    ### Server-side pagination

    To prevent loading entire large data sets from a server, use the `loadPageData` property to control the data displayed in the table based on the current page. This can accept an array of row data, or a `Promise` that resolves an array of row data. While the `Promise` is pending, a loading spinner will be rendered in the data table.

    The `totalRowCount` property should also be used to display the total number of rows, since this is normally determined by the length of the `data` array.

    The `loadPageData` function receives a `sorting` parameter when a column is sorted, allowing the server to return data in the correct sort order. When sorting changes, the page index automatically resets to 0.

    ```tsx theme={null}
    const [totalRowCount, setTotalRowCount] = useState<number>(0); // update this with the length of the data from the server

    async function fetchPageData({
      pageIndex,
      pageSize,
      sorting,
    }: {
      pageIndex: number;
      pageSize: number;
      sorting?: SortedColumn;
    }) {
      // fetch data from the server for the given pageIndex, pageSize, and sorting
      // results are cached automatically per page index, so revisiting
      // a previously loaded page returns cached data without calling this again
      // this should return TableRow<TableColumns>[], similar to the data prop
    }
    return (
      <DataTable
        columns={columns}
        pagination={{
          loadPageData: fetchPageData,
          totalRowCount,
        }}
      />
    );
    ```

    ### Caching

    `DataTable` caches `loadPageData` results by default. When a user navigates to a previously loaded page, the cached data displays instantly without calling `loadPageData` again or showing a loading spinner.

    Configure caching behavior through the `cache` property on the pagination config:

    ```tsx theme={null}
    // Disable caching
    <DataTable
      columns={columns}
      pagination={{
        loadPageData: fetchPageData,
        totalRowCount,
        cache: { enabled: false },
      }}
    />

    // Configure max cache size (default: 15 pages)
    <DataTable
      columns={columns}
      pagination={{
        loadPageData: fetchPageData,
        totalRowCount,
        cache: { maxSize: 50 },
      }}
    />
    ```

    #### Clearing the cache

    Use a ref to imperatively clear the page data cache without refetching:

    ```tsx theme={null}
    import { useRef } from "react";
    import { DataTable, DataTableRef } from "@servicetitan/anvil2/beta";

    const ExampleComponent = () => {
      const dataTableRef = useRef<DataTableRef>(null);

      const handleClearCache = () => {
        dataTableRef.current?.clearCache();
      };

      return (
        <>
          <DataTable
            ref={dataTableRef}
            columns={columns}
            pagination={{
              loadPageData: fetchPageData,
              totalRowCount,
            }}
          />
          <button onClick={handleClearCache}>Clear Cache</button>
        </>
      );
    };
    ```

    Call `refresh()` to clear the cache, refetch data, and reset to the first page. Use this when the underlying data has changed and the table needs to reflect the update:

    ```tsx theme={null}
    const dataTableRef = useRef<DataTableRef>(null);

    const handleDataChange = () => {
      dataTableRef.current?.refresh();
    };
    ```

    #### Automatic cache invalidation

    The cache automatically clears when any of the following change:

    * `refreshKey` — Use this to invalidate the cache when external filters or search parameters change. The page index resets to 0
    * `rowsPerPage` — Cached data becomes invalid when the page size changes
    * `refresh()` — Calling `refresh()` on the ref clears the cache and refetches from page 0

    ```tsx theme={null}
    const [filters, setFilters] = useState({ status: "active" });

    <DataTable
      columns={columns}
      pagination={{
        loadPageData: fetchPageData,
        totalRowCount,
        refreshKey: JSON.stringify(filters),
      }}
    />
    ```

    <Note>
      Caching only applies when using `loadPageData` for server-side pagination. Client-side pagination with the `data` prop does not use this cache.
    </Note>

    ## Footers

    Data tables can include footer rows, which can be defined in two ways:

    1. With the `footerContent` option when defining a column. This method places the footer cell in the same column as the header and body cells according to the column definition.
    2. With the `customFooter` prop on the `DataTable` component. This method creates footer rows with cells that have custom-defined col-spans, rather than aligning with the rest of the table content.

    <Note>
      If the data table has a specific height or max-height, the footer columns will have sticky positioning when scrolling vertically.
    </Note>

    ### Default footers (`footerContent`)

    The `footerContent` property can accept a `ReactNode` or `ReactNode[]`. If an array is given, each node will add a new footer row.

    ```tsx theme={null}
    createColumn("amount_due", {
      header: { label: "Amount Due" },
      renderCell: currencyFormatter,
      // create two footer rows, one with the total amount due, and one with the average
      footerContent: [
        <span key="total">
          {currencyFormatter(rowData.reduce((acc, row) => acc + row.amount_due, 0))}
        </span>,
        <span key="average">
          {currencyFormatter(
            rowData.reduce((acc, row) => acc + row.amount_due, 0) / data.length,
          )}{" "}
          (avg)
        </span>,
      ]
    }),
    ```

    ### Custom footers (`DataTable.customFooter`)

    The `DataTable.customFooter` prop accepts an array of arrays, which can include objects that have `content: ReactNode` and `colSpan: number` properties. The `content` will render within the cell, and the `colSpan` will determine how many columns that cell covers.

    <LiveCode showCode example="data-table-custom-footer" fullWidth screenshot>
      ```tsx lines expandable theme={null}
      import {
        DataTable,
        createColumnHelper,
        currencyFormatter,
        type TableRow,
      } from "@servicetitan/anvil2/beta";

      type InvoiceData = {
        id: string;
        customer_name: string;
        service: string;
        amount: number;
        tax: number;
        total: number;
      };

      const data: TableRow<InvoiceData>[] = [
        {
          id: "INV-001",
          customer_name: "Jose Ramirez",
          service: "HVAC Repair",
          amount: 450.0,
          tax: 36.0,
          total: 486.0,
        },
        {
          id: "INV-002",
          customer_name: "Sophia Rodriguez",
          service: "Plumbing Install",
          amount: 1200.0,
          tax: 96.0,
          total: 1296.0,
        },
        {
          id: "INV-003",
          customer_name: "TechCorp Solutions",
          service: "Electrical Inspection",
          amount: 275.0,
          tax: 22.0,
          total: 297.0,
        },
        {
          id: "INV-004",
          customer_name: "Ahmed Hassan",
          service: "HVAC Maintenance",
          amount: 350.0,
          tax: 28.0,
          total: 378.0,
        },
      ];

      const createColumn = createColumnHelper<InvoiceData>();

      const columns = [
        createColumn("id", {
          header: { label: "Invoice ID" },
          minWidth: 100,
        }),
        createColumn("customer_name", {
          header: { label: "Customer" },
          minWidth: 150,
        }),
        createColumn("service", {
          header: { label: "Service" },
          minWidth: 150,
        }),
        createColumn("amount", {
          header: { label: "Amount" },
          renderCell: (value: InvoiceData["amount"]) => currencyFormatter(value),
          minWidth: 100,
        }),
        createColumn("tax", {
          header: { label: "Tax" },
          renderCell: (value: InvoiceData["tax"]) => currencyFormatter(value),
          minWidth: 80,
        }),
        createColumn("total", {
          header: { label: "Total" },
          renderCell: (value: InvoiceData["total"]) => currencyFormatter(value),
          minWidth: 100,
        }),
      ];

      const totalAmount = data.reduce((acc, row) => acc + row.amount, 0);
      const totalTax = data.reduce((acc, row) => acc + row.tax, 0);
      const grandTotal = data.reduce((acc, row) => acc + row.total, 0);

      function App() {
        return (
          <DataTable
            data={data}
            columns={columns}
            customFooter={[
              [
                { content: <b>Totals</b>, colSpan: 3 },
                { content: currencyFormatter(totalAmount), colSpan: 1 },
                { content: currencyFormatter(totalTax), colSpan: 1 },
                { content: <b>{currencyFormatter(grandTotal)}</b>, colSpan: 1 },
              ],
            ]}
          />
        );
      }

      export default App;
      ```
    </LiveCode>

    ## Optimizing re-renders with `getRowVersion`

    By default, every row in a `DataTable` re-renders whenever the parent component re-renders. For most tables this is fine, but on large tables or tables whose parent re-renders often, you can opt each row into memoization by providing a `getRowVersion` function.

    `getRowVersion` receives a row and returns a value that signals "this row has changed." Rows only re-render when that value differs between renders (or when table-managed state like selection or expansion changes). If a row's cell rendering depends on state outside the row object (for example, a highlighted row ID stored elsewhere), fold that state into the returned value so memoization stays correct.

    ```tsx theme={null}
    <DataTable
      data={data}
      columns={columns}
      getRowVersion={(row) => `${row.updatedAt}-${highlightedId}`}
    />
    ```

    If `getRowVersion` is not provided, rows behave exactly as before — no memoization, no surprises.

    ## React Accessibility

    * The DataTable implements proper ARIA attributes for table structure and interactive elements
    * Sortable column headers are keyboard accessible and announce sort state to screen readers
    * Row selection checkboxes include appropriate `aria-label` attributes
    * Expand/collapse buttons include `aria-label`, `aria-expanded`, and `aria-controls` attributes
    * The component uses proper semantic HTML table elements (`table`, `thead`, `tbody`, `tr`, `th`, `td`)
    * Loading states are announced to screen readers using `aria-live` regions
    * Focus management ensures keyboard navigation follows expected patterns
    * Editable cells are keyboard accessible and announce their editable state

    ## Feedback

    The DataTable is in beta as the team gathers feedback on the API and visual design. Once stabilized, it will be promoted to the stable layer. If you encounter issues or have suggestions, reach out to the Anvil team in [#ask-designsystem](https://servicetitan.enterprise.slack.com/archives/CBSRGHTRS) on Slack.
  </Tab>

  <Tab title="DataTable Props">
    ```tsx theme={null}
    <DataTable
      data={data}
      columns={columns}
      isSelectable
      pagination={{
        rowsPerPage: 25,
        defaultPageIndex: 0,
        showCount: true,
      }}
      onSort={(sortedColumn) => console.log(sortedColumn)}
      onSelectRow={(selectedIds) => console.log(selectedIds)}
    />
    ```

    ## `DataTable` Props

    <ParamField path="columns" type="ColumnDef<T>[]" required>
      Column definitions created using the `createColumnHelper` factory function. Each column definition specifies how data should be displayed and what features are enabled.
    </ParamField>

    <ParamField path="data" type="TableRow<T>[] | Promise<TableRow<T>[]>" required>
      The data for the table. Must be an array of objects where each object has an `id` property. Can be a Promise for async data loading, in which case a loading spinner displays until the data resolves.
    </ParamField>

    <ParamField path="activeRowIds" type={`TableRow<T>["id"][]`}>
      The controlled active row IDs. Must be used with `onActivateRow` for controlled activation. Requires `isActivatable` to be set.
    </ParamField>

    <ParamField path="customFooter" type="CustomTableFooterCellProps[][]">
      Custom footer content for the table. Each array represents a footer row, with objects specifying `content` and `colSpan`. This footer is overridden by the `footerContent` property of individual columns.
    </ParamField>

    <ParamField path="defaultActiveRowIds" type={`TableRow<T>["id"][]`}>
      The default active row IDs for uncontrolled activation. Requires `isActivatable` to be set.
    </ParamField>

    <ParamField path="defaultExpandedRowIds" type={`"all" | TableRow<T>["id"][]`}>
      The default expanded row IDs for uncontrolled expansion. Use `"all"` to expand all rows, or provide an array of specific row IDs to expand.
    </ParamField>

    <ParamField path="defaultSelectedRowIds" type={`TableRow<T>["id"][]`}>
      The default selected row IDs for uncontrolled selection.
    </ParamField>

    <ParamField path="defaultSortedColumn" type="SortedColumn">
      The default sorted column for uncontrolled sorting. Object with `id` (column identifier) and `desc` (boolean for descending order).
    </ParamField>

    <ParamField path="disableVirtualization" type="boolean" default="false">
      Disables row virtualization. By default, tables with 101 or more rows virtualize their body to keep DOM size bounded; set this to `true` to render every row in the DOM regardless of count. Intended as an escape hatch for rollout safety and for flows that need every row mounted (for example, find-in-page, or certain print and export paths).
    </ParamField>

    <ParamField path="disableExpandAll" type="boolean" default="false">
      Disables the "expand all" action in the table header. When `true`, users can only expand individual rows.
    </ParamField>

    <ParamField path="disableSelectAll" type="boolean" default="false">
      Disables the "select all" action in the table header. When `true`, users can only select individual rows.
    </ParamField>

    <ParamField path="emptyState" type="DataTableEmptyState">
      Configuration for the empty state displayed when the table has no data. Accepts an object with optional `svg` (an SVG component) and `content` (ReactNode). Only rendered when data is empty and not loading.
    </ParamField>

    <ParamField path="emptyCellContent" type="string">
      Custom string to display when a cell value is empty (`null`, `undefined`, or empty string). Overrides the default em dash (—) for all columns. Individual columns can further override this with their own `emptyCellContent` property in the column definition.
    </ParamField>

    <ParamField path="expandedRowIds" type={`"all" | TableRow<T>["id"][]`}>
      The controlled expanded row IDs. Use `"all"` to expand all rows, or provide an array of specific row IDs. Must be used with `onExpandRow` for controlled expansion.
    </ParamField>

    <ParamField path="getRowVersion" type="(row: T) => unknown">
      Opt-in row memoization signal. When provided, each row is memoized and only re-renders when the returned value differs from the previous render, or when table-managed row state (selection, expansion, etc.) changes. When omitted, every row re-renders whenever the `DataTable` re-renders.

      Bake anything outside the row's own data that affects cell rendering into the returned value (for example `` `${row.updatedAt}-${highlightedId}` ``), otherwise cells may appear stale when external state changes.
    </ParamField>

    <ParamField path="isActivatable" type="boolean | ((row: T) => boolean)" default="false">
      Whether rows can be activated by clicking. When enabled, rows receive a pointer cursor and hover background, and clicking a row toggles it into the active set. Can be a boolean to enable activation for all rows, or a function to conditionally enable activation based on row data. Activating a parent row cascades the active state to every descendant.
    </ParamField>

    <ParamField path="isSelectable" type="boolean | ((row: T) => boolean)" default="false">
      Whether rows are selectable with checkboxes. Can be a boolean to enable selection for all rows, or a function to conditionally enable selection based on row data.
    </ParamField>

    <ParamField path="onActivateRow" type="(activeRowIds: TableRow<T>['id'][]) => void">
      Callback when row activation state changes. Receives an array of active row IDs.
    </ParamField>

    <ParamField path="onExpandRow" type="(expandedRowIds: 'all' | TableRow<T>['id'][]) => void">
      Callback when row expansion state changes. Receives either `"all"` when all rows are expanded, or an array of expanded row IDs.
    </ParamField>

    <ParamField path="onSelectRow" type="(selectedRowIds: TableRow<T>['id'][]) => void">
      Callback when row selection state changes. Receives an array of selected row IDs.
    </ParamField>

    <ParamField path="onSort" type="(sortedColumn: SortedColumn | undefined) => void">
      Callback when column sorting state changes. Receives the sorted column object with `id` and `desc` properties, or `undefined` when sorting is cleared.
    </ParamField>

    <ParamField path="pagination" type="boolean | DataTablePaginationConfig<T>">
      Pagination configuration. When `true`, enables pagination with default settings (25 items per page). When `false` or `undefined`, no pagination is applied and all data is shown. Provide a `DataTablePaginationConfig` object for custom configuration.
    </ParamField>

    <ParamField path="readOnlyRowIds" type={`TableRow<T>["id"][]`}>
      Row IDs that should render as read-only. Read-only rows gray their plain-text cell content, show a `not-allowed` cursor when the row is also activatable, and disable the selection checkbox. Links, chips, and custom cell content are unaffected. Pre-seeded `selectedRowIds` and `activeRowIds` are still honored — this prop only gates user interaction, not visual state.
    </ParamField>

    <ParamField path="selectedRowIds" type={`TableRow<T>["id"][]`}>
      The controlled selected row IDs. Must be used with `onSelectRow` for controlled selection.
    </ParamField>

    <ParamField path="sortedColumn" type="SortedColumn">
      The controlled sorted column. Object with `id` (column identifier) and `desc` (boolean for descending order). Must be used with `onSort` for controlled sorting.
    </ParamField>
  </Tab>

  <Tab title="Column Definition (ColumnDef)">
    ```tsx theme={null}
    const columnHelper = createColumnHelper<DataType>();

    const column = columnHelper("fieldName", {
      header: {
        label: "Column Header",
        required: true,
        moreInfo: "Shown in a tooltip trigger.",
      },
      sortable: true,
      resizable: true,
      align: "start",
      minWidth: 150,
      maxWidth: 400,
      renderCell: (value, { row, depth }) => <CustomComponent value={value} />,
    });
    ```

    ## Column Definition Parameters

    Columns are created using the `createColumnHelper` factory function, which provides type-safe column definitions.

    <ParamField path="header" type="{ label: string; required?: boolean; moreInfo?: ReactNode }" required>
      Header configuration object. `label` sets the visible header text, `required` adds the shared required affordance, and `moreInfo` renders a keyboard-accessible tooltip trigger.
    </ParamField>

    <ParamField path="header.label" type="string" required>
      The visible label text displayed in the column header.
    </ParamField>

    <ParamField path="header.required" type="boolean">
      Whether the header should display the shared required indicator.
    </ParamField>

    <ParamField path="header.moreInfo" type="ReactNode">
      Additional contextual content shown from the header more-info trigger.
    </ParamField>

    <ParamField path="Column Definition Options (below)" type="object" required />

    ### Column Definition Options

    <ParamField path="align" type={`"start" | "center" | "end"`}>
      The horizontal alignment of the header and cell content within the column.
    </ParamField>

    <ParamField path="columns" type="ColumnDef<T>[]">
      Nested columns for creating grouped column headers. Used when creating column groups with the `{ group: string }` syntax in `createColumnHelper`.
    </ParamField>

    <ParamField path="editMode" type={`"text" | "select" | "multiselect"`}>
      The edit mode for cells in this column. When set, cells become editable inline. Must be used with `onChange` callback.
    </ParamField>

    <ParamField path="emptyCellContent" type="string">
      Custom string to display when a cell value in this column is empty (`null`, `undefined`, or empty string). Overrides both the default em dash (—) and any table-level `emptyCellContent` set on the `DataTable` component.
    </ParamField>

    <ParamField path="footerContent" type="ReactNode | ReactNode[]">
      The content displayed in the footer cell for this column. When an array is provided, multiple footer rows are created, one for each array element.
    </ParamField>

    <ParamField path="maxWidth" type="number">
      The maximum width of the column in pixels. Useful for constraining column expansion.
    </ParamField>

    <ParamField path="minWidth" type="number">
      The minimum width of the column in pixels. Useful for ensuring minimum readability.
    </ParamField>

    <ParamField path="onChange" type="(value: T[keyof T], rowId: string) => void">
      Callback function called when the value of an editable cell is saved. Receives the new value and the row ID. Must be used with `editMode`.
    </ParamField>

    <ParamField path="options" type="{ value: T[keyof T]; label: string }[]">
      The options for select or multiselect edit modes. Each option has a `value` (the data value) and `label` (display text). Required when `editMode` is `"select"` or `"multiselect"`.
    </ParamField>

    <ParamField path="pinned" type={`"left" | "right"`}>
      The pinning location for the column. Pinned columns remain visible during horizontal scrolling.
    </ParamField>

    <ParamField path="renderCell" type="(value: T[keyof T], { row, depth }: { row?: TableRow<T>; depth?: number }) => ReactNode">
      Custom rendering function for cell content. Receives the cell value, the full row data object, and the row depth (0 for top-level rows, 1+ for nested rows). Return a React node to customize the cell display.
    </ParamField>

    <ParamField path="resizable" type="boolean">
      Whether the column can be resized by dragging the column border. When enabled, users can adjust column width within the `minWidth` and `maxWidth` constraints.
    </ParamField>

    <ParamField path="sortable" type="boolean | ((valueA: T[keyof T], valueB: T[keyof T]) => number)">
      Whether the column is sortable. When `true`, uses default comparison. When a function is provided, uses custom sorting logic (similar to `Array.sort` comparator).
    </ParamField>
  </Tab>

  <Tab title="Pagination Configuration">
    ```tsx theme={null}
    <DataTable
      data={data}
      columns={columns}
      pagination={{
        rowsPerPage: 25,
        currentPageIndex: 0,
        defaultPageIndex: 0,
        totalRowCount: 1000,
        refreshKey: "some-filter-key",
        cache: { enabled: true, maxSize: 15 },
        showCount: true,
        onPageChange: (pageIndex) => console.log(pageIndex),
        loadPageData: async ({ pageIndex, pageSize, sorting }) => {
          const url = new URL("/api", window.location.origin);
          url.searchParams.set("page", String(pageIndex));
          url.searchParams.set("size", String(pageSize));
          if (sorting) {
            url.searchParams.set("sortBy", sorting.id);
            url.searchParams.set("sortDir", sorting.desc ? "desc" : "asc");
          }
          const response = await fetch(url);
          return response.json();
        },
      }}
    />
    ```

    ## `DataTablePaginationConfig` Properties

    <ParamField path="cache" type="DataTableCacheOptions">
      Configuration for caching `loadPageData` results per page index:

      * `enabled` — Whether caching is enabled (default: `true`)
      * `maxSize` — Maximum number of pages to cache before clearing the entire cache (default: `15`)
    </ParamField>

    <ParamField path="currentPageIndex" type="number">
      Current page index (0-based) for controlled pagination mode. Must be used with `onPageChange` for controlled pagination.
    </ParamField>

    <ParamField path="defaultPageIndex" type="number" default="0">
      Default page index for uncontrolled pagination mode (0-based).
    </ParamField>

    <ParamField path="loadPageData" type="({ pageIndex, pageSize, sorting }) => TableRow<T>[] | Promise<TableRow<T>[]>">
      Function to load data for the current page. When provided, enables server-side pagination where the component doesn't manage the full dataset. Receives `pageIndex` (0-based), `pageSize`, and an optional `sorting` object (`{ id: string; desc: boolean }`) when a column is sorted. Use with `totalRowCount` to display accurate pagination controls.
    </ParamField>

    <ParamField path="onPageChange" type="(pageIndex: number) => void">
      Callback when the page changes. Receives the new page index (0-based).
    </ParamField>

    <ParamField path="onRowsPerPageChange" type="(rowsPerPage: number) => void">
      Callback when the rows per page selection changes. Receives the new rows per page value.
    </ParamField>

    <ParamField path="rowsPerPage" type="number" default="25">
      Number of rows displayed per page.
    </ParamField>

    <ParamField path="refreshKey" type="string | number">
      Key that triggers a data refetch and cache clear when its value changes. The page index automatically resets to 0 on change. Use this to force a refetch when external filters or search parameters change.
    </ParamField>

    <ParamField path="rowsPerPageOptions" type="number[]" default="[25, 50, 100]">
      Available options for rows per page selection. The dropdown selector will display these values as options for users to choose from.
    </ParamField>

    <ParamField path="showCount" type="boolean" default="true">
      Whether to display the item count information in the pagination controls (e.g., "Showing 1-25 of 100").
    </ParamField>

    <ParamField path="totalRowCount" type="number">
      Total number of rows across all pages. Falls back to `data.length` if not provided. Useful for server-side pagination scenarios where `totalRowCount` may differ from the data array length.
    </ParamField>
  </Tab>
</Tabs>
