improve table loading ux

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing
2026-08-13 10:31:05 +02:00
parent bf40babe6b
commit 6804710d3c
18 changed files with 129 additions and 73 deletions
+4 -1
View File
@@ -1,9 +1,12 @@
<script>
export let center = false;
export let square = false;
// deterministic placeholder width so the skeleton keeps the same calm shape
// across reloads instead of reshuffling on every mount
export let index = 0;
const widths = ['w-16', 'w-20', 'w-24'];
let height = 'h-4';
let width = widths[Math.floor(Math.random() * widths.length)];
let width = widths[index % widths.length];
let gradient = 'from-gray-300 dark:from-gray-600 to-transparent bg-gradient-to-r';
if (square) {
width = 'h-4';
+79 -37
View File
@@ -4,7 +4,7 @@
import TableHeader from './TableHeader.svelte';
import Search from '$lib/components/Search.svelte';
import Select from '$lib/components/Select.svelte';
import { afterUpdate, onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import TableCell from './TableCell.svelte';
import TableRow from './TableRow.svelte';
import TableCellEmpty from './TableCellEmpty.svelte';
@@ -32,21 +32,57 @@
/** @type {'none'|'some'|'all'} */
export let headerState = 'none';
let tableWrapper = null;
let columnsLength = columns.length;
// wait this long before revealing any loading state, so quick loads never
// flash an indicator; the moment the load finishes we drop it again, so an
// instant load leaves no lingering dim
const LOADING_DELAY_MS = 150;
let rowsLength = 0;
// busy is the delayed view of isGhost: only true for loads slower than the delay
let busy = false;
// stays true once real rows have been on screen, so later loads dim the
// existing rows in place instead of tearing them down for a skeleton
let hasRenderedData = false;
let showTimer = null;
afterUpdate(() => {
const elements = tableWrapper?.querySelectorAll('table > tr.table-row');
rowsLength = elements?.length ?? 0;
});
const startBusy = () => {
if (busy || showTimer) {
return;
}
showTimer = setTimeout(() => {
showTimer = null;
busy = true;
}, LOADING_DELAY_MS);
};
const stopBusy = () => {
if (showTimer) {
clearTimeout(showTimer);
showTimer = null;
}
busy = false;
};
$: isGhost ? startBusy() : stopBusy();
$: if (hasData) {
hasRenderedData = true;
}
// first load with nothing on screen yet shows the skeleton; any later load
// keeps the current rows visible and just dims them
$: showSkeleton = busy && !hasRenderedData;
$: dimRows = busy && hasRenderedData;
$: columnsLength = columns.length + (hasActions ? 2 : 0) + (selectable ? 1 : 0);
$: skeletonRows = pagination?.perPage || 10;
onMount(() => {
if (!pagination && sortable?.length) {
console.warn('You need to pass a pagination object to make the column sortable');
}
columnsLength = columns.length + (hasActions ? 2 : 0) + (selectable ? 1 : 0);
});
onDestroy(() => {
clearTimeout(showTimer);
});
let currentPage = pagination && pagination.currentPage;
@@ -63,50 +99,56 @@
{/if}
</div>
<div
bind:this={tableWrapper}
class="
border-2 border-gray-200 dark:border-gray-700/60 rounded-md px-4 py-4 overflow-x-auto bg-white dark:bg-gray-900/80 transition-colors duration-200
{scrollBarClassesHorizontal}"
>
<table
class="w-full table-fixed bg-white dark:bg-gray-900/80 transition-colors duration-200"
class:animate-pulse={isGhost}
class:animate-pulse={showSkeleton}
>
<TableHeader
{isGhost}
isGhost={showSkeleton}
{columns}
{sortable}
{hasActions}
{pagination}
{selectable}
{headerState}
showSelect={hasData}
on:toggleAll
/>
{#if !hasData && !isGhost}
<EmptyTableResult page={currentPage} {plural} colspan={columnsLength} />
{/if}
{#if !isGhost}
<slot />
{:else}
{#each Array(rowsLength || pagination?.perPage) as _, row}
<TableRow>
{#if selectable}
<TableCellEmpty />
{/if}
{#each columns as column}
<TableCell>
<GhostText />
</TableCell>
{/each}
{#if hasActions}
<TableCellEmpty />
<TableCellAction>
<GhostText square center />
</TableCellAction>
{/if}
</TableRow>
{/each}
{/if}
<tbody
class="transition-opacity duration-200"
class:animate-pulse={dimRows}
class:pointer-events-none={dimRows}
>
{#if showSkeleton}
{#each Array(skeletonRows) as _, row}
<TableRow>
{#if selectable}
<TableCellEmpty />
{/if}
{#each columns as column, col}
<TableCell>
<GhostText index={row * columns.length + col} />
</TableCell>
{/each}
{#if hasActions}
<TableCellEmpty />
<TableCellAction>
<GhostText square center />
</TableCellAction>
{/if}
</TableRow>
{/each}
{:else}
{#if !hasData}
<EmptyTableResult page={currentPage} {plural} colspan={columnsLength} />
{/if}
<slot />
{/if}
</tbody>
</table>
</div>
{#if pagination}
@@ -3,11 +3,17 @@
/** @type {'none'|'some'|'all'} */
export let state = 'none';
// non-interactive placeholder used while the table skeleton is showing, so
// the checkbox column keeps its width and the columns do not shift on load
export let disabled = false;
const dispatch = createEventDispatcher();
// clicking selects every page row unless they are all selected already
const onToggle = () => {
if (disabled) {
return;
}
dispatch('toggleAll', state !== 'all');
};
</script>
@@ -15,13 +21,18 @@
<th
class="pl-4 w-12 bg-grayblue-light dark:bg-gray-800/60 py-4 border-hidden rounded-tl-lg rounded-bl-lg transition-colors duration-200"
>
<label class="relative inline-flex items-center cursor-pointer">
<label
class="relative inline-flex items-center"
class:cursor-pointer={!disabled}
class:opacity-0={disabled}
>
<input
type="checkbox"
class="peer sr-only"
checked={state === 'all'}
{disabled}
on:change={onToggle}
tabindex="0"
tabindex={disabled ? -1 : 0}
/>
<div
class="w-5 h-5 border-2 border-slate-300 dark:border-gray-700/60 rounded
@@ -18,6 +18,10 @@
export let selectable = false;
/** @type {'none'|'some'|'all'} */
export let headerState = 'none';
// the checkbox column keeps its width whenever selectable is set; showSelect
// controls whether the select all control is actually shown, so an empty or
// loading table reserves the gutter without a dangling control
export let showSelect = false;
$: sortableMap = {};
@@ -31,11 +35,7 @@
<TableHead>
<TableRow>
{#if selectable}
{#if !isGhost}
<TableHeadCellCheckbox state={headerState} on:toggleAll />
{:else}
<TableHeadCellEmpty />
{/if}
<TableHeadCellCheckbox state={headerState} disabled={isGhost || !showSelect} on:toggleAll />
{/if}
{#each columns as column, i (typeof column === 'object' ? column.column : column)}
{#if typeof column === 'object'}
+2 -2
View File
@@ -80,7 +80,7 @@
.filter((s) => !globalButtonDisabledAttributes(s, contextCompanyID).disabled)
.map((s) => s.id);
$: headerState = headerSelectionState($selection, selectablePageIds);
$: showMultiSelect = selectablePageIds.length > 1;
$: showMultiSelect = apiSenders.length > 0;
const onClickBulkDelete = async () => {
await runBulkDelete({
ids: [...$selection],
@@ -373,7 +373,7 @@
on:clear={() => selection.clear()}
/>
<Table
selectable={showMultiSelect}
selectable
{headerState}
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
columns={[
@@ -68,7 +68,7 @@
.filter((a) => !globalButtonDisabledAttributes(a, contextCompanyID).disabled)
.map((a) => a.id);
$: headerState = headerSelectionState($selection, selectablePageIds);
$: showMultiSelect = selectablePageIds.length > 1;
$: showMultiSelect = assets.length > 0;
const onClickBulkDelete = async () => {
await runBulkDelete({ ids: [...$selection], deleteFn: api.asset.delete, noun: 'asset' });
await refreshAssets();
@@ -461,7 +461,7 @@
on:clear={() => selection.clear()}
/>
<Table
selectable={showMultiSelect}
selectable
{headerState}
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
columns={[
+2 -2
View File
@@ -79,7 +79,7 @@
.filter((a) => !globalButtonDisabledAttributes(a, contextCompanyID).disabled)
.map((a) => a.id);
$: headerState = headerSelectionState($selection, selectablePageIds);
$: showMultiSelect = selectablePageIds.length > 1;
$: showMultiSelect = attachments.length > 0;
const onClickBulkDelete = async () => {
await runBulkDelete({
ids: [...$selection],
@@ -303,7 +303,7 @@
on:clear={() => selection.clear()}
/>
<Table
selectable={showMultiSelect}
selectable
{headerState}
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
columns={[
@@ -151,7 +151,7 @@
.filter((t) => !globalButtonDisabledAttributes(t, contextCompanyID).disabled)
.map((t) => t.id);
$: headerState = headerSelectionState($selection, selectablePageIds);
$: showMultiSelect = selectablePageIds.length > 1;
$: showMultiSelect = templates.length > 0;
const onClickBulkDelete = async () => {
await runBulkDelete({
ids: [...$selection],
@@ -661,7 +661,7 @@
on:clear={() => selection.clear()}
/>
<Table
selectable={showMultiSelect}
selectable
{headerState}
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
columns={[
+2 -2
View File
@@ -254,7 +254,7 @@
.filter((c) => !globalButtonDisabledAttributes(c, contextCompanyID).disabled)
.map((c) => c.id);
$: headerState = headerSelectionState($selection, selectablePageIds);
$: showMultiSelect = selectablePageIds.length > 1;
$: showMultiSelect = (campaigns ?? []).length > 0;
const onClickBulkDelete = async () => {
await runBulkDelete({ ids: [...$selection], deleteFn: api.campaign.delete, noun: 'campaign' });
await refreshCampaigns();
@@ -1582,7 +1582,7 @@
on:clear={() => selection.clear()}
/>
<Table
selectable={showMultiSelect}
selectable
{headerState}
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
columns={[
+2 -2
View File
@@ -89,7 +89,7 @@
.filter((e) => !globalButtonDisabledAttributes(e, contextCompanyID).disabled)
.map((e) => e.id);
$: headerState = headerSelectionState($selection, selectablePageIds);
$: showMultiSelect = selectablePageIds.length > 1;
$: showMultiSelect = emails.length > 0;
const onClickBulkDelete = async () => {
await runBulkDelete({ ids: [...$selection], deleteFn: api.email.delete, noun: 'email' });
await refreshEmails();
@@ -414,7 +414,7 @@
on:clear={() => selection.clear()}
/>
<Table
selectable={showMultiSelect}
selectable
{headerState}
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
columns={[
+2 -2
View File
@@ -68,7 +68,7 @@
.filter((entry) => !globalButtonDisabledAttributes(entry, contextCompanyID).disabled)
.map((entry) => entry.id);
$: headerState = headerSelectionState($selection, selectablePageIds);
$: showMultiSelect = selectablePageIds.length > 1;
$: showMultiSelect = allowDenyList.length > 0;
const onClickBulkDelete = async () => {
await runBulkDelete({ ids: [...$selection], deleteFn: api.allowDeny.delete, noun: 'filter' });
await refreshAllowDenies();
@@ -430,7 +430,7 @@
on:clear={() => selection.clear()}
/>
<Table
selectable={showMultiSelect}
selectable
{headerState}
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
columns={[
@@ -66,7 +66,7 @@
.filter((p) => !globalButtonDisabledAttributes(p, contextCompanyID).disabled)
.map((p) => p.id);
$: headerState = headerSelectionState($selection, selectablePageIds);
$: showMultiSelect = selectablePageIds.length > 1;
$: showMultiSelect = providers.length > 0;
const onClickBulkDelete = async () => {
await runBulkDelete({
ids: [...$selection],
@@ -530,7 +530,7 @@
on:clear={() => selection.clear()}
/>
<Table
selectable={showMultiSelect}
selectable
{headerState}
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
columns={['Name', 'Status']}
+2 -2
View File
@@ -66,7 +66,7 @@
.filter((p) => !globalButtonDisabledAttributes(p, contextCompanyID).disabled)
.map((p) => p.id);
$: headerState = headerSelectionState($selection, selectablePageIds);
$: showMultiSelect = selectablePageIds.length > 1;
$: showMultiSelect = pages.length > 0;
const onClickBulkDelete = async () => {
await runBulkDelete({ ids: [...$selection], deleteFn: api.page.delete, noun: 'page' });
await refreshPages();
@@ -354,7 +354,7 @@
on:clear={() => selection.clear()}
/>
<Table
selectable={showMultiSelect}
selectable
{headerState}
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
columns={[
+2 -2
View File
@@ -87,7 +87,7 @@
.filter((p) => !globalButtonDisabledAttributes(p, contextCompanyID).disabled)
.map((p) => p.id);
$: headerState = headerSelectionState($selection, selectablePageIds);
$: showMultiSelect = selectablePageIds.length > 1;
$: showMultiSelect = proxies.length > 0;
const onClickBulkDelete = async () => {
await runBulkDelete({
ids: [...$selection],
@@ -686,7 +686,7 @@ portal.example.com:
on:clear={() => selection.clear()}
/>
<Table
selectable={showMultiSelect}
selectable
{headerState}
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
columns={[
+2 -2
View File
@@ -104,7 +104,7 @@
.filter((r) => !globalButtonDisabledAttributes(r, contextCompanyID).disabled)
.map((r) => r.id);
$: headerState = headerSelectionState($selection, selectablePageIds);
$: showMultiSelect = selectablePageIds.length > 1;
$: showMultiSelect = recipients.length > 0;
let isRecipientsTableLoading = false;
@@ -465,7 +465,7 @@
/>
<Table
isGhost={isRecipientsTableLoading}
selectable={showMultiSelect}
selectable
{headerState}
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
columns={[
@@ -90,7 +90,7 @@
.filter((g) => !globalButtonDisabledAttributes(g, contextCompanyID).disabled)
.map((g) => g.id);
$: headerState = headerSelectionState($selection, selectablePageIds);
$: showMultiSelect = selectablePageIds.length > 1;
$: showMultiSelect = groups.length > 0;
// dynamic update modal state
let isDynamicUpdateModalVisible = false;
@@ -380,7 +380,7 @@
on:clear={() => selection.clear()}
/>
<Table
selectable={showMultiSelect}
selectable
{headerState}
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
columns={[
@@ -76,7 +76,7 @@
.filter((c) => !globalButtonDisabledAttributes(c, contextCompanyID).disabled)
.map((c) => c.id);
$: headerState = headerSelectionState($selection, selectablePageIds);
$: showMultiSelect = selectablePageIds.length > 1;
$: showMultiSelect = configurations.length > 0;
const onClickBulkDelete = async () => {
await runBulkDelete({
ids: [...$selection],
@@ -456,7 +456,7 @@
on:clear={() => selection.clear()}
/>
<Table
selectable={showMultiSelect}
selectable
{headerState}
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
columns={[
+2 -2
View File
@@ -62,7 +62,7 @@
.filter((w) => !globalButtonDisabledAttributes(w, contextCompanyID).disabled)
.map((w) => w.id);
$: headerState = headerSelectionState($selection, selectablePageIds);
$: showMultiSelect = selectablePageIds.length > 1;
$: showMultiSelect = webhooks.length > 0;
const onClickBulkDelete = async () => {
await runBulkDelete({ ids: [...$selection], deleteFn: api.webhook.delete, noun: 'webhook' });
await refreshWebhooks();
@@ -315,7 +315,7 @@
on:clear={() => selection.clear()}
/>
<Table
selectable={showMultiSelect}
selectable
{headerState}
on:toggleAll={(e) => selection.setPageSelection(selectablePageIds, e.detail)}
columns={[