Files
Astro-Component/src/components/Table.astro
T

368 lines
13 KiB
Plaintext

---
import type { TableProps } from "../types/table/types";
import { cn } from "../utils/cn";
const {
columns = [],
data = [],
selectable = false,
search = false,
striped = false,
pagignations = true,
pageSize = 10,
fetchData,
columnsRendererKey,
} = Astro.props as TableProps<any>;
const id = `table-${Math.random().toString(36).slice(2, 9)}`;
---
<div id={id} class="wr:space-y-3">
{
search && (
<div class="wr:flex wr:justify-between wr:items-center wr:gap-2">
<input
type="text"
placeholder="Search..."
class="wr:w-full wr:rounded-md wr:border wr:border-gray-300 wr:dark:border-neutral-700 wr:bg-white wr:dark:bg-neutral-800 wr:px-3 wr:py-2 wr:text-sm wr:focus:ring-2 wr:focus:ring-primary"
data-search
/>
</div>
)
}
<!-- 🖥️ Desktop Table -->
<div
class="wr:overflow-x-auto wr:rounded-xl wr:border wr:border-gray-300 wr:dark:border-neutral-700 wr:hidden wr:md:block"
>
<table
class={`wr:w-full wr:text-sm ${striped ? "wr:divide-y wr:divide-gray-200 wr:dark:divide-neutral-700" : ""}`}
>
<thead
class="wr:bg-gray-100 wr:dark:bg-neutral-700 wr:text-gray-600 wr:dark:text-gray-300"
>
<tr>
{
selectable && (
<th class="wr:p-3 wr:text-left wr:w-12">
<input type="checkbox" data-select-all />
</th>
)
}
{
columns.map((col) => (
<th
class={`wr:py-3 wr:px-4 ${col.align ? `wr:text-${col.align}` : "wr:text-left"} ${col.sortable ? "wr:cursor-pointer" : ""}`}
style={col.width ? `width:${col.width}` : ""}
data-sort-key={
col.sortable ? col.key : undefined
}
>
<div class="wr:flex wr:items-center wr:gap-1">
<span>{col.label}</span>
{col.sortable && (
<iconify-icon
icon="solar:sort-vertical-bold-duotone"
class="wr:text-lg wr:text-gray-400 sort-indicator"
/>
)}
</div>
</th>
))
}
</tr>
</thead>
<tbody data-body> </tbody>
</table>
</div>
<!-- 📱 Mobile Cards -->
<div class="wr:space-y-4 wr:md:hidden" data-mobile-body></div>
<!-- 📄 Pagination -->
<div
class={cn(
"wr:flex wr:justify-between wr:items-center wr:gap-4 wr:flex-wrap wr:mt-4",
!pagignations && "wr:!hidden",
)}
>
<div class="wr:flex wr:items-center wr:gap-2 wr:text-sm">
<span class="wr:text-gray-600 wr:dark:text-gray-400">
Rows per page:
</span>
<select
class="wr:w-[75px] wr:rounded-md wr:border wr:border-gray-300 wr:dark:border-neutral-600 wr:bg-white wr:dark:bg-neutral-800 wr:px-2 wr:py-1 wr:text-sm wr:focus:ring-2 wr:focus:ring-primary"
data-page-size
>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div class="wr:flex wr:items-center wr:gap-3 wr:flex-wrap">
<div
class="wr:text-sm wr:text-gray-600 wr:dark:text-gray-400"
data-page-info
>
</div>
<div
class="wr:flex wr:items-center wr:gap-1 wr:bg-gray-50 wr:dark:bg-neutral-800 wr:rounded-full wr:px-2 wr:py-1 wr:shadow-sm"
data-pagination
>
</div>
</div>
</div>
</div>
<script
type="module"
is:inline
define:vars={{
id,
pagignations,
pageSize,
fetchData,
columns,
data,
columnsRendererKey,
}}
>
const start = () => {
const table = document.getElementById(id);
const searchInput = table.querySelector("[data-search]");
const sortHeaders = table.querySelectorAll("[data-sort-key]");
const tbody = table.querySelector("[data-body]");
const mobileBody = table.querySelector("[data-mobile-body]");
const pageInfo = table.querySelector("[data-page-info]");
const paginationContainer = table.querySelector("[data-pagination]");
const pageSizeSelector = table.querySelector("[data-page-size]");
let currentPage = 1;
let rowsPerPage = pagignations
? parseInt(pageSizeSelector.value)
: 9999;
let sortKey = null;
let sortOrder = "asc";
let totalRows = data.length;
let currentData = [...data];
const arrowLeftSVG = `
<svg xmlns="http://www.w3.org/2000/svg" class="wr:w-5 wr:h-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>`;
const arrowRightSVG = `
<svg xmlns="http://www.w3.org/2000/svg" class="wr:w-5 wr:h-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>`;
const fetchDataFn = fetchData ? window[fetchData] : null;
/** 🧱 Render rows */
function renderRows(dataset) {
tbody.innerHTML = "";
mobileBody.innerHTML = "";
dataset.forEach((row, i) => {
// 🖥️ Desktop
let tr = `<tr class="wr:transition wr:bg-white wr:dark:bg-neutral-900 wr:hover:bg-gray-100 wr:dark:hover:bg-neutral-700 ${dataset.length - 1 != i && "wr:border-b"} wr:border-gray-300 wr:dark:border-neutral-800">`;
columns.forEach((col) => {
let value = row[col.key] ?? "";
const renderers = columnsRendererKey
? window[columnsRendererKey]
: {};
columns.forEach((col) => {
if (renderers[col.key]) col.render = renderers[col.key];
});
if (col.render) {
value = col.render(value, row);
}
tr += `<td class="wr:px-4 wr:py-3">${value}</td>`;
});
tr += `</tr>`;
tbody.insertAdjacentHTML("beforeend", tr);
// 📱 Mobile
let card = `<div class="wr:rounded-lg wr:border wr:border-gray-300 wr:dark:border-neutral-700 wr:p-4 wr:space-y-2 wr:bg-white wr:dark:bg-neutral-800">`;
columns.forEach((col) => {
let value = row[col.key] ?? "";
const renderers = columnsRendererKey
? window[columnsRendererKey]
: {};
columns.forEach((col) => {
if (renderers[col.key]) col.render = renderers[col.key];
});
if (col.render) {
value = col.render(value, row);
}
card += `
<div class="wr:flex wr:justify-between wr:items-start wr:gap-4">
<div class="wr:text-sm wr:font-medium wr:text-gray-600 wr:dark:text-gray-300">${col.label}</div>
<div class="wr:text-sm wr:text-gray-800 wr:dark:text-gray-100 wr:text-right wr:max-w-[60%] wr:break-words">${value}</div>
</div>`;
});
card += `</div>`;
mobileBody.insertAdjacentHTML("beforeend", card);
});
}
async function loadData() {
let paginated = [];
if (fetchDataFn) {
const result = await fetchDataFn({
page: currentPage,
pageSize: rowsPerPage,
sortKey,
sortOrder,
search: searchInput?.value || "",
});
currentData = result.data;
totalRows = result.total;
paginated = currentData;
} else {
currentData = [...data];
totalRows = currentData.length;
const start = (currentPage - 1) * rowsPerPage;
const end = start + rowsPerPage;
paginated = currentData.slice(start, end);
}
// sort locally if needed
if (sortKey !== null) {
const colKey = columns[sortKey]?.key;
if (colKey) {
currentData.sort((a, b) => {
const aVal = String(a[colKey] ?? "");
const bVal = String(b[colKey] ?? "");
if (aVal < bVal) return sortOrder === "asc" ? -1 : 1;
if (aVal > bVal) return sortOrder === "asc" ? 1 : -1;
return 0;
});
}
}
// paginate
renderRows(paginated);
renderPagination(totalRows);
}
/** 📄 Pagination */
function renderPagination(totalRows) {
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
paginationContainer.innerHTML = "";
const createButton = (
label,
page,
disabled = false,
isActive = false,
icon = null,
) => {
const btn = document.createElement("button");
btn.className = `
wr:flex wr:items-center wr:justify-center wr:w-8 wr:h-8
wr:rounded-full wr:text-sm wr:transition
${
isActive
? "wr:bg-primary wr:text-white wr:shadow-md"
: "wr:text-gray-600 wr:dark:text-gray-300 wr:hover:bg-gray-200 wr:dark:hover:bg-neutral-700"
}
${disabled ? "wr:opacity-50 wr:cursor-not-allowed" : ""}
`;
if (icon === "left") btn.innerHTML = arrowLeftSVG;
else if (icon === "right") btn.innerHTML = arrowRightSVG;
else btn.textContent = label;
if (!disabled && !isActive) {
btn.addEventListener("click", () => {
currentPage = page;
loadData();
});
}
paginationContainer.appendChild(btn);
};
createButton("", currentPage - 1, currentPage === 1, false, "left");
const maxVisible = 5;
let start = Math.max(1, currentPage - Math.floor(maxVisible / 2));
let end = Math.min(totalPages, start + maxVisible - 1);
if (end - start < maxVisible - 1)
start = Math.max(1, end - maxVisible + 1);
for (let i = start; i <= end; i++)
createButton(i, i, false, i === currentPage);
createButton(
"",
currentPage + 1,
currentPage === totalPages,
false,
"right",
);
pageInfo.textContent = `Page ${currentPage} of ${totalPages} • ${totalRows} total`;
}
// 🕵️ Search
searchInput?.addEventListener("input", () => {
currentPage = 1;
loadData();
});
// 🧭 Sorting
sortHeaders.forEach((th, index) => {
th.addEventListener("click", () => {
if (sortKey === index)
sortOrder = sortOrder === "asc" ? "desc" : "asc";
else {
sortKey = index;
sortOrder = "asc";
}
sortHeaders.forEach((h) =>
h
.querySelector(".sort-indicator")
?.setAttribute(
"icon",
"solar:sort-vertical-bold-duotone",
),
);
th.querySelector(".sort-indicator")?.setAttribute(
"icon",
sortOrder === "asc"
? "solar:sort-up-bold-duotone"
: "solar:sort-down-bold-duotone",
);
loadData();
});
});
// 📊 Page size
pageSizeSelector?.addEventListener("change", (e) => {
rowsPerPage = parseInt(e.target.value);
currentPage = 1;
loadData();
});
// 🚀 Initial
loadData();
};
document.addEventListener("astro:page-load", start);
document.addEventListener("DOMContentLoaded", start);
</script>