diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs b/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs index ea6ca48e781..369ce658905 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs @@ -320,7 +320,9 @@ private bool IsFixRight(ITableColumn col) // 获得当前列索引 var index = columns.IndexOf(col); - return !columns.Take(index).All(i => i.Fixed); + // 前缀固定列判定为左固定,该列到最后一列全部固定(构成固定后缀)时判定为右固定 + // 孤立的中间固定列回落为左固定,避免动态切换固定列时误判为右固定 + return !columns.Take(index).All(i => i.Fixed) && columns.Skip(index).All(i => i.Fixed); } /// @@ -358,7 +360,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); @@ -377,7 +379,12 @@ string GetFixedHeaderStyleString() => IsFixedHeader while (index > start) { var column = columns[start++]; - width += column.Width ?? defaultWidth; + + // 仅累加左固定列宽度 未固定列滚动时移出视口 不参与 sticky 偏移 + if (column.Fixed) + { + width += column.Width ?? defaultWidth; + } } return $"left: {width}px;"; } @@ -393,7 +400,12 @@ string GetFixedHeaderStyleString() => IsFixedHeader for (var i = index + 1; i < columns.Count; i++) { var column = columns[i]; - width += column.Width ?? defaultWidth; + + // 仅累加右固定列宽度 未固定列滚动时移出视口 不参与 sticky 偏移 + if (column.Fixed) + { + width += column.Width ?? defaultWidth; + } } if (ShowExtendButtons && FixedExtendButtonsColumn) { diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 388fede7a16..4c474437a2d 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web.Virtualization; +using System.Collections.Concurrent; using System.Reflection; namespace BootstrapBlazor.Components; @@ -90,7 +91,7 @@ public partial class Table : ITable, IModelEqualityComparer where .AddClass("table-excel", IsExcel) .AddClass("table-bordered", IsBordered) .AddClass("table-striped table-hover", IsStriped) - .AddClass("table-layout-fixed", IsFixedHeader) + .AddClass("table-layout-fixed", IsFixedHeader || IsFixedColumnLayout) .AddClass("table-draggable", AllowDragColumn) .Build(); @@ -117,6 +118,19 @@ public partial class Table : ITable, IModelEqualityComparer where private bool FixedColumn => FixedExtendButtonsColumn || FixedMultipleColumn || FixedDetailRowHeaderColumn || FixedLineNoColumn || Columns.Any(c => c.Fixed); + /// + /// 存在固定列且所有可见列均有宽度时使用 fixed 布局 保证 colgroup 宽度精确生效与 sticky 偏移一致 + /// Use fixed table layout when fixed columns exist and all visible columns have explicit width + /// + private bool IsFixedColumnLayout + { + get + { + var columns = GetVisibleColumns(); + return columns.Count > 0 && columns.All(i => i.Width.HasValue) && columns.Any(i => i.Fixed); + } + } + /// /// 获得 Body 内行样式 /// Get Body Row CSS Class @@ -1322,6 +1336,9 @@ private List GetTableColumns() private async Task BuildTableColumnsAsync() { + // 重建列信息前先同步运行时变更的固定列状态,支持动态设置固定列 + var fixedChanged = SyncColumnsFixedState(); + // 构建列信息 var cols = GetTableColumns(); @@ -1337,6 +1354,12 @@ private async Task BuildTableColumnsAsync() // 加载客户端持久化列状态 ResetTableColumns(); + + // 列重建回放后固定状态为本轮最终值 此时再同步列宽度 + if (fixedChanged) + { + SyncColumnsWidthState(); + } } private void EnsureTemplateColumnFieldNames() @@ -1400,9 +1423,124 @@ private async Task LoadTableColumnStates() } } + /// + /// 运行时动态固定列缓存 键为列名称 值为运行时变更后的固定状态 列重建时回放 未变更过的列保持代码声明值 + /// Runtime dynamic fixed state cache keyed by column name. Replayed on column rebuild + /// + private readonly Dictionary _dynamicFixedColumnCache = []; + + /// + /// 列固定状态应用缓存 键为列实例 值为最后一次应用到该实例上的固定状态 用于识别运行时变更 + /// Applied fixed state cache keyed by column instance. Used to detect runtime changes + /// + private readonly ConcurrentDictionary _appliedFixedColumnCache = new(ReferenceEqualityComparer.Instance); + + /// + /// 自动回填宽度缓存 键为列名称 值为固定列时回填的宽度与原始宽度 用于取消固定时还原 + /// Auto backfill width cache keyed by column name. Used to restore width when unfixed + /// + private readonly Dictionary _autoFixedColumnWidthCache = []; + + /// + /// 客户端实际渲染列宽快照 渲染结束后由脚本测量更新 未显式设置宽度列的实际宽度由浏览器布局决定 服务器端无法得知 + /// Snapshot of actual rendered column widths measured by script after each render + /// + private Dictionary? _clientColumnWidths; + + private async Task RefreshClientColumnWidthsAsync() + { + var widths = await InvokeAsync>("getColumnWidths", Id); + if (widths != null) + { + _clientColumnWidths = widths; + } + } + + /// + /// 同步运行时变更的固定列状态 仅记录应用过固定状态的列实例 未变更过的列保持代码声明值 + /// Sync runtime fixed state changes. Only columns whose applied state exists are tracked + /// + /// + /// 是否存在固定状态变更 + /// Whether any fixed state changed + /// + private bool SyncColumnsFixedState() + { + var changed = false; + foreach (var col in Columns) + { + if (_appliedFixedColumnCache.TryGetValue(col, out var applied) && applied != col.Fixed) + { + _appliedFixedColumnCache[col] = col.Fixed; + _dynamicFixedColumnCache[col.GetFieldName()] = col.Fixed; + changed = true; + } + } + return changed; + } + + private void SyncColumnsWidthState() + { + if (Columns.Exists(i => i.Fixed)) + { + // 存在固定列时将所有列宽度覆盖为客户端实际渲染宽度并记录原始宽度用于还原 + // auto 布局下 colgroup 宽度按内容盒渲染与显式宽度不一致 全列覆盖实测宽度后启用 fixed 布局 + // colgroup 宽度精确生效 保证 sticky 偏移与实际渲染一致且切换时无宽度跳变 + foreach (var col in Columns) + { + var state = _tableColumnStates.Find(i => i.Name == col.GetFieldName()); + if (state == null) + { + continue; + } + + if (_clientColumnWidths != null && _clientColumnWidths.TryGetValue(state.Name, out var width)) + { + if (state.Width != width) + { + var original = _autoFixedColumnWidthCache.TryGetValue(state.Name, out var exist) ? exist.Original : col.Width; + _autoFixedColumnWidthCache[state.Name] = (width, original); + state.Width = width; + col.Width = width; + } + } + else if (col.Fixed && !col.Width.HasValue && state.Width == null) + { + // 无实测宽度时为固定列兜底默认宽度 + _autoFixedColumnWidthCache[state.Name] = (DefaultFixedColumnWidth, null); + state.Width = DefaultFixedColumnWidth; + col.Width = DefaultFixedColumnWidth; + } + } + } + else + { + // 无固定列时还原全部覆盖宽度恢复原始布局 用户拖拽调整过列宽时保留调整值 + foreach (var (name, item) in _autoFixedColumnWidthCache) + { + var state = _tableColumnStates.Find(i => i.Name == name); + if (state != null && state.Width == item.Applied) + { + state.Width = item.Original; + var col = Columns.Find(i => i.GetFieldName() == name); + if (col != null) + { + col.Width = item.Original; + } + } + } + _autoFixedColumnWidthCache.Clear(); + } + } + private void ResetTableColumns() { _visibleColumnsCache.Clear(); + _appliedFixedColumnCache.Clear(); + + // 固定列变化影响相邻列的 fl fr 样式 重建时清除缓存重新计算 + FirstFixedColumnCache.Clear(); + LastFixedColumnCache.Clear(); if (_tableColumnStates.Count == 0) { @@ -1417,6 +1555,7 @@ private void ResetTableColumns() var state = CreateTableColumnState(col); _tableColumnStates.Add(state); + _appliedFixedColumnCache[col] = col.Fixed; if (col.GetVisible(_screenSize)) { @@ -1489,6 +1628,15 @@ private void ResetTableColumns() { var item = _tableColumnStates[index]; var col = columnMap[item.Name]; + + // 将列状态回放到列实例上 运行时动态变更过的固定状态回放 未变更过的列保持代码声明值 + if (_dynamicFixedColumnCache.TryGetValue(item.Name, out var fixedState)) + { + col.Fixed = fixedState; + } + col.Width = item.Width ?? col.Width; + _appliedFixedColumnCache[col] = col.Fixed; + if (item.Visible) { // 增加到可见列缓存集合 @@ -1553,6 +1701,13 @@ private async Task OnTableRenderAsync(bool firstRender) { await OnAfterRenderCallback(this, firstRender); } + + // 渲染结束后刷新客户端实际列宽快照供运行时切换固定列时使用 + // 不可在渲染管线中等待脚本测量 否则 RenderTemplate 会渲染空内容导致表格重建 客户端脚本引用失效 + if (Columns.Exists(i => !i.Width.HasValue)) + { + await RefreshClientColumnWidthsAsync(); + } } private string? GetTableStyleString(bool hasHeader) @@ -1995,6 +2150,45 @@ public async Task FitAllColumnWidth() await InvokeVoidAsync("fitAllColumnWidth", Id); } + /// + /// 更新表格列客户端状态方法 + /// Update Table Column Client Status Method + /// + /// + public async Task UpdateTableColumnClientStatus() + { + if (Columns.Count != 0) + { + // 存在固定列且有列缺少宽度时刷新客户端实测列宽快照 + var needMeasure = Columns.Exists(i => i.Fixed) && Columns.Exists(i => !i.Width.HasValue); + if (needMeasure) + { + await RefreshClientColumnWidthsAsync(); + } + + // 用户在外面变更了列状态后,为避免用户变更状态丢失,须将变更后的状态同步到缓存中 + foreach (var item in Columns) + { + var columnState = _tableColumnStates.Find(x => x.Name == item.GetFieldName()); + if (columnState != null) + { + columnState.Width = item.Width; + } + } + + // 固定状态变化或固定列缺少宽度时同步列宽度 其余场景跳过防止手动调整的列宽被实测快照覆盖 + if (SyncColumnsFixedState() || needMeasure) + { + SyncColumnsWidthState(); + } + StateHasChanged(); + } + // 如果启用了 ClientTableName 则更新浏览器持久化列状态 + await InvokeVoidAsync("updateColumnStates", Id, _tableColumnStates); + + return _tableColumnStateCache; + } + /// /// 清除表格列客户端状态实例方法 /// clear table column client status instance method @@ -2010,6 +2204,7 @@ public async Task ClearTableColumnClientStatus() // 清除缓存的列状态 _tableColumnStateCache.Clear(); + _autoFixedColumnWidthCache.Clear(); StateHasChanged(); } diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.js b/src/BootstrapBlazor/Components/Table/Table.razor.js index 31a6c9649a2..65cf3aa7b08 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.js +++ b/src/BootstrapBlazor/Components/Table/Table.razor.js @@ -620,7 +620,7 @@ const autoFitColumnWidth = async (table, col) => { const index = indexOfCol(col); let rows = null; let maxWidth = getColumnMaxCellWidth(table, index); - + if (table.options.fitColumnWidthIncludeHeader) { const th = getColumnHeader(col); maxWidth = Math.max(maxWidth, getCellWidth(th)); @@ -970,6 +970,29 @@ const removeColumnWidthState = tableName => { localStorage.removeItem(columnWidthKey); } +export function getColumnWidths(id) { + const table = Data.get(id) + if (!table || !table.tables || table.tables.length === 0) { + return null + } + const widths = {} + table.tables[0].querySelectorAll('thead > tr > th[data-bb-field]').forEach(th => { + widths[th.getAttribute('data-bb-field')] = Math.round(getWidth(th)) + }) + return widths +} + +export function updateColumnStates(id, columnStates) { + const table = Data.get(id) + if (!table) { + return + } + if (columnStates) { + table.options.columnStates = columnStates; + } + saveColumnStateToLocalstorage(table); +} + export function clearColumnStates(tableName) { localStorage.removeItem(tableName); localStorage.removeItem(`bb-table-${tableName}`); diff --git a/test/UnitTest/Components/TableTest.cs b/test/UnitTest/Components/TableTest.cs index 19678620191..17d27cd05d8 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -9022,6 +9022,533 @@ public async Task OnTableColumnClientStatusChanged_ResizeColumn_Ok() Assert.NotNull(clientState); } + [Fact] + public async Task UpdateTableColumnClientStatus_Ok() + { + var state = new TableColumnClientStatus(); + state.TableWidth = 220; + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Name), Visible = true }); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Address), Visible = true, Width = 120 }); + + Context.JSInterop.Setup("getColumnStates", "test_update").SetResult(state); + var invoker = Context.JSInterop.SetupVoid("updateColumnStates", "test_update"); + invoker.SetVoidResult(); + + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.ClientTableName, "test_update"); + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.AllowResizing, true); + pb.Add(a => a.OnQueryAsync, OnQueryAsync(localizer)); + pb.Add(a => a.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", "Name"); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); + builder.AddAttribute(3, "Fixed", true); + builder.CloseComponent(); + + builder.OpenComponent>(0); + builder.AddAttribute(3, "Field", "Address"); + builder.AddAttribute(4, "FieldExpression", Utility.GenerateValueExpression(foo, "Address", typeof(string))); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + var colGroup = table.Find("colgroup"); + Assert.Contains("style=\"width: 120px;\"", colGroup.ToMarkup()); + + var status = await cut.InvokeAsync(() => table.Instance.UpdateTableColumnClientStatus()); + Assert.True(table.Instance.Columns[0].Fixed); + Assert.Equal(state.Columns.Count, status.Columns.Count); + + table = cut.FindComponent>(); + var columns = cut.FindAll("th"); + colGroup = table.Find("colgroup"); + Assert.Contains("style=\"width: 200px;\"", colGroup.ToMarkup()); + if (columns[0].ClassName.Contains("fixed")) + { + var fixedWidth = cut.FindAll("col")[0].OuterHtml.Contains("width: 200px"); + Assert.Equal("fixedWidth:True", $"fixedWidth:{fixedWidth}"); + } + } + + [Fact] + public async Task DynamicFixedColumn_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.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); + builder.AddAttribute(3, "Width", 100); + builder.CloseComponent(); + + builder.OpenComponent>(4); + builder.AddAttribute(5, "Field", foo.Count); + builder.AddAttribute(6, "FieldExpression", Utility.GenerateValueExpression(foo, "Count", typeof(int))); + builder.AddAttribute(7, "Width", 100); + builder.CloseComponent(); + + builder.OpenComponent>(8); + builder.AddAttribute(9, "Field", foo.Address); + builder.AddAttribute(10, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(foo.Address), typeof(string))); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 初始无固定列 + Assert.DoesNotContain("table-fixed-column", cut.Markup); + + // 运行时设置前两列固定 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = true; + table.Instance.Columns[1].Fixed = true; + }); + table.Render(); + + Assert.Contains("table-fixed-column", cut.Markup); + var columns = cut.FindAll("thead th"); + Assert.Contains("fixed", columns[0].ClassName); + Assert.Contains("left: 0px;", columns[0].OuterHtml); + Assert.Contains("fixed", columns[1].ClassName); + + // 最后一个左固定列应包含 fr 样式 + Assert.Contains("fr", columns[1].ClassName); + Assert.Contains("left: 100px;", columns[1].OuterHtml); + Assert.DoesNotContain("fixed", columns[2].ClassName ?? ""); + + // 运行时取消固定列 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = false; + table.Instance.Columns[1].Fixed = false; + }); + table.Render(); + + Assert.DoesNotContain("table-fixed-column", cut.Markup); + columns = cut.FindAll("thead th"); + Assert.DoesNotContain("fixed", columns[0].ClassName ?? ""); + Assert.DoesNotContain("fixed", columns[1].ClassName ?? ""); + } + + [Fact] + public async Task DynamicFixedColumn_ClientTableName_Ok() + { + // 固定列状态由代码管理不参与持久化 持久化状态只包含宽度可见性等用户个性化配置 + var state = new TableColumnClientStatus(); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Name), Visible = true, Width = 100 }); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Address), Visible = true, Width = 120 }); + + Context.JSInterop.Setup("getColumnStates", "test_dynamic_fixed").SetResult(state); + + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.ClientTableName, "test_dynamic_fixed"); + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + pb.Add(a => a.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); + builder.CloseComponent(); + + builder.OpenComponent>(4); + builder.AddAttribute(5, "Field", foo.Address); + builder.AddAttribute(6, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(foo.Address), typeof(string))); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 持久化状态中的 Fixed 不恢复 列实例保持代码声明值 宽度正常恢复 + var columns = cut.FindAll("thead th"); + Assert.DoesNotContain("fixed", columns[0].ClassName ?? ""); + Assert.False(table.Instance.Columns[0].Fixed); + Assert.Equal(100, table.Instance.Columns[0].Width); + Assert.Equal(120, table.Instance.Columns[1].Width); + + // 运行时切换固定列仍正常工作 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = true; + }); + table.Render(); + + columns = cut.FindAll("thead th"); + Assert.Contains("fixed", columns[0].ClassName); + Assert.True(table.Instance.Columns[0].Fixed); + + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = false; + }); + table.Render(); + + columns = cut.FindAll("thead th"); + Assert.DoesNotContain("fixed", columns[0].ClassName ?? ""); + Assert.False(table.Instance.Columns[0].Fixed); + } + + [Fact] + public async Task DynamicFixedColumn_AutoGenerateColumns_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.AutoGenerateColumns, true); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + }); + }); + + var table = cut.FindComponent>(); + Assert.DoesNotContain("table-fixed-column", cut.Markup); + + // 运行时设置前两列固定 模拟 #8094 场景 自动生成列每次渲染重建实例 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = true; + table.Instance.Columns[1].Fixed = true; + }); + table.Render(); + + Assert.Contains("table-fixed-column", cut.Markup); + var columns = cut.FindAll("thead th"); + Assert.Contains("fixed", columns[0].ClassName); + Assert.Contains("fixed", columns[1].ClassName); + Assert.Contains("fr", columns[1].ClassName); + + // 再次渲染后固定状态保持 不被自动生成列重建逻辑重置 + table.Render(); + columns = cut.FindAll("thead th"); + Assert.Contains("fixed", columns[0].ClassName); + Assert.Contains("fixed", columns[1].ClassName); + + // 运行时取消固定列 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = false; + table.Instance.Columns[1].Fixed = false; + }); + table.Render(); + + Assert.DoesNotContain("table-fixed-column", cut.Markup); + } + + [Fact] + public async Task DynamicFixedColumn_ClientWidth_Ok() + { + // 模拟客户端实际渲染宽度 未显式设置 Width 的列固定时使用实际宽度而不是默认宽度 200 + Context.JSInterop.Setup>("getColumnWidths", _ => true).SetResult(new Dictionary + { + { nameof(Foo.Name), 120 }, + { nameof(Foo.Address), 180 }, + { nameof(Foo.Count), 80 } + }); + + 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.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(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(); + + builder.OpenComponent>(6); + builder.AddAttribute(7, "Field", foo.Count); + builder.AddAttribute(8, "FieldExpression", Utility.GenerateValueExpression(foo, "Count", typeof(int))); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 运行时固定前两列 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = true; + table.Instance.Columns[1].Fixed = true; + }); + table.Render(); + + var columns = cut.FindAll("thead th"); + Assert.Contains("left: 0px;", columns[0].OuterHtml); + + // 第二列偏移量使用第一列客户端实际宽度 120 而不是默认宽度 200 + Assert.Contains("left: 120px;", columns[1].OuterHtml); + + // colgroup 应用实际宽度 固定期间列宽保持稳定不跳变 + var colGroup = table.Find("colgroup"); + Assert.Contains("width: 120px;", colGroup.ToMarkup()); + Assert.Contains("width: 180px;", colGroup.ToMarkup()); + + // 未固定列同样回填实际宽度 全列宽度已知后启用 fixed 布局保证宽度精确生效 + Assert.Contains("width: 80px;", colGroup.ToMarkup()); + Assert.Contains("table-layout-fixed", cut.Markup); + + // 取消固定后还原自动回填宽度 恢复浏览器自动布局 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = false; + table.Instance.Columns[1].Fixed = false; + }); + table.Render(); + + Assert.False(table.Instance.Columns[0].Width.HasValue); + Assert.False(table.Instance.Columns[1].Width.HasValue); + colGroup = table.Find("colgroup"); + Assert.DoesNotContain("width: 120px;", colGroup.ToMarkup()); + Assert.DoesNotContain("width: 180px;", colGroup.ToMarkup()); + Assert.DoesNotContain("width: 80px;", colGroup.ToMarkup()); + Assert.DoesNotContain("table-layout-fixed", cut.Markup); + } + + [Fact] + public async Task DynamicFixedColumn_FixRight_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.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); + builder.AddAttribute(3, "Width", 100); + builder.CloseComponent(); + + builder.OpenComponent>(4); + builder.AddAttribute(5, "Field", foo.Address); + builder.AddAttribute(6, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(foo.Address), typeof(string))); + builder.AddAttribute(7, "Width", 100); + builder.CloseComponent(); + + builder.OpenComponent>(8); + builder.AddAttribute(9, "Field", foo.Count); + builder.AddAttribute(10, "FieldExpression", Utility.GenerateValueExpression(foo, "Count", typeof(int))); + builder.AddAttribute(11, "Width", 100); + builder.CloseComponent(); + + builder.OpenComponent>(12); + builder.AddAttribute(13, "Field", foo.Complete); + builder.AddAttribute(14, "FieldExpression", Utility.GenerateValueExpression(foo, "Complete", typeof(bool))); + builder.AddAttribute(15, "Width", 100); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 固定最后两列 构成固定后缀 判定为右固定 + await cut.InvokeAsync(() => + { + table.Instance.Columns[2].Fixed = true; + table.Instance.Columns[3].Fixed = true; + }); + table.Render(); + + var columns = cut.FindAll("thead th"); + Assert.Contains("fixed-right", columns[2].ClassName); + Assert.Contains("right: 100px;", columns[2].OuterHtml); + Assert.Contains("fixed-right", columns[3].ClassName); + Assert.Contains("right: 0px;", columns[3].OuterHtml); + + // 取消固定后 固定中间单列 不构成固定后缀 回落为左固定 不应误判为右固定 + await cut.InvokeAsync(() => + { + table.Instance.Columns[2].Fixed = false; + table.Instance.Columns[3].Fixed = false; + }); + table.Render(); + await cut.InvokeAsync(() => + { + table.Instance.Columns[1].Fixed = true; + }); + table.Render(); + + columns = cut.FindAll("thead th"); + Assert.DoesNotContain("fixed-right", columns[1].ClassName); + + // 孤立固定列前面无固定列 偏移不累加未固定列宽度 + Assert.Contains("left: 0px;", columns[1].OuterHtml); + + // 全部列固定 判定为左固定 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = true; + table.Instance.Columns[2].Fixed = true; + table.Instance.Columns[3].Fixed = true; + }); + table.Render(); + + columns = cut.FindAll("thead th"); + Assert.All(columns, th => Assert.DoesNotContain("fixed-right", th.ClassName ?? "")); + Assert.Contains("left: 300px;", columns[3].OuterHtml); + } + + [Fact] + public async Task DynamicFixedColumn_ExplicitWidth_Ok() + { + // auto 布局下显式宽度列实际渲染宽度大于设置值(含 padding/border)固定时应使用实测宽度避免跳变 + Context.JSInterop.Setup>("getColumnWidths", _ => true).SetResult(new Dictionary + { + { nameof(Foo.Name), 197 }, + { nameof(Foo.Address), 220 } + }); + + 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.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); + builder.AddAttribute(3, "Width", 180); + builder.CloseComponent(); + + builder.OpenComponent>(4); + builder.AddAttribute(5, "Field", foo.Address); + builder.AddAttribute(6, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(foo.Address), typeof(string))); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 固定第一列 显式宽度 180 的列使用实测宽度 197 保证切换时渲染宽度不变 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = true; + }); + table.Render(); + + var colGroup = table.Find("colgroup"); + Assert.Contains("width: 197px;", colGroup.ToMarkup()); + Assert.Contains("width: 220px;", colGroup.ToMarkup()); + Assert.Contains("table-layout-fixed", cut.Markup); + + // 取消固定后还原原始显式宽度 180 未设置宽度列还原为空 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = false; + }); + table.Render(); + + Assert.Equal(180, table.Instance.Columns[0].Width); + Assert.False(table.Instance.Columns[1].Width.HasValue); + colGroup = table.Find("colgroup"); + Assert.Contains("width: 180px;", colGroup.ToMarkup()); + Assert.DoesNotContain("width: 197px;", colGroup.ToMarkup()); + Assert.DoesNotContain("width: 220px;", colGroup.ToMarkup()); + Assert.DoesNotContain("table-layout-fixed", cut.Markup); + } + + [Fact] + public async Task DynamicFixedColumn_ResizedWidth_Ok() + { + Context.JSInterop.Setup>("getColumnWidths", _ => true).SetResult(new Dictionary + { + { nameof(Foo.Name), 120 }, + { nameof(Foo.Address), 180 } + }); + + 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.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(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>(); + + // 运行时固定第一列 使用客户端实际宽度 120 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = true; + }); + table.Render(); + Assert.Equal(120, table.Instance.Columns[0].Width); + + // 模拟用户固定期间拖拽调整列宽 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Width = 150; + }); + await cut.InvokeAsync(() => table.Instance.UpdateTableColumnClientStatus()); + + // 取消固定后保留用户调整值 不还原自动回填宽度 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = false; + }); + table.Render(); + + Assert.Equal(150, table.Instance.Columns[0].Width); + } + [Fact] public async Task ClearTableColumnClientStatus_Ok() {