mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-17 05:31:01 +08:00
fix(Select): 修复下拉空数据缺少空数据指示并同步 AutoComplete/Mentions
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user