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

452 lines
16 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,
classInfos = "",
tableClassNames = "",
hideHeader = false,
rowClick = "",
} = 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={cn(
`wr:w-full wr:text-sm`,
striped
? "wr:divide-y wr:divide-gray-200 wr:dark:divide-neutral-700"
: "",
tableClassNames,
)}
>
{
!hideHeader && (
<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={cn(
`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,
classInfos,
rowClick,
}}
>
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;
const rowClickFn = rowClick ? window[rowClick] : null;
const classNames = classInfos ? window[classInfos] : {};
const resolveClasses = (input, ...ctx) => {
if (!input) return "";
const val = typeof input === "function" ? input(...ctx) : input;
if (typeof val === "string") return val;
if (val && typeof val === "object") {
return Object.entries(val)
.filter(([, on]) => !!on)
.map(([cls]) => cls)
.join(" ");
}
return "";
};
/** 🧱 Render rows */
function renderRows(dataset) {
tbody.innerHTML = "";
mobileBody.innerHTML = "";
dataset.forEach((row, i) => {
const rowCls = resolveClasses(classNames?.row, row, i);
// 🖥️ Desktop
let tr = `<tr data-row-idx="${i}" 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",
rowCls,
rowClickFn ? "wr:cursor-pointer" : "",
]
.filter(Boolean)
.join(" ")}">`;
columns.forEach((col, colIndex) => {
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);
}
const columnCls = resolveClasses(
classNames?.column,
col,
colIndex,
);
const cellCls = resolveClasses(classNames?.cell, {
row,
value,
col,
i,
colIndex,
});
tr += `<td class="wr:px-4 wr:py-3 ${columnCls} ${cellCls}">${value}</td>`;
});
tr += `</tr>`;
tbody.insertAdjacentHTML("beforeend", tr);
tbody.onclick = (e) => {
if (!rowClickFn) return;
const tr = e.target.closest("tr[data-row-idx]");
if (!tr) return;
const idx = Number(tr.dataset.rowIdx);
const row = dataset[idx];
rowClickFn(row);
};
// 📱 Mobile
let card = `<div data-row-idx="${i}" 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 ${rowClickFn ? "wr:cursor-pointer" : ""}">`;
columns.forEach((col, colIndex) => {
let value = row[col.key] ?? "";
const renderers = columnsRendererKey
? window[columnsRendererKey]
: {};
columns.forEach((c) => {
if (renderers[c.key]) c.render = renderers[c.key];
});
if (col.render) value = col.render(value, row);
const columnCls = resolveClasses(
classNames?.column,
col,
colIndex,
);
const cellCls = resolveClasses(classNames?.cell, {
row,
value,
col,
i,
colIndex,
});
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 ${cellCls}">${value}</div>
</div>`;
});
card += `</div>`;
mobileBody.insertAdjacentHTML("beforeend", card);
mobileBody.onclick = (e) => {
if (!rowClickFn) return;
const tr = e.target.closest("div[data-row-idx]");
if (!tr) return;
const idx = Number(tr.dataset.rowIdx);
const row = dataset[idx];
rowClickFn(row);
};
});
}
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>