From 2babb1caa4a5b5f3c2d1357870d692ca13067355 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:08:45 +0000 Subject: [PATCH 01/14] Initial plan From 8efbfdcd5eaa92def44e83ed4ed88d7c7a050a83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:14:30 +0000 Subject: [PATCH 02/14] Add Calendar component implementation with basic structure Co-authored-by: csharpfritz <78577+csharpfritz@users.noreply.github.com> --- src/BlazorWebFormsComponents/Calendar.razor | 81 ++++ .../Calendar.razor.cs | 435 ++++++++++++++++++ 2 files changed, 516 insertions(+) create mode 100644 src/BlazorWebFormsComponents/Calendar.razor create mode 100644 src/BlazorWebFormsComponents/Calendar.razor.cs diff --git a/src/BlazorWebFormsComponents/Calendar.razor b/src/BlazorWebFormsComponents/Calendar.razor new file mode 100644 index 00000000..c8bc574e --- /dev/null +++ b/src/BlazorWebFormsComponents/Calendar.razor @@ -0,0 +1,81 @@ +@inherits BaseStyledComponent + +@if (Visible) +{ + + @if (ShowTitle) + { + + @if (ShowNextPrevMonth && SelectionMode == "DayWeekMonth") + { + + } + @if (ShowNextPrevMonth) + { + + } + + @if (ShowNextPrevMonth) + { + + } + + } + @if (ShowDayHeader) + { + + @if (SelectionMode == "DayWeek" || SelectionMode == "DayWeekMonth") + { + + } + @foreach (var day in GetDayHeaders()) + { + + } + + } + @foreach (var week in GetCalendarWeeks()) + { + + @if (SelectionMode == "DayWeek" || SelectionMode == "DayWeekMonth") + { + + } + @foreach (var date in week) + { + var dayArgs = CreateDayRenderArgs(date); + + } + + } +
+ @SelectMonthText + + @((MarkupString)PrevMonthText) + + @GetTitleText() + + @((MarkupString)NextMonthText) +
+ @GetDayName(day) +
+ @((MarkupString)SelectWeekText) + + @if (dayArgs.IsSelectable) + { + + @date.Day + + } + else + { + @date.Day + } +
+} diff --git a/src/BlazorWebFormsComponents/Calendar.razor.cs b/src/BlazorWebFormsComponents/Calendar.razor.cs new file mode 100644 index 00000000..759f8892 --- /dev/null +++ b/src/BlazorWebFormsComponents/Calendar.razor.cs @@ -0,0 +1,435 @@ +using BlazorWebFormsComponents.Enums; +using Microsoft.AspNetCore.Components; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; + +namespace BlazorWebFormsComponents +{ + public partial class Calendar : BaseStyledComponent + { + private DateTime _visibleMonth; + private readonly HashSet _selectedDays = new HashSet(); + private bool _initialized = false; + + public Calendar() + { + _visibleMonth = DateTime.Today; + SelectedDate = DateTime.MinValue; + } + + #region Properties + + /// + /// Gets or sets the selected date. + /// + [Parameter] + public DateTime SelectedDate { get; set; } + + /// + /// Event raised when the selection changes. + /// + [Parameter] + public EventCallback SelectedDateChanged { get; set; } + + /// + /// Gets the collection of selected dates for multi-selection. + /// + public IReadOnlyCollection SelectedDates => _selectedDays.ToList().AsReadOnly(); + + /// + /// Gets or sets the month to display. + /// + [Parameter] + public DateTime VisibleDate + { + get => _visibleMonth; + set + { + if (_visibleMonth != value) + { + var oldDate = _visibleMonth; + _visibleMonth = value; + if (_initialized && OnVisibleMonthChanged.HasDelegate) + { + _ = OnVisibleMonthChanged.InvokeAsync(new CalendarMonthChangedArgs + { + CurrentMonth = value, + PreviousMonth = oldDate + }); + } + } + } + } + + /// + /// Gets or sets the selection mode: None, Day, DayWeek, or DayWeekMonth. + /// + [Parameter] + public string SelectionMode { get; set; } = "Day"; + + /// + /// Event raised when a day is rendered, allowing customization. + /// + [Parameter] + public EventCallback OnDayRender { get; set; } + + /// + /// Event raised when the selection changes. + /// + [Parameter] + public EventCallback OnSelectionChanged { get; set; } + + /// + /// Event raised when the visible month changes. + /// + [Parameter] + public EventCallback OnVisibleMonthChanged { get; set; } + + /// + /// Shows or hides the title section. + /// + [Parameter] + public bool ShowTitle { get; set; } = true; + + /// + /// Shows or hides grid lines around days. + /// + [Parameter] + public bool ShowGridLines { get; set; } = false; + + /// + /// Shows or hides the day names row. + /// + [Parameter] + public bool ShowDayHeader { get; set; } = true; + + /// + /// Shows or hides next/previous month navigation. + /// + [Parameter] + public bool ShowNextPrevMonth { get; set; } = true; + + /// + /// Format for displaying day names. + /// + [Parameter] + public string DayNameFormat { get; set; } = "Short"; + + /// + /// Format for the title. + /// + [Parameter] + public string TitleFormat { get; set; } = "MonthYear"; + + /// + /// Text for next month link. + /// + [Parameter] + public string NextMonthText { get; set; } = ">"; + + /// + /// Text for previous month link. + /// + [Parameter] + public string PrevMonthText { get; set; } = "<"; + + /// + /// Text for selecting the entire week. + /// + [Parameter] + public string SelectWeekText { get; set; } = ">>"; + + /// + /// Text for selecting the entire month. + /// + [Parameter] + public string SelectMonthText { get; set; } = ">>"; + + /// + /// First day of the week. + /// + [Parameter] + public DayOfWeek FirstDayOfWeek { get; set; } = DayOfWeek.Sunday; + + /// + /// Cell padding for the table. + /// + [Parameter] + public int CellPadding { get; set; } = 2; + + /// + /// Cell spacing for the table. + /// + [Parameter] + public int CellSpacing { get; set; } = 0; + + /// + /// Tooltip text. + /// + [Parameter] + public string ToolTip { get; set; } + + // Style properties for different day types + [Parameter] + public string TitleStyleCss { get; set; } + + [Parameter] + public string DayHeaderStyleCss { get; set; } + + [Parameter] + public string DayStyleCss { get; set; } + + [Parameter] + public string TodayDayStyleCss { get; set; } + + [Parameter] + public string SelectedDayStyleCss { get; set; } + + [Parameter] + public string OtherMonthDayStyleCss { get; set; } + + [Parameter] + public string WeekendDayStyleCss { get; set; } + + [Parameter] + public string NextPrevStyleCss { get; set; } + + [Parameter] + public string SelectorStyleCss { get; set; } + + #endregion + + protected override void OnInitialized() + { + base.OnInitialized(); + _initialized = true; + + // Initialize selection if SelectedDate was provided + if (SelectedDate != DateTime.MinValue) + { + _selectedDays.Add(SelectedDate.Date); + } + } + + private string GetTableStyle() + { + var baseStyle = Style ?? ""; + if (ShowGridLines) + { + baseStyle += "border-collapse:collapse;"; + } + return string.IsNullOrWhiteSpace(baseStyle) ? null : baseStyle; + } + + private string GetBorder() + { + return ShowGridLines ? "1" : null; + } + + private string GetTitleText() + { + var format = TitleFormat == "Month" ? "MMMM" : "MMMM yyyy"; + return _visibleMonth.ToString(format, CultureInfo.CurrentCulture); + } + + private List GetDayHeaders() + { + var days = new List(); + var current = FirstDayOfWeek; + for (var i = 0; i < 7; i++) + { + days.Add(current); + current = (DayOfWeek)(((int)current + 1) % 7); + } + return days; + } + + private string GetDayName(DayOfWeek day) + { + return DayNameFormat switch + { + "Full" => CultureInfo.CurrentCulture.DateTimeFormat.GetDayName(day), + "FirstLetter" => CultureInfo.CurrentCulture.DateTimeFormat.GetDayName(day).Substring(0, 1), + "FirstTwoLetters" => CultureInfo.CurrentCulture.DateTimeFormat.GetDayName(day).Substring(0, 2), + "Shortest" => CultureInfo.CurrentCulture.DateTimeFormat.GetShortestDayName(day), + _ => CultureInfo.CurrentCulture.DateTimeFormat.GetAbbreviatedDayName(day) + }; + } + + private List> GetCalendarWeeks() + { + var weeks = new List>(); + var firstOfMonth = new DateTime(_visibleMonth.Year, _visibleMonth.Month, 1); + + // Find the first day to display (may be from previous month) + var startDate = firstOfMonth; + while (startDate.DayOfWeek != FirstDayOfWeek) + { + startDate = startDate.AddDays(-1); + } + + // Build 6 weeks of days + for (var week = 0; week < 6; week++) + { + var weekDays = new List(); + for (var day = 0; day < 7; day++) + { + weekDays.Add(startDate); + startDate = startDate.AddDays(1); + } + weeks.Add(weekDays); + } + + return weeks; + } + + private async Task HandlePreviousMonth() + { + VisibleDate = _visibleMonth.AddMonths(-1); + await InvokeAsync(StateHasChanged); + } + + private async Task HandleNextMonth() + { + VisibleDate = _visibleMonth.AddMonths(1); + await InvokeAsync(StateHasChanged); + } + + private async Task HandleDayClick(DateTime date) + { + if (SelectionMode == "None") return; + + _selectedDays.Clear(); + _selectedDays.Add(date.Date); + SelectedDate = date.Date; + + if (SelectedDateChanged.HasDelegate) + { + await SelectedDateChanged.InvokeAsync(date.Date); + } + + if (OnSelectionChanged.HasDelegate) + { + await OnSelectionChanged.InvokeAsync(); + } + + await InvokeAsync(StateHasChanged); + } + + private async Task HandleWeekClick(List week) + { + if (SelectionMode != "DayWeek" && SelectionMode != "DayWeekMonth") return; + + _selectedDays.Clear(); + foreach (var day in week) + { + _selectedDays.Add(day.Date); + } + SelectedDate = week[0].Date; + + if (SelectedDateChanged.HasDelegate) + { + await SelectedDateChanged.InvokeAsync(week[0].Date); + } + + if (OnSelectionChanged.HasDelegate) + { + await OnSelectionChanged.InvokeAsync(); + } + + await InvokeAsync(StateHasChanged); + } + + private async Task HandleMonthClick() + { + if (SelectionMode != "DayWeekMonth") return; + + _selectedDays.Clear(); + var firstOfMonth = new DateTime(_visibleMonth.Year, _visibleMonth.Month, 1); + var lastOfMonth = firstOfMonth.AddMonths(1).AddDays(-1); + + for (var d = firstOfMonth; d <= lastOfMonth; d = d.AddDays(1)) + { + _selectedDays.Add(d.Date); + } + SelectedDate = firstOfMonth.Date; + + if (SelectedDateChanged.HasDelegate) + { + await SelectedDateChanged.InvokeAsync(firstOfMonth.Date); + } + + if (OnSelectionChanged.HasDelegate) + { + await OnSelectionChanged.InvokeAsync(); + } + + await InvokeAsync(StateHasChanged); + } + + private string GetDayCellCss(DateTime date) + { + var isToday = date.Date == DateTime.Today; + var isSelected = _selectedDays.Contains(date.Date); + var isOtherMonth = date.Month != _visibleMonth.Month; + var isWeekend = date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday; + + // Priority order for styling + if (isSelected && !string.IsNullOrEmpty(SelectedDayStyleCss)) + return SelectedDayStyleCss; + if (isToday && !string.IsNullOrEmpty(TodayDayStyleCss)) + return TodayDayStyleCss; + if (isOtherMonth && !string.IsNullOrEmpty(OtherMonthDayStyleCss)) + return OtherMonthDayStyleCss; + if (isWeekend && !string.IsNullOrEmpty(WeekendDayStyleCss)) + return WeekendDayStyleCss; + + return DayStyleCss; + } + + private CalendarDayRenderArgs CreateDayRenderArgs(DateTime date) + { + var args = new CalendarDayRenderArgs + { + Date = date, + IsSelectable = SelectionMode != "None", + IsWeekend = date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday, + IsToday = date.Date == DateTime.Today, + IsOtherMonth = date.Month != _visibleMonth.Month, + IsSelected = _selectedDays.Contains(date.Date) + }; + + if (OnDayRender.HasDelegate) + { + _ = OnDayRender.InvokeAsync(args); + } + + return args; + } + } + + /// + /// Event arguments for day rendering events. + /// + public class CalendarDayRenderArgs : EventArgs + { + public DateTime Date { get; set; } + public bool IsSelectable { get; set; } + public bool IsWeekend { get; set; } + public bool IsToday { get; set; } + public bool IsOtherMonth { get; set; } + public bool IsSelected { get; set; } + } + + /// + /// Event arguments for month change events. + /// + public class CalendarMonthChangedArgs : EventArgs + { + public DateTime CurrentMonth { get; set; } + public DateTime PreviousMonth { get; set; } + } +} From 127d1d709b913c8eff7ba5a37f39c15d6a01c9c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:16:16 +0000 Subject: [PATCH 03/14] Add unit tests for Calendar component - all 19 tests passing Co-authored-by: csharpfritz <78577+csharpfritz@users.noreply.github.com> --- .../Calendar/Properties.razor | 91 +++++++++++++++++++ .../Calendar/Rendering.razor | 83 +++++++++++++++++ .../Calendar/Selection.razor | 84 +++++++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 src/BlazorWebFormsComponents.Test/Calendar/Properties.razor create mode 100644 src/BlazorWebFormsComponents.Test/Calendar/Rendering.razor create mode 100644 src/BlazorWebFormsComponents.Test/Calendar/Selection.razor diff --git a/src/BlazorWebFormsComponents.Test/Calendar/Properties.razor b/src/BlazorWebFormsComponents.Test/Calendar/Properties.razor new file mode 100644 index 00000000..98fce5bc --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/Calendar/Properties.razor @@ -0,0 +1,91 @@ +@inherits BlazorWebFormsTestContext +@using Shouldly + +@code { + [Fact] + public void Calendar_DayNameFormatShort_DisplaysAbbreviatedNames() + { + // Arrange & Act + var cut = Render(@); + + // Assert + var headers = cut.FindAll("th"); + headers.Count.ShouldBeGreaterThan(0); + // Short format should be less than full names (e.g., "Sun" vs "Sunday") + headers.Any(h => h.TextContent.Length <= 3).ShouldBeTrue(); + } + + [Fact] + public void Calendar_DayNameFormatFull_DisplaysFullNames() + { + // Arrange & Act + var cut = Render(@); + + // Assert + var headers = cut.FindAll("th"); + headers.Any(h => h.TextContent.Length > 5).ShouldBeTrue(); + } + + [Fact] + public void Calendar_TitleFormatMonth_DisplaysMonthOnly() + { + // Arrange + var testDate = new DateTime(2024, 6, 15); + + // Act + var cut = Render(@); + + // Assert + var title = cut.Find("td[align='center']"); + title.TextContent.ShouldBe("June"); + title.TextContent.ShouldNotContain("2024"); + } + + [Fact] + public void Calendar_TitleFormatMonthYear_DisplaysMonthAndYear() + { + // Arrange + var testDate = new DateTime(2024, 6, 15); + + // Act + var cut = Render(@); + + // Assert + var title = cut.Find("td[align='center']"); + title.TextContent.ShouldContain("June"); + title.TextContent.ShouldContain("2024"); + } + + [Fact] + public void Calendar_CustomNavigationText_RendersCustomText() + { + // Arrange & Act + var cut = Render(@); + + // Assert + var navLinks = cut.FindAll("td a"); + navLinks.Count.ShouldBeGreaterThan(0); + } + + [Fact] + public void Calendar_WithToolTip_RendersTitle() + { + // Arrange & Act + var cut = Render(@); + + // Assert + var table = cut.Find("table"); + table.GetAttribute("title").ShouldBe("Select a date"); + } + + [Fact] + public void Calendar_WithClientID_RendersId() + { + // Arrange & Act + var cut = Render(@); + + // Assert + var table = cut.Find("table"); + table.Id.ShouldContain("myCalendar"); + } +} diff --git a/src/BlazorWebFormsComponents.Test/Calendar/Rendering.razor b/src/BlazorWebFormsComponents.Test/Calendar/Rendering.razor new file mode 100644 index 00000000..8a739ec3 --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/Calendar/Rendering.razor @@ -0,0 +1,83 @@ +@inherits BlazorWebFormsTestContext +@using Shouldly + +@code { + [Fact] + public void Calendar_DefaultState_RendersCurrentMonth() + { + // Arrange & Act + var cut = Render(@); + + // Assert + cut.Find("table").ShouldNotBeNull(); + var title = cut.Find("td[align='center']"); + title.TextContent.ShouldContain(DateTime.Today.ToString("MMMM")); + } + + [Fact] + public void Calendar_WithVisibleDate_RendersSpecifiedMonth() + { + // Arrange + var testDate = new DateTime(2024, 6, 15); + + // Act + var cut = Render(@); + + // Assert + var title = cut.Find("td[align='center']"); + title.TextContent.ShouldContain("June 2024"); + } + + [Fact] + public void Calendar_ShowTitleFalse_HidesTitleRow() + { + // Arrange & Act + var cut = Render(@); + + // Assert + // No title cell should be present + cut.FindAll("td[align='center']").Where(td => td.TextContent.Contains("2024") || td.TextContent.Contains("2026")).ShouldBeEmpty(); + } + + [Fact] + public void Calendar_ShowDayHeaderFalse_HidesDayNames() + { + // Arrange & Act + var cut = Render(@); + + // Assert + cut.FindAll("th").ShouldBeEmpty(); + } + + [Fact] + public void Calendar_ShowGridLinesTrue_RendersTableBorder() + { + // Arrange & Act + var cut = Render(@); + + // Assert + var table = cut.Find("table"); + table.GetAttribute("border").ShouldBe("1"); + } + + [Fact] + public void Calendar_WithCssClass_AppliesClass() + { + // Arrange & Act + var cut = Render(@); + + // Assert + var table = cut.Find("table"); + table.ClassList.ShouldContain("custom-calendar"); + } + + [Fact] + public void Calendar_VisibleFalse_DoesNotRender() + { + // Arrange & Act + var cut = Render(@); + + // Assert + cut.FindAll("table").ShouldBeEmpty(); + } +} diff --git a/src/BlazorWebFormsComponents.Test/Calendar/Selection.razor b/src/BlazorWebFormsComponents.Test/Calendar/Selection.razor new file mode 100644 index 00000000..56c7d901 --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/Calendar/Selection.razor @@ -0,0 +1,84 @@ +@inherits BlazorWebFormsTestContext +@using Shouldly + +@code { + [Fact] + public void Calendar_DayClick_SelectsDate() + { + // Arrange + DateTime selectedDate = DateTime.MinValue; + var testDate = new DateTime(2024, 6, 15); + var cut = Render(@); + + // Act + var dayLink = cut.FindAll("td a").First(a => a.TextContent == "15"); + dayLink.Click(); + + // Assert + selectedDate.ShouldBe(new DateTime(2024, 6, 15)); + } + + [Fact] + public void Calendar_SelectionModeNone_DaysNotSelectable() + { + // Arrange + var testDate = new DateTime(2024, 6, 15); + var cut = Render(@); + + // Act & Assert + // Days rendered as non-clickable spans when selection is disabled + var daySpans = cut.FindAll("td span"); + daySpans.ShouldNotBeEmpty(); + } + + [Fact] + public void Calendar_OnSelectionChanged_InvokesCallback() + { + // Arrange + var callbackInvoked = false; + var testDate = new DateTime(2024, 6, 15); + var cut = Render(@); + + // Act + var dayLink = cut.FindAll("td a").First(a => a.TextContent == "10"); + dayLink.Click(); + + // Assert + callbackInvoked.ShouldBeTrue(); + } + + [Fact] + public void Calendar_PreviousMonthClick_ChangesMonth() + { + // Arrange + var testDate = new DateTime(2024, 6, 15); + var cut = Render(@); + + // Act + // Find the previous month link - it's the first nav link in the title row + var prevLink = cut.FindAll("a").First(a => a.TextContent.Contains("<") || a.TextContent.Contains("<")); + prevLink.Click(); + + // Assert + var title = cut.Find("td[align='center']"); + title.TextContent.ShouldContain("May"); + } + + [Fact] + public void Calendar_NextMonthClick_ChangesMonth() + { + // Arrange + var testDate = new DateTime(2024, 6, 15); + var cut = Render(@); + + // Act + // Find the next month link - it's the last nav link that contains ">" or ">" + var nextLink = cut.FindAll("a").Last(a => a.TextContent.Contains(">") || a.TextContent.Contains(">")); + nextLink.Click(); + + // Assert + var title = cut.Find("td[align='center']"); + title.TextContent.ShouldContain("July"); + } +} From bdcb2f1d856c32f59e55b6d043a7422bc2c1ccba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:18:06 +0000 Subject: [PATCH 04/14] Add Calendar sample page and documentation Co-authored-by: csharpfritz <78577+csharpfritz@users.noreply.github.com> --- docs/EditorControls/Calendar.md | 380 ++++++++++++++++++ mkdocs.yml | 1 + .../Pages/ControlSamples/Calendar/Index.razor | 134 ++++++ 3 files changed, 515 insertions(+) create mode 100644 docs/EditorControls/Calendar.md create mode 100644 samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Calendar/Index.razor diff --git a/docs/EditorControls/Calendar.md b/docs/EditorControls/Calendar.md new file mode 100644 index 00000000..042c7e22 --- /dev/null +++ b/docs/EditorControls/Calendar.md @@ -0,0 +1,380 @@ +# Calendar + +The Calendar component provides a Blazor implementation of the ASP.NET Web Forms Calendar control, enabling users to select dates and navigate through months. + +Original Microsoft documentation: https://docs.microsoft.com/en-us/dotnet/api/system.web.ui.webcontrols.calendar?view=netframework-4.8 + +## Features Supported in Blazor + +- `SelectedDate` property for single date selection +- `SelectedDates` collection for multi-date selection (read-only) +- `VisibleDate` property to set the displayed month +- `SelectionMode` (None, Day, DayWeek, DayWeekMonth) +- Two-way binding with `@bind-SelectedDate` +- Month/year navigation with next/previous buttons +- `OnSelectionChanged` event when a date is selected +- `OnDayRender` event for customizing individual days +- `OnVisibleMonthChanged` event when the month changes +- Customizable display options: + - `ShowTitle` - Show/hide title bar + - `ShowDayHeader` - Show/hide day name headers + - `ShowGridLines` - Show/hide grid borders + - `ShowNextPrevMonth` - Show/hide navigation +- Day name formatting (`DayNameFormat`: Full, Short, FirstLetter, FirstTwoLetters, Shortest) +- Title formatting (`TitleFormat`: Month, MonthYear) +- Customizable navigation text (`NextMonthText`, `PrevMonthText`, `SelectWeekText`, `SelectMonthText`) +- First day of week configuration (`FirstDayOfWeek`) +- Cell padding and spacing options +- Style attributes for different day types: + - `TitleStyleCss` - Title bar style + - `DayHeaderStyleCss` - Day header style + - `DayStyleCss` - Regular day style + - `TodayDayStyleCss` - Today's date style + - `SelectedDayStyleCss` - Selected date style + - `OtherMonthDayStyleCss` - Days from other months style + - `WeekendDayStyleCss` - Weekend day style +- `Visible` property to show/hide the calendar +- `CssClass` for custom CSS styling +- `ToolTip` for accessibility + +## Web Forms Features NOT Supported + +- `DayRender` event cannot add custom controls to cells (Blazor limitation) +- `Caption` and `CaptionAlign` properties not implemented +- `TodaysDate` property not implemented (use `DateTime.Today`) +- `UseAccessibleHeader` not implemented +- Individual style objects (`DayStyle`, `TitleStyle`, etc.) not supported - use CSS class names instead + +## Web Forms Declarative Syntax + +```html + + + + + + + + + + + +``` + +## Blazor Declarative Syntax + +```razor + +``` + +## Usage Examples + +### Basic Calendar + +```razor +@page "/calendar-demo" + +

Select a Date

+ +

You selected: @selectedDate.ToShortDateString()

+ +@code { + private DateTime selectedDate = DateTime.Today; +} +``` + +### Calendar with Week Selection + +```razor + + +@code { + private DateTime weekStart = DateTime.Today; + + private void HandleSelection() + { + // User selected a week starting at weekStart + } +} +``` + +### Calendar with Month Selection + +```razor + + +@code { + private DateTime monthStart = DateTime.Today; +} +``` + +### Customized Calendar + +```razor + +``` + +### Styled Calendar + +```razor + + + +``` + +### Calendar with Event Handlers + +```razor + + +

Selection count: @selectionCount

+

Current month: @currentMonth.ToString("MMMM yyyy")

+ +@code { + private DateTime selectedDate = DateTime.Today; + private int selectionCount = 0; + private DateTime currentMonth = DateTime.Today; + + private void HandleSelectionChanged() + { + selectionCount++; + } + + private void HandleMonthChanged(CalendarMonthChangedArgs args) + { + currentMonth = args.CurrentMonth; + } + + private void HandleDayRender(CalendarDayRenderArgs args) + { + // Disable Sundays + if (args.Date.DayOfWeek == DayOfWeek.Sunday) + { + args.IsSelectable = false; + } + + // Disable past dates + if (args.Date < DateTime.Today) + { + args.IsSelectable = false; + } + } +} +``` + +### Display Specific Month + +```razor + + +@code { + private DateTime specificMonth = new DateTime(2024, 12, 1); + private DateTime selectedDate = DateTime.Today; +} +``` + +### Read-Only Calendar (No Selection) + +```razor + +``` + +## Migration Notes + +### From Web Forms to Blazor + +**Web Forms:** +```aspx + + + +``` + +```csharp +protected void Calendar1_SelectionChanged(object sender, EventArgs e) +{ + DateTime selected = Calendar1.SelectedDate; +} +``` + +**Blazor:** +```razor + + + +``` + +```csharp +@code { + private DateTime selectedDate = DateTime.Today; + + private void HandleSelectionChanged() + { + // Date is available in selectedDate variable + } +} +``` + +### Key Differences + +1. **Style Properties**: Use CSS classes instead of inline style objects +2. **Event Handlers**: Use EventCallback pattern instead of event delegates +3. **Data Binding**: Use `@bind-SelectedDate` for two-way binding +4. **Day Rendering**: The `OnDayRender` event provides day information but cannot inject custom HTML into cells + +## Common Scenarios + +### Date Range Picker + +```razor +

Start Date

+ + +

End Date

+ + +@code { + private DateTime startDate = DateTime.Today; + private DateTime endDate = DateTime.Today.AddDays(7); + + private void HandleEndDateDayRender(CalendarDayRenderArgs args) + { + // Disable dates before start date + if (args.Date < startDate) + { + args.IsSelectable = false; + } + } +} +``` + +### Holiday Calendar + +```razor + + +@code { + private DateTime selectedDate = DateTime.Today; + private List holidays = new List + { + new DateTime(2024, 1, 1), // New Year + new DateTime(2024, 7, 4), // Independence Day + new DateTime(2024, 12, 25) // Christmas + }; + + private void HandleHolidayRender(CalendarDayRenderArgs args) + { + if (holidays.Contains(args.Date)) + { + args.IsSelectable = false; + } + } +} +``` + +## See Also + +- [TextBox](TextBox.md) - For alternative date input using `TextBoxMode.Date` +- [Button](Button.md) - For submitting forms with selected dates +- [Panel](Panel.md) - For grouping calendar with related controls diff --git a/mkdocs.yml b/mkdocs.yml index fb0ac6c9..b1bdcf92 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -66,6 +66,7 @@ nav: - AdRotator: EditorControls/AdRotator.md - BulletedList: EditorControls/BulletedList.md - Button: EditorControls/Button.md + - Calendar: EditorControls/Calendar.md - CheckBox: EditorControls/CheckBox.md - CheckBoxList: EditorControls/CheckBoxList.md - DropDownList: EditorControls/DropDownList.md diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Calendar/Index.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Calendar/Index.razor new file mode 100644 index 00000000..6de17db2 --- /dev/null +++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Calendar/Index.razor @@ -0,0 +1,134 @@ +@page "/ControlSamples/Calendar" +@using BlazorWebFormsComponents.Enums +@using static BlazorWebFormsComponents.WebColor + +

Calendar Component Samples

+ +

Basic Calendar

+ +

Selected Date: @selectedDate.ToShortDateString()

+ +

Calendar with Custom Visible Month

+ +

Selected: @customSelectedDate.ToShortDateString()

+ +

Selection Modes

+

None - No Selection

+ + +

Day - Single Day Selection (Default)

+ +

Selected: @dayModeDate.ToShortDateString()

+ +

DayWeek - Select Week

+ +

Selected: @weekModeDate.ToShortDateString()

+ +

DayWeekMonth - Select Month

+ +

Selected: @monthModeDate.ToShortDateString()

+ +

Calendar with Grid Lines

+ + +

Calendar Without Title

+ + +

Calendar Without Day Header

+ + +

Custom Day Name Format

+

Full Day Names

+ + +

First Letter Only

+ + +

Shortest (Default: Short)

+ + +

Custom Title Format

+

Month Only

+ + +

Custom Navigation Text

+ + +

Styled Calendar

+ + + + +

Event Handling

+ +

Selected: @eventDate.ToShortDateString()

+

Selection Changed: @selectionChangedCount times

+

Month Changed: @monthChangedCount times

+ +@code { + private DateTime selectedDate = DateTime.Today; + private DateTime customMonth = new DateTime(2024, 12, 25); + private DateTime customSelectedDate = DateTime.Today; + private DateTime dayModeDate = DateTime.Today; + private DateTime weekModeDate = DateTime.Today; + private DateTime monthModeDate = DateTime.Today; + private DateTime gridDate = DateTime.Today; + private DateTime noTitleDate = DateTime.Today; + private DateTime noDayHeaderDate = DateTime.Today; + private DateTime fullNameDate = DateTime.Today; + private DateTime firstLetterDate = DateTime.Today; + private DateTime shortestDate = DateTime.Today; + private DateTime monthOnlyDate = DateTime.Today; + private DateTime customNavDate = DateTime.Today; + private DateTime styledDate = DateTime.Today; + private DateTime eventDate = DateTime.Today; + private int selectionChangedCount = 0; + private int monthChangedCount = 0; + + private void HandleSelectionChanged() + { + selectionChangedCount++; + } + + private void HandleMonthChanged(CalendarMonthChangedArgs args) + { + monthChangedCount++; + } +} + +
+ +

Code Examples

+ +<Calendar @@bind-SelectedDate="selectedDate" />
+<Calendar ShowGridLines="true" />
+<Calendar SelectionMode="DayWeek" />
+<Calendar DayNameFormat="Full" />
+<Calendar TitleFormat="Month" />
+<Calendar NextMonthText="Next »" PrevMonthText="« Prev" />
+<Calendar OnSelectionChanged="HandleSelectionChanged" /> +
From fe9843f5f87efd45aa578049446bf5568a06e820 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:20:09 +0000 Subject: [PATCH 05/14] Add Calendar sample page and documentation --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e9f41144..d6403179 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ There are a significant number of controls in ASP.NET Web Forms, and we will foc - [AdRotator](docs/EditorControls/AdRotator.md) - [BulletedList](docs/EditorControls/BulletedList.md) - [Button](docs/EditorControls/Button.md) - - Calendar + - [Calendar](docs/EditorControls/Calendar.md) - [CheckBox](docs/EditorControls/CheckBox.md) - [CheckBoxList](docs/EditorControls/CheckBoxList.md) - [DropDownList](docs/EditorControls/DropDownList.md) From 7f45ad9c5117f23075fabc197c02eb9487e14caf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:25:35 +0000 Subject: [PATCH 06/14] Fix code review issues: safe substring and synchronous event handling Co-authored-by: csharpfritz <78577+csharpfritz@users.noreply.github.com> --- .../Calendar.razor.cs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/BlazorWebFormsComponents/Calendar.razor.cs b/src/BlazorWebFormsComponents/Calendar.razor.cs index 759f8892..7d144cd8 100644 --- a/src/BlazorWebFormsComponents/Calendar.razor.cs +++ b/src/BlazorWebFormsComponents/Calendar.razor.cs @@ -252,13 +252,21 @@ private string GetDayName(DayOfWeek day) return DayNameFormat switch { "Full" => CultureInfo.CurrentCulture.DateTimeFormat.GetDayName(day), - "FirstLetter" => CultureInfo.CurrentCulture.DateTimeFormat.GetDayName(day).Substring(0, 1), - "FirstTwoLetters" => CultureInfo.CurrentCulture.DateTimeFormat.GetDayName(day).Substring(0, 2), + "FirstLetter" => SafeSubstring(CultureInfo.CurrentCulture.DateTimeFormat.GetDayName(day), 0, 1), + "FirstTwoLetters" => SafeSubstring(CultureInfo.CurrentCulture.DateTimeFormat.GetDayName(day), 0, 2), "Shortest" => CultureInfo.CurrentCulture.DateTimeFormat.GetShortestDayName(day), _ => CultureInfo.CurrentCulture.DateTimeFormat.GetAbbreviatedDayName(day) }; } + private static string SafeSubstring(string str, int start, int length) + { + if (string.IsNullOrEmpty(str) || start >= str.Length) + return str; + + return str.Substring(start, Math.Min(length, str.Length - start)); + } + private List> GetCalendarWeeks() { var weeks = new List>(); @@ -390,6 +398,10 @@ private string GetDayCellCss(DateTime date) return DayStyleCss; } + /// + /// Creates day render arguments and invokes the OnDayRender event. + /// Note: OnDayRender is invoked synchronously during rendering. Handlers should not perform async operations or modify component state directly. + /// private CalendarDayRenderArgs CreateDayRenderArgs(DateTime date) { var args = new CalendarDayRenderArgs @@ -402,9 +414,10 @@ private CalendarDayRenderArgs CreateDayRenderArgs(DateTime date) IsSelected = _selectedDays.Contains(date.Date) }; + // Invoke synchronously to allow handler to modify day properties before rendering if (OnDayRender.HasDelegate) { - _ = OnDayRender.InvokeAsync(args); + OnDayRender.InvokeAsync(args).GetAwaiter().GetResult(); } return args; From d33e156a24f57ec9aceafbf4bb23f29872ed1589 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Tue, 10 Feb 2026 11:08:36 -0500 Subject: [PATCH 07/14] fix: refactor Calendar to use CalendarSelectionMode enum (#333) - Create CalendarSelectionMode enum (None, Day, DayWeek, DayWeekMonth) - Refactor Calendar.SelectionMode from string to CalendarSelectionMode enum - Remove .GetAwaiter().GetResult() blocking call in CreateDayRenderArgs - Add Caption, CaptionAlign, UseAccessibleHeader properties - Update tests and samples to use enum values --- .../Pages/ControlSamples/Calendar/Index.razor | 8 ++--- .../Calendar/Selection.razor | 3 +- src/BlazorWebFormsComponents/Calendar.razor | 13 ++++--- .../Calendar.razor.cs | 35 ++++++++++++++----- .../Enums/CalendarSelectionMode.cs | 28 +++++++++++++++ 5 files changed, 70 insertions(+), 17 deletions(-) create mode 100644 src/BlazorWebFormsComponents/Enums/CalendarSelectionMode.cs diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Calendar/Index.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Calendar/Index.razor index 6de17db2..a8de5806 100644 --- a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Calendar/Index.razor +++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Calendar/Index.razor @@ -14,18 +14,18 @@

Selection Modes

None - No Selection

- +

Day - Single Day Selection (Default)

- +

Selected: @dayModeDate.ToShortDateString()

DayWeek - Select Week

- +

Selected: @weekModeDate.ToShortDateString()

DayWeekMonth - Select Month

- +

Selected: @monthModeDate.ToShortDateString()

Calendar with Grid Lines

diff --git a/src/BlazorWebFormsComponents.Test/Calendar/Selection.razor b/src/BlazorWebFormsComponents.Test/Calendar/Selection.razor index 56c7d901..a1be5943 100644 --- a/src/BlazorWebFormsComponents.Test/Calendar/Selection.razor +++ b/src/BlazorWebFormsComponents.Test/Calendar/Selection.razor @@ -1,5 +1,6 @@ @inherits BlazorWebFormsTestContext @using Shouldly +@using BlazorWebFormsComponents.Enums @code { [Fact] @@ -23,7 +24,7 @@ { // Arrange var testDate = new DateTime(2024, 6, 15); - var cut = Render(@); + var cut = Render(@); // Act & Assert // Days rendered as non-clickable spans when selection is disabled diff --git a/src/BlazorWebFormsComponents/Calendar.razor b/src/BlazorWebFormsComponents/Calendar.razor index c8bc574e..c0e256b1 100644 --- a/src/BlazorWebFormsComponents/Calendar.razor +++ b/src/BlazorWebFormsComponents/Calendar.razor @@ -1,4 +1,5 @@ @inherits BaseStyledComponent +@using BlazorWebFormsComponents.Enums @if (Visible) { @@ -9,10 +10,14 @@ cellspacing="@CellSpacing" border="@GetBorder()" title="@ToolTip"> + @if (!string.IsNullOrEmpty(Caption)) + { + @Caption + } @if (ShowTitle) { - @if (ShowNextPrevMonth && SelectionMode == "DayWeekMonth") + @if (ShowNextPrevMonth && SelectionMode == CalendarSelectionMode.DayWeekMonth) { @SelectMonthText @@ -38,13 +43,13 @@ @if (ShowDayHeader) { - @if (SelectionMode == "DayWeek" || SelectionMode == "DayWeekMonth") + @if (SelectionMode == CalendarSelectionMode.DayWeek || SelectionMode == CalendarSelectionMode.DayWeekMonth) { } @foreach (var day in GetDayHeaders()) { - + @GetDayName(day) } @@ -53,7 +58,7 @@ @foreach (var week in GetCalendarWeeks()) { - @if (SelectionMode == "DayWeek" || SelectionMode == "DayWeekMonth") + @if (SelectionMode == CalendarSelectionMode.DayWeek || SelectionMode == CalendarSelectionMode.DayWeekMonth) { @((MarkupString)SelectWeekText) diff --git a/src/BlazorWebFormsComponents/Calendar.razor.cs b/src/BlazorWebFormsComponents/Calendar.razor.cs index 7d144cd8..f36883c0 100644 --- a/src/BlazorWebFormsComponents/Calendar.razor.cs +++ b/src/BlazorWebFormsComponents/Calendar.razor.cs @@ -65,10 +65,28 @@ public DateTime VisibleDate } /// - /// Gets or sets the selection mode: None, Day, DayWeek, or DayWeekMonth. + /// Gets or sets the selection mode of the Calendar control. /// [Parameter] - public string SelectionMode { get; set; } = "Day"; + public CalendarSelectionMode SelectionMode { get; set; } = CalendarSelectionMode.Day; + + /// + /// Gets or sets the text displayed as the caption of the calendar table. + /// + [Parameter] + public string Caption { get; set; } + + /// + /// Gets or sets the alignment of the caption relative to the calendar table. + /// + [Parameter] + public TableCaptionAlign CaptionAlign { get; set; } = TableCaptionAlign.NotSet; + + /// + /// Gets or sets whether the calendar renders accessible table headers using scope attributes. + /// + [Parameter] + public bool UseAccessibleHeader { get; set; } = true; /// /// Event raised when a day is rendered, allowing customization. @@ -308,7 +326,7 @@ private async Task HandleNextMonth() private async Task HandleDayClick(DateTime date) { - if (SelectionMode == "None") return; + if (SelectionMode == CalendarSelectionMode.None) return; _selectedDays.Clear(); _selectedDays.Add(date.Date); @@ -329,7 +347,7 @@ private async Task HandleDayClick(DateTime date) private async Task HandleWeekClick(List week) { - if (SelectionMode != "DayWeek" && SelectionMode != "DayWeekMonth") return; + if (SelectionMode != CalendarSelectionMode.DayWeek && SelectionMode != CalendarSelectionMode.DayWeekMonth) return; _selectedDays.Clear(); foreach (var day in week) @@ -353,7 +371,7 @@ private async Task HandleWeekClick(List week) private async Task HandleMonthClick() { - if (SelectionMode != "DayWeekMonth") return; + if (SelectionMode != CalendarSelectionMode.DayWeekMonth) return; _selectedDays.Clear(); var firstOfMonth = new DateTime(_visibleMonth.Year, _visibleMonth.Month, 1); @@ -407,17 +425,18 @@ private CalendarDayRenderArgs CreateDayRenderArgs(DateTime date) var args = new CalendarDayRenderArgs { Date = date, - IsSelectable = SelectionMode != "None", + IsSelectable = SelectionMode != CalendarSelectionMode.None, IsWeekend = date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday, IsToday = date.Date == DateTime.Today, IsOtherMonth = date.Month != _visibleMonth.Month, IsSelected = _selectedDays.Contains(date.Date) }; - // Invoke synchronously to allow handler to modify day properties before rendering + // Fire-and-forget: the handler can modify args properties synchronously + // before InvokeAsync yields. Avoid blocking with GetAwaiter().GetResult(). if (OnDayRender.HasDelegate) { - OnDayRender.InvokeAsync(args).GetAwaiter().GetResult(); + _ = OnDayRender.InvokeAsync(args); } return args; diff --git a/src/BlazorWebFormsComponents/Enums/CalendarSelectionMode.cs b/src/BlazorWebFormsComponents/Enums/CalendarSelectionMode.cs new file mode 100644 index 00000000..18ea8063 --- /dev/null +++ b/src/BlazorWebFormsComponents/Enums/CalendarSelectionMode.cs @@ -0,0 +1,28 @@ +namespace BlazorWebFormsComponents.Enums +{ + /// + /// Specifies the selection mode of the Calendar control. + /// + public enum CalendarSelectionMode + { + /// + /// No dates can be selected. + /// + None = 0, + + /// + /// A single date can be selected. + /// + Day = 1, + + /// + /// A single date or an entire week can be selected. + /// + DayWeek = 2, + + /// + /// A single date, an entire week, or an entire month can be selected. + /// + DayWeekMonth = 3 + } +} From 6c126e8851bab98cbde149796b4c28d1849ae4c7 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Tue, 10 Feb 2026 11:10:00 -0500 Subject: [PATCH 08/14] samples: add demo pages for Calendar, FileUpload, ImageMap - Calendar: date selection, selection modes, styling, day/title formats, events - FileUpload: basic upload, file type filtering, multiple files, disabled, styled - ImageMap: navigate/postback/mixed hot spot modes, rectangle/circle/polygon shapes - Updated NavMenu and ComponentList with links to all three new components --- .../Components/Layout/NavMenu.razor | 3 + .../Components/Pages/ComponentList.razor | 3 + .../Pages/ControlSamples/Calendar/Index.razor | 206 +++++++------ .../ControlSamples/FileUpload/Index.razor | 156 ++++++++++ .../Pages/ControlSamples/ImageMap/Index.razor | 276 ++++++++++++++++++ 5 files changed, 560 insertions(+), 84 deletions(-) create mode 100644 samples/AfterBlazorServerSide/Components/Pages/ControlSamples/FileUpload/Index.razor create mode 100644 samples/AfterBlazorServerSide/Components/Pages/ControlSamples/ImageMap/Index.razor diff --git a/samples/AfterBlazorServerSide/Components/Layout/NavMenu.razor b/samples/AfterBlazorServerSide/Components/Layout/NavMenu.razor index 27a594ad..2d9198b0 100644 --- a/samples/AfterBlazorServerSide/Components/Layout/NavMenu.razor +++ b/samples/AfterBlazorServerSide/Components/Layout/NavMenu.razor @@ -19,6 +19,7 @@ + @@ -28,8 +29,10 @@ + + diff --git a/samples/AfterBlazorServerSide/Components/Pages/ComponentList.razor b/samples/AfterBlazorServerSide/Components/Pages/ComponentList.razor index d02f190a..f7146b3b 100644 --- a/samples/AfterBlazorServerSide/Components/Pages/ComponentList.razor +++ b/samples/AfterBlazorServerSide/Components/Pages/ComponentList.razor @@ -5,11 +5,14 @@
  • AdRotator
  • BulletedList
  • Button
  • +
  • Calendar
  • CheckBox
  • DropDownList
  • +
  • FileUpload
  • HiddenField
  • Image
  • HyperLink
  • +
  • ImageMap
  • LinkButton
  • Literal
  • Panel
  • diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Calendar/Index.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Calendar/Index.razor index a8de5806..9353e2be 100644 --- a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Calendar/Index.razor +++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Calendar/Index.razor @@ -1,134 +1,172 @@ @page "/ControlSamples/Calendar" +@using BlazorWebFormsComponents @using BlazorWebFormsComponents.Enums -@using static BlazorWebFormsComponents.WebColor + +Calendar Sample

    Calendar Component Samples

    +

    The Calendar control displays a one-month calendar and lets users select dates and navigate + between months. In Web Forms this was <asp:Calendar>.

    + +
    + +

    Basic Calendar

    Selected Date: @selectedDate.ToShortDateString()

    -

    Calendar with Custom Visible Month

    - -

    Selected: @customSelectedDate.ToShortDateString()

    +

    Code:

    +
    <Calendar @@bind-SelectedDate="selectedDate" />
    +
    + +

    Selection Modes

    -

    None - No Selection

    - -

    Day - Single Day Selection (Default)

    - +

    None — Display Only

    + + +

    Day — Single Day (Default)

    +

    Selected: @dayModeDate.ToShortDateString()

    -

    DayWeek - Select Week

    - +

    DayWeek — Select Entire Weeks

    +

    Selected: @weekModeDate.ToShortDateString()

    -

    DayWeekMonth - Select Month

    - +

    DayWeekMonth — Select Weeks or Month

    +

    Selected: @monthModeDate.ToShortDateString()

    -

    Calendar with Grid Lines

    +

    Code:

    +
    <Calendar SelectionMode="DayWeek" @@bind-SelectedDate="weekDate" />
    +<Calendar SelectionMode="DayWeekMonth" @@bind-SelectedDate="monthDate" />
    + +
    + + +

    Display Options

    + +

    Grid Lines

    -

    Calendar Without Title

    +

    Without Title

    -

    Calendar Without Day Header

    +

    Without Day Header

    -

    Custom Day Name Format

    +

    Code:

    +
    <Calendar ShowGridLines="true" />
    +<Calendar ShowTitle="false" />
    +<Calendar ShowDayHeader="false" />
    + +
    + + +

    Day Name & Title Formats

    +

    Full Day Names

    First Letter Only

    -

    Shortest (Default: Short)

    - - -

    Custom Title Format

    -

    Month Only

    +

    Month-Only Title

    +

    Code:

    +
    <Calendar DayNameFormat="Full" />
    +<Calendar DayNameFormat="FirstLetter" />
    +<Calendar TitleFormat="Month" />
    + +
    + +

    Custom Navigation Text

    - + + +

    Code:

    +
    <Calendar NextMonthText="Next &raquo;" PrevMonthText="&laquo; Prev" />
    + +
    +

    Styled Calendar

    -Apply CSS classes to title, selected day, today, and weekend cells:

    + + +

    Code:

    +
    <Calendar CssClass="styled-calendar"
    +          TitleStyleCss="calendar-title"
    +          SelectedDayStyleCss="selected-day"
    +          TodayDayStyleCss="today-day"
    +          ShowGridLines="true" />
    + +
    + +

    Event Handling

    -Track selection changes and month navigation, matching the Web Forms + SelectionChanged and VisibleMonthChanged events:

    + +

    Selected: @eventDate.ToShortDateString()

    -

    Selection Changed: @selectionChangedCount times

    -

    Month Changed: @monthChangedCount times

    +

    Selection changed @selectionChangedCount time(s)

    +

    Month navigated @monthChangedCount time(s)

    -@code { - private DateTime selectedDate = DateTime.Today; - private DateTime customMonth = new DateTime(2024, 12, 25); - private DateTime customSelectedDate = DateTime.Today; - private DateTime dayModeDate = DateTime.Today; - private DateTime weekModeDate = DateTime.Today; - private DateTime monthModeDate = DateTime.Today; - private DateTime gridDate = DateTime.Today; - private DateTime noTitleDate = DateTime.Today; - private DateTime noDayHeaderDate = DateTime.Today; - private DateTime fullNameDate = DateTime.Today; - private DateTime firstLetterDate = DateTime.Today; - private DateTime shortestDate = DateTime.Today; - private DateTime monthOnlyDate = DateTime.Today; - private DateTime customNavDate = DateTime.Today; - private DateTime styledDate = DateTime.Today; - private DateTime eventDate = DateTime.Today; - private int selectionChangedCount = 0; - private int monthChangedCount = 0; - - private void HandleSelectionChanged() - { - selectionChangedCount++; - } - - private void HandleMonthChanged(CalendarMonthChangedArgs args) - { - monthChangedCount++; - } -} +

    Code:

    +
    <Calendar @@bind-SelectedDate="eventDate"
    +          OnSelectionChanged="HandleSelectionChanged"
    +          OnVisibleMonthChanged="HandleMonthChanged" />
     
    -
    +@@code { + void HandleSelectionChanged() => selectionChangedCount++; + void HandleMonthChanged(CalendarMonthChangedArgs args) => monthChangedCount++; +}
    -

    Code Examples

    - -<Calendar @@bind-SelectedDate="selectedDate" />
    -<Calendar ShowGridLines="true" />
    -<Calendar SelectionMode="DayWeek" />
    -<Calendar DayNameFormat="Full" />
    -<Calendar TitleFormat="Month" />
    -<Calendar NextMonthText="Next »" PrevMonthText="« Prev" />
    -<Calendar OnSelectionChanged="HandleSelectionChanged" /> -
    +@code { + private DateTime selectedDate = DateTime.Today; + private DateTime dayModeDate = DateTime.Today; + private DateTime weekModeDate = DateTime.Today; + private DateTime monthModeDate = DateTime.Today; + private DateTime gridDate = DateTime.Today; + private DateTime noTitleDate = DateTime.Today; + private DateTime noDayHeaderDate = DateTime.Today; + private DateTime fullNameDate = DateTime.Today; + private DateTime firstLetterDate = DateTime.Today; + private DateTime monthOnlyDate = DateTime.Today; + private DateTime customNavDate = DateTime.Today; + private DateTime styledDate = DateTime.Today; + private DateTime eventDate = DateTime.Today; + private int selectionChangedCount = 0; + private int monthChangedCount = 0; + + private void HandleSelectionChanged() + { + selectionChangedCount++; + } + + private void HandleMonthChanged(CalendarMonthChangedArgs args) + { + monthChangedCount++; + } +} \ No newline at end of file diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/FileUpload/Index.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/FileUpload/Index.razor new file mode 100644 index 00000000..1fe642e1 --- /dev/null +++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/FileUpload/Index.razor @@ -0,0 +1,156 @@ +@page "/ControlSamples/FileUpload" +@using BlazorWebFormsComponents +@using BlazorWebFormsComponents.Enums + +FileUpload Sample + +

    FileUpload Component Samples

    + +

    The FileUpload control lets users select files for upload, emulating the ASP.NET Web Forms + <asp:FileUpload> control. It renders as an HTML <input type="file">.

    + +
    + + +

    Basic File Upload

    +

    A simple file picker, just like the default Web Forms FileUpload:

    + +
    + +
    +
    + +

    Code:

    +
    <FileUpload @@ref="basicUpload" />
    +<Button Text="Check File" OnClick="CheckBasicFile" />
    +
    +@@code {
    +    FileUpload basicUpload;
    +    void CheckBasicFile()
    +    {
    +        if (basicUpload.HasFile)
    +            status = $"Selected: {basicUpload.FileName}";
    +    }
    +}
    + +
    + + +

    Restrict to Images Only

    +

    Use Accept to limit file types, similar to adding a RegularExpressionValidator + in Web Forms:

    + +
    + +
    + +

    Code:

    +
    <FileUpload Accept="image/*" />
    + +
    + +

    Accept Specific File Types

    +

    Restrict to PDF and Word documents:

    + +
    + +
    + +

    Code:

    +
    <FileUpload Accept=".pdf,.doc,.docx" />
    + +
    + + +

    Multiple File Selection

    +

    Set AllowMultiple="true" to allow selecting more than one file at a time:

    + +
    + +
    +
    + +

    Code:

    +
    <FileUpload AllowMultiple="true" />
    + +
    + + +

    Disabled FileUpload

    +

    Set Enabled="false" to prevent interaction, just like Web Forms:

    + +
    + +
    + +

    Code:

    +
    <FileUpload Enabled="false" />
    + +
    + + +

    Styled FileUpload

    +

    Apply styling with CssClass, BackColor, and Width:

    + +
    + +
    + +

    Code:

    +
    <FileUpload CssClass="form-control"
    +            BackColor="WebColor.LightCyan"
    +            Width="Unit.Pixel(400)" />
    + +
    + + +

    Visibility Toggle

    +

    Use the Visible property to show/hide, matching Web Forms behavior:

    + +
    + +
    + +
    + +

    Code:

    +
    <FileUpload Visible="@@uploadVisible" />
    + +@code { + private FileUpload basicUpload; + private FileUpload multiUpload; + private string basicStatus; + private string multiStatus; + private bool uploadVisible = true; + + private void CheckBasicFile() + { + basicStatus = basicUpload.HasFile + ? $"Selected: {basicUpload.FileName}" + : "No file selected."; + } + + private void CheckMultiFiles() + { + multiStatus = multiUpload.HasFile + ? $"File(s) ready for upload." + : "No files selected."; + } + + private void ToggleUploadVisibility() + { + uploadVisible = !uploadVisible; + } +} diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/ImageMap/Index.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/ImageMap/Index.razor new file mode 100644 index 00000000..edd22fda --- /dev/null +++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/ImageMap/Index.razor @@ -0,0 +1,276 @@ +@page "/ControlSamples/ImageMap" +@using BlazorWebFormsComponents +@using BlazorWebFormsComponents.Enums + +ImageMap Sample + +

    ImageMap Component Samples

    + +

    The ImageMap control displays an image with clickable hot spot regions, emulating + <asp:ImageMap> from ASP.NET Web Forms. Hot spots can navigate to URLs + or raise postback events, just like in Web Forms.

    + +
    + + +

    Navigation Hot Spots

    +

    Click a region to navigate. This mirrors the Web Forms HotSpotMode.Navigate behavior:

    + +
    + +
    + +

    The image above has three hot spots defined:

    +
      +
    • Left rectangle (0,0 → 130,200) — navigates to the Button sample
    • +
    • Center circle (200,100 radius 60) — navigates to the CheckBox sample
    • +
    • Right rectangle (270,0 → 400,200) — navigates to the Image sample
    • +
    + +

    Code:

    +
    <ImageMap ImageUrl="image.png"
    +          AlternateText="Navigation demo"
    +          HotSpotMode="HotSpotMode.Navigate"
    +          HotSpots="@@navigationHotSpots" />
    +
    +@@code {
    +    List<HotSpot> navigationHotSpots = new()
    +    {
    +        new RectangleHotSpot {
    +            Left = 0, Top = 0, Right = 130, Bottom = 200,
    +            NavigateUrl = "/ControlSamples/Button",
    +            AlternateText = "Button Samples"
    +        },
    +        new CircleHotSpot {
    +            X = 200, Y = 100, Radius = 60,
    +            NavigateUrl = "/ControlSamples/CheckBox",
    +            AlternateText = "CheckBox Samples"
    +        }
    +    };
    +}
    + +
    + + +

    PostBack Hot Spots

    +

    Click a region to trigger a server-side event with a PostBackValue. + This is equivalent to handling ImageMap.Click in Web Forms code-behind:

    + +
    + + + @if (!string.IsNullOrEmpty(clickedRegion)) + { +
    + You clicked: @clickedRegion +
    + } +
    + +

    Code:

    +
    <ImageMap HotSpotMode="HotSpotMode.PostBack"
    +          HotSpots="@@postBackHotSpots"
    +          OnClick="HandleHotSpotClick" />
    +
    +@@code {
    +    void HandleHotSpotClick(ImageMapEventArgs e)
    +    {
    +        clickedRegion = e.PostBackValue;
    +    }
    +}
    + +
    + + +

    Mixed Hot Spot Modes

    +

    Each hot spot can override the default HotSpotMode. This image has a + navigate region, a postback region, and an inactive region:

    + +
    + + + @if (!string.IsNullOrEmpty(mixedClickResult)) + { +
    @mixedClickResult
    + } +
    + +

    Hot spot behaviors on this image:

    +
      +
    • Left third — Navigate to home page
    • +
    • Center — PostBack (raises click event with value "center")
    • +
    • Right third — Inactive (no action)
    • +
    + +

    Code:

    +
    new RectangleHotSpot {
    +    Left = 0, Top = 0, Right = 150, Bottom = 180,
    +    HotSpotMode = HotSpotMode.Navigate,
    +    NavigateUrl = "/",
    +    AlternateText = "Home"
    +},
    +new CircleHotSpot {
    +    X = 225, Y = 90, Radius = 50,
    +    HotSpotMode = HotSpotMode.PostBack,
    +    PostBackValue = "center",
    +    AlternateText = "Click Me"
    +},
    +new RectangleHotSpot {
    +    Left = 300, Top = 0, Right = 450, Bottom = 180,
    +    HotSpotMode = HotSpotMode.Inactive,
    +    AlternateText = "Inactive Region"
    +}
    + +
    + + +

    Polygon Hot Spot

    +

    Use PolygonHotSpot for irregularly shaped regions, defined by coordinate pairs:

    + +
    + + + @if (!string.IsNullOrEmpty(polygonClickResult)) + { +
    @polygonClickResult
    + } +
    + +

    Code:

    +
    new PolygonHotSpot {
    +    Coordinates = "150,20,280,180,20,180",
    +    PostBackValue = "triangle",
    +    AlternateText = "Triangle region"
    +}
    + +
    + + +

    Accessibility

    +

    Always provide AlternateText on both the ImageMap and each HotSpot for screen readers. + Use GenerateEmptyAlternateText for purely decorative images:

    + +
    + +

    Decorative image with empty alt text.

    +
    + +

    Code:

    +
    <ImageMap GenerateEmptyAlternateText="true" ... />
    + +@code { + private string clickedRegion; + private string mixedClickResult; + private string polygonClickResult; + + // Navigation mode hot spots + private List navigationHotSpots = new() + { + new RectangleHotSpot + { + Left = 0, Top = 0, Right = 130, Bottom = 200, + NavigateUrl = "/ControlSamples/Button", + AlternateText = "Go to Button samples" + }, + new CircleHotSpot + { + X = 200, Y = 100, Radius = 60, + NavigateUrl = "/ControlSamples/CheckBox", + AlternateText = "Go to CheckBox samples" + }, + new RectangleHotSpot + { + Left = 270, Top = 0, Right = 400, Bottom = 200, + NavigateUrl = "/ControlSamples/Image", + AlternateText = "Go to Image samples" + } + }; + + // PostBack mode hot spots + private List postBackHotSpots = new() + { + new RectangleHotSpot + { + Left = 0, Top = 0, Right = 200, Bottom = 200, + PostBackValue = "Left Region", + AlternateText = "Left region" + }, + new RectangleHotSpot + { + Left = 200, Top = 0, Right = 400, Bottom = 200, + PostBackValue = "Right Region", + AlternateText = "Right region" + } + }; + + // Mixed mode hot spots — each overrides the default Inactive mode + private List mixedHotSpots = new() + { + new RectangleHotSpot + { + Left = 0, Top = 0, Right = 150, Bottom = 180, + HotSpotMode = HotSpotMode.Navigate, + NavigateUrl = "/", + AlternateText = "Navigate to Home" + }, + new CircleHotSpot + { + X = 225, Y = 90, Radius = 50, + HotSpotMode = HotSpotMode.PostBack, + PostBackValue = "center", + AlternateText = "Click Me" + }, + new RectangleHotSpot + { + Left = 300, Top = 0, Right = 450, Bottom = 180, + HotSpotMode = HotSpotMode.Inactive, + AlternateText = "Inactive Region" + } + }; + + // Polygon hot spot + private List polygonHotSpots = new() + { + new PolygonHotSpot + { + Coordinates = "150,20,280,180,20,180", + PostBackValue = "triangle", + AlternateText = "Triangle region" + } + }; + + // Decorative image — no meaningful hot spots + private List emptyHotSpots = new(); + + private void HandleHotSpotClick(ImageMapEventArgs e) + { + clickedRegion = e.PostBackValue; + } + + private void HandleMixedClick(ImageMapEventArgs e) + { + mixedClickResult = $"PostBack received: {e.PostBackValue}"; + } + + private void HandlePolygonClick(ImageMapEventArgs e) + { + polygonClickResult = $"Polygon clicked: {e.PostBackValue}"; + } +} From 047908d9b05c94081f0684bf4ed16b9a69fcb28d Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Tue, 10 Feb 2026 11:10:51 -0500 Subject: [PATCH 09/14] docs: add documentation for Calendar, FileUpload, ImageMap, PageService --- docs/EditorControls/FileUpload.md | 261 +++++++++++++++++++++ docs/NavigationControls/ImageMap.md | 346 ++++++++++++++++++++++++++++ docs/UtilityFeatures/PageService.md | 262 +++++++++++++++++++++ mkdocs.yml | 3 + 4 files changed, 872 insertions(+) create mode 100644 docs/EditorControls/FileUpload.md create mode 100644 docs/NavigationControls/ImageMap.md create mode 100644 docs/UtilityFeatures/PageService.md diff --git a/docs/EditorControls/FileUpload.md b/docs/EditorControls/FileUpload.md new file mode 100644 index 00000000..577ead2d --- /dev/null +++ b/docs/EditorControls/FileUpload.md @@ -0,0 +1,261 @@ +# FileUpload + +The **FileUpload** component provides file upload functionality that emulates the ASP.NET Web Forms FileUpload control. It renders an HTML file input element and exposes properties and methods familiar to Web Forms developers, such as `HasFile`, `FileName`, `PostedFile`, and `SaveAs`. + +Original Microsoft documentation: https://docs.microsoft.com/en-us/dotnet/api/system.web.ui.webcontrols.fileupload?view=netframework-4.8 + +## Features Supported in Blazor + +- `HasFile` — indicates whether a file has been selected +- `FileName` — the name of the selected file +- `FileBytes` — the file content as a byte array (synchronous) +- `FileContent` — a `Stream` pointing to the uploaded file +- `PostedFile` — a `PostedFileWrapper` providing `ContentLength`, `ContentType`, `FileName`, `InputStream`, and `SaveAs` (compatible with Web Forms `HttpPostedFile` patterns) +- `AllowMultiple` — enables multi-file selection (default: `false`) +- `Accept` — restricts file types via the HTML `accept` attribute (e.g., `".jpg,.png"` or `"image/*"`) +- `MaxFileSize` — maximum file size in bytes (default: `512000` / ~500 KiB) +- `ToolTip` — tooltip text displayed on hover +- `OnFileSelected` — event raised when a file is selected +- `SaveAs(filename)` — saves the uploaded file to a specified server path +- `GetFileBytesAsync()` — async method to get file content as a byte array +- `GetMultipleFiles()` — returns all selected files when `AllowMultiple` is enabled +- `SaveAllFiles(directory)` — saves all uploaded files to a directory with sanitized filenames +- `Enabled` — enables or disables the file input +- `Visible` — controls visibility +- All base style properties (`CssClass`, `Style`, etc.) + +## Web Forms Features NOT Supported + +- **PostedFile.SaveAs with HttpContext** — Blazor's `SaveAs` works directly with `IBrowserFile` streams; there is no `HttpContext`-based file handling +- **Server.MapPath** — Use absolute paths or `IWebHostEnvironment.WebRootPath` in Blazor +- **Request.Files collection** — Use the component's `GetMultipleFiles()` method instead +- **Lifecycle events** (`OnDataBinding`, `OnInit`, etc.) — Use Blazor lifecycle methods instead + +## Web Forms Declarative Syntax + +```html + +``` + +## Blazor Razor Syntax + +### Basic File Upload + +```razor + + +@code { + void HandleFileSelected(InputFileChangeEventArgs args) + { + // File has been selected + } +} +``` + +### File Upload with Type Restriction + +```razor + +``` + +### Multiple File Upload + +```razor + +``` + +### File Upload with Increased Size Limit + +```razor + + +@code { + async Task HandleLargeFile(InputFileChangeEventArgs args) + { + // MaxFileSize is set to 10 MB + } +} +``` + +### Saving an Uploaded File + +```razor +@inject IWebHostEnvironment Environment + + + + + + +
    +

    View 2 - Details

    +

    This is the second view with more details.

    + + +
    +
    + +
    +

    View 3 - Complete

    +

    You've reached the final view!

    + +
    +
    + + +@code { + private int activeIndex = 0; +} + +
    + +

    Code:

    + +<MultiView ActiveViewIndex="@@activeIndex">
    +  <View>View 1 content</View>
    +  <View>View 2 content</View>
    +  <View>View 3 content</View>
    +</MultiView> +
    diff --git a/src/BlazorWebFormsComponents.Test/Localize/BasicFormat.razor b/src/BlazorWebFormsComponents.Test/Localize/BasicFormat.razor new file mode 100644 index 00000000..7d5df3a4 --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/Localize/BasicFormat.razor @@ -0,0 +1,9 @@ + +@code { +[Fact] +public void Localize_RendersPlainText() +{ + var cut = Render(@); + cut.MarkupMatches(@This is normal text); +} +} diff --git a/src/BlazorWebFormsComponents.Test/Localize/EmptyText.razor b/src/BlazorWebFormsComponents.Test/Localize/EmptyText.razor new file mode 100644 index 00000000..c212cdf0 --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/Localize/EmptyText.razor @@ -0,0 +1,9 @@ + +@code { +[Fact] +public void Localize_WithEmptyText_RendersNothing() +{ + var cut = Render(@); + cut.MarkupMatches(string.Empty); +} +} diff --git a/src/BlazorWebFormsComponents.Test/Localize/HtmlEncoded.razor b/src/BlazorWebFormsComponents.Test/Localize/HtmlEncoded.razor new file mode 100644 index 00000000..514eb896 --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/Localize/HtmlEncoded.razor @@ -0,0 +1,9 @@ + +@code { +[Fact] +public void Localize_EncodesHtmlByDefault() +{ + var cut = Render(@); + cut.MarkupMatches(@<b>This is encoded text</b>); +} +} diff --git a/src/BlazorWebFormsComponents.Test/Localize/HtmlNotEncoded.razor b/src/BlazorWebFormsComponents.Test/Localize/HtmlNotEncoded.razor new file mode 100644 index 00000000..d5356ad1 --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/Localize/HtmlNotEncoded.razor @@ -0,0 +1,10 @@ +@using BlazorWebFormsComponents.Enums + +@code { +[Fact] +public void Localize_WithModePassThrough_RendersRawHtml() +{ + var cut = Render(@); + cut.MarkupMatches(@This is bold text); +} +} diff --git a/src/BlazorWebFormsComponents.Test/Localize/InheritsLiteral.razor b/src/BlazorWebFormsComponents.Test/Localize/InheritsLiteral.razor new file mode 100644 index 00000000..7e31681d --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/Localize/InheritsLiteral.razor @@ -0,0 +1,24 @@ +@using BlazorWebFormsComponents.Enums + +@code { +[Fact] +public void Localize_IsSubclassOfLiteral() +{ + var localize = new Localize(); + Assert.IsAssignableFrom(localize); +} + +[Fact] +public void Localize_DefaultModeIsEncode() +{ + var localize = new Localize(); + Assert.Equal(LiteralMode.Encode, localize.Mode); +} + +[Fact] +public void Localize_DefaultTextIsEmpty() +{ + var localize = new Localize(); + Assert.Equal(string.Empty, localize.Text); +} +} diff --git a/src/BlazorWebFormsComponents.Test/LoginControls/ChangePassword/BasicFormat.razor b/src/BlazorWebFormsComponents.Test/LoginControls/ChangePassword/BasicFormat.razor new file mode 100644 index 00000000..4523b99c --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/LoginControls/ChangePassword/BasicFormat.razor @@ -0,0 +1,101 @@ +@using System.Security.Claims; +@using Microsoft.AspNetCore.Components.Authorization +@using BlazorWebFormsComponents.LoginControls; +@using Moq; + +@code { + [Fact] + public void ChangePassword_RendersTitle() + { + var principal = new ClaimsPrincipal(); + var identity = new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Name, "TestUser") }, "Test"); + principal.AddIdentity(identity); + + var authMock = new Mock(); + authMock.Setup(x => x.GetAuthenticationStateAsync()).Returns(Task.FromResult(new AuthenticationState(principal))); + + Services.AddSingleton(authMock.Object); + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + cut.Find("td[align='center']").TextContent.ShouldContain("Change Your Password"); + } + + [Fact] + public void ChangePassword_RendersPasswordFields() + { + var principal = new ClaimsPrincipal(); + var identity = new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Name, "TestUser") }, "Test"); + principal.AddIdentity(identity); + + var authMock = new Mock(); + authMock.Setup(x => x.GetAuthenticationStateAsync()).Returns(Task.FromResult(new AuthenticationState(principal))); + + Services.AddSingleton(authMock.Object); + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + // Should have current password, new password, confirm new password + cut.Find("#cp1_CurrentPassword").ShouldNotBeNull(); + cut.Find("#cp1_NewPassword").ShouldNotBeNull(); + cut.Find("#cp1_ConfirmNewPassword").ShouldNotBeNull(); + } + + [Fact] + public void ChangePassword_WithDisplayUserName_RendersUserNameField() + { + var principal = new ClaimsPrincipal(); + var identity = new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Name, "TestUser") }, "Test"); + principal.AddIdentity(identity); + + var authMock = new Mock(); + authMock.Setup(x => x.GetAuthenticationStateAsync()).Returns(Task.FromResult(new AuthenticationState(principal))); + + Services.AddSingleton(authMock.Object); + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + cut.Find("#cp1_UserName").ShouldNotBeNull(); + } + + [Fact] + public void ChangePassword_WithoutDisplayUserName_HidesUserNameField() + { + var principal = new ClaimsPrincipal(); + var identity = new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Name, "TestUser") }, "Test"); + principal.AddIdentity(identity); + + var authMock = new Mock(); + authMock.Setup(x => x.GetAuthenticationStateAsync()).Returns(Task.FromResult(new AuthenticationState(principal))); + + Services.AddSingleton(authMock.Object); + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + Assert.Throws(() => cut.Find("#cp1_UserName")); + } + + [Fact] + public void ChangePassword_RendersChangePasswordButton() + { + var principal = new ClaimsPrincipal(); + var identity = new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Name, "TestUser") }, "Test"); + principal.AddIdentity(identity); + + var authMock = new Mock(); + authMock.Setup(x => x.GetAuthenticationStateAsync()).Returns(Task.FromResult(new AuthenticationState(principal))); + + Services.AddSingleton(authMock.Object); + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + var submitButton = cut.Find("#cp1_ChangePasswordButton"); + submitButton.ShouldNotBeNull(); + submitButton.GetAttribute("value").ShouldBe("Change Password"); + } +} diff --git a/src/BlazorWebFormsComponents.Test/LoginControls/CreateUserWizard/BasicFormat.razor b/src/BlazorWebFormsComponents.Test/LoginControls/CreateUserWizard/BasicFormat.razor new file mode 100644 index 00000000..76deaa2d --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/LoginControls/CreateUserWizard/BasicFormat.razor @@ -0,0 +1,80 @@ +@using System.Security.Claims; +@using Microsoft.AspNetCore.Components.Authorization +@using BlazorWebFormsComponents.LoginControls; +@using Moq; + +@code { + [Fact] + public void CreateUserWizard_RendersTitle() + { + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + cut.Find("td[align='center']").TextContent.ShouldContain("Sign Up for Your New Account"); + } + + [Fact] + public void CreateUserWizard_RendersUserNameField() + { + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + cut.Find("#cuw1_UserName").ShouldNotBeNull(); + } + + [Fact] + public void CreateUserWizard_RendersPasswordFields() + { + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + cut.Find("#cuw1_Password").ShouldNotBeNull(); + cut.Find("#cuw1_ConfirmPassword").ShouldNotBeNull(); + } + + [Fact] + public void CreateUserWizard_WithRequireEmail_RendersEmailField() + { + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + cut.Find("#cuw1_Email").ShouldNotBeNull(); + } + + [Fact] + public void CreateUserWizard_WithAutoGeneratePassword_HidesPasswordFields() + { + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + Assert.Throws(() => cut.Find("#cuw1_Password")); + Assert.Throws(() => cut.Find("#cuw1_ConfirmPassword")); + } + + [Fact] + public void CreateUserWizard_RendersCreateUserButton() + { + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + var button = cut.Find("#cuw1_CreateUserButton"); + button.ShouldNotBeNull(); + button.GetAttribute("value").ShouldBe("Create User"); + } + + [Fact] + public void CreateUserWizard_WithDisplayCancelButton_RendersCancelButton() + { + Services.AddSingleton(new Mock().Object); + + var cut = Render(@); + + cut.Find("#cuw1_CancelButton").ShouldNotBeNull(); + } +} diff --git a/src/BlazorWebFormsComponents.Test/MultiView/BasicFormat.razor b/src/BlazorWebFormsComponents.Test/MultiView/BasicFormat.razor new file mode 100644 index 00000000..3e7c4057 --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/MultiView/BasicFormat.razor @@ -0,0 +1,50 @@ + +@code { + [Fact] + public void MultiView_WithActiveViewIndex0_RendersFirstView() + { + var cut = Render( + @ +

    View One

    +

    View Two

    +
    + ); + + cut.MarkupMatches(@

    View One

    ); + } + + [Fact] + public void MultiView_WithActiveViewIndex1_RendersSecondView() + { + var cut = Render( + @ +

    View One

    +

    View Two

    +
    + ); + + cut.MarkupMatches(@

    View Two

    ); + } + + [Fact] + public void MultiView_WithActiveViewIndexMinus1_RendersNothing() + { + var cut = Render( + @ +

    View One

    +

    View Two

    +
    + ); + + cut.MarkupMatches(string.Empty); + } + + [Fact] + public void MultiView_HasCommandNameConstants() + { + Assert.Equal("NextView", MultiView.NextViewCommandName); + Assert.Equal("PrevView", MultiView.PreviousViewCommandName); + Assert.Equal("SwitchViewByID", MultiView.SwitchViewByIDCommandName); + Assert.Equal("SwitchViewByIndex", MultiView.SwitchViewByIndexCommandName); + } +} diff --git a/src/BlazorWebFormsComponents/Localize.cs b/src/BlazorWebFormsComponents/Localize.cs new file mode 100644 index 00000000..a633e48f --- /dev/null +++ b/src/BlazorWebFormsComponents/Localize.cs @@ -0,0 +1,8 @@ +namespace BlazorWebFormsComponents; + +/// +/// Emulates the ASP.NET Web Forms Localize control. +/// Functionally identical to Literal — exists for markup compatibility. +/// In Blazor, pass localized strings via IStringLocalizer to the Text property. +/// +public class Localize : Literal { } diff --git a/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor b/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor new file mode 100644 index 00000000..d6c83dea --- /dev/null +++ b/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor @@ -0,0 +1,201 @@ +@inherits BaseWebFormsComponent + +@using BlazorWebFormsComponents.Validations; +@using Microsoft.AspNetCore.Components.Forms; +@using BlazorWebFormsComponents.Enums; + + + + + + + + + + + + @ChildContent + + + + + + + + + + @if (!ShowSuccessView) + { + @if (ChangePasswordTemplate != null) + { + @ChangePasswordTemplate + } + else + { + + + + + + +
    + + + @if (!string.IsNullOrEmpty(ChangePasswordTitleText)) + { + + + + } + @if (!string.IsNullOrEmpty(InstructionText)) + { + + + + } + @if (DisplayUserName) + { + + + + + } + + + + + + + + + + + + + @if (!string.IsNullOrEmpty(PasswordHintText)) + { + + + + } + @if (ShowFailureText) + { + + + + } + + + + @if (HasCreateUser || HasPasswordRecovery || HasHelp || HasEditProfile) + { + + + + } + +
    @ChangePasswordTitleText
    @InstructionText
    + + + +
    + + + +
    + + + +
    + + + +
    @PasswordHintText
    + @ChangePasswordFailureText +
    + + +
    + @if (!string.IsNullOrEmpty(CreateUserIconUrl)) + { + @CreateUserText + } + @if (!string.IsNullOrEmpty(CreateUserText)) + { + @CreateUserText + } + @if (HasCreateUser && (HasPasswordRecovery || HasHelp || HasEditProfile)) + { +
    + } + @if (!string.IsNullOrEmpty(PasswordRecoveryIconUrl)) + { + @PasswordRecoveryText + } + @if (!string.IsNullOrEmpty(PasswordRecoveryText)) + { + @PasswordRecoveryText + } + @if (HasPasswordRecovery && (HasHelp || HasEditProfile)) + { +
    + } + @if (!string.IsNullOrEmpty(HelpPageIconUrl)) + { + @HelpPageText + } + @if (!string.IsNullOrEmpty(HelpPageText)) + { + @HelpPageText + } + @if (HasHelp && HasEditProfile) + { +
    + } + @if (!string.IsNullOrEmpty(EditProfileIconUrl)) + { + @EditProfileText + } + @if (!string.IsNullOrEmpty(EditProfileText)) + { + @EditProfileText + } +
    +
    + } + } + else + { + @if (SuccessTemplate != null) + { + @SuccessTemplate + } + else + { + + + + + + +
    + + + + + + + + + + + + +
    @SuccessTitleText
    @SuccessText
    + +
    +
    + } + } + +
    diff --git a/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor.cs b/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor.cs new file mode 100644 index 00000000..e202858f --- /dev/null +++ b/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor.cs @@ -0,0 +1,252 @@ +using BlazorWebFormsComponents.Enums; +using BlazorWebFormsComponents.Validations; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using System; +using System.Threading.Tasks; + +namespace BlazorWebFormsComponents.LoginControls +{ + public partial class ChangePassword : BaseWebFormsComponent + { + #region Obsolete + + [Parameter, Obsolete("MembershipProvider not supported in Blazor")] + public string MembershipProvider { get; set; } + + #endregion + + #region Button Properties + + [Parameter] public string CancelButtonImageUrl { get; set; } + [Parameter] public string CancelButtonText { get; set; } = "Cancel"; + [Parameter] public ButtonType CancelButtonType { get; set; } = ButtonType.Button; + [Parameter] public string CancelDestinationPageUrl { get; set; } + + [Parameter] public string ChangePasswordButtonImageUrl { get; set; } + [Parameter] public string ChangePasswordButtonText { get; set; } = "Change Password"; + [Parameter] public ButtonType ChangePasswordButtonType { get; set; } = ButtonType.Button; + + [Parameter] public string ContinueButtonImageUrl { get; set; } + [Parameter] public string ContinueButtonText { get; set; } = "Continue"; + [Parameter] public ButtonType ContinueButtonType { get; set; } = ButtonType.Button; + [Parameter] public string ContinueDestinationPageUrl { get; set; } + + #endregion + + #region Text Properties + + [Parameter] public string ChangePasswordTitleText { get; set; } = "Change Your Password"; + [Parameter] public string ConfirmNewPasswordLabelText { get; set; } = "Confirm New Password:"; + [Parameter] public string ConfirmPasswordCompareErrorMessage { get; set; } = "The Confirm New Password must match the New Password entry."; + [Parameter] public string ConfirmPasswordRequiredErrorMessage { get; set; } = "Confirm New Password is required."; + [Parameter] public string ChangePasswordFailureText { get; set; } = "Password incorrect or New Password invalid."; + [Parameter] public string InstructionText { get; set; } + [Parameter] public string NewPasswordLabelText { get; set; } = "New Password:"; + [Parameter] public string NewPasswordRegularExpression { get; set; } + [Parameter] public string NewPasswordRegularExpressionErrorMessage { get; set; } + [Parameter] public string NewPasswordRequiredErrorMessage { get; set; } = "New Password is required."; + [Parameter] public string PasswordHintText { get; set; } + [Parameter] public string PasswordLabelText { get; set; } = "Password:"; + [Parameter] public string PasswordRequiredErrorMessage { get; set; } = "Password is required."; + [Parameter] public string SuccessPageUrl { get; set; } + [Parameter] public string SuccessText { get; set; } = "Your password has been changed!"; + [Parameter] public string SuccessTitleText { get; set; } = "Change Password Complete"; + + #endregion + + #region User Properties + + [Parameter] public bool DisplayUserName { get; set; } + [Parameter] public string UserName { get => Model?.UserName ?? string.Empty; set { if (Model != null) Model.UserName = value; } } + [Parameter] public string UserNameLabelText { get; set; } = "User Name:"; + [Parameter] public string UserNameRequiredErrorMessage { get; set; } = "User Name is required."; + + #endregion + + #region Link Properties + + [Parameter] public string CreateUserIconUrl { get; set; } + [Parameter] public string CreateUserText { get; set; } + [Parameter] public string CreateUserUrl { get; set; } + [Parameter] public string EditProfileIconUrl { get; set; } + [Parameter] public string EditProfileText { get; set; } + [Parameter] public string EditProfileUrl { get; set; } + [Parameter] public string HelpPageIconUrl { get; set; } + [Parameter] public string HelpPageText { get; set; } + [Parameter] public string HelpPageUrl { get; set; } + [Parameter] public string PasswordRecoveryIconUrl { get; set; } + [Parameter] public string PasswordRecoveryText { get; set; } + [Parameter] public string PasswordRecoveryUrl { get; set; } + + #endregion + + #region Layout Properties + + [Parameter] public int BorderPadding { get; set; } = 1; + [Parameter] public bool RenderOuterTable { get; set; } = true; + + #endregion + + #region Events + + [Parameter] public EventCallback OnCancelButtonClick { get; set; } + [Parameter] public EventCallback OnChangedPassword { get; set; } + [Parameter] public EventCallback OnChangePasswordError { get; set; } + [Parameter] public EventCallback OnChangingPassword { get; set; } + [Parameter] public EventCallback OnContinueButtonClick { get; set; } + + #endregion + + #region Templates + + [Parameter] public RenderFragment ChildContent { get; set; } + [Parameter] public RenderFragment ChangePasswordTemplate { get; set; } + [Parameter] public RenderFragment SuccessTemplate { get; set; } + + #endregion + + #region Style + + [CascadingParameter(Name = "FailureTextStyle")] + private TableItemStyle FailureTextStyle { get; set; } = new TableItemStyle(); + + [CascadingParameter(Name = "TitleTextStyle")] + private TableItemStyle TitleTextStyle { get; set; } = new TableItemStyle(); + + [CascadingParameter(Name = "LabelStyle")] + private TableItemStyle LabelStyle { get; set; } = new TableItemStyle(); + + [CascadingParameter(Name = "InstructionTextStyle")] + private TableItemStyle InstructionTextStyle { get; set; } = new TableItemStyle(); + + [CascadingParameter(Name = "TextBoxStyle")] + private Style TextBoxStyle { get; set; } = new Style(); + + [CascadingParameter(Name = "LoginButtonStyle")] + private Style LoginButtonStyle { get; set; } = new Style(); + + [CascadingParameter(Name = "ValidatorTextStyle")] + private Style ValidatorTextStyle { get; set; } = new Style(); + + [CascadingParameter(Name = "HyperLinkStyle")] + private TableItemStyle HyperLinkStyle { get; set; } = new TableItemStyle(); + + #endregion + + #region Services + + [Inject] + protected AuthenticationStateProvider AuthenticationStateProvider { get; set; } + + [Inject] + protected NavigationManager NavigationManager { get; set; } + + #endregion + + #region Internal State + + private ChangePasswordModel Model { get; set; } + private bool ShowSuccessView { get; set; } + private bool ShowFailureText { get; set; } + + private bool HasHelp => !string.IsNullOrEmpty(HelpPageText) || !string.IsNullOrEmpty(HelpPageIconUrl); + private bool HasPasswordRecovery => !string.IsNullOrEmpty(PasswordRecoveryText) || !string.IsNullOrEmpty(PasswordRecoveryIconUrl); + private bool HasCreateUser => !string.IsNullOrEmpty(CreateUserText) || !string.IsNullOrEmpty(CreateUserIconUrl); + private bool HasEditProfile => !string.IsNullOrEmpty(EditProfileText) || !string.IsNullOrEmpty(EditProfileIconUrl); + + public string CurrentPassword { get => Model?.CurrentPassword ?? string.Empty; set { if (Model != null) Model.CurrentPassword = value; } } + public string NewPassword { get => Model?.NewPassword ?? string.Empty; set { if (Model != null) Model.NewPassword = value; } } + + #endregion + + protected override async Task OnInitializedAsync() + { + Model = new ChangePasswordModel(); + + var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + if (!DisplayUserName && authState.User?.Identity?.IsAuthenticated == true) + { + Model.UserName = authState.User.Identity.Name ?? string.Empty; + } + + await base.OnInitializedAsync(); + } + + private async Task HandleValidSubmit() + { + var cancelArgs = new LoginCancelEventArgs { Sender = this }; + await OnChangingPassword.InvokeAsync(cancelArgs); + + if (cancelArgs.Cancel) + { + Model.CurrentPassword = string.Empty; + Model.NewPassword = string.Empty; + Model.ConfirmNewPassword = string.Empty; + return; + } + + // The developer must handle OnChangingPassword to do the actual password change. + // If they didn't cancel, we consider it successful. + ShowFailureText = false; + ShowSuccessView = true; + await OnChangedPassword.InvokeAsync(EventArgs.Empty); + } + + internal async Task HandleChangePasswordError() + { + ShowFailureText = true; + Model.CurrentPassword = string.Empty; + Model.NewPassword = string.Empty; + Model.ConfirmNewPassword = string.Empty; + await OnChangePasswordError.InvokeAsync(EventArgs.Empty); + } + + private async Task HandleCancelClick() + { + await OnCancelButtonClick.InvokeAsync(EventArgs.Empty); + if (!string.IsNullOrEmpty(CancelDestinationPageUrl)) + { + NavigationManager.NavigateTo(CancelDestinationPageUrl); + } + } + + private async Task HandleContinueClick() + { + await OnContinueButtonClick.InvokeAsync(EventArgs.Empty); + if (!string.IsNullOrEmpty(ContinueDestinationPageUrl)) + { + NavigationManager.NavigateTo(ContinueDestinationPageUrl); + } + else if (!string.IsNullOrEmpty(SuccessPageUrl)) + { + NavigationManager.NavigateTo(SuccessPageUrl); + } + } + + protected override void HandleUnknownAttributes() + { + if (AdditionalAttributes?.Count > 0) + { + FailureTextStyle.FromUnknownAttributes(AdditionalAttributes, "FailureTextStyle-"); + TitleTextStyle.FromUnknownAttributes(AdditionalAttributes, "TitleTextStyle-"); + LabelStyle.FromUnknownAttributes(AdditionalAttributes, "LabelStyle-"); + InstructionTextStyle.FromUnknownAttributes(AdditionalAttributes, "InstructionTextStyle-"); + TextBoxStyle.FromUnknownAttributes(AdditionalAttributes, "TextBoxStyle-"); + LoginButtonStyle.FromUnknownAttributes(AdditionalAttributes, "LoginButtonStyle-"); + ValidatorTextStyle.FromUnknownAttributes(AdditionalAttributes, "ValidatorTextStyle-"); + HyperLinkStyle.FromUnknownAttributes(AdditionalAttributes, "HyperLinkStyle-"); + } + + base.HandleUnknownAttributes(); + } + + public class ChangePasswordModel + { + public string UserName { get; set; } = string.Empty; + public string CurrentPassword { get; set; } = string.Empty; + public string NewPassword { get; set; } = string.Empty; + public string ConfirmNewPassword { get; set; } = string.Empty; + } + } +} diff --git a/src/BlazorWebFormsComponents/LoginControls/CreateUserErrorEventArgs.cs b/src/BlazorWebFormsComponents/LoginControls/CreateUserErrorEventArgs.cs new file mode 100644 index 00000000..1bd45163 --- /dev/null +++ b/src/BlazorWebFormsComponents/LoginControls/CreateUserErrorEventArgs.cs @@ -0,0 +1,17 @@ +using System; + +namespace BlazorWebFormsComponents.LoginControls +{ + /// + /// Provides data for the CreateUserError event of CreateUserWizard. + /// + public class CreateUserErrorEventArgs : EventArgs + { + public string ErrorMessage { get; set; } + + /// + /// The component that raised this event. + /// + public object Sender { get; set; } + } +} diff --git a/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor b/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor new file mode 100644 index 00000000..f097e01c --- /dev/null +++ b/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor @@ -0,0 +1,217 @@ +@inherits BaseWebFormsComponent + +@using BlazorWebFormsComponents.Validations; +@using Microsoft.AspNetCore.Components.Forms; +@using BlazorWebFormsComponents.Enums; + + + + + + + + + + + + @ChildContent + + + + + + + + + + @if (!ShowCompleteStep) + { + @if (CreateUserStep != null) + { + @CreateUserStep + } + else + { + + + + @if (DisplaySideBar) + { + + } + + + +
    + @if (SideBarTemplate != null) + { + @SideBarTemplate + } + else + { + Create User
    + Complete + } +
    + @if (HeaderTemplate != null) + { + @HeaderTemplate + } + + + + + + @if (!string.IsNullOrEmpty(InstructionText)) + { + + + + } + + + + + @if (!AutoGeneratePassword) + { + + + + + + + + + } + @if (RequireEmail) + { + + + + + } + @if (ShowSecurityQuestion) + { + + + + + + + + + } + @if (!string.IsNullOrEmpty(PasswordHintText)) + { + + + + } + @if (ShowFailureText) + { + + + + } + + + + @if (HasHelp || HasEditProfile) + { + + + + } + +
    Sign Up for Your New Account
    @InstructionText
    + + + +
    + + + +
    + + + +
    + + + +
    + + + +
    + + + +
    @PasswordHintText
    + @FailureText +
    + + @if (DisplayCancelButton) + { + + } +
    + @if (!string.IsNullOrEmpty(HelpPageIconUrl)) + { + @HelpPageText + } + @if (!string.IsNullOrEmpty(HelpPageText)) + { + @HelpPageText + } + @if (HasHelp && HasEditProfile) + { +
    + } + @if (!string.IsNullOrEmpty(EditProfileIconUrl)) + { + @EditProfileText + } + @if (!string.IsNullOrEmpty(EditProfileText)) + { + @EditProfileText + } +
    +
    + } + } + else + { + @if (CompleteStep != null) + { + @CompleteStep + } + else + { + + + + + + +
    + + + + + + + + + + + + +
    Complete
    @CompleteSuccessText
    + +
    +
    + } + } + +
    diff --git a/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor.cs b/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor.cs new file mode 100644 index 00000000..3d4edd46 --- /dev/null +++ b/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor.cs @@ -0,0 +1,257 @@ +using BlazorWebFormsComponents.Enums; +using BlazorWebFormsComponents.Validations; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using System; +using System.Threading.Tasks; + +namespace BlazorWebFormsComponents.LoginControls +{ + public partial class CreateUserWizard : BaseWebFormsComponent + { + #region Obsolete + + [Parameter, Obsolete("MembershipProvider not supported in Blazor")] + public string MembershipProvider { get; set; } + + #endregion + + #region User Properties + + [Parameter] public string UserName { get => Model?.UserName ?? string.Empty; set { if (Model != null) Model.UserName = value; } } + [Parameter] public string UserNameLabelText { get; set; } = "User Name:"; + [Parameter] public string UserNameRequiredErrorMessage { get; set; } = "User Name is required."; + [Parameter] public string Password { get => Model?.Password ?? string.Empty; set { if (Model != null) Model.Password = value; } } + [Parameter] public string PasswordLabelText { get; set; } = "Password:"; + [Parameter] public string PasswordRequiredErrorMessage { get; set; } = "Password is required."; + [Parameter] public string PasswordHintText { get; set; } + [Parameter] public string PasswordRegularExpression { get; set; } + [Parameter] public string PasswordRegularExpressionErrorMessage { get; set; } + [Parameter] public string ConfirmPasswordLabelText { get; set; } = "Confirm Password:"; + [Parameter] public string ConfirmPasswordCompareErrorMessage { get; set; } = "The Password and Confirmation Password must match."; + [Parameter] public string ConfirmPasswordRequiredErrorMessage { get; set; } = "Confirm Password is required."; + [Parameter] public string Email { get => Model?.Email ?? string.Empty; set { if (Model != null) Model.Email = value; } } + [Parameter] public string EmailLabelText { get; set; } = "E-mail:"; + [Parameter] public string EmailRequiredErrorMessage { get; set; } = "E-mail is required."; + [Parameter] public string EmailRegularExpression { get; set; } + [Parameter] public string EmailRegularExpressionErrorMessage { get; set; } + [Parameter] public string Question { get => Model?.Question ?? string.Empty; set { if (Model != null) Model.Question = value; } } + [Parameter] public string QuestionLabelText { get; set; } = "Security Question:"; + [Parameter] public string QuestionRequiredErrorMessage { get; set; } = "Security question is required."; + [Parameter] public string Answer { get => Model?.Answer ?? string.Empty; set { if (Model != null) Model.Answer = value; } } + [Parameter] public string AnswerLabelText { get; set; } = "Security Answer:"; + [Parameter] public string AnswerRequiredErrorMessage { get; set; } = "Security answer is required."; + [Parameter] public bool RequireEmail { get; set; } = true; + [Parameter] public bool AutoGeneratePassword { get; set; } + [Parameter] public bool DisableCreatedUser { get; set; } + [Parameter] public bool LoginCreatedUser { get; set; } = true; + + #endregion + + #region Button Properties + + [Parameter] public string CancelButtonImageUrl { get; set; } + [Parameter] public string CancelButtonText { get; set; } = "Cancel"; + [Parameter] public ButtonType CancelButtonType { get; set; } = ButtonType.Button; + [Parameter] public string CancelDestinationPageUrl { get; set; } + [Parameter] public bool DisplayCancelButton { get; set; } + + [Parameter] public string CreateUserButtonImageUrl { get; set; } + [Parameter] public string CreateUserButtonText { get; set; } = "Create User"; + [Parameter] public ButtonType CreateUserButtonType { get; set; } = ButtonType.Button; + + [Parameter] public string ContinueButtonImageUrl { get; set; } + [Parameter] public string ContinueButtonText { get; set; } = "Continue"; + [Parameter] public ButtonType ContinueButtonType { get; set; } = ButtonType.Button; + [Parameter] public string ContinueDestinationPageUrl { get; set; } + + #endregion + + #region Text Properties + + [Parameter] public string CompleteSuccessText { get; set; } = "Your account has been successfully created."; + [Parameter] public string InstructionText { get; set; } + [Parameter] public string DuplicateEmailErrorMessage { get; set; } = "The e-mail address that you entered is already in use. Please enter a different e-mail address."; + [Parameter] public string DuplicateUserNameErrorMessage { get; set; } = "Please enter a different user name."; + [Parameter] public string InvalidAnswerErrorMessage { get; set; } = "Please enter a different security answer."; + [Parameter] public string InvalidEmailErrorMessage { get; set; } = "Please enter a valid e-mail address."; + [Parameter] public string InvalidPasswordErrorMessage { get; set; } = "Password length minimum: {0}. Non-alphanumeric characters required: {1}."; + [Parameter] public string InvalidQuestionErrorMessage { get; set; } = "Please enter a different security question."; + [Parameter] public string UnknownErrorMessage { get; set; } = "Your account was not created. Please try again."; + + #endregion + + #region Link Properties + + [Parameter] public string EditProfileIconUrl { get; set; } + [Parameter] public string EditProfileText { get; set; } + [Parameter] public string EditProfileUrl { get; set; } + [Parameter] public string HelpPageIconUrl { get; set; } + [Parameter] public string HelpPageText { get; set; } + [Parameter] public string HelpPageUrl { get; set; } + + #endregion + + #region Layout Properties + + [Parameter] public int ActiveStepIndex { get; set; } + [Parameter] public int BorderPadding { get; set; } = 1; + [Parameter] public bool RenderOuterTable { get; set; } = true; + [Parameter] public bool DisplaySideBar { get; set; } = true; + + #endregion + + #region Events + + [Parameter] public EventCallback OnCreatingUser { get; set; } + [Parameter] public EventCallback OnCreatedUser { get; set; } + [Parameter] public EventCallback OnCreateUserError { get; set; } + [Parameter] public EventCallback OnCancelButtonClick { get; set; } + [Parameter] public EventCallback OnContinueButtonClick { get; set; } + [Parameter] public EventCallback OnActiveStepChanged { get; set; } + [Parameter] public EventCallback OnNextButtonClick { get; set; } + [Parameter] public EventCallback OnPreviousButtonClick { get; set; } + [Parameter] public EventCallback OnFinishButtonClick { get; set; } + + #endregion + + #region Templates + + [Parameter] public RenderFragment ChildContent { get; set; } + [Parameter] public RenderFragment CreateUserStep { get; set; } + [Parameter] public RenderFragment CompleteStep { get; set; } + [Parameter] public RenderFragment SideBarTemplate { get; set; } + [Parameter] public RenderFragment HeaderTemplate { get; set; } + + #endregion + + #region Style + + [CascadingParameter(Name = "FailureTextStyle")] + private TableItemStyle FailureTextStyle { get; set; } = new TableItemStyle(); + + [CascadingParameter(Name = "TitleTextStyle")] + private TableItemStyle TitleTextStyle { get; set; } = new TableItemStyle(); + + [CascadingParameter(Name = "LabelStyle")] + private TableItemStyle LabelStyle { get; set; } = new TableItemStyle(); + + [CascadingParameter(Name = "InstructionTextStyle")] + private TableItemStyle InstructionTextStyle { get; set; } = new TableItemStyle(); + + [CascadingParameter(Name = "TextBoxStyle")] + private Style TextBoxStyle { get; set; } = new Style(); + + [CascadingParameter(Name = "LoginButtonStyle")] + private Style LoginButtonStyle { get; set; } = new Style(); + + [CascadingParameter(Name = "ValidatorTextStyle")] + private Style ValidatorTextStyle { get; set; } = new Style(); + + [CascadingParameter(Name = "HyperLinkStyle")] + private TableItemStyle HyperLinkStyle { get; set; } = new TableItemStyle(); + + #endregion + + #region Services + + [Inject] + protected NavigationManager NavigationManager { get; set; } + + #endregion + + #region Internal State + + private CreateUserModel Model { get; set; } + private bool ShowCompleteStep { get; set; } + private bool ShowFailureText { get; set; } + private string FailureText { get; set; } + + private bool HasHelp => !string.IsNullOrEmpty(HelpPageText) || !string.IsNullOrEmpty(HelpPageIconUrl); + private bool HasEditProfile => !string.IsNullOrEmpty(EditProfileText) || !string.IsNullOrEmpty(EditProfileIconUrl); + private bool ShowSecurityQuestion => !string.IsNullOrEmpty(Model?.Question); + + #endregion + + protected override async Task OnInitializedAsync() + { + Model = new CreateUserModel(); + await base.OnInitializedAsync(); + } + + private async Task HandleValidSubmit() + { + var cancelArgs = new LoginCancelEventArgs { Sender = this }; + await OnCreatingUser.InvokeAsync(cancelArgs); + + if (cancelArgs.Cancel) + { + return; + } + + // Developer handles OnCreatingUser to perform actual user creation. + // If not cancelled, we consider it successful. + ShowFailureText = false; + ShowCompleteStep = true; + ActiveStepIndex = 1; + await OnCreatedUser.InvokeAsync(EventArgs.Empty); + await OnActiveStepChanged.InvokeAsync(EventArgs.Empty); + } + + internal async Task HandleCreateUserError(string errorMessage) + { + ShowFailureText = true; + FailureText = errorMessage ?? UnknownErrorMessage; + await OnCreateUserError.InvokeAsync(new CreateUserErrorEventArgs + { + ErrorMessage = FailureText, + Sender = this + }); + } + + private async Task HandleCancelClick() + { + await OnCancelButtonClick.InvokeAsync(EventArgs.Empty); + if (!string.IsNullOrEmpty(CancelDestinationPageUrl)) + { + NavigationManager.NavigateTo(CancelDestinationPageUrl); + } + } + + private async Task HandleContinueClick() + { + await OnContinueButtonClick.InvokeAsync(EventArgs.Empty); + if (!string.IsNullOrEmpty(ContinueDestinationPageUrl)) + { + NavigationManager.NavigateTo(ContinueDestinationPageUrl); + } + } + + protected override void HandleUnknownAttributes() + { + if (AdditionalAttributes?.Count > 0) + { + FailureTextStyle.FromUnknownAttributes(AdditionalAttributes, "FailureTextStyle-"); + TitleTextStyle.FromUnknownAttributes(AdditionalAttributes, "TitleTextStyle-"); + LabelStyle.FromUnknownAttributes(AdditionalAttributes, "LabelStyle-"); + InstructionTextStyle.FromUnknownAttributes(AdditionalAttributes, "InstructionTextStyle-"); + TextBoxStyle.FromUnknownAttributes(AdditionalAttributes, "TextBoxStyle-"); + LoginButtonStyle.FromUnknownAttributes(AdditionalAttributes, "LoginButtonStyle-"); + ValidatorTextStyle.FromUnknownAttributes(AdditionalAttributes, "ValidatorTextStyle-"); + HyperLinkStyle.FromUnknownAttributes(AdditionalAttributes, "HyperLinkStyle-"); + } + + base.HandleUnknownAttributes(); + } + + public class CreateUserModel + { + public string UserName { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string ConfirmPassword { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string Question { get; set; } = string.Empty; + public string Answer { get; set; } = string.Empty; + } + } +} diff --git a/src/BlazorWebFormsComponents/MultiView.razor b/src/BlazorWebFormsComponents/MultiView.razor new file mode 100644 index 00000000..0ed49f37 --- /dev/null +++ b/src/BlazorWebFormsComponents/MultiView.razor @@ -0,0 +1,5 @@ +@inherits BaseWebFormsComponent + + + @ChildContent + diff --git a/src/BlazorWebFormsComponents/MultiView.razor.cs b/src/BlazorWebFormsComponents/MultiView.razor.cs new file mode 100644 index 00000000..4ce7f599 --- /dev/null +++ b/src/BlazorWebFormsComponents/MultiView.razor.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; + +namespace BlazorWebFormsComponents +{ + public partial class MultiView : BaseWebFormsComponent + { + public const string NextViewCommandName = "NextView"; + public const string PreviousViewCommandName = "PrevView"; + public const string SwitchViewByIDCommandName = "SwitchViewByID"; + public const string SwitchViewByIndexCommandName = "SwitchViewByIndex"; + + private int _activeViewIndex = -1; + + [Parameter] + public int ActiveViewIndex + { + get => _activeViewIndex; + set + { + if (value < -1 || (Views.Count > 0 && value >= Views.Count)) + { + throw new ArgumentOutOfRangeException(nameof(ActiveViewIndex), + $"ActiveViewIndex is set to '{value}', which is out of range. Must be between -1 and {Views.Count - 1}."); + } + if (_activeViewIndex != value) + { + UpdateActiveView(_activeViewIndex, value); + _activeViewIndex = value; + } + } + } + + [Parameter] + public EventCallback OnActiveViewChanged { get; set; } + + [Parameter] + public RenderFragment ChildContent { get; set; } + + public List Views { get; } = new List(); + + public View GetActiveView() + { + if (_activeViewIndex < 0 || _activeViewIndex >= Views.Count) + { + throw new InvalidOperationException( + "The ActiveViewIndex is not set to a valid View control."); + } + return Views[_activeViewIndex]; + } + + public void SetActiveView(View view) + { + if (view == null) throw new ArgumentNullException(nameof(view)); + + var index = Views.IndexOf(view); + if (index < 0) + { + throw new ArgumentOutOfRangeException(nameof(view), + "The specified View is not part of this MultiView."); + } + ActiveViewIndex = index; + } + + internal void RegisterView(View view) + { + if (!Views.Contains(view)) + { + Views.Add(view); + + var index = Views.IndexOf(view); + if (index == _activeViewIndex) + { + view.Visible = true; + view.NotifyActivated(); + } + else + { + view.Visible = false; + } + } + } + + private void UpdateActiveView(int oldIndex, int newIndex) + { + if (oldIndex >= 0 && oldIndex < Views.Count) + { + Views[oldIndex].Visible = false; + Views[oldIndex].NotifyDeactivated(); + } + + if (newIndex >= 0 && newIndex < Views.Count) + { + Views[newIndex].Visible = true; + Views[newIndex].NotifyActivated(); + } + + OnActiveViewChanged.InvokeAsync(EventArgs.Empty); + } + + protected override void OnBubbledEvent(object sender, EventArgs args) + { + base.OnBubbledEvent(sender, args); + } + } +} diff --git a/src/BlazorWebFormsComponents/View.razor b/src/BlazorWebFormsComponents/View.razor new file mode 100644 index 00000000..905fd765 --- /dev/null +++ b/src/BlazorWebFormsComponents/View.razor @@ -0,0 +1,6 @@ +@inherits BaseWebFormsComponent + +@if (Visible) +{ + @ChildContent +} diff --git a/src/BlazorWebFormsComponents/View.razor.cs b/src/BlazorWebFormsComponents/View.razor.cs new file mode 100644 index 00000000..4cc0d15d --- /dev/null +++ b/src/BlazorWebFormsComponents/View.razor.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; + +namespace BlazorWebFormsComponents +{ + public partial class View : BaseWebFormsComponent + { + [CascadingParameter(Name = "ParentMultiView")] + public MultiView ParentMultiView { get; set; } + + [Parameter] + public RenderFragment ChildContent { get; set; } + + [Parameter] + public EventCallback OnActivate { get; set; } + + [Parameter] + public EventCallback OnDeactivate { get; set; } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + ParentMultiView?.RegisterView(this); + } + + internal void NotifyActivated() + { + OnActivate.InvokeAsync(EventArgs.Empty); + } + + internal void NotifyDeactivated() + { + OnDeactivate.InvokeAsync(EventArgs.Empty); + } + } +} diff --git a/status.md b/status.md index b5d55381..35acaefa 100644 --- a/status.md +++ b/status.md @@ -2,12 +2,12 @@ | Category | Completed | In Progress | Not Started | Total | |----------|-----------|-------------|-------------|-------| -| Editor Controls | 18 | 0 | 9 | 27 | +| Editor Controls | 20 | 0 | 7 | 27 | | Data Controls | 7 | 0 | 2 | 9 | | Validation Controls | 7 | 0 | 0 | 7 | | Navigation Controls | 3 | 0 | 0 | 3 | -| Login Controls | 4 | 0 | 3 | 7 | -| **TOTAL** | **39** | **0** | **14** | **53** | +| Login Controls | 6 | 0 | 1 | 7 | +| **TOTAL** | **41** | **0** | **12** | **53** | --- @@ -36,14 +36,14 @@ | FileUpload | 🔴 Not Started | Consider Blazor InputFile | | ImageMap | ✅ Complete | Documented, tested (23 tests) | | ListBox | ✅ Complete | Documented, tested, supports single/multi-select | -| Localize | 🔴 Not Started | Localization control | -| MultiView | 🔴 Not Started | Tab container | +| Localize | ✅ Complete | Documented, tested, inherits from Literal | +| MultiView | ✅ Complete | Documented, tested, with View component | | Panel | ✅ Complete | Documented, tested | | PlaceHolder | ✅ Complete | Documented, tested - renders no wrapper element | | RadioButtonList | ✅ Complete | Documented, tested (30 tests) | | Substitution | 🔴 Not Started | Cache substitution - may not apply | | Table | ✅ Complete | Includes TableRow, TableCell, TableHeaderCell, TableHeaderRow, TableFooterRow | -| View | 🔴 Not Started | Used with MultiView | +| View | ✅ Complete | Used with MultiView | | Xml | 🔴 Not Started | XML display/transform | ### 🟡 Data Controls (7/9 - 78% Complete) @@ -89,8 +89,8 @@ | LoginName | ✅ Complete | Documented, tested, sample page exists | | LoginStatus | ✅ Complete | Documented, tested, sample pages exist | | LoginView | ✅ Complete | Documented, tested | -| ChangePassword | 🔴 Not Started | Complex ASP.NET Identity integration | -| CreateUserWizard | 🔴 Not Started | Complex - user registration wizard | +| ChangePassword | ✅ Complete | Documented, tested, table-based layout | +| CreateUserWizard | ✅ Complete | Documented, tested, two-step wizard | | PasswordRecovery | 🔴 Not Started | Complex ASP.NET Identity integration | ---