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 98bd52b27e3..fffa2a45320 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,147 @@ 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 = []; + + /// + /// 占位宽度列名集合 由 NormalizeVisibleColumnWidth/CreateTableColumnState 回填兜底宽度时标记 真实宽度写入时移除 + /// 列状态回放到列实例时据此区分占位宽与真实宽 避免按值比较误伤与占位值相等的真实宽度 + /// Names of columns whose state width is a placeholder backfill. Marked when backfilled, removed when a real width is written. + /// Used during column state replay to distinguish placeholders from real widths without value comparison. + /// + private readonly HashSet _placeholderWidthColumnNames = new(StringComparer.Ordinal); + + /// + /// 客户端实际渲染列宽快照 渲染结束后由脚本测量更新 未显式设置宽度列的实际宽度由浏览器布局决定 服务器端无法得知 + /// 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; + + // 实测宽度为真实宽度 移除占位标记 + _placeholderWidthColumnNames.Remove(state.Name); + } + } + else if (col.Fixed && !col.Width.HasValue && state.Width == null) + { + // 无实测宽度时为固定列兜底默认宽度 + _autoFixedColumnWidthCache[state.Name] = (DefaultFixedColumnWidth, null); + state.Width = DefaultFixedColumnWidth; + col.Width = DefaultFixedColumnWidth; + + // 兜底宽度已应用到列实例 视为真实宽度 移除占位标记 + _placeholderWidthColumnNames.Remove(state.Name); + } + } + } + else + { + // 无固定列时还原全部覆盖宽度恢复原始布局 + RestoreAutoFixedColumnWidths(); + } + } + + private void RestoreAutoFixedColumnWidths() + { + // 还原全部覆盖宽度恢复原始布局 用户拖拽调整过列宽时保留调整值 + 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; + } + + // 还原后宽度身份重置 真实值已由上方写回 空值由下轮重建的占位回填重新标记 + _placeholderWidthColumnNames.Remove(name); + } + } + _autoFixedColumnWidthCache.Clear(); + } + private void ResetTableColumns() { _visibleColumnsCache.Clear(); + _appliedFixedColumnCache.Clear(); + + // 固定列变化影响相邻列的 fl fr 样式 重建时清除缓存重新计算 + FirstFixedColumnCache.Clear(); + LastFixedColumnCache.Clear(); if (_tableColumnStates.Count == 0) { @@ -1417,6 +1578,7 @@ private void ResetTableColumns() var state = CreateTableColumnState(col); _tableColumnStates.Add(state); + _appliedFixedColumnCache[col] = col.Fixed; if (col.GetVisible(_screenSize)) { @@ -1456,6 +1618,8 @@ private void ResetTableColumns() } else { + // 列已不存在 同步移除其占位标记防止残留 + _placeholderWidthColumnNames.Remove(item.Name); _tableColumnStates.RemoveAt(i); } } @@ -1497,6 +1661,23 @@ private void ResetTableColumns() { var item = _tableColumnStates[index]; var col = columnMap[item.Name]; + + // 将列状态回放到列实例上 运行时动态变更过的固定状态回放 未变更过的列保持代码声明值 + if (_dynamicFixedColumnCache.TryGetValue(item.Name, out var fixedState)) + { + col.Fixed = fixedState; + } + + // 占位回填宽不回灌列实例 占位宽仅保留在列状态中供客户端布局使用 列实例保持无宽 + // 以便实测宽度快照采集、固定布局判定、取消固定还原等逻辑按真实宽度工作 + // 占位身份由 NormalizeVisibleColumnWidth/CreateTableColumnState 回填时显式标记 真实宽度写入时移除 + // 不做按值比较 与占位值相等的真实宽度(如用户拖拽至 ColumnMinWidth)不受误伤 + if (col.Width.HasValue || !_placeholderWidthColumnNames.Contains(item.Name)) + { + col.Width = item.Width ?? col.Width; + } + _appliedFixedColumnCache[col] = col.Fixed; + if (item.Visible) { // 增加到可见列缓存集合 @@ -1506,13 +1687,29 @@ private void ResetTableColumns() } } - private TableColumnState CreateTableColumnState(ITableColumn col) => new TableColumnState() + private TableColumnState CreateTableColumnState(ITableColumn col) { - DisplayName = col.GetDisplayName(), - Name = col.GetFieldName(), - Width = col.Fixed && !col.Width.HasValue ? DefaultFixedColumnWidth : col.Width, - Visible = col.GetVisible(_screenSize) - }; + var state = new TableColumnState() + { + DisplayName = col.GetDisplayName(), + Name = col.GetFieldName(), + Width = col.Fixed && !col.Width.HasValue ? DefaultFixedColumnWidth : col.Width, + Visible = col.GetVisible(_screenSize) + }; + + if (col.Fixed && !col.Width.HasValue) + { + // 固定列无显式宽度时状态宽为占位回填值 标记占位身份 + _placeholderWidthColumnNames.Add(state.Name); + } + else + { + // 同名状态重建等场景下清除可能残留的占位标记 + _placeholderWidthColumnNames.Remove(state.Name); + } + + return state; + } private bool NormalizeVisibleColumnWidth(TableColumnState item, ITableColumn col) { @@ -1522,6 +1719,9 @@ private bool NormalizeVisibleColumnWidth(TableColumnState item, ITableColumn col } item.Width = GetDefaultColumnStateWidth(col); + + // 回填的兜底宽仅为客户端布局占位 标记占位身份 不代表列真实宽度 + _placeholderWidthColumnNames.Add(item.Name); return true; } @@ -1600,6 +1800,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) @@ -1633,6 +1840,9 @@ public void ResetVisibleColumns(IReadOnlyCollection columns) if (col.Width.HasValue) { column.Width = col.Width; + + // 宿主显式设置的宽度为真实宽度 移除占位标记 + _placeholderWidthColumnNames.Remove(col.Name); } } } @@ -2042,6 +2252,51 @@ 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 (item.Width.HasValue) + { + _placeholderWidthColumnNames.Remove(columnState.Name); + } + } + } + + // 固定状态变化或固定列缺少宽度时同步列宽度 其余场景跳过防止手动调整的列宽被实测快照覆盖 + if (SyncColumnsFixedState() || needMeasure) + { + SyncColumnsWidthState(); + } + StateHasChanged(); + } + // 如果启用了 ClientTableName 则更新浏览器持久化列状态 + await InvokeVoidAsync("updateColumnStates", Id, _tableColumnStates); + + return _tableColumnStateCache; + } + /// /// 清除表格列客户端状态实例方法 /// clear table column client status instance method @@ -2055,9 +2310,16 @@ public async Task ClearTableColumnClientStatus() await InvokeVoidAsync("clearColumnStates", ClientTableName); } + // 固定列期间清除状态时先还原自动回填的覆盖宽度 避免后续取消固定时宽度残留 + // 注意须在清空前还原 清空后 _tableColumnStates 为空将无法还原 + RestoreAutoFixedColumnWidths(); + // 清除缓存的列状态 _tableColumnStateCache.Clear(); + // 列状态已全部清除 占位标记同步清空 + _placeholderWidthColumnNames.Clear(); + StateHasChanged(); } @@ -2096,7 +2358,7 @@ public async Task DragColumnCallback(int originIndex, int currentIndex) if (OnTableColumnClientStatusChanged != null) { - await OnTableColumnClientStatusChanged(firstColumn.Name, _tableColumnStateCache); + await OnTableColumnClientStatusChanged(firstColumn.Name, GetTableColumnClientStatusForPersist()); } _resetColumns = true; @@ -2119,12 +2381,32 @@ public async Task ResizeColumnCallback(string name, TableColumnClientStatus colu // 触发回调 if (OnTableColumnClientStatusChanged != null) { - await OnTableColumnClientStatusChanged(name, _tableColumnStateCache); + await OnTableColumnClientStatusChanged(name, GetTableColumnClientStatusForPersist()); } StateHasChanged(); } + /// + /// 获得供宿主持久化的列状态副本 占位回填宽度以 null 输出 防止占位值被宿主存档后下一会话误认作真实宽度 + /// Get a copy of column states for host persistence. Placeholder widths are emitted as null so they are not mistaken for real widths in the next session. + /// + private TableColumnClientStatus GetTableColumnClientStatusForPersist() + { + var status = new TableColumnClientStatus() { TableWidth = _tableColumnStateCache.TableWidth }; + foreach (var item in _tableColumnStates) + { + status.Columns.Add(new TableColumnState() + { + Name = item.Name, + DisplayName = item.DisplayName, + Visible = item.Visible, + Width = _placeholderWidthColumnNames.Contains(item.Name) ? null : item.Width + }); + } + return status; + } + /// /// 列宽自适应回调方法 由 JavaScript 脚本调用 /// Auto Fit Column Width Callback called by JavaScript @@ -2143,6 +2425,12 @@ private void UpdateTableColumnState(TableColumnClientStatus columnState) if (colState != null) { item.Width = colState.Width; + + // 客户端回传的拖拽/实测宽度为真实宽度 移除占位标记 + if (colState.Width.HasValue) + { + _placeholderWidthColumnNames.Remove(item.Name); + } } } _tableColumnStateCache.TableWidth = columnState.TableWidth; 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 84220a3c520..ae1264dff23 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -9132,6 +9132,753 @@ 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()); + Assert.Contains("fixed", columns[0].ClassName ?? ""); + Assert.Contains("width: 200px", cut.FindAll("col")[0].OuterHtml); + } + + [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 DynamicFixedColumn_IgnoreColumn_Ok() + { + // 固定列时被忽略列没有列状态 同步列宽时跳过该列不影响固定列 + Context.JSInterop.Setup>("getColumnWidths", _ => true).SetResult(new Dictionary + { + { nameof(Foo.Name), 120 } + }); + + 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.AddAttribute(6, "Ignore", true); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 运行时固定第一列 被忽略的 Address 列无列状态 同步宽度时跳过 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = true; + }); + table.Render(); + + var columns = cut.FindAll("thead th"); + Assert.Contains("fixed", columns[0].ClassName); + Assert.Contains("left: 0px;", columns[0].OuterHtml); + Assert.Equal(120, table.Instance.Columns[0].Width); + } + + [Fact] + public async Task DynamicFixedColumn_PersistedDefaultWidthCollision_Ok() + { + // 持久化宽度恰与 DefaultFixedColumnWidth(200) 相同 该宽度是真实用户配置而非占位回填 应正常回放到列实例 + var state = new TableColumnClientStatus(); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Name), Visible = true, Width = 200 }); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Address), Visible = true }); + + Context.JSInterop.Setup("getColumnStates", "test_width_collision_default").SetResult(state); + + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.ClientTableName, "test_width_collision_default"); + 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>(); + + // 真实持久化宽度 200 回放到列实例 + Assert.Equal(200, table.Instance.Columns[0].Width); + + // 同步客户端状态后持久化宽度不被擦除 + var status = await cut.InvokeAsync(() => table.Instance.UpdateTableColumnClientStatus()); + Assert.Equal(200, status.Columns.Find(i => i.Name == nameof(Foo.Name))?.Width); + } + + [Fact] + public async Task DynamicFixedColumn_PersistedMinWidthCollision_Ok() + { + // 固定列持久化宽度恰与 ColumnMinWidth(64) 相同(用户曾拖拽至最小宽) 该宽度是真实用户配置 + // 应回放到列实例 保证 fixed 布局生效 sticky 偏移精确 + var state = new TableColumnClientStatus(); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Name), Visible = true, Width = 64 }); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Address), Visible = true, Width = 120 }); + + Context.JSInterop.Setup("getColumnStates", "test_width_collision_min").SetResult(state); + + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.ClientTableName, "test_width_collision_min"); + 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, "Fixed", true); + builder.CloseComponent(); + + builder.OpenComponent>(3); + builder.AddAttribute(4, "Field", foo.Address); + builder.AddAttribute(5, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(foo.Address), typeof(string))); + builder.AddAttribute(6, "Width", 120); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 真实持久化宽度 64 回放到列实例 fixed 布局生效 + Assert.Equal(64, table.Instance.Columns[0].Width); + Assert.Contains("table-layout-fixed", cut.Markup); + } + + [Fact] + public async Task DynamicFixedColumn_PlaceholderWidthNotLeakToHost_Ok() + { + // 占位回填宽仅服务客户端布局 不应经 OnTableColumnClientStatusChanged 泄漏给宿主持久化 + // 防止占位值被宿主存档后下一会话误认作真实宽度 + TableColumnClientStatus? captured = null; + + 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.AllowResizing, true); + pb.Add(a => a.OnTableColumnClientStatusChanged, (string name, TableColumnClientStatus status) => + { + captured = status; + return Task.CompletedTask; + }); + 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>(); + + // 模拟客户端拖拽 Name 列宽后回传列状态 Address 列仍为占位回填宽 + var dragState = new TableColumnClientStatus(); + dragState.Columns.Add(new TableColumnState() { Name = nameof(Foo.Name), Visible = true, Width = 100 }); + await cut.InvokeAsync(() => table.Instance.ResizeColumnCallback(nameof(Foo.Name), dragState)); + + Assert.NotNull(captured); + Assert.Equal(100, captured.Columns.Find(i => i.Name == nameof(Foo.Name))?.Width); + Assert.Null(captured.Columns.Find(i => i.Name == nameof(Foo.Address))?.Width); + } + + [Fact] + public async Task DynamicFixedColumn_PlaceholderWidthNotLeakOnDrag_Ok() + { + // 拖拽列顺序触发宿主持久化回调时 占位回填宽同样不泄漏 + TableColumnClientStatus? captured = null; + + 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.AllowDragColumn, true); + pb.Add(a => a.OnTableColumnClientStatusChanged, (string name, TableColumnClientStatus status) => + { + captured = status; + return Task.CompletedTask; + }); + 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>(); + + // 拖拽第一列到第二列位置 触发宿主持久化回调 + await cut.InvokeAsync(() => table.Instance.DragColumnCallback(0, 1)); + + Assert.NotNull(captured); + Assert.All(captured.Columns, c => Assert.Null(c.Width)); + } + [Fact] public async Task ClearTableColumnClientStatus_Ok() { @@ -9181,6 +9928,60 @@ public async Task ClearTableColumnClientStatus_Ok() Assert.Contains("style=\"width: 80px;\"", colGroup.ToMarkup()); } + [Fact] + public async Task ClearTableColumnClientStatus_RestoreAutoFixedWidth_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.ClearTableColumnClientStatus()); + + // 取消固定后 自动回填的宽度应还原为空 不残留在列实例上 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = false; + }); + table.Render(); + Assert.False(table.Instance.Columns[0].Width.HasValue); + } + [Fact] public void ColumnStates_PartialMatch_KeepColumnOrder_Ok() {