Something went wrong. Try again.
svelte astro
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272<script lang="ts"> import { type CSVData } from "../utils"; import { tableState } from "../State.svelte"; let { csv }: { csv: CSVData } = $props();
// Row sizing const ROW_HEIGHT = 32; // px, height of every data row const ROW_OVERSCAN = 3; // extra rows rendered above/below the viewport
// Column sizing const INDEX_COL_WIDTH = 60; // px, width of the "#" row-number column const COLUMN_WIDTH = 150; // px, width used for every data column // NOTE: all columns share one width for now, which is what makes the // math below simple. // Supporting per-column widths later just means // turning COLUMN_WIDTH into an array and adjusting the offset math accordingly. const COLUMN_OVERSCAN = 2; // extra columns rendered left/right of the viewport
let scrollLeft = $state(0); let scrollTop = $derived((tableState.currentRow - 1) * ROW_HEIGHT);
// Actual on-screen size of the scrollable area, virtualization stays // correct regardless of where the table sits on the page // and adapts automatically if the container is resized. let containerWidth = $state(0); let containerHeight = $state(0);
// How many whole rows/columns fit in the visible area. let visibleRowCount = $derived(Math.ceil(containerHeight / ROW_HEIGHT)); let visibleColCount = $derived( Math.max( 0, Math.ceil((containerWidth - INDEX_COL_WIDTH) / COLUMN_WIDTH), ), );
// Vertical windw let startRow = $derived( Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - ROW_OVERSCAN), ); let endRow = $derived( Math.min( csv.rows.length, startRow + visibleRowCount + ROW_OVERSCAN * 2, ), );
// Horizontal window let startCol = $derived( Math.max(0, Math.floor(scrollLeft / COLUMN_WIDTH) - COLUMN_OVERSCAN), ); let endCol = $derived( Math.min( (csv.headers as string[]).length, startCol + visibleColCount + COLUMN_OVERSCAN * 2, ), );
// Whether we need a blank spacer cell before/after the rendered column // slice, and how wide (in px) that spacer needs to be to stand in for // all the skipped columns. let hasLeadingSpacer = $derived(startCol > 0); let hasTrailingSpacer = $derived(endCol < (csv.headers as string[]).length); let leadingSpacerWidth = $derived(startCol * COLUMN_WIDTH); let trailingSpacerWidth = $derived( ((csv.headers as string[]).length - endCol) * COLUMN_WIDTH, );
// total width of the table (index column + every data column). let totalTableWidth = $derived( INDEX_COL_WIDTH + (csv.headers as string[]).length * COLUMN_WIDTH, );
// Reference to the fixed header element so its scroll position can be // mirrored to match the body. let headerEl: HTMLDivElement | undefined;
function handleScroll(e: Event) { const target = e.target as HTMLElement; // Keep tableState.currentRow expressed as a 1-indexed row number, // never as raw pixels, so every reader/writer of currentRow // (Table.svelte included) shares the same units. tableState.currentRow = target.scrollTop / ROW_HEIGHT + 1; scrollLeft = target.scrollLeft; if (headerEl) headerEl.scrollLeft = scrollLeft; }
let scrollEl: HTMLDivElement | undefined;
$effect(() => { if (scrollEl) { // scrollTop here is the $derived pixel value computed from // tableState.currentRow above, not tableState.currentRow itself. scrollEl.scrollTop = scrollTop; scrollEl.scrollLeft = scrollLeft; } });</script>
{#snippet columnGroup()} <colgroup> <col style="width: {INDEX_COL_WIDTH}px" /> {#if hasLeadingSpacer} <col style="width: {leadingSpacerWidth}px" /> {/if} {#each Array.from({ length: endCol - startCol }) as _} <col style="width: {COLUMN_WIDTH}px" /> {/each} {#if hasTrailingSpacer} <col style="width: {trailingSpacerWidth}px" /> {/if} </colgroup>{/snippet}
<div class="container"> <!-- Fixed header row. Vertically it never scrolls; horizontally it's driven by the body's scroll position via handleScroll. --> <div class="header" bind:this={headerEl}> <table style="width: {totalTableWidth}px"> {@render columnGroup()} <thead> <tr> <th class="index-col">#</th> {#if hasLeadingSpacer}<th></th>{/if} {#each (csv.headers as string[]).slice(startCol, endCol) as col} <th>{col}</th> {/each} {#if hasTrailingSpacer}<th></th>{/if} </tr> </thead> </table> </div>
<div class="scroll" bind:this={scrollEl} bind:clientWidth={containerWidth} bind:clientHeight={containerHeight} onscroll={handleScroll} > <!-- Full-size spacer so both scrollbars reflect the true data size (csv.rows.length rows tall), even though only a slice of rows is actually rendered below. --> <div style="height: {csv.rows.length * ROW_HEIGHT}px; position: relative;" > <!-- Positioned to start exactly at the first rendered row. --> <table style="top: {startRow * ROW_HEIGHT}px; width: {totalTableWidth}px" > {@render columnGroup()} <tbody> {#each csv.rows.slice(startRow, endRow) as row, i} <tr> <td class="index-col">{startRow + i + 1}</td> {#if hasLeadingSpacer}<td></td>{/if} {#each row.slice(startCol, endCol) as cell} <td>{cell}</td> {/each} {#if hasTrailingSpacer}<td></td>{/if} </tr> {/each} </tbody> </table> </div> </div></div>
<style> .container { display: flex; flex-direction: column; height: 100%; }
.header { overflow: hidden; background: var(--text); color: var(--surface); flex-shrink: 0; }
.header th { height: 2em; padding: 0 var(--padding); font-weight: bold; }
.scroll { flex: 1; overflow-y: scroll; overflow-x: auto; background: var(--surface); }
.scroll::-webkit-scrollbar { width: 6px; height: 6px; } .scroll::-webkit-scrollbar-track { background: transparent; } .scroll::-webkit-scrollbar-thumb { background: var(--border); }
.scroll table { position: absolute; }
/* Authoritative column sizing: with fixed layout, column widths come exclusively from <colgroup> (via `width` on each <col>), regardless of cell content — including empty spacer cells, which is what makes the spacer columns actually take up their intended space. */ .header table, .scroll table { table-layout: fixed; }
td { height: 32px; padding: 0 10px; font-size: 0.85em; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-bottom: 1px solid var(--border); transition: background-color 0.3s, color 0.3s; }
/* Row-number column. Pinned to the left edge with `position: sticky` so it stays visible while scrolling horizontally through data columns. Its actual width comes from the shared <colgroup> (INDEX_COL_WIDTH), not from CSS. An opaque background is required so columns scrolling underneath don't show through. */ .index-col { position: sticky; left: 0; z-index: 1; }
.header .index-col { background: var(--text); }
td.index-col { background: var(--surface); color: var(--textMuted); font-size: 0.75em; text-align: right; padding-right: 12px; border-right: 1px solid var(--border); }
tbody tr:hover td { color: var(--surface); font-weight: bold; background: var(--textMuted); border-bottom-color: var(--textMuted); }
tbody tr:hover td.index-col { color: var(--surface); border-right-color: var(--surface); }</style>