@svelte-lean/table 0.15.3 → 0.15.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/dist/style.css +180 -91
- package/dist/table/data-table.svelte +248 -84
- package/dist/table.svelte.js +12 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# @svelte-lean/table
|
|
2
2
|
|
|
3
|
+
## 0.15.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- cbb6e13: Faster long grid tables, measured on an admin list of 1000 rows and 17 columns:
|
|
8
|
+
|
|
9
|
+
- The stylesheet's rules are keyed on an element, class or attribute (no `:where(a, b)` lists, no
|
|
10
|
+
`:is(th, td)` or bare `:has()` key, no `*`), so the browser no longer tries them against every
|
|
11
|
+
element of the page; the found-value rule is `td:has([data-slean-found])`.
|
|
12
|
+
- A sort, a page or any state change that leaves the visible columns as they were keeps their
|
|
13
|
+
array, the cells' attributes and the track list, so a manual table's sort click draws no cell
|
|
14
|
+
again.
|
|
15
|
+
- Body sections hold 20 rows instead of 50, and the idle warming lays out one section per idle
|
|
16
|
+
period that has room for it, by reading a box inside it (no second style pass), and remembers
|
|
17
|
+
which sections are laid out with which rows and tracks, so a sort, a refetch or a scroll does not
|
|
18
|
+
start it over.
|
|
19
|
+
- A progressive mount measures the page's longest rows (by their formatted values) with its first
|
|
20
|
+
rows, before the first frame, so a long value further down no longer widens its column later.
|
|
21
|
+
- A progressive slice grows at most twice from the last one, so a slice after a search is cleared
|
|
22
|
+
no longer draws 400 rows in one task; and rows arriving again while a progressive mount is under
|
|
23
|
+
way (a cached page, then the server's) no longer draw the rest of the page in one task.
|
|
24
|
+
- A row's `vertical-align` and `border-color` are set by the stylesheet instead of inherited (the
|
|
25
|
+
user agent's `inherit`), so a grid section skipped and drawn again in a scroll no longer restyles
|
|
26
|
+
its rows.
|
|
27
|
+
|
|
28
|
+
`apps/playground/tests/bench/workload.bench.spec.ts` (1000 rows, 17 columns, 4x CPU, against the
|
|
29
|
+
published 0.15.3, median of 7): every row drawn 5151 -> 3737 ms, blocking while mounting 285 -> 102
|
|
30
|
+
ms, the longest idle task 53 -> 0 ms, a second fast scroll's style 168 -> 52 ms, a manual sort click
|
|
31
|
+
200 -> 104 ms, style per 1000 elements 14.2 -> 7.1 ms.
|
|
32
|
+
On the admin list it came from (1000 bets of staging data, 4x CPU, three alternating runs against
|
|
33
|
+
0.15.2): every row drawn 5140 -> 3744 ms, a first fast scroll's blocking time 2610 -> 239 ms, a sort
|
|
34
|
+
click 776 -> 176 ms, layout shift 0.016 -> 0.010.
|
|
35
|
+
|
|
3
36
|
## 0.15.3
|
|
4
37
|
|
|
5
38
|
### Patch Changes
|
package/dist/style.css
CHANGED
|
@@ -110,20 +110,19 @@
|
|
|
110
110
|
:where(.slean-table[data-density='spacious']) {
|
|
111
111
|
--slean-table-cell-padding-y: 0.875rem;
|
|
112
112
|
}
|
|
113
|
-
:where(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
.slean-table-scroll > table
|
|
121
|
-
) {
|
|
113
|
+
:where(.slean-table-button),
|
|
114
|
+
:where(.slean-table-input),
|
|
115
|
+
:where(.slean-table-sort),
|
|
116
|
+
:where(.slean-table-resize),
|
|
117
|
+
:where(.slean-table-column-panel),
|
|
118
|
+
:where(.slean-table-scroll),
|
|
119
|
+
:where(.slean-table-scroll > table) {
|
|
122
120
|
box-sizing: border-box;
|
|
123
121
|
}
|
|
124
122
|
|
|
125
123
|
/* Owned controls: 32px, outline, tokens. */
|
|
126
|
-
:where(.slean-table-button,
|
|
124
|
+
:where(.slean-table-button),
|
|
125
|
+
:where(.slean-table-columns > summary) {
|
|
127
126
|
display: inline-flex;
|
|
128
127
|
align-items: center;
|
|
129
128
|
justify-content: center;
|
|
@@ -143,7 +142,8 @@
|
|
|
143
142
|
white-space: nowrap;
|
|
144
143
|
cursor: pointer;
|
|
145
144
|
}
|
|
146
|
-
:where(.slean-table-button:hover:not(:disabled),
|
|
145
|
+
:where(.slean-table-button:hover:not(:disabled)),
|
|
146
|
+
:where(.slean-table-columns > summary:hover) {
|
|
147
147
|
background: var(--slean-table-soft);
|
|
148
148
|
}
|
|
149
149
|
:where(.slean-table-button:disabled) {
|
|
@@ -171,15 +171,13 @@
|
|
|
171
171
|
outline: 2px solid transparent;
|
|
172
172
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--slean-table-focus) 22%, transparent);
|
|
173
173
|
}
|
|
174
|
-
:where(
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
.slean-table-check
|
|
182
|
-
):focus-visible {
|
|
174
|
+
:where(.slean-table-button):focus-visible,
|
|
175
|
+
:where(.slean-table-sort):focus-visible,
|
|
176
|
+
:where(.slean-table-resize):focus-visible,
|
|
177
|
+
:where(.slean-table-group-toggle):focus-visible,
|
|
178
|
+
:where(.slean-table-columns > summary):focus-visible,
|
|
179
|
+
:where(.slean-table-scroll):focus-visible,
|
|
180
|
+
:where(.slean-table-check):focus-visible {
|
|
183
181
|
outline: 2px solid var(--slean-table-focus);
|
|
184
182
|
outline-offset: 2px;
|
|
185
183
|
}
|
|
@@ -188,6 +186,14 @@
|
|
|
188
186
|
outline: 2px solid var(--slean-table-focus);
|
|
189
187
|
outline-offset: -2px;
|
|
190
188
|
}
|
|
189
|
+
/* The user agent gives a row `vertical-align: inherit` and `border-color: inherit`; an element
|
|
190
|
+
that inherits explicitly is styled again with its parent, so each time a grid section was
|
|
191
|
+
skipped or drawn again in a scroll, every row in it was too. Set here (the cells set their
|
|
192
|
+
own alignment; a row draws no border), a section's rows stay as they are. */
|
|
193
|
+
:where(.slean-table-scroll > table > tbody > tr) {
|
|
194
|
+
vertical-align: middle;
|
|
195
|
+
border-color: currentColor;
|
|
196
|
+
}
|
|
191
197
|
:where(.slean-table-scroll > table > tbody > tr[data-clickable]) {
|
|
192
198
|
cursor: pointer;
|
|
193
199
|
}
|
|
@@ -295,14 +301,16 @@
|
|
|
295
301
|
padding: 0.75rem;
|
|
296
302
|
color: var(--slean-table-muted);
|
|
297
303
|
}
|
|
298
|
-
:where(.slean-table-scroll > table > * > tr >
|
|
304
|
+
:where(.slean-table-scroll > table > * > tr > th),
|
|
305
|
+
:where(.slean-table-scroll > table > * > tr > td) {
|
|
299
306
|
padding: var(--slean-table-cell-padding-y) var(--slean-table-cell-padding-x);
|
|
300
307
|
border-bottom: 1px solid var(--slean-table-border);
|
|
301
308
|
background: var(--slean-table-row-bg, var(--slean-table-bg));
|
|
302
309
|
overflow-wrap: anywhere;
|
|
303
310
|
vertical-align: middle;
|
|
304
311
|
}
|
|
305
|
-
:where(.slean-table-scroll > table > thead > tr >
|
|
312
|
+
:where(.slean-table-scroll > table > thead > tr > th),
|
|
313
|
+
:where(.slean-table-scroll > table > thead > tr > td) {
|
|
306
314
|
background: var(--slean-table-header-bg);
|
|
307
315
|
color: var(--slean-table-header-fg);
|
|
308
316
|
font-size: var(--slean-table-header-font-size);
|
|
@@ -364,7 +372,7 @@
|
|
|
364
372
|
> table
|
|
365
373
|
> thead
|
|
366
374
|
> tr:first-child
|
|
367
|
-
> :first-child
|
|
375
|
+
> th:first-child
|
|
368
376
|
) {
|
|
369
377
|
border-start-start-radius: calc(var(--slean-table-radius) - 1px);
|
|
370
378
|
}
|
|
@@ -374,7 +382,7 @@
|
|
|
374
382
|
> table
|
|
375
383
|
> thead
|
|
376
384
|
> tr:first-child
|
|
377
|
-
> :last-child
|
|
385
|
+
> th:last-child
|
|
378
386
|
) {
|
|
379
387
|
border-start-end-radius: calc(var(--slean-table-radius) - 1px);
|
|
380
388
|
}
|
|
@@ -455,7 +463,8 @@
|
|
|
455
463
|
cursor: col-resize;
|
|
456
464
|
touch-action: none;
|
|
457
465
|
}
|
|
458
|
-
:where(.slean-table-resize:hover,
|
|
466
|
+
:where(.slean-table-resize:hover),
|
|
467
|
+
:where(.slean-table-resize:focus-visible) {
|
|
459
468
|
background: linear-gradient(var(--slean-table-primary), var(--slean-table-primary)) center / 2px
|
|
460
469
|
100% no-repeat;
|
|
461
470
|
}
|
|
@@ -506,7 +515,9 @@
|
|
|
506
515
|
}
|
|
507
516
|
|
|
508
517
|
/* Row controls: 24px borderless icon buttons. */
|
|
509
|
-
:where(.slean-table-expand,
|
|
518
|
+
:where(.slean-table-expand),
|
|
519
|
+
:where(.slean-table-edit),
|
|
520
|
+
:where(.slean-table-grip) {
|
|
510
521
|
min-inline-size: 1.5rem;
|
|
511
522
|
min-block-size: 1.5rem;
|
|
512
523
|
padding: 0;
|
|
@@ -515,12 +526,10 @@
|
|
|
515
526
|
color: var(--slean-table-muted);
|
|
516
527
|
flex-shrink: 0;
|
|
517
528
|
}
|
|
518
|
-
:where(
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
.slean-table-grip:focus-visible
|
|
523
|
-
) {
|
|
529
|
+
:where(.slean-table-expand:hover),
|
|
530
|
+
:where(.slean-table-edit:hover),
|
|
531
|
+
:where(.slean-table-grip:hover),
|
|
532
|
+
:where(.slean-table-grip:focus-visible) {
|
|
524
533
|
color: var(--slean-table-fg);
|
|
525
534
|
}
|
|
526
535
|
/* The row grip (onRowMove): the dots are masked on a pseudo-element, so the focus ring stays. */
|
|
@@ -570,7 +579,8 @@
|
|
|
570
579
|
margin-inline-start: auto;
|
|
571
580
|
opacity: 0.4;
|
|
572
581
|
}
|
|
573
|
-
:where(td:hover > .slean-table-cell > .slean-table-edit,
|
|
582
|
+
:where(td:hover > .slean-table-cell > .slean-table-edit),
|
|
583
|
+
:where(.slean-table-edit:focus-visible) {
|
|
574
584
|
opacity: 1;
|
|
575
585
|
}
|
|
576
586
|
:where(.slean-table-ellipsis > .slean-table-cell) {
|
|
@@ -600,10 +610,14 @@
|
|
|
600
610
|
:where(.slean-table-scroll > table > tbody > tr[data-selected='true']) {
|
|
601
611
|
--slean-table-row-bg: var(--slean-table-row-selected);
|
|
602
612
|
}
|
|
603
|
-
:where(.slean-table[data-bordered='true'] > .slean-table-scroll > table > * > tr >
|
|
613
|
+
:where(.slean-table[data-bordered='true'] > .slean-table-scroll > table > * > tr > th),
|
|
614
|
+
:where(.slean-table[data-bordered='true'] > .slean-table-scroll > table > * > tr > td) {
|
|
604
615
|
border-inline-end: 1px solid var(--slean-table-border);
|
|
605
616
|
}
|
|
606
|
-
:where(.slean-table[data-bordered='true'] > .slean-table-scroll > table > * > tr > :last-child)
|
|
617
|
+
:where(.slean-table[data-bordered='true'] > .slean-table-scroll > table > * > tr > th:last-child),
|
|
618
|
+
:where(
|
|
619
|
+
.slean-table[data-bordered='true'] > .slean-table-scroll > table > * > tr > td:last-child
|
|
620
|
+
) {
|
|
607
621
|
border-inline-end: 0;
|
|
608
622
|
}
|
|
609
623
|
|
|
@@ -613,14 +627,19 @@
|
|
|
613
627
|
z-index: 2;
|
|
614
628
|
}
|
|
615
629
|
/* A cell holding a value the browser's find opened (a findable, `data-slean-found`) lets it
|
|
616
|
-
stand past its edge, above the pinned cells beside it and below the sticky header.
|
|
617
|
-
|
|
630
|
+
stand past its edge, above the pinned cells beside it and below the sticky header.
|
|
631
|
+
Keyed on the cell element: a bare `:has()` was tried, with its walk of the subtree, against
|
|
632
|
+
every element of the page. */
|
|
633
|
+
:where(.slean-table-scroll > table > tbody > tr > td:has([data-slean-found])),
|
|
634
|
+
:where(.slean-table-scroll > table > tbody > tr > th:has([data-slean-found])) {
|
|
618
635
|
overflow: visible;
|
|
619
636
|
z-index: 4;
|
|
620
637
|
}
|
|
621
638
|
|
|
622
639
|
/* Leading control columns (expand, grip, selection) and pinned columns. */
|
|
623
|
-
:where(.slean-table-mover,
|
|
640
|
+
:where(.slean-table-mover),
|
|
641
|
+
:where(.slean-table-selection),
|
|
642
|
+
:where(.slean-table-expander) {
|
|
624
643
|
position: sticky;
|
|
625
644
|
inset-inline-start: 0;
|
|
626
645
|
z-index: 3;
|
|
@@ -638,7 +657,9 @@
|
|
|
638
657
|
width: var(--slean-table-expand-width, 40px);
|
|
639
658
|
padding-inline: 0;
|
|
640
659
|
}
|
|
641
|
-
:where(thead .slean-table-mover,
|
|
660
|
+
:where(thead .slean-table-mover),
|
|
661
|
+
:where(thead .slean-table-selection),
|
|
662
|
+
:where(thead .slean-table-expander) {
|
|
642
663
|
z-index: 4;
|
|
643
664
|
}
|
|
644
665
|
/* Pinned edges: a line on the edge cells, one shade per side. */
|
|
@@ -656,7 +677,8 @@
|
|
|
656
677
|
:where(.slean-table[data-layout='grid'] > .slean-table-scroll[data-pinned]) {
|
|
657
678
|
grid-template-columns: max(100%, var(--slean-table-min-width, 0px));
|
|
658
679
|
}
|
|
659
|
-
:where(.slean-table-scroll[data-pinned] >
|
|
680
|
+
:where(.slean-table-scroll[data-pinned] > table),
|
|
681
|
+
:where(.slean-table-scroll[data-pinned] > .slean-table-pin-shade) {
|
|
660
682
|
grid-area: 1 / 1;
|
|
661
683
|
isolation: isolate;
|
|
662
684
|
}
|
|
@@ -675,10 +697,8 @@
|
|
|
675
697
|
inset-inline: auto var(--slean-table-pin-inset);
|
|
676
698
|
margin-inline: 0 var(--slean-table-pin-inset);
|
|
677
699
|
}
|
|
678
|
-
:where(
|
|
679
|
-
|
|
680
|
-
.slean-table-pin-shade[data-pin='left']:dir(rtl)
|
|
681
|
-
) {
|
|
700
|
+
:where(.slean-table-pin-shade[data-pin='right']:dir(ltr)),
|
|
701
|
+
:where(.slean-table-pin-shade[data-pin='left']:dir(rtl)) {
|
|
682
702
|
scale: -1 1;
|
|
683
703
|
}
|
|
684
704
|
/* Shown while content lies past that side; without scroll-state, while rows overflow. */
|
|
@@ -721,7 +741,17 @@
|
|
|
721
741
|
user-select: none;
|
|
722
742
|
-webkit-user-select: none;
|
|
723
743
|
}
|
|
724
|
-
|
|
744
|
+
/* The root's cursor reaches the rest by inheritance; only what draws a cursor of its own is
|
|
745
|
+
named (a `*` here was tried against every element of the page). */
|
|
746
|
+
:where(.slean-table[data-row-dragging]),
|
|
747
|
+
:where(.slean-table[data-row-dragging] > .slean-table-scroll > table > tbody > tr),
|
|
748
|
+
:where(.slean-table[data-row-dragging] a),
|
|
749
|
+
:where(.slean-table[data-row-dragging] button),
|
|
750
|
+
:where(.slean-table[data-row-dragging] input),
|
|
751
|
+
:where(.slean-table[data-row-dragging] select),
|
|
752
|
+
:where(.slean-table[data-row-dragging] textarea),
|
|
753
|
+
:where(.slean-table[data-row-dragging] label),
|
|
754
|
+
:where(.slean-table[data-row-dragging] summary) {
|
|
725
755
|
cursor: grabbing;
|
|
726
756
|
}
|
|
727
757
|
:where(
|
|
@@ -730,7 +760,31 @@
|
|
|
730
760
|
> table
|
|
731
761
|
> tbody
|
|
732
762
|
> tr:not([data-lifted])
|
|
733
|
-
>
|
|
763
|
+
> [data-pin]
|
|
764
|
+
),
|
|
765
|
+
:where(
|
|
766
|
+
.slean-table[data-row-dragging]
|
|
767
|
+
> .slean-table-scroll
|
|
768
|
+
> table
|
|
769
|
+
> tbody
|
|
770
|
+
> tr:not([data-lifted])
|
|
771
|
+
> .slean-table-mover
|
|
772
|
+
),
|
|
773
|
+
:where(
|
|
774
|
+
.slean-table[data-row-dragging]
|
|
775
|
+
> .slean-table-scroll
|
|
776
|
+
> table
|
|
777
|
+
> tbody
|
|
778
|
+
> tr:not([data-lifted])
|
|
779
|
+
> .slean-table-selection
|
|
780
|
+
),
|
|
781
|
+
:where(
|
|
782
|
+
.slean-table[data-row-dragging]
|
|
783
|
+
> .slean-table-scroll
|
|
784
|
+
> table
|
|
785
|
+
> tbody
|
|
786
|
+
> tr:not([data-lifted])
|
|
787
|
+
> .slean-table-expander
|
|
734
788
|
) {
|
|
735
789
|
z-index: 1;
|
|
736
790
|
}
|
|
@@ -865,7 +919,8 @@
|
|
|
865
919
|
min-inline-size: 4.75rem;
|
|
866
920
|
font-size: inherit;
|
|
867
921
|
}
|
|
868
|
-
:where(.slean-table-pager,
|
|
922
|
+
:where(.slean-table-pager),
|
|
923
|
+
:where(.slean-table-pages) {
|
|
869
924
|
display: flex;
|
|
870
925
|
align-items: center;
|
|
871
926
|
gap: 0.25rem;
|
|
@@ -890,16 +945,16 @@
|
|
|
890
945
|
color: var(--slean-table-muted);
|
|
891
946
|
}
|
|
892
947
|
/* Direction-bearing icons point the other way in right-to-left tables. */
|
|
893
|
-
:where(
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
.slean-table-column-panel .slean-table-icon
|
|
897
|
-
):dir(rtl) {
|
|
948
|
+
:where(.slean-table-pager .slean-table-icon):dir(rtl),
|
|
949
|
+
:where([aria-expanded='false'] > .slean-table-icon):dir(rtl),
|
|
950
|
+
:where(.slean-table-column-panel .slean-table-icon):dir(rtl) {
|
|
898
951
|
scale: -1 1;
|
|
899
952
|
}
|
|
900
953
|
|
|
901
954
|
@media (prefers-reduced-motion: no-preference) {
|
|
902
|
-
:where(.slean-table-button,
|
|
955
|
+
:where(.slean-table-button),
|
|
956
|
+
:where(.slean-table-input),
|
|
957
|
+
:where(.slean-table-columns > summary) {
|
|
903
958
|
transition-property: background-color, border-color, box-shadow, color;
|
|
904
959
|
transition-duration: var(--slean-table-duration);
|
|
905
960
|
}
|
|
@@ -953,7 +1008,8 @@
|
|
|
953
1008
|
:where(.slean-table-column-panel) {
|
|
954
1009
|
min-inline-size: 18rem;
|
|
955
1010
|
}
|
|
956
|
-
:where(.slean-table-page-size,
|
|
1011
|
+
:where(.slean-table-page-size),
|
|
1012
|
+
:where(.slean-table-pages) {
|
|
957
1013
|
display: none;
|
|
958
1014
|
}
|
|
959
1015
|
}
|
|
@@ -999,12 +1055,10 @@
|
|
|
999
1055
|
width: max(100%, var(--slean-table-min-width, 0px));
|
|
1000
1056
|
min-width: 0;
|
|
1001
1057
|
}
|
|
1002
|
-
:where(
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
> :is(thead, tbody, tfoot, caption)
|
|
1007
|
-
) {
|
|
1058
|
+
:where(.slean-table[data-layout='grid'] > .slean-table-scroll > table > thead),
|
|
1059
|
+
:where(.slean-table[data-layout='grid'] > .slean-table-scroll > table > tbody),
|
|
1060
|
+
:where(.slean-table[data-layout='grid'] > .slean-table-scroll > table > tfoot),
|
|
1061
|
+
:where(.slean-table[data-layout='grid'] > .slean-table-scroll > table > caption) {
|
|
1008
1062
|
display: block;
|
|
1009
1063
|
}
|
|
1010
1064
|
:where(.slean-table[data-layout='grid'] > .slean-table-scroll > table > * > tr) {
|
|
@@ -1023,7 +1077,7 @@
|
|
|
1023
1077
|
:where(.slean-table[data-layout='grid'] > .slean-table-scroll > table > tbody.slean-table-chunk) {
|
|
1024
1078
|
content-visibility: auto;
|
|
1025
1079
|
contain-intrinsic-block-size: auto
|
|
1026
|
-
calc(var(--slean-table-chunk-rows,
|
|
1080
|
+
calc(var(--slean-table-chunk-rows, 20) * var(--slean-table-row-estimate, 2.75rem));
|
|
1027
1081
|
}
|
|
1028
1082
|
/* A section whose rows are still being mounted holds the height they are expected to stand. */
|
|
1029
1083
|
:where(
|
|
@@ -1033,21 +1087,26 @@
|
|
|
1033
1087
|
> tbody.slean-table-chunk[data-pending]
|
|
1034
1088
|
) {
|
|
1035
1089
|
min-block-size: calc(
|
|
1036
|
-
var(--slean-table-chunk-rows,
|
|
1090
|
+
var(--slean-table-chunk-rows, 20) * var(--slean-table-row-estimate, 2.75rem)
|
|
1037
1091
|
);
|
|
1038
1092
|
}
|
|
1039
|
-
:where(.slean-table[data-layout='grid'] > .slean-table-scroll > table > * > tr >
|
|
1093
|
+
:where(.slean-table[data-layout='grid'] > .slean-table-scroll > table > * > tr > th),
|
|
1094
|
+
:where(.slean-table[data-layout='grid'] > .slean-table-scroll > table > * > tr > td) {
|
|
1040
1095
|
display: flex;
|
|
1041
1096
|
align-items: center;
|
|
1042
1097
|
min-width: 0;
|
|
1043
1098
|
}
|
|
1044
1099
|
/* The renderer's own boxes in a cell fill it; content drawn straight into a cell (a badge, a
|
|
1045
|
-
chip, a link in a plain grid cell, a control) keeps its own width. */
|
|
1100
|
+
chip, a link in a plain grid cell, a control) keeps its own width (flex's initial value). */
|
|
1046
1101
|
:where(
|
|
1047
|
-
.slean-table[data-layout='grid']
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1102
|
+
.slean-table[data-layout='grid']
|
|
1103
|
+
> .slean-table-scroll
|
|
1104
|
+
> table
|
|
1105
|
+
> *
|
|
1106
|
+
> tr
|
|
1107
|
+
> :is(th, td)
|
|
1108
|
+
> .slean-table-cell
|
|
1109
|
+
),
|
|
1051
1110
|
:where(
|
|
1052
1111
|
.slean-table[data-layout='grid']
|
|
1053
1112
|
> .slean-table-scroll
|
|
@@ -1055,19 +1114,29 @@
|
|
|
1055
1114
|
> *
|
|
1056
1115
|
> tr
|
|
1057
1116
|
> :is(th, td)
|
|
1058
|
-
>
|
|
1059
|
-
)
|
|
1060
|
-
flex: 1 1 auto;
|
|
1061
|
-
min-width: 0;
|
|
1062
|
-
}
|
|
1063
|
-
/* The selection and expand columns center their one control. */
|
|
1117
|
+
> .slean-table-heading
|
|
1118
|
+
),
|
|
1064
1119
|
:where(
|
|
1065
1120
|
.slean-table[data-layout='grid']
|
|
1066
1121
|
> .slean-table-scroll
|
|
1067
1122
|
> table
|
|
1068
1123
|
> *
|
|
1069
1124
|
> tr
|
|
1070
|
-
> :is(
|
|
1125
|
+
> :is(th, td)
|
|
1126
|
+
> .slean-table-editor
|
|
1127
|
+
) {
|
|
1128
|
+
flex: 1 1 auto;
|
|
1129
|
+
min-width: 0;
|
|
1130
|
+
}
|
|
1131
|
+
/* The selection and expand columns center their one control. */
|
|
1132
|
+
:where(
|
|
1133
|
+
.slean-table[data-layout='grid'] > .slean-table-scroll > table > * > tr > .slean-table-mover
|
|
1134
|
+
),
|
|
1135
|
+
:where(
|
|
1136
|
+
.slean-table[data-layout='grid'] > .slean-table-scroll > table > * > tr > .slean-table-selection
|
|
1137
|
+
),
|
|
1138
|
+
:where(
|
|
1139
|
+
.slean-table[data-layout='grid'] > .slean-table-scroll > table > * > tr > .slean-table-expander
|
|
1071
1140
|
) {
|
|
1072
1141
|
justify-content: center;
|
|
1073
1142
|
}
|
|
@@ -1100,14 +1169,16 @@
|
|
|
1100
1169
|
display: block;
|
|
1101
1170
|
width: max-content;
|
|
1102
1171
|
}
|
|
1103
|
-
:where(.slean-table-scroll > table[data-measuring] >
|
|
1172
|
+
:where(.slean-table-scroll > table[data-measuring] > thead),
|
|
1173
|
+
:where(.slean-table-scroll > table[data-measuring] > tbody) {
|
|
1104
1174
|
display: block;
|
|
1105
1175
|
}
|
|
1106
1176
|
:where(.slean-table-scroll > table[data-measuring] > * > tr) {
|
|
1107
1177
|
display: grid;
|
|
1108
1178
|
content-visibility: visible;
|
|
1109
1179
|
}
|
|
1110
|
-
:where(.slean-table-scroll > table[data-measuring] > * > tr >
|
|
1180
|
+
:where(.slean-table-scroll > table[data-measuring] > * > tr > th),
|
|
1181
|
+
:where(.slean-table-scroll > table[data-measuring] > * > tr > td) {
|
|
1111
1182
|
display: flex;
|
|
1112
1183
|
align-items: center;
|
|
1113
1184
|
position: static;
|
|
@@ -1118,9 +1189,8 @@
|
|
|
1118
1189
|
grid-auto-columns: max-content;
|
|
1119
1190
|
width: max-content;
|
|
1120
1191
|
}
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
}
|
|
1192
|
+
/* A measured cell's children keep their content width: set inline on the copies by the
|
|
1193
|
+
renderer (`measure()`), not by a `> *` rule every element of the page would be tried on. */
|
|
1124
1194
|
:where(.slean-table-scroll > table[data-measuring] > thead > tr) {
|
|
1125
1195
|
grid-auto-columns: min-content;
|
|
1126
1196
|
}
|
|
@@ -1161,7 +1231,15 @@
|
|
|
1161
1231
|
> table
|
|
1162
1232
|
> tbody
|
|
1163
1233
|
> tr
|
|
1164
|
-
> td.slean-table-cell
|
|
1234
|
+
> td.slean-table-cell[data-clip]
|
|
1235
|
+
),
|
|
1236
|
+
:where(
|
|
1237
|
+
.slean-table[data-layout='grid']
|
|
1238
|
+
> .slean-table-scroll
|
|
1239
|
+
> table
|
|
1240
|
+
> tbody
|
|
1241
|
+
> tr
|
|
1242
|
+
> td.slean-table-cell.slean-table-ellipsis
|
|
1165
1243
|
) {
|
|
1166
1244
|
display: block;
|
|
1167
1245
|
overflow: hidden;
|
|
@@ -1177,13 +1255,25 @@
|
|
|
1177
1255
|
:where(.slean-table[data-layout='grid'] > .slean-table-scroll > table > tbody > tr > td) {
|
|
1178
1256
|
background: none;
|
|
1179
1257
|
}
|
|
1258
|
+
:where(.slean-table[data-layout='grid'] > .slean-table-scroll > table > tbody > tr > [data-pin]),
|
|
1259
|
+
:where(
|
|
1260
|
+
.slean-table[data-layout='grid'] > .slean-table-scroll > table > tbody > tr > .slean-table-mover
|
|
1261
|
+
),
|
|
1262
|
+
:where(
|
|
1263
|
+
.slean-table[data-layout='grid']
|
|
1264
|
+
> .slean-table-scroll
|
|
1265
|
+
> table
|
|
1266
|
+
> tbody
|
|
1267
|
+
> tr
|
|
1268
|
+
> .slean-table-selection
|
|
1269
|
+
),
|
|
1180
1270
|
:where(
|
|
1181
1271
|
.slean-table[data-layout='grid']
|
|
1182
1272
|
> .slean-table-scroll
|
|
1183
1273
|
> table
|
|
1184
1274
|
> tbody
|
|
1185
1275
|
> tr
|
|
1186
|
-
>
|
|
1276
|
+
> .slean-table-expander
|
|
1187
1277
|
) {
|
|
1188
1278
|
background: var(--slean-table-row-bg, var(--slean-table-bg));
|
|
1189
1279
|
}
|
|
@@ -1193,14 +1283,12 @@
|
|
|
1193
1283
|
overflow-wrap: normal;
|
|
1194
1284
|
}
|
|
1195
1285
|
@media print {
|
|
1196
|
-
:where(
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
.slean-table-pin-shade
|
|
1203
|
-
) {
|
|
1286
|
+
:where(.slean-table-toolbar),
|
|
1287
|
+
:where(.slean-table-pagination),
|
|
1288
|
+
:where(.slean-table-resize),
|
|
1289
|
+
:where(.slean-table-edit),
|
|
1290
|
+
:where(.slean-table-grip),
|
|
1291
|
+
:where(.slean-table-pin-shade) {
|
|
1204
1292
|
display: none !important;
|
|
1205
1293
|
}
|
|
1206
1294
|
:where(.slean-table-scroll) {
|
|
@@ -1211,7 +1299,8 @@
|
|
|
1211
1299
|
min-width: 0;
|
|
1212
1300
|
width: 100%;
|
|
1213
1301
|
}
|
|
1214
|
-
:where(.slean-table-scroll > table > * > tr >
|
|
1302
|
+
:where(.slean-table-scroll > table > * > tr > th),
|
|
1303
|
+
:where(.slean-table-scroll > table > * > tr > td) {
|
|
1215
1304
|
position: static !important;
|
|
1216
1305
|
box-shadow: none;
|
|
1217
1306
|
}
|
|
@@ -30,6 +30,15 @@
|
|
|
30
30
|
/** A click on one of these inside a cell belongs to the control, not to the row. */
|
|
31
31
|
const NOT_ROW_CLICK =
|
|
32
32
|
'button,a,input,select,textarea,label,summary,[contenteditable],[role="menuitem"],[data-action],[data-no-row-click]';
|
|
33
|
+
/**
|
|
34
|
+
* `next`, or `previous` when it says the same (compared as JSON): what every row reads keeps its
|
|
35
|
+
* object across a state change that does not change it.
|
|
36
|
+
*/
|
|
37
|
+
function same<V>(previous: V | undefined, next: V): V {
|
|
38
|
+
return previous !== undefined && JSON.stringify(previous) === JSON.stringify(next)
|
|
39
|
+
? previous
|
|
40
|
+
: next;
|
|
41
|
+
}
|
|
33
42
|
/** The inline-start inset of a sticky column: the widths of the columns before it, summed. */
|
|
34
43
|
function inset(...widths: (string | false)[]): string {
|
|
35
44
|
const parts = widths.filter(Boolean);
|
|
@@ -379,8 +388,11 @@
|
|
|
379
388
|
!SERVER && !hydrating && batch && model.rows.length > batch ? batch : Number.POSITIVE_INFINITY
|
|
380
389
|
)
|
|
381
390
|
);
|
|
382
|
-
/**
|
|
383
|
-
|
|
391
|
+
/**
|
|
392
|
+
* The rows of the last model; the first `limit` of them are drawn. A row drawn there keeps its
|
|
393
|
+
* place (in grid layout, its body section) in the next model.
|
|
394
|
+
*/
|
|
395
|
+
let renderedRows: RowNode<T>[] | null = null;
|
|
384
396
|
let job = 0;
|
|
385
397
|
let rowsPerSlice = 10;
|
|
386
398
|
const shown = $derived(limit >= model.rows.length ? model.rows : model.rows.slice(0, limit));
|
|
@@ -391,8 +403,15 @@
|
|
|
391
403
|
* section of the page stands from the start: one whose rows are not drawn yet holds their
|
|
392
404
|
* expected height, so a progressive mount does not grow the page, and move what is below it,
|
|
393
405
|
* with every slice.
|
|
406
|
+
*
|
|
407
|
+
* Twenty rows: a section the browser has not laid out yet is styled and laid out in one piece,
|
|
408
|
+
* in the frame that scrolls it into view or in the idle task that warms it, and fifty rows of an
|
|
409
|
+
* admin list (17 columns of spans) took about 70 ms at 4x CPU, a long task each; twenty take
|
|
410
|
+
* about 30 ms. Ten would take 15 ms but spend more on every frame of a scroll (each section is a
|
|
411
|
+
* box the browser checks, lays out and paints on its own: a fifth more main-thread work than at
|
|
412
|
+
* twenty in the admin's fast scroll).
|
|
394
413
|
*/
|
|
395
|
-
const CHUNK =
|
|
414
|
+
const CHUNK = 20;
|
|
396
415
|
let previousChunks: RowNode<T>[][] = [];
|
|
397
416
|
const chunks = $derived.by(() => {
|
|
398
417
|
if (!grid) return (previousChunks = []);
|
|
@@ -414,13 +433,19 @@
|
|
|
414
433
|
const rows = model.rows;
|
|
415
434
|
untrack(() => {
|
|
416
435
|
const size = batch;
|
|
417
|
-
const
|
|
418
|
-
|
|
436
|
+
const before = renderedRows;
|
|
437
|
+
renderedRows = rows;
|
|
419
438
|
job++;
|
|
420
|
-
if (!
|
|
439
|
+
if (!before) {
|
|
421
440
|
if (limit < rows.length) continueMount(job);
|
|
422
441
|
return;
|
|
423
442
|
}
|
|
443
|
+
// Only the rows drawn so far count as drawn: new data arriving while a progressive mount
|
|
444
|
+
// is under way (a cached page, then the server's) once drew the rest of the page in one
|
|
445
|
+
// task, its rows' ids being known though they were never drawn (800 ms at 4x CPU).
|
|
446
|
+
const previous = new Map(
|
|
447
|
+
before.slice(0, Math.min(limit, before.length)).map((n, i) => [n.id, i])
|
|
448
|
+
);
|
|
424
449
|
// Rows to draw: new ones, and in grid layout rows that changed section (a section's rows
|
|
425
450
|
// are drawn anew when they move to another one). Rows before the first of them stay.
|
|
426
451
|
let drawn = 0;
|
|
@@ -445,6 +470,8 @@
|
|
|
445
470
|
job++;
|
|
446
471
|
});
|
|
447
472
|
function continueMount(id: number) {
|
|
473
|
+
// A new mount starts from a modest slice and grows from what its own slices cost.
|
|
474
|
+
rowsPerSlice = Math.min(rowsPerSlice, 40);
|
|
448
475
|
const channel = new MessageChannel();
|
|
449
476
|
channel.port1.onmessage = () => {
|
|
450
477
|
if (id !== job) return channel.port1.close();
|
|
@@ -453,7 +480,13 @@
|
|
|
453
480
|
limit += count;
|
|
454
481
|
flushSync();
|
|
455
482
|
const cost = (performance.now() - start) / count;
|
|
456
|
-
|
|
483
|
+
// At most twice as many rows as the slice just measured: rows the prefix reaches that
|
|
484
|
+
// were drawn already cost nothing, and a slice of them alone once sized the next one at
|
|
485
|
+
// 400 new rows (a 400 ms task at 4x CPU after a search was cleared).
|
|
486
|
+
rowsPerSlice = Math.max(
|
|
487
|
+
5,
|
|
488
|
+
Math.min(400, count * 2, Math.floor(SLICE_MS / Math.max(cost, 0.05)))
|
|
489
|
+
);
|
|
457
490
|
if (id !== job || limit >= model.rows.length) {
|
|
458
491
|
if (id === job) limit = Number.POSITIVE_INFINITY;
|
|
459
492
|
return channel.port1.close();
|
|
@@ -470,7 +503,7 @@
|
|
|
470
503
|
* What a plain body cell's attributes are, per column, computed once per column change: the
|
|
471
504
|
* cell template reads them as plain properties, so a row adds no per-cell computations.
|
|
472
505
|
*/
|
|
473
|
-
const
|
|
506
|
+
const cellMetaNext = $derived(
|
|
474
507
|
Object.fromEntries(
|
|
475
508
|
cols.map((col) => {
|
|
476
509
|
const pin = pins.get(col.id);
|
|
@@ -489,6 +522,13 @@
|
|
|
489
522
|
})
|
|
490
523
|
)
|
|
491
524
|
);
|
|
525
|
+
/**
|
|
526
|
+
* The cell attributes every row reads, the same object while they say the same: the pins are
|
|
527
|
+
* derived from the state, which a sort or a page replaces, and a new object here would run
|
|
528
|
+
* every cell's attribute update again.
|
|
529
|
+
*/
|
|
530
|
+
let shownMeta: typeof cellMetaNext | undefined;
|
|
531
|
+
const cellMeta = $derived((shownMeta = same(shownMeta, cellMetaNext)));
|
|
492
532
|
/**
|
|
493
533
|
* The plain cells' contexts of one row, by column id: built once per row (a `{@const}` in the
|
|
494
534
|
* row), again only when the row's node or the columns change. `index` is read through a getter,
|
|
@@ -578,6 +618,36 @@
|
|
|
578
618
|
let measuredColumns = '';
|
|
579
619
|
let measuredRows: Set<RowId> = new Set();
|
|
580
620
|
let measuredWhole = false;
|
|
621
|
+
/**
|
|
622
|
+
* The probe: the page's longest rows that a progressive mount has not drawn yet, one per column
|
|
623
|
+
* (judged by the length of the formatted value, which costs no layout), drawn once into a hidden
|
|
624
|
+
* `display: none` copy of the table and measured with the first rows, before they are painted.
|
|
625
|
+
* A long value further down the page then widens its column in the first frame, not seconds
|
|
626
|
+
* later, when the whole page is measured, under the reader (a layout shift). `probedFirst` is the
|
|
627
|
+
* first row of the page last probed.
|
|
628
|
+
*/
|
|
629
|
+
let probedFirst = $state<RowId | undefined>(undefined);
|
|
630
|
+
let probeRegion = $state<HTMLDivElement | undefined>(undefined);
|
|
631
|
+
const probe = $derived.by((): RowNode<T>[] => {
|
|
632
|
+
const rows = model.rows;
|
|
633
|
+
if (!grid || SERVER || limit >= rows.length || rows[0]?.id === probedFirst) return [];
|
|
634
|
+
const rest = rows.slice(limit);
|
|
635
|
+
const picked = new Set<RowNode<T>>();
|
|
636
|
+
for (const col of cols) {
|
|
637
|
+
let best: RowNode<T> | undefined;
|
|
638
|
+
let longest = 0;
|
|
639
|
+
for (const node of rest) {
|
|
640
|
+
if (node.data === undefined) continue;
|
|
641
|
+
const length = textOf(col, valueOf(node.data, col), node.data)?.length ?? 0;
|
|
642
|
+
if (length > longest) {
|
|
643
|
+
longest = length;
|
|
644
|
+
best = node;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (best) picked.add(best);
|
|
648
|
+
}
|
|
649
|
+
return [...picked];
|
|
650
|
+
});
|
|
581
651
|
$effect(() => {
|
|
582
652
|
if (!grid) return;
|
|
583
653
|
const columns = cols.map((col) => col.id).join('\u0000');
|
|
@@ -591,11 +661,21 @@
|
|
|
591
661
|
measuredRows = new Set(rows.map((n) => n.id));
|
|
592
662
|
measuredWhole = complete;
|
|
593
663
|
measure(complete);
|
|
664
|
+
probedFirst = rows[0]!.id;
|
|
594
665
|
void tick().then(() => untrack(() => estimateRows()));
|
|
595
666
|
return;
|
|
596
667
|
}
|
|
597
|
-
// A
|
|
598
|
-
|
|
668
|
+
// A new page mounting progressively: its first rows and the probe, before they are
|
|
669
|
+
// painted; the rest of it once the mount completes.
|
|
670
|
+
if (!complete) {
|
|
671
|
+
if (probe.length && !measuredRows.has(rows[0]!.id)) {
|
|
672
|
+
measuredRows = new Set(rows.map((n) => n.id));
|
|
673
|
+
measuredWhole = false;
|
|
674
|
+
measure(false);
|
|
675
|
+
probedFirst = rows[0]!.id;
|
|
676
|
+
}
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
599
679
|
const fresh = rows.some((n) => !measuredRows.has(n.id));
|
|
600
680
|
if (!fresh && measuredWhole) return;
|
|
601
681
|
// The first rows are measured again only on a new page (its first row is new); a page whose
|
|
@@ -654,7 +734,17 @@
|
|
|
654
734
|
const rows = [...ownRows()];
|
|
655
735
|
// The first rows and the header, and for a whole page twenty rows spread over the rest; a pass
|
|
656
736
|
// over a page whose first rows were measured already (`first` false) takes only the spread.
|
|
657
|
-
|
|
737
|
+
// With the first rows come the probe's: the page's longest rows not drawn yet.
|
|
738
|
+
const sample = every
|
|
739
|
+
? rows
|
|
740
|
+
: first
|
|
741
|
+
? [
|
|
742
|
+
...rows.slice(0, MEASURE_ROWS),
|
|
743
|
+
...(probeRegion?.querySelectorAll<HTMLTableRowElement>(
|
|
744
|
+
':scope > table > tbody > tr[data-row-id]'
|
|
745
|
+
) ?? [])
|
|
746
|
+
]
|
|
747
|
+
: [];
|
|
658
748
|
if (!every && whole && rows.length > MEASURE_ROWS) {
|
|
659
749
|
const step = Math.ceil((rows.length - MEASURE_ROWS) / 20);
|
|
660
750
|
for (let i = MEASURE_ROWS; i < rows.length; i += step) sample.push(rows[i]!);
|
|
@@ -673,6 +763,10 @@
|
|
|
673
763
|
const tbody = document.createElement('tbody');
|
|
674
764
|
for (const row of sample) tbody.append(row.cloneNode(true));
|
|
675
765
|
clone.append(thead, tbody);
|
|
766
|
+
// Every cell's content at its own width (no shrinking), written on the copies here rather
|
|
767
|
+
// than by a stylesheet rule keyed on `*`, which the browser would try on every element.
|
|
768
|
+
for (const cell of clone.querySelectorAll<HTMLElement>(':scope > * > tr > *'))
|
|
769
|
+
for (const child of cell.children) (child as HTMLElement).style.flex = 'none';
|
|
676
770
|
region.append(clone);
|
|
677
771
|
root.append(region);
|
|
678
772
|
const next = { ...measured };
|
|
@@ -689,60 +783,106 @@
|
|
|
689
783
|
region.remove();
|
|
690
784
|
if (changed) measured = next;
|
|
691
785
|
}
|
|
786
|
+
/**
|
|
787
|
+
* What each section was last laid out with: its rows (the chunk array, which keeps its identity
|
|
788
|
+
* while its rows do) and the track list. A section is warm while both still hold, whoever laid
|
|
789
|
+
* it out: this warming, or the browser drawing it (`contentvisibilityautostatechange`). The
|
|
790
|
+
* browser keeps the layout of a section it skips again, so a warm section stays warm off screen.
|
|
791
|
+
*/
|
|
792
|
+
const warmedWith = new WeakMap<Element, { rows: RowNode<T>[]; template: string }>();
|
|
793
|
+
function isWarm(section: Element, c: number): boolean {
|
|
794
|
+
const was = warmedWith.get(section);
|
|
795
|
+
return !!was && was.rows === chunks[c] && was.template === (tracks?.template ?? '');
|
|
796
|
+
}
|
|
797
|
+
function markWarm(section: Element, c: number) {
|
|
798
|
+
const rows = chunks[c];
|
|
799
|
+
if (rows) warmedWith.set(section, { rows, template: tracks?.template ?? '' });
|
|
800
|
+
}
|
|
801
|
+
/** The browser drew a section itself: it is laid out with the rows and tracks of now. */
|
|
802
|
+
function sectionShown(event: Event) {
|
|
803
|
+
const section = event.target as HTMLElement;
|
|
804
|
+
if (
|
|
805
|
+
(event as Event & { skipped?: boolean }).skipped === false &&
|
|
806
|
+
section.parentElement === tableElement &&
|
|
807
|
+
section.classList.contains('slean-table-chunk')
|
|
808
|
+
)
|
|
809
|
+
markWarm(section, chunkIndex(section));
|
|
810
|
+
}
|
|
811
|
+
/** A section's place among the sections (a placeholder body may stand before the first). */
|
|
812
|
+
function chunkIndex(section: HTMLElement): number {
|
|
813
|
+
const bodies = tableElement.tBodies;
|
|
814
|
+
const at = Array.prototype.indexOf.call(bodies, section);
|
|
815
|
+
return bodies[0]?.classList.contains('slean-table-chunk') ? at : at - 1;
|
|
816
|
+
}
|
|
817
|
+
/** This table's body sections, in order (grid layout). */
|
|
818
|
+
function chunkSections(): HTMLElement[] {
|
|
819
|
+
return [...tableElement.tBodies].filter((b) => b.classList.contains('slean-table-chunk'));
|
|
820
|
+
}
|
|
821
|
+
// The event does not bubble: one capturing listener on the table.
|
|
822
|
+
$effect(() => {
|
|
823
|
+
if (!grid) return;
|
|
824
|
+
const table = tableElement;
|
|
825
|
+
table.addEventListener('contentvisibilityautostatechange', sectionShown, true);
|
|
826
|
+
return () => table.removeEventListener('contentvisibilityautostatechange', sectionShown, true);
|
|
827
|
+
});
|
|
828
|
+
/** The idle time warming one section is expected to take, from the last one warmed. */
|
|
829
|
+
let warmCost = 0;
|
|
692
830
|
/**
|
|
693
831
|
* Idle warming (grid layout). A section the browser has skipped (`content-visibility: auto`) is
|
|
694
832
|
* styled and laid out in the frame that scrolls it into view; in a fast scroll through a long
|
|
695
833
|
* page that is every frame. Once a page is fully rendered, sections are laid out ahead in idle
|
|
696
|
-
* time, nearest the screen first,
|
|
697
|
-
* arrives costs only its paint
|
|
698
|
-
* does the browser's work early. New rows
|
|
834
|
+
* time, nearest the screen first, one per idle period that has room for it, so a section that
|
|
835
|
+
* arrives costs only its paint and no input waits behind more than one section. Nothing is
|
|
836
|
+
* windowed: every row stays in the document; this only does the browser's work early. New rows
|
|
837
|
+
* and new column widths make sections cold again.
|
|
699
838
|
*/
|
|
700
839
|
$effect(() => {
|
|
701
840
|
if (!grid || limit !== Number.POSITIVE_INFINITY || typeof requestIdleCallback !== 'function')
|
|
702
841
|
return;
|
|
842
|
+
// Rerun (and look for cold sections) when rows move between sections or the tracks change;
|
|
843
|
+
// a section whose rows and tracks held stays warm across reruns.
|
|
703
844
|
void chunks;
|
|
704
845
|
void tracks?.template;
|
|
705
|
-
const warm = new WeakSet<Element>();
|
|
706
846
|
let handle = 0;
|
|
707
|
-
let warmCost = 10;
|
|
708
847
|
const step = (deadline: IdleDeadline) => {
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
const distance =
|
|
717
|
-
box.bottom < view.top
|
|
718
|
-
? view.top - box.bottom
|
|
719
|
-
: Math.max(0, box.top - Math.min(view.bottom, innerHeight));
|
|
720
|
-
return { section, distance };
|
|
721
|
-
})
|
|
722
|
-
.sort((a, b) => a.distance - b.distance);
|
|
723
|
-
// As many sections as the idle period has room for, judged by what the last one cost (half
|
|
724
|
-
// of it may run past the period): warming a section is a layout of its rows, and a long
|
|
725
|
-
// idle callback is a long task all the same. A callback the browser ran late
|
|
726
|
-
// (`didTimeout`, a busy page) warms one.
|
|
727
|
-
let done = 0;
|
|
728
|
-
for (const { section } of cold) {
|
|
729
|
-
if (deadline.didTimeout ? done > 0 : deadline.timeRemaining() < Math.min(warmCost / 2, 40))
|
|
730
|
-
break;
|
|
731
|
-
warm.add(section);
|
|
732
|
-
// The section itself is never skipped, its rows are: a drawn first row means the
|
|
733
|
-
// browser has laid this section out already.
|
|
734
|
-
if (section.firstElementChild?.checkVisibility?.({ contentVisibilityAuto: true })) continue;
|
|
735
|
-
const start = performance.now();
|
|
736
|
-
section.style.contentVisibility = 'visible';
|
|
737
|
-
void section.offsetHeight;
|
|
738
|
-
section.style.contentVisibility = '';
|
|
739
|
-
warmCost = performance.now() - start;
|
|
740
|
-
done++;
|
|
848
|
+
const sections = chunkSections();
|
|
849
|
+
// One section per idle callback, and only when the period has room for what the last one
|
|
850
|
+
// took: a section's style and layout are one piece of work the browser cannot split, and
|
|
851
|
+
// input that arrives meanwhile waits for it. The first one is tried in a full period.
|
|
852
|
+
if (deadline.timeRemaining() < Math.min(Math.max(warmCost, 10), 45)) {
|
|
853
|
+
handle = requestIdleCallback(step);
|
|
854
|
+
return;
|
|
741
855
|
}
|
|
742
|
-
|
|
743
|
-
|
|
856
|
+
// Nearest the screen first: from the first section reaching the region's top, outwards.
|
|
857
|
+
const top = Math.max(0, viewport.getBoundingClientRect().top);
|
|
858
|
+
let low = 0;
|
|
859
|
+
let high = sections.length - 1;
|
|
860
|
+
while (low < high) {
|
|
861
|
+
const middle = (low + high) >> 1;
|
|
862
|
+
if (sections[middle]!.getBoundingClientRect().bottom < top) low = middle + 1;
|
|
863
|
+
else high = middle;
|
|
864
|
+
}
|
|
865
|
+
let target = -1;
|
|
866
|
+
for (let d = 0; d < sections.length * 2 && target < 0; d++) {
|
|
867
|
+
const c = d % 2 ? low - Math.ceil(d / 2) : low + d / 2;
|
|
868
|
+
const section = sections[c];
|
|
869
|
+
if (!section || isWarm(section, c)) continue;
|
|
870
|
+
// Drawn now: the browser has laid it out with the current rows.
|
|
871
|
+
if (section.firstElementChild?.checkVisibility?.({ contentVisibilityAuto: true }))
|
|
872
|
+
markWarm(section, c);
|
|
873
|
+
else target = c;
|
|
874
|
+
}
|
|
875
|
+
if (target < 0) return;
|
|
876
|
+
const section = sections[target]!;
|
|
877
|
+
const start = performance.now();
|
|
878
|
+
// Reading a box inside a skipped section lays the section out where it stands, without
|
|
879
|
+
// turning its skipping off and on (which costs a second style pass).
|
|
880
|
+
void section.lastElementChild?.getBoundingClientRect();
|
|
881
|
+
warmCost = performance.now() - start;
|
|
882
|
+
markWarm(section, target);
|
|
883
|
+
handle = requestIdleCallback(step);
|
|
744
884
|
};
|
|
745
|
-
handle = requestIdleCallback(step
|
|
885
|
+
handle = requestIdleCallback(step);
|
|
746
886
|
return () => cancelIdleCallback(handle);
|
|
747
887
|
});
|
|
748
888
|
/**
|
|
@@ -753,7 +893,7 @@
|
|
|
753
893
|
* not fit, the rows scroll sideways. Pinned and resized columns keep their exact width, which
|
|
754
894
|
* the sticky offsets are computed from.
|
|
755
895
|
*/
|
|
756
|
-
const
|
|
896
|
+
const tracksNext = $derived.by(() => {
|
|
757
897
|
if (!grid) return null;
|
|
758
898
|
const lead = leads;
|
|
759
899
|
const parts = [...lead];
|
|
@@ -774,6 +914,9 @@
|
|
|
774
914
|
min: lead.length ? `calc(${min}px + ${lead.join(' + ')})` : `${min}px`
|
|
775
915
|
};
|
|
776
916
|
});
|
|
917
|
+
/** The track list, the same object while it says the same (a sort or a page changes nothing). */
|
|
918
|
+
let shownTracks: { template: string; min: string } | null | undefined;
|
|
919
|
+
const tracks = $derived((shownTracks = same(shownTracks, tracksNext)));
|
|
777
920
|
/**
|
|
778
921
|
* Header rows. In grid layout every row is a grid of its own, so a cell spanning rows sits in
|
|
779
922
|
* the lowest row it spans, placed at its first column; the leading control headers sit there
|
|
@@ -963,6 +1106,10 @@
|
|
|
963
1106
|
const ariaRowCount = $derived(
|
|
964
1107
|
manual && rowCount !== undefined ? rowCount + headerRowCount : undefined
|
|
965
1108
|
);
|
|
1109
|
+
/** Where this page's rows start in `aria-rowindex`: one number every row reads, not the state. */
|
|
1110
|
+
const rowIndexBase = $derived(
|
|
1111
|
+
ariaRowCount === undefined ? undefined : headerRowCount + model.pageIndex * s.pageSize
|
|
1112
|
+
);
|
|
966
1113
|
const pageSizes = $derived([...new Set([...pageSizeOptions, s.pageSize])]);
|
|
967
1114
|
/**
|
|
968
1115
|
* A paged table loading draws a page of placeholders (at most 25, a screenful and more), so
|
|
@@ -1324,29 +1471,31 @@
|
|
|
1324
1471
|
>{/snippet}
|
|
1325
1472
|
|
|
1326
1473
|
<!-- One body row: a group row, or a data row and its detail row. -->
|
|
1327
|
-
<!-- A plain grid row (see `plainRows`): the row and its cells, with no per-row branches.
|
|
1474
|
+
<!-- A plain grid row (see `plainRows`): the row and its cells, with no per-row branches. The cells
|
|
1475
|
+
read `meta`, the row's one read of `cellMeta`: a value every cell of the page reads directly
|
|
1476
|
+
has one dependent per cell, and removing a thousand rows took them out one by one, each a
|
|
1477
|
+
search through that list. -->
|
|
1328
1478
|
{#snippet plainRow(node: RowNode<T>, index: number)}{@const row = node.data!}{@const contexts =
|
|
1329
|
-
rowContexts(node, row, () => index)}
|
|
1479
|
+
rowContexts(node, row, () => index)}{@const meta =
|
|
1480
|
+
cellMeta}<!-- svelte-ignore a11y_no_redundant_roles -->
|
|
1330
1481
|
<tr
|
|
1331
1482
|
class={rowClass?.(row, index) ?? ''}
|
|
1332
1483
|
data-row-id={String(node.id)}
|
|
1333
1484
|
role="row"
|
|
1334
1485
|
data-even={striped && index % 2 === 1 ? '' : undefined}
|
|
1335
1486
|
data-clickable={onRowClick ? true : undefined}
|
|
1336
|
-
aria-rowindex={
|
|
1337
|
-
? undefined
|
|
1338
|
-
: headerRowCount + model.pageIndex * s.pageSize + index + 1}
|
|
1487
|
+
aria-rowindex={rowIndexBase === undefined ? undefined : rowIndexBase + index + 1}
|
|
1339
1488
|
tabindex={focusableRows ? 0 : undefined}
|
|
1340
1489
|
>{#if expander}{@render plainExpander(node, row)}{/if}{#each cols as col (col.id)}<td
|
|
1341
1490
|
data-cell
|
|
1342
1491
|
role="cell"
|
|
1343
1492
|
data-column={col.id}
|
|
1344
|
-
data-align={
|
|
1345
|
-
data-pin={
|
|
1346
|
-
data-pin-edge={
|
|
1347
|
-
data-clip={
|
|
1348
|
-
class={
|
|
1349
|
-
style={
|
|
1493
|
+
data-align={meta[col.id]!.align}
|
|
1494
|
+
data-pin={meta[col.id]!.pin}
|
|
1495
|
+
data-pin-edge={meta[col.id]!.edge}
|
|
1496
|
+
data-clip={meta[col.id]!.clip}
|
|
1497
|
+
class={meta[col.id]!.gridClass}
|
|
1498
|
+
style={meta[col.id]!.style}>{@render (col.cell ?? plainCell)(contexts[col.id]!)}</td
|
|
1350
1499
|
>{/each}</tr
|
|
1351
1500
|
>{#if rowDetails && table.isExpanded(node.id) && rowExpandable(row)}<!-- svelte-ignore a11y_no_redundant_roles --><tr
|
|
1352
1501
|
class="slean-table-detail"
|
|
@@ -1392,7 +1541,7 @@
|
|
|
1392
1541
|
>
|
|
1393
1542
|
{:else if node.data !== undefined}{@const row = node.data}{@const open = table.isExpanded(
|
|
1394
1543
|
node.id
|
|
1395
|
-
)}{@const contexts = rowContexts(node, row, () => index)}
|
|
1544
|
+
)}{@const contexts = rowContexts(node, row, () => index)}{@const meta = cellMeta}
|
|
1396
1545
|
<tr
|
|
1397
1546
|
class={rowClass?.(row, index) ?? ''}
|
|
1398
1547
|
data-row-id={String(node.id)}
|
|
@@ -1403,9 +1552,7 @@
|
|
|
1403
1552
|
role={grid ? 'row' : undefined}
|
|
1404
1553
|
data-selected={table.isSelected(node.id)}
|
|
1405
1554
|
data-clickable={onRowClick ? true : undefined}
|
|
1406
|
-
aria-rowindex={
|
|
1407
|
-
? undefined
|
|
1408
|
-
: headerRowCount + model.pageIndex * s.pageSize + index + 1}
|
|
1555
|
+
aria-rowindex={rowIndexBase === undefined ? undefined : rowIndexBase + index + 1}
|
|
1409
1556
|
tabindex={focusableRows ? 0 : undefined}
|
|
1410
1557
|
>
|
|
1411
1558
|
{#if expander}<td
|
|
@@ -1561,13 +1708,13 @@
|
|
|
1561
1708
|
{:else}<td
|
|
1562
1709
|
data-cell
|
|
1563
1710
|
role={grid ? 'cell' : undefined}
|
|
1564
|
-
data-clip={
|
|
1711
|
+
data-clip={meta[col.id]!.clip}
|
|
1565
1712
|
data-column={col.id}
|
|
1566
|
-
data-align={
|
|
1567
|
-
data-pin={
|
|
1568
|
-
data-pin-edge={
|
|
1569
|
-
class={
|
|
1570
|
-
style={
|
|
1713
|
+
data-align={meta[col.id]!.align}
|
|
1714
|
+
data-pin={meta[col.id]!.pin}
|
|
1715
|
+
data-pin-edge={meta[col.id]!.edge}
|
|
1716
|
+
class={meta[col.id]!.className}
|
|
1717
|
+
style={meta[col.id]!.style}
|
|
1571
1718
|
><div class="slean-table-cell">
|
|
1572
1719
|
{@render (col.cell ?? plainCell)(contexts[col.id]!)}
|
|
1573
1720
|
</div></td
|
|
@@ -1575,20 +1722,20 @@
|
|
|
1575
1722
|
data-cell
|
|
1576
1723
|
role="cell"
|
|
1577
1724
|
data-column={col.id}
|
|
1578
|
-
data-align={
|
|
1579
|
-
data-pin={
|
|
1580
|
-
data-pin-edge={
|
|
1581
|
-
data-clip={
|
|
1582
|
-
class={
|
|
1583
|
-
style={
|
|
1725
|
+
data-align={meta[col.id]!.align}
|
|
1726
|
+
data-pin={meta[col.id]!.pin}
|
|
1727
|
+
data-pin-edge={meta[col.id]!.edge}
|
|
1728
|
+
data-clip={meta[col.id]!.clip}
|
|
1729
|
+
class={meta[col.id]!.gridClass}
|
|
1730
|
+
style={meta[col.id]!.style}>{@render (col.cell ?? plainCell)(contexts[col.id]!)}</td
|
|
1584
1731
|
>{/each}{:else}{#each cols as col (col.id)}<td
|
|
1585
1732
|
data-cell
|
|
1586
1733
|
data-column={col.id}
|
|
1587
|
-
data-align={
|
|
1588
|
-
data-pin={
|
|
1589
|
-
data-pin-edge={
|
|
1590
|
-
class={
|
|
1591
|
-
style={
|
|
1734
|
+
data-align={meta[col.id]!.align}
|
|
1735
|
+
data-pin={meta[col.id]!.pin}
|
|
1736
|
+
data-pin-edge={meta[col.id]!.edge}
|
|
1737
|
+
class={meta[col.id]!.className}
|
|
1738
|
+
style={meta[col.id]!.style}
|
|
1592
1739
|
><div class="slean-table-cell">
|
|
1593
1740
|
{@render (col.cell ?? plainCell)(contexts[col.id]!)}
|
|
1594
1741
|
</div></td
|
|
@@ -1907,6 +2054,23 @@
|
|
|
1907
2054
|
style:--slean-table-pin-inset={inset}
|
|
1908
2055
|
></div>{/each}
|
|
1909
2056
|
</div>
|
|
2057
|
+
<!-- The probe (see `probe`): never shown, never styled (display: none), removed once measured. -->
|
|
2058
|
+
{#if probe.length}<div
|
|
2059
|
+
bind:this={probeRegion}
|
|
2060
|
+
class="slean-table-probe"
|
|
2061
|
+
hidden
|
|
2062
|
+
aria-hidden="true"
|
|
2063
|
+
style="display:none"
|
|
2064
|
+
>
|
|
2065
|
+
<table data-measuring>
|
|
2066
|
+
<tbody
|
|
2067
|
+
>{#each probe as node (node.id)}{@render (plainRows ? plainRow : bodyRow)(
|
|
2068
|
+
node,
|
|
2069
|
+
model.rows.indexOf(node)
|
|
2070
|
+
)}{/each}</tbody
|
|
2071
|
+
>
|
|
2072
|
+
</table>
|
|
2073
|
+
</div>{/if}
|
|
1910
2074
|
{#if pagination}<div class="slean-table-pagination">
|
|
1911
2075
|
<span class="slean-table-page-status" aria-live="polite"
|
|
1912
2076
|
>{m.page(
|
package/dist/table.svelte.js
CHANGED
|
@@ -14,7 +14,18 @@ export function createTable(options) {
|
|
|
14
14
|
const state = $derived(controlled ? defaultState(options.state()) : owned);
|
|
15
15
|
const columns = $derived(resolve(options.columns));
|
|
16
16
|
const leaves = $derived(leafColumns(columns));
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The visible columns keep their array while they stay the same columns in the same order: every
|
|
19
|
+
* state change (a sort, a page, a selection) builds a new state, and a new array here would hand
|
|
20
|
+
* every row of a renderer new columns to draw its cells again for.
|
|
21
|
+
*/
|
|
22
|
+
let shownColumns = [];
|
|
23
|
+
const visible = $derived.by(() => {
|
|
24
|
+
const next = visibleColumns(columns, state);
|
|
25
|
+
if (next.length === shownColumns.length && next.every((col, i) => col === shownColumns[i]))
|
|
26
|
+
return shownColumns;
|
|
27
|
+
return (shownColumns = next);
|
|
28
|
+
});
|
|
18
29
|
const headers = $derived(headerRows(columns, visible));
|
|
19
30
|
const model = $derived.by(() => {
|
|
20
31
|
const paginated = resolve(options.pagination) ?? true;
|