@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 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
- .slean-table-button,
115
- .slean-table-input,
116
- .slean-table-sort,
117
- .slean-table-resize,
118
- .slean-table-column-panel,
119
- .slean-table-scroll,
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, .slean-table-columns > summary) {
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), .slean-table-columns > summary:hover) {
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
- .slean-table-button,
176
- .slean-table-sort,
177
- .slean-table-resize,
178
- .slean-table-group-toggle,
179
- .slean-table-columns > summary,
180
- .slean-table-scroll,
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 > :is(th, td)) {
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 > :is(th, td)) {
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, .slean-table-resize:focus-visible) {
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, .slean-table-edit, .slean-table-grip) {
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
- .slean-table-expand:hover,
520
- .slean-table-edit:hover,
521
- .slean-table-grip:hover,
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, .slean-table-edit:focus-visible) {
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 > :is(th, td)) {
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
- :where(.slean-table-scroll > table > tbody > tr > :has([data-slean-found])) {
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, .slean-table-selection, .slean-table-expander) {
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, thead .slean-table-selection, thead .slean-table-expander) {
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
- .slean-table-pin-shade[data-pin='right']:dir(ltr),
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
- :where(.slean-table[data-row-dragging], .slean-table[data-row-dragging] *) {
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
- > :is([data-pin], .slean-table-mover, .slean-table-selection, .slean-table-expander)
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, .slean-table-pages) {
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
- .slean-table-pager .slean-table-icon,
895
- [aria-expanded='false'] > .slean-table-icon,
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, .slean-table-input, .slean-table-columns > summary) {
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, .slean-table-pages) {
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
- .slean-table[data-layout='grid']
1004
- > .slean-table-scroll
1005
- > table
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, 50) * var(--slean-table-row-estimate, 2.75rem));
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, 50) * var(--slean-table-row-estimate, 2.75rem)
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 > :is(th, td)) {
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'] > .slean-table-scroll > table > * > tr > :is(th, td) > *
1048
- ) {
1049
- flex: 0 1 auto;
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
- > :is(.slean-table-cell, .slean-table-heading, .slean-table-editor)
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(.slean-table-mover, .slean-table-selection, .slean-table-expander)
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 > :is(th, td)) {
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
- :where(.slean-table-scroll > table[data-measuring] > * > tr > :is(th, td) > *) {
1122
- flex: none;
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:is([data-clip], .slean-table-ellipsis)
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
- > :is([data-pin], .slean-table-mover, .slean-table-selection, .slean-table-expander)
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
- .slean-table-toolbar,
1198
- .slean-table-pagination,
1199
- .slean-table-resize,
1200
- .slean-table-edit,
1201
- .slean-table-grip,
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 > :is(th, td)) {
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
- /** Where each rendered row stood: its index, and so, in grid layout, its body section. */
383
- let renderedAt: Map<RowId, number> | null = null;
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 = 50;
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 previous = renderedAt;
418
- renderedAt = new Map(rows.map((n, i) => [n.id, i]));
436
+ const before = renderedRows;
437
+ renderedRows = rows;
419
438
  job++;
420
- if (!previous) {
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
- rowsPerSlice = Math.max(5, Math.min(400, Math.floor(SLICE_MS / Math.max(cost, 0.05))));
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 cellMeta = $derived(
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 progressive mount is measured once, when it completes.
598
- if (!complete) return;
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
- const sample = every ? rows : first ? rows.slice(0, MEASURE_ROWS) : [];
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, as many per idle period as it has room for, so a section that
697
- * arrives costs only its paint. Nothing is windowed: every row stays in the document; this only
698
- * does the browser's work early. New rows and new column widths make sections cold again.
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 view = viewport.getBoundingClientRect();
710
- const cold = [
711
- ...tableElement.querySelectorAll<HTMLElement>(':scope > tbody.slean-table-chunk')
712
- ]
713
- .filter((section) => !warm.has(section))
714
- .map((section) => {
715
- const box = section.getBoundingClientRect();
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
- if (cold.some(({ section }) => !warm.has(section)))
743
- handle = requestIdleCallback(step, { timeout: 2000 });
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, { timeout: 2000 });
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 tracks = $derived.by(() => {
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)}<!-- svelte-ignore a11y_no_redundant_roles -->
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={ariaRowCount === undefined
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={cellMeta[col.id]!.align}
1345
- data-pin={cellMeta[col.id]!.pin}
1346
- data-pin-edge={cellMeta[col.id]!.edge}
1347
- data-clip={cellMeta[col.id]!.clip}
1348
- class={cellMeta[col.id]!.gridClass}
1349
- style={cellMeta[col.id]!.style}>{@render (col.cell ?? plainCell)(contexts[col.id]!)}</td
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={ariaRowCount === undefined
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={cellMeta[col.id]!.clip}
1711
+ data-clip={meta[col.id]!.clip}
1565
1712
  data-column={col.id}
1566
- data-align={cellMeta[col.id]!.align}
1567
- data-pin={cellMeta[col.id]!.pin}
1568
- data-pin-edge={cellMeta[col.id]!.edge}
1569
- class={cellMeta[col.id]!.className}
1570
- style={cellMeta[col.id]!.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={cellMeta[col.id]!.align}
1579
- data-pin={cellMeta[col.id]!.pin}
1580
- data-pin-edge={cellMeta[col.id]!.edge}
1581
- data-clip={cellMeta[col.id]!.clip}
1582
- class={cellMeta[col.id]!.gridClass}
1583
- style={cellMeta[col.id]!.style}>{@render (col.cell ?? plainCell)(contexts[col.id]!)}</td
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={cellMeta[col.id]!.align}
1588
- data-pin={cellMeta[col.id]!.pin}
1589
- data-pin-edge={cellMeta[col.id]!.edge}
1590
- class={cellMeta[col.id]!.className}
1591
- style={cellMeta[col.id]!.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(
@@ -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
- const visible = $derived(visibleColumns(columns, state));
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-lean/table",
3
- "version": "0.15.3",
3
+ "version": "0.15.4",
4
4
  "description": "A Svelte-native table and data-grid engine that ships only the features you use.",
5
5
  "license": "MIT",
6
6
  "author": "Ömer Say",