diff --git a/docs/controls/desktop/data-entry/auto-complete/changelog.md b/docs/controls/desktop/data-entry/auto-complete/changelog.md index ae5171505..d19f364a2 100644 --- a/docs/controls/desktop/data-entry/auto-complete/changelog.md +++ b/docs/controls/desktop/data-entry/auto-complete/changelog.md @@ -2,6 +2,16 @@ 本文档记录 AutoComplete 控件级设计、API、主题契约、Token 和实现结构的变化。它不替代仓库根目录 `CHANGELOG.md`,也不作为正式版本发布说明。 +## 2026-08-19 + +- Behavior + - Unify pointer and keyboard candidate navigation through the shared `CandidateList` active candidate owner. + - Keep pointer movement non-committing and non-scrolling while ensuring `Enter` commits the single visual active candidate. +- Theme + - Stop inherited `:pointerover` styling from rendering a second candidate highlight; committed selection remains visually dominant. +- Tests + - Add popup integration coverage for pointer migration followed by `Enter` commit. + ## 2026-06-26 - Docs diff --git a/docs/controls/desktop/data-entry/auto-complete/implementation.md b/docs/controls/desktop/data-entry/auto-complete/implementation.md index 5497f234b..9511aaad7 100644 --- a/docs/controls/desktop/data-entry/auto-complete/implementation.md +++ b/docs/controls/desktop/data-entry/auto-complete/implementation.md @@ -1,6 +1,6 @@ # AutoComplete 桌面版实现原理 -本文档描述 AutoComplete 桌面版的内部实现范围、源码职责、状态流、生命周期、资源边界和维护规则。公共设计与 API 契约见 [AutoComplete 桌面版架构设计](overview.md),变化记录见 [AutoComplete Changelog](changelog.md)。涉及控件 Token 的实现应同时阅读 [AutoComplete Token 设计](token.md)。 +本文档描述 AutoComplete 桌面版的内部实现范围、源码职责、状态流、生命周期、资源边界和维护规则。公共设计与 API 契约见 [AutoComplete 桌面版架构设计](overview.md),候选列表状态契约见 [候选列表统一交互设计](../select/candidate-interaction-design.md),变化记录见 [AutoComplete Changelog](changelog.md)。涉及控件 Token 的实现应同时阅读 [AutoComplete Token 设计](token.md)。 ## 1. 实现定位 @@ -126,6 +126,8 @@ AutoComplete 的交互事件应从输入源收敛到控件级语义事件: - ItemsSource、selection、checked、expanded、filter、paging 或 upload task 的集合同步。 - 动效启停、初始加载阶段 transition 抑制和卸载取消。 +候选交互必须遵循统一 active candidate 流:`CandidateList` 保存唯一候选 owner,鼠标命中可用项时迁移该状态但不滚动,键盘导航复用同一写入路径并可滚动到可见位置;容器回收、过滤或 source 重建时只恢复 owner 状态,不把 `:pointerover` 当作独立候选来源。`Enter` 从 active candidate 读取提交目标,不能从旧的 `SelectedItem` 或指针命中状态重新推导。 + 实现文档不逐行解释私有方法。若某个私有算法成为稳定维护入口,应在本节补充算法不变量,而不是把代码复述为说明书。 ## 8. 资源、性能与 AOT 边界 diff --git a/docs/controls/desktop/data-entry/auto-complete/overview.md b/docs/controls/desktop/data-entry/auto-complete/overview.md index 9a9a8c95f..f7ba86f5d 100644 --- a/docs/controls/desktop/data-entry/auto-complete/overview.md +++ b/docs/controls/desktop/data-entry/auto-complete/overview.md @@ -1,6 +1,6 @@ # AutoComplete 桌面版架构设计 -本文档定义 `AutoComplete` 桌面版的最新设计定位、公共契约、状态模型、视觉主题关系和兼容边界。通用控件研发约束见 [控件研发标准](../../../../engineering/development/control-development-guidelines.md),内部实现原理见 [AutoComplete 桌面版实现原理](implementation.md),AutoComplete Token 的专项设计见 [AutoComplete Token 设计](token.md),设计和契约变化记录见 [AutoComplete Changelog](changelog.md)。 +本文档定义 `AutoComplete` 桌面版的最新设计定位、公共契约、状态模型、视觉主题关系和兼容边界。通用控件研发约束见 [控件研发标准](../../../../engineering/development/control-development-guidelines.md),候选列表统一交互见 [候选列表统一交互设计](../select/candidate-interaction-design.md),内部实现原理见 [AutoComplete 桌面版实现原理](implementation.md),AutoComplete Token 的专项设计见 [AutoComplete Token 设计](token.md),设计和契约变化记录见 [AutoComplete Changelog](changelog.md)。 ## 1. 控件定位 @@ -140,7 +140,7 @@ AutoComplete 与同分类控件共享尺寸、状态、Token、Gallery 展示和 ### 8.1 选择与当前项模型 -AutoComplete 的当前项状态必须由单一 owner 推导。public 选择属性、集合项容器和伪类之间只能做单向同步,集合替换、清空和模板重套用时必须回放当前状态。 +AutoComplete 的候选当前项由弹层中的 `CandidateList` 作为单一 active candidate owner。鼠标移动和 `Up` / `Down` 都只迁移这个候选,不提前改变输入值;`Enter` 使用同一个 active candidate 提交,提交后才更新 `Value` / `SelectionChanged` 并按控件契约关闭弹层。候选项容器只投影 active、selected 和 disabled 状态,`:pointerover` 不再形成第二个候选高亮。过滤结果、异步加载、集合替换、清空和模板重套用时,失效的 active candidate 必须被清除或重新从当前视图解析。 ### 8.2 弹层与宿主模型 diff --git a/docs/controls/desktop/data-entry/cascader/changelog.md b/docs/controls/desktop/data-entry/cascader/changelog.md index a1c79bae5..7b288e2d2 100644 --- a/docs/controls/desktop/data-entry/cascader/changelog.md +++ b/docs/controls/desktop/data-entry/cascader/changelog.md @@ -3,6 +3,17 @@ 本文档记录 Cascader 控件级设计、API、主题契约、Token 和实现结构的变化。 它不替代仓库根目录 CHANGELOG.md,也不作为正式版本发布说明。 +## 2026-08-19 + +- Behavior + - Make pointer movement and keyboard navigation share one active candidate in both ordinary tree columns and filtered path results. + - Preserve hover-triggered expansion while excluding disabled, hidden, and loading nodes from active candidate ownership. + - Make `Enter` commit the filtered or tree candidate represented by the single active visual state. +- Theme + - Remove independent pointer-over candidate highlighting while preserving expanded and selected visual precedence. +- Tests + - Add mixed pointer/keyboard and pointer-to-`Enter` coverage for tree and filtered modes. + ## 2026-07-05 - API diff --git a/docs/controls/desktop/data-entry/cascader/implementation.md b/docs/controls/desktop/data-entry/cascader/implementation.md index f17ee4809..4d1d5d8a4 100644 --- a/docs/controls/desktop/data-entry/cascader/implementation.md +++ b/docs/controls/desktop/data-entry/cascader/implementation.md @@ -1,6 +1,6 @@ # Cascader 桌面版实现原理 -本文档描述 Cascader 桌面版的输入壳体、选项集合、级联展开、选择同步、勾选、过滤、异步加载、绑定型选项和资源生命周期。公共设计与 API 契约见 [Cascader 桌面版架构设计](overview.md),Token 语义见 [Cascader Token 设计](token.md),变化记录见 [Cascader Changelog](changelog.md)。 +本文档描述 Cascader 桌面版的输入壳体、选项集合、级联展开、选择同步、勾选、过滤、异步加载、绑定型选项和资源生命周期。公共设计与 API 契约见 [Cascader 桌面版架构设计](overview.md),候选列表状态契约见 [候选列表统一交互设计](../select/candidate-interaction-design.md),Token 语义见 [Cascader Token 设计](token.md),变化记录见 [Cascader Changelog](changelog.md)。 ## 1. 实现定位 @@ -149,7 +149,7 @@ container clear / detach -> dispose attachment and subscriptions `CascaderView.OnApplyTemplate()` 获取 `PART_ItemsPanel`、`PART_RootLevelList` 和 `PART_FilterList`。Root level list 设置 `Level=1` 和 `OwnerView=this`,filter list 必须解除旧 selection 订阅再订阅新实例。 -`CascaderView` 分别持有树形 `_keyboardCandidateItem` 与 `CascaderViewFilterList` 的过滤候选。过滤结果重建或清空、filter list 重套模板、以及 pointer 在过滤列表内移动时清除过滤候选;单选 popup 关闭时 `ResetInteractionState()` 清除两类候选并折叠展开路径。虚拟化树容器回收时必须清除 `IsCandidateSelectedProperty`,不能让候选视觉进入下一个 option 的复用容器。 +`CascaderView` 分别持有树列和过滤列的 active candidate owner。普通树列的 pointer move 先把 enabled item 提升为 active candidate,再按 `ExpandTrigger` 执行展开;过滤列 pointer move 同样迁移过滤候选,不再无条件清除。过滤结果重建或清空、filter list 重套模板、popup 关闭时 `ResetInteractionState()` 清除两类候选并折叠展开路径。虚拟化树容器回收时必须清除 `IsCandidateSelectedProperty`,不能让候选视觉进入下一个 option 的复用容器。 `CascaderViewLevelList.ContainerForItemPreparedOverride()` 对每个 `ICascaderOption` 调用 `CascaderViewItem.PrepareCascaderOptionData()`。如果 option 是 `BindableCascaderOption`,容器创建同一生命周期的 `CompositeDisposable`,用于保存 resource host attachment、属性订阅和 children 集合订阅。 @@ -167,6 +167,7 @@ Pointer 路径: - popup 内 pointer 事件不触发外层关闭。 - tag close button 点击不触发外层 popup toggle。 - `CascaderViewLevelList` 点击 option 时先触发 item click,再按 `ExpandTrigger` 决定是否展开。 +- `CascaderViewLevelList` / `CascaderViewFilterList` 的 pointer move 只负责把命中的 enabled item 迁移为 active candidate;普通列的鼠标路径不滚动,过滤列不因 pointer move 无条件清除候选。 键盘路径: diff --git a/docs/controls/desktop/data-entry/cascader/overview.md b/docs/controls/desktop/data-entry/cascader/overview.md index bdbf93817..1bd2e3b20 100644 --- a/docs/controls/desktop/data-entry/cascader/overview.md +++ b/docs/controls/desktop/data-entry/cascader/overview.md @@ -1,6 +1,6 @@ # Cascader 桌面版架构设计 -本文档定义 `AtomUI.Desktop.Controls.Cascader` 的最新设计定位、公共契约、状态模型、视觉主题关系和兼容边界。通用控件研发约束见 [控件研发标准](../../../../engineering/development/control-development-guidelines.md),内部实现原理见 [Cascader 桌面版实现原理](implementation.md),Cascader Token 的专项设计见 [Cascader Token 设计](token.md),设计和契约变化记录见 [Cascader Changelog](changelog.md)。 +本文档定义 `AtomUI.Desktop.Controls.Cascader` 的最新设计定位、公共契约、状态模型、视觉主题关系和兼容边界。通用控件研发约束见 [控件研发标准](../../../../engineering/development/control-development-guidelines.md),候选列表统一交互见 [候选列表统一交互设计](../select/candidate-interaction-design.md),内部实现原理见 [Cascader 桌面版实现原理](implementation.md),Cascader Token 的专项设计见 [Cascader Token 设计](token.md),设计和契约变化记录见 [Cascader Changelog](changelog.md)。 ## 1. 控件定位 @@ -181,11 +181,13 @@ input display + Form value - `FilterValue` 非空且控件 loaded 时,CascaderView 收集所有叶子路径并按 `Filter` 过滤。 - 过滤结果显示完整路径文本,选中过滤结果后回写目标 option。 - 过滤模式下,`Up` / `Down` 在可用结果间循环移动内部候选高亮,不修改 `SelectedOption`;`Enter` 提交当前候选,尚无候选时提交第一个可用结果,没有可用结果时保持选择和 popup 状态不变。路径中任一祖先 disabled 时,该过滤结果也不可作为候选或提交。 +- 过滤列表拥有独立于树列的 active candidate owner;过滤结果重建、过滤清空、popup 关闭和容器回收时清除旧候选。树列与过滤列不会同时保留两个候选视觉。 - 清空过滤值或关闭 popup 后,过滤列表、过滤计数和缓存路径会被清理。 树形键盘导航: - popup 打开且未过滤时,`Up` / `Down` 在当前已展开列的可见 enabled item 间循环移动内部候选;候选高亮与真实选择相互独立。 +- 普通树列的 active candidate 由 `CascaderView` 单一持有;鼠标移动到 enabled item 时迁移该候选并继续执行 `ExpandTrigger=Hover` 的展开逻辑,但不提前提交选择、不滚动列表。`Enter` 使用同一 active candidate 作为选择或展开目标。 - `Right` 从当前候选或第一个可见 enabled item 开始,展开可展开节点并把候选移到下一列的第一个 enabled child。 - `Left` 优先把子级候选移回父级;候选已位于展开的根级非叶节点时折叠该节点。 - `Enter` 提交 enabled、非 loading 的叶子候选;`IsAllowSelectParent=true` 时也可提交父节点,否则沿用 `Right` 的展开并进入子级行为。 @@ -220,7 +222,7 @@ Cascader 的默认视觉由 Cascader 根主题、输入壳体、PopupHost、Casc - `OptionTemplate` 的 DataContext 必须保持为 `ICascaderOption`,不能改为 header 文本。 - Popup 宽度和空状态宽度匹配语义必须保持:普通级联列使用列宽,空状态需要匹配输入宽度。 - 选项行的 checkbox、icon、header、expand / loading icon 间距由 Token 管理,不应在单个模板节点中写死。 -- disabled、expanded、pointerover、checked 和 loading 状态 selector 不能被绕过。 +- disabled、expanded、pointerover、checked、loading 和 active candidate 状态 selector 不能被绕过;`pointerover` 只触发 active candidate 迁移,不独立绘制第二个候选背景。 ## 6. 控件家族或集成关系 diff --git a/docs/controls/desktop/data-entry/mentions/changelog.md b/docs/controls/desktop/data-entry/mentions/changelog.md index 0d6cf33a4..f8753d28d 100644 --- a/docs/controls/desktop/data-entry/mentions/changelog.md +++ b/docs/controls/desktop/data-entry/mentions/changelog.md @@ -8,6 +8,16 @@ - 不记录临时讨论、纯格式化或没有长期价值的实现细节。 - 架构文档始终描述最新设计状态;历史变化记录在本文档。 +## 2026-08-19 + +- Behavior + - Unify pointer and keyboard candidate navigation through the shared `CandidateList` active candidate owner. + - Keep the current mention trigger context intact while `Enter` inserts the single visual active candidate. +- Theme + - Stop inherited `:pointerover` styling from rendering a second candidate highlight; committed selection remains visually dominant. +- Tests + - Add trigger-popup integration coverage for pointer migration followed by mention insertion. + ## 2026-07-06 - API diff --git a/docs/controls/desktop/data-entry/mentions/implementation.md b/docs/controls/desktop/data-entry/mentions/implementation.md index 326e7a515..272940d10 100644 --- a/docs/controls/desktop/data-entry/mentions/implementation.md +++ b/docs/controls/desktop/data-entry/mentions/implementation.md @@ -1,6 +1,6 @@ # Mentions 桌面版实现原理 -本文档描述 Mentions 桌面版的内部模板组合、触发符识别、候选弹层、同步/异步加载、过滤、候选插入、Form 和 Token 资源边界。公共设计与 API 契约见 [Mentions 桌面版架构设计](overview.md),Token 语义见 [Mentions Token 设计](token.md),变化记录见 [Mentions Changelog](changelog.md)。 +本文档描述 Mentions 桌面版的内部模板组合、触发符识别、候选弹层、同步/异步加载、过滤、候选插入、Form 和 Token 资源边界。公共设计与 API 契约见 [Mentions 桌面版架构设计](overview.md),候选列表状态契约见 [候选列表统一交互设计](../select/candidate-interaction-design.md),Token 语义见 [Mentions Token 设计](token.md),变化记录见 [Mentions Changelog](changelog.md)。 ## 1. 实现定位 @@ -27,7 +27,7 @@ Mentions 的实现以 `MentionTextArea` 为输入内核,`Popup` 和 `Candidate `MentionTextArea` 是文本输入和 trigger 检测边界。它继承 `TextArea`,复用 TextArea 的多行输入、清除、状态、Form feedback 和尺寸能力,并增加 `TriggerPrefix`、`FilterValue`、`IsDropDownOpen`、`CandidateOpenRequest` 和 `CandidateCloseRequest`。 -`CandidateList` 是候选选择边界。Mentions 只依赖 `ICandidateList` 的 `ItemsSource`、`SelectedItem`、`Commit`、`Cancel` 和 `HandleKeyDown()`,不直接管理候选项容器。 +`CandidateList` 是候选选择边界和 active candidate owner。Mentions 只依赖 `ICandidateList` 的 `ItemsSource`、`SelectedItem`、`Commit`、`Cancel` 和 `HandleKeyDown()`,不直接管理候选项容器;鼠标候选迁移、键盘导航和 `Enter` 提交必须最终读取同一 active candidate。 `IMentionOptionsAsyncLoader` 是异步数据边界。控件只调用 `LoadAsync(context, token)`,不假设远程协议、缓存策略或错误显示方式。 @@ -122,6 +122,8 @@ Popup 打开时创建 `_subscriptionsOnOpen`,订阅自身 `IsVisible`、`IsEna - 弹层打开时点击非文本区域会关闭弹层。 - 普通按下状态通过标准 `:pressed` 伪类表达。 +候选项的 `:pointerover` 只表示指针命中事实,不单独绘制候选 active 背景。候选列表把鼠标移动到可用项转换为 active candidate 迁移;键盘路径复用同一 owner,且只有键盘路径负责滚动到可见区域。 + 候选提交: ```text diff --git a/docs/controls/desktop/data-entry/mentions/overview.md b/docs/controls/desktop/data-entry/mentions/overview.md index ea76c7ff0..5d2661eca 100644 --- a/docs/controls/desktop/data-entry/mentions/overview.md +++ b/docs/controls/desktop/data-entry/mentions/overview.md @@ -1,6 +1,6 @@ # Mentions 桌面版架构设计 -本文档定义 `AtomUI.Desktop.Controls.Mentions` 的最新设计定位、公共契约、状态模型、视觉主题关系和兼容边界。通用控件研发约束见 [控件研发标准](../../../../engineering/development/control-development-guidelines.md),内部实现原理见 [Mentions 桌面版实现原理](implementation.md),Token 专项设计见 [Mentions Token 设计](token.md),设计和契约变化记录见 [Mentions Changelog](changelog.md)。 +本文档定义 `AtomUI.Desktop.Controls.Mentions` 的最新设计定位、公共契约、状态模型、视觉主题关系和兼容边界。通用控件研发约束见 [控件研发标准](../../../../engineering/development/control-development-guidelines.md),候选列表统一交互见 [候选列表统一交互设计](../select/candidate-interaction-design.md),内部实现原理见 [Mentions 桌面版实现原理](implementation.md),Token 专项设计见 [Mentions Token 设计](token.md),设计和契约变化记录见 [Mentions Changelog](changelog.md)。 ## 1. 控件定位 @@ -225,7 +225,7 @@ Mentions 属于 Data Entry 文本输入控件家族,与 LineEdit/TextArea 共 Mentions 将 `OptionsSource` 缓存为 `List`,再根据 `FilterValue` 构造当前候选视图。默认过滤器是 contains。`FilterValueSelector` 存在时使用 selector 返回值;否则按 `Header`、`Value`、`Key` 的顺序选择过滤文本。 -过滤过程通过 `_filterInAction` 和 `_cancelRequested` 避免重入时生成过期视图。刷新完成后,候选列表自动选中第一项。 +过滤过程通过 `_filterInAction` 和 `_cancelRequested` 避免重入时生成过期视图。刷新完成后,`CandidateList` 从当前有效视图建立 active candidate;鼠标移动和键盘导航共享同一候选状态,`Enter` 使用该状态插入 mention。过滤、异步结果替换或候选列表回收时,旧候选必须失效,不能由 pointer-over 视觉继续保留。 ### 8.2 异步加载模型 diff --git a/docs/controls/desktop/data-entry/select/candidate-interaction-design.md b/docs/controls/desktop/data-entry/select/candidate-interaction-design.md index 5ece2e817..4b001d2a8 100644 --- a/docs/controls/desktop/data-entry/select/candidate-interaction-design.md +++ b/docs/controls/desktop/data-entry/select/candidate-interaction-design.md @@ -1,10 +1,10 @@ -# Select 候选交互设计 +# 候选列表统一交互设计 -本文档定义 Select 候选弹层中鼠标、键盘、已确认选择和虚拟化容器之间的统一候选交互模型。控件总体设计见 [Select 桌面版架构设计](overview.md),源码职责与生命周期见 [Select 桌面版实现原理](implementation.md)。 +本文档定义 AtomUI 下拉候选列表家族中鼠标、键盘、已确认选择和虚拟化容器之间的统一候选交互模型。Select 是该契约的最早实现;AutoComplete、Mentions、ComboBox 和 Cascader 的候选列表复用同一状态语义,但由各自控件负责把 active candidate 映射到自己的提交和展开流程。Select 的总体设计见 [Select 桌面版架构设计](overview.md),源码职责与生命周期见 [Select 桌面版实现原理](implementation.md)。 ## 1. 设计定位 -Select 使用 active candidate 表达用户在候选弹层中的当前操作目标。active candidate 可以由鼠标移动或键盘导航产生,是 `Enter` 提交或切换选择的唯一候选目标。 +候选列表使用 active candidate 表达用户在候选弹层中的当前操作目标。active candidate 可以由鼠标移动或键盘导航产生,是 `Enter` 提交、切换选择或继续展开的唯一候选目标。 该模型覆盖: @@ -15,9 +15,23 @@ Select 使用 active candidate 表达用户在候选弹层中的当前操作目 - 候选项虚拟化、容器回收和重新准备。 - 候选弹层关闭、过滤上下文变化和选项源重建时的状态失效。 +### 1.1 适用边界 + +该契约适用于同时满足以下条件的弹层内容:方向键移动的是尚未提交的候选目标,鼠标命中项能够成为同一次 `Enter` 操作的目标,并且候选高亮与已确认选择具有独立状态。当前纳入该契约的控件如下: + +| 控件 | 候选 owner | +| --- | --- | +| Select | `SelectCandidateList` | +| AutoComplete、Mentions | 公共 `CandidateList` | +| ComboBox | ComboBox internal candidate index 与 item projection | +| Cascader 普通树列 | `CascaderView` 当前树候选 | +| Cascader 过滤结果 | `CascaderViewFilterList` 当前过滤候选 | + +TreeSelect、DatePicker/Calendar 和 TimePicker 不机械套用本契约:TreeSelect 的 TreeView 方向键状态属于树选择与焦点契约;日期范围 hover、日历焦点和时间列 current value 还承担范围预览、日期焦点或时间值语义,不是独立的下拉列表 active candidate。它们仍需保证各自状态模型只有一个 owner,但不能通过关闭通用 hover 视觉来替代本身的选择、焦点和预览契约。 + ## 2. 设计原则 -1. `SelectCandidateList` 是 active candidate 的唯一 owner;候选项容器只投影 owner 状态。 +1. 每个候选列表实例只有一个 active candidate owner;候选项容器只投影 owner 状态。Select 使用 `SelectCandidateList`,AutoComplete 和 Mentions 使用公共 `CandidateList`,ComboBox 与 Cascader 在各自 view 中维护等价的 internal owner。 2. 鼠标和键盘共享同一个 active candidate,不维护相互独立的 hover candidate 和 keyboard candidate。 3. `:pointerover` 只表达 Avalonia 指针命中事实,不独立决定 Select 候选高亮。 4. active candidate 与已确认选择相互独立;候选迁移不能改变 `SelectedOption` 或 `SelectedOptions`。 @@ -33,14 +47,14 @@ Select 使用 active candidate 表达用户在候选弹层中的当前操作目 | 术语 | 定义 | | --- | --- | -| Active candidate | 当前可以由 `Enter` 提交或切换选择的唯一候选项。 | +| Active candidate | 当前可以由 `Enter` 提交、切换选择或继续展开的唯一候选项。 | | Committed selection | 已写入 `SelectedOption` 或 `SelectedOptions` 的确认选择。 | | Pointer hit | Avalonia 通过 `IsPointerOver` / `:pointerover` 表达的当前指针命中事实。 | | Source index | 候选项在有效候选源中的索引,是 `CandidateSelectedIndex` 的索引语义。 | | View index | 过滤和分组后候选项在当前 ListView 投影中的位置。 | | Candidate projection | 已准备容器上的 `IsCandidateSelected` 状态。 | -active candidate 使用以下 internal 状态表达: +active candidate 使用以下 internal 状态表达(具体控件可使用等价的私有字段或 internal 属性): ```text CandidateSelectedIndex @@ -61,8 +75,8 @@ CandidateSelectedItem | 状态 | Owner | 作用 | | --- | --- | --- | -| Active candidate | `SelectCandidateList` | 导航、提交目标和候选高亮。 | -| Committed selection | Select 公共选择属性与 ListView selection | 已确认值、selected 视觉和 Form 值。 | +| Active candidate | 候选列表 owner | 导航、提交/展开目标和候选高亮。 | +| Committed selection | 各控件公共选择属性与列表 selection | 已确认值、selected 视觉和 Form 值。 | | Pointer hit | Avalonia input system | 产生鼠标候选迁移请求,不直接绘制候选视觉。 | 同一候选项可以同时是 active candidate 和 committed selection。此时 selected 视觉保持优先,active 状态仍作为 `Enter` 的交互目标存在。 @@ -88,20 +102,20 @@ CandidateSelectedItem | 组件 | 职责 | 不负责 | | --- | --- | --- | -| `Select` | 公共选择属性、候选源、过滤、模式和 popup 生命周期协调 | 不直接维护容器 active 视觉 | -| `SelectCandidateList` | active candidate owner、输入映射、索引转换、键盘滚动和提交/取消 | 不把 active 状态写入业务 option | -| `SelectCandidateListItem` | 投影 `IsCandidateSelected`、`IsSelected`、启用和分组状态 | 不持有跨回收周期的候选状态 | -| `CandidateVirtualizingStackPanel` | 键盘候选滚动到可见区域 | 不决定 active candidate | -| `SelectCandidateListItemTheme` | 把 active、selected、disabled 和隐藏状态映射为视觉 | 不从 `:pointerover` 推导独立候选 | +| 下拉控件 | 公共选择属性、候选源、过滤、模式和 popup 生命周期协调 | 不直接维护容器 active 视觉 | +| 候选列表 owner | active candidate owner、输入映射、索引转换、键盘滚动和提交/取消 | 不把 active 状态写入业务 option | +| 候选项容器 | 投影 `IsCandidateSelected`、`IsSelected`、启用、分组和控件特有状态 | 不持有跨回收周期的候选状态 | +| 虚拟化列表面板 | 键盘候选滚动到可见区域 | 不决定 active candidate | +| 候选项主题 | 把 active、selected、disabled、expanded 或 checked 状态映射为视觉 | 不从 `:pointerover` 推导独立候选 | 状态流保持单向: ```text pointer move / Up / Down -> resolve valid candidate source index - -> SelectCandidateList active candidate - -> realized container IsCandidateSelected - -> SelectCandidateListItemTheme active visual + -> candidate-list owner active candidate + -> realized container candidate projection + -> item theme active visual Enter / pointer click -> active or clicked candidate @@ -112,7 +126,7 @@ Enter / pointer click ## 6. Theme 与组合契约 -`SelectCandidateListItemTheme` 基于 `ListViewItemTheme`,但 Select 的候选视觉必须由统一 active candidate 状态控制。 +候选项主题基于 `ListViewItemTheme`,但候选视觉必须由统一 active candidate 状态控制。各控件可以有不同的 selected、expanded 或 checked 视觉,但不能恢复独立的候选 hover owner。 主题遵守以下层次: @@ -133,12 +147,12 @@ Enter / pointer click ```text PointerMoved - -> 从事件源解析最近的 SelectCandidateListItem + -> 从事件源解析最近的候选项容器 -> 验证非 group、可见、启用且允许选择 -> view index 映射为 source index -> 与 CandidateSelectedIndex 比较 -> 相同则返回 - -> 更新 active candidate,不调用 ScrollCandidateItemIntoView + -> 更新 active candidate,不调用候选滚动方法 ``` 鼠标在同一候选项内部移动是稳态热路径,必须在索引比较后直接返回。目标变化时只清理旧候选容器并设置新候选容器,不扫描全部候选项。 diff --git a/docs/controls/desktop/data-entry/select/changelog.md b/docs/controls/desktop/data-entry/select/changelog.md index dc06be700..dfd0070f1 100644 --- a/docs/controls/desktop/data-entry/select/changelog.md +++ b/docs/controls/desktop/data-entry/select/changelog.md @@ -3,6 +3,14 @@ 本文档记录 Select 控件级设计、API、主题契约、Token 和实现结构的变化。 它不替代仓库根目录 CHANGELOG.md,也不作为正式版本发布说明。 +## 2026-08-19 + +- Design + - Generalize `candidate-interaction-design.md` from the Select-specific implementation into the shared contract for Select, AutoComplete, Mentions, ComboBox, and Cascader candidate lists. + - Record the explicit boundary for TreeSelect, DatePicker/Calendar, and TimePicker state models so their tree selection, range preview, focus, and current-value semantics are not replaced by list hover suppression. +- Tests + - Align family-level verification with pointer-to-keyboard, keyboard-to-pointer, `Enter`, disabled candidate, lifecycle, and projection invariants. + ## 2026-08-18 - Design diff --git a/docs/controls/desktop/navigation/combo-box/changelog.md b/docs/controls/desktop/navigation/combo-box/changelog.md index 5aa94a96f..3caeb2207 100644 --- a/docs/controls/desktop/navigation/combo-box/changelog.md +++ b/docs/controls/desktop/navigation/combo-box/changelog.md @@ -2,6 +2,16 @@ 本文档记录 ComboBox 控件级设计、API、主题契约、Token 和实现结构的变化。它不替代仓库根目录 `CHANGELOG.md`,也不作为正式版本发布说明。 +## 2026-08-19 + +- Behavior + - Make pointer movement and keyboard navigation update the same internal active candidate without committing `SelectedItem`. + - Keep pointer migration non-scrolling and make `Enter` commit the current visual candidate. +- Theme + - Neutralize inherited pointer-over candidate styling and preserve selected-item visual precedence. +- Tests + - Add mixed pointer/keyboard navigation and pointer-to-`Enter` commit regression coverage. + ## 2026-08-18 - Behavior diff --git a/docs/controls/desktop/navigation/combo-box/implementation.md b/docs/controls/desktop/navigation/combo-box/implementation.md index 358c9f4d5..8b64c7c05 100644 --- a/docs/controls/desktop/navigation/combo-box/implementation.md +++ b/docs/controls/desktop/navigation/combo-box/implementation.md @@ -1,6 +1,6 @@ # ComboBox 桌面版实现原理 -本文档描述 ComboBox 桌面版的内部实现范围、源码职责、状态流、生命周期、资源边界和维护规则。公共设计与 API 契约见 [ComboBox 桌面版架构设计](overview.md),变化记录见 [ComboBox Changelog](changelog.md)。涉及控件 Token 的实现应同时阅读 [ComboBox Token 设计](token.md)。 +本文档描述 ComboBox 桌面版的内部实现范围、源码职责、状态流、生命周期、资源边界和维护规则。公共设计与 API 契约见 [ComboBox 桌面版架构设计](overview.md),候选列表状态契约见 [候选列表统一交互设计](../../data-entry/select/candidate-interaction-design.md),变化记录见 [ComboBox Changelog](changelog.md)。涉及控件 Token 的实现应同时阅读 [ComboBox Token 设计](token.md)。 ## 1. 实现定位 @@ -68,6 +68,7 @@ Public API / ItemsSource / Command / Event - `IFormItemAware` 的值读写直接映射到 `SelectedItem`:`SetFormValue(value)` 保留对象实例并设置选择,`GetFormValue()` 返回选择对象,`ClearFormValue()` 清空选择。 - 非编辑态选中内容溢出提示由 `OverflowTip` 托管,只在 `SelectedContentPresenter` 视觉溢出时写入 `ToolTip.Tip`,延迟和位置分别映射到 `ToolTip.ShowDelay` 与 `ToolTip.Placement`;非编辑态显示节点以外层 `AddOnDecoratedBox` 作为 `PlacementTarget`,避免 tooltip 左边按内部文本 padding 对齐;编辑态输入文本仍由 `PART_EditableTextBox` 自己承载,不自动开启该提示。 - 伪类和 internal state 必须从单一 owner 推导,避免双向同步导致循环更新。 +- popup 候选必须由单一 active candidate owner 驱动:鼠标命中可用 `ComboBoxItem` 时只迁移候选,不滚动、不提交;`Up` / `Down` 复用同一状态并允许滚动,`Enter` 从 active candidate 写入真实 `SelectedItem`。容器 recycle、ItemsSource / filter 变化和 popup close 必须清理旧投影。 - overview.md 的 API 契约说明应与源码实际状态流一致。 ## 5. 生命周期与模板接入 diff --git a/docs/controls/desktop/navigation/combo-box/overview.md b/docs/controls/desktop/navigation/combo-box/overview.md index a4a626711..cf2ccf94d 100644 --- a/docs/controls/desktop/navigation/combo-box/overview.md +++ b/docs/controls/desktop/navigation/combo-box/overview.md @@ -1,6 +1,6 @@ # ComboBox 桌面版架构设计 -本文档定义 `ComboBox` 桌面版的最新设计定位、公共契约、状态模型、视觉主题关系和兼容边界。通用控件研发约束见 [控件研发标准](../../../../engineering/development/control-development-guidelines.md),内部实现原理见 [ComboBox 桌面版实现原理](implementation.md),ComboBox Token 的专项设计见 [ComboBox Token 设计](token.md),设计和契约变化记录见 [ComboBox Changelog](changelog.md)。 +本文档定义 `ComboBox` 桌面版的最新设计定位、公共契约、状态模型、视觉主题关系和兼容边界。通用控件研发约束见 [控件研发标准](../../../../engineering/development/control-development-guidelines.md),候选列表统一交互见 [候选列表统一交互设计](../../data-entry/select/candidate-interaction-design.md),内部实现原理见 [ComboBox 桌面版实现原理](implementation.md),ComboBox Token 的专项设计见 [ComboBox Token 设计](token.md),设计和契约变化记录见 [ComboBox Changelog](changelog.md)。 ## 1. 控件定位 @@ -85,6 +85,7 @@ Public API / inherited command / item source / user input - Disabled 或不可交互状态优先屏蔽 pointer、keyboard、motion 和提交类反馈。 - open/close、collection/filter、input/value、motion、visual option 状态由控件实例或明确的数据 owner 推导,不能在 template part 之间双向竞争。 - 单选结果以继承的 `SelectedItem` / `SelectedIndex` 为准;AtomUI Form 集成使用 `SelectedItem` 作为 ComboBox 的默认表单值,`SetFormValue`、`GetFormValue` 和 `ClearFormValue` 不应转换为字符串或读取展示文本。 +- 下拉项的 active candidate 与 `SelectedItem` / `SelectedIndex` 分离。鼠标移动和键盘 `Up` / `Down` 共享同一个 active candidate;迁移只改变候选视觉,`Enter` 才提交 `SelectedItem`。`:pointerover` 不得形成第二个候选高亮,selected 视觉优先于 active 视觉。 - 模板重套用时必须把 public API 对应状态回放到新的 part、伪类和主题变量。 - 集合、弹层、异步、动效或窗口相关状态必须能处理 reset、close、cancel、detach 和 owner 释放。 @@ -144,6 +145,8 @@ ComboBox 与同分类控件共享尺寸、状态、Token、Gallery 展示和验 ComboBox 涉及弹层、窗口或 overlay 宿主时,打开状态、取消事件、定位和宿主释放必须保持一致。重复打开、关闭、窗口失活和 template reapply 都必须释放旧宿主引用。 +ComboBox 的 active candidate 归属于当前 popup 的候选视图。popup 关闭、ItemsSource / filter 重建或容器回收时清除失效候选;重新打开后从当前视图重新建立候选,不继承旧容器的 active 投影。 + ### 8.2 集合与数据同步模型 ComboBox 的集合状态必须能处理 source replace、reset、clear 和 container recycle。业务数据对象不应反向持有视觉对象,虚拟化或懒创建路径必须在容器回收时清理旧状态。 diff --git a/src/AtomUI.Desktop.Controls/Cascader/CascaderView.cs b/src/AtomUI.Desktop.Controls/Cascader/CascaderView.cs index d6086dc0e..1fb9f6490 100644 --- a/src/AtomUI.Desktop.Controls/Cascader/CascaderView.cs +++ b/src/AtomUI.Desktop.Controls/Cascader/CascaderView.cs @@ -728,6 +728,13 @@ public partial class CascaderView : TemplatedControl, protected override void OnPointerMoved(PointerEventArgs e) { base.OnPointerMoved(e); + if (e.Pointer.Type == PointerType.Mouse && + GetContainerFromEventSource(e.Source) is { } activeCandidate && + activeCandidate.IsVisible && activeCandidate.IsEnabled && !activeCandidate.IsLoading) + { + SetKeyboardCandidate(activeCandidate); + } + if (e.Source is Visual source) { if (ExpandTrigger == CascaderViewExpandTrigger.Hover) @@ -950,7 +957,8 @@ public partial class CascaderView : TemplatedControl, for (var i = 0; i < levelList.ItemCount; i++) { - if (levelList.ContainerFromIndex(i) is CascaderViewItem item && item.IsEnabled) + if (levelList.ContainerFromIndex(i) is CascaderViewItem item && + item.IsVisible && item.IsEnabled && !item.IsLoading) { candidates.Add(item); } diff --git a/src/AtomUI.Desktop.Controls/Cascader/CascaderViewFilterList.cs b/src/AtomUI.Desktop.Controls/Cascader/CascaderViewFilterList.cs index 65b551da7..8d999bf10 100644 --- a/src/AtomUI.Desktop.Controls/Cascader/CascaderViewFilterList.cs +++ b/src/AtomUI.Desktop.Controls/Cascader/CascaderViewFilterList.cs @@ -64,7 +64,18 @@ internal class CascaderViewFilterList : ListBox protected override void OnPointerMoved(PointerEventArgs e) { base.OnPointerMoved(e); - ClearCandidate(); + if (e.Pointer.Type != PointerType.Mouse || + GetContainerFromEventSource(e.Source) is not CascaderViewFilterListItem listItem) + { + return; + } + + var index = IndexFromContainer(listItem); + if (index >= 0 && index < ItemCount && + Items[index] is CascaderViewFilterListItemData itemData && itemData.IsEnabled) + { + SetCandidate(index); + } } internal bool TryMoveCandidate(int delta) diff --git a/src/AtomUI.Desktop.Controls/Cascader/Themes/CascaderViewFilterListTheme.axaml b/src/AtomUI.Desktop.Controls/Cascader/Themes/CascaderViewFilterListTheme.axaml index c26218935..7566f5b54 100644 --- a/src/AtomUI.Desktop.Controls/Cascader/Themes/CascaderViewFilterListTheme.axaml +++ b/src/AtomUI.Desktop.Controls/Cascader/Themes/CascaderViewFilterListTheme.axaml @@ -15,9 +15,20 @@ + + + + diff --git a/src/AtomUI.Desktop.Controls/Cascader/Themes/CascaderViewItemTheme.axaml b/src/AtomUI.Desktop.Controls/Cascader/Themes/CascaderViewItemTheme.axaml index 3a0c00658..072ab525c 100644 --- a/src/AtomUI.Desktop.Controls/Cascader/Themes/CascaderViewItemTheme.axaml +++ b/src/AtomUI.Desktop.Controls/Cascader/Themes/CascaderViewItemTheme.axaml @@ -89,7 +89,7 @@ - + + - - \ No newline at end of file + diff --git a/tests/AtomUI.Desktop.Controls.Tests/AutoComplete/AutoCompleteCandidateInteractionTests.cs b/tests/AtomUI.Desktop.Controls.Tests/AutoComplete/AutoCompleteCandidateInteractionTests.cs new file mode 100644 index 000000000..adb8a8500 --- /dev/null +++ b/tests/AtomUI.Desktop.Controls.Tests/AutoComplete/AutoCompleteCandidateInteractionTests.cs @@ -0,0 +1,158 @@ +using System.Reflection; +using AtomUI.Controls.Primitives; +using AtomUI.Desktop.Controls.Primitives; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Headless; +using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Shouldly; +using Xunit; +using AvaloniaWindow = Avalonia.Controls.Window; + +namespace AtomUI.Desktop.Controls.Tests.AutoComplete; + +public class AutoCompleteCandidateInteractionTests +{ + static AutoCompleteCandidateInteractionTests() + { + AvaloniaTestApp.EnsureInitialized(); + } + + [Fact] + public void Pointer_Move_Then_Enter_Commits_The_Pointer_Candidate() + { + var alpha = new AutoCompleteOption { Header = "Alpha", Content = "Alpha" }; + var alpine = new AutoCompleteOption { Header = "Alpine", Content = "Alpine" }; + var autoComplete = new Desktop.Controls.AutoComplete + { + Width = 240, + IsMotionEnabled = false, + OptionsSource = new[] { alpha, alpine } + }; + var window = CreateWindow(autoComplete); + + try + { + var textBox = autoComplete.GetVisualDescendants() + .OfType() + .Single(); + textBox.Text = "Al"; + textBox.CaretIndex = 2; + Dispatcher.UIThread.RunJobs(); + + autoComplete.IsDropDownOpen.ShouldBeTrue(); + var candidateList = FindCandidateList(window); + var alphaContainer = GetContainer(candidateList, 0); + var alpineContainer = GetContainer(candidateList, 1); + + PressCandidateKey(candidateList, Key.Down); + MovePointerTo(alpineContainer, window); + Dispatcher.UIThread.RunJobs(); + + alphaContainer.IsCandidateSelected.ShouldBeFalse(); + alpineContainer.IsCandidateSelected.ShouldBeTrue(); + candidateList.CandidateSelectedItem.ShouldBeSameAs(alpine); + + PressCandidateKey(candidateList, Key.Enter); + Dispatcher.UIThread.RunJobs(); + + autoComplete.SelectedOption.ShouldBeSameAs(alpine); + autoComplete.Value.ShouldBe("Alpine"); + autoComplete.IsDropDownOpen.ShouldBeFalse(); + } + finally + { + window.Close(); + } + } + + private static CandidateList FindCandidateList(Visual root) + { + for (var i = 0; i < 20; i++) + { + Dispatcher.UIThread.RunJobs(); + var candidateList = root.GetVisualDescendants() + .OfType() + .SingleOrDefault(); + if (candidateList is not null) + { + return candidateList; + } + } + + throw new InvalidOperationException("Expected AutoComplete popup candidate list to be realized."); + } + + private static CandidateListItem GetContainer(CandidateList candidateList, int index) + { + var container = candidateList.ContainerFromIndex(index) as CandidateListItem; + container.ShouldNotBeNull(); + return container; + } + + private static void MovePointerTo(Control target, AvaloniaWindow window) + { + var point = target.TranslatePoint( + new Point(target.Bounds.Width / 2, target.Bounds.Height / 2), + window); + point.ShouldNotBeNull(); + + window.MouseMove(point.Value); + Dispatcher.UIThread.RunJobs(); + } + + private static void PressCandidateKey(CandidateList candidateList, Key key) + { + candidateList.HandleKeyDown(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Source = candidateList, + Key = key, + PhysicalKey = key switch + { + Key.Enter => PhysicalKey.Enter, + Key.Down => PhysicalKey.ArrowDown, + _ => PhysicalKey.None + }, + KeyModifiers = KeyModifiers.None + }); + } + + private static AvaloniaWindow CreateWindow(Control content) + { + 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 + }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + return window; + } + + 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/Cascader/CascaderSearchSelectionTests.cs b/tests/AtomUI.Desktop.Controls.Tests/Cascader/CascaderSearchSelectionTests.cs index fc826a168..4886ee8fa 100644 --- a/tests/AtomUI.Desktop.Controls.Tests/Cascader/CascaderSearchSelectionTests.cs +++ b/tests/AtomUI.Desktop.Controls.Tests/Cascader/CascaderSearchSelectionTests.cs @@ -410,6 +410,54 @@ public class CascaderSearchSelectionTests } } + [Fact] + public void Tree_Mode_Pointer_And_Keyboard_Navigation_Share_One_Active_Candidate() + { + var (options, _, _, _) = CreateLakeOptions(); + var cascader = new Desktop.Controls.Cascader + { + Width = 240, + IsMotionEnabled = false, + OptionsSource = options + }; + var window = CreateWindow(cascader); + + try + { + cascader.IsDropDownOpen = true; + Dispatcher.UIThread.RunJobs(); + + var cascaderView = GetCascaderView(cascader); + var zhejiangItem = WaitFor( + () => FindCascaderViewItem(cascaderView, "Zhejiang"), + "the first root candidate should be visible."); + var jiangsuItem = WaitFor( + () => FindCascaderViewItem(cascaderView, "Jiangsu"), + "the second root candidate should be visible."); + + RaiseKeyDown(cascader, Key.Down); + Dispatcher.UIThread.RunJobs(); + zhejiangItem.IsCandidateSelected.ShouldBeTrue(); + + MovePointerTo(jiangsuItem, window); + + zhejiangItem.IsCandidateSelected.ShouldBeFalse(); + jiangsuItem.IsCandidateSelected.ShouldBeTrue(); + + RaiseKeyDown(cascader, Key.Up); + Dispatcher.UIThread.RunJobs(); + + jiangsuItem.IsPointerOver.ShouldBeTrue( + "the pointer remains over Jiangsu while keyboard navigation moves the active candidate."); + zhejiangItem.IsCandidateSelected.ShouldBeTrue(); + jiangsuItem.IsCandidateSelected.ShouldBeFalse(); + } + finally + { + window.Close(); + } + } + [Theory] [InlineData("")] [InlineData(" ")] @@ -444,7 +492,7 @@ public class CascaderSearchSelectionTests } [Fact] - public void Moving_Pointer_Over_Filter_Result_Clears_Keyboard_Candidate() + public void Moving_Pointer_Over_Filter_Result_Migrates_Active_Candidate() { var (options, _, _, _) = CreateLakeOptions(); var cascaderView = new CascaderView @@ -480,9 +528,16 @@ public class CascaderSearchSelectionTests MovePointerTo(secondItem, window); firstItem.IsCandidateSelected.ShouldBeFalse(); - filterList.CandidateSelectedIndex.ShouldBe(-1); - filterList.CandidateSelectedItem.ShouldBeNull(); + secondItem.IsCandidateSelected.ShouldBeTrue(); + filterList.CandidateSelectedIndex.ShouldBe(1); + filterList.CandidateSelectedItem.ShouldBeSameAs(filterList.Items[1]); secondItem.IsPointerOver.ShouldBeTrue(); + + cascaderView.TryMoveFilterCandidate(-1).ShouldBeTrue(); + Dispatcher.UIThread.RunJobs(); + + firstItem.IsCandidateSelected.ShouldBeTrue(); + secondItem.IsCandidateSelected.ShouldBeFalse(); } finally { @@ -800,6 +855,58 @@ public class CascaderSearchSelectionTests } } + [Fact] + public void Search_Result_Pointer_Move_Then_Enter_Commits_Pointer_Candidate() + { + var (options, _, _, _) = CreateLakeOptions(); + var cascader = new Desktop.Controls.Cascader + { + Width = 240, + IsFilterEnabled = true, + IsMotionEnabled = false, + OptionsSource = options + }; + var window = CreateWindow(cascader); + + try + { + cascader.IsDropDownOpen = true; + Dispatcher.UIThread.RunJobs(); + + var cascaderView = GetCascaderView(cascader); + cascader.FilterValue = "lake"; + Dispatcher.UIThread.RunJobs(); + + var filterList = WaitFor( + () => cascaderView.GetVisualDescendants() + .OfType() + .FirstOrDefault(list => list.IsVisible), + "the popup filter result list should be visible after setting a filter value."); + var pointerTarget = WaitFor( + () => filterList.ContainerFromIndex(1) as CascaderViewFilterListItem, + "the second filter result container should be realized."); + var itemData = filterList.Items[1].ShouldBeOfType(); + var expectedOption = itemData.ExpandItems!.Last(); + + RaiseKeyDown(cascader, Key.Down); + MovePointerTo(pointerTarget, window); + Dispatcher.UIThread.RunJobs(); + + filterList.CandidateSelectedIndex.ShouldBe(1); + pointerTarget.IsCandidateSelected.ShouldBeTrue(); + + RaiseKeyDown(cascader, Key.Enter); + Dispatcher.UIThread.RunJobs(); + + cascader.SelectedOption.ShouldBeSameAs(expectedOption); + cascader.IsDropDownOpen.ShouldBeFalse(); + } + finally + { + window.Close(); + } + } + [Fact] public void Tree_Mode_Right_And_Enter_Navigate_To_Leaf_And_Select() { diff --git a/tests/AtomUI.Desktop.Controls.Tests/ComboBox/ComboBoxDisplayMemberBindingTests.cs b/tests/AtomUI.Desktop.Controls.Tests/ComboBox/ComboBoxDisplayMemberBindingTests.cs index 845644b6d..a078bab03 100644 --- a/tests/AtomUI.Desktop.Controls.Tests/ComboBox/ComboBoxDisplayMemberBindingTests.cs +++ b/tests/AtomUI.Desktop.Controls.Tests/ComboBox/ComboBoxDisplayMemberBindingTests.cs @@ -1120,6 +1120,78 @@ public class ComboBoxDisplayMemberBindingTests }); } + [Fact] + public void Pointer_And_Keyboard_Navigation_Share_One_Active_Candidate() + { + var comboBox = new AtomUIComboBox + { + Width = 200, + IsEditable = true, + IsFilterEnabled = true, + IsMotionEnabled = false + }; + comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpha" }); + comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpine" }); + + ShowInWindow(comboBox, window => + { + comboBox.Text = "Al"; + comboBox.IsDropDownOpen = true; + Dispatcher.UIThread.RunJobs(); + + var visibleRows = GetVisibleStringComboBoxItems(window); + MovePointerTo(window, visibleRows[0]); + + IsCandidateSelected(visibleRows[0]).ShouldBeTrue(); + IsCandidateSelected(visibleRows[1]).ShouldBeFalse(); + + PressKey(window, Key.Down, PhysicalKey.ArrowDown); + Dispatcher.UIThread.RunJobs(); + + visibleRows[0].IsPointerOver.ShouldBeTrue( + "keyboard navigation must replace the candidate even while the pointer remains over the old row."); + IsCandidateSelected(visibleRows[0]).ShouldBeFalse(); + IsCandidateSelected(visibleRows[1]).ShouldBeTrue(); + comboBox.SelectedIndex.ShouldBe(-1); + }); + } + + [Fact] + public void Pointer_Move_Then_Enter_Commits_The_Pointer_Candidate() + { + var comboBox = new AtomUIComboBox + { + Width = 200, + IsEditable = true, + IsFilterEnabled = true, + IsMotionEnabled = false + }; + comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpha" }); + comboBox.Items.Add(new AtomUIComboBoxItem { Content = "Alpine" }); + + ShowInWindow(comboBox, window => + { + comboBox.Text = "Al"; + comboBox.IsDropDownOpen = true; + Dispatcher.UIThread.RunJobs(); + + var visibleRows = GetVisibleStringComboBoxItems(window); + PressKey(window, Key.Down, PhysicalKey.ArrowDown); + MovePointerTo(window, visibleRows[1]); + Dispatcher.UIThread.RunJobs(); + + IsCandidateSelected(visibleRows[0]).ShouldBeFalse(); + IsCandidateSelected(visibleRows[1]).ShouldBeTrue(); + + PressKey(window, Key.Enter, PhysicalKey.Enter); + Dispatcher.UIThread.RunJobs(); + + comboBox.SelectedIndex.ShouldBe(1); + comboBox.Text.ShouldBe("Alpine"); + comboBox.IsDropDownOpen.ShouldBeFalse(); + }); + } + [Fact] public void Editable_Filter_Enter_Key_Commits_Active_Candidate_And_Closes_DropDown() { @@ -1252,6 +1324,17 @@ public class ComboBoxDisplayMemberBindingTests window.MouseUp(clickPoint.Value, MouseButton.Left); } + private static void MovePointerTo(AvaloniaWindow window, Control control) + { + var point = control.TranslatePoint( + new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), + window); + point.ShouldNotBeNull(); + + window.MouseMove(point.Value); + Dispatcher.UIThread.RunJobs(); + } + private static List GetVisibleStringComboBoxItems(Control root) { return root.GetVisualDescendants() diff --git a/tests/AtomUI.Desktop.Controls.Tests/Mentions/MentionsBehaviorTests.cs b/tests/AtomUI.Desktop.Controls.Tests/Mentions/MentionsBehaviorTests.cs index 3965ceccd..b9adda8d6 100644 --- a/tests/AtomUI.Desktop.Controls.Tests/Mentions/MentionsBehaviorTests.cs +++ b/tests/AtomUI.Desktop.Controls.Tests/Mentions/MentionsBehaviorTests.cs @@ -5,6 +5,8 @@ using AtomUI.Desktop.Controls.Primitives; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; +using Avalonia.Headless; +using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Threading; using Avalonia.VisualTree; @@ -133,6 +135,55 @@ public class MentionsBehaviorTests } } + [Fact] + public void Pointer_Move_Then_Enter_Inserts_The_Pointer_Candidate() + { + var anna = new MentionOption { Header = "anna", Value = "anna" }; + var amy = new MentionOption { Header = "amy", Value = "amy" }; + var mentions = new AtomUIMentions + { + Width = 240, + IsMotionEnabled = false, + OptionsSource = [anna, amy] + }; + var window = CreateWindow(mentions); + + try + { + var textArea = mentions.GetVisualDescendants() + .OfType() + .Single(); + textArea.Text = "@"; + textArea.CaretIndex = 1; + Dispatcher.UIThread.RunJobs(); + + mentions.IsDropDownOpen.ShouldBeTrue(); + var candidateList = FindCandidateList(window); + var annaContainer = GetContainer(candidateList, 0); + var amyContainer = GetContainer(candidateList, 1); + + PressCandidateKey(candidateList, Key.Down); + MovePointerTo(amyContainer, window); + Dispatcher.UIThread.RunJobs(); + + annaContainer.IsCandidateSelected.ShouldBeFalse(); + amyContainer.IsCandidateSelected.ShouldBeTrue(); + candidateList.CandidateSelectedItem.ShouldBeSameAs(amy); + + PressCandidateKey(candidateList, Key.Enter); + Dispatcher.UIThread.RunJobs(); + + mentions.Value.ShouldNotBeNull(); + mentions.Value.ShouldContain("@amy"); + mentions.Value.ShouldNotContain("@anna"); + mentions.IsDropDownOpen.ShouldBeFalse(); + } + finally + { + window.Close(); + } + } + private static AvaloniaWindow CreateWindow(Control content) { var overlayPanel = new ScopeAwareOverlayLayerPanel @@ -184,6 +235,41 @@ public class MentionsBehaviorTests throw new InvalidOperationException("Expected Mentions popup candidate list to be realized."); } + private static CandidateListItem GetContainer(CandidateList candidateList, int index) + { + var container = candidateList.ContainerFromIndex(index) as CandidateListItem; + container.ShouldNotBeNull(); + return container; + } + + private static void MovePointerTo(Control target, AvaloniaWindow window) + { + var point = target.TranslatePoint( + new Point(target.Bounds.Width / 2, target.Bounds.Height / 2), + window); + point.ShouldNotBeNull(); + + window.MouseMove(point.Value); + Dispatcher.UIThread.RunJobs(); + } + + private static void PressCandidateKey(CandidateList candidateList, Key key) + { + candidateList.HandleKeyDown(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Source = candidateList, + Key = key, + PhysicalKey = key switch + { + Key.Enter => PhysicalKey.Enter, + Key.Down => PhysicalKey.ArrowDown, + _ => PhysicalKey.None + }, + KeyModifiers = KeyModifiers.None + }); + } + private static void EnablePopupOverlayLayer(VisualLayerManager visualLayerManager) { var property = typeof(VisualLayerManager).GetProperty( diff --git a/tests/AtomUI.Desktop.Controls.Tests/Primitives/CandidateListBehaviorTests.cs b/tests/AtomUI.Desktop.Controls.Tests/Primitives/CandidateListBehaviorTests.cs new file mode 100644 index 000000000..f62f65dad --- /dev/null +++ b/tests/AtomUI.Desktop.Controls.Tests/Primitives/CandidateListBehaviorTests.cs @@ -0,0 +1,154 @@ +using AtomUI.Desktop.Controls.Primitives; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Input; +using Avalonia.Threading; +using Shouldly; +using Xunit; +using AvaloniaWindow = Avalonia.Controls.Window; + +namespace AtomUI.Desktop.Controls.Tests.Primitives; + +public class CandidateListBehaviorTests +{ + static CandidateListBehaviorTests() + { + AvaloniaTestApp.EnsureInitialized(); + } + + [Fact] + public void Pointer_And_Keyboard_Navigation_Share_One_Active_Candidate() + { + var alpha = new AutoCompleteOption { Header = "Alpha", Content = "Alpha" }; + var beta = new AutoCompleteOption { Header = "Beta", Content = "Beta" }; + var candidateList = new CandidateList + { + Width = 240, + Height = 120, + ItemsSource = new[] { alpha, beta } + }; + object? committedItem = null; + candidateList.Commit += (_, _) => committedItem = candidateList.SelectedItem; + var window = Show(candidateList); + + try + { + var alphaContainer = GetContainer(candidateList, 0); + var betaContainer = GetContainer(candidateList, 1); + + MovePointerTo(alphaContainer, window); + + candidateList.CandidateSelectedItem.ShouldBeSameAs(alpha); + alphaContainer.IsCandidateSelected.ShouldBeTrue(); + betaContainer.IsCandidateSelected.ShouldBeFalse(); + + PressCandidateKey(candidateList, Key.Down); + Dispatcher.UIThread.RunJobs(); + + alphaContainer.IsPointerOver.ShouldBeTrue( + "the regression requires the pointer to remain over the old candidate while keyboard navigation continues."); + candidateList.CandidateSelectedItem.ShouldBeSameAs(beta); + alphaContainer.IsCandidateSelected.ShouldBeFalse(); + betaContainer.IsCandidateSelected.ShouldBeTrue(); + + PressCandidateKey(candidateList, Key.Enter); + Dispatcher.UIThread.RunJobs(); + + committedItem.ShouldBeSameAs(beta); + candidateList.SelectedItem.ShouldBeNull( + "the primitive clears its transient ListBox selection after notifying the owner."); + candidateList.CandidateSelectedIndex.ShouldBe(-1); + candidateList.CandidateSelectedItem.ShouldBeNull(); + } + finally + { + window.Close(); + } + } + + [Fact] + public void Initial_Keyboard_Navigation_Skips_Disabled_Candidate() + { + var disabled = new AutoCompleteOption + { + Header = "Disabled", + Content = "Disabled", + IsEnabled = false + }; + var available = new AutoCompleteOption { Header = "Available", Content = "Available" }; + var candidateList = new CandidateList + { + Width = 240, + Height = 120, + ItemsSource = new[] { disabled, available } + }; + var window = Show(candidateList); + + try + { + MovePointerTo(GetContainer(candidateList, 0), window); + candidateList.CandidateSelectedItem.ShouldBeNull(); + + PressCandidateKey(candidateList, Key.Down); + Dispatcher.UIThread.RunJobs(); + + candidateList.CandidateSelectedItem.ShouldBeSameAs(available); + GetContainer(candidateList, 0).IsCandidateSelected.ShouldBeFalse(); + GetContainer(candidateList, 1).IsCandidateSelected.ShouldBeTrue(); + } + finally + { + window.Close(); + } + } + + private static CandidateListItem GetContainer(CandidateList candidateList, int index) + { + var container = candidateList.ContainerFromIndex(index) as CandidateListItem; + container.ShouldNotBeNull(); + return container; + } + + private static void MovePointerTo(Control target, AvaloniaWindow window) + { + var point = target.TranslatePoint( + new Point(target.Bounds.Width / 2, target.Bounds.Height / 2), + window); + point.ShouldNotBeNull(); + + window.MouseMove(point.Value); + Dispatcher.UIThread.RunJobs(); + } + + private static void PressCandidateKey(CandidateList candidateList, Key key) + { + candidateList.HandleKeyDown(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Source = candidateList, + Key = key, + PhysicalKey = key switch + { + Key.Enter => PhysicalKey.Enter, + Key.Up => PhysicalKey.ArrowUp, + Key.Down => PhysicalKey.ArrowDown, + _ => PhysicalKey.None + }, + KeyModifiers = KeyModifiers.None + }); + } + + private static AvaloniaWindow Show(Control content) + { + var window = new AvaloniaWindow + { + Width = 360, + Height = 240, + Content = content + }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + return window; + } +}