Introduction
is an attempt to an 1:1 port of svelte-window
, but there are small differences. Concerning cell-merging, that is a good thing. There are discussions about it, but bottom line is that cell-merging with react-window
is not easy. With react-window
it is not a big deal.
svelte-window
The difference between the libraries is that
directly renders its react-window
, while children
only hands the necessary data as an array to its svelte-window
. The Svelte approach implies that the consumer of the library needs to implement a little more herself, but it also means that some of the control is shifted.
slots
With cell-merging I mean that you can form combine cells in a grid into bigger blocks, spanning over several rows and columns. Only rectangular blocks are covered. The most efficient implementation can depend on the type of merging you want to have in your grid. I only show some examples, there are many more ways to achieve cell-merging.
Basics
Let's assume, we got a completely filled grid, where every cell is uniquely identified by the row and column index, and every combination of row and column index maps to exactly one cell. Eg. If we want to merge the cell in row
and column 4
with the cell 8
in column 5
, we could increase the height of cell (8
,4
) by the height of row 8
and skip the rendering of cell (5
,5
). If we additionally want to merge the cells of the next column (8
,4
) and (9
,5
), we would skip to render these cells and increase the width of the cell (9
,4
) by the width of column 8
. With a simple table this is good practise, eg. see this 3x3 table implementation, where the cells in the second and third row and column are merged:
9
<table> <tr> <td>1-1</td> <td>1-2</td> <td>1-3</td> </tr><tr> <td>2-1</td> <td rowspan="2" colspan="2">2-2</td> <td style="display:none">2-3</td> </tr><tr> <td>3-1</td> <td style="display:none">3-2</td> <td style="display:none">3-3</td> </tr> </table>
The result looks like this:
1-1 | 1-2 | 1-3 |
2-1 | 2-2 | |
3-1 |
It gets more complicated when we introduce
. It means, that we have a big table, and only those cells are supposed to be rendered, which are currently within the users' view. So, if we have merged the cells (windowing
, 4
) to (8
, 5
), but the top-left cell of our window is (9
, 5
), only the lower part of the merged cell is in view. The first question is, what do we want to render? There are three options:
8
1 render the whole cell 1 render only the lower half 1 skip the whole cell
To render the whole merged cell, we would need to acquire the height of row
. 4
hands us an array of item-data starting in the cell ( svelte-window
, 5
), so the information would not be present in that array. With a fixed-height table, that would not be a problem, since we already know the height. It would be more cumbersome with flexible height.
8
Option 3 is the simplest, but it may look glitchy. When you don't have big merged areas and experiment with the props
and overscanColumnCount
, it might even work. These props tell overscanRowCount
to render a few rows and columns outside of the visible range.
svelte-window
My examples will focus on option 2. It only deals with the item array from
and it shifts the text or cell content into the visible area.
svelte-window
Implemented cell-merging
Here I will show two examples, of how to implement cell-merging. The first extends the basic use-case of
, while the second approach html tables, which is a bit unconventional, at least in a svelte-window
-ish context, since something like this is not easily possible with it.
react-window
Example 1
An important part of cell-merging is how you structure your data. In the example, there is a generic rule for merging, but in real-world data, it would probably be not so hard to programmatically assign the required attributes. The merging itself is quick because the array of visible cells is rather small.
Data preparation - Grid
The goal of this example is to merge 2 rows and 3 cells in every sevenths row and column. The table has 1000 rows and 1000 columns. Here is the (somewhat ugly) data creation step:
const data = new Array(1000) .fill(true) .map((v,i)=>new Array(1000) .fill(true) .map((v,j)=>{ if ((i % 7) === 0){ if ((j % 7) === 0){ return {color:"red",mergeRow:2,mergeCol:3} }else if ((j % 7 ) === 1){ return {color:"red",mergeRow:2,mergeCol:2} }else if ((j % 7 ) === 2){ return {color:"red",mergeRow:2} } }else if ((i % 7) === 1){ if ((j % 7) === 0){ return {color:"red",mergeCol:3} }else if ((j % 7 ) === 1){ return {color:"red",mergeCol:2} }else if ((j % 7 ) === 2){ return {color:"red"} } } return {color:"blue"} }))
So, eg. the cell in row
, column 7
is merged up to cell (7
, 8
), so the data item for that cell gets attributes 9
and mergeRow:2
. Cell merging is only looking downward and to the right, so cell (mergeCol:3
, 7
) also gets the attribute 8
, but only mergeRow:2
. The cell (mergeCol:2
, 8
) does not get the 7
attribute, which corresponds to mergeRow
(~every cell is merged with itself), but also mergeRow:1
since it is merged with the two cells to its right. All merged cells get the same content (color: "red").
mergeCol:3
Grid implementation
Here is the grid code:
<Grid columnCount={1000} columnWidth={(index) => columnWidths[index]} height={400} rowCount={1000} rowHeight={(index) => rowHeights[index]} width={400} overscanRowCount={0} overscanColumnCount={0} let:items> {#each mergeCells(items) as it (it.key)} <div style={sty(it.style) + "background-color:"+it.color}> {it.text} </div> {/each} </Grid>
In this case I use the variable size grid. The only important deviation from the default library examples is that a function
is wrapped around the mergeCells
array in the items
command. Let`s look at the implementation:
#each
const mergeCells = (items) => { // create a new array, which will be rendered const retitems = []; // object, to save, which cells can be skipped const skip = {}; for (let i = 0; i < items.length; i++) { // retain only a copy of the original item // especially copy the style object, since we are going to mutate it const item = {...items[i],style:{...items[i].style}}; let old; if (old=skip[item.rowIndex+"-"+item.columnIndex]){ // cell can be skipped. if (old.rowIndex < item.rowIndex){ old.rowIndex = item.rowIndex; old.style.height += item.style.height; } if (old.columnIndex < item.columnIndex){ old.columnIndex = item.columnIndex; old.style.width += item.style.width; } continue } // process cell const dataItem = data[item.rowIndex][item.columnIndex]; item.color = dataItem.color; item.text = item.rowIndex + "-" + item.columnIndex; retitems.push(item); // check if other cells can be skipped if ((dataItem.mergeCol||1) === 1 && (dataItem.mergeRow||1)===1){ continue; } for (let m = 0; m < (dataItem.mergeRow||1);m++){ for (let k = 0; k < (dataItem.mergeCol||1); k++){ // found a cell which can be skipped skip[(item.rowIndex + m) + "-" + (item.columnIndex+k)] = item; } } } return retitems; };
Compared to our 1000x1000 input array, the items array is small, so looping through it is not expensive with regard to performance. The array
always starts in the top left corner and then moves row-wise. In the loop, the code processes the cell and if the corresponding data item has either attribute items
or mergeRow
larger than one, it adds the following row and/or column indices as key to the mergeCol
object, the value is the processed data item itself. When one of the cells, which is supposed to be skipped is reached, it finds its index in the skip
object. It then checks if the row height or column width should be added to the rendered cell since it should only be added once.
skip
<script lang="ts"> import { VariableSizeGrid as Grid, styleString as sty, } from "svelte-window"; const rowHeights = new Array(1000) .fill(true) .map(() => 25 + Math.round(Math.random() * 50)); const columnWidths = new Array(1000) .fill(true) .map(() => 75 + Math.round(Math.random() * 50)); const data = new Array(1000) .fill(true) .map((v,i)=>new Array(1000) .fill(true) .map((v,j)=>{ if ((i % 7) === 0){ if ((j % 7) === 0){ return {color:"red",mergeRow:2,mergeCol:3} }else if ((j % 7 ) === 1){ return {color:"red",mergeRow:2,mergeCol:2} }else if ((j % 7 ) === 2){ return {color:"red",mergeRow:2} } }else if ((i % 7) === 1){ if ((j % 7) === 0){ return {color:"red",mergeCol:3} }else if ((j % 7 ) === 1){ return {color:"red",mergeCol:2} }else if ((j % 7 ) === 2){ return {color:"red"} } } return {color:"blue"} })) const mergeCells = (items : GridChildComponentProps[]) => { // create a new array, which will be rendered const retitems : (GridChildComponentProps & {color?:string,text?:string})[] = []; // object, to save, which cells can be skipped const skip = {}; for (let i = 0; i < items.length; i++) { // retain only a copy of the original item // especially copy the style object, since we are going to mutate it const item : GridChildComponentProps & {color?:string,text?:string} = {...items[i],style:{...items[i].style}}; let old; if (old=skip[item.rowIndex+"-"+item.columnIndex]){ // cell can be skipped. if (old.rowIndex < item.rowIndex){ old.rowIndex = item.rowIndex; old.style.height += item.style.height; } if (old.columnIndex < item.columnIndex){ old.columnIndex = item.columnIndex; old.style.width += item.style.width; } continue } // process cell const dataItem = data[item.rowIndex][item.columnIndex]; item.color = dataItem.color; item.text = item.rowIndex + "-" + item.columnIndex; retitems.push(item); // check if other cells can be skipped if ((dataItem.mergeCol||1) === 1 && (dataItem.mergeRow||1)===1){ continue; } for (let m = 0; m < (dataItem.mergeRow||1);m++){ for (let k = 0; k < (dataItem.mergeCol||1); k++){ // found a cell which can be skipped skip[(item.rowIndex + m) + "-" + (item.columnIndex+k)] = item; } } } return retitems; }; </script> <Grid columnCount={1000} columnWidth={(index) => columnWidths[index]} height={600} rowCount={1000} rowHeight={(index) => rowHeights[index]} width={1200} overscanRowCount={0} overscanColumnCount={0} let:items> {#each mergeCells(items) as it (it.key)} <div style={sty(it.style) + "background-color:"+it.color}> {it.text} </div> {/each} </Grid>
So, if the cell (
, 7
) is somewhere in the middle of the viewable range, it is rendered as a 2x3 sized cell. If it is near the right border, eg. only column 7
is visible, the width of column 8
is not added to it, so it is rendered as a 2x2 sized cell. If it is outside of the visible range, eg. the visible range starts at row 9
, then the cell (8
, 8
) is rendered as a 1x3 sized cell.
7
Example 2
The second example I want to show here involves an html table. It might happen that you got a normal table, which becomes too large over time, so you want to add windowing. Usually, windowed tables with libraries like
and react-window
use react-virtualized
s to create grids, which can lead to inconveniences, since these are styled a bit differently than html tables. Since you got more control over the rendering with div
you could migrate your table to something like I will show here. The final table shows, at least to me, surprisingly good performance
svelte-window
Let's start with the grid, I use a fixed size grid for simplicity here. I move out the table code to a separate file, instead of inlining everything. Mainly just the
array is passed as a prob:
items
<Grid columnCount={1000} columnWidth={160} height={600} rowCount={1000} rowHeight={80} width={1200} let:items> <BigTable items={items} rowHeight={80}/> </Grid>
The html code in
is a normal html table:
BigTable.svelte
<table style={'table-layout: fixed;position: absolute;top: 0;left: 0;width:' + tablewidth + 'px;transform:translate(' + left + 'px,' + top + 'px);'}> {#each rows as _, r} <tr style={`height:${rowHeight}px;`}> {#each cols as __, c} <td {...spans(r, c)} > {isScrolling ? '...' : r + rowmin + ' - ' + (c + colmin)} </td> {/each} </tr> {/each} </table>
The table is moved around, it is always pushed to the position of the top-left cell in the
array. I am using items
instead of setting top and left because it should deliver better performance. All cell merging is handled with the function transform: translate
and I will show it down below. From the arrays spans
and rows
, I only use the index, the value itself is not interesting. Here is how the values are generated within the cols
-section:
<script>
export let items = []; export let rowHeight; $: ({ top, left } = items[0].style); $: [rowmin, colmin] = [items[0].rowIndex, items[0].columnIndex]; $: [[rowmax, colmax, tablewidth]] = items .slice(-1) .map((v) => [ v.rowIndex, v.columnIndex, v.style.left + v.style.width - left, v.style.top + v.style.height - top, ]); $: (nrows = rowmax - rowmin + 1); $: (ncols = colmax - colmin + 1); $: (isScrolling = items[0].isScrolling); $: (rows = Array(nrows).fill(true)); $: (cols = Array(ncols).fill(true));
, top
, left
and rowmin
are taken from the first element in the array colmin
and items
, rowmax
and colmax
are inferred from the last item.
tablewidth
Data preparation - Table
The data structuring from the first and this example are pretty much equivalent, you can choose what you like more. Here I again generate a 1000x1000 Array of objects like this
with row index { text: i + " - " + j }
and column index i
. To every tenth row and column, I add an attribute j
, which is filled withmerge
, so this is the top-left cell of a merged block of size 8x7. If this is cell ({rowspan:4, colspan:7}
,10
), the cells up to cell (10
,13
) get an attribute 16
, which points to the top-left cell: mergehead
.
{ row: 10, col: 10 }
The function
returns the props for the cells, so:
spans
- an empty object for normal cells
for hidden cells, which adds the style{class:"noshow"}
display:none
- an object with
and/orrowspan
for the top-left cell of a merged blockcolspan
The code is a bit lengthy. The basic idea is that you only need to look at the top row and the most left column of the items array to handle overlapping merged blocks, which are only partially in the visible window.
Here is the complete code:
<script> export let items = []; export let rowHeight; $: ({ top, left } = items[0].style); $: [rowmin, colmin] = [items[0].rowIndex, items[0].columnIndex]; $: [[rowmax, colmax, tablewidth]] = items .slice(-1) .map((v) => [ v.rowIndex, v.columnIndex, v.style.left + v.style.width - left, v.style.top + v.style.height - top, ]); $: (nrows = rowmax - rowmin + 1); $: (ncols = colmax - colmin + 1); $: (isScrolling = items[0].isScrolling); $: (rows = Array(nrows).fill(false)); $: (cols = Array(ncols).fill(true)); const data = (() => { return new Array(1000).fill(true).map((v, i) => new Array(1000).fill(true).map((w, j) => { return { text: i + " - " + j }; }) ); })(); for (let a = 0; a < 99; a++) { for (let b = 0; b < 99; b++) { for (let i = 0; i < 4; i++) { for (let j = 0; j < 7; j++) { if (i === 0 && j === 0) { data[10*a + i][10 * b + j].merge = { rowspan: 4, colspan: 7 }; } else { data[10 * a + i][10 * b + j].mergehead = { row: 10 * a, col: 10 * b }; } } } } } let rowspanblock = 1, colspanblock = 1; const clampspan = (r, c, obj) => { const re = {}; if (obj.rowspan) { re.rowspan = Math.min(obj.rowspan, nrows - r); } if (obj.colspan) { re.colspan = Math.min(obj.colspan, ncols - c); } return re; }; // r and c start with 0 const spans = (r, c) => { const d = data[r + rowmin][c + colmin]; if (d.merge) { if (d.merge) { if (c === 0) { rowspanblock = d.merge.rowspan || 1; } if (r === 0) { colspanblock = d.merge.colspan || 1; } return clampspan(r, c, d.merge); } } if (r === 0 && c === 0) { rowspanblock = 1; colspanblock = 1; } if (c === 0 && rowspanblock > 1) { rowspanblock--; return { class: "noshow" }; } if (r === 0 && colspanblock > 1) { colspanblock--; return { class: "noshow" }; } if (!d.mergehead) { return {}; } if (r !== 0 && c !== 0) { return { class: "noshow" }; } const head = data[d.mergehead.row][d.mergehead.col].merge; let rs = (head.rowspan || 1) + (r === 0 ? d.mergehead.row - rowmin : 0); let cs = (head.colspan || 1) + (c === 0 ? d.mergehead.col - colmin : 0); if (c === 0) { rowspanblock = rs; } if (r === 0) { colspanblock = cs; } return clampspan(r, c, { rowspan: rs, colspan: cs, }); }; </script> <style> table, td { border: 1px solid black; border-collapse: collapse; } .noshow { display: none; } </style> <table style={'table-layout: fixed;position: absolute;top: 0;left: 0;width:' + tablewidth + 'px;transform:translate(' + left + 'px,' + top + 'px);'}> {#each rows as i, r} <tr> {#each cols as j, c} <td {...spans(r, c)} style={`height:${rowHeight}px;`}> {isScrolling ? '...' : r + rowmin + ' - ' + (c + colmin)} </td> {/each} </tr> {/each} </table>
Conclusion
With
I tried to port svelte-window
as closely as possible, but some deviations are inevitable. Here I showed, that this leads to new opportunities: you can merge cells in big grids with good performance. There are many more ways, how you could implement cell-merging and the best solution depends on your use-case.
react-window
I showed two examples, one with the canonical way of using windowed tables with
s, and one with html tables. Feel free to use any part of the code shown here in your projects.
div
Share this article on twitter - Discuss on Reddit - Vote on next topics - Support me
This might also be interesting for you:
- How to Implement Custom React Hooks in Svelte
- React Hooks in Svelte
- tree-shake-css - project page
- Sveltes tick is my new best friend - porting react-textfit to Svelte
- Porting React to Svelte - From react-window 1:1 to svelte-window
- Poll: Which React library would you love to see in Svelte?
- Porting React components to Svelte - It might be easier than you think
- Svelte and Typescript
- How this blog is made - Basic Structure, Elder and Markdown
- How this blog is made part 2 - Make your website sketchy with Tailwind and Roughjs
- Showcase: The Descent Ripple Effect or The React Descent Ripple Effect
- Why are component libraries so complicated with React compared to Svelte?