table.svelte
1 <script module lang="ts"> 2 export interface RowClickEventPayload { 3 rowIndex: number; 4 event: MouseEvent; 5 } 6 </script> 7 8 <script lang="ts"> 9 import { createSvelteTable, flexRender, type TableOptions } from '@tanstack/svelte-table'; 10 import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; 11 import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; 12 import InfoCircle from '$lib/components/icons/InfoCircle.svelte'; 13 import Tooltip from '../tooltip/tooltip.svelte'; 14 import { browser } from '$app/environment'; 15 16 interface Props { 17 // eslint-disable-next-line @typescript-eslint/no-explicit-any 18 options: TableOptions<any>; 19 rowHeight?: number | undefined; 20 isRowClickable?: boolean; 21 onRowClick?: (payload: RowClickEventPayload) => void; 22 } 23 24 let { options, rowHeight = undefined, isRowClickable = false, onRowClick }: Props = $props(); 25 26 function handleRowClick(index: number, e: MouseEvent) { 27 if (isRowClickable) { 28 onRowClick?.({ rowIndex: index, event: e }); 29 } 30 } 31 32 let rowElems: HTMLTableRowElement[] = $state([]); 33 34 function handleKeyboard(e: KeyboardEvent) { 35 if (e.key === 'Enter' || e.code === 'Space') { 36 const focussedElem = document.activeElement; 37 38 if ( 39 !focussedElem || 40 !(focussedElem instanceof HTMLTableRowElement) || 41 !rowElems.includes(focussedElem) 42 ) { 43 return; 44 } 45 46 e.preventDefault(); 47 focussedElem.dispatchEvent( 48 new PointerEvent('click', { 49 metaKey: e.metaKey, 50 }), 51 ); 52 } 53 } 54 let table = $derived(createSvelteTable(options)); 55 </script> 56 57 <svelte:window onkeydown={handleKeyboard} /> 58 59 <!-- Tanstack table rendering is for some reason broken on SSR. TODO: get rid of this god forsaken dependency --> 60 {#if browser} 61 <table> 62 <thead> 63 {#each $table.getHeaderGroups() as headerGroup} 64 <tr> 65 {#each headerGroup.headers as header} 66 <th 67 onclick={header.column.getToggleSortingHandler()} 68 class:sortable={header.column.getCanSort()} 69 style={`width: ${header.column.getSize()}%`} 70 > 71 {#if !header.isPlaceholder} 72 <div> 73 <div class="header"> 74 {#if typeof header.column.columnDef.header === 'string'} 75 <span class="typo-all-caps">{header.column.columnDef.header}</span> 76 {/if} 77 {#if typeof header.column.columnDef.meta === 'object'} 78 {#if 'tooltipMessage' in header.column.columnDef.meta && typeof header.column.columnDef.meta['tooltipMessage'] === 'string'} 79 <Tooltip> 80 <InfoCircle style="height: 1rem;" /> 81 {#snippet tooltip_content()} 82 {// eslint-disable-next-line @typescript-eslint/no-explicit-any 83 (header.column.columnDef.meta as any).tooltipMessage} 84 {/snippet} 85 </Tooltip> 86 {/if} 87 {/if} 88 </div> 89 {#if header.column.getIsSorted() === 'asc'} 90 <ChevronDown /> 91 {:else if header.column.getIsSorted() === 'desc'} 92 <ChevronUp /> 93 {/if} 94 </div> 95 {/if} 96 </th> 97 {/each} 98 </tr> 99 {/each} 100 </thead> 101 <tbody> 102 {#each $table.getRowModel().rows as row, index} 103 <tr 104 style:height="{rowHeight}px" 105 onclick={(e) => handleRowClick(index, e)} 106 onauxclick={(e) => handleRowClick(index, e)} 107 class:cursor-pointer={isRowClickable} 108 tabindex={isRowClickable ? 0 : -1} 109 bind:this={rowElems[index]} 110 > 111 {#each row.getVisibleCells() as cell} 112 {@const props = cell.getContext().getValue()} 113 {@const SvelteComponent = flexRender(cell.column.columnDef.cell, props)} 114 <td 115 class:typo-text-bold={cell.column.getIsSorted()} 116 class:sorted={cell.column.getIsSorted()} 117 > 118 <div> 119 <SvelteComponent {...typeof props === 'object' ? props : {}} /> 120 </div> 121 </td> 122 {/each} 123 </tr> 124 {/each} 125 </tbody> 126 <tfoot> 127 {#each $table.getFooterGroups() as footerGroup} 128 <tr> 129 {#each footerGroup.headers as header} 130 <th> 131 {#if !header.isPlaceholder} 132 {@const SvelteComponent_1 = flexRender( 133 header.column.columnDef.footer, 134 header.getContext(), 135 )} 136 <SvelteComponent_1 /> 137 {/if} 138 </th> 139 {/each} 140 </tr> 141 {/each} 142 </tfoot> 143 </table> 144 {/if} 145 146 <style> 147 table { 148 padding: 0; 149 border-collapse: separate; 150 border-spacing: 0; 151 box-sizing: border-box; 152 min-width: 100%; 153 --border: 1px solid var(--color-foreground-level-3); 154 --first-cell-padding-left: 1.25rem; 155 } 156 157 .header { 158 display: flex; 159 } 160 161 tbody { 162 width: 100%; 163 } 164 165 tbody > tr:first-child > td:first-child { 166 border-radius: 1rem 0 0 0; 167 } 168 169 tbody > tr:last-child > td:first-child { 170 border-radius: 0 0 0 1rem; 171 } 172 173 tbody > tr:last-child > td:last-child { 174 border-radius: 0 0 1rem 0; 175 } 176 177 tbody > tr > td { 178 border-top: var(--border); 179 } 180 181 tbody > tr > td:first-child { 182 border-left: var(--border); 183 padding-left: var(--first-cell-padding-left); 184 } 185 186 tbody > tr > td:last-child { 187 border-right: var(--border); 188 } 189 190 tbody > tr:last-child > td { 191 border-bottom: var(--border); 192 } 193 194 tbody > tr:only-child > td:first-child { 195 border-radius: 1rem 0 0 1rem; 196 } 197 198 tbody > tr:only-child > td:last-child { 199 border-radius: 0 0 1rem 0; 200 } 201 202 td { 203 padding: 0.75rem; 204 vertical-align: middle; 205 color: var(--color-foreground); 206 } 207 208 td.sorted { 209 color: var(--color-foreground); 210 } 211 212 td > div { 213 display: flex; 214 align-items: center; 215 white-space: nowrap; 216 } 217 218 thead th { 219 text-align: left; 220 padding: 0.75rem; 221 user-select: none; 222 color: var(--color-foreground); 223 white-space: nowrap; 224 } 225 226 thead th:first-child { 227 padding-left: var(--first-cell-padding-left); 228 } 229 230 thead th div { 231 display: flex; 232 align-items: center; 233 } 234 235 thead th.sortable { 236 cursor: pointer; 237 } 238 239 tfoot th { 240 font-weight: normal; 241 } 242 243 tr { 244 transition: background-color 300ms; 245 } 246 247 tbody tr:not(:hover) { 248 background-color: var(--color-background); 249 } 250 251 tr.cursor-pointer:hover { 252 background-color: var(--color-primary-level-1); 253 } 254 255 tr.cursor-pointer:focus { 256 background-color: var(--color-primary-level-1); 257 outline: none; 258 } 259 </style>