{
  "contract": "tessli.public-library-item.v1",
  "canonicalPath": "/library/data-table",
  "representations": {
    "json": "/library/data-table/item.json",
    "markdown": "/library/data-table/item.md"
  },
  "preview": "/preview/data-table",
  "item": {
    "slug": "data-table",
    "title": "Data table with filters and states",
    "kind": "component",
    "category": "tables",
    "summary": "A compact, sortable table with labelled search and one filter, and designed loading, empty, no-results and error states that keep the layout still.",
    "status": "community",
    "install": "npx shadcn@latest add online-scope-lab/tessli/data-table",
    "registry": {
      "type": "registry:component",
      "registryDependencies": [
        "button"
      ],
      "dependencies": []
    },
    "notes": {
      "id": "data-table",
      "kind": "component",
      "category": "tables",
      "title": "Data table with filters and states",
      "summary": "A compact, sortable table with labelled search and one filter, and designed loading, empty, no-results and error states that keep the layout still.",
      "use_when": [
        "People scan, compare and open records that share the same few attributes, such as notes, invoices, members or orders.",
        "The full list fits in the browser (up to a few hundred rows), so search, filter and sort can run on the client.",
        "One or two columns carry the comparison (status, a count, a date) and deserve sorting."
      ],
      "avoid_when": [
        "Records have little in common or are read rather than compared; use a list of cards or a feed instead.",
        "There are thousands of rows; move search, sort and pagination to the server and keep these states.",
        "People need to edit cells in place, select many rows or reorder columns; use a grid component built for that.",
        "There are only two or three records; a short list reads better than a table."
      ],
      "rules": [
        "ST-01",
        "ST-02",
        "ST-03",
        "ST-04",
        "S-03",
        "T-03",
        "A-05",
        "R-02"
      ],
      "ai_default": "A bare table with roomy rows and every column in the same grey, a search box with only a placeholder, unsortable headers or click-to-sort with no aria-sort, a centred spinner (or nothing) while loading, one \"No data\" message for an empty account, a search with no matches and a failed request alike, and a table that either overflows the phone screen or is shrunk to tiny text.",
      "why_this_is_better": [
        "Five states are designed, not one. Loading keeps the header and placeholder rows so nothing jumps; first use offers the next step; no results repeats the query and clears filters in one action; an error says what failed and offers a retry.",
        "Sorting is a native button inside each header, and the sorted header carries aria-sort, so the order is announced as well as shown by the chevron.",
        "The search and the filter have visible labels, and the result count (\"Showing 3 of 12 notes\") sits in a polite live region, so screen-reader users hear the effect of each change without losing focus.",
        "Rows stay at 44px with compact padding, numbers are right-aligned in tabular figures, and the first column is a real row header that links to the record, so the table is quick to scan and compare.",
        "On narrow screens the table scrolls inside its own frame with the first column pinned and a text hint, so the page never scrolls sideways and every row stays identifiable.",
        "Placeholder rows do not pulse or shimmer; nothing moves unless someone acts."
      ],
      "failures": [
        "Dropping the loading rows for a spinner, which collapses the frame and makes the page jump when data arrives.",
        "Reusing the first-use message for no results, so people with a typo are told they have no data.",
        "Repeating the header's primary action inside the empty state, so two buttons compete for the same job.",
        "Adding more columns until the pinned column and one other are all a phone can show; move secondary detail to the record page instead.",
        "Turning rows into clickable divs instead of linking the row header, which breaks keyboard use, middle click and screen-reader row navigation.",
        "Colouring statuses without the status text, which fails in greyscale and for screen readers."
      ],
      "states": [
        "default",
        "loading",
        "empty",
        "no-results",
        "error"
      ],
      "responsive": {
        "desktop": "Title, description and the one primary action share a row above a labelled search box and filter; the table fills the width with compact 44px rows, text columns left and numbers right.",
        "mobile": "Header, action, search and filter stack full width in that order; the table scrolls sideways inside its bordered frame with the first column pinned and a line saying it scrolls, while the page itself never scrolls sideways."
      },
      "a11y": [
        "The section and the table are labelled by the visible heading; the first cell of each row is a th with scope=\"row\" and the column headers have scope=\"col\".",
        "Sortable headers are native buttons; the sorted column's th has aria-sort, and the chevron icon is decorative.",
        "Search and filter have visible labels and sit in a form with role=\"search\"; Enter does not reload the page.",
        "The result count is a polite live region that also reports loading, no data and a failed load; the error state adds a text label above its title, so it never relies on colour.",
        "When the table overflows, its scroll container becomes a labelled, focusable region so keyboard users can scroll it; focus rings are visible on every control.",
        "Clearing filters from the no-results state moves focus to the search box, because the button that was pressed disappears.",
        "Placeholder rows are hidden from assistive technology and the table is marked aria-busy while loading."
      ],
      "status": "community",
      "reviewed_by": [],
      "reviewed_at": null,
      "contributors": [],
      "provenance": "original",
      "license": "MIT"
    },
    "rules": [
      {
        "id": "ST-01",
        "statement": "Design empty, loading and error states for every region that loads or lists data.",
        "path": "/rules/ST-01"
      },
      {
        "id": "ST-02",
        "statement": "Give first use, no results and failure their own message and action.",
        "path": "/rules/ST-02"
      },
      {
        "id": "ST-03",
        "statement": "Reserve space so loading, toggles and validation don't shift surrounding content.",
        "path": "/rules/ST-03"
      },
      {
        "id": "ST-04",
        "statement": "Say what failed, what is still safe, and one way forward (retry, edit or contact).",
        "path": "/rules/ST-04"
      },
      {
        "id": "S-03",
        "statement": "Make data surfaces compact and reading surfaces roomy; don't use one density everywhere.",
        "path": "/rules/S-03"
      },
      {
        "id": "T-03",
        "statement": "Set prices, counts and numeric columns in tabular figures.",
        "path": "/rules/T-03"
      },
      {
        "id": "A-05",
        "statement": "Announce results, saves and errors that appear in place through a polite live region.",
        "path": "/rules/A-05"
      },
      {
        "id": "R-02",
        "statement": "Fit every surface into 320 CSS px with no sideways scroll; only tables and code may scroll.",
        "path": "/rules/R-02"
      }
    ],
    "explanation": "Most tables are designed for the moment the data is already there. This one is designed for the moments around it: while it loads, before anything exists, when a search finds nothing and when the request fails. Each of those gets its own copy and its own next step, and all of them keep the header and the frame in place so the page does not jump.\n\nThe table itself stays plain and compact. The first column is the row header and links to the record, numbers are right-aligned in tabular figures, and secondary text is muted so the names carry the eye. Sorting uses native buttons with `aria-sort`; search and the one filter have visible labels, and the result count is announced as it changes.\n\nPass columns and rows as plain data. On phones the table scrolls inside its own frame with the first column pinned, which keeps rows identifiable; if a phone can show only that column and one more, move the extra columns to the record page rather than squeezing them in. For large data sets keep the states and move search, sort and pagination to the server.",
    "source": {
      "path": "web/registry/items/data-table/data-table.tsx",
      "code": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/registry/shadcn/lib/utils\";\nimport { Button } from \"@/registry/shadcn/ui/button\";\n\ntype HeadingLevel = 2 | 3 | 4;\ntype CellValue = string | number | null;\ntype SortDirection = \"ascending\" | \"descending\";\n\nexport type DataTableColumn = {\n  /** Key into each row's `values`. */\n  key: string;\n  /** Header text. Short nouns: \"Status\", \"Words\", \"Updated\". */\n  label: string;\n  /**\n   * `text` sorts alphabetically; `number` right-aligns in tabular figures;\n   * `date` takes an ISO date (\"2026-09-14\") and formats it for `locale`.\n   */\n  type?: \"text\" | \"number\" | \"date\";\n  /** Adds a sort button to the header. */\n  sortable?: boolean;\n};\n\nexport type DataTableRow = {\n  /** Stable id: used as the React key. */\n  id: string;\n  /** Where the row's first cell links to. Omit for rows with no detail page. */\n  href?: string;\n  values: Record<string, CellValue>;\n};\n\nexport type DataTableMessage = {\n  /** Says what happened, in the user's words: \"No notes in Field research yet\". */\n  title: React.ReactNode;\n  /** Why, what is safe, and what will appear here. */\n  description?: React.ReactNode;\n  /** One next step. In the error state, the retry. */\n  action?: React.ReactNode;\n};\n\nexport type DataTableProps = Omit<React.ComponentProps<\"section\">, \"title\"> & {\n  /** Names the data, not the widget: \"Notes in Field research\". */\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  headingLevel?: HeadingLevel;\n  /** The page's one primary action for this data, e.g. \"New note\". */\n  actions?: React.ReactNode;\n  /** The first column is the row header; it links to `row.href` and stays in view when the table scrolls sideways. */\n  columns: DataTableColumn[];\n  rows: DataTableRow[];\n  /** What a row is, for the result count: { one: \"note\", other: \"notes\" }. */\n  noun: { one: string; other: string };\n  /** `loading` keeps the layout with placeholder rows; `error` replaces the rows with `error`. */\n  status?: \"ready\" | \"loading\" | \"error\";\n  /** Column keys the search box matches, and how to describe them. */\n  search: { keys: string[]; label: string; scope: string };\n  /**\n   * One column to filter by. `options` sets their order (for example the\n   * order a status moves through); without it, the column's distinct values\n   * are listed alphabetically.\n   */\n  filter?: { key: string; label: string; allLabel: string; options?: string[] };\n  defaultSort?: { key: string; direction: SortDirection };\n  defaultQuery?: string;\n  defaultFilter?: string;\n  /** First use: there are no rows at all. */\n  empty: DataTableMessage;\n  /** Loading failed. Say what failed and what is still safe; offer a retry. */\n  error?: DataTableMessage;\n  /** Shown before the error title so the state never relies on colour. */\n  errorLabel?: string;\n  locale?: string;\n  /** Placeholder rows while loading. Match a typical page so nothing jumps. */\n  loadingRows?: number;\n};\n\nfunction formatCell(\n  value: CellValue,\n  type: DataTableColumn[\"type\"],\n  locale: string,\n) {\n  if (value === null || value === \"\") return \"—\";\n  if (type === \"number\" && typeof value === \"number\") {\n    return new Intl.NumberFormat(locale).format(value);\n  }\n  if (type === \"date\" && typeof value === \"string\") {\n    const date = new Date(`${value}T00:00:00Z`);\n    if (Number.isNaN(date.getTime())) return value;\n    return new Intl.DateTimeFormat(locale, {\n      day: \"numeric\",\n      month: \"short\",\n      year: \"numeric\",\n      timeZone: \"UTC\",\n    }).format(date);\n  }\n  return String(value);\n}\n\nfunction compareValues(a: CellValue, b: CellValue, locale: string) {\n  // Empty values sort last in both directions; the caller flips the rest.\n  if (typeof a === \"number\" && typeof b === \"number\") return a - b;\n  return String(a).localeCompare(String(b), locale, { numeric: true });\n}\n\nfunction SortIcon({ direction }: { direction?: SortDirection }) {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      viewBox=\"0 0 16 16\"\n      className={cn(\n        \"size-3.5 shrink-0\",\n        direction ? \"text-foreground\" : \"text-muted-foreground\",\n      )}\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.5\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      {direction !== \"descending\" ? <path d=\"M5 6.5 8 3.5l3 3\" /> : null}\n      {direction !== \"ascending\" ? <path d=\"m5 9.5 3 3 3-3\" /> : null}\n    </svg>\n  );\n}\n\nexport function DataTable({\n  title,\n  description,\n  headingLevel = 2,\n  actions,\n  columns,\n  rows,\n  noun,\n  status = \"ready\",\n  search,\n  filter,\n  defaultSort,\n  defaultQuery = \"\",\n  defaultFilter = \"\",\n  empty,\n  error,\n  errorLabel = \"Error\",\n  locale = \"en-US\",\n  loadingRows = 6,\n  className,\n  ...props\n}: DataTableProps) {\n  const id = React.useId();\n  const headingId = `${id}-heading`;\n  const searchId = `${id}-search`;\n  const filterId = `${id}-filter`;\n  const Heading = `h${headingLevel}` as const;\n  const MessageHeading = `h${Math.min(headingLevel + 1, 6)}` as \"h3\";\n\n  const [query, setQuery] = React.useState(defaultQuery);\n  const [filterValue, setFilterValue] = React.useState(defaultFilter);\n  const [sort, setSort] = React.useState(defaultSort);\n  const searchRef = React.useRef<HTMLInputElement>(null);\n\n  // Show a scroll hint and make the region focusable only when the table is\n  // wider than its container.\n  const scrollRef = React.useRef<HTMLDivElement>(null);\n  const [overflowing, setOverflowing] = React.useState(false);\n  React.useEffect(() => {\n    const element = scrollRef.current;\n    if (!element || typeof ResizeObserver === \"undefined\") return;\n    const measure = () =>\n      setOverflowing(element.scrollWidth > element.clientWidth + 1);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, []);\n\n  const filterOptions = React.useMemo(() => {\n    if (!filter) return [];\n    if (filter.options) return filter.options;\n    const seen = new Set<string>();\n    for (const row of rows) {\n      const value = row.values[filter.key];\n      if (value !== null && value !== \"\") seen.add(String(value));\n    }\n    return [...seen].sort((a, b) => a.localeCompare(b, locale));\n  }, [filter, rows, locale]);\n\n  const visibleRows = React.useMemo(() => {\n    const needle = query.trim().toLocaleLowerCase(locale);\n    const matching = rows.filter((row) => {\n      if (\n        filter &&\n        filterValue &&\n        String(row.values[filter.key]) !== filterValue\n      ) {\n        return false;\n      }\n      if (!needle) return true;\n      return search.keys.some((key) =>\n        String(row.values[key] ?? \"\")\n          .toLocaleLowerCase(locale)\n          .includes(needle),\n      );\n    });\n    if (!sort) return matching;\n    const flip = sort.direction === \"ascending\" ? 1 : -1;\n    return [...matching].sort((a, b) => {\n      const left = a.values[sort.key] ?? null;\n      const right = b.values[sort.key] ?? null;\n      if (left === null || right === null) {\n        return left === right ? 0 : left === null ? 1 : -1;\n      }\n      return compareValues(left, right, locale) * flip;\n    });\n  }, [rows, query, filter, filterValue, search.keys, sort, locale]);\n\n  const isLoading = status === \"loading\";\n  const isError = status === \"error\";\n  const isEmpty = status === \"ready\" && rows.length === 0;\n  const isFiltered = query.trim() !== \"\" || filterValue !== \"\";\n  const noResults =\n    status === \"ready\" && rows.length > 0 && visibleRows.length === 0;\n  const controlsDisabled = isLoading || isError || isEmpty;\n\n  const countText = isLoading\n    ? `Loading ${noun.other}…`\n    : isError\n      ? `${noun.other.charAt(0).toUpperCase()}${noun.other.slice(1)} didn’t load`\n      : isEmpty\n        ? `No ${noun.other} yet`\n        : isFiltered\n          ? `Showing ${visibleRows.length} of ${rows.length} ${rows.length === 1 ? noun.one : noun.other}`\n          : `${rows.length} ${rows.length === 1 ? noun.one : noun.other}`;\n\n  function clearFilters() {\n    setQuery(\"\");\n    setFilterValue(\"\");\n    // The button that was focused disappears; put focus somewhere useful.\n    searchRef.current?.focus();\n  }\n\n  function toggleSort(key: string) {\n    setSort((current) =>\n      current?.key === key && current.direction === \"ascending\"\n        ? { key, direction: \"descending\" }\n        : { key, direction: \"ascending\" },\n    );\n  }\n\n  const filterDescription =\n    filter && filterValue ? `${filter.label}: ${filterValue}` : \"\";\n\n  const message: (DataTableMessage & { kind: string }) | null = isError\n    ? {\n        kind: \"error\",\n        title: error?.title ?? countText,\n        description: error?.description,\n        action: error?.action,\n      }\n    : isEmpty\n      ? { kind: \"empty\", ...empty }\n      : noResults\n        ? {\n            kind: \"no-results\",\n            title: query.trim() ? (\n              <>\n                No {noun.other} match <q>{query.trim()}</q>\n              </>\n            ) : (\n              `No ${noun.other} match these filters`\n            ),\n            description: `Search covers ${search.scope}${\n              filterDescription ? `, filtered to ${filterDescription}` : \"\"\n            }. Check the spelling or clear the filters to see all ${rows.length} ${noun.other}.`,\n            action: (\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                size=\"lg\"\n                className=\"h-11 w-full px-5 sm:w-auto\"\n                onClick={clearFilters}\n              >\n                Clear search and filters\n              </Button>\n            ),\n          }\n        : null;\n\n  const cellBase = \"border-b border-border px-3 text-left align-middle\";\n  const stickyCell =\n    \"sticky left-0 z-10 group-data-[overflowing=true]/table:border-r\";\n\n  return (\n    <section\n      aria-labelledby={headingId}\n      data-slot=\"data-table\"\n      data-status={status}\n      className={cn(\"text-foreground\", className)}\n      {...props}\n    >\n      <div className=\"flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between\">\n        <div className=\"max-w-prose\">\n          <Heading\n            id={headingId}\n            className=\"text-2xl leading-tight font-semibold tracking-tight text-balance\"\n          >\n            {title}\n          </Heading>\n          {description ? (\n            <p className=\"mt-2 text-sm leading-relaxed text-pretty text-muted-foreground\">\n              {description}\n            </p>\n          ) : null}\n        </div>\n        {actions ? (\n          <div className=\"flex shrink-0 flex-col gap-3 sm:flex-row\">\n            {actions}\n          </div>\n        ) : null}\n      </div>\n\n      <form\n        role=\"search\"\n        aria-label={`Filter ${noun.other}`}\n        onSubmit={(event) => event.preventDefault()}\n        className=\"mt-6 grid gap-3 sm:grid-cols-[minmax(0,20rem)_minmax(0,12rem)] sm:items-end\"\n      >\n        <div className=\"grid gap-1.5\">\n          <label htmlFor={searchId} className=\"text-sm font-medium\">\n            {search.label}\n          </label>\n          <input\n            ref={searchRef}\n            id={searchId}\n            type=\"search\"\n            value={query}\n            onChange={(event) => setQuery(event.target.value)}\n            disabled={controlsDisabled}\n            autoComplete=\"off\"\n            className=\"h-11 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50\"\n          />\n        </div>\n        {filter ? (\n          <div className=\"grid gap-1.5\">\n            <label htmlFor={filterId} className=\"text-sm font-medium\">\n              {filter.label}\n            </label>\n            <select\n              id={filterId}\n              value={filterValue}\n              onChange={(event) => setFilterValue(event.target.value)}\n              disabled={controlsDisabled}\n              className=\"h-11 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50\"\n            >\n              <option value=\"\">{filter.allLabel}</option>\n              {filterOptions.map((option) => (\n                <option key={option} value={option}>\n                  {option}\n                </option>\n              ))}\n            </select>\n          </div>\n        ) : null}\n      </form>\n\n      <p\n        role=\"status\"\n        aria-live=\"polite\"\n        className=\"mt-4 min-h-5 text-sm text-muted-foreground tabular-nums\"\n      >\n        {countText}\n      </p>\n\n      <div\n        data-overflowing={overflowing}\n        className=\"group/table mt-2 overflow-hidden rounded-lg border border-border bg-card text-card-foreground\"\n      >\n        <div\n          ref={scrollRef}\n          role={overflowing ? \"region\" : undefined}\n          aria-label={\n            overflowing ? `Table of ${noun.other}, scrolls sideways` : undefined\n          }\n          tabIndex={overflowing ? 0 : undefined}\n          className=\"overflow-x-auto focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none focus-visible:ring-inset\"\n        >\n          <table\n            aria-labelledby={headingId}\n            aria-busy={isLoading || undefined}\n            className=\"w-full min-w-[40rem] border-separate border-spacing-0 text-sm\"\n          >\n            <thead>\n              <tr>\n                {columns.map((column, index) => {\n                  const numeric = column.type === \"number\";\n                  const direction =\n                    sort?.key === column.key ? sort.direction : undefined;\n                  return (\n                    <th\n                      key={column.key}\n                      scope=\"col\"\n                      aria-sort={direction}\n                      className={cn(\n                        cellBase,\n                        \"h-11 bg-muted py-0 font-medium whitespace-nowrap text-muted-foreground\",\n                        numeric && \"text-right\",\n                        index === 0 && stickyCell,\n                      )}\n                    >\n                      {column.sortable ? (\n                        <button\n                          type=\"button\"\n                          onClick={() => toggleSort(column.key)}\n                          disabled={controlsDisabled}\n                          className={cn(\n                            \"-mx-2 inline-flex min-h-11 items-center gap-1.5 rounded-sm px-2 font-medium outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-default disabled:hover:text-muted-foreground\",\n                            numeric && \"flex-row-reverse\",\n                            direction && \"text-foreground\",\n                          )}\n                        >\n                          {column.label}\n                          <SortIcon direction={direction} />\n                        </button>\n                      ) : (\n                        column.label\n                      )}\n                    </th>\n                  );\n                })}\n              </tr>\n            </thead>\n            <tbody>\n              {isLoading\n                ? Array.from({ length: loadingRows }, (_, rowIndex) => (\n                    <tr key={rowIndex} aria-hidden=\"true\">\n                      {columns.map((column, index) => (\n                        <td\n                          key={column.key}\n                          className={cn(\n                            cellBase,\n                            \"h-11 bg-card\",\n                            index === 0 && stickyCell,\n                            rowIndex === loadingRows - 1 && \"border-b-0\",\n                          )}\n                        >\n                          <span\n                            className={cn(\n                              \"block h-3 rounded-sm bg-accent\",\n                              column.type === \"number\" && \"ml-auto\",\n                              index === 0\n                                ? [\"w-4/5\", \"w-3/5\", \"w-2/3\"][rowIndex % 3]\n                                : \"w-16\",\n                            )}\n                          />\n                        </td>\n                      ))}\n                    </tr>\n                  ))\n                : message\n                  ? null\n                  : visibleRows.map((row, rowIndex) => (\n                      <tr key={row.id}>\n                        {columns.map((column, index) => {\n                          const value = formatCell(\n                            row.values[column.key] ?? null,\n                            column.type,\n                            locale,\n                          );\n                          const last = rowIndex === visibleRows.length - 1;\n                          const classes = cn(\n                            cellBase,\n                            \"h-11 bg-card py-1.5\",\n                            column.type === \"number\" &&\n                              \"text-right tabular-nums\",\n                            column.type === \"date\" &&\n                              \"whitespace-nowrap tabular-nums\",\n                            last && \"border-b-0\",\n                          );\n                          if (index === 0) {\n                            return (\n                              <th\n                                key={column.key}\n                                scope=\"row\"\n                                className={cn(\n                                  classes,\n                                  stickyCell,\n                                  \"min-w-40 py-0 font-medium sm:min-w-56\",\n                                )}\n                              >\n                                {row.href ? (\n                                  <a\n                                    href={row.href}\n                                    className=\"-mx-1 inline-flex min-h-11 items-center rounded-sm px-1 underline-offset-4 outline-none hover:underline focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n                                  >\n                                    {value}\n                                  </a>\n                                ) : (\n                                  value\n                                )}\n                              </th>\n                            );\n                          }\n                          return (\n                            <td\n                              key={column.key}\n                              className={cn(\n                                classes,\n                                \"whitespace-nowrap\",\n                                column.type === undefined ||\n                                  column.type === \"text\"\n                                  ? \"text-muted-foreground\"\n                                  : null,\n                              )}\n                            >\n                              {value}\n                            </td>\n                          );\n                        })}\n                      </tr>\n                    ))}\n            </tbody>\n          </table>\n        </div>\n\n        {message ? (\n          <div data-message={message.kind} className=\"px-4 py-8 sm:px-6\">\n            <div className=\"max-w-prose\">\n              <MessageHeading className=\"text-base leading-snug font-semibold text-balance\">\n                {message.kind === \"error\" ? (\n                  <span className=\"mb-1 block text-sm font-semibold text-destructive\">\n                    {errorLabel}\n                  </span>\n                ) : null}\n                {message.title}\n              </MessageHeading>\n              {message.description ? (\n                <div className=\"mt-2 text-sm leading-relaxed text-pretty text-muted-foreground\">\n                  {message.description}\n                </div>\n              ) : null}\n            </div>\n            {message.action ? (\n              <div className=\"mt-5 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center\">\n                {message.action}\n              </div>\n            ) : null}\n          </div>\n        ) : null}\n      </div>\n\n      <p\n        aria-hidden=\"true\"\n        className={cn(\n          \"mt-2 text-sm text-muted-foreground\",\n          overflowing && !message ? \"block\" : \"hidden\",\n        )}\n      >\n        Scroll the table sideways to see every column.\n      </p>\n    </section>\n  );\n}\n"
    }
  },
  "boundaries": [
    "Community items are merged and pass automated checks (schema, build, accessibility, overflow and console errors in three themes at five widths); only items with status reviewed have been checked by a maintainer.",
    "Each rule id links to its page at /rules/<id>; every rule is a draft until a maintainer signs it.",
    "Tessli does not claim that using an item improves AI-generated UI; that claim waits for a published evaluation."
  ]
}
