fix(Select): 修复下拉空数据缺少空数据指示并同步 AutoComplete/Mentions

This commit is contained in:
chinboy
2026-09-05 11:00:51 +08:00
parent ac5ca6bdb1
commit 40ff208c04
10 changed files with 288 additions and 4 deletions
+1
View File
@@ -214,4 +214,5 @@ cmake-build-*/
.superpowers/
.worktrees/
.zcode/
tmp/
docs/superpowers/plans
@@ -2,6 +2,11 @@
本文档记录 AutoComplete 控件级设计、API、主题契约、Token 和实现结构的变化。它不替代仓库根目录 `CHANGELOG.md`,也不作为正式版本发布说明。
## 2026-09-05
- Fix
- The shared `CandidateList` now maintains `IsDefaultEmptyIndicatorVisible`, so an AutoComplete dropdown with an empty or fully filtered option set shows the built-in default `Empty` indicator instead of a blank panel. Note: Ant Design's combobox-mode AutoComplete renders no default empty content; if combobox-parity is preferred, set `IsShowEmptyIndicator=False` on the candidate list theme.
## 2026-08-25
- Docs
@@ -8,6 +8,11 @@
- 不记录临时讨论、纯格式化或没有长期价值的实现细节。
- 架构文档始终描述最新设计状态;历史变化记录在本文档。
## 2026-09-05
- Fix
- The shared `CandidateList` now maintains `IsDefaultEmptyIndicatorVisible`, so a Mentions dropdown with no matching options shows the built-in default `Empty` indicator instead of a blank panel, matching Ant Design Mentions' default `notFoundContent` rendering. The `EmptyIndicator` / `EmptyIndicatorTemplate` / `IsShowEmptyIndicator` / `EmptyIndicatorPadding` public properties remain unwired for Mentions itself (see overview limitation note).
## 2026-08-25
- Docs
@@ -3,6 +3,18 @@
本文档记录 Select 控件级设计、API、主题契约、Token 和实现结构的变化。
它不替代仓库根目录 CHANGELOG.md,也不作为正式版本发布说明。
## 2026-09-05
- Fix
- Restore the built-in empty indicator for the dropdown: `SelectCandidateList.ConfigureEmptyIndicator()` now maintains `IsDefaultEmptyIndicatorVisible` like the `ListView` base, so an empty or fully filtered candidate list shows the default `Empty` (`PresetImage=Simple`, localized No data) instead of a blank panel, matching Ant Design's default `notFoundContent`.
- Wire `AbstractSelect.IsShowEmptyIndicator`, `EmptyIndicator` and `EmptyIndicatorTemplate` to the candidate list in `EnsurePopupContent()`; a custom empty indicator is honored and hides the default one. `EmptyIndicatorPadding` stays unwired (same as TreeSelect) so the theme per-size padding remains effective.
- Theme
- Add `MultiModePrefixIndent` / `MultiModePrefixIndentSM` / `MultiModePrefixIndentLG` tokens and apply them as the extra left `Margin` of `PART_ContentLeftAddOn` in `SelectAddOnDecoratedBoxTheme` for Multiple / Tags mode with a non-empty selection.
- The content frame keeps the small multi-mode left padding for the tag area while the prefix (ContentLeftAddOn) aligns with the single-mode horizontal padding, matching Ant Design's selector-padding + prefix-margin model.
- Tests
- Add headless tests for the empty dropdown indicator (default shown, custom honored, `IsShowEmptyIndicator=false` respected).
- Add headless layout tests asserting Multiple / Tags prefix left inset equals the Single mode inset, and a SelectToken unit test asserting `MultiModePadding*.Left + MultiModePrefixIndent*.Left == SingleModePadding*.Left` per size.
## 2026-08-25
- Docs
@@ -148,6 +148,8 @@ OpenDropDown()
`ClearPopupContent()` 必须解除 `_candidateList.SelectionChanged`、`Commit`、`Cancel`,清空 `ItemsSource`,移除 `TemplatedParent`,断开 `PopupFrame.Child` 和 `Popup.Child`,并清空 `_candidateListActivated`。
空候选状态由 `EnsurePopupContent()` 中的绑定驱动:`AbstractSelect.IsShowEmptyIndicator`、`EmptyIndicator` 和 `EmptyIndicatorTemplate` 绑定到候选列表的对应 `ListView` 属性。`SelectCandidateList.ConfigureEmptyIndicator()` 覆写必须同时维护 `IsEffectiveEmptyVisible` 与 `IsDefaultEmptyIndicatorVisible`:未提供自定义 `EmptyIndicator` / `EmptyIndicatorTemplate` 时显示主题内置的默认 `Empty`(`PresetImage=Simple`,对应 Ant Design 的 No data);`EmptyIndicatorPadding` 不在此处接线,由 `ListViewTheme` 按尺寸提供默认值。
Popup 打开期间,`AbstractSelect.PopupOpened()` 创建 `SubscriptionsOnOpen`,订阅自身和祖先可见性变化;Popup 关闭时清空这些订阅。控件 detach 时释放 TopLevel deactivation 订阅,窗口失活时关闭弹层。
## 6. 交互与事件处理
@@ -592,6 +592,8 @@ public class CandidateList : ListBox, ICandidateList
{
SetCurrentValue(IsEffectiveEmptyVisibleProperty, isEffectiveEmptyVisible);
}
SetCurrentValue(IsDefaultEmptyIndicatorVisibleProperty,
IsEffectiveEmptyVisible && EmptyIndicator is null && EmptyIndicatorTemplate is null);
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+6 -4
View File
@@ -393,12 +393,14 @@ public partial class Select : AbstractSelect
{
_candidateList = new SelectCandidateList
{
Name = "PART_CandidateList",
BorderThickness = new Thickness(0),
IsShowEmptyIndicator = true,
ItemsSource = _effectiveOptions
Name = "PART_CandidateList",
BorderThickness = new Thickness(0),
ItemsSource = _effectiveOptions
};
_candidateList.SetTemplatedParent(this);
_candidateList[!ListView.IsShowEmptyIndicatorProperty] = this[!IsShowEmptyIndicatorProperty];
_candidateList[!ListView.EmptyIndicatorProperty] = this[!EmptyIndicatorProperty];
_candidateList[!ListView.EmptyIndicatorTemplateProperty] = this[!EmptyIndicatorTemplateProperty];
_candidateList[!ListView.FilterProperty] = this[!FilterProperty];
_candidateList[!ListView.FilterValueProperty] = this[!FilterValueProperty];
_candidateList[!ListView.FilterValueSelectorProperty] = this[!FilterValueSelectorProperty];
@@ -791,6 +791,8 @@ internal class SelectCandidateList : ListView, ICandidateList
{
SetCurrentValue(IsEffectiveEmptyVisibleProperty, IsShowEmptyIndicator && (IsEmptyDataSource || !HasAnyVisibleItem));
}
SetCurrentValue(IsDefaultEmptyIndicatorVisibleProperty,
IsEffectiveEmptyVisible && EmptyIndicator is null && EmptyIndicatorTemplate is null);
}
protected override Size ArrangeOverride(Size finalSize)
@@ -0,0 +1,94 @@
using System.Reflection;
using AtomUI.Controls;
using AtomUI.Controls.Primitives;
using AtomUI.Desktop.Controls.Primitives;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Shouldly;
using Xunit;
using AvaloniaWindow = Avalonia.Controls.Window;
namespace AtomUI.Desktop.Controls.Tests.AutoComplete;
public class AutoCompleteEmptyIndicatorTests
{
static AutoCompleteEmptyIndicatorTests()
{
AvaloniaTestApp.EnsureInitialized();
}
[Fact]
public void Empty_Options_Dropdown_Shows_Default_Empty_Indicator()
{
var autoComplete = new Desktop.Controls.AutoComplete
{
Width = 240,
IsMotionEnabled = false,
Value = "A",
OptionsSource = new List<IAutoCompleteOption>()
};
ShowInWindow(autoComplete, window =>
{
autoComplete.IsPopupPinnedOpen = true;
Dispatcher.UIThread.RunJobs();
var candidateList = window.GetVisualDescendants()
.OfType<CandidateList>()
.Single(c => c.Name == AutoCompleteThemeConstants.CandidateListPart);
candidateList.IsEffectiveEmptyVisible.ShouldBeTrue();
candidateList.IsDefaultEmptyIndicatorVisible
.ShouldBeTrue("空数据源时应显示内置默认空指示(对应 Ant Design 的 No data)");
var defaultEmpty = window.GetVisualDescendants()
.OfType<Desktop.Controls.Empty>()
.Single(e => e.Name == "DefaultEmptyIndicator");
defaultEmpty.IsVisible.ShouldBeTrue();
});
}
private static void ShowInWindow(Control content, Action<AvaloniaWindow> assertion)
{
var overlayPanel = new ScopeAwareOverlayLayerPanel
{
Width = 420,
Height = 320
};
overlayPanel.Children.Add(content);
var visualLayerManager = new VisualLayerManager
{
Child = overlayPanel
};
EnablePopupOverlayLayer(visualLayerManager);
var window = new AvaloniaWindow
{
Width = 420,
Height = 320,
Content = visualLayerManager
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
assertion(window);
}
finally
{
window.Close();
}
}
private static void EnablePopupOverlayLayer(VisualLayerManager visualLayerManager)
{
var property = typeof(VisualLayerManager).GetProperty(
"EnablePopupOverlayLayer",
BindingFlags.Instance | BindingFlags.NonPublic);
property.ShouldNotBeNull();
property.SetValue(visualLayerManager, true);
}
}
@@ -0,0 +1,159 @@
using System.Reflection;
using AtomUI.Controls;
using AtomUI.Controls.Primitives;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Shouldly;
using Xunit;
using AvaloniaWindow = Avalonia.Controls.Window;
namespace AtomUI.Desktop.Controls.Tests.SelectControl;
public class SelectEmptyIndicatorTests
{
static SelectEmptyIndicatorTests()
{
AvaloniaTestApp.EnsureInitialized();
}
[Fact]
public void Empty_Options_Dropdown_Shows_Default_Empty_Indicator()
{
var select = new Desktop.Controls.Select
{
Width = 240,
IsMotionEnabled = false,
OptionsSource = new List<ISelectOption>()
};
ShowInWindow(select, window =>
{
select.IsPopupPinnedOpen = true;
Dispatcher.UIThread.RunJobs();
var candidateList = GetCandidateList(select);
candidateList.ShouldNotBeNull();
candidateList.IsEffectiveEmptyVisible.ShouldBeTrue();
candidateList.IsDefaultEmptyIndicatorVisible
.ShouldBeTrue("空数据源时应显示内置默认空指示(对应 Ant Design 的 No data)");
var defaultEmpty = window.GetVisualDescendants()
.OfType<Desktop.Controls.Empty>()
.Single(e => e.Name == "DefaultEmptyIndicator");
defaultEmpty.IsVisible.ShouldBeTrue();
});
}
[Fact]
public void Custom_EmptyIndicator_Should_Be_Honored_And_Hide_Default()
{
var select = new Desktop.Controls.Select
{
Width = 240,
IsMotionEnabled = false,
OptionsSource = new List<ISelectOption>(),
EmptyIndicator = "Custom Empty Content"
};
ShowInWindow(select, window =>
{
select.IsPopupPinnedOpen = true;
Dispatcher.UIThread.RunJobs();
var candidateList = GetCandidateList(select);
candidateList.ShouldNotBeNull();
candidateList.EmptyIndicator.ShouldBe("Custom Empty Content");
candidateList.IsDefaultEmptyIndicatorVisible
.ShouldBeFalse("自定义 EmptyIndicator 时不应再显示默认空指示");
var indicatorPresenter = window.GetVisualDescendants()
.OfType<ContentPresenter>()
.Single(p => p.Name == "EmptyIndicator");
indicatorPresenter.IsVisible.ShouldBeTrue();
var defaultEmpty = window.GetVisualDescendants()
.OfType<Desktop.Controls.Empty>()
.Single(e => e.Name == "DefaultEmptyIndicator");
defaultEmpty.IsVisible.ShouldBeFalse();
});
}
[Fact]
public void IsShowEmptyIndicator_False_Hides_Empty_Indicator()
{
var select = new Desktop.Controls.Select
{
Width = 240,
IsMotionEnabled = false,
OptionsSource = new List<ISelectOption>(),
IsShowEmptyIndicator = false
};
ShowInWindow(select, window =>
{
select.IsPopupPinnedOpen = true;
Dispatcher.UIThread.RunJobs();
var candidateList = GetCandidateList(select);
candidateList.ShouldNotBeNull();
candidateList.IsEffectiveEmptyVisible.ShouldBeFalse();
candidateList.IsDefaultEmptyIndicatorVisible.ShouldBeFalse();
});
}
private static SelectCandidateList? GetCandidateList(Desktop.Controls.Select select)
{
var field = typeof(Desktop.Controls.Select).GetField(
"_candidateList",
BindingFlags.Instance | BindingFlags.NonPublic);
field.ShouldNotBeNull();
return field.GetValue(select) as SelectCandidateList;
}
private static void ShowInWindow(Control content, Action<AvaloniaWindow> assertion)
{
var overlayPanel = new ScopeAwareOverlayLayerPanel
{
Width = 420,
Height = 320
};
overlayPanel.Children.Add(content);
var visualLayerManager = new VisualLayerManager
{
Child = overlayPanel
};
EnablePopupOverlayLayer(visualLayerManager);
var window = new AvaloniaWindow
{
Width = 420,
Height = 320,
Content = visualLayerManager
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
assertion(window);
}
finally
{
window.Close();
}
}
private static void EnablePopupOverlayLayer(VisualLayerManager visualLayerManager)
{
var property = typeof(VisualLayerManager).GetProperty(
"EnablePopupOverlayLayer",
BindingFlags.Instance | BindingFlags.NonPublic);
property.ShouldNotBeNull();
property.SetValue(visualLayerManager, true);
}
}