fix(DatePicker): align range panel anchor and endpoint highlights

This commit is contained in:
chinboy
2026-10-04 15:07:13 +08:00
parent a307d79e06
commit 55b5b97807
7 changed files with 92 additions and 19 deletions
@@ -13,6 +13,7 @@
- Align RangeDatePicker preview precedence with the shared range model so reverse start editing renders only the normalized live endpoints instead of retaining a third committed endpoint.
- Keep range Hover state atomic across shared panels and preserve the established endpoint corner geometry.
- Preserve the opposite endpoint value, input text and DateViewer selected range state when confirming one RangeDatePicker endpoint and rotating the active endpoint.
- Keep complete range popups anchored to the start period and suppress selected/range endpoint projection on duplicate outside cells in dual-panel range viewers.
## 2026-10-03
@@ -77,9 +77,10 @@ Pointer、键盘、滚轮与 Automation 共用面板算法,事件消费由 Pic
## 8. 算法与关键流程
单位归一、范围视觉与导航由共享内核提供;Formatting 保留输入格式和宽度。
终点打开按该端点与面板周期定位,起点不能通过同步改写浏览区域。
仅有终点值时,打开按该端点与面板周期定位;起点不能通过同步改写浏览区域。
已有完整范围重新打开时,本次确认进度从当前活动端开始重建;选择当前端点后保持弹层打开并转移到另一端,
只有另一端也在本次编辑中确认后才执行最终确认和关闭。
完整范围双面板打开时以 start 所在周期作为左面板锚点;active end 不能倒推一个周期来改变面板锚点。
Today/Now/Confirm 共用有效约束,但时间来源和确认行为按原规则。
视觉顺序与候选端点分离,范围预览不能改写真实值,反向 hover 只改变预览文本和预览高亮的显示顺序。
选择/确认一个端点时必须结束旧 Hover 预览,避免旧 hover 在活动端翻转后被解释成新活动端的预览并覆盖 viewer 已提交选中态。
@@ -222,6 +222,7 @@ Picker、Calendar 以及 DateViewer 分别拥有自己的 Semantic owner。组
提交前重新验证有效性,避免模型生成后约束发生变化使过期 Cell 被提交。
范围真实端点、候选端点和 hover preview 分别推导;预览中间区域与端点不污染真实选中标记。
激活有效 Cell 时必须先清理旧 Hover preview,再让宿主提交和切换活动端,防止旧 hover 在新活动端上下文中覆盖已提交范围选中态。
双面板范围选择中,非本面板周期的 outside cell 只作为浏览填充,不投影 selected、visual endpoint、range endpoint、range middle 或 preview 状态;同一个真实端点只能在所属面板周期内高亮一次。
周模式按整行输出连续视觉,周号与七个日期参与同一范围;命中区域包含日期格间隙和两个面板,不只依赖 Cell PointerEntered。
### 7.2 Picker 编辑会话
@@ -241,6 +242,7 @@ Picker、Calendar 以及 DateViewer 分别拥有自己的 Semantic owner。组
范围活动端点的显示定位使用该端点与当前面板周期,不允许开始值同步将结束面板拉回开始区域。
RangeDatePicker 每次打开都重建本次端点确认进度:空范围从 start 到 end;单端点范围把已有端点作为固定端;
完整范围保留提交值作为初始显示,但本次编辑仍要求从当前活动端开始重新确认两个端点。
完整范围使用 start 所在周期作为双面板左锚点;active end 只表达当前编辑端点,不能通过倒推一个周期改变左面板月份。
设置当前端点后,活动端、输入焦点、Popup 箭头和 DatePanel active part 必须同步翻转到另一端。
端点翻转不得清理对向端点的值或本端刚确认值;清理行为只允许发生在显式 Clear、表单清空或既有关闭路径要求的未完整范围回滚中。
范围日期时间的有效确认行为与用户原始 IsNeedConfirm 请求分开表示,不需要覆盖 public 属性再保存 backup 来表示内部派生状态;
@@ -55,21 +55,9 @@ internal class RangeDatePickerPresenter : DatePickerPresenter
protected override DateTime? ResolveOpenDisplayAnchor()
{
var active = EditSession.OpenAnchor;
if (active is null || PanelCount == 1 || ActiveRangePart == DateRangeActivePart.Start)
if (active is null || PanelCount == 1)
return active;
try
{
return PickerMode switch
{
DatePickerMode.Month or DatePickerMode.Quarter => active.Value.AddYears(-1),
DatePickerMode.Year => new DateTime(Math.Max(1, active.Value.Year / 10 * 10 - 10), 1, 1),
_ => active.Value.AddMonths(-1)
};
}
catch (ArgumentOutOfRangeException)
{
return active;
}
return EditSession.Draft.Start ?? active;
}
protected override void ApplyDraft(DateViewerRange draft)
@@ -237,12 +237,13 @@ internal static class DatePanelAlgorithms
bool Matches(DateTime? target) => value is { } candidate && target is { } selection &&
Normalize(candidate, comparisonUnit, firstDay) == Normalize(selection, comparisonUnit, firstDay);
var selectableWeek = kind != DateViewerCellType.Week || input.SelectionUnit == DateViewerSelectionUnit.Week;
var rangeGrid = input.SelectionUnit == comparisonUnit && input.Range is not null;
var rangeVisualEligible = IsRangeVisualEligible(input, inView);
var rangeGrid = rangeVisualEligible && input.SelectionUnit == comparisonUnit && input.Range is not null;
var state = rangeGrid && value is { } rangeValue
? GetRangeState(input, rangeValue, firstDay)
: default;
var selected = !state.HasPreview && !disabled && selectableWeek && (Matches(input.SelectedDate) ||
input.Range is { } range && (Matches(range.Start) || Matches(range.End)));
rangeVisualEligible && input.Range is { } range && (Matches(range.Start) || Matches(range.End)));
var hovered = !disabled && selectableWeek && Matches(input.HoveredValue);
var visualEndpoint = state.HasPreview ? state.PreviewStart || state.PreviewEnd : selected;
var weekVisual = input.SelectionUnit == DateViewerSelectionUnit.Week && input.PanelKind == DateViewerPanelKind.Date &&
@@ -266,6 +267,9 @@ internal static class DatePanelAlgorithms
};
}
private static bool IsRangeVisualEligible(DatePanelInput input, bool isInView) =>
input is not { IsRangeSelection: true, PanelCount: > 1 } || isInView;
private static DateViewerSelectionUnit GetCellUnit(DateViewerCellType kind) => kind switch
{
DateViewerCellType.Date => DateViewerSelectionUnit.Date,
@@ -144,7 +144,7 @@ public class DatePickerViewerBehaviorTests
}
[Fact]
public void Range_End_Open_Uses_The_Previous_Dual_Panel_Period_And_Keeps_The_Host_As_Value_Owner()
public void Range_End_Open_Uses_The_Start_Dual_Panel_Period_And_Keeps_The_Host_As_Value_Owner()
{
var start = new DateTime(2026, 1, 12);
var end = new DateTime(2026, 2, 11);
@@ -164,7 +164,7 @@ public class DatePickerViewerBehaviorTests
var presenter = picker.PickerPresenter.ShouldBeAssignableTo<RangeDatePickerPresenter>()!;
var viewer = presenter.GetVisualDescendants().OfType<RangeDateViewer>().Single();
viewer.PanelSession.Input.DisplayDate.ShouldBe(end.AddMonths(-1));
viewer.PanelSession.Input.DisplayDate.ShouldBe(start);
viewer.PanelSession.Input.Range.ShouldBe(new DateViewerRange(start, end));
viewer.PanelSession.Input.PanelCount.ShouldBe(2);
viewer.Bounds.Width.ShouldBe(540, 0.5);
@@ -178,6 +178,35 @@ public class DatePickerViewerBehaviorTests
});
}
[Fact]
public void Range_End_Open_Keeps_The_Start_Period_As_The_Left_Dual_Panel_When_The_Range_Is_In_One_Month()
{
var start = new DateTime(2026, 7, 6);
var end = new DateTime(2026, 7, 12);
var picker = new RangeDatePicker
{
PickerDisplayDate = new DateTime(2027, 9, 23),
RangeStartSelectedDate = start,
RangeEndSelectedDate = end
};
picker.RangeActivatedPart = RangeActivatedPart.End;
ShowPicker(picker, () =>
{
picker.IsPickerOpen = true;
Drain();
var presenter = picker.PickerPresenter.ShouldBeAssignableTo<RangeDatePickerPresenter>()!;
var viewer = presenter.GetVisualDescendants().OfType<RangeDateViewer>().Single();
viewer.PanelSession.Input.DisplayDate.ShouldBe(start);
viewer.PanelSession.Models[0].Anchor.ShouldBe(new DateTime(2026, 7, 1));
viewer.PanelSession.Models[1].Anchor.ShouldBe(new DateTime(2026, 8, 1));
viewer.PanelSession.Input.Range.ShouldBe(new DateViewerRange(start, end));
viewer.PanelSession.Input.ActiveRangePart.ShouldBe(DateRangeActivePart.End);
});
}
[Fact]
public void Range_Partial_Confirmation_Stays_Open_And_Final_Confirmation_Orders_Dates()
{
@@ -84,6 +84,54 @@ public class DateViewerTests
finally { window.Close(); Dispatcher.UIThread.RunJobs(); }
}
[Fact]
public void Range_Dual_Panel_Does_Not_Select_Duplicate_Outside_Endpoint_Cells()
{
var start = new DateTime(2026, 9, 28);
var end = new DateTime(2026, 10, 4);
var viewer = new RangeDateViewer
{
DisplayDate = new DateTime(2026, 9, 1),
Value = new DateViewerRange(start, end)
};
var window = new Avalonia.Controls.Window { Width = 600, Height = 360, Content = viewer };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var cells = viewer.GetVisualDescendants().OfType<DateViewerCell>().ToArray();
var visibleStart = cells.Single(cell => cell.Model?.Value == start && cell.Model.IsInView);
var visibleEnd = cells.Single(cell => cell.Model?.Value == end && cell.Model.IsInView);
var duplicateStart = cells.Single(cell => cell.Model?.Value == start && cell.Model.IsInView == false);
var duplicateEnd = cells.Single(cell => cell.Model?.Value == end && cell.Model.IsInView == false);
visibleStart.Model!.IsSelected.ShouldBeTrue();
visibleStart.Model.IsVisualEndpoint.ShouldBeTrue();
visibleStart.Model.IsRangeStart.ShouldBeTrue();
visibleEnd.Model!.IsSelected.ShouldBeTrue();
visibleEnd.Model.IsVisualEndpoint.ShouldBeTrue();
visibleEnd.Model.IsRangeEnd.ShouldBeTrue();
duplicateStart.Model!.IsSelected.ShouldBeFalse();
duplicateStart.Model.IsVisualEndpoint.ShouldBeFalse();
duplicateStart.Model.IsRangeStart.ShouldBeFalse();
duplicateStart.Model.IsRangeMiddle.ShouldBeFalse();
duplicateStart.Model.IsRangeEnd.ShouldBeFalse();
duplicateEnd.Model!.IsSelected.ShouldBeFalse();
duplicateEnd.Model.IsVisualEndpoint.ShouldBeFalse();
duplicateEnd.Model.IsRangeStart.ShouldBeFalse();
duplicateEnd.Model.IsRangeMiddle.ShouldBeFalse();
duplicateEnd.Model.IsRangeEnd.ShouldBeFalse();
}
finally
{
window.Close();
Dispatcher.UIThread.RunJobs();
}
}
[Fact]
public void Header_Commands_Browse_And_Same_Value_Selection_Notifies_Once_Per_Activation()
{