Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions samples/ControlCatalog/Pages/CalendarDatePickerPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@

<TextBlock Text="Validation Example"/>
<CalendarDatePicker SelectedDate="{CompiledBinding ValidatedDateExample, Mode=TwoWay}"/>

<TextBlock Text="Show WeekNumbers" />
<CalendarDatePicker IsWeekNumberVisible="True"
FirstDayOfWeek="Monday"
WeekNumberRule="FirstFourDayWeek" />
</StackPanel>

</StackPanel>
Expand Down
6 changes: 6 additions & 0 deletions samples/ControlCatalog/Pages/CalendarPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@
<Calendar Name="BlackoutDatesCalendar"
SelectionMode="SingleDate" />
</StackPanel>
<StackPanel>
<TextBlock Text="Weeknumbers"/>
<Calendar SelectionMode="SingleDate"
IsWeekNumberVisible="True"
WeekNumberHeader="CW" />
</StackPanel>
</WrapPanel>
</StackPanel>
</ContentPage>
57 changes: 56 additions & 1 deletion src/Avalonia.Controls/Calendar/Calendar.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// (c) Copyright Microsoft Corporation.
// (c) Copyright Microsoft Corporation.
// This source is subject to the Microsoft Public License (Ms-PL).
// Please see https://go.microsoft.com/fwlink/?LinkID=131993 for details.
// All other rights reserved.

using System;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using Avalonia.Automation.Peers;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives;
Expand Down Expand Up @@ -353,6 +354,57 @@ public IBrush? HeaderBackground
set => SetValue(HeaderBackgroundProperty, value);
}

/// <summary>
/// Defines the <see cref="IsWeekNumberVisible"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsWeekNumberVisibleProperty =
AvaloniaProperty.Register<Calendar, bool>(nameof(IsWeekNumberVisible));

/// <summary>
/// Gets or sets a value indicating whether week numbers are shown in the month view.
/// </summary>
public bool IsWeekNumberVisible
{
get => GetValue(IsWeekNumberVisibleProperty);
set => SetValue(IsWeekNumberVisibleProperty, value);
}

/// <summary>
/// Defines the <see cref="WeekNumberRule"/> property.
/// </summary>
public static readonly StyledProperty<CalendarWeekNumberRule> WeekNumberRuleProperty =
AvaloniaProperty.Register<Calendar, CalendarWeekNumberRule>(
nameof(WeekNumberRule),
defaultValue: (CalendarWeekNumberRule)DateTimeHelper.GetCurrentDateFormat().CalendarWeekRule);

/// <summary>
/// Gets or sets the rule used to determine the first week of the year for week number display.
/// The default is taken from the current culture. Use <see cref="CalendarWeekNumberRule.Iso"/>
/// for ISO 8601 week numbering.
/// </summary>
public CalendarWeekNumberRule WeekNumberRule
{
get => GetValue(WeekNumberRuleProperty);
set => SetValue(WeekNumberRuleProperty, value);
}

/// <summary>
/// Defines the <see cref="WeekNumberHeader"/> property.
/// </summary>
public static readonly StyledProperty<object?> WeekNumberHeaderProperty =
AvaloniaProperty.Register<Calendar, object?>(nameof(WeekNumberHeader), "#");

/// <summary>
/// Gets or sets the content displayed in the week-number column header cell.
/// Set this to a localized string such as <c>"CW"</c>, <c>"KW"</c>, or <c>"Wk"</c>
/// to give users context for the week-number column. Defaults to <c>"#"</c>.
/// </summary>
public object? WeekNumberHeader
{
get => GetValue(WeekNumberHeaderProperty);
set => SetValue(WeekNumberHeaderProperty, value);
}

public static readonly StyledProperty<CalendarMode> DisplayModeProperty =
AvaloniaProperty.Register<Calendar, CalendarMode>(
nameof(DisplayMode),
Expand Down Expand Up @@ -2209,6 +2261,9 @@ static Calendar()
DisplayDateProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnDisplayDateChanged(e));
DisplayDateStartProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnDisplayDateStartChanged(e));
DisplayDateEndProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnDisplayDateEndChanged(e));
IsWeekNumberVisibleProperty.Changed.AddClassHandler<Calendar>((x, _) => x.UpdateMonths());
WeekNumberRuleProperty.Changed.AddClassHandler<Calendar>((x, _) => x.UpdateMonths());
WeekNumberHeaderProperty.Changed.AddClassHandler<Calendar>((x, _) => x.UpdateMonths());
KeyDownEvent.AddClassHandler<Calendar>((x, e) => x.Calendar_KeyDown(e));
KeyUpEvent.AddClassHandler<Calendar>((x, e) => x.Calendar_KeyUp(e));
}
Expand Down
69 changes: 64 additions & 5 deletions src/Avalonia.Controls/Calendar/CalendarItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ namespace Avalonia.Controls.Primitives
[TemplatePart(PART_ElementNextButton, typeof(Button))]
[TemplatePart(PART_ElementPreviousButton, typeof(Button))]
[TemplatePart(PART_ElementYearView, typeof(Grid))]
[PseudoClasses(":calendardisabled")]
[PseudoClasses(":calendardisabled", ":hasweeknumbers")]
public sealed class CalendarItem : TemplatedControl
{
/// <summary>
Expand All @@ -49,6 +49,9 @@ public sealed class CalendarItem : TemplatedControl

private readonly System.Globalization.Calendar _calendar = new GregorianCalendar();

private CalendarWeekNumberLabel[] _weekNumberLabels = Array.Empty<CalendarWeekNumberLabel>();
private CalendarWeekNumberLabel? _weekNumberHeaderLabel;

internal Calendar? Owner { get; set; }
internal CalendarDayButton? CurrentButton { get; set; }

Expand Down Expand Up @@ -168,16 +171,18 @@ private void PopulateGrids()
{
if (MonthView != null)
{
var childCount = Calendar.RowsPerMonth + Calendar.RowsPerMonth * Calendar.ColumnsPerMonth;
// +7 for the week-number column (1 header + 6 data cells)
var childCount = Calendar.RowsPerMonth + Calendar.RowsPerMonth * Calendar.ColumnsPerMonth + Calendar.RowsPerMonth;
using var children = new PooledList<Control>(childCount);

// Day-of-week title cells — day columns are at 1-7 (shifted right by 1)
for (int i = 0; i < Calendar.ColumnsPerMonth; i++)
{
if (DayTitleTemplate?.Build() is Control cell)
{
cell.DataContext = string.Empty;
cell.SetValue(Grid.RowProperty, 0);
cell.SetValue(Grid.ColumnProperty, i);
cell.SetValue(Grid.ColumnProperty, i + 1);
children.Add(cell);
}
}
Expand All @@ -198,15 +203,34 @@ private void PopulateGrids()
cell.Owner = Owner;
}
cell.SetValue(Grid.RowProperty, i);
cell.SetValue(Grid.ColumnProperty, j);
cell.SetValue(Grid.ColumnProperty, j + 1);
cell.CalendarDayButtonMouseDown += cellMouseLeftButtonDown;
cell.CalendarDayButtonMouseUp += cellMouseLeftButtonUp;
cell.PointerEntered += cellMouseEntered;
cell.Click += cellClick;
children.Add(cell);
}
}


// Week-number cells — added after existing cells to preserve child indices 0–48.
// Header placeholder (row 0, col 0)
_weekNumberHeaderLabel = new CalendarWeekNumberLabel();
_weekNumberHeaderLabel.IsHeader = true;
_weekNumberHeaderLabel.SetValue(Grid.RowProperty, 0);
_weekNumberHeaderLabel.SetValue(Grid.ColumnProperty, 0);
children.Add(_weekNumberHeaderLabel);

// Data labels (rows 1–6, col 0)
_weekNumberLabels = new CalendarWeekNumberLabel[Calendar.RowsPerMonth - 1];
for (int i = 1; i < Calendar.RowsPerMonth; i++)
{
var label = new CalendarWeekNumberLabel();
label.SetValue(Grid.RowProperty, i);
label.SetValue(Grid.ColumnProperty, 0);
_weekNumberLabels[i - 1] = label;
children.Add(label);
}

MonthView.Children.AddRange(children);
}

Expand Down Expand Up @@ -254,6 +278,10 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
NextButton = e.NameScope.Find<Button>(PART_ElementNextButton);
MonthView = e.NameScope.Find<Grid>(PART_ElementMonthView);
YearView = e.NameScope.Find<Grid>(PART_ElementYearView);

// Prepend a column for week numbers; day columns follow in positions 1–7.
// Done here so PopulateGrids() can place cells at the correct indices.
MonthView?.ColumnDefinitions.Insert(0, new ColumnDefinition(GridLength.Auto));

if (Owner != null)
{
Expand Down Expand Up @@ -373,6 +401,7 @@ internal void UpdateMonthMode()
{
SetDayTitles();
SetCalendarDayButtons(_currentMonth);
UpdateWeekNumberLabels(_currentMonth);
}
}
private void SetMonthModeHeaderButton()
Expand Down Expand Up @@ -589,6 +618,36 @@ private void SetCalendarDayButtons(DateTime firstDayOfMonth)
}
}

private void UpdateWeekNumberLabels(DateTime firstDayOfMonth)
{
if (_weekNumberLabels.Length == 0)
return;

int lastMonthToDisplay = PreviousMonthDays(firstDayOfMonth);
DateTime firstDateDisplayed = DateTimeHelper.CompareYearMonth(firstDayOfMonth, DateTime.MinValue) > 0
? _calendar.AddDays(firstDayOfMonth, -lastMonthToDisplay)
: firstDayOfMonth;

bool show = Owner?.IsWeekNumberVisible ?? false;
var rule = Owner?.WeekNumberRule ?? (CalendarWeekNumberRule)DateTimeHelper.GetCurrentDateFormat().CalendarWeekRule;
var firstDayOfWeek = Owner?.FirstDayOfWeek ?? DateTimeHelper.GetCurrentDateFormat().FirstDayOfWeek;

PseudoClasses.Set(":hasweeknumbers", show);

for (int i = 0; i < _weekNumberLabels.Length; i++)
{
DateTime firstDayOfRow = _calendar.AddDays(firstDateDisplayed, i * NumberOfDaysPerWeek);
_weekNumberLabels[i].Content = DateTimeHelper.GetWeekOfYear(firstDayOfRow, rule, firstDayOfWeek).ToString();
_weekNumberLabels[i].IsVisible = show;
}

if (_weekNumberHeaderLabel != null)
{
_weekNumberHeaderLabel.Content = Owner?.WeekNumberHeader;
_weekNumberHeaderLabel.IsVisible = show;
}
}

internal void UpdateYearMode()
{
if (Owner != null)
Expand Down
27 changes: 27 additions & 0 deletions src/Avalonia.Controls/Calendar/CalendarWeekNumberLabel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace Avalonia.Controls.Primitives
{
/// <summary>
/// Displays a week number in the month view of a <see cref="Calendar"/>.
/// Apply styles targeting <see cref="CalendarWeekNumberLabel"/> to customise
/// the appearance — for example <c>FontWeight="Bold"</c>.
/// Use the <c>:header</c> pseudo-class to target the column header cell (row 0).
/// </summary>
public sealed class CalendarWeekNumberLabel : ContentControl
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO an entire new primitive probably isn't needed for this concept. It can be done with text and light-weight styling resources alone.

That in turn allows room for future expansion in this area. For example -- we might want to make it interactive as a button in some way, the calendar might get a week ONLY view, etc.

I'm also not a fan of the legacy term "Label" being used for new controls. That just isn't done in modern XAML.

{
/// <summary>
/// Gets or sets a value indicating whether this label is the column header cell
/// (placed in row 0 of the month grid, above the week-number data cells).
/// Themes can target this with the <c>:header</c> pseudo-class.
/// </summary>
public bool IsHeader
{
get => field;
internal set
{
if (field == value) return;
field = value;
PseudoClasses.Set(":header", value);
}
}
}
}
37 changes: 37 additions & 0 deletions src/Avalonia.Controls/Calendar/CalendarWeekNumberRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
namespace Avalonia.Controls
{
/// <summary>
/// Defines the rule that determines the first week of the calendar year for week-number display.
/// </summary>
public enum CalendarWeekNumberRule
{
/// <summary>
/// The first week of the year starts on the first day of the year and ends before the
/// following designated first day of the week. Equivalent to
/// <see cref="System.Globalization.CalendarWeekRule.FirstDay"/>.
/// </summary>
FirstDay = 0,

/// <summary>
/// The first week of the year begins on the first occurrence of the designated first day
/// of the week on or after the first day of the year. Equivalent to
/// <see cref="System.Globalization.CalendarWeekRule.FirstFullWeek"/>.
/// </summary>
FirstFullWeek = 1,

/// <summary>
/// The first week of the year is the first week with four or more days before the
/// designated first day of the week. Equivalent to
/// <see cref="System.Globalization.CalendarWeekRule.FirstFourDayWeek"/>.
/// </summary>
FirstFourDayWeek = 2,

/// <summary>
/// Uses ISO 8601 week numbering: the first week of the year must contain at least four days,
/// and Monday is treated as the first day of the week, regardless of
/// <see cref="Calendar.FirstDayOfWeek"/>. This is the most common rule in European locales
/// and professional calendar applications.
/// </summary>
Iso = 3,
}
}
22 changes: 22 additions & 0 deletions src/Avalonia.Controls/Calendar/DateTimeHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,5 +131,27 @@ public static string ToYearString(DateTime date)
var format = GetCurrentDateFormat();
return date.Year.ToString(format);
}

/// <summary>
/// Gets the week-of-year number for the specified date.
/// </summary>
/// <param name="date">The date.</param>
/// <param name="rule">The rule that defines what constitutes the first week of the year.</param>
/// <param name="firstDayOfWeek">The first day of the week. Ignored when <paramref name="rule"/> is <see cref="CalendarWeekNumberRule.Iso"/>.</param>
/// <returns>The week number that <paramref name="date"/> falls in.</returns>
public static int GetWeekOfYear(DateTime date, CalendarWeekNumberRule rule, DayOfWeek firstDayOfWeek)
{
var mappedRule = (CalendarWeekRule)(int)rule;

// Iso is an explicit shorthand for ISO 8601 (FirstFourDayWeek + Monday).
// Also catches FirstFourDayWeek + Monday explicitly, because .NET's
// Calendar.GetWeekOfYear incorrectly returns week 53 for late-December dates
// that ISO 8601 assigns to week 1 of the next year (e.g. 2018-12-31).
if (rule == CalendarWeekNumberRule.Iso ||
(mappedRule == CalendarWeekRule.FirstFourDayWeek && firstDayOfWeek == DayOfWeek.Monday))
return ISOWeek.GetWeekOfYear(date);

return CultureInfo.CurrentCulture.Calendar.GetWeekOfYear(date, mappedRule, firstDayOfWeek);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Globalization;
using Avalonia.Controls.Primitives;
using Avalonia.Data;
using Avalonia.Layout;
Expand Down Expand Up @@ -140,6 +141,24 @@ public partial class CalendarDatePicker
public static readonly StyledProperty<VerticalAlignment> VerticalContentAlignmentProperty =
ContentControl.VerticalContentAlignmentProperty.AddOwner<CalendarDatePicker>();

/// <summary>
/// Defines the <see cref="IsWeekNumberVisible"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsWeekNumberVisibleProperty =
Calendar.IsWeekNumberVisibleProperty.AddOwner<CalendarDatePicker>();

/// <summary>
/// Defines the <see cref="WeekNumberRule"/> property.
/// </summary>
public static readonly StyledProperty<CalendarWeekNumberRule> WeekNumberRuleProperty =
Calendar.WeekNumberRuleProperty.AddOwner<CalendarDatePicker>();

/// <summary>
/// Defines the <see cref="WeekNumberHeader"/> property.
/// </summary>
public static readonly StyledProperty<object?> WeekNumberHeaderProperty =
Calendar.WeekNumberHeaderProperty.AddOwner<CalendarDatePicker>();

/// <summary>
/// Gets a collection of dates that are marked as not selectable.
/// </summary>
Expand Down Expand Up @@ -358,5 +377,26 @@ public VerticalAlignment VerticalContentAlignment
get => GetValue(VerticalContentAlignmentProperty);
set => SetValue(VerticalContentAlignmentProperty, value);
}

/// <inheritdoc cref="Calendar.IsWeekNumberVisible"/>
public bool IsWeekNumberVisible
{
get => GetValue(IsWeekNumberVisibleProperty);
set => SetValue(IsWeekNumberVisibleProperty, value);
}

/// <inheritdoc cref="Calendar.WeekNumberRule"/>
public CalendarWeekNumberRule WeekNumberRule
{
get => GetValue(WeekNumberRuleProperty);
set => SetValue(WeekNumberRuleProperty, value);
}

/// <inheritdoc cref="Calendar.WeekNumberHeader"/>
public object? WeekNumberHeader
{
get => GetValue(WeekNumberHeaderProperty);
set => SetValue(WeekNumberHeaderProperty, value);
}
}
}
Loading