/ src / lib / components / table / table.svelte
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>