{
"files": [
{
"content": "defmodule Shadix.Components.DataTable do\n @moduledoc \"\"\"\n A styling and sorting **recipe** layered over `Shadix.Components.Table`.\n\n The shadcn \"Data Table\" example is not a standalone component: it is a guide\n for wiring [TanStack Table](https://tanstack.com/table) (column defs, sorting,\n filtering, pagination, row selection, visibility) into the presentational\n `Table` primitives. This v1 is deliberately **not** a TanStack port. It is a\n thin set of helpers that render the *chrome* — a bordered container, sortable\n column-header buttons, and an optional pagination bar — on top of the existing\n `Table` components.\n\n ## Scope and simplifications\n\n * **The consumer owns the data and all state.** Your LiveView holds the rows,\n the current sort field/direction, and the current page; it does the actual\n sorting and slicing. These helpers only render the surface. There is **no**\n LiveView hook and no client-side state.\n * **You build the table yourself.** `data_table/1` is just a bordered wrapper:\n put a `<Shadix.Components.Table.table>` (with its own header/body) inside it.\n The recipe does not generate rows from column definitions.\n * **Sorting is \"controlled\".** `data_table_column_header/1` renders a button\n that shows the current sort direction for that column (via `:sort`,\n one of `\"asc\"`, `\"desc\"`, or `nil`) and runs the `:on_sort` JS you pass.\n Your handler decides the next state and re-renders with an updated `:sort`.\n * **Pagination is \"controlled\".** `data_table_pagination/1` renders prev/next\n buttons and a \"Page X of Y\" label. It enforces nothing; your `:on_prev` /\n `:on_next` handlers update the page and you pass `:page` / `:total_pages`\n back in. Buttons can be disabled via `:prev_disabled` / `:next_disabled`.\n\n ## data-slots\n\n * `data-table` — the bordered container `<div>`.\n * `data-table-column-header` — the sortable header `<button>`.\n * `data-table-pagination` — the pagination bar `<div>` (a labelled `navigation` landmark).\n\n ## Accessibility\n\n The sortable header is a plain `<button>` and intentionally does **not** carry\n `aria-sort` — that attribute is not allowed on `<button>`; it belongs on the\n `columnheader` (the `<th>`). If you want to expose the sort state to assistive\n technology via `aria-sort`, set it on the consuming\n `<Shadix.Components.Table.table_head>` (e.g. `aria-sort=\"ascending\"`). The\n button instead conveys the sort control through an `aria-label`.\n\n ## Example\n\n <Shadix.Components.DataTable.data_table>\n <Shadix.Components.Table.table>\n <Shadix.Components.Table.table_header>\n <Shadix.Components.Table.table_row>\n <Shadix.Components.Table.table_head>\n <Shadix.Components.DataTable.data_table_column_header\n label=\"Name\"\n sort={@sort_dir_for_name}\n on_sort={JS.push(\"sort\", value: %{field: \"name\"})}\n />\n </Shadix.Components.Table.table_head>\n </Shadix.Components.Table.table_row>\n </Shadix.Components.Table.table_header>\n <Shadix.Components.Table.table_body>\n <Shadix.Components.Table.table_row :for={row <- @rows}>\n <Shadix.Components.Table.table_cell>{row.name}</Shadix.Components.Table.table_cell>\n </Shadix.Components.Table.table_row>\n </Shadix.Components.Table.table_body>\n </Shadix.Components.Table.table>\n </Shadix.Components.DataTable.data_table>\n\n <Shadix.Components.DataTable.data_table_pagination\n page={@page}\n total_pages={@total_pages}\n prev_disabled={@page <= 1}\n next_disabled={@page >= @total_pages}\n on_prev={JS.push(\"prev_page\")}\n on_next={JS.push(\"next_page\")}\n />\n \"\"\"\n use Phoenix.Component\n\n alias Phoenix.LiveView.JS\n\n import Shadix.Cn\n\n @doc \"\"\"\n A bordered container for a data table.\n\n Renders a `rounded-md border` wrapper carrying `data-slot=\"data-table\"`. Place a\n `<Shadix.Components.Table.table>` inside via the inner block; the table's own\n scroll container nests cleanly within the rounded border.\n \"\"\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def data_table(assigns) do\n ~H\"\"\"\n <div\n data-slot=\"data-table\"\n class={cn([\"overflow-hidden rounded-md border\", @class])}\n {@rest}\n >\n {render_slot(@inner_block)}\n </div>\n \"\"\"\n end\n\n @doc \"\"\"\n A sortable column header button for use inside a `table_head`.\n\n Renders a `<button data-slot=\"data-table-column-header\">` showing `:label` and a\n chevron reflecting `:sort`:\n\n * `\"asc\"` — up chevron\n * `\"desc\"` — down chevron\n * `nil` (default) — neutral up/down chevrons\n\n Clicking runs the caller's `:on_sort` JS. The component is \"controlled\": it\n renders the sort state you give it and does not toggle anything itself — your\n handler computes the next direction and passes back an updated `:sort`.\n\n The button carries an `aria-label` describing the sort control (and the current\n direction). `aria-sort` is **not** placed on the button — it is not a valid\n attribute there; set it on the consuming `<th>`/`table_head` instead.\n \"\"\"\n attr(:label, :string, required: true)\n\n attr(:sort, :string,\n default: nil,\n values: [\"asc\", \"desc\", nil],\n doc: ~s(Current sort direction for this column: \"asc\", \"desc\", or nil.)\n )\n\n attr(:on_sort, JS, default: %JS{}, doc: \"JS run when the header is clicked.\")\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n\n def data_table_column_header(assigns) do\n ~H\"\"\"\n <button\n type=\"button\"\n data-slot=\"data-table-column-header\"\n data-sort={@sort}\n aria-label={\n case @sort do\n \"asc\" -> \"Sort by \" <> @label <> \", current sort ascending\"\n \"desc\" -> \"Sort by \" <> @label <> \", current sort descending\"\n _ -> \"Sort by \" <> @label\n end\n }\n phx-click={@on_sort}\n class={\n cn([\n \"-ml-3 inline-flex h-8 select-none items-center gap-2 rounded-md px-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[sort]:text-accent-foreground\",\n @class\n ])\n }\n {@rest}\n >\n {@label}\n <.sort_chevron sort={@sort} />\n </button>\n \"\"\"\n end\n\n # Up chevron for \"asc\", down chevron for \"desc\", neutral up/down for unsorted.\n attr(:sort, :string, default: nil)\n\n defp sort_chevron(%{sort: \"asc\"} = assigns) do\n ~H\"\"\"\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n class=\"size-4\"\n aria-hidden=\"true\"\n >\n <path d=\"m18 15-6-6-6 6\" />\n </svg>\n \"\"\"\n end\n\n defp sort_chevron(%{sort: \"desc\"} = assigns) do\n ~H\"\"\"\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n class=\"size-4\"\n aria-hidden=\"true\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n \"\"\"\n end\n\n defp sort_chevron(assigns) do\n ~H\"\"\"\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n class=\"size-4 text-muted-foreground\"\n aria-hidden=\"true\"\n >\n <path d=\"m7 15 5 5 5-5\" />\n <path d=\"m7 9 5-5 5 5\" />\n </svg>\n \"\"\"\n end\n\n @doc \"\"\"\n An optional pagination bar with prev/next buttons and a \"Page X of Y\" label.\n\n Purely presentational and \"controlled\": it renders `:page` / `:total_pages` and\n runs `:on_prev` / `:on_next`. Disable the edges with `:prev_disabled` /\n `:next_disabled`; your handlers update the page state you pass back in.\n \"\"\"\n attr(:page, :integer, default: 1)\n attr(:total_pages, :integer, default: 1)\n attr(:prev_disabled, :boolean, default: false)\n attr(:next_disabled, :boolean, default: false)\n attr(:on_prev, JS, default: %JS{}, doc: \"JS run when the previous-page button is clicked.\")\n attr(:on_next, JS, default: %JS{}, doc: \"JS run when the next-page button is clicked.\")\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n\n def data_table_pagination(assigns) do\n ~H\"\"\"\n <div\n data-slot=\"data-table-pagination\"\n role=\"navigation\"\n aria-label=\"Pagination\"\n class={cn([\"flex items-center justify-end gap-2 py-4\", @class])}\n {@rest}\n >\n <div\n data-slot=\"data-table-pagination-info\"\n aria-live=\"polite\"\n class=\"text-sm text-muted-foreground\"\n >\n Page {@page} of {@total_pages}\n </div>\n <button\n type=\"button\"\n data-slot=\"data-table-pagination-prev\"\n disabled={@prev_disabled}\n phx-click={@on_prev}\n class=\"inline-flex h-8 select-none items-center justify-center rounded-md border bg-background px-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\"\n >\n Previous\n </button>\n <button\n type=\"button\"\n data-slot=\"data-table-pagination-next\"\n disabled={@next_disabled}\n phx-click={@on_next}\n class=\"inline-flex h-8 select-none items-center justify-center rounded-md border bg-background px-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\"\n >\n Next\n </button>\n </div>\n \"\"\"\n end\nend\n",
"path": "data_table.ex"
}
],
"hooks": [],
"name": "data_table",
"npm_deps": [],
"registry_deps": [
"cn"
]
}