feat(frontend): make data-table fully responsive

This commit is contained in:
Julian Lobbes 2023-06-02 13:08:40 +01:00
parent 2882db284d
commit b3d291c3ee
9 changed files with 325 additions and 192 deletions

View file

@ -42,3 +42,6 @@ Columns: 1
163 163 163 background-700 163 163 163 background-700
112 112 112 background-800 112 112 112 background-800
51 51 51 background-900 51 51 51 background-900
0 204 0 success
237 192 0 warning
228 0 0 failure

View file

@ -70,7 +70,36 @@
} }
button:disabled { button:disabled {
@apply opacity-25; @apply opacity-25;
@apply bg-neutral-100; @apply cursor-not-allowed;
}
button:disabled:hover {
@apply bg-accent;
@apply text-primary-400 font-semibold;
@apply cursor-not-allowed;
}
button.outlineButton {
@apply bg-secondary/20;
@apply border-2 border-secondary;
@apply text-secondary font-medium;
}
button.outlineButton:hover {
@apply bg-secondary;
@apply text-secondary-900 font-bold;
}
button.outlineButton:active {
@apply bg-secondary-600;
@apply text-secondary-900;
}
button.outlineButton:focus {
@apply ring-secondary-400;
}
button.outlineButton:disabled {
@apply opacity-25;
}
button.outlineButton:disabled:hover {
@apply bg-secondary/20;
@apply text-secondary font-medium;
@apply cursor-not-allowed;
} }
a.button { a.button {
@apply p-2; @apply p-2;
@ -114,24 +143,27 @@
tr:last-of-type td:last-of-type { tr:last-of-type td:last-of-type {
@apply rounded-br-md; @apply rounded-br-md;
} }
tr {
@apply hover:bg-secondary-300/50;
@apply border-y border-primary/50;
@apply bg-secondary-100/10;
}
tr:first-of-type, tr:last-of-type { tr:first-of-type, tr:last-of-type {
@apply border-y-0; @apply border-y-0;
} }
tr:nth-child(odd) {
@apply bg-secondary/5;
}
tr:nth-child(even) {
@apply bg-secondary/20;
}
tr {
@apply hover:bg-accent/25;
}
th { th {
@apply bg-primary font-semibold text-secondary-100; @apply bg-primary font-semibold text-secondary-100;
}
caption, th, td {
@apply p-1; @apply p-1;
} }
@media screen(sm) { td {
caption, th, td { @apply p-1;
@apply p-2 text-start; }
} caption {
@apply p-1 text-start font-semibold text-xl;
} }
fieldset { fieldset {
@ -158,4 +190,11 @@
label { label {
@apply text-sm text-secondary font-light; @apply text-sm text-secondary font-light;
} }
select {
@apply text-primary font-semibold;
@apply border border-secondary;
@apply bg-secondary/20;
@apply rounded-md;
@apply p-1;
}
} }

View file

@ -31,17 +31,34 @@
args: [], args: [],
} }
export let defaultSortField: string = columns[0].field;
export let defaultSortOrder: 'asc' | 'desc' = 'asc';
let viewportWidth: number = 0;
let table: Element;
let tableMaxWidth: number = 0;
let compactView = false;
function checkForTableResize() {
if (!table) return;
if (table.scrollWidth > tableMaxWidth) {
tableMaxWidth = table.scrollWidth;
}
compactView = tableMaxWidth >= viewportWidth;
}
type SortEvent = { type SortEvent = {
detail: { detail: {
order: null | 'asc' | 'desc', order: null | 'asc' | 'desc',
field: string, field: string,
} }
} }
let currentSortField: string = columns[0].field; let currentSortField: string = defaultSortField;
let currentSortOrder: 'asc' | 'desc' = 'asc'; let currentSortOrder: 'asc' | 'desc' = defaultSortOrder;
let currentState: 'finished' | 'loading' | 'error' = 'finished'; let currentState: 'finished' | 'loading' | 'error' = 'finished';
let errorMessages: string[] = []; let errorMessage: string;
let currentItems: any[] = []; // TODO apply generics here let currentItems: any[] = []; // TODO apply generics here
let totalItemCount: number | null = null; let totalItemCount: number | null = null;
@ -134,15 +151,12 @@
); );
currentState = 'finished'; currentState = 'finished';
} catch (error: any) { } catch (error: any) {
if (!errorMessages.length) { if (typeof error.body.message === 'string') {
// Make sure we have an error message to show to the user errorMessage = error.body.message;
if (typeof error.message === 'string') { } else {
errorMessages.push(error.message); errorMessage = "An unknown error occurred.";
} else {
errorMessages.push("An unknown error occurred.");
}
} }
console.log(error)
currentState = 'error'; currentState = 'error';
} }
} }
@ -155,73 +169,140 @@
} }
onMount(async () => { onMount(async () => {
checkForTableResize();
await updateTable(); await updateTable();
checkForTableResize();
}); });
</script> </script>
<table> <svelte:window bind:outerWidth={viewportWidth} on:resize={checkForTableResize} />
{#if caption !== null}
<caption>{caption}</caption> <div id="table-container"
{/if} class="flex flex-col items-center gap-2"
<thead> class:compact-view={compactView}
<tr> class:extended-view={!compactView}
>
<table
bind:this={table}
>
{#if caption !== null}
<caption>{caption}</caption>
{/if}
{#if compactView}
<div class="table-controls m-2">
<span>Sort by:</span>
<select bind:value={currentSortField} on:change={updateTable} class="mr-2">
{#each columns as column} {#each columns as column}
<Th <option selected={column.field === currentSortField} value={column.field}>{column.heading}</option>
on:sortEvent={handleSortEvent}
field={column.field}
heading={column.heading}
order={currentSortField === column.field ? currentSortOrder : null}
sortable={column.sortable ?? true}
/>
{/each} {/each}
</tr> </select>
</thead> <span>Order:</span>
<tbody> <select bind:value={currentSortOrder} on:change={updateTable}>
{#if currentState === 'finished'} <option selected={currentSortOrder === 'asc'} value={'asc'}>Ascending</option>
{#each currentItems as item} <option selected={currentSortOrder === 'desc'} value={'desc'}>Descending</option>
</select>
</div>
{/if}
{#if !compactView}
<thead>
<tr> <tr>
{#each columns as column} {#each columns as column}
<Td <Th
data={item[column.field]} on:sortEvent={handleSortEvent}
type={column.type ?? 'string'} field={column.field}
isLink={column.isLink ?? false} heading={column.heading}
linkTarget={i(column.linkTarget, item) ?? ''} order={currentSortField === column.field ? currentSortOrder : null}
sortable={column.sortable ?? true}
/> />
{/each} {/each}
</tr> </tr>
{/each} </thead>
{:else if currentState === 'loading'}
<tr>
<td colspan={columns.length}>Loading...</td>
</tr>
{:else if currentState === 'error'}
{#each errorMessages as message}
<tr>
<td colspan={columns.length} class="text-center text-red-500">{message}</td>
</tr>
{/each}
{/if} {/if}
</tbody> <tbody
</table> class:compact-view={compactView}
<button on:click={handleGotoFirstPage} disabled={gotoFirstPageDisabled}> class:extended-view={!compactView}
<Icon src={ChevronDoubleLeft} mini class="w-6 h-6" /> >
</button> {#if currentState === 'finished'}
<button on:click={handleGotoPrevPage} disabled={gotoPrevPageDisabled}> {#each currentItems as item}
<Icon src={ChevronLeft} mini class="w-6 h-6" /> <tr>
</button> {#each columns as column}
<input <Td
type=number bind:value={selectedCurrentPage} min=0 max={lastPage} disabled={lastPage === 0} fieldName={column.heading}
on:change={handleCurrentPageInputChanged} on:focus={handleCurrentPageInputFocussed} data={item[column.field]}
> type={column.type ?? 'string'}
<span>of {lastPage + 1}</span> isLink={column.isLink ?? false}
<button on:click={handleGotoNextPage} disabled={gotoNextPageDisabled}> linkTarget={i(column.linkTarget, item) ?? ''}
<Icon src={ChevronRight} mini class="w-6 h-6" /> compactView={compactView}
</button> />
<button on:click={handleGotoLastPage} disabled={gotoLastPageDisabled}> {/each}
<Icon src={ChevronDoubleRight} mini class="w-6 h-6" /> </tr>
</button> {/each}
<select bind:value={selectedItemsPerPage} on:change={handleItemsPerPageChange}> {:else if currentState === 'loading'}
{#each itemsPerPageOptions as option} <tr>
<option selected={option === currentItemsPerPage} value={option}>{option}</option> <td colspan={columns.length}>Loading...</td>
{/each} </tr>
</select> {:else if currentState === 'error'}
<tr>
<td colspan={columns.length} class="text-center text-failure font-bold">{errorMessage}</td>
</tr>
{/if}
</tbody>
</table>
<div class="table-controls">
<label for="itemsPerPageSelector" class="text-lg">Items per page</label>
<select id="itemsPerPageSelector" bind:value={selectedItemsPerPage} on:change={handleItemsPerPageChange} class="mr-2">
{#each itemsPerPageOptions as option}
<option selected={option === currentItemsPerPage} value={option}>{option}</option>
{/each}
</select>
<span>Total: <b>{totalItemCount}</b></span>
</div>
<div class="table-controls">
<button on:click={handleGotoFirstPage} disabled={gotoFirstPageDisabled} class="outlineButton">
<Icon src={ChevronDoubleLeft} mini class="w-6 h-6" />
</button>
<button on:click={handleGotoPrevPage} disabled={gotoPrevPageDisabled} class="outlineButton">
<Icon src={ChevronLeft} mini class="w-6 h-6" />
</button>
<label for="currentPageNumberInput" class="text-lg">Page</label>
<input id="currentPageNumberInput"
type=number bind:value={selectedCurrentPage} min=0 max={lastPage} disabled={lastPage === 0}
on:change={handleCurrentPageInputChanged} on:focus={handleCurrentPageInputFocussed}
class="text-center"
>
<span>of {lastPage + 1}</span>
<button on:click={handleGotoNextPage} disabled={gotoNextPageDisabled} class="outlineButton">
<Icon src={ChevronRight} mini class="w-6 h-6" />
</button>
<button on:click={handleGotoLastPage} disabled={gotoLastPageDisabled} class="outlineButton">
<Icon src={ChevronDoubleRight} mini class="w-6 h-6" />
</button>
</div>
</div>
<style>
option {
@apply text-center;
}
#table-container.compact-view {
@apply max-w-fit;
@apply p-4;
}
#table-container.extended-view {
@apply max-w-max;
}
.compact-view caption {
@apply text-center;
}
.compact-view tr {
@apply border border-primary;
}
div.table-controls {
@apply flex gap-1 items-center justify-center;
}
.table-controls span {
@apply text-lg text-secondary font-light;
}
</style>

View file

@ -1,30 +1,76 @@
<script lang="ts"> <script lang="ts">
import { Icon, Check, XMark } from 'svelte-hero-icons'; import { Icon, Check, Minus, XMark } from 'svelte-hero-icons';
export let fieldName: string;
export let data: any; export let data: any;
export let type: 'string' | 'date' | 'boolean' = 'string'; export let type: 'string' | 'date' | 'number' | 'boolean' = 'string';
export let isLink: boolean = false; export let isLink: boolean = false;
export let linkTarget: string = ""; export let linkTarget: string = "";
export let compactView = false;
$: extendedView = !compactView;
</script> </script>
<td> <td
{#if type === 'date'}
{#if data !== null && data !== undefined} class:compact-view={compactView}
{new Date(data).toLocaleDateString()} class:extended-view={extendedView}
{:else}
<!-- date undefined --> class:boolean-data={type === 'boolean'}
{/if} class:string-data={type === 'string'}
{:else if type === 'boolean'} class:number-data={type === 'number'}
{#if data} class:date-data={type === 'date'}
<Icon src={Check} mini class="w-6 h-6" /> class:empty-data={data === null || data === undefined}
{:else} >
<Icon src={XMark} mini class="w-6 h-6" /> {#if compactView}
{/if} <p class="field-name">{fieldName}:</p>
{/if}
{#if data === null || data === undefined}
<Icon src={Minus} mini class="field-data w-6 h-6 text-primary/40" />
{:else} {:else}
{#if isLink} {#if type === 'date'}
<a href={linkTarget}>{data}</a> <p class="field-data">{new Date(data).toLocaleDateString()}</p>
{:else if type === 'boolean'}
{#if data}
<Icon src={Check} mini class="field-data w-6 h-6 text-success/50" />
{:else}
<Icon src={XMark} mini class="field-data w-6 h-6 text-failure/50" />
{/if}
{:else} {:else}
{data} {#if isLink}
<a href={linkTarget} class="field-data">{data}</a>
{:else}
<p class="field-data">{data}</p>
{/if}
{/if} {/if}
{/if} {/if}
</td> </td>
<style>
td.compact-view {
@apply grid grid-cols-2 items-center justify-start gap-x-2;
}
.compact-view .field-name {
@apply text-primary/50 font-light;
@apply text-end;
}
.compact-view .field-data {
@apply truncate;
@apply font-medium;
}
td.compact-view.date-data .field-data, td.compact-view.number-data .field-data {
@apply text-start;
}
td.extended-view {
@apply table-cell;
}
td.extended-view.boolean-data, td.extended-view.empty-data {
@apply flex justify-center;
}
td.extended-view.date-data .field-data, td.extended-view.number-data .field-data {
@apply text-end;
}
</style>

View file

@ -29,7 +29,7 @@
} }
</script> </script>
<svelte:window bind:outerWidth={viewportWidth}/> <svelte:window bind:outerWidth={viewportWidth} />
<nav <nav
class="sticky top-0 transition-transform ease-linear z-40" class="sticky top-0 transition-transform ease-linear z-40"
@ -37,15 +37,8 @@
bind:clientHeight={currentHeight} bind:clientHeight={currentHeight}
> >
{#if !largeScreen} {#if !largeScreen}
<div id="navHead" <div id="navHead" class="flex" class:smallScreen={!largeScreen}>
class="flex" <a href="/" on:click={closeMenu} class="group">
class:smallScreen={!largeScreen}
>
<a
href="/"
on:click={closeMenu}
class="group"
>
<img <img
src="/images/common/logo.svg" src="/images/common/logo.svg"
alt="Dewit Logo" alt="Dewit Logo"
@ -71,41 +64,16 @@
</a> </a>
{/if} {/if}
{#if user} {#if user}
<a <a href={`/users/${user.id}`} on:click={closeMenu}>Profile</a>
href={`/users/${user.id}/todos`} <a href={`/users/${user.id}/todos`} on:click={closeMenu}>Todos</a>
on:click={closeMenu}
>
Todos
</a>
<a
href={`/users/${user.id}`}
on:click={closeMenu}
>
My Profile
</a>
{#if user.isAdmin} {#if user.isAdmin}
<a <a href={`/users/all`} on:click={closeMenu}>All Users</a>
href={`/users/all`}
on:click={closeMenu}
>
All Users
</a>
{/if} {/if}
{#if largeScreen}<div class="grow" aria-hidden />{/if} {#if largeScreen}<div class="grow" aria-hidden />{/if}
<button <button class="outlineButton" on:click={handleLogout}>Log Out</button>
class="outlineButton"
on:click={handleLogout}
>
Log Out
</button>
{:else} {:else}
{#if largeScreen}<div class="grow" aria-hidden />{/if} {#if largeScreen}<div class="grow" aria-hidden />{/if}
<a <a href="/login" on:click={closeMenu}>Log In</a>
href="/login"
on:click={closeMenu}
>
Log In
</a>
{/if} {/if}
</div> </div>
{/if} {/if}
@ -132,20 +100,4 @@
.navbarHidden { .navbarHidden {
@apply -translate-y-full; @apply -translate-y-full;
} }
.outlineButton {
@apply bg-primary-400/50;
@apply border-2 border-secondary;
@apply text-secondary font-medium;
}
.outlineButton:hover {
@apply bg-secondary;
@apply text-secondary-900 font-bold;
}
.outlineButton:active {
@apply bg-secondary-600;
@apply text-secondary-900;
}
.outlineButton:focus {
@apply ring-secondary-400;
}
</style> </style>

View file

@ -28,7 +28,6 @@
import { loadUserIntoStore } from '$lib/auth/session'; import { loadUserIntoStore } from '$lib/auth/session';
import Navbar from '$lib/components/navbar/Navbar.svelte'; import Navbar from '$lib/components/navbar/Navbar.svelte';
import Footer from '$lib/components/footer/Footer.svelte'; import Footer from '$lib/components/footer/Footer.svelte';
import { slide } from 'svelte/transition';
export let title = import.meta.env.PUBLIC_TITLE; export let title = import.meta.env.PUBLIC_TITLE;
export let description = import.meta.env.PUBLIC_DESCRIPTION; export let description = import.meta.env.PUBLIC_DESCRIPTION;
@ -40,9 +39,10 @@
let lastKnownScrollPosY = 0; let lastKnownScrollPosY = 0;
const navbarToggleThreshold = 32; const navbarToggleThreshold = 32;
let navbarHeight: number; let navbarHeight: number;
let viewportWidth: number;
function handleScroll(event: Event): void { function handleScroll(event: Event): void {
if (window.scrollY <= navbarHeight) { if (viewportWidth >= 640 || window.scrollY <= navbarHeight) {
navbarHidden = false; navbarHidden = false;
} else { } else {
let scrollDistance = Math.abs(window.scrollY - lastKnownScrollPosY); let scrollDistance = Math.abs(window.scrollY - lastKnownScrollPosY);
@ -65,10 +65,10 @@
}); });
</script> </script>
<svelte:window on:scroll={handleScroll} /> <svelte:window bind:outerWidth={viewportWidth} on:scroll={handleScroll} />
<Navbar bind:isHidden={navbarHidden} bind:currentHeight={navbarHeight} /> <Navbar bind:isHidden={navbarHidden} bind:currentHeight={navbarHeight} />
<div class="flex flex-col grow justify-center"> <div class="flex flex-col grow justify-center items-center">
<slot /> <slot />
</div> </div>
<Footer /> <Footer />

View file

@ -3,44 +3,47 @@
import type { Column } from '$lib/components/data-table/types'; import type { Column } from '$lib/components/data-table/types';
import type { UserTodosPage } from "./+page"; import type { UserTodosPage } from "./+page";
import { readTodos, readTodoCount } from '$lib/api/endpoints'; import { readTodos, readTodoCount } from '$lib/api/endpoints';
import { getUserFromLocalstorage as getUser } from '$lib/auth/session';
export let data: UserTodosPage; export let data: UserTodosPage;
const user = getUser();
const columns: Column[] = [ const columns: Column[] = [
{ {
field: "id", field: 'id',
heading: "ID", heading: 'ID',
type: 'number,'
}, },
{ {
field: "title", field: 'title',
heading: "Title", heading: 'Title',
isLink: true, isLink: true,
linkTarget: '/todos/%id%', linkTarget: '/todos/%id%',
}, },
{ {
field: "done", field: 'done',
heading: "Done", heading: 'Done',
type: "boolean", type: 'boolean',
}, },
{ {
field: "created", field: 'created',
heading: "Created", heading: 'Created',
type: "date", type: 'date',
}, },
{ {
field: "updated", field: 'updated',
heading: "Updated", heading: 'Updated',
type: "date", type: 'date',
}, },
{ {
field: "finished", field: 'finished',
heading: "Finished", heading: 'Finished',
type: "date", type: 'date',
}, },
]; ];
const table = { const table = {
caption: `List of TODOs for user ${data.user.first_name} ${data.user.last_name}`, caption: user.id === data.user.id ? 'My TODOs' : `${data.user.first_name} ${data.user.last_name}'s TODOs`,
columns: columns, columns: columns,
getItemsEndpoint: { getItemsEndpoint: {
callable: readTodos, callable: readTodos,
@ -53,7 +56,9 @@
args: [ args: [
data.user.id data.user.id
] ]
} },
defaultSortField: 'updated',
defaultSortOrder: 'desc',
}; };
</script> </script>

View file

@ -2,39 +2,41 @@
import Table from '$lib/components/data-table/Table.svelte'; import Table from '$lib/components/data-table/Table.svelte';
import type { Column } from '$lib/components/data-table/types'; import type { Column } from '$lib/components/data-table/types';
import { readUsers, readUserCount } from '$lib/api/endpoints'; import { readUsers, readUserCount } from '$lib/api/endpoints';
import { onMount } from 'svelte';
const columns: Column[] = [ const columns: Column[] = [
{ {
field: "id", field: 'id',
heading: "ID", heading: 'ID',
type: 'number',
}, },
{ {
field: "email", field: 'email',
heading: "Email", heading: 'Email',
isLink: true, isLink: true,
linkTarget: '/users/%id%', linkTarget: '/users/%id%',
}, },
{ {
field: "first_name", field: 'first_name',
heading: "First Name", heading: 'First Name',
}, },
{ {
field: "last_name", field: 'last_name',
heading: "Last Name", heading: 'Last Name',
}, },
{ {
field: "created", field: 'created',
heading: "Created", heading: 'Created',
type: 'date', type: 'date',
}, },
{ {
field: "updated", field: 'updated',
heading: "Updated", heading: 'Updated',
type: 'date', type: 'date',
}, },
{ {
field: "is_admin", field: 'is_admin',
heading: "Admin", heading: 'Admin',
type: 'boolean', type: 'boolean',
}, },
] ]
@ -49,7 +51,9 @@
getItemCountEndpoint: { getItemCountEndpoint: {
callable: readUserCount, callable: readUserCount,
args: [] args: []
} },
defaultSortField: 'id',
defaultSortOrder: 'asc',
}; };
</script> </script>

View file

@ -65,6 +65,9 @@ export default {
800: "#707070", 800: "#707070",
900: "#333333", 900: "#333333",
}, },
success: "#00CC00",
warning: "#EDC000",
failure: "#E40000",
}, },
screens: { screens: {
'3xl': '1920px', '3xl': '1920px',