feat(Calendar): add typed header context with change-value/mode commands

CalendarHeaderContext exposes Value/Mode plus ICommands. ChangeValueCommand
commits as Customize source; both commands validate parameter type in
CanExecute. Adds internal CalendarRelayCommand helper.
This commit is contained in:
chinboy
2026-07-30 19:48:12 +08:00
parent 4bb61f60f3
commit f1bf14da2f
4 changed files with 164 additions and 0 deletions
@@ -1,4 +1,5 @@
using System;
using System.Windows.Input;
using AtomUI.Desktop.Controls.Internal.Calendar;
using Avalonia;
using Avalonia.Controls;
@@ -177,4 +178,22 @@ public class Calendar : TemplatedControl
PanelChanged?.Invoke(this, new CalendarPanelChangedEventArgs(Value.Date, mode));
}
/// <summary>
/// 为自定义 Header 模板构建强类型上下文(spec §5.4)。命令的 CanExecute 校验参数类型;
/// ChangeValueCommand 以 <see cref="CalendarSelectSource.Customize"/> 提交,且不自动应用
/// ValidRange 或 DisabledDate(约束由自定义 Header 负责)。
/// </summary>
internal CalendarHeaderContext BuildHeaderContext()
{
var changeValue = new CalendarRelayCommand(
execute: p => CommitUserSelection((DateTime)p!, CalendarSelectSource.Customize),
canExecute: p => p is DateTime);
var changeMode = new CalendarRelayCommand(
execute: p => CommitModeChange((CalendarMode)p!),
canExecute: p => p is CalendarMode mode && (mode == CalendarMode.Month || mode == CalendarMode.Year));
return new CalendarHeaderContext(Value.Date, Mode, changeValue, changeMode);
}
}
@@ -0,0 +1,30 @@
using System.Windows.Input;
namespace AtomUI.Desktop.Controls;
/// <summary>
/// 自定义 Header 模板(<see cref="Calendar.HeaderTemplate"/>)的数据上下文。
/// 不暴露 Calendar 实例;模板只能通过命令提交用户意图。
/// </summary>
public sealed class CalendarHeaderContext
{
public CalendarHeaderContext(DateTime value, CalendarMode mode, ICommand changeValueCommand, ICommand changeModeCommand)
{
Value = value;
Mode = mode;
ChangeValueCommand = changeValueCommand;
ChangeModeCommand = changeModeCommand;
}
/// <summary>当前 Value。</summary>
public DateTime Value { get; }
/// <summary>当前 Mode。</summary>
public CalendarMode Mode { get; }
/// <summary>提交 <see cref="DateTime"/>,来源为 <see cref="CalendarSelectSource.Customize"/>。参数非 <see cref="DateTime"/> 时 CanExecute 为 false。</summary>
public ICommand ChangeValueCommand { get; }
/// <summary>提交 <see cref="CalendarMode"/>。参数非合法枚举时 CanExecute 为 false。</summary>
public ICommand ChangeModeCommand { get; }
}
@@ -0,0 +1,36 @@
using System;
using System.Windows.Input;
namespace AtomUI.Desktop.Controls.Internal.Calendar;
/// <summary>
/// Calendar Header Context 使用的轻量 <see cref="ICommand"/>。参数校验交给 <c>canExecute</c>,
/// 非法参数时 <see cref="CanExecute"/> 返回 false 且 <see cref="Execute"/> 不动作。
/// </summary>
internal sealed class CalendarRelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Func<object?, bool> _canExecute;
public CalendarRelayCommand(Action<object?> execute, Func<object?, bool> canExecute)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute ?? throw new ArgumentNullException(nameof(canExecute));
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => _canExecute(parameter);
public void Execute(object? parameter)
{
if (!_canExecute(parameter))
{
return;
}
_execute(parameter);
}
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
@@ -0,0 +1,79 @@
using System;
using System.Reflection;
using Shouldly;
using Xunit;
using AtomUICalendar = AtomUI.Desktop.Controls.Calendar;
namespace AtomUI.Desktop.Controls.Tests.Calendar;
public class CalendarHeaderContextTests
{
static CalendarHeaderContextTests()
{
AvaloniaTestApp.EnsureInitialized();
}
[Fact]
public void HeaderContext_ExposesValueAndMode()
{
var c = new AtomUICalendar { Value = new DateTime(2026, 7, 1), Mode = CalendarMode.Year };
var ctx = BuildHeaderContext(c);
ctx.Value.ShouldBe(new DateTime(2026, 7, 1));
ctx.Mode.ShouldBe(CalendarMode.Year);
}
[Fact]
public void ChangeValueCommand_RejectsNonDateParameter()
{
var c = new AtomUICalendar { Value = new DateTime(2026, 7, 1) };
var ctx = BuildHeaderContext(c);
ctx.ChangeValueCommand.CanExecute("not-a-date").ShouldBeFalse();
ctx.ChangeValueCommand.CanExecute(null).ShouldBeFalse();
ctx.ChangeValueCommand.CanExecute(new DateTime(2026, 8, 1)).ShouldBeTrue();
}
[Fact]
public void ChangeValueCommand_CommitsAsCustomizeSource()
{
var c = new AtomUICalendar { Value = new DateTime(2026, 7, 1) };
CalendarSelectSource? seen = null;
c.Selected += (_, e) => seen = e.Source;
var ctx = BuildHeaderContext(c);
ctx.ChangeValueCommand.Execute(new DateTime(2026, 9, 3));
seen.ShouldBe(CalendarSelectSource.Customize);
c.Value.ShouldBe(new DateTime(2026, 9, 3));
}
[Fact]
public void ChangeModeCommand_RejectsInvalidParameter()
{
var c = new AtomUICalendar();
var ctx = BuildHeaderContext(c);
ctx.ChangeModeCommand.CanExecute("nope").ShouldBeFalse();
ctx.ChangeModeCommand.CanExecute((CalendarMode)999).ShouldBeFalse();
ctx.ChangeModeCommand.CanExecute(CalendarMode.Year).ShouldBeTrue();
}
[Fact]
public void ChangeModeCommand_SwitchesModeAndRaisesPanelChanged()
{
var c = new AtomUICalendar { Value = new DateTime(2026, 7, 1) };
var panelRaised = 0;
c.PanelChanged += (_, _) => panelRaised++;
var ctx = BuildHeaderContext(c);
ctx.ChangeModeCommand.Execute(CalendarMode.Year);
c.Mode.ShouldBe(CalendarMode.Year);
panelRaised.ShouldBe(1);
}
private static CalendarHeaderContext BuildHeaderContext(AtomUICalendar c)
{
var m = typeof(AtomUICalendar).GetMethod(
"BuildHeaderContext", BindingFlags.Instance | BindingFlags.NonPublic)!;
return (CalendarHeaderContext)m.Invoke(c, Array.Empty<object>())!;
}
}