diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.razor index 90b25dcac90..be4fb1d27f2 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.razor @@ -1,7 +1,7 @@ @namespace Bit.BlazorUI -
+
[Parameter] public EventCallback OnViewChange { get; set; } + /// + /// When true, the calendar becomes presentation-only: the "Add Event" button and the + /// per-cell add affordances are hidden, events can no longer be dragged or resized, and the + /// edit/delete actions are removed from the event details dialog. + /// + /// Everything that does not modify events keeps working - date navigation, view and mode + /// switching, filtering, the settings panel, and opening an event to read its details. + /// + /// + [Parameter] public bool ReadOnly { get; set; } + /// /// Resources displayed as rows in the resource timeline view. When null or empty, /// the resource timeline tab is hidden from the header. Each event's @@ -190,6 +201,20 @@ public partial class BitFullCalendar : IDisposable /// [Parameter, TwoWayBound] public BitFullCalendarView View { get; set; } = BitFullCalendarView.Month; + /// + /// The views the calendar offers, in the order the view tabs render them. When null or + /// empty, every view (day, week, month, year, agenda) is offered in that order. Unknown and + /// repeated entries are ignored. + /// + /// Excluded views are unreachable: the view tabs omit them, and , + /// , and the indirect navigation paths (for example selecting a month + /// from the year overview) are clamped into the supplied set. The tab strip is hidden entirely + /// when a single view is left. Timeline mode still renders only the day, week, and month + /// layouts, so it is unavailable when none of them is listed here. + /// + /// + [Parameter] public IReadOnlyList? Views { get; set; } + /// /// Optional template for customizing event rendering in the week view. /// When provided, replaces the default event card content inside the time-grid blocks. @@ -300,9 +325,12 @@ protected override void OnParametersSet() State.SyncEvents([]); State.SyncResources(Resources); + State.SyncViews(Views); + State.SetReadOnly(ReadOnly); - // Apply the view, mode, and date after resources are synced: Timeline mode requires - // Resources to be populated to take effect. + // Apply the view, mode, and date after resources and views are synced: Timeline mode + // requires Resources to be populated to take effect, and both the mode and the view are + // clamped into the allowed view set. ApplyBoundState(); ApplySettings(); @@ -320,6 +348,13 @@ protected override void OnParametersSet() _pendingDateChange = null; InvokeAsync(() => OnDateChange.InvokeAsync(pending)); } + + // A bound View/Mode the state refuses can resolve to the value that is already active, which + // emits no state change and so reaches no reconciliation through HandleStateChanged. Reconcile + // explicitly here so the corrected value is always pushed back into the binding. This is an + // echo of the parameters just applied, so it stays silent (raiseEvents: false) like the + // reconciliations queued during the parameter-application window. + InvokeAsync(() => ReconcileBoundState(raiseEvents: false)); } private void ApplyBoundState() @@ -328,15 +363,17 @@ private void ApplyBoundState() if (ModeHasBeenSet) { // Controlled: keep the state aligned with the bound Mode on every parameter change. - // State.SetMode falls back to Event when Timeline is requested without resources. + // State.SetMode falls back to Event when Timeline is requested without the resources or + // the timeline-capable views it needs. State.SetMode(Mode); } else if (!_defaultModeApplied && DefaultMode.HasValue) { - // Timeline default needs at least one resource to take effect; defer until resources are - // available so a later Resources assignment is not permanently ignored. + // Timeline default needs at least one resource and one timeline-capable view to take + // effect; defer until they are available so a later Resources/Views assignment is not + // permanently ignored. var canApplyDefaultMode = DefaultMode.Value != BitFullCalendarMode.Timeline - || Resources is { Count: > 0 }; + || State.IsTimelineModeAvailable; if (canApplyDefaultMode) { _defaultModeApplied = true; @@ -404,19 +441,27 @@ private void HandleStateChanged() // additional event is raised here for the date. private async Task ReconcileBoundState(bool raiseEvents) { - if (!EqualityComparer.Default.Equals(_lastMode, State.Mode)) + var modeChanged = !EqualityComparer.Default.Equals(_lastMode, State.Mode); + // A refused bound Mode (Timeline without the resources or timeline-capable views it needs) can + // resolve to the mode that is already active. The state then reports no change at all, so the + // divergence has to be detected against the parameter itself - otherwise the binding would keep + // reporting a mode the calendar never entered. Only a genuine state change raises OnModeChange. + if (modeChanged || (ModeHasBeenSet && !EqualityComparer.Default.Equals(Mode, State.Mode))) { _lastMode = State.Mode; await AssignMode(State.Mode); - if (raiseEvents) + if (modeChanged && raiseEvents) await OnModeChange.InvokeAsync(State.Mode); } - if (!EqualityComparer.Default.Equals(_lastView, State.View)) + var viewChanged = !EqualityComparer.Default.Equals(_lastView, State.View); + // Same for a bound View excluded by Views: when the clamp lands on the active view there is no + // state change to reconcile against, only a parameter that no longer matches what is rendered. + if (viewChanged || (ViewHasBeenSet && !EqualityComparer.Default.Equals(View, State.View))) { _lastView = State.View; await AssignView(State.View); - if (raiseEvents) + if (viewChanged && raiseEvents) await OnViewChange.InvokeAsync(State.View); } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.scss index b2d43f3495c..7f3a0edd346 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.scss @@ -2015,6 +2015,28 @@ button.bit-bfc-cell-add-hint:focus-visible { left: 8px; } +/* ===== Read-only mode ===== */ +/* The time-grid slots and timeline cells are add targets only, so a read-only calendar must not + advertise them as clickable. The month cell keeps its pointer affordance because clicking it + still selects the date. */ +.bit-bfc-readonly .bit-bfc-hour-row, +.bit-bfc-readonly .bit-bfc-tl-cell { + cursor: default; +} + +.bit-bfc-readonly .bit-bfc-hour-row:hover, +.bit-bfc-readonly .bit-bfc-tl-cell:hover { + background: none; +} + +/* Events stay clickable (they open the read-only details dialog) but are no longer draggable, so + the grab cursor would promise a gesture that does nothing. */ +.bit-bfc-readonly .bit-bfc-event-badge, +.bit-bfc-readonly .bit-bfc-event-block, +.bit-bfc-readonly .bit-bfc-timeline-event { + cursor: pointer; +} + /* ===== Scrollbar styling ===== */ .bit-bfc-body ::-webkit-scrollbar, .bit-bfc-week-scroll::-webkit-scrollbar { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcAddEditEventDialog.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcAddEditEventDialog.razor.cs index dfd24798cb5..41fd26ca4f2 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcAddEditEventDialog.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcAddEditEventDialog.razor.cs @@ -49,6 +49,21 @@ public partial class BitFcAddEditEventDialog : IAsyncDisposable private int? _lastStartMinute; private string? _lastResource; + protected override void OnInitialized() => State.OnStateChanged += HandleStateChanged; + + /// + /// The calendar can be switched to read-only while this dialog is open - every entry point only + /// checks read-only when it opens the dialog, so an already-open form would otherwise stay live. + /// Close it instead of leaving a Save button that refuses. + /// + private void HandleStateChanged() + { + if (State.ReadOnly is false) + return; + + _ = InvokeAsync(OnClose.InvokeAsync); + } + protected override void OnParametersSet() { // Re-run initialization whenever the parameters that drive the form change, so a reused @@ -159,6 +174,11 @@ private Task OnEndDateChanged(DateTime value) private async Task Submit() { + // Last line of defense for every host of this dialog (add entry points and the details + // dialog's edit overlay): read-only may have been switched on after the dialog opened, so + // refuse the save rather than mutating state the calendar no longer allows to change. + if (State.ReadOnly) return; + // Guard against re-entrancy: a second click or Enter press while the first save is still // in flight would otherwise add/update the event twice before the dialog closes. if (_isSubmitting) return; @@ -244,6 +264,7 @@ await Notifier.NotifyAsync(new BitFullCalendarChangeEventArgs public async ValueTask DisposeAsync() { + State.OnStateChanged -= HandleStateChanged; await BitFcDialogInterop.TeardownAsync(JS, _dialogRef); } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventDetailsDialog.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventDetailsDialog.razor index 6ae9b3d7777..9106e3270ff 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventDetailsDialog.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventDetailsDialog.razor @@ -64,11 +64,16 @@
- diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventDetailsDialog.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventDetailsDialog.razor.cs index 8c3d332e126..16bdc727494 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventDetailsDialog.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventDetailsDialog.razor.cs @@ -30,6 +30,9 @@ protected override async Task OnAfterRenderAsync(bool firstRender) private void Edit() { + if (State.ReadOnly) + return; + _showEdit = true; } @@ -48,6 +51,9 @@ private async Task OnEditSaved() private async Task Delete() { + if (State.ReadOnly) + return; + // Guard against double invocation (rapid clicks / Enter while the async work is in flight): // keep the flag set through the notifier and OnClose so the delete only runs once. if (_isDeleting) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/DragDrop/BitFcDraggableEvent.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/DragDrop/BitFcDraggableEvent.razor index 93014f90664..a58b1f4b2b6 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/DragDrop/BitFcDraggableEvent.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/DragDrop/BitFcDraggableEvent.razor @@ -6,7 +6,7 @@ avoiding that nesting; drag/click handlers are preserved verbatim. *@
} - + @if (!State.ReadOnly) + { + + } @if (!HideSettings) { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcCalendarHeader.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcCalendarHeader.razor.cs index f213c2857f4..1d6fca9e1c3 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcCalendarHeader.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcCalendarHeader.razor.cs @@ -13,6 +13,9 @@ public partial class BitFcCalendarHeader private async Task OnAddEventClick() { + if (State.ReadOnly) + return; + if (OnAddClick.HasDelegate) { var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot( diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcModeTabs.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcModeTabs.razor index 4eb2433193d..249cebc6e82 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcModeTabs.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcModeTabs.razor @@ -1,12 +1,12 @@ @namespace Bit.BlazorUI @* - Top-level mode switch (Event ⇄ Timeline). The whole switch is rendered only when at least one - resource is supplied, because the Timeline mode is meaningless without resources and a lone - Event tab adds no value. + Top-level mode switch (Event ⇄ Timeline). The whole switch is rendered only when the Timeline + mode is actually reachable - it needs at least one resource and at least one allowed view it can + lay out - because a lone Event tab adds no value. *@ -@if (State.Resources.Count > 0) +@if (State.IsTimelineModeAvailable) {
@foreach (var mode in _modes) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcViewTabs.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcViewTabs.razor index 761c772d772..8d655701e4e 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcViewTabs.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcViewTabs.razor @@ -1,17 +1,22 @@ @namespace Bit.BlazorUI -
- @foreach (var view in _views) - { - if (State.Mode == BitFullCalendarMode.Timeline && !_timelineViews.Contains(view)) - continue; +@{ + var views = State.AvailableViews; +} - - } -
+@* A lone tab offers no choice, so the whole strip is collapsed when a single view is available. *@ +@if (views.Count > 1) +{ +
+ @foreach (var view in views) + { + + } +
+} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcViewTabs.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcViewTabs.razor.cs index cd3d5bc04c8..94849ea454d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcViewTabs.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcViewTabs.razor.cs @@ -4,16 +4,4 @@ public partial class BitFcViewTabs { [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; - - private static readonly BitFullCalendarView[] _views = [ - BitFullCalendarView.Day, BitFullCalendarView.Week, BitFullCalendarView.Month, - BitFullCalendarView.Year, BitFullCalendarView.Agenda - ]; - - private static readonly HashSet _timelineViews = - [ - BitFullCalendarView.Day, - BitFullCalendarView.Week, - BitFullCalendarView.Month - ]; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarState.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarState.cs index 9506e2a134c..60fabd0824d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarState.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarState.cs @@ -4,9 +4,28 @@ namespace Bit.BlazorUI; public class BitFullCalendarState { + /// Every view the calendar offers, in the order the tab strip renders them. + private static readonly BitFullCalendarView[] _allViews = + [ + BitFullCalendarView.Day, + BitFullCalendarView.Week, + BitFullCalendarView.Month, + BitFullCalendarView.Year, + BitFullCalendarView.Agenda + ]; + + /// The subset of views the timeline layout can render. + private static readonly BitFullCalendarView[] _timelineViews = + [ + BitFullCalendarView.Day, + BitFullCalendarView.Week, + BitFullCalendarView.Month + ]; + private List _allEvents = []; private List _filteredEvents = []; private List _resources = []; + private List _views = [.. _allViews]; private readonly List _selectedColors = []; public DateTime SelectedDate { get; private set; } = DateTime.Today; @@ -14,6 +33,25 @@ public class BitFullCalendarState public BitFullCalendarMode Mode { get; private set; } = BitFullCalendarMode.Event; public IReadOnlyList SelectedColors => _selectedColors; + /// + /// When true the calendar is presentation-only: the add affordances, drag-and-drop, + /// resizing, and the edit/delete actions are suppressed while navigation, view switching, + /// and filtering keep working. + /// + public bool ReadOnly { get; private set; } + + /// The views the consumer allowed, in display order. + public IReadOnlyList Views => _views; + + /// The allowed views that the active can render, in display order. + public IReadOnlyList AvailableViews => GetViewsForMode(Mode); + + /// + /// True when Timeline mode can be entered: it needs at least one resource to lay out rows and + /// at least one allowed view the timeline supports. + /// + public bool IsTimelineModeAvailable => _resources.Count > 0 && _views.Any(_timelineViews.Contains); + /// When set, only events that include this attendee (by ) are shown. public string? SelectedAttendeeKey { get; private set; } public bool Use24HourFormat { get; private set; } = true; @@ -66,7 +104,7 @@ public void SetSelectedDate(DateTime date) public void SetView(BitFullCalendarView view) { - var clamped = ClampViewForMode(view, Mode); + var clamped = ClampView(view, Mode); if (clamped == View) return; @@ -81,16 +119,16 @@ public void SetView(BitFullCalendarView view) /// public void SetMode(BitFullCalendarMode mode) { - // Timeline mode requires at least one resource. Refuse to enter it when there are none - // so the state never lands in an unsupported (timeline-without-resources) configuration. - if (mode == BitFullCalendarMode.Timeline && _resources.Count == 0) + // Timeline mode requires at least one resource and one allowed view it can lay out. Refuse + // to enter it otherwise so the state never lands in an unsupported configuration. + if (mode == BitFullCalendarMode.Timeline && !IsTimelineModeAvailable) mode = BitFullCalendarMode.Event; if (Mode == mode) return; Mode = mode; - var clamped = ClampViewForMode(View, mode); + var clamped = ClampView(View, mode); var viewChanged = clamped != View; if (viewChanged) View = clamped; @@ -103,16 +141,106 @@ public void SetMode(BitFullCalendarMode mode) NotifyDateRangeChanged(); } - private static BitFullCalendarView ClampViewForMode(BitFullCalendarView view, BitFullCalendarMode mode) + /// The allowed views the supplied mode can render, in display order. + public IReadOnlyList GetViewsForMode(BitFullCalendarMode mode) + => mode == BitFullCalendarMode.Timeline + ? [.. _views.Where(_timelineViews.Contains)] + : _views; + + /// True when the supplied view is reachable in the active mode. + public bool IsViewAvailable(BitFullCalendarView view) => AvailableViews.Contains(view); + + private BitFullCalendarView ClampView(BitFullCalendarView view, BitFullCalendarMode mode) { - if (mode != BitFullCalendarMode.Timeline) + var available = GetViewsForMode(mode); + if (available.Contains(view)) return view; - return view switch + // Timeline mode has always fallen back to the week layout for the views it cannot render; + // keep that whenever Week is still allowed, and otherwise land on the first allowed view so + // the calendar never renders a view the consumer excluded. + if (mode == BitFullCalendarMode.Timeline && available.Contains(BitFullCalendarView.Week)) + return BitFullCalendarView.Week; + + return available.Count > 0 ? available[0] : view; + } + + /// + /// Turns the presentation-only mode on or off. A drag that is still in flight is dropped so a + /// pending gesture cannot commit a change after the calendar has become read-only. + /// + public void SetReadOnly(bool value) + { + if (ReadOnly == value) + return; + + ReadOnly = value; + if (ReadOnly) + DraggedEvent = null; + + NotifyStateChanged(); + } + + /// + /// Replaces the set of views the calendar offers. Safe to call from OnParametersSet - it + /// short-circuits when the supplied list matches the current one. A null or empty list + /// restores every built-in view; unknown and repeated entries are dropped. + /// + public void SyncViews(IReadOnlyList? views) + { + var next = NormalizeViews(views); + if (ViewsMatch(next)) + return; + + _views = next; + + // Timeline mode needs at least one view it can lay out, so a set that removes them all has + // to fall back to Event mode before the active view is re-clamped into the new set. + if (Mode == BitFullCalendarMode.Timeline && !IsTimelineModeAvailable) + Mode = BitFullCalendarMode.Event; + + var clamped = ClampView(View, Mode); + var viewChanged = clamped != View; + if (viewChanged) + View = clamped; + + UpdateUI(); + + if (viewChanged) + NotifyDateRangeChanged(); + } + + private static List NormalizeViews(IReadOnlyList? views) + { + if (views is null || views.Count == 0) + return [.. _allViews]; + + var result = new List(views.Count); + foreach (var view in views) { - BitFullCalendarView.Day or BitFullCalendarView.Week or BitFullCalendarView.Month => view, - _ => BitFullCalendarView.Week - }; + // Values outside the enum would render a blank tab and never match the active view, and + // a repeated entry would render the same tab twice; skip both instead. + if (!Enum.IsDefined(view) || result.Contains(view)) + continue; + + result.Add(view); + } + + return result.Count > 0 ? result : [.. _allViews]; + } + + private bool ViewsMatch(List views) + { + if (_views.Count != views.Count) + return false; + + for (var i = 0; i < _views.Count; i++) + { + if (_views[i] != views[i]) + return false; + } + + return true; } public void SetUse24HourFormat(bool value) @@ -250,8 +378,9 @@ public void SyncResources(IReadOnlyList? resources) // If resources were emptied while Timeline mode is active, fall back to Event mode so the // calendar never stays in the unsupported timeline-without-resources state. Event mode - // supports every view, so no view clamp is needed here (ClampViewForMode is a no-op). - if (Mode == BitFullCalendarMode.Timeline && _resources.Count == 0) + // offers every allowed view and the timeline views are a subset of them, so the active view + // is already valid here and no clamp is needed. + if (Mode == BitFullCalendarMode.Timeline && !IsTimelineModeAvailable) { Mode = BitFullCalendarMode.Event; } @@ -425,6 +554,11 @@ private void PruneInvalidAttendeeFilter() // Drag-and-drop helpers public void StartDrag(BitFullCalendarEvent ev) { + // Single choke point for every drag entry point: a read-only calendar never enters the + // dragging state, so the drop handlers downstream have nothing to commit. + if (ReadOnly) + return; + DraggedEvent = ev; NotifyStateChanged(); } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarDayView.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarDayView.razor index fbfea6ed983..e17ea35e4ec 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarDayView.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarDayView.razor @@ -39,8 +39,8 @@
}
- + @if (!State.ReadOnly) + { + + }
} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarDayView.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarDayView.razor.cs index 50dadc03d55..b0e8fb6e468 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarDayView.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarDayView.razor.cs @@ -25,6 +25,12 @@ public partial class BitFcCalendarDayView : IDisposable private int? _dragHour; private int? _dragMinute; + // The hour slots exist only as add/drop targets, so a read-only grid must not expose 48 + // focusable no-op buttons per day to keyboard and assistive-technology users. A null attribute + // value is omitted from the rendered markup. + private string? _slotRole => State.ReadOnly ? null : "button"; + private string? _slotTabIndex => State.ReadOnly ? null : "0"; + protected override void OnInitialized() { // The "Happening now" panel is derived from DateTime.Now; refresh once a minute so it @@ -56,6 +62,9 @@ private async Task SelectEvent(BitFullCalendarEvent ev) private async Task OnHourClickAsync(int hour, int minute = 0) { + if (State.ReadOnly) + return; + if (OnAddClick.HasDelegate) { var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot(State.SelectedDate, hour, minute); @@ -76,8 +85,12 @@ private async Task OnHourKeyDownAsync(KeyboardEventArgs e, int hour, int minute await OnHourClickAsync(hour, minute); } - private string HourSlotAriaLabel(int hour, int minute = 0) + private string? HourSlotAriaLabel(int hour, int minute = 0) { + // The slot is inert in read-only mode, so it carries no label to announce. + if (State.ReadOnly) + return null; + var start = State.SelectedDate.Date.AddHours(hour).AddMinutes(minute); return $"{Texts.AddEventHoverHint}, {BitFullCalendarHelpers.FormatTime(start, State.Use24HourFormat, State.Culture)}"; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarWeekView.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarWeekView.razor index 183ada5175e..fc511e0f1a0 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarWeekView.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarWeekView.razor @@ -51,8 +51,8 @@
}
- + @if (!State.ReadOnly) + { + + }
} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarWeekView.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarWeekView.razor.cs index 18d1e14e966..cf89e511ed7 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarWeekView.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarWeekView.razor.cs @@ -24,6 +24,12 @@ public partial class BitFcCalendarWeekView private int? _dragHour; private int? _dragMinute; + // The hour slots exist only as add/drop targets, so a read-only grid must not expose hundreds + // of focusable no-op buttons to keyboard and assistive-technology users. A null attribute value + // is omitted from the rendered markup. + private string? _slotRole => State.ReadOnly ? null : "button"; + private string? _slotTabIndex => State.ReadOnly ? null : "0"; + private async Task SelectEvent(BitFullCalendarEvent ev) { if (OnEventClick.HasDelegate) @@ -37,6 +43,11 @@ private async Task SelectEvent(BitFullCalendarEvent ev) private async Task OnHourClickAsync(DateTime day, int hour, int minute = 0) { + // The slot is purely an add affordance, so a read-only grid leaves it inert - including the + // date selection, which the user can still perform from the header and the mini calendar. + if (State.ReadOnly) + return; + State.SetSelectedDate(day); if (OnAddClick.HasDelegate) @@ -59,8 +70,12 @@ private async Task OnHourKeyDownAsync(KeyboardEventArgs e, DateTime day, int hou await OnHourClickAsync(day, hour, minute); } - private string HourSlotAriaLabel(DateTime day, int hour, int minute = 0) + private string? HourSlotAriaLabel(DateTime day, int hour, int minute = 0) { + // The slot is inert in read-only mode, so it carries no label to announce. + if (State.ReadOnly) + return null; + var start = day.Date.AddHours(hour).AddMinutes(minute); return $"{Texts.AddEventHoverHint}, {day.ToString("ddd", State.Culture)} {BitFullCalendarHelpers.FormatTime(start, State.Use24HourFormat, State.Culture)}"; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcEventBlock.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcEventBlock.razor index 4bb5c7236f1..d96e3a006ae 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcEventBlock.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcEventBlock.razor @@ -30,7 +30,7 @@ title="@tooltip" role="button" tabindex="0" - draggable="true" + draggable="@(State.ReadOnly ? "false" : "true")" @ondragstart="OnDragStart" @ondragend="OnDragEnd" @onclick="OnClick" @@ -38,11 +38,14 @@ @onclick:stopPropagation="true" @onkeydown:stopPropagation="true"> -
+ @if (!State.ReadOnly) + { +
+ } @if (EventTemplate != null) { @@ -66,11 +69,14 @@ } } -
+ @if (!State.ReadOnly) + { +
+ } @if (_isResizing) { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcEventBlock.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcEventBlock.razor.cs index 54b07c7c3ec..4d75386bc36 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcEventBlock.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcEventBlock.razor.cs @@ -62,6 +62,16 @@ private async Task OnKeyDown(KeyboardEventArgs e) protected override async Task OnAfterRenderAsync(bool firstRender) { + // A read-only block renders no resize handles, so there is nothing to bind the JS listeners + // to. The handles are removed from the DOM (taking their listeners with them), so the flag + // is cleared as well - otherwise the fresh handles rendered when read-only is turned back + // off would be skipped here and never receive listeners. + if (State.ReadOnly) + { + _resizeInitialized = false; + return; + } + if (_resizeInitialized) return; @@ -86,6 +96,11 @@ public void OnResizeStart(string direction) if (direction is not ("top" or "bottom")) return; + // The handles are not rendered while read-only, but a listener bound before the switch can + // still deliver a start; refuse it so the block never enters resize mode in read-only. + if (State.ReadOnly) + return; + _isResizing = true; _resizeDirection = direction; _resizeBaseEvent = Event; @@ -102,6 +117,23 @@ public Task OnResizeMove(string direction, int deltaMinutes) if (!_isResizing || _resizeBaseEvent == null) return Task.CompletedTask; + // Read-only can be switched on mid-gesture: the handle leaves the DOM, but the document-level + // pointer listeners keep running. Cancel the whole gesture (not just the preview) so the block + // snaps back to the stored times and stays there - keeping the resize alive would let it pick + // up again, and commit on release, if read-only were switched back off before the pointer up. + if (State.ReadOnly) + { + _previewStart = null; + _previewEnd = null; + _isResizing = false; + _resizeBaseEvent = null; + _resizeDirection = null; + // The pointer is still down: swallow the click its release produces so cancelling a resize + // doesn't select the event. + _suppressClickUntilUtc = DateTime.UtcNow.AddMilliseconds(300); + return InvokeAsync(StateHasChanged); + } + // Finger back at (or very near) the grab point → show the original span again and cancel // any in-progress preview so the user can "undo" without releasing early. if (deltaMinutes == 0 || Math.Abs(deltaMinutes) <= ResizeDeadZoneMinutes) @@ -175,7 +207,9 @@ public async Task OnResizeEnd() { try { - if (_resizeBaseEvent != null && _previewStart.HasValue && _previewEnd.HasValue) + // Never commit in read-only: the switch can land between the last move and the release, + // which would otherwise persist a resize the calendar no longer allows. + if (State.ReadOnly is false && _resizeBaseEvent != null && _previewStart.HasValue && _previewEnd.HasValue) { var s = _previewStart.Value; var e = _previewEnd.Value; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcDayCell.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcDayCell.razor index 5f27b79e04e..f8e9f873eb8 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcDayCell.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcDayCell.razor @@ -12,13 +12,16 @@ @onclick="() => OnCellClick()">
@Cell.Day
- + @if (!State.ReadOnly) + { + + }
@for (int i = 0; i < 3; i++) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcDayCell.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcDayCell.razor.cs index 0de55c2020f..23f3e63ed3d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcDayCell.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcDayCell.razor.cs @@ -31,6 +31,11 @@ private async Task ShowEventDetails(BitFullCalendarEvent ev) private async Task OnCellClick() { State.SetSelectedDate(Cell.Date); + // Selecting the date above is navigation and stays available in read-only mode; only the + // add affordance behind the same click is suppressed. + if (State.ReadOnly) + return; + // Build the draft once and use it for both the external add handler and the built-in dialog // fallback so they always agree on the start date/time. Seed from the calendar's start-of-day // hour (matching the other month-view add entry points) instead of DateTime.Now.Hour. diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcMonthEventBadge.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcMonthEventBadge.razor index d89b036ff41..f7b8036f29d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcMonthEventBadge.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcMonthEventBadge.razor @@ -32,7 +32,7 @@ role="button" tabindex="0" aria-label="@badgeAriaLabel" - draggable="true" + draggable="@(State.ReadOnly ? "false" : "true")" @ondragstart="OnDragStart" @ondragend="OnDragEnd" @onclick="() => OnSelected.InvokeAsync(Event)" diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineDayView.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineDayView.razor index fc1129c7ddb..ee0f9b87a58 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineDayView.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineDayView.razor @@ -64,8 +64,8 @@ style="inset-inline-start:@(hour * hourWidth)px;width:@(hourWidth)px;">
- + @if (!State.ReadOnly) + { + + }
} @@ -106,8 +109,8 @@ style="inset-inline-start:@(hour * hourWidth)px;width:@(hourWidth)px;">
- + @if (!State.ReadOnly) + { + + } } @if (hasUnassigned && grouped.TryGetValue(_unassignedKey, out var unassignedLanes)) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineDayView.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineDayView.razor.cs index 3694b525ad7..1c56d1669ee 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineDayView.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineDayView.razor.cs @@ -30,6 +30,11 @@ public partial class BitFcTimelineDayView private int? _dragHour; private int? _dragMinute; + // The slots exist only as add/drop targets, so a read-only timeline must not expose a focusable + // no-op button per half hour and resource. A null attribute value is omitted from the markup. + private string? _slotRole => State.ReadOnly ? null : "button"; + private string? _slotTabIndex => State.ReadOnly ? null : "0"; + private RenderFragment RenderLanes(List> lanes) => builder => { var inv = System.Globalization.CultureInfo.InvariantCulture; @@ -78,6 +83,9 @@ private async Task SelectEvent(BitFullCalendarEvent ev) private async Task OnSlotClickAsync(string resourceId, int hour, int minute) { + if (State.ReadOnly) + return; + if (OnAddClick.HasDelegate) { var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot(State.SelectedDate, hour, minute); @@ -101,8 +109,12 @@ private async Task OnSlotKeyDownAsync(KeyboardEventArgs e, string resourceId, in await OnSlotClickAsync(resourceId, hour, minute); } - private string SlotAriaLabel(string rowLabel, int hour, int minute) + private string? SlotAriaLabel(string rowLabel, int hour, int minute) { + // The slot is inert in read-only mode, so it carries no label to announce. + if (State.ReadOnly) + return null; + var start = State.SelectedDate.Date.AddHours(hour).AddMinutes(minute); return $"{Texts.AddEventHoverHint}, {rowLabel}, {BitFullCalendarHelpers.FormatTime(start, State.Use24HourFormat, State.Culture)}"; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineEventBlock.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineEventBlock.razor index 09c6f842ee2..9a7d9876d19 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineEventBlock.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineEventBlock.razor @@ -34,14 +34,14 @@ } var blockStyle = $"{colorStyleVar}{widthOverride}transform:translateX({translateXPx.ToString("F2", inv)}px);"; - var canResize = PixelsPerMinute > 0; + var canResize = PixelsPerMinute > 0 && !State.ReadOnly; }
- + @if (!State.ReadOnly) + { + + }
} @@ -118,8 +121,8 @@ var isPreview = State.IsDragging && _dragResourceId == rowKey && _dragDay == day.Date;
State.ReadOnly ? null : "button"; + private string? _slotTabIndex => State.ReadOnly ? null : "0"; + private RenderFragment RenderLanes(List> lanes, DateTime monthStart, int daysInMonth) => builder => { var inv = System.Globalization.CultureInfo.InvariantCulture; @@ -90,6 +95,9 @@ private async Task SelectEvent(BitFullCalendarEvent ev) private async Task OnSlotClickAsync(string resourceId, DateTime day) { + if (State.ReadOnly) + return; + if (OnAddClick.HasDelegate) { var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot(day, State.StartOfDayHour); @@ -112,8 +120,12 @@ private async Task OnSlotKeyDownAsync(KeyboardEventArgs e, string resourceId, Da await OnSlotClickAsync(resourceId, day); } - private string SlotAriaLabel(DateTime day, string rowLabel) + private string? SlotAriaLabel(DateTime day, string rowLabel) { + // The cell is inert in read-only mode, so it carries no label to announce. + if (State.ReadOnly) + return null; + return $"{Texts.AddEventHoverHint}, {rowLabel}, {day.ToString("D", State.Culture)}"; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineWeekView.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineWeekView.razor index b7d3f54ff6b..3bfaf010c7e 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineWeekView.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineWeekView.razor @@ -111,8 +111,8 @@
- + @if (!State.ReadOnly) + { + + }
} @@ -160,8 +163,8 @@
State.ReadOnly ? null : "button"; + private string? _slotTabIndex => State.ReadOnly ? null : "0"; + private RenderFragment RenderLanes(List> lanes, DateTime day, int dayOffsetPx) => builder => { var inv = System.Globalization.CultureInfo.InvariantCulture; @@ -79,6 +84,9 @@ private async Task SelectEvent(BitFullCalendarEvent ev) private async Task OnSlotClickAsync(string resourceId, DateTime day, int hour, int minute) { + if (State.ReadOnly) + return; + if (OnAddClick.HasDelegate) { var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot(day, hour, minute); @@ -102,8 +110,12 @@ private async Task OnSlotKeyDownAsync(KeyboardEventArgs e, string resourceId, Da await OnSlotClickAsync(resourceId, day, hour, minute); } - private string SlotAriaLabel(string rowLabel, DateTime day, int hour, int minute) + private string? SlotAriaLabel(string rowLabel, DateTime day, int hour, int minute) { + // The slot is inert in read-only mode, so it carries no label to announce. + if (State.ReadOnly) + return null; + var start = day.Date.AddHours(hour).AddMinutes(minute); return $"{Texts.AddEventHoverHint}, {rowLabel}, {day.ToString("ddd", State.Culture)} {BitFullCalendarHelpers.FormatTime(start, State.Use24HourFormat, State.Culture)}"; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/YearView/BitFcCalendarYearView.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/YearView/BitFcCalendarYearView.razor.cs index 8743dde1c96..90a6348bc58 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/YearView/BitFcCalendarYearView.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/YearView/BitFcCalendarYearView.razor.cs @@ -13,7 +13,11 @@ public partial class BitFcCalendarYearView private void GoToMonth(DateTime month) { State.SetSelectedDate(month); - State.SetView(BitFullCalendarView.Month); + + // Drilling into a month is an indirect route to the month view; when the consumer excluded + // it, navigating the date is all this does rather than landing on some other view. + if (State.IsViewAvailable(BitFullCalendarView.Month)) + State.SetView(BitFullCalendarView.Month); } private void ShowEventsForDay(DateTime date, List events) diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/FullCalendar/BitFullCalendarDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/FullCalendar/BitFullCalendarDemo.razor index 8ae107cb2b0..ce0915b5b8a 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/FullCalendar/BitFullCalendarDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/FullCalendar/BitFullCalendarDemo.razor @@ -171,6 +171,41 @@
+ + +
+ Restrict which views the calendar offers - and in which order they appear - with the + Views parameter. Excluded views become unreachable: their tabs are gone, + and the active view is clamped into the list no matter how it was requested. A single + allowed view collapses the tab strip altogether. +
+
+ + + + + +
+ +
+ + +
+ Present the calendar without letting anyone change it. ReadOnly hides the + "Add Event" button and the per-cell add affordances, stops events from being dragged or + resized, and drops the edit and delete actions from the event details dialog. + Navigating dates, switching views, filtering, the settings panel, and opening an event + to read its details all keep working. +
+
+ +
+ +
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/FullCalendar/BitFullCalendarDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/FullCalendar/BitFullCalendarDemo.razor.cs index 4d6f470e1e0..1ac2cfd06f8 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/FullCalendar/BitFullCalendarDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/FullCalendar/BitFullCalendarDemo.razor.cs @@ -164,6 +164,13 @@ public partial class BitFullCalendarDemo Href = "#view-enum", }, new() + { + Name = "ReadOnly", + Type = "bool", + DefaultValue = "false", + Description = "When true, the calendar becomes presentation-only: the add button and per-cell add affordances are hidden, drag-and-drop and resizing are disabled, and the edit/delete actions are removed from the event details dialog. Navigation, view/mode switching, filtering, and reading event details keep working.", + }, + new() { Name = "Resources", Type = "IReadOnlyList?", @@ -209,6 +216,15 @@ public partial class BitFullCalendarDemo Href = "#view-enum", }, new() + { + Name = "Views", + Type = "IReadOnlyList?", + DefaultValue = "null", + Description = "The views the calendar offers, in the order the view tabs render them. When null or empty, every view (Day, Week, Month, Year, Agenda) is offered in that order; unknown and repeated entries are ignored. Excluded views are unreachable - the tabs omit them and View, DefaultView, and the indirect navigation paths are clamped into the list. The tab strip is hidden when a single view is left, and Timeline mode is unavailable when none of Day, Week, or Month is listed.", + LinkType = LinkType.Link, + Href = "#view-enum", + }, + new() { Name = "WeekEventTemplate", Type = "RenderFragment?", @@ -508,6 +524,19 @@ public partial class BitFullCalendarDemo private readonly List changeEvents = CreateEvents(); private readonly List localizationEvents = CreateEvents(); private readonly List layoutEvents = CreateEvents(); + private readonly List viewsEvents = CreateEvents(); + private readonly List readOnlyEvents = CreateEvents(); + + private bool isReadOnly = true; + + private string viewsPreset = "week-day"; + + private BitFullCalendarView[] SelectedViews => viewsPreset switch + { + "month-agenda" => [BitFullCalendarView.Month, BitFullCalendarView.Agenda], + "month" => [BitFullCalendarView.Month], + _ => [BitFullCalendarView.Week, BitFullCalendarView.Day] + }; private BitFullCalendarEventLayout layoutMode = BitFullCalendarEventLayout.Stack; private BitFullCalendarSettings layoutSettings = new() @@ -967,6 +996,39 @@ private Task HandleChange(BitFullCalendarChangeEventArgs args) private readonly string example9RazorCode = @" @code {" + eventsCode + @" +}"; + + private readonly string example10RazorCode = @""" + TValue=""string"" + @bind-Value=""viewsPreset""> + + + + +
+ + +@code { + private string viewsPreset = ""week-day""; + + private BitFullCalendarView[] SelectedViews => viewsPreset switch + { + ""month-agenda"" => [BitFullCalendarView.Month, BitFullCalendarView.Agenda], + ""month"" => [BitFullCalendarView.Month], + _ => [BitFullCalendarView.Week, BitFullCalendarView.Day] + }; +" + eventsCode + @" +}"; + + private readonly string example11RazorCode = @" +
+ + +@code { + private bool isReadOnly = true; +" + eventsCode + @" }"; private readonly string example7RazorCode = @" +/// Covers the view-restriction and read-only rules on the shared calendar state, which is where +/// every entry point (view tabs, bound parameters, indirect navigation) is funnelled through. +/// +[TestClass] +public class BitFullCalendarStateTests +{ + private static BitFullCalendarState CreateState() + { + var state = new BitFullCalendarState(); + state.Initialize([]); + return state; + } + + private static List Resources() => + [ + new() { Id = "r1", Title = "Room 1" } + ]; + + [TestMethod] + public void ViewsShouldDefaultToEveryViewInDeclarationOrder() + { + var state = CreateState(); + + CollectionAssert.AreEqual( + new[] + { + BitFullCalendarView.Day, + BitFullCalendarView.Week, + BitFullCalendarView.Month, + BitFullCalendarView.Year, + BitFullCalendarView.Agenda + }, + state.Views.ToArray()); + } + + [TestMethod] + public void SyncViewsShouldKeepTheSuppliedOrder() + { + var state = CreateState(); + + state.SyncViews([BitFullCalendarView.Agenda, BitFullCalendarView.Day]); + + CollectionAssert.AreEqual( + new[] { BitFullCalendarView.Agenda, BitFullCalendarView.Day }, + state.Views.ToArray()); + } + + [TestMethod] + public void SyncViewsShouldRestoreEveryViewForNullOrEmpty() + { + var state = CreateState(); + state.SyncViews([BitFullCalendarView.Day]); + + state.SyncViews(null); + Assert.AreEqual(5, state.Views.Count); + + state.SyncViews([BitFullCalendarView.Day]); + state.SyncViews([]); + Assert.AreEqual(5, state.Views.Count); + } + + [TestMethod] + public void SyncViewsShouldDropRepeatedAndUndefinedEntries() + { + var state = CreateState(); + + state.SyncViews( + [ + BitFullCalendarView.Week, + BitFullCalendarView.Week, + (BitFullCalendarView)42, + BitFullCalendarView.Day + ]); + + CollectionAssert.AreEqual( + new[] { BitFullCalendarView.Week, BitFullCalendarView.Day }, + state.Views.ToArray()); + } + + [TestMethod] + public void SyncViewsShouldFallBackToEveryViewWhenNothingSurvivesNormalization() + { + var state = CreateState(); + + state.SyncViews([(BitFullCalendarView)42, (BitFullCalendarView)43]); + + Assert.AreEqual(5, state.Views.Count); + } + + [TestMethod] + public void SyncViewsShouldClampTheActiveViewIntoTheNewSet() + { + var state = CreateState(); + Assert.AreEqual(BitFullCalendarView.Month, state.View); + + state.SyncViews([BitFullCalendarView.Week, BitFullCalendarView.Day]); + + Assert.AreEqual(BitFullCalendarView.Week, state.View); + } + + [TestMethod] + public void SyncViewsShouldLeaveAnAllowedActiveViewAlone() + { + var state = CreateState(); + + state.SyncViews([BitFullCalendarView.Agenda, BitFullCalendarView.Month]); + + Assert.AreEqual(BitFullCalendarView.Month, state.View); + } + + [TestMethod] + public void SetViewShouldClampAnExcludedViewToTheFirstAllowedOne() + { + var state = CreateState(); + state.SyncViews([BitFullCalendarView.Week, BitFullCalendarView.Day]); + + state.SetView(BitFullCalendarView.Year); + + Assert.AreEqual(BitFullCalendarView.Week, state.View); + } + + [TestMethod] + public void IsViewAvailableShouldReflectTheAllowedSet() + { + var state = CreateState(); + state.SyncViews([BitFullCalendarView.Week, BitFullCalendarView.Day]); + + Assert.IsTrue(state.IsViewAvailable(BitFullCalendarView.Week)); + Assert.IsFalse(state.IsViewAvailable(BitFullCalendarView.Month)); + } + + [TestMethod] + public void AvailableViewsShouldDropTheNonTimelineViewsInTimelineMode() + { + var state = CreateState(); + state.SyncResources(Resources()); + state.SetMode(BitFullCalendarMode.Timeline); + + CollectionAssert.AreEqual( + new[] { BitFullCalendarView.Day, BitFullCalendarView.Week, BitFullCalendarView.Month }, + state.AvailableViews.ToArray()); + } + + [TestMethod] + public void TimelineModeShouldKeepFallingBackToTheWeekLayoutWhenWeekIsAllowed() + { + var state = CreateState(); + state.SyncResources(Resources()); + state.SetView(BitFullCalendarView.Year); + + state.SetMode(BitFullCalendarMode.Timeline); + + Assert.AreEqual(BitFullCalendarView.Week, state.View); + } + + [TestMethod] + public void TimelineModeShouldFallBackToTheFirstAllowedViewWhenWeekIsExcluded() + { + var state = CreateState(); + state.SyncResources(Resources()); + state.SyncViews([BitFullCalendarView.Year, BitFullCalendarView.Month, BitFullCalendarView.Day]); + state.SetView(BitFullCalendarView.Year); + + state.SetMode(BitFullCalendarMode.Timeline); + + Assert.AreEqual(BitFullCalendarMode.Timeline, state.Mode); + // Week is not allowed, so the clamp lands on the first allowed timeline view instead. + Assert.AreEqual(BitFullCalendarView.Month, state.View); + } + + [TestMethod] + public void TimelineModeShouldBeUnavailableWithoutResources() + { + var state = CreateState(); + + Assert.IsFalse(state.IsTimelineModeAvailable); + + state.SetMode(BitFullCalendarMode.Timeline); + + Assert.AreEqual(BitFullCalendarMode.Event, state.Mode); + } + + [TestMethod] + public void TimelineModeShouldBeUnavailableWhenNoAllowedViewSupportsIt() + { + var state = CreateState(); + state.SyncResources(Resources()); + state.SyncViews([BitFullCalendarView.Year, BitFullCalendarView.Agenda]); + + Assert.IsFalse(state.IsTimelineModeAvailable); + + state.SetMode(BitFullCalendarMode.Timeline); + + Assert.AreEqual(BitFullCalendarMode.Event, state.Mode); + } + + [TestMethod] + public void SyncViewsShouldLeaveTimelineModeWhenItRemovesEveryTimelineView() + { + var state = CreateState(); + state.SyncResources(Resources()); + state.SetMode(BitFullCalendarMode.Timeline); + Assert.AreEqual(BitFullCalendarMode.Timeline, state.Mode); + + state.SyncViews([BitFullCalendarView.Year, BitFullCalendarView.Agenda]); + + Assert.AreEqual(BitFullCalendarMode.Event, state.Mode); + Assert.AreEqual(BitFullCalendarView.Year, state.View); + } + + [TestMethod] + public void SyncViewsShouldNotNotifyWhenTheSetIsUnchanged() + { + var state = CreateState(); + state.SyncViews([BitFullCalendarView.Week, BitFullCalendarView.Day]); + + var notifications = 0; + state.OnStateChanged += () => notifications++; + + // A fresh list with the same contents must short-circuit: the parameter is re-supplied on + // every OnParametersSet, and re-notifying there would loop the render. + state.SyncViews([BitFullCalendarView.Week, BitFullCalendarView.Day]); + + Assert.AreEqual(0, notifications); + } + + [TestMethod] + public void SetReadOnlyShouldBlockDragStart() + { + var state = CreateState(); + var ev = new BitFullCalendarEvent { Id = "1", Title = "Standup" }; + + state.SetReadOnly(true); + state.StartDrag(ev); + + Assert.IsFalse(state.IsDragging); + Assert.IsNull(state.DraggedEvent); + } + + [TestMethod] + public void SetReadOnlyShouldDropADragThatIsAlreadyInFlight() + { + var state = CreateState(); + var ev = new BitFullCalendarEvent { Id = "1", Title = "Standup" }; + state.StartDrag(ev); + Assert.IsTrue(state.IsDragging); + + state.SetReadOnly(true); + + Assert.IsFalse(state.IsDragging); + } + + [TestMethod] + public void ReadOnlyDropShouldNotMoveTheEvent() + { + var start = new System.DateTime(2026, 8, 13, 9, 0, 0); + var ev = new BitFullCalendarEvent { Id = "1", Title = "Standup", StartDate = start, EndDate = start.AddHours(1) }; + var state = new BitFullCalendarState(); + state.Initialize([ev]); + + state.SetReadOnly(true); + state.StartDrag(ev); + state.HandleDrop(start.Date.AddDays(1), 14, 0); + + var stored = state.AllEvents.Single(); + Assert.AreEqual(start, stored.StartDate); + } + + [TestMethod] + public void SetReadOnlyShouldRoundTrip() + { + var state = CreateState(); + Assert.IsFalse(state.ReadOnly); + + state.SetReadOnly(true); + Assert.IsTrue(state.ReadOnly); + + state.SetReadOnly(false); + Assert.IsFalse(state.ReadOnly); + + var ev = new BitFullCalendarEvent { Id = "1", Title = "Standup" }; + state.StartDrag(ev); + Assert.IsTrue(state.IsDragging); + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/FullCalendar/BitFullCalendarTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/FullCalendar/BitFullCalendarTests.cs new file mode 100644 index 00000000000..353117db638 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/FullCalendar/BitFullCalendarTests.cs @@ -0,0 +1,552 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Components.Extras.FullCalendar; + +/// +/// Covers the rendered surface of the ReadOnly and Views parameters: which affordances reach the +/// DOM, and which ones stay out of reach of the pointer and the keyboard. +/// +[TestClass] +public class BitFullCalendarTests : BunitTestContext +{ + private const string AddButtonSelector = ".bit-bfc-header-right .bit-bfc-btn-primary"; + + private static List Events() + { + var today = DateTime.Today; + return + [ + new() { Id = "1", Title = "Standup", Description = "Daily sync", StartDate = today.AddHours(9), EndDate = today.AddHours(10) } + ]; + } + + private static List Resources() => + [ + new() { Id = "r1", Title = "Room 1" } + ]; + + /// + /// Renders a calendar over the shared event, carrying only the parameters the caller asks for so + /// each test reads as the configuration it is about. Tests that bind a parameter or hook a + /// callback build their own parameter set instead. + /// + private IRenderedComponent RenderCalendar(bool readOnly = false, + BitFullCalendarView? defaultView = null, + IReadOnlyList? views = null, + bool resources = false, + BitFullCalendarMode? defaultMode = null) + { + return RenderComponent(parameters => + { + parameters.Add(p => p.Events, Events()); + + if (readOnly) + { + parameters.Add(p => p.ReadOnly, true); + } + + if (defaultView is not null) + { + parameters.Add(p => p.DefaultView, defaultView); + } + + if (views is not null) + { + parameters.Add(p => p.Views, views); + } + + if (resources) + { + parameters.Add(p => p.Resources, Resources()); + } + + if (defaultMode is not null) + { + parameters.Add(p => p.DefaultMode, defaultMode); + } + }); + } + + #region ReadOnly + + [TestMethod] + public void BitFullCalendarShouldRenderTheAddAffordancesByDefault() + { + var component = RenderCalendar(); + + Assert.IsFalse(component.Find(".bit-bfc").ClassList.Contains("bit-bfc-readonly")); + Assert.AreEqual(1, component.FindAll(AddButtonSelector).Count); + Assert.IsTrue(component.FindAll(".bit-bfc-cell-add-hint").Count > 0); + } + + [TestMethod] + public void BitFullCalendarReadOnlyShouldHideTheAddButtonAndCellHints() + { + var component = RenderCalendar(readOnly: true); + + Assert.IsTrue(component.Find(".bit-bfc").ClassList.Contains("bit-bfc-readonly")); + Assert.AreEqual(0, component.FindAll(AddButtonSelector).Count); + Assert.AreEqual(0, component.FindAll(".bit-bfc-cell-add-hint").Count); + } + + [TestMethod] + public void BitFullCalendarReadOnlyShouldKeepNavigationAndFiltering() + { + var component = RenderCalendar(readOnly: true); + + // Everything that does not modify events has to survive read-only. + Assert.AreEqual(5, component.FindAll(".bit-bfc-view-tab").Count); + Assert.IsNotNull(component.Find(".bit-bfc-header-left")); + Assert.IsTrue(component.FindAll(".bit-bfc-header-right .bit-bfc-dropdown").Count > 0); + } + + [TestMethod] + public void BitFullCalendarReadOnlyShouldMakeMonthEventsNonDraggable() + { + var editable = RenderCalendar(); + Assert.AreEqual("true", editable.Find(".bit-bfc-event-badge").GetAttribute("draggable")); + + var readOnly = RenderCalendar(readOnly: true); + + Assert.AreEqual("false", readOnly.Find(".bit-bfc-event-badge").GetAttribute("draggable")); + } + + [TestMethod] + public void BitFullCalendarReadOnlyShouldNotOpenTheAddDialogFromAMonthCell() + { + var component = RenderCalendar(readOnly: true); + + component.Find(".bit-bfc-month-cell").Click(); + + // The add/edit dialog is the only one carrying form fields. + Assert.AreEqual(0, component.FindAll(".bit-bfc-field").Count); + } + + [TestMethod] + public void BitFullCalendarShouldOpenTheAddDialogFromAMonthCellWhenEditable() + { + var component = RenderCalendar(); + + component.Find(".bit-bfc-month-cell").Click(); + + Assert.IsTrue(component.FindAll(".bit-bfc-field").Count > 0); + } + + [TestMethod] + public void BitFullCalendarReadOnlyShouldNotRaiseOnAddClick() + { + var editableClicks = 0; + var editable = RenderComponent(parameters => + { + parameters.Add(p => p.Events, Events()); + parameters.Add(p => p.OnAddClick, EventCallback.Factory.Create(this, _ => editableClicks++)); + }); + editable.Find(".bit-bfc-month-cell").Click(); + Assert.AreEqual(1, editableClicks); + + var readOnlyClicks = 0; + var readOnly = RenderComponent(parameters => + { + parameters.Add(p => p.Events, Events()); + parameters.Add(p => p.ReadOnly, true); + parameters.Add(p => p.OnAddClick, EventCallback.Factory.Create(this, _ => readOnlyClicks++)); + }); + readOnly.Find(".bit-bfc-month-cell").Click(); + + Assert.AreEqual(0, readOnlyClicks); + } + + [TestMethod] + public void BitFullCalendarReadOnlyShouldDropEditAndDeleteFromTheEventDetails() + { + var editable = RenderCalendar(); + editable.Find(".bit-bfc-event-badge").Click(); + Assert.AreEqual(1, editable.FindAll(".bit-bfc-dialog-footer .bit-bfc-btn-danger").Count); + Assert.AreEqual(3, editable.FindAll(".bit-bfc-dialog-footer button").Count); + + var readOnly = RenderCalendar(readOnly: true); + readOnly.Find(".bit-bfc-event-badge").Click(); + + // The details stay readable; only the mutating actions go, leaving Close on its own. + Assert.IsNotNull(readOnly.Find(".bit-bfc-dialog")); + Assert.AreEqual(0, readOnly.FindAll(".bit-bfc-dialog-footer .bit-bfc-btn-danger").Count); + Assert.AreEqual(1, readOnly.FindAll(".bit-bfc-dialog-footer button").Count); + } + + [TestMethod] + public void BitFullCalendarShouldCloseAnOpenEditDialogWhenReadOnlyIsTurnedOn() + { + var changes = new List(); + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Events, Events()); + parameters.Add(p => p.OnChange, EventCallback.Factory.Create(this, changes.Add)); + }); + + component.Find(".bit-bfc-event-badge").Click(); + // Details footer while editable: Edit, Delete, Close. + component.FindAll(".bit-bfc-dialog-footer button")[0].Click(); + // The edit dialog opens on top of the details dialog, and it owns the only Save button. + Assert.AreEqual(2, component.FindAll(".bit-bfc-dialog").Count); + Assert.AreEqual(1, component.FindAll(".bit-bfc-dialog-footer .bit-bfc-btn-primary").Count); + + // Read-only arrives while the form is open: every entry point only checks read-only when it + // opens the dialog, so the open form has to close itself instead of staying live. + component.Render(parameters => parameters.Add(p => p.ReadOnly, true)); + + Assert.AreEqual(1, component.FindAll(".bit-bfc-dialog").Count); + Assert.AreEqual(0, component.FindAll(".bit-bfc-dialog-footer .bit-bfc-btn-primary").Count); + Assert.AreEqual(0, changes.Count); + } + + [TestMethod] + public void BitFullCalendarReadOnlyShouldStripTheHourSlotButtonSemantics() + { + var editable = RenderCalendar(defaultView: BitFullCalendarView.Day); + var editableSlot = editable.Find(".bit-bfc-hour-slot"); + Assert.AreEqual("button", editableSlot.GetAttribute("role")); + Assert.AreEqual("0", editableSlot.GetAttribute("tabindex")); + + var readOnly = RenderCalendar(readOnly: true, defaultView: BitFullCalendarView.Day); + + // An inert slot must not be announced or reachable by keyboard. + var readOnlySlot = readOnly.Find(".bit-bfc-hour-slot"); + Assert.IsNull(readOnlySlot.GetAttribute("role")); + Assert.IsNull(readOnlySlot.GetAttribute("tabindex")); + Assert.IsNull(readOnlySlot.GetAttribute("aria-label")); + } + + [TestMethod] + public void BitFullCalendarReadOnlyShouldRemoveTheResizeHandles() + { + var editable = RenderCalendar(defaultView: BitFullCalendarView.Day); + Assert.AreEqual(2, editable.FindAll(".bit-bfc-resize-handle").Count); + + var readOnly = RenderCalendar(readOnly: true, defaultView: BitFullCalendarView.Day); + + Assert.AreEqual(0, readOnly.FindAll(".bit-bfc-resize-handle").Count); + Assert.AreEqual("false", readOnly.Find(".bit-bfc-event-block").GetAttribute("draggable")); + } + + [TestMethod] + public async Task BitFullCalendarShouldNotResumeADayResizeThatCrossedAReadOnlySwitch() + { + var changes = new List(); + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Events, Events()); + parameters.Add(p => p.DefaultView, BitFullCalendarView.Day); + parameters.Add(p => p.OnChange, EventCallback.Factory.Create(this, changes.Add)); + }); + + var block = component.FindComponent().Instance; + await component.InvokeAsync(() => block.OnResizeStart("bottom")); + await component.InvokeAsync(() => block.OnResizeMove("bottom", 60)); + Assert.AreEqual(1, component.FindAll(".bit-bfc-resize-preview").Count); + + // Read-only lands mid-gesture: the preview goes away and the gesture is cancelled outright. + component.Render(parameters => parameters.Add(p => p.ReadOnly, true)); + Assert.AreSame(block, component.FindComponent().Instance); + await component.InvokeAsync(() => block.OnResizeMove("bottom", 90)); + Assert.AreEqual(0, component.FindAll(".bit-bfc-resize-preview").Count); + + // Read-only switched back off before the pointer is released: the cancelled gesture must not + // pick up again, so neither the remaining moves nor the release may change the event. + component.Render(parameters => parameters.Add(p => p.ReadOnly, false)); + await component.InvokeAsync(() => block.OnResizeMove("bottom", 120)); + await component.InvokeAsync(block.OnResizeEnd); + + Assert.AreEqual(0, changes.Count); + Assert.AreEqual(0, component.FindAll(".bit-bfc-resize-preview").Count); + + // A brand new gesture still resizes: cancelling must not wedge the block. + await component.InvokeAsync(() => block.OnResizeStart("bottom")); + await component.InvokeAsync(() => block.OnResizeMove("bottom", 60)); + await component.InvokeAsync(block.OnResizeEnd); + + Assert.AreEqual(1, changes.Count); + Assert.AreEqual(BitFullCalendarChangeSource.Resize, changes[0].Source); + } + + [TestMethod] + public async Task BitFullCalendarShouldNotResumeATimelineResizeThatCrossedAReadOnlySwitch() + { + var changes = new List(); + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Events, Events()); + parameters.Add(p => p.Resources, Resources()); + parameters.Add(p => p.DefaultMode, BitFullCalendarMode.Timeline); + parameters.Add(p => p.DefaultView, BitFullCalendarView.Day); + parameters.Add(p => p.OnChange, EventCallback.Factory.Create(this, changes.Add)); + }); + + var block = component.FindComponent().Instance; + await component.InvokeAsync(() => block.OnResizeStart("end")); + await component.InvokeAsync(() => block.OnResizeMove("end", 200)); + Assert.AreEqual(1, component.FindAll(".bit-bfc-resize-preview").Count); + + component.Render(parameters => parameters.Add(p => p.ReadOnly, true)); + Assert.AreSame(block, component.FindComponent().Instance); + await component.InvokeAsync(() => block.OnResizeMove("end", 240)); + Assert.AreEqual(0, component.FindAll(".bit-bfc-resize-preview").Count); + + component.Render(parameters => parameters.Add(p => p.ReadOnly, false)); + await component.InvokeAsync(() => block.OnResizeMove("end", 280)); + await component.InvokeAsync(block.OnResizeEnd); + + Assert.AreEqual(0, changes.Count); + Assert.AreEqual(0, component.FindAll(".bit-bfc-resize-preview").Count); + + await component.InvokeAsync(() => block.OnResizeStart("end")); + await component.InvokeAsync(() => block.OnResizeMove("end", 200)); + await component.InvokeAsync(block.OnResizeEnd); + + Assert.AreEqual(1, changes.Count); + Assert.AreEqual(BitFullCalendarChangeSource.Resize, changes[0].Source); + } + + [TestMethod] + public void BitFullCalendarReadOnlyShouldStripTheTimelineSlotButtonSemantics() + { + var editable = RenderCalendar(defaultView: BitFullCalendarView.Day, resources: true, defaultMode: BitFullCalendarMode.Timeline); + Assert.AreEqual("button", editable.Find(".bit-bfc-tl-cell-slot").GetAttribute("role")); + Assert.IsTrue(editable.FindAll(".bit-bfc-cell-add-hint").Count > 0); + + var readOnly = RenderCalendar(readOnly: true, defaultView: BitFullCalendarView.Day, resources: true, defaultMode: BitFullCalendarMode.Timeline); + + var slot = readOnly.Find(".bit-bfc-tl-cell-slot"); + Assert.IsNull(slot.GetAttribute("role")); + Assert.IsNull(slot.GetAttribute("tabindex")); + Assert.AreEqual(0, readOnly.FindAll(".bit-bfc-cell-add-hint").Count); + Assert.AreEqual("false", readOnly.Find(".bit-bfc-timeline-event").GetAttribute("draggable")); + } + + [TestMethod] + public void BitFullCalendarShouldRestoreTheAffordancesWhenReadOnlyIsTurnedOff() + { + var component = RenderCalendar(readOnly: true); + Assert.AreEqual(0, component.FindAll(AddButtonSelector).Count); + + component.Render(parameters => + { + parameters.Add(p => p.Events, Events()); + parameters.Add(p => p.ReadOnly, false); + }); + + Assert.AreEqual(1, component.FindAll(AddButtonSelector).Count); + Assert.AreEqual("true", component.Find(".bit-bfc-event-badge").GetAttribute("draggable")); + Assert.IsFalse(component.Find(".bit-bfc").ClassList.Contains("bit-bfc-readonly")); + } + + #endregion + + #region Views + + [TestMethod] + public void BitFullCalendarShouldRenderEveryViewTabByDefault() + { + var component = RenderCalendar(); + + var labels = component.FindAll(".bit-bfc-view-tab").Select(t => t.TextContent.Trim()).ToArray(); + + CollectionAssert.AreEqual(new[] { "Day", "Week", "Month", "Year", "Agenda" }, labels); + } + + [TestMethod] + public void BitFullCalendarViewsShouldRestrictAndOrderTheTabs() + { + var component = RenderCalendar(views: [BitFullCalendarView.Agenda, BitFullCalendarView.Week]); + + var labels = component.FindAll(".bit-bfc-view-tab").Select(t => t.TextContent.Trim()).ToArray(); + + CollectionAssert.AreEqual(new[] { "Agenda", "Week" }, labels); + } + + [TestMethod] + public void BitFullCalendarViewsShouldCollapseTheTabStripForASingleView() + { + var component = RenderCalendar(views: [BitFullCalendarView.Month]); + + Assert.AreEqual(0, component.FindAll(".bit-bfc-view-tabs").Count); + Assert.IsNotNull(component.Find(".bit-bfc-month")); + } + + [TestMethod] + public void BitFullCalendarViewsShouldClampTheActiveViewIntoTheAllowedSet() + { + var component = RenderCalendar(views: [BitFullCalendarView.Agenda, BitFullCalendarView.Week]); + + // Month is the component default and is excluded here, so the first allowed view renders. + Assert.AreEqual(BitFullCalendarView.Agenda, component.Instance.View); + Assert.IsNotNull(component.Find(".bit-bfc-agenda")); + } + + [TestMethod] + public void BitFullCalendarViewsShouldClampAnExcludedDefaultView() + { + var component = RenderCalendar(defaultView: BitFullCalendarView.Year, + views: [BitFullCalendarView.Week, BitFullCalendarView.Day]); + + Assert.AreEqual(BitFullCalendarView.Week, component.Instance.View); + } + + [TestMethod] + public void BitFullCalendarViewsShouldClampAndPushBackAnExcludedBoundView() + { + var view = BitFullCalendarView.Year; + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Events, Events()); + parameters.Add(p => p.Views, [BitFullCalendarView.Week, BitFullCalendarView.Day]); + parameters.Bind(p => p.View, view, v => view = v); + }); + + component.WaitForAssertion(() => + { + Assert.AreEqual(BitFullCalendarView.Week, view); + Assert.AreEqual(BitFullCalendarView.Week, component.Instance.View); + }); + } + + [TestMethod] + public void BitFullCalendarViewsShouldPushBackAnExcludedBoundViewThatClampsToTheActiveView() + { + var view = BitFullCalendarView.Agenda; + + // Month is both the active view and the clamp target here, so the state reports no change at + // all - the excluded bound value still has to be corrected. + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Events, Events()); + parameters.Add(p => p.Views, [BitFullCalendarView.Month, BitFullCalendarView.Week]); + parameters.Bind(p => p.View, view, v => view = v); + }); + + component.WaitForAssertion(() => + { + Assert.AreEqual(BitFullCalendarView.Month, view); + Assert.AreEqual(BitFullCalendarView.Month, component.Instance.View); + Assert.IsNotNull(component.Find(".bit-bfc-month")); + }); + } + + [TestMethod] + public void BitFullCalendarViewsShouldPushBackARefusedBoundTimelineMode() + { + var mode = BitFullCalendarMode.Timeline; + + // No allowed view supports the timeline layout, so the refused mode resolves to the Event mode + // that is already active and the binding has to be corrected without a state change to react to. + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Events, Events()); + parameters.Add(p => p.Resources, Resources()); + parameters.Add(p => p.Views, [BitFullCalendarView.Year, BitFullCalendarView.Agenda]); + parameters.Bind(p => p.Mode, mode, m => mode = m); + }); + + component.WaitForAssertion(() => + { + Assert.AreEqual(BitFullCalendarMode.Event, mode); + Assert.AreEqual(BitFullCalendarMode.Event, component.Instance.Mode); + }); + } + + [TestMethod] + public void BitFullCalendarViewTabShouldSwitchTheRenderedView() + { + var component = RenderCalendar(views: [BitFullCalendarView.Month, BitFullCalendarView.Agenda]); + Assert.IsNotNull(component.Find(".bit-bfc-month")); + + component.FindAll(".bit-bfc-view-tab")[1].Click(); + + component.WaitForAssertion(() => + { + Assert.AreEqual(BitFullCalendarView.Agenda, component.Instance.View); + Assert.IsNotNull(component.Find(".bit-bfc-agenda")); + }); + } + + [TestMethod] + public void BitFullCalendarViewsShouldMakeTheYearDrillDownStayPutWhenMonthIsExcluded() + { + var component = RenderCalendar(views: [BitFullCalendarView.Year, BitFullCalendarView.Day]); + Assert.AreEqual(BitFullCalendarView.Year, component.Instance.View); + + component.Find(".bit-bfc-year-month-title").Click(); + + // Month is excluded, so drilling into it must not land on some other allowed view either. + component.WaitForAssertion(() => Assert.AreEqual(BitFullCalendarView.Year, component.Instance.View)); + } + + [TestMethod] + public void BitFullCalendarViewsShouldStillDrillIntoTheMonthWhenItIsAllowed() + { + var component = RenderCalendar(defaultView: BitFullCalendarView.Year); + + component.Find(".bit-bfc-year-month-title").Click(); + + component.WaitForAssertion(() => Assert.AreEqual(BitFullCalendarView.Month, component.Instance.View)); + } + + [TestMethod] + public void BitFullCalendarViewsShouldHideTheModeTabsWhenNoViewSupportsTheTimeline() + { + var withTimeline = RenderCalendar(resources: true); + Assert.AreEqual(1, withTimeline.FindAll(".bit-bfc-mode-tabs").Count); + + var withoutTimeline = RenderCalendar(views: [BitFullCalendarView.Year, BitFullCalendarView.Agenda], resources: true); + + Assert.AreEqual(0, withoutTimeline.FindAll(".bit-bfc-mode-tabs").Count); + } + + [TestMethod] + public void BitFullCalendarViewsShouldIgnoreAnExcludedTimelineModeDefault() + { + var component = RenderCalendar(views: [BitFullCalendarView.Year, BitFullCalendarView.Agenda], + resources: true, + defaultMode: BitFullCalendarMode.Timeline); + + Assert.AreEqual(BitFullCalendarMode.Event, component.Instance.Mode); + Assert.AreEqual(BitFullCalendarView.Year, component.Instance.View); + } + + [TestMethod] + public void BitFullCalendarViewsShouldIntersectWithTheTimelineLayouts() + { + var component = RenderCalendar(views: [BitFullCalendarView.Agenda, BitFullCalendarView.Week, BitFullCalendarView.Day], + resources: true, + defaultMode: BitFullCalendarMode.Timeline); + + var labels = component.FindAll(".bit-bfc-view-tab").Select(t => t.TextContent.Trim()).ToArray(); + + // Agenda is allowed but the timeline cannot lay it out, so only the supported ones remain. + CollectionAssert.AreEqual(new[] { "Week", "Day" }, labels); + } + + [TestMethod] + public void BitFullCalendarViewsShouldReactToALaterChange() + { + var component = RenderCalendar(); + Assert.AreEqual(5, component.FindAll(".bit-bfc-view-tab").Count); + + component.Render(parameters => + { + parameters.Add(p => p.Events, Events()); + parameters.Add(p => p.Views, [BitFullCalendarView.Day, BitFullCalendarView.Week]); + }); + + Assert.AreEqual(2, component.FindAll(".bit-bfc-view-tab").Count); + Assert.AreEqual(BitFullCalendarView.Day, component.Instance.View); + } + + #endregion +}