diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendar.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendar.razor
index 9a5c9b2fb3b..808082b1733 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendar.razor
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendar.razor
@@ -6,6 +6,7 @@
var todayYear = _culture.Calendar.GetYear(today);
var todayMonth = _culture.Calendar.GetMonth(today);
var focusableDay = GetFocusableDay();
+ var isInvalid = ValueInvalid is true;
var prevMonthNavIconCss = BitIconInfo.From(PrevMonthNavIcon, PrevMonthNavIconName ?? "Up")?.GetCssClasses();
var nextMonthNavIconCss = BitIconInfo.From(NextMonthNavIcon, NextMonthNavIconName ?? "Up bit-ico-r180")?.GetCssClasses();
@@ -23,200 +24,260 @@
var decreaseMinuteIconCss = BitIconInfo.From(TimePickerDecreaseMinuteIcon, TimePickerDecreaseMinuteIconName ?? "ChevronDownSmall")?.GetCssClasses();
}
+@* A calendar is a composite of labelled grids, so it needs no role of its own - but one given a name is a
+ named group, which is what lets a screen reader say which of two calendars on a page the focus is in. *@
-
+ @if (HeaderTemplate is not null)
+ {
+
@HeaderTemplate
+ }
+
+
+ @* The selection as words, for a screen reader alone: the day cell carries it as aria-selected, which
+ is state rather than something announced when it changes. Left empty while nothing is selected, so
+ clearing the value does not announce a selection of nothing. *@
- @if (ShowMonthPicker && (ShowMonthPickerAsOverlay || (ShowTimePicker && ShowTimePickerAsOverlay is false)))
- {
- var title = string.Format(MonthPickerToggleTitle, _monthTitle);
-
- }
- else
- {
-
- @_monthTitle
-
- }
- @{
- var prevDisabled = CanChangeMonth(false) is false;
- var nextDisabled = CanChangeMonth(true) is false;
- }
-
-
- @if (ShowGoToToday && (ShowMonthPickerAsOverlay || ShowMonthPicker is false))
- {
- var goToTodayDisabled = IsGoToTodayButtonDisabled(todayYear, todayMonth);
+ var prevDisabled = CanChangeMonth(false) is false;
+ var nextDisabled = CanChangeMonth(true) is false;
-
- }
-
- @if (ShowTimePicker && ShowTimePickerAsOverlay && (ShowMonthPicker is false || ShowMonthPickerAsOverlay))
+ @* A calendar showing more than one month is the same day picker once per month, side by side:
+ only the first of them heads the navigation backwards and the month picker's toggle, and only
+ the last the navigation forwards, so the strip reads as one calendar rather than three. *@
+ @for (var monthIndex = 0; monthIndex < _monthCount; monthIndex++)
+ {
+ var index = monthIndex;
+ var isFirstMonth = index == 0;
+ var isLastMonth = index == _monthCount - 1;
+ var monthTitle = _monthTitles[index];
+
+
- @if (ShowWeekNumbers)
- {
-
- }
-
- @for (var index = 0; index < 7; index++)
+ else
{
- var dayOfWeek = GetDayOfWeek(index);
- var dayOfWeekName = _culture.DateTimeFormat.GetShortestDayName(dayOfWeek);
-
- @dayOfWeekName[0]
+
+ @monthTitle
}
-
-
- @for (var week = 0; week < 6; week++)
- {
- //to ignore the last empty week of the month || to ignore the first whole week of the month
- if (_daysOfCurrentMonth[week, 0].HasValue is false) continue;
+
@if (ShowWeekNumbers)
{
- var weekNumber = GetWeekNumber(week);
- var title = string.Format(WeekNumberTitle, weekNumber);
-
- @weekNumber
-
+ @* The corner above the week numbers is empty, so it is named rather than read out
+ as a blank column header before the first weekday of every row. *@
+
}
- @for (var day = 0; day < 7; day++)
+
+ @for (var dayIndex = 0; dayIndex < 7; dayIndex++)
{
- var date = _daysOfCurrentMonth[week, day]!.Value;
+ var dayOfWeek = GetDayOfWeek(dayIndex);
+ var dayOfWeekName = _culture.DateTimeFormat.GetShortestDayName(dayOfWeek);
+
+ @GetNarrowDayName(dayOfWeekName)
+
+ }
+
+
+ @for (var week = 0; week < 6; week++)
+ {
+ @* The weeks past the end of the month (FixedWeeks off) hold no days at all. The check
+ spans the whole row: at the very edge of the calendar's supported range a row can
+ start with empty cells and still hold days to show. *@
+ if (IsWeekRowEmpty(index, week)) continue;
- if (ShowOutsideDays is false && IsInCurrentMonth(date) is false)
+
+ @if (ShowWeekNumbers)
{
-
- continue;
+ var weekNumber = GetWeekNumber(index, week);
+ var title = string.Format(WeekNumberTitle, weekNumber);
+
+ @weekNumber
+
}
+ @for (var day = 0; day < 7; day++)
+ {
+ var nullableDate = _daysOfMonths[index][week, day];
- var disabled = IsEnabled is false || IsDayDisabled(date);
- var isSelected = IsSelectedDate(date);
- var isToday = IsInCurrentMonth(date) && date == today.Date;
- var (style, klass) = GetDayButtonCss(date);
- var dayEvents = GetDayEvents(date);
-
- }
-
- }
+
+ var date = nullableDate.Value;
+
+ var disabled = IsEnabled is false || IsDayDisabled(date);
+ var isSelected = IsSelectedDate(date);
+ var isToday = IsInMonth(date, index) && date == today.Date;
+ var (style, klass) = GetDayButtonCss(date, index);
+ var dayEvents = GetDayEvents(date);
+ var opensEventDialog = ShowEventDetails && dayEvents.Count > 0;
+ @* A read-only calendar still browses, so its days stay focusable - but they no
+ longer select anything, which aria-disabled is what reports.
+
+ Only the selected cell carries aria-selected, the way the APG's own date
+ picker does: an explicit false on the other forty-one would have a screen
+ reader say "not selected" on every day arrowed over. *@
+
+ }
+
- @for (var cellIndex = 1; cellIndex <= 4; cellIndex++)
- {
- var month = (rowIndex * 4) + cellIndex;
- var monthName = _culture.DateTimeFormat.GetMonthName(month);
- var disabled = IsEnabled is false || IsMonthOutOfMinAndMaxDate(month);
- var selected = month == _currentMonth;
-
- }
-
- }
+ @* Not every calendar has twelve months: a leap year of the Hebrew calendar has thirteen. *@
+ var monthsInYear = GetMonthsInCurrentYear();
+ var monthRowCount = (monthsInYear + 3) / 4;
+ var focusableMonth = GetFocusableMonth();
+
+ @for (var cellIndex = 1; cellIndex <= 4; cellIndex++)
+ {
+ var month = (rowIndex * 4) + cellIndex;
+
+ if (month > monthsInYear) continue;
+
+ var monthName = GetMonthName(_currentYear, month);
+ var disabled = IsEnabled is false || IsMonthOutOfMinAndMaxDate(month);
+ var selected = month == _currentMonth;
+
+ @* The current month and year are reported whether or not they are painted:
+ HighlightCurrentMonth decides how the grid looks, not what a screen reader
+ is able to find in it. *@
+
+ }
+
+ @for (var cellIndex = 0; cellIndex <= 3; cellIndex++)
+ {
+ var year = _yearPickerStartYear + (rowIndex * 4) + cellIndex;
+ var disabled = IsEnabled is false || IsYearOutOfMinAndMaxDate(year);
+ var selected = year == _currentYear;
+
+ }
+
+ }
}
@@ -453,18 +524,23 @@
}
-
+
@if (ShowNowButton)
{
+ @* A read-only calendar browses but does not change: the buttons that would set the
+ time stay focusable, and say that they are unavailable rather than looking
+ pressable and doing nothing. *@
-
:
+
:
+ @if (FooterTemplate is not null)
+ {
+
@FooterTemplate
+ }
+
@if (_showEventModal)
{
+ @* The details of a day's events are a modal dialog: named by the date it is headed with, dismissed with
+ Escape or the close button, keeping Tab inside it while it is open, and handing the keyboard back to
+ the day cell it was opened from once it closes (SyncEventDialogFocus). *@
+ @foreach (var evt in GetEventModalEvents())
+ {
+ @* The card of an event is painted with its own color, the same one its dot on the
+ day cell was; the default template adds the layout of its three parts, which a
+ template of the app's own lays out itself. *@
+
+ @if (EventTemplate is not null)
+ {
+ @EventTemplate(evt)
+ }
+ else
+ {
+
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendar.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendar.razor.cs
index d5b72dff21b..bb41b22bfe2 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendar.razor.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendar.razor.cs
@@ -6,42 +6,61 @@ namespace Bit.BlazorUI;
///
/// BitCalendar is a fully-featured inline calendar for browsing and selecting a single date, or a date and time when the built-in time picker is enabled.
-/// It offers day, month, and year views, day events, flexible day/week rules, any culture and time zone, and complete keyboard accessibility.
+/// It offers day, month and year grids - each of them operated with the arrow keys the way the WAI-ARIA grid pattern prescribes - day events with a details
+/// dialog, flexible day/week rules, header and footer slots, three sizes, and any culture and time zone, including the non-Gregorian ones.
///
public partial class BitCalendar : BitInputBase
{
private const int DEFAULT_WEEK_COUNT = 6;
private const int DEFAULT_DAY_COUNT_PER_WEEK = 7;
+ private const int MAX_MONTH_COUNT = 3;
private int _currentYear;
private int _currentMonth;
+ private int _monthCount = 1;
private bool _showYearPicker;
private bool _showTimePicker;
private bool _showMonthPicker;
private bool _showEventModal;
- private bool _focusAfterRender;
private DateTime? _focusedDate;
+ private int? _focusedYearCell;
+ private int? _focusedMonthCell;
+ private string? _focusElementIdAfterRender;
+ private bool _focusTimePickerAfterRender;
+ private bool _eventModalFocusTrapped;
+ private bool _eventModalFocusStored;
private DateOnly _eventModalDate;
private HashSet _disabledDates = [];
private HashSet _highlightedDates = [];
private HashSet _disabledDaysOfWeek = [];
- private IReadOnlyList _eventModalEvents = [];
private Dictionary> _eventsByDate = [];
private int _yearPickerEndYear;
private int _yearPickerStartYear;
- private string _monthTitle = string.Empty;
private ElementReference _inputTimeHourRef = default!;
private ElementReference _inputTimeMinuteRef = default!;
private TimeZoneInfo _timeZone = TimeZoneInfo.Local;
private CultureInfo _culture = CultureInfo.CurrentUICulture;
private CancellationTokenSource _cancellationTokenSource = new();
- private readonly DateTime?[,] _daysOfCurrentMonth = new DateTime?[DEFAULT_WEEK_COUNT, DEFAULT_DAY_COUNT_PER_WEEK];
+
+ // One grid, one title and one (year, month) pair per rendered month: a calendar showing a quarter at a
+ // time is the same day picker three times over, and everything each of them draws is read out of these
+ // by the index of its pane. The first of them is the month the navigation, the month picker and the
+ // year picker all work on - the rest follow it.
+ private string[] _monthTitles = [string.Empty];
+ private (int Year, int Month)[] _monthsOfView = [default];
+ private DateTime?[][,] _daysOfMonths = [new DateTime?[DEFAULT_WEEK_COUNT, DEFAULT_DAY_COUNT_PER_WEEK]];
+
+ // The parts of the event dialog that have to name one another: the surface the focus is trapped in and
+ // restored from, and the heading it takes its accessible name from.
+ private string _eventDialogId => $"{_Id}-event-dialog";
+ private string _eventDialogTitleId => $"{_Id}-event-dialog-title";
private int _hour;
+ private int _hourBeforeInput;
private int _hourView
{
get
@@ -63,17 +82,25 @@ private int _hourView
}
set
{
- if (value > 23)
- {
- _hour = 23;
- }
- else if (value < 0)
+ if (IsEnabled is false || ReadOnly) return;
+
+ _hourBeforeInput = _hour;
+
+ // The field is bound on every keystroke, so what lands here is whatever has been typed so far -
+ // the first digit of a two-digit hour among it. It is only brought into the day here; the
+ // HourStep grid is laid over it once the field is committed (HandleOnTimeInputChange), which is
+ // what lets 12 be typed into a picker whose step is 3.
+ if (TimeFormat == BitTimeFormat.TwelveHours)
{
- _hour = 0;
+ // A value typed into the hour is an hour of the clock face (1-12), so it lands in the half of
+ // the day the picker is already on: typing 5 into an afternoon time means 17:00, not a silent
+ // flip to the morning - which half the time is in is what the AM/PM pair is there to change.
+ // Both 12 and 0 mean the top of the clock, which is hour zero of the half.
+ _hour = BitTimeSteps.Wrap(value, 12) + (_hour >= 12 ? 12 : 0);
}
else
{
- _hour = value;
+ _hour = Math.Clamp(value, 0, 23);
}
UpdateTime();
@@ -81,23 +108,18 @@ private int _hourView
}
private int _minute;
+ private int _minuteBeforeInput;
private int _minuteView
{
get => _minute;
set
{
- if (value > 59)
- {
- _minute = 59;
- }
- else if (value < 0)
- {
- _minute = 0;
- }
- else
- {
- _minute = value;
- }
+ if (IsEnabled is false || ReadOnly) return;
+
+ // Brought into the hour here and held to the MinuteStep grid on commit, for the same reason the
+ // hour above is.
+ _minuteBeforeInput = _minute;
+ _minute = Math.Clamp(value, 0, 59);
UpdateTime();
}
@@ -109,6 +131,24 @@ private int _minuteView
+ ///
+ /// Gets or sets the cascading parameters for the calendar component.
+ ///
+ ///
+ /// This property receives its value from an ancestor component via Blazor's cascading parameter mechanism.
+ ///
+ /// The intended use is to allow shared configuration or settings to be applied to multiple calendar components through the component.
+ ///
+ [CascadingParameter(Name = BitCalendarParams.ParamName)]
+ public BitCalendarParams? CascadingParameters { get; set; }
+
+
+
+ ///
+ /// Whether selecting the already selected day deselects it, clearing the value.
+ ///
+ [Parameter] public bool AllowDeselect { get; set; }
+
///
/// Custom CSS classes for different parts of the BitCalendar component.
///
@@ -151,17 +191,25 @@ private int _minuteView
[Parameter] public RenderFragment? DayCellTemplate { get; set; }
///
- /// Disables every day after today, exactly as a of today would.
+ /// Disables every day after today, exactly as a of now would.
/// When both are set, the earlier of the two bounds wins.
///
+ ///
+ /// Today stays selectable, and a time picker on screen is held to the hours of it up to this very
+ /// minute: the bound is an instant, so a calendar refusing the future refuses the rest of today too.
+ ///
[Parameter]
[CallOnSet(nameof(OnSetParameters))]
public bool DisableFuture { get; set; }
///
- /// Disables every day before today, exactly as a of today would.
+ /// Disables every day before today, exactly as a of now would.
/// When both are set, the later of the two bounds wins.
///
+ ///
+ /// Today stays selectable, and a time picker on screen is held to the hours of it from this very
+ /// minute on: the bound is an instant, so a calendar refusing the past refuses this morning too.
+ ///
[Parameter]
[CallOnSet(nameof(OnSetParameters))]
public bool DisablePast { get; set; }
@@ -177,10 +225,22 @@ private int _minuteView
[Parameter] public IEnumerable? DisabledDaysOfWeek { get; set; }
///
- /// The list of events to display on calendar days.
+ /// The list of events to display on calendar days. The events of a day are ordered the way an agenda of it
+ /// is: the all-day ones (which carry no start time) first, then the rest by the time they start at.
///
[Parameter] public IEnumerable? Events { get; set; }
+ ///
+ /// The title (tooltip) and the accessible name of the close button of the event details dialog.
+ ///
+ [Parameter] public string EventDetailsCloseButtonTitle { get; set; } = "Close";
+
+ ///
+ /// Used to customize how an event is rendered in the details dialog, in place of its title, its time and
+ /// its body - the place for a link to whatever the event stands for in the application.
+ ///
+ [Parameter] public RenderFragment? EventTemplate { get; set; }
+
///
/// The text shown before the start time of an event when only a start time is present (e.g. "From 09:00").
///
@@ -191,6 +251,12 @@ private int _minuteView
///
[Parameter] public string EventTimeUntilText { get; set; } = "Until";
+ ///
+ /// Rendered under the pickers, inside the root of the calendar: the place for the actions a calendar is
+ /// often given of its own, such as a Clear button or a summary of the selection.
+ ///
+ [Parameter] public RenderFragment? FooterTemplate { get; set; }
+
///
/// Overrides the first day of the week in the day picker. If not set, the first day of the week of the Culture is used.
///
@@ -200,7 +266,8 @@ private int _minuteView
///
/// Whether the day picker should always render six weeks, filling the extra rows with the days of the adjacent months,
- /// to keep the calendar height fixed while navigating between months.
+ /// to keep the calendar height fixed while navigating between months. It is always on when
+ /// renders more than one month, so the months keep an even height next to each other.
///
[Parameter]
[CallOnSet(nameof(OnSetParameters))]
@@ -211,6 +278,12 @@ private int _minuteView
///
[Parameter] public Func? GetDayClass { get; set; }
+ ///
+ /// Rendered above the pickers, inside the root of the calendar: the place for a caption or a legend of
+ /// its own, before the day, month and year grids.
+ ///
+ [Parameter] public RenderFragment? HeaderTemplate { get; set; }
+
///
/// The title of the Go to next month button (tooltip).
///
@@ -304,13 +377,19 @@ private int _minuteView
///
[Parameter] public bool HighlightSelectedMonth { get; set; }
+ ///
+ /// Whether the day picker should highlight today's day. It only affects the visual style of the day
+ /// cell; the accessibility attributes still report the day as the current date.
+ ///
+ [Parameter] public bool HighlightToday { get; set; } = true;
+
///
/// The step, in hours, the spin buttons of the time picker move the hour by.
///
///
- /// A step greater than 1 lays a grid over the day that every hour the buttons produce sits on, starting at
- /// midnight, so a picker that only accepts times on a three-hour grid can say so. A time entered as text is
- /// not held to it. Values below 1 are treated as 1.
+ /// A step greater than 1 lays a grid over the day that every hour the picker produces sits on, starting at
+ /// midnight, so a picker that only accepts times on a three-hour grid can say so. The buttons, the keys and
+ /// what is typed into the hour are all held to it. Values below 1 are treated as 1.
///
[Parameter] public int HourStep { get; set; } = 1;
@@ -327,6 +406,11 @@ private int _minuteView
///
/// The maximum allowable date of the calendar.
///
+ ///
+ /// The days after it are ruled out as a whole, and the day it itself falls on stays selectable. Where a
+ /// time picker is on screen, the time it carries bounds the hours of that day too, so the calendar
+ /// cannot produce an instant past the bound.
+ ///
[Parameter]
[CallOnSet(nameof(OnSetParameters))]
public DateTimeOffset? MaxDate { get; set; }
@@ -334,6 +418,7 @@ private int _minuteView
///
/// The minimum allowable date of the calendar.
///
+ ///
[Parameter]
[CallOnSet(nameof(OnSetParameters))]
public DateTimeOffset? MinDate { get; set; }
@@ -342,14 +427,28 @@ private int _minuteView
/// The step, in minutes, the spin buttons of the time picker move the minute by.
///
///
- /// A step greater than 1 lays a grid over the hour that every minute the buttons produce sits on, starting
- /// at the top of the hour, which is what turns it into a five-minute or quarter-hour picker. A time entered
- /// as text is not held to it. Values below 1 are treated as 1.
+ /// A step greater than 1 lays a grid over the hour that every minute the picker produces sits on, starting
+ /// at the top of the hour, which is what turns it into a five-minute or quarter-hour picker. The buttons,
+ /// the keys and what is typed into the minute are all held to it. Values below 1 are treated as 1.
///
[Parameter] public int MinuteStep { get; set; } = 1;
///
- /// Used to customize how content inside the month cell is rendered.
+ /// The number of consecutive months rendered side by side in the day picker (1 to 3), which turns the
+ /// calendar into a view of a whole season - the arrow keys, the selection and the events carry on
+ /// across the months of it.
+ ///
+ ///
+ /// More than one month always renders six week rows, so the months keep an even height next to each
+ /// other, and never renders the days of the adjacent months, since each of them is a day of the pane
+ /// beside it. The months stack instead of overflowing when the width they are given cannot fit them.
+ ///
+ [Parameter, ResetClassBuilder]
+ [CallOnSet(nameof(OnSetParameters))]
+ public int MonthCount { get; set; } = 1;
+
+ ///
+ /// Used to customize how content inside the month cell is rendered.
///
[Parameter] public RenderFragment? MonthCellTemplate { get; set; }
@@ -402,6 +501,13 @@ private int _minuteView
///
[Parameter] public EventCallback OnSelectDate { get; set; }
+ ///
+ /// Whether the previous and next navigation buttons move the calendar by all of its rendered months
+ /// instead of one, so consecutive pages of a multi-month calendar never overlap.
+ /// It has no effect when renders a single month.
+ ///
+ [Parameter] public bool PagedNavigation { get; set; }
+
///
/// Gets or sets the icon to display in the Go to previous month button using custom CSS classes for external icon libraries.
/// Takes precedence over when both are set.
@@ -436,7 +542,8 @@ private int _minuteView
[Parameter] public string? PrevYearRangeNavIconName { get; set; }
///
- /// The text of selected date aria-atomic of the calendar.
+ /// The template of the text a screen reader is given when the selection changes, where {0} is the selected
+ /// date written with the . Nothing is announced while no date is selected.
///
[Parameter] public string SelectedDateAriaAtomic { get; set; } = "Selected date {0}";
@@ -450,6 +557,13 @@ private int _minuteView
///
[Parameter] public bool ShowGoToToday { get; set; } = true;
+ ///
+ /// Whether clicking a day that carries events opens the dialog listing them.
+ /// The day is selected either way; turning this off leaves the events to the indicator, the tooltip and
+ /// whatever the page itself shows for the selected day.
+ ///
+ [Parameter] public bool ShowEventDetails { get; set; } = true;
+
///
/// Whether the month picker is shown or hidden.
///
@@ -460,10 +574,14 @@ private int _minuteView
///
/// Show month picker on top of date picker when visible.
///
- [Parameter] public bool ShowMonthPickerAsOverlay { get; set; }
+ [Parameter]
+ [CallOnSet(nameof(OnSetParameters))]
+ public bool ShowMonthPickerAsOverlay { get; set; }
///
/// Whether the days of the previous and next months should be shown in the day picker.
+ /// It has no effect when renders more than one month, since those days would
+ /// then show up in two grids at once.
///
[Parameter] public bool ShowOutsideDays { get; set; } = true;
@@ -477,7 +595,9 @@ private int _minuteView
///
/// Show time picker on top of date picker when visible.
///
- [Parameter] public bool ShowTimePickerAsOverlay { get; set; }
+ [Parameter]
+ [CallOnSet(nameof(OnSetParameters))]
+ public bool ShowTimePickerAsOverlay { get; set; }
///
/// Gets or sets the icon to display in the ShowTimePicker button using custom CSS classes for external icon libraries.
@@ -500,6 +620,12 @@ private int _minuteView
///
[Parameter] public bool ShowWeekNumbers { get; set; }
+ ///
+ /// The size of the calendar, which scales its cells and their text.
+ ///
+ [Parameter, ResetClassBuilder]
+ public BitSize? Size { get; set; }
+
///
/// Specifies the date and time of the calendar when it is showing without any selected value.
///
@@ -528,6 +654,16 @@ private int _minuteView
///
[Parameter] public string? TimePickerDecreaseHourIconName { get; set; }
+ ///
+ /// The title (tooltip) and the accessible name of the time-picker's decrease-hour button.
+ ///
+ [Parameter] public string TimePickerDecreaseHourTitle { get; set; } = "Decrease hour";
+
+ ///
+ /// The title (tooltip) and the accessible name of the time-picker's decrease-minute button.
+ ///
+ [Parameter] public string TimePickerDecreaseMinuteTitle { get; set; } = "Decrease minute";
+
///
/// Gets or sets the icon to display in the decrease-minute button using custom CSS classes for external icon libraries.
/// Takes precedence over when both are set.
@@ -571,6 +707,16 @@ private int _minuteView
///
[Parameter] public string? TimePickerIncreaseMinuteIconName { get; set; }
+ ///
+ /// The title (tooltip) and the accessible name of the time-picker's increase-hour button.
+ ///
+ [Parameter] public string TimePickerIncreaseHourTitle { get; set; } = "Increase hour";
+
+ ///
+ /// The title (tooltip) and the accessible name of the time-picker's increase-minute button.
+ ///
+ [Parameter] public string TimePickerIncreaseMinuteTitle { get; set; } = "Increase minute";
+
///
/// TimeZone for the DatePicker.
///
@@ -588,6 +734,11 @@ private int _minuteView
///
[Parameter] public CalendarWeekRule? WeekNumberRule { get; set; }
+ ///
+ /// The accessible name of the empty column header above the week numbers.
+ ///
+ [Parameter] public string WeekNumbersHeaderTitle { get; set; } = "Week";
+
///
/// The title of the week number (tooltip).
///
@@ -618,6 +769,12 @@ protected override void RegisterCssClasses()
ClassBuilder.Register(() => BitCssClasses.Color(Color, "bit-cal"));
+ ClassBuilder.Register(() => BitCssClasses.Size(Size, "bit-cal"));
+
+ // A strip of months is given the width of all of them, so it says it is one: what it is given is not
+ // always what it asks for, and the months then stack instead of overflowing whatever holds them.
+ ClassBuilder.Register(() => Math.Clamp(MonthCount, 1, MAX_MONTH_COUNT) > 1 ? "bit-cal-mcv" : null);
+
// A culture that writes right to left implies the direction of the calendar as well, so one that was
// given no explicit Dir still lays itself out the way its culture reads.
ClassBuilder.Register(() => BitCssClasses.CultureRtl(Dir, _culture));
@@ -639,12 +796,22 @@ protected override void OnInitialized()
base.OnInitialized();
}
+ [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BitCalendarParams))]
protected override void OnParametersSet()
{
+ CascadingParameters?.UpdateParameters(this);
+
base.OnParametersSet();
BuildEventsLookup();
BuildDatesLookups();
+
+ // The dialog lists the events of its day, so it goes away with the last of them - a page that clears
+ // the events of the visible month is not left with an empty dialog over its calendar.
+ if (_showEventModal && GetEventModalEvents().Count == 0)
+ {
+ CloseEventModal();
+ }
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -662,16 +829,74 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
catch (JSDisconnectedException) { } // we can ignore this exception here
}
- if (_focusAfterRender && _focusedDate.HasValue)
+ // The cell the keyboard moved to, which can be in a month - or in a whole picker - that was not on
+ // screen when the key was pressed, so the focus is placed after the render that brought it there.
+ if (_focusElementIdAfterRender.HasValue())
+ {
+ var elementId = _focusElementIdAfterRender!;
+ _focusElementIdAfterRender = null;
+
+ try
+ {
+ await _js.BitCalendarsFocusCell(elementId);
+ }
+ catch (JSDisconnectedException) { } // we can ignore this exception here
+ }
+
+ if (_focusTimePickerAfterRender)
{
- _focusAfterRender = false;
+ _focusTimePickerAfterRender = false;
try
{
- await _js.BitCalendarsFocusCell(GetDayButtonId(_focusedDate.Value));
+ await _inputTimeHourRef.FocusAsync();
}
catch (JSDisconnectedException) { } // we can ignore this exception here
}
+
+ await SyncEventDialogFocus();
+ }
+
+ ///
+ /// Gives focus to the calendar: the day of the day grid that is in the tab sequence, or the month, the
+ /// year or the hour input when the day grid is not the picker on screen.
+ ///
+ ///
+ /// The calendar renders no input element of its own - what the keyboard operates is a grid of buttons -
+ /// so this overrides the base implementation rather than focusing a field.
+ ///
+ public override ValueTask FocusAsync() => FocusAsync(false);
+
+ ///
+ /// Whether the browser should refrain from scrolling the newly focused cell
+ /// into view.
+ public override async ValueTask FocusAsync(bool preventScroll)
+ {
+ MoveFocusToTheVisiblePicker();
+
+ if (_focusElementIdAfterRender.HasValue())
+ {
+ var elementId = _focusElementIdAfterRender!;
+ _focusElementIdAfterRender = null;
+
+ try
+ {
+ await _js.BitCalendarsFocusCell(elementId, preventScroll);
+ }
+ catch (JSDisconnectedException) { } // we can ignore this exception here
+
+ return;
+ }
+
+ if (_focusTimePickerAfterRender is false) return;
+
+ _focusTimePickerAfterRender = false;
+
+ try
+ {
+ await _inputTimeHourRef.FocusAsync(preventScroll);
+ }
+ catch (JSDisconnectedException) { } // we can ignore this exception here
}
protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out DateTimeOffset? result, [NotNullWhen(false)] out string? validationErrorMessage)
@@ -704,38 +929,65 @@ protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(fa
+ // A value the user just picked is not a parameter change: the pickers that are on screen stay on screen.
+ // Recomputing them here would close the time picker overlay on the first press of a spin button, and take
+ // the month overlay away from under a selection made in it.
private void HandleOnValueChanged(object? sender, EventArgs args)
{
- OnSetParameters();
+ OnSetParameters(resetPickers: false);
}
- private void OnSetParameters()
+ // Internal rather than private so that BitCalendarParams can run the pass again after it has filled in the
+ // parameters the view is built from: the cascade reaches the component after OnInitialized has already run it.
+ internal void OnSetParameters() => OnSetParameters(true);
+
+ private void OnSetParameters(bool resetPickers)
{
- _showTimePicker = ShowTimePicker && ShowTimePickerAsOverlay is false;
- _showMonthPicker = _showTimePicker is false && ShowMonthPicker && ShowMonthPickerAsOverlay is false;
+ if (resetPickers)
+ {
+ _showTimePicker = ShowTimePicker && ShowTimePickerAsOverlay is false;
+ _showMonthPicker = _showTimePicker is false && ShowMonthPicker && ShowMonthPickerAsOverlay is false;
+ }
_timeZone = TimeZone ?? TimeZoneInfo.Local;
_culture = Culture ?? CultureInfo.CurrentUICulture;
+ _monthCount = Math.Clamp(MonthCount, 1, MAX_MONTH_COUNT);
- var dateTime = CurrentValue.GetValueOrDefault(StartingValue.GetValueOrDefault(Today ?? DateTimeOffset.Now));
+ var value = CurrentValue.GetValueOrDefault(StartingValue.GetValueOrDefault(GetNow()));
- var minDate = GetMinDate();
- var maxDate = GetMaxDate();
+ // Everything the calendar shows - the month it opens on, the time in the time picker - belongs to the
+ // TimeZone of the component, not to the offset the value happens to carry.
+ var dateTime = GetDateTime(value);
- if (minDate.HasValue && minDate > dateTime)
+ // The bounds rule out days rather than instants, the way every other comparison against them does, so
+ // a value falling on the MinDate's or the MaxDate's own day is already in range: comparing the two as
+ // instants would pull today's midnight up to a DisablePast bound of now and overwrite the time of day
+ // with the bound's on every value the user picks.
+ var minDate = GetMinDate();
+ if (minDate.HasValue && GetDateTime(minDate.Value).Date > dateTime.Date)
{
- dateTime = minDate.Value;
+ dateTime = GetDateTime(minDate.Value);
}
- if (maxDate.HasValue && maxDate < dateTime)
+ var maxDate = GetMaxDate();
+ if (maxDate.HasValue && GetDateTime(maxDate.Value).Date < dateTime.Date)
{
- dateTime = maxDate.Value;
+ dateTime = GetDateTime(maxDate.Value);
}
_hour = CurrentValue.HasValue || StartingValue.HasValue ? dateTime.Hour : 0;
_minute = CurrentValue.HasValue || StartingValue.HasValue ? dateTime.Minute : 0;
- GenerateCalendarData(dateTime.DateTime);
+ // A value falling inside the months already on screen leaves the view where it is: a calendar
+ // showing a season is not paged by picking a day of its second or its third month.
+ if (_monthCount > 1 && _monthsOfView.Length == _monthCount && IsInDisplayedMonths(dateTime.Date))
+ {
+ GenerateMonthData(_currentYear, _currentMonth);
+ }
+ else
+ {
+ GenerateCalendarData(dateTime);
+ }
}
private async Task SelectDate(DateTime selectedDate)
@@ -747,19 +999,61 @@ private async Task SelectDate(DateTime selectedDate)
var previousYear = _currentYear;
var previousMonth = _currentMonth;
+ // Selecting the selected day again deselects it (AllowDeselect), leaving the calendar on the month
+ // the user is looking at rather than jumping back onto today the way an emptied value otherwise would.
+ if (AllowDeselect && IsSelectedDate(selectedDate))
+ {
+ // Emptying the value runs the whole of OnSetParameters, which rebuilds the view around today (or
+ // around StartingValue) - the displayed month, the range the year picker offers and the time in
+ // the time picker alike. None of that is what the user asked for by clearing a day, so all of it
+ // is put back: what a deselection changes is the value and nothing else.
+ var hour = _hour;
+ var minute = _minute;
+ var yearPickerEndYear = _yearPickerEndYear;
+ var yearPickerStartYear = _yearPickerStartYear;
+
+ _focusedDate = selectedDate;
+
+ CurrentValue = null;
+
+ _hour = hour;
+ _minute = minute;
+ _currentYear = previousYear;
+ _currentMonth = previousMonth;
+ _yearPickerEndYear = yearPickerEndYear;
+ _yearPickerStartYear = yearPickerStartYear;
+
+ GenerateMonthData(_currentYear, _currentMonth);
+
+ await OnSelectDate.InvokeAsync(CurrentValue);
+
+ return;
+ }
+
_focusedDate = selectedDate;
+ // The time the picker is holding travels onto the day being selected, where a bound carrying a time
+ // of day can allow less of it than the day it came from did - the morning of a calendar bounded at
+ // "now", for one. So it arrives inside what this day allows rather than pushing the value past it.
+ ClampTimeToBounds(selectedDate);
+
selectedDate = selectedDate.AddHours(_hour);
selectedDate = selectedDate.AddMinutes(_minute);
CurrentValue = new DateTimeOffset(selectedDate, _timeZone.GetUtcOffset(selectedDate));
- _currentYear = _culture.Calendar.GetYear(selectedDate);
- _currentMonth = _culture.Calendar.GetMonth(selectedDate);
+ // A day of a month the view already shows leaves the view where it is - which is what keeps a
+ // multi-month calendar still while its second and third months are picked from. Only a day outside
+ // of them, one of the adjacent months filling the grid, scrolls the calendar onto its own month.
+ if (IsInDisplayedMonths(selectedDate.Date) is false)
+ {
+ _currentYear = _culture.Calendar.GetYear(selectedDate);
+ _currentMonth = _culture.Calendar.GetMonth(selectedDate);
+ }
if (_currentYear != previousYear || _currentMonth != previousMonth)
{
- _focusAfterRender = true;
+ _focusElementIdAfterRender = GetDayButtonId(_focusedDate.Value);
}
GenerateMonthData(_currentYear, _currentMonth);
@@ -778,6 +1072,7 @@ private async Task SelectMonth(int month)
var previousMonth = _currentMonth;
_currentMonth = month;
+ _focusedMonthCell = month;
GenerateMonthData(_currentYear, _currentMonth);
@@ -801,10 +1096,16 @@ private async Task SelectYear(int year)
ChangeYearRanges(_currentYear - 1);
+ ClampCurrentMonthToYear();
+
GenerateMonthData(_currentYear, _currentMonth);
ToggleBetweenMonthAndYearPicker();
+ // The year that was activated goes away with the year grid, so the focus is handed to the month grid
+ // that replaces it - otherwise a keyboard selection drops the focus onto the body.
+ FocusMonthCell(GetFocusableMonth());
+
await NotifyMonthChange(previousYear, previousMonth);
}
@@ -813,6 +1114,11 @@ private void ToggleBetweenMonthAndYearPicker()
if (IsEnabled is false) return;
_showYearPicker = !_showYearPicker;
+
+ // The grid that comes into view starts its roving tabindex over, on the month or the year the
+ // calendar is actually displaying, rather than on wherever the keyboard left it last time.
+ _focusedYearCell = null;
+ _focusedMonthCell = null;
}
private async Task HandleMonthChange(bool isNext)
@@ -823,29 +1129,15 @@ private async Task HandleMonthChange(bool isNext)
var previousYear = _currentYear;
var previousMonth = _currentMonth;
- if (isNext)
- {
- if (_currentMonth < 12)
- {
- _currentMonth++;
- }
- else
- {
- _currentYear++;
- _currentMonth = 1;
- }
- }
- else
+ // With PagedNavigation the calendar moves a whole page of months at once, but never past the point
+ // where the single-month navigation would have stopped.
+ var steps = PagedNavigation ? _monthCount : 1;
+
+ for (var i = 0; i < steps; i++)
{
- if (_currentMonth > 1)
- {
- _currentMonth--;
- }
- else
- {
- _currentYear--;
- _currentMonth = 12;
- }
+ if (i > 0 && CanChangeMonth(isNext) is false) break;
+
+ (_currentYear, _currentMonth) = AddCalendarMonths(_currentYear, _currentMonth, isNext ? 1 : -1);
}
GenerateMonthData(_currentYear, _currentMonth);
@@ -863,6 +1155,8 @@ private async Task HandleYearChange(bool isNext)
_currentYear += isNext ? +1 : -1;
+ ClampCurrentMonthToYear();
+
GenerateMonthData(_currentYear, _currentMonth);
await NotifyMonthChange(previousYear, previousMonth);
@@ -887,6 +1181,11 @@ private async Task HandleGoToToday()
GenerateCalendarData(GetToday());
+ // The month and the year the roving tabindex was left on are not the ones on screen any more, so the
+ // grids start it over on what they now display.
+ _focusedYearCell = null;
+ _focusedMonthCell = null;
+
await NotifyMonthChange(previousYear, previousMonth);
}
@@ -900,13 +1199,29 @@ private void HandleNowButtonClick()
_hour = now.Hour;
_minute = now.Minute;
+ // "Now" is a time like any other the picker produces, so the bounds have the same say over it.
+ ClampTimeToBounds();
+
UpdateTime();
}
private void GenerateCalendarData(DateTime dateTime)
{
- _currentMonth = _culture.Calendar.GetMonth(dateTime);
- _currentYear = _culture.Calendar.GetYear(dateTime);
+ var calendar = _culture.Calendar;
+
+ // A date the culture's calendar does not support has no year or month to be read off it, so the view
+ // opens on the nearest date it does - the Hebrew calendar of .NET, for one, starts in 1583.
+ if (dateTime < calendar.MinSupportedDateTime)
+ {
+ dateTime = calendar.MinSupportedDateTime;
+ }
+ else if (dateTime > calendar.MaxSupportedDateTime)
+ {
+ dateTime = calendar.MaxSupportedDateTime;
+ }
+
+ _currentMonth = calendar.GetMonth(dateTime);
+ _currentYear = calendar.GetYear(dateTime);
_yearPickerStartYear = _currentYear - 1;
_yearPickerEndYear = _currentYear + 10;
@@ -914,109 +1229,332 @@ private void GenerateCalendarData(DateTime dateTime)
GenerateMonthData(_currentYear, _currentMonth);
}
+ // Fills the grid of every rendered month, starting from the given one.
private void GenerateMonthData(int year, int month)
{
- _monthTitle = $"{_culture.DateTimeFormat.GetMonthName(month)} {year}";
+ // The last page of a multi-month view is a full one. A start so late that the months following it
+ // are past the end of the calendar would repeat the last of them - two panes of the same month,
+ // down to the ids of its days - so the view is pulled back far enough to hold as many as it shows.
+ if (_monthCount > 1)
+ {
+ var (maxYear, maxMonth) = GetMaxCalendarYearMonth();
+ var (latestStartYear, latestStartMonth) = AddCalendarMonths(maxYear, maxMonth, -(_monthCount - 1));
+
+ if (CompareYearMonth(year, month, latestStartYear, latestStartMonth) > 0)
+ {
+ (year, month) = (latestStartYear, latestStartMonth);
+ (_currentYear, _currentMonth) = (year, month);
+ }
+ }
+
+ if (_daysOfMonths.Length != _monthCount)
+ {
+ _daysOfMonths = new DateTime?[_monthCount][,];
+ for (var i = 0; i < _monthCount; i++)
+ {
+ _daysOfMonths[i] = new DateTime?[DEFAULT_WEEK_COUNT, DEFAULT_DAY_COUNT_PER_WEEK];
+ }
+
+ _monthTitles = new string[_monthCount];
+ _monthsOfView = new (int, int)[_monthCount];
+ }
+
+ for (var i = 0; i < _monthCount; i++)
+ {
+ var (y, m) = AddCalendarMonths(year, month, i);
+
+ GenerateSingleMonthData(i, y, m);
+ }
+ }
+
+ // Walks the (year, month) pair the given number of months forward or backward, through the months of
+ // the culture's own calendar: a leap year of the Hebrew calendar has thirteen of them, so the count of
+ // each year is asked for rather than assumed. A pair that walks off the supported range comes back
+ // resting on its edge, the way every other move of the calendar does.
+ private (int Year, int Month) AddCalendarMonths(int year, int month, int offset)
+ {
+ var (minYear, minMonth) = GetMinCalendarYearMonth();
+ var (maxYear, maxMonth) = GetMaxCalendarYearMonth();
+ for (var i = 0; i < Math.Abs(offset); i++)
+ {
+ if (offset > 0)
+ {
+ if (year > maxYear || (year == maxYear && month >= maxMonth)) return (maxYear, maxMonth);
+
+ if (month < GetMonthsInYear(year))
+ {
+ month++;
+ }
+ else
+ {
+ year++;
+ month = 1;
+ }
+ }
+ else
+ {
+ if (year < minYear || (year == minYear && month <= minMonth)) return (minYear, minMonth);
+
+ if (month > 1)
+ {
+ month--;
+ }
+ else
+ {
+ year--;
+ month = GetMonthsInYear(year);
+ }
+ }
+ }
+
+ return (year, month);
+ }
+
+ private void GenerateSingleMonthData(int monthIndex, int year, int month)
+ {
+ _monthsOfView[monthIndex] = (year, month);
+ _monthTitles[monthIndex] = $"{GetMonthName(year, month)} {year}";
+
+ var days = _daysOfMonths[monthIndex];
var calendar = _culture.Calendar;
- var firstDayOfMonth = new DateTime(year, month, 1, calendar);
int daysInMonth = calendar.GetDaysInMonth(year, month);
- int dayOfWeek = (int)calendar.GetDayOfWeek(firstDayOfMonth);
int firstDayOfWeek = (int)GetFirstDayOfWeek();
+ int dayOfWeek;
- // Adjust dayOfWeek to match the culture's first day of week
- dayOfWeek = (dayOfWeek - firstDayOfWeek + 7) % 7;
-
- DateTime previousMonth;
- if (month == 1)
+ var firstDayOfMonth = TryCreateDate(year, month, 1);
+ if (firstDayOfMonth.HasValue)
{
- previousMonth = new(year - 1, 12, 1);
+ dayOfWeek = (int)calendar.GetDayOfWeek(firstDayOfMonth.Value);
}
else
{
- previousMonth = new(year, month - 1, 1);
+ // The first supported month of a calendar does not have to start at its first day (the minimum
+ // of the Hebrew calendar falls in the middle of a month), so the weekday of the unrepresentable
+ // first day is walked back from the first day the calendar does support.
+ var minDate = calendar.MinSupportedDateTime;
+ dayOfWeek = ((int)calendar.GetDayOfWeek(minDate) - (calendar.GetDayOfMonth(minDate) - 1)) % 7;
+ if (dayOfWeek < 0)
+ {
+ dayOfWeek += 7;
+ }
}
- int daysInPreviousMonth = calendar.GetDaysInMonth(previousMonth.Year, previousMonth.Month);
- DateTime nextMonth;
- if (month == 12)
+ // Adjust dayOfWeek to match the culture's first day of week
+ dayOfWeek = (dayOfWeek - firstDayOfWeek + 7) % 7;
+
+ // The adjacent months are kept as plain year/month numbers of the culture's own calendar: a DateTime
+ // built out of them would be a Gregorian date of a year that calendar never had, and a thirteenth
+ // month - which a leap year of the Hebrew calendar does have - has no Gregorian counterpart at all.
+ int monthsInYear = calendar.GetMonthsInYear(year);
+
+ int previousYear = month == 1 ? year - 1 : year;
+ int previousMonth;
+ int daysInPreviousMonth;
+ if (previousYear < GetMinCalendarYearMonth().Year)
{
- nextMonth = new(year + 1, 1, 1);
+ // The year before the calendar's first year cannot be asked anything - every one of its days
+ // comes out as an empty cell anyway, so any day numbers at all do for the counting.
+ previousMonth = 1;
+ daysInPreviousMonth = dayOfWeek;
}
else
{
- nextMonth = new(year, month + 1, 1);
+ previousMonth = month == 1 ? calendar.GetMonthsInYear(previousYear) : month - 1;
+ daysInPreviousMonth = calendar.GetDaysInMonth(previousYear, previousMonth);
}
+ int nextYear = month == monthsInYear ? year + 1 : year;
+ int nextMonth = month == monthsInYear ? 1 : month + 1;
+
int day = daysInPreviousMonth - dayOfWeek + 1;
- for (int i = 0; i < 1; i++)
+ for (int j = 0; j < dayOfWeek; j++)
{
- for (int j = 0; j < dayOfWeek; j++)
- {
- _daysOfCurrentMonth[i, j] = new(previousMonth.Year, previousMonth.Month, day, calendar);
- day++;
- }
+ days[0, j] = TryCreateDate(previousYear, previousMonth, day);
+ day++;
}
day = 1;
var ended = false;
- for (int i = 0; i < 6; i++)
+ // Months of unequal height would make a multi-month strip ragged, so the six rows are always laid
+ // out there even when FixedWeeks is off.
+ var fixedWeeks = FixedWeeks || _monthCount > 1;
+ for (int i = 0; i < DEFAULT_WEEK_COUNT; i++)
{
- for (int j = 0; j < 7; j++)
+ for (int j = 0; j < DEFAULT_DAY_COUNT_PER_WEEK; j++)
{
if (i == 0 && j < dayOfWeek) continue;
if (day <= daysInMonth)
{
- _daysOfCurrentMonth[i, j] = new(year, month, day, calendar);
+ days[i, j] = TryCreateDate(year, month, day);
day++;
}
else
{
- if (j == 0 && FixedWeeks is false)
+ if (j == 0 && fixedWeeks is false)
{
ended = true;
}
- _daysOfCurrentMonth[i, j] = ended ? null : new(nextMonth.Year, nextMonth.Month, day - daysInMonth, calendar);
+ days[i, j] = ended ? null : TryCreateDate(nextYear, nextMonth, day - daysInMonth);
day++;
}
}
}
}
- private void ChangeYearRanges(int fromYear)
- {
- _yearPickerStartYear = fromYear;
- _yearPickerEndYear = fromYear + 11;
- }
-
- private int GetDayOfCurrentMonth(DateTime date)
- {
- return _culture.Calendar.GetDayOfMonth(date);
- }
-
- private bool IsGoToTodayButtonDisabled(int todayYear, int todayMonth, bool showYearPicker = false)
+ // A day at the very edge of the calendar - the days around its first and last supported months - cannot
+ // be represented as a DateTime at all, so it becomes an empty cell instead of an exception.
+ private DateTime? TryCreateDate(int year, int month, int day)
{
- if (IsEnabled is false) return true;
-
- if (showYearPicker)
+ try
{
- return _yearPickerStartYear == todayYear - 1
- && _yearPickerEndYear == todayYear + 10
- && todayMonth == _currentMonth
- && todayYear == _currentYear;
+ return new DateTime(year, month, day, _culture.Calendar);
}
- else
+ catch (ArgumentException)
{
- return todayMonth == _currentMonth
- && todayYear == _currentYear;
+ return null;
}
}
- private DayOfWeek GetFirstDayOfWeek()
+ // The first day of a month at the very edge of the calendar's supported range does not have to be
+ // representable (the range of the Hebrew calendar starts in the middle of a month), so the nearest day
+ // the calendar does support stands in for it.
+ private DateTime GetFirstDayOfMonthOrClamp(int year, int month)
{
- return FirstDayOfWeek ?? _culture.DateTimeFormat.FirstDayOfWeek;
+ var date = TryCreateDate(year, month, 1);
+ if (date.HasValue) return date.Value;
+
+ var calendar = _culture.Calendar;
+ var (minYear, minMonth) = GetMinCalendarYearMonth();
+
+ return year == minYear && month <= minMonth
+ ? calendar.MinSupportedDateTime.Date
+ : calendar.MaxSupportedDateTime.Date;
+ }
+
+ // DateTime is bounded to the years 1 through 9999 of the Gregorian calendar, and some calendars support
+ // even less, so everything the navigation can reach is bounded by the calendar's own range the same way
+ // MinDate and MaxDate bound it.
+ private (int Year, int Month) GetMinCalendarYearMonth()
+ {
+ var calendar = _culture.Calendar;
+ var minDate = calendar.MinSupportedDateTime;
+
+ return (calendar.GetYear(minDate), calendar.GetMonth(minDate));
+ }
+
+ ///
+ private (int Year, int Month) GetMaxCalendarYearMonth()
+ {
+ var calendar = _culture.Calendar;
+ var maxDate = calendar.MaxSupportedDateTime;
+
+ return (calendar.GetYear(maxDate), calendar.GetMonth(maxDate));
+ }
+
+ private int GetMonthsInCurrentYear()
+ {
+ // Not every calendar has twelve months: a leap year of the Hebrew calendar has thirteen.
+ return GetMonthsInYear(_currentYear);
+ }
+
+ ///
+ private int GetMonthsInYear(int year)
+ {
+ return _culture.Calendar.GetMonthsInYear(year);
+ }
+
+ // A culture whose month names are the thirteen of a Hebrew leap year names the months of a common year
+ // one place further along that table from the seventh on - the thirteenth name is the second Adar, which
+ // a common year does not have. .NET's own formatter makes the same shift, so a month read off the names
+ // directly would be spelled differently from the very date the calendar is showing.
+ private string GetMonthName(int year, int month) => _culture.DateTimeFormat.GetMonthName(GetMonthNameIndex(year, month));
+
+ ///
+ private string GetAbbreviatedMonthName(int year, int month) => _culture.DateTimeFormat.GetAbbreviatedMonthName(GetMonthNameIndex(year, month));
+
+ ///
+ private int GetMonthNameIndex(int year, int month)
+ {
+ if (month < 7) return month;
+
+ // Only a culture whose table really does hold thirteen names shifts: everywhere else the thirteenth
+ // entry is the empty string every DateTimeFormatInfo pads its twelve names with.
+ var monthNames = _culture.DateTimeFormat.MonthNames;
+ if (monthNames.Length < 13 || monthNames[12].HasNoValue()) return month;
+
+ return GetMonthsInYear(year) == 13 ? month : month + 1;
+ }
+
+ private bool IsWeekRowEmpty(int monthIndex, int weekIndex)
+ {
+ for (var day = 0; day < DEFAULT_DAY_COUNT_PER_WEEK; day++)
+ {
+ if (_daysOfMonths[monthIndex][weekIndex, day].HasValue) return false;
+ }
+
+ return true;
+ }
+
+ // Moving to another year of a calendar whose years do not all have the same number of months (the Hebrew
+ // one) can leave the displayed month past the end of the year that is now displayed - and moving to the
+ // first or the last supported year can leave it past the supported part of that year.
+ private void ClampCurrentMonthToYear()
+ {
+ var monthsInYear = GetMonthsInCurrentYear();
+
+ if (_currentMonth > monthsInYear)
+ {
+ _currentMonth = monthsInYear;
+ }
+
+ var (minCalendarYear, minCalendarMonth) = GetMinCalendarYearMonth();
+ if (_currentYear == minCalendarYear && _currentMonth < minCalendarMonth)
+ {
+ _currentMonth = minCalendarMonth;
+ }
+
+ var (maxCalendarYear, maxCalendarMonth) = GetMaxCalendarYearMonth();
+ if (_currentYear == maxCalendarYear && _currentMonth > maxCalendarMonth)
+ {
+ _currentMonth = maxCalendarMonth;
+ }
+ }
+
+ private void ChangeYearRanges(int fromYear)
+ {
+ _yearPickerStartYear = fromYear;
+ _yearPickerEndYear = fromYear + 11;
+ }
+
+ private int GetDayOfCurrentMonth(DateTime date)
+ {
+ return _culture.Calendar.GetDayOfMonth(date);
+ }
+
+ private bool IsGoToTodayButtonDisabled(int todayYear, int todayMonth, bool showYearPicker = false)
+ {
+ if (IsEnabled is false) return true;
+
+ if (showYearPicker)
+ {
+ return _yearPickerStartYear == todayYear - 1
+ && _yearPickerEndYear == todayYear + 10
+ && todayMonth == _currentMonth
+ && todayYear == _currentYear;
+ }
+ else
+ {
+ return todayMonth == _currentMonth
+ && todayYear == _currentYear;
+ }
+ }
+
+ private DayOfWeek GetFirstDayOfWeek()
+ {
+ return FirstDayOfWeek ?? _culture.DateTimeFormat.FirstDayOfWeek;
}
private DayOfWeek GetDayOfWeek(int index)
@@ -1031,56 +1569,158 @@ private DayOfWeek GetDayOfWeek(int index)
return (DayOfWeek)dayOfWeek;
}
- private int GetWeekNumber(int weekIndex)
+ // The single letter each weekday column is headed with, taken off the culture's shortest day name. A
+ // culture is free to leave those empty, which would be an index out of range, and the first character of
+ // one that is written as a surrogate pair is two of them - half of it is not a character at all.
+ private static string GetNarrowDayName(string name)
+ {
+ if (name.HasNoValue()) return string.Empty;
+
+ return char.IsHighSurrogate(name[0]) && name.Length > 1 ? name[..2] : name[..1];
+ }
+
+ private int GetWeekNumber(int monthIndex, int weekIndex)
{
- return _culture.Calendar.GetWeekOfYear(_daysOfCurrentMonth[weekIndex, 0]!.Value, WeekNumberRule ?? CalendarWeekRule.FirstFullWeek, GetFirstDayOfWeek());
+ // The first cells of the week can be empty at the very edge of the calendar's supported range, so the
+ // number of the week is read off the first day of it that actually exists.
+ var date = _daysOfMonths[monthIndex][weekIndex, 0];
+ for (var day = 1; date.HasValue is false && day < DEFAULT_DAY_COUNT_PER_WEEK; day++)
+ {
+ date = _daysOfMonths[monthIndex][weekIndex, day];
+ }
+
+ return _culture.Calendar.GetWeekOfYear(date!.Value, WeekNumberRule ?? CalendarWeekRule.FirstFullWeek, GetFirstDayOfWeek());
}
private void ToggleMonthPickerOverlay()
{
_showMonthPicker = !_showMonthPicker;
+
+ // Each toggle swaps one whole picker for another, taking the button that was activated out of the DOM
+ // with it, so the focus has to be handed over to the picker that takes its place.
+ _focusedYearCell = null;
+ _focusedMonthCell = null;
+
+ MoveFocusToTheVisiblePicker();
}
private void ToggleTimePickerOverlay()
{
_showTimePicker = !_showTimePicker;
+
+ MoveFocusToTheVisiblePicker();
+ }
+
+ // A picker laid over the day grid is a surface the keyboard was sent into, so Escape is what leaves it -
+ // the same way it leaves every other dismissible surface in the library - and the day grid underneath
+ // takes the focus back. A picker that sits beside the day grid is not covering anything and stays put.
+ private void DismissMonthPickerOverlay()
+ {
+ if (ShowMonthPickerAsOverlay is false && (ShowTimePicker && ShowTimePickerAsOverlay is false) is false) return;
+
+ ToggleMonthPickerOverlay();
+ }
+
+ ///
+ private void HandleTimePickerKeyDown(KeyboardEventArgs e)
+ {
+ if (IsEnabled is false) return;
+ if (e.Key is not "Escape") return;
+ if (ShowTimePickerAsOverlay is false) return;
+
+ ToggleTimePickerOverlay();
+ }
+
+ private void MoveFocusToTheVisiblePicker()
+ {
+ if (DayPickerIsVisible())
+ {
+ _focusedDate = GetFocusableDay();
+ _focusElementIdAfterRender = GetDayButtonId(_focusedDate.Value);
+ }
+ else if (MonthPickerIsVisible() && _showYearPicker is false)
+ {
+ FocusMonthCell(GetFocusableMonth());
+ }
+ else if (MonthPickerIsVisible())
+ {
+ FocusYearCell(GetFocusableYear());
+ }
+ else if (_showTimePicker)
+ {
+ // Nothing but the time picker is left on screen, so its hour input is where the focus goes.
+ _focusTimePickerAfterRender = true;
+ }
}
private bool CanChangeMonth(bool isNext)
{
if (IsEnabled is false) return false;
- var maxDate = GetMaxDate();
- if (isNext && maxDate.HasValue)
+ if (isNext)
{
- var maxDateYear = _culture.Calendar.GetYear(maxDate.Value.DateTime);
- var maxDateMonth = _culture.Calendar.GetMonth(maxDate.Value.DateTime);
+ // What the next button runs into is the last month of the view rather than the first: a
+ // calendar showing a quarter has already reached the bound once its third pane is on it.
+ var (lastYear, lastMonth) = GetLastDisplayedMonth();
- if (maxDateYear == _currentYear && maxDateMonth == _currentMonth) return false;
- }
+ var (maxCalendarYear, maxCalendarMonth) = GetMaxCalendarYearMonth();
+ if (CompareYearMonth(lastYear, lastMonth, maxCalendarYear, maxCalendarMonth) >= 0) return false;
- var minDate = GetMinDate();
- if (isNext is false && minDate.HasValue)
+ var max = GetMaxDate();
+ if (max.HasValue)
+ {
+ var maxDate = GetDateTime(max.Value);
+ var maxDateYear = _culture.Calendar.GetYear(maxDate);
+ var maxDateMonth = _culture.Calendar.GetMonth(maxDate);
+
+ if (CompareYearMonth(lastYear, lastMonth, maxDateYear, maxDateMonth) >= 0) return false;
+ }
+ }
+ else
{
- var minDateYear = _culture.Calendar.GetYear(minDate.Value.DateTime);
- var minDateMonth = _culture.Calendar.GetMonth(minDate.Value.DateTime);
+ var (minCalendarYear, minCalendarMonth) = GetMinCalendarYearMonth();
+ if (CompareYearMonth(_currentYear, _currentMonth, minCalendarYear, minCalendarMonth) <= 0) return false;
+
+ var min = GetMinDate();
+ if (min.HasValue)
+ {
+ var minDate = GetDateTime(min.Value);
+ var minDateYear = _culture.Calendar.GetYear(minDate);
+ var minDateMonth = _culture.Calendar.GetMonth(minDate);
- if (minDateYear == _currentYear && minDateMonth == _currentMonth) return false;
+ if (CompareYearMonth(_currentYear, _currentMonth, minDateYear, minDateMonth) <= 0) return false;
+ }
}
return true;
}
+ // The month of the last pane of the day picker, which is the one the next button runs into.
+ private (int Year, int Month) GetLastDisplayedMonth()
+ {
+ return _monthCount == 1 ? (_currentYear, _currentMonth) : AddCalendarMonths(_currentYear, _currentMonth, _monthCount - 1);
+ }
+
+ // Two months of the same calendar in order, so a bound that falls anywhere before or after the view -
+ // not only on the very month it is showing - stops the navigation.
+ private static int CompareYearMonth(int year, int month, int otherYear, int otherMonth)
+ {
+ return year != otherYear ? year.CompareTo(otherYear) : month.CompareTo(otherMonth);
+ }
+
private bool CanChangeYear(bool isNext)
{
if (IsEnabled is false) return false;
+ if (isNext && _currentYear >= GetMaxCalendarYearMonth().Year) return false;
+ if (isNext is false && _currentYear <= GetMinCalendarYearMonth().Year) return false;
+
var maxDate = GetMaxDate();
var minDate = GetMinDate();
return (
- (isNext && maxDate.HasValue && _culture.Calendar.GetYear(maxDate.Value.DateTime) == _currentYear) ||
- (isNext is false && minDate.HasValue && _culture.Calendar.GetYear(minDate.Value.DateTime) == _currentYear)
+ (isNext && maxDate.HasValue && _culture.Calendar.GetYear(GetDateTime(maxDate.Value)) == _currentYear) ||
+ (isNext is false && minDate.HasValue && _culture.Calendar.GetYear(GetDateTime(minDate.Value)) == _currentYear)
) is false;
}
@@ -1088,12 +1728,15 @@ private bool CanChangeYearRange(bool isNext)
{
if (IsEnabled is false) return false;
+ if (isNext && GetMaxCalendarYearMonth().Year < _yearPickerStartYear + 12) return false;
+ if (isNext is false && GetMinCalendarYearMonth().Year >= _yearPickerStartYear) return false;
+
var maxDate = GetMaxDate();
var minDate = GetMinDate();
return (
- (isNext && maxDate.HasValue && _culture.Calendar.GetYear(maxDate.Value.DateTime) < _yearPickerStartYear + 12) ||
- (isNext is false && minDate.HasValue && _culture.Calendar.GetYear(minDate.Value.DateTime) >= _yearPickerStartYear)
+ (isNext && maxDate.HasValue && _culture.Calendar.GetYear(GetDateTime(maxDate.Value)) < _yearPickerStartYear + 12) ||
+ (isNext is false && minDate.HasValue && _culture.Calendar.GetYear(GetDateTime(minDate.Value)) >= _yearPickerStartYear)
) is false;
}
@@ -1102,27 +1745,17 @@ private DateTimeOffset GetNow()
return Today ?? DateTimeOffset.Now;
}
- // Today as a whole day in the time zone of the component: the days it is compared against carry no
- // time of day, so a bound taken from the current instant would place today on the wrong side of it.
- private (DateTimeOffset start, DateTimeOffset end) GetTodayBounds()
- {
- var today = GetDateTime(GetNow()).Date;
-
- var start = new DateTimeOffset(today, _timeZone.GetUtcOffset(today));
-
- return (start, start.AddDays(1).AddTicks(-1));
- }
-
// DisablePast and DisableFuture bound the selectable days by today exactly the way MinDate and MaxDate
// do, so every consumer of the allowed range reads the bounds through these two accessors. Where both
- // apply, the narrower of the two wins. Today itself stays selectable under either of them.
+ // apply, the narrower of the two wins. Today itself stays selectable under either of them, since every
+ // comparison below weighs a whole day rather than an instant.
private DateTimeOffset? GetMinDate()
{
if (DisablePast is false) return MinDate;
- var startOfToday = GetTodayBounds().start;
+ var now = GetNow();
- return MinDate.HasValue && MinDate.Value > startOfToday ? MinDate : startOfToday;
+ return MinDate.HasValue && MinDate.Value > now ? MinDate : now;
}
///
@@ -1130,23 +1763,25 @@ private DateTimeOffset GetNow()
{
if (DisableFuture is false) return MaxDate;
- var endOfToday = GetTodayBounds().end;
+ var now = GetNow();
- return MaxDate.HasValue && MaxDate.Value < endOfToday ? MaxDate : endOfToday;
+ return MaxDate.HasValue && MaxDate.Value < now ? MaxDate : now;
}
+ // Every caller weighs a day of the calendar, so the comparison is day against day: a MinDate that carries
+ // a time of day rules out the days before it, not the day it itself falls on.
private bool IsWeekDayOutOfMinAndMaxDate(DateTime date)
{
var maxDate = GetMaxDate();
if (maxDate.HasValue)
{
- if (date > GetDateTime(maxDate.Value)) return true;
+ if (date.Date > GetDateTime(maxDate.Value).Date) return true;
}
var minDate = GetMinDate();
if (minDate.HasValue)
{
- if (date < GetDateTime(minDate.Value)) return true;
+ if (date.Date < GetDateTime(minDate.Value).Date) return true;
}
return false;
@@ -1154,20 +1789,30 @@ private bool IsWeekDayOutOfMinAndMaxDate(DateTime date)
private bool IsMonthOutOfMinAndMaxDate(int month)
{
- var maxDate = GetMaxDate();
- if (maxDate.HasValue)
+ // The supported range of the calendar itself bounds the selection the same way MinDate and MaxDate do:
+ // a month past its edge has no representable days at all.
+ var (minCalendarYear, minCalendarMonth) = GetMinCalendarYearMonth();
+ if (_currentYear < minCalendarYear || (_currentYear == minCalendarYear && month < minCalendarMonth)) return true;
+
+ var (maxCalendarYear, maxCalendarMonth) = GetMaxCalendarYearMonth();
+ if (_currentYear > maxCalendarYear || (_currentYear == maxCalendarYear && month > maxCalendarMonth)) return true;
+
+ var max = GetMaxDate();
+ if (max.HasValue)
{
- var maxDateYear = _culture.Calendar.GetYear(maxDate.Value.DateTime);
- var maxDateMonth = _culture.Calendar.GetMonth(maxDate.Value.DateTime);
+ var maxDate = GetDateTime(max.Value);
+ var maxDateYear = _culture.Calendar.GetYear(maxDate);
+ var maxDateMonth = _culture.Calendar.GetMonth(maxDate);
if (_currentYear > maxDateYear || (_currentYear == maxDateYear && month > maxDateMonth)) return true;
}
- var minDate = GetMinDate();
- if (minDate.HasValue)
+ var min = GetMinDate();
+ if (min.HasValue)
{
- var minDateYear = _culture.Calendar.GetYear(minDate.Value.DateTime);
- var minDateMonth = _culture.Calendar.GetMonth(minDate.Value.DateTime);
+ var minDate = GetDateTime(min.Value);
+ var minDateYear = _culture.Calendar.GetYear(minDate);
+ var minDateMonth = _culture.Calendar.GetMonth(minDate);
if (_currentYear < minDateYear || (_currentYear == minDateYear && month < minDateMonth)) return true;
}
@@ -1180,11 +1825,15 @@ private bool IsYearOutOfMinAndMaxDate(int year)
var maxDate = GetMaxDate();
var minDate = GetMinDate();
- return (maxDate.HasValue && year > _culture.Calendar.GetYear(maxDate.Value.DateTime))
- || (minDate.HasValue && year < _culture.Calendar.GetYear(minDate.Value.DateTime));
+ // The years outside of the calendar's own supported range are as unselectable as the ones outside of
+ // MinDate and MaxDate - the year picker can show them at the edges of its ranges.
+ return year < GetMinCalendarYearMonth().Year
+ || year > GetMaxCalendarYearMonth().Year
+ || (maxDate.HasValue && year > _culture.Calendar.GetYear(GetDateTime(maxDate.Value)))
+ || (minDate.HasValue && year < _culture.Calendar.GetYear(GetDateTime(minDate.Value)));
}
- private (string style, string klass) GetDayButtonCss(DateTime date)
+ private (string style, string klass) GetDayButtonCss(DateTime date, int monthIndex)
{
StringBuilder klass = new StringBuilder();
StringBuilder style = new StringBuilder();
@@ -1198,16 +1847,13 @@ private bool IsYearOutOfMinAndMaxDate(int year)
klass.Append(' ').Append(Classes?.SelectedDayButton);
}
- if (Styles?.SelectedDayButton is not null)
- {
- style.Append(Styles?.SelectedDayButton);
- }
+ AppendStyle(style, Styles?.SelectedDayButton);
}
- var month = _culture.Calendar.GetMonth(date);
+ var isInMonth = IsInMonth(date, monthIndex);
- //Isn't in current month
- if (month != _currentMonth)
+ //Isn't in the month of this pane
+ if (isInMonth is false)
{
klass.Append(" bit-cal-dbo");
}
@@ -1222,14 +1868,11 @@ private bool IsYearOutOfMinAndMaxDate(int year)
klass.Append(' ').Append(Classes?.HighlightedDayButton);
}
- if (Styles?.HighlightedDayButton is not null)
- {
- style.Append(' ').Append(Styles?.HighlightedDayButton);
- }
+ AppendStyle(style, Styles?.HighlightedDayButton);
}
//Is today
- if (month == _currentMonth && date == GetToday().Date)
+ if (HighlightToday && isInMonth && date == GetToday().Date)
{
klass.Append(" bit-cal-dtd");
@@ -1238,10 +1881,7 @@ private bool IsYearOutOfMinAndMaxDate(int year)
klass.Append(' ').Append(Classes?.TodayDayButton);
}
- if (Styles?.TodayDayButton is not null)
- {
- style.Append(' ').Append(Styles?.TodayDayButton);
- }
+ AppendStyle(style, Styles?.TodayDayButton);
}
var customClass = GetDayClass?.Invoke(GetDateTimeOfDayCell(date));
@@ -1250,9 +1890,28 @@ private bool IsYearOutOfMinAndMaxDate(int year)
klass.Append(' ').Append(customClass);
}
+ // The style of every day comes last so it wins over the state specific ones, and it goes through the
+ // same appender so it is separated from them by a semicolon.
+ AppendStyle(style, Styles?.DayButton);
+
return (style.ToString(), klass.ToString());
}
+ // The styles of a day come from more than one state at a time (a selected day that is also today), so each
+ // one is closed with a semicolon before the next is appended - without it the last declaration of one and
+ // the first of the next would run together into a single invalid one.
+ private static void AppendStyle(StringBuilder builder, string? style)
+ {
+ if (style.HasNoValue()) return;
+
+ if (builder.Length > 0 && builder[^1] != ';')
+ {
+ builder.Append(';');
+ }
+
+ builder.Append(style);
+ }
+
private string GetMonthCellCssClass(int monthIndex, int todayYear, int todayMonth)
{
var className = new StringBuilder();
@@ -1276,7 +1935,7 @@ private DateTimeOffset GetDateTimeOfDayCell(DateTime date)
private DateTimeOffset GetDateTimeOfMonthCell(int monthIndex)
{
- var date = _culture.Calendar.ToDateTime(_currentYear, monthIndex, 1, 0, 0, 0, 0);
+ var date = GetFirstDayOfMonthOrClamp(_currentYear, monthIndex);
return new(date, _timeZone.GetUtcOffset(date));
}
@@ -1302,9 +1961,12 @@ private void UpdateTime()
private void BuildEventsLookup()
{
+ // The events of a day read as its agenda, so they are ordered the way one is: the all-day ones (which
+ // carry no start time) first, then the rest by the time they start at.
_eventsByDate = Events is null
? []
- : Events.GroupBy(e => e.Date).ToDictionary(g => g.Key, g => g.ToList());
+ : Events.GroupBy(e => e.Date)
+ .ToDictionary(g => g.Key, g => g.OrderBy(e => e.StartTime ?? TimeOnly.MinValue).ToList());
}
private void BuildDatesLookups()
@@ -1332,9 +1994,24 @@ private DateTime GetToday()
return GetDateTime(Today ?? DateTimeOffset.Now);
}
- private bool IsInCurrentMonth(DateTime date)
+ // Whether the date is a day of the month the given pane of the day picker is headed with, rather than one
+ // of the adjacent months that fill the first and the last row of its grid.
+ private bool IsInMonth(DateTime date, int monthIndex)
{
- return _culture.Calendar.GetYear(date) == _currentYear && _culture.Calendar.GetMonth(date) == _currentMonth;
+ var (year, month) = _monthsOfView[monthIndex];
+
+ return _culture.Calendar.GetYear(date) == year && _culture.Calendar.GetMonth(date) == month;
+ }
+
+ ///
+ private bool IsInDisplayedMonths(DateTime date)
+ {
+ for (var monthIndex = 0; monthIndex < _monthCount; monthIndex++)
+ {
+ if (IsInMonth(date, monthIndex)) return true;
+ }
+
+ return false;
}
private string GetDayButtonId(DateTime date)
@@ -1342,29 +2019,264 @@ private string GetDayButtonId(DateTime date)
return FormattableString.Invariant($"{_Id}-day-{date.Year:D4}-{date.Month:D2}-{date.Day:D2}");
}
+ private string GetMonthButtonId(int month)
+ {
+ return FormattableString.Invariant($"{_Id}-month-{month:D2}");
+ }
+
+ private string GetYearButtonId(int year)
+ {
+ return FormattableString.Invariant($"{_Id}-year-{year:D4}");
+ }
+
+ // A day of the previous or the next month is a day of the pane beside it in a multi-month view, so it is
+ // never drawn twice: what fills the first and the last row of every grid there is an empty cell.
+ private bool ShowOutsideDaysOfMonth() => ShowOutsideDays && _monthCount == 1;
+
+ // The single day of the grid that is in the tab sequence (the roving tabindex of the APG grid pattern):
+ // the one the keyboard last landed on, otherwise the selection, otherwise today, and as a last resort the
+ // first day that can actually be selected - the grid must never be unreachable.
private DateTime GetFocusableDay()
{
- if (_focusedDate.HasValue && IsInCurrentMonth(_focusedDate.Value) && IsDayDisabled(_focusedDate.Value) is false) return _focusedDate.Value;
+ if (_focusedDate.HasValue && IsInDisplayedMonths(_focusedDate.Value) && IsDayDisabled(_focusedDate.Value) is false) return _focusedDate.Value;
if (CurrentValue.HasValue)
{
var selectedDate = GetDateTime(CurrentValue.Value).Date;
- if (IsInCurrentMonth(selectedDate) && IsDayDisabled(selectedDate) is false) return selectedDate;
+ if (IsInDisplayedMonths(selectedDate) && IsDayDisabled(selectedDate) is false) return selectedDate;
}
var today = GetToday().Date;
- if (IsInCurrentMonth(today) && IsDayDisabled(today) is false) return today;
+ if (IsInDisplayedMonths(today) && IsDayDisabled(today) is false) return today;
- for (var week = 0; week < DEFAULT_WEEK_COUNT; week++)
+ for (var monthIndex = 0; monthIndex < _monthCount; monthIndex++)
{
- for (var day = 0; day < DEFAULT_DAY_COUNT_PER_WEEK; day++)
+ for (var week = 0; week < DEFAULT_WEEK_COUNT; week++)
{
- var date = _daysOfCurrentMonth[week, day];
- if (date.HasValue && IsInCurrentMonth(date.Value) && IsDayDisabled(date.Value) is false) return date.Value;
+ for (var day = 0; day < DEFAULT_DAY_COUNT_PER_WEEK; day++)
+ {
+ var date = _daysOfMonths[monthIndex][week, day];
+ if (date.HasValue && IsInMonth(date.Value, monthIndex) && IsDayDisabled(date.Value) is false) return date.Value;
+ }
}
}
- return _daysOfCurrentMonth[0, 0].GetValueOrDefault(today);
+ // A month can be disabled from end to end, and a disabled day is not focusable anyway - but the
+ // tabindex still has to land on a day of a month of the view itself: the days around them are either
+ // not rendered as buttons at all (ShowOutsideDays turned off) or belong to the pane next door.
+ for (var monthIndex = 0; monthIndex < _monthCount; monthIndex++)
+ {
+ for (var week = 0; week < DEFAULT_WEEK_COUNT; week++)
+ {
+ for (var day = 0; day < DEFAULT_DAY_COUNT_PER_WEEK; day++)
+ {
+ var date = _daysOfMonths[monthIndex][week, day];
+ if (date.HasValue && IsInMonth(date.Value, monthIndex)) return date.Value;
+ }
+ }
+ }
+
+ return today;
+ }
+
+ // The single month of the month grid that is in the tab sequence: the one the keyboard last landed on,
+ // otherwise the month the calendar displays, and as a last resort the first month the Min/Max range
+ // allows - a month is always returned, so the grid is reachable even when every month is disabled.
+ private int GetFocusableMonth()
+ {
+ var monthsInYear = GetMonthsInCurrentYear();
+
+ if (_focusedMonthCell.HasValue &&
+ _focusedMonthCell.Value >= 1 && _focusedMonthCell.Value <= monthsInYear &&
+ IsMonthOutOfMinAndMaxDate(_focusedMonthCell.Value) is false) return _focusedMonthCell.Value;
+
+ if (_currentMonth >= 1 && _currentMonth <= monthsInYear &&
+ IsMonthOutOfMinAndMaxDate(_currentMonth) is false) return _currentMonth;
+
+ for (var month = 1; month <= monthsInYear; month++)
+ {
+ if (IsMonthOutOfMinAndMaxDate(month) is false) return month;
+ }
+
+ return Math.Clamp(_currentMonth, 1, monthsInYear);
+ }
+
+ // The same roving tabindex for the year grid. The displayed year is not always inside the range the year
+ // picker shows (browsing the ranges moves the range alone), so the first year of the range is what the tab
+ // sequence falls back to.
+ private int GetFocusableYear()
+ {
+ if (_focusedYearCell.HasValue &&
+ _focusedYearCell.Value >= _yearPickerStartYear && _focusedYearCell.Value <= _yearPickerEndYear &&
+ IsYearOutOfMinAndMaxDate(_focusedYearCell.Value) is false) return _focusedYearCell.Value;
+
+ if (_currentYear >= _yearPickerStartYear && _currentYear <= _yearPickerEndYear &&
+ IsYearOutOfMinAndMaxDate(_currentYear) is false) return _currentYear;
+
+ for (var year = _yearPickerStartYear; year <= _yearPickerEndYear; year++)
+ {
+ if (IsYearOutOfMinAndMaxDate(year) is false) return year;
+ }
+
+ return _yearPickerStartYear;
+ }
+
+ // The month grid answers the same keys as the day grid, one row being four months wide, and
+ // PageUp/PageDown moving to the same month of the adjacent year.
+ private async Task HandleMonthKeyDown(KeyboardEventArgs e, int month)
+ {
+ if (IsEnabled is false) return;
+
+ if (e.Key is "Escape")
+ {
+ DismissMonthPickerOverlay();
+ return;
+ }
+
+ if (e.Key is "PageUp" or "PageDown")
+ {
+ var isNext = e.Key is "PageDown";
+
+ if (CanChangeYear(isNext) is false) return;
+
+ await HandleYearChange(isNext);
+
+ FocusMonthCell(GetFocusableMonth());
+ return;
+ }
+
+ var isRtl = BitCssClasses.IsRtl(Dir, _culture);
+
+ int? target = e.Key switch
+ {
+ "ArrowLeft" => FindEnabledMonth(month, isRtl ? 1 : -1),
+ "ArrowRight" => FindEnabledMonth(month, isRtl ? -1 : 1),
+ "ArrowUp" => FindEnabledMonth(month, -4),
+ "ArrowDown" => FindEnabledMonth(month, 4),
+ "Home" => FindEnabledMonthFrom(1, 1),
+ "End" => FindEnabledMonthFrom(GetMonthsInCurrentYear(), -1),
+ _ => null
+ };
+
+ if (target.HasValue is false) return;
+
+ FocusMonthCell(target.Value);
+ }
+
+ private void FocusMonthCell(int month)
+ {
+ _focusedMonthCell = month;
+ _focusElementIdAfterRender = GetMonthButtonId(month);
+ }
+
+ private int? FindEnabledMonth(int from, int step)
+ {
+ var monthsInYear = GetMonthsInCurrentYear();
+ var month = from + step;
+
+ while (month >= 1 && month <= monthsInYear)
+ {
+ if (IsMonthOutOfMinAndMaxDate(month) is false) return month;
+
+ month += step;
+ }
+
+ return null;
+ }
+
+ private int? FindEnabledMonthFrom(int from, int step)
+ {
+ var monthsInYear = GetMonthsInCurrentYear();
+ var month = from;
+
+ while (month >= 1 && month <= monthsInYear)
+ {
+ if (IsMonthOutOfMinAndMaxDate(month) is false) return month;
+
+ month += step;
+ }
+
+ return null;
+ }
+
+ // The year grid answers the same keys, one row being four years wide, and PageUp/PageDown moving to the
+ // adjacent range of years.
+ private void HandleYearKeyDown(KeyboardEventArgs e, int year)
+ {
+ if (IsEnabled is false) return;
+
+ // Escape leaves the year grid the way it was reached: back to the months of the year it is showing,
+ // and from there - a second Escape - out of the overlay entirely.
+ if (e.Key is "Escape")
+ {
+ ToggleBetweenMonthAndYearPicker();
+ FocusMonthCell(GetFocusableMonth());
+ return;
+ }
+
+ if (e.Key is "PageUp" or "PageDown")
+ {
+ var isNext = e.Key is "PageDown";
+
+ if (CanChangeYearRange(isNext) is false) return;
+
+ HandleYearRangeChange(isNext);
+
+ _focusedYearCell = null;
+ FocusYearCell(GetFocusableYear());
+ return;
+ }
+
+ var isRtl = BitCssClasses.IsRtl(Dir, _culture);
+
+ int? target = e.Key switch
+ {
+ "ArrowLeft" => FindEnabledYear(year, isRtl ? 1 : -1),
+ "ArrowRight" => FindEnabledYear(year, isRtl ? -1 : 1),
+ "ArrowUp" => FindEnabledYear(year, -4),
+ "ArrowDown" => FindEnabledYear(year, 4),
+ "Home" => FindEnabledYearFrom(_yearPickerStartYear, 1),
+ "End" => FindEnabledYearFrom(_yearPickerEndYear, -1),
+ _ => null
+ };
+
+ if (target.HasValue is false) return;
+
+ FocusYearCell(target.Value);
+ }
+
+ private void FocusYearCell(int year)
+ {
+ _focusedYearCell = year;
+ _focusElementIdAfterRender = GetYearButtonId(year);
+ }
+
+ private int? FindEnabledYear(int from, int step)
+ {
+ var year = from + step;
+
+ while (year >= _yearPickerStartYear && year <= _yearPickerEndYear)
+ {
+ if (IsYearOutOfMinAndMaxDate(year) is false) return year;
+
+ year += step;
+ }
+
+ return null;
+ }
+
+ private int? FindEnabledYearFrom(int from, int step)
+ {
+ var year = from;
+
+ while (year >= _yearPickerStartYear && year <= _yearPickerEndYear)
+ {
+ if (IsYearOutOfMinAndMaxDate(year) is false) return year;
+
+ year += step;
+ }
+
+ return null;
}
private async Task HandleDayKeyDown(KeyboardEventArgs e, DateTime date)
@@ -1380,9 +2292,9 @@ private async Task HandleDayKeyDown(KeyboardEventArgs e, DateTime date)
"ArrowUp" => FindEnabledDay(date, -7),
"ArrowDown" => FindEnabledDay(date, 7),
"Home" => FindEnabledDayTowards(GetStartOfWeek(date), date),
- "End" => FindEnabledDayTowards(GetStartOfWeek(date).AddDays(6), date),
- "PageUp" => FindEnabledDayTowards(e.ShiftKey ? _culture.Calendar.AddYears(date, -1) : _culture.Calendar.AddMonths(date, -1), date),
- "PageDown" => FindEnabledDayTowards(e.ShiftKey ? _culture.Calendar.AddYears(date, 1) : _culture.Calendar.AddMonths(date, 1), date),
+ "End" => FindEnabledDayTowards(GetEndOfWeek(date), date),
+ "PageUp" => FindEnabledDayTowards(e.ShiftKey ? TryAddYears(date, -1) : TryAddMonths(date, -1), date),
+ "PageDown" => FindEnabledDayTowards(e.ShiftKey ? TryAddYears(date, 1) : TryAddMonths(date, 1), date),
_ => null
};
@@ -1391,13 +2303,57 @@ private async Task HandleDayKeyDown(KeyboardEventArgs e, DateTime date)
await MoveFocusToDay(target.Value);
}
+ // A step that runs off the end of what a DateTime, or of what the culture's own calendar, can represent
+ // has nowhere to land - so it is refused rather than thrown, and the key simply moves the focus nowhere.
+ // The days at the very edge of the range are reachable all the same: every step that does land is taken.
+ private static DateTime? TryAddDays(DateTime date, int days)
+ {
+ try
+ {
+ return date.AddDays(days);
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return null;
+ }
+ }
+
+ ///
+ private DateTime? TryAddMonths(DateTime date, int months)
+ {
+ try
+ {
+ return _culture.Calendar.AddMonths(date, months);
+ }
+ catch (ArgumentException)
+ {
+ return null;
+ }
+ }
+
+ ///
+ private DateTime? TryAddYears(DateTime date, int years)
+ {
+ try
+ {
+ return _culture.Calendar.AddYears(date, years);
+ }
+ catch (ArgumentException)
+ {
+ return null;
+ }
+ }
+
private DateTime? FindEnabledDay(DateTime from, int stepDays)
{
var date = from;
for (var i = 0; i < 366; i++)
{
- date = date.AddDays(stepDays);
+ var next = TryAddDays(date, stepDays);
+ if (next.HasValue is false) return null;
+
+ date = next.Value;
if (IsWeekDayOutOfMinAndMaxDate(date)) return null;
@@ -1407,11 +2363,14 @@ private async Task HandleDayKeyDown(KeyboardEventArgs e, DateTime date)
return null;
}
- private DateTime? FindEnabledDayTowards(DateTime target, DateTime origin)
+ private DateTime? FindEnabledDayTowards(DateTime? target, DateTime origin)
{
- var step = target < origin ? 1 : -1;
- var date = target;
+ if (target.HasValue is false) return null;
+ var step = target.Value < origin ? 1 : -1;
+ var date = target.Value;
+
+ // Both ends are days the calendar can represent, so every day walked between them is one too.
while (date != origin)
{
if (IsDayDisabled(date) is false) return date;
@@ -1422,11 +2381,19 @@ private async Task HandleDayKeyDown(KeyboardEventArgs e, DateTime date)
return null;
}
- private DateTime GetStartOfWeek(DateTime date)
+ private DateTime? GetStartOfWeek(DateTime date)
{
var diff = ((int)date.DayOfWeek - (int)GetFirstDayOfWeek() + 7) % 7;
- return date.AddDays(-diff);
+ return TryAddDays(date, -diff);
+ }
+
+ ///
+ private DateTime? GetEndOfWeek(DateTime date)
+ {
+ var start = GetStartOfWeek(date);
+
+ return start.HasValue ? TryAddDays(start.Value, 6) : null;
}
private async Task MoveFocusToDay(DateTime target)
@@ -1434,19 +2401,24 @@ private async Task MoveFocusToDay(DateTime target)
var previousYear = _currentYear;
var previousMonth = _currentMonth;
- var year = _culture.Calendar.GetYear(target);
- var month = _culture.Calendar.GetMonth(target);
-
- if (year != _currentYear || month != _currentMonth)
+ // A day the view already shows is reached without moving the calendar - the arrow keys cross from
+ // one month of a multi-month view into the next without paging it. A day outside of the view scrolls
+ // it by the least that brings the day into it: onto its month going back, onto the page ending with
+ // its month going forward, so the day the keyboard lands on is where it was reached from.
+ if (IsInDisplayedMonths(target) is false)
{
- _currentYear = year;
- _currentMonth = month;
+ var year = _culture.Calendar.GetYear(target);
+ var month = _culture.Calendar.GetMonth(target);
+
+ (_currentYear, _currentMonth) = CompareYearMonth(year, month, _currentYear, _currentMonth) < 0
+ ? (year, month)
+ : AddCalendarMonths(year, month, -(_monthCount - 1));
GenerateMonthData(_currentYear, _currentMonth);
}
_focusedDate = target;
- _focusAfterRender = true;
+ _focusElementIdAfterRender = GetDayButtonId(target);
await NotifyMonthChange(previousYear, previousMonth);
}
@@ -1456,11 +2428,33 @@ private async Task NotifyMonthChange(int previousYear, int previousMonth)
if (previousYear == _currentYear && previousMonth == _currentMonth) return;
if (OnMonthChange.HasDelegate is false) return;
- var date = _culture.Calendar.ToDateTime(_currentYear, _currentMonth, 1, 0, 0, 0, 0);
+ var date = GetFirstDayOfMonthOrClamp(_currentYear, _currentMonth);
await OnMonthChange.InvokeAsync(new(date, _timeZone.GetUtcOffset(date)));
}
+ // How many dots a day can wear before the row of them stops saying anything a shorter row does not.
+ private const int MAX_EVENT_INDICATORS = 3;
+
+ private static IEnumerable GetIndicatorEvents(IReadOnlyList events)
+ {
+ return events.Take(MAX_EVENT_INDICATORS);
+ }
+
+ // An event of its own color paints its dot with it; one without takes whatever the calendar's event color
+ // is, which is the Color role unless --bit-Calendar-event-color says otherwise.
+ private static string? GetEventIndicatorColorClass(BitCalendarEvent evt)
+ {
+ return evt.Color.HasValue ? BitCssClasses.Color(evt.Color, "bit-cal-evi") : null;
+ }
+
+ // The card of an event in the details dialog is painted with the same color as its dot, so the stripe
+ // down its side is the dot said again beside what the dot stood for.
+ private static string? GetEventItemColorClass(BitCalendarEvent evt)
+ {
+ return evt.Color.HasValue ? BitCssClasses.Color(evt.Color, "bit-cal-emi") : null;
+ }
+
private IReadOnlyList GetDayEvents(DateTime date)
{
var dateOnly = DateOnly.FromDateTime(date);
@@ -1482,34 +2476,117 @@ private string FormatEventModalDate(DateOnly date)
return date.ToDateTime(TimeOnly.MinValue).ToString(DateFormat ?? _culture.DateTimeFormat.ShortDatePattern, _culture);
}
+ // The events of a day as one line each, for the native tooltip of its cell.
private string GetEventTooltip(IReadOnlyList events)
{
- return string.Join("\n", events.Select(e =>
- e.StartTime.HasValue ? $"{e.Title} ({FormatEventTime(e.StartTime.Value)})" : e.Title));
+ return GetEventSummary(events, "\n");
}
private async Task HandleDayClick(DateTime date, IReadOnlyList events)
{
- if (events.Count > 0)
+ if (ShowEventDetails && events.Count > 0)
{
- OpenEventModal(date, events);
+ OpenEventModal(date);
+
+ // Opening the details of the day the value is already on is a second look at its events, not a
+ // second press of a toggle: the day stays selected, so its dialog can be opened as often as the
+ // user likes. Without this, AllowDeselect would empty the value on every other look.
+ if (AllowDeselect && IsSelectedDate(date)) return;
}
-
+
await SelectDate(date);
}
- private void OpenEventModal(DateTime date, IReadOnlyList events)
+ private void OpenEventModal(DateTime date)
{
_eventModalDate = DateOnly.FromDateTime(date);
- _eventModalEvents = events;
_showEventModal = true;
}
+ // Only the day is held, never the events of it: the events the dialog lists are read from the lookup on
+ // every render, so a page that loads them for the visible month while the dialog is open shows what it
+ // loaded rather than what the day held when it was opened.
+ private IReadOnlyList GetEventModalEvents()
+ {
+ return _eventsByDate.TryGetValue(_eventModalDate, out var list) ? list : [];
+ }
+
private void CloseEventModal()
{
_showEventModal = false;
}
+ // Escape closes the dialog, the way every dismissible surface in the library is left, and hands the
+ // keyboard back to the day that opened it (SyncEventDialogFocus).
+ private void HandleEventDialogKeyDown(KeyboardEventArgs e)
+ {
+ if (e.Key is not "Escape") return;
+
+ CloseEventModal();
+ }
+
+ // The dialog takes the keyboard over while it is open: the focus is recorded and moved into it, Tab is kept
+ // cycling inside it, and closing it puts the focus back on the day cell it was opened from. Driven from
+ // OnAfterRenderAsync rather than from the open and close handlers, since the surface has to be in the DOM
+ // before the focus can be placed in it and gone from it before the focus can be handed back.
+ private async Task SyncEventDialogFocus()
+ {
+ if (IsDisposed) return;
+
+ if (_showEventModal)
+ {
+ if (_eventModalFocusTrapped) return;
+
+ _eventModalFocusTrapped = true;
+ _eventModalFocusStored = true;
+
+ try
+ {
+ await _js.BitUtilsStoreFocus(_eventDialogId);
+ await _js.BitUtilsSetupFocusTrap(_eventDialogId);
+ await _js.BitUtilsFocusFirstElement(_eventDialogId);
+ }
+ catch (JSDisconnectedException) { } // we can ignore this exception here
+
+ return;
+ }
+
+ if (_eventModalFocusTrapped is false) return;
+
+ _eventModalFocusTrapped = false;
+
+ try
+ {
+ await _js.BitUtilsDisposeFocusTrap(_eventDialogId);
+
+ if (_eventModalFocusStored)
+ {
+ _eventModalFocusStored = false;
+
+ await _js.BitUtilsRestoreFocus(_eventDialogId);
+ }
+ }
+ catch (JSDisconnectedException) { } // we can ignore this exception here
+ }
+
+ // What a screen reader is told a day is: the date, and the events it carries - the indicator dot and the
+ // tooltip that report them to everyone else are both invisible to it. The label replaces the content of the
+ // button, so the events cannot simply be added to the markup inside it.
+ private string GetDayAriaLabel(DateTime date, IReadOnlyList events)
+ {
+ var label = date.ToString(_culture.DateTimeFormat.LongDatePattern, _culture);
+
+ if (events.Count == 0) return label;
+
+ return $"{label}, {GetEventSummary(events, ", ")}";
+ }
+
+ private string GetEventSummary(IReadOnlyList events, string separator)
+ {
+ return string.Join(separator, events.Select(e =>
+ e.StartTime.HasValue ? $"{e.Title} ({FormatEventTime(e.StartTime.Value)})" : e.Title));
+ }
+
private DateTime GetDateTime(DateTimeOffset dateTimeOffset)
{
return TimeZoneInfo.ConvertTimeFromUtc(dateTimeOffset.UtcDateTime, _timeZone);
@@ -1529,20 +2606,16 @@ private async Task HandleOnTimeMinuteFocus()
await _js.BitUtilsSelectText(_inputTimeMinuteRef);
}
- private void ToggleAmPmTime()
- {
- if (ReadOnly) return;
- if (IsEnabled is false) return;
-
- _hourView = _hour + (_hour >= 12 ? -12 : 12);
- }
-
private void HandleOnAmClick()
{
if (ReadOnly) return;
if (IsEnabled is false) return;
_hour %= 12; // "12:-- am" is "00:--" in 24h
+
+ // Half a day is a wide move, so it can land outside of what a bound on this day allows.
+ ClampTimeToBounds();
+
UpdateTime();
}
@@ -1557,6 +2630,9 @@ private void HandleOnPmClick()
}
_hour %= 24;
+
+ ClampTimeToBounds();
+
UpdateTime();
}
@@ -1665,7 +2741,10 @@ private void ResetCts()
// one, and a step that does not divide the day wraps to the top of the grid instead of drifting off it.
private void ChangeHour(bool isNext)
{
- _hour = BitTimeSteps.StepToAllowed(_hour, isNext, 24, h => BitTimeSteps.IsOnGrid(h, HourStep, 0, 24)) ?? _hour;
+ _hour = BitTimeSteps.StepToAllowed(_hour, isNext, 24, IsHourAllowed) ?? _hour;
+
+ // Stepping onto the hour a bound falls in can leave the minute past the bound, so it comes along.
+ _minute = BitTimeSteps.FindNearestAllowed(_minute, 60, IsMinuteAllowed) ?? _minute;
UpdateTime();
}
@@ -1673,12 +2752,170 @@ private void ChangeHour(bool isNext)
///
private void ChangeMinute(bool isNext)
{
- _minute = BitTimeSteps.StepToAllowed(_minute, isNext, 60, m => BitTimeSteps.IsOnGrid(m, MinuteStep, 0, 60)) ?? _minute;
+ _minute = BitTimeSteps.StepToAllowed(_minute, isNext, 60, IsMinuteAllowed) ?? _minute;
UpdateTime();
}
- public bool DayPickerIsVisible()
+ // The grid HourStep and MinuteStep lay over the day and over the hour, which everything that moves the
+ // time - the spin buttons, the keys, what is typed - is held to, so no two of them can disagree about
+ // which times the picker offers.
+ private bool IsHourOnGrid(int hour) => BitTimeSteps.IsOnGrid(hour, HourStep, 0, 24);
+
+ ///
+ private bool IsMinuteOnGrid(int minute) => BitTimeSteps.IsOnGrid(minute, MinuteStep, 0, 60);
+
+ // MinDate and MaxDate rule out whole days everywhere else in the calendar and the very hours of one day
+ // here: the day a bound falls on stays selectable, but only from the time of day the bound carries. So
+ // the time picker of a calendar bounded at "now" cannot be wound back into this morning, the way its day
+ // grid cannot be wound back to yesterday. Everything that moves the time is held to it alongside the
+ // step grid, since both are simply which times this picker offers.
+ private bool IsHourAllowed(int hour)
+ {
+ if (IsHourOnGrid(hour) is false) return false;
+
+ var (min, max) = GetTimeBounds();
+
+ if (min.HasValue && hour < min.Value.Hours) return false;
+ if (max.HasValue && hour > max.Value.Hours) return false;
+
+ return true;
+ }
+
+ ///
+ private bool IsMinuteAllowed(int minute)
+ {
+ if (IsMinuteOnGrid(minute) is false) return false;
+
+ var (min, max) = GetTimeBounds();
+
+ // Only the hour a bound falls in is bounded by its minutes: every later hour of the day the minimum
+ // falls on, and every earlier one of the day the maximum does, is in range from end to end.
+ if (min.HasValue && _hour == min.Value.Hours && minute < min.Value.Minutes) return false;
+ if (max.HasValue && _hour == max.Value.Hours && minute > max.Value.Minutes) return false;
+
+ return true;
+ }
+
+ ///
+ private (TimeSpan? Min, TimeSpan? Max) GetTimeBounds()
+ {
+ // The picker writes into the value, so the day it is setting the time of is the day the value is on.
+ // Without one it produces nothing and nothing constrains it.
+ return CurrentValue.HasValue ? GetTimeBounds(GetDateTime(CurrentValue.Value).Date) : (null, null);
+ }
+
+ ///
+ private (TimeSpan? Min, TimeSpan? Max) GetTimeBounds(DateTime day)
+ {
+ TimeSpan? min = null;
+ TimeSpan? max = null;
+
+ // Only a calendar carrying a time picker picks a time at all. Without one the time of the value is
+ // whatever it was given rather than something the user chose here, and a day bound rules out days:
+ // pulling today's midnight up to this very minute would hand the same minute to every later day
+ // picked afterwards, none of it ever shown.
+ if (ShowTimePicker is false) return (min, max);
+
+ var minDate = GetMinDate();
+ if (minDate.HasValue)
+ {
+ // The seconds of a bound are dropped rather than rounded up, so the minute it falls in - the one
+ // a bound of "now" is in - is a minute the picker can still be set to.
+ var date = GetDateTime(minDate.Value);
+ if (date.Date == day.Date)
+ {
+ min = new TimeSpan(date.Hour, date.Minute, 0);
+ }
+ }
+
+ var maxDate = GetMaxDate();
+ if (maxDate.HasValue)
+ {
+ var date = GetDateTime(maxDate.Value);
+ if (date.Date == day.Date)
+ {
+ max = new TimeSpan(date.Hour, date.Minute, 0);
+ }
+ }
+
+ return (min, max);
+ }
+
+ ///
+ private void ClampTimeToBounds()
+ {
+ if (CurrentValue.HasValue is false) return;
+
+ ClampTimeToBounds(GetDateTime(CurrentValue.Value).Date);
+ }
+
+ // The time brought into what the bounds allow on the given day. The bound itself is where an out of
+ // range time lands, whether or not it sits on the step grid: a time the application declared is a time
+ // the picker may produce.
+ private void ClampTimeToBounds(DateTime day)
+ {
+ var (min, max) = GetTimeBounds(day);
+
+ if (min.HasValue is false && max.HasValue is false) return;
+
+ var time = new TimeSpan(_hour, _minute, 0);
+
+ if (min.HasValue && time < min.Value)
+ {
+ _hour = min.Value.Hours;
+ _minute = min.Value.Minutes;
+ }
+ else if (max.HasValue && time > max.Value)
+ {
+ _hour = max.Value.Hours;
+ _minute = max.Value.Minutes;
+ }
+ }
+
+ // A committed hour or minute - the field left, Enter pressed, an arrow key or a spinner of the number
+ // input stepped, each of which fires a change of its own - is held to the HourStep/MinuteStep grid, so
+ // the two halves of the same control cannot disagree about which times the picker offers. The value
+ // typed on the way there was left alone (see the setters above), so the grid never rewrites a half-typed
+ // number; the value the editing started from is what tells a move of exactly one - an arrow key - from
+ // a number that was typed, so a step of a sparser grid moves on rather than sitting still.
+ private void HandleOnTimeInputChange(bool isHour)
+ {
+ if (IsEnabled is false || ReadOnly) return;
+
+ // A typed number is brought inside the bounds before the grid has its say: distance on a clock is
+ // measured around it, so the allowed value nearest to a number below the minimum can be the one late
+ // on the other side of the day - while what was meant is plainly the earliest time on offer.
+ ClampTimeToBounds();
+
+ if (isHour)
+ {
+ _hour = BitTimeSteps.FindAllowedNear(_hour, _hourBeforeInput, 24, IsHourAllowed) ?? _hour;
+ _minute = BitTimeSteps.FindNearestAllowed(_minute, 60, IsMinuteAllowed) ?? _minute;
+ }
+ else
+ {
+ _minute = BitTimeSteps.FindAllowedNear(_minute, _minuteBeforeInput, 60, IsMinuteAllowed) ?? _minute;
+ }
+
+ UpdateTime();
+ }
+
+ // The hour and the minute answer PageUp and PageDown with the same step the spin buttons next to them
+ // move by, so the time can be set without leaving the keyboard or the field.
+ private void HandleOnTimeInputKeyDown(KeyboardEventArgs e, bool isHour)
+ {
+ if (IsEnabled is false || ReadOnly) return;
+
+ if (e.Key is not ("PageUp" or "PageDown")) return;
+
+ ChangeTime(e.Key is "PageUp", isHour);
+ }
+
+ // Which of the pickers is on screen is the calendar's own business: what the day picker, the month and
+ // year picker and the time picker leave room for one another depends on which of them are shown at all,
+ // which of them are laid over the others, and which of the overlays is currently up.
+ private bool DayPickerIsVisible()
{
if (_showMonthPicker is false && _showTimePicker is false) return true;
@@ -1727,6 +2964,20 @@ protected override async ValueTask DisposeAsync(bool disposing)
try
{
await _js.BitCalendarsDispose(_Id);
+
+ if (_eventModalFocusTrapped)
+ {
+ _eventModalFocusTrapped = false;
+
+ await _js.BitUtilsDisposeFocusTrap(_eventDialogId);
+ }
+
+ if (_eventModalFocusStored)
+ {
+ _eventModalFocusStored = false;
+
+ await _js.BitUtilsForgetFocus(_eventDialogId);
+ }
}
catch (JSDisconnectedException) { } // we can ignore this exception here
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendar.scss b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendar.scss
index f1b0933da31..08a6c2eaf40 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendar.scss
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendar.scss
@@ -1,6 +1,65 @@
@import "../../../Styles/functions.scss";
+@import "../../../Styles/color-role-maps.scss";
+
+// Public CSS variables, read off the root and never declared here, so a value set on :root re-skins every
+// calendar and one set on the Style of an instance re-skins that one alone:
+// --bit-Calendar-background background behind the whole calendar (default: transparent)
+// --bit-Calendar-radius corner radius of the calendar and of the
+// overlay of the event dialog (default: $shp-radius-control)
+// --bit-Calendar-padding padding of each picker pane (default: spacing(1.5))
+// --bit-Calendar-color text color of the cells and the headers (default: $clr-fg-pri)
+// --bit-Calendar-divider-color color of the rule between the panes, beside
+// the week numbers and between event items (default: $clr-brd-sec)
+// --bit-Calendar-focus-color color of the keyboard focus ring (default: the role's focus color)
+// --bit-Calendar-disabled-color text color of a disabled cell (default: $clr-fg-dis)
+// --bit-Calendar-hover-background background of a cell on hover (default: $clr-bg-pri-hover)
+// --bit-Calendar-active-background background of a cell while pressed (default: $clr-bg-pri-active)
+// --bit-Calendar-day-size width and height of a day cell, which the
+// week numbers, the weekday headers and the
+// navigation buttons line up with (default: per Size)
+// --bit-Calendar-day-font-size text size of the day cells and of the
+// headers that line up with them (default: per Size)
+// --bit-Calendar-day-radius corner radius of a day cell (default: $shp-radius-control)
+// --bit-Calendar-outside-day-color text color of a day of an adjacent month (default: $clr-fg-sec)
+// --bit-Calendar-selected-background background of the selected day (default: $clr-bg-sec)
+// --bit-Calendar-selected-color text color of the selected day (default: the cell color)
+// --bit-Calendar-selected-border-color color of the ring drawn around the selected
+// day (default: $clr-brd-pri)
+// --bit-Calendar-today-background background of today, and of the highlighted
+// current month (default: the Color role's main color)
+// --bit-Calendar-today-color text color of today (default: the Color role's on-color)
+// --bit-Calendar-today-hover-background background of today on hover, and of the
+// highlighted current month and the selected
+// AM/PM button (default: the Color role's hover color)
+// --bit-Calendar-today-active-background background of today while pressed, and of
+// the selected AM/PM button (default: the Color role's active color)
+// --bit-Calendar-today-radius corner radius of today (default: $shp-radius-full)
+// --bit-Calendar-highlighted-background background of a HighlightedDates day (default: $clr-bg-ter)
+// --bit-Calendar-week-number-color text color of a week number (default: $clr-fg-sec)
+// --bit-Calendar-week-number-background background of a week number (default: $clr-bg-sec)
+// --bit-Calendar-event-color color of an event indicator dot the event
+// itself named no color for (default: the Color role's main color)
+// --bit-Calendar-event-size diameter of an event indicator dot (default: spacing(0.5))
+// --bit-Calendar-event-gap space between the dots of a day carrying
+// more than one event (default: spacing(0.25))
+
+// The default size, declared as a mixin so the root can fall back to it without a size class. The two values
+// an author can override are named apart from the ones they feed (see the indirection on .bit-cal below), so
+// a size class and a CSS variable never fight over the same property.
+@mixin cal-size-medium {
+ --bit-cal-cell-size-def: #{spacing(3.5)};
+ --bit-cal-cell-fs-def: #{$tg-fs-sm};
+ --bit-cal-mcell-w: #{spacing(6)};
+ --bit-cal-mcell-h: #{spacing(5.17)};
+ --bit-cal-tcell-size: #{spacing(3.75)};
+ --bit-cal-pane-min-w: #{spacing(26)};
+}
.bit-cal {
+ // The public overrides resolve here, once, so every part that lines up with a day - the week numbers, the
+ // empty cells, the weekday headers, the navigation buttons - stays in step with the day cells.
+ --bit-cal-cell-size: var(--bit-Calendar-day-size, var(--bit-cal-cell-size-def));
+ --bit-cal-cell-fs: var(--bit-Calendar-day-font-size, var(--bit-cal-cell-fs-def));
margin: 0;
padding: 0;
box-shadow: none;
@@ -10,6 +69,12 @@
box-sizing: border-box;
font-size: $tg-fs-lg;
font-family: $tg-font-family;
+ // The radius sits on the element the background is painted on, so it is the calendar's own surface that
+ // is rounded - the header and footer slots inside it included, which is more than the pickers alone.
+ border-radius: var(--bit-Calendar-radius, #{$shp-radius-control});
+ background-color: var(--bit-Calendar-background, transparent);
+
+ @include cal-size-medium;
&.bit-dis {
.bit-cal-cnt {
@@ -19,13 +84,18 @@
}
.bit-cal-tdv,
+ .bit-cal-tin,
+ .bit-cal-tbt,
.bit-cal-pkt,
.bit-cal-nbt,
+ .bit-cal-gtb,
+ .bit-cal-gtn,
.bit-cal-wlb,
+ .bit-cal-wnm,
.bit-cal-dbt,
.bit-cal-pkb,
.bit-cal-ptb {
- color: $clr-fg-dis;
+ color: var(--bit-Calendar-disabled-color, #{$clr-fg-dis});
}
.bit-cal-dbt {
@@ -34,19 +104,47 @@
}
}
}
+
+ // A value the EditContext rejected: the calendar is a grid rather than a field with a border to redden,
+ // so the rule is drawn around the whole control, and the cells' focus ring follows it into the error
+ // color so a keyboard user is not shown two different states at once.
+ &.bit-inv {
+ .bit-cal-cnt {
+ outline: $shp-border-width $shp-border-style $clr-err;
+ }
+
+ --bit-cal-clr-focus: #{$clr-err-focus};
+ }
}
.bit-cal-cnt {
display: flex;
font-weight: $tg-fw-regular;
- top: spacing(4.25);
outline: transparent;
box-sizing: border-box;
font-size: $tg-fs-sm;
font-family: $tg-font-family;
- border-radius: $shp-radius-control;
+ border-radius: var(--bit-Calendar-radius, #{$shp-radius-control});
+}
+
+// The two slots a calendar can be given content of its own in: a caption above the pickers and a row of
+// actions below them. Both are laid out by whatever is put in them; only the padding is the calendar's.
+.bit-cal-hdr,
+.bit-cal-ftr {
+ color: var(--bit-Calendar-color, #{$clr-fg-pri});
+ font-size: $tg-fs-sm;
+ padding: var(--bit-Calendar-padding, #{spacing(1.5)});
+}
+
+.bit-cal-hdr {
+ padding-bottom: 0;
+}
+
+.bit-cal-ftr {
+ padding-top: 0;
}
+// The selection as words, for a screen reader alone.
.bit-cal-sdt {
height: 0;
border: 0;
@@ -61,20 +159,17 @@
margin: 0;
display: flex;
box-shadow: none;
- padding: spacing(1.5);
- min-width: spacing(26);
box-sizing: content-box;
flex-flow: column nowrap;
+ min-width: var(--bit-cal-pane-min-w);
+ padding: var(--bit-Calendar-padding, #{spacing(1.5)});
}
-.bit-cal-dgd {
- border-spacing: 0;
- position: relative;
- text-align: center;
- font-size: inherit;
- table-layout: fixed;
- margin-top: spacing(0.5);
- border-collapse: collapse;
+// A strip of months is wider than a single one, and what holds it is not always as wide as it asks for -
+// a phone, a column of a form - so the months wrap onto the next line instead of overflowing it. Each of
+// them keeps its own padding, which is what leaves them evenly apart however they end up laid out.
+.bit-cal-mcv .bit-cal-cnt {
+ flex-wrap: wrap;
}
.bit-cal-grd {
@@ -87,6 +182,7 @@
width: 100%;
display: flex;
flex-flow: row nowrap;
+ justify-content: space-evenly;
}
.bit-cal-wlb {
@@ -94,17 +190,17 @@
padding: 0;
border: none;
font-weight: $tg-fw-regular;
- color: $clr-fg-pri;
position: relative;
text-align: center;
- width: spacing(3.5);
- height: spacing(3.5);
- font-size: $tg-fs-xs;
- line-height: spacing(3.5);
animation-fill-mode: both;
- animation-duration: $mot-duration-long;
animation-name: bit-fade-in;
+ width: var(--bit-cal-cell-size);
+ height: var(--bit-cal-cell-size);
+ font-size: var(--bit-cal-cell-fs);
+ line-height: var(--bit-cal-cell-size);
+ animation-duration: $mot-duration-long;
animation-timing-function: $mot-easing;
+ color: var(--bit-Calendar-color, #{$clr-fg-pri});
}
.bit-cal-dbt {
@@ -113,82 +209,94 @@
display: flex;
cursor: pointer;
overflow: visible;
- color: $clr-fg-pri;
position: relative;
- width: spacing(3.5);
align-items: center;
- height: spacing(3.5);
outline: transparent;
font-weight: inherit;
box-sizing: border-box;
- font-size: $tg-fs-xs;
- line-height: spacing(3);
justify-content: center;
background-color: transparent;
- border-radius: $shp-radius-control;
+ width: var(--bit-cal-cell-size);
+ height: var(--bit-cal-cell-size);
+ line-height: var(--bit-cal-cell-size);
+ color: var(--bit-Calendar-color, #{$clr-fg-pri});
+ font-size: var(--bit-cal-cell-fs);
+ border-radius: var(--bit-Calendar-day-radius, #{$shp-radius-control});
+ // Only what a state actually changes is transitioned, so the cell that is hovered or selected eases
+ // rather than snapping - and collapses to nothing under prefers-reduced-motion, which the duration
+ // token carries on its own.
+ transition: background-color $mot-duration-short $mot-easing, color $mot-duration-short $mot-easing;
@media (hover: hover) {
&:hover {
- background-color: $clr-bg-pri-hover;
+ background-color: var(--bit-Calendar-hover-background, #{$clr-bg-pri-hover});
}
}
&:active {
- background-color: $clr-bg-pri-active;
+ background-color: var(--bit-Calendar-active-background, #{$clr-bg-pri-active});
}
&:disabled {
- color: $clr-fg-dis;
pointer-events: none;
background-color: transparent;
+ color: var(--bit-Calendar-disabled-color, #{$clr-fg-dis});
}
+ // The grid moves the focus with the arrow keys, so the focused day has to be visible as such - and above
+ // its neighbors, whose backgrounds would otherwise clip the ring. The ring is drawn without an offset:
+ // the cells of a week sit against one another, and a gap between the cell and its ring would be filled
+ // with the background of the day next to it.
&:focus-visible {
z-index: 1;
- outline: $shp-border-width $shp-border-style $clr-brd-pri;
+ @include focus-ring(var(--bit-Calendar-focus-color, var(--bit-cal-clr-focus)), 0);
}
}
+// The placeholder of a day of an adjacent month while ShowOutsideDays is off, and of a day outside the
+// range the calendar can represent: it keeps the columns of the week aligned without rendering a day.
.bit-cal-dbe {
- width: spacing(3.5);
- height: spacing(3.5);
+ width: var(--bit-cal-cell-size);
+ height: var(--bit-cal-cell-size);
}
.bit-cal-dhl {
font-weight: $tg-fw-semibold;
- background-color: $clr-bg-ter;
+ background-color: var(--bit-Calendar-highlighted-background, #{$clr-bg-ter});
}
.bit-cal-dbs {
- background-color: $clr-bg-sec;
+ color: var(--bit-Calendar-selected-color, var(--bit-Calendar-color, #{$clr-fg-pri}));
+ background-color: var(--bit-Calendar-selected-background, #{$clr-bg-sec});
&::after {
inset: 0;
content: "";
position: absolute;
- border: $shp-border-width $shp-border-style $clr-brd-pri;
+ border-radius: inherit;
+ border: $shp-border-width $shp-border-style var(--bit-Calendar-selected-border-color, #{$clr-brd-pri});
}
}
.bit-cal-dbo {
font-weight: $tg-fw-regular;
- color: $clr-fg-sec;
+ color: var(--bit-Calendar-outside-day-color, #{$clr-fg-sec});
}
.bit-cal-dtd {
font-weight: $tg-fw-semibold;
- border-radius: 50%;
- color: var(--bit-cal-clr-txt);
- background-color: var(--bit-cal-clr);
+ border-radius: var(--bit-Calendar-today-radius, #{$shp-radius-full});
+ color: var(--bit-Calendar-today-color, var(--bit-cal-clr-txt));
+ background-color: var(--bit-Calendar-today-background, var(--bit-cal-clr));
@media(hover: hover) {
&:hover {
- background-color: var(--bit-cal-clr-hover);
+ background-color: var(--bit-Calendar-today-hover-background, var(--bit-cal-clr-hover));
}
}
&:active {
- background-color: var(--bit-cal-clr-active);
+ background-color: var(--bit-Calendar-today-active-background, var(--bit-cal-clr-active));
}
}
@@ -196,24 +304,24 @@
margin: 0;
display: flex;
font-weight: $tg-fw-regular;
- color: $clr-fg-sec;
- width: spacing(3.5);
align-items: center;
- height: spacing(3.5);
box-sizing: border-box;
- font-size: $tg-fs-xs;
justify-content: center;
padding: spacing(0.375) 0 0 0;
- background-color: $clr-bg-sec;
- border-inline-end: $shp-border-width $shp-border-style $clr-brd-sec;
+ width: var(--bit-cal-cell-size);
+ height: var(--bit-cal-cell-size);
+ font-size: var(--bit-cal-cell-fs);
+ color: var(--bit-Calendar-week-number-color, #{$clr-fg-sec});
+ background-color: var(--bit-Calendar-week-number-background, #{$clr-bg-sec});
+ border-inline-end: $shp-border-width $shp-border-style var(--bit-Calendar-divider-color, #{$clr-brd-sec});
}
.bit-cal-pkh {
width: 100%;
position: relative;
- height: spacing(3.5);
display: inline-flex;
- line-height: spacing(5.5);
+ height: var(--bit-cal-cell-size);
+ line-height: var(--bit-cal-cell-size);
}
.bit-cal-tph {
@@ -229,7 +337,6 @@
overflow: hidden;
font-weight: $tg-fw-semibold;
text-align: start;
- color: $clr-fg-pri;
position: relative;
white-space: nowrap;
align-items: center;
@@ -240,23 +347,29 @@
// without the row having to run past the wrapper - see the header floor at the end of this file.
padding: 0 spacing(0.75);
font-size: $tg-fs-sm;
- line-height: spacing(3.5);
animation-fill-mode: both;
- animation-duration: $mot-duration-long;
animation-name: bit-fade-show;
background-color: transparent;
- border-radius: $shp-radius-control;
+ line-height: var(--bit-cal-cell-size);
+ animation-duration: $mot-duration-long;
animation-timing-function: $mot-easing;
+ color: var(--bit-Calendar-color, #{$clr-fg-pri});
+ border-radius: var(--bit-Calendar-day-radius, #{$shp-radius-control});
}
.bit-cal-ptb {
cursor: pointer;
+ transition: background-color $mot-duration-short $mot-easing;
@media (hover: hover) {
&:hover {
- background-color: $clr-bg-pri-hover;
+ background-color: var(--bit-Calendar-hover-background, #{$clr-bg-pri-hover});
}
}
+
+ &:focus-visible {
+ @include focus-ring(var(--bit-Calendar-focus-color, var(--bit-cal-clr-focus)), 0);
+ }
}
.bit-cal-nbc {
@@ -273,32 +386,42 @@
display: block;
cursor: pointer;
overflow: visible;
- color: $clr-fg-pri;
position: relative;
text-align: center;
- width: spacing(3.5);
- height: spacing(3.5);
outline: transparent;
- font-size: $tg-fs-xs;
- min-width: spacing(3.5);
- min-height: spacing(3.5);
- line-height: spacing(3.5);
background-color: transparent;
- border-radius: $shp-radius-control;
+ width: var(--bit-cal-cell-size);
+ height: var(--bit-cal-cell-size);
+ min-width: var(--bit-cal-cell-size);
+ min-height: var(--bit-cal-cell-size);
+ line-height: var(--bit-cal-cell-size);
+ font-size: var(--bit-cal-cell-fs);
+ color: var(--bit-Calendar-color, #{$clr-fg-pri});
+ border-radius: var(--bit-Calendar-day-radius, #{$shp-radius-control});
+ transition: background-color $mot-duration-short $mot-easing;
@media (hover: hover) {
&:hover {
- background-color: $clr-bg-pri-hover;
+ background-color: var(--bit-Calendar-hover-background, #{$clr-bg-pri-hover});
}
}
+ &:active {
+ background-color: var(--bit-Calendar-active-background, #{$clr-bg-pri-active});
+ }
+
+ &:focus-visible {
+ z-index: 1;
+ @include focus-ring(var(--bit-Calendar-focus-color, var(--bit-cal-clr-focus)), 0);
+ }
+
&:disabled {
pointer-events: none;
background-color: transparent;
.bit-icon,
.bit-cal-ico {
- color: $clr-fg-dis;
+ color: var(--bit-Calendar-disabled-color, #{$clr-fg-dis});
}
}
}
@@ -309,24 +432,30 @@
cursor: pointer;
aspect-ratio: 1;
overflow: visible;
- color: $clr-fg-pri;
position: relative;
text-align: center;
- width: spacing(3.75);
outline: transparent;
- line-height: spacing(3.75);
- font-size: $tg-fs-xs;
background-color: transparent;
- border-radius: $shp-radius-control;
+ width: var(--bit-cal-tcell-size);
+ line-height: var(--bit-cal-tcell-size);
+ font-size: var(--bit-cal-cell-fs);
+ color: var(--bit-Calendar-color, #{$clr-fg-pri});
+ border-radius: var(--bit-Calendar-day-radius, #{$shp-radius-control});
+ transition: background-color $mot-duration-short $mot-easing, color $mot-duration-short $mot-easing;
@media (hover: hover) {
&:hover {
- background-color: $clr-bg-pri-hover;
+ background-color: var(--bit-Calendar-hover-background, #{$clr-bg-pri-hover});
}
}
&:active {
- background-color: $clr-bg-pri-hover;
+ background-color: var(--bit-Calendar-active-background, #{$clr-bg-pri-active});
+ }
+
+ &:focus-visible {
+ z-index: 1;
+ @include focus-ring(var(--bit-Calendar-focus-color, var(--bit-cal-clr-focus)), 0);
}
i {
@@ -337,30 +466,29 @@
}
&:disabled {
- color: $clr-fg-dis;
pointer-events: none;
background-color: transparent;
+ color: var(--bit-Calendar-disabled-color, #{$clr-fg-dis});
i {
- color: $clr-fg-dis;
+ color: var(--bit-Calendar-disabled-color, #{$clr-fg-dis});
}
}
}
.bit-cal-dvd {
top: 0;
- border-inline-end: $shp-border-width $shp-border-style $clr-brd-sec;
+ border-inline-end: $shp-border-width $shp-border-style var(--bit-Calendar-divider-color, #{$clr-brd-sec});
}
.bit-cal-mwp {
margin: 0;
display: flex;
- overflow: hidden;
box-shadow: none;
- padding: spacing(1.5);
- min-width: spacing(26);
box-sizing: content-box;
flex-flow: column nowrap;
+ min-width: var(--bit-cal-pane-min-w);
+ padding: var(--bit-Calendar-padding, #{spacing(1.5)});
}
.bit-cal-pkr {
@@ -378,50 +506,61 @@
border: none;
cursor: pointer;
overflow: hidden;
- width: spacing(6);
- color: $clr-fg-pri;
position: relative;
white-space: nowrap;
outline: transparent;
- max-width: spacing(7);
- font-size: $tg-fs-xs;
text-overflow: ellipsis;
- line-height: spacing(5.17);
background-color: transparent;
- border-radius: $shp-radius-control;
+ width: var(--bit-cal-mcell-w);
+ line-height: var(--bit-cal-mcell-h);
+ font-size: var(--bit-cal-cell-fs);
+ color: var(--bit-Calendar-color, #{$clr-fg-pri});
+ border-radius: var(--bit-Calendar-day-radius, #{$shp-radius-control});
+ transition: background-color $mot-duration-short $mot-easing, color $mot-duration-short $mot-easing;
@media (hover: hover) {
&:hover {
- background-color: $clr-bg-pri-hover;
+ background-color: var(--bit-Calendar-hover-background, #{$clr-bg-pri-hover});
}
}
+ &:active {
+ background-color: var(--bit-Calendar-active-background, #{$clr-bg-pri-active});
+ }
+
+ // The month and year grids move the focus with the arrow keys just as the day grid does, so the cell the
+ // keyboard is on has to say so.
+ &:focus-visible {
+ z-index: 1;
+ @include focus-ring(var(--bit-Calendar-focus-color, var(--bit-cal-clr-focus)), 0);
+ }
+
&:disabled {
- color: $clr-fg-dis;
pointer-events: none;
background-color: transparent;
+ color: var(--bit-Calendar-disabled-color, #{$clr-fg-dis});
}
}
.bit-cal-psm {
font-weight: $tg-fw-semibold;
- background-color: $clr-bg-sec;
+ background-color: var(--bit-Calendar-selected-background, #{$clr-bg-sec});
@media (hover: hover) {
&:hover {
- background-color: $clr-bg-pri-hover;
+ background-color: var(--bit-Calendar-hover-background, #{$clr-bg-pri-hover});
}
}
}
.bit-cal-pcm {
- color: var(--bit-cal-clr-txt);
- background-color: var(--bit-cal-clr);
+ color: var(--bit-Calendar-today-color, var(--bit-cal-clr-txt));
+ background-color: var(--bit-Calendar-today-background, var(--bit-cal-clr));
@media (hover: hover) {
&:hover {
- color: var(--bit-cal-clr-txt);
- background-color: var(--bit-cal-clr-hover);
+ color: var(--bit-Calendar-today-color, var(--bit-cal-clr-txt));
+ background-color: var(--bit-Calendar-today-hover-background, var(--bit-cal-clr-hover));
}
}
}
@@ -454,6 +593,7 @@
font-size: $tg-fs-xl;
justify-content: center;
font-weight: $tg-font-weight;
+ color: var(--bit-Calendar-color, #{$clr-fg-pri});
}
.bit-cal-am-pm {
@@ -473,13 +613,16 @@
overflow: hidden;
text-align: center;
align-items: center;
- width: spacing(3.75);
box-sizing: border-box;
justify-content: center;
font-size: $tg-fs-lg;
-moz-appearance: textfield; // Firefox
font-weight: $tg-font-weight;
- background-color: $clr-bg-pri;
+ width: var(--bit-cal-tcell-size);
+ color: var(--bit-Calendar-color, #{$clr-fg-pri});
+ // Transparent like every other part of the calendar, so the two number inputs sit on whatever surface
+ // the calendar itself is painted with rather than punching two opaque squares through a tinted one.
+ background-color: transparent;
&::-webkit-inner-spin-button, // Chrome, Safari, Edge, Opera
&::-webkit-outer-spin-button {
@@ -489,24 +632,28 @@
&:disabled {
cursor: default;
- color: $clr-fg-dis;
+ color: var(--bit-Calendar-disabled-color, #{$clr-fg-dis});
}
&:focus {
- @include focus-underline-ring;
+ @include focus-underline-ring(var(--bit-Calendar-focus-color, var(--bit-cal-clr-focus)));
border: none;
}
}
+// The two halves of the day are labelled buttons, so they carry the tracking and the case a design system
+// gives the label of an interactive control (Material upper-cases them; Fluent leaves both alone).
.bit-cal-bam,
.bit-cal-bpm {
font-size: $tg-fs-sm;
font-weight: $tg-font-weight;
+ letter-spacing: $tg-ctrl-letter-spacing;
+ text-transform: $tg-ctrl-text-transform;
}
.bit-cal-bns {
- color: var(--bit-cal-clr-txt);
- background-color: var(--bit-cal-clr);
+ color: var(--bit-Calendar-today-color, var(--bit-cal-clr-txt));
+ background-color: var(--bit-Calendar-today-background, var(--bit-cal-clr));
&:disabled {
background-color: $clr-bg-dis;
@@ -514,12 +661,12 @@
@media (hover: hover) {
&:hover {
- background-color: var(--bit-cal-clr-hover);
+ background-color: var(--bit-Calendar-today-hover-background, var(--bit-cal-clr-hover));
}
}
&:active {
- background-color: var(--bit-cal-clr-active);
+ background-color: var(--bit-Calendar-today-active-background, var(--bit-cal-clr-active));
}
}
@@ -528,21 +675,36 @@
display: flex;
overflow: hidden;
box-shadow: none;
- padding: spacing(1.5);
box-sizing: content-box;
flex-flow: column nowrap;
+ padding: var(--bit-Calendar-padding, #{spacing(1.5)});
}
-.bit-cal-evi {
+// The dots of a day sit in a row of their own along the bottom of its cell, so a second and a third stay
+// centered under the number instead of each having to know where the others are.
+.bit-cal-evc {
left: 50%;
+ display: flex;
position: absolute;
- border-radius: 50%;
- width: spacing(0.5);
- height: spacing(0.5);
+ pointer-events: none;
+ gap: var(--bit-Calendar-event-gap, #{spacing(0.25)});
bottom: spacing(0.125);
- background-color: var(--bit-cal-clr);
transform: translateX(-50%);
- transition: transform $mot-duration $mot-easing;
+}
+
+.bit-cal-evi {
+ flex: none;
+ border-radius: $shp-radius-full;
+ width: var(--bit-Calendar-event-size, #{spacing(0.5)});
+ height: var(--bit-Calendar-event-size, #{spacing(0.5)});
+ background-color: var(--bit-cal-evt-clr, var(--bit-Calendar-event-color, var(--bit-cal-clr)));
+}
+
+// Today is painted with the Color role's main color, which is what a dot of no color of its own is painted
+// with too - so on today the dot falls back to the color that reads against it instead of disappearing into
+// it. An event that named a color keeps it, here as everywhere else.
+.bit-cal-dtd .bit-cal-evi {
+ background-color: var(--bit-cal-evt-clr, var(--bit-Calendar-event-color, var(--bit-cal-clr-txt)));
}
.bit-cal-eov {
@@ -553,196 +715,222 @@
position: absolute;
align-items: center;
justify-content: center;
- border-radius: $shp-radius-control;
background-color: $clr-bg-overlay;
+ border-radius: var(--bit-Calendar-radius, #{$shp-radius-control});
+ animation: bit-cal-eov-in $mot-duration-short $mot-easing-decelerate;
}
+// The dialog is a header that stays put above a list that scrolls, rather than one box that scrolls whole:
+// the date it is headed with is the only thing on the surface that says which day is open, so it is the one
+// thing that must not scroll away from the events it names.
.bit-cal-emc {
+ @include focus-anchor;
+ display: flex;
cursor: default;
+ overflow: hidden;
max-height: 80%;
- overflow-y: auto;
- color: $clr-fg-pri;
- padding: spacing(1.5);
+ gap: spacing(1);
+ padding: $spa-dialog;
+ box-sizing: border-box;
min-width: spacing(20);
- max-width: spacing(30);
+ flex-flow: column nowrap;
+ // The overlay is only as wide as the calendar it covers, which a small one is narrower than the dialog's
+ // own ceiling - so the ceiling is whichever of the two is smaller and the dialog never runs past its edge.
+ max-width: min(#{spacing(30)}, 100%);
+ box-shadow: $box-shadow-dialog;
background-color: $clr-bg-pri;
- border-radius: $shp-radius-popup;
- border: $shp-border-width $shp-border-style $clr-brd-sec;
- box-shadow: $box-shadow-md;
+ border-radius: $shp-radius-dialog;
+ color: var(--bit-Calendar-color, #{$clr-fg-pri});
+ border: $shp-border-width $shp-border-style var(--bit-Calendar-divider-color, #{$clr-brd-sec});
+ animation: bit-cal-emc-in $mot-duration-short $mot-easing-decelerate;
}
.bit-cal-emh {
+ flex: none;
display: flex;
- font-weight: $tg-fw-semibold;
+ gap: spacing(1);
align-items: center;
- font-size: $tg-fs-sm;
- margin-bottom: spacing(1);
+ font-size: $tg-fs-md;
+ font-weight: $tg-fw-semibold;
justify-content: space-between;
+ padding-bottom: spacing(0.75);
+ border-bottom: $shp-border-width $shp-border-style var(--bit-Calendar-divider-color, #{$clr-brd-sec});
}
.bit-cal-emx {
+ padding: 0;
+ flex: none;
border: none;
+ display: flex;
cursor: pointer;
+ line-height: 1;
+ align-items: center;
color: $clr-fg-sec;
- font-size: $tg-fs-sm;
+ font-size: $tg-fs-md;
+ justify-content: center;
background-color: transparent;
- border-radius: $shp-radius-control;
- padding: spacing(0.25) spacing(1);
+ // The 24px floor of WCAG 2.2's target-size minimum: the glyph alone is a few pixels short of it.
+ width: spacing(3);
+ height: spacing(3);
+ border-radius: $shp-radius-full;
+ transition: color $mot-duration-short $mot-easing, background-color $mot-duration-short $mot-easing;
@media (hover: hover) {
&:hover {
- background-color: $clr-bg-pri-hover;
+ color: var(--bit-Calendar-color, #{$clr-fg-pri});
+ background-color: var(--bit-Calendar-hover-background, #{$clr-bg-pri-hover});
}
}
+
+ &:focus-visible {
+ @include focus-ring(var(--bit-Calendar-focus-color, var(--bit-cal-clr-focus)));
+ }
}
+// The events of the day are a list of them, so they are marked up as one - a screen reader then says how
+// many there are before reading the first. The list is also what scrolls, so a day with a dozen events keeps
+// its date and its close button on screen.
+.bit-cal-eml {
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex: 1 1 auto;
+ list-style: none;
+ overflow-y: auto;
+ gap: spacing(0.5);
+ scrollbar-width: thin;
+ flex-flow: column nowrap;
+}
+
+// Each event is a card of its own with a stripe of its color down its side - the dot of the day cell, said
+// again beside the event the dot stood for - rather than a hairline between one run of three lines and the
+// next.
.bit-cal-emi {
- padding: spacing(0.75) 0;
- border-top: $shp-border-width $shp-border-style $clr-brd-sec;
+ position: relative;
+ padding: spacing(0.75) spacing(1);
+ padding-inline-start: spacing(1.5);
+ background-color: $clr-bg-sec;
+ border-radius: $shp-radius-surface;
- &:first-of-type {
- padding-top: 0;
- border-top: none;
+ &::before {
+ content: '';
+ position: absolute;
+ inset-block: spacing(0.75);
+ inset-inline-start: spacing(0.5);
+ width: spacing(0.375);
+ border-radius: $shp-radius-full;
+ background-color: var(--bit-cal-evt-clr, var(--bit-Calendar-event-color, var(--bit-cal-clr)));
}
}
+// The default template lays the title out beside the time it runs at, with the body under both, so a list of
+// events reads as a column of titles rather than as three lines each. Only the default one: a template of an
+// app's own is laid out by the app, so the card stops at the surface and the stripe.
+.bit-cal-emi-dft {
+ display: grid;
+ align-items: baseline;
+ column-gap: spacing(1);
+ grid-template-columns: minmax(0, 1fr) auto;
+}
+
.bit-cal-eit {
- font-weight: $tg-fw-semibold;
+ min-width: 0;
+ grid-column: 1;
font-size: $tg-fs-sm;
- margin-bottom: spacing(0.25);
+ overflow-wrap: break-word;
+ font-weight: $tg-fw-semibold;
}
+// The time is a chip of the dialog's own background on the card's, so it reads as a tag on the title rather
+// than as a second line of it, and its digits are tabular so the times of a list line up under one another.
.bit-cal-eis {
+ grid-column: 2;
+ justify-self: end;
+ white-space: nowrap;
color: $clr-fg-sec;
font-size: $tg-fs-xs;
- margin-bottom: spacing(0.25);
+ background-color: $clr-bg-pri;
+ border-radius: $shp-radius-chip;
+ font-variant-numeric: tabular-nums;
+ padding: spacing(0.125) spacing(0.5);
}
.bit-cal-eib {
font-size: $tg-fs-xs;
- color: $clr-fg-pri;
white-space: pre-wrap;
+ grid-column: 1 / -1;
+ margin-top: spacing(0.5);
+ overflow-wrap: break-word;
+ color: var(--bit-Calendar-color, #{$clr-fg-pri});
}
+// The details are put on top of the calendar rather than appearing in its place: the overlay fades the days
+// back and the dialog grows the last of the way in over them. Both are timed by the motion token, so
+// prefers-reduced-motion collapses them to nothing on its own.
+@keyframes bit-cal-eov-in {
+ 0% {
+ opacity: 0;
+ }
-.bit-cal-pri {
- --bit-cal-clr: #{$clr-pri};
- --bit-cal-clr-txt: #{$clr-pri-text};
- --bit-cal-clr-hover: #{$clr-pri-hover};
- --bit-cal-clr-active: #{$clr-pri-active};
-}
-
-.bit-cal-sec {
- --bit-cal-clr: #{$clr-sec};
- --bit-cal-clr-txt: #{$clr-sec-text};
- --bit-cal-clr-hover: #{$clr-sec-hover};
- --bit-cal-clr-active: #{$clr-sec-active};
-}
-
-.bit-cal-ter {
- --bit-cal-clr: #{$clr-ter};
- --bit-cal-clr-txt: #{$clr-ter-text};
- --bit-cal-clr-hover: #{$clr-ter-hover};
- --bit-cal-clr-active: #{$clr-ter-active};
-}
-
-.bit-cal-inf {
- --bit-cal-clr: #{$clr-inf};
- --bit-cal-clr-txt: #{$clr-inf-text};
- --bit-cal-clr-hover: #{$clr-inf-hover};
- --bit-cal-clr-active: #{$clr-inf-active};
-}
-
-.bit-cal-suc {
- --bit-cal-clr: #{$clr-suc};
- --bit-cal-clr-txt: #{$clr-suc-text};
- --bit-cal-clr-hover: #{$clr-suc-hover};
- --bit-cal-clr-active: #{$clr-suc-active};
-}
-
-.bit-cal-wrn {
- --bit-cal-clr: #{$clr-wrn};
- --bit-cal-clr-txt: #{$clr-wrn-text};
- --bit-cal-clr-hover: #{$clr-wrn-hover};
- --bit-cal-clr-active: #{$clr-wrn-active};
-}
-
-.bit-cal-swr {
- --bit-cal-clr: #{$clr-swr};
- --bit-cal-clr-txt: #{$clr-swr-text};
- --bit-cal-clr-hover: #{$clr-swr-hover};
- --bit-cal-clr-active: #{$clr-swr-active};
+ 100% {
+ opacity: 1;
+ }
}
-.bit-cal-err {
- --bit-cal-clr: #{$clr-err};
- --bit-cal-clr-txt: #{$clr-err-text};
- --bit-cal-clr-hover: #{$clr-err-hover};
- --bit-cal-clr-active: #{$clr-err-active};
-}
+@keyframes bit-cal-emc-in {
+ 0% {
+ opacity: 0;
+ transform: scale(0.96);
+ }
-.bit-cal-pbg {
- --bit-cal-clr: #{$clr-bg-pri};
- --bit-cal-clr-txt: #{$clr-fg-pri};
- --bit-cal-clr-hover: #{$clr-bg-pri-hover};
- --bit-cal-clr-active: #{$clr-bg-pri-active};
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
}
-.bit-cal-sbg {
- --bit-cal-clr: #{$clr-bg-sec};
- --bit-cal-clr-txt: #{$clr-fg-pri};
- --bit-cal-clr-hover: #{$clr-bg-sec-hover};
- --bit-cal-clr-active: #{$clr-bg-sec-active};
-}
-.bit-cal-tbg {
- --bit-cal-clr: #{$clr-bg-ter};
- --bit-cal-clr-txt: #{$clr-fg-pri};
- --bit-cal-clr-hover: #{$clr-bg-ter-hover};
- --bit-cal-clr-active: #{$clr-bg-ter-active};
-}
+// Role classes are generated from the shared $bit-color-roles map (see color-role-maps.scss).
+@each $role, $tokens in $bit-color-roles {
+ .bit-cal-#{$role} {
+ --bit-cal-clr: #{role($tokens, main)};
+ --bit-cal-clr-txt: #{role($tokens, on)};
+ --bit-cal-clr-hover: #{role($tokens, hover)};
+ --bit-cal-clr-active: #{role($tokens, active)};
+ --bit-cal-clr-focus: #{role($tokens, focus)};
+ }
-.bit-cal-pfg {
- --bit-cal-clr: #{$clr-fg-pri};
- --bit-cal-clr-txt: #{$clr-bg-pri};
- --bit-cal-clr-hover: #{$clr-fg-pri-hover};
- --bit-cal-clr-active: #{$clr-fg-pri-active};
+ // The color of a single event, set on its own dot and on the stripe of its card in the details dialog,
+ // so a day can carry several colors at once in both places.
+ .bit-cal-evi-#{$role},
+ .bit-cal-emi-#{$role} {
+ --bit-cal-evt-clr: #{role($tokens, main)};
+ }
}
-.bit-cal-sfg {
- --bit-cal-clr: #{$clr-fg-sec};
- --bit-cal-clr-txt: #{$clr-bg-pri};
- --bit-cal-clr-hover: #{$clr-fg-sec-hover};
- --bit-cal-clr-active: #{$clr-fg-sec-active};
-}
-.bit-cal-tfg {
- --bit-cal-clr: #{$clr-fg-ter};
- --bit-cal-clr-txt: #{$clr-bg-pri};
- --bit-cal-clr-hover: #{$clr-fg-ter-hover};
- --bit-cal-clr-active: #{$clr-fg-ter-active};
+.bit-cal-sm {
+ --bit-cal-cell-size-def: #{spacing(3)};
+ --bit-cal-cell-fs-def: #{$tg-fs-xs};
+ --bit-cal-mcell-w: #{spacing(5.25)};
+ --bit-cal-mcell-h: #{spacing(4.5)};
+ --bit-cal-tcell-size: #{spacing(3.25)};
+ --bit-cal-pane-min-w: #{spacing(23)};
}
-.bit-cal-pbr {
- --bit-cal-clr: #{$clr-brd-pri};
- --bit-cal-clr-txt: #{$clr-fg-pri};
- --bit-cal-clr-hover: #{$clr-brd-pri-hover};
- --bit-cal-clr-active: #{$clr-brd-pri-active};
+.bit-cal-md {
+ @include cal-size-medium;
}
-.bit-cal-sbr {
- --bit-cal-clr: #{$clr-brd-sec};
- --bit-cal-clr-txt: #{$clr-fg-pri};
- --bit-cal-clr-hover: #{$clr-brd-sec-hover};
- --bit-cal-clr-active: #{$clr-brd-sec-active};
+.bit-cal-lg {
+ --bit-cal-cell-size-def: #{spacing(4.25)};
+ --bit-cal-cell-fs-def: #{$tg-fs-md};
+ --bit-cal-mcell-w: #{spacing(7)};
+ --bit-cal-mcell-h: #{spacing(6)};
+ --bit-cal-tcell-size: #{spacing(4.5)};
+ --bit-cal-pane-min-w: #{spacing(31)};
}
-.bit-cal-tbr {
- --bit-cal-clr: #{$clr-brd-ter};
- --bit-cal-clr-txt: #{$clr-fg-pri};
- --bit-cal-clr-hover: #{$clr-brd-ter-hover};
- --bit-cal-clr-active: #{$clr-brd-ter-active};
-}
// --- month header floor ---
// The header of the day view names the month in full, and the day wrapper it sits in is floored by a
@@ -750,9 +938,70 @@
// longest month name already overruns that floor at the Fluent 14px base ("September 2024" wants 125px
// of the 124px the arrows leave it) and overruns it further under a preset that sets a larger one, so
// the label was silently ellipsised. The button therefore carries its own floor in em: it is read
-// against the button's own font, so it tracks whatever step the active ramp puts here, and the day
+// against the label's own font, so it tracks whatever step the active ramp puts here, and the day
// rows distribute the few extra pixels evenly (space-evenly) rather than leaving a gap at one end.
-// Only the day view takes it - the month view's header is a bare year and needs none of the room.
-.bit-cal-dwp .bit-cal-ptb {
+// Both spellings of the header take it - the button that opens the month picker and the plain label
+// standing in for it - since either of them can be the one naming the month. Only the day view does:
+// the month view's header is a bare year and needs none of the room.
+.bit-cal-dwp .bit-cal-ptb,
+.bit-cal-dwp .bit-cal-pkt {
min-width: 8.75em;
}
+
+
+// Windows High Contrast / forced-colors: the palette is reduced to the system pair, which strips every
+// background the calendar paints - and with them today, the selection, the highlighted days and the
+// meridiem that is in force, all of which are conveyed by a background alone. Each is re-established with
+// a border or a system color, so the states stay distinguishable.
+@media (forced-colors: active) {
+ .bit-cal-dbt,
+ .bit-cal-pkb {
+ &:disabled {
+ color: GrayText;
+ }
+ }
+
+ .bit-cal-dtd,
+ .bit-cal-pcm,
+ .bit-cal-bns {
+ color: HighlightText;
+ background-color: Highlight;
+ forced-color-adjust: none;
+ }
+
+ .bit-cal-dbs::after {
+ border-color: CanvasText;
+ }
+
+ .bit-cal-dhl {
+ outline: $shp-border-width dotted CanvasText;
+ outline-offset: calc(-1 * #{$shp-border-width});
+ }
+
+ .bit-cal-evi {
+ background-color: CanvasText;
+ forced-color-adjust: none;
+ }
+
+ // Today is painted Highlight in this palette, which CanvasText does not have to read against.
+ .bit-cal-dtd .bit-cal-evi {
+ background-color: HighlightText;
+ }
+
+ // The card of an event and the stripe down its side are both a background, which this palette strips:
+ // the card is given an edge of its own instead and the stripe keeps a system color.
+ .bit-cal-emi {
+ border: $shp-border-width $shp-border-style CanvasText;
+
+ &::before {
+ background-color: CanvasText;
+ forced-color-adjust: none;
+ }
+ }
+
+ // An outline rather than a border: it gives the calendar an edge in a palette that strips every
+ // background without taking a couple of pixels out of the grid to do it.
+ .bit-cal-cnt {
+ outline: $shp-border-width $shp-border-style CanvasText;
+ }
+}
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendarClassStyles.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendarClassStyles.cs
index 8954ece1339..361f1291262 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendarClassStyles.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendarClassStyles.cs
@@ -12,6 +12,16 @@ public class BitCalendarClassStyles
///
public string? Container { get; set; }
+ ///
+ /// Custom CSS classes/styles for the wrapper of the HeaderTemplate of the BitCalendar.
+ ///
+ public string? Header { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the wrapper of the FooterTemplate of the BitCalendar.
+ ///
+ public string? Footer { get; set; }
+
///
/// Custom CSS classes/styles for the day-picker's wrapper of the BitCalendar.
///
@@ -343,7 +353,12 @@ public class BitCalendarClassStyles
public string? YearButton { get; set; }
///
- /// Custom CSS classes/styles for the event indicator dot of the BitCalendar.
+ /// Custom CSS classes/styles for the row of event indicator dots on a day of the BitCalendar.
+ ///
+ public string? EventIndicators { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for each event indicator dot of the BitCalendar.
///
public string? EventIndicator { get; set; }
@@ -367,6 +382,11 @@ public class BitCalendarClassStyles
///
public string? EventModalCloseButton { get; set; }
+ ///
+ /// Custom CSS classes/styles for the list of events in the event modal of the BitCalendar.
+ ///
+ public string? EventList { get; set; }
+
///
/// Custom CSS classes/styles for each event item in the event modal of the BitCalendar.
///
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendarEvent.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendarEvent.cs
index 6c0b578c460..941e998ca51 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendarEvent.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendarEvent.cs
@@ -20,6 +20,12 @@ public class BitCalendarEvent
///
public DateOnly Date { get; set; }
+ ///
+ /// The color of the indicator dot the event puts on its day. Without one the dot takes the color of the
+ /// calendar itself, so a calendar whose events are all of a kind needs to say nothing here.
+ ///
+ public BitColor? Color { get; set; }
+
///
/// The optional start time of the event.
///
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendarParams.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendarParams.cs
new file mode 100644
index 00000000000..d8610f70690
--- /dev/null
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Calendar/BitCalendarParams.cs
@@ -0,0 +1,1134 @@
+using System.Globalization;
+
+namespace Bit.BlazorUI;
+
+///
+/// The parameters for component.
+///
+public class BitCalendarParams : BitComponentBaseParams, IBitComponentParams
+{
+ ///
+ /// Represents the parameter name used to identify the cascading parameters within .
+ ///
+ ///
+ /// This constant is typically used when referencing or accessing the BitCalendar value in
+ /// parameterized APIs or configuration settings. Using this constant helps ensure consistency and reduces the risk
+ /// of typographical errors.
+ ///
+ public const string ParamName = $"{nameof(BitParams)}.{nameof(BitCalendar)}";
+
+
+
+ public string Name => ParamName;
+
+
+
+ ///
+ /// Whether selecting the already selected day deselects it, clearing the value.
+ ///
+ public bool? AllowDeselect { get; set; }
+
+ ///
+ /// Custom CSS classes for different parts of the BitCalendar component.
+ ///
+ public BitCalendarClassStyles? Classes { get; set; }
+
+ ///
+ /// The general color of the calendar that applies to the today day button, the highlighted current month,
+ /// the selected AM/PM button, and the event indicators.
+ ///
+ public BitColor? Color { get; set; }
+
+ ///
+ /// The delay in milliseconds before the hour/minute of the time picker starts changing continuously while an
+ /// increase/decrease button is held down.
+ ///
+ public int? ContinuousSpinDelay { get; set; }
+
+ ///
+ /// The interval in milliseconds between two consecutive changes while an increase/decrease button is held down.
+ ///
+ public int? ContinuousSpinInterval { get; set; }
+
+ ///
+ /// CultureInfo for the Calendar.
+ ///
+ public CultureInfo? Culture { get; set; }
+
+ ///
+ /// The format of the date in the Calendar.
+ ///
+ public string? DateFormat { get; set; }
+
+ ///
+ /// Used to customize how content inside the day cell is rendered.
+ ///
+ public RenderFragment? DayCellTemplate { get; set; }
+
+ ///
+ /// Disables every day after today, exactly as a of now would.
+ /// When both are set, the earlier of the two bounds wins.
+ ///
+ public bool? DisableFuture { get; set; }
+
+ ///
+ /// Disables every day before today, exactly as a of now would.
+ /// When both are set, the later of the two bounds wins.
+ ///
+ public bool? DisablePast { get; set; }
+
+ ///
+ /// The list of dates that are disabled (not selectable) in the calendar, in addition to MinDate and MaxDate.
+ ///
+ public IEnumerable? DisabledDates { get; set; }
+
+ ///
+ /// The days of the week that are disabled (not selectable) in the calendar (e.g. weekends).
+ ///
+ public IEnumerable? DisabledDaysOfWeek { get; set; }
+
+ ///
+ /// The list of events to display on calendar days.
+ ///
+ public IEnumerable? Events { get; set; }
+
+ ///
+ /// The title (tooltip) and the accessible name of the close button of the event details dialog.
+ ///
+ public string? EventDetailsCloseButtonTitle { get; set; }
+
+ ///
+ /// Used to customize how an event is rendered in the details dialog, in place of its title, its time and its body.
+ ///
+ public RenderFragment? EventTemplate { get; set; }
+
+ ///
+ /// The text shown before the start time of an event when only a start time is present (e.g. "From 09:00").
+ ///
+ public string? EventTimeFromText { get; set; }
+
+ ///
+ /// The text shown before the end time of an event when only an end time is present (e.g. "Until 17:00").
+ ///
+ public string? EventTimeUntilText { get; set; }
+
+ ///
+ /// Rendered under the pickers, inside the root of the calendar.
+ ///
+ public RenderFragment? FooterTemplate { get; set; }
+
+ ///
+ /// Overrides the first day of the week in the day picker. If not set, the first day of the week of the Culture is used.
+ ///
+ public DayOfWeek? FirstDayOfWeek { get; set; }
+
+ ///
+ /// Whether the day picker should always render six weeks, filling the extra rows with the days of the adjacent months.
+ ///
+ public bool? FixedWeeks { get; set; }
+
+ ///
+ /// Custom function to provide additional CSS classes for each day button of the calendar.
+ ///
+ public Func? GetDayClass { get; set; }
+
+ ///
+ /// Rendered above the pickers, inside the root of the calendar.
+ ///
+ public RenderFragment? HeaderTemplate { get; set; }
+
+ ///
+ /// The title of the Go to next month button (tooltip).
+ ///
+ public string? GoToNextMonthTitle { get; set; }
+
+ ///
+ /// The title of the Go to next year range button (tooltip).
+ ///
+ public string? GoToNextYearRangeTitle { get; set; }
+
+ ///
+ /// The title of the Go to next year button (tooltip).
+ ///
+ public string? GoToNextYearTitle { get; set; }
+
+ ///
+ /// Gets or sets the icon to display in the now button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ ///
+ public BitIconInfo? NowButtonIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to display in the now button from the built-in Fluent UI icons.
+ ///
+ public string? NowButtonIconName { get; set; }
+
+ ///
+ /// The title of the now button (tooltip).
+ ///
+ public string? NowButtonTitle { get; set; }
+
+ ///
+ /// The title of the Go to previous month button (tooltip).
+ ///
+ public string? GoToPrevMonthTitle { get; set; }
+
+ ///
+ /// The title of the Go to previous year range button (tooltip).
+ ///
+ public string? GoToPrevYearRangeTitle { get; set; }
+
+ ///
+ /// The title of the Go to previous year button (tooltip).
+ ///
+ public string? GoToPrevYearTitle { get; set; }
+
+ ///
+ /// Gets or sets the icon to display in the GoToToday button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ ///
+ public BitIconInfo? GoToTodayIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to display in the GoToToday button from the built-in Fluent UI icons.
+ ///
+ public string? GoToTodayIconName { get; set; }
+
+ ///
+ /// The title of the GoToToday button (tooltip).
+ ///
+ public string? GoToTodayTitle { get; set; }
+
+ ///
+ /// Gets or sets the icon to display in the HideTimePicker button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ ///
+ public BitIconInfo? HideTimePickerIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to display in the HideTimePicker button from the built-in Fluent UI icons.
+ ///
+ public string? HideTimePickerIconName { get; set; }
+
+ ///
+ /// The title of the HideTimePicker button (tooltip).
+ ///
+ public string? HideTimePickerTitle { get; set; }
+
+ ///
+ /// Whether the month picker should highlight the current month.
+ ///
+ public bool? HighlightCurrentMonth { get; set; }
+
+ ///
+ /// The list of dates that are highlighted (marked) in the day picker.
+ ///
+ public IEnumerable? HighlightedDates { get; set; }
+
+ ///
+ /// Whether the month picker should highlight the selected month.
+ ///
+ public bool? HighlightSelectedMonth { get; set; }
+
+ ///
+ /// Whether the day picker should highlight today's day.
+ ///
+ public bool? HighlightToday { get; set; }
+
+ ///
+ /// The step, in hours, the spin buttons of the time picker move the hour by.
+ ///
+ public int? HourStep { get; set; }
+
+ ///
+ /// The custom validation error message for the invalid value.
+ ///
+ public string? InvalidErrorMessage { get; set; }
+
+ ///
+ /// Custom function to determine if a specific date is disabled (not selectable) in the calendar.
+ ///
+ public Func? IsDateDisabled { get; set; }
+
+ ///
+ /// The maximum allowable date of the calendar.
+ ///
+ public DateTimeOffset? MaxDate { get; set; }
+
+ ///
+ /// The minimum allowable date of the calendar.
+ ///
+ public DateTimeOffset? MinDate { get; set; }
+
+ ///
+ /// The step, in minutes, the spin buttons of the time picker move the minute by.
+ ///
+ public int? MinuteStep { get; set; }
+
+ ///
+ /// The number of consecutive months rendered side by side in the day picker (1 to 3).
+ ///
+ public int? MonthCount { get; set; }
+
+ ///
+ /// Used to customize how content inside the month cell is rendered.
+ ///
+ public RenderFragment? MonthCellTemplate { get; set; }
+
+ ///
+ /// The title of the month picker's toggle (tooltip).
+ ///
+ public string? MonthPickerToggleTitle { get; set; }
+
+ ///
+ /// Gets or sets the icon to display in the Go to next month button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ ///
+ public BitIconInfo? NextMonthNavIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to display in the Go to next month button from the built-in Fluent UI icons.
+ ///
+ public string? NextMonthNavIconName { get; set; }
+
+ ///
+ /// Gets or sets the icon to display in the Go to next year button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ ///
+ public BitIconInfo? NextYearNavIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to display in the Go to next year button from the built-in Fluent UI icons.
+ ///
+ public string? NextYearNavIconName { get; set; }
+
+ ///
+ /// Gets or sets the icon to display in the Go to next year range button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ ///
+ public BitIconInfo? NextYearRangeNavIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to display in the Go to next year range button from the built-in Fluent UI icons.
+ ///
+ public string? NextYearRangeNavIconName { get; set; }
+
+ ///
+ /// Whether the previous and next navigation buttons move the calendar by all of its rendered months instead of one.
+ ///
+ public bool? PagedNavigation { get; set; }
+
+ ///
+ /// Gets or sets the icon to display in the Go to previous month button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ ///
+ public BitIconInfo? PrevMonthNavIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to display in the Go to previous month button from the built-in Fluent UI icons.
+ ///
+ public string? PrevMonthNavIconName { get; set; }
+
+ ///
+ /// Gets or sets the icon to display in the Go to previous year button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ ///
+ public BitIconInfo? PrevYearNavIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to display in the Go to previous year button from the built-in Fluent UI icons.
+ ///
+ public string? PrevYearNavIconName { get; set; }
+
+ ///
+ /// Gets or sets the icon to display in the Go to previous year range button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ ///
+ public BitIconInfo? PrevYearRangeNavIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to display in the Go to previous year range button from the built-in Fluent UI icons.
+ ///
+ public string? PrevYearRangeNavIconName { get; set; }
+
+ ///
+ /// The template of the text a screen reader is given when the selection changes, where {0} is the selected
+ /// date written with the .
+ ///
+ public string? SelectedDateAriaAtomic { get; set; }
+
+ ///
+ /// Whether the now button should be shown or not.
+ ///
+ public bool? ShowNowButton { get; set; }
+
+ ///
+ /// Whether the GoToToday button should be shown or not.
+ ///
+ public bool? ShowGoToToday { get; set; }
+
+ ///
+ /// Whether clicking a day that carries events opens the dialog listing them.
+ ///
+ public bool? ShowEventDetails { get; set; }
+
+ ///
+ /// Whether the month picker is shown or hidden.
+ ///
+ public bool? ShowMonthPicker { get; set; }
+
+ ///
+ /// Show month picker on top of date picker when visible.
+ ///
+ public bool? ShowMonthPickerAsOverlay { get; set; }
+
+ ///
+ /// Whether the days of the previous and next months should be shown in the day picker.
+ ///
+ public bool? ShowOutsideDays { get; set; }
+
+ ///
+ /// Whether the time picker should be shown or not.
+ ///
+ public bool? ShowTimePicker { get; set; }
+
+ ///
+ /// Show time picker on top of date picker when visible.
+ ///
+ public bool? ShowTimePickerAsOverlay { get; set; }
+
+ ///
+ /// Gets or sets the icon to display in the ShowTimePicker button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ ///
+ public BitIconInfo? ShowTimePickerIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to display in the ShowTimePicker button from the built-in Fluent UI icons.
+ ///
+ public string? ShowTimePickerIconName { get; set; }
+
+ ///
+ /// The title of the ShowTimePicker button (tooltip).
+ ///
+ public string? ShowTimePickerTitle { get; set; }
+
+ ///
+ /// Whether the week number (weeks 1 to 53) should be shown before each week row.
+ ///
+ public bool? ShowWeekNumbers { get; set; }
+
+ ///
+ /// The size of the calendar, which scales its cells and their text.
+ ///
+ public BitSize? Size { get; set; }
+
+ ///
+ /// Specifies the date and time of the calendar when it is showing without any selected value.
+ ///
+ public DateTimeOffset? StartingValue { get; set; }
+
+ ///
+ /// Custom CSS styles for different parts of the BitCalendar component.
+ ///
+ public BitCalendarClassStyles? Styles { get; set; }
+
+ ///
+ /// The time format of the time-picker, 24H or 12H.
+ ///
+ public BitTimeFormat? TimeFormat { get; set; }
+
+ ///
+ /// Gets or sets the icon to display in the decrease-hour button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ ///
+ public BitIconInfo? TimePickerDecreaseHourIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to display in the decrease-hour button from the built-in Fluent UI icons.
+ ///
+ public string? TimePickerDecreaseHourIconName { get; set; }
+
+ ///
+ /// The title (tooltip) and the accessible name of the time-picker's decrease-hour button.
+ ///
+ public string? TimePickerDecreaseHourTitle { get; set; }
+
+ ///
+ /// The title (tooltip) and the accessible name of the time-picker's decrease-minute button.
+ ///
+ public string? TimePickerDecreaseMinuteTitle { get; set; }
+
+ ///
+ /// Gets or sets the icon to display in the decrease-minute button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ ///
+ public BitIconInfo? TimePickerDecreaseMinuteIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to display in the decrease-minute button from the built-in Fluent UI icons.
+ ///
+ public string? TimePickerDecreaseMinuteIconName { get; set; }
+
+ ///
+ /// The title (tooltip) and the accessible name of the time-picker's hour input.
+ ///
+ public string? TimePickerHourTitle { get; set; }
+
+ ///
+ /// The title (tooltip) and the accessible name of the time-picker's minute input.
+ ///
+ public string? TimePickerMinuteTitle { get; set; }
+
+ ///
+ /// Gets or sets the icon to display in the increase-hour button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ ///
+ public BitIconInfo? TimePickerIncreaseHourIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to display in the increase-hour button from the built-in Fluent UI icons.
+ ///
+ public string? TimePickerIncreaseHourIconName { get; set; }
+
+ ///
+ /// Gets or sets the icon to display in the increase-minute button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ ///
+ public BitIconInfo? TimePickerIncreaseMinuteIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to display in the increase-minute button from the built-in Fluent UI icons.
+ ///
+ public string? TimePickerIncreaseMinuteIconName { get; set; }
+
+ ///
+ /// The title (tooltip) and the accessible name of the time-picker's increase-hour button.
+ ///
+ public string? TimePickerIncreaseHourTitle { get; set; }
+
+ ///
+ /// The title (tooltip) and the accessible name of the time-picker's increase-minute button.
+ ///
+ public string? TimePickerIncreaseMinuteTitle { get; set; }
+
+ ///
+ /// TimeZone for the Calendar.
+ ///
+ public TimeZoneInfo? TimeZone { get; set; }
+
+ ///
+ /// Overrides the current date and time considered as "today" and "now" in the calendar.
+ ///
+ public DateTimeOffset? Today { get; set; }
+
+ ///
+ /// The rule used to calculate the week numbers. Defaults to the FirstFullWeek rule.
+ ///
+ public CalendarWeekRule? WeekNumberRule { get; set; }
+
+ ///
+ /// The accessible name of the empty column header above the week numbers.
+ ///
+ public string? WeekNumbersHeaderTitle { get; set; }
+
+ ///
+ /// The title of the week number (tooltip).
+ ///
+ public string? WeekNumberTitle { get; set; }
+
+ ///
+ /// Used to customize how content inside the year cell is rendered.
+ ///
+ public RenderFragment? YearCellTemplate { get; set; }
+
+ ///
+ /// The title of the year picker's toggle (tooltip).
+ ///
+ public string? YearPickerToggleTitle { get; set; }
+
+ ///
+ /// The title of the year range picker's toggle (tooltip).
+ ///
+ public string? YearRangePickerToggleTitle { get; set; }
+
+
+
+ ///
+ /// Updates the properties of the specified instance with any values that have been set on
+ /// this object, if those properties have not already been set on the .
+ ///
+ ///
+ /// Only properties that have a value set and have not already been set on the will be updated.
+ /// This method does not overwrite existing values on .
+ ///
+ ///
+ /// The instance whose properties will be updated. Cannot be null.
+ ///
+ public void UpdateParameters(BitCalendar bitCalendar)
+ {
+ if (bitCalendar is null) return;
+
+ UpdateBaseParameters(bitCalendar);
+
+ // The parameters the calendar rebuilds its view from are the ones carrying a [CallOnSet(OnSetParameters)] on
+ // the component, and the component has already run that pass in OnInitialized - before anything cascaded
+ // here reached it. So whichever of them the cascade fills in, the pass is run once more at the end.
+ var rebuildView = false;
+
+ if (AllowDeselect.HasValue && bitCalendar.HasNotBeenSet(nameof(AllowDeselect)))
+ {
+ bitCalendar.AllowDeselect = AllowDeselect.Value;
+ }
+
+ if (Classes is not null && bitCalendar.HasNotBeenSet(nameof(Classes)))
+ {
+ bitCalendar.Classes = Classes;
+
+ bitCalendar.ClassBuilder.Reset();
+ }
+
+ if (Color.HasValue && bitCalendar.HasNotBeenSet(nameof(Color)))
+ {
+ bitCalendar.Color = Color.Value;
+
+ bitCalendar.ClassBuilder.Reset();
+ }
+
+ if (ContinuousSpinDelay.HasValue && bitCalendar.HasNotBeenSet(nameof(ContinuousSpinDelay)))
+ {
+ bitCalendar.ContinuousSpinDelay = ContinuousSpinDelay.Value;
+ }
+
+ if (ContinuousSpinInterval.HasValue && bitCalendar.HasNotBeenSet(nameof(ContinuousSpinInterval)))
+ {
+ bitCalendar.ContinuousSpinInterval = ContinuousSpinInterval.Value;
+ }
+
+ if (Culture is not null && bitCalendar.HasNotBeenSet(nameof(Culture)))
+ {
+ bitCalendar.Culture = Culture;
+
+ bitCalendar.ClassBuilder.Reset();
+
+ rebuildView = true;
+ }
+
+ if (DateFormat.HasValue() && bitCalendar.HasNotBeenSet(nameof(DateFormat)))
+ {
+ bitCalendar.DateFormat = DateFormat;
+ }
+
+ if (DayCellTemplate is not null && bitCalendar.HasNotBeenSet(nameof(DayCellTemplate)))
+ {
+ bitCalendar.DayCellTemplate = DayCellTemplate;
+ }
+
+ if (DisableFuture.HasValue && bitCalendar.HasNotBeenSet(nameof(DisableFuture)))
+ {
+ bitCalendar.DisableFuture = DisableFuture.Value;
+
+ rebuildView = true;
+ }
+
+ if (DisablePast.HasValue && bitCalendar.HasNotBeenSet(nameof(DisablePast)))
+ {
+ bitCalendar.DisablePast = DisablePast.Value;
+
+ rebuildView = true;
+ }
+
+ if (DisabledDates is not null && bitCalendar.HasNotBeenSet(nameof(DisabledDates)))
+ {
+ bitCalendar.DisabledDates = DisabledDates;
+ }
+
+ if (DisabledDaysOfWeek is not null && bitCalendar.HasNotBeenSet(nameof(DisabledDaysOfWeek)))
+ {
+ bitCalendar.DisabledDaysOfWeek = DisabledDaysOfWeek;
+ }
+
+ if (Events is not null && bitCalendar.HasNotBeenSet(nameof(Events)))
+ {
+ bitCalendar.Events = Events;
+ }
+
+ if (EventDetailsCloseButtonTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(EventDetailsCloseButtonTitle)))
+ {
+ bitCalendar.EventDetailsCloseButtonTitle = EventDetailsCloseButtonTitle!;
+ }
+
+ if (EventTemplate is not null && bitCalendar.HasNotBeenSet(nameof(EventTemplate)))
+ {
+ bitCalendar.EventTemplate = EventTemplate;
+ }
+
+ if (EventTimeFromText.HasValue() && bitCalendar.HasNotBeenSet(nameof(EventTimeFromText)))
+ {
+ bitCalendar.EventTimeFromText = EventTimeFromText!;
+ }
+
+ if (EventTimeUntilText.HasValue() && bitCalendar.HasNotBeenSet(nameof(EventTimeUntilText)))
+ {
+ bitCalendar.EventTimeUntilText = EventTimeUntilText!;
+ }
+
+ if (FooterTemplate is not null && bitCalendar.HasNotBeenSet(nameof(FooterTemplate)))
+ {
+ bitCalendar.FooterTemplate = FooterTemplate;
+ }
+
+ if (FirstDayOfWeek.HasValue && bitCalendar.HasNotBeenSet(nameof(FirstDayOfWeek)))
+ {
+ bitCalendar.FirstDayOfWeek = FirstDayOfWeek.Value;
+
+ rebuildView = true;
+ }
+
+ if (FixedWeeks.HasValue && bitCalendar.HasNotBeenSet(nameof(FixedWeeks)))
+ {
+ bitCalendar.FixedWeeks = FixedWeeks.Value;
+
+ rebuildView = true;
+ }
+
+ if (GetDayClass is not null && bitCalendar.HasNotBeenSet(nameof(GetDayClass)))
+ {
+ bitCalendar.GetDayClass = GetDayClass;
+ }
+
+ if (HeaderTemplate is not null && bitCalendar.HasNotBeenSet(nameof(HeaderTemplate)))
+ {
+ bitCalendar.HeaderTemplate = HeaderTemplate;
+ }
+
+ if (GoToNextMonthTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(GoToNextMonthTitle)))
+ {
+ bitCalendar.GoToNextMonthTitle = GoToNextMonthTitle!;
+ }
+
+ if (GoToNextYearRangeTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(GoToNextYearRangeTitle)))
+ {
+ bitCalendar.GoToNextYearRangeTitle = GoToNextYearRangeTitle!;
+ }
+
+ if (GoToNextYearTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(GoToNextYearTitle)))
+ {
+ bitCalendar.GoToNextYearTitle = GoToNextYearTitle!;
+ }
+
+ if (NowButtonIcon is not null && bitCalendar.HasNotBeenSet(nameof(NowButtonIcon)))
+ {
+ bitCalendar.NowButtonIcon = NowButtonIcon;
+ }
+
+ if (NowButtonIconName.HasValue() && bitCalendar.HasNotBeenSet(nameof(NowButtonIconName)))
+ {
+ bitCalendar.NowButtonIconName = NowButtonIconName;
+ }
+
+ if (NowButtonTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(NowButtonTitle)))
+ {
+ bitCalendar.NowButtonTitle = NowButtonTitle!;
+ }
+
+ if (GoToPrevMonthTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(GoToPrevMonthTitle)))
+ {
+ bitCalendar.GoToPrevMonthTitle = GoToPrevMonthTitle!;
+ }
+
+ if (GoToPrevYearRangeTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(GoToPrevYearRangeTitle)))
+ {
+ bitCalendar.GoToPrevYearRangeTitle = GoToPrevYearRangeTitle!;
+ }
+
+ if (GoToPrevYearTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(GoToPrevYearTitle)))
+ {
+ bitCalendar.GoToPrevYearTitle = GoToPrevYearTitle!;
+ }
+
+ if (GoToTodayIcon is not null && bitCalendar.HasNotBeenSet(nameof(GoToTodayIcon)))
+ {
+ bitCalendar.GoToTodayIcon = GoToTodayIcon;
+ }
+
+ if (GoToTodayIconName.HasValue() && bitCalendar.HasNotBeenSet(nameof(GoToTodayIconName)))
+ {
+ bitCalendar.GoToTodayIconName = GoToTodayIconName;
+ }
+
+ if (GoToTodayTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(GoToTodayTitle)))
+ {
+ bitCalendar.GoToTodayTitle = GoToTodayTitle!;
+ }
+
+ if (HideTimePickerIcon is not null && bitCalendar.HasNotBeenSet(nameof(HideTimePickerIcon)))
+ {
+ bitCalendar.HideTimePickerIcon = HideTimePickerIcon;
+ }
+
+ if (HideTimePickerIconName.HasValue() && bitCalendar.HasNotBeenSet(nameof(HideTimePickerIconName)))
+ {
+ bitCalendar.HideTimePickerIconName = HideTimePickerIconName;
+ }
+
+ if (HideTimePickerTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(HideTimePickerTitle)))
+ {
+ bitCalendar.HideTimePickerTitle = HideTimePickerTitle!;
+ }
+
+ if (HighlightCurrentMonth.HasValue && bitCalendar.HasNotBeenSet(nameof(HighlightCurrentMonth)))
+ {
+ bitCalendar.HighlightCurrentMonth = HighlightCurrentMonth.Value;
+ }
+
+ if (HighlightedDates is not null && bitCalendar.HasNotBeenSet(nameof(HighlightedDates)))
+ {
+ bitCalendar.HighlightedDates = HighlightedDates;
+ }
+
+ if (HighlightSelectedMonth.HasValue && bitCalendar.HasNotBeenSet(nameof(HighlightSelectedMonth)))
+ {
+ bitCalendar.HighlightSelectedMonth = HighlightSelectedMonth.Value;
+ }
+
+ if (HighlightToday.HasValue && bitCalendar.HasNotBeenSet(nameof(HighlightToday)))
+ {
+ bitCalendar.HighlightToday = HighlightToday.Value;
+ }
+
+ if (HourStep.HasValue && bitCalendar.HasNotBeenSet(nameof(HourStep)))
+ {
+ bitCalendar.HourStep = HourStep.Value;
+ }
+
+ if (InvalidErrorMessage.HasValue() && bitCalendar.HasNotBeenSet(nameof(InvalidErrorMessage)))
+ {
+ bitCalendar.InvalidErrorMessage = InvalidErrorMessage;
+ }
+
+ if (IsDateDisabled is not null && bitCalendar.HasNotBeenSet(nameof(IsDateDisabled)))
+ {
+ bitCalendar.IsDateDisabled = IsDateDisabled;
+ }
+
+ if (MaxDate.HasValue && bitCalendar.HasNotBeenSet(nameof(MaxDate)))
+ {
+ bitCalendar.MaxDate = MaxDate.Value;
+
+ rebuildView = true;
+ }
+
+ if (MinDate.HasValue && bitCalendar.HasNotBeenSet(nameof(MinDate)))
+ {
+ bitCalendar.MinDate = MinDate.Value;
+
+ rebuildView = true;
+ }
+
+ if (MinuteStep.HasValue && bitCalendar.HasNotBeenSet(nameof(MinuteStep)))
+ {
+ bitCalendar.MinuteStep = MinuteStep.Value;
+ }
+
+ if (MonthCount.HasValue && bitCalendar.HasNotBeenSet(nameof(MonthCount)))
+ {
+ bitCalendar.MonthCount = MonthCount.Value;
+
+ bitCalendar.ClassBuilder.Reset();
+
+ rebuildView = true;
+ }
+
+ if (MonthCellTemplate is not null && bitCalendar.HasNotBeenSet(nameof(MonthCellTemplate)))
+ {
+ bitCalendar.MonthCellTemplate = MonthCellTemplate;
+ }
+
+ if (MonthPickerToggleTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(MonthPickerToggleTitle)))
+ {
+ bitCalendar.MonthPickerToggleTitle = MonthPickerToggleTitle!;
+ }
+
+ if (NextMonthNavIcon is not null && bitCalendar.HasNotBeenSet(nameof(NextMonthNavIcon)))
+ {
+ bitCalendar.NextMonthNavIcon = NextMonthNavIcon;
+ }
+
+ if (NextMonthNavIconName.HasValue() && bitCalendar.HasNotBeenSet(nameof(NextMonthNavIconName)))
+ {
+ bitCalendar.NextMonthNavIconName = NextMonthNavIconName;
+ }
+
+ if (NextYearNavIcon is not null && bitCalendar.HasNotBeenSet(nameof(NextYearNavIcon)))
+ {
+ bitCalendar.NextYearNavIcon = NextYearNavIcon;
+ }
+
+ if (NextYearNavIconName.HasValue() && bitCalendar.HasNotBeenSet(nameof(NextYearNavIconName)))
+ {
+ bitCalendar.NextYearNavIconName = NextYearNavIconName;
+ }
+
+ if (NextYearRangeNavIcon is not null && bitCalendar.HasNotBeenSet(nameof(NextYearRangeNavIcon)))
+ {
+ bitCalendar.NextYearRangeNavIcon = NextYearRangeNavIcon;
+ }
+
+ if (NextYearRangeNavIconName.HasValue() && bitCalendar.HasNotBeenSet(nameof(NextYearRangeNavIconName)))
+ {
+ bitCalendar.NextYearRangeNavIconName = NextYearRangeNavIconName;
+ }
+
+ if (PagedNavigation.HasValue && bitCalendar.HasNotBeenSet(nameof(PagedNavigation)))
+ {
+ bitCalendar.PagedNavigation = PagedNavigation.Value;
+ }
+
+ if (PrevMonthNavIcon is not null && bitCalendar.HasNotBeenSet(nameof(PrevMonthNavIcon)))
+ {
+ bitCalendar.PrevMonthNavIcon = PrevMonthNavIcon;
+ }
+
+ if (PrevMonthNavIconName.HasValue() && bitCalendar.HasNotBeenSet(nameof(PrevMonthNavIconName)))
+ {
+ bitCalendar.PrevMonthNavIconName = PrevMonthNavIconName;
+ }
+
+ if (PrevYearNavIcon is not null && bitCalendar.HasNotBeenSet(nameof(PrevYearNavIcon)))
+ {
+ bitCalendar.PrevYearNavIcon = PrevYearNavIcon;
+ }
+
+ if (PrevYearNavIconName.HasValue() && bitCalendar.HasNotBeenSet(nameof(PrevYearNavIconName)))
+ {
+ bitCalendar.PrevYearNavIconName = PrevYearNavIconName;
+ }
+
+ if (PrevYearRangeNavIcon is not null && bitCalendar.HasNotBeenSet(nameof(PrevYearRangeNavIcon)))
+ {
+ bitCalendar.PrevYearRangeNavIcon = PrevYearRangeNavIcon;
+ }
+
+ if (PrevYearRangeNavIconName.HasValue() && bitCalendar.HasNotBeenSet(nameof(PrevYearRangeNavIconName)))
+ {
+ bitCalendar.PrevYearRangeNavIconName = PrevYearRangeNavIconName;
+ }
+
+ if (SelectedDateAriaAtomic.HasValue() && bitCalendar.HasNotBeenSet(nameof(SelectedDateAriaAtomic)))
+ {
+ bitCalendar.SelectedDateAriaAtomic = SelectedDateAriaAtomic!;
+ }
+
+ if (ShowNowButton.HasValue && bitCalendar.HasNotBeenSet(nameof(ShowNowButton)))
+ {
+ bitCalendar.ShowNowButton = ShowNowButton.Value;
+ }
+
+ if (ShowGoToToday.HasValue && bitCalendar.HasNotBeenSet(nameof(ShowGoToToday)))
+ {
+ bitCalendar.ShowGoToToday = ShowGoToToday.Value;
+ }
+
+ if (ShowEventDetails.HasValue && bitCalendar.HasNotBeenSet(nameof(ShowEventDetails)))
+ {
+ bitCalendar.ShowEventDetails = ShowEventDetails.Value;
+ }
+
+ if (ShowMonthPicker.HasValue && bitCalendar.HasNotBeenSet(nameof(ShowMonthPicker)))
+ {
+ bitCalendar.ShowMonthPicker = ShowMonthPicker.Value;
+
+ rebuildView = true;
+ }
+
+ if (ShowMonthPickerAsOverlay.HasValue && bitCalendar.HasNotBeenSet(nameof(ShowMonthPickerAsOverlay)))
+ {
+ bitCalendar.ShowMonthPickerAsOverlay = ShowMonthPickerAsOverlay.Value;
+
+ rebuildView = true;
+ }
+
+ if (ShowOutsideDays.HasValue && bitCalendar.HasNotBeenSet(nameof(ShowOutsideDays)))
+ {
+ bitCalendar.ShowOutsideDays = ShowOutsideDays.Value;
+ }
+
+ if (ShowTimePicker.HasValue && bitCalendar.HasNotBeenSet(nameof(ShowTimePicker)))
+ {
+ bitCalendar.ShowTimePicker = ShowTimePicker.Value;
+
+ rebuildView = true;
+ }
+
+ if (ShowTimePickerAsOverlay.HasValue && bitCalendar.HasNotBeenSet(nameof(ShowTimePickerAsOverlay)))
+ {
+ bitCalendar.ShowTimePickerAsOverlay = ShowTimePickerAsOverlay.Value;
+
+ rebuildView = true;
+ }
+
+ if (ShowTimePickerIcon is not null && bitCalendar.HasNotBeenSet(nameof(ShowTimePickerIcon)))
+ {
+ bitCalendar.ShowTimePickerIcon = ShowTimePickerIcon;
+ }
+
+ if (ShowTimePickerIconName.HasValue() && bitCalendar.HasNotBeenSet(nameof(ShowTimePickerIconName)))
+ {
+ bitCalendar.ShowTimePickerIconName = ShowTimePickerIconName;
+ }
+
+ if (ShowTimePickerTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(ShowTimePickerTitle)))
+ {
+ bitCalendar.ShowTimePickerTitle = ShowTimePickerTitle!;
+ }
+
+ if (ShowWeekNumbers.HasValue && bitCalendar.HasNotBeenSet(nameof(ShowWeekNumbers)))
+ {
+ bitCalendar.ShowWeekNumbers = ShowWeekNumbers.Value;
+ }
+
+ if (Size.HasValue && bitCalendar.HasNotBeenSet(nameof(Size)))
+ {
+ bitCalendar.Size = Size.Value;
+
+ bitCalendar.ClassBuilder.Reset();
+ }
+
+ if (StartingValue.HasValue && bitCalendar.HasNotBeenSet(nameof(StartingValue)))
+ {
+ bitCalendar.StartingValue = StartingValue.Value;
+
+ rebuildView = true;
+ }
+
+ if (Styles is not null && bitCalendar.HasNotBeenSet(nameof(Styles)))
+ {
+ bitCalendar.Styles = Styles;
+
+ bitCalendar.StyleBuilder.Reset();
+ }
+
+ if (TimeFormat.HasValue && bitCalendar.HasNotBeenSet(nameof(TimeFormat)))
+ {
+ bitCalendar.TimeFormat = TimeFormat.Value;
+ }
+
+ if (TimePickerDecreaseHourIcon is not null && bitCalendar.HasNotBeenSet(nameof(TimePickerDecreaseHourIcon)))
+ {
+ bitCalendar.TimePickerDecreaseHourIcon = TimePickerDecreaseHourIcon;
+ }
+
+ if (TimePickerDecreaseHourIconName.HasValue() && bitCalendar.HasNotBeenSet(nameof(TimePickerDecreaseHourIconName)))
+ {
+ bitCalendar.TimePickerDecreaseHourIconName = TimePickerDecreaseHourIconName;
+ }
+
+ if (TimePickerDecreaseHourTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(TimePickerDecreaseHourTitle)))
+ {
+ bitCalendar.TimePickerDecreaseHourTitle = TimePickerDecreaseHourTitle!;
+ }
+
+ if (TimePickerDecreaseMinuteTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(TimePickerDecreaseMinuteTitle)))
+ {
+ bitCalendar.TimePickerDecreaseMinuteTitle = TimePickerDecreaseMinuteTitle!;
+ }
+
+ if (TimePickerDecreaseMinuteIcon is not null && bitCalendar.HasNotBeenSet(nameof(TimePickerDecreaseMinuteIcon)))
+ {
+ bitCalendar.TimePickerDecreaseMinuteIcon = TimePickerDecreaseMinuteIcon;
+ }
+
+ if (TimePickerDecreaseMinuteIconName.HasValue() && bitCalendar.HasNotBeenSet(nameof(TimePickerDecreaseMinuteIconName)))
+ {
+ bitCalendar.TimePickerDecreaseMinuteIconName = TimePickerDecreaseMinuteIconName;
+ }
+
+ if (TimePickerHourTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(TimePickerHourTitle)))
+ {
+ bitCalendar.TimePickerHourTitle = TimePickerHourTitle!;
+ }
+
+ if (TimePickerMinuteTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(TimePickerMinuteTitle)))
+ {
+ bitCalendar.TimePickerMinuteTitle = TimePickerMinuteTitle!;
+ }
+
+ if (TimePickerIncreaseHourIcon is not null && bitCalendar.HasNotBeenSet(nameof(TimePickerIncreaseHourIcon)))
+ {
+ bitCalendar.TimePickerIncreaseHourIcon = TimePickerIncreaseHourIcon;
+ }
+
+ if (TimePickerIncreaseHourIconName.HasValue() && bitCalendar.HasNotBeenSet(nameof(TimePickerIncreaseHourIconName)))
+ {
+ bitCalendar.TimePickerIncreaseHourIconName = TimePickerIncreaseHourIconName;
+ }
+
+ if (TimePickerIncreaseMinuteIcon is not null && bitCalendar.HasNotBeenSet(nameof(TimePickerIncreaseMinuteIcon)))
+ {
+ bitCalendar.TimePickerIncreaseMinuteIcon = TimePickerIncreaseMinuteIcon;
+ }
+
+ if (TimePickerIncreaseMinuteIconName.HasValue() && bitCalendar.HasNotBeenSet(nameof(TimePickerIncreaseMinuteIconName)))
+ {
+ bitCalendar.TimePickerIncreaseMinuteIconName = TimePickerIncreaseMinuteIconName;
+ }
+
+ if (TimePickerIncreaseHourTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(TimePickerIncreaseHourTitle)))
+ {
+ bitCalendar.TimePickerIncreaseHourTitle = TimePickerIncreaseHourTitle!;
+ }
+
+ if (TimePickerIncreaseMinuteTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(TimePickerIncreaseMinuteTitle)))
+ {
+ bitCalendar.TimePickerIncreaseMinuteTitle = TimePickerIncreaseMinuteTitle!;
+ }
+
+ if (TimeZone is not null && bitCalendar.HasNotBeenSet(nameof(TimeZone)))
+ {
+ bitCalendar.TimeZone = TimeZone;
+
+ rebuildView = true;
+ }
+
+ if (Today.HasValue && bitCalendar.HasNotBeenSet(nameof(Today)))
+ {
+ bitCalendar.Today = Today.Value;
+ }
+
+ if (WeekNumberRule.HasValue && bitCalendar.HasNotBeenSet(nameof(WeekNumberRule)))
+ {
+ bitCalendar.WeekNumberRule = WeekNumberRule.Value;
+ }
+
+ if (WeekNumbersHeaderTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(WeekNumbersHeaderTitle)))
+ {
+ bitCalendar.WeekNumbersHeaderTitle = WeekNumbersHeaderTitle!;
+ }
+
+ if (WeekNumberTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(WeekNumberTitle)))
+ {
+ bitCalendar.WeekNumberTitle = WeekNumberTitle!;
+ }
+
+ if (YearCellTemplate is not null && bitCalendar.HasNotBeenSet(nameof(YearCellTemplate)))
+ {
+ bitCalendar.YearCellTemplate = YearCellTemplate;
+ }
+
+ if (YearPickerToggleTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(YearPickerToggleTitle)))
+ {
+ bitCalendar.YearPickerToggleTitle = YearPickerToggleTitle!;
+ }
+
+ if (YearRangePickerToggleTitle.HasValue() && bitCalendar.HasNotBeenSet(nameof(YearRangePickerToggleTitle)))
+ {
+ bitCalendar.YearRangePickerToggleTitle = YearRangePickerToggleTitle!;
+ }
+
+ if (rebuildView)
+ {
+ bitCalendar.OnSetParameters();
+ }
+ }
+}
diff --git a/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/CalendarsJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/CalendarsJsRuntimeExtensions.cs
index a042f7c9388..2bea9b128fe 100644
--- a/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/CalendarsJsRuntimeExtensions.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/CalendarsJsRuntimeExtensions.cs
@@ -12,8 +12,8 @@ internal static ValueTask BitCalendarsDispose(this IJSRuntime jsRuntime, string
return jsRuntime.InvokeVoid("BitBlazorUI.Calendars.dispose", id);
}
- internal static ValueTask BitCalendarsFocusCell(this IJSRuntime jsRuntime, string cellId)
+ internal static ValueTask BitCalendarsFocusCell(this IJSRuntime jsRuntime, string cellId, bool preventScroll = false)
{
- return jsRuntime.InvokeVoid("BitBlazorUI.Calendars.focusCell", cellId);
+ return jsRuntime.InvokeVoid("BitBlazorUI.Calendars.focusCell", cellId, preventScroll);
}
}
diff --git a/src/BlazorUI/Bit.BlazorUI/Scripts/Calendars.ts b/src/BlazorUI/Bit.BlazorUI/Scripts/Calendars.ts
index e022a35ab59..aaaa8e00064 100644
--- a/src/BlazorUI/Bit.BlazorUI/Scripts/Calendars.ts
+++ b/src/BlazorUI/Bit.BlazorUI/Scripts/Calendars.ts
@@ -49,8 +49,8 @@ namespace BitBlazorUI {
Calendars._handlers.delete(id);
}
- public static focusCell(cellId: string) {
- document.getElementById(cellId)?.focus();
+ public static focusCell(cellId: string, preventScroll?: boolean) {
+ document.getElementById(cellId)?.focus({ preventScroll: !!preventScroll });
}
// Which keys the browser has to be stopped from scrolling the page with depends on what holds the
diff --git a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Services/Mcp/BlazorUIMarkdown.cs b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Services/Mcp/BlazorUIMarkdown.cs
index 307d1dd47b2..07dc4b446bb 100644
--- a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Services/Mcp/BlazorUIMarkdown.cs
+++ b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Services/Mcp/BlazorUIMarkdown.cs
@@ -1,5 +1,6 @@
using System.Text;
using System.Reflection;
+using Microsoft.AspNetCore.Components;
using Bit.BlazorUI.Demo.Client.Core.Models;
namespace Bit.BlazorUI.Demo.Server.Services.Mcp;
@@ -443,12 +444,38 @@ private static void AppendCascadingParams(StringBuilder builder, BlazorUICompone
var name = component.CascadingParams.Name;
- var count = component.CascadingParams
+ var carried = component.CascadingParams
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
- .Count(p => p.CanWrite);
+ .Where(p => p.CanWrite)
+ .Select(p => p.Name)
+ .ToHashSet(StringComparer.Ordinal);
+
+ var baseName = component.CascadingParams.BaseType is { } baseType && baseType != typeof(object)
+ ? baseType.Name
+ : null;
+
+ // Read off the compiled component rather than off its table: the parameters a params object
+ // leaves out are the ones a reader would otherwise assume are there, and the type is what
+ // has the last word on which exist. Inherited ones are included - they are exactly the set
+ // in question, since a params object derives from BitComponentBaseParams and so carries the
+ // BitComponentBase half of them and nothing of what an input base adds.
+ var missing = component.ComponentType is null ? [] : component.ComponentType
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Where(p => p.IsDefined(typeof(ParameterAttribute)) && carried.Contains(p.Name) is false)
+ .Select(p => p.Name)
+ .Distinct(StringComparer.Ordinal)
+ .Order(StringComparer.Ordinal)
+ .ToArray();
+
+ var inherits = baseName is null ? string.Empty : $" - its own and the `{baseName}` ones alike";
builder.AppendLine("## Cascading parameters").AppendLine();
- builder.AppendLine($"`{name}` carries {count} of this component's parameters again as nullables - its own and the inherited ones alike - and a `BitParams` provides one to a whole subtree: ``. Every `{component.Name}` below it takes each parameter it did not write for itself from there - a default rather than an override, parameter by parameter, so one instance steps out of the group it is in by writing that one parameter and nothing else. `GetBitBlazorUIType(typeName: \"{name}\")` lists its members; `GetBitBlazorUIComponent(name: \"BitParams\")` is the component that provides them.").AppendLine();
+ builder.AppendLine($"`{name}` carries {carried.Count} of this component's parameters again as nullables{inherits} - and a `BitParams` provides one to a whole subtree: ``. Every `{component.Name}` below it takes each parameter it did not write for itself from there - a default rather than an override, parameter by parameter, so one instance steps out of the group it is in by writing that one parameter and nothing else. `GetBitBlazorUIType(typeName: \"{name}\")` lists its members; `GetBitBlazorUIComponent(name: \"BitParams\")` is the component that provides them.").AppendLine();
+
+ if (missing.Length > 0)
+ {
+ builder.AppendLine($"Not on it, so they are written on the instance itself: {string.Join(", ", missing.Select(n => $"`{n}`"))}.").AppendLine();
+ }
}
///
diff --git a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Services/Mcp/BlazorUIReflection.cs b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Services/Mcp/BlazorUIReflection.cs
index 7783fb58142..7e47c416c31 100644
--- a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Services/Mcp/BlazorUIReflection.cs
+++ b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Services/Mcp/BlazorUIReflection.cs
@@ -40,11 +40,41 @@ public static void AppendMembers(StringBuilder builder, Type type, string? path
AppendConstants(builder, type);
AppendNested(builder, type, path);
AppendProperties(builder, type);
+ AppendInherited(builder, type);
AppendMethods(builder, type);
AppendEvents(builder, type);
AppendExtensions(builder, type);
}
+ ///
+ /// The members a type takes from a base class of this library, named rather than tabulated
+ /// again under it.
+ ///
+ /// The tables above are read with DeclaredOnly, which is right for the hundred types
+ /// whose base is object and wrong for the ones that have a real base: a
+ /// BitCalendarParams answered with its own hundred properties alone says its
+ /// Class, Style and IsEnabled do not exist, while the component's own
+ /// answer counts them. So each library base is named with the members it brings and the call
+ /// that documents them - the set once, not once per type that closes it.
+ ///
+ ///
+ private static void AppendInherited(StringBuilder builder, Type type)
+ {
+ for (var current = type.BaseType; current is not null && current != typeof(object); current = current.BaseType)
+ {
+ if (BlazorUIAssemblies.All.Contains(current.Assembly) is false) continue;
+
+ var names = current.GetProperties(Public)
+ .Where(p => p.GetIndexParameters().Length == 0)
+ .Select(p => p.Name)
+ .ToArray();
+
+ if (names.Length == 0) continue;
+
+ builder.AppendLine($"Inherited from `{current.Name}`: {string.Join(", ", names.Select(n => $"`{n}`"))}. `GetBitBlazorUIType(typeName: \"{current.Name}\")` documents them.").AppendLine();
+ }
+ }
+
///
/// What another package adds to this type. Answered here, under the type the members are
/// written on, because that is where they are read: BitThemePresets.MaterialDark is
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Map/BitMapDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Map/BitMapDemo.razor
index ca1ed5a89d3..0c03d49ee3e 100644
--- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Map/BitMapDemo.razor
+++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Map/BitMapDemo.razor
@@ -366,8 +366,8 @@
Add a little ✨ to your text — or a → arrow, a ½ fraction, or π.
";
+ private string? emojiHtml = "
Add a little ✨ to your text - or a → arrow, a ½ fraction, or π.
";
private string? countHtml = "
Counting characters and words.
";
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Calendar/BitCalendarDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Calendar/BitCalendarDemo.razor
index 940bd8fe884..7d44965ee44 100644
--- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Calendar/BitCalendarDemo.razor
+++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Calendar/BitCalendarDemo.razor
@@ -5,188 +5,148 @@
Description="BitCalendar is a fully-featured inline calendar for browsing and selecting a date and time, with day events, custom cultures, time zones, and complete keyboard accessibility." />
-
+
+
- The essential BitCalendar configurations: the default calendar, the disabled state, week numbers,
- highlighting the current and selected months, starting the view on a specific date and time using
- StartingValue,
- overriding the current date using Today, and hiding the GoToToday button using ShowGoToToday.
+ The calendar renders a day grid with a month and year picker beside it. IsEnabled false dims it
+ and stops every interaction; ReadOnly keeps it browsable but stops the value from changing.
+ ShowGoToToday hides the button that returns the view to the current month.
-
Basic Calendar:
+
Basic:
-
Disabled:
+
IsEnabled = false:
-
Week numbers:
-
-
-
Highlight months:
-
-
-
StartingValue: December 2020, Time: 20:45:
-
-
-
Custom Today (March 2021):
-
+
ReadOnly:
+
-
Without the GoToToday button:
+
ShowGoToToday = false:
-
+
- Limit the selectable dates using MinDate and MaxDate.
- Days outside the range are disabled, and the month, year, and year-range navigation stops at the boundaries.
- DisablePast and DisableFuture bound the range by today exactly as a MinDate or a
- MaxDate of today would, without having to keep one up to date; where both apply, the narrower
- of the two wins.
+ Bind the selected date with @@bind-Value, or listen to OnSelectDate to react to each
+ selection. AllowDeselect turns the selected day into a toggle, so clicking it again empties
+ the value without moving the view off its month.
Customize the increment/decrement step of the time picker's hour and minute buttons using HourStep
- and MinuteStep.
+
+
+ Without a value the calendar opens on today. StartingValue opens it somewhere else (and seeds
+ the time picker further down this page), and Today overrides which day counts as today, for a
+ custom time provider or a test. OnMonthChange reports the first day of the newly displayed
+ month after every navigation, which is where the events of that month are usually loaded.
- By default, BitCalendar uses the current culture of the app, but any custom CultureInfo can be
- provided
- to fully localize the calendar: month and day names, the first day of the week, date formats, and even
- non-Gregorian calendars such as the Persian calendar.
-
- You also can use our
-
- CultureInfoHelper
- class or check its code to see how to create a custom culture.
+ MinDate and MaxDate bound the selectable range: the days outside it are disabled and the
+ month, year and year-range navigation stops at the edges. DisablePast and DisableFuture
+ do the same against today without a bound to keep up to date; where both apply the narrower wins, and
+ today stays selectable under either.
-
+
-
fa-IR culture with Farsi names:
-
-
+
MinDate / MaxDate = ±5 days:
+
-
fa-IR culture with Fingilish names:
-
-
+
MinDate / MaxDate = -5 years / +1 year:
+
+
+
DisablePast:
+
+
+
DisableFuture:
+
-
+
- Specify the TimeZone used to interpret and display the selected date and time.
-
- Keep in mind that the available time zone data differs between runtimes; it depends on the OS
- the code is running on and on the project settings (like InvariantTimezone in the project file,
- more info).
+ Beyond the bounds, single days can be ruled out with DisabledDates, whole weekdays with
+ DisabledDaysOfWeek, and anything else with the IsDateDisabled predicate. A disabled day
+ is not selectable and the arrow keys skip over it. HighlightedDates marks days without
+ disabling them, and GetDayClass hands any day a class of your own.
-
Default (local TimeZone):
-
+
DisabledDaysOfWeek (weekends):
+
+
+
DisabledDates & IsDateDisabled (three dates, plus every odd day):
+
+
+
HighlightedDates & GetDayClass (Sundays in red):
+
-
-
Selected date: @timeZoneDate1?.ToString()
-
- @{
- TimeZoneInfo? timeZoneInfo = null;
- var allTimeZones = TimeZoneInfo.GetSystemTimeZones();
- if (allTimeZones.Count > 0)
- {
- timeZoneInfo = allTimeZones[0];
- }
- }
-
- @if (timeZoneInfo is not null)
- {
-
-
"@timeZoneInfo.Id" TimeZone:
-
-
-
-
-
Selected date: @timeZoneDate2?.ToString()
- }
-
+
- Two-way bind the selected date using @@bind-Value to keep your model and the calendar in sync,
- or listen to the OnSelectDate event to react to every selection.
+ FirstDayOfWeek overrides the day the week starts on, WeekNumberRule picks how
+ ShowWeekNumbers counts the weeks (Monday plus FirstFourDayWeek is ISO 8601),
+ ShowOutsideDays hides the days of the adjacent months, and FixedWeeks always renders six
+ weeks so the height never changes while browsing.
- The ReadOnly parameter displays the calendar in a read-only manner:
- the value stays visible and the calendar can still be browsed, but the selected date and time cannot be
- changed.
-
-
-
-
+
ShowOutsideDays = false:
+
-
+
FixedWeeks:
+
-
+
- Control the month and year pickers: toggle their visibility using ShowMonthPicker,
- or render them as an overlay on top of the day picker using ShowMonthPickerAsOverlay.
+ ShowMonthPicker toggles the month and year grids, and ShowMonthPickerAsOverlay moves
+ them on top of the day grid, reached from the month title. HighlightSelectedMonth marks the
+ month that is displayed and HighlightCurrentMonth the current one - worth turning on, since
+ otherwise the month grid shows no sign of where you are.
@@ -201,80 +161,88 @@
+
+
HighlightSelectedMonth & HighlightCurrentMonth:
+
-
+
- Enable the built-in time picker using ShowTimePicker to let users select the time of day along with
- the date.
- Switch between the 24-hour and 12-hour clocks using TimeFormat,
- render the time picker as an overlay using ShowTimePickerAsOverlay,
- and hide the now button using ShowNowButton.
+ ShowTimePicker adds an hour and minute picker beside the day grid, or on top of it with
+ ShowTimePickerAsOverlay. TimeFormat switches between the 24-hour and 12-hour clocks,
+ ShowNowButton hides the button that jumps to the current time, and HourStep /
+ MinuteStep lay a grid over the day and the hour that everything the picker produces lands on -
+ the spin buttons, the arrow and page keys, and what is typed. A MinDate or MaxDate
+ carrying a time of day bounds the hours of the very day it falls on, so the picker cannot produce
+ an instant outside of them.
-
+
-
Basic time picker (24-hour):
-
-
Selected DateTime: @selectedDateTime.ToString()
+
ShowTimePicker:
+
+
Selected: @selectedDateTime.ToString()
-
TimeFormat (12-hour with AM/PM):
-
+
TimeFormat = TwelveHours, without the now button:
+
-
ShowTimePickerAsOverlay (toggle it using the clock button):
+
ShowTimePickerAsOverlay (use the clock button):
-
Without the now button:
-
+
HourStep = 3, MinuteStep = 15:
+
+
+
MinDate / MaxDate with a time of day (from 09:30 today, until 17:00 in two days):
+
+
Selected: @boundedDateTime.ToString()
-
+
- Attach events to calendar days using the Events parameter.
- Days with events show a colored indicator dot; hovering the day reveals a quick tooltip,
- and clicking it opens a modal listing the title, time, and description of each event of that day.
+ Events marks a day with one dot per event (up to three) and a tooltip, and clicking such a day
+ opens a modal dialog listing the title, time and description of each of them, all-day first. An event
+ can name its own Color; the rest take the calendar's. The dialog is dismissed with Escape or
+ its close button and hands the keyboard back to the day it came from. ShowEventDetails false
+ keeps the dots and the tooltip but leaves the day's events to the page, EventTemplate renders
+ an event's row in the dialog itself, and EventTimeFromText / EventTimeUntilText
+ localize the one-sided time rows.
+
Events (click a marked day):
+
+
Color per event:
+
+
+
ShowEventDetails = false:
+
+
Events of the selected day: @eventsOfSelectedDay
+
+
EventTemplate:
+
+
+
+ @context.Title
+
+ Open
+
+
+
+
+
Opened: @(openedEvent ?? "-")
-
-
- Use BitCalendar inside an EditForm and validate the selected date with data annotations, just like
- any other input component.
-
- Take full control of the rendering using DayCellTemplate, MonthCellTemplate, and
- YearCellTemplate
- to customize the content of the day, month, and year cells.
+ DayCellTemplate, MonthCellTemplate and YearCellTemplate replace the content of a
+ cell, while HeaderTemplate and FooterTemplate add markup of your own above and below
+ the pickers - a caption, a legend, or the actions a calendar is often given.
- Disable specific dates beyond MinDate and MaxDate: pass an explicit list using DisabledDates,
- disable whole days of the week using DisabledDaysOfWeek (e.g. weekends),
- or provide any custom rule using the IsDateDisabled function.
- Disabled dates are not selectable and are skipped by the keyboard navigation.
+ The calendar reads the month and day names, the first day of the week, the date format, the AM/PM
+ designators and the calendar system itself off its Culture, defaulting to the culture of the
+ app. A non-Gregorian calendar (Persian, Hebrew - including its thirteen-month leap years) needs
+ nothing else. See our
+ CultureInfoHelper for how such a culture is built.
-
DisabledDaysOfWeek (weekends are disabled):
-
-
-
-
DisabledDates (a few specific upcoming dates are disabled):
+
fa-IR with Farsi names:
-
+
-
IsDateDisabled (every odd day of the month is disabled):
+
fa-IR with Fingilish names:
-
+
-
+
- Mark important dates using HighlightedDates, or provide custom CSS classes for any day using the
- GetDayClass function.
+ TimeZone is the zone the days and the time picker are read and written in, defaulting to the
+ local one. The available zone data differs between runtimes - it depends on the OS and on project
+ settings such as InvariantTimezone
+ (more info).
+
Local (the default):
-
HighlightedDates:
-
-
-
-
GetDayClass (Sundays get a custom color):
-
-
+
+
+
Selected date: @timeZoneDate1?.ToString()
+
+ @{
+ TimeZoneInfo? timeZoneInfo = null;
+ var allTimeZones = TimeZoneInfo.GetSystemTimeZones();
+ if (allTimeZones.Count > 0)
+ {
+ timeZoneInfo = allTimeZones[0];
+ }
+ }
+
+ @if (timeZoneInfo is not null)
+ {
+
+
"@timeZoneInfo.Id":
+
+
+
+
+
Selected date: @timeZoneDate2?.ToString()
+ }
-
+
- Customize how weeks are rendered: override the first day of the week using FirstDayOfWeek,
- choose the week numbering rule using WeekNumberRule,
- hide the days of the adjacent months using ShowOutsideDays,
- and keep the calendar height fixed using FixedWeeks.
+ Inside an EditForm the calendar validates like any other input. A rejected value draws a rule
+ around the control and turns its focus ring red, and the day grid reports aria-invalid
+ along with aria-required for a Required field.
-
-
FirstDayOfWeek (Monday):
-
-
-
-
ISO 8601 week numbers (FirstFourDayWeek rule with Monday as the first day of the week):
+ Every grid - days, months and years - follows the WAI-ARIA grid pattern: one cell is in the tab
+ sequence and the arrow keys move the focus between the rest.
-
ShowOutsideDays="false" (hides the days of the previous and next months):
-
-
+
+
← / →: previous/next cell, mirrored in right-to-left
+
↑ / ↓: one row up/down - a week in the day grid, four cells in the others
+
Home / End: first/last cell of the week, of the year, of the range
+
PageUp / PageDown: previous/next month in the day grid, year in the month
+ grid, year range in the year grid
+
Shift+PageUp / Shift+PageDown: same day of the previous/next year
+
Enter / Space: activate the focused cell
+
Escape: leave the event details dialog, the year grid, or a picker laid over the
+ day grid
+
+ In the time picker the arrow keys move the hour and the minute by one step of the grid and
+ PageUp / PageDown do the same from anywhere in the field.
-
FixedWeeks (always renders six weeks to keep the height fixed):
-
-
+ Crossing a month, year or range boundary navigates the calendar and keeps the focus on the cell, and
+ disabled cells are skipped. Beyond that: AriaLabel names the whole calendar for a screen
+ reader, each day is announced with its full date and its events, today and the current month and
+ year carry aria-current="date", and every button - the arrows, the spin buttons, the
+ AM/PM pair - has a name and a state of its own, all of them overridable through the *Title
+ parameters.
+
+
+
+
-
+
- The OnMonthChange event fires whenever the displayed month changes (navigation buttons, month/year
- pickers, GoToToday, or keyboard navigation),
- receiving the first day of the newly displayed month. It is ideal for lazy-loading the events of the visible
- month.
+ MonthCount renders up to three consecutive months side by side, which browses a whole season
+ at once - the arrow keys, the selection and the events carry on across them. Such a strip always
+ draws six week rows, never draws the days of the adjacent months (each of them is a day of the pane
+ beside it), and wraps onto the next line when it is given less width than it asks for.
+ PagedNavigation makes the arrows move a whole page of months instead of one.
MonthCount = 3, paged navigation, month picker as an overlay:
+
-
+
- BitCalendar fully supports keyboard navigation following the WAI-ARIA grid pattern.
- Focus a day using the Tab key, then:
-
-
-
← / →: previous/next day
-
↑ / ↓: same day of the previous/next week
-
Home / End: first/last day of the week
-
PageUp / PageDown: same day of the previous/next month
-
Shift+PageUp / Shift+PageDown: same day of the previous/next year
-
Enter / Space: select the focused day
-
-
- Crossing a month boundary automatically navigates the calendar and keeps the focus on the day, and disabled
- days are skipped.
+ BitParams carries a BitCalendarParams down to every calendar under it, so a page that shows
+ several of them sets the week rules, the bounds or the culture once instead of at every calendar in it.
+ What it carries is a default and not an override: a calendar that sets a parameter for itself keeps its own
+ value, and only what it left unset is filled in from the cascade - which is what lets one calendar step out
+ of the section it is in without the section having to know about it. Every parameter of the component is on
+ the params object, and each of them is filled in on its own, so a calendar can take the week layout from
+ the cascade and the month picker from its own markup.
-
+
+
+
Takes the week layout, the bounds and the highlighting from the cascade:
+
+
+
So does this one, without repeating any of it:
+
+
+
Its own bounds and no month picker, the cascaded week layout:
+
+
+
+
Outside the cascade, and back to the defaults:
-
+
- Offering a range of specialized color variants with Primary being the default, providing visual cues for
- specific actions or states within your application.
+ Color paints today, the highlighted current month, the event indicators and the AM/PM button
+ that is in force.
@@ -487,16 +532,12 @@
-
+
- Use icons from external libraries like FontAwesome and Bootstrap Icons with the GoToTodayIcon,
- NowButtonIcon, PrevMonthNavIcon, NextMonthNavIcon, and other icon parameters.
-
- For built-in icons use the IconName variant of each parameter (e.g. GoToTodayIconName).
-
- The Icon parameters accept a
- BitIconInfo value, created via helpers like
- BitIconInfo.Fa(...), BitIconInfo.Bi(...), or BitIconInfo.Css(...).
+ Every icon of the calendar has an *Icon parameter taking a
+ BitIconInfo from an external library
+ (BitIconInfo.Fa(...), BitIconInfo.Bi(...), BitIconInfo.Css(...)), and an
+ *IconName one taking a built-in Fluent icon. The Icon form wins when both are set.
+ Size scales the cells and their text together, so the whole calendar keeps its proportions.
+ The smallest cell still clears the 24 px minimum pointer target.
+
+
+
+
Small:
+
+
+
Medium (the default):
+
+
+
Large:
+
+
+
+
+
- Customize the appearance of BitCalendar: use Style and Class on the root element,
- or target every individual part of the calendar using the Styles and Classes parameters.
+ Style and Class land on the root, while Styles and Classes reach every
+ part of the calendar on its own - see
+ BitCalendarClassStyles. Prefer Classes when
+ your CSS owns states such as hover and focus, which inline styles cannot express.
-
Component's Style & Class:
+
Component's Style & Class:
@@ -538,7 +599,7 @@
-
Styles & Classes:
+
Styles & Classes:
+
+
+ The calendar also reads a set of CSS variables off
+ its root for what no parameter covers. They inherit, so a value on :root or any ancestor
+ re-skins every calendar below it, and one on the Style of an instance re-skins that one alone.
+
+
+
CSS variables:
+
+
+
A filled selection and a square today:
+
+
+
Roomier cells, rounded days, tinted week numbers:
+
+
+
Set once on an ancestor, inherited by every calendar inside it:
+
+
+
+
+
- Render BitCalendar in right-to-left (RTL) using the Dir parameter.
- Providing an RTL culture (like fa-IR) enables it automatically, and the keyboard navigation adapts to the
- direction as well.
+ Dir Rtl mirrors the calendar, and the left and right arrow keys swap with it. A culture that
+ reads right to left turns it on by itself.
-
-
-
+
+
+
-
\ No newline at end of file
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Calendar/BitCalendarDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Calendar/BitCalendarDemo.razor.cs
index 6d503ae74f0..3f742afdaee 100644
--- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Calendar/BitCalendarDemo.razor.cs
+++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Calendar/BitCalendarDemo.razor.cs
@@ -4,6 +4,13 @@ public partial class BitCalendarDemo
{
private readonly List componentParameters =
[
+ new()
+ {
+ Name = "AllowDeselect",
+ Type = "bool",
+ DefaultValue = "false",
+ Description = "Whether selecting the already selected day deselects it, clearing the value. The calendar stays on the month the day was deselected in.",
+ },
new()
{
Name = "Classes",
@@ -62,14 +69,14 @@ public partial class BitCalendarDemo
Name = "DisableFuture",
Type = "bool",
DefaultValue = "false",
- Description = "Disables every day after today, exactly as a MaxDate of today would. When both are set, the earlier of the two bounds wins.",
+ Description = "Disables every day after today, exactly as a MaxDate of now would. When both are set, the earlier of the two bounds wins. Today stays selectable, and a time picker on screen is held to the hours of it up to this very minute.",
},
new()
{
Name = "DisablePast",
Type = "bool",
DefaultValue = "false",
- Description = "Disables every day before today, exactly as a MinDate of today would. When both are set, the later of the two bounds wins.",
+ Description = "Disables every day before today, exactly as a MinDate of now would. When both are set, the later of the two bounds wins. Today stays selectable, and a time picker on screen is held to the hours of it from this very minute on.",
},
new()
{
@@ -90,7 +97,23 @@ public partial class BitCalendarDemo
Name = "Events",
Type = "IEnumerable?",
DefaultValue = "null",
- Description = "The list of events to display on calendar days. Days with events show an indicator dot that reveals a tooltip on hover and a detail modal on click.",
+ Description = "The list of events to display on calendar days. Days with events show an indicator dot that reveals a tooltip on hover and a details dialog on click. The events of a day are ordered the way an agenda of it is: the all-day ones first, then the rest by the time they start at.",
+ Href = "#calendar-event",
+ LinkType = LinkType.Link
+ },
+ new()
+ {
+ Name = "EventDetailsCloseButtonTitle",
+ Type = "string",
+ DefaultValue = "Close",
+ Description = "The title (tooltip) and the accessible name of the close button of the event details dialog."
+ },
+ new()
+ {
+ Name = "EventTemplate",
+ Type = "RenderFragment?",
+ DefaultValue = "null",
+ Description = "Used to customize how an event is rendered in the details dialog, in place of its title, its time and its body - the place for a link to whatever the event stands for in the application.",
Href = "#calendar-event",
LinkType = LinkType.Link
},
@@ -120,7 +143,14 @@ public partial class BitCalendarDemo
Name = "FixedWeeks",
Type = "bool",
DefaultValue = "false",
- Description = "Whether the day picker should always render six weeks, filling the extra rows with the days of the adjacent months, to keep the calendar height fixed while navigating between months."
+ Description = "Whether the day picker should always render six weeks, filling the extra rows with the days of the adjacent months, to keep the calendar height fixed while navigating between months. It is always on when MonthCount renders more than one month, so the months keep an even height next to each other."
+ },
+ new()
+ {
+ Name = "FooterTemplate",
+ Type = "RenderFragment?",
+ DefaultValue = "null",
+ Description = "Rendered under the pickers, inside the root of the calendar: the place for the actions a calendar is often given of its own, such as a Clear button or a summary of the selection."
},
new()
{
@@ -222,6 +252,13 @@ public partial class BitCalendarDemo
Description = "The title of the GoToToday button (tooltip)."
},
new()
+ {
+ Name = "HeaderTemplate",
+ Type = "RenderFragment?",
+ DefaultValue = "null",
+ Description = "Rendered above the pickers, inside the root of the calendar: the place for a caption or a legend of its own, before the day, month and year grids."
+ },
+ new()
{
Name = "HideTimePickerIcon",
Type = "BitIconInfo?",
@@ -268,11 +305,18 @@ public partial class BitCalendarDemo
Description = "Whether the month picker should highlight the selected month."
},
new()
+ {
+ Name = "HighlightToday",
+ Type = "bool",
+ DefaultValue = "true",
+ Description = "Whether the day picker should highlight today's day. It only affects the visual style of the day cell; the accessibility attributes still report the day as the current date."
+ },
+ new()
{
Name = "HourStep",
Type = "int",
DefaultValue = "1",
- Description = "The step, in hours, the spin buttons move the hour by. A step greater than 1 lays a grid over the day, starting at midnight, that every hour the buttons produce sits on. A time entered as text is not held to it.",
+ Description = "The step, in hours, the spin buttons move the hour by. A step greater than 1 lays a grid over the day, starting at midnight, that every hour the picker produces sits on. The buttons, the keys and what is typed into the hour are all held to it.",
},
new()
{
@@ -293,21 +337,21 @@ public partial class BitCalendarDemo
Name = "MaxDate",
Type = "DateTimeOffset?",
DefaultValue = "null",
- Description = "The maximum allowable date of the calendar."
+ Description = "The maximum allowable date of the calendar. The days after it are ruled out as a whole, and the day it itself falls on stays selectable; where a time picker is on screen, the time the bound carries bounds the hours of that day too."
},
new()
{
Name = "MinDate",
Type = "DateTimeOffset?",
DefaultValue = "null",
- Description = "The minimum allowable date of the calendar."
+ Description = "The minimum allowable date of the calendar. The days before it are ruled out as a whole, and the day it itself falls on stays selectable; where a time picker is on screen, the time the bound carries bounds the hours of that day too."
},
new()
{
Name = "MinuteStep",
Type = "int",
DefaultValue = "1",
- Description = "The step, in minutes, the spin buttons move the minute by. A step greater than 1 lays a grid over the hour, starting at the top of it, that every minute the buttons produce sits on - which is what turns it into a five-minute or quarter-hour picker. A time entered as text is not held to it.",
+ Description = "The step, in minutes, the spin buttons move the minute by. A step greater than 1 lays a grid over the hour, starting at the top of it, that every minute the picker produces sits on - which is what turns it into a five-minute or quarter-hour picker. The buttons, the keys and what is typed into the minute are all held to it.",
},
new()
{
@@ -317,6 +361,13 @@ public partial class BitCalendarDemo
Description = "Used to customize how content inside the month cell is rendered."
},
new()
+ {
+ Name = "MonthCount",
+ Type = "int",
+ DefaultValue = "1",
+ Description = "The number of consecutive months rendered side by side in the day picker (1 to 3), which turns the calendar into a view of a whole season - the arrow keys, the selection and the events carry on across the months of it. More than one month always renders six week rows and never the days of the adjacent months, and the months stack instead of overflowing when the width they are given cannot fit them.",
+ },
+ new()
{
Name = "MonthPickerToggleTitle",
Type = "string",
@@ -390,6 +441,13 @@ public partial class BitCalendarDemo
Description = "Callback for when the user selects a date."
},
new()
+ {
+ Name = "PagedNavigation",
+ Type = "bool",
+ DefaultValue = "false",
+ Description = "Whether the previous and next navigation buttons move the calendar by all of its rendered months instead of one, so consecutive pages of a multi-month calendar never overlap. It has no effect when MonthCount renders a single month.",
+ },
+ new()
{
Name = "PrevMonthNavIcon",
Type = "BitIconInfo?",
@@ -448,7 +506,14 @@ public partial class BitCalendarDemo
Name = "SelectedDateAriaAtomic",
Type = "string",
DefaultValue = "Selected date {0}",
- Description = "The text of selected date aria-atomic of the calendar."
+ Description = "The template of the text a screen reader is given when the selection changes, where {0} is the selected date written with the DateFormat. Nothing is announced while no date is selected."
+ },
+ new()
+ {
+ Name = "ShowEventDetails",
+ Type = "bool",
+ DefaultValue = "true",
+ Description = "Whether clicking a day that carries events opens the modal dialog listing them. The day is selected either way; turning this off leaves the events to the indicator, the tooltip and whatever the page itself shows for the selected day."
},
new()
{
@@ -483,7 +548,7 @@ public partial class BitCalendarDemo
Name = "ShowOutsideDays",
Type = "bool",
DefaultValue = "true",
- Description = "Whether the days of the previous and next months should be shown in the day picker."
+ Description = "Whether the days of the previous and next months should be shown in the day picker. It has no effect when MonthCount renders more than one month, since those days would then show up in two grids at once."
},
new()
{
@@ -532,6 +597,15 @@ public partial class BitCalendarDemo
Description = "Whether the week number (weeks 1 to 53) should be shown before each week row."
},
new()
+ {
+ Name = "Size",
+ Type = "BitSize?",
+ DefaultValue = "null",
+ Description = "The size of the calendar, which scales its cells and their text.",
+ LinkType = LinkType.Link,
+ Href = "#size-enum",
+ },
+ new()
{
Name = "StartingValue",
Type = "DateTimeOffset?",
@@ -629,6 +703,48 @@ public partial class BitCalendarDemo
Href = "https://blazorui.bitplatform.dev/iconography"
},
new()
+ {
+ Name = "TimePickerDecreaseHourTitle",
+ Type = "string",
+ DefaultValue = "Decrease hour",
+ Description = "The title (tooltip) and the accessible name of the time-picker's decrease-hour button."
+ },
+ new()
+ {
+ Name = "TimePickerDecreaseMinuteTitle",
+ Type = "string",
+ DefaultValue = "Decrease minute",
+ Description = "The title (tooltip) and the accessible name of the time-picker's decrease-minute button."
+ },
+ new()
+ {
+ Name = "TimePickerHourTitle",
+ Type = "string",
+ DefaultValue = "Hour",
+ Description = "The title (tooltip) and the accessible name of the time-picker's hour input."
+ },
+ new()
+ {
+ Name = "TimePickerIncreaseHourTitle",
+ Type = "string",
+ DefaultValue = "Increase hour",
+ Description = "The title (tooltip) and the accessible name of the time-picker's increase-hour button."
+ },
+ new()
+ {
+ Name = "TimePickerIncreaseMinuteTitle",
+ Type = "string",
+ DefaultValue = "Increase minute",
+ Description = "The title (tooltip) and the accessible name of the time-picker's increase-minute button."
+ },
+ new()
+ {
+ Name = "TimePickerMinuteTitle",
+ Type = "string",
+ DefaultValue = "Minute",
+ Description = "The title (tooltip) and the accessible name of the time-picker's minute input."
+ },
+ new()
{
Name = "TimeZone",
Type = "TimeZoneInfo?",
@@ -650,6 +766,13 @@ public partial class BitCalendarDemo
Description = "The rule used to calculate the week numbers. Defaults to the FirstFullWeek rule."
},
new()
+ {
+ Name = "WeekNumbersHeaderTitle",
+ Type = "string",
+ DefaultValue = "Week",
+ Description = "The accessible name of the empty column header above the week numbers."
+ },
+ new()
{
Name = "WeekNumberTitle",
Type = "string",
@@ -839,6 +962,33 @@ public partial class BitCalendarDemo
Value="1"
}
]
+ },
+ new()
+ {
+ Id = "size-enum",
+ Name = "BitSize",
+ Description = "",
+ Items =
+ [
+ new()
+ {
+ Name= "Small",
+ Description="The small size calendar.",
+ Value="0",
+ },
+ new()
+ {
+ Name= "Medium",
+ Description="The medium size calendar.",
+ Value="1",
+ },
+ new()
+ {
+ Name= "Large",
+ Description="The large size calendar.",
+ Value="2",
+ }
+ ]
}
];
@@ -865,6 +1015,20 @@ public partial class BitCalendarDemo
Description = "Custom CSS classes/styles for the main container of the BitCalendar."
},
new()
+ {
+ Name = "Header",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the wrapper of the HeaderTemplate of the BitCalendar."
+ },
+ new()
+ {
+ Name = "Footer",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the wrapper of the FooterTemplate of the BitCalendar."
+ },
+ new()
{
Name = "DayPickerWrapper",
Type = "string?",
@@ -921,6 +1085,34 @@ public partial class BitCalendarDemo
Description = "Custom CSS classes/styles for the Go to today icon of the BitCalendar."
},
new()
+ {
+ Name = "NowButton",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the Go to now button of the BitCalendar."
+ },
+ new()
+ {
+ Name = "NowButtonIcon",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the Go to now icon of the BitCalendar."
+ },
+ new()
+ {
+ Name = "HideTimePickerButton",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the hide time-picker button of the BitCalendar."
+ },
+ new()
+ {
+ Name = "HideTimePickerIcon",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the hide time-picker icon of the BitCalendar."
+ },
+ new()
{
Name = "NextMonthNavButton",
Type = "string?",
@@ -1006,10 +1198,24 @@ public partial class BitCalendarDemo
},
new()
{
- Name = "TimePickerContainer",
+ Name = "TimeInputContainer",
Type = "string?",
DefaultValue = "null",
- Description = "Custom CSS classes/styles for the time-picker's main container of the BitCalendar."
+ Description = "Custom CSS classes/styles for the time-picker's input container of the BitCalendar."
+ },
+ new()
+ {
+ Name = "HourInputContainer",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the time-picker's hour input container of the BitCalendar."
+ },
+ new()
+ {
+ Name = "MinuteInputContainer",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the time-picker's minute input container of the BitCalendar."
},
new()
{
@@ -1040,6 +1246,83 @@ public partial class BitCalendarDemo
Description = "Custom CSS classes/styles for the time-picker's minute input of the BitCalendar."
},
new()
+ {
+ Name = "TimePickerIncreaseHourButton",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the time-picker's increase hour button of the BitCalendar."
+ },
+ new()
+ {
+ Name = "TimePickerIncreaseHourIcon",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the time-picker's increase hour icon of the BitCalendar."
+ },
+ new()
+ {
+ Name = "TimePickerDecreaseHourButton",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the time-picker's decrease hour button of the BitCalendar."
+ },
+ new()
+ {
+ Name = "TimePickerDecreaseHourIcon",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the time-picker's decrease hour icon of the BitCalendar."
+ },
+ new()
+ {
+ Name = "TimePickerIncreaseMinuteButton",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the time-picker's increase minute button of the BitCalendar."
+ },
+ new()
+ {
+ Name = "TimePickerIncreaseMinuteIcon",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the time-picker's increase minute icon of the BitCalendar."
+ },
+ new()
+ {
+ Name = "TimePickerDecreaseMinuteButton",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the time-picker's decrease minute button of the BitCalendar."
+ },
+ new()
+ {
+ Name = "TimePickerDecreaseMinuteIcon",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the time-picker's decrease minute icon of the BitCalendar."
+ },
+ new()
+ {
+ Name = "TimePickerAmPmContainer",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the time-picker's Am Pm container of the BitCalendar."
+ },
+ new()
+ {
+ Name = "TimePickerAmButton",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the time-picker's Am button of the BitCalendar."
+ },
+ new()
+ {
+ Name = "TimePickerPmButton",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the time-picker's Pm button of the BitCalendar."
+ },
+ new()
{
Name = "Divider",
Type = "string?",
@@ -1061,6 +1344,13 @@ public partial class BitCalendarDemo
Description = "Custom CSS classes/styles for the month-picker's header of the BitCalendar."
},
new()
+ {
+ Name = "TimePickerHeader",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the time-picker's header of the BitCalendar."
+ },
+ new()
{
Name = "YearPickerToggleButton",
Type = "string?",
@@ -1068,6 +1358,20 @@ public partial class BitCalendarDemo
Description = "Custom CSS classes/styles for the year-picker's toggle button of the BitCalendar."
},
new()
+ {
+ Name = "ShowTimePickerButton",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the show time-picker button of the BitCalendar."
+ },
+ new()
+ {
+ Name = "ShowTimePickerIcon",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the show time-picker icon of the BitCalendar."
+ },
+ new()
{
Name = "MonthPickerNavWrapper",
Type = "string?",
@@ -1075,6 +1379,13 @@ public partial class BitCalendarDemo
Description = "Custom CSS classes/styles for the wrapper of the month-picker's nav buttons of the BitCalendar."
},
new()
+ {
+ Name = "TimePickerNavWrapper",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the wrapper of the time-picker's nav buttons of the BitCalendar."
+ },
+ new()
{
Name = "PrevYearNavButton",
Type = "string?",
@@ -1180,11 +1491,18 @@ public partial class BitCalendarDemo
Description = "Custom CSS classes/styles for each year button of the BitCalendar."
},
new()
+ {
+ Name = "EventIndicators",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the row of event indicator dots on a day of the BitCalendar."
+ },
+ new()
{
Name = "EventIndicator",
Type = "string?",
DefaultValue = "null",
- Description = "Custom CSS classes/styles for the event indicator dot of the BitCalendar."
+ Description = "Custom CSS classes/styles for each event indicator dot of the BitCalendar."
},
new()
{
@@ -1215,6 +1533,13 @@ public partial class BitCalendarDemo
Description = "Custom CSS classes/styles for the event modal close button of the BitCalendar."
},
new()
+ {
+ Name = "EventList",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the list of events in the event modal of the BitCalendar."
+ },
+ new()
{
Name = "EventItem",
Type = "string?",
@@ -1272,6 +1597,13 @@ public partial class BitCalendarDemo
Description = "The date on which the event occurs."
},
new()
+ {
+ Name = "Color",
+ Type = "BitColor?",
+ DefaultValue = "null",
+ Description = "The color of the indicator dot the event puts on its day. Without one the dot takes the color of the calendar itself."
+ },
+ new()
{
Name = "StartTime",
Type = "TimeOnly?",
@@ -1321,8 +1653,195 @@ public partial class BitCalendarDemo
+ private readonly List componentPublicMembers =
+ [
+ new()
+ {
+ Name = "FocusAsync",
+ Type = "ValueTask",
+ Description = "Gives focus to the calendar: the day of the day grid that is in the tab sequence, or the month, the year or the hour input when the day grid is not the picker on screen.",
+ },
+ ];
+
+ private readonly List componentCssVariables =
+ [
+ new()
+ {
+ Name = "--bit-Calendar-background",
+ DefaultValue = "transparent",
+ Description = "Background behind the whole calendar, including the area around the pickers.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-radius",
+ DefaultValue = "--bit-shp-radius-control",
+ Description = "Corner radius of the calendar, which the overlay of the event details dialog follows.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-padding",
+ DefaultValue = "8px * 1.5 (the spacing unit)",
+ Description = "Padding of each picker pane - the day grid, the month and year grids, the time picker - and of the HeaderTemplate and FooterTemplate wrappers.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-color",
+ DefaultValue = "--bit-clr-fg-pri",
+ Description = "Text color of the cells, the headers and the navigation buttons. The selected day, today and a disabled cell paint their own through the variables below.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-divider-color",
+ DefaultValue = "--bit-clr-brd-sec",
+ Description = "Color of the rule between the panes, of the one beside the week numbers, and of the borders inside the event details dialog.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-focus-color",
+ DefaultValue = "The Color role's focus color (--bit-clr-err-focus while the value is invalid)",
+ Description = "Color of the keyboard focus ring drawn around every cell and button of the calendar.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-disabled-color",
+ DefaultValue = "--bit-clr-fg-dis",
+ Description = "Text color of a disabled cell, and of every part of a calendar whose IsEnabled is false.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-hover-background",
+ DefaultValue = "--bit-clr-bg-pri-hover",
+ Description = "Background of a cell or button on hover (pointer devices only).",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-active-background",
+ DefaultValue = "--bit-clr-bg-pri-active",
+ Description = "Background of a cell or button while pressed.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-day-size",
+ DefaultValue = "Per Size: 24px / 28px / 34px at the Fluent defaults",
+ Description = "Width and height of a day cell. The week numbers, the weekday headers, the empty cells and the navigation buttons all take it, so the whole day grid stays in step.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-day-font-size",
+ DefaultValue = "Per Size: --bit-tpg-fs-xs / -sm / -md",
+ Description = "Text size of the day cells and of the headers and week numbers that line up with them.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-day-radius",
+ DefaultValue = "--bit-shp-radius-control",
+ Description = "Corner radius of a day cell, which the month, year, navigation and time-picker buttons share. Set it to 999px for round days.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-outside-day-color",
+ DefaultValue = "--bit-clr-fg-sec",
+ Description = "Text color of a day of the previous or next month while ShowOutsideDays is on.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-selected-background",
+ DefaultValue = "--bit-clr-bg-sec",
+ Description = "Background of the selected day, and of the displayed month while HighlightSelectedMonth is on.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-selected-color",
+ DefaultValue = "--bit-Calendar-color",
+ Description = "Text color of the selected day, for a filled selection that needs its own contrast.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-selected-border-color",
+ DefaultValue = "--bit-clr-brd-pri",
+ Description = "Color of the ring drawn around the selected day. Set it to transparent for a selection carried by its background alone.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-today-background",
+ DefaultValue = "The Color role's main color",
+ Description = "Background of today, of the current month while HighlightCurrentMonth is on, and of the AM/PM button that is in force.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-today-color",
+ DefaultValue = "The Color role's on-color",
+ Description = "Text color of today, of the highlighted current month and of the AM/PM button that is in force.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-today-hover-background",
+ DefaultValue = "The Color role's hover color",
+ Description = "Background of today on hover (pointer devices only), which the highlighted current month and the AM/PM button that is in force share. Set it alongside --bit-Calendar-today-background so the hover does not fall back to the Color role.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-today-active-background",
+ DefaultValue = "The Color role's active color",
+ Description = "Background of today while pressed, which the AM/PM button that is in force shares.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-today-radius",
+ DefaultValue = "--bit-shp-radius-full",
+ Description = "Corner radius of today, which is a circle by default.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-highlighted-background",
+ DefaultValue = "--bit-clr-bg-ter",
+ Description = "Background of a day listed in HighlightedDates.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-week-number-color",
+ DefaultValue = "--bit-clr-fg-sec",
+ Description = "Text color of a week number.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-week-number-background",
+ DefaultValue = "--bit-clr-bg-sec",
+ Description = "Background of the week-number column.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-event-color",
+ DefaultValue = "The Color role's main color",
+ Description = "Color of an indicator dot whose event named no Color of its own. On today it falls to the on-color instead, which is what reads against the cell.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-event-size",
+ DefaultValue = "8px * 0.5 (the spacing unit)",
+ Description = "Diameter of each indicator dot on a day that carries events.",
+ },
+ new()
+ {
+ Name = "--bit-Calendar-event-gap",
+ DefaultValue = "8px * 0.25 (the spacing unit)",
+ Description = "Space between the dots of a day that carries more than one event.",
+ },
+ ];
+
+
+
+
private DateTimeOffset? selectedDate = new DateTimeOffset(2023, 8, 19, 0, 0, 0, DateTimeOffset.Now.Offset);
+ private DateTimeOffset? deselectableDate = DateTimeOffset.Now;
+
+ private DateTimeOffset? footerDate;
+
+ // Two days on from today, so the sample below shows the filled selection and the square today as two
+ // different cells rather than as one cell wearing both.
+ private DateTimeOffset? cssVarsDate = DateTimeOffset.Now.AddDays(2);
+
private DateTimeOffset? onSelectDate;
private void HandleOnSelectDate(DateTimeOffset? date)
@@ -1330,6 +1849,23 @@ private void HandleOnSelectDate(DateTimeOffset? date)
onSelectDate = date;
}
+ private string? openedEvent;
+ private string eventsOfSelectedDay = "-";
+
+ private void HandleOnEventDayClick(DateTimeOffset? date)
+ {
+ if (date is null)
+ {
+ eventsOfSelectedDay = "-";
+ return;
+ }
+
+ var day = DateOnly.FromDateTime(date.Value.DateTime);
+ var titles = calendarEvents.Where(e => e.Date == day).Select(e => e.Title).ToArray();
+
+ eventsOfSelectedDay = titles.Length == 0 ? "none" : string.Join(", ", titles);
+ }
+
private List calendarEvents =
[
new() { Title = "Team standup",
@@ -1345,18 +1881,63 @@ private void HandleOnSelectDate(DateTimeOffset? date)
new() { Title = "All-day workshop",
Body = "Full-day frontend architecture workshop.",
Date = DateOnly.FromDateTime(DateTime.Today.AddDays(3)) },
+ new() { Title = "Retro",
+ Body = "Sprint retrospective.",
+ Date = DateOnly.FromDateTime(DateTime.Today.AddDays(3)),
+ StartTime = new TimeOnly(16, 0),
+ EndTime = new TimeOnly(17, 0) },
new() { Title = "Client call",
Body = "Introductory call with the new client.",
Date = DateOnly.FromDateTime(DateTime.Today.AddDays(7)),
StartTime = new TimeOnly(11, 30) }
];
+ // The same week seen through a page that sorts its events by kind, so each dot says which kind it is.
+ private List coloredEvents =
+ [
+ new() { Title = "Release 2.4",
+ Body = "Ship the release once the pipeline is green.",
+ Color = BitColor.Success,
+ Date = DateOnly.FromDateTime(DateTime.Today.AddDays(1)) },
+ new() { Title = "On-call handover",
+ Color = BitColor.Warning,
+ Date = DateOnly.FromDateTime(DateTime.Today.AddDays(1)),
+ StartTime = new TimeOnly(10, 0) },
+ new() { Title = "Incident review",
+ Color = BitColor.Error,
+ Date = DateOnly.FromDateTime(DateTime.Today.AddDays(1)),
+ StartTime = new TimeOnly(15, 0),
+ EndTime = new TimeOnly(16, 0) },
+ new() { Title = "Design sync",
+ Color = BitColor.Info,
+ Date = DateOnly.FromDateTime(DateTime.Today.AddDays(4)),
+ StartTime = new TimeOnly(13, 0) }
+ ];
+
private CultureInfo culture = CultureInfo.CurrentUICulture;
private bool showMonthPicker = true;
private bool showMonthPickerAsOverlay;
+ private DateTimeOffset? seasonDate;
+
+ private readonly BitCalendarParams[] calendarParams =
+ [
+ new()
+ {
+ ShowWeekNumbers = true,
+ FirstDayOfWeek = DayOfWeek.Monday,
+ WeekNumberRule = CalendarWeekRule.FirstFourDayWeek,
+ HighlightCurrentMonth = true,
+ MinDate = DateTimeOffset.Now.AddMonths(-1),
+ MaxDate = DateTimeOffset.Now.AddMonths(1),
+ }
+ ];
+
private DateTimeOffset? selectedDateTime = DateTimeOffset.Now;
+ private DateTimeOffset? boundedDateTime = DateTime.Today.AddHours(12);
+ private DateTimeOffset boundedMinDate = DateTime.Today.AddHours(9).AddMinutes(30);
+ private DateTimeOffset boundedMaxDate = DateTime.Today.AddDays(2).AddHours(17);
private DateTimeOffset? startingValue = new DateTimeOffset(2020, 12, 4, 20, 45, 0, DateTimeOffset.Now.Offset);
private DateTimeOffset? customToday = new DateTimeOffset(2021, 3, 15, 0, 0, 0, DateTimeOffset.Now.Offset);
@@ -1386,8 +1967,6 @@ private void HandleOnMonthChange(DateTimeOffset month)
private DateTimeOffset? timeZoneDate1;
private DateTimeOffset? timeZoneDate2;
- private DateTimeOffset? readOnlyDate = DateTimeOffset.Now;
-
private string SuccessMessage = string.Empty;
private BitCalendarValidationModel validationModel = new();
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Calendar/BitCalendarDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Calendar/BitCalendarDemo.razor.samples.cs
index 4810985815f..1ee6aca1037 100644
--- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Calendar/BitCalendarDemo.razor.samples.cs
+++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Calendar/BitCalendarDemo.razor.samples.cs
@@ -4,100 +4,152 @@ public partial class BitCalendarDemo
{
private readonly string example1RazorCode = @"
+
-
-
-
-
+
+
+
";
- private readonly string example1CsharpCode = @"
-private DateTimeOffset? startingValue = new DateTimeOffset(2020, 12, 4, 20, 45, 0, DateTimeOffset.Now.Offset);
-private DateTimeOffset? customToday = new DateTimeOffset(2021, 3, 15, 0, 0, 0, DateTimeOffset.Now.Offset);";
private readonly string example2RazorCode = @"
+
+