From 7c30b14c1ff568d71591f90edc9f27afa965ca22 Mon Sep 17 00:00:00 2001 From: lee Date: Sat, 8 Aug 2026 13:30:23 +0800 Subject: [PATCH 1/4] fix(Table): support set Fixed on column at runtime (#8094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复运行时动态设置列 Fixed 后固定列错位问题 - colgroup 渲染时根据列当前 Fixed 状态回退默认固定列宽 - GetLeftStyle 使用 DefaultFixedColumnWidth 替代硬编码 200 - 运行时清空固定列边界缓存重新计算 fr/fl 样式 - 脚本列宽自适应测量跳过固定列 close #8094 --- .../Components/Table/Table.razor | 2 +- .../Components/Table/Table.razor.Sort.cs | 2 +- .../Components/Table/Table.razor.cs | 12 +++++ .../Components/Table/Table.razor.js | 3 +- test/UnitTest/Components/TableTest.cs | 54 +++++++++++++++++++ 5 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor b/src/BootstrapBlazor/Components/Table/Table.razor index 5c2bf9ccdf0..6070d7cbd3b 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor +++ b/src/BootstrapBlazor/Components/Table/Table.razor @@ -253,7 +253,7 @@ { if (col.Visible) { - + } } @if (ShowExtendButtons && !IsExtendButtonsInRowHeader) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs b/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs index ea6ca48e781..15785795cbe 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs @@ -358,7 +358,7 @@ string GetFixedHeaderStyleString() => IsFixedHeader private string? GetLeftStyle(ITableColumn col) { var columns = GetVisibleColumns(); - var defaultWidth = 200; + var defaultWidth = DefaultFixedColumnWidth; var width = 0; var start = 0; var index = columns.IndexOf(col); diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 388fede7a16..3ae39f29bda 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -206,6 +206,14 @@ private string GetSortTooltip(ITableColumn col) => SortName != col.GetFieldName( private static string? GetColWidthString(int? width) => (width.HasValue && width.Value > 0) ? $"width: {width.Value}px;" : null; + private int? GetColWidth(TableColumnState state) + { + // 列状态中的宽度在首次构建列时生成,运行时动态设置 Fixed 后状态中的宽度可能为空 + // 固定列未设置宽度时使用默认固定列宽,保证固定列偏移量与实际列宽一致 + var col = Columns.Find(i => i.GetFieldName() == state.Name); + return state.Width ?? (col is { Fixed: true } ? DefaultFixedColumnWidth : null); + } + /// /// 获得/设置 滚动条宽度 默认 null 未设置使用 配置类中的 /// Gets or sets Scroll Width. Default null (Use ) @@ -1102,6 +1110,10 @@ protected override void OnParametersSet() { // 动态列模式 ResetDynamicContext(); + + // 固定列状态可能在运行时发生改变,清空缓存重新计算固定列边界 + FirstFixedColumnCache.Clear(); + LastFixedColumnCache.Clear(); } // 检查状态变化 diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.js b/src/BootstrapBlazor/Components/Table/Table.razor.js index 31a6c9649a2..c3219e24429 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.js +++ b/src/BootstrapBlazor/Components/Table/Table.razor.js @@ -1176,7 +1176,8 @@ const setColSize = (table, options) => { return; } const th = headerCollection.find(i => i.getAttribute('data-bb-field') === col.name); - if (th === void 0) { + if (th === void 0 || th.classList.contains('fixed')) { + // skip fixed column which width is controlled by colgroup return; } const colIndex = headerCollection.indexOf(th); diff --git a/test/UnitTest/Components/TableTest.cs b/test/UnitTest/Components/TableTest.cs index 19678620191..b54d1d91a47 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -1889,6 +1889,60 @@ public void ColumnFixed_Ok(bool showExtendButton, bool isFixedHeader) } } + [Fact] + public void ColumnFixed_Dynamic_Ok() + { + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + pb.Add(a => a.DefaultFixedColumnWidth, 200); + pb.Add(a => a.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(Foo.Name), typeof(string))); + builder.CloseComponent(); + + builder.OpenComponent>(3); + builder.AddAttribute(4, "Field", foo.Address); + builder.AddAttribute(5, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(Foo.Address), typeof(string))); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 未固定列时 col 元素不输出宽度 + cut.DoesNotContain("width: 200px;"); + + // 运行时动态设置前两列为固定列 + table.Instance.Columns[0].Fixed = true; + table.Instance.Columns[1].Fixed = true; + cut.Render(); + + // 固定列未设置宽度时使用默认固定列宽渲染 col 元素 + var cols = cut.FindAll("colgroup col"); + Assert.Equal(2, cols.Count); + Assert.All(cols, col => Assert.Contains("width: 200px;", col.GetAttribute("style"))); + + // 固定列单元格输出固定样式与偏移量 + cut.Contains("left: 0px;"); + cut.Contains("left: 200px;"); + + // 取消固定后恢复 + table.Instance.Columns[0].Fixed = false; + table.Instance.Columns[1].Fixed = false; + cut.Render(); + + cut.DoesNotContain("width: 200px;"); + cut.DoesNotContain("left: 200px;"); + } + [Fact] public void ColumnFixed_TailColumn_Ok() { From d74e11aa290f9872bf56656c0ce9f31f9e2ad81f Mon Sep 17 00:00:00 2001 From: lee Date: Sat, 8 Aug 2026 14:43:51 +0800 Subject: [PATCH 2/4] fix(Table): replay runtime fixed state on column rebuild (#8094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复自动生成列模式下每次渲染重建列实例导致运行时 Fixed 变更丢失的问题 - 列重建前缓存运行时固定列状态,重建后按字段名回放 - 固定列边界缓存移至 ResetTableColumns 清理,避免按引用累积过期列实例 - 补充自动生成列模式下动态切换固定列的单元测试 --- .../Components/Table/Table.razor.cs | 25 +++++++++-- test/UnitTest/Components/TableTest.cs | 45 +++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 3ae39f29bda..172c5619d6e 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -1110,10 +1110,6 @@ protected override void OnParametersSet() { // 动态列模式 ResetDynamicContext(); - - // 固定列状态可能在运行时发生改变,清空缓存重新计算固定列边界 - FirstFixedColumnCache.Clear(); - LastFixedColumnCache.Clear(); } // 检查状态变化 @@ -1334,6 +1330,10 @@ private List GetTableColumns() private async Task BuildTableColumnsAsync() { + // 缓存运行时动态设置的固定列状态 + // 自动生成列模式下列实例每次渲染均会重新创建,重建后回放固定状态防止丢失 + var fixedColumns = Columns.Where(i => i.Fixed).Select(i => i.GetFieldName()).ToHashSet(StringComparer.Ordinal); + // 构建列信息 var cols = GetTableColumns(); @@ -1347,6 +1347,18 @@ private async Task BuildTableColumnsAsync() Columns.AddRange(cols.OrderFunc()); EnsureTemplateColumnFieldNames(); + // 回放运行时动态设置的固定列状态 + if (fixedColumns.Count != 0) + { + foreach (var col in Columns) + { + if (fixedColumns.Contains(col.GetFieldName())) + { + col.Fixed = true; + } + } + } + // 加载客户端持久化列状态 ResetTableColumns(); } @@ -1416,6 +1428,11 @@ private void ResetTableColumns() { _visibleColumnsCache.Clear(); + // 固定列状态可能在运行时发生改变且自动生成列模式下列实例每次渲染均会重新创建 + // 清空缓存重新计算固定列边界,同时防止缓存按引用累积过期列实例 + FirstFixedColumnCache.Clear(); + LastFixedColumnCache.Clear(); + if (_tableColumnStates.Count == 0) { // 重建缓存 diff --git a/test/UnitTest/Components/TableTest.cs b/test/UnitTest/Components/TableTest.cs index b54d1d91a47..0c96a2dc0a0 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -1943,6 +1943,51 @@ public void ColumnFixed_Dynamic_Ok() cut.DoesNotContain("left: 200px;"); } + [Fact] + public void ColumnFixed_DynamicAutoGenerateColumns_Ok() + { + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + pb.Add(a => a.AutoGenerateColumns, true); + pb.Add(a => a.DefaultFixedColumnWidth, 200); + }); + }); + + var table = cut.FindComponent>(); + + // 运行时动态设置前两列为固定列 + table.Instance.Columns[0].Fixed = true; + table.Instance.Columns[1].Fixed = true; + cut.Render(); + + // 自动生成列模式下列实例每次渲染均会重新创建,固定状态应保留 + Assert.True(table.Instance.Columns[0].Fixed); + Assert.True(table.Instance.Columns[1].Fixed); + + // 未设置宽度的固定列使用默认固定列宽渲染 col 元素,已设置宽度的固定列保持原宽度 + var cols = cut.FindAll("colgroup col"); + Assert.Single(cols.Where(c => (c.GetAttribute("style") ?? "").Contains("width: 200px;"))); + Assert.Single(cols.Where(c => (c.GetAttribute("style") ?? "").Contains("width: 180px;"))); + + // 固定列单元格输出固定样式与偏移量 + cut.Contains("left: 0px;"); + cut.Contains("left: 180px;"); + + // 取消固定后恢复 + table.Instance.Columns[0].Fixed = false; + table.Instance.Columns[1].Fixed = false; + cut.Render(); + + Assert.False(table.Instance.Columns[0].Fixed); + cut.DoesNotContain("width: 200px;"); + cut.DoesNotContain("left: 180px;"); + } + [Fact] public void ColumnFixed_TailColumn_Ok() { From 596bc8c1737e2855c9ce23f9612f9acae2932af0 Mon Sep 17 00:00:00 2001 From: lee Date: Sat, 8 Aug 2026 18:56:26 +0800 Subject: [PATCH 3/4] fix(Table): recalc fixed column offset by actual rendered width (#8094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 自动布局下列实际渲染宽度可能大于声明宽度,导致 sticky 偏移错位滚动覆盖 - 固定列集合变化时通知客户端脚本按实测宽度重算偏移 - 增加 ResizeObserver 跟踪数据加载/翻页/尺寸变化后的布局漂移 --- .../Components/Table/Table.razor.cs | 18 +++- .../Components/Table/Table.razor.js | 96 +++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 172c5619d6e..466656b1986 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -1163,6 +1163,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) var measureColumnMinWidth = _measureColumnMinWidth; var updateSortTooltip = _updateSortTooltip; var scrollToTop = _shouldScrollTop; + var fixColumnOffset = _fixColumnOffset; _invoke = false; _resetColumnListPopover = false; @@ -1171,6 +1172,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) _measureColumnMinWidth = false; _updateSortTooltip = false; _shouldScrollTop = false; + _fixColumnOffset = false; await InvokeVoidAsync("updateTableState", Id, new { @@ -1185,7 +1187,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender) AutoScrollLastSelectedRowToView, AutoScrollVerticalAlign = AutoScrollVerticalAlign.ToDescriptionString(), ScrollIntoViewBehavior = ScrollIntoViewBehavior.ToDescriptionString(), - ScrollToTop = scrollToTop + ScrollToTop = scrollToTop, + FixColumnOffset = fixColumnOffset }); } @@ -1361,8 +1364,21 @@ private async Task BuildTableColumnsAsync() // 加载客户端持久化列状态 ResetTableColumns(); + + // 固定列集合发生变化时通知客户端脚本按实际渲染宽度修正固定列偏移量 + var fixedColumnNames = Columns.Where(i => i.Fixed).Select(i => i.GetFieldName()).ToHashSet(StringComparer.Ordinal); + if (!fixedColumnNames.SetEquals(_lastFixedColumnNames)) + { + _lastFixedColumnNames = fixedColumnNames; + _fixColumnOffset = true; + _invoke = true; + } } + private HashSet _lastFixedColumnNames = new(StringComparer.Ordinal); + + private bool _fixColumnOffset; + private void EnsureTemplateColumnFieldNames() { const string prefix = "bb_template_column_"; diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.js b/src/BootstrapBlazor/Components/Table/Table.razor.js index c3219e24429..16c12b0b766 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.js +++ b/src/BootstrapBlazor/Components/Table/Table.razor.js @@ -34,6 +34,14 @@ export async function reset(id) { cancelAnimationFrame(table.minWidthRaf); table.minWidthRaf = null; } + if (table.fixedOffsetObserver) { + table.fixedOffsetObserver.disconnect(); + table.fixedOffsetObserver = null; + } + if (table.fixedOffsetRaf) { + cancelAnimationFrame(table.fixedOffsetRaf); + table.fixedOffsetRaf = null; + } table.autoColumns = []; table.columns = []; @@ -118,6 +126,10 @@ export async function reset(id) { if (table.thead) { setColSize(table, table.options); } + + // 修正固定列 sticky 偏移量并监听表格尺寸变化 + fixColumnOffset(table); + setFixedOffsetObserver(table); } export async function switchCardView(id) { @@ -147,6 +159,14 @@ const destroyTable = table => { if (table.minWidthRaf) { cancelAnimationFrame(table.minWidthRaf); } + if (table.fixedOffsetObserver) { + table.fixedOffsetObserver.disconnect(); + table.fixedOffsetObserver = null; + } + if (table.fixedOffsetRaf) { + cancelAnimationFrame(table.fixedOffsetRaf); + table.fixedOffsetRaf = null; + } if (table.thead) { EventHandler.off(table.body, 'scroll') } @@ -1107,6 +1127,10 @@ export async function updateTableState(id, options) { setColSize(table, options); } + if (options.fixColumnOffset) { + fixColumnOffset(table); + } + if (options.resetColumnListPopover) { resetColumnListPopover(table); } @@ -1207,6 +1231,78 @@ const applyColumnMinWidth = table => { } } +const setFixedOffsetObserver = table => { + if (!window.ResizeObserver || !table.tables || table.tables.length === 0) { + return; + } + table.fixedOffsetObserver = new ResizeObserver(() => { + if (table.fixedOffsetRaf) { + cancelAnimationFrame(table.fixedOffsetRaf); + } + table.fixedOffsetRaf = requestAnimationFrame(() => { + table.fixedOffsetRaf = null; + fixColumnOffset(table); + }); + }); + table.tables.forEach(t => table.fixedOffsetObserver.observe(t)); +} + +const fixColumnOffset = table => { + if (!table.tables || table.tables.length === 0) { + return; + } + + table.tables.forEach(t => { + if (isVisible(t) === false || t.querySelector('.fixed') === null) { + return; + } + + // 以首个完整行单元格的实际渲染宽度为基准重新计算固定列 sticky 偏移量 + // 自动布局下列实际渲染宽度可能大于服务端声明宽度,导致偏移量与实际列宽错位 + const cells = getOffsetBaseCells(t); + if (cells.length === 0) { + return; + } + + const leftOffsets = []; + const rightOffsets = []; + let left = 0; + cells.forEach((cell, index) => { + leftOffsets[index] = left; + left += getWidth(cell); + }); + let right = 0; + for (let index = cells.length - 1; index >= 0; index--) { + rightOffsets[index] = right; + right += getWidth(cells[index]); + } + + [...t.querySelectorAll('tr')].forEach(row => { + [...row.children].forEach((cell, index) => { + if (cell.classList.contains('fixed')) { + if (cell.classList.contains('fixed-right')) { + if (rightOffsets[index] !== void 0) { + cell.style.right = `${rightOffsets[index]}px`; + } + } + else if (leftOffsets[index] !== void 0) { + cell.style.left = `${leftOffsets[index]}px`; + } + } + }); + }); + }); +} + +const getOffsetBaseCells = t => { + const headerRow = t.querySelector('thead > tr'); + if (headerRow) { + return [...headerRow.children]; + } + const bodyRow = [...t.querySelectorAll('tbody > tr')].find(r => !r.classList.contains('is-detail')); + return bodyRow ? [...bodyRow.children] : []; +} + const setAutoColWidths = (table, apply) => { const colgroups = table.tables.map(t => t.querySelectorAll('colgroup col')); table.autoColumns.forEach(({ colIndex, minWidth }) => { From 346a1621356f7b7b6e71d41129136bb005498258 Mon Sep 17 00:00:00 2001 From: LEE <33386249+h2ls@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:19:42 +0800 Subject: [PATCH 4/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: LEE <33386249+h2ls@users.noreply.github.com> --- src/BootstrapBlazor/Components/Table/Table.razor.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 466656b1986..94936491a1d 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -211,7 +211,8 @@ private string GetSortTooltip(ITableColumn col) => SortName != col.GetFieldName( // 列状态中的宽度在首次构建列时生成,运行时动态设置 Fixed 后状态中的宽度可能为空 // 固定列未设置宽度时使用默认固定列宽,保证固定列偏移量与实际列宽一致 var col = Columns.Find(i => i.GetFieldName() == state.Name); - return state.Width ?? (col is { Fixed: true } ? DefaultFixedColumnWidth : null); + var width = state.Width ?? col?.Width; + return width ?? (col is { Fixed: true } ? DefaultFixedColumnWidth : null); } ///