diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFcCalendarBody.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFcCalendarBody.razor new file mode 100644 index 0000000000..12014b20e9 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFcCalendarBody.razor @@ -0,0 +1,44 @@ +@namespace Bit.BlazorUI +@implements IDisposable + +
+ @if (State.Mode == BitFullCalendarMode.Timeline) + { + switch (State.View) + { + case BitFullCalendarView.Day: + + break; + case BitFullCalendarView.Week: + + break; + case BitFullCalendarView.Month: + + break; + default: + + break; + } + } + else + { + switch (State.View) + { + case BitFullCalendarView.Month: + + break; + case BitFullCalendarView.Week: + + break; + case BitFullCalendarView.Day: + + break; + case BitFullCalendarView.Year: + + break; + case BitFullCalendarView.Agenda: + + break; + } + } +
diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFcCalendarBody.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFcCalendarBody.razor.cs new file mode 100644 index 0000000000..7eec8d634f --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFcCalendarBody.razor.cs @@ -0,0 +1,36 @@ +namespace Bit.BlazorUI; + +public partial class BitFcCalendarBody +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + + [Parameter] public RenderFragment? MonthEventTemplate { get; set; } + [Parameter] public RenderFragment? WeekEventTemplate { get; set; } + [Parameter] public RenderFragment? DayEventTemplate { get; set; } + [Parameter] public RenderFragment? TimelineEventTemplate { get; set; } + + private List _singleDayEvents = []; + private List _multiDayEvents = []; + private List _timelineEvents = []; + + protected override void OnInitialized() + { + State.OnStateChanged += Refresh; + ComputeEvents(); + } + + private void Refresh() + { + ComputeEvents(); + InvokeAsync(StateHasChanged); + } + + private void ComputeEvents() + { + _singleDayEvents = State.Events.Where(e => e.IsSingleDay).ToList(); + _multiDayEvents = State.Events.Where(e => e.IsMultiDay).ToList(); + _timelineEvents = State.Events.ToList(); + } + + public void Dispose() => State.OnStateChanged -= Refresh; +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFcCalendarToast.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFcCalendarToast.razor new file mode 100644 index 0000000000..0eaf5ccd14 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFcCalendarToast.razor @@ -0,0 +1,10 @@ +@namespace Bit.BlazorUI + +
+ @foreach (var toast in _toasts) + { +
+ @toast.Message +
+ } +
diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFcCalendarToast.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFcCalendarToast.razor.cs new file mode 100644 index 0000000000..0903803adc --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFcCalendarToast.razor.cs @@ -0,0 +1,126 @@ +namespace Bit.BlazorUI; + +public partial class BitFcCalendarToast : IAsyncDisposable +{ + private readonly List _toasts = []; + private readonly List _removalTokens = []; + private readonly object _removalTokensLock = new(); + private int _nextId; + + public void Show(string message, bool isError = false) + { + // Allocate the id atomically: Show can be invoked off the renderer thread (the list mutation + // is marshalled below, but the id is assigned here on the caller's thread), so a plain + // _nextId++ could hand out duplicate ids under concurrent calls and break RemoveAfterDelay's + // per-id removal. + var item = new ToastItem { Id = Interlocked.Increment(ref _nextId), Message = message, IsError = isError }; + + var cts = new CancellationTokenSource(); + // Capture the token up front, before DisposeAsync (which can run concurrently and dispose + // the source) gets a chance to. Reading cts.Token later inside RemoveAfterDelay would risk + // an ObjectDisposedException if the component is torn down before the delay is queued. + var token = cts.Token; + lock (_removalTokensLock) + { + _removalTokens.Add(cts); + } + + // Marshal the list mutation and render onto the renderer's dispatcher so the whole toast + // lifecycle (add here, remove in RemoveAfterDelay) stays dispatcher-safe even when Show is + // invoked from a non-renderer thread. + _ = InvokeAsync(() => + { + // DisposeAsync cancels every queued token; if it ran between scheduling this callback + // and it executing, bail out before touching _toasts/StateHasChanged so no UI work (or + // RemoveAfterDelay timer) starts after the component has been torn down. + if (token.IsCancellationRequested) + return; + + _toasts.Add(item); + StateHasChanged(); + // Start the expiration timer only after the toast has actually been queued into the UI, + // so the 3s lifetime begins from when it becomes visible rather than from when Show was + // scheduled (which may run on a non-renderer thread before the add is dispatched). + _ = RemoveAfterDelay(item.Id, cts, token); + }); + } + + private async Task RemoveAfterDelay(int id, CancellationTokenSource cts, CancellationToken token) + { + try + { + try + { + await Task.Delay(3000, token); + } + catch (OperationCanceledException) + { + return; + } + + // Final cancellation check before the dispatcher hop: DisposeAsync may have cancelled the + // token after Task.Delay completed but before we queue the render. Bail out so _toasts and + // StateHasChanged are never touched once teardown has started. + if (token.IsCancellationRequested) + return; + + // Mutate the toast list on the renderer's dispatcher to avoid racing the template's foreach. + await InvokeAsync(() => + { + // Re-check inside the dispatcher callback: DisposeAsync can cancel after the check + // above but before this lambda runs, so no-op once teardown has begun instead of + // mutating _toasts / calling StateHasChanged on a disposed component. + if (token.IsCancellationRequested) + return; + + _toasts.RemoveAll(t => t.Id == id); + StateHasChanged(); + }); + } + finally + { + // Drop the token as soon as its timer finishes (or is cancelled) so _removalTokens + // doesn't grow unbounded on long-lived pages that show many toasts. + bool removed; + lock (_removalTokensLock) + { + removed = _removalTokens.Remove(cts); + } + if (removed) + cts.Dispose(); + } + } + + public ValueTask DisposeAsync() + { + // Snapshot under the lock: RemoveAfterDelay also removes/disposes tokens as their timers + // complete, so reading the live list here could race with that cleanup. + CancellationTokenSource[] tokens; + lock (_removalTokensLock) + { + tokens = _removalTokens.ToArray(); + _removalTokens.Clear(); + } + + foreach (var cts in tokens) + { + try + { + cts.Cancel(); + cts.Dispose(); + } + catch (ObjectDisposedException) + { + // Already disposed by RemoveAfterDelay's cleanup; nothing to do. + } + } + return ValueTask.CompletedTask; + } + + private class ToastItem + { + public int Id { get; set; } + public string Message { get; set; } = ""; + public bool IsError { get; set; } + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.razor new file mode 100644 index 0000000000..90b25dcac9 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.razor @@ -0,0 +1,12 @@ +@namespace Bit.BlazorUI + + +
+ + + +
+
diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.razor.cs new file mode 100644 index 0000000000..d588ef2d3e --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.razor.cs @@ -0,0 +1,451 @@ +using System.Globalization; + +namespace Bit.BlazorUI; + +public partial class BitFullCalendar : IDisposable +{ + /// + /// Culture for the calendar. Accepts any CultureInfo, e.g. new CultureInfo("fa-IR"). + /// NOTE: do NOT use this parameter when the component is rendered with + /// @rendermode="InteractiveServer" - CultureInfo is not JSON-serializable. + /// Use instead for server-interactive scenarios. + /// + [Parameter] public CultureInfo? Culture { get; set; } + + /// + /// Culture name string (e.g. "fa-IR", "ar-SA", "fr-FR"). + /// Preferred over when using @rendermode="InteractiveServer" + /// because plain strings are safely serialized by Blazor's parameter persistence. + /// When both are supplied, CultureName takes precedence. + /// Blazor WebAssembly hosts must set BlazorWebAssemblyLoadAllGlobalizationData to + /// true (or load a custom ICU shard) for cultures outside the default EFIGS/CJK shards. + /// + [Parameter] public string? CultureName { get; set; } + + /// + /// The currently selected (anchor) date of the calendar that determines the visible date range. (two-way bound) + /// + /// When set, the calendar navigates to the supplied date. User interactions (prev/next/today navigation, + /// selecting a day or a month) update this value through the generated DateChanged callback. Use + /// to provide an initial value without taking over control of the date. + /// + /// + [Parameter, TwoWayBound] public DateTime Date { get; set; } = DateTime.Today; + + /// + /// Optional template for customizing event rendering in the day view. + /// When provided, replaces the default event card content inside the time-grid blocks. + /// + [Parameter] public RenderFragment? DayEventTemplate { get; set; } + + /// + /// The default selected date to be initially used when the parameter is not set. + /// Determines the date range the calendar shows on first render. Applied once during initialization; + /// afterwards the active date is driven by user interaction or by the two-way bound parameter. + /// + [Parameter] public DateTime? DefaultDate { get; set; } + + /// + /// The default layout mode to be initially used when the parameter is not set. + /// shows the standard day/week/month/year/agenda views. + /// switches to the resource × time layout (day, week, month) + /// and requires to contain at least one entry; otherwise the Timeline tab + /// and mode have no effect. Applied once during initialization; afterwards the active mode is driven + /// by user interaction or by the two-way bound parameter. + /// + [Parameter] public BitFullCalendarMode? DefaultMode { get; set; } + + /// + /// The default view to be initially used when the parameter is not set. + /// Controls how the date range and events are laid out (day, week, month, year, or agenda). + /// Applied once during initialization; afterwards the active view is driven by user + /// interaction or by the two-way bound parameter. + /// + [Parameter] public BitFullCalendarView? DefaultView { get; set; } + + /// + /// Ordered list of event colors shown in pickers, filters, agenda headers, badges, and bullets. + /// Each entry has its own (matched against + /// ), + /// (the display name shown verbatim - for example "SkyBlue"), and + /// (any CSS color value used for swatches and badges). + /// When null or empty, is used. + /// + [Parameter] public IReadOnlyList? EventColorOptions { get; set; } + + /// + /// Events displayed in the calendar. Assign a list from parent state; updates are synced on each + /// render when the reference or contents change. User-driven add, edit, and delete actions are + /// reported through - update this list (or your backing store) in the handler + /// to keep the UI in sync. + /// + [Parameter] public List? Events { get; set; } + + /// + /// When true, the built-in color and attendee filter dropdowns are hidden from the calendar header. + /// Consumers can provide their own external filter UI and pass pre-filtered events to the calendar. + /// + [Parameter] public bool HideFilters { get; set; } + + /// + /// When true, the built-in settings gear button is hidden from the calendar header. + /// Consumers can still drive settings programmatically through the object. + /// + [Parameter] public bool HideSettings { get; set; } + + /// + /// The currently active layout mode of the calendar ( or + /// ). (two-way bound) + /// + /// When set, the calendar reflects the supplied mode. Timeline mode requires + /// to contain at least one entry; otherwise it falls back to . + /// User interactions (mode tabs) update this value through the generated ModeChanged callback. + /// Use to provide an initial value without taking over control of the mode. + /// + /// + [Parameter, TwoWayBound] public BitFullCalendarMode Mode { get; set; } = BitFullCalendarMode.Event; + + /// + /// Optional template for customizing event rendering in the month view. + /// When provided, replaces the default event badge content inside month grid cells. + /// + [Parameter] public RenderFragment? MonthEventTemplate { get; set; } + + /// + /// When assigned, the built-in add dialog is suppressed. The callback receives a draft + /// with and + /// set from the interaction (for example the clicked day/week slot); + /// is empty and other fields are left at defaults. + /// Consumers should show their own UI and + /// raise (or mutate bound to parent state) after persisting changes. + /// + [Parameter] public EventCallback OnAddClick { get; set; } + + /// + /// Raised when a user adds, edits, or deletes an event in the calendar UI. + /// + [Parameter] public EventCallback OnChange { get; set; } + + /// + /// Raised when the visible date range changes - for example when the user navigates + /// with prev/next/today buttons or switches views. The callback receives the inclusive + /// start and end dates of the new range together with the active view. + /// + [Parameter] public EventCallback OnDateChange { get; set; } + + /// + /// When assigned, the built-in event details dialog is suppressed when an event is clicked. + /// The callback receives the clicked . Consumers should + /// show their own event details UI. This applies to all views (day, week, month, agenda) and + /// to multi-day event rows and event list dialogs. + /// + [Parameter] public EventCallback OnEventClick { get; set; } + + /// + /// Raised when the active layout mode changes - for example when the user switches between the + /// Event and Timeline tabs. The callback receives the new . + /// + [Parameter] public EventCallback OnModeChange { get; set; } + + /// + /// Raised when the active view changes - for example when the user selects a view tab or + /// navigates from the year overview into a month. The callback receives the new . + /// + [Parameter] public EventCallback OnViewChange { 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 + /// is matched against the resource Id. + /// + [Parameter] public IReadOnlyList? Resources { get; set; } + + /// + /// Configuration settings controlling initial calendar preferences + /// such as dark mode, time format, badge variant, day start hour, and agenda grouping. + /// Values are applied when the component initializes or when a new instance is assigned. + /// + [Parameter] public BitFullCalendarSettings Settings { get; set; } = new(); + + /// + /// Localized strings for calendar UI labels, buttons, dialogs, filters, and accessibility text. + /// Defaults to English; override individual properties on a + /// instance to localize the component without replacing built-in dialogs. + /// + [Parameter] public BitFullCalendarTexts Texts { get; set; } = new(); + + /// + /// Optional template for customizing event rendering in the resource timeline view. + /// When provided, replaces the default event card content inside the timeline blocks. + /// + [Parameter] public RenderFragment? TimelineEventTemplate { get; set; } + + /// + /// The currently active view of the calendar (day, week, month, year, or agenda). (two-way bound) + /// + /// When set, the calendar reflects the supplied view. User interactions (view tabs, year navigation) + /// update this value through the generated ViewChanged callback. Use + /// to provide an initial value without taking over control of the view. + /// + /// + [Parameter, TwoWayBound] public BitFullCalendarView View { get; set; } = BitFullCalendarView.Month; + + /// + /// Optional template for customizing event rendering in the week view. + /// When provided, replaces the default event card content inside the time-grid blocks. + /// + [Parameter] public RenderFragment? WeekEventTemplate { get; set; } + + public BitFullCalendarState State { get; set; } = new(); + private BitFullCalendarChangeNotifier _changeNotifier = default!; + private BitFullCalendarColorScheme _colorScheme = new(null); + private BitFullCalendarSettings? _appliedSettings; + private bool _defaultViewApplied; + private bool _defaultModeApplied; + private bool _defaultDateApplied; + // The last view/mode/date the component reconciled with the bound parameters. Used to detect + // genuine (user-driven) state changes so OnViewChange/OnModeChange are not raised for + // parameter- or default-driven updates and the bound parameters stay in sync. + private BitFullCalendarView _lastView; + private BitFullCalendarMode _lastMode; + private DateTime _lastDate; + // True while OnParametersSet pushes parameter/default values into the state. Suppresses the + // OnViewChange/OnModeChange callbacks for those echoes while still keeping the bound + // View/Mode/Date parameters in sync with the resulting state. + private bool _applyingParameters; + // While applying parameters, several state setters (SetCulture, SetMode/SetView, SetSelectedDate) + // can each emit a date-range change in one pass. We coalesce them here and raise only the final + // resolved range to consumers once ApplyBoundState has finished, instead of forwarding every + // intermediate range. + private BitFullCalendarDateChangeEventArgs? _pendingDateChange; + + private BitCascadingValueList BuildCascadingValues() => new() + { + { State }, + { Texts }, + { _changeNotifier }, + { _colorScheme }, + { Settings }, + { HideFilters, "HideFilters" }, + { HideSettings, "HideSettings" }, + { OnAddClick, "OnAddClick" }, + { OnEventClick, "OnEventClick" }, + }; + + private CultureInfo ResolveCulture() + { + if (CultureName is { Length: > 0 } name) + { + try + { + return new CultureInfo(name); + } + catch (CultureNotFoundException) + { + // Invalid CultureName supplied; fall back to the explicit Culture or the current UI culture. + } + } + + return Culture ?? CultureInfo.CurrentUICulture; + } + + protected override void OnInitialized() + { + // Settings/Texts have default instances but can be set to null when bound externally. + // Normalize before any downstream use (ApplySettings, cascaded Texts) to avoid NREs. + Settings ??= new(); + Texts ??= new(); + + State.Initialize(Events ?? [], ResolveCulture()); + ApplySettings(); + _changeNotifier = new BitFullCalendarChangeNotifier(State, args => OnChange.InvokeAsync(args)); + State.OnStateChanged += HandleStateChanged; + State.OnDateRangeChanged += HandleDateRangeChanged; + + // Seed the reconciliation baseline so the first genuine (user-driven) view/mode/date change is + // detected correctly and parameter/default-driven initialization does not raise callbacks. + _lastView = State.View; + _lastMode = State.Mode; + _lastDate = State.SelectedDate; + } + + protected override void OnParametersSet() + { + // A null Settings/Texts can arrive from external binding, overriding the default instances; + // restore valid defaults before ApplySettings and the cascaded Texts are consumed downstream. + Settings ??= new(); + Texts ??= new(); + + // Mark the parameter-application window so state changes triggered below (events, resources, + // view, mode, date) keep the bound View/Mode/Date parameters in sync without raising + // OnViewChange/OnModeChange. + _applyingParameters = true; + try + { + _colorScheme = new BitFullCalendarColorScheme(EventColorOptions); + var resolved = ResolveCulture(); + // Compare the calendar identity in addition to the culture name: two cultures can share + // the same Name but resolve to different calendars (for example a culture whose calendar + // was switched), and a name-only check would skip the required SetCulture when only the + // calendar changed - leaving the calendar rendering against the previous calendar system. + if (!string.Equals(resolved.Name, State.Culture.Name, StringComparison.Ordinal) + || resolved.Calendar.GetType() != State.Culture.Calendar.GetType()) + State.SetCulture(resolved); + + if (Events is not null) + State.SyncEvents(Events); + else + // Events was cleared (set back to null); drop any previously loaded events so the + // calendar display reflects the empty state instead of keeping stale items. + State.SyncEvents([]); + + State.SyncResources(Resources); + + // Apply the view, mode, and date after resources are synced: Timeline mode requires + // Resources to be populated to take effect. + ApplyBoundState(); + + ApplySettings(); + } + finally + { + _applyingParameters = false; + } + + // Raise the single coalesced date-range change (if any) now that all parameter-driven + // setters have run, so consumers see only the final resolved range rather than each + // intermediate one produced while applying parameters. + if (_pendingDateChange is { } pending) + { + _pendingDateChange = null; + InvokeAsync(() => OnDateChange.InvokeAsync(pending)); + } + } + + private void ApplyBoundState() + { + // Mode is applied before View because entering Timeline mode clamps the available views. + 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(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. + var canApplyDefaultMode = DefaultMode.Value != BitFullCalendarMode.Timeline + || Resources is { Count: > 0 }; + if (canApplyDefaultMode) + { + _defaultModeApplied = true; + State.SetMode(DefaultMode.Value); + } + } + + if (ViewHasBeenSet) + { + // Controlled: keep the state aligned with the bound View on every parameter change. + State.SetView(View); + } + else if (!_defaultViewApplied && DefaultView.HasValue) + { + _defaultViewApplied = true; + State.SetView(DefaultView.Value); + } + + if (DateHasBeenSet) + { + // Controlled: keep the state aligned with the bound Date. SetSelectedDate does not + // short-circuit on equal values, so guard against redundant navigation/re-render loops. + if (State.SelectedDate != Date) + State.SetSelectedDate(Date); + } + else if (!_defaultDateApplied && DefaultDate.HasValue) + { + _defaultDateApplied = true; + if (State.SelectedDate != DefaultDate.Value) + State.SetSelectedDate(DefaultDate.Value); + } + } + + private void ApplySettings() + { + // Sync each individual value rather than short-circuiting on a reference comparison: the same + // BitFullCalendarSettings instance can be mutated in place by the consumer, so comparing the + // reference would silently ignore those updates. The State.Set* methods each guard against + // no-op changes, so re-applying unchanged values is cheap and raises no spurious notifications. + _appliedSettings = Settings; + State.SetUse24HourFormat(Settings.Use24HourFormat); + State.SetBadgeVariant(Settings.BadgeVariant); + State.SetStartOfDayHour(Settings.StartOfDayHour); + State.SetAgendaModeGroupBy(Settings.AgendaModeGroupBy); + State.SetEventLayout(Settings.EventLayout); + State.SetShowDayViewCalendar(Settings.ShowDayViewCalendar); + } + + private void HandleStateChanged() + { + // Capture the flag now: the queued callback may run after OnParametersSet's finally block + // has reset _applyingParameters to false, which would otherwise wrongly raise events. + var applyingParameters = _applyingParameters; + InvokeAsync(async () => + { + await ReconcileBoundState(raiseEvents: !applyingParameters); + StateHasChanged(); + }); + } + + // Pushes the current state view/mode/date back into the two-way bound parameters when they change. + // When raiseEvents is true (user-driven change) the OnViewChange/OnModeChange callbacks are + // invoked; parameter- and default-driven echoes pass false to keep the bindings in sync silently. + // Date changes are surfaced separately through OnDateChange (via the date-range channel), so no + // additional event is raised here for the date. + private async Task ReconcileBoundState(bool raiseEvents) + { + if (!EqualityComparer.Default.Equals(_lastMode, State.Mode)) + { + _lastMode = State.Mode; + await AssignMode(State.Mode); + if (raiseEvents) + await OnModeChange.InvokeAsync(State.Mode); + } + + if (!EqualityComparer.Default.Equals(_lastView, State.View)) + { + _lastView = State.View; + await AssignView(State.View); + if (raiseEvents) + await OnViewChange.InvokeAsync(State.View); + } + + if (_lastDate != State.SelectedDate) + { + _lastDate = State.SelectedDate; + await AssignDate(State.SelectedDate); + } + } + + private void HandleDateRangeChanged(BitFullCalendarDateChangeEventArgs args) + { + // While applying parameters, coalesce: keep only the latest range and let OnParametersSet + // raise it once after ApplyBoundState finishes. Outside that window (user-driven navigation, + // view switches) forward each change immediately as before. + if (_applyingParameters) + { + _pendingDateChange = args; + return; + } + + InvokeAsync(() => OnDateChange.InvokeAsync(args)); + } + + + + public void Dispose() + { + State.OnStateChanged -= HandleStateChanged; + State.OnDateRangeChanged -= HandleDateRangeChanged; + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.scss new file mode 100644 index 0000000000..bcc3d186f6 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.scss @@ -0,0 +1,2637 @@ +/* BitFullCalendar */ + +@import "../../../Bit.BlazorUI/Styles/functions.scss"; + +/* ===== CSS Variables ===== + The calendar maps its internal --bit-bfc-* custom properties onto the Bit BlazorUI theme tokens + (--bit-clr-*, shape and shadow tokens). Because those tokens are redefined by the active Bit + theme (including dark mode via :root[bit-theme="dark"]), the calendar follows the application + theme automatically without any component-level theme or dark-mode switch. */ +.bit-bfc { + --bit-bfc-bg: #{$clr-bg-pri}; + --bit-bfc-bg-secondary: #{$clr-bg-sec}; + --bit-bfc-bg-hover: #{$clr-bg-pri-hover}; + --bit-bfc-border: #{$clr-brd-ter}; + --bit-bfc-text: #{$clr-fg-pri}; + --bit-bfc-text-secondary: #{$clr-fg-sec}; + --bit-bfc-text-muted: #{$clr-fg-ter}; + --bit-bfc-primary: #{$clr-pri}; + --bit-bfc-primary-hover: #{$clr-pri-hover}; + --bit-bfc-primary-text: #{$clr-pri-text}; + --bit-bfc-danger: #{$clr-err}; + --bit-bfc-danger-hover: #{$clr-err-hover}; + --bit-bfc-radius: #{$shp-border-radius}; + --bit-bfc-radius-sm: #{$shp-border-radius}; + --bit-bfc-shadow: #{$box-shadow-sm}; + --bit-bfc-shadow-lg: #{$box-shadow-md}; + --bit-bfc-hour-height: 96px; + --bit-bfc-height: 600px; + --bit-bfc-timeline-color: #{$clr-err}; + --bit-bfc-resize-ring: color-mix(in srgb, #{$clr-pri} 65%, transparent); + --bit-bfc-resize-glow: color-mix(in srgb, #{$clr-pri} 22%, transparent); + --bit-bfc-resize-preview-bg: #{$clr-bg-pri}; + --bit-bfc-resize-preview-border: color-mix(in srgb, #{$clr-pri} 45%, transparent); + --bit-bfc-resize-handle-line: #{$clr-pri}; +} + +/* ===== Base ===== */ +.bit-bfc { + font-family: #{$tg-font-family}; + background: var(--bit-bfc-bg); + color: var(--bit-bfc-text); + border: 1px solid var(--bit-bfc-border); + border-radius: var(--bit-bfc-radius); + overflow: hidden; + display: flex; + flex-direction: column; + /* A definite default height keeps the day/week time grids from expanding to their full + (24 * hour-height) intrinsic height when the host doesn't constrain the calendar. This makes + the views scrollable (so "scroll to start of day" works) out of the box. Override via the + --bit-bfc-height custom property, e.g. style="--bit-bfc-height: 100%" inside a sized container. */ + height: var(--bit-bfc-height, 600px); +} + +/* ===== Header ===== */ +.bit-bfc-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--bit-bfc-border); + background: var(--bit-bfc-bg); + flex-wrap: wrap; + gap: 8px; +} + +.bit-bfc-header-left, +.bit-bfc-header-center, +.bit-bfc-header-right { + display: flex; + align-items: center; + gap: 8px; +} + +.bit-bfc-header-left { + flex: 1; +} + +.bit-bfc-header-right { + flex: 1; + justify-content: flex-end; +} + +/* ===== Buttons ===== */ +.bit-bfc-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid var(--bit-bfc-border); + border-radius: var(--bit-bfc-radius-sm); + background: var(--bit-bfc-bg); + color: var(--bit-bfc-text); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + line-height: 1.817; +} + +.bit-bfc-btn:hover { + background: var(--bit-bfc-bg-hover); +} + +.bit-bfc-btn-primary { + background: var(--bit-bfc-primary); + color: var(--bit-bfc-primary-text); + border-color: var(--bit-bfc-primary); +} + +.bit-bfc-btn-primary:hover { + background: var(--bit-bfc-primary-hover); +} + +.bit-bfc-btn-danger { + background: var(--bit-bfc-danger); + color: #fff; + border-color: var(--bit-bfc-danger); +} + +.bit-bfc-btn-danger:hover { + background: var(--bit-bfc-danger-hover); +} + +.bit-bfc-btn-icon { + padding: 6px; + width: 32px; + height: 32px; +} + +.bit-bfc-btn-sm { + padding: 4px 8px; + font-size: 12px; +} + +.bit-bfc-btn-group { + display: inline-flex; + border: 1px solid var(--bit-bfc-border); + border-radius: var(--bit-bfc-radius-sm); + overflow: hidden; +} + +.bit-bfc-btn-group .bit-bfc-btn { + border: none; + border-radius: 0; + border-inline-end: 1px solid var(--bit-bfc-border); +} + +.bit-bfc-btn-group .bit-bfc-btn:last-child { + border-inline-end: none; +} + +.bit-bfc-btn-group .bit-bfc-btn.active { + background: var(--bit-bfc-primary); + color: var(--bit-bfc-primary-text); +} + +/* ===== View Tabs ===== */ +.bit-bfc-view-tabs { + display: inline-flex; + background: var(--bit-bfc-bg-secondary); + border-radius: var(--bit-bfc-radius-sm); + padding: 2px; + gap: 2px; +} + +.bit-bfc-view-tab { + padding: 5px 12px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--bit-bfc-text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.bit-bfc-view-tab:hover { + color: var(--bit-bfc-text); + background: var(--bit-bfc-bg-hover); +} + +.bit-bfc-view-tab.active { + background: var(--bit-bfc-bg); + color: var(--bit-bfc-text); + box-shadow: var(--bit-bfc-shadow); +} + +/* ===== Calendar Body ===== */ +.bit-bfc-body { + flex: 1; + min-height: 0; + overflow: hidden; + position: relative; + animation: bit-bfc-fade-in 0.2s ease; +} + +@keyframes bit-bfc-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +/* ===== Month View ===== */ +.bit-bfc-month { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow-y: auto; +} + +.bit-bfc-month-header { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + border-bottom: 1px solid var(--bit-bfc-border); + flex-shrink: 0; +} + +.bit-bfc-month-header-cell { + padding: 8px; + text-align: center; + font-size: 12px; + font-weight: 600; + color: var(--bit-bfc-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.bit-bfc-month-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + flex: 0 0 auto; + align-content: start; +} + +.bit-bfc-month-cell { + border-right: 1px solid var(--bit-bfc-border); + border-bottom: 1px solid var(--bit-bfc-border); + padding: 4px; + min-height: 100px; + min-width: 0; + position: relative; + cursor: pointer; + transition: background 0.1s; + overflow: clip; + overflow-clip-margin: 4px; +} + +.bit-bfc-month-cell:hover { + background: var(--bit-bfc-bg-hover); +} + +.bit-bfc-month-cell:nth-child(7n) { + border-right: none; +} + +.bit-bfc-month-cell.other-month { + opacity: 0.4; +} + +.bit-bfc-month-cell-day { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + font-size: 13px; + font-weight: 500; + margin-bottom: 2px; +} + +.bit-bfc-month-cell-day.today { + background: var(--bit-bfc-primary); + color: var(--bit-bfc-primary-text); +} + +.bit-bfc-month-events { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + overflow: hidden; +} + +.bit-bfc-month-event-slot { + height: 22px; + min-width: 0; + overflow: hidden; +} + +.bit-bfc-month-more { + font-size: 11px; + color: var(--bit-bfc-text-secondary); + padding: 2px 4px; + cursor: pointer; + font-weight: 500; + /* Rendered as a real + +
+
+ + + @if (_errors.ContainsKey("title")) + { +
@_errors["title"]
+ } +
+ +
+ + +
+ +
+ + + @if (_errors.ContainsKey("endDate")) + { +
@_errors["endDate"]
+ } +
+ +
+ + +
+ +
+ + + @if (_errors.ContainsKey("description")) + { +
@_errors["description"]
+ } +
+ +
+ + @if (_attendees.Count > 0) + { +
+ @foreach (var a in _attendees) + { + var attendee = a; + + @attendee.Initials + @attendee.FullName + + + } +
+ } +
+ + + + +
+ @if (_errors.ContainsKey("attendee")) + { +
@_errors["attendee"]
+ } +
+
+ + + 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 new file mode 100644 index 0000000000..dfd24798cb --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcAddEditEventDialog.razor.cs @@ -0,0 +1,249 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Bit.BlazorUI; + +public partial class BitFcAddEditEventDialog : IAsyncDisposable +{ + [Inject] private IJSRuntime JS { get; set; } = default!; + + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarColorScheme ColorScheme { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [Parameter] public BitFullCalendarEvent? ExistingEvent { get; set; } + [Parameter] public DateTime? StartDate { get; set; } + [Parameter] public int? StartHour { get; set; } + [Parameter] public int? StartMinute { get; set; } + [Parameter] public string? Resource { get; set; } + [Parameter] public EventCallback OnClose { get; set; } + [Parameter] public EventCallback OnSaved { get; set; } + + // Per-instance unique ids so multiple open dialogs don't collide on element ids, which would + // break label-to-control association and the dialog's aria-labelledby reference. + private readonly string _dialogTitleId = $"bfc-dlg-title-{Guid.NewGuid():N}"; + private readonly string _titleInputId = $"bfc-title-{Guid.NewGuid():N}"; + private readonly string _colorSelectId = $"bfc-color-{Guid.NewGuid():N}"; + private readonly string _descriptionInputId = $"bfc-desc-{Guid.NewGuid():N}"; + + private ElementReference _dialogRef; + + private bool _isEditing; + private bool _isSubmitting; + private string _title = ""; + private string _description = ""; + private DateTime _startDate; + private DateTime _endDate; + private string _color = BitFullCalendarColorScheme.FallbackColorId; + private List _attendees = []; + private string _newFirstName = ""; + private string _newLastName = ""; + private string _newId = ""; + private Dictionary _errors = new(); + + private bool _initialized; + private BitFullCalendarEvent? _lastExistingEvent; + private DateTime? _lastStartDate; + private DateTime _lastSelectedDate; + private int? _lastStartHour; + private int? _lastStartMinute; + private string? _lastResource; + + protected override void OnParametersSet() + { + // Re-run initialization whenever the parameters that drive the form change, so a reused + // dialog instance reflects the new ExistingEvent / start parameters instead of stale values. + // State.SelectedDate is only the fallback base date for a NEW event when no explicit + // StartDate is supplied (see the non-editing branch below). A selected-date change must + // therefore only force a reset while the dialog is actually using that fallback source - + // never while editing an ExistingEvent or when an explicit StartDate was provided, otherwise + // an unrelated calendar navigation would clobber the in-progress form. + var usesFallbackDate = ExistingEvent is null && StartDate is null; + var selectedDateChanged = usesFallbackDate && _lastSelectedDate != State.SelectedDate; + var parametersChanged = !_initialized + || !ReferenceEquals(_lastExistingEvent, ExistingEvent) + || _lastStartDate != StartDate + || selectedDateChanged + || _lastStartHour != StartHour + || _lastStartMinute != StartMinute + || _lastResource != Resource; + + if (!parametersChanged) + return; + + _initialized = true; + _lastExistingEvent = ExistingEvent; + _lastStartDate = StartDate; + _lastSelectedDate = State.SelectedDate; + _lastStartHour = StartHour; + _lastStartMinute = StartMinute; + _lastResource = Resource; + + // Clear transient editing state so a reused dialog instance doesn't carry over stale + // validation errors or half-typed attendee draft inputs from a previous open. + _errors = new(); + _newFirstName = ""; + _newLastName = ""; + _newId = ""; + + _isEditing = ExistingEvent != null; + var defaultColor = ColorScheme.Options.Count > 0 + ? ColorScheme.Options[0].Id + : BitFullCalendarColorScheme.FallbackColorId; + + if (_isEditing) + { + _title = ExistingEvent!.Title; + _description = ExistingEvent.Description; + _startDate = ExistingEvent.StartDate; + _endDate = ExistingEvent.EndDate; + _color = string.IsNullOrWhiteSpace(ExistingEvent.Color) ? defaultColor : ExistingEvent.Color; + _attendees = [.. ExistingEvent.Attendees]; + } + else + { + _title = ""; + _description = ""; + _color = defaultColor; + _attendees = []; + var baseDate = StartDate ?? State.SelectedDate; + _startDate = baseDate.Date.AddHours(StartHour ?? DateTime.Now.Hour).AddMinutes(StartMinute ?? 0); + _endDate = _startDate.AddMinutes(30); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + // Move focus into the dialog and trap Tab navigation once it has rendered; teardown in + // DisposeAsync restores focus to the element that was focused before it opened. Mirrors + // BitFcEventDetailsDialog so the add/edit dialog behaves like a true modal. + if (firstRender) + await BitFcDialogInterop.SetupAsync(JS, _dialogRef); + } + + private void AddAttendee() + { + _errors.Remove("attendee"); + + if (string.IsNullOrWhiteSpace(_newFirstName) && string.IsNullOrWhiteSpace(_newLastName)) + { + _errors["attendee"] = Texts.ValidationAttendeeNameRequired; + return; + } + + _attendees.Add(new BitFullCalendarAttendee + { + FirstName = _newFirstName.Trim(), + LastName = _newLastName.Trim(), + Id = string.IsNullOrWhiteSpace(_newId) ? null : _newId.Trim() + }); + + _newFirstName = ""; + _newLastName = ""; + _newId = ""; + } + + private void RemoveAttendee(BitFullCalendarAttendee attendee) => _attendees.Remove(attendee); + + private Task OnStartDateChanged(DateTime value) + { + _startDate = value; + return Task.CompletedTask; + } + + private Task OnEndDateChanged(DateTime value) + { + _endDate = value; + return Task.CompletedTask; + } + + private async Task Submit() + { + // 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; + + _errors.Clear(); + if (string.IsNullOrWhiteSpace(_title)) + _errors["title"] = Texts.ValidationTitleRequired; + if (string.IsNullOrWhiteSpace(_description)) + _errors["description"] = Texts.ValidationDescriptionRequired; + if (_endDate <= _startDate) + _errors["endDate"] = Texts.ValidationEndAfterStart; + + if (_errors.Count > 0) return; + + _isSubmitting = true; + try + { + var oldSnapshot = _isEditing && ExistingEvent is not null + ? BitFullCalendarChangeNotifier.CloneEvent(ExistingEvent) + : null; + + var ev = new BitFullCalendarEvent + { + Id = _isEditing ? ExistingEvent!.Id : Guid.NewGuid().ToString("N"), + Title = _title, + Description = _description, + StartDate = _startDate, + EndDate = _endDate, + Color = _color, + Resource = _isEditing ? ExistingEvent!.Resource : Resource, + Data = _isEditing ? ExistingEvent!.Data : null, + Attendees = [.. _attendees] + }; + + if (_isEditing) + State.UpdateEvent(ev); + else + State.AddEvent(ev); + + try + { + await Notifier.NotifyAsync(new BitFullCalendarChangeEventArgs + { + Event = BitFullCalendarChangeNotifier.CloneEvent(ev), + OldEvent = oldSnapshot, + Kind = _isEditing ? BitFullCalendarChangeKind.Edit : BitFullCalendarChangeKind.Add, + Source = BitFullCalendarChangeSource.Dialog + }); + } + catch + { + // Compensate so the dialog is safe to retry: a throwing notifier must not leave the + // event committed to State, otherwise a second submit would add a duplicate (Add) or + // the edit would be applied without its consumers ever being notified. Restore the + // pre-submit snapshot on edit, or remove the just-added event on add. Only notifier + // failures roll back - the event is committed once notification succeeds. + if (_isEditing) + { + if (oldSnapshot is not null) + State.UpdateEvent(oldSnapshot); + } + else + { + State.RemoveEvent(ev.Id); + } + throw; + } + + // Notification succeeded and the event is committed; post-notify callbacks run outside + // the compensation scope so an OnSaved/OnClose exception does not roll back the change. + // Prefer the dedicated success path when provided (e.g. the details dialog closes itself + // only on a real save), otherwise fall back to OnClose for standalone add/edit usages. + if (OnSaved.HasDelegate) + await OnSaved.InvokeAsync(); + else + await OnClose.InvokeAsync(); + } + finally + { + _isSubmitting = false; + } + } + + public async ValueTask DisposeAsync() + { + await BitFcDialogInterop.TeardownAsync(JS, _dialogRef); + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcDateTimePicker.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcDateTimePicker.razor new file mode 100644 index 0000000000..49220ea9aa --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcDateTimePicker.razor @@ -0,0 +1,80 @@ +@using System.Globalization +@namespace Bit.BlazorUI + +
+ + + @if (_isOpen) + { +
+
+ +
@GetMonthYearLabel(_visibleMonthAnchor)
+ +
+ +
+ @foreach (var weekday in _weekdayHeaders) + { + @weekday + } +
+ +
+ @foreach (var day in BuildCalendarDays()) + { + var dayClass = GetDayCellClass(day); + + } +
+ +
+ @if (Use24HourFormat) + { + + : + + } + else + { + + : + + + } +
+
+ } +
diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcDateTimePicker.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcDateTimePicker.razor.cs new file mode 100644 index 0000000000..dedf56049a --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcDateTimePicker.razor.cs @@ -0,0 +1,240 @@ +using System.Globalization; + +namespace Bit.BlazorUI; + +public partial class BitFcDateTimePicker : IDisposable +{ + [Parameter] public DateTime Value { get; set; } + [Parameter] public EventCallback ValueChanged { get; set; } + [Parameter] public CultureInfo Culture { get; set; } = CultureInfo.CurrentCulture; + /// When true the time selects/display use 24-hour values; otherwise a 12-hour hour list plus an AM/PM select. + [Parameter] public bool Use24HourFormat { get; set; } = true; + [Parameter] public string PreviousMonthAriaLabel { get; set; } = "Previous month"; + [Parameter] public string NextMonthAriaLabel { get; set; } = "Next month"; + [Parameter] public string HourAriaLabel { get; set; } = "Hour"; + [Parameter] public string MinuteAriaLabel { get; set; } = "Minute"; + [Parameter] public string MeridiemAriaLabel { get; set; } = "AM/PM"; + [Parameter] public string SelectedDayAriaLabel { get; set; } = "selected"; + + /// + /// Accessible name for the picker's trigger button. When set, assistive tech announces this + /// field name together with the current value (e.g. "Start date and time: 5/1/2025 09:00"). + /// + [Parameter] public string? TriggerAriaLabel { get; set; } + + private DateTime _visibleMonthAnchor; + private int _hour; + private int _minute; + private bool _isOpen; + private DateTime _lastSyncedDate = DateTime.MinValue; + private CultureInfo? _lastSyncedCulture; + private string[] _weekdayHeaders = []; + private CancellationTokenSource? _closeCts; + + protected override void OnParametersSet() + { + // Re-anchor the visible month when the value changes, and also when the culture/calendar + // system changes - the same DateTime maps to a different month label across calendars. + // Compare by culture name (not reference) so a change carried on a different CultureInfo + // instance is still detected, and also by calendar system so reusing the same culture name + // with a different calendar is detected too. + var calendarChanged = _lastSyncedCulture is null + || _lastSyncedCulture.DateTimeFormat.Calendar.GetType() != Culture.DateTimeFormat.Calendar.GetType(); + var cultureChanged = calendarChanged + || !string.Equals(_lastSyncedCulture?.Name, Culture.Name, StringComparison.Ordinal); + + if (_lastSyncedDate != Value || cultureChanged) + { + _hour = Value.Hour; + _minute = Value.Minute; + _visibleMonthAnchor = GetFirstDayOfMonth(Value); + _lastSyncedDate = Value; + } + + _lastSyncedCulture = Culture; + _weekdayHeaders = BuildWeekdayHeaders(); + } + + private Calendar ActiveCalendar => Culture.DateTimeFormat.Calendar; + + private string[] BuildWeekdayHeaders() + { + var source = Culture.DateTimeFormat.AbbreviatedDayNames; + var firstDay = (int)Culture.DateTimeFormat.FirstDayOfWeek; + return Enumerable.Range(0, 7) + .Select(i => source[(i + firstDay) % 7]) + .ToArray(); + } + + private void ToggleOpen() => _isOpen = !_isOpen; + + private void ShowPreviousMonth() + { + _visibleMonthAnchor = GetFirstDayOfMonth(ActiveCalendar.AddMonths(_visibleMonthAnchor, -1)); + } + + private void ShowNextMonth() + { + _visibleMonthAnchor = GetFirstDayOfMonth(ActiveCalendar.AddMonths(_visibleMonthAnchor, 1)); + } + + private async Task SelectDate(DateTime date) + { + var selected = new DateTime(date.Year, date.Month, date.Day, _hour, _minute, 0, Value.Kind); + Value = selected; + _lastSyncedDate = selected; + // Re-anchor the visible month to the selected date so picking a muted overflow day from a + // neighboring month doesn't leave the grid on the old month (OnParametersSet won't re-anchor + // because _lastSyncedDate now matches Value). + _visibleMonthAnchor = GetFirstDayOfMonth(selected); + _isOpen = false; + await ValueChanged.InvokeAsync(selected); + } + + private async Task OnTimeChanged() + { + var selected = new DateTime(Value.Year, Value.Month, Value.Day, _hour, _minute, 0, Value.Kind); + Value = selected; + _lastSyncedDate = selected; + await ValueChanged.InvokeAsync(selected); + } + + // 12-hour view helpers. _hour stays canonical (0-23); these project it onto the 12-hour + // hour list and AM/PM selector and convert edits back to the canonical 24-hour value. + private int Hour12 => _hour % 12 == 0 ? 12 : _hour % 12; + private bool IsPm => _hour >= 12; + + private static int ConvertTo24Hour(int hour12, bool pm) + { + var h = hour12 % 12; // 12 maps to 0 + return pm ? h + 12 : h; + } + + private async Task OnHour12Changed(ChangeEventArgs e) + { + if (!int.TryParse(e.Value?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var h12)) + return; + _hour = ConvertTo24Hour(Math.Clamp(h12, 1, 12), IsPm); + await OnTimeChanged(); + } + + private async Task OnMeridiemChanged(ChangeEventArgs e) + { + var pm = string.Equals(e.Value?.ToString(), "PM", StringComparison.Ordinal); + _hour = ConvertTo24Hour(Hour12, pm); + await OnTimeChanged(); + } + + private void OnFocusOut(FocusEventArgs args) + { + // Blazor's FocusEventArgs doesn't expose relatedTarget, so we can't tell from the event + // alone whether focus moved to a child (day button, time select) or left the picker. + // Defer the close briefly: if focus lands back inside the picker, OnFocusIn cancels it. + _closeCts?.Cancel(); + _closeCts?.Dispose(); + _closeCts = new CancellationTokenSource(); + var token = _closeCts.Token; + _ = CloseAfterDelay(token); + } + + private void OnFocusIn(FocusEventArgs _) + { + // Focus returned to (or moved within) the picker - keep it open. + _closeCts?.Cancel(); + } + + private async Task CloseAfterDelay(CancellationToken token) + { + try + { + await Task.Delay(120, token); + } + catch (OperationCanceledException) + { + return; + } + + if (token.IsCancellationRequested || !_isOpen) + return; + + _isOpen = false; + await InvokeAsync(StateHasChanged); + } + + private IEnumerable BuildCalendarDays() + { + var firstDayOfMonth = GetFirstDayOfMonth(_visibleMonthAnchor); + var firstDayOfWeek = Culture.DateTimeFormat.FirstDayOfWeek; + var shift = ((int)firstDayOfMonth.DayOfWeek - (int)firstDayOfWeek + 7) % 7; + var gridStart = firstDayOfMonth.AddDays(-shift); + + for (var i = 0; i < 42; i++) + { + var date = gridStart.AddDays(i); + yield return new CalendarDay( + Date: date, + Label: ActiveCalendar.GetDayOfMonth(date).ToString(Culture), + IsCurrentMonth: IsSameCalendarMonth(date, _visibleMonthAnchor), + IsSelected: date.Date == Value.Date); + } + } + + private string GetMonthYearLabel(DateTime date) + { + var month = ActiveCalendar.GetMonth(date); + var year = ActiveCalendar.GetYear(date); + var monthName = Culture.DateTimeFormat.GetMonthName(month); + return $"{monthName} {year.ToString(Culture)}"; + } + + private DateTime GetFirstDayOfMonth(DateTime date) + { + var year = ActiveCalendar.GetYear(date); + var month = ActiveCalendar.GetMonth(date); + // Anchor the first day within the same era as the source date. Era-based calendars (e.g. + // JapaneseCalendar) repeat year numbers across eras, so resolving without the era would + // map the anchor to the wrong era's month. + var era = ActiveCalendar.GetEra(date); + return ActiveCalendar.ToDateTime(year, month, 1, 0, 0, 0, 0, era); + } + + private bool IsSameCalendarMonth(DateTime left, DateTime right) => + ActiveCalendar.GetYear(left) == ActiveCalendar.GetYear(right) + && ActiveCalendar.GetMonth(left) == ActiveCalendar.GetMonth(right); + + private string GetDayCellClass(CalendarDay day) + { + var classes = "bit-bfc-dtp-day"; + if (!day.IsCurrentMonth) + classes += " bit-bfc-dtp-day-muted"; + if (day.IsSelected) + classes += " bit-bfc-dtp-day-selected"; + return classes; + } + + private string GetDayAriaLabel(CalendarDay day) + { + var fullDate = day.Date.ToString("D", Culture); + return day.IsSelected ? $"{fullDate}, {SelectedDayAriaLabel}" : fullDate; + } + + private string GetDisplayText() + { + var datePart = Value.ToString("d", Culture); + // Honor the configured time format: 24-hour ("HH:mm") or 12-hour with the culture's AM/PM + // designator ("h:mm tt"), matching the rest of the calendar's time rendering. + var timePart = Value.ToString(Use24HourFormat ? "HH:mm" : "h:mm tt", Culture); + return $"{datePart} {timePart}"; + } + + private string? GetTriggerAriaLabel() + => string.IsNullOrEmpty(TriggerAriaLabel) ? null : $"{TriggerAriaLabel}: {GetDisplayText()}"; + + private sealed record CalendarDay(DateTime Date, string Label, bool IsCurrentMonth, bool IsSelected); + + public void Dispose() + { + _closeCts?.Cancel(); + _closeCts?.Dispose(); + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventDetailsDialog.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventDetailsDialog.razor new file mode 100644 index 0000000000..6ae9b3d777 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventDetailsDialog.razor @@ -0,0 +1,80 @@ +@namespace Bit.BlazorUI + +
+ +
+ +@if (_showEdit) +{ + +} 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 new file mode 100644 index 0000000000..8c3d332e12 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventDetailsDialog.razor.cs @@ -0,0 +1,103 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Bit.BlazorUI; + +public partial class BitFcEventDetailsDialog : IAsyncDisposable +{ + [Inject] private IJSRuntime JS { get; set; } = default!; + + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarColorScheme ColorScheme { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [Parameter] public BitFullCalendarEvent Event { get; set; } = default!; + [Parameter] public EventCallback OnClose { get; set; } + + private bool _showEdit; + private bool _isDeleting; + private bool _deleteCommitted; + private ElementReference _dialogRef; + private readonly string _dialogTitleId = $"bfc-details-title-{Guid.NewGuid():N}"; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + // Move focus into the dialog and trap Tab navigation once it has rendered; teardown in + // DisposeAsync restores focus to the element that was focused before it opened. + if (firstRender) + await BitFcDialogInterop.SetupAsync(JS, _dialogRef); + } + + private void Edit() + { + _showEdit = true; + } + + private void OnEditClose() + { + // Cancelling the edit overlay must only dismiss the edit dialog, not the parent + // details dialog. The details dialog is closed via OnEditSaved on a real save. + _showEdit = false; + } + + private async Task OnEditSaved() + { + _showEdit = false; + await OnClose.InvokeAsync(); + } + + private async Task Delete() + { + // 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) + return; + _isDeleting = true; + + try + { + // Once the local removal AND the Delete notification have both succeeded, never send it + // again: _deleteCommitted is set only after NotifyAsync returns. If NotifyAsync throws, + // the local removal is rolled back so State stays in sync with consumers (mirroring the + // add/edit save compensation), and the flag stays unset so a retry re-runs both steps. + if (!_deleteCommitted) + { + var snapshot = BitFullCalendarChangeNotifier.CloneEvent(Event); + State.RemoveEvent(Event.Id); + try + { + await Notifier.NotifyAsync(new BitFullCalendarChangeEventArgs + { + Event = snapshot, + OldEvent = snapshot, + Kind = BitFullCalendarChangeKind.Delete, + Source = BitFullCalendarChangeSource.Dialog + }); + } + catch + { + // Restore the removed event so the calendar doesn't show it gone while consumers + // were never notified; the next attempt will remove and notify again. + State.AddEvent(snapshot); + throw; + } + // Mark committed only after the notification has succeeded. + _deleteCommitted = true; + } + + await OnClose.InvokeAsync(); + } + finally + { + // The event has already been removed from state, so a throwing notifier/close must not + // leave the dialog wedged with _isDeleting stuck true - reset it so the user can retry + // (e.g. close) instead of the delete button staying permanently inert. + _isDeleting = false; + } + } + + public async ValueTask DisposeAsync() + { + await BitFcDialogInterop.TeardownAsync(JS, _dialogRef); + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventListDialog.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventListDialog.razor new file mode 100644 index 0000000000..e683a1ea3e --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventListDialog.razor @@ -0,0 +1,35 @@ +@namespace Bit.BlazorUI + +
+ +
+ +@if (_showDetails && _selectedEvent != null) +{ + +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventListDialog.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventListDialog.razor.cs new file mode 100644 index 0000000000..e27605c949 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Dialogs/BitFcEventListDialog.razor.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Bit.BlazorUI; + +public partial class BitFcEventListDialog : IAsyncDisposable +{ + [Inject] private IJSRuntime JS { get; set; } = default!; + + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarColorScheme ColorScheme { get; set; } = default!; + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + [Parameter] public DateTime Date { get; set; } + [Parameter] public List Events { get; set; } = []; + [Parameter] public EventCallback OnClose { get; set; } + + private bool _showDetails; + private BitFullCalendarEvent? _selectedEvent; + private ElementReference _dialogRef; + private readonly string _dialogTitleId = $"bfc-list-title-{Guid.NewGuid():N}"; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + // Move focus into the dialog and trap Tab navigation once it has rendered; teardown in + // DisposeAsync restores focus to the element that was focused before it opened. + if (firstRender) + await BitFcDialogInterop.SetupAsync(JS, _dialogRef); + } + + private async Task SelectEvent(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + _showDetails = true; + } + + public async ValueTask DisposeAsync() + { + await BitFcDialogInterop.TeardownAsync(JS, _dialogRef); + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/DragDrop/BitFcDraggableEvent.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/DragDrop/BitFcDraggableEvent.razor new file mode 100644 index 0000000000..93014f9066 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/DragDrop/BitFcDraggableEvent.razor @@ -0,0 +1,18 @@ +@namespace Bit.BlazorUI + +@* Host is a non-button element: ChildContent is free-form and may itself contain interactive + content (buttons, links), so wrapping it in a native + @if (!HideSettings) + { + + } + + + +@if (_showAddDialog) +{ + +} 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 new file mode 100644 index 0000000000..f213c2857f --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcCalendarHeader.razor.cs @@ -0,0 +1,30 @@ +namespace Bit.BlazorUI; + +public partial class BitFcCalendarHeader +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarSettings Settings { get; set; } = default!; + [CascadingParameter(Name = "HideFilters")] public bool HideFilters { get; set; } + [CascadingParameter(Name = "HideSettings")] public bool HideSettings { get; set; } + [CascadingParameter(Name = "OnAddClick")] public EventCallback OnAddClick { get; set; } + + private bool _showAddDialog; + + private async Task OnAddEventClick() + { + if (OnAddClick.HasDelegate) + { + var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot( + State.SelectedDate, + State.StartOfDayHour); + await OnAddClick.InvokeAsync(draft); + } + else + _showAddDialog = true; + } + + protected override void OnInitialized() => State.OnStateChanged += Refresh; + private void Refresh() => InvokeAsync(StateHasChanged); + public void Dispose() => State.OnStateChanged -= Refresh; +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcDateNavigator.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcDateNavigator.razor new file mode 100644 index 0000000000..d2257e6b1c --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcDateNavigator.razor @@ -0,0 +1,13 @@ +@namespace Bit.BlazorUI + +
+ + + @BitFullCalendarHelpers.RangeText(State.View, State.SelectedDate, State.Culture) + + +
diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcDateNavigator.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcDateNavigator.razor.cs new file mode 100644 index 0000000000..fe749dff85 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcDateNavigator.razor.cs @@ -0,0 +1,7 @@ +namespace Bit.BlazorUI; + +public partial class BitFcDateNavigator +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcFilterEvents.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcFilterEvents.razor new file mode 100644 index 0000000000..b75678c9aa --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcFilterEvents.razor @@ -0,0 +1,19 @@ +@namespace Bit.BlazorUI + +
+ + + +
diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcFilterEvents.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcFilterEvents.razor.cs new file mode 100644 index 0000000000..b2c817adb4 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcFilterEvents.razor.cs @@ -0,0 +1,24 @@ +namespace Bit.BlazorUI; + +public partial class BitFcFilterEvents +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarColorScheme ColorScheme { get; set; } = default!; + + private string SelectedColorValue => State.SelectedColors.Count == 1 + ? State.SelectedColors[0] + : string.Empty; + + private void HandleColorChange(ChangeEventArgs e) + { + var value = e.Value?.ToString(); + State.SetColorFilter(string.IsNullOrWhiteSpace(value) ? null : value); + } + + private void HandleAttendeeChange(ChangeEventArgs e) + { + var value = e.Value?.ToString(); + State.SetAttendeeFilter(string.IsNullOrWhiteSpace(value) ? null : value); + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcModeTabs.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcModeTabs.razor new file mode 100644 index 0000000000..4eb2433193 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcModeTabs.razor @@ -0,0 +1,23 @@ +@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. +*@ + +@if (State.Resources.Count > 0) +{ +
+ @foreach (var mode in _modes) + { + + } +
+} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcModeTabs.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcModeTabs.razor.cs new file mode 100644 index 0000000000..7bc7278d6a --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcModeTabs.razor.cs @@ -0,0 +1,13 @@ +namespace Bit.BlazorUI; + +public partial class BitFcModeTabs +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + + private static readonly BitFullCalendarMode[] _modes = + [ + BitFullCalendarMode.Event, + BitFullCalendarMode.Timeline + ]; +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcSettings.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcSettings.razor new file mode 100644 index 0000000000..4c730c70b2 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcSettings.razor @@ -0,0 +1,73 @@ +@namespace Bit.BlazorUI + +
+ + @if (_open) + { +
+
@Texts.CalendarSettingsLabel
+ + + + + + + + + +
+ +
+ @Texts.DayStartsAtLabel +
+ + @Texts.HourSuffix +
+
+ +
+
@Texts.AgendaGroupByLabel
+ + + +
+ } +
diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcSettings.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcSettings.razor.cs new file mode 100644 index 0000000000..18c65f5a4b --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcSettings.razor.cs @@ -0,0 +1,23 @@ +namespace Bit.BlazorUI; + +public partial class BitFcSettings +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + private bool _open; + + // Unique per instance so multiple calendars on one page don't produce duplicate element IDs. + private readonly string _menuId = $"bit-bfc-settings-menu-{Guid.NewGuid():N}"; + + private void OnStartHourChange(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out int val)) + { + // Constrain to the same range SetStartOfDayHour accepts (0-16) so the textbox and + // state stay in sync even when the user types an out-of-range value. + val = Math.Clamp(val, 0, 16); + State.SetStartOfDayHour(val); + StateHasChanged(); + } + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcTodayButton.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcTodayButton.razor new file mode 100644 index 0000000000..61681e591c --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcTodayButton.razor @@ -0,0 +1,3 @@ +@namespace Bit.BlazorUI + + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcTodayButton.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcTodayButton.razor.cs new file mode 100644 index 0000000000..fd66806843 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcTodayButton.razor.cs @@ -0,0 +1,7 @@ +namespace Bit.BlazorUI; + +public partial class BitFcTodayButton +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcViewTabs.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcViewTabs.razor new file mode 100644 index 0000000000..761c772d77 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcViewTabs.razor @@ -0,0 +1,17 @@ +@namespace Bit.BlazorUI + +
+ @foreach (var view in _views) + { + if (State.Mode == BitFullCalendarMode.Timeline && !_timelineViews.Contains(view)) + continue; + + + } +
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 new file mode 100644 index 0000000000..cd3d5bc04c --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Header/BitFcViewTabs.razor.cs @@ -0,0 +1,19 @@ +namespace Bit.BlazorUI; + +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/Models/BitFullCalendarAttendee.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarAttendee.cs new file mode 100644 index 0000000000..885df90348 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarAttendee.cs @@ -0,0 +1,24 @@ +namespace Bit.BlazorUI; + +public class BitFullCalendarAttendee +{ + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string? Id { get; set; } + + public string FullName => string.Join(' ', new[] { FirstName?.Trim(), LastName?.Trim() } + .Where(part => !string.IsNullOrEmpty(part))); + + public string Initials + { + get + { + var firstName = FirstName?.Trim(); + var lastName = LastName?.Trim(); + var first = firstName?.Length > 0 ? char.ToUpperInvariant(firstName[0]).ToString() : ""; + var last = lastName?.Length > 0 ? char.ToUpperInvariant(lastName[0]).ToString() : ""; + return first + last; + } + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarCell.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarCell.cs new file mode 100644 index 0000000000..b2360377d5 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarCell.cs @@ -0,0 +1,9 @@ +namespace Bit.BlazorUI; + +public class BitFullCalendarCell +{ + public int Day { get; set; } + public bool CurrentMonth { get; set; } + public DateTime Date { get; set; } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarChangeEventArgs.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarChangeEventArgs.cs new file mode 100644 index 0000000000..19d4cd7eb1 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarChangeEventArgs.cs @@ -0,0 +1,52 @@ +namespace Bit.BlazorUI; + +/// +/// Identifies the kind of change applied to a calendar event. +/// +public enum BitFullCalendarChangeKind +{ + Add, + Edit, + Delete +} + +/// +/// Identifies where a calendar event change originated from in the UI. +/// This describes the interaction origin only; the action itself (add/edit/delete) +/// is carried by . +/// +public enum BitFullCalendarChangeSource +{ + Dialog, + Drag, + Resize +} + +/// +/// Provides details about a user-applied calendar event change. +/// +public sealed class BitFullCalendarChangeEventArgs +{ + /// + /// The current event snapshot after the change for Add/Edit, + /// or the removed event snapshot for Delete. + /// + public required BitFullCalendarEvent Event { get; init; } + + /// + /// The change type that occurred. + /// + public required BitFullCalendarChangeKind Kind { get; init; } + + /// + /// The event snapshot before the change for Edit/Delete. + /// Null for Add. + /// + public BitFullCalendarEvent? OldEvent { get; init; } + + /// + /// The UI source that triggered this change. + /// + public required BitFullCalendarChangeSource Source { get; init; } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarColorOption.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarColorOption.cs new file mode 100644 index 0000000000..8836d26e9f --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarColorOption.cs @@ -0,0 +1,45 @@ +namespace Bit.BlazorUI; + +/// +/// Describes one selectable event color in the calendar UI (picker, filters, agenda headers, +/// event badges, bullets, swatches). The list and order are controlled by the calendar component's +/// EventColorOptions parameter; events reference a color through its . +/// +public sealed class BitFullCalendarColorOption +{ + /// + /// Stable identifier of the color (matched against ). + /// Treated case-insensitively. Use a short, slug-style value such as "blue" or "skyblue". + /// + public string Id { get; init; } = string.Empty; + + /// + /// Display label shown in pickers, filters, agenda headers, and event details. This is the full + /// human-readable color name and is used as-is (no localization/transformation is applied). + /// + public string Title { get; init; } = string.Empty; + + /// + /// CSS color value used for swatches, bullets, badge accents, and chip surfaces - any value + /// accepted in CSS such as a hex ("#3b82f6"), rgb(), hsl(), or named color + /// ("skyblue"). The calendar derives badge background, border, and text contrast tints + /// from this single value at runtime. + /// + public string Value { get; init; } = string.Empty; + + /// + /// Built-in palette used when EventColorOptions is null or empty. The IDs + /// ("blue", "green", ...) match the values previously emitted by + /// BitFullCalendarEventColor, so events created against the defaults need no migration. + /// + public static IReadOnlyList Defaults { get; } = + new BitFullCalendarColorOption[] + { + new() { Id = "blue", Title = "Blue", Value = "#3b82f6" }, + new() { Id = "green", Title = "Green", Value = "#22c55e" }, + new() { Id = "red", Title = "Red", Value = "#ef4444" }, + new() { Id = "yellow", Title = "Yellow", Value = "#eab308" }, + new() { Id = "purple", Title = "Purple", Value = "#a855f7" }, + new() { Id = "orange", Title = "Orange", Value = "#f97316" }, + }.AsReadOnly(); +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarColorScheme.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarColorScheme.cs new file mode 100644 index 0000000000..0810a20128 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarColorScheme.cs @@ -0,0 +1,144 @@ +namespace Bit.BlazorUI; + +/// +/// Resolved color list and lookup helpers. Built from the calendar's EventColorOptions +/// parameter (or the built-in palette when +/// none was supplied). Events reference a color through . +/// +public sealed class BitFullCalendarColorScheme +{ + /// + /// Preferred id for events with no explicit color, used only when the configured palette actually + /// contains it. When a custom palette omits this id, the resolver falls back to the first + /// configured swatch instead (see ). + /// + public const string FallbackColorId = "blue"; + + /// Inline style emitted on color-bearing elements (bullets, swatches, chips, blocks). + public const string ColorVariableName = "--bit-bfc-evt-color"; + + private readonly Dictionary _byId; + + /// + /// The id that blank (null/empty/whitespace) color ids resolve to. Prefers + /// when the configured palette contains it, otherwise the first + /// configured swatch, so default-colored events always map to a real entry in the current scheme + /// rather than assuming "blue" exists. + /// + private readonly string _fallbackId; + + public BitFullCalendarColorScheme(IReadOnlyList? options) + { + var list = options is { Count: > 0 } ? options : BitFullCalendarColorOption.Defaults; + // Build Options and the _byId lookup from the SAME canonicalized sequence: trim ids, skip + // blanks, and keep only the first occurrence of each id (case-insensitive). Otherwise Options + // (consumed by the UI / filters / GetSortOrder) could expose blank or duplicate entries that + // the id resolver silently ignores, so what the user sees would drift from what Find resolves. + _byId = new Dictionary(StringComparer.OrdinalIgnoreCase); + var canonical = new List(list.Count); + foreach (var o in list) + { + var id = o.Id?.Trim(); + if (string.IsNullOrEmpty(id) || _byId.ContainsKey(id)) + continue; + // Store a normalized copy (trimmed id) in BOTH collections so Options never exposes an + // untrimmed id that Find would otherwise silently resolve through its trimmed key. + var normalized = string.Equals(o.Id, id, StringComparison.Ordinal) + ? o + : new BitFullCalendarColorOption { Id = id, Title = o.Title, Value = o.Value }; + _byId[id] = normalized; + canonical.Add(normalized); + } + // Wrap in a read-only view so consumers can't mutate Options after construction and + // desynchronize it from the _byId lookup it was built alongside. + Options = canonical.AsReadOnly(); + + // Resolve the blank-id fallback against the CURRENT scheme: prefer "blue" when present, + // otherwise the first configured swatch. Only assume the "blue" literal when nothing was + // configured at all (Options is empty), so blank ids never point at a non-existent entry. + _fallbackId = _byId.ContainsKey(FallbackColorId) + ? FallbackColorId + : (Options.Count > 0 ? Options[0].Id : FallbackColorId); + } + + /// Configured colors in display order. + public IReadOnlyList Options { get; } + + /// + /// Maps a blank (null/empty/whitespace) color id to the scheme's resolved fallback swatch + /// () and trims the rest, so blank ids resolve to the same swatch as the + /// default-colored events everywhere (lookup, label, css value, sort order) instead of drifting + /// between methods. + /// + private string NormalizeId(string? colorId) + => string.IsNullOrWhiteSpace(colorId) ? _fallbackId : colorId.Trim(); + + /// Looks up a color option by id (case-insensitive). Returns null when unknown. + public BitFullCalendarColorOption? Find(string? colorId) + { + return _byId.TryGetValue(NormalizeId(colorId), out var o) ? o : null; + } + + /// Display label for dropdowns, filters, agenda headers, and event details. + public string GetLabel(string? colorId) + { + var opt = Find(colorId); + if (opt is not null && !string.IsNullOrWhiteSpace(opt.Title)) + return opt.Title; + // Trim the raw fallback so whitespace-padded/unknown ids resolve to a cleaned label, + // consistent with the trimming applied everywhere else in the resolver. + return colorId?.Trim() ?? string.Empty; + } + + /// CSS color value for the supplied id (falls back to the first configured color). + public string GetCssValue(string? colorId) + { + var opt = Find(colorId); + if (opt is not null && !string.IsNullOrWhiteSpace(opt.Value)) + return opt.Value; + var first = Options.Count > 0 ? Options[0] : null; + return !string.IsNullOrWhiteSpace(first?.Value) ? first!.Value : "#3b82f6"; + } + + /// + /// Inline style string that publishes the resolved color value as the + /// CSS custom property. Combine with the matching CSS classes + /// (e.g. bit-bfc-color, bit-bfc-bg, bit-bfc-bullet) to render the chip surface. + /// + public string GetColorStyle(string? colorId) => + $"{ColorVariableName}:{GetCssValue(colorId)};"; + + /// + /// Options shown in the add/edit dialog. If the event references an id that is not in + /// (for example a color removed at runtime) the missing entry is + /// appended so the value remains selectable. + /// + public IReadOnlyList GetEditorOptions(string? editingColorId) + { + if (string.IsNullOrWhiteSpace(editingColorId) || _byId.ContainsKey(editingColorId.Trim())) + return Options; + + var extra = new List(Options.Count + 1); + extra.AddRange(Options); + extra.Add(new BitFullCalendarColorOption + { + Id = editingColorId.Trim(), + Title = editingColorId.Trim(), + Value = GetCssValue(editingColorId) + }); + return extra; + } + + /// Sort key for agenda grouping - configured order first, then unknown ids (sorted by name at the call site). + public int GetSortOrder(string? colorId) + { + var trimmed = NormalizeId(colorId); + for (var i = 0; i < Options.Count; i++) + { + if (string.Equals(Options[i].Id?.Trim(), trimmed, StringComparison.OrdinalIgnoreCase)) + return i; + } + // Deterministic: all unknown ids share the same key and are ordered lexically by a secondary sort. + return int.MaxValue; + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarDateChangeEventArgs.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarDateChangeEventArgs.cs new file mode 100644 index 0000000000..93376d0154 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarDateChangeEventArgs.cs @@ -0,0 +1,17 @@ +namespace Bit.BlazorUI; + +/// +/// Provides details about a date range change in the calendar, +/// fired when the user navigates (prev/next/today) or switches views. +/// +public sealed class BitFullCalendarDateChangeEventArgs +{ + /// Start of the visible date range (inclusive). + public required DateTime Start { get; init; } + + /// End of the visible date range (inclusive). + public required DateTime End { get; init; } + + /// The active calendar view when the change occurred. + public required BitFullCalendarView View { get; init; } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarEvent.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarEvent.cs new file mode 100644 index 0000000000..1522081419 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarEvent.cs @@ -0,0 +1,47 @@ +namespace Bit.BlazorUI; + +public class BitFullCalendarEvent +{ + public string Id { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + /// + /// Identifier of the color (matches a from the + /// calendar's configured palette). Defaults to + /// so that out-of-the-box rendering keeps working with the built-in palette. + /// + public string Color { get; set; } = BitFullCalendarColorScheme.FallbackColorId; + private List _attendees = []; + /// + /// Attendees of the event. Never null: assigning null coalesces to an empty list + /// so downstream code can safely iterate without null checks. + /// + public List Attendees + { + get => _attendees; + set => _attendees = value ?? []; + } + + /// + /// Optional resource identifier linking this event to a + /// (for example a meeting room name or a machine id). Used by the resource timeline view to + /// place the event on the matching resource row. null or empty means the event is unassigned. + /// Whitespace-only values are normalized to null so a blank id can never map to a resource + /// row, mirroring how rejects blank identifiers. + /// + public string? Resource + { + get => _resource; + set => _resource = string.IsNullOrWhiteSpace(value) ? null : value; + } + private string? _resource; + + public bool IsSingleDay => StartDate.Date == BitFullCalendarHelpers.GetInclusiveEndDate(this); + public bool IsMultiDay => !IsSingleDay; + public TimeSpan Duration => EndDate - StartDate; + + public object? Data { get; set; } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarResource.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarResource.cs new file mode 100644 index 0000000000..cf91953567 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarResource.cs @@ -0,0 +1,41 @@ +namespace Bit.BlazorUI; + +/// +/// A schedulable resource shown as a row in the resource timeline view (for example, +/// a meeting room, a person, a piece of equipment). +/// Events are linked to a resource through +/// matching . +/// +public sealed class BitFullCalendarResource +{ + /// + /// Stable identifier matched against . + /// Cannot be null, empty, or whitespace - grouping helpers key rows by this value and assume a + /// non-empty key, so a blank id is rejected at assignment time. Ids must also be unique across + /// the resource list assigned to the calendar; duplicate ids are rejected when the resources are + /// applied because timeline row grouping and rendering key on this value. + /// + public required string Id + { + get => _id; + set => _id = string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Resource Id cannot be null, empty, or whitespace.", nameof(value)) + : value; + } + private string _id = null!; + + /// + /// Display name for the resource (for example "Bay Wing", "Alice Johnson", "Meeting Room 3B"). + /// + public string Title { get; set; } = string.Empty; + + /// + /// Optional subtitle shown below the resource title (for example building, department). + /// + public string? Subtitle { get; set; } + + /// + /// Optional consumer-defined payload available to templates and click handlers. + /// + public object? Data { get; set; } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarSettings.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarSettings.cs new file mode 100644 index 0000000000..ea8ab5dda6 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarSettings.cs @@ -0,0 +1,32 @@ +namespace Bit.BlazorUI; + +/// +/// Configuration settings for the component. +/// These values are applied as initial defaults when the component mounts, +/// or whenever a new instance is assigned. +/// +public class BitFullCalendarSettings +{ + /// Uses 24-hour time format instead of 12-hour (AM/PM). + public bool Use24HourFormat { get; set; } = true; + + /// Badge display style in the month view. + public BitFullCalendarBadgeVariant BadgeVariant { get; set; } = BitFullCalendarBadgeVariant.Colored; + + /// Hour (0–16) at which the day/week time grid begins. Values outside the range are clamped. + public int StartOfDayHour + { + get => _startOfDayHour; + set => _startOfDayHour = Math.Clamp(value, 0, 16); + } + private int _startOfDayHour = 8; + + /// How events are grouped in the agenda view. + public BitFullCalendarAgendaGroupBy AgendaModeGroupBy { get; set; } = BitFullCalendarAgendaGroupBy.Date; + + /// How overlapping event cards are positioned in the day and week views. + public BitFullCalendarEventLayout EventLayout { get; set; } = BitFullCalendarEventLayout.Overlap; + + /// Renders the mini calendar shown in the day view sidebar. + public bool ShowDayViewCalendar { get; set; } = true; +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarTexts.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarTexts.cs new file mode 100644 index 0000000000..2318ecf5f9 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Models/BitFullCalendarTexts.cs @@ -0,0 +1,120 @@ +namespace Bit.BlazorUI; + +public class BitFullCalendarTexts +{ + public string ViewDay { get; set; } = "Day"; + public string ViewWeek { get; set; } = "Week"; + public string ViewMonth { get; set; } = "Month"; + public string ViewYear { get; set; } = "Year"; + public string ViewAgenda { get; set; } = "Agenda"; + + public string ModeEvent { get; set; } = "Events"; + public string ModeTimeline { get; set; } = "Timeline"; + + public string BitFcTodayButton { get; set; } = "Today"; + public string AddEventButton { get; set; } = "Add Event"; + public string AddEventHoverHint { get; set; } = "Add event"; + public string PreviousButtonTitle { get; set; } = "Previous"; + public string NextButtonTitle { get; set; } = "Next"; + public string PreviousMonthAriaLabel { get; set; } = "Previous month"; + public string NextMonthAriaLabel { get; set; } = "Next month"; + public string PickerHourAriaLabel { get; set; } = "Hour"; + public string PickerMinuteAriaLabel { get; set; } = "Minute"; + public string PickerMeridiemAriaLabel { get; set; } = "AM/PM"; + public string PickerSelectedDayAriaLabel { get; set; } = "selected"; + public string SettingsButtonTitle { get; set; } = "Settings"; + + public string FilterByColorAriaLabel { get; set; } = "Filter events by color"; + public string FilterByPersonAriaLabel { get; set; } = "Filter events by person in current view"; + public string AllColorsOption { get; set; } = "All colors"; + public string AllPeopleOption { get; set; } = "All people"; + public string UnnamedAttendee { get; set; } = "(Unnamed)"; + + public string CalendarSettingsLabel { get; set; } = "Calendar settings"; + public string DotBadgeLabel { get; set; } = "Dot badge"; + public string TwentyFourHourFormatLabel { get; set; } = "24-hour format"; + public string DayStartsAtLabel { get; set; } = "Day starts at"; + public string HourSuffix { get; set; } = "h"; + public string AgendaGroupByLabel { get; set; } = "Agenda group by"; + public string AgendaGroupByDate { get; set; } = "Date"; + public string AgendaGroupByColor { get; set; } = "Color"; + public string StackedEventsLabel { get; set; } = "Stack overlapping events"; + public string ShowDayViewCalendarLabel { get; set; } = "Show calendar in day view"; + + public string WeekMobileWarning { get; set; } = "Weekly view is not recommended on smaller devices. Please switch to a desktop device or use the daily view instead."; + public string HappeningNowTitle { get; set; } = "Happening now"; + public string NoAppointmentsNow { get; set; } = "No appointments at the moment"; + + public string SearchEventsPlaceholder { get; set; } = "Search events..."; + public string NoEventsFound { get; set; } = "No events found."; + + // Full format templates ({0} = the relevant value) so localized strings control word order and + // placement rather than concatenating fixed English fragments at the call site. + public string EventListTitleFormat { get; set; } = "Events on {0}"; + public string EventListCountFormat { get; set; } = "{0} event(s)"; + public string MoreEventsFormat { get; set; } = "+{0} more"; + + public string AddEventDialogTitle { get; set; } = "Add New Event"; + public string EditEventDialogTitle { get; set; } = "Edit Event"; + public string AddEventDialogSubtitle { get; set; } = "Create a new event for your calendar."; + public string EditEventDialogSubtitle { get; set; } = "Modify your existing event."; + + public string CloseAriaLabel { get; set; } = "Close"; + public string CloseButton { get; set; } = "Close"; + public string CancelButton { get; set; } = "Cancel"; + public string EditButton { get; set; } = "Edit"; + public string DeleteButton { get; set; } = "Delete"; + public string CreateEventButton { get; set; } = "Create Event"; + public string SaveChangesButton { get; set; } = "Save Changes"; + + public string TitleLabel { get; set; } = "Title"; + public string EventTitlePlaceholder { get; set; } = "Event title"; + public string StartDateTimeLabel { get; set; } = "Start Date & Time"; + public string EndDateTimeLabel { get; set; } = "End Date & Time"; + public string ColorLabel { get; set; } = "Color"; + public string EventColorAriaLabel { get; set; } = "Event color"; + public string DescriptionLabel { get; set; } = "Description"; + public string EventDescriptionPlaceholder { get; set; } = "Event description"; + public string AttendeesLabel { get; set; } = "Attendees"; + public string NoAttendeesText { get; set; } = "No attendees"; + public string FirstNamePlaceholder { get; set; } = "First name"; + public string LastNamePlaceholder { get; set; } = "Last name"; + public string IdOptionalPlaceholder { get; set; } = "ID (optional)"; + public string AddButton { get; set; } = "Add"; + public string RemoveAttendeeAriaLabel { get; set; } = "Remove attendee"; + + public string StartDateLabel { get; set; } = "Start Date"; + public string EndDateLabel { get; set; } = "End Date"; + public string AtWord { get; set; } = "at"; + + public string ValidationTitleRequired { get; set; } = "Title is required"; + public string ValidationDescriptionRequired { get; set; } = "Description is required"; + public string ValidationEndAfterStart { get; set; } = "End date must be after start date"; + public string ValidationAttendeeNameRequired { get; set; } = "First name or last name is required"; + + public string ResizePreviewAriaLabel { get; set; } = "New time range"; + + public string ResourceLabel { get; set; } = "Resource"; + public string ResourceColumnHeader { get; set; } = "Resource"; + public string NoResourceLabel { get; set; } = "Unassigned"; + public string NoResourceOption { get; set; } = "(none)"; + public string NoResourcesMessage { get; set; } = "No resources to display."; + + public string GetViewLabel(BitFullCalendarView view) => view switch + { + BitFullCalendarView.Day => ViewDay, + BitFullCalendarView.Week => ViewWeek, + BitFullCalendarView.Month => ViewMonth, + BitFullCalendarView.Year => ViewYear, + BitFullCalendarView.Agenda => ViewAgenda, + _ => view.ToString() + }; + + public string GetModeLabel(BitFullCalendarMode mode) => mode switch + { + BitFullCalendarMode.Event => ModeEvent, + BitFullCalendarMode.Timeline => ModeTimeline, + _ => mode.ToString() + }; +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcAgendaScrollInterop.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcAgendaScrollInterop.cs new file mode 100644 index 0000000000..8227317a59 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcAgendaScrollInterop.cs @@ -0,0 +1,39 @@ +using System.Globalization; +using Microsoft.JSInterop; + +namespace Bit.BlazorUI; + +internal static class BitFcAgendaScrollInterop +{ + public static async ValueTask TryScrollToDateAsync( + IJSRuntime js, + string scrollContainerId, + DateTime date, + CancellationToken cancellationToken = default) + { + try + { + return await js.InvokeAsync( + "BitBlazorUI.FullCalendar.scrollAgendaToDate", + cancellationToken, + scrollContainerId, + date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + } + catch (JSDisconnectedException) + { + return false; + } + catch (OperationCanceledException) + { + return false; + } + catch (JSException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcDialogInterop.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcDialogInterop.cs new file mode 100644 index 0000000000..04f17fdef2 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcDialogInterop.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Bit.BlazorUI; + +/// +/// Focus management for the calendar's modal dialogs: moving focus into the dialog and trapping +/// Tab navigation within it while it is open, then restoring focus to the previously focused +/// element when it closes. All calls swallow the expected interop teardown exceptions so a +/// dialog disposing during circuit shutdown never surfaces an error. +/// +internal static class BitFcDialogInterop +{ + public static async ValueTask SetupAsync(IJSRuntime js, ElementReference container) + { + // Only suppress the cases that legitimately occur when the circuit is going away + // (disconnect / cancellation). A JSException or InvalidOperationException here means the + // focus-management interop is genuinely broken, so let it surface instead of silently + // leaving the dialog without focus trapping. + try + { + await js.InvokeVoidAsync("BitBlazorUI.FullCalendar.setupDialog", container); + } + catch (JSDisconnectedException) { } + catch (OperationCanceledException) { } + } + + public static async ValueTask TeardownAsync(IJSRuntime js, ElementReference container) + { + try + { + await js.InvokeVoidAsync("BitBlazorUI.FullCalendar.teardownDialog", container); + } + catch (JSDisconnectedException) { } + catch (OperationCanceledException) { } + catch (JSException) { } + catch (InvalidOperationException) { } + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcTimeGridScrollInterop.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcTimeGridScrollInterop.cs new file mode 100644 index 0000000000..564d90e7cc --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcTimeGridScrollInterop.cs @@ -0,0 +1,40 @@ +using Microsoft.JSInterop; + +namespace Bit.BlazorUI; + +internal static class BitFcTimeGridScrollInterop +{ + public static async ValueTask TryScrollToStartOfDayAsync( + IJSRuntime js, + string elementId, + int startOfDayHour, + CancellationToken cancellationToken = default) + { + try + { + return await js.InvokeAsync( + "BitBlazorUI.FullCalendar.scrollToHour", + cancellationToken, + elementId, + startOfDayHour, + BitFullCalendarHelpers.HourHeightPx); + } + catch (JSDisconnectedException) + { + return false; + } + catch (OperationCanceledException) + { + return false; + } + catch (JSException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcTimelineScrollInterop.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcTimelineScrollInterop.cs new file mode 100644 index 0000000000..b8944d7762 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFcTimelineScrollInterop.cs @@ -0,0 +1,36 @@ +using Microsoft.JSInterop; + +namespace Bit.BlazorUI; + +internal static class BitFcTimelineScrollInterop +{ + public static async ValueTask TryScrollToTargetAsync( + IJSRuntime js, + string scrollContainerId, + CancellationToken cancellationToken = default) + { + try + { + return await js.InvokeAsync( + "BitBlazorUI.FullCalendar.scrollTimelineToTarget", + cancellationToken, + scrollContainerId); + } + catch (JSDisconnectedException) + { + return false; + } + catch (OperationCanceledException) + { + return false; + } + catch (JSException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarChangeNotifier.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarChangeNotifier.cs new file mode 100644 index 0000000000..6fdc6561e3 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarChangeNotifier.cs @@ -0,0 +1,98 @@ +using Bit.BlazorUI; + +namespace Bit.BlazorUI; + +/// +/// Dispatches calendar event change notifications to the component consumer. +/// Also provides wrappers for mutation paths that need pre/post snapshots. +/// +public sealed class BitFullCalendarChangeNotifier +{ + private readonly BitFullCalendarState _state; + private readonly Func _dispatch; + + public BitFullCalendarChangeNotifier(BitFullCalendarState state, Func dispatch) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(dispatch); + + _state = state; + _dispatch = dispatch; + } + + /// + /// Dispatches a change payload to the component's OnChange callback. + /// + public Task NotifyAsync(BitFullCalendarChangeEventArgs args) => _dispatch(args); + + /// + /// Applies drop logic through and emits + /// an Edit change when the event date-time has actually changed. + /// + public Task HandleDropAsync(DateTime targetDate, int? hour = null, int? minute = null) + => HandleDropCoreAsync(targetDate, hour, minute, resourceId: null, applyResource: false); + + /// + /// Drops the dragged event on the supplied date/time and (optionally) reassigns its resource, + /// emitting an Edit change when anything actually changed. + /// + public Task HandleResourceDropAsync(DateTime targetDate, int? hour, int? minute, string? resourceId) + => HandleDropCoreAsync(targetDate, hour, minute, resourceId, applyResource: true); + + private Task HandleDropCoreAsync(DateTime targetDate, int? hour, int? minute, string? resourceId, bool applyResource) + { + var dragged = _state.DraggedEvent; + if (dragged is null) + return Task.CompletedTask; + + var oldSnapshot = CloneEvent(dragged); + var eventId = dragged.Id; + + _state.HandleDrop(targetDate, hour, minute, resourceId, applyResource); + + var after = _state.AllEvents.FirstOrDefault(e => e.Id == eventId); + if (after is null) + return Task.CompletedTask; + + var sameTime = after.StartDate == oldSnapshot.StartDate && after.EndDate == oldSnapshot.EndDate; + var sameResource = string.Equals(after.Resource ?? "", oldSnapshot.Resource ?? "", StringComparison.Ordinal); + if (sameTime && sameResource) + return Task.CompletedTask; + + return NotifyAsync(new BitFullCalendarChangeEventArgs + { + Event = CloneEvent(after), + OldEvent = oldSnapshot, + Kind = BitFullCalendarChangeKind.Edit, + Source = BitFullCalendarChangeSource.Drag + }); + } + + /// + /// Creates a snapshot of a calendar event payload suitable for change args. Value-type fields + /// and the collection are copied into fresh + /// instances, but the consumer-defined payload is + /// shared by reference (it is an opaque object? that cannot be generically cloned). + /// + public static BitFullCalendarEvent CloneEvent(BitFullCalendarEvent source) => + new() + { + Id = source.Id, + Title = source.Title, + Description = source.Description, + StartDate = source.StartDate, + EndDate = source.EndDate, + Color = source.Color, + Resource = source.Resource, + Data = source.Data, + Attendees = source.Attendees + .Select(a => new BitFullCalendarAttendee + { + FirstName = a.FirstName, + LastName = a.LastName, + Id = a.Id + }) + .ToList() + }; +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarHelpers.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarHelpers.cs new file mode 100644 index 0000000000..4cbf1d025d --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarHelpers.cs @@ -0,0 +1,815 @@ +using System.Globalization; + +namespace Bit.BlazorUI; + +public static class BitFullCalendarHelpers +{ + public const int HourHeightPx = 96; + /// Width of a single hour column on the timeline-mode day/week views. + public const int TimelineHourWidthPx = 96; + /// Width of a single day column on the timeline-mode month view. + public const int TimelineDayWidthPx = 56; + private const string FormatString = "MMM d, yyyy"; + + /// + /// Returns the inclusive end date of an event, treating a 00:00 end as ending the + /// previous day (exclusive midnight). Centralizes the AddTicks(-1) normalization used by + /// the overlap/placement helpers and the model's + /// so an event ending at midnight is never counted on the following day. + /// + public static DateTime GetInclusiveEndDate(BitFullCalendarEvent ev) + => (ev.EndDate > ev.StartDate ? ev.EndDate.AddTicks(-1) : ev.EndDate).Date; + + /// + /// True when an event overlaps the period [periodStart, periodEndInclusive]. The end is + /// treated exclusively (EndDate > periodStart) so an event ending exactly at the period + /// boundary doesn't leak in, but zero-length single-day events (StartDate == EndDate, e.g. + /// a 00:00 all-day marker) that fall on or after are still kept - + /// otherwise they'd be dropped by the strict EndDate > periodStart check even though they + /// sit inside the visible range. Used by the year/week/month period helpers so they share one rule. + /// + private static bool OverlapsPeriod(BitFullCalendarEvent ev, DateTime periodStart, DateTime periodEndInclusive) + => ev.StartDate.Date <= periodEndInclusive + && (ev.EndDate > periodStart || (ev.StartDate == ev.EndDate && ev.StartDate >= periodStart)); + + // -- Culture-aware: Range text ------------------------------ + + public static string RangeText(BitFullCalendarView view, DateTime date, CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var cal = culture.Calendar; + + switch (view) + { + case BitFullCalendarView.Month: + case BitFullCalendarView.Agenda: + { + // Use the culture's YearMonth pattern so the field ordering (month-before-year vs + // year-before-month) follows the culture's calendar instead of being hard-coded. + return date.ToString("Y", culture); + } + case BitFullCalendarView.Week: + { + var start = StartOfWeek(date, culture); + var end = start.AddDays(6); + return $"{FormatCultureDate(start, culture)} - {FormatCultureDate(end, culture)}"; + } + case BitFullCalendarView.Day: + return FormatCultureDate(date, culture); + case BitFullCalendarView.Year: + { + int y = cal.GetYear(date); + return y.ToString(culture); + } + default: + return "Error"; + } + } + + /// Formats a date with an abbreviated month using the culture's field ordering and calendar. + public static string FormatCultureDate(DateTime date, CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var dtf = culture.DateTimeFormat; + + // Honor the culture's field ordering (and calendar) by deriving the date pattern from the + // culture's long-date pattern instead of hard-coding "Mon d, yyyy": drop the weekday token + // and prefer the abbreviated month so the label stays compact across cultures (e.g. + // day-month-year in much of Europe, year-month-day in East Asian calendars). Falls back to + // the previous manual format if the derived pattern turns out to be unusable. + try + { + var pattern = BuildAbbreviatedDatePattern(dtf.LongDatePattern); + if (!string.IsNullOrWhiteSpace(pattern)) + return date.ToString(pattern, culture); + } + catch (FormatException) + { + // Derived pattern was not a valid format string for this culture; fall through. + } + + var cal = culture.Calendar; + int y = cal.GetYear(date); + int m = cal.GetMonth(date); + int d = cal.GetDayOfMonth(date); + return $"{dtf.GetAbbreviatedMonthName(m)} {d.ToString(culture)}, {y.ToString(culture)}"; + } + + /// + /// Derives a compact date pattern from a culture's long-date pattern by removing the day-of-week + /// token and using the abbreviated month form, while preserving the culture's field ordering. + /// + private static string BuildAbbreviatedDatePattern(string longDatePattern) + { + if (string.IsNullOrWhiteSpace(longDatePattern)) + return string.Empty; + + // Remove the day-of-week token (ddd / dddd), switch to the abbreviated month, then clean up + // any separators left behind (whitespace and comma variants, including the Arabic comma). + var p = System.Text.RegularExpressions.Regex.Replace(longDatePattern, "d{3,4}", ""); + p = p.Replace("MMMM", "MMM"); + p = System.Text.RegularExpressions.Regex.Replace(p, "^[\\s,\u060C]+", ""); + p = System.Text.RegularExpressions.Regex.Replace(p, "[\\s,\u060C]+$", ""); + p = System.Text.RegularExpressions.Regex.Replace(p, "\\s{2,}", " "); + return p.Trim(); + } + + // -- Culture-aware: Navigation ------------------------------ + + public static DateTime NavigateDate(DateTime date, BitFullCalendarView view, bool forward, CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var cal = culture.Calendar; + int delta = forward ? 1 : -1; + return view switch + { + BitFullCalendarView.Month => cal.AddMonths(date, delta), + BitFullCalendarView.Week => date.AddDays(forward ? 7 : -7), + BitFullCalendarView.Day => date.AddDays(delta), + BitFullCalendarView.Year => cal.AddYears(date, delta), + BitFullCalendarView.Agenda => cal.AddMonths(date, delta), + _ => date + }; + } + + // -- Culture-aware: Week helpers ------------------------------ + + public static DateTime StartOfWeek(DateTime date, CultureInfo? culture = null) + { + var startDay = (culture ?? CultureInfo.CurrentUICulture).DateTimeFormat.FirstDayOfWeek; + return StartOfWeek(date, startDay); + } + + public static DateTime StartOfWeek(DateTime date, DayOfWeek startDay) + { + int diff = (7 + (date.DayOfWeek - startDay)) % 7; + return date.Date.AddDays(-diff); + } + + public static DateTime[] GetWeekDates(DateTime date, CultureInfo? culture = null) + { + var start = StartOfWeek(date, culture); + return Enumerable.Range(0, 7).Select(i => start.AddDays(i)).ToArray(); + } + + // -- Culture-aware: Weekday header names ------------------------------ + + /// + /// Returns 7 shortest day-name strings (1 char) starting from + /// culture.DateTimeFormat.FirstDayOfWeek. + /// + public static string[] GetWeekDayHeaders(CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var dtf = culture.DateTimeFormat; + var first = (int)dtf.FirstDayOfWeek; + return Enumerable.Range(0, 7) + .Select(i => dtf.GetShortestDayName((DayOfWeek)((first + i) % 7))) + .ToArray(); + } + + /// + /// Returns 7 abbreviated day-name strings (2-3 chars) starting from + /// culture.DateTimeFormat.FirstDayOfWeek. + /// + public static string[] GetAbbreviatedWeekDayHeaders(CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var dtf = culture.DateTimeFormat; + var first = (int)dtf.FirstDayOfWeek; + return Enumerable.Range(0, 7) + .Select(i => dtf.GetAbbreviatedDayName((DayOfWeek)((first + i) % 7))) + .ToArray(); + } + + // -- Culture-aware: Calendar grid cells ------------------------------ + + public static List GetCalendarCells(DateTime selectedDate, CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var cal = culture.Calendar; + var dtf = culture.DateTimeFormat; + + // Anchor on the first day of the cultural month using pure date arithmetic derived from the + // selected date itself, then walk adjacent days with AddDays / AddMonths. This keeps every + // reconstructed date in the selected date's own era (e.g. JapaneseCalendar) instead of + // re-materializing year/month numbers against CurrentEra, and avoids manual +/-1 month math + // on GetYear/GetMonth results that can resolve the wrong era or an invalid day. + int culturalDay = cal.GetDayOfMonth(selectedDate); + DateTime firstDay = selectedDate.Date.AddDays(1 - culturalDay); + + // Days in this month = distance to the first day of the next month (era-preserving transition). + DateTime nextMonthFirstDay = cal.AddMonths(firstDay, 1); + int daysInMonth = (int)(nextMonthFirstDay.Date - firstDay.Date).TotalDays; + + // Leading blank cells (days from the previous cultural month) + int firstDow = (int)cal.GetDayOfWeek(firstDay); + int culturalFirst = (int)dtf.FirstDayOfWeek; + int leadingDays = (firstDow - culturalFirst + 7) % 7; + + var cells = new List(); + + for (int i = leadingDays; i > 0; i--) + { + DateTime date = firstDay.AddDays(-i); + cells.Add(new BitFullCalendarCell { Day = cal.GetDayOfMonth(date), CurrentMonth = false, Date = date }); + } + + for (int i = 0; i < daysInMonth; i++) + { + DateTime date = firstDay.AddDays(i); + cells.Add(new BitFullCalendarCell { Day = cal.GetDayOfMonth(date), CurrentMonth = true, Date = date }); + } + + int totalDays = leadingDays + daysInMonth; + int trailing = (7 - (totalDays % 7)) % 7; + + for (int i = 0; i < trailing; i++) + { + DateTime date = nextMonthFirstDay.AddDays(i); + cells.Add(new BitFullCalendarCell { Day = cal.GetDayOfMonth(date), CurrentMonth = false, Date = date }); + } + + return cells; + } + + // -- Culture-aware: Day-of-month display ------------------------------ + + public static int GetCulturalDayOfMonth(DateTime date, CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + return culture.Calendar.GetDayOfMonth(date); + } + + // -- Culture-aware: Events for year ------------------------------ + + public static List GetEventsForYear(List events, DateTime date, CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var cal = culture.Calendar; + int culturalYear = cal.GetYear(date); + DateTime yearStart = cal.ToDateTime(culturalYear, 1, 1, 0, 0, 0, 0); + int monthsInYear = cal.GetMonthsInYear(culturalYear); + int lastDayOfYear = cal.GetDaysInMonth(culturalYear, monthsInYear); + DateTime yearEnd = cal.ToDateTime(culturalYear, monthsInYear, lastDayOfYear, 23, 59, 59, 0); + return events.Where(ev => OverlapsPeriod(ev, yearStart, yearEnd)).ToList(); + } + + public static string FormatTime(DateTime date, bool use24Hour, CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + return use24Hour + ? date.ToString("HH:mm", culture) + : date.ToString("h:mm tt", culture); + } + + /// + /// Builds a human-friendly tooltip string for an event. Includes the title, the time range, + /// and (when present and not redundant with the title) the description. Used by event cards + /// where layout space may hide most of the visual content. + /// + public static string BuildEventTooltip(BitFullCalendarEvent ev, bool use24Hour, CultureInfo? culture = null) + { + if (ev is null) + return string.Empty; + + var title = string.IsNullOrWhiteSpace(ev.Title) ? string.Empty : ev.Title.Trim(); + var time = $"{FormatTime(ev.StartDate, use24Hour, culture)} - {FormatTime(ev.EndDate, use24Hour, culture)}"; + + var lines = new List(3); + if (!string.IsNullOrEmpty(title)) + lines.Add(title); + lines.Add(time); + + if (!string.IsNullOrWhiteSpace(ev.Description)) + { + var description = ev.Description.Trim(); + if (!string.Equals(description, title, StringComparison.Ordinal)) + lines.Add(description); + } + + return string.Join('\n', lines); + } + + public static string FormatHourLabel(int hour, bool use24Hour, CultureInfo? culture = null) + { + var dt = DateTime.Today.AddHours(hour); + culture ??= CultureInfo.CurrentUICulture; + return use24Hour + ? dt.ToString("HH:00", culture) + : dt.ToString("h tt", culture); + } + + /// + /// Computes horizontal pixel position and width for an event placed on a resource timeline row. + /// The event range is clipped to the visible day so events that span past midnight stay + /// inside the row. Returns (LeftPx, WidthPx) or null when the event has no overlap with the day. + /// + public static (double LeftPx, double WidthPx)? GetTimelineBlockPosition( + BitFullCalendarEvent ev, DateTime day, int hourWidthPx = TimelineHourWidthPx) + { + var dayStart = day.Date; + var dayEnd = dayStart.AddDays(1); + + var clippedStart = ev.StartDate < dayStart ? dayStart : ev.StartDate; + var clippedEnd = ev.EndDate > dayEnd ? dayEnd : ev.EndDate; + if (clippedEnd <= clippedStart) + return null; + + var pxPerMinute = hourWidthPx / 60.0; + var leftMinutes = (clippedStart - dayStart).TotalMinutes; + var widthMinutes = (clippedEnd - clippedStart).TotalMinutes; + + return (leftMinutes * pxPerMinute, widthMinutes * pxPerMinute); + } + + /// + /// Groups events for a single day by their id. + /// Within each resource, events are arranged into non-overlapping lanes so overlapping events + /// stack vertically inside the same row (similar to for day/week). + /// Events with no resource id (or an id not in ) are placed + /// under . + /// + public static Dictionary>> GroupEventsByResourceForDay( + List events, + DateTime day, + IEnumerable resourceIds, + string unassignedKey) + { + var dayStart = day.Date; + var dayEnd = dayStart.AddDays(1); + + var keyed = new Dictionary>(StringComparer.Ordinal); + foreach (var id in resourceIds) + { + if (!keyed.ContainsKey(id)) + keyed[id] = []; + } + keyed[unassignedKey] = []; + + var validIds = new HashSet(keyed.Keys, StringComparer.Ordinal); + + foreach (var ev in events) + { + if (ev.StartDate >= dayEnd) + continue; + // Keep zero-length markers (StartDate == EndDate, e.g. a 00:00 all-day marker) that fall + // on/after dayStart, matching OverlapsPeriod/GetEventsForDay and the month grouping; the + // strict EndDate <= dayStart check would otherwise drop a midnight marker sitting at the + // day start instead of laning it. + if (ev.EndDate <= dayStart && !(ev.StartDate == ev.EndDate && ev.StartDate >= dayStart)) + continue; + + var key = ev.Resource is { Length: > 0 } r && validIds.Contains(r) ? r : unassignedKey; + keyed[key].Add(ev); + } + + return keyed.ToDictionary( + kv => kv.Key, + kv => GroupEvents(kv.Value), + StringComparer.Ordinal); + } + + /// + /// Groups events that overlap a calendar month by their + /// id. Within each resource the events are arranged into non-overlapping lanes so multi-day events + /// stack vertically inside the resource row. Events with no resource id (or an id not in + /// ) are placed under . + /// + public static Dictionary>> GroupEventsByResourceForMonth( + List events, + DateTime monthStart, + int daysInMonth, + IEnumerable resourceIds, + string unassignedKey) + { + var monthEnd = monthStart.AddDays(daysInMonth); + + var keyed = new Dictionary>(StringComparer.Ordinal); + foreach (var id in resourceIds) + { + if (!keyed.ContainsKey(id)) + keyed[id] = []; + } + keyed[unassignedKey] = []; + + var validIds = new HashSet(keyed.Keys, StringComparer.Ordinal); + + foreach (var ev in events) + { + if (ev.StartDate >= monthEnd) + continue; + // Keep zero-length markers (StartDate == EndDate, e.g. a 00:00 all-day marker) that fall + // on/after monthStart, matching OverlapsPeriod/GetInclusiveEndDate; the strict + // EndDate <= monthStart check would otherwise drop a midnight marker sitting at the month + // start instead of laning it. + if (ev.EndDate <= monthStart && !(ev.StartDate == ev.EndDate && ev.StartDate >= monthStart)) + continue; + + var key = ev.Resource is { Length: > 0 } r && validIds.Contains(r) ? r : unassignedKey; + keyed[key].Add(ev); + } + + return keyed.ToDictionary( + kv => kv.Key, + kv => GroupEventsByDayRange(kv.Value, monthStart, monthEnd), + StringComparer.Ordinal); + } + + /// + /// Day-range variant of : events are sorted by start, then placed in the + /// first lane whose tail event ends on or before the candidate's start day. Used by the timeline + /// month view where the granularity is one column per day. + /// + private static List> GroupEventsByDayRange( + List events, DateTime rangeStart, DateTime rangeEnd) + { + var sorted = events.OrderBy(e => e.StartDate).ThenByDescending(e => e.EndDate).ToList(); + var lanes = new List>(); + + DateTime ClipStartDate(BitFullCalendarEvent e) => (e.StartDate < rangeStart ? rangeStart : e.StartDate).Date; + DateTime ClipEndDate(BitFullCalendarEvent e) + { + // Zero-length markers (StartDate == EndDate) occupy their single start day - don't shift + // a 00:00 end back before the start, which would mis-lane the marker (negative span). + if (e.StartDate == e.EndDate) + return ClipStartDate(e); + var end = e.EndDate > rangeEnd ? rangeEnd : e.EndDate; + // Treat 00:00 boundary as ending the previous day (exclusive end). + return end.TimeOfDay == TimeSpan.Zero ? end.Date.AddDays(-1) : end.Date; + } + + foreach (var ev in sorted) + { + var s = ClipStartDate(ev); + var placed = false; + foreach (var lane in lanes) + { + if (s > ClipEndDate(lane[^1])) + { + lane.Add(ev); + placed = true; + break; + } + } + if (!placed) + lanes.Add([ev]); + } + + return lanes; + } + + public static List> GroupEvents(List dayEvents) + { + var sorted = dayEvents.OrderBy(e => e.StartDate).ToList(); + var groups = new List>(); + + foreach (var ev in sorted) + { + bool placed = false; + foreach (var group in groups) + { + if (ev.StartDate >= group[^1].EndDate) + { + group.Add(ev); + placed = true; + break; + } + } + if (!placed) + groups.Add([ev]); + } + + return groups; + } + + public static (double TopPx, double WidthPercent, double LeftPercent) GetEventBlockStyle( + BitFullCalendarEvent ev, DateTime day, int groupIndex, int groupSize) + { + var dayStart = day.Date; + var eventStart = ev.StartDate < dayStart ? dayStart : ev.StartDate; + double startMinutes = (eventStart - dayStart).TotalMinutes; + double topPx = startMinutes / 60.0 * HourHeightPx; + double width = 100.0 / groupSize; + double left = groupIndex * width; + return (topPx, width, left); + } + + private static (int Year, int Month, int Day) MonthGridDayKey(DateTime d) + { + d = d.Date; + return (d.Year, d.Month, d.Day); + } + + public static Dictionary CalculateMonthEventPositions( + List multiDayEvents, + List singleDayEvents, + DateTime selectedDate, + CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var cal = culture.Calendar; + int y = cal.GetYear(selectedDate); + int m = cal.GetMonth(selectedDate); + DateTime monthStart = cal.ToDateTime(y, m, 1, 0, 0, 0, 0); + DateTime monthEnd = cal.AddMonths(monthStart, 1).AddDays(-1); + + var eventPositions = new Dictionary(); + var occupiedPositions = new Dictionary<(int Year, int Month, int Day), bool[]>(); + + for (var d = monthStart; d <= monthEnd; d = d.AddDays(1)) + occupiedPositions[MonthGridDayKey(d)] = new bool[3]; + + var sorted = multiDayEvents + .OrderByDescending(e => (e.EndDate - e.StartDate).TotalDays) + .ThenBy(e => e.StartDate) + .Concat(singleDayEvents.OrderBy(e => e.StartDate)) + .ToList(); + + foreach (var ev in sorted) + { + var evStart = ev.StartDate.Date; + // Treat a 00:00 end as ending the previous day (exclusive midnight), consistent with + // IsSingleDay and GroupEventsByDayRange. + var evEnd = GetInclusiveEndDate(ev); + var rangeStart = evStart < monthStart ? monthStart : evStart; + var rangeEnd = evEnd > monthEnd ? monthEnd : evEnd; + + var eventDays = new List(); + for (var d = rangeStart; d <= rangeEnd; d = d.AddDays(1)) + eventDays.Add(d); + + int position = -1; + for (int i = 0; i < 3; i++) + { + if (eventDays.All(d => + { + var key = MonthGridDayKey(d); + return occupiedPositions.TryGetValue(key, out var slots) && !slots[i]; + })) + { + position = i; + break; + } + } + + if (position != -1) + { + foreach (var d in eventDays) + { + var key = MonthGridDayKey(d); + if (occupiedPositions.TryGetValue(key, out var slots)) + slots[position] = true; + } + eventPositions[ev.Id] = position; + } + } + + return eventPositions; + } + + public static List<(BitFullCalendarEvent Event, int Position, bool IsMultiDay)> GetMonthCellEvents( + DateTime date, List events, Dictionary eventPositions) + { + var dayStart = date.Date; + var eventsForDate = events.Where(ev => + { + var s = ev.StartDate.Date; + // Treat a 00:00 end as ending the previous day (exclusive midnight), consistent with + // IsSingleDay and GroupEventsByDayRange, so an event ending at midnight doesn't show + // up as a carry-over in the next day's cell. + var e = GetInclusiveEndDate(ev); + return (dayStart >= s && dayStart <= e) || s == dayStart || e == dayStart; + }).ToList(); + + var raw = eventsForDate + .Select(ev => ( + Event: ev, + Position: eventPositions.GetValueOrDefault(ev.Id, -1), + IsMultiDay: ev.IsMultiDay + )) + .OrderByDescending(x => x.IsMultiDay) + .ThenBy(x => x.Position < 0 ? 100 : x.Position) + .ThenBy(x => x.Event.StartDate) + .ToList(); + + return AssignMonthCellDisplayRows(raw); + } + + private static List<(BitFullCalendarEvent Event, int Position, bool IsMultiDay)> AssignMonthCellDisplayRows( + List<(BitFullCalendarEvent Event, int Position, bool IsMultiDay)> raw) + { + var occupied = new bool[3]; + var result = new List<(BitFullCalendarEvent Event, int Position, bool IsMultiDay)>(); + + foreach (var x in raw) + { + var p = x.Position; + if (p is >= 0 and < 3 && !occupied[p]) + { + occupied[p] = true; + result.Add((x.Event, p, x.IsMultiDay)); + continue; + } + + var free = -1; + for (var i = 0; i < 3; i++) + { + if (!occupied[i]) + { + free = i; + break; + } + } + + if (free >= 0) + { + occupied[free] = true; + result.Add((x.Event, free, x.IsMultiDay)); + } + else + { + result.Add((x.Event, -1, x.IsMultiDay)); + } + } + + return result + .OrderByDescending(x => x.IsMultiDay) + .ThenBy(x => x.Position < 0 ? 100 : x.Position) + .ThenBy(x => x.Event.StartDate) + .ToList(); + } + + public static List GetEventsForDay(List events, DateTime date, bool weekOnly = false) + { + var target = date.Date; + return events.Where(ev => + { + var s = ev.StartDate.Date; + // Treat a 00:00 end as ending the previous day (exclusive midnight), consistent with + // IsSingleDay and GroupEventsByDayRange. + var e = GetInclusiveEndDate(ev); + if (weekOnly) + return ev.IsMultiDay && s <= target && e >= target; + return s <= target && e >= target; + }).ToList(); + } + + public static List GetEventsForWeek(List events, DateTime date, CultureInfo? culture = null) + { + var weekStart = StartOfWeek(date, culture); + var weekEnd = weekStart.AddDays(6); + return events.Where(ev => OverlapsPeriod(ev, weekStart, weekEnd)).ToList(); + } + + public static List GetEventsForMonth(List events, DateTime date, CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var cal = culture.Calendar; + int y = cal.GetYear(date); + int m = cal.GetMonth(date); + DateTime monthStart = cal.ToDateTime(y, m, 1, 0, 0, 0, 0); + DateTime monthEnd = cal.AddMonths(monthStart, 1).AddDays(-1); + return events.Where(ev => OverlapsPeriod(ev, monthStart, monthEnd)).ToList(); + } + + /// + /// Events overlapping the date range implied by the current view and selected date + /// (used for attendee filters and similar "in this view" logic). + /// + public static List GetEventsForView( + List events, + BitFullCalendarView view, + DateTime selectedDate, + CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + return view switch + { + BitFullCalendarView.Day => GetEventsForDay(events, selectedDate), + BitFullCalendarView.Week => GetEventsForWeek(events, selectedDate, culture), + BitFullCalendarView.Month => GetEventsForMonth(events, selectedDate, culture), + BitFullCalendarView.Year => GetEventsForYear(events, selectedDate, culture), + BitFullCalendarView.Agenda => GetEventsForMonth(events, selectedDate, culture), + _ => events.ToList() + }; + } + + /// + /// Smallest time t' >= where (t' - t'.Date) is a whole multiple of + /// . If is already on such a boundary, + /// returns unchanged. Note: when the ceiling crosses midnight (for example + /// 23:59 with a 30-minute interval), the result rolls over to 00:00 of the next calendar day. + /// + public static DateTime CeilToMinuteInterval(DateTime dt, int intervalMinutes) + { + if (intervalMinutes <= 0) + throw new ArgumentOutOfRangeException(nameof(intervalMinutes)); + + var dayStart = dt.Date; + var minutesSinceDay = (dt - dayStart).TotalMinutes; + var slots = Math.Ceiling(minutesSinceDay / intervalMinutes); + return dayStart.AddMinutes(slots * intervalMinutes); + } + + /// + /// Largest time t' <= on the same calendar day where + /// (t' - t'.Date) is a whole multiple of . + /// + public static DateTime FloorToMinuteInterval(DateTime dt, int intervalMinutes) + { + if (intervalMinutes <= 0) + throw new ArgumentOutOfRangeException(nameof(intervalMinutes)); + + var dayStart = dt.Date; + var minutesSinceDay = (dt - dayStart).TotalMinutes; + var slots = Math.Floor(minutesSinceDay / intervalMinutes); + return dayStart.AddMinutes(slots * intervalMinutes); + } + + /// Stable key for filtering events by attendee (Id preferred, else full name). + public static string AttendeeFilterKey(BitFullCalendarAttendee a) + { + if (!string.IsNullOrWhiteSpace(a.Id)) + return "id:" + a.Id.Trim(); + if (!string.IsNullOrWhiteSpace(a.FullName)) + return "name:" + a.FullName.Trim().ToLowerInvariant(); + return ""; + } + + public static double GetCurrentTimeLineTopPx() + { + double minutes = DateTime.Now.TimeOfDay.TotalMinutes; + return minutes / 60.0 * HourHeightPx; + } + + /// + /// New event with only and + /// set (same default duration as the built-in add dialog: 30 minutes from the slot start). + /// + public static BitFullCalendarEvent CreateDraftEventForTimeSlot( + DateTime day, + int hour, + int startMinute = 0, + int durationMinutes = 30) + { + if (hour is < 0 or > 23) + throw new ArgumentOutOfRangeException(nameof(hour), hour, "Hour must be between 0 and 23."); + if (startMinute is < 0 or > 59) + throw new ArgumentOutOfRangeException(nameof(startMinute), startMinute, "Start minute must be between 0 and 59."); + if (durationMinutes <= 0) + throw new ArgumentOutOfRangeException(nameof(durationMinutes), durationMinutes, "Duration must be greater than zero."); + + var start = day.Date.AddHours(hour).AddMinutes(startMinute); + return new BitFullCalendarEvent + { + StartDate = start, + EndDate = start.AddMinutes(durationMinutes) + }; + } + + /// + /// Computes the inclusive start/end dates for the visible range of the given view. + /// + public static (DateTime Start, DateTime End) GetDateRange( + BitFullCalendarView view, DateTime selectedDate, CultureInfo? culture = null) + { + culture ??= CultureInfo.CurrentUICulture; + var cal = culture.Calendar; + + return view switch + { + BitFullCalendarView.Day => (selectedDate.Date, selectedDate.Date), + BitFullCalendarView.Week => + ( + StartOfWeek(selectedDate, culture), + StartOfWeek(selectedDate, culture).AddDays(6) + ), + BitFullCalendarView.Month or BitFullCalendarView.Agenda => + ( + cal.ToDateTime(cal.GetYear(selectedDate), cal.GetMonth(selectedDate), 1, 0, 0, 0, 0), + cal.AddMonths( + cal.ToDateTime(cal.GetYear(selectedDate), cal.GetMonth(selectedDate), 1, 0, 0, 0, 0), 1) + .AddDays(-1) + ), + BitFullCalendarView.Year => + ( + cal.ToDateTime(cal.GetYear(selectedDate), 1, 1, 0, 0, 0, 0), + cal.ToDateTime(cal.GetYear(selectedDate), cal.GetMonthsInYear(cal.GetYear(selectedDate)), + cal.GetDaysInMonth(cal.GetYear(selectedDate), cal.GetMonthsInYear(cal.GetYear(selectedDate))), + 0, 0, 0, 0) + ), + _ => (selectedDate.Date, selectedDate.Date) + }; + } + + public static string Capitalize(string str, CultureInfo? culture = null) + { + if (string.IsNullOrEmpty(str)) return ""; + // Use culture-aware casing (e.g. Turkish dotted/dotless I) so the first character is + // capitalized consistently with the other culture-sensitive formatting helpers. + var textInfo = (culture ?? CultureInfo.CurrentCulture).TextInfo; + return textInfo.ToUpper(str[0]) + str[1..]; + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarState.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarState.cs new file mode 100644 index 0000000000..9506e2a134 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Services/BitFullCalendarState.cs @@ -0,0 +1,534 @@ +using System.Globalization; + +namespace Bit.BlazorUI; + +public class BitFullCalendarState +{ + private List _allEvents = []; + private List _filteredEvents = []; + private List _resources = []; + private readonly List _selectedColors = []; + + public DateTime SelectedDate { get; private set; } = DateTime.Today; + public BitFullCalendarView View { get; private set; } = BitFullCalendarView.Month; + public BitFullCalendarMode Mode { get; private set; } = BitFullCalendarMode.Event; + public IReadOnlyList SelectedColors => _selectedColors; + + /// When set, only events that include this attendee (by ) are shown. + public string? SelectedAttendeeKey { get; private set; } + public bool Use24HourFormat { get; private set; } = true; + public BitFullCalendarBadgeVariant BadgeVariant { get; private set; } = BitFullCalendarBadgeVariant.Colored; + public int StartOfDayHour { get; private set; } = 8; + public BitFullCalendarAgendaGroupBy AgendaModeGroupBy { get; private set; } = BitFullCalendarAgendaGroupBy.Date; + public BitFullCalendarEventLayout EventLayout { get; private set; } = BitFullCalendarEventLayout.Overlap; + /// Whether the mini calendar is rendered in the day view sidebar. + public bool ShowDayViewCalendar { get; private set; } = true; + /// Incremented when is invoked in agenda view so the list can scroll to today. + public ulong AgendaScrollToTodayNonce { get; private set; } + + public CultureInfo Culture { get; private set; } = CultureInfo.CurrentUICulture; + public bool IsRtl => Culture.TextInfo.IsRightToLeft; + + // Drag state. The setter is private so all drag mutations go through StartDrag/EndDrag, + // which keeps the OnStateChanged notification consistent. + public BitFullCalendarEvent? DraggedEvent { get; private set; } + public bool IsDragging => DraggedEvent != null; + + public IReadOnlyList Events => _filteredEvents; + public IReadOnlyList AllEvents => _allEvents; + public IReadOnlyList Resources => _resources; + + public event Action? OnStateChanged; + public event Action? OnDateRangeChanged; + + public void Initialize(List events, CultureInfo? culture = null) + { + _allEvents = [.. events]; + NormalizeEventIds(); + if (culture != null) + Culture = culture; + UpdateUI(); + } + + public void SetCulture(CultureInfo culture) + { + Culture = culture; + UpdateUI(); + NotifyDateRangeChanged(); + } + + public void SetSelectedDate(DateTime date) + { + SelectedDate = date; + UpdateUI(); + NotifyDateRangeChanged(); + } + + public void SetView(BitFullCalendarView view) + { + var clamped = ClampViewForMode(view, Mode); + if (clamped == View) + return; + + View = clamped; + UpdateUI(); + NotifyDateRangeChanged(); + } + + /// + /// Switches between Event and Timeline modes. When entering Timeline mode the active view + /// is clamped to Day / Week / Month (Year and Agenda are not supported in timeline mode). + /// + 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) + mode = BitFullCalendarMode.Event; + + if (Mode == mode) + return; + + Mode = mode; + var clamped = ClampViewForMode(View, mode); + var viewChanged = clamped != View; + if (viewChanged) + View = clamped; + + UpdateUI(); + // The visible date range is a function of View + SelectedDate (+ Culture); none of those + // change here unless the View was clamped. Only surface OnDateChange when the range really + // changed, matching the BitFullCalendar.razor.cs visible-range contract. + if (viewChanged) + NotifyDateRangeChanged(); + } + + private static BitFullCalendarView ClampViewForMode(BitFullCalendarView view, BitFullCalendarMode mode) + { + if (mode != BitFullCalendarMode.Timeline) + return view; + + return view switch + { + BitFullCalendarView.Day or BitFullCalendarView.Week or BitFullCalendarView.Month => view, + _ => BitFullCalendarView.Week + }; + } + + public void SetUse24HourFormat(bool value) + { + if (Use24HourFormat == value) + return; + Use24HourFormat = value; + NotifyStateChanged(); + } + + public void ToggleTimeFormat() + { + Use24HourFormat = !Use24HourFormat; + NotifyStateChanged(); + } + + public void SetBadgeVariant(BitFullCalendarBadgeVariant variant) + { + if (BadgeVariant == variant) + return; + BadgeVariant = variant; + NotifyStateChanged(); + } + + public void SetStartOfDayHour(int hour) + { + var clamped = Math.Clamp(hour, 0, 16); + if (StartOfDayHour == clamped) + return; + StartOfDayHour = clamped; + NotifyStateChanged(); + } + + public void SetAgendaModeGroupBy(BitFullCalendarAgendaGroupBy groupBy) + { + if (AgendaModeGroupBy == groupBy) + return; + AgendaModeGroupBy = groupBy; + NotifyStateChanged(); + } + public void SetEventLayout(BitFullCalendarEventLayout layout) + { + if (EventLayout == layout) + return; + EventLayout = layout; + NotifyStateChanged(); + } + + public void SetShowDayViewCalendar(bool value) + { + if (ShowDayViewCalendar == value) + return; + ShowDayViewCalendar = value; + NotifyStateChanged(); + } + + public void ToggleShowDayViewCalendar() + { + ShowDayViewCalendar = !ShowDayViewCalendar; + NotifyStateChanged(); + } + + public void ToggleEventLayout() + { + EventLayout = EventLayout == BitFullCalendarEventLayout.Overlap + ? BitFullCalendarEventLayout.Stack + : BitFullCalendarEventLayout.Overlap; + NotifyStateChanged(); + } + public void NavigatePrevious() + { + SelectedDate = BitFullCalendarHelpers.NavigateDate(SelectedDate, View, false, Culture); + UpdateUI(); + NotifyDateRangeChanged(); + } + + public void NavigateNext() + { + SelectedDate = BitFullCalendarHelpers.NavigateDate(SelectedDate, View, true, Culture); + UpdateUI(); + NotifyDateRangeChanged(); + } + + public void GoToToday() + { + SelectedDate = DateTime.Today; + if (View == BitFullCalendarView.Agenda) + AgendaScrollToTodayNonce++; + UpdateUI(); + NotifyDateRangeChanged(); + } + + /// + /// Replaces the internal event list with the supplied collection when the contents differ. + /// Safe to call from OnParametersSet - it short-circuits when the list hasn't changed, + /// preventing infinite re-render loops. + /// + public void SyncEvents(List events) + { + if (EventsMatch(events)) + { + // References are unchanged, but event properties (color, attendees, id, ...) may have + // been mutated in place. Re-normalize ids first so a blanked or now-duplicate Id can't + // leave the month-view slot dictionaries keyed by colliding ids, then recompute the + // filtered projection so filter-dependent state stays accurate. Skip the change + // notification to avoid a re-render loop from OnParametersSet. + NormalizeEventIds(); + ApplyFilters(); + return; + } + + _allEvents = [.. events]; + NormalizeEventIds(); + ApplyFilters(); + NotifyStateChanged(); + } + + /// + /// Replaces the resource list shown by the resource timeline view. Safe to call from + /// OnParametersSet - it short-circuits when the supplied list matches the current one. + /// + public void SyncResources(IReadOnlyList? resources) + { + var next = resources is null ? new List() : [.. resources]; + if (ResourcesMatch(next)) + return; + + // Resource ids key the timeline row grouping and rendering, so two resources sharing an id + // would collapse or mis-render rows. The Id setter already rejects blank ids; enforce + // uniqueness here (the resource-building path that populates State.Resources) before the + // resources reach the FullCalendar models. + EnsureUniqueResourceIds(next); + + _resources = next; + + // 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) + { + Mode = BitFullCalendarMode.Event; + } + + NotifyStateChanged(); + } + + private static void EnsureUniqueResourceIds(List resources) + { + if (resources.Count < 2) + return; + + var seen = new HashSet(StringComparer.Ordinal); + foreach (var resource in resources) + { + if (!seen.Add(resource.Id)) + throw new ArgumentException( + $"Duplicate resource Id '{resource.Id}'. Resource ids must be unique.", + nameof(resources)); + } + } + + private bool ResourcesMatch(List resources) { + if (_resources.Count != resources.Count) + return false; + + for (var i = 0; i < _resources.Count; i++) + { + if (!ReferenceEquals(_resources[i], resources[i])) + return false; + } + + return true; + } + + private bool EventsMatch(List events) + { + if (_allEvents.Count != events.Count) + return false; + + for (var i = 0; i < _allEvents.Count; i++) + { + if (!ReferenceEquals(_allEvents[i], events[i])) + return false; + } + + return true; + } + + public void AddEvent(BitFullCalendarEvent ev) + { + _allEvents.Add(ev); + NormalizeEventIds(); + UpdateUI(); + } + + public void UpdateEvent(BitFullCalendarEvent ev) + { + var idx = _allEvents.FindIndex(e => e.Id == ev.Id); + if (idx >= 0) _allEvents[idx] = ev; + UpdateUI(); + } + + public void RemoveEvent(string eventId) + { + _allEvents.RemoveAll(e => e.Id == eventId); + UpdateUI(); + } + + public void FilterByColor(string colorId) + { + if (string.IsNullOrWhiteSpace(colorId)) + return; + + var trimmed = colorId.Trim(); + var existing = _selectedColors.FindIndex(c => string.Equals(c, trimmed, StringComparison.OrdinalIgnoreCase)); + if (existing >= 0) + _selectedColors.RemoveAt(existing); + else + _selectedColors.Add(trimmed); + UpdateUI(); + } + + public void SetColorFilter(string? colorId) + { + _selectedColors.Clear(); + if (!string.IsNullOrWhiteSpace(colorId)) + _selectedColors.Add(colorId.Trim()); + + UpdateUI(); + } + + public void SetAttendeeFilter(string? attendeeKey) + { + SelectedAttendeeKey = string.IsNullOrWhiteSpace(attendeeKey) ? null : attendeeKey.Trim(); + UpdateUI(); + } + + public void UpdateUI() + { + ApplyFilters(); + NotifyStateChanged(); + } + + /// Distinct attendees on events visible in the current view/date range. + public IReadOnlyList<(string Key, string DisplayName)> GetAttendeesInCurrentView(string unnamedAttendeeText = "(Unnamed)") + { + var viewEvents = BitFullCalendarHelpers.GetEventsForView(_allEvents.ToList(), View, SelectedDate, Culture); + var map = new Dictionary(StringComparer.Ordinal); + foreach (var ev in viewEvents) + { + foreach (var a in ev.Attendees) + { + var key = BitFullCalendarHelpers.AttendeeFilterKey(a); + if (key.Length == 0) + continue; + if (map.ContainsKey(key)) + continue; + var label = string.IsNullOrWhiteSpace(a.FullName) + ? (string.IsNullOrWhiteSpace(a.Id) ? unnamedAttendeeText : a.Id.Trim()) + : a.FullName.Trim(); + map[key] = label; + } + } + + return map + .OrderBy(kv => kv.Value, StringComparer.Create(Culture, ignoreCase: true)) + .Select(kv => (kv.Key, kv.Value)) + .ToList(); + } + + public void ClearFilter() + { + _selectedColors.Clear(); + SelectedAttendeeKey = null; + _filteredEvents = [.. _allEvents]; + NotifyStateChanged(); + } + + private void ApplyFilters() + { + PruneInvalidAttendeeFilter(); + + var result = _allEvents.AsEnumerable(); + + if (_selectedColors.Count > 0) + result = result.Where(e => _selectedColors.Any(c => string.Equals(c, e.Color, StringComparison.OrdinalIgnoreCase))); + + if (SelectedAttendeeKey is not null) + result = result.Where(e => e.Attendees.Any(a => BitFullCalendarHelpers.AttendeeFilterKey(a) == SelectedAttendeeKey)); + + _filteredEvents = result.ToList(); + } + + private void PruneInvalidAttendeeFilter() + { + if (SelectedAttendeeKey is null) + return; + + var validKeys = BitFullCalendarHelpers + .GetEventsForView(_allEvents.ToList(), View, SelectedDate, Culture) + .SelectMany(e => e.Attendees) + .Select(BitFullCalendarHelpers.AttendeeFilterKey) + .Where(k => k.Length > 0) + .ToHashSet(StringComparer.Ordinal); + + if (!validKeys.Contains(SelectedAttendeeKey)) + SelectedAttendeeKey = null; + } + + // Drag-and-drop helpers + public void StartDrag(BitFullCalendarEvent ev) + { + DraggedEvent = ev; + NotifyStateChanged(); + } + + public void EndDrag() + { + if (DraggedEvent == null) + return; + + DraggedEvent = null; + NotifyStateChanged(); + } + + public void HandleDrop(DateTime targetDate, int? hour = null, int? minute = null) + => HandleDrop(targetDate, hour, minute, resourceId: null, applyResource: false); + + /// + /// Drops the currently dragged event onto a date/time and optionally re-assigns its + /// . When is + /// false the event keeps its existing resource. Pass as + /// null together with = true to clear the + /// resource (drop on the unassigned row). + /// + public void HandleDrop(DateTime targetDate, int? hour, int? minute, string? resourceId, bool applyResource) + { + if (DraggedEvent == null) return; + + var originalStart = DraggedEvent.StartDate; + var originalResource = DraggedEvent.Resource; + var duration = DraggedEvent.Duration; + + var newStart = targetDate.Date; + if (hour.HasValue) + newStart = newStart.AddHours(hour.Value).AddMinutes(minute ?? 0); + else + newStart = newStart.AddHours(originalStart.Hour).AddMinutes(originalStart.Minute); + + var newResource = applyResource ? resourceId : originalResource; + + var resourceChanged = applyResource && !string.Equals(originalResource ?? "", newResource ?? "", StringComparison.Ordinal); + + if (newStart == originalStart && !resourceChanged) + { + EndDrag(); + return; + } + + var updated = new BitFullCalendarEvent + { + Id = DraggedEvent.Id, + Title = DraggedEvent.Title, + Description = DraggedEvent.Description, + StartDate = newStart, + EndDate = newStart + duration, + Color = DraggedEvent.Color, + Resource = newResource, + Data = DraggedEvent.Data, + Attendees = [.. DraggedEvent.Attendees] + }; + + UpdateEvent(updated); + EndDrag(); + } + + private void NormalizeEventIds() => NormalizeEventIds(_allEvents); + + /// + /// Ensures every event carries a non-blank, unique before + /// layout state is built. Month positioning keys its slot dictionaries by event id + /// (see / + /// ), so blank or duplicate ids would + /// overwrite each other and drop events from the grid. Blank or colliding ids are remapped to a + /// generated stable key; already-unique ids are left untouched. + /// + private static void NormalizeEventIds(List events) + { + var seen = new HashSet(StringComparer.Ordinal); + foreach (var ev in events) + { + if (!string.IsNullOrWhiteSpace(ev.Id) && seen.Add(ev.Id)) + continue; + + string generated; + do + { + generated = Guid.NewGuid().ToString("n"); + } + while (!seen.Add(generated)); + ev.Id = generated; + } + } + + private void NotifyStateChanged() => OnStateChanged?.Invoke(); + + private void NotifyDateRangeChanged() + { + if (OnDateRangeChanged is null) return; + var (start, end) = BitFullCalendarHelpers.GetDateRange(View, SelectedDate, Culture); + OnDateRangeChanged.Invoke(new BitFullCalendarDateChangeEventArgs + { + Start = start, + End = end, + View = View + }); + } +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/AgendaView/BitFcAgendaEvents.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/AgendaView/BitFcAgendaEvents.razor new file mode 100644 index 0000000000..8aea2b6c8d --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/AgendaView/BitFcAgendaEvents.razor @@ -0,0 +1,96 @@ +@namespace Bit.BlazorUI +@implements IDisposable +@inject IJSRuntime JS + +@{ + var monthEvents = BitFullCalendarHelpers.GetEventsForMonth(State.Events.ToList(), State.SelectedDate, State.Culture); + if (!string.IsNullOrWhiteSpace(_search)) + { + var q = _search.ToLowerInvariant(); + monthEvents = monthEvents.Where(e => + (e.Title ?? "").Contains(q, StringComparison.OrdinalIgnoreCase) || + (e.Description ?? "").Contains(q, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + List<(string? DateKey, string GroupTitle, List Items)> agendaGroups; + if (monthEvents.Count == 0) + { + agendaGroups = []; + } + else if (State.AgendaModeGroupBy == BitFullCalendarAgendaGroupBy.Date) + { + agendaGroups = monthEvents + .GroupBy(e => e.StartDate.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture)) + .OrderBy(g => g.Key) + .Select(g => + { + var title = DateTime.TryParse(g.Key, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dt) + ? $"{dt.ToString("dddd", State.Culture)}, {BitFullCalendarHelpers.FormatCultureDate(dt, State.Culture)}" + : g.Key; + return (DateKey: (string?)g.Key, GroupTitle: title, Items: g.ToList()); + }) + .ToList(); + } + else + { + agendaGroups = monthEvents + .GroupBy(e => e.Color) + .OrderBy(g => ColorScheme.GetSortOrder(g.Key)) + .ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase) + .Select(g => (DateKey: (string?)null, GroupTitle: ColorScheme.GetLabel(g.Key), Items: g.ToList())) + .ToList(); + } +} + +
+ + + @if (agendaGroups.Count == 0) + { +
@Texts.NoEventsFound
+ } + else + { + @foreach (var (dateKey, groupTitle, items) in agendaGroups) + { +
+
@groupTitle
+ @foreach (var ev in items) + { + + } +
+ } + } +
+ +@if (_showDetails && _selectedEvent != null) +{ + +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/AgendaView/BitFcAgendaEvents.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/AgendaView/BitFcAgendaEvents.razor.cs new file mode 100644 index 0000000000..d076bc8af7 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/AgendaView/BitFcAgendaEvents.razor.cs @@ -0,0 +1,51 @@ +namespace Bit.BlazorUI; + +public partial class BitFcAgendaEvents +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarColorScheme ColorScheme { get; set; } = default!; + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + + private string _search = ""; + private bool _showDetails; + private BitFullCalendarEvent? _selectedEvent; + private ulong _lastAgendaScrollNonce; + private readonly string _scrollContainerId = "bit-bfc-agenda-scroll-" + Guid.NewGuid().ToString("N"); + + protected override void OnInitialized() => State.OnStateChanged += Refresh; + private void Refresh() => InvokeAsync(StateHasChanged); + public void Dispose() => State.OnStateChanged -= Refresh; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + var nonce = State.AgendaScrollToTodayNonce; + if (nonce == _lastAgendaScrollNonce) + return; + + try + { + var scrolled = await BitFcAgendaScrollInterop.TryScrollToDateAsync(JS, _scrollContainerId, DateTime.Today); + if (scrolled) + _lastAgendaScrollNonce = nonce; + } + catch (Exception ex) when (ex is JSDisconnectedException or JSException or OperationCanceledException or InvalidOperationException) + { + // The circuit/render is mid-teardown, the JS side isn't reachable, or interop was issued + // during prerender (InvalidOperationException); the scroll is a best-effort convenience, + // so swallow the transient failure and retry on a later render (the nonce is intentionally + // left unchanged so the scroll is re-attempted). + } + } + + private async Task ShowDetails(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + _showDetails = true; + } +} 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 new file mode 100644 index 0000000000..fbfea6ed98 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarDayView.razor @@ -0,0 +1,120 @@ +@namespace Bit.BlazorUI +@inject IJSRuntime JS + +@{ + var dayEvents = SingleDayEvents.Where(e => + e.StartDate.Date == State.SelectedDate.Date).ToList(); + var groupedEvents = BitFullCalendarHelpers.GroupEvents(dayEvents); + // Include in-progress multi-day events too, not just single-day ones, so the sidebar shows + // every event currently happening (e.g. a multi-day conference active right now). + var currentEvents = SingleDayEvents.Concat(MultiDayEvents).Where(e => + DateTime.Now >= e.StartDate && DateTime.Now <= e.EndDate).ToList(); +} + +
+
+
+ +
+ + @State.SelectedDate.ToString("ddd", State.Culture) + + + @BitFullCalendarHelpers.GetCulturalDayOfMonth(State.SelectedDate, State.Culture) + +
+
+ + + +
+ +
+ @for (int h = 0; h < 24; h++) + { + var hour = h; +
+ @if (h > 0) + { +
+ } +
+
+ +
+ } + + + +
+
+
+ + @if (State.ShowDayViewCalendar) + { +
+ + +
+
+ + @Texts.HappeningNowTitle +
+ @if (currentEvents.Count == 0) + { +
@Texts.NoAppointmentsNow
+ } + else + { + @foreach (var ev in currentEvents) + { + + } + } +
+
+ } +
+ +@if (_showAddDialog) +{ + +} + +@if (_selectedEvent != null) +{ + +} + 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 new file mode 100644 index 0000000000..50dadc03d5 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarDayView.razor.cs @@ -0,0 +1,132 @@ +namespace Bit.BlazorUI; + +public partial class BitFcCalendarDayView : IDisposable +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [CascadingParameter(Name = "OnAddClick")] public EventCallback OnAddClick { get; set; } + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + [Parameter] public List SingleDayEvents { get; set; } = []; + [Parameter] public List MultiDayEvents { get; set; } = []; + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private string? _timeGridScrollSignature; + private readonly string _scrollContainerId = "bit-bfc-day-timegrid-scroll-" + Guid.NewGuid().ToString("N"); + private Timer? _nowTimer; + private bool _isDisposed; + + private bool _showAddDialog; + private DateTime _addStartDate; + private int _addStartHour; + private int _addStartMinute; + + private BitFullCalendarEvent? _selectedEvent; + private int? _dragHour; + private int? _dragMinute; + + protected override void OnInitialized() + { + // The "Happening now" panel is derived from DateTime.Now; refresh once a minute so it + // doesn't go stale during long sessions. The callback can fire after disposal, so guard + // against re-rendering a disposed component. + _nowTimer = new Timer(_ => + { + if (_isDisposed) + return; + InvokeAsync(() => + { + if (_isDisposed) + return; + StateHasChanged(); + }); + }, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); + } + + private async Task SelectEvent(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + } + private void CloseEventDetails() => _selectedEvent = null; + + private async Task OnHourClickAsync(int hour, int minute = 0) + { + if (OnAddClick.HasDelegate) + { + var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot(State.SelectedDate, hour, minute); + await OnAddClick.InvokeAsync(draft); + return; + } + + _addStartDate = State.SelectedDate; + _addStartHour = hour; + _addStartMinute = minute; + _showAddDialog = true; + } + + private async Task OnHourKeyDownAsync(KeyboardEventArgs e, int hour, int minute = 0) + { + // Ignore auto-repeat keydown events so a held Enter/Space only creates a single draft event. + if (e.Key is "Enter" or " " or "Spacebar" && !e.Repeat) + await OnHourClickAsync(hour, minute); + } + + private string HourSlotAriaLabel(int hour, int minute = 0) + { + var start = State.SelectedDate.Date.AddHours(hour).AddMinutes(minute); + return $"{Texts.AddEventHoverHint}, {BitFullCalendarHelpers.FormatTime(start, State.Use24HourFormat, State.Culture)}"; + } + + private async Task OnDropHour(int hour, int minute) + { + _dragHour = null; + _dragMinute = null; + await Notifier.HandleDropAsync(State.SelectedDate, hour, minute); + } + + private void OnDragEnterHour(int hour, int minute) + { + if (!State.IsDragging) + return; + + _dragHour = hour; + _dragMinute = minute; + } + + private string GetHourDropClass(int hour, int minute) + { + if (!State.IsDragging) + return string.Empty; + + return _dragHour == hour && _dragMinute == minute + ? (minute == 30 ? "bit-bfc-drop-preview-half" : "bit-bfc-drop-preview-hour") + : string.Empty; + } + + private string BuildTimeGridScrollSignature() => + $"{State.SelectedDate:yyyy-MM-dd}|{State.StartOfDayHour}"; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + var sig = BuildTimeGridScrollSignature(); + if (sig == _timeGridScrollSignature) + return; + + if (await BitFcTimeGridScrollInterop.TryScrollToStartOfDayAsync( + JS, + _scrollContainerId, + State.StartOfDayHour)) + _timeGridScrollSignature = sig; + } + + public void Dispose() + { + _isDisposed = true; + _nowTimer?.Dispose(); + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarTimeline.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarTimeline.razor new file mode 100644 index 0000000000..7b619b744d --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarTimeline.razor @@ -0,0 +1,9 @@ +@namespace Bit.BlazorUI +@implements IDisposable + +
+ @BitFullCalendarHelpers.FormatTime(DateTime.Now, State.Use24HourFormat, State.Culture) + + +
+ diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarTimeline.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarTimeline.razor.cs new file mode 100644 index 0000000000..4e43c9fdd7 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarTimeline.razor.cs @@ -0,0 +1,51 @@ +namespace Bit.BlazorUI; + +public partial class BitFcCalendarTimeline +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + + private double _positionPx; + private Timer? _timer; + private bool _isDisposed; + + protected override void OnInitialized() + { + UpdatePosition(); + + // Align the first tick to the next clock-minute boundary so the "now" marker doesn't lag + // by up to ~60s; subsequent ticks fire every minute. + var now = DateTime.Now; + var msUntilNextMinute = 60_000 - ((now.Second * 1000) + now.Millisecond); + var dueTime = TimeSpan.FromMilliseconds(msUntilNextMinute); + + _timer = new Timer(_ => + { + // The timer can fire after disposal; skip queuing work so StateHasChanged isn't called + // on a disposed component (which throws ObjectDisposedException). + if (_isDisposed) + return; + + // Run both the state mutation and the re-render on the renderer's dispatcher so + // _positionPx is never modified outside the synchronization context. + InvokeAsync(() => + { + if (_isDisposed) + return; + + UpdatePosition(); + StateHasChanged(); + }); + }, null, dueTime, TimeSpan.FromMinutes(1)); + } + + private void UpdatePosition() + { + _positionPx = BitFullCalendarHelpers.GetCurrentTimeLineTopPx(); + } + + public void Dispose() + { + _isDisposed = true; + _timer?.Dispose(); + } +} 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 new file mode 100644 index 0000000000..183ada5175 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarWeekView.razor @@ -0,0 +1,100 @@ +@namespace Bit.BlazorUI +@inject IJSRuntime JS + +@{ + var weekStart = BitFullCalendarHelpers.StartOfWeek(State.SelectedDate, State.Culture); + var weekDays = Enumerable.Range(0, 7).Select(i => weekStart.AddDays(i)).ToArray(); +} + +
+
+ @Texts.WeekMobileWarning +
+ + + +
+
+
+ @foreach (var day in weekDays) + { + var isToday = day.Date == DateTime.Today; +
+ @day.ToString("ddd", State.Culture) + @BitFullCalendarHelpers.GetCulturalDayOfMonth(day, State.Culture) +
+ } +
+ +
+ +
+ @foreach (var day in weekDays) + { + var d = day; + // Interval-overlap against the day's half-open window [dayStart, dayEnd) so events + // that span day boundaries are included once, and a 00:00 end (exclusive midnight) + // does not bleed onto the next day. + var dayStart = d.Date; + var dayEnd = d.Date.AddDays(1); + var dayEvents = SingleDayEvents.Where(e => + e.StartDate < dayEnd && e.EndDate > dayStart).ToList(); + var grouped = BitFullCalendarHelpers.GroupEvents(dayEvents); + +
+ @for (int h = 0; h < 24; h++) + { + var hour = h; +
+ @if (h > 0) + { +
+ } +
+
+ +
+ } + + + + @if (d.Date == DateTime.Today) + { + + } +
+ } +
+
+
+
+ +@if (_showAddDialog) +{ + +} + +@if (_selectedEvent != null) +{ + +} + 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 new file mode 100644 index 0000000000..18d1e14e96 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcCalendarWeekView.razor.cs @@ -0,0 +1,111 @@ +namespace Bit.BlazorUI; + +public partial class BitFcCalendarWeekView +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [CascadingParameter(Name = "OnAddClick")] public EventCallback OnAddClick { get; set; } + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + [Parameter] public List SingleDayEvents { get; set; } = []; + [Parameter] public List MultiDayEvents { get; set; } = []; + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private string? _timeGridScrollSignature; + private readonly string _timeGridScrollElementId = "bit-bfc-week-timegrid-scroll-" + Guid.NewGuid().ToString("N"); + + private bool _showAddDialog; + private DateTime _addDate; + private int _addHour; + private int _addMinute; + + private BitFullCalendarEvent? _selectedEvent; + private DateTime? _dragDate; + private int? _dragHour; + private int? _dragMinute; + + private async Task SelectEvent(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + } + private void CloseEventDetails() => _selectedEvent = null; + + private async Task OnHourClickAsync(DateTime day, int hour, int minute = 0) + { + State.SetSelectedDate(day); + + if (OnAddClick.HasDelegate) + { + var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot(day, hour, minute); + await OnAddClick.InvokeAsync(draft); + return; + } + + _addDate = day; + _addHour = hour; + _addMinute = minute; + _showAddDialog = true; + } + + private async Task OnHourKeyDownAsync(KeyboardEventArgs e, DateTime day, int hour, int minute = 0) + { + // Ignore auto-repeat keydown events so a held Enter/Space only creates a single draft event. + if (e.Key is "Enter" or " " or "Spacebar" && !e.Repeat) + await OnHourClickAsync(day, hour, minute); + } + + private string HourSlotAriaLabel(DateTime day, int hour, int minute = 0) + { + var start = day.Date.AddHours(hour).AddMinutes(minute); + return $"{Texts.AddEventHoverHint}, {day.ToString("ddd", State.Culture)} {BitFullCalendarHelpers.FormatTime(start, State.Use24HourFormat, State.Culture)}"; + } + + private async Task OnDrop(DateTime day, int hour, int minute) + { + _dragDate = null; + _dragHour = null; + _dragMinute = null; + await Notifier.HandleDropAsync(day, hour, minute); + } + + private void OnDragEnterSlot(DateTime day, int hour, int minute) + { + if (!State.IsDragging) + return; + + _dragDate = day.Date; + _dragHour = hour; + _dragMinute = minute; + } + + private string GetWeekDropClass(DateTime day, int hour, int minute) + { + if (!State.IsDragging) + return string.Empty; + + return _dragDate == day.Date && _dragHour == hour && _dragMinute == minute + ? (minute == 30 ? "bit-bfc-drop-preview-half" : "bit-bfc-drop-preview-hour") + : string.Empty; + } + + private string BuildTimeGridScrollSignature() => + $"{State.SelectedDate:yyyy-MM-dd}|{State.StartOfDayHour}"; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + var sig = BuildTimeGridScrollSignature(); + if (sig == _timeGridScrollSignature) + return; + + if (await BitFcTimeGridScrollInterop.TryScrollToStartOfDayAsync( + JS, + _timeGridScrollElementId, + State.StartOfDayHour)) + _timeGridScrollSignature = sig; + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcDayViewMultiDayEventsRow.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcDayViewMultiDayEventsRow.razor new file mode 100644 index 0000000000..b354d80c26 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcDayViewMultiDayEventsRow.razor @@ -0,0 +1,18 @@ +@namespace Bit.BlazorUI + +@if (_multiDayForDay.Count > 0) +{ +
+ @foreach (var ev in _multiDayForDay) + { + var position = GetPosition(ev); + + } +
+} + +@if (_selectedEvent != null) +{ + +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcDayViewMultiDayEventsRow.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcDayViewMultiDayEventsRow.razor.cs new file mode 100644 index 0000000000..6c2c0d35df --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcDayViewMultiDayEventsRow.razor.cs @@ -0,0 +1,40 @@ +namespace Bit.BlazorUI; + +public partial class BitFcDayViewMultiDayEventsRow +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + [Parameter] public List MultiDayEvents { get; set; } = []; + [Parameter] public DateTime Date { get; set; } + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private List _multiDayForDay = []; + private BitFullCalendarEvent? _selectedEvent; + + protected override void OnParametersSet() + { + _multiDayForDay = BitFullCalendarHelpers.GetEventsForDay(MultiDayEvents, Date, true); + } + + private string GetPosition(BitFullCalendarEvent ev) + { + if (ev.StartDate.Date == Date.Date) return "first"; + // Treat a 00:00 end as ending the previous day (exclusive midnight), consistent with + // GetEventsForDay/GroupEventsByDayRange, so an event ending at midnight is still marked + // "last" on its real final visible day rather than "middle". + var endInclusive = BitFullCalendarHelpers.GetInclusiveEndDate(ev); + if (endInclusive == Date.Date) return "last"; + return "middle"; + } + + private async Task ShowEventDetails(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + } + private void CloseEventDetails() => _selectedEvent = null; +} 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 new file mode 100644 index 0000000000..4bb5c7236f --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcEventBlock.razor @@ -0,0 +1,83 @@ +@namespace Bit.BlazorUI +@inject IJSRuntime JS +@implements IDisposable + +@{ + var displayStart = _previewStart ?? Event.StartDate; + var displayEnd = _previewEnd ?? Event.EndDate; + var durationMin = (displayEnd - displayStart).TotalMinutes; + var heightPx = (durationMin / 60.0) * BitFullCalendarHelpers.HourHeightPx; + var isDotVariant = State.BadgeVariant == BitFullCalendarBadgeVariant.Dot; + var colorClass = isDotVariant ? "dot-variant" : "bit-bfc-color"; + var colorStyleVar = ColorScheme.GetColorStyle(Event.Color); + double translateYPx = 0; + if (_isResizing && string.Equals(_resizeDirection, "top", StringComparison.Ordinal) && _resizeBaseEvent != null && _previewStart.HasValue) + translateYPx = (displayStart - _resizeBaseEvent.StartDate).TotalMinutes / 60.0 * BitFullCalendarHelpers.HourHeightPx; + var inv = System.Globalization.CultureInfo.InvariantCulture; + var blockStyle = $"{colorStyleVar}height:{heightPx.ToString("F0", inv)}px;transform:translateY({translateYPx.ToString("F2", inv)}px);"; + var tooltip = BitFullCalendarHelpers.BuildEventTooltip(Event, State.Use24HourFormat, State.Culture); + var resizeClasses = ""; + if (_isResizing) + { + resizeClasses = "bit-bfc-event-block-resizing"; + if (_resizeDirection is "top" or "bottom") + resizeClasses += $" bit-bfc-resize-dir-{_resizeDirection}"; + } +} + +
+ +
+ + @if (EventTemplate != null) + { + @EventTemplate(Event) + } + else + { +
+ @if (isDotVariant) + { + + } + @Event.Title +
+ + @if (durationMin > 25) + { +
+ @BitFullCalendarHelpers.FormatTime(displayStart, State.Use24HourFormat, State.Culture) - @BitFullCalendarHelpers.FormatTime(displayEnd, State.Use24HourFormat, State.Culture) +
+ } + } + +
+ + @if (_isResizing) + { +
+ + @BitFullCalendarHelpers.FormatTime(displayStart, State.Use24HourFormat, State.Culture) - @BitFullCalendarHelpers.FormatTime(displayEnd, State.Use24HourFormat, State.Culture) +
+ } +
+ 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 new file mode 100644 index 0000000000..54b07c7c3e --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcEventBlock.razor.cs @@ -0,0 +1,229 @@ +namespace Bit.BlazorUI; + +public partial class BitFcEventBlock +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarColorScheme ColorScheme { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [Parameter] public BitFullCalendarEvent Event { get; set; } = default!; + [Parameter] public EventCallback OnSelected { get; set; } + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private readonly string _instanceId = Guid.NewGuid().ToString("N"); + private DotNetObjectReference? _dotNetRef; + private BitFullCalendarEvent? _resizeBaseEvent; + private string? _resizeDirection; + private DateTime? _previewStart; + private DateTime? _previewEnd; + private bool _resizeInitialized; + private bool _isResizing; + private DateTime _suppressClickUntilUtc; + private string _topHandleId => $"bit-bfc-resize-top-{_instanceId}"; + private string _bottomHandleId => $"bit-bfc-resize-bottom-{_instanceId}"; + + /// Minimum event length enforced by resize (minutes). + private const int MinEventDurationMinutes = 30; + + /// + /// Pointer movement below this (in minutes along the time axis) does not change start/end, + /// so the edge does not jump as soon as the user presses the handle. + /// + private const int ResizeDeadZoneMinutes = MinEventDurationMinutes / 2; + + private void OnDragStart() + { + if (_isResizing) + return; + + State.StartDrag(Event); + } + + private void OnDragEnd() => State.EndDrag(); + + private async Task OnClick() + { + if (DateTime.UtcNow <= _suppressClickUntilUtc || _isResizing) + return; + + await OnSelected.InvokeAsync(Event); + } + + private async Task OnKeyDown(KeyboardEventArgs e) + { + if (_isResizing) + return; + + // Ignore auto-repeat keydown events (matching the month badge logic) so holding + // Enter/Space cannot fire OnSelected repeatedly for the same event. + if (e.Key is "Enter" or " " or "Spacebar" && !e.Repeat) + await OnSelected.InvokeAsync(Event); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_resizeInitialized) + return; + + try + { + _dotNetRef ??= DotNetObjectReference.Create(this); + await JS.InvokeVoidAsync("BitBlazorUI.FullCalendar.initResize", _dotNetRef, _topHandleId, "top"); + await JS.InvokeVoidAsync("BitBlazorUI.FullCalendar.initResize", _dotNetRef, _bottomHandleId, "bottom"); + _resizeInitialized = true; + } + catch (Exception ex) when (ex is JSException or JSDisconnectedException or InvalidOperationException or OperationCanceledException) + { + // BitBlazorUI.FullCalendar JS not yet available, or the circuit/render is mid-teardown; retry on next render. + } + } + + [JSInvokable] + public void OnResizeStart(string direction) + { + // Guard against unrecognized directions from JS interop so the block can't enter resize + // mode with a direction that OnResizeMove would later ignore (leaving it stuck "resizing"). + if (direction is not ("top" or "bottom")) + return; + + _isResizing = true; + _resizeDirection = direction; + _resizeBaseEvent = Event; + _previewStart = null; + _previewEnd = null; + _suppressClickUntilUtc = DateTime.UtcNow.AddMilliseconds(300); + State.EndDrag(); + _ = InvokeAsync(StateHasChanged); + } + + [JSInvokable] + public Task OnResizeMove(string direction, int deltaMinutes) + { + if (!_isResizing || _resizeBaseEvent == null) + return Task.CompletedTask; + + // 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) + { + if (_previewStart.HasValue || _previewEnd.HasValue) + { + _previewStart = null; + _previewEnd = null; + return InvokeAsync(StateHasChanged); + } + return Task.CompletedTask; + } + + var effectiveDelta = deltaMinutes - Math.Sign(deltaMinutes) * ResizeDeadZoneMinutes; + const int slotMinutes = MinEventDurationMinutes; + var baseEvent = _resizeBaseEvent; + + var newStart = baseEvent.StartDate; + var newEnd = baseEvent.EndDate; + + if (direction == "top") + { + var maxStart = baseEvent.EndDate.AddMinutes(-slotMinutes); + var candidateStart = baseEvent.StartDate.AddMinutes(effectiveDelta); + newStart = effectiveDelta > 0 + ? BitFullCalendarHelpers.CeilToMinuteInterval(candidateStart, slotMinutes) + : BitFullCalendarHelpers.FloorToMinuteInterval(candidateStart, slotMinutes); + if (newStart > maxStart) + newStart = maxStart; + } + else if (direction == "bottom") + { + var minEnd = baseEvent.StartDate.AddMinutes(slotMinutes); + var candidateEnd = baseEvent.EndDate.AddMinutes(effectiveDelta); + newEnd = effectiveDelta > 0 + ? BitFullCalendarHelpers.CeilToMinuteInterval(candidateEnd, slotMinutes) + : BitFullCalendarHelpers.FloorToMinuteInterval(candidateEnd, slotMinutes); + if (newEnd < minEnd) + newEnd = minEnd; + } + else + { + // Unknown direction from JS interop: ignore rather than silently mutating the end-time. + return Task.CompletedTask; + } + + // Snapped range matches drag-start range → treat as restored original (clear preview). + if (newStart == baseEvent.StartDate && newEnd == baseEvent.EndDate) + { + if (_previewStart.HasValue || _previewEnd.HasValue) + { + _previewStart = null; + _previewEnd = null; + return InvokeAsync(StateHasChanged); + } + return Task.CompletedTask; + } + + var curStart = _previewStart ?? baseEvent.StartDate; + var curEnd = _previewEnd ?? baseEvent.EndDate; + if (newStart == curStart && newEnd == curEnd) + return Task.CompletedTask; + + _previewStart = newStart; + _previewEnd = newEnd; + return InvokeAsync(StateHasChanged); + } + + [JSInvokable] + public async Task OnResizeEnd() + { + try + { + if (_resizeBaseEvent != null && _previewStart.HasValue && _previewEnd.HasValue) + { + var s = _previewStart.Value; + var e = _previewEnd.Value; + if (s != _resizeBaseEvent.StartDate || e != _resizeBaseEvent.EndDate) + { + var b = _resizeBaseEvent; + var updated = new BitFullCalendarEvent + { + Id = b.Id, + Title = b.Title, + Description = b.Description, + StartDate = s, + EndDate = e, + Color = b.Color, + Resource = b.Resource, + Data = b.Data, + Attendees = [.. b.Attendees] + }; + + State.UpdateEvent(updated); + + await Notifier.NotifyAsync(new BitFullCalendarChangeEventArgs + { + Event = BitFullCalendarChangeNotifier.CloneEvent(updated), + OldEvent = BitFullCalendarChangeNotifier.CloneEvent(b), + Kind = BitFullCalendarChangeKind.Edit, + Source = BitFullCalendarChangeSource.Resize + }); + } + } + } + finally + { + _previewStart = null; + _previewEnd = null; + _isResizing = false; + _resizeBaseEvent = null; + _resizeDirection = null; + _suppressClickUntilUtc = DateTime.UtcNow.AddMilliseconds(300); + + // Render from the finally path so the preview state is always cleared on screen, even if + // State.UpdateEvent or Notifier.NotifyAsync above threw. + await InvokeAsync(StateHasChanged); + } + } + + private void OnResizeTopStart() { } + private void OnResizeBottomStart() { } + + public void Dispose() => _dotNetRef?.Dispose(); +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcMiniCalendar.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcMiniCalendar.razor new file mode 100644 index 0000000000..fff84dc0bd --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcMiniCalendar.razor @@ -0,0 +1,55 @@ +@namespace Bit.BlazorUI + +@{ + var cells = BitFullCalendarHelpers.GetCalendarCells(_displayMonth, State.Culture); + var miniCal = State.Culture.Calendar; + var miniDtf = State.Culture.DateTimeFormat; + int miniYear = miniCal.GetYear(_displayMonth); + int miniMonth = miniCal.GetMonth(_displayMonth); + string miniTitle = $"{miniDtf.GetMonthName(miniMonth)} {miniYear}"; + var miniHeaders = BitFullCalendarHelpers.GetAbbreviatedWeekDayHeaders(State.Culture); +} + +
+
+ + @miniTitle + +
+ + + + @foreach (var d in miniHeaders) + { + + } + + + + @for (int row = 0; row < cells.Count / 7; row++) + { + + @for (int col = 0; col < 7; col++) + { + var cell = cells[row * 7 + col]; + var isToday = cell.Date.Date == DateTime.Today; + var isSelected = cell.Date.Date == State.SelectedDate.Date; + var cls = !cell.CurrentMonth + ? "other-month" + : $"{(isToday ? "today" : "")} {(isSelected ? "selected" : "")}".Trim(); + + } + + } + +
@(d.Length > 2 ? d[..2] : d)
+ +
+
+ diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcMiniCalendar.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcMiniCalendar.razor.cs new file mode 100644 index 0000000000..0fbcf20dce --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcMiniCalendar.razor.cs @@ -0,0 +1,56 @@ +namespace Bit.BlazorUI; + +public partial class BitFcMiniCalendar +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + + private DateTime _displayMonth; + private DateTime _lastSyncedSelectedDate; + private string? _lastSyncedCultureName; + private Type? _lastSyncedCalendarType; + + protected override void OnInitialized() + { + _lastSyncedSelectedDate = State.SelectedDate; + _lastSyncedCultureName = State.Culture.Name; + _lastSyncedCalendarType = State.Culture.Calendar.GetType(); + _displayMonth = StartOfDisplayMonth(State.SelectedDate); + } + + protected override void OnParametersSet() + { + // Keep _displayMonth aligned with external SelectedDate changes without clobbering the + // user's in-component month browsing (PrevMonth/NextMonth leave SelectedDate untouched). + // A culture/calendar switch also requires re-normalizing the display month so the header + // and grid reflect the new calendar system. The calendar type is tracked alongside the + // culture name because two cultures can share a name while using different calendars. + if (_lastSyncedSelectedDate != State.SelectedDate + || !string.Equals(_lastSyncedCultureName, State.Culture.Name, StringComparison.Ordinal) + || _lastSyncedCalendarType != State.Culture.Calendar.GetType()) + { + _lastSyncedSelectedDate = State.SelectedDate; + _lastSyncedCultureName = State.Culture.Name; + _lastSyncedCalendarType = State.Culture.Calendar.GetType(); + _displayMonth = StartOfDisplayMonth(State.SelectedDate); + } + } + + private DateTime StartOfDisplayMonth(DateTime date) + { + var cal = State.Culture.Calendar; + // Preserve the source era so era-based calendars (e.g. Japanese) rebuild the month in the + // same era; the era-less ToDateTime overload can otherwise select the wrong era or throw. + return cal.ToDateTime(cal.GetYear(date), cal.GetMonth(date), 1, 0, 0, 0, 0, cal.GetEra(date)); + } + + private void PrevMonth() => _displayMonth = State.Culture.Calendar.AddMonths(_displayMonth, -1); + private void NextMonth() => _displayMonth = State.Culture.Calendar.AddMonths(_displayMonth, 1); + + private void SelectDate(DateTime date) + { + State.SetSelectedDate(date); + _lastSyncedSelectedDate = State.SelectedDate; + _displayMonth = StartOfDisplayMonth(date); + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcRenderGroupedEvents.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcRenderGroupedEvents.razor new file mode 100644 index 0000000000..7546e86ba1 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcRenderGroupedEvents.razor @@ -0,0 +1,85 @@ +@namespace Bit.BlazorUI + +@{ + var inv = System.Globalization.CultureInfo.InvariantCulture; + var isStack = State.EventLayout == BitFullCalendarEventLayout.Stack; +} + +@foreach (var (group, groupIndex) in GroupedEvents.Select((g, i) => (g, i))) +{ + @foreach (var ev in group) + { + var style = BitFullCalendarHelpers.GetEventBlockStyle(ev, Day, groupIndex, GroupedEvents.Count); + + // Determine how many columns this event really needs. Counting every lane that touches ev + // overcounts: lanes whose events are staggered (each overlaps ev but never coexist with one + // another inside ev's own time range) would each take a column and shrink ev unnecessarily. + // Instead compute the peak number of events (including ev) that are simultaneously active at + // any instant within ev's [Start, End] range, clamping every overlapping event to that range + // so concurrency is measured strictly where ev lives. + var boundaries = new List<(DateTime Time, int Delta)>(); + for (var i = 0; i < GroupedEvents.Count; i++) + { + foreach (var other in GroupedEvents[i]) + { + if (!(ev.StartDate < other.EndDate && ev.EndDate > other.StartDate)) + continue; + var s = other.StartDate < ev.StartDate ? ev.StartDate : other.StartDate; + var e = other.EndDate > ev.EndDate ? ev.EndDate : other.EndDate; + if (e <= s) + continue; + boundaries.Add((s, 1)); + boundaries.Add((e, -1)); + } + } + // Sweep the clamped boundaries. Process an end (-1) before a start (+1) at the same instant + // so events that merely touch (one ends as the next begins) are not counted as concurrent. + var running = 0; + var depth = 1; + foreach (var b in boundaries.OrderBy(b => b.Time).ThenBy(b => b.Delta)) + { + running += b.Delta; + if (running > depth) depth = running; + } + + // Place ev within those columns. Lanes to the left of ev's lane that overlap it set its + // column, clamped into the available column count so a staggered left-neighbour can never + // push left/width past the available width. + var leftConflicts = 0; + for (var i = 0; i < groupIndex; i++) + { + if (GroupedEvents[i].Any(other => ev.StartDate < other.EndDate && ev.EndDate > other.StartDate)) + leftConflicts++; + } + var rank = Math.Min(leftConflicts, depth - 1); + + double left; + double width; + int zIndex; + + if (isStack) + { + // Stacked layout: split the column into equal-width lanes placed side by side with no + // overlap, so conflicting events sit close together and stay fully readable. + var laneWidth = 100.0 / depth; + left = rank * laneWidth; + width = laneWidth; + zIndex = 2; + } + else + { + // Overlap layout: offset each successive overlapping card by a percentage of the column + // width and let the card extend to the right edge so users can still see most of every + // card. The step is capped so even with many conflicts the narrowest card stays ~50% wide. + var step = depth > 1 ? Math.Min(20.0, 50.0 / (depth - 1)) : 0.0; + left = rank * step; + width = 100.0 - left; + zIndex = 2 + rank; + } + +
+ +
+ } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcRenderGroupedEvents.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcRenderGroupedEvents.razor.cs new file mode 100644 index 0000000000..cc58c00d4f --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcRenderGroupedEvents.razor.cs @@ -0,0 +1,10 @@ +namespace Bit.BlazorUI; + +public partial class BitFcRenderGroupedEvents +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [Parameter] public List> GroupedEvents { get; set; } = []; + [Parameter] public DateTime Day { get; set; } + [Parameter] public EventCallback OnEventSelected { get; set; } + [Parameter] public RenderFragment? EventTemplate { get; set; } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcTimeColumn.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcTimeColumn.razor new file mode 100644 index 0000000000..de3d66885e --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcTimeColumn.razor @@ -0,0 +1,15 @@ +@namespace Bit.BlazorUI + +
+ @for (int h = 0; h < 24; h++) + { + var hour = h; +
+ @if (hour > 0) + { + @BitFullCalendarHelpers.FormatHourLabel(hour, State.Use24HourFormat, State.Culture) + } +
+ } +
+ diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcTimeColumn.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcTimeColumn.razor.cs new file mode 100644 index 0000000000..4af5c556f6 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcTimeColumn.razor.cs @@ -0,0 +1,6 @@ +namespace Bit.BlazorUI; + +public partial class BitFcTimeColumn +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcWeekViewMultiDayEventsRow.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcWeekViewMultiDayEventsRow.razor new file mode 100644 index 0000000000..679e9d01c2 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcWeekViewMultiDayEventsRow.razor @@ -0,0 +1,40 @@ +@namespace Bit.BlazorUI + +@if (_rowCount > 0) +{ +
+
+ @foreach (var day in _weekDays) + { + var d = day; +
+ @for (int row = 0; row < _rowCount; row++) + { + var r = row; + var ev = _cellLookup.GetValueOrDefault((d.Date, r)); + if (ev != null) + { + // Normalize a 00:00 end as ending the previous day (exclusive midnight) + // via the shared helper, matching the row-placement logic so the badge on + // the true last day is tagged "last" rather than "middle". + var evEndInclusive = BitFullCalendarHelpers.GetInclusiveEndDate(ev); + var position = ev.StartDate.Date == d.Date ? "first" + : evEndInclusive == d.Date ? "last" : "middle"; + + } + else + { +
+ } + } +
+ } +
+
+} + +@if (_selectedEvent != null) +{ + +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcWeekViewMultiDayEventsRow.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcWeekViewMultiDayEventsRow.razor.cs new file mode 100644 index 0000000000..0b116cde86 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/DayWeekView/BitFcWeekViewMultiDayEventsRow.razor.cs @@ -0,0 +1,84 @@ +namespace Bit.BlazorUI; + +public partial class BitFcWeekViewMultiDayEventsRow +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + [Parameter] public List MultiDayEvents { get; set; } = []; + [Parameter] public DateTime Date { get; set; } + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private DateTime[] _weekDays = []; + private List _weekEvents = []; + private Dictionary _eventRows = new(); + private Dictionary<(DateTime Day, int Row), BitFullCalendarEvent> _cellLookup = new(); + private int _rowCount; + private BitFullCalendarEvent? _selectedEvent; + + protected override void OnParametersSet() + { + _weekDays = BitFullCalendarHelpers.GetWeekDates(Date); + _weekEvents = BitFullCalendarHelpers.GetEventsForWeek(MultiDayEvents, Date) + .Where(e => e.IsMultiDay) + .OrderByDescending(e => (e.EndDate - e.StartDate).TotalDays) + .ThenBy(e => e.StartDate) + .ToList(); + + _eventRows = new Dictionary(); + var rowUsageByDay = _weekDays.ToDictionary(d => d.Date, _ => new HashSet()); + + foreach (var ev in _weekEvents) + { + var evEndInclusive = BitFullCalendarHelpers.GetInclusiveEndDate(ev); + var evDays = _weekDays + .Where(d => ev.StartDate.Date <= d.Date && evEndInclusive >= d.Date) + .ToList(); + + // An event whose adjusted range maps to no visible week day must not be assigned a + // row: All(...) over an empty set is vacuously true and would allocate a phantom row. + if (evDays.Count == 0) + continue; + + for (int row = 0; ; row++) + { + if (evDays.All(d => !rowUsageByDay[d.Date].Contains(row))) + { + _eventRows[ev] = row; + foreach (var d in evDays) + rowUsageByDay[d.Date].Add(row); + break; + } + } + } + + _rowCount = _eventRows.Count > 0 ? _eventRows.Values.Max() + 1 : 0; + + // Precompute (day, row) -> event so the render loop is an O(1) lookup instead of a + // FirstOrDefault scan per cell. + _cellLookup = new Dictionary<(DateTime, int), BitFullCalendarEvent>(); + foreach (var ev in _weekEvents) + { + // Only events that were actually assigned a row participate in the cell lookup; + // events skipped above (no visible week day) have no entry in _eventRows. + if (!_eventRows.TryGetValue(ev, out var row)) + continue; + var evEndInclusive = BitFullCalendarHelpers.GetInclusiveEndDate(ev); + foreach (var d in _weekDays) + { + if (ev.StartDate.Date <= d.Date && evEndInclusive >= d.Date) + _cellLookup[(d.Date, row)] = ev; + } + } + } + + private async Task ShowEventDetails(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + } + private void CloseEventDetails() => _selectedEvent = null; +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcCalendarMonthView.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcCalendarMonthView.razor new file mode 100644 index 0000000000..71d01e9b2d --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcCalendarMonthView.razor @@ -0,0 +1,24 @@ +@namespace Bit.BlazorUI + +@{ + var cells = BitFullCalendarHelpers.GetCalendarCells(State.SelectedDate, State.Culture); + var allEvents = MultiDayEvents.Concat(SingleDayEvents).ToList(); + var eventPositions = BitFullCalendarHelpers.CalculateMonthEventPositions(MultiDayEvents, SingleDayEvents, State.SelectedDate, State.Culture); + var weekDayHeaders = BitFullCalendarHelpers.GetWeekDayHeaders(State.Culture); +} + +
+
+ @foreach (var day in weekDayHeaders) + { +
@day
+ } +
+ +
+ @foreach (var cell in cells) + { + + } +
+
diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcCalendarMonthView.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcCalendarMonthView.razor.cs new file mode 100644 index 0000000000..2384269c79 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcCalendarMonthView.razor.cs @@ -0,0 +1,9 @@ +namespace Bit.BlazorUI; + +public partial class BitFcCalendarMonthView +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [Parameter] public List SingleDayEvents { get; set; } = []; + [Parameter] public List MultiDayEvents { get; set; } = []; + [Parameter] public RenderFragment? EventTemplate { get; set; } +} 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 new file mode 100644 index 0000000000..5f27b79e04 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcDayCell.razor @@ -0,0 +1,66 @@ +@namespace Bit.BlazorUI + +@{ + var isToday = Cell.Date.Date == DateTime.Today; + var cellEvents = BitFullCalendarHelpers.GetMonthCellEvents(Cell.Date, Events, EventPositions); +} + +
+ +
@Cell.Day
+ + +
+ @for (int i = 0; i < 3; i++) + { + var pos = i; + var item = cellEvents.FirstOrDefault(e => e.Position == pos); + @if (item.Event != null) + { + var position = GetBadgePosition(item.Event, Cell.Date); +
+ +
+ } + else + { +
+ } + } +
+ + @{ + var moreCount = cellEvents.Count - 3; + } + @if (Cell.CurrentMonth && moreCount > 0) + { + + } +
+ +@if (_showEventList) +{ + +} + +@if (_showAddDialog) +{ + +} + +@if (_selectedEvent != null) +{ + +} 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 new file mode 100644 index 0000000000..0de55c2020 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcDayCell.razor.cs @@ -0,0 +1,67 @@ +namespace Bit.BlazorUI; + +public partial class BitFcDayCell +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [CascadingParameter(Name = "OnAddClick")] public EventCallback OnAddClick { get; set; } + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + [Parameter] public BitFullCalendarCell Cell { get; set; } = default!; + [Parameter] public List Events { get; set; } = []; + [Parameter] public Dictionary EventPositions { get; set; } = new(); + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private bool _showEventList; + private bool _showAddDialog; + private DateTime _addDraftStart; + private BitFullCalendarEvent? _selectedEvent; + + private async Task ShowEventDetails(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + } + private void CloseEventDetails() => _selectedEvent = null; + + private async Task OnCellClick() { + State.SetSelectedDate(Cell.Date); + + // 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. + var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot(Cell.Date, State.StartOfDayHour); + + if (OnAddClick.HasDelegate) + { + await OnAddClick.InvokeAsync(draft); + } + else + { + _addDraftStart = draft.StartDate; + _showAddDialog = true; + } + } + + private string GetBadgePosition(BitFullCalendarEvent ev, DateTime cellDate) + { + if (ev.IsSingleDay) return "none"; + if (ev.StartDate.Date == cellDate.Date) return "first"; + // Treat a 00:00 end as ending the previous day (exclusive midnight), consistent with + // GetMonthCellEvents, so the badge on the true last day is marked "last" rather than "middle". + var lastDate = BitFullCalendarHelpers.GetInclusiveEndDate(ev); + if (lastDate == cellDate.Date) return "last"; + return "middle"; + } + + private void OnDragOver() { } + + private async Task OnDrop() + { + await Notifier.HandleDropAsync(Cell.Date); + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcEventBullet.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcEventBullet.razor new file mode 100644 index 0000000000..9ea8a60f28 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcEventBullet.razor @@ -0,0 +1,3 @@ +@namespace Bit.BlazorUI + + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcEventBullet.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcEventBullet.razor.cs new file mode 100644 index 0000000000..9bcf6f52b0 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcEventBullet.razor.cs @@ -0,0 +1,8 @@ +namespace Bit.BlazorUI; + +public partial class BitFcEventBullet +{ + [CascadingParameter] public BitFullCalendarColorScheme ColorScheme { get; set; } = default!; + + [Parameter] public string? Color { get; set; } +} 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 new file mode 100644 index 0000000000..d89b036ff4 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcMonthEventBadge.razor @@ -0,0 +1,65 @@ +@namespace Bit.BlazorUI + +@{ + var positionClass = Position switch + { + "first" => "position-first", + "middle" => "position-middle", + "last" => "position-last", + _ => "" + }; + + var isDotVariant = State.BadgeVariant == BitFullCalendarBadgeVariant.Dot; + var colorClass = isDotVariant ? "dot-variant" : "bit-bfc-color"; + var colorStyleVar = ColorScheme.GetColorStyle(Event.Color); + var showTitle = Position is "first" or "none"; + var showTime = Position is "last" or "none"; + // Include both the start and end dates (not just the times) so multi-day and overnight events + // are announced unambiguously to assistive technology. BitFcDayCell.GetBadgePosition() treats a + // 00:00 end as ending the previous day (exclusive midnight) via GetInclusiveEndDate, so the + // "last" badge for such events renders on that previous day. Mirror the same inclusive-end + // adjustment here so the announced/rendered end day and time match where the badge actually + // appears instead of pointing at the next day's midnight. + var endIsExclusiveMidnight = Event.EndDate > Event.StartDate && Event.EndDate.TimeOfDay == TimeSpan.Zero; + var displayEnd = endIsExclusiveMidnight ? Event.EndDate.AddTicks(-1) : Event.EndDate; + var startLabel = $"{Event.StartDate.ToString("d", State.Culture)} {BitFullCalendarHelpers.FormatTime(Event.StartDate, State.Use24HourFormat, State.Culture)}"; + var endLabel = $"{displayEnd.ToString("d", State.Culture)} {BitFullCalendarHelpers.FormatTime(displayEnd, State.Use24HourFormat, State.Culture)}"; + var badgeAriaLabel = $"{Event.Title}, {startLabel} - {endLabel}"; +} + +
+ + @if (EventTemplate != null) + { + @EventTemplate(Event) + } + else + { +
+ @if (isDotVariant && Position is not "middle" and not "last") + { + + } + @if (showTitle) + { + @Event.Title + } +
+ + @if (showTime) + { + @BitFullCalendarHelpers.FormatTime(Position == "last" ? displayEnd : Event.StartDate, State.Use24HourFormat, State.Culture) + } + } +
diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcMonthEventBadge.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcMonthEventBadge.razor.cs new file mode 100644 index 0000000000..77a0d79691 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/MonthView/BitFcMonthEventBadge.razor.cs @@ -0,0 +1,36 @@ +namespace Bit.BlazorUI; + +public partial class BitFcMonthEventBadge +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarColorScheme ColorScheme { get; set; } = default!; + [Parameter] public BitFullCalendarEvent Event { get; set; } = default!; + [Parameter] public DateTime CellDate { get; set; } + [Parameter] public string Position { get; set; } = "none"; + [Parameter] public EventCallback OnSelected { get; set; } + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private string MarginStyle + { + get + { + var isRtl = State.IsRtl; + return Position switch + { + "first" => isRtl ? "margin-left:-4px; margin-right:2px;" : "margin-left:2px; margin-right:-4px;", + "middle" => "margin-left:-4px; margin-right:-4px;", + "last" => isRtl ? "margin-left:2px; margin-right:-4px;" : "margin-left:-4px; margin-right:2px;", + _ => "margin:0 2px;" + }; + } + } + + private void OnDragStart() => State.StartDrag(Event); + private void OnDragEnd() => State.EndDrag(); + + private async Task OnKeyDown(KeyboardEventArgs e) + { + if (e.Key is "Enter" or " " or "Spacebar" && !e.Repeat) + await 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 new file mode 100644 index 0000000000..fc1129c7dd --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineDayView.razor @@ -0,0 +1,148 @@ +@namespace Bit.BlazorUI +@inject IJSRuntime JS + +@* + Timeline mode - Day view. + Resources × 24 one-hour columns covering the selected date. +*@ + +@{ + const int hourWidth = BitFullCalendarHelpers.TimelineHourWidthPx; + const int laneHeight = 44; + const int laneGap = 4; + const int rowPadding = 4; + const int minRowHeight = 60; + + var resources = State.Resources; + var resourceIds = resources.Select(r => r.Id).Where(id => !string.IsNullOrWhiteSpace(id)).ToList(); + var grouped = BitFullCalendarHelpers.GroupEventsByResourceForDay( + Events, State.SelectedDate, resourceIds, _unassignedKey); + + int LaneCountFor(string key) => + grouped.TryGetValue(key, out var lanes) && lanes.Count > 0 ? lanes.Count : 1; + + int RowHeight(int laneCount) + { + var content = (laneCount * laneHeight) + (Math.Max(0, laneCount - 1) * laneGap) + (rowPadding * 2); + return Math.Max(minRowHeight, content); + } + + var hasUnassigned = (grouped.TryGetValue(_unassignedKey, out var u) && u.Count > 0) || resourceIds.Count == 0; + var unassignedLaneCount = hasUnassigned ? LaneCountFor(_unassignedKey) : 1; + var scrollHour = State.StartOfDayHour; +} + + + + +
+ @for (var h = 0; h < 24; h++) + { + var hour = h; + var isScrollTarget = hour == scrollHour; +
+ @BitFullCalendarHelpers.FormatHourLabel(hour, State.Use24HourFormat, State.Culture) +
+ } +
+
+ + + @{ var rowKey = resource.Id; var rowLabel = resource.Title; } + @* Drop targets *@ + @for (var h = 0; h < 24; h++) + { + var hour = h; + var isPreviewHour = State.IsDragging && _dragResourceId == rowKey && _dragHour == hour && _dragMinute == 0; + var isPreviewHalf = State.IsDragging && _dragResourceId == rowKey && _dragHour == hour && _dragMinute == 30; +
+
+
+ +
+ } + + @* Events *@ + @if (grouped.TryGetValue(rowKey, out var lanes)) + { + @RenderLanes(lanes) + } +
+ + + @{ var rowKey = _unassignedKey; var rowLabel = Texts.NoResourceLabel; } + @for (var h = 0; h < 24; h++) + { + var hour = h; + var isPreviewHour = State.IsDragging && _dragResourceId == rowKey && _dragHour == hour && _dragMinute == 0; + var isPreviewHalf = State.IsDragging && _dragResourceId == rowKey && _dragHour == hour && _dragMinute == 30; +
+
+
+ +
+ } + @if (hasUnassigned && grouped.TryGetValue(_unassignedKey, out var unassignedLanes)) + { + @RenderLanes(unassignedLanes) + } +
+
+ +@if (_showAddDialog) +{ + +} + +@if (_selectedEvent != null) +{ + +} + 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 new file mode 100644 index 0000000000..3694b525ad --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineDayView.razor.cs @@ -0,0 +1,137 @@ +namespace Bit.BlazorUI; + +public partial class BitFcTimelineDayView +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [CascadingParameter(Name = "OnAddClick")] public EventCallback OnAddClick { get; set; } + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + + [Parameter] public List Events { get; set; } = []; + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private const string _unassignedKey = "__bfc_unassigned__"; + private const int _laneHeight = 44; + private const int _laneGap = 4; + private const int _rowPadding = 4; + private readonly string _scrollContainerId = "bit-bfc-tl-day-scroll-" + Guid.NewGuid().ToString("N"); + + private string? _scrollSignature; + + private BitFullCalendarEvent? _selectedEvent; + private bool _showAddDialog; + private DateTime _addStartDate; + private int _addStartHour; + private int _addStartMinute; + private string? _addResourceId; + + private string? _dragResourceId; + private int? _dragHour; + private int? _dragMinute; + + private RenderFragment RenderLanes(List> lanes) => builder => + { + var inv = System.Globalization.CultureInfo.InvariantCulture; + const int hourWidth = BitFullCalendarHelpers.TimelineHourWidthPx; + for (var li = 0; li < lanes.Count; li++) + { + var laneTop = _rowPadding + (li * (_laneHeight + _laneGap)); + foreach (var ev in lanes[li]) + { + var pos = BitFullCalendarHelpers.GetTimelineBlockPosition(ev, State.SelectedDate, hourWidth); + if (pos is not { } p) + continue; + + var style = $"left:{p.LeftPx.ToString("F2", inv)}px;width:{Math.Max(p.WidthPx, 12).ToString("F2", inv)}px;top:{laneTop}px;height:{_laneHeight}px;"; + // Key the per-event block by the event's stable identity so Blazor preserves the + // correct BitFcTimelineEventBlock instance (and its in-flight drag/resize state) when + // lane ordering is recomputed, instead of reusing a sibling's component by position - + // matching the week/month timeline views. + builder.OpenElement(0, "div"); + builder.SetKey(ev.Id); + builder.AddAttribute(1, "class", "bit-bfc-tl-event-anchor"); + builder.AddAttribute(2, "style", style); + builder.OpenComponent(3); + builder.AddAttribute(4, "Event", ev); + builder.AddAttribute(5, "OnSelected", EventCallback.Factory.Create(this, SelectEvent)); + builder.AddAttribute(6, "EventTemplate", EventTemplate); + builder.AddAttribute(7, "PixelsPerMinute", hourWidth / 60.0); + builder.AddAttribute(8, "SnapMinutes", 30); + builder.CloseComponent(); + builder.CloseElement(); + } + } + }; + + private async Task SelectEvent(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + } + + private void CloseEventDetails() => _selectedEvent = null; + + private async Task OnSlotClickAsync(string resourceId, int hour, int minute) + { + if (OnAddClick.HasDelegate) + { + var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot(State.SelectedDate, hour, minute); + draft.Resource = resourceId == _unassignedKey ? null : resourceId; + await OnAddClick.InvokeAsync(draft); + return; + } + + _addStartDate = State.SelectedDate; + _addStartHour = hour; + _addStartMinute = minute; + _addResourceId = resourceId == _unassignedKey ? null : resourceId; + _showAddDialog = true; + } + + private async Task OnSlotKeyDownAsync(KeyboardEventArgs e, string resourceId, int hour, int minute) + { + // Ignore auto-repeat keydowns so a held Enter/Space only creates a single draft event, + // matching the day/week view behavior. + if (e.Key is "Enter" or " " or "Spacebar" && !e.Repeat) + await OnSlotClickAsync(resourceId, hour, minute); + } + + private string SlotAriaLabel(string rowLabel, int hour, int minute) + { + var start = State.SelectedDate.Date.AddHours(hour).AddMinutes(minute); + return $"{Texts.AddEventHoverHint}, {rowLabel}, {BitFullCalendarHelpers.FormatTime(start, State.Use24HourFormat, State.Culture)}"; + } + + private void OnDragEnter(string resourceId, int hour, int minute) + { + if (!State.IsDragging) return; + _dragResourceId = resourceId; + _dragHour = hour; + _dragMinute = minute; + } + + private async Task OnDrop(string resourceId, int hour, int minute) + { + if (!State.IsDragging) return; + + _dragResourceId = null; + _dragHour = null; + _dragMinute = null; + var newResourceId = resourceId == _unassignedKey ? null : resourceId; + await Notifier.HandleResourceDropAsync(State.SelectedDate, hour, minute, newResourceId); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + var sig = $"{State.SelectedDate:yyyy-MM-dd}|{State.StartOfDayHour}"; + if (sig == _scrollSignature) return; + + if (await BitFcTimelineScrollInterop.TryScrollToTargetAsync(JS, _scrollContainerId)) + _scrollSignature = sig; + } +} 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 new file mode 100644 index 0000000000..09c6f842ee --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineEventBlock.razor @@ -0,0 +1,96 @@ +@namespace Bit.BlazorUI +@inject IJSRuntime JS +@implements IDisposable + +@{ + var displayStart = _previewStart ?? Event.StartDate; + var displayEnd = _previewEnd ?? Event.EndDate; + + var isDotVariant = State.BadgeVariant == BitFullCalendarBadgeVariant.Dot; + var colorClass = isDotVariant ? "dot-variant" : "bit-bfc-color"; + var colorStyleVar = ColorScheme.GetColorStyle(Event.Color); + var passThrough = State.IsDragging && State.DraggedEvent?.Id != Event.Id; + + // While resizing we override the absolute placement of the anchor so the live preview + // tracks the snapped value instead of the original event time. + var inv = System.Globalization.CultureInfo.InvariantCulture; + var widthOverride = ""; + var translateXPx = 0.0; + if (_isResizing && _resizeBaseEvent != null && _previewStart.HasValue && _previewEnd.HasValue) + { + var newSpanPx = ToPx(displayEnd - displayStart); + if (newSpanPx < 12) newSpanPx = 12; + widthOverride = $"width:{newSpanPx.ToString("F2", inv)}px;"; + if (string.Equals(_resizeDirection, "start", StringComparison.Ordinal)) + translateXPx = ToPx(displayStart - _resizeBaseEvent.StartDate); + } + + var resizeClasses = ""; + if (_isResizing) + { + resizeClasses = "bit-bfc-timeline-event-resizing"; + if (_resizeDirection is "start" or "end") + resizeClasses += $" bit-bfc-resize-dir-{_resizeDirection}"; + } + + var blockStyle = $"{colorStyleVar}{widthOverride}transform:translateX({translateXPx.ToString("F2", inv)}px);"; + var canResize = PixelsPerMinute > 0; +} + +
+ + @if (canResize) + { +
+ } + + @if (EventTemplate != null) + { + @EventTemplate(Event) + } + else + { +
+ @if (isDotVariant) + { + + } + @Event.Title +
+
+ @BitFullCalendarHelpers.FormatTime(displayStart, State.Use24HourFormat, State.Culture) - @BitFullCalendarHelpers.FormatTime(displayEnd, State.Use24HourFormat, State.Culture) +
+ } + + @if (canResize) + { +
+ } + + @if (_isResizing) + { +
+ + @FormatPreview(displayStart, displayEnd) +
+ } +
diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineEventBlock.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineEventBlock.razor.cs new file mode 100644 index 0000000000..04e18f387a --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineEventBlock.razor.cs @@ -0,0 +1,266 @@ +namespace Bit.BlazorUI; + +public partial class BitFcTimelineEventBlock +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarColorScheme ColorScheme { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + + [Parameter] public BitFullCalendarEvent Event { get; set; } = default!; + [Parameter] public EventCallback OnSelected { get; set; } + [Parameter] public RenderFragment? EventTemplate { get; set; } + + /// Pixels per minute on the timeline axis (e.g. 96/60 for hour columns, 56/1440 for day columns). + [Parameter] public double PixelsPerMinute { get; set; } + + /// Snap interval in minutes used while resizing. Hour timelines snap to 30 min, day timelines snap to a full day. + [Parameter] public int SnapMinutes { get; set; } = 30; + + /// Minimum length the event can be resized to (minutes). Defaults to . + [Parameter] public int? MinDurationMinutes { get; set; } + + /// When true, the resize preview shows the date instead of the time-of-day (used by month timeline). + [Parameter] public bool PreviewAsDate { get; set; } + + private readonly string _instanceId = Guid.NewGuid().ToString("N"); + private DotNetObjectReference? _dotNetRef; + private bool _startResizeInitialized; + private bool _endResizeInitialized; + private bool _isResizing; + private string? _resizeDirection; + private BitFullCalendarEvent? _resizeBaseEvent; + private DateTime? _previewStart; + private DateTime? _previewEnd; + private DateTime _suppressClickUntilUtc; + + private string _startHandleId => $"bit-bfc-tl-resize-start-{_instanceId}"; + private string _endHandleId => $"bit-bfc-tl-resize-end-{_instanceId}"; + + private int EffectiveMinDurationMinutes => MinDurationMinutes ?? Math.Max(1, SnapMinutes); + + /// Pointer movement below this many pixels keeps the original time so the edge does not jump on press. + private double DeadZonePx => Math.Max(2.0, EffectiveMinDurationMinutes * PixelsPerMinute / 2.0); + + private double ToPx(TimeSpan span) => span.TotalMinutes * PixelsPerMinute; + + private void OnDragStart() + { + if (_isResizing) return; + State.StartDrag(Event); + } + + private void OnDragEnd() => State.EndDrag(); + + private async Task OnClick() + { + if (DateTime.UtcNow <= _suppressClickUntilUtc || _isResizing) return; + await OnSelected.InvokeAsync(Event); + } + + private async Task OnKeyDown(KeyboardEventArgs e) + { + if (e.Key is "Enter" or " " or "Spacebar") + await OnClick(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (PixelsPerMinute <= 0) return; + if (_startResizeInitialized && _endResizeInitialized) return; + + try + { + _dotNetRef ??= DotNetObjectReference.Create(this); + // Track each handle independently so a failure registering one handle doesn't force a + // re-registration of the already-bound handle on the next render. + if (!_startResizeInitialized) + { + await JS.InvokeVoidAsync("BitBlazorUI.FullCalendar.initResizeHorizontal", _dotNetRef, _startHandleId, "start"); + _startResizeInitialized = true; + } + if (!_endResizeInitialized) + { + await JS.InvokeVoidAsync("BitBlazorUI.FullCalendar.initResizeHorizontal", _dotNetRef, _endHandleId, "end"); + _endResizeInitialized = true; + } + } + catch (Exception ex) when (ex is JSException or JSDisconnectedException or InvalidOperationException or OperationCanceledException) + { + // BitFullCalendar JS not yet available, or the circuit/render is mid-teardown; retry on next render. + } + } + + [JSInvokable] + public void OnResizeStart(string direction) + { + if (direction is not ("start" or "end")) + return; + + _isResizing = true; + _resizeDirection = direction; + _resizeBaseEvent = Event; + _previewStart = null; + _previewEnd = null; + _suppressClickUntilUtc = DateTime.UtcNow.AddMilliseconds(300); + State.EndDrag(); + _ = InvokeAsync(StateHasChanged); + } + + [JSInvokable] + public Task OnResizeMove(string direction, double deltaPx) + { + if (direction is not ("start" or "end")) + return Task.CompletedTask; + + if (!_isResizing || _resizeBaseEvent == null || PixelsPerMinute <= 0) + return Task.CompletedTask; + + // Inside the dead-zone treat the gesture as "no change" so users can press without + // immediately snapping the edge. Also clears any previously committed preview. + if (Math.Abs(deltaPx) <= DeadZonePx) + { + if (_previewStart.HasValue || _previewEnd.HasValue) + { + _previewStart = null; + _previewEnd = null; + return InvokeAsync(StateHasChanged); + } + return Task.CompletedTask; + } + + var sign = Math.Sign(deltaPx); + var effectivePx = deltaPx - sign * DeadZonePx; + var deltaMinutes = effectivePx / PixelsPerMinute; + + var snap = Math.Max(1, SnapMinutes); + var minDur = Math.Max(1, EffectiveMinDurationMinutes); + var baseEvent = _resizeBaseEvent; + + var newStart = baseEvent.StartDate; + var newEnd = baseEvent.EndDate; + + if (direction == "start") + { + var maxStart = baseEvent.EndDate.AddMinutes(-minDur); + var candidate = baseEvent.StartDate.AddMinutes(deltaMinutes); + newStart = deltaMinutes > 0 + ? BitFullCalendarHelpers.CeilToMinuteInterval(candidate, snap) + : BitFullCalendarHelpers.FloorToMinuteInterval(candidate, snap); + if (newStart > maxStart) + newStart = BitFullCalendarHelpers.FloorToMinuteInterval(maxStart, snap); + } + else + { + var minEnd = baseEvent.StartDate.AddMinutes(minDur); + var candidate = baseEvent.EndDate.AddMinutes(deltaMinutes); + newEnd = deltaMinutes > 0 + ? BitFullCalendarHelpers.CeilToMinuteInterval(candidate, snap) + : BitFullCalendarHelpers.FloorToMinuteInterval(candidate, snap); + if (newEnd < minEnd) + newEnd = BitFullCalendarHelpers.CeilToMinuteInterval(minEnd, snap); + } + + // Snapped range matches drag-start range → treat as restored original. + if (newStart == baseEvent.StartDate && newEnd == baseEvent.EndDate) + { + if (_previewStart.HasValue || _previewEnd.HasValue) + { + _previewStart = null; + _previewEnd = null; + return InvokeAsync(StateHasChanged); + } + return Task.CompletedTask; + } + + var curStart = _previewStart ?? baseEvent.StartDate; + var curEnd = _previewEnd ?? baseEvent.EndDate; + if (newStart == curStart && newEnd == curEnd) + return Task.CompletedTask; + + _previewStart = newStart; + _previewEnd = newEnd; + return InvokeAsync(StateHasChanged); + } + + [JSInvokable] + public async Task OnResizeEnd() + { + try + { + if (_resizeBaseEvent != null && _previewStart.HasValue && _previewEnd.HasValue) + { + var s = _previewStart.Value; + var e = _previewEnd.Value; + if (s != _resizeBaseEvent.StartDate || e != _resizeBaseEvent.EndDate) + { + var b = _resizeBaseEvent; + // Snapshot the previous state before UpdateEvent runs, so the OldEvent payload + // is independent of whether the store mutates or replaces the existing instance. + var oldSnapshot = BitFullCalendarChangeNotifier.CloneEvent(b); + var updated = new BitFullCalendarEvent + { + Id = b.Id, + Title = b.Title, + Description = b.Description, + StartDate = s, + EndDate = e, + Color = b.Color, + Resource = b.Resource, + Data = b.Data, + Attendees = [.. b.Attendees] + }; + + State.UpdateEvent(updated); + + try + { + await Notifier.NotifyAsync(new BitFullCalendarChangeEventArgs + { + Event = BitFullCalendarChangeNotifier.CloneEvent(updated), + OldEvent = oldSnapshot, + Kind = BitFullCalendarChangeKind.Edit, + Source = BitFullCalendarChangeSource.Resize + }); + } + catch + { + // Notification failed: restore the pre-resize event so local state stays in + // sync with what consumers believe, instead of leaving the committed resize. + State.UpdateEvent(oldSnapshot); + throw; + } + } + } + } + finally + { + _previewStart = null; + _previewEnd = null; + _isResizing = false; + _resizeBaseEvent = null; + _resizeDirection = null; + _suppressClickUntilUtc = DateTime.UtcNow.AddMilliseconds(300); + } + + await InvokeAsync(StateHasChanged); + } + + private void NoOpResize() { /* pointerdown handled in JS via initResizeHorizontal */ } + + private string FormatPreview(DateTime start, DateTime end) + { + if (PreviewAsDate) + { + // Month timeline: end is exclusive (00:00 next day) so render the inclusive end. + var displayEnd = end.TimeOfDay == TimeSpan.Zero && end > start ? end.AddDays(-1) : end; + var startStr = start.ToString("MMM d", State.Culture); + var endStr = displayEnd.ToString("MMM d", State.Culture); + return startStr == endStr ? startStr : $"{startStr} - {endStr}"; + } + + return $"{BitFullCalendarHelpers.FormatTime(start, State.Use24HourFormat, State.Culture)} - {BitFullCalendarHelpers.FormatTime(end, State.Use24HourFormat, State.Culture)}"; + } + + public void Dispose() => _dotNetRef?.Dispose(); +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineLayout.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineLayout.razor new file mode 100644 index 0000000000..b0ebec8426 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineLayout.razor @@ -0,0 +1,76 @@ +@namespace Bit.BlazorUI + +@* + Shared scaffolding used by every timeline view. + + Layout (flex rows so the resource gutter can stay pinned across the full horizontal scroll): + - header row : flex row that sticks to the top; corner cell sticks to the inline-start + - body rows : flex rows; each starts with the sticky resource cell, followed by the lane area + + Each timeline view renders the time-axis header inside and the lane content + for every resource inside , while this component handles the + layout, sticky positioning, RTL flip, and the unassigned row. +*@ + +@{ + // Resources with empty Ids are treated as "unassigned" by the event grouping logic + // (BitFullCalendarEvent.Resource contract), so they must not get their own rows here either. + var resources = State.Resources.Where(r => !string.IsNullOrWhiteSpace(r.Id)).ToList(); + var hasResources = resources.Count > 0; + var totalContentWidth = ColumnCount * ColumnWidthPx; + var totalGridWidth = ResourceColumnWidthPx + totalContentWidth; +} + +
+ @if (!hasResources && !HasUnassignedRow) + { +
@Texts.NoResourcesMessage
+ } + else + { +
+
+ +
+
@Texts.ResourceColumnHeader
+
+ @HeaderContent +
+
+ + @foreach (var resource in resources) + { + var res = resource; + var rowHeight = RowHeightFor(res); + +
+
+
@res.Title
+ @if (!string.IsNullOrWhiteSpace(res.Subtitle)) + { +
@res.Subtitle
+ } +
+
+ @RowContent(res) +
+
+ } + + @if (HasUnassignedRow) + { + var unassignedHeight = UnassignedRowHeight; + +
+
+
@Texts.NoResourceLabel
+
+
+ @UnassignedRowContent +
+
+ } +
+
+ } +
diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineLayout.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineLayout.razor.cs new file mode 100644 index 0000000000..86caa0a700 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineLayout.razor.cs @@ -0,0 +1,37 @@ +namespace Bit.BlazorUI; + +public partial class BitFcTimelineLayout +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + + /// Width of a single time-axis column in pixels. + [Parameter] public int ColumnWidthPx { get; set; } = BitFullCalendarHelpers.TimelineHourWidthPx; + + /// Total number of time-axis columns (e.g. 24 for day, 168 for week, days-in-month for month). + [Parameter] public int ColumnCount { get; set; } + + /// Width of the sticky resource gutter on the left. + [Parameter] public int ResourceColumnWidthPx { get; set; } = 200; + + /// DOM id assigned to the horizontal scroll container so views can scroll it via JS interop. + [Parameter] public string ScrollContainerId { get; set; } = "bit-bfc-tl-scroll-" + Guid.NewGuid().ToString("N"); + + /// Time-axis header(s) rendered in the top-right cell. Total width must equal ColumnCount * ColumnWidthPx. + [Parameter] public RenderFragment? HeaderContent { get; set; } + + /// Row content for a single resource. + [Parameter] public RenderFragment RowContent { get; set; } = default!; + + /// Whether to render the trailing "Unassigned" row. + [Parameter] public bool HasUnassignedRow { get; set; } + + /// Content rendered inside the unassigned row. + [Parameter] public RenderFragment? UnassignedRowContent { get; set; } + + /// Resolves the height in pixels for a given resource row (lets each view stack overlapping events into lanes). + [Parameter] public Func RowHeightFor { get; set; } = _ => 56; + + /// Height in pixels for the unassigned row. + [Parameter] public int UnassignedRowHeight { get; set; } = 56; +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineMonthView.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineMonthView.razor new file mode 100644 index 0000000000..98f45bf985 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineMonthView.razor @@ -0,0 +1,146 @@ +@namespace Bit.BlazorUI +@inject IJSRuntime JS + +@* + Timeline mode - Month view. + Resources × (one column per day in the active month). Day-precision drops re-place an event + on a different resource and date but keep its original time-of-day. +*@ + +@{ + const int dayWidth = BitFullCalendarHelpers.TimelineDayWidthPx; + const int laneHeight = 40; + const int laneGap = 4; + const int rowPadding = 4; + const int minRowHeight = 60; + + var culture = State.Culture; + var cal = culture.Calendar; + // Capture the era from the selected date and pass it explicitly so multi-era calendars (e.g. the + // Japanese calendar) resolve the year/month/days against the date's own era instead of falling + // back to CurrentEra, which would mis-handle a SelectedDate from a previous era. + var era = cal.GetEra(State.SelectedDate); + var year = cal.GetYear(State.SelectedDate); + var month = cal.GetMonth(State.SelectedDate); + var monthStart = cal.ToDateTime(year, month, 1, 0, 0, 0, 0, era); + var daysInMonth = cal.GetDaysInMonth(year, month, era); + var monthDays = Enumerable.Range(0, daysInMonth).Select(i => monthStart.AddDays(i)).ToArray(); + + var resources = State.Resources; + var resourceIds = resources.Select(r => r.Id).Where(id => !string.IsNullOrWhiteSpace(id)).ToList(); + + // Group every event that touches the month into resource-keyed lists. + var perResource = BitFullCalendarHelpers.GroupEventsByResourceForMonth( + Events, monthStart, daysInMonth, resourceIds, _unassignedKey); + + int LaneCountFor(string resourceId) => + perResource.TryGetValue(resourceId, out var lanes) && lanes.Count > 0 ? lanes.Count : 1; + + int RowHeight(int laneCount) + { + var content = (laneCount * laneHeight) + (Math.Max(0, laneCount - 1) * laneGap) + (rowPadding * 2); + return Math.Max(minRowHeight, content); + } + + // Render the unassigned row when there are unassigned events, OR when there are no persistable + // resources at all (resourceIds is the set of valid, non-blank ids) so the user always has at + // least one row to create the first event in - blank-id resources still fall back here. + var hasUnassignedEvents = perResource.TryGetValue(_unassignedKey, out var u) && u.Count > 0; + var hasUnassigned = hasUnassignedEvents || resourceIds.Count == 0; + var unassignedLaneCount = hasUnassignedEvents ? perResource[_unassignedKey].Count : 1; + + // If today falls inside the active month, scroll horizontally to today's day cell. + var todayIdx = Array.FindIndex(monthDays, d => d.Date == DateTime.Today); +} + + + + +
+ @for (var di = 0; di < monthDays.Length; di++) + { + var dayIndex = di; + var d = monthDays[dayIndex]; + var isToday = d.Date == DateTime.Today; + var dayNum = BitFullCalendarHelpers.GetCulturalDayOfMonth(d, culture); + var dowInitial = culture.DateTimeFormat.GetShortestDayName(d.DayOfWeek); + var isScrollTarget = dayIndex == todayIdx; +
+ @dayNum @dowInitial +
+ } +
+
+ + + @{ var rowKey = resource.Id; } + @for (var di = 0; di < daysInMonth; di++) + { + var dayIndex = di; + var day = monthDays[dayIndex]; + var leftPx = dayIndex * dayWidth; + var isPreview = State.IsDragging && _dragResourceId == rowKey && _dragDay == day.Date; +
+ +
+ } + + @if (perResource.TryGetValue(rowKey, out var lanes)) + { + @RenderLanes(lanes, monthStart, daysInMonth) + } +
+ + + @{ var rowKey = _unassignedKey; } + @for (var di = 0; di < daysInMonth; di++) + { + var dayIndex = di; + var day = monthDays[dayIndex]; + var leftPx = dayIndex * dayWidth; + var isPreview = State.IsDragging && _dragResourceId == rowKey && _dragDay == day.Date; +
+ } + @if (hasUnassignedEvents) + { + @RenderLanes(perResource[_unassignedKey], monthStart, daysInMonth) + } +
+
+ +@if (_showAddDialog) +{ + +} + +@if (_selectedEvent != null) +{ + +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineMonthView.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineMonthView.razor.cs new file mode 100644 index 0000000000..a6f419a97f --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineMonthView.razor.cs @@ -0,0 +1,162 @@ +namespace Bit.BlazorUI; + +public partial class BitFcTimelineMonthView +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [CascadingParameter(Name = "OnAddClick")] public EventCallback OnAddClick { get; set; } + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + + [Parameter] public List Events { get; set; } = []; + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private const string _unassignedKey = "__bfc_unassigned__"; + private const int _laneHeight = 40; + private const int _laneGap = 4; + private const int _rowPadding = 4; + private readonly string _scrollContainerId = "bit-bfc-tl-month-scroll-" + Guid.NewGuid().ToString("N"); + + private string? _scrollSignature; + + private BitFullCalendarEvent? _selectedEvent; + private bool _showAddDialog; + private DateTime _addStartDate; + private int _addStartHour; + private string? _addResourceId; + + private string? _dragResourceId; + private DateTime? _dragDay; + + private RenderFragment RenderLanes(List> lanes, DateTime monthStart, int daysInMonth) => builder => + { + var inv = System.Globalization.CultureInfo.InvariantCulture; + const int dayWidth = BitFullCalendarHelpers.TimelineDayWidthPx; + var monthEnd = monthStart.AddDays(daysInMonth); + + for (var li = 0; li < lanes.Count; li++) + { + var laneTop = _rowPadding + (li * (_laneHeight + _laneGap)); + foreach (var ev in lanes[li]) + { + var clippedStart = ev.StartDate < monthStart ? monthStart : ev.StartDate; + var clippedEnd = ev.EndDate > monthEnd ? monthEnd : ev.EndDate; + // Allow zero-length markers (clippedEnd == clippedStart) through so a 00:00 all-day + // marker is still rendered (as a single-day block below) instead of being hidden. + if (clippedEnd < clippedStart) continue; + + // Use full-day boundaries for span (start of start day → start of next-after-end day). + var startDayIdx = (int)(clippedStart.Date - monthStart.Date).TotalDays; + var endExclusiveIdx = (int)(clippedEnd.Date - monthStart.Date).TotalDays + (clippedEnd.TimeOfDay > TimeSpan.Zero ? 1 : 0); + if (endExclusiveIdx <= startDayIdx) endExclusiveIdx = startDayIdx + 1; + + var leftPx = startDayIdx * dayWidth; + var widthPx = (endExclusiveIdx - startDayIdx) * dayWidth - 2; // -2 for visual gap + + var style = $"left:{leftPx.ToString("F2", inv)}px;width:{Math.Max(widthPx, 12).ToString("F2", inv)}px;top:{laneTop}px;height:{_laneHeight}px;"; + // Key the per-event block by the event's stable identity so Blazor preserves the + // correct BitFcTimelineEventBlock instance (and its in-flight state) when lane ordering + // is recomputed, instead of reusing a sibling's component by position - matching the + // week timeline view. + builder.OpenElement(0, "div"); + builder.SetKey(ev.Id); + builder.AddAttribute(1, "class", "bit-bfc-tl-event-anchor"); + builder.AddAttribute(2, "style", style); + builder.OpenComponent(3); + builder.AddAttribute(4, "Event", ev); + builder.AddAttribute(5, "OnSelected", EventCallback.Factory.Create(this, SelectEvent)); + builder.AddAttribute(6, "EventTemplate", EventTemplate); + builder.AddAttribute(7, "PixelsPerMinute", dayWidth / 1440.0); + builder.AddAttribute(8, "SnapMinutes", 1440); + builder.AddAttribute(9, "MinDurationMinutes", 1440); + builder.AddAttribute(10, "PreviewAsDate", true); + builder.CloseComponent(); + builder.CloseElement(); + } + } + }; + + private async Task SelectEvent(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + } + + private void CloseEventDetails() => _selectedEvent = null; + + private async Task OnSlotClickAsync(string resourceId, DateTime day) + { + if (OnAddClick.HasDelegate) + { + var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot(day, State.StartOfDayHour); + draft.Resource = resourceId == _unassignedKey ? null : resourceId; + await OnAddClick.InvokeAsync(draft); + return; + } + + _addStartDate = day; + _addStartHour = State.StartOfDayHour; + _addResourceId = resourceId == _unassignedKey ? null : resourceId; + _showAddDialog = true; + } + + private async Task OnSlotKeyDownAsync(KeyboardEventArgs e, string resourceId, DateTime day) + { + // Ignore auto-repeat keydowns so a held Enter/Space only creates a single draft event, + // matching the day/week view behavior. + if (e.Key is "Enter" or " " or "Spacebar" && !e.Repeat) + await OnSlotClickAsync(resourceId, day); + } + + private string SlotAriaLabel(DateTime day, string rowLabel) + { + return $"{Texts.AddEventHoverHint}, {rowLabel}, {day.ToString("D", State.Culture)}"; + } + + private void OnDragEnter(string resourceId, DateTime day) + { + if (!State.IsDragging) return; + _dragResourceId = resourceId; + _dragDay = day.Date; + } + + private async Task OnDrop(string resourceId, DateTime day) + { + if (!State.IsDragging) return; + + _dragResourceId = null; + _dragDay = null; + var newResourceId = resourceId == _unassignedKey ? null : resourceId; + // Day-precision drop: keep the original time of day (passing null hour/minute makes + // HandleResourceDropAsync preserve the dragged event's time). + await Notifier.HandleResourceDropAsync(day, hour: null, minute: null, newResourceId); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + // Only the current month has a "today" scroll target; skip the interop entirely otherwise + // so we don't make a JS round-trip on every render of a non-current month. + // Compare using the active culture's calendar since the rendered month follows that + // calendar system, not the Gregorian one. + var cal = State.Culture.Calendar; + var today = DateTime.Today; + if (cal.GetYear(State.SelectedDate) != cal.GetYear(today) || + cal.GetMonth(State.SelectedDate) != cal.GetMonth(today)) + { + // Reset the signature so that navigating back to the current month later re-triggers + // the scroll-to-today interop instead of being skipped by a stale matching signature. + _scrollSignature = ""; + return; + } + + var sig = $"{cal.GetYear(State.SelectedDate)}-{cal.GetMonth(State.SelectedDate)}|{cal.GetYear(today)}-{cal.GetMonth(today)}-{cal.GetDayOfMonth(today)}"; + if (sig == _scrollSignature) return; + + if (await BitFcTimelineScrollInterop.TryScrollToTargetAsync(JS, _scrollContainerId)) + _scrollSignature = sig; + } +} 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 new file mode 100644 index 0000000000..b7d3f54ff6 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineWeekView.razor @@ -0,0 +1,200 @@ +@namespace Bit.BlazorUI +@inject IJSRuntime JS + +@* + Timeline mode - Week view. + Resources × (7 days × 24 hours) hour-precision columns. + Two-row time header: top row spans each day, bottom row shows hourly slots. +*@ + +@{ + const int hourWidth = BitFullCalendarHelpers.TimelineHourWidthPx; + const int laneHeight = 44; + const int laneGap = 4; + const int rowPadding = 4; + const int minRowHeight = 60; + + var weekStart = BitFullCalendarHelpers.StartOfWeek(State.SelectedDate, State.Culture); + var weekDays = Enumerable.Range(0, 7).Select(i => weekStart.AddDays(i)).ToArray(); + + var resources = State.Resources; + var resourceIds = resources.Select(r => r.Id).Where(id => !string.IsNullOrWhiteSpace(id)).ToList(); + + // Lanes per (resourceId, dayIndex) + var perDay = weekDays.Select(d => + BitFullCalendarHelpers.GroupEventsByResourceForDay(Events, d, resourceIds, _unassignedKey) + ).ToArray(); + + int LaneCountFor(string resourceId) + { + if (string.IsNullOrEmpty(resourceId)) + return 1; + + var max = 1; + foreach (var dayMap in perDay) + { + if (dayMap.TryGetValue(resourceId, out var lanes) && lanes.Count > max) + max = lanes.Count; + } + return max; + } + + int RowHeight(int laneCount) + { + var content = (laneCount * laneHeight) + (Math.Max(0, laneCount - 1) * laneGap) + (rowPadding * 2); + return Math.Max(minRowHeight, content); + } + + // Render the unassigned lane when there are unassigned events, OR when there are no resources at + // all so the user always has at least one row to add the first event to. + var hasUnassignedEvents = perDay.Any(m => m.TryGetValue(_unassignedKey, out var l) && l.Count > 0); + var hasUnassigned = hasUnassignedEvents || resourceIds.Count == 0; + var unassignedLaneCount = hasUnassigned ? LaneCountFor(_unassignedKey) : 1; + var totalColumns = 7 * 24; + + // Pick the day and hour cell that should be in view after render: today's day if it falls + // inside the visible week, otherwise the first day. Within that day, the start-of-day hour. + var todayIdx = Array.FindIndex(weekDays, d => d.Date == DateTime.Today); + var scrollDayIdx = todayIdx >= 0 ? todayIdx : 0; + var scrollHour = State.StartOfDayHour; +} + + + + +
+ @foreach (var d in weekDays) + { + var isToday = d.Date == DateTime.Today; +
+ @d.ToString("ddd", State.Culture) + @d.ToString("M/d", State.Culture) +
+ } +
+
+ @for (var di = 0; di < 7; di++) + { + var dayIndex = di; + @for (var h = 0; h < 24; h++) + { + var hour = h; + var isScrollTarget = dayIndex == scrollDayIdx && hour == scrollHour; +
+ @BitFullCalendarHelpers.FormatHourLabel(hour, State.Use24HourFormat, State.Culture) +
+ } + } +
+
+ + + @{ var rowKey = resource.Id; var rowLabel = resource.Title; } + @for (var di = 0; di < 7; di++) + { + var dayIndex = di; + var day = weekDays[dayIndex]; + var dayOffsetPx = dayIndex * 24 * hourWidth; + var dayMap = perDay[dayIndex]; + + @for (var h = 0; h < 24; h++) + { + var hour = h; + var leftPx = dayOffsetPx + (hour * hourWidth); + var isPreviewHour = State.IsDragging && _dragResourceId == rowKey && _dragDay == day.Date && _dragHour == hour && _dragMinute == 0; + var isPreviewHalf = State.IsDragging && _dragResourceId == rowKey && _dragDay == day.Date && _dragHour == hour && _dragMinute == 30; +
+
+
+ +
+ } + + @if (!string.IsNullOrEmpty(rowKey) && dayMap.TryGetValue(rowKey, out var lanes)) + { + @RenderLanes(lanes, day, dayOffsetPx) + } + } +
+ + + @{ var rowKey = _unassignedKey; var rowLabel = Texts.NoResourceLabel; } + @for (var di = 0; di < 7; di++) + { + var dayIndex = di; + var day = weekDays[dayIndex]; + var dayOffsetPx = dayIndex * 24 * hourWidth; + var dayMap = perDay[dayIndex]; + + @for (var h = 0; h < 24; h++) + { + var hour = h; + var leftPx = dayOffsetPx + (hour * hourWidth); + var isPreviewHour = State.IsDragging && _dragResourceId == rowKey && _dragDay == day.Date && _dragHour == hour && _dragMinute == 0; + var isPreviewHalf = State.IsDragging && _dragResourceId == rowKey && _dragDay == day.Date && _dragHour == hour && _dragMinute == 30; +
+
+
+
+ } + + @if (!string.IsNullOrEmpty(rowKey) && dayMap.TryGetValue(rowKey, out var lanes)) + { + @RenderLanes(lanes, day, dayOffsetPx) + } + } +
+
+ +@if (_showAddDialog) +{ + +} + +@if (_selectedEvent != null) +{ + +} + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineWeekView.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineWeekView.razor.cs new file mode 100644 index 0000000000..d80bf3635b --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/TimelineMode/BitFcTimelineWeekView.razor.cs @@ -0,0 +1,141 @@ +namespace Bit.BlazorUI; + +public partial class BitFcTimelineWeekView +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [CascadingParameter] public BitFullCalendarTexts Texts { get; set; } = default!; + [CascadingParameter] public BitFullCalendarChangeNotifier Notifier { get; set; } = default!; + [CascadingParameter(Name = "OnAddClick")] public EventCallback OnAddClick { get; set; } + [CascadingParameter(Name = "OnEventClick")] public EventCallback OnEventClick { get; set; } + + [Parameter] public List Events { get; set; } = []; + [Parameter] public RenderFragment? EventTemplate { get; set; } + + private const string _unassignedKey = "__bfc_unassigned__"; + private const int _laneHeight = 44; + private const int _laneGap = 4; + private const int _rowPadding = 4; + private readonly string _scrollContainerId = "bit-bfc-tl-week-scroll-" + Guid.NewGuid().ToString("N"); + + private string? _scrollSignature; + + private BitFullCalendarEvent? _selectedEvent; + private bool _showAddDialog; + private DateTime _addStartDate; + private int _addStartHour; + private int _addStartMinute; + private string? _addResourceId; + + private string? _dragResourceId; + private DateTime? _dragDay; + private int? _dragHour; + private int? _dragMinute; + + private RenderFragment RenderLanes(List> lanes, DateTime day, int dayOffsetPx) => builder => + { + var inv = System.Globalization.CultureInfo.InvariantCulture; + const int hourWidth = BitFullCalendarHelpers.TimelineHourWidthPx; + + for (var li = 0; li < lanes.Count; li++) + { + var laneTop = _rowPadding + (li * (_laneHeight + _laneGap)); + foreach (var ev in lanes[li]) + { + var pos = BitFullCalendarHelpers.GetTimelineBlockPosition(ev, day, hourWidth); + if (pos is not { } p) + continue; + + var style = $"left:{(dayOffsetPx + p.LeftPx).ToString("F2", inv)}px;width:{Math.Max(p.WidthPx, 12).ToString("F2", inv)}px;top:{laneTop}px;height:{_laneHeight}px;"; + // Key the per-event block by the event's stable identity so Blazor preserves the + // correct BitFcTimelineEventBlock instance (and its in-flight drag/resize state) when + // lane ordering is recomputed, instead of reusing a sibling's component by position. + builder.OpenElement(0, "div"); + builder.SetKey(ev.Id); + builder.AddAttribute(1, "class", "bit-bfc-tl-event-anchor"); + builder.AddAttribute(2, "style", style); + builder.OpenComponent(3); + builder.AddAttribute(4, "Event", ev); + builder.AddAttribute(5, "OnSelected", EventCallback.Factory.Create(this, SelectEvent)); + builder.AddAttribute(6, "EventTemplate", EventTemplate); + builder.AddAttribute(7, "PixelsPerMinute", hourWidth / 60.0); + builder.AddAttribute(8, "SnapMinutes", 30); + builder.CloseComponent(); + builder.CloseElement(); + } + } + }; + + private async Task SelectEvent(BitFullCalendarEvent ev) + { + if (OnEventClick.HasDelegate) + { + await OnEventClick.InvokeAsync(ev); + return; + } + _selectedEvent = ev; + } + + private void CloseEventDetails() => _selectedEvent = null; + + private async Task OnSlotClickAsync(string resourceId, DateTime day, int hour, int minute) + { + if (OnAddClick.HasDelegate) + { + var draft = BitFullCalendarHelpers.CreateDraftEventForTimeSlot(day, hour, minute); + draft.Resource = resourceId == _unassignedKey ? null : resourceId; + await OnAddClick.InvokeAsync(draft); + return; + } + + _addStartDate = day; + _addStartHour = hour; + _addStartMinute = minute; + _addResourceId = resourceId == _unassignedKey ? null : resourceId; + _showAddDialog = true; + } + + private async Task OnSlotKeyDownAsync(KeyboardEventArgs e, string resourceId, DateTime day, int hour, int minute) + { + // Ignore auto-repeat keydowns so a held Enter/Space only creates a single draft event, + // matching the day/month timeline view behavior. + if (e.Key is "Enter" or " " or "Spacebar" && !e.Repeat) + await OnSlotClickAsync(resourceId, day, hour, minute); + } + + private string SlotAriaLabel(string rowLabel, DateTime day, int hour, int minute) + { + var start = day.Date.AddHours(hour).AddMinutes(minute); + return $"{Texts.AddEventHoverHint}, {rowLabel}, {day.ToString("ddd", State.Culture)} {BitFullCalendarHelpers.FormatTime(start, State.Use24HourFormat, State.Culture)}"; + } + + private void OnDragEnter(string resourceId, DateTime day, int hour, int minute) + { + if (!State.IsDragging) return; + _dragResourceId = resourceId; + _dragDay = day.Date; + _dragHour = hour; + _dragMinute = minute; + } + + private async Task OnDrop(string resourceId, DateTime day, int hour, int minute) + { + if (!State.IsDragging) return; + + _dragResourceId = null; + _dragDay = null; + _dragHour = null; + _dragMinute = null; + var newResourceId = resourceId == _unassignedKey ? null : resourceId; + await Notifier.HandleResourceDropAsync(day, hour, minute, newResourceId); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + var weekStart = BitFullCalendarHelpers.StartOfWeek(State.SelectedDate, State.Culture); + var sig = $"{weekStart:yyyy-MM-dd}|{State.StartOfDayHour}|{DateTime.Today:yyyy-MM-dd}"; + if (sig == _scrollSignature) return; + + if (await BitFcTimelineScrollInterop.TryScrollToTargetAsync(JS, _scrollContainerId)) + _scrollSignature = sig; + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/YearView/BitFcCalendarYearView.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/YearView/BitFcCalendarYearView.razor new file mode 100644 index 0000000000..5721c105e3 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/YearView/BitFcCalendarYearView.razor @@ -0,0 +1,148 @@ +@namespace Bit.BlazorUI + +@{ + var allEvents = MultiDayEvents.Concat(SingleDayEvents).ToList(); + var cal = State.Culture.Calendar; + var dtf = State.Culture.DateTimeFormat; + // Capture the era of the anchor date and pass it explicitly into every calendar call below so + // the year range stays correct for era-based calendars (e.g. JapaneseCalendar) where the same + // year number repeats across eras and the default (current) era would resolve the wrong dates. + int era = cal.GetEra(State.SelectedDate); + int culturalYear = cal.GetYear(State.SelectedDate); + // Era-based calendars (e.g. JapaneseCalendar) can have partial years where the era begins or + // ends mid-year, so the nominal month 1 (or the last month) may not exist in that era year. + // Build the visible month list from only the months that can actually be materialized for this + // era/year instead of assuming a full span anchored at month 1, which would throw. + var months = new List(); + var monthsInYear = cal.GetMonthsInYear(culturalYear, era); + for (int m = 1; m <= monthsInYear; m++) + { + // Day 1 may not exist in a partial era month (the era can begin mid-month), so probe for + // the first day that actually materializes instead of assuming day 1 and dropping the whole + // month. Only skip a month when no day in it is valid for this era/year. + int daysInM; + try + { + daysInM = cal.GetDaysInMonth(culturalYear, m, era); + } + catch (ArgumentOutOfRangeException) + { + continue; + } + + DateTime? firstValid = null; + for (int d = 1; d <= daysInM; d++) + { + try + { + firstValid = cal.ToDateTime(culturalYear, m, d, 0, 0, 0, 0, era); + break; + } + catch (ArgumentOutOfRangeException) + { + // This day does not exist in the selected partial era month; try the next one. + } + } + + if (firstValid.HasValue) + months.Add(firstValid.Value); + } + var yearWeekDayHeaders = BitFullCalendarHelpers.GetAbbreviatedWeekDayHeaders(State.Culture); + + // Pre-compute a date -> events lookup once instead of scanning every event for every cell + // (the year grid has ~500 cells, so the per-cell Where was O(cells * events)). + // Derive the lookup range from the actual first/last visible months rather than the nominal + // year boundaries, so partial era years (where month 1 or the last month may not exist) don't + // produce invalid dates. + var yearStartDate = months.Count > 0 ? months[0].Date : State.SelectedDate.Date; + DateTime yearEndDate; + if (months.Count > 0) + { + var lastMonth = months[^1]; + var lastMonthNum = cal.GetMonth(lastMonth); + var lastMonthYear = cal.GetYear(lastMonth); + yearEndDate = cal.ToDateTime(lastMonthYear, lastMonthNum, cal.GetDaysInMonth(lastMonthYear, lastMonthNum, era), 0, 0, 0, 0, era); + } + else + { + yearEndDate = State.SelectedDate.Date; + } + var lookupStart = yearStartDate.AddDays(-7); + var lookupEnd = yearEndDate.AddDays(7); + var eventsByDay = new Dictionary>(); + foreach (var ev in allEvents) + { + var from = ev.StartDate.Date < lookupStart ? lookupStart : ev.StartDate.Date; + // Treat a 00:00 end as ending the previous day (exclusive midnight), consistent with the + // rest of the calendar, so an event ending at midnight isn't indexed onto the next day. + var endInclusive = BitFullCalendarHelpers.GetInclusiveEndDate(ev); + var to = endInclusive > lookupEnd ? lookupEnd : endInclusive; + for (var d = from; d <= to; d = d.AddDays(1)) + { + if (!eventsByDay.TryGetValue(d, out var list)) + { + list = []; + eventsByDay[d] = list; + } + list.Add(ev); + } + } +} + +
+ @foreach (var monthDate in months) + { + var cells = BitFullCalendarHelpers.GetCalendarCells(monthDate, State.Culture); + var monthIndex = cal.GetMonth(monthDate); + var monthName = dtf.GetMonthName(monthIndex); +
+ +
+ @foreach (var wd in yearWeekDayHeaders) + { +
@(wd.Length > 2 ? wd[..2] : wd)
+ } +
+
+ @foreach (var cell in cells) + { + var cellCulturalMonth = cal.GetMonth(cell.Date); + var isCurrentMonth = cellCulturalMonth == monthIndex + && cal.GetYear(cell.Date) == culturalYear; + var isToday = cell.Date.Date == DateTime.Today; + var dayEvents = eventsByDay.TryGetValue(cell.Date.Date, out var evs) ? evs : []; + var hasEvents = isCurrentMonth && dayEvents.Count > 0; + + @if (hasEvents) + { + + } + else + { +
+ @cell.Day +
+ } + } +
+
+ } +
+ +@if (_showEventList) +{ + +} 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 new file mode 100644 index 0000000000..8743dde1c9 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/FullCalendar/Views/YearView/BitFcCalendarYearView.razor.cs @@ -0,0 +1,25 @@ +namespace Bit.BlazorUI; + +public partial class BitFcCalendarYearView +{ + [CascadingParameter] public BitFullCalendarState State { get; set; } = default!; + [Parameter] public List SingleDayEvents { get; set; } = []; + [Parameter] public List MultiDayEvents { get; set; } = []; + + private bool _showEventList; + private DateTime _eventListDate; + private List _eventListEvents = []; + + private void GoToMonth(DateTime month) + { + State.SetSelectedDate(month); + State.SetView(BitFullCalendarView.Month); + } + + private void ShowEventsForDay(DateTime date, List events) + { + _eventListDate = date; + _eventListEvents = events; + _showEventList = true; + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss index ca854411fa..24e1d480eb 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss @@ -4,6 +4,7 @@ @import "../Components/DataGrid/Pagination/BitDataGridPaginator.scss"; @import "../Components/ErrorBoundary/BitErrorBoundary.scss"; @import "../Components/Flag/BitFlag.scss"; +@import "../Components/FullCalendar/BitFullCalendar.scss"; @import "../Components/InfiniteScrolling/BitInfiniteScrolling.scss"; @import "../Components/Map/BitMap.scss"; @import "../Components/MarkdownEditor/BitMarkdownEditor.scss"; 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 new file mode 100644 index 0000000000..e7449041e7 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/FullCalendar/BitFullCalendarDemo.razor @@ -0,0 +1,188 @@ +@page "/components/fullcalendar" + + + + + + + To use this component, you need to install the + + + + nuget package, as described in the Optional steps of the + Getting started page. + + + + + The BitFullCalendar brings a complete scheduling experience to Blazor: multiple view modes + (day, week, month, year, agenda, and a resource timeline), built-in event create/edit/delete, + drag-and-drop, resize, filtering, theming that follows the Bit theme (including dark mode), + and culture-aware date rendering. + Explore a live calendar below - switch views, add events, and drag them around. + + + + +
The simplest usage of a BitFullCalendar is by passing a list of events.
+
+ +
+ + +
+ Drive initial preferences from code with the Settings parameter - 12/24-hour + time format, day start hour, badge variant, and agenda grouping. +
+
+ +
+ + +
+ Choose how overlapping event cards are arranged in the day and week views with + Settings.EventLayout. Overlap (default) cascades the cards on top of + each other, while Stack places them side by side in equal-width columns. + Switch to the Day or Week view to see the difference - pick a layout below, or toggle it + from the settings gear ("Stack overlapping events"). +
+
+ + + + +
+ +
+ + +
Customize the event card content for each view with the per-view templates.
+
+ +
+ + +
+ Supply Resources to enable the Timeline mode, where rows are resources (rooms, machines, + people) and columns are time. Drag events between rows to reassign their resource. +
+
+ +
+ + +
The OnChange callback is raised whenever a user adds, edits, or deletes an event.
+
+ +
+ Last change: @(lastChange ?? "-") +
+ + +
+ The View, Mode, and Date parameters are two-way bound, + so you can drive the calendar from outside and react to user-driven changes through + OnViewChange, OnModeChange, and OnDateChange. + The DefaultView, DefaultMode, and DefaultDate parameters + set the initial values that are used when their two-way counterparts are not bound. +
+
+
+ + + + + @if (bindingMode == BitFullCalendarMode.Event) + { + + + } + + + + + +
+ Prev day + Today + Next day +
+
+
+ +
+ View: @bindingView | Mode: @bindingMode | Date: @bindingDate.ToString("yyyy-MM-dd") + Last calendar event: @(bindingLog ?? "-") +
+ + +
+ Render the calendar with a localized culture (here Persian, fa-IR) and + override the UI strings through a customized BitFullCalendarTexts instance. + This example translates most of the available strings - toolbar, view/mode tabs, filters, + settings panel, search/agenda, dialogs, form fields, validation messages, and resource labels. +
+
+ +
+ + +
+ Hide the built-in color/attendee filters and the settings gear to provide your own + external controls or a minimal header. +
+
+ +
+
+
+ +@code { + private RenderFragment EventCard => ev => + @
+ @ev.Title + @if (!string.IsNullOrWhiteSpace(ev.Description)) + { + @ev.Description + } +
; + + private RenderFragment MonthBadge => ev => @📌 @ev.Title; +} 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 new file mode 100644 index 0000000000..4d6f470e1e --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/FullCalendar/BitFullCalendarDemo.razor.cs @@ -0,0 +1,1082 @@ +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.FullCalendar; + +public partial class BitFullCalendarDemo +{ + private readonly List componentParameters = + [ + new() + { + Name = "Culture", + Type = "CultureInfo?", + DefaultValue = "CultureInfo.CurrentUICulture", + Description = "Sets calendar/date rendering and formatting. Do not use with @rendermode=\"InteractiveServer\" - use CultureName instead.", + }, + new() + { + Name = "CultureName", + Type = "string?", + DefaultValue = "null", + Description = "Culture name shortcut (e.g. \"fa-IR\", \"ar-SA\", \"fr-FR\"). Takes precedence over Culture when both are supplied.", + }, + new() + { + Name = "Date", + Type = "DateTime", + DefaultValue = "DateTime.Today", + Description = "The currently selected (anchor) date of the calendar that determines the visible date range. (two-way bound)", + }, + new() + { + Name = "DayEventTemplate", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "Replaces the default event card content inside day-view time-grid blocks.", + LinkType = LinkType.Link, + Href = "#event-class", + }, + new() + { + Name = "DefaultDate", + Type = "DateTime?", + DefaultValue = "null", + Description = "The default selected date used initially when the Date parameter is not set. Determines the date range shown on first render.", + }, + new() + { + Name = "DefaultMode", + Type = "BitFullCalendarMode?", + DefaultValue = "null", + Description = "The default layout mode used initially when the Mode parameter is not set. Event shows the day/week/month/year/agenda views. Timeline shows a resources × time grid (requires Resources to be non-empty) and supports only the day, week, and month layouts - Year and Agenda fall back to the week layout in Timeline mode.", + LinkType = LinkType.Link, + Href = "#mode-enum", + }, + new() + { + Name = "DefaultView", + Type = "BitFullCalendarView?", + DefaultValue = "null", + Description = "The default view used initially when the View parameter is not set. In Event mode any of Day, Week, Month, Year, or Agenda apply; in Timeline mode only Day, Week, and Month are supported (Year and Agenda fall back to the week layout).", + LinkType = LinkType.Link, + Href = "#view-enum", + }, + new() + { + Name = "EventColorOptions", + Type = "IReadOnlyList?", + DefaultValue = "null", + Description = "Ordered list of event colors shown in pickers, filters, agenda headers, badges, and bullets.", + LinkType = LinkType.Link, + Href = "#color-option-class", + }, + new() + { + Name = "Events", + Type = "List?", + DefaultValue = "null", + Description = "List of calendar events to display.", + LinkType = LinkType.Link, + Href = "#event-class", + }, + new() + { + Name = "HideFilters", + Type = "bool", + DefaultValue = "false", + Description = "When true, hides the built-in color and attendee filter dropdowns. Consumers provide their own filter UI and pass pre-filtered events.", + }, + new() + { + Name = "HideSettings", + Type = "bool", + DefaultValue = "false", + Description = "When true, hides the built-in settings gear button. Settings can still be driven programmatically through the Settings parameter.", + }, + new() + { + Name = "Mode", + Type = "BitFullCalendarMode", + DefaultValue = "BitFullCalendarMode.Event", + Description = "The currently active layout mode of the calendar (Event or Timeline). Timeline requires Resources to be non-empty and only supports the Day, Week, and Month views (Year and Agenda fall back to the week layout). (two-way bound)", + LinkType = LinkType.Link, + Href = "#mode-enum", + }, + new() + { + Name = "MonthEventTemplate", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "Replaces the default event badge content inside month-view cells.", + LinkType = LinkType.Link, + Href = "#event-class", + }, + new() + { + Name = "OnAddClick", + Type = "EventCallback", + DefaultValue = "", + Description = "When assigned, the built-in add dialog is suppressed. Receives a draft event with the start/end dates pre-filled from the calendar's selected date and configured start hour (or the clicked slot when adding from a time grid).", + LinkType = LinkType.Link, + Href = "#event-class", + }, + new() + { + Name = "OnChange", + Type = "EventCallback", + DefaultValue = "", + Description = "Raised when a user adds, edits, or deletes an event (Kind: Add, Edit, Delete; Source: Dialog, Drag, Resize).", + LinkType = LinkType.Link, + Href = "#change-args-class", + }, + new() + { + Name = "OnDateChange", + Type = "EventCallback", + DefaultValue = "", + Description = "Raised when the visible date range changes after prev/next/today navigation or a view switch. Payload includes inclusive Start/End and the active View.", + LinkType = LinkType.Link, + Href = "#date-change-args-class", + }, + new() + { + Name = "OnEventClick", + Type = "EventCallback", + DefaultValue = "", + Description = "When assigned, the built-in event details dialog is suppressed when an event is clicked. Receives the clicked event.", + LinkType = LinkType.Link, + Href = "#event-class", + }, + new() + { + Name = "OnModeChange", + Type = "EventCallback", + DefaultValue = "", + Description = "Raised when the active layout mode changes (switching between the Event and Timeline tabs).", + LinkType = LinkType.Link, + Href = "#mode-enum", + }, + new() + { + Name = "OnViewChange", + Type = "EventCallback", + DefaultValue = "", + Description = "Raised when the active view changes (selecting a view tab or navigating from the year overview into a month).", + LinkType = LinkType.Link, + Href = "#view-enum", + }, + new() + { + Name = "Resources", + Type = "IReadOnlyList?", + DefaultValue = "null", + Description = "Resources displayed as rows in Timeline mode. Each event's Resource property is matched against the resource Id. The Timeline mode tab is hidden when null or empty.", + LinkType = LinkType.Link, + Href = "#resource-class", + }, + new() + { + Name = "Settings", + Type = "BitFullCalendarSettings", + DefaultValue = "new()", + Description = "Initial preferences - 12/24-hour time format, badge variant, day start hour, agenda grouping, and event card layout.", + LinkType = LinkType.Link, + Href = "#settings-class", + }, + new() + { + Name = "Texts", + Type = "BitFullCalendarTexts", + DefaultValue = "new()", + Description = "Custom UI strings for labels, placeholders, action buttons, aria labels, and validation messages.", + LinkType = LinkType.Link, + Href = "#texts-class", + }, + new() + { + Name = "TimelineEventTemplate", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "Replaces the default event card content inside Timeline mode blocks.", + LinkType = LinkType.Link, + Href = "#event-class", + }, + new() + { + Name = "View", + Type = "BitFullCalendarView", + DefaultValue = "BitFullCalendarView.Month", + Description = "The currently active view of the calendar (Day, Week, Month, Year, Agenda). In Timeline mode only Day, Week, and Month are supported (Year and Agenda fall back to the week layout). (two-way bound)", + LinkType = LinkType.Link, + Href = "#view-enum", + }, + new() + { + Name = "WeekEventTemplate", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "Replaces the default event card content inside week-view time-grid blocks.", + LinkType = LinkType.Link, + Href = "#event-class", + }, + ]; + + private readonly List componentSubEnums = + [ + new() + { + Id = "mode-enum", + Name = "BitFullCalendarMode", + Description = "Top-level layout mode for the calendar surface.", + Items = + [ + new() { Name = "Event", Description = "Day, week, month, year, and agenda views on a date grid.", Value = "0" }, + new() { Name = "Timeline", Description = "Resource-centric layout (resources × time grid); requires Resources. Supports only the day, week, and month layouts.", Value = "1" }, + ] + }, + new() + { + Id = "view-enum", + Name = "BitFullCalendarView", + Description = "Active view inside the current mode. In Timeline mode only Day, Week, and Month are supported; Year and Agenda fall back to the week layout.", + Items = + [ + new() { Name = "Day", Description = "Single-day detailed view.", Value = "0" }, + new() { Name = "Week", Description = "7-day view with hourly time slots.", Value = "1" }, + new() { Name = "Month", Description = "Month grid with multi-day events.", Value = "2" }, + new() { Name = "Year", Description = "12-month overview. Event mode only - falls back to the week layout in Timeline mode.", Value = "3" }, + new() { Name = "Agenda", Description = "Searchable list grouped by date or color. Event mode only - falls back to the week layout in Timeline mode.", Value = "4" }, + ] + }, + new() + { + Id = "badge-variant-enum", + Name = "BitFullCalendarBadgeVariant", + Description = "Badge display style in the month view.", + Items = + [ + new() { Name = "Colored", Description = "Colored badge.", Value = "0" }, + new() { Name = "Dot", Description = "Colored dot bullet.", Value = "1" }, + ] + }, + new() + { + Id = "agenda-group-by-enum", + Name = "BitFullCalendarAgendaGroupBy", + Description = "How events are grouped in the agenda view.", + Items = + [ + new() { Name = "Date", Description = "Group agenda items by date.", Value = "0" }, + new() { Name = "Color", Description = "Group agenda items by color.", Value = "1" }, + ] + }, + new() + { + Id = "event-layout-enum", + Name = "BitFullCalendarEventLayout", + Description = "How overlapping event cards are positioned in the day and week views.", + Items = + [ + new() { Name = "Overlap", Description = "Overlapping cards cascade on top of each other, each offset to the right and extending to the column edge.", Value = "0" }, + new() { Name = "Stack", Description = "Overlapping cards are placed side by side in equal-width columns with no overlap.", Value = "1" }, + ] + }, + new() + { + Id = "change-kind-enum", + Name = "BitFullCalendarChangeKind", + Description = "Identifies the kind of change applied to a calendar event.", + Items = + [ + new() { Name = "Add", Description = "An event was added.", Value = "0" }, + new() { Name = "Edit", Description = "An event was edited.", Value = "1" }, + new() { Name = "Delete", Description = "An event was deleted.", Value = "2" }, + ] + }, + new() + { + Id = "change-source-enum", + Name = "BitFullCalendarChangeSource", + Description = "Identifies where a calendar event change originated from in the UI.", + Items = + [ + new() { Name = "Dialog", Description = "From the add/edit dialog.", Value = "0" }, + new() { Name = "Drag", Description = "From a drag-and-drop move.", Value = "1" }, + new() { Name = "Resize", Description = "From resizing an event block.", Value = "2" }, + ] + }, + ]; + + + + private readonly List componentSubClasses = + [ + new() + { + Id = "event-class", + Title = "BitFullCalendarEvent", + Description = "Represents a single calendar event rendered across the day, week, month, year, agenda, and timeline views.", + Parameters = + [ + new() { Name = "Id", Type = "string", DefaultValue = "string.Empty", Description = "Unique identifier of the event." }, + new() { Name = "Title", Type = "string", DefaultValue = "string.Empty", Description = "Event title shown on the event card, badge, and dialogs." }, + new() { Name = "Description", Type = "string", DefaultValue = "string.Empty", Description = "Event description shown in the details and add/edit dialogs." }, + new() { Name = "StartDate", Type = "DateTime", DefaultValue = "", Description = "Start date and time of the event." }, + new() { Name = "EndDate", Type = "DateTime", DefaultValue = "", Description = "End date and time of the event." }, + new() { Name = "Color", Type = "string", DefaultValue = "BitFullCalendarColorScheme.FallbackColorId", Description = "Identifier of the color matching a BitFullCalendarColorOption.Id from the configured palette.", LinkType = LinkType.Link, Href = "#color-option-class" }, + new() { Name = "Attendees", Type = "List", DefaultValue = "[]", Description = "People attending the event.", LinkType = LinkType.Link, Href = "#attendee-class" }, + new() { Name = "Resource", Type = "string?", DefaultValue = "null", Description = "Optional resource identifier linking this event to a BitFullCalendarResource. Used by the timeline view to place the event on the matching resource row. null or empty means the event is unassigned.", LinkType = LinkType.Link, Href = "#resource-class" }, + new() { Name = "IsSingleDay", Type = "bool", DefaultValue = "", Description = "Read-only. True when the event starts and ends on the same date." }, + new() { Name = "IsMultiDay", Type = "bool", DefaultValue = "", Description = "Read-only. True when the event spans more than one date." }, + new() { Name = "Duration", Type = "TimeSpan", DefaultValue = "", Description = "Read-only. The difference between EndDate and StartDate." }, + new() { Name = "Data", Type = "object?", DefaultValue = "null", Description = "Optional consumer-defined payload available to templates and click handlers." }, + ] + }, + new() + { + Id = "attendee-class", + Title = "BitFullCalendarAttendee", + Description = "Represents a person attending an event, shown in the event details and add/edit dialogs.", + Parameters = + [ + new() { Name = "FirstName", Type = "string", DefaultValue = "string.Empty", Description = "First name of the attendee." }, + new() { Name = "LastName", Type = "string", DefaultValue = "string.Empty", Description = "Last name of the attendee." }, + new() { Name = "Id", Type = "string?", DefaultValue = "null", Description = "Optional identifier of the attendee." }, + new() { Name = "FullName", Type = "string", DefaultValue = "", Description = "Read-only. The combined and trimmed first and last name." }, + new() { Name = "Initials", Type = "string", DefaultValue = "", Description = "Read-only. The uppercased initials derived from the first and last name." }, + ] + }, + new() + { + Id = "color-option-class", + Title = "BitFullCalendarColorOption", + Description = "Describes one selectable event color shown in the picker, filters, agenda headers, badges, bullets, and swatches. Events reference a color through its Id.", + Parameters = + [ + new() { Name = "Id", Type = "string", DefaultValue = "string.Empty", Description = "Stable identifier of the color matched against BitFullCalendarEvent.Color (case-insensitive). Use a short, slug-style value such as \"blue\" or \"skyblue\"." }, + new() { Name = "Title", Type = "string", DefaultValue = "string.Empty", Description = "Display label shown in pickers, filters, agenda headers, and event details. Used as-is with no localization." }, + new() { Name = "Value", Type = "string", DefaultValue = "string.Empty", Description = "CSS color value used for swatches, bullets, badge accents, and chip surfaces. Any valid CSS color such as hex, rgb(), hsl(), or a named color. Badge background, border, and text contrast tints are derived from this value at runtime." }, + new() { Name = "Defaults", Type = "static IReadOnlyList", DefaultValue = "", Description = "Built-in palette (blue, green, red, yellow, purple, orange) used when EventColorOptions is null or empty." }, + ] + }, + new() + { + Id = "resource-class", + Title = "BitFullCalendarResource", + Description = "A schedulable resource shown as a row in the resource timeline view (for example a meeting room, a person, or a piece of equipment). Events are linked to a resource through BitFullCalendarEvent.Resource matching Id.", + Parameters = + [ + new() { Name = "Id", Type = "string", DefaultValue = "", Description = "Required. Stable, non-blank identifier matched against BitFullCalendarEvent.Resource. Cannot be null, empty, or whitespace - a blank id is rejected at assignment time." }, + new() { Name = "Title", Type = "string", DefaultValue = "string.Empty", Description = "Display name for the resource (for example \"Bay Wing\", \"Alice Johnson\", \"Meeting Room 3B\")." }, + new() { Name = "Subtitle", Type = "string?", DefaultValue = "null", Description = "Optional subtitle shown below the resource title (for example building or department)." }, + new() { Name = "Data", Type = "object?", DefaultValue = "null", Description = "Optional consumer-defined payload available to templates and click handlers." }, + ] + }, + new() + { + Id = "settings-class", + Title = "BitFullCalendarSettings", + Description = "Configuration settings applied as initial defaults when the component mounts, or whenever a new instance is assigned to the Settings parameter.", + Parameters = + [ + new() { Name = "Use24HourFormat", Type = "bool", DefaultValue = "true", Description = "Uses 24-hour time format instead of 12-hour (AM/PM)." }, + new() { Name = "BadgeVariant", Type = "BitFullCalendarBadgeVariant", DefaultValue = "BitFullCalendarBadgeVariant.Colored", Description = "Badge display style in the month view.", LinkType = LinkType.Link, Href = "#badge-variant-enum" }, + new() { Name = "StartOfDayHour", Type = "int", DefaultValue = "8", Description = "Hour (0–16) at which the day/week time grid begins." }, + new() { Name = "AgendaModeGroupBy", Type = "BitFullCalendarAgendaGroupBy", DefaultValue = "BitFullCalendarAgendaGroupBy.Date", Description = "How events are grouped in the agenda view.", LinkType = LinkType.Link, Href = "#agenda-group-by-enum" }, + new() { Name = "EventLayout", Type = "BitFullCalendarEventLayout", DefaultValue = "BitFullCalendarEventLayout.Overlap", Description = "How overlapping event cards are positioned in the day and week views.", LinkType = LinkType.Link, Href = "#event-layout-enum" }, + new() { Name = "ShowDayViewCalendar", Type = "bool", DefaultValue = "true", Description = "Renders the mini calendar shown in the day view sidebar." }, + ] + }, + new() + { + Id = "change-args-class", + Title = "BitFullCalendarChangeEventArgs", + Description = "Provides details about a user-applied calendar event change, passed to the OnChange callback.", + Parameters = + [ + new() { Name = "Event", Type = "BitFullCalendarEvent", DefaultValue = "", Description = "The current event snapshot after the change for Add/Edit, or the removed event snapshot for Delete.", LinkType = LinkType.Link, Href = "#event-class" }, + new() { Name = "Kind", Type = "BitFullCalendarChangeKind", DefaultValue = "", Description = "The change type that occurred (Add, Edit, Delete).", LinkType = LinkType.Link, Href = "#change-kind-enum" }, + new() { Name = "OldEvent", Type = "BitFullCalendarEvent?", DefaultValue = "null", Description = "The event snapshot before the change for Edit/Delete. Null for Add.", LinkType = LinkType.Link, Href = "#event-class" }, + new() { Name = "Source", Type = "BitFullCalendarChangeSource", DefaultValue = "", Description = "The UI source that triggered this change (Dialog, Drag, Resize).", LinkType = LinkType.Link, Href = "#change-source-enum" }, + ] + }, + new() + { + Id = "date-change-args-class", + Title = "BitFullCalendarDateChangeEventArgs", + Description = "Provides details about a date range change, fired when the user navigates (prev/next/today) or switches views. Passed to the OnDateChange callback.", + Parameters = + [ + new() { Name = "Start", Type = "DateTime", DefaultValue = "", Description = "Start of the visible date range (inclusive)." }, + new() { Name = "End", Type = "DateTime", DefaultValue = "", Description = "End of the visible date range (inclusive)." }, + new() { Name = "View", Type = "BitFullCalendarView", DefaultValue = "", Description = "The active calendar view when the change occurred.", LinkType = LinkType.Link, Href = "#view-enum" }, + ] + }, + new() + { + Id = "texts-class", + Title = "BitFullCalendarTexts", + Description = "Custom UI strings for labels, placeholders, action buttons, aria labels, and validation messages. Used for localization and customization of all built-in text.", + Parameters = + [ + new() { Name = "ViewDay", Type = "string", DefaultValue = "\"Day\"", Description = "Label for the day view tab." }, + new() { Name = "ViewWeek", Type = "string", DefaultValue = "\"Week\"", Description = "Label for the week view tab." }, + new() { Name = "ViewMonth", Type = "string", DefaultValue = "\"Month\"", Description = "Label for the month view tab." }, + new() { Name = "ViewYear", Type = "string", DefaultValue = "\"Year\"", Description = "Label for the year view tab." }, + new() { Name = "ViewAgenda", Type = "string", DefaultValue = "\"Agenda\"", Description = "Label for the agenda view tab." }, + new() { Name = "ModeEvent", Type = "string", DefaultValue = "\"Events\"", Description = "Label for the event mode tab." }, + new() { Name = "ModeTimeline", Type = "string", DefaultValue = "\"Timeline\"", Description = "Label for the timeline mode tab." }, + new() { Name = "BitFcTodayButton", Type = "string", DefaultValue = "\"Today\"", Description = "Label for the today navigation button." }, + new() { Name = "AddEventButton", Type = "string", DefaultValue = "\"Add Event\"", Description = "Label for the add event button." }, + new() { Name = "AddEventHoverHint", Type = "string", DefaultValue = "\"Add event\"", Description = "Tooltip shown when hovering the add event affordance." }, + new() { Name = "PreviousButtonTitle", Type = "string", DefaultValue = "\"Previous\"", Description = "Title for the previous navigation button." }, + new() { Name = "NextButtonTitle", Type = "string", DefaultValue = "\"Next\"", Description = "Title for the next navigation button." }, + new() { Name = "PreviousMonthAriaLabel", Type = "string", DefaultValue = "\"Previous month\"", Description = "Aria label for the mini calendar previous month navigation button." }, + new() { Name = "NextMonthAriaLabel", Type = "string", DefaultValue = "\"Next month\"", Description = "Aria label for the mini calendar next month navigation button." }, + new() { Name = "SettingsButtonTitle", Type = "string", DefaultValue = "\"Settings\"", Description = "Title for the settings gear button." }, + new() { Name = "FilterByColorAriaLabel", Type = "string", DefaultValue = "\"Filter events by color\"", Description = "Aria label for the color filter dropdown." }, + new() { Name = "FilterByPersonAriaLabel", Type = "string", DefaultValue = "\"Filter events by person in current view\"", Description = "Aria label for the attendee filter dropdown." }, + new() { Name = "AllColorsOption", Type = "string", DefaultValue = "\"All colors\"", Description = "Option text for clearing the color filter." }, + new() { Name = "AllPeopleOption", Type = "string", DefaultValue = "\"All people\"", Description = "Option text for clearing the attendee filter." }, + new() { Name = "UnnamedAttendee", Type = "string", DefaultValue = "\"(Unnamed)\"", Description = "Fallback text for an attendee with no name." }, + new() { Name = "CalendarSettingsLabel", Type = "string", DefaultValue = "\"Calendar settings\"", Description = "Heading for the settings panel." }, + new() { Name = "DotBadgeLabel", Type = "string", DefaultValue = "\"Dot badge\"", Description = "Label for the dot badge setting toggle." }, + new() { Name = "TwentyFourHourFormatLabel", Type = "string", DefaultValue = "\"24-hour format\"", Description = "Label for the 24-hour format setting toggle." }, + new() { Name = "DayStartsAtLabel", Type = "string", DefaultValue = "\"Day starts at\"", Description = "Label for the day start hour setting." }, + new() { Name = "HourSuffix", Type = "string", DefaultValue = "\"h\"", Description = "Suffix appended to hour values in the settings." }, + new() { Name = "AgendaGroupByLabel", Type = "string", DefaultValue = "\"Agenda group by\"", Description = "Label for the agenda grouping setting." }, + new() { Name = "AgendaGroupByDate", Type = "string", DefaultValue = "\"Date\"", Description = "Option text for grouping the agenda by date." }, + new() { Name = "AgendaGroupByColor", Type = "string", DefaultValue = "\"Color\"", Description = "Option text for grouping the agenda by color." }, + new() { Name = "StackedEventsLabel", Type = "string", DefaultValue = "\"Stack overlapping events\"", Description = "Label for the overlapping events layout toggle." }, + new() { Name = "ShowDayViewCalendarLabel", Type = "string", DefaultValue = "\"Show calendar in day view\"", Description = "Label for the day view mini calendar toggle." }, + new() { Name = "WeekMobileWarning", Type = "string", DefaultValue = "\"Weekly view is not recommended...\"", Description = "Warning shown when using the week view on small devices." }, + new() { Name = "HappeningNowTitle", Type = "string", DefaultValue = "\"Happening now\"", Description = "Title for the happening-now indicator." }, + new() { Name = "NoAppointmentsNow", Type = "string", DefaultValue = "\"No appointments at the moment\"", Description = "Text shown when there are no current appointments." }, + new() { Name = "SearchEventsPlaceholder", Type = "string", DefaultValue = "\"Search events...\"", Description = "Placeholder for the agenda search box." }, + new() { Name = "NoEventsFound", Type = "string", DefaultValue = "\"No events found.\"", Description = "Text shown when a search returns no events." }, + new() { Name = "EventListTitleFormat", Type = "string", DefaultValue = "\"Events on {0}\"", Description = "Format template for the event list dialog title; {0} is the formatted date." }, + new() { Name = "EventListCountFormat", Type = "string", DefaultValue = "\"{0} event(s)\"", Description = "Format template for the event count in the event list dialog; {0} is the count." }, + new() { Name = "MoreEventsFormat", Type = "string", DefaultValue = "\"+{0} more\"", Description = "Format template for the \"+N more\" affordance in month cells; {0} is the hidden-event count." }, + new() { Name = "AddEventDialogTitle", Type = "string", DefaultValue = "\"Add New Event\"", Description = "Title for the add event dialog." }, + new() { Name = "EditEventDialogTitle", Type = "string", DefaultValue = "\"Edit Event\"", Description = "Title for the edit event dialog." }, + new() { Name = "AddEventDialogSubtitle", Type = "string", DefaultValue = "\"Create a new event for your calendar.\"", Description = "Subtitle for the add event dialog." }, + new() { Name = "EditEventDialogSubtitle", Type = "string", DefaultValue = "\"Modify your existing event.\"", Description = "Subtitle for the edit event dialog." }, + new() { Name = "CloseAriaLabel", Type = "string", DefaultValue = "\"Close\"", Description = "Aria label for the dialog close button." }, + new() { Name = "CloseButton", Type = "string", DefaultValue = "\"Close\"", Description = "Label for the close button." }, + new() { Name = "CancelButton", Type = "string", DefaultValue = "\"Cancel\"", Description = "Label for the cancel button." }, + new() { Name = "EditButton", Type = "string", DefaultValue = "\"Edit\"", Description = "Label for the edit button." }, + new() { Name = "DeleteButton", Type = "string", DefaultValue = "\"Delete\"", Description = "Label for the delete button." }, + new() { Name = "CreateEventButton", Type = "string", DefaultValue = "\"Create Event\"", Description = "Label for the create event button." }, + new() { Name = "SaveChangesButton", Type = "string", DefaultValue = "\"Save Changes\"", Description = "Label for the save changes button." }, + new() { Name = "TitleLabel", Type = "string", DefaultValue = "\"Title\"", Description = "Label for the event title field." }, + new() { Name = "EventTitlePlaceholder", Type = "string", DefaultValue = "\"Event title\"", Description = "Placeholder for the event title field." }, + new() { Name = "StartDateTimeLabel", Type = "string", DefaultValue = "\"Start Date & Time\"", Description = "Label for the start date and time field." }, + new() { Name = "EndDateTimeLabel", Type = "string", DefaultValue = "\"End Date & Time\"", Description = "Label for the end date and time field." }, + new() { Name = "ColorLabel", Type = "string", DefaultValue = "\"Color\"", Description = "Label for the color field." }, + new() { Name = "EventColorAriaLabel", Type = "string", DefaultValue = "\"Event color\"", Description = "Aria label for the color picker." }, + new() { Name = "DescriptionLabel", Type = "string", DefaultValue = "\"Description\"", Description = "Label for the description field." }, + new() { Name = "EventDescriptionPlaceholder", Type = "string", DefaultValue = "\"Event description\"", Description = "Placeholder for the description field." }, + new() { Name = "AttendeesLabel", Type = "string", DefaultValue = "\"Attendees\"", Description = "Label for the attendees field." }, + new() { Name = "NoAttendeesText", Type = "string", DefaultValue = "\"No attendees\"", Description = "Text shown when an event has no attendees." }, + new() { Name = "FirstNamePlaceholder", Type = "string", DefaultValue = "\"First name\"", Description = "Placeholder for the attendee first name field." }, + new() { Name = "LastNamePlaceholder", Type = "string", DefaultValue = "\"Last name\"", Description = "Placeholder for the attendee last name field." }, + new() { Name = "IdOptionalPlaceholder", Type = "string", DefaultValue = "\"ID (optional)\"", Description = "Placeholder for the optional attendee id field." }, + new() { Name = "AddButton", Type = "string", DefaultValue = "\"Add\"", Description = "Label for the add attendee button." }, + new() { Name = "RemoveAttendeeAriaLabel", Type = "string", DefaultValue = "\"Remove attendee\"", Description = "Aria label for the remove attendee button on an attendee chip." }, + new() { Name = "StartDateLabel", Type = "string", DefaultValue = "\"Start Date\"", Description = "Label for the start date in the event details." }, + new() { Name = "EndDateLabel", Type = "string", DefaultValue = "\"End Date\"", Description = "Label for the end date in the event details." }, + new() { Name = "AtWord", Type = "string", DefaultValue = "\"at\"", Description = "Connector word between date and time in the event details." }, + new() { Name = "ValidationTitleRequired", Type = "string", DefaultValue = "\"Title is required\"", Description = "Validation message when the title is empty." }, + new() { Name = "ValidationDescriptionRequired", Type = "string", DefaultValue = "\"Description is required\"", Description = "Validation message when the description is empty." }, + new() { Name = "ValidationEndAfterStart", Type = "string", DefaultValue = "\"End date must be after start date\"", Description = "Validation message when the end date is not after the start date." }, + new() { Name = "ValidationAttendeeNameRequired", Type = "string", DefaultValue = "\"First name or last name is required\"", Description = "Validation message when an attendee has no name." }, + new() { Name = "ResizePreviewAriaLabel", Type = "string", DefaultValue = "\"New time range\"", Description = "Aria label for the resize preview indicator." }, + new() { Name = "ResourceLabel", Type = "string", DefaultValue = "\"Resource\"", Description = "Label for the resource field in the add/edit dialog." }, + new() { Name = "ResourceColumnHeader", Type = "string", DefaultValue = "\"Resource\"", Description = "Header for the resource column in the timeline view." }, + new() { Name = "NoResourceLabel", Type = "string", DefaultValue = "\"Unassigned\"", Description = "Label for events not assigned to a resource." }, + new() { Name = "NoResourceOption", Type = "string", DefaultValue = "\"(none)\"", Description = "Option text for clearing the resource assignment." }, + new() { Name = "NoResourcesMessage", Type = "string", DefaultValue = "\"No resources to display.\"", Description = "Message shown when there are no resources in the timeline view." }, + new() { Name = "GetViewLabel(BitFullCalendarView)", Type = "string", DefaultValue = "", Description = "Method that returns the localized label for the given view." }, + new() { Name = "GetModeLabel(BitFullCalendarMode)", Type = "string", DefaultValue = "", Description = "Method that returns the localized label for the given mode." }, + ] + }, + ]; + + + + private readonly List basicEvents = CreateEvents(); + private readonly List settingsEvents = CreateEvents(); + private readonly List templateEvents = CreateEvents(); + private readonly List changeEvents = CreateEvents(); + private readonly List localizationEvents = CreateEvents(); + private readonly List layoutEvents = CreateEvents(); + + private BitFullCalendarEventLayout layoutMode = BitFullCalendarEventLayout.Stack; + private BitFullCalendarSettings layoutSettings = new() + { + EventLayout = BitFullCalendarEventLayout.Stack + }; + + private void HandleLayoutChange(BitFullCalendarEventLayout layout) + { + layoutMode = layout; + // Assign a new settings instance so the calendar re-applies the layout (the + // Settings parameter is re-applied only when a new reference is supplied). + layoutSettings = new() { EventLayout = layout }; + } + + private readonly BitFullCalendarSettings settings = new() + { + Use24HourFormat = false, + StartOfDayHour = 7, + BadgeVariant = BitFullCalendarBadgeVariant.Dot + }; + + private readonly BitFullCalendarTexts persianTexts = new() + { + // View & mode tabs + ViewDay = "روز", + ViewWeek = "هفته", + ViewMonth = "ماه", + ViewYear = "سال", + ViewAgenda = "برنامه", + ModeEvent = "رویدادها", + ModeTimeline = "خط زمانی", + + // Toolbar + BitFcTodayButton = "امروز", + AddEventButton = "افزودن رویداد", + AddEventHoverHint = "افزودن رویداد", + PreviousButtonTitle = "قبلی", + NextButtonTitle = "بعدی", + PreviousMonthAriaLabel = "ماه قبل", + NextMonthAriaLabel = "ماه بعد", + SettingsButtonTitle = "تنظیمات", + + // Filters + FilterByColorAriaLabel = "فیلتر رویدادها بر اساس رنگ", + FilterByPersonAriaLabel = "فیلتر رویدادها بر اساس شخص در نمای فعلی", + AllColorsOption = "همه رنگ‌ها", + AllPeopleOption = "همه افراد", + UnnamedAttendee = "(بدون نام)", + + // Settings panel + CalendarSettingsLabel = "تنظیمات تقویم", + DotBadgeLabel = "نشان نقطه‌ای", + TwentyFourHourFormatLabel = "قالب ۲۴ ساعته", + DayStartsAtLabel = "شروع روز از", + HourSuffix = "ساعت", + AgendaGroupByLabel = "گروه‌بندی برنامه بر اساس", + AgendaGroupByDate = "تاریخ", + AgendaGroupByColor = "رنگ", + StackedEventsLabel = "چیدمان رویدادهای هم‌پوشان", + ShowDayViewCalendarLabel = "نمایش تقویم در نمای روزانه", + + // Messages + WeekMobileWarning = "نمای هفتگی برای دستگاه‌های کوچک توصیه نمی‌شود. لطفاً از رایانه استفاده کنید یا نمای روزانه را انتخاب کنید.", + HappeningNowTitle = "در حال انجام", + NoAppointmentsNow = "در حال حاضر قراری وجود ندارد", + + // Search & agenda + SearchEventsPlaceholder = "جستجوی رویدادها...", + NoEventsFound = "رویدادی یافت نشد.", + EventListTitleFormat = "رویدادهای {0}", + EventListCountFormat = "{0} رویداد", + MoreEventsFormat = "+{0} بیشتر", + + // Dialogs + AddEventDialogTitle = "افزودن رویداد جدید", + EditEventDialogTitle = "ویرایش رویداد", + AddEventDialogSubtitle = "یک رویداد جدید برای تقویم خود ایجاد کنید.", + EditEventDialogSubtitle = "رویداد موجود خود را تغییر دهید.", + + // Buttons + CloseAriaLabel = "بستن", + CloseButton = "بستن", + CancelButton = "انصراف", + EditButton = "ویرایش", + DeleteButton = "حذف", + CreateEventButton = "ایجاد رویداد", + SaveChangesButton = "ذخیره تغییرات", + + // Event form fields + TitleLabel = "عنوان", + EventTitlePlaceholder = "عنوان رویداد", + StartDateTimeLabel = "تاریخ و زمان شروع", + EndDateTimeLabel = "تاریخ و زمان پایان", + ColorLabel = "رنگ", + EventColorAriaLabel = "رنگ رویداد", + DescriptionLabel = "توضیحات", + EventDescriptionPlaceholder = "توضیحات رویداد", + AttendeesLabel = "شرکت‌کنندگان", + NoAttendeesText = "بدون شرکت‌کننده", + FirstNamePlaceholder = "نام", + LastNamePlaceholder = "نام خانوادگی", + IdOptionalPlaceholder = "شناسه (اختیاری)", + AddButton = "افزودن", + RemoveAttendeeAriaLabel = "حذف شرکت‌کننده", + + // Event details + StartDateLabel = "تاریخ شروع", + EndDateLabel = "تاریخ پایان", + AtWord = "در", + + // Validation + ValidationTitleRequired = "عنوان الزامی است", + ValidationDescriptionRequired = "توضیحات الزامی است", + ValidationEndAfterStart = "تاریخ پایان باید بعد از تاریخ شروع باشد", + ValidationAttendeeNameRequired = "نام یا نام خانوادگی الزامی است", + + // Resources & timeline + ResizePreviewAriaLabel = "بازه زمانی جدید", + ResourceLabel = "منبع", + ResourceColumnHeader = "منبع", + NoResourceLabel = "تخصیص‌نیافته", + NoResourceOption = "(هیچ‌کدام)", + NoResourcesMessage = "منبعی برای نمایش وجود ندارد." + }; + + private readonly List resources = + [ + new() { Id = "room-bay", Title = "HQ - Bay Wing", Subtitle = "Headquarters" }, + new() { Id = "room-garden", Title = "The Garden", Subtitle = "Headquarters" }, + new() { Id = "room-war", Title = "War Room (B1)", Subtitle = "Basement" }, + ]; + + private readonly List resourceEvents = CreateResourceEvents(); + + private readonly List bindingEvents = CreateResourceEvents(); + private BitFullCalendarView bindingView = BitFullCalendarView.Week; + private BitFullCalendarMode _bindingMode = BitFullCalendarMode.Event; + private BitFullCalendarMode bindingMode + { + get => _bindingMode; + set + { + _bindingMode = value; + // Timeline mode only supports Day/Week/Month; coerce an unsupported view (Year/Agenda) + // back to a supported one so the bound View can't drift out of sync with the rendered view. + if (value == BitFullCalendarMode.Timeline && bindingView is BitFullCalendarView.Year or BitFullCalendarView.Agenda) + bindingView = BitFullCalendarView.Week; + } + } + private DateTime bindingDate = DateTime.Today; + private string? bindingLog; + + private void HandleViewChange(BitFullCalendarView view) => bindingLog = $"View changed to {view}"; + + private void HandleModeChange(BitFullCalendarMode mode) => bindingLog = $"Mode changed to {mode}"; + + private void HandleDateChange(BitFullCalendarDateChangeEventArgs args) + => bindingLog = $"Range {args.Start:yyyy-MM-dd} → {args.End:yyyy-MM-dd} ({args.View})"; + + private string? lastChange; + + private Task HandleChange(BitFullCalendarChangeEventArgs args) + { + lastChange = $"{args.Kind} ({args.Source}): {args.Event.Title}"; + + // Persist the change into our backing list so it stays in sync with the calendar's internal + // state. The calendar copies Events into its own store, so without applying the add/edit/delete + // here a later re-render would re-sync this stale list and discard the user's change. + switch (args.Kind) + { + case BitFullCalendarChangeKind.Add: + changeEvents.Add(args.Event); + break; + case BitFullCalendarChangeKind.Edit: + var index = changeEvents.FindIndex(e => e.Id == args.Event.Id); + if (index >= 0) + changeEvents[index] = args.Event; + else + changeEvents.Add(args.Event); + break; + case BitFullCalendarChangeKind.Delete: + changeEvents.RemoveAll(e => e.Id == args.Event.Id); + break; + } + + return InvokeAsync(StateHasChanged); + } + + private static List CreateEvents() + { + var today = DateTime.Today; + var id = 0; + return + [ + new() { Id = (++id).ToString(), Title = "Team Standup", Description = "Daily sync with engineering.", StartDate = today.AddHours(9), EndDate = today.AddHours(9).AddMinutes(45), Color = "blue" }, + new() { Id = (++id).ToString(), Title = "Design Review", Description = "Dashboard mockups v2.", StartDate = today.AddHours(10), EndDate = today.AddHours(11), Color = "purple" }, + new() { Id = (++id).ToString(), Title = "1:1 with Manager", Description = "Career and sprint check-in.", StartDate = today.AddHours(10).AddMinutes(30), EndDate = today.AddHours(11).AddMinutes(15), Color = "yellow" }, + new() { Id = (++id).ToString(), Title = "Lunch with Client", Description = "Q3 roadmap discussion.", StartDate = today.AddHours(12), EndDate = today.AddHours(13).AddMinutes(30), Color = "green" }, + new() { Id = (++id).ToString(), Title = "Sprint Planning", Description = "Next sprint goals and capacity.", StartDate = today.AddHours(14), EndDate = today.AddHours(15).AddMinutes(30), Color = "orange" }, + new() { Id = (++id).ToString(), Title = "Code Review", Description = "Auth module PRs.", StartDate = today.AddHours(16), EndDate = today.AddHours(17), Color = "red" }, + new() { Id = (++id).ToString(), Title = "Tech Conference", Description = "Keynotes and workshops.", StartDate = today.AddDays(1).AddHours(9), EndDate = today.AddDays(3).AddHours(17), Color = "blue" }, + new() { Id = (++id).ToString(), Title = "Client Onboarding", Description = "Platform walkthrough.", StartDate = today.AddDays(1).AddHours(10), EndDate = today.AddDays(1).AddHours(11).AddMinutes(30), Color = "yellow" }, + new() { Id = (++id).ToString(), Title = "Architecture Review", Description = "Migration plan.", StartDate = today.AddDays(2).AddHours(14), EndDate = today.AddDays(2).AddHours(16), Color = "red" }, + new() { Id = (++id).ToString(), Title = "Company Retreat", Description = "Strategy and team building.", StartDate = today.AddDays(5), EndDate = today.AddDays(7).AddHours(16), Color = "purple" }, + new() { Id = (++id).ToString(), Title = "Quarterly Review", Description = "Company-wide QBR.", StartDate = today.AddDays(-3).AddHours(10), EndDate = today.AddDays(-3).AddHours(12), Color = "red" }, + new() { Id = (++id).ToString(), Title = "Product Demo", Description = "Stakeholder walkthrough.", StartDate = today.AddDays(-2).AddHours(14), EndDate = today.AddDays(-2).AddHours(15), Color = "orange" }, + ]; + } + + private static List CreateResourceEvents() + { + var today = DateTime.Today; + var id = 100; + return + [ + new() { Id = (++id).ToString(), Title = "Design Review", StartDate = today.AddHours(10), EndDate = today.AddHours(11), Resource = "room-bay", Color = "purple" }, + new() { Id = (++id).ToString(), Title = "Standup", StartDate = today.AddHours(9), EndDate = today.AddHours(9).AddMinutes(30), Resource = "room-garden", Color = "blue" }, + new() { Id = (++id).ToString(), Title = "Incident Bridge", StartDate = today.AddHours(13), EndDate = today.AddHours(15), Resource = "room-war", Color = "red" }, + new() { Id = (++id).ToString(), Title = "Workshop", StartDate = today.AddHours(14), EndDate = today.AddHours(16), Resource = "room-bay", Color = "orange" }, + ]; + } + + + + private const string eventsCode = @" + private readonly List events = CreateEvents(); + + private static List CreateEvents() + { + var today = DateTime.Today; + var id = 0; + return + [ + new() { Id = (++id).ToString(), Title = ""Team Standup"", Description = ""Daily sync with engineering."", StartDate = today.AddHours(9), EndDate = today.AddHours(9).AddMinutes(45), Color = ""blue"" }, + new() { Id = (++id).ToString(), Title = ""Design Review"", Description = ""Dashboard mockups v2."", StartDate = today.AddHours(10), EndDate = today.AddHours(11), Color = ""purple"" }, + new() { Id = (++id).ToString(), Title = ""1:1 with Manager"", Description = ""Career and sprint check-in."", StartDate = today.AddHours(10).AddMinutes(30), EndDate = today.AddHours(11).AddMinutes(15), Color = ""yellow"" }, + new() { Id = (++id).ToString(), Title = ""Lunch with Client"", Description = ""Q3 roadmap discussion."", StartDate = today.AddHours(12), EndDate = today.AddHours(13).AddMinutes(30), Color = ""green"" }, + new() { Id = (++id).ToString(), Title = ""Sprint Planning"", Description = ""Next sprint goals and capacity."", StartDate = today.AddHours(14), EndDate = today.AddHours(15).AddMinutes(30), Color = ""orange"" }, + new() { Id = (++id).ToString(), Title = ""Code Review"", Description = ""Auth module PRs."", StartDate = today.AddHours(16), EndDate = today.AddHours(17), Color = ""red"" }, + new() { Id = (++id).ToString(), Title = ""Tech Conference"", Description = ""Keynotes and workshops."", StartDate = today.AddDays(1).AddHours(9), EndDate = today.AddDays(3).AddHours(17), Color = ""blue"" }, + new() { Id = (++id).ToString(), Title = ""Client Onboarding"", Description = ""Platform walkthrough."", StartDate = today.AddDays(1).AddHours(10), EndDate = today.AddDays(1).AddHours(11).AddMinutes(30), Color = ""yellow"" }, + new() { Id = (++id).ToString(), Title = ""Architecture Review"", Description = ""Migration plan."", StartDate = today.AddDays(2).AddHours(14), EndDate = today.AddDays(2).AddHours(16), Color = ""red"" }, + new() { Id = (++id).ToString(), Title = ""Company Retreat"", Description = ""Strategy and team building."", StartDate = today.AddDays(5), EndDate = today.AddDays(7).AddHours(16), Color = ""purple"" }, + new() { Id = (++id).ToString(), Title = ""Quarterly Review"", Description = ""Company-wide QBR."", StartDate = today.AddDays(-3).AddHours(10), EndDate = today.AddDays(-3).AddHours(12), Color = ""red"" }, + new() { Id = (++id).ToString(), Title = ""Product Demo"", Description = ""Stakeholder walkthrough."", StartDate = today.AddDays(-2).AddHours(14), EndDate = today.AddDays(-2).AddHours(15), Color = ""orange"" }, + ]; + }"; + + private readonly string example1RazorCode = @" + +@code {" + eventsCode + @" +}"; + + private readonly string example2RazorCode = @" + +@code { + private readonly BitFullCalendarSettings settings = new() + { + Use24HourFormat = false, + StartOfDayHour = 7, + BadgeVariant = BitFullCalendarBadgeVariant.Dot + }; +" + eventsCode + @" +}"; + + private readonly string example4RazorCode = @" + +@code { + private RenderFragment EventCard => ev => + @
+ @ev.Title + @if (!string.IsNullOrWhiteSpace(ev.Description)) + { + @ev.Description + } +
; + + private RenderFragment MonthBadge => ev => @📌 @ev.Title; +" + eventsCode + @" +}"; + + private readonly string example5RazorCode = @" + +@code { + private readonly List resources = + [ + new() { Id = ""room-bay"", Title = ""HQ - Bay Wing"", Subtitle = ""Headquarters"" }, + new() { Id = ""room-garden"", Title = ""The Garden"", Subtitle = ""Headquarters"" }, + new() { Id = ""room-war"", Title = ""War Room (B1)"", Subtitle = ""Basement"" }, + ]; + + private readonly List events = CreateResourceEvents(); + + private static List CreateResourceEvents() + { + var today = DateTime.Today; + var id = 100; + return + [ + new() { Id = (++id).ToString(), Title = ""Design Review"", StartDate = today.AddHours(10), EndDate = today.AddHours(11), Resource = ""room-bay"", Color = ""purple"" }, + new() { Id = (++id).ToString(), Title = ""Standup"", StartDate = today.AddHours(9), EndDate = today.AddHours(9).AddMinutes(30), Resource = ""room-garden"", Color = ""blue"" }, + new() { Id = (++id).ToString(), Title = ""Incident Bridge"", StartDate = today.AddHours(13), EndDate = today.AddHours(15), Resource = ""room-war"", Color = ""red"" }, + new() { Id = (++id).ToString(), Title = ""Workshop"", StartDate = today.AddHours(14), EndDate = today.AddHours(16), Resource = ""room-bay"", Color = ""orange"" }, + ]; + } +}"; + + private readonly string example6RazorCode = @" +
+Last change: @(lastChange ?? ""-"") + +@code { + private string? lastChange; + + private Task HandleChange(BitFullCalendarChangeEventArgs args) + { + lastChange = $""{args.Kind} ({args.Source}): {args.Event.Title}""; + + // Keep the bound list in sync with the calendar's internal state so changes are not lost on re-render. + switch (args.Kind) + { + case BitFullCalendarChangeKind.Add: + events.Add(args.Event); + break; + case BitFullCalendarChangeKind.Edit: + var index = events.FindIndex(e => e.Id == args.Event.Id); + if (index >= 0) + events[index] = args.Event; + else + events.Add(args.Event); + break; + case BitFullCalendarChangeKind.Delete: + events.RemoveAll(e => e.Id == args.Event.Id); + break; + } + + return InvokeAsync(StateHasChanged); + } +" + eventsCode + @" +}"; + + private readonly string example8RazorCode = @" + +@code { + private readonly BitFullCalendarTexts persianTexts = new() + { + // View & mode tabs + ViewDay = ""روز"", + ViewWeek = ""هفته"", + ViewMonth = ""ماه"", + ViewYear = ""سال"", + ViewAgenda = ""برنامه"", + ModeEvent = ""رویدادها"", + ModeTimeline = ""خط زمانی"", + + // Toolbar + BitFcTodayButton = ""امروز"", + AddEventButton = ""افزودن رویداد"", + AddEventHoverHint = ""افزودن رویداد"", + PreviousButtonTitle = ""قبلی"", + NextButtonTitle = ""بعدی"", + PreviousMonthAriaLabel = ""ماه قبل"", + NextMonthAriaLabel = ""ماه بعد"", + SettingsButtonTitle = ""تنظیمات"", + + // Filters + FilterByColorAriaLabel = ""فیلتر رویدادها بر اساس رنگ"", + FilterByPersonAriaLabel = ""فیلتر رویدادها بر اساس شخص در نمای فعلی"", + AllColorsOption = ""همه رنگ‌ها"", + AllPeopleOption = ""همه افراد"", + UnnamedAttendee = ""(بدون نام)"", + + // Settings panel + CalendarSettingsLabel = ""تنظیمات تقویم"", + DotBadgeLabel = ""نشان نقطه‌ای"", + TwentyFourHourFormatLabel = ""قالب ۲۴ ساعته"", + DayStartsAtLabel = ""شروع روز از"", + HourSuffix = ""ساعت"", + AgendaGroupByLabel = ""گروه‌بندی برنامه بر اساس"", + AgendaGroupByDate = ""تاریخ"", + AgendaGroupByColor = ""رنگ"", + StackedEventsLabel = ""چیدمان رویدادهای هم‌پوشان"", + ShowDayViewCalendarLabel = ""نمایش تقویم در نمای روزانه"", + + // Messages + WeekMobileWarning = ""نمای هفتگی برای دستگاه‌های کوچک توصیه نمی‌شود. لطفاً از رایانه استفاده کنید یا نمای روزانه را انتخاب کنید."", + HappeningNowTitle = ""در حال انجام"", + NoAppointmentsNow = ""در حال حاضر قراری وجود ندارد"", + + // Search & agenda + SearchEventsPlaceholder = ""جستجوی رویدادها..."", + NoEventsFound = ""رویدادی یافت نشد."", + EventListTitleFormat = ""رویدادهای {0}"", + EventListCountFormat = ""{0} رویداد"", + MoreEventsFormat = ""+{0} بیشتر"", + + // Dialogs + AddEventDialogTitle = ""افزودن رویداد جدید"", + EditEventDialogTitle = ""ویرایش رویداد"", + AddEventDialogSubtitle = ""یک رویداد جدید برای تقویم خود ایجاد کنید."", + EditEventDialogSubtitle = ""رویداد موجود خود را تغییر دهید."", + + // Buttons + CloseAriaLabel = ""بستن"", + CloseButton = ""بستن"", + CancelButton = ""انصراف"", + EditButton = ""ویرایش"", + DeleteButton = ""حذف"", + CreateEventButton = ""ایجاد رویداد"", + SaveChangesButton = ""ذخیره تغییرات"", + + // Event form fields + TitleLabel = ""عنوان"", + EventTitlePlaceholder = ""عنوان رویداد"", + StartDateTimeLabel = ""تاریخ و زمان شروع"", + EndDateTimeLabel = ""تاریخ و زمان پایان"", + ColorLabel = ""رنگ"", + EventColorAriaLabel = ""رنگ رویداد"", + DescriptionLabel = ""توضیحات"", + EventDescriptionPlaceholder = ""توضیحات رویداد"", + AttendeesLabel = ""شرکت‌کنندگان"", + NoAttendeesText = ""بدون شرکت‌کننده"", + FirstNamePlaceholder = ""نام"", + LastNamePlaceholder = ""نام خانوادگی"", + IdOptionalPlaceholder = ""شناسه (اختیاری)"", + AddButton = ""افزودن"", + RemoveAttendeeAriaLabel = ""حذف شرکت‌کننده"", + + // Event details + StartDateLabel = ""تاریخ شروع"", + EndDateLabel = ""تاریخ پایان"", + AtWord = ""در"", + + // Validation + ValidationTitleRequired = ""عنوان الزامی است"", + ValidationDescriptionRequired = ""توضیحات الزامی است"", + ValidationEndAfterStart = ""تاریخ پایان باید بعد از تاریخ شروع باشد"", + ValidationAttendeeNameRequired = ""نام یا نام خانوادگی الزامی است"", + + // Resources & timeline + ResizePreviewAriaLabel = ""بازه زمانی جدید"", + ResourceLabel = ""منبع"", + ResourceColumnHeader = ""منبع"", + NoResourceLabel = ""تخصیص‌نیافته"", + NoResourceOption = ""(هیچ‌کدام)"", + NoResourcesMessage = ""منبعی برای نمایش وجود ندارد."" + }; +" + eventsCode + @" +}"; + + private readonly string example9RazorCode = @" + +@code {" + eventsCode + @" +}"; + + private readonly string example7RazorCode = @""" + TValue=""BitFullCalendarView"" + @bind-Value=""bindingView""> + + + + @if (bindingMode == BitFullCalendarMode.Event) + { + + + } + +"" + TValue=""BitFullCalendarMode"" + @bind-Value=""bindingMode""> + + + + bindingDate = bindingDate.AddDays(-1)"">Prev day + bindingDate = DateTime.Today"">Today + bindingDate = bindingDate.AddDays(1)"">Next day + + + +View: @bindingView | Mode: @bindingMode | Date: @bindingDate.ToString(""yyyy-MM-dd"") +Last calendar event: @(bindingLog ?? ""-"") + +@code { + private BitFullCalendarView bindingView = BitFullCalendarView.Week; + private BitFullCalendarMode _bindingMode = BitFullCalendarMode.Event; + private BitFullCalendarMode bindingMode + { + get => _bindingMode; + set + { + _bindingMode = value; + if (value == BitFullCalendarMode.Timeline && bindingView is BitFullCalendarView.Year or BitFullCalendarView.Agenda) + bindingView = BitFullCalendarView.Week; + } + } + private DateTime bindingDate = DateTime.Today; + private string? bindingLog; + + private void HandleViewChange(BitFullCalendarView view) => bindingLog = $""View changed to {view}""; + + private void HandleModeChange(BitFullCalendarMode mode) => bindingLog = $""Mode changed to {mode}""; + + private void HandleDateChange(BitFullCalendarDateChangeEventArgs args) + => bindingLog = $""Range {args.Start:yyyy-MM-dd} → {args.End:yyyy-MM-dd} ({args.View})""; + + private readonly List resources = + [ + new() { Id = ""room-bay"", Title = ""HQ - Bay Wing"", Subtitle = ""Headquarters"" }, + new() { Id = ""room-garden"", Title = ""The Garden"", Subtitle = ""Headquarters"" }, + new() { Id = ""room-war"", Title = ""War Room (B1)"", Subtitle = ""Basement"" }, + ]; + + private readonly List events = CreateResourceEvents(); + + private static List CreateResourceEvents() + { + var today = DateTime.Today; + var id = 100; + return + [ + new() { Id = (++id).ToString(), Title = ""Design Review"", StartDate = today.AddHours(10), EndDate = today.AddHours(11), Resource = ""room-bay"", Color = ""purple"" }, + new() { Id = (++id).ToString(), Title = ""Standup"", StartDate = today.AddHours(9), EndDate = today.AddHours(9).AddMinutes(30), Resource = ""room-garden"", Color = ""blue"" }, + new() { Id = (++id).ToString(), Title = ""Incident Bridge"", StartDate = today.AddHours(13), EndDate = today.AddHours(15), Resource = ""room-war"", Color = ""red"" }, + new() { Id = (++id).ToString(), Title = ""Workshop"", StartDate = today.AddHours(14), EndDate = today.AddHours(16), Resource = ""room-bay"", Color = ""orange"" }, + ]; + } +}"; + + private readonly string example3RazorCode = @""" + TValue=""BitFullCalendarEventLayout"" + Value=""layoutMode"" + OnChange=""HandleLayoutChange""> + + + +
+ + +@code { + private BitFullCalendarEventLayout layoutMode = BitFullCalendarEventLayout.Stack; + private BitFullCalendarSettings layoutSettings = new() + { + EventLayout = BitFullCalendarEventLayout.Stack + }; + + private void HandleLayoutChange(BitFullCalendarEventLayout layout) + { + layoutMode = layout; + // Assign a new settings instance so the calendar re-applies the layout (the + // Settings parameter is re-applied only when a new reference is supplied). + layoutSettings = new() { EventLayout = layout }; + } +" + eventsCode + @" +}"; +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Home/ComponentsSection.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Home/ComponentsSection.razor index 1610817236..27c4ceb3c9 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Home/ComponentsSection.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Home/ComponentsSection.razor @@ -59,6 +59,9 @@ Slider + + TagsInput + TextField @@ -194,6 +197,9 @@ Modal + + ModalService + Panel @@ -274,11 +280,16 @@ Flag + + FullCalendar + featured + InfiniteScrolling Map + featured MarkdownEditor @@ -289,9 +300,6 @@ MessageBox - - ModalService - NavPanel @@ -304,11 +312,15 @@ ProModal + + ProModalService + ProPanel RichTextEditor + featured diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs index 9224d74fa2..360f92dc6d 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs @@ -161,6 +161,7 @@ public partial class MainLayout new() { Text = "DataGrid", Url = "/components/datagrid", AdditionalUrls = ["/components/data-grid"] }, new() { Text = "ErrorBoundary", Url = "/components/errorboundary" }, new() { Text = "Flag", Url = "/components/flag" }, + new() { Text = "FullCalendar", Url = "/components/fullcalendar", Description = "Scheduler" }, new() { Text = "InfiniteScrolling", Url = "/components/infinitescrolling" }, new() { Text = "Map", Url = "/components/map" }, new() { Text = "MarkdownEditor", Url = "/components/markdowneditor", Description = "MdEditor" },