bitboss-ui 3.0.0-beta.4 → 3.0.0-beta.8

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.
Files changed (42) hide show
  1. package/README.md +36 -29
  2. package/dist/ai/BbBadge.md +10 -5
  3. package/dist/ai/BbSelect.md +8 -0
  4. package/dist/ai/BbTable.md +257 -51
  5. package/dist/ai/changelog.json +86 -2
  6. package/dist/ai/components.json +25 -15
  7. package/dist/ai/guides/design-language.md +14 -14
  8. package/dist/ai/guides/design-tokens.md +25 -18
  9. package/dist/ai/guides/installation-and-plugin-setup.md +1 -1
  10. package/dist/ai/guides/migration/components/bb-dialog.md +35 -29
  11. package/dist/ai/guides/migration/components/bb-table.md +406 -28
  12. package/dist/ai/guides/migration/v2-to-v3.md +175 -75
  13. package/dist/ai/index.md +1 -1
  14. package/dist/ai/recipes/inertia/layout-scaffold.md +5 -1
  15. package/dist/ai/recipes/inertia/ownership-atlas.md +41 -20
  16. package/dist/ai/recipes/inertia/records-workspace.md +26 -7
  17. package/dist/ai/recipes/nuxt/layout-scaffold.md +5 -1
  18. package/dist/ai/recipes/nuxt/records-workspace.md +26 -7
  19. package/dist/ai/recipes/vue/layout-scaffold.md +5 -1
  20. package/dist/ai/recipes/vue/records-workspace.md +26 -7
  21. package/dist/ai/source/BbBaseInputContainer.md +26 -0
  22. package/dist/ai/source/BbBreadcrumbs.md +31 -0
  23. package/dist/ai/source/BbDialog.md +15 -9
  24. package/dist/ai/source/BbOffCanvas.md +17 -11
  25. package/dist/ai/source/BbProgress.md +2 -2
  26. package/dist/ai/source/BbTable.md +837 -787
  27. package/dist/ai/source/CommonInputWrapper.md +4 -0
  28. package/dist/components/BbPagination/BbPagination.vue.d.ts +1 -1
  29. package/dist/components/BbTable/BbTable.vue.d.ts +5 -2
  30. package/dist/components/BbTable/BbTable.vue_vue_type_script_setup_true_lang.js +439 -484
  31. package/dist/components/BbTable/types.d.ts +83 -21
  32. package/dist/components/BbTable/utils.d.ts +18 -3
  33. package/dist/components/BbTable/utils.js +36 -28
  34. package/dist/index.d.ts +5 -1
  35. package/dist/index.js +68 -67
  36. package/dist/llms-full.txt +1065 -265
  37. package/dist/llms-medium.txt +15 -15
  38. package/dist/llms.txt +1 -1
  39. package/dist/styles.css +1 -1
  40. package/llms.txt +1 -1
  41. package/package.json +1 -1
  42. package/dist/assets/svgs/sort.svg_raw.js +0 -4
@@ -17,45 +17,51 @@
17
17
  :style="containerStyle"
18
18
  >
19
19
  <legend v-if="selectable" class="sr-only">{{ legendText }}</legend>
20
- <table
20
+ <!-- The caption lives OUTSIDE the table role (a `caption` role is invalid
21
+ there for a div table) and names the table through `aria-labelledby`. -->
22
+ <div
23
+ v-if="caption"
24
+ :id="captionId"
25
+ class="bb-table-caption"
26
+ :class="{
27
+ 'sr-only': !displayCaption,
28
+ }"
29
+ >
30
+ {{ caption }}
31
+ </div>
32
+ <!-- The grid root. It owns `grid-template-columns` (the tracks ARE the
33
+ column API); rowgroups and rows are subgrids that inherit them. The role
34
+ chain table → rowgroup → row → columnheader/cell must stay unbroken: a
35
+ single unroled wrapper between two levels collapses the whole tree to
36
+ generic for assistive tech. -->
37
+ <div
21
38
  ref="tableRef"
39
+ :aria-labelledby="caption ? captionId : undefined"
22
40
  :aria-rowcount="
23
- hasProvidedAccessibilityData ? contextState.totalItems : undefined
41
+ hasProvidedAccessibilityData && contextState.totalItems != null
42
+ ? contextState.totalItems + 1
43
+ : undefined
24
44
  "
45
+ class="bb-table__table"
46
+ role="table"
47
+ :style="{ gridTemplateColumns: gridTemplate }"
25
48
  >
26
- <caption
27
- v-if="caption"
28
- class="bb-table-caption"
29
- :class="{
30
- 'sr-only': !displayCaption,
31
- }"
32
- >
33
- {{
34
- caption
35
- }}
36
- </caption>
37
- <colgroup>
38
- <col
39
- v-for="track in columnTracks"
40
- :key="track.key"
41
- :style="{ width: track.width }"
42
- />
43
- </colgroup>
44
49
  <!-- Inert while loading too: sorting or select-all would fire against
45
50
  rows that are about to be replaced. -->
46
- <thead :inert="contentInert">
51
+ <div class="bb-table__head" :inert="contentInert" role="rowgroup">
47
52
  <!-- @vue-ignore -->
48
53
  <slot name="thead">
49
- <tr
54
+ <div
50
55
  :aria-rowindex="hasProvidedAccessibilityData ? 1 : undefined"
51
56
  class="bb-table-header-row"
52
57
  :class="headerRowClass"
58
+ role="row"
53
59
  >
54
- <th
60
+ <div
55
61
  v-if="selectable"
56
62
  class="bb-table-header bb-table-header--select"
57
63
  :class="thClass"
58
- scope="col"
64
+ role="columnheader"
59
65
  >
60
66
  <!-- @vue-ignore -->
61
67
  <slot
@@ -93,8 +99,8 @@
93
99
  >
94
100
  </span>
95
101
  </slot>
96
- </th>
97
- <th
102
+ </div>
103
+ <div
98
104
  v-for="(header, index) in mappedHeaders"
99
105
  :key="header.key"
100
106
  :aria-sort="
@@ -109,8 +115,13 @@
109
115
  index === mappedHeaders.length - 1,
110
116
  },
111
117
  ]"
112
- scope="col"
118
+ role="columnheader"
113
119
  >
120
+ <component
121
+ :is="headerAffixFor(header.key, 'prepend')"
122
+ v-if="headerAffixFor(header.key, 'prepend')"
123
+ v-bind="headerAffixScope(header)"
124
+ />
114
125
  <!-- @vue-ignore -->
115
126
  <slot
116
127
  :classes="header.classes"
@@ -134,22 +145,39 @@
134
145
  <span class="bb-table-header__content">{{
135
146
  header.label
136
147
  }}</span>
137
- <BbIcon
148
+ <!-- One up arrow (ascending, AG Grid convention); the desc state
149
+ rotates it 180° (the glyph is its own mirror), so the flip
150
+ between directions animates. -->
151
+ <svg
152
+ aria-hidden="true"
138
153
  class="bb-table-sort__icon"
139
- :icon="sortIcon"
140
- size="sm"
141
- />
154
+ fill="none"
155
+ height="1em"
156
+ stroke="currentColor"
157
+ stroke-linecap="round"
158
+ stroke-linejoin="round"
159
+ stroke-width="2"
160
+ viewBox="0 0 24 24"
161
+ width="1em"
162
+ >
163
+ <path d="M12 20V4m-7 7l7-7l7 7" />
164
+ </svg>
142
165
  </BbBaseButton>
143
166
  <span v-else class="bb-table-header__content">{{
144
167
  header.label
145
168
  }}</span>
146
169
  </slot>
147
- </th>
148
- <th
170
+ <component
171
+ :is="headerAffixFor(header.key, 'append')"
172
+ v-if="headerAffixFor(header.key, 'append')"
173
+ v-bind="headerAffixScope(header)"
174
+ />
175
+ </div>
176
+ <div
149
177
  v-if="actions"
150
178
  class="bb-table-header bb-table-header--actions"
151
179
  :class="thClass"
152
- scope="col"
180
+ role="columnheader"
153
181
  >
154
182
  <!-- @vue-ignore -->
155
183
  <slot
@@ -160,11 +188,11 @@
160
188
  {{ actionsText || t('table.actionsText').value }}
161
189
  </span>
162
190
  </slot>
163
- </th>
164
- </tr>
191
+ </div>
192
+ </div>
165
193
  </slot>
166
- </thead>
167
- <tbody>
194
+ </div>
195
+ <div class="bb-table__body" role="rowgroup">
168
196
  <!-- Skeletons only when there is nothing to show. A refetch over
169
197
  existing rows keeps them on screen (dimmed, with the busy bar above)
170
198
  instead of blanking the table — see `.bb-table--loading` in the CSS. -->
@@ -174,23 +202,26 @@
174
202
  name="loading"
175
203
  >
176
204
  <template v-for="(_, rowIndex) in skeletonLength" :key="rowIndex">
177
- <tr
205
+ <div
178
206
  :aria-rowindex="
179
207
  hasProvidedAccessibilityData ? rowIndex + 2 : undefined
180
208
  "
181
209
  class="bb-table-skeleton__row"
210
+ role="row"
182
211
  >
183
- <td
212
+ <div
184
213
  v-if="selectable"
185
214
  class="bb-table-skeleton__cell bb-table-skeleton__cell--select"
215
+ role="cell"
186
216
  >
187
217
  <div class="bb-table-skeleton__placeholder" />
188
- </td>
189
- <td
218
+ </div>
219
+ <div
190
220
  v-for="(col, colIndex) in props.columns"
191
221
  :key="col.key"
192
222
  class="bb-table-skeleton__cell"
193
223
  :class="[`bb-table-skeleton__cell--${col.skeleton ?? 'text'}`]"
224
+ role="cell"
194
225
  >
195
226
  <!-- text: 2 lines with dynamic widths -->
196
227
  <template v-if="!col.skeleton || col.skeleton === 'text'">
@@ -245,37 +276,42 @@
245
276
  v-else-if="col.skeleton === 'badge'"
246
277
  class="bb-table-skeleton__badge"
247
278
  />
248
- </td>
249
- <td
279
+ </div>
280
+ <div
250
281
  v-if="actions"
251
282
  class="bb-table-skeleton__cell bb-table-skeleton__cell--actions"
283
+ role="cell"
252
284
  >
253
285
  <div class="bb-table-skeleton__placeholder" />
254
- </td>
255
- </tr>
286
+ </div>
287
+ </div>
256
288
  </template>
257
289
  </slot>
258
290
  <!-- The empty state's row/cell shell is the table's, not the
259
- consumer's: the slot fills a full-width cell (the colspan already
260
- accounts for the selection/actions columns) so custom content is
261
- centred across the table instead of landing as stray markup inside
262
- `<tbody>`. -->
263
- <tr
291
+ consumer's: the slot fills a full-width cell (spanning the selection/
292
+ actions columns too) so custom content is centred across the table
293
+ instead of landing as stray markup inside the body. -->
294
+ <div
264
295
  v-else-if="!options.length"
265
296
  :aria-rowindex="hasProvidedAccessibilityData ? 2 : undefined"
266
297
  class="bb-table-no-data__row"
298
+ role="row"
267
299
  >
268
- <td class="bb-table-no-data__cell" :colspan="replacementContentSpan">
300
+ <div
301
+ :aria-colspan="replacementContentSpan"
302
+ class="bb-table-no-data__cell"
303
+ role="cell"
304
+ >
269
305
  <slot name="no-data">
270
306
  <span class="bb-table-no-data__text">{{
271
307
  noDataText || t('common.noDataText').value
272
308
  }}</span>
273
309
  </slot>
274
- </td>
275
- </tr>
310
+ </div>
311
+ </div>
276
312
  <slot v-else name="tbody">
277
313
  <template v-for="(item, index) in mappedItems" :key="item.valueHash">
278
- <tr
314
+ <div
279
315
  :aria-current="item.highlighted ? 'true' : undefined"
280
316
  :aria-rowindex="
281
317
  hasProvidedAccessibilityData && item.rowIndex
@@ -288,6 +324,7 @@
288
324
  { 'bb-table-data__row--highlighted': item.highlighted },
289
325
  ]"
290
326
  :inert="contentInert"
327
+ role="row"
291
328
  :tabindex="
292
329
  keyboardNavigation
293
330
  ? index === activeRowIndex
@@ -301,10 +338,11 @@
301
338
  @focus="() => onRowFocus(index)"
302
339
  @keydown="(event) => onRowKeydown(event, index)"
303
340
  >
304
- <td
341
+ <div
305
342
  v-if="selectable"
306
343
  class="bb-table-data__cell bb-table-data__cell--select"
307
344
  :class="tdClass"
345
+ role="cell"
308
346
  >
309
347
  <!-- @vue-ignore -->
310
348
  <slot
@@ -358,8 +396,8 @@
358
396
  }}</span>
359
397
  </label>
360
398
  </slot>
361
- </td>
362
- <td
399
+ </div>
400
+ <div
363
401
  v-for="(col, colIndex) in item.cols"
364
402
  :key="col.key"
365
403
  class="bb-table-data__cell"
@@ -371,6 +409,7 @@
371
409
  colIndex === item.cols.length - 1,
372
410
  },
373
411
  ]"
412
+ role="cell"
374
413
  >
375
414
  <!-- @vue-ignore -->
376
415
  <slot
@@ -389,11 +428,12 @@
389
428
  :value="item.value"
390
429
  >{{ col.content }}</slot
391
430
  >
392
- </td>
393
- <td
431
+ </div>
432
+ <div
394
433
  v-if="actions"
395
434
  class="bb-table__cell bb-table__cell--actions"
396
435
  :class="tdClass"
436
+ role="cell"
397
437
  >
398
438
  <!-- @vue-ignore -->
399
439
  <slot
@@ -408,9 +448,9 @@
408
448
  :toggle-selected="() => toggleSelectedFor(item)"
409
449
  :value="item.value"
410
450
  ></slot>
411
- </td>
412
- </tr>
413
- <tr
451
+ </div>
452
+ </div>
453
+ <div
414
454
  v-if="item.expanded"
415
455
  :id="`expanded_${item.valueHash}`"
416
456
  :aria-rowindex="
@@ -420,10 +460,12 @@
420
460
  "
421
461
  class="bb-table-expand__row"
422
462
  :class="{ 'bb-table-expand__row--highlighted': item.highlighted }"
463
+ role="row"
423
464
  >
424
- <td
465
+ <div
466
+ :aria-colspan="replacementContentSpan"
425
467
  class="bb-table-expand__cell"
426
- :colspan="replacementContentSpan"
468
+ role="cell"
427
469
  >
428
470
  <!-- @vue-ignore -->
429
471
  <slot
@@ -438,8 +480,8 @@
438
480
  :toggle-selected="() => toggleSelectedFor(item)"
439
481
  :value="item.value"
440
482
  ></slot>
441
- </td>
442
- </tr>
483
+ </div>
484
+ </div>
443
485
  </template>
444
486
  </slot>
445
487
 
@@ -448,8 +490,11 @@
448
490
  rows alike — and now also when a custom `#loading` slot replaces the
449
491
  skeleton, which used to swallow it. Deliberately NOT inert (unlike
450
492
  the data rows above): `inert` would remove it from the a11y tree and
451
- the table would go busy in silence. -->
452
- <tr
493
+ the table would go busy in silence. The live region sits on the
494
+ inner text, not on the cell: a `status` role ON the cell leaves the
495
+ row with zero cells in the accessibility tree (a native table
496
+ repaired that; a div table does not). -->
497
+ <div
453
498
  v-if="loading"
454
499
  :aria-rowindex="
455
500
  hasProvidedAccessibilityData && !options.length
@@ -457,24 +502,30 @@
457
502
  : undefined
458
503
  "
459
504
  class="bb-table-loading__row bb-table-loading__row--sr-only"
505
+ role="row"
460
506
  >
461
- <td
462
- aria-live="polite"
507
+ <div
508
+ :aria-colspan="replacementContentSpan"
463
509
  class="bb-table-loading__cell"
464
- :colspan="replacementContentSpan"
465
- role="status"
510
+ role="cell"
466
511
  >
467
- <span class="bb-table-loading__text sr-only">{{
468
- loadingText || t('common.loadingText').value
469
- }}</span>
470
- </td>
471
- </tr>
472
- </tbody>
473
- <tfoot>
512
+ <span
513
+ aria-live="polite"
514
+ class="bb-table-loading__text sr-only"
515
+ role="status"
516
+ >{{ loadingText || t('common.loadingText').value }}</span
517
+ >
518
+ </div>
519
+ </div>
520
+ </div>
521
+ <!-- Rendered unconditionally (class parity); the rowgroup role only
522
+ when there is content, since an empty `rowgroup` fails the required
523
+ owned-elements check. -->
524
+ <div class="bb-table__foot" :role="slots.tfoot ? 'rowgroup' : undefined">
474
525
  <!-- @vue-ignore -->
475
526
  <slot :column-count="replacementContentSpan" name="tfoot"></slot>
476
- </tfoot>
477
- </table>
527
+ </div>
528
+ </div>
478
529
  <input v-for="input in hiddenInputs" :key="input.value" v-bind="input" />
479
530
  </component>
480
531
  </template>
@@ -515,8 +566,6 @@ import {
515
566
  } from '@/utilities/functions/parsePaginationNumber';
516
567
  import BaseCheckbox from '../BbBaseCheckbox/BbBaseCheckbox.vue';
517
568
  import BaseRadio from '../BbBaseRadio/BbBaseRadio.vue';
518
- import BbIcon from '../BbIcon/BbIcon.vue';
519
- import sortIcon from '@/assets/svgs/sort.svg?raw';
520
569
  import type { Option as BaseOption } from '@/types/Option';
521
570
  import type { Classes } from '@/types/Classes';
522
571
  import type { ColumnName } from '@/types/ColumnName';
@@ -524,10 +573,12 @@ import type { PrefixWith } from '@/types/PrefixWith';
524
573
  import type {
525
574
  BbTableColumn,
526
575
  BbTableEvents,
576
+ BbTableHeaderAffixSlotProps,
527
577
  BbTableProps,
528
578
  BbTableSlots,
529
579
  BbTableSortEntry,
530
580
  ExpandControllerProps,
581
+ HeaderAffixSuffix,
531
582
  ItemKey,
532
583
  ReservedColumnKey,
533
584
  MappedCell,
@@ -540,6 +591,7 @@ import {
540
591
  mergeCellClasses,
541
592
  mergeHeaderClasses,
542
593
  mergeRowClasses,
594
+ readResolvedTracks,
543
595
  } from './utils';
544
596
  import { useBbTableContextHost } from '../../composables/useBbTableContext';
545
597
  import { useLogger } from '@/composables/useLogger';
@@ -586,7 +638,7 @@ const props = withDefaults(defineProps<InternalProps<T>>(), {
586
638
 
587
639
  const emit = defineEmits<BbTableEvents>();
588
640
 
589
- defineSlots<
641
+ const slots = defineSlots<
590
642
  BbTableSlots<Item> &
591
643
  Partial<
592
644
  Record<
@@ -620,6 +672,12 @@ defineSlots<
620
672
  toggleSort?: () => void;
621
673
  }) => any
622
674
  >
675
+ > &
676
+ Partial<
677
+ Record<
678
+ InferredHeaderAffixSlots,
679
+ (props: BbTableHeaderAffixSlotProps<Item>) => any
680
+ >
623
681
  >
624
682
  >();
625
683
 
@@ -646,6 +704,7 @@ type InternalProps<K extends string> = Omit<BbTableProps<Item>, 'columns'> & {
646
704
 
647
705
  type InferredBodySlots = ColumnName<T>;
648
706
  type InferredHeaderSlots = PrefixWith<ColumnName<T>, 'header:'>;
707
+ type InferredHeaderAffixSlots = `${InferredHeaderSlots}:${HeaderAffixSuffix}`;
649
708
 
650
709
  if (props.multiple && !Array.isArray(props.modelValue)) {
651
710
  const logger = useLogger();
@@ -667,6 +726,15 @@ const RESERVED_COLUMN_KEYS: readonly ReservedColumnKey[] = [
667
726
  'select',
668
727
  ];
669
728
 
729
+ // Annotated with the exported union for the same reason as above.
730
+ const HEADER_AFFIX_SUFFIXES: readonly HeaderAffixSuffix[] = [
731
+ 'prepend',
732
+ 'append',
733
+ ];
734
+
735
+ const isHeaderAffixSuffix = (base: string): base is HeaderAffixSuffix =>
736
+ HEADER_AFFIX_SUFFIXES.includes(base as HeaderAffixSuffix);
737
+
670
738
  if (process.env.NODE_ENV !== 'production') {
671
739
  const reserved = props.columns.filter((column) =>
672
740
  RESERVED_COLUMN_KEYS.includes(column.key as ReservedColumnKey)
@@ -679,17 +747,31 @@ if (process.env.NODE_ENV !== 'production') {
679
747
  : 'Selection is controlled by the `selectable` prop.')
680
748
  );
681
749
  }
750
+
751
+ // Such a key would make the column's full-replacement slot name
752
+ // (`header:prepend`) collide with the table-wide affix slot of the same
753
+ // name, so the table-wide interpretation wins for it.
754
+ for (const column of props.columns) {
755
+ const base = columnKeyToSlotName(column.key);
756
+ if (isHeaderAffixSuffix(base)) {
757
+ useLogger().warn(
758
+ `BbTable: column key '${String(column.key)}' normalizes to the slot name '${base}', which collides with the table-wide \`#header:${base}\` slot. That name keeps its table-wide meaning and per-column header slots are unavailable for this column.`
759
+ );
760
+ }
761
+ }
682
762
  }
683
763
 
684
764
  const { t } = useLocale();
685
765
 
686
- const tableRef = ref<HTMLTableElement>();
766
+ /** The grid root (`.bb-table__table`, `role="table"`). */
767
+ const tableRef = ref<HTMLElement>();
687
768
  const containerRef = ref<HTMLElement>();
688
769
 
689
770
  const {
690
771
  id: tableId,
691
772
  parentId,
692
773
  parentNode,
774
+ node,
693
775
  cssVars,
694
776
  parentVar,
695
777
  setTracks,
@@ -702,6 +784,9 @@ const {
702
784
  : undefined,
703
785
  });
704
786
 
787
+ /** Id of the caption element the grid root is labelled by. */
788
+ const captionId = computed(() => `${tableId.value}__caption`);
789
+
705
790
  /** The highlight as a 0-or-1 array, which is what the hash index wants. */
706
791
  const highlightedValues = computed(() =>
707
792
  isNil(highlighted.value) ? [] : [highlighted.value]
@@ -850,11 +935,11 @@ const inheritColumnWidths = computed(
850
935
  );
851
936
 
852
937
  /**
853
- * Assigned column widths (frozen, snapped, or inherited) are only authoritative
854
- * under `table-layout: fixed`; in auto layout they behave like a minimum. So a
855
- * table that drives widths — explicitly `fixed`, inheriting from a parent, or
856
- * with a column carrying a `width` or `snap` — uses fixed layout even when
857
- * `fixed` was not set. (`snap` is a width definition: the parent track range.)
938
+ * A table that drives its widths — explicitly `fixed`, inheriting from a
939
+ * parent, or with a column carrying a `width` or `snap` (`snap` is a width
940
+ * definition: the parent track range). Under grid the tracks are authoritative
941
+ * in every layout, so this only feeds the `bb-table--fixed` class, kept as a
942
+ * styling hook for consumers.
858
943
  */
859
944
  const fixedLayout = computed(
860
945
  () =>
@@ -864,18 +949,16 @@ const fixedLayout = computed(
864
949
  (inheritColumnWidths.value && !!parentId.value)
865
950
  );
866
951
 
867
- /**
868
- * A nested table inheriting its parent's widths must size itself to the sum of
869
- * those columns rather than stretching to fill its container — otherwise fixed
870
- * layout would distribute the slack and the columns would no longer match the
871
- * parent. Tables with the same column count already sum to the full width, so
872
- * this is a no-op for them and only narrows tables with fewer columns.
873
- */
874
952
  /** True for a nested table that inherits its column widths from a parent. */
875
953
  const isInheriting = computed(
876
954
  () => inheritColumnWidths.value && !!parentId.value
877
955
  );
878
956
 
957
+ /**
958
+ * A nested table inheriting its parent's widths sizes itself to those tracks
959
+ * and shares the ancestor scrollport instead of opening its own — see
960
+ * `.bb-table--inherit-widths` in the CSS.
961
+ */
879
962
  const sizeToColumns = isInheriting;
880
963
 
881
964
  /** Numbers and bare numeric strings (e.g. `400`, `'400'`) default to px; values
@@ -1021,117 +1104,219 @@ const fillColumnIndex = computed(() => {
1021
1104
  // render, or columns skipped by the first snap) and those right of the last are
1022
1105
  // folded into the edge columns as the structural offset, keeping the table
1023
1106
  // aligned to the parent. `offsetStartValue` becomes the first column's padding.
1024
- const offsetStartValue = computed(() => {
1107
+ /*
1108
+ * Nested geometry — the decision tree (plans/BBTABLE-GRID.md § v2.2).
1109
+ *
1110
+ * Every table publishes three kinds of rail: `select` = the START REGION
1111
+ * (everything left of the first content column), `0…N-1` = the pure content
1112
+ * columns, `actions` = the END REGION (unmapped trailing parent columns plus
1113
+ * the parent's actions). An inheriting table reserves both regions and snaps
1114
+ * its content columns 1:1, so alignment holds at any depth by induction.
1115
+ *
1116
+ * Per side, with R = the parent's region on that side:
1117
+ * 1. own structural column on that side → its track is `minmax(R, max-content)`:
1118
+ * equal to the parent's when the content fits, wider only when it must.
1119
+ * No padding fold; the region is consumed by the structural track.
1120
+ * 2. no own structural column, R > 0 → R is folded into the edge content
1121
+ * cell (padding + width, routable to the container via `--offset-*`) and
1122
+ * published UNFOLDED, with R itself published under the structural key,
1123
+ * so the next level reserves it too.
1124
+ * 3. no own structural column, R = 0 → nothing to reserve.
1125
+ * Surplus — an own structural cell wider than R (a select the parent lacks,
1126
+ * three buttons against the parent's two) — is reclaimed from the ADJACENT
1127
+ * content column only: its track shrinks by the surplus (a negative fold)
1128
+ * and its content edge moves by that much; every other rail holds. Interior
1129
+ * rails never absorb structure.
1130
+ */
1131
+
1132
+ /** The parent's start region: its select cell plus any columns skipped by the first snap. */
1133
+ const startRegion = computed(() => {
1025
1134
  const first = columnIntervals.value[0];
1026
- // No parent to align to (a root table) means no offset at all — without this
1027
- // the structural reclaim below would still fold a negative `-ownSelect` in.
1028
1135
  if (!inheritColumnWidths.value || !parentId.value || !first) return null;
1029
- // The fill runs from the parent's left edge to this table's first data
1030
- // column — the parent's select cell, if any, plus any skipped data columns.
1031
- const head = gridRangeWidth(0, first.start);
1032
- // This table's own select column sits flush at the left edge and takes its
1033
- // measured width, so reclaim it from the fill (mirror of the actions side):
1034
- // the first data column then lines the *next* column up flush with the
1035
- // parent. When the parent has a select cell it is part of `head`, so only the
1036
- // surplus over it is reclaimed; when the parent has none the whole select
1037
- // width comes out of the first column, which shrinks to make room for it.
1038
- const ownSelect = props.selectable ? structuralFit.select : null;
1039
- if (!ownSelect) return head;
1040
- return `calc(${head ?? '0px'} - ${ownSelect})`;
1136
+ return gridRangeWidth(0, first.start);
1041
1137
  });
1042
- const offsetEndValue = computed(() => {
1138
+ /**
1139
+ * The parent's end region: unmapped trailing data columns plus its actions
1140
+ * cell. With more columns than the parent there is no trailing region — the
1141
+ * table runs past the parent's edge with content-sized overflow columns.
1142
+ */
1143
+ const endRegion = computed(() => {
1043
1144
  const intervals = columnIntervals.value;
1044
1145
  const last = intervals[intervals.length - 1];
1045
- // No parent to align to (a root table) means no offset at all — without this
1046
- // the structural reclaim below would still fold a negative `-ownActions` in.
1047
1146
  if (!inheritColumnWidths.value || !parentId.value || !last) return null;
1048
- // With more columns than the parent, the table runs past the parent's right
1049
- // edge: there is no trailing parent region to fold, and the last column is a
1050
- // content-sized overflow column, so there is no end fill.
1051
1147
  if (overflowsParent.value) return null;
1052
- // When the last column is the (auto) fill column it already absorbs the whole
1053
- // trailing region via fixed layout, so folding the offset in too would double
1054
- // it — and skew its right-aligned content inward.
1055
- if (fillColumnIndex.value === props.columns.length - 1) return null;
1056
- // The fill runs from the last data column's end all the way to the parent's
1057
- // right edge — any unmapped data columns plus the parent's actions cell, if
1058
- // it has one.
1059
- const tail = gridRangeWidth(last.end, tableEndCell.value);
1060
- // This table's own actions column sits flush against that right edge and
1061
- // takes its measured content width, so reclaim it from the fill: the table
1062
- // then ends exactly at the parent's edge instead of overflowing by the
1063
- // actions width. When the parent has an actions cell it is part of `tail`, so
1064
- // only the surplus over it is effectively reclaimed; when the parent has none
1065
- // the whole actions width comes out of the data fill. The result may be
1066
- // negative — the last data column then shrinks to make room for the actions.
1067
- const ownActions = props.actions ? structuralFit.actions : null;
1068
- if (!ownActions) return tail;
1069
- return `calc(${tail ?? '0px'} - ${ownActions})`;
1148
+ return gridRangeWidth(last.end, tableEndCell.value);
1070
1149
  });
1150
+
1151
+ /**
1152
+ * This table's OWN last-measured structural track (`select` / `actions`) as
1153
+ * the px the grid resolved it to — `null` before the first measure. Feeds the
1154
+ * surplus reclaim: it is `minmax(R, max-content)`, independent of the data
1155
+ * columns, so folding it converges instead of oscillating.
1156
+ */
1157
+ const ownStructuralTrack = (key: 'select' | 'actions') =>
1158
+ node.tracks[key] ?? null;
1159
+
1160
+ /**
1161
+ * The edge fold. Without an own structural column it is the whole parent
1162
+ * region (case 2: padding + width on the edge content cell). With one, the
1163
+ * region lives in the structural track and only the SURPLUS over it — the
1164
+ * own cell's resolved width minus R, never positive-padded, ≤ 0 by
1165
+ * construction — is folded, shrinking the adjacent content column.
1166
+ */
1167
+ const edgeFold = (
1168
+ region: string | null,
1169
+ own: string | null,
1170
+ hasOwn: boolean
1171
+ ): string | null => {
1172
+ if (!inheritColumnWidths.value || !parentId.value) return null;
1173
+ if (!hasOwn) return region;
1174
+ if (!own) return null; // not measured yet — nothing to reclaim
1175
+ return `calc(${region ?? '0px'} - ${own})`;
1176
+ };
1177
+ const offsetStartValue = computed(() =>
1178
+ edgeFold(
1179
+ startRegion.value,
1180
+ props.selectable ? ownStructuralTrack('select') : null,
1181
+ !!props.selectable
1182
+ )
1183
+ );
1184
+ const offsetEndValue = computed(() =>
1185
+ overflowsParent.value
1186
+ ? null
1187
+ : edgeFold(
1188
+ endRegion.value,
1189
+ props.actions ? ownStructuralTrack('actions') : null,
1190
+ !!props.actions
1191
+ )
1192
+ );
1071
1193
  const offsetStartActive = computed(() => offsetStartValue.value !== null);
1072
1194
  const offsetEndActive = computed(() => offsetEndValue.value !== null);
1073
1195
 
1074
1196
  /**
1075
- * Measured intrinsic content widths of the structural columns. In a fixed table
1076
- * these pin `select`/`actions` to their content so the remaining space is shared
1077
- * among the data columns; `null` falls back to the regular auto behavior.
1197
+ * A structural track: `max-content` for a root table; for an inheriting table
1198
+ * with a parent region on that side, `minmax(R, max-content)` (case 1). The
1199
+ * chain is only emitted once the parent has published (see
1200
+ * `parentTracksPublished`).
1078
1201
  */
1079
- const structuralFit = reactive<{
1080
- select: string | null;
1081
- actions: string | null;
1082
- }>({ select: null, actions: null });
1202
+ const structuralTrack = (region: string | null) =>
1203
+ region && parentTracksPublished.value
1204
+ ? `minmax(${region}, max-content)`
1205
+ : 'max-content';
1206
+
1207
+ /**
1208
+ * Whether the parent has published its tracks yet. Until it has (SSR, the
1209
+ * first client frame — a nested table mounts before its parent measures),
1210
+ * the inherited `var()` chains must NOT be emitted: their `0px` fallbacks
1211
+ * would crush the tracks to literally zero under grid (fixed table layout
1212
+ * used to redistribute; grid honours 0), and a fallbackless `var()` would
1213
+ * invalidate the whole template. Content-sized tracks stand in until the
1214
+ * parent's first measure lands, then the chains take over and this table's
1215
+ * own header-cell observer re-measures.
1216
+ */
1217
+ const parentTracksPublished = computed(
1218
+ () => Object.keys(parentNode.value?.tracks ?? {}).length > 0
1219
+ );
1083
1220
 
1084
1221
  /**
1085
1222
  * Rendered columns in DOM order, each with the track key it publishes/inherits
1086
- * and the width to apply via `<colgroup>`. A frozen column width wins; otherwise
1087
- * an unfrozen column syncs to the parent's matching track (or `auto` at root).
1088
- * The first/last column additionally absorbs the structural edge offset.
1223
+ * and its `grid-template-columns` entry. The tracks ARE the column API: the
1224
+ * grid root owns the template and every rowgroup/row is a subgrid of it.
1225
+ *
1226
+ * - structural `select` / `actions`: `max-content` — min == max for both
1227
+ * cells (the actions cell is `white-space: nowrap`, which is what makes that
1228
+ * track sizing-critical).
1229
+ * - frozen (`width`): the declared length verbatim. A `%` resolves against the
1230
+ * root box, which under `width: fit-content; min-width: 100%` matches the
1231
+ * old `<table>` box. When EVERY data column of a root table is frozen there
1232
+ * is no flexible column to absorb the container's surplus, so each becomes
1233
+ * `minmax(<width>, <n>fr)` with `n` = its numeric width: the declared width
1234
+ * is the floor, and any surplus is shared in proportion to the declared
1235
+ * widths — the table keeps filling its container instead of leaving a
1236
+ * trailing gap (fixed table layout used to inflate those columns too).
1237
+ * - root unfrozen column: `auto` (min-content floor, max-content ceiling, an
1238
+ * equal share of the surplus) — the same wrap floor and overflow threshold as
1239
+ * table auto layout. With `fixed` every unfrozen column is `minmax(0, 1fr)`
1240
+ * instead: an equal split of the root, exactly as fixed table layout did.
1241
+ * - inheriting fill column: `minmax(0, 1fr)` — absorbs exactly the parent's
1242
+ * leftover and yields width authority when squeezed, like the old fixed
1243
+ * layout `auto` fill.
1244
+ * - inheriting overflow column (past the parent's last column): `min-content`,
1245
+ * what the deleted probe measured.
1246
+ * - inheriting in-range column: the parent's track `var()` chain (see
1247
+ * `gridRangeWidth`), gated on the parent having published.
1248
+ *
1249
+ * The first/last column additionally absorbs the structural edge offset. Never
1250
+ * emit `repeat()` or line names: the track reader splits a flat px list.
1089
1251
  */
1252
+ /**
1253
+ * A column `width` that is a grid sizing keyword rather than a length —
1254
+ * `toLength()` passes these through verbatim. They size the track directly
1255
+ * and must never be wrapped in `calc()`/`minmax()`.
1256
+ */
1257
+ const isTrackKeyword = (value: string): boolean =>
1258
+ /^(auto|min-content|max-content|fit-content\(.*\)|-?[\d.]+fr)$/.test(
1259
+ value.trim()
1260
+ );
1261
+
1090
1262
  const columnTracks = computed(() => {
1091
- const sync = (key: string) =>
1092
- inheritColumnWidths.value ? parentVar(key) : 'auto';
1093
- // In fixed layout `auto` columns share width equally, so pin the structural
1094
- // columns to their measured content width and let the data columns share.
1095
- const structural = (key: 'select' | 'actions') =>
1096
- fixedLayout.value && structuralFit[key]
1097
- ? (structuralFit[key] as string)
1098
- : sync(key);
1099
1263
  const tracks: Array<{ key: string; width: string }> = [];
1100
1264
 
1101
1265
  if (props.selectable) {
1102
- tracks.push({ key: 'select', width: structural('select') });
1266
+ tracks.push({ key: 'select', width: structuralTrack(startRegion.value) });
1103
1267
  }
1104
1268
 
1105
1269
  const lastIndex = props.columns.length - 1;
1270
+ // A root table whose data columns are ALL frozen (see the doc comment):
1271
+ // the frozen lengths become proportional floors instead of hard sizes.
1272
+ const allFrozen =
1273
+ !isInheriting.value &&
1274
+ props.columns.length > 0 &&
1275
+ props.columns.every((column) => column.width != null);
1106
1276
  props.columns.forEach((column, index) => {
1107
1277
  const key = String(index);
1108
1278
  const frozen = column.width != null ? toLength(column.width) : null;
1109
1279
  const interval = columnIntervals.value[index];
1110
1280
 
1111
- // The fill column is left `auto` so fixed layout (the inheriting table has
1112
- // a definite `width: 100%`) hands it the leftover space — its own inherited
1113
- // track plus the folded edge region plus any deficit. Skipped when the
1114
- // table overflows: there is no leftover then, and it must content-size.
1115
- if (index === fillColumnIndex.value && !overflowsParent.value) {
1116
- tracks.push({ key, width: 'auto' });
1117
- return;
1118
- }
1119
-
1120
- let span: string | null;
1121
- if (frozen != null) {
1281
+ // `span` is a CSS *length* expression (frozen width or inherited chain)
1282
+ // when the offset must be folded into it arithmetically; a `keyword`
1283
+ // track sizes to the cell's content, which already includes the padding
1284
+ // the offset adds, so it needs no folding.
1285
+ let span: string | null = null;
1286
+ let keyword: string | null = null;
1287
+ if (frozen != null && isTrackKeyword(frozen)) {
1288
+ // A keyword `width` (`auto`, `min-content`, `1fr`, …) is a track, not a
1289
+ // length: it can neither be folded into the offset `calc()` (invalid
1290
+ // at computed-value time — and an invalid template drops EVERY track)
1291
+ // nor serve as a `minmax()` floor with an `fr` share.
1292
+ keyword = frozen;
1293
+ } else if (frozen != null && allFrozen) {
1294
+ // `parseFloat` reads the leading number of any unit (`120px` → 120,
1295
+ // `25%` → 25); a non-numeric length still gets an equal share.
1296
+ const share = parseFloat(frozen) || 1;
1297
+ keyword = `minmax(${frozen}, ${share}fr)`;
1298
+ } else if (frozen != null) {
1122
1299
  span = frozen;
1123
1300
  } else if (isFlexColumn(index)) {
1124
- // Root table auto column: a precise share of the leftover space from the
1125
- // proportional probe rather than fixed layout's equal split. `auto` is
1126
- // the fallback before the probe runs.
1127
- span = (fixedLayout.value && probeColWidths.value[index]) || null;
1301
+ keyword = props.fixed ? 'minmax(0, 1fr)' : 'auto';
1302
+ } else if (!parentTracksPublished.value) {
1303
+ // Inheriting, but the parent has not published yet (SSR, first client
1304
+ // frame): content-size on `max-content`. Checked BEFORE the overflow
1305
+ // test, which is vacuously true while the parent's geometry is unknown.
1306
+ keyword = 'max-content';
1128
1307
  } else if (isOverflowColumn(interval)) {
1129
1308
  // Inheriting table, past the parent's last column: no track to snap to,
1130
1309
  // so size to its own content and let the table overflow (and scroll).
1131
- span = columnMinWidths.value[index] ?? null;
1310
+ keyword = 'min-content';
1132
1311
  } else {
1133
1312
  // In range: snap to the parent's matching column.
1134
1313
  span = gridRangeWidth(interval.start, interval.end);
1314
+ if (span === null) keyword = 'auto';
1315
+ }
1316
+
1317
+ if (keyword !== null) {
1318
+ tracks.push({ key, width: keyword });
1319
+ return;
1135
1320
  }
1136
1321
 
1137
1322
  const offsets: string[] = [];
@@ -1142,22 +1327,35 @@ const columnTracks = computed(() => {
1142
1327
  offsets.push('var(--offset-internal-end, 0px)');
1143
1328
  }
1144
1329
 
1145
- // When folding an offset, use a calc-safe length base (no `auto`) so the
1146
- // column width = offset(s) + the column's own span.
1147
- const width = offsets.length
1148
- ? `calc(${[...offsets, span ?? '0px'].join(' + ')})`
1149
- : (span ?? 'auto');
1330
+ // Fold the offset(s) in so the track = offset(s) + the column's own span.
1331
+ const folded = offsets.length
1332
+ ? `calc(${[...offsets, span].join(' + ')})`
1333
+ : (span as string);
1334
+
1335
+ // The fill column: the last unfrozen/unsnapped column of an inheriting
1336
+ // table grows to re-align everything after a narrower frozen sibling. Its
1337
+ // rail is the floor — it never shrinks below it (structural surplus is
1338
+ // not its job any more; see the decision tree above).
1339
+ const width =
1340
+ index === fillColumnIndex.value && !overflowsParent.value
1341
+ ? `minmax(${folded}, 1fr)`
1342
+ : folded;
1150
1343
 
1151
1344
  tracks.push({ key, width });
1152
1345
  });
1153
1346
 
1154
1347
  if (props.actions) {
1155
- tracks.push({ key: 'actions', width: structural('actions') });
1348
+ tracks.push({ key: 'actions', width: structuralTrack(endRegion.value) });
1156
1349
  }
1157
1350
 
1158
1351
  return tracks;
1159
1352
  });
1160
1353
 
1354
+ /** The grid root's `grid-template-columns` value. */
1355
+ const gridTemplate = computed(() =>
1356
+ columnTracks.value.map((track) => track.width).join(' ')
1357
+ );
1358
+
1161
1359
  /**
1162
1360
  * Standalone edge-offset custom properties consumed by the first/last cell
1163
1361
  * padding and the first/last column width. Set only when an offset is active;
@@ -1174,460 +1372,124 @@ const structuralOffset = computed<Record<string, string>>(() => {
1174
1372
  return style;
1175
1373
  });
1176
1374
 
1177
- const containerStyle = computed(() => {
1178
- const vars: Record<string, string> = {
1179
- ...cssVars.value,
1180
- ...structuralOffset.value,
1181
- };
1182
-
1183
- if (naturalTotalWidth.value) {
1184
- vars['--natural-width'] = naturalTotalWidth.value;
1185
- }
1186
-
1187
- if (fillValue.value > 0) {
1188
- vars['--fill'] = `${fillValue.value}px`;
1189
- }
1190
-
1191
- return vars;
1192
- });
1193
-
1194
- /** Reads the header cells in render order: [select?, ...data, actions?]. */
1195
- const headerCells = () =>
1196
- Array.from(
1197
- tableRef.value?.querySelectorAll<HTMLElement>(
1198
- ':scope > thead > tr.bb-table-header-row > th'
1199
- ) ?? []
1200
- );
1201
-
1202
- /** Measures the rendered columns and publishes them as this table's tracks. */
1203
- const measureTracks = () => {
1204
- const keys = columnTracks.value.map((track) => track.key);
1205
- const tracks: Record<string, string> = {};
1206
- headerCells().forEach((cell, index) => {
1207
- const key = keys[index];
1208
- if (key != null) tracks[key] = `${cell.getBoundingClientRect().width}px`;
1209
- });
1210
- setTracks(tracks);
1211
- };
1375
+ const containerStyle = computed(() => ({
1376
+ ...cssVars.value,
1377
+ ...structuralOffset.value,
1378
+ }));
1212
1379
 
1213
1380
  /**
1214
- * Measures the intrinsic content width of the structural columns across header
1215
- * and body. Fixed layout sizes columns from the header row only, so the body is
1216
- * required here: the actions header is `sr-only`, while the real control lives
1217
- * in the body. The measured child width is independent of the column width, so
1218
- * pinning the column to it converges instead of oscillating.
1381
+ * The cells whose border boxes ARE this table's tracks: the header row's
1382
+ * cells, which are stretched subgrid items. When a `#thead` slot replaces the
1383
+ * header with fewer cells than tracks, the first row (head or body) with at
1384
+ * least one child per track stands in — so a short custom header on an empty
1385
+ * table publishes nothing until it has rows (see `observeColumns`), and the
1386
+ * `mappedItems` watch re-arms the observer when rows arrive or are re-keyed.
1387
+ * Scoped with `:scope >` so a nested table's rows are never picked up.
1219
1388
  */
1220
- const measureStructuralFit = () => {
1221
- const table = tableRef.value;
1222
- if (!table) return;
1223
-
1224
- // Non-replaced block-level boxes stretch to their container's width in block
1225
- // formatting; everything else (inline-*, floats, tables) shrink-wraps.
1226
- const STRETCHING = ['block', 'flex', 'grid', 'flow-root', 'list-item'];
1227
- // Widgets and replaced elements size to their content regardless of display,
1228
- // so their rendered width is always intrinsic.
1229
- const SHRINK_WRAPPING_TAGS = [
1230
- 'BUTTON',
1231
- 'INPUT',
1232
- 'SELECT',
1233
- 'TEXTAREA',
1234
- 'IMG',
1235
- 'svg',
1236
- 'VIDEO',
1237
- 'CANVAS',
1238
- ];
1239
-
1240
- /**
1241
- * Intrinsic occupied width of an element's in-flow contents: the sum of each
1242
- * child's own intrinsic width plus horizontal margins and any flex/grid
1243
- * column gaps — an actions cell holds several side-by-side controls and the
1244
- * column must fit them all. Crucially this is *not* their shared extent: the
1245
- * extent depends on how the container spreads them across its width, which
1246
- * feeds the column width back into its own measurement and runs away into
1247
- * the thousands.
1248
- *
1249
- * A shrink-wrapping child's rendered width does not depend on the cell, so
1250
- * it is used as-is. A *stretching* child's rendered width IS its container's
1251
- * width — a consumer slotting `<div class="flex">…</div>` would otherwise
1252
- * ratchet the column wider on every container resize — so recurse into its
1253
- * contents instead. A child stretches only in block formatting: flex/grid
1254
- * items size to their content on the main axis, so inside a flex/grid
1255
- * container every child's rendered width is trusted. Out-of-flow children
1256
- * (e.g. an `sr-only` label parked far off-screen, a popover) are skipped so
1257
- * they cannot inflate the result.
1258
- */
1259
- const contentsWidth = (el: HTMLElement): number => {
1260
- const style = getComputedStyle(el);
1261
- const itemsShrinkWrap =
1262
- style.display.includes('flex') || style.display.includes('grid');
1263
- let total = 0;
1264
- let count = 0;
1265
- el.querySelectorAll<HTMLElement>(':scope > *').forEach((child) => {
1266
- const childStyle = getComputedStyle(child);
1267
- if (childStyle.position === 'absolute' || childStyle.position === 'fixed')
1268
- return;
1269
- const margin =
1270
- parseFloat(childStyle.marginLeft) + parseFloat(childStyle.marginRight);
1271
- const stretches =
1272
- !itemsShrinkWrap &&
1273
- !SHRINK_WRAPPING_TAGS.includes(child.tagName) &&
1274
- childStyle.cssFloat === 'none' &&
1275
- STRETCHING.includes(childStyle.display);
1276
- const own = stretches
1277
- ? contentsWidth(child) +
1278
- parseFloat(childStyle.paddingLeft) +
1279
- parseFloat(childStyle.paddingRight) +
1280
- parseFloat(childStyle.borderLeftWidth) +
1281
- parseFloat(childStyle.borderRightWidth)
1282
- : child.getBoundingClientRect().width;
1283
- total += own + (margin || 0);
1284
- count += 1;
1285
- });
1286
- if (count === 0) {
1287
- // Text-only content: measure the laid-out text run itself.
1288
- const range = document.createRange();
1289
- range.selectNodeContents(el);
1290
- return range.getBoundingClientRect().width;
1291
- }
1292
- if (count > 1) {
1293
- const gap = parseFloat(style.columnGap);
1294
- if (gap) total += gap * (count - 1);
1295
- }
1296
- return total;
1297
- };
1298
-
1299
- const fit = (selector: string) => {
1300
- let max = 0;
1301
- table.querySelectorAll<HTMLElement>(selector).forEach((cell) => {
1302
- const style = getComputedStyle(cell);
1303
- const padding =
1304
- parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
1305
- max = Math.max(max, contentsWidth(cell) + padding);
1306
- });
1307
- return max > 0 ? `${Math.ceil(max)}px` : null;
1308
- };
1309
-
1310
- // Scoped to this table's own header/body rows: an unscoped descendant query
1311
- // would also match the structural cells of a nested table living in an expand
1312
- // row, letting a child's actions/select width hijack this table's column.
1313
- structuralFit.select = props.selectable
1314
- ? fit(
1315
- ':scope > thead > tr > th.bb-table-header--select, :scope > tbody > tr > td.bb-table-data__cell--select'
1316
- )
1317
- : null;
1318
- structuralFit.actions = props.actions
1319
- ? fit(
1320
- ':scope > thead > tr > th.bb-table-header--actions, :scope > tbody > tr > td.bb-table__cell--actions'
1389
+ const trackCells = (): HTMLElement[] => {
1390
+ const root = tableRef.value;
1391
+ const trackCount = columnTracks.value.length;
1392
+ if (!root || !trackCount) return [];
1393
+ const headerRow = root.querySelector<HTMLElement>(
1394
+ ':scope > .bb-table__head > .bb-table-header-row'
1395
+ );
1396
+ // The default header row is the first candidate, never the only one: a
1397
+ // custom `#thead` may reuse its class with fewer cells than tracks.
1398
+ const candidates = [
1399
+ ...(headerRow ? [headerRow] : []),
1400
+ ...Array.from(
1401
+ root.querySelectorAll<HTMLElement>(
1402
+ ':scope > .bb-table__head > [role="row"], :scope > .bb-table__body > [role="row"]'
1321
1403
  )
1322
- : null;
1404
+ ),
1405
+ ];
1406
+ const row = candidates.find((r) => r.children.length >= trackCount);
1407
+ return row ? (Array.from(row.children) as HTMLElement[]) : [];
1323
1408
  };
1324
1409
 
1325
- const measure = () => {
1326
- measureStructuralFit();
1327
- measureTracks();
1328
- };
1410
+ /** The joined px list last published, for the equality guard. */
1411
+ let publishedTracks = '';
1329
1412
 
1330
1413
  /**
1331
- * Proportional `<col>` widths derived from an auto-layout probe, one entry per
1332
- * data column (`null` for frozen columns — they keep their declared width).
1414
+ * Measures the resolved columns and publishes them as this table's tracks.
1415
+ * ONE read — `getComputedStyle(root).gridTemplateColumns` — yields the used px
1416
+ * of every track type through both subgrid levels, synchronously and to the
1417
+ * subpixel. The px substrings are published verbatim (no float round-trip:
1418
+ * nested alignment is asserted at 1.5px and depends on it), zipped
1419
+ * positionally with `columnTracks`.
1333
1420
  *
1334
- * For each auto column the formula is:
1335
- * width = max(minimum, available × (minimum / minimumTotal))
1336
- *
1337
- * where `minimum` is the column's `min-content` width (what it shrinks to in a
1338
- * very narrow container, i.e. the floor below which content would be clipped),
1339
- * `available` = containerWidth − frozenColumnWidths − structuralColumnWidths
1340
- * and `minimumTotal` = sum of the auto columns' minimums.
1341
- *
1342
- * So when the container has room (`available ≥ minimumTotal`) the slack is shared
1343
- * out proportionally to each column's minimum, scaling them up together; when it
1344
- * does not, every column drops to its minimum and the table-level `min-width`
1345
- * lets it overflow (the container scrolls) rather than squashing content further.
1346
- *
1347
- * Re-measured after any DOM mutation (debounced 200 ms) and after any container
1348
- * resize (via a dedicated ResizeObserver on the container element).
1421
+ * Guarded twice: a non-px token means the table is hidden (the specified
1422
+ * template comes back, and the observer fires on hide) — keep the last good
1423
+ * vars, the observer fires again on re-show; and `setTracks` is skipped on an
1424
+ * unchanged list — loop safety, since publishing re-renders the container
1425
+ * style, which is observed by the very header cells that trigger this.
1349
1426
  */
1350
- const probeColWidths = ref<Array<string | null>>([]);
1351
-
1352
- /**
1353
- * Each unfrozen column's measured `min-content` width (`null` for frozen). Used
1354
- * to content-size the overflow columns of an inheriting table — those past the
1355
- * parent's last column, with no track to snap to.
1356
- */
1357
- const columnMinWidths = ref<Array<string | null>>([]);
1358
-
1359
- /**
1360
- * Leftover px the inheriting fill column adds so the table reaches its parent's
1361
- * width. Published as `--fill`; `0` when the table already fills or
1362
- * overflows. Derived self-correctingly from the live total (see the probe).
1363
- */
1364
- const fillValue = ref(0);
1365
-
1366
- /**
1367
- * Total minimum width of the table (all columns at their `min-content`, plus the
1368
- * structural columns). Published as `--natural-width` and used as
1369
- * `min-width` on the `<table>` so the table scrolls horizontally only once the
1370
- * container is narrower than this true minimum.
1371
- */
1372
- const naturalTotalWidth = ref<string | null>(null);
1373
-
1374
- const runNaturalWidthProbe = () => {
1375
- const container = containerRef.value;
1376
- if (!container || !fixedLayout.value) return;
1377
- // Runs for a frozen-column table (to share space), or any inheriting table
1378
- // (to content-size overflow columns and drive the fill column's slack).
1379
- if (
1380
- !props.columns.some((col) => col.width != null) &&
1381
- !overflowsParent.value &&
1382
- fillColumnIndex.value < 0
1383
- )
1384
- return;
1385
-
1386
- const containerWidth = container.offsetWidth;
1387
- const dataOffset = props.selectable ? 1 : 0;
1388
-
1389
- // ── Real-table measurements ────────────────────────────────────────────
1390
- // Structural column widths come from measureStructuralFit (already run).
1391
- const selectWidth = structuralFit.select
1392
- ? parseFloat(structuralFit.select)
1393
- : 0;
1394
- const actionsWidth = structuralFit.actions
1395
- ? parseFloat(structuralFit.actions)
1396
- : 0;
1397
-
1398
- // Live rendered widths from the fixed-layout table. `frozenTotal` (declared
1399
- // widths) feeds the stable min-width floor; `nonFlexTotal` (frozen + inherited)
1400
- // is what the flex columns share the remainder of. The inherited part is only
1401
- // used for that `available` figure — never fed back into min-width, since those
1402
- // widths stretch with the table and would otherwise lock min-width above
1403
- // max-content and force a spurious uniform stretch on every column.
1404
- const realThs = headerCells();
1405
- let frozenTotal = 0;
1406
- let nonFlexTotal = 0;
1407
- props.columns.forEach((col, i) => {
1408
- const width = realThs[dataOffset + i]?.getBoundingClientRect().width ?? 0;
1409
- if (col.width != null) frozenTotal += width;
1410
- if (!isFlexColumn(i)) nonFlexTotal += width;
1411
- });
1412
-
1413
- // ── Offscreen probe for the auto columns' minimum (min-content) widths ──
1414
- // The clone's inner table is forced to `width: min-content` and the auto <col>
1415
- // hints are stripped so each <th> reports the narrowest width it can take
1416
- // before content would be clipped, independent of the container size or
1417
- // previous probe cycles.
1418
- const probe = document.createElement('div');
1419
- probe.style.cssText = [
1420
- 'position:absolute',
1421
- 'top:-9999px',
1422
- 'left:-9999px',
1423
- 'visibility:hidden',
1424
- 'pointer-events:none',
1425
- `width:${containerWidth}px`,
1426
- `height:${container.offsetHeight}px`,
1427
- 'overflow:visible',
1428
- ].join(';');
1429
-
1430
- const clone = container.cloneNode(true) as HTMLElement;
1431
- clone.classList.remove('bb-table--fixed');
1432
-
1433
- // Expand rows are full-width (colspan) and hold nested tables; under the
1434
- // auto-layout probe their content would back-pressure this table's own
1435
- // columns, so drop them and measure this table's columns in isolation.
1436
- clone
1437
- .querySelectorAll('.bb-table-expand__row')
1438
- .forEach((row) => row.remove());
1439
-
1440
- // Auto <col> hints are cleared so a previous cycle's computed width cannot
1441
- // act as a minimum and inflate the measurement; frozen hints are left intact
1442
- // since their declared width is a genuine floor for that column.
1443
- const probeColEls = Array.from(clone.querySelectorAll<HTMLElement>('col'));
1444
- props.columns.forEach((col, i) => {
1445
- if (col.width == null) {
1446
- const probeCol = probeColEls[dataOffset + i];
1447
- if (probeCol) probeCol.style.width = '';
1448
- }
1427
+ const measureTracks = () => {
1428
+ const root = tableRef.value;
1429
+ if (!root) return;
1430
+ const resolved = readResolvedTracks(root);
1431
+ if (!resolved) return;
1432
+ const keys = columnTracks.value.map((track) => track.key);
1433
+ if (resolved.length !== keys.length) return;
1434
+ const joined = resolved.join(' ');
1435
+ if (joined === publishedTracks) return;
1436
+ publishedTracks = joined;
1437
+ const tracks: Record<string, string> = {};
1438
+ keys.forEach((key, index) => {
1439
+ tracks[key] = resolved[index];
1449
1440
  });
1450
-
1451
- const innerTable = clone.querySelector<HTMLElement>('table');
1452
- if (innerTable) {
1453
- innerTable.style.width = 'min-content';
1454
- innerTable.style.minWidth = '0';
1441
+ // Publish the CONTRACT, not the raw template (decision tree, case 2): an
1442
+ // edge content column that folded a parent region into its width is
1443
+ // published unfolded — the pure content rail — and the region goes out
1444
+ // under the structural key even though no cell renders it, so the next
1445
+ // level reserves it exactly as this one did.
1446
+ const lastKey = String(props.columns.length - 1);
1447
+ const startFold = offsetStartValue.value;
1448
+ const endFold = offsetEndValue.value;
1449
+ if (startFold && tracks['0']) {
1450
+ tracks['0'] = `calc(${tracks['0']} - (${startFold}))`;
1451
+ if (!props.selectable && startRegion.value)
1452
+ tracks.select = startRegion.value;
1455
1453
  }
1456
-
1457
- probe.appendChild(clone);
1458
- document.body.appendChild(probe);
1459
-
1460
- const headerRow = probe.querySelector<HTMLElement>(
1461
- 'table thead tr.bb-table-header-row'
1462
- );
1463
- const ths = headerRow
1464
- ? Array.from(headerRow.querySelectorAll<HTMLElement>(':scope > th'))
1465
- : [];
1466
-
1467
- // Min-content of every unfrozen column (0 for frozen). Independent of the live
1468
- // table's width, so it is a stable basis for both the flex share and the
1469
- // min-width floor — unlike the rendered inherited widths.
1470
- const minContents = props.columns.map((col, i) => {
1471
- if (col.width != null) return 0;
1472
- const th = ths[dataOffset + i];
1473
- return th ? th.getBoundingClientRect().width : 0;
1474
- });
1475
-
1476
- document.body.removeChild(probe);
1477
-
1478
- // ── Proportional <col> widths ──────────────────────────────────────────
1479
- // Only the flex columns share `available`; the inherited columns are already
1480
- // laid out and excluded via `nonFlexTotal`.
1481
- const flexMinTotal = props.columns.reduce(
1482
- (s, _, i) => s + (isFlexColumn(i) ? minContents[i] : 0),
1483
- 0
1484
- );
1485
- const available = containerWidth - nonFlexTotal - selectWidth - actionsWidth;
1486
-
1487
- // Share the available space proportionally to each flex column's minimum, but
1488
- // never drop below that minimum — when space runs out the table overflows
1489
- // (and the container scrolls) instead of clipping content.
1490
- //
1491
- // Widths are kept sub-pixel (not rounded up): the raw shares sum to exactly
1492
- // `available`, whereas ceiling each column would over-allocate by up to a
1493
- // pixel apiece and, under `table-layout: fixed`, push the table a few px past
1494
- // the container and trip the horizontal scrollbar. `toFixed` only trims the
1495
- // float tail, well below the 1px a scrollbar needs.
1496
- const px = (n: number) => `${Number(n.toFixed(3))}px`;
1497
- probeColWidths.value = props.columns.map((_, i) => {
1498
- if (!isFlexColumn(i)) return null;
1499
- const minimum = minContents[i];
1500
- if (flexMinTotal <= 0) return px(minimum);
1501
- const share = available > 0 ? available * (minimum / flexMinTotal) : 0;
1502
- return px(Math.max(minimum, share));
1503
- });
1504
-
1505
- // Min-content per unfrozen column, for content-sizing an inheriting table's
1506
- // overflow columns (no parent track to snap to).
1507
- columnMinWidths.value = minContents.map((w) => (w > 0 ? px(w) : null));
1508
-
1509
- // Leftover the inheriting fill column must absorb to reach the parent's width.
1510
- // `parentTotal` is the parent table's full width (the expand cell this table
1511
- // sits in); the container itself shrink-wraps to the table, so it can't be the
1512
- // reference. Self-correcting: the live total already includes last cycle's
1513
- // fill, so adding it back recovers the intrinsic total and the result is the
1514
- // same once the table fills (0 when it already fills or overflows).
1515
- if (fillColumnIndex.value >= 0) {
1516
- const parentTotal = Object.values(parentNode.value?.tracks ?? {}).reduce(
1517
- (s, w) => s + (parseFloat(w) || 0),
1518
- 0
1519
- );
1520
- const liveTotal = realThs.reduce(
1521
- (s, th) => s + th.getBoundingClientRect().width,
1522
- 0
1523
- );
1524
- fillValue.value = Math.max(0, parentTotal - liveTotal + fillValue.value);
1525
- } else {
1526
- fillValue.value = 0;
1454
+ if (endFold && tracks[lastKey]) {
1455
+ tracks[lastKey] = `calc(${tracks[lastKey]} - (${endFold}))`;
1456
+ if (!props.actions && endRegion.value) tracks.actions = endRegion.value;
1457
+ }
1458
+ setTracks(tracks);
1459
+ // Sticky column offsets are prefix sums of these tracks: keep them current.
1460
+ if (props.fixedColumns?.length) {
1461
+ applyFixedTableColumns(root, props.fixedColumns);
1527
1462
  }
1528
-
1529
- // Table min-width = frozen (declared) widths + every unfrozen column's
1530
- // min-content + structural. Built only from values that do not depend on the
1531
- // table's own width, so it can never exceed max-content and force a stretch.
1532
- const minContentTotal = minContents.reduce((s, w) => s + w, 0);
1533
- const naturalTotal =
1534
- frozenTotal + minContentTotal + selectWidth + actionsWidth;
1535
- naturalTotalWidth.value =
1536
- naturalTotal > 0 ? `${Math.ceil(naturalTotal)}px` : null;
1537
1463
  };
1538
1464
 
1539
- let naturalWidthDebounceTimer: ReturnType<typeof setTimeout> | null = null;
1540
- let mutationObserver: MutationObserver | undefined;
1541
-
1542
1465
  /**
1543
- * True while a container resize is waiting on the debounced probe to reflow
1544
- * the columns. A shrink leaves the old (wider) column widths in place until
1545
- * the probe lands, so `overflow-x: auto` would flash a horizontal scrollbar
1546
- * for that window. While measuring, overflow is clipped instead — but only
1547
- * when the table wasn't already overflowing when the resize hit (an existing
1548
- * scrollbar stays; hiding it would jolt a table the user may be scrolling).
1466
+ * The single observer: the per-header-cell ResizeObserver. A header cell is a
1467
+ * stretched subgrid item whose border box is the track, so it is the complete
1468
+ * signal for every cause of a track change — including content-driven
1469
+ * redistribution at a constant root width, which a root observer cannot see.
1470
+ * Measuring inside the callback is post-layout, pre-paint: the republished
1471
+ * vars restyle nested children in the same frame.
1549
1472
  */
1550
- const measuring = ref(false);
1551
-
1552
- /** Whether the table genuinely overflowed its container at the last settle. */
1553
- let hadOverflow = false;
1554
-
1555
- const updateOverflowState = () => {
1556
- const container = containerRef.value;
1557
- const table = tableRef.value;
1558
- hadOverflow =
1559
- !!container &&
1560
- !!table &&
1561
- table.getBoundingClientRect().width > container.clientWidth + 1;
1562
- };
1563
-
1564
- /** Invalidates the cached natural width and schedules a fresh probe. */
1565
- const scheduleNaturalWidthProbe = () => {
1566
- if (naturalWidthDebounceTimer !== null)
1567
- clearTimeout(naturalWidthDebounceTimer);
1568
- naturalWidthDebounceTimer = setTimeout(() => {
1569
- naturalWidthDebounceTimer = null;
1570
- runNaturalWidthProbe();
1571
- // Lift the overflow clip only after the new column widths have rendered,
1572
- // then re-read the settled overflow state (a scrollbar reappears here
1573
- // only if the table truly cannot fit the new container width).
1574
- nextTick(() => {
1575
- measuring.value = false;
1576
- updateOverflowState();
1577
- });
1578
- }, 200);
1579
- };
1580
-
1581
1473
  let resizeObserver: ResizeObserver | undefined;
1582
- /** Observes the container element specifically for size changes so the
1583
- * proportional <col> widths are recomputed whenever the container grows or
1584
- * shrinks — separately from the header-cell observer that drives measureTracks. */
1585
- let containerResizeObserver: ResizeObserver | undefined;
1586
1474
 
1587
1475
  const observeColumns = () => {
1588
1476
  if (!resizeObserver) return;
1589
1477
  resizeObserver.disconnect();
1590
- headerCells().forEach((cell) => resizeObserver?.observe(cell));
1591
- measure();
1478
+ const cells = trackCells();
1479
+ cells.forEach((cell) => resizeObserver?.observe(cell));
1480
+ // No observable cells (a short custom `#thead` over an empty table): publish
1481
+ // nothing rather than a snapshot nothing would ever refresh — an inheriting
1482
+ // child then stays on its content-sized gate until rows arrive.
1483
+ if (cells.length) measureTracks();
1592
1484
  };
1593
1485
 
1594
1486
  onMounted(() => {
1595
- resizeObserver = new ResizeObserver(() => measure());
1487
+ resizeObserver = new ResizeObserver(measureTracks);
1596
1488
  observeColumns();
1597
-
1598
- // Initial probe runs after the first render so all cells have content.
1599
- nextTick(runNaturalWidthProbe);
1600
-
1601
- // Invalidate the cache whenever rows or cell content change.
1602
- if (tableRef.value) {
1603
- mutationObserver = new MutationObserver(scheduleNaturalWidthProbe);
1604
- mutationObserver.observe(tableRef.value, {
1605
- childList: true,
1606
- subtree: true,
1607
- characterData: true,
1608
- });
1609
- }
1610
-
1611
- // Re-probe whenever the container itself is resized so that proportional
1612
- // <col> widths stay accurate as the available space changes. Runs before
1613
- // paint, so clipping overflow here means the stale-width scrollbar never
1614
- // shows. The hadOverflow guard also breaks the feedback loop of the clip
1615
- // itself resizing the content box.
1616
- if (containerRef.value) {
1617
- containerResizeObserver = new ResizeObserver(() => {
1618
- if (!hadOverflow) measuring.value = true;
1619
- scheduleNaturalWidthProbe();
1620
- });
1621
- containerResizeObserver.observe(containerRef.value);
1622
- }
1623
1489
  });
1624
1490
 
1625
1491
  onBeforeUnmount(() => {
1626
1492
  resizeObserver?.disconnect();
1627
- containerResizeObserver?.disconnect();
1628
- mutationObserver?.disconnect();
1629
- if (naturalWidthDebounceTimer !== null)
1630
- clearTimeout(naturalWidthDebounceTimer);
1631
1493
  });
1632
1494
 
1633
1495
  watch(columnTracks, () => nextTick(observeColumns), { flush: 'post' });
@@ -1658,8 +1520,8 @@ const loading = computed(() => props.loading || innerLoading.value);
1658
1520
  * out: a refetch is about to replace the rows, so editing, selecting or sorting
1659
1521
  * them races the incoming data.
1660
1522
  *
1661
- * Applied to `<thead>` and to each row rather than to `<table>` or `<tbody>` on
1662
- * purpose — `inert` removes descendants from the a11y tree, and the loading
1523
+ * Applied to the header rowgroup and to each row rather than to the table or
1524
+ * the body rowgroup on purpose — `inert` removes descendants from the a11y tree, and the loading
1663
1525
  * announcement lives in the same body as the rows, so a broader target would
1664
1526
  * silence it and the table would go busy in complete silence.
1665
1527
  */
@@ -1688,7 +1550,6 @@ const containerClass = computed(() => ({
1688
1550
  'bb-table--loading': loading.value,
1689
1551
  'bb-table--empty': !options.value.length,
1690
1552
  'bb-table--selectable': !!props.selectable,
1691
- 'bb-table--measuring': measuring.value,
1692
1553
  'scrollbar-border': true,
1693
1554
  }));
1694
1555
 
@@ -1856,7 +1717,14 @@ const mapColumnToCell = (
1856
1717
  const mappedHeaders = computed(() =>
1857
1718
  props.columns.map((column: BbTableColumn<Item>): MappedHeader => {
1858
1719
  const align = column.align ?? 'left';
1859
- const slotName = `header:${slotNamesByKey.value[column.key]}`;
1720
+ const base = slotNamesByKey.value[column.key];
1721
+ // A key normalizing to an affix suffix collides with the table-wide
1722
+ // `header:prepend/append` slots, and the table-wide reading wins (dev
1723
+ // builds warn) — so route this column's own slot to a name no template
1724
+ // can declare (the space guarantees it) and let the fallback render.
1725
+ const slotName = isHeaderAffixSuffix(base)
1726
+ ? `header:${base} (shadowed)`
1727
+ : `header:${base}`;
1860
1728
  // The live sort model wins over the static `sorted` column flag.
1861
1729
  const sortOrder = sortOrderFor(column.key);
1862
1730
  const sorted = sortOrder ?? column.sorted;
@@ -1883,6 +1751,39 @@ const mappedHeaders = computed(() =>
1883
1751
  })
1884
1752
  );
1885
1753
 
1754
+ /**
1755
+ * Resolves a header cell's prepend/append content: the column's own
1756
+ * `header:<key>:prepend/append` slot wins over the table-wide
1757
+ * `header:prepend/append` one — they never stack (BbDropdown's item rule).
1758
+ * A column whose key shadows an affix suffix only gets the table-wide slots.
1759
+ */
1760
+ const headerAffixFor = (key: string, suffix: HeaderAffixSuffix) => {
1761
+ const base = slotNamesByKey.value[key];
1762
+ return (
1763
+ (isHeaderAffixSuffix(base)
1764
+ ? undefined
1765
+ : slots[`header:${base}:${suffix}`]) ?? slots[`header:${suffix}`]
1766
+ );
1767
+ };
1768
+
1769
+ /**
1770
+ * Affix slots receive the `header:<key>` payload plus the column key so a
1771
+ * table-wide template can branch per column. Named `columnKey`, not `key`:
1772
+ * the payload is v-bound onto a rendered slot function, where `key` would be
1773
+ * consumed as the vnode key instead of reaching the slot.
1774
+ */
1775
+ const headerAffixScope = (
1776
+ header: MappedHeader
1777
+ ): BbTableHeaderAffixSlotProps<Item> => ({
1778
+ classes: header.classes,
1779
+ columnKey: header.key,
1780
+ items: internalItems.value,
1781
+ label: header.label,
1782
+ sortable: header.sortable,
1783
+ sortOrder: header.sortOrder,
1784
+ toggleSort: () => toggleSortFor(header.key),
1785
+ });
1786
+
1886
1787
  /**
1887
1788
  * Resolves the CURRENT option for a row at event time. Row handlers are built
1888
1789
  * in the selection-free formatter projection below, so they cannot capture a
@@ -1924,17 +1825,17 @@ const projectedItems = computed(() =>
1924
1825
  const item = liveOptionFor(index, base.valueHash);
1925
1826
  if (!item) return;
1926
1827
  onRowClickHighlight(event, item);
1927
- emit('click:row', event, item, !!item.selected);
1828
+ emit('click:row', event, item.item);
1928
1829
  },
1929
1830
  onRowContextMenu: (event: MouseEvent) => {
1930
1831
  const item = liveOptionFor(index, base.valueHash);
1931
1832
  if (!item) return;
1932
- emit('contextmenu:row', event, item, !!item.selected);
1833
+ emit('contextmenu:row', event, item.item);
1933
1834
  },
1934
1835
  onRowDblClick: (event: MouseEvent) => {
1935
1836
  const item = liveOptionFor(index, base.valueHash);
1936
1837
  if (!item) return;
1937
- emit('dblclick:row', event, item, !!item.selected);
1838
+ emit('dblclick:row', event, item.item);
1938
1839
  },
1939
1840
  item: base.item,
1940
1841
  text: base.text,
@@ -2005,7 +1906,7 @@ watch(
2005
1906
  /** This table's own data rows (excludes expand rows and nested tables). */
2006
1907
  const dataRows = () =>
2007
1908
  tableRef.value?.querySelectorAll<HTMLElement>(
2008
- ':scope > tbody > tr.bb-table-data__row'
1909
+ ':scope > .bb-table__body > .bb-table-data__row'
2009
1910
  );
2010
1911
 
2011
1912
  const focusRow = (index: number) => {
@@ -2228,6 +2129,10 @@ watch(
2228
2129
  if (tableRef.value && props.fixedColumns?.length) {
2229
2130
  applyFixedTableColumns(tableRef.value, props.fixedColumns);
2230
2131
  }
2132
+ // With a custom `#thead` the observed cells may live on a data row that
2133
+ // just arrived, or was re-keyed and replaced: re-arm the observer. The
2134
+ // default header row is stable, so this is skipped for it.
2135
+ if (slots.thead) nextTick(observeColumns);
2231
2136
  },
2232
2137
  { flush: 'post' }
2233
2138
  );
@@ -2283,6 +2188,14 @@ export type ItemKey<Item = any> = NestedPaths<Item> | (string & {});
2283
2188
  */
2284
2189
  export type ReservedColumnKey = 'actions' | 'select';
2285
2190
 
2191
+ /**
2192
+ * Suffixes of the header affix slots (`header:prepend` / `header:append` and
2193
+ * their per-column `header:<key>:prepend/append` variants). A column key that
2194
+ * normalizes to one of these shadows the table-wide slot name; the table-wide
2195
+ * interpretation wins and dev builds warn.
2196
+ */
2197
+ export type HeaderAffixSuffix = 'prepend' | 'append';
2198
+
2286
2199
  /**
2287
2200
  * Skeleton placeholder style for the loading state.
2288
2201
  * Matches the expected content type for a better loading experience.
@@ -2353,19 +2266,19 @@ export type BbTableColumn<Item = any> = BaseColumn<Item> & {
2353
2266
  sorted?: 'asc' | 'desc';
2354
2267
 
2355
2268
  /**
2356
- * Classes this column contributes to the whole row's `<tr>`. Every column's
2269
+ * Classes this column contributes to the whole row element. Every column's
2357
2270
  * `rowClass` is accumulated with the table-level `rowClass` prop and applied
2358
2271
  * together. Can also be a function of the cell's content for dynamic values.
2359
2272
  */
2360
2273
  rowClass?: ColumnClasses<Item>;
2361
2274
 
2362
2275
  /**
2363
- * Defines the classes to be passed to the `<td>` elements. Can also be a function for dynamic values.
2276
+ * Defines the classes to be passed to this column's data cells. Can also be a function for dynamic values.
2364
2277
  */
2365
2278
  tdClass?: ColumnClasses<Item>;
2366
2279
 
2367
2280
  /**
2368
- * Defines the classes to be passed to the `<th>`.
2281
+ * Defines the classes to be passed to this column's header cell.
2369
2282
  */
2370
2283
  thClass?: Classes;
2371
2284
 
@@ -2427,7 +2340,7 @@ export type BbTableProps<Item = any> = {
2427
2340
  /**
2428
2341
  * Opt a nested table into inheriting its column widths from an ancestor
2429
2342
  * table's matching tracks (accounting for `select`/`actions` columns). This
2430
- * also forces `table-layout: fixed`, so it is **off by default** — a nested
2343
+ * also makes the parent's tracks authoritative, so it is **off by default** — a nested
2431
2344
  * table renders independently unless you ask for alignment.
2432
2345
  * - `false` (the default): do not inherit; size independently.
2433
2346
  * - `true`: inherit from the nearest ancestor table.
@@ -2508,7 +2421,8 @@ export type BbTableProps<Item = any> = {
2508
2421
  displayCaption?: boolean;
2509
2422
 
2510
2423
  /**
2511
- * Defines the table layout as fixed
2424
+ * Splits the width equally among the columns that declare no `width`
2425
+ * (each undeclared column becomes a `minmax(0, 1fr)` track)
2512
2426
  * making each column take up the same amount of space.
2513
2427
  */
2514
2428
  fixed?: boolean;
@@ -2654,13 +2568,13 @@ export type BbTableProps<Item = any> = {
2654
2568
  readonly?: boolean;
2655
2569
 
2656
2570
  /**
2657
- * Defines the classes to be passed to each data `<tr>`. Can also be a
2571
+ * Defines the classes to be passed to each data row. Can also be a
2658
2572
  * function of the row's item for dynamic values.
2659
2573
  */
2660
2574
  rowClass?: RowClasses<Item>;
2661
2575
 
2662
2576
  /**
2663
- * Defines the classes to be passed to the header `<tr>`.
2577
+ * Defines the classes to be passed to the header row.
2664
2578
  */
2665
2579
  headerRowClass?: Classes;
2666
2580
 
@@ -2710,12 +2624,12 @@ export type BbTableProps<Item = any> = {
2710
2624
  selectText?: string;
2711
2625
 
2712
2626
  /**
2713
- * Defines the classes to be passed to the `<td>` elements. Can also be a function for dynamic values.
2627
+ * Defines the classes to be passed to every data cell. Can also be a function for dynamic values.
2714
2628
  */
2715
2629
  tdClass?: ColumnClasses<Item>;
2716
2630
 
2717
2631
  /**
2718
- * Defines the classes to be passed to the `<th>`.
2632
+ * Defines the classes to be passed to every header cell.
2719
2633
  */
2720
2634
  thClass?: Classes;
2721
2635
 
@@ -2791,7 +2705,7 @@ export type MappedItem = Option & {
2791
2705
  * Projection that represents a header
2792
2706
  */
2793
2707
  export type MappedHeader = {
2794
- align: HTMLTableCellElement['align'];
2708
+ align: string;
2795
2709
  classes?: Classes;
2796
2710
  key: string;
2797
2711
  label: string;
@@ -2821,9 +2735,12 @@ export type ExpandControllerProps = {
2821
2735
  };
2822
2736
 
2823
2737
  export type BbTableEvents = {
2824
- (e: 'click:row', event: MouseEvent, item: any, selected: boolean): void;
2825
- (e: 'contextmenu:row', event: MouseEvent, item: any, selected: boolean): void;
2826
- (e: 'dblclick:row', event: MouseEvent, item: any, selected: boolean): void;
2738
+ // Row events carry `(event, item)` only, where `item` is the RAW record
2739
+ // from `items` — not the table's internal row wrapper. Selection state is
2740
+ // derivable from the selection v-model, so it is not repeated here.
2741
+ (e: 'click:row', event: MouseEvent, item: any): void;
2742
+ (e: 'contextmenu:row', event: MouseEvent, item: any): void;
2743
+ (e: 'dblclick:row', event: MouseEvent, item: any): void;
2827
2744
  (e: 'update:highlighted', value: any): void;
2828
2745
  (e: 'update:modelValue', value: any): void;
2829
2746
  (e: 'update:selectAll', value: boolean): void;
@@ -2834,22 +2751,58 @@ export type BbTableEvents = {
2834
2751
  (e: 'item:unselected', value: any): void;
2835
2752
  };
2836
2753
 
2754
+ /**
2755
+ * Props exposed by the table-wide `header:prepend` / `header:append` slots and
2756
+ * their per-column `header:<key>:prepend` / `header:<key>:append` variants —
2757
+ * the `header:<key>` payload plus the column key.
2758
+ */
2759
+ export type BbTableHeaderAffixSlotProps<Item = any> = {
2760
+ /** Merged header classes for this column. */
2761
+ classes?: Classes;
2762
+ /**
2763
+ * The raw key of the column being rendered — branch on it in the
2764
+ * table-wide slots.
2765
+ */
2766
+ columnKey: string;
2767
+ /** The current table items. */
2768
+ items?: Item[];
2769
+ /** The column's label. */
2770
+ label?: string;
2771
+ /** Whether this column is sortable. */
2772
+ sortable?: boolean;
2773
+ /** This column's current sort direction, or `null` when unsorted. */
2774
+ sortOrder?: 'asc' | 'desc' | null;
2775
+ /** Cycles this column's sort: unsorted → asc → desc → unsorted. */
2776
+ toggleSort?: () => void;
2777
+ };
2778
+
2837
2779
  export type BbTableSlots<Item = any> = {
2838
2780
  /**
2839
- * Replaces the entire `<thead>` element. Use when you need full control over the header markup.
2781
+ * Replaces the content of the header row group (`.bb-table__head`). Use when
2782
+ * you need full control over the header markup. The table is a CSS grid,
2783
+ * not a `<table>`: provide role-complete rows — `<div role="row">` holding
2784
+ * one `<div role="columnheader">` per column — and never a `<tr>`/`<th>`.
2785
+ * Rows are subgrid rows, so each cell lands on the matching column track.
2840
2786
  */
2841
2787
  thead?: (props: object) => any;
2842
2788
  /**
2843
- * Replaces the entire `<tbody>` element. Use when you need full control over the body markup.
2789
+ * Replaces the content of the body row group (`.bb-table__body`). Use when
2790
+ * you need full control over the body markup. Provide role-complete rows —
2791
+ * `<div role="row">` holding one `<div role="cell">` per column — never a
2792
+ * `<tr>`/`<td>`. A full-width cell spans with `grid-column: 1 / -1`.
2844
2793
  */
2845
2794
  tbody?: (props: object) => any;
2846
2795
  /**
2847
- * Replaces the entire `<tfoot>` element. Use when you need full control over the footer markup.
2796
+ * Fills the footer row group (`.bb-table__foot`). Use when you need a
2797
+ * footer. Provide role-complete rows — `<div role="row">` holding
2798
+ * `<div role="cell">` elements — never a `<tr>`/`<td>`.
2848
2799
  */
2849
2800
  tfoot?: (props: {
2850
2801
  /**
2851
- * Total rendered column count — the value to give a full-width footer row's
2852
- * `colspan` (accounts for the selection/expand columns), so you never hand-count it.
2802
+ * Total rendered column count (accounts for the selection/actions
2803
+ * columns), so you never hand-count it. A footer cell spanning the
2804
+ * whole table uses `grid-column: 1 / -1`; one spanning a subset uses
2805
+ * `grid-column: span N` with `N` up to this value.
2853
2806
  */
2854
2807
  columnCount: number;
2855
2808
  }) => any;
@@ -2869,6 +2822,20 @@ export type BbTableSlots<Item = any> = {
2869
2822
  * @param text - The accessible label text for the actions column header.
2870
2823
  */
2871
2824
  'header:actions'?: (props: { text: string }) => any;
2825
+ /**
2826
+ * Content rendered before every data column's header cell content — the
2827
+ * label + sort control, or the column's own `header:<key>` replacement.
2828
+ * A column's `header:<key>:prepend` slot wins over this one for that
2829
+ * column; they never stack. Branch on `columnKey` for per-column output.
2830
+ */
2831
+ 'header:prepend'?: (props: BbTableHeaderAffixSlotProps<Item>) => any;
2832
+ /**
2833
+ * Content rendered after every data column's header cell content — the
2834
+ * label + sort control, or the column's own `header:<key>` replacement.
2835
+ * A column's `header:<key>:append` slot wins over this one for that
2836
+ * column; they never stack. Branch on `columnKey` for per-column output.
2837
+ */
2838
+ 'header:append'?: (props: BbTableHeaderAffixSlotProps<Item>) => any;
2872
2839
  /**
2873
2840
  * Content shown while table rows are loading (replaces the skeleton rows).
2874
2841
  * @param items - The current (possibly stale) list of items, useful to match the previous row count.
@@ -2876,9 +2843,9 @@ export type BbTableSlots<Item = any> = {
2876
2843
  loading?: (props: { items: Item[] }) => any;
2877
2844
  /**
2878
2845
  * Content shown when the items list is empty and not loading. It fills the
2879
- * table's own full-width empty-state cell (the colspan already accounts for
2880
- * the selection/actions columns), so provide the *content* — a message, an
2881
- * illustration, a "clear filters" button — never a `<tr>`/`<td>` of your own.
2846
+ * table's own full-width empty-state cell (it already spans the
2847
+ * selection/actions columns), so provide the *content* — a message, an
2848
+ * illustration, a "clear filters" button — never a row/cell of your own.
2882
2849
  */
2883
2850
  'no-data'?: (props: object) => any;
2884
2851
  /**
@@ -2950,6 +2917,11 @@ export type BbTableSlots<Item = any> = {
2950
2917
  string,
2951
2918
  (props: {
2952
2919
  classes?: Classes[];
2920
+ /**
2921
+ * Present only in the `header:<key>:prepend/append` affix slots — the
2922
+ * raw key of the column being rendered.
2923
+ */
2924
+ columnKey?: string;
2953
2925
  content?: any;
2954
2926
  expanded?: boolean;
2955
2927
  expandProps?: ExpandControllerProps;
@@ -2977,6 +2949,12 @@ export type BbTableSlots<Item = any> = {
2977
2949
 
2978
2950
  ```css
2979
2951
  .bb-table {
2952
+ /* The table's surface colour. Cells are transparent at rest so the row can
2953
+ tint them, but anything that sticks over scrolled content — the stuck
2954
+ head band and `fixed-columns` cells — must be opaque, and paints this.
2955
+ Set it on the table (`.my-table { --bg: … }`) when the table sits on a
2956
+ surface that is not `--bb-panel`; a nested table re-declares its own. */
2957
+ --bg: var(--bb-panel);
2980
2958
  --padding-x: 12px;
2981
2959
  --cell-h: 36px;
2982
2960
  --padding-y: 6px;
@@ -2993,9 +2971,6 @@ export type BbTableSlots<Item = any> = {
2993
2971
  --offset-internal-end: var(--offset-end);
2994
2972
  --offset-external-start: 0px;
2995
2973
  --offset-external-end: 0px;
2996
- /* Slack the fill column adds to reach the parent's width. Reset per table so a
2997
- nested table never inherits an ancestor's value through the cascade. */
2998
- --fill: 0px;
2999
2974
  display: grid;
3000
2975
  /* Override the UA `min-inline-size: min-content` that a <fieldset> container
3001
2976
  (rendered for selectable tables) carries — without this the container can't
@@ -3004,14 +2979,6 @@ export type BbTableSlots<Item = any> = {
3004
2979
  min-width: 0;
3005
2980
  overflow-x: auto;
3006
2981
 
3007
- /* While a container resize waits on the debounced column re-probe, the stale
3008
- (wider) column widths would flash a horizontal scrollbar; clip instead of
3009
- scroll for that window. Applied only when the table wasn't already
3010
- overflowing, so no live scroll position is ever clipped away. */
3011
- &--measuring {
3012
- overflow-x: clip;
3013
- }
3014
-
3015
2982
  padding-inline-end: var(--offset-external-end);
3016
2983
  padding-inline-start: var(--offset-external-start);
3017
2984
 
@@ -3035,89 +3002,133 @@ export type BbTableSlots<Item = any> = {
3035
3002
  --padding-y: 4px;
3036
3003
  }
3037
3004
 
3005
+ /* The visible caption keeps the UA `<caption>` centring it used to get. */
3006
+ &-caption {
3007
+ text-align: center;
3008
+ }
3009
+
3010
+ /* --- The grid -------------------------------------------------------------
3011
+ The root owns the column tracks (`grid-template-columns` is set inline
3012
+ from the column definitions) and every rowgroup and row is a subgrid of
3013
+ it, so a cell lands on its track through two levels without any width
3014
+ of its own. Real boxes at every level, never `display: contents`: the
3015
+ rowgroups host the busy bar and the loading opacity, and only a real box
3016
+ can be sticky.
3017
+
3018
+ `gap: 0` on the root is the authority — the heir of the old
3019
+ `border-spacing: 0` preflight reset. A subgrid-level `gap: 0` CANNOT
3020
+ neutralise a gap set on the root: the subgrid closes its gutters by
3021
+ growing its edge cells into the half-gutters (200px tracks came out
3022
+ 215/230/215), so the root value is the only real protection; the two
3023
+ subgrid-level declarations below are defence in depth only. */
3024
+ &__table {
3025
+ display: grid;
3026
+ gap: 0;
3027
+ /* Reproduces the old `<table>` box: fills the container when the tracks
3028
+ sum below it, grows to the tracks' sum (and scrolls the container) when
3029
+ they don't. Bare `min-width: 100%` would leave this box at container
3030
+ width with the tracks overflowing it — row backgrounds and the busy bar
3031
+ would paint short and `%` widths would resolve against the wrong box.
3032
+ Nothing paints on the root itself: rowgroups and rows are the paint
3033
+ surfaces. */
3034
+ min-width: 100%;
3035
+ width: fit-content;
3036
+ }
3037
+
3038
+ &__head,
3039
+ &__body,
3040
+ &__foot {
3041
+ display: grid;
3042
+ gap: 0;
3043
+ grid-column: 1 / -1;
3044
+ grid-template-columns: subgrid;
3045
+ }
3046
+
3047
+ /* Anchors the busy bar (`::after` below). Positioned unconditionally so the
3048
+ fixed-header rule (a plain `sticky`, higher specificity) never has to fight
3049
+ a loading-only `relative`. */
3050
+ &__head {
3051
+ position: relative;
3052
+ }
3053
+
3054
+ &__head > [role='row'],
3055
+ &__body > [role='row'],
3056
+ &__foot > [role='row'] {
3057
+ display: grid;
3058
+ gap: 0;
3059
+ grid-column: 1 / -1;
3060
+ grid-template-columns: subgrid;
3061
+ }
3062
+
3063
+ /* Sticky headers live on the head ROWGROUP: a sticky cell or row inside the
3064
+ double subgrid cannot stick in any engine (an item's vertical sticky slack
3065
+ is bounded by its own grid container — the one-row-tall row). Descendant
3066
+ form on purpose, so a nested table's head sticks along, as it always did.
3067
+ The stuck band paints `--bg` so scrolled rows never show through it.
3068
+ Sticky-column header cells (inline sticky from `fixed-columns`) sit above
3069
+ their scrolled siblings within the band. */
3038
3070
  &--fixed-header {
3039
- table {
3040
- thead {
3041
- th {
3042
- position: sticky;
3043
- top: 0;
3044
- z-index: 1;
3045
- }
3046
- th[style*='sticky'] {
3047
- position: sticky;
3048
- top: 0;
3049
- z-index: 2;
3050
- }
3051
- }
3071
+ .bb-table__head {
3072
+ background-color: var(--bg);
3073
+ position: sticky;
3074
+ top: 0;
3075
+ z-index: 1;
3076
+ }
3077
+ .bb-table__head [style*='sticky'] {
3078
+ z-index: 2;
3052
3079
  }
3053
3080
  }
3054
3081
 
3055
- &--selectable {
3056
- tr {
3057
- > .bb-table-data__cell--select {
3058
- text-align: center;
3059
- width: 0px;
3060
- }
3061
- }
3082
+ /* A `fixed-columns` cell (inline `position: sticky` from the component)
3083
+ slides over its scrolled siblings, so it cannot stay transparent. It
3084
+ paints the row's current tint when the row has one (`--row-bg`, set by
3085
+ the hover / highlighted states below), else the table surface — so a
3086
+ pinned cell never reads as a hole in a hovered or current row. */
3087
+ [style*='sticky'] {
3088
+ background-color: var(--row-bg, var(--bg));
3089
+ /* Same curve and duration as the row's own tint transition, so a pinned
3090
+ cell fades in step with its row instead of snapping ahead of it. */
3091
+ transition: background-color var(--bb-transition-fast) var(--bb-ease);
3062
3092
  }
3063
3093
 
3064
- table {
3065
- /* On the <table> element itself, not the container: `border-spacing` is
3066
- inherited, so a container-level `0` loses to the UA's element rule
3067
- (`table { border-spacing: 2px }`) — without a preflight reset every
3068
- cell would drift by 2px per gridline and break nested-table
3069
- alignment. */
3070
- border-collapse: separate;
3071
- border-spacing: 0;
3072
- min-width: 100%;
3094
+ &--selectable {
3095
+ [role='row'] > .bb-table-data__cell--select {
3096
+ text-align: center;
3097
+ }
3073
3098
  }
3074
3099
 
3075
- /* A nested table inheriting its parent's widths sizes to the sum of its
3076
- columns instead of filling its container, so fixed layout cannot stretch
3077
- them out of alignment. Declared after `--fixed` and the base table rule so
3078
- it overrides their width/min-width at equal specificity. */
3100
+ /* A nested table inheriting its parent's widths sizes to its tracks (the
3101
+ parent's, republished as custom properties) instead of filling its
3102
+ container, so its columns stay aligned with the parent's. */
3079
3103
  &--inherit-widths {
3080
3104
  /* Never grow past the parent's expand cell: a child with more columns
3081
- than the parent overflows the cell, so cap the container here and let its
3082
- own `overflow-x: auto` scroll the surplus columns inside the expand row
3083
- rather than stretching the ancestor. */
3105
+ than the parent overflows the cell, so cap the container here rather
3106
+ than stretching the ancestor. */
3084
3107
  max-width: 100%;
3085
3108
  min-width: 0;
3086
3109
 
3087
- table {
3088
- min-width: 0;
3089
- /* A *definite* width (not `max-content`) so `table-layout: fixed` stays
3090
- content-independent: inherited columns keep their colgroup widths and
3091
- align with the parent instead of growing to fit content. The fill
3092
- column is `auto`, so fixed layout hands it the leftover; when columns
3093
- overflow, the table grows past 100% and scrolls. */
3094
- width: 100%;
3095
- }
3096
- }
3097
-
3098
- /* Declared after the base `table { min-width: 100% }` rule so it wins the
3099
- cascade at equal specificity. The probe publishes the natural content width
3100
- of all columns; the table fills its container freely but never compresses
3101
- below that floor — the container's `overflow-x: auto` then shows a
3102
- scrollbar instead of squishing content. */
3103
- &--fixed {
3104
- > table {
3105
- table-layout: fixed;
3106
- width: 100%;
3107
-
3108
- thead {
3109
- .bb-table-header--actions {
3110
- width: auto;
3111
- }
3112
- }
3113
- }
3114
-
3115
- table {
3116
- min-width: var(--natural-width, 0px);
3117
- }
3110
+ /* No scrollport of its own — the ancestor's is the one that matters.
3111
+ A child that inherits its parent's tracks is sized to the parent's
3112
+ CONTENT width, so a scrollport here would have its right edge off in
3113
+ the overflow where nobody can see it, and `position: sticky` from
3114
+ `fixed-columns` would affix to that instead of to the visible edge:
3115
+ the parent's actions pin correctly while the child's sit hundreds of
3116
+ pixels off-screen until you scroll all the way right.
3117
+
3118
+ Scrolling it separately was never coherent anyway. The whole point of
3119
+ `inherit-column-widths` is that the child's columns line up with the
3120
+ parent's, and an independent scrollport breaks that alignment the
3121
+ moment either one moves. One scrollport, one scroll position, columns
3122
+ that stay aligned — and sticky that affixes where the reader is
3123
+ looking. Both axes: with only `overflow-x` set, `overflow-y` computes
3124
+ to `auto` and the scrollport comes back. */
3125
+ overflow: visible;
3118
3126
  }
3119
3127
 
3120
3128
  &-header {
3129
+ /* Centres the content vertically like the UA did for table cells; the
3130
+ cell stays a block container so header affixes keep their inline flow. */
3131
+ align-content: center;
3121
3132
  border-bottom: var(--bb-border-w) solid var(--bb-border);
3122
3133
  color: var(--bb-text-muted);
3123
3134
  font-size: var(--bb-fs);
@@ -3131,16 +3142,13 @@ export type BbTableSlots<Item = any> = {
3131
3142
  &--right {
3132
3143
  text-align: right;
3133
3144
  }
3134
-
3135
- &--actions {
3136
- width: 0;
3137
- }
3138
3145
  }
3139
3146
 
3140
- /* Sortable header control: label + the two-arrow sort icon. The icon is
3141
- invisible at rest and fades in muted on hover/focus; an active sort keeps
3142
- it visible with the active direction's arrow at full strength while the
3143
- other arrow stays muted. */
3147
+ /* Sortable header control: label + one arrow. Invisible at rest; hovering
3148
+ or focusing the header previews a faint arrow (the direction the next
3149
+ click sorts by); an active sort shows it solid — up for ascending,
3150
+ rotated down for descending (AG Grid convention) — and the third click
3151
+ clears it. The opacity ladder and the rotation both transition. */
3144
3152
  &-sort {
3145
3153
  align-items: center;
3146
3154
  background: none;
@@ -3165,60 +3173,87 @@ export type BbTableSlots<Item = any> = {
3165
3173
  }
3166
3174
 
3167
3175
  .bb-table-sort__icon {
3168
- color: var(--bb-muted);
3176
+ --arrow-preview: 0.45;
3177
+ color: var(--bb-text);
3169
3178
  flex-shrink: 0;
3170
3179
  opacity: 0;
3171
- transition: opacity var(--bb-transition-fast) var(--bb-ease);
3180
+ transition:
3181
+ opacity var(--bb-transition-fast) var(--bb-ease),
3182
+ transform var(--bb-transition-fast) var(--bb-ease);
3172
3183
  }
3173
3184
 
3174
- /* The arrows are `fill: currentColor`, so transitioning `color` fades
3175
- them between the muted rest tone and the active `--bb-text`. */
3176
- .sort-icon__asc,
3177
- .sort-icon__desc {
3178
- transition: color var(--bb-transition-fast) var(--bb-ease);
3185
+ /* Preview: faint arrow while the header is hovered or focused. */
3186
+ &:hover .bb-table-sort__icon,
3187
+ &:focus-visible .bb-table-sort__icon {
3188
+ opacity: var(--arrow-preview);
3179
3189
  }
3180
3190
 
3181
- &:hover .bb-table-sort__icon,
3182
- &:focus-visible .bb-table-sort__icon,
3191
+ /* Active sort: solid, and the second direction flips the arrow. */
3183
3192
  &--asc .bb-table-sort__icon,
3184
- &--desc .bb-table-sort__icon {
3193
+ &--desc .bb-table-sort__icon,
3194
+ &--asc:hover .bb-table-sort__icon,
3195
+ &--desc:hover .bb-table-sort__icon {
3185
3196
  opacity: 1;
3186
3197
  }
3187
3198
 
3188
- &--asc .sort-icon__asc,
3189
- &--desc .sort-icon__desc {
3190
- color: var(--bb-text);
3199
+ &--desc .bb-table-sort__icon {
3200
+ transform: rotate(180deg);
3191
3201
  }
3192
3202
  }
3193
3203
 
3194
- tbody {
3195
- tr {
3196
- /* On the cells, not the row: the table renders with the separate border
3197
- model, where a `tr` border never paints. */
3198
- &:not(:last-child) > td {
3199
- border-bottom: var(--bb-border-w) solid var(--bb-border);
3200
- }
3201
- td {
3202
- color: var(--bb-text);
3203
- font-size: var(--bb-fs);
3204
- font-weight: 400;
3205
- height: var(--cell-h);
3206
- padding: var(--padding-y) var(--padding-x);
3207
- }
3204
+ /* Every cell of this table's own body rows (data, structural, skeleton,
3205
+ empty-state, expand, loading) — the `:scope >`-style child chain keeps a
3206
+ nested table's cells out. The row step is wrapped in `:where()` so these
3207
+ rules stay at (0,2,0): the `<table>` era's `.bb-table tbody tr td` was
3208
+ (0,1,3), and consumer overrides scoped to one class + a cell class that
3209
+ won then must keep winning now. */
3210
+ &__body > :where([role='row']) {
3211
+ /* Row separators on the cells, not the row, so a full-width cell and a
3212
+ data cell draw the same line. */
3213
+ &:where(:not(:last-child)) > [role='cell'] {
3214
+ border-bottom: var(--bb-border-w) solid var(--bb-border);
3215
+ }
3208
3216
 
3209
- td.bb-table-expand__cell {
3210
- /* Collapse to the content's height instead of inheriting the data-row
3211
- cell height, so an expand row with no slot content takes no space. */
3212
- height: auto;
3213
- padding: 0;
3217
+ > [role='cell'] {
3218
+ /* Centres like the UA `vertical-align: middle` of table cells. */
3219
+ align-content: center;
3220
+ color: var(--bb-text);
3221
+ font-size: var(--bb-fs);
3222
+ font-weight: 400;
3223
+ /* A minimum, as a `<td>` height always was: a grid box's `height` is a
3224
+ hard cap and would clip wrapped content. */
3225
+ min-height: var(--cell-h);
3226
+ padding: var(--padding-y) var(--padding-x);
3227
+ }
3214
3228
 
3215
- &:empty {
3216
- display: none;
3217
- }
3229
+ > .bb-table-expand__cell {
3230
+ /* Collapse to the content's height instead of inheriting the data-row
3231
+ cell height, so an expand row with no slot content takes no space. */
3232
+ min-height: 0;
3233
+ padding: 0;
3234
+
3235
+ &:empty {
3236
+ display: none;
3218
3237
  }
3219
3238
  }
3220
3239
  }
3221
3240
 
3241
+ /* Full-width cells span every track of their subgrid row. `contain:
3242
+ inline-size` is what keeps them from sizing the tracks: without it a
3243
+ spanning item's max-content contribution back-pressures the columns
3244
+ (300px tracks became 500px), and with a nested inheriting table inside
3245
+ that is a runaway loop. Containment holds the parent's tracks exactly;
3246
+ the content still overflows visibly, the ancestor scrollport still
3247
+ scrolls it, and sticky descendants keep working. Scoped to these three
3248
+ cells only — on every cell it would zero every intrinsic contribution. */
3249
+ &-expand__cell,
3250
+ &-no-data__cell,
3251
+ &-loading__cell {
3252
+ contain: inline-size;
3253
+ grid-column: 1 / -1;
3254
+ min-width: 0;
3255
+ }
3256
+
3222
3257
  /* Apply the structural edge offsets to the first/last *data* column. They
3223
3258
  must land on the data cells, not the absolute first/last child: a select
3224
3259
  or actions column would otherwise absorb the fill and leave the data
@@ -3228,15 +3263,15 @@ export type BbTableSlots<Item = any> = {
3228
3263
  reserved as extra edge padding, but a *negative* offset (a select/actions
3229
3264
  column reclaimed from this column's width) must not eat into the normal
3230
3265
  `--padding-x` — that only shrinks the column width, not its inner padding. */
3231
- thead tr > th.bb-table-header--edge-start,
3232
- tbody tr > td.bb-table-data__cell--edge-start {
3266
+ &__head > [role='row'] > .bb-table-header--edge-start,
3267
+ &__body > [role='row'] > .bb-table-data__cell--edge-start {
3233
3268
  padding-inline-start: calc(
3234
3269
  var(--padding-x) + max(0px, var(--offset-internal-start))
3235
3270
  );
3236
3271
  }
3237
3272
 
3238
- thead tr > th.bb-table-header--edge-end,
3239
- tbody tr > td.bb-table-data__cell--edge-end {
3273
+ &__head > [role='row'] > .bb-table-header--edge-end,
3274
+ &__body > [role='row'] > .bb-table-data__cell--edge-end {
3240
3275
  padding-inline-end: calc(
3241
3276
  var(--padding-x) + max(0px, var(--offset-internal-end))
3242
3277
  );
@@ -3245,8 +3280,8 @@ export type BbTableSlots<Item = any> = {
3245
3280
  &.bb-table--loading {
3246
3281
  .bb-table-loading__row {
3247
3282
  .bb-table-loading__cell {
3283
+ align-content: start;
3248
3284
  padding: 0 var(--padding-x) !important;
3249
- vertical-align: top;
3250
3285
  }
3251
3286
  }
3252
3287
 
@@ -3254,6 +3289,7 @@ export type BbTableSlots<Item = any> = {
3254
3289
  .bb-table-loading__cell {
3255
3290
  border: none;
3256
3291
  height: 0;
3292
+ min-height: 0;
3257
3293
  overflow: hidden;
3258
3294
  padding: 0 !important;
3259
3295
  }
@@ -3262,15 +3298,17 @@ export type BbTableSlots<Item = any> = {
3262
3298
 
3263
3299
  &-skeleton {
3264
3300
  &__cell {
3265
- height: var(--cell-h);
3266
- overflow: hidden;
3301
+ align-content: center;
3302
+ min-height: var(--cell-h);
3303
+ /* `clip`, not `hidden`: a scroll container zeroes a grid item's
3304
+ automatic minimum, which let skeleton tracks compress below the
3305
+ shimmer's width and cut it flush at the column border. */
3306
+ overflow: clip;
3267
3307
  padding: var(--padding-y) var(--padding-x);
3268
- vertical-align: middle;
3269
3308
 
3270
3309
  &--select,
3271
3310
  &--actions {
3272
3311
  text-align: center;
3273
- width: 0;
3274
3312
  }
3275
3313
  }
3276
3314
 
@@ -3375,9 +3413,9 @@ export type BbTableSlots<Item = any> = {
3375
3413
  centres itself regardless of the table's column alignment. */
3376
3414
  &-no-data {
3377
3415
  &__cell {
3416
+ align-content: center;
3378
3417
  padding: var(--padding-y) var(--padding-x);
3379
3418
  text-align: center;
3380
- vertical-align: middle;
3381
3419
  }
3382
3420
 
3383
3421
  &__text {
@@ -3387,15 +3425,20 @@ export type BbTableSlots<Item = any> = {
3387
3425
 
3388
3426
  &-data {
3389
3427
  &__row {
3428
+ /* Keep a row on one printed page. */
3429
+ break-inside: avoid;
3390
3430
  transition: background-color var(--bb-transition-fast) var(--bb-ease);
3391
3431
 
3432
+ /* Row states publish their fill as `--row-bg` too, so pinned
3433
+ (sticky) cells — which must be opaque — paint the same colour. */
3392
3434
  &:hover {
3393
- background-color: var(--bb-surface-hover);
3435
+ --row-bg: var(--bb-surface-hover);
3436
+ background-color: var(--row-bg);
3394
3437
  }
3395
3438
 
3396
3439
  /* Focusable only with `keyboardNavigation`; the ring is mandatory
3397
- focus indication, not decoration. Inset outline (not box-shadow) —
3398
- it paints reliably on table rows. */
3440
+ focus indication, not decoration. Inset outline so it never
3441
+ escapes the scrollport. */
3399
3442
  &:focus-visible {
3400
3443
  outline: var(--bb-ring-size) solid var(--bb-ring);
3401
3444
  outline-offset: calc(var(--bb-ring-size) * -1);
@@ -3413,19 +3456,18 @@ export type BbTableSlots<Item = any> = {
3413
3456
  while the pointer is over it. Consumers can still override the whole
3414
3457
  look via `row-class`. */
3415
3458
  &--highlighted {
3416
- background-color: color-mix(
3417
- in oklab,
3418
- var(--bb-primary) 8%,
3419
- var(--bb-panel)
3420
- );
3421
- box-shadow: inset 3px 0 0 0 var(--bb-primary);
3459
+ --row-bg: color-mix(in oklab, var(--bb-primary) 8%, var(--bg));
3460
+ background-color: var(--row-bg);
3461
+
3462
+ /* The accent bar sits on the first CELL, not the row: a pinned
3463
+ (opaque) first column would otherwise cover it. Same position
3464
+ either way — the first cell is the row's left edge. */
3465
+ > :first-child {
3466
+ box-shadow: inset 3px 0 0 0 var(--bb-primary);
3467
+ }
3422
3468
 
3423
3469
  &:hover {
3424
- background-color: color-mix(
3425
- in oklab,
3426
- var(--bb-primary) 15%,
3427
- var(--bb-panel)
3428
- );
3470
+ --row-bg: color-mix(in oklab, var(--bb-primary) 15%, var(--bg));
3429
3471
  }
3430
3472
  }
3431
3473
  }
@@ -3443,6 +3485,14 @@ export type BbTableSlots<Item = any> = {
3443
3485
 
3444
3486
  &__cell {
3445
3487
  &--actions {
3488
+ /* Controls sit at the row's end. Invisible on a root table (the
3489
+ track IS the content width) — it matters in a nested table, whose
3490
+ actions track is widened to the parent's region so the two cells
3491
+ are equal and the controls line up under the parent's. */
3492
+ text-align: end;
3493
+ /* Track-sizing-critical: the actions track is `max-content`, and
3494
+ `nowrap` is what makes that the width of the controls laid side by
3495
+ side rather than a wrapped stack. */
3446
3496
  white-space: nowrap;
3447
3497
  > *:not(:first-child) {
3448
3498
  margin-left: var(--actions-spacing);
@@ -3481,23 +3531,23 @@ export type BbTableSlots<Item = any> = {
3481
3531
  dependency change (a filter, a poll, an autosave round-trip). Keep the rows
3482
3532
  on screen, mark them stale, and run an indeterminate bar along the header/body
3483
3533
  boundary. `aria-busy` on the root already carries the semantics and the
3484
- sr-only row in `<tbody>` still announces, so the bar is decorative — hence a
3534
+ sr-only row in the body still announces, so the bar is decorative — hence a
3485
3535
  pseudo-element rather than a node. `.bb-table--empty` is "nothing to keep". */
3486
3536
  .bb-table--loading:not(.bb-table--empty) {
3487
- & tbody {
3537
+ & .bb-table__body {
3488
3538
  opacity: 0.55;
3489
3539
  transition: opacity var(--bb-transition-fast) ease;
3490
3540
  }
3491
3541
 
3492
- /* Anchors the bar. When `fixed-header` is on, thead is already sticky and
3493
- the bar rides along with it. */
3494
- & thead {
3495
- position: relative;
3496
- }
3542
+ /* Anchored to the head rowgroup (positioned above). With `fixed-header`
3543
+ the head is sticky, so the bar rides along with the stuck band.
3497
3544
 
3498
- /* Animating background-position repaints a 2px strip instead of laying out
3499
- a moving node every frame, and needs no wrapper to clip it. */
3500
- & thead::after {
3545
+ MUST stay absolutely positioned: on a subgrid box a non-positioned
3546
+ pseudo-element becomes a grid item and inserts a phantom row (the header
3547
+ row doubled in height). Animating background-position repaints a 2px
3548
+ strip instead of laying out a moving node every frame, and needs no
3549
+ wrapper to clip it. */
3550
+ & .bb-table__head::after {
3501
3551
  animation: bb-table-busy-slide 1.1s linear infinite;
3502
3552
  background: linear-gradient(
3503
3553
  90deg,
@@ -3528,7 +3578,7 @@ export type BbTableSlots<Item = any> = {
3528
3578
  }
3529
3579
 
3530
3580
  @media (prefers-reduced-motion: reduce) {
3531
- .bb-table--loading:not(.bb-table--empty) thead::after {
3581
+ .bb-table--loading:not(.bb-table--empty) .bb-table__head::after {
3532
3582
  animation: none;
3533
3583
  background: var(--bb-primary);
3534
3584
  opacity: 0.5;