mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-16 21:21:03 +08:00
fix(DatePicker): align range panel anchor and endpoint highlights
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user