diff --git a/.gitignore b/.gitignore index f20709c08..005355849 100644 --- a/.gitignore +++ b/.gitignore @@ -214,4 +214,5 @@ cmake-build-*/ .superpowers/ .worktrees/ .zcode/ +tmp/ docs/superpowers/plans diff --git a/docs/controls/desktop/data-entry/auto-complete/changelog.md b/docs/controls/desktop/data-entry/auto-complete/changelog.md index c84311b6f..e7c8525c5 100644 --- a/docs/controls/desktop/data-entry/auto-complete/changelog.md +++ b/docs/controls/desktop/data-entry/auto-complete/changelog.md @@ -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 diff --git a/docs/controls/desktop/data-entry/mentions/changelog.md b/docs/controls/desktop/data-entry/mentions/changelog.md index e6cc2be77..5193d9879 100644 --- a/docs/controls/desktop/data-entry/mentions/changelog.md +++ b/docs/controls/desktop/data-entry/mentions/changelog.md @@ -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 diff --git a/docs/controls/desktop/data-entry/select/changelog.md b/docs/controls/desktop/data-entry/select/changelog.md index 7842c0158..9dd3467be 100644 --- a/docs/controls/desktop/data-entry/select/changelog.md +++ b/docs/controls/desktop/data-entry/select/changelog.md @@ -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 diff --git a/docs/controls/desktop/data-entry/select/implementation.md b/docs/controls/desktop/data-entry/select/implementation.md index 2c89f1f62..47ea29f00 100644 --- a/docs/controls/desktop/data-entry/select/implementation.md +++ b/docs/controls/desktop/data-entry/select/implementation.md @@ -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. 交互与事件处理 diff --git a/src/AtomUI.Desktop.Controls/Primitives/CandidateList/CandidateList.cs b/src/AtomUI.Desktop.Controls/Primitives/CandidateList/CandidateList.cs index 0d4a1b8ab..7ac6d6ae8 100644 --- a/src/AtomUI.Desktop.Controls/Primitives/CandidateList/CandidateList.cs +++ b/src/AtomUI.Desktop.Controls/Primitives/CandidateList/CandidateList.cs @@ -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) diff --git a/src/AtomUI.Desktop.Controls/Select/Select.cs b/src/AtomUI.Desktop.Controls/Select/Select.cs index 449da02f2..878a39a19 100644 --- a/src/AtomUI.Desktop.Controls/Select/Select.cs +++ b/src/AtomUI.Desktop.Controls/Select/Select.cs @@ -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]; diff --git a/src/AtomUI.Desktop.Controls/Select/SelectCandidateList.cs b/src/AtomUI.Desktop.Controls/Select/SelectCandidateList.cs index 3cf74a3c6..f3ba57cba 100644 --- a/src/AtomUI.Desktop.Controls/Select/SelectCandidateList.cs +++ b/src/AtomUI.Desktop.Controls/Select/SelectCandidateList.cs @@ -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) diff --git a/tests/AtomUI.Desktop.Controls.Tests/AutoComplete/AutoCompleteEmptyIndicatorTests.cs b/tests/AtomUI.Desktop.Controls.Tests/AutoComplete/AutoCompleteEmptyIndicatorTests.cs new file mode 100644 index 000000000..a9f31c931 --- /dev/null +++ b/tests/AtomUI.Desktop.Controls.Tests/AutoComplete/AutoCompleteEmptyIndicatorTests.cs @@ -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() + }; + + ShowInWindow(autoComplete, window => + { + autoComplete.IsPopupPinnedOpen = true; + Dispatcher.UIThread.RunJobs(); + + var candidateList = window.GetVisualDescendants() + .OfType() + .Single(c => c.Name == AutoCompleteThemeConstants.CandidateListPart); + candidateList.IsEffectiveEmptyVisible.ShouldBeTrue(); + candidateList.IsDefaultEmptyIndicatorVisible + .ShouldBeTrue("空数据源时应显示内置默认空指示(对应 Ant Design 的 No data)"); + + var defaultEmpty = window.GetVisualDescendants() + .OfType() + .Single(e => e.Name == "DefaultEmptyIndicator"); + defaultEmpty.IsVisible.ShouldBeTrue(); + }); + } + + private static void ShowInWindow(Control content, Action 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); + } +} diff --git a/tests/AtomUI.Desktop.Controls.Tests/Select/SelectEmptyIndicatorTests.cs b/tests/AtomUI.Desktop.Controls.Tests/Select/SelectEmptyIndicatorTests.cs new file mode 100644 index 000000000..683d5c2e9 --- /dev/null +++ b/tests/AtomUI.Desktop.Controls.Tests/Select/SelectEmptyIndicatorTests.cs @@ -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() + }; + + 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() + .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(), + 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() + .Single(p => p.Name == "EmptyIndicator"); + indicatorPresenter.IsVisible.ShouldBeTrue(); + + var defaultEmpty = window.GetVisualDescendants() + .OfType() + .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(), + 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 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); + } +}