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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
@namespace Bit.BlazorUI

<BitCascadingValueProvider ValueList="BuildCascadingValues()">
<div class="bit-bfc" dir="@(State.IsRtl ? "rtl" : "ltr")">
<div class="bit-bfc @(State.ReadOnly ? "bit-bfc-readonly" : "")" dir="@(State.IsRtl ? "rtl" : "ltr")">
<BitFcCalendarHeader />
<BitFcCalendarBody MonthEventTemplate="MonthEventTemplate"
WeekEventTemplate="WeekEventTemplate"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,17 @@ public partial class BitFullCalendar : IDisposable
/// </summary>
[Parameter] public EventCallback<BitFullCalendarView> OnViewChange { get; set; }

/// <summary>
/// When <c>true</c>, 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.
/// <para>
/// 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.
/// </para>
/// </summary>
[Parameter] public bool ReadOnly { get; set; }

/// <summary>
/// Resources displayed as rows in the resource timeline view. When <c>null</c> or empty,
/// the resource timeline tab is hidden from the header. Each event's
Expand Down Expand Up @@ -190,6 +201,20 @@ public partial class BitFullCalendar : IDisposable
/// </summary>
[Parameter, TwoWayBound] public BitFullCalendarView View { get; set; } = BitFullCalendarView.Month;

/// <summary>
/// The views the calendar offers, in the order the view tabs render them. When <c>null</c> or
/// empty, every view (day, week, month, year, agenda) is offered in that order. Unknown and
/// repeated entries are ignored.
/// <para>
/// Excluded views are unreachable: the view tabs omit them, and <see cref="View"/>,
/// <see cref="DefaultView"/>, 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.
/// </para>
/// </summary>
[Parameter] public IReadOnlyList<BitFullCalendarView>? Views { get; set; }
Comment thread
msynk marked this conversation as resolved.

/// <summary>
/// Optional template for customizing event rendering in the week view.
/// When provided, replaces the default event card content inside the time-grid blocks.
Expand Down Expand Up @@ -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();
Expand All @@ -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()
Expand All @@ -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;
Expand Down Expand Up @@ -404,19 +441,27 @@ private void HandleStateChanged()
// additional event is raised here for the date.
private async Task ReconcileBoundState(bool raiseEvents)
{
if (!EqualityComparer<BitFullCalendarMode>.Default.Equals(_lastMode, State.Mode))
var modeChanged = !EqualityComparer<BitFullCalendarMode>.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<BitFullCalendarMode>.Default.Equals(Mode, State.Mode)))
{
_lastMode = State.Mode;
await AssignMode(State.Mode);
if (raiseEvents)
if (modeChanged && raiseEvents)
await OnModeChange.InvokeAsync(State.Mode);
}

if (!EqualityComparer<BitFullCalendarView>.Default.Equals(_lastView, State.View))
var viewChanged = !EqualityComparer<BitFullCalendarView>.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<BitFullCalendarView>.Default.Equals(View, State.View)))
{
_lastView = State.View;
await AssignView(State.View);
if (raiseEvents)
if (viewChanged && raiseEvents)
await OnViewChange.InvokeAsync(State.View);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,21 @@ public partial class BitFcAddEditEventDialog : IAsyncDisposable
private int? _lastStartMinute;
private string? _lastResource;

protected override void OnInitialized() => State.OnStateChanged += HandleStateChanged;

/// <summary>
/// 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 <see cref="Submit"/> refuses.
/// </summary>
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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -244,6 +264,7 @@ await Notifier.NotifyAsync(new BitFullCalendarChangeEventArgs

public async ValueTask DisposeAsync()
{
State.OnStateChanged -= HandleStateChanged;
await BitFcDialogInterop.TeardownAsync(JS, _dialogRef);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,16 @@
</div>
</div>
</div>
<div class="bit-bfc-dialog-footer" style="justify-content:space-between;">
<div style="display:flex;gap:8px;">
<button type="button" class="bit-bfc-btn" @onclick="Edit">@Texts.EditButton</button>
<button type="button" class="bit-bfc-btn bit-bfc-btn-danger" @onclick="Delete" disabled="@_isDeleting">@Texts.DeleteButton</button>
</div>
@* Read-only keeps the details readable but drops the mutating actions, leaving Close alone
in the footer - so the row switches from space-between to a plain trailing alignment. *@
<div class="bit-bfc-dialog-footer" style="justify-content:@(State.ReadOnly ? "flex-end" : "space-between");">
@if (!State.ReadOnly)
{
<div style="display:flex;gap:8px;">
<button type="button" class="bit-bfc-btn" @onclick="Edit">@Texts.EditButton</button>
<button type="button" class="bit-bfc-btn bit-bfc-btn-danger" @onclick="Delete" disabled="@_isDeleting">@Texts.DeleteButton</button>
</div>
}
<button type="button" class="bit-bfc-btn" @onclick="OnClose">@Texts.CloseButton</button>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ protected override async Task OnAfterRenderAsync(bool firstRender)

private void Edit()
{
if (State.ReadOnly)
return;

Comment thread
msynk marked this conversation as resolved.
_showEdit = true;
}

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
avoiding that nesting; drag/click handlers are preserved verbatim. *@
<div role="button"
tabindex="0"
draggable="true"
draggable="@(State.ReadOnly ? "false" : "true")"
class="@(_isDragged ? "bit-bfc-dragging" : "") @Class"
style="appearance:none;border:none;background:none;font:inherit;color:inherit;padding:0;text-align:inherit;cursor:pointer;"
@ondragstart="OnDragStart"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@
{
<BitFcFilterEvents />
}
<button type="button" class="bit-bfc-btn bit-bfc-btn-primary bit-bfc-btn-sm" @onclick="OnAddEventClick">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
@Texts.AddEventButton
</button>
@if (!State.ReadOnly)
{
<button type="button" class="bit-bfc-btn bit-bfc-btn-primary bit-bfc-btn-sm" @onclick="OnAddEventClick">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
@Texts.AddEventButton
</button>
}
@if (!HideSettings)
{
<BitFcSettings />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ public partial class BitFcCalendarHeader

private async Task OnAddEventClick()
{
if (State.ReadOnly)
return;

if (OnAddClick.HasDelegate)
{
var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot(
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
msynk marked this conversation as resolved.
{
<div class="bit-bfc-mode-tabs" role="tablist">
@foreach (var mode in _modes)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
@namespace Bit.BlazorUI

<div class="bit-bfc-view-tabs" role="tablist">
@foreach (var view in _views)
{
if (State.Mode == BitFullCalendarMode.Timeline && !_timelineViews.Contains(view))
continue;
@{
var views = State.AvailableViews;
}

<button class="bit-bfc-view-tab @(State.View == view ? "active" : "")"
type="button"
role="tab"
aria-selected="@(State.View == view ? "true" : "false")"
@onclick="() => State.SetView(view)">
@Texts.GetViewLabel(view)
</button>
}
</div>
@* A lone tab offers no choice, so the whole strip is collapsed when a single view is available. *@
@if (views.Count > 1)
Comment thread
msynk marked this conversation as resolved.
{
<div class="bit-bfc-view-tabs" role="tablist">
@foreach (var view in views)
{
<button class="bit-bfc-view-tab @(State.View == view ? "active" : "")"
type="button"
role="tab"
aria-selected="@(State.View == view ? "true" : "false")"
@onclick="() => State.SetView(view)">
@Texts.GetViewLabel(view)
</button>
}
</div>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<BitFullCalendarView> _timelineViews =
[
BitFullCalendarView.Day,
BitFullCalendarView.Week,
BitFullCalendarView.Month
];
}
Loading
Loading