bitboss-ui 2.1.127 → 2.1.128

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.
@@ -6,10 +6,13 @@
6
6
  <template>
7
7
  <component
8
8
  :is="containerTag"
9
+ ref="containerRef"
9
10
  :aria-busy="loading"
10
11
  :aria-rowcount="hasProvidedAccessibilityData ? props.totalItems : undefined"
11
12
  :class="containerClass"
13
+ :data-bb-table-id="tableId"
12
14
  :inert="disabled"
15
+ :style="containerStyle"
13
16
  >
14
17
  <legend v-if="selectable" class="sr-only">{{ legend }}</legend>
15
18
  <table ref="tableRef">
@@ -24,6 +27,13 @@
24
27
  caption
25
28
  }}
26
29
  </caption>
30
+ <colgroup>
31
+ <col
32
+ v-for="track in columnTracks"
33
+ :key="track.key"
34
+ :style="{ width: track.width }"
35
+ />
36
+ </colgroup>
27
37
  <thead>
28
38
  <!-- @vue-ignore -->
29
39
  <slot name="thead">
@@ -373,7 +383,16 @@
373
383
  lang="ts"
374
384
  generic="Item = any, T extends ItemKey<Item> = string & {}"
375
385
  >
376
- import { computed, reactive, ref, toRef, watch } from 'vue';
386
+ import {
387
+ computed,
388
+ nextTick,
389
+ onBeforeUnmount,
390
+ onMounted,
391
+ reactive,
392
+ ref,
393
+ toRef,
394
+ watch,
395
+ } from 'vue';
377
396
  import { clamp } from '@/utilities/functions/clamp';
378
397
  import { hash } from '@/utilities/functions/hash';
379
398
  import { isNil } from '@/utilities/functions/isNil';
@@ -385,6 +404,7 @@ import { usePrefill } from '@/composables/usePrefill';
385
404
  import { useIndexById } from '@/composables/useIndexById';
386
405
  import { useBaseOptions } from '@/composables/useBaseOptions';
387
406
  import { useHashedWatcher } from '@/composables/useHashedWatcher';
407
+ import { useTableWidthContext } from '@/composables/useTableWidthContext';
388
408
  import { useLocale } from '@/composables/useLocale';
389
409
  import BaseCheckbox from '../BaseCheckbox/BaseCheckbox.vue';
390
410
  import BaseRadio from '../BaseRadio/BaseRadio.vue';
@@ -437,6 +457,7 @@ const props = withDefaults(defineProps<InternalProps<T>>(), {
437
457
  max: Infinity,
438
458
  multiple: true,
439
459
  modelValue: () => [],
460
+ inheritColumnWidths: true,
440
461
  unselectedItems: () => [],
441
462
  });
442
463
 
@@ -476,6 +497,524 @@ if (props.multiple && !Array.isArray(props.modelValue)) {
476
497
  const { t } = useLocale();
477
498
 
478
499
  const tableRef = ref<HTMLTableElement>();
500
+ const containerRef = ref<HTMLElement>();
501
+
502
+ const {
503
+ id: tableId,
504
+ parentId,
505
+ parentNode,
506
+ cssVars,
507
+ parentVar,
508
+ setTracks,
509
+ } = useTableWidthContext({
510
+ id: () => props.id,
511
+ container: () => containerRef.value,
512
+ });
513
+
514
+ const inheritColumnWidths = computed(() => props.inheritColumnWidths !== false);
515
+
516
+ /**
517
+ * Assigned column widths (frozen or inherited) are only authoritative under
518
+ * `table-layout: fixed`; in auto layout they behave like a minimum. So a table
519
+ * that drives widths — explicitly `fixed`, inheriting from a parent, or with a
520
+ * frozen column — uses fixed layout even when `fixed` was not set.
521
+ */
522
+ const fixedLayout = computed(
523
+ () =>
524
+ props.fixed ||
525
+ props.columns.some((column) => column.width != null) ||
526
+ (inheritColumnWidths.value && !!parentId.value)
527
+ );
528
+
529
+ /**
530
+ * A nested table inheriting its parent's widths must size itself to the sum of
531
+ * those columns rather than stretching to fill its container — otherwise fixed
532
+ * layout would distribute the slack and the columns would no longer match the
533
+ * parent. Tables with the same column count already sum to the full width, so
534
+ * this is a no-op for them and only narrows tables with fewer columns.
535
+ */
536
+ const sizeToColumns = computed(
537
+ () => inheritColumnWidths.value && !!parentId.value
538
+ );
539
+
540
+ /** Numbers and bare numeric strings (e.g. `400`, `'400'`) default to px; values
541
+ * that already carry a unit/keyword (`'50%'`, `'10rem'`, `'auto'`) pass through. */
542
+ const toLength = (width: number | string) => {
543
+ if (typeof width === 'number') return `${width}px`;
544
+ const trimmed = width.trim();
545
+ return /^-?\d*\.?\d+$/.test(trimmed) ? `${trimmed}px` : trimmed;
546
+ };
547
+
548
+ /** Number of data columns the parent renders (numeric track keys). */
549
+ const parentDataColumns = computed(() => {
550
+ const tracks = parentNode.value?.tracks;
551
+ return tracks
552
+ ? Object.keys(tracks).filter((key) => /^\d+$/.test(key)).length
553
+ : 0;
554
+ });
555
+
556
+ /**
557
+ * The parent's rendered columns in order — `select` (if any), data columns
558
+ * `0…N-1`, then `actions` (if any) — form a gridline coordinate, one cell per
559
+ * column. Cell `c` is the range `[c, c + 1]`. So a no-select parent starts its
560
+ * data at cell 0, while a selectable parent starts it at cell 1.
561
+ */
562
+ const parentColumnKeys = computed(() => {
563
+ const tracks = parentNode.value?.tracks ?? {};
564
+ const keys: string[] = [];
565
+ if ('select' in tracks) keys.push('select');
566
+ for (let k = 0; k < parentDataColumns.value; k++) keys.push(String(k));
567
+ if ('actions' in tracks) keys.push('actions');
568
+ return keys;
569
+ });
570
+
571
+ /** Gridline cells where the parent's data region begins/ends and the table ends. */
572
+ const firstDataCell = computed(() =>
573
+ 'select' in (parentNode.value?.tracks ?? {}) ? 1 : 0
574
+ );
575
+ const dataEndCell = computed(
576
+ () => firstDataCell.value + parentDataColumns.value
577
+ );
578
+ const tableEndCell = computed(() => parentColumnKeys.value.length);
579
+
580
+ /** Parent track custom property for the integer cell `[cell, cell + 1]`. */
581
+ const cellTrackVar = (cell: number) => {
582
+ const key = parentColumnKeys.value[cell];
583
+ return key ? parentVar(key, '0px') : '0px';
584
+ };
585
+
586
+ /** CSS length summing the parent tracks across the gridline range `[a, b]`. */
587
+ const gridRangeWidth = (a: number, b: number): string | null => {
588
+ if (!parentId.value || b - a <= 1e-6) return null;
589
+ const parts: string[] = [];
590
+ for (let cell = Math.floor(a); cell < Math.ceil(b - 1e-6); cell++) {
591
+ const fraction = Math.min(b, cell + 1) - Math.max(a, cell);
592
+ if (fraction <= 1e-6) continue;
593
+ const track = cellTrackVar(cell);
594
+ parts.push(
595
+ fraction >= 1 - 1e-6 ? track : `${Number(fraction.toFixed(4))} * ${track}`
596
+ );
597
+ }
598
+ if (!parts.length) return null;
599
+ // A bare `var()` can stand alone, but a single fractional term still needs a
600
+ // `calc()` wrapper for the multiplication to be valid CSS.
601
+ if (parts.length === 1 && !parts[0].includes('*')) return parts[0];
602
+ return `calc(${parts.join(' + ')})`;
603
+ };
604
+
605
+ /**
606
+ * Resolved parent snap interval for each data column.
607
+ * - A number snaps to that start point; the column then runs up to the next
608
+ * column's snap point (or one whole track if the next is unset). So a single
609
+ * snap that skips ahead widens this column to cover the gap — `snap: 4` on the
610
+ * second column makes the first effectively `[start, 4]`.
611
+ * - A `[start, end]` pair is taken verbatim (`end` of `-1` runs to the end of
612
+ * the data region).
613
+ * - Omitted, the column chains from the previous column's end.
614
+ */
615
+ const columnIntervals = computed(() => {
616
+ const cols = props.columns;
617
+ // Explicit start points; a later column's snap sets the previous column's end.
618
+ const starts = cols.map((column) =>
619
+ typeof column.snap === 'number'
620
+ ? column.snap
621
+ : Array.isArray(column.snap)
622
+ ? column.snap[0]
623
+ : null
624
+ );
625
+ let cursor = firstDataCell.value;
626
+ return cols.map((column, i) => {
627
+ const start = starts[i] ?? cursor;
628
+ let end: number;
629
+ if (Array.isArray(column.snap)) {
630
+ end = column.snap[1] === -1 ? dataEndCell.value : column.snap[1];
631
+ } else {
632
+ const next = starts[i + 1];
633
+ end = next != null && next > start ? next : Math.floor(start) + 1;
634
+ }
635
+ cursor = end;
636
+ return { start, end };
637
+ });
638
+ });
639
+
640
+ // Parent columns left of the first column (e.g. a select this table doesn't
641
+ // render, or columns skipped by the first snap) and those right of the last are
642
+ // folded into the edge columns as the structural offset, keeping the table
643
+ // aligned to the parent. `offsetStartValue` becomes the first column's padding.
644
+ const offsetStartValue = computed(() => {
645
+ const first = columnIntervals.value[0];
646
+ if (!inheritColumnWidths.value || !first) return null;
647
+ // Skip the parent's select cell only when this table renders its own.
648
+ const lead = props.selectable && firstDataCell.value === 1 ? 1 : 0;
649
+ return gridRangeWidth(lead, first.start);
650
+ });
651
+ const offsetEndValue = computed(() => {
652
+ const intervals = columnIntervals.value;
653
+ const last = intervals[intervals.length - 1];
654
+ if (!inheritColumnWidths.value || !last) return null;
655
+ const hasActions = 'actions' in (parentNode.value?.tracks ?? {});
656
+ const end =
657
+ props.actions && hasActions ? tableEndCell.value - 1 : tableEndCell.value;
658
+ return gridRangeWidth(last.end, end);
659
+ });
660
+ const offsetStartActive = computed(() => offsetStartValue.value !== null);
661
+ const offsetEndActive = computed(() => offsetEndValue.value !== null);
662
+
663
+ /**
664
+ * Measured intrinsic content widths of the structural columns. In a fixed table
665
+ * these pin `select`/`actions` to their content so the remaining space is shared
666
+ * among the data columns; `null` falls back to the regular auto behavior.
667
+ */
668
+ const structuralFit = reactive<{
669
+ select: string | null;
670
+ actions: string | null;
671
+ }>({ select: null, actions: null });
672
+
673
+ /**
674
+ * Rendered columns in DOM order, each with the track key it publishes/inherits
675
+ * and the width to apply via `<colgroup>`. A frozen column width wins; otherwise
676
+ * an unfrozen column syncs to the parent's matching track (or `auto` at root).
677
+ * The first/last column additionally absorbs the structural edge offset.
678
+ */
679
+ const columnTracks = computed(() => {
680
+ const sync = (key: string) =>
681
+ inheritColumnWidths.value ? parentVar(key) : 'auto';
682
+ // In fixed layout `auto` columns share width equally, so pin the structural
683
+ // columns to their measured content width and let the data columns share.
684
+ const structural = (key: 'select' | 'actions') =>
685
+ fixedLayout.value && structuralFit[key]
686
+ ? (structuralFit[key] as string)
687
+ : sync(key);
688
+ const tracks: { key: string; width: string }[] = [];
689
+
690
+ if (props.selectable) {
691
+ tracks.push({ key: 'select', width: structural('select') });
692
+ }
693
+
694
+ const lastIndex = props.columns.length - 1;
695
+ props.columns.forEach((column, index) => {
696
+ const key = String(index);
697
+ const frozen = column.width != null ? toLength(column.width) : null;
698
+ // For auto columns in fixed layout, use the proportional width derived
699
+ // from the offscreen probe so each column is pinned to a precise value
700
+ // rather than relying on equal-share distribution (which ignores natural
701
+ // content widths and can under-allocate to wider auto columns).
702
+ const probeWidth =
703
+ frozen == null && fixedLayout.value
704
+ ? (probeColWidths.value[index] ?? null)
705
+ : null;
706
+ // The column's own width is the gridline range it spans of the parent.
707
+ const interval = columnIntervals.value[index];
708
+ const span =
709
+ frozen ?? probeWidth ?? gridRangeWidth(interval.start, interval.end);
710
+
711
+ const offsets: string[] = [];
712
+ if (index === 0 && offsetStartActive.value) {
713
+ offsets.push('var(--bb-table-offset-internal-start)');
714
+ }
715
+ if (index === lastIndex && offsetEndActive.value) {
716
+ offsets.push('var(--bb-table-offset-internal-end)');
717
+ }
718
+
719
+ // When folding an offset, use a calc-safe length base (no `auto`) so the
720
+ // column width = offset(s) + the column's own span.
721
+ const width = offsets.length
722
+ ? `calc(${[...offsets, span ?? '0px'].join(' + ')})`
723
+ : (span ?? 'auto');
724
+
725
+ tracks.push({ key, width });
726
+ });
727
+
728
+ if (props.actions) {
729
+ tracks.push({ key: 'actions', width: structural('actions') });
730
+ }
731
+
732
+ return tracks;
733
+ });
734
+
735
+ /**
736
+ * Standalone edge-offset custom properties consumed by the first/last cell
737
+ * padding and the first/last column width. Set only when an offset is active;
738
+ * otherwise the CSS default of `0px` makes both calcs collapse to their base.
739
+ */
740
+ const structuralOffset = computed<Record<string, string>>(() => {
741
+ const style: Record<string, string> = {};
742
+ if (offsetStartValue.value) {
743
+ style['--bb-table-offset-start'] = offsetStartValue.value;
744
+ }
745
+ if (offsetEndValue.value) {
746
+ style['--bb-table-offset-end'] = offsetEndValue.value;
747
+ }
748
+ return style;
749
+ });
750
+
751
+ const containerStyle = computed(() => {
752
+ const vars: Record<string, string> = {
753
+ ...cssVars.value,
754
+ ...structuralOffset.value,
755
+ };
756
+
757
+ if (naturalTotalWidth.value) {
758
+ vars['--bb-table-natural-width'] = naturalTotalWidth.value;
759
+ }
760
+
761
+ return vars;
762
+ });
763
+
764
+ /** Reads the header cells in render order: [select?, ...data, actions?]. */
765
+ const headerCells = () =>
766
+ Array.from(
767
+ tableRef.value?.querySelectorAll<HTMLElement>(
768
+ ':scope > thead > tr.bb-table-header-row > th'
769
+ ) ?? []
770
+ );
771
+
772
+ /** Measures the rendered columns and publishes them as this table's tracks. */
773
+ const measureTracks = () => {
774
+ const keys = columnTracks.value.map((track) => track.key);
775
+ const tracks: Record<string, string> = {};
776
+ headerCells().forEach((cell, index) => {
777
+ const key = keys[index];
778
+ if (key != null) tracks[key] = `${cell.getBoundingClientRect().width}px`;
779
+ });
780
+ setTracks(tracks);
781
+ };
782
+
783
+ /**
784
+ * Measures the intrinsic content width of the structural columns across header
785
+ * and body. Fixed layout sizes columns from the header row only, so the body is
786
+ * required here: the actions header is `sr-only`, while the real control lives
787
+ * in the body. The measured child width is independent of the column width, so
788
+ * pinning the column to it converges instead of oscillating.
789
+ */
790
+ const measureStructuralFit = () => {
791
+ const table = tableRef.value;
792
+ if (!table) return;
793
+
794
+ const fit = (selector: string) => {
795
+ let max = 0;
796
+ table.querySelectorAll<HTMLElement>(selector).forEach((cell) => {
797
+ let content = 0;
798
+ cell.querySelectorAll<HTMLElement>(':scope > *').forEach((child) => {
799
+ content = Math.max(content, child.getBoundingClientRect().width);
800
+ });
801
+ const style = getComputedStyle(cell);
802
+ const padding =
803
+ parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
804
+ max = Math.max(max, content + padding);
805
+ });
806
+ return max > 0 ? `${Math.ceil(max)}px` : null;
807
+ };
808
+
809
+ structuralFit.select = props.selectable
810
+ ? fit('.bb-table-header--select, .bb-table-data__cell--select')
811
+ : null;
812
+ structuralFit.actions = props.actions
813
+ ? fit('.bb-table-header--actions, .bb-table__cell--actions')
814
+ : null;
815
+ };
816
+
817
+ const measure = () => {
818
+ measureStructuralFit();
819
+ measureTracks();
820
+ };
821
+
822
+ /**
823
+ * Proportional `<col>` widths derived from an auto-layout probe, one entry per
824
+ * data column (`null` for frozen columns — they keep their declared width).
825
+ *
826
+ * For each auto column the formula is:
827
+ * width = max(natural, available × (natural / autoTotal))
828
+ *
829
+ * where `available` = containerWidth − frozenColumnWidths − structuralColumnWidths
830
+ * and `autoTotal` = sum of auto-column natural widths.
831
+ *
832
+ * This gives proportional growth when the container is wide (auto columns scale
833
+ * relative to each other) and a hard natural-width floor when it is narrow (the
834
+ * table-level `min-width` then triggers horizontal overflow instead of squishing).
835
+ *
836
+ * Re-measured after any DOM mutation (debounced 200 ms) and after any container
837
+ * resize (via a dedicated ResizeObserver on the container element).
838
+ */
839
+ const probeColWidths = ref<(string | null)[]>([]);
840
+
841
+ /**
842
+ * Total natural minimum width of the table (all columns, including structural).
843
+ * Published as `--bb-table-natural-width` and used as `min-width` on the
844
+ * `<table>` element so fixed-layout compression never squeezes content below its
845
+ * natural size.
846
+ */
847
+ const naturalTotalWidth = ref<string | null>(null);
848
+
849
+ const runNaturalWidthProbe = () => {
850
+ const container = containerRef.value;
851
+ if (!container || !fixedLayout.value) return;
852
+ if (!props.columns.some((col) => col.width != null)) return;
853
+
854
+ const containerWidth = container.offsetWidth;
855
+ const dataOffset = props.selectable ? 1 : 0;
856
+
857
+ // ── Real-table measurements ────────────────────────────────────────────
858
+ // Structural column widths come from measureStructuralFit (already run).
859
+ const selectWidth = structuralFit.select
860
+ ? parseFloat(structuralFit.select)
861
+ : 0;
862
+ const actionsWidth = structuralFit.actions
863
+ ? parseFloat(structuralFit.actions)
864
+ : 0;
865
+
866
+ // Frozen column widths from the live fixed-layout table — getBoundingClientRect
867
+ // returns the exact px value the browser computed from the declared CSS length,
868
+ // so no unit parsing is needed and viewport-relative units work correctly.
869
+ const realThs = headerCells();
870
+ let frozenTotal = 0;
871
+ props.columns.forEach((col, i) => {
872
+ if (col.width != null) {
873
+ frozenTotal +=
874
+ realThs[dataOffset + i]?.getBoundingClientRect().width ?? 0;
875
+ }
876
+ });
877
+
878
+ // ── Offscreen probe for natural (max-content) auto-column widths ───────
879
+ // The clone's inner table is forced to `width: max-content` and all <col>
880
+ // hints are stripped so each <th> reports its true content-driven width,
881
+ // independent of the container size or previous probe cycles.
882
+ const probe = document.createElement('div');
883
+ probe.style.cssText = [
884
+ 'position:absolute',
885
+ 'top:-9999px',
886
+ 'left:-9999px',
887
+ 'visibility:hidden',
888
+ 'pointer-events:none',
889
+ `width:${containerWidth}px`,
890
+ `height:${container.offsetHeight}px`,
891
+ 'overflow:visible',
892
+ ].join(';');
893
+
894
+ const clone = container.cloneNode(true) as HTMLElement;
895
+ clone.classList.remove('bb-table--fixed');
896
+
897
+ // Frozen <col> hints are kept intact so auto columns are probed in the
898
+ // correct spatial context (a 400 px frozen column still occupies 400 px,
899
+ // giving neighbouring auto columns the right available space to size against).
900
+ // Only auto column hints are cleared so previous probe cycles cannot inflate
901
+ // their measured natural widths.
902
+ const probeColEls = Array.from(clone.querySelectorAll<HTMLElement>('col'));
903
+ props.columns.forEach((col, i) => {
904
+ if (col.width == null) {
905
+ const probeCol = probeColEls[dataOffset + i];
906
+ if (probeCol) probeCol.style.width = '';
907
+ }
908
+ });
909
+
910
+ const innerTable = clone.querySelector<HTMLElement>('table');
911
+ if (innerTable) {
912
+ innerTable.style.width = 'max-content';
913
+ innerTable.style.minWidth = '0';
914
+ }
915
+
916
+ probe.appendChild(clone);
917
+ document.body.appendChild(probe);
918
+
919
+ const headerRow = probe.querySelector<HTMLElement>(
920
+ 'table thead tr.bb-table-header-row'
921
+ );
922
+ const ths = headerRow
923
+ ? Array.from(headerRow.querySelectorAll<HTMLElement>(':scope > th'))
924
+ : [];
925
+
926
+ // Only auto columns — frozen columns use their declared widths from above.
927
+ const autoNaturals = props.columns.map((col, i) => {
928
+ if (col.width != null) return 0;
929
+ const th = ths[dataOffset + i];
930
+ return th ? th.getBoundingClientRect().width : 0;
931
+ });
932
+
933
+ document.body.removeChild(probe);
934
+
935
+ // ── Proportional <col> widths ──────────────────────────────────────────
936
+ const autoNaturalTotal = autoNaturals.reduce((s, w) => s + w, 0);
937
+ const available = containerWidth - frozenTotal - selectWidth - actionsWidth;
938
+
939
+ // Scale each auto column proportionally to available space, but never
940
+ // below its natural content width so content is never squished.
941
+ probeColWidths.value = props.columns.map((col, i) => {
942
+ if (col.width != null) return null;
943
+ const natural = autoNaturals[i];
944
+ if (autoNaturalTotal <= 0) return `${Math.ceil(natural)}px`;
945
+ const share = available > 0 ? available * (natural / autoNaturalTotal) : 0;
946
+ return `${Math.ceil(Math.max(natural, share))}px`;
947
+ });
948
+
949
+ // Table min-width = real frozen widths + natural auto widths + structural.
950
+ // Using real frozen widths (not probe) ensures declared px/rem/% lengths
951
+ // are respected rather than the probe's stripped-hint approximation.
952
+ const naturalTotal =
953
+ frozenTotal + autoNaturalTotal + selectWidth + actionsWidth;
954
+ naturalTotalWidth.value =
955
+ naturalTotal > 0 ? `${Math.ceil(naturalTotal)}px` : null;
956
+ };
957
+
958
+ let naturalWidthDebounceTimer: ReturnType<typeof setTimeout> | null = null;
959
+ let mutationObserver: MutationObserver | undefined;
960
+
961
+ /** Invalidates the cached natural width and schedules a fresh probe. */
962
+ const scheduleNaturalWidthProbe = () => {
963
+ if (naturalWidthDebounceTimer !== null)
964
+ clearTimeout(naturalWidthDebounceTimer);
965
+ naturalWidthDebounceTimer = setTimeout(() => {
966
+ naturalWidthDebounceTimer = null;
967
+ runNaturalWidthProbe();
968
+ }, 200);
969
+ };
970
+
971
+ let resizeObserver: ResizeObserver | undefined;
972
+ /** Observes the container element specifically for size changes so the
973
+ * proportional <col> widths are recomputed whenever the container grows or
974
+ * shrinks — separately from the header-cell observer that drives measureTracks. */
975
+ let containerResizeObserver: ResizeObserver | undefined;
976
+
977
+ const observeColumns = () => {
978
+ if (!resizeObserver) return;
979
+ resizeObserver.disconnect();
980
+ headerCells().forEach((cell) => resizeObserver?.observe(cell));
981
+ measure();
982
+ };
983
+
984
+ onMounted(() => {
985
+ resizeObserver = new ResizeObserver(() => measure());
986
+ observeColumns();
987
+
988
+ // Initial probe runs after the first render so all cells have content.
989
+ nextTick(runNaturalWidthProbe);
990
+
991
+ // Invalidate the cache whenever rows or cell content change.
992
+ if (tableRef.value) {
993
+ mutationObserver = new MutationObserver(scheduleNaturalWidthProbe);
994
+ mutationObserver.observe(tableRef.value, {
995
+ childList: true,
996
+ subtree: true,
997
+ characterData: true,
998
+ });
999
+ }
1000
+
1001
+ // Re-probe whenever the container itself is resized so that proportional
1002
+ // <col> widths stay accurate as the available space changes.
1003
+ if (containerRef.value) {
1004
+ containerResizeObserver = new ResizeObserver(scheduleNaturalWidthProbe);
1005
+ containerResizeObserver.observe(containerRef.value);
1006
+ }
1007
+ });
1008
+
1009
+ onBeforeUnmount(() => {
1010
+ resizeObserver?.disconnect();
1011
+ containerResizeObserver?.disconnect();
1012
+ mutationObserver?.disconnect();
1013
+ if (naturalWidthDebounceTimer !== null)
1014
+ clearTimeout(naturalWidthDebounceTimer);
1015
+ });
1016
+
1017
+ watch(columnTracks, () => nextTick(observeColumns), { flush: 'post' });
479
1018
 
480
1019
  /** Builds accessible label for selection controls from row content. */
481
1020
  const getAccessibleLabel = (columns: MappedCell[]) =>
@@ -493,7 +1032,8 @@ const containerClass = computed(() => ({
493
1032
  'bb-table': true,
494
1033
  [`bb-table--align-${props.align}`]: true,
495
1034
  'bb-table--compact': props.compact,
496
- 'bb-table--fixed': props.fixed,
1035
+ 'bb-table--fixed': fixedLayout.value,
1036
+ 'bb-table--inherit-widths': sizeToColumns.value,
497
1037
  'bb-table--fixed-header': props.fixedHeaders,
498
1038
  'bb-table--loading': loading.value,
499
1039
  'bb-table--empty': !options.value.length,
@@ -611,6 +1151,7 @@ const mappedHeaders = computed(() =>
611
1151
  ? 'ascending'
612
1152
  : 'descending'
613
1153
  : undefined;
1154
+
614
1155
  return {
615
1156
  align,
616
1157
  key: column.key,
@@ -919,6 +1460,30 @@ export type BbTableColumn<Item = any> = BaseColumn<Item> & {
919
1460
  * Defines the classes to be passed to the `<th>`.
920
1461
  */
921
1462
  thClass?: Classes;
1463
+
1464
+ /**
1465
+ * Freezes the column to a fixed width (numbers are treated as px). When
1466
+ * omitted the column syncs its width to the parent table's matching track.
1467
+ */
1468
+ width?: number | string;
1469
+
1470
+ /**
1471
+ * For a nested table, where this column snaps onto the parent's grid. The
1472
+ * parent's columns form snap points where `select` occupies `[0, 1]`, data
1473
+ * column `k` occupies `[k + 1, k + 2]` and `actions` occupies `[N + 1, N + 2]`.
1474
+ *
1475
+ * - A single number is a start snap point that takes one parent track, e.g.
1476
+ * `2` → `[2, 3]`. It may skip ahead (`5` jumps straight to the sixth track),
1477
+ * and the next column resumes from where it ends.
1478
+ * - A `[start, end]` pair snaps between two points; the width is the sum of
1479
+ * the fractional parent tracks it covers, so `[3, 4.5]` is the full fourth
1480
+ * track plus half of the fifth. `end` of `-1` runs to the end of the data
1481
+ * region.
1482
+ *
1483
+ * When omitted the column chains from the previous column's end to the next
1484
+ * whole snap point (a one-to-one inheritance by index).
1485
+ */
1486
+ snap?: number | [start: number, end: number];
922
1487
  };
923
1488
 
924
1489
  export type BbTableProps<Item = any> = {
@@ -943,6 +1508,18 @@ export type BbTableProps<Item = any> = {
943
1508
  */
944
1509
  align?: 'left' | 'center' | 'right';
945
1510
 
1511
+ /**
1512
+ * Stable id for this table's width context. When omitted a unique id is
1513
+ * generated. Nested tables use the nearest ancestor id to inherit widths.
1514
+ */
1515
+ id?: string;
1516
+
1517
+ /**
1518
+ * When `true` (the default) unfrozen columns inherit their width from the
1519
+ * parent table's matching track, accounting for `select`/`actions` columns.
1520
+ */
1521
+ inheritColumnWidths?: boolean;
1522
+
946
1523
  /**
947
1524
  * Boolean that defines whether to display a "Select all" checkbox.
948
1525
  */
@@ -1291,10 +1868,24 @@ export type BbTableSlots<Item = any> = {
1291
1868
  --padding-x: 16px;
1292
1869
  --padding-y: 8px;
1293
1870
  --actions-spacing: 8px;
1871
+ /* Raw structural edge offset measured by the component (the parent's
1872
+ select/actions track width). Default 0 keeps every calc at its base. */
1873
+ --bb-table-offset-start: 0px;
1874
+ --bb-table-offset-end: 0px;
1875
+ /* Routing of that offset. By default it is "internal": added to the
1876
+ first/last cell padding and folded into the first/last column width.
1877
+ Swap internal↔external (set internal to 0 and external to the raw offset)
1878
+ to pad the whole table instead of the edge cells. */
1879
+ --bb-table-offset-internal-start: var(--bb-table-offset-start);
1880
+ --bb-table-offset-internal-end: var(--bb-table-offset-end);
1881
+ --bb-table-offset-external-start: 0px;
1882
+ --bb-table-offset-external-end: 0px;
1294
1883
  border-collapse: separate;
1295
1884
  border-spacing: 0;
1296
1885
  display: grid;
1297
1886
  overflow-x: auto;
1887
+ padding-inline-end: var(--bb-table-offset-external-end);
1888
+ padding-inline-start: var(--bb-table-offset-external-start);
1298
1889
 
1299
1890
  position: relative;
1300
1891
 
@@ -1367,6 +1958,28 @@ export type BbTableSlots<Item = any> = {
1367
1958
  min-width: 100%;
1368
1959
  }
1369
1960
 
1961
+ /* A nested table inheriting its parent's widths sizes to the sum of its
1962
+ columns instead of filling its container, so fixed layout cannot stretch
1963
+ them out of alignment. Declared after `--fixed` and the base table rule so
1964
+ it overrides their width/min-width at equal specificity. */
1965
+ &--inherit-widths {
1966
+ table {
1967
+ min-width: 0;
1968
+ width: max-content;
1969
+ }
1970
+ }
1971
+
1972
+ /* Declared after the base `table { min-width: 100% }` rule so it wins the
1973
+ cascade at equal specificity. The probe publishes the natural content width
1974
+ of all columns; the table fills its container freely but never compresses
1975
+ below that floor — the container's `overflow-x: auto` then shows a
1976
+ scrollbar instead of squishing content. */
1977
+ &--fixed {
1978
+ table {
1979
+ min-width: var(--bb-table-natural-width, 0px);
1980
+ }
1981
+ }
1982
+
1370
1983
  .bb-table-caption {
1371
1984
  }
1372
1985
 
@@ -1421,6 +2034,26 @@ export type BbTableSlots<Item = any> = {
1421
2034
  }
1422
2035
  }
1423
2036
 
2037
+ /* Apply the structural edge offsets to the first/last cell of every data
2038
+ row. The full-width expand and loading cells are excluded. */
2039
+ thead tr > th:first-child,
2040
+ tbody
2041
+ tr
2042
+ > td:first-child:not(.bb-table-expand__cell):not(.bb-table-loading__cell) {
2043
+ padding-inline-start: calc(
2044
+ var(--padding-x) + var(--bb-table-offset-internal-start)
2045
+ );
2046
+ }
2047
+
2048
+ thead tr > th:last-child,
2049
+ tbody
2050
+ tr
2051
+ > td:last-child:not(.bb-table-expand__cell):not(.bb-table-loading__cell) {
2052
+ padding-inline-end: calc(
2053
+ var(--padding-x) + var(--bb-table-offset-internal-end)
2054
+ );
2055
+ }
2056
+
1424
2057
  &.bb-table--loading {
1425
2058
  .bb-table-loading__row {
1426
2059
  .bb-table-loading__cell {