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/26] 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/26] 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 000000000..c8bc574e2 --- /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 000000000..759f88921 --- /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/26] 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 000000000..98fce5bc1 --- /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 000000000..8a739ec3c --- /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 000000000..56c7d9014 --- /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/26] 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 000000000..042c7e228 --- /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 fb0ac6c9a..b1bdcf928 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 000000000..6de17db24 --- /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/26] 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 e9f411442..d64031797 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/26] 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 759f88921..7d144cd82 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/26] 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 6de17db24..a8de58061 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 56c7d9014..a1be59436 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 c8bc574e2..c0e256b13 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 7d144cd82..f36883c05 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 000000000..18ea80635 --- /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/26] 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 27a594ad6..2d9198b03 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 d02f190ae..f7146b3be 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 a8de58061..9353e2be2 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 000000000..1fe642e1f --- /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 000000000..edd22fda2 --- /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/26] 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 000000000..577ead2da --- /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 + + +
    [Parameter] public List> Columns { get; set; } + + /// + /// Whether this row is in edit mode + /// + [Parameter] public bool IsEditing { get; set; } + + /// + /// Style applied when the row is in edit mode + /// + [Parameter] public TableItemStyle EditRowStyle { get; set; } + + /// + /// Reference to the parent GridView + /// + [Parameter] public GridView GridView { get; set; } } } diff --git a/src/BlazorWebFormsComponents/GridViewSortEventArgs.cs b/src/BlazorWebFormsComponents/GridViewSortEventArgs.cs new file mode 100644 index 000000000..40fa2278d --- /dev/null +++ b/src/BlazorWebFormsComponents/GridViewSortEventArgs.cs @@ -0,0 +1,18 @@ +using BlazorWebFormsComponents.Enums; +using System; + +namespace BlazorWebFormsComponents +{ + public class GridViewSortEventArgs : EventArgs + { + public string SortExpression { get; set; } + public SortDirection SortDirection { get; set; } + public bool Cancel { get; set; } + + public GridViewSortEventArgs(string sortExpression, SortDirection sortDirection) + { + SortExpression = sortExpression; + SortDirection = sortDirection; + } + } +} diff --git a/src/BlazorWebFormsComponents/GridViewUpdateEventArgs.cs b/src/BlazorWebFormsComponents/GridViewUpdateEventArgs.cs new file mode 100644 index 000000000..01fe9d59a --- /dev/null +++ b/src/BlazorWebFormsComponents/GridViewUpdateEventArgs.cs @@ -0,0 +1,15 @@ +using System; + +namespace BlazorWebFormsComponents +{ + public class GridViewUpdateEventArgs : EventArgs + { + public int RowIndex { get; } + public bool Cancel { get; set; } + + public GridViewUpdateEventArgs(int rowIndex) + { + RowIndex = rowIndex; + } + } +} diff --git a/src/BlazorWebFormsComponents/HyperLink.razor b/src/BlazorWebFormsComponents/HyperLink.razor index 7d6ff5a4a..67f8dd632 100644 --- a/src/BlazorWebFormsComponents/HyperLink.razor +++ b/src/BlazorWebFormsComponents/HyperLink.razor @@ -3,13 +3,13 @@ @if (Visible) { - @if (NavigationUrl == null) + @if (NavigateUrl == null) { @Text } else { - @Text + @Text } } diff --git a/src/BlazorWebFormsComponents/HyperLink.razor.cs b/src/BlazorWebFormsComponents/HyperLink.razor.cs index 79c825226..4ed15461c 100644 --- a/src/BlazorWebFormsComponents/HyperLink.razor.cs +++ b/src/BlazorWebFormsComponents/HyperLink.razor.cs @@ -1,11 +1,19 @@ -using Microsoft.AspNetCore.Components; +using System; +using Microsoft.AspNetCore.Components; namespace BlazorWebFormsComponents { public partial class HyperLink : BaseStyledComponent { [Parameter] - public string NavigationUrl { get; set; } + public string NavigateUrl { get; set; } + + [Obsolete("Use NavigateUrl instead")] + public string NavigationUrl + { + get => NavigateUrl; + set => NavigateUrl = value; + } [Parameter] public string Target { get; set; } = string.Empty; diff --git a/src/BlazorWebFormsComponents/Image.razor b/src/BlazorWebFormsComponents/Image.razor index 5ea365dbb..c5222ca36 100644 --- a/src/BlazorWebFormsComponents/Image.razor +++ b/src/BlazorWebFormsComponents/Image.razor @@ -1,41 +1,49 @@ -@using System.Text -@inherits BaseWebFormsComponent -@{ - var element = new StringBuilder($"\"{0}\"", +} - if (!string.IsNullOrEmpty(ToolTip)) - { - element.AppendFormat(" title=\"{0}\"", ToolTip); - } +@code { + private string GetAltText() + { + if (!string.IsNullOrEmpty(AlternateText)) + return AlternateText; + if (GenerateEmptyAlternateText) + return ""; + return null; + } - if(ImageAlign != Enums.ImageAlign.NotSet) - { - element.AppendFormat(" align=\"{0}\"", ImageAlign.ToString().ToLower()); - } + private string GetId() + { + return !string.IsNullOrEmpty(ClientID) ? ClientID : null; + } - element.Append(" />"); -} + private string GetCssClassOrNull() + { + return !string.IsNullOrEmpty(CssClass) ? CssClass : null; + } -@if (Visible) -{ - @((MarkupString)element.ToString()) + private string GetTitle() + { + return !string.IsNullOrEmpty(ToolTip) ? ToolTip : null; + } + + private string GetLongDesc() + { + return DescriptionUrl; + } + + private string GetAlign() + { + return ImageAlign != Enums.ImageAlign.NotSet ? ImageAlign.ToString().ToLower() : null; + } } diff --git a/src/BlazorWebFormsComponents/Image.razor.cs b/src/BlazorWebFormsComponents/Image.razor.cs index b8afdff64..c94f233b0 100644 --- a/src/BlazorWebFormsComponents/Image.razor.cs +++ b/src/BlazorWebFormsComponents/Image.razor.cs @@ -4,7 +4,7 @@ namespace BlazorWebFormsComponents { - public partial class Image : BaseWebFormsComponent, IImageComponent + public partial class Image : BaseStyledComponent, IImageComponent { [Parameter] public string AlternateText { get; set; } diff --git a/src/BlazorWebFormsComponents/Interfaces/ICalendarStyleContainer.cs b/src/BlazorWebFormsComponents/Interfaces/ICalendarStyleContainer.cs new file mode 100644 index 000000000..38892f68d --- /dev/null +++ b/src/BlazorWebFormsComponents/Interfaces/ICalendarStyleContainer.cs @@ -0,0 +1,18 @@ +namespace BlazorWebFormsComponents.Interfaces +{ + /// + /// Interface for components that contain Calendar-specific TableItemStyle properties. + /// + public interface ICalendarStyleContainer + { + TableItemStyle DayStyle { get; } + TableItemStyle TitleStyle { get; } + TableItemStyle DayHeaderStyle { get; } + TableItemStyle TodayDayStyle { get; } + TableItemStyle SelectedDayStyle { get; } + TableItemStyle OtherMonthDayStyle { get; } + TableItemStyle WeekendDayStyle { get; } + TableItemStyle NextPrevStyle { get; } + TableItemStyle SelectorStyle { get; } + } +} diff --git a/src/BlazorWebFormsComponents/Interfaces/IColumn.cs b/src/BlazorWebFormsComponents/Interfaces/IColumn.cs index 83727d1f2..b764ec816 100644 --- a/src/BlazorWebFormsComponents/Interfaces/IColumn.cs +++ b/src/BlazorWebFormsComponents/Interfaces/IColumn.cs @@ -12,10 +12,20 @@ public interface IColumn /// string HeaderText { get; set; } + /// + /// The sort expression for the column + /// + string SortExpression { get; set; } + /// /// The parent IColumnCollection where the IColumn resides /// IColumnCollection ParentColumnsCollection { get; set; } RenderFragment Render(ItemType item); + + /// + /// Renders the column in edit mode. Falls back to Render if not overridden. + /// + RenderFragment RenderEdit(ItemType item); } } diff --git a/src/BlazorWebFormsComponents/Label.razor b/src/BlazorWebFormsComponents/Label.razor index fa0eaff9a..249d14b78 100644 --- a/src/BlazorWebFormsComponents/Label.razor +++ b/src/BlazorWebFormsComponents/Label.razor @@ -1,6 +1,25 @@ -@inherits BaseWebFormsComponent +@inherits BaseStyledComponent @if (Visible) { - @Text + @if (!string.IsNullOrEmpty(AssociatedControlID)) + { + + } + else + { + @Text + } +} + +@code { + private string GetCssClassOrNull() + { + return !string.IsNullOrEmpty(CssClass) ? CssClass : null; + } + + private string GetAccessKeyOrNull() + { + return !string.IsNullOrEmpty(AccessKey) ? AccessKey : null; + } } diff --git a/src/BlazorWebFormsComponents/Label.razor.cs b/src/BlazorWebFormsComponents/Label.razor.cs index c4fb56ebf..244267655 100644 --- a/src/BlazorWebFormsComponents/Label.razor.cs +++ b/src/BlazorWebFormsComponents/Label.razor.cs @@ -3,9 +3,12 @@ namespace BlazorWebFormsComponents { - public partial class Label : BaseWebFormsComponent, ITextComponent + public partial class Label : BaseStyledComponent, ITextComponent { [Parameter] public string Text { get; set; } + + [Parameter] + public string AssociatedControlID { get; set; } } } diff --git a/src/BlazorWebFormsComponents/ListBox.razor b/src/BlazorWebFormsComponents/ListBox.razor index c399ba722..5627fadd4 100644 --- a/src/BlazorWebFormsComponents/ListBox.razor +++ b/src/BlazorWebFormsComponents/ListBox.razor @@ -1,7 +1,7 @@ @using BlazorWebFormsComponents.DataBinding @using BlazorWebFormsComponents.Enums @typeparam TItem -@inherits DataBoundComponent +@inherits BaseListControl @if (Visible) { diff --git a/src/BlazorWebFormsComponents/ListBox.razor.cs b/src/BlazorWebFormsComponents/ListBox.razor.cs index 9582c23b2..f169ed13c 100644 --- a/src/BlazorWebFormsComponents/ListBox.razor.cs +++ b/src/BlazorWebFormsComponents/ListBox.razor.cs @@ -13,14 +13,8 @@ namespace BlazorWebFormsComponents /// Represents a list box control that allows the user to select one or more items from a list. /// /// The type of items in the data source. - public partial class ListBox : DataBoundComponent, IStyle + public partial class ListBox : BaseListControl { - /// - /// Gets or sets the collection of list items in the ListBox. - /// - [Parameter] - public ListItemCollection StaticItems { get; set; } = new(); - /// /// Gets or sets the selected value. /// @@ -57,18 +51,6 @@ public partial class ListBox : DataBoundComponent, IStyle [Parameter] public EventCallback SelectedIndexChanged { get; set; } - /// - /// Gets or sets the field of the data source that provides the text content of the list items. - /// - [Parameter] - public string DataTextField { get; set; } - - /// - /// Gets or sets the field of the data source that provides the value of each list item. - /// - [Parameter] - public string DataValueField { get; set; } - /// /// Gets or sets the number of rows displayed in the ListBox control. /// @@ -105,36 +87,6 @@ public partial class ListBox : DataBoundComponent, IStyle public IEnumerable SelectedItems => GetItems().Where(i => SelectedValues.Contains(i.Value)); - // IStyle implementation - [Parameter] - public WebColor BackColor { get; set; } - - [Parameter] - public WebColor BorderColor { get; set; } - - [Parameter] - public BorderStyle BorderStyle { get; set; } - - [Parameter] - public Unit BorderWidth { get; set; } - - [Parameter] - public string CssClass { get; set; } - - [Parameter] - public FontInfo Font { get; set; } = new FontInfo(); - - [Parameter] - public WebColor ForeColor { get; set; } - - [Parameter] - public Unit Height { get; set; } - - [Parameter] - public Unit Width { get; set; } - - protected string Style => this.ToStyle().NullIfEmpty(); - private bool IsSelected(string value) { if (SelectionMode == ListSelectionMode.Multiple) @@ -169,35 +121,5 @@ private async Task HandleChange(ChangeEventArgs e) await OnSelectedIndexChanged.InvokeAsync(e); } - private IEnumerable GetItems() - { - // Return static Items first - foreach (var item in StaticItems) - { - yield return item; - } - - // Then data-bound items - if (Items != null) - { - foreach (var dataItem in Items) - { - yield return new ListItem - { - Text = GetPropertyValue(dataItem, DataTextField), - Value = GetPropertyValue(dataItem, DataValueField) - }; - } - } - } - - private string GetPropertyValue(TItem item, string propertyName) - { - if (string.IsNullOrEmpty(propertyName)) - return item?.ToString() ?? string.Empty; - - var prop = typeof(TItem).GetProperty(propertyName); - return prop?.GetValue(item)?.ToString() ?? string.Empty; - } } } diff --git a/src/BlazorWebFormsComponents/ListView.razor.cs b/src/BlazorWebFormsComponents/ListView.razor.cs index 728fbc435..2b49f7cf7 100644 --- a/src/BlazorWebFormsComponents/ListView.razor.cs +++ b/src/BlazorWebFormsComponents/ListView.razor.cs @@ -48,7 +48,7 @@ public ListView() /// Style is not applied by this control /// [Parameter, Obsolete("Style is not applied by this control")] - public string Style { get; set; } + public new string Style { get; set; } [Parameter] public RenderFragment ChildContent { get; set; } diff --git a/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor b/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor index d6c83deaf..e4b767dc3 100644 --- a/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor +++ b/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor @@ -1,4 +1,4 @@ -@inherits BaseWebFormsComponent +@inherits BaseStyledComponent @using BlazorWebFormsComponents.Validations; @using Microsoft.AspNetCore.Components.Forms; @@ -32,7 +32,7 @@ } else { - +
    @@ -172,7 +172,7 @@ } else { - +
    @@ -199,3 +199,10 @@ } + +@code { + private string GetCssClassOrNull() + { + return !string.IsNullOrEmpty(CssClass) ? CssClass : null; + } +} diff --git a/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor.cs b/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor.cs index e202858fb..b5ff0fb1e 100644 --- a/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor.cs +++ b/src/BlazorWebFormsComponents/LoginControls/ChangePassword.razor.cs @@ -7,7 +7,7 @@ namespace BlazorWebFormsComponents.LoginControls { - public partial class ChangePassword : BaseWebFormsComponent + public partial class ChangePassword : BaseStyledComponent { #region Obsolete @@ -238,6 +238,7 @@ protected override void HandleUnknownAttributes() HyperLinkStyle.FromUnknownAttributes(AdditionalAttributes, "HyperLinkStyle-"); } + this.SetFontsFromAttributes(AdditionalAttributes); base.HandleUnknownAttributes(); } diff --git a/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor b/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor index f097e01c4..c72027ada 100644 --- a/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor +++ b/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor @@ -1,4 +1,4 @@ -@inherits BaseWebFormsComponent +@inherits BaseStyledComponent @using BlazorWebFormsComponents.Validations; @using Microsoft.AspNetCore.Components.Forms; @@ -32,7 +32,7 @@ } else { - +
    @if (DisplaySideBar) @@ -188,7 +188,7 @@ } else { -
    +
    @@ -215,3 +215,10 @@ } + +@code { + private string GetCssClassOrNull() + { + return !string.IsNullOrEmpty(CssClass) ? CssClass : null; + } +} diff --git a/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor.cs b/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor.cs index 3d4edd46c..65e12a813 100644 --- a/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor.cs +++ b/src/BlazorWebFormsComponents/LoginControls/CreateUserWizard.razor.cs @@ -7,7 +7,7 @@ namespace BlazorWebFormsComponents.LoginControls { - public partial class CreateUserWizard : BaseWebFormsComponent + public partial class CreateUserWizard : BaseStyledComponent { #region Obsolete @@ -241,6 +241,7 @@ protected override void HandleUnknownAttributes() HyperLinkStyle.FromUnknownAttributes(AdditionalAttributes, "HyperLinkStyle-"); } + this.SetFontsFromAttributes(AdditionalAttributes); base.HandleUnknownAttributes(); } diff --git a/src/BlazorWebFormsComponents/LoginControls/Login.razor b/src/BlazorWebFormsComponents/LoginControls/Login.razor index e17f86e12..f072d9b5d 100644 --- a/src/BlazorWebFormsComponents/LoginControls/Login.razor +++ b/src/BlazorWebFormsComponents/LoginControls/Login.razor @@ -1,4 +1,4 @@ -@inherits BaseWebFormsComponent +@inherits BaseStyledComponent @using BlazorWebFormsComponents.Validations; @using Microsoft.AspNetCore.Components.Forms; @@ -29,7 +29,7 @@ @if (VisibleWhenLoggedIn || !UserAuthenticated) { - +
    } diff --git a/src/BlazorWebFormsComponents/Menu.razor.cs b/src/BlazorWebFormsComponents/Menu.razor.cs index 150b7e20b..e85e452d0 100644 --- a/src/BlazorWebFormsComponents/Menu.razor.cs +++ b/src/BlazorWebFormsComponents/Menu.razor.cs @@ -229,6 +229,17 @@ public StaticMenuItemStyle StaticMenuItemStyle { [Parameter] public RenderFragment ChildContent { get; set; } + protected override void OnParametersSet() + { + base.OnParametersSet(); + + // Auto-generate an ID if none was provided, ensuring JS interop has a valid element ID + if (string.IsNullOrEmpty(ID)) + { + ID = $"menu_{GetHashCode():x}"; + } + } + protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); diff --git a/src/BlazorWebFormsComponents/wwwroot/Menu/Menu.js b/src/BlazorWebFormsComponents/wwwroot/Menu/Menu.js index 918f5a94e..fd60272b4 100644 --- a/src/BlazorWebFormsComponents/wwwroot/Menu/Menu.js +++ b/src/BlazorWebFormsComponents/wwwroot/Menu/Menu.js @@ -10,11 +10,16 @@ if (!Sys.WebForms) { Sys.WebForms = {}; } // including allocation of the _MenuContainer object. Sys.WebForms.Menu = function(options) { + try { this.items = []; this.depth = options.depth || 1; this.parentMenuItem = options.parentMenuItem; this.element = Sys.WebForms.Menu._domHelper.getElement(options.element); + if (!this.element) { + return; + } + if (this.element.tagName === 'DIV') { var containerElement = this.element; this.element = Sys.WebForms.Menu._domHelper.firstChild(containerElement); @@ -104,6 +109,10 @@ Sys.WebForms.Menu = function(options) { } this.items[this.items.length] = menuItem; } + } catch (e) { + // Prevent unhandled JS exceptions from crashing the Blazor circuit + console.warn('Menu initialization error:', e.message); + } }; Sys.WebForms.Menu.prototype = { From 06796af7c27419b150354606768e75b81bb1ffc7 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Tue, 24 Feb 2026 12:58:11 -0500 Subject: [PATCH 21/26] docs: update Cyclops history and decision inbox for M8 bug fixes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .ai-team/agents/cyclops/history.md | 6 +++ .../inbox/cyclops-menu-auto-id-pattern.md | 18 ++++++++ README.md | 12 ++--- docs/DataControls/Chart.md | 6 +-- docs/Migration/DeferredControls.md | 12 ++--- mkdocs.yml | 5 +-- status.md | 44 +++++++++---------- 7 files changed, 63 insertions(+), 40 deletions(-) create mode 100644 .ai-team/decisions/inbox/cyclops-menu-auto-id-pattern.md diff --git a/.ai-team/agents/cyclops/history.md b/.ai-team/agents/cyclops/history.md index ffb48bd8d..8b25f3def 100644 --- a/.ai-team/agents/cyclops/history.md +++ b/.ai-team/agents/cyclops/history.md @@ -63,3 +63,9 @@ Audited 13 controls. Found: AccessKey/ToolTip missing from base class (universal Team update (2026-02-23): P2 test observation Login/ChangePassword/CreateUserWizard already inherit BaseStyledComponent, so WI-52 may have been a no-op or template-only change decided by Rogue Team update (2026-02-23): Milestone 6 Work Plan ratified 54 WIs across P0/P1/P2 tiers targeting ~345 feature gaps decided by Forge Team update (2026-02-23): UI overhaul requested ComponentCatalog (UI-2) and search (UI-8) assigned to Cyclops decided by Jeffrey T. Fritz + +### Milestone 8 Release-Readiness Bug Fixes (2026-02-24) + +- **Menu JS interop crash (Bug 1):** `Menu.js` `Sys.WebForms.Menu` constructor crashes when `getElement()` returns null (e.g., headless Chrome timing). Fixed by adding null guard after `getElement()` (early return if element missing) and wrapping entire constructor body in try/catch to prevent unhandled exceptions from killing the Blazor circuit. File: `src/BlazorWebFormsComponents/wwwroot/Menu/Menu.js`. +- **Calendar attribute rendering (Bug 2):** `Calendar.razor` line 64 used raw Razor expression injection to conditionally add `scope="col"` to `
    @@ -158,7 +158,10 @@ public ForwardRef> UsernameInput = new ForwardRef>(); public ForwardRef> PasswordInput = new ForwardRef>(); - + private string GetCssClassOrNull() + { + return !string.IsNullOrEmpty(CssClass) ? CssClass : null; + } public class LoginModel diff --git a/src/BlazorWebFormsComponents/LoginControls/Login.razor.cs b/src/BlazorWebFormsComponents/LoginControls/Login.razor.cs index e244736e4..faacc5df1 100644 --- a/src/BlazorWebFormsComponents/LoginControls/Login.razor.cs +++ b/src/BlazorWebFormsComponents/LoginControls/Login.razor.cs @@ -7,7 +7,7 @@ namespace BlazorWebFormsComponents.LoginControls { - public partial class Login : BaseWebFormsComponent + public partial class Login : BaseStyledComponent { #region Obsolete Attributes / Properties @@ -184,6 +184,7 @@ protected override void HandleUnknownAttributes() } + this.SetFontsFromAttributes(AdditionalAttributes); base.HandleUnknownAttributes(); } diff --git a/src/BlazorWebFormsComponents/Menu.razor b/src/BlazorWebFormsComponents/Menu.razor index e27c86f42..fcd84553a 100644 --- a/src/BlazorWebFormsComponents/Menu.razor +++ b/src/BlazorWebFormsComponents/Menu.razor @@ -1,8 +1,9 @@ @inherits BaseWebFormsComponent @using BlazorComponentUtilities +@using BlazorWebFormsComponents.Enums
    -
      +
        @ChildContent @ChildNodesRenderFragment @@ -29,6 +30,10 @@ width: auto; } + @($"#{ID} ul.horizontal > li") { + display: inline-block; + } + @($"#{ID} ul.dynamic") { @DynamicMenuStyle?.ToStyle().ToString() z-index: 1; diff --git a/src/BlazorWebFormsComponents/Menu.razor.cs b/src/BlazorWebFormsComponents/Menu.razor.cs index 960201fb4..7e1b7550c 100644 --- a/src/BlazorWebFormsComponents/Menu.razor.cs +++ b/src/BlazorWebFormsComponents/Menu.razor.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using System.Xml; using BlazorComponentUtilities; +using BlazorWebFormsComponents.Enums; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.JSInterop; @@ -30,6 +31,9 @@ public partial class Menu : BaseWebFormsComponent [Parameter] public int DisappearAfter { get; set; } + [Parameter] + public Orientation Orientation { get; set; } = Orientation.Vertical; + [Parameter] public int StaticDisplayLevels { get; set; } @@ -110,7 +114,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { - await JS.InvokeVoidAsync("bwfc.Page.AddScriptElement", $"{StaticFilesLocation}Menu/Menu.js", $"new Sys.WebForms.Menu({{ element: '{ID}', disappearAfter: {DisappearAfter}, orientation: 'vertical', tabIndex: 0, disabled: false }});"); + await JS.InvokeVoidAsync("bwfc.Page.AddScriptElement", $"{StaticFilesLocation}Menu/Menu.js", $"new Sys.WebForms.Menu({{ element: '{ID}', disappearAfter: {DisappearAfter}, orientation: '{Orientation.ToString().ToLower()}', tabIndex: 0, disabled: false }});"); } } diff --git a/src/BlazorWebFormsComponents/RadioButton.razor.cs b/src/BlazorWebFormsComponents/RadioButton.razor.cs index e9311d623..4b02efb13 100644 --- a/src/BlazorWebFormsComponents/RadioButton.razor.cs +++ b/src/BlazorWebFormsComponents/RadioButton.razor.cs @@ -1,3 +1,4 @@ +using BlazorWebFormsComponents.Validations; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using System; @@ -10,6 +11,15 @@ public partial class RadioButton : BaseStyledComponent private string _inputId => !string.IsNullOrEmpty(ClientID) ? ClientID : _generatedInputId; private readonly string _generatedInputId = Guid.NewGuid().ToString("N"); + [Parameter] + public bool CausesValidation { get; set; } = true; + + [Parameter] + public string ValidationGroup { get; set; } + + [CascadingParameter(Name = "ValidationGroupCoordinator")] + protected ValidationGroupCoordinator Coordinator { get; set; } + [Parameter] public bool Checked { get; set; } @@ -36,6 +46,12 @@ public partial class RadioButton : BaseStyledComponent private async Task HandleChange(ChangeEventArgs e) { Checked = true; + + if (CausesValidation && Coordinator != null) + { + Coordinator.ValidateGroup(ValidationGroup); + } + await CheckedChanged.InvokeAsync(Checked); await OnCheckedChanged.InvokeAsync(e); } diff --git a/src/BlazorWebFormsComponents/RadioButtonList.razor b/src/BlazorWebFormsComponents/RadioButtonList.razor index e49cb2aa8..e7385a564 100644 --- a/src/BlazorWebFormsComponents/RadioButtonList.razor +++ b/src/BlazorWebFormsComponents/RadioButtonList.razor @@ -1,7 +1,7 @@ @using BlazorWebFormsComponents.DataBinding @using BlazorWebFormsComponents.Enums @typeparam TItem -@inherits DataBoundComponent +@inherits BaseListControl @if (Visible) { diff --git a/src/BlazorWebFormsComponents/RadioButtonList.razor.cs b/src/BlazorWebFormsComponents/RadioButtonList.razor.cs index 04dd58ce7..68e757638 100644 --- a/src/BlazorWebFormsComponents/RadioButtonList.razor.cs +++ b/src/BlazorWebFormsComponents/RadioButtonList.razor.cs @@ -13,28 +13,10 @@ namespace BlazorWebFormsComponents /// Represents a list control that displays a group of radio buttons for single selection. /// /// The type of items in the data source. - public partial class RadioButtonList : DataBoundComponent, IStyle + public partial class RadioButtonList : BaseListControl { private string _groupName = Guid.NewGuid().ToString("N"); - /// - /// Gets or sets the collection of static list items in the RadioButtonList. - /// - [Parameter] - public ListItemCollection StaticItems { get; set; } = new(); - - /// - /// Gets or sets the field of the data source that provides the text content of the list items. - /// - [Parameter] - public string DataTextField { get; set; } - - /// - /// Gets or sets the field of the data source that provides the value of each list item. - /// - [Parameter] - public string DataValueField { get; set; } - /// /// Gets or sets the number of columns to display in the list control. /// @@ -113,36 +95,6 @@ public partial class RadioButtonList : DataBoundComponent, IStyle /// public ListItem SelectedItem => GetItems().FirstOrDefault(i => i.Value == SelectedValue); - // IStyle implementation - [Parameter] - public WebColor BackColor { get; set; } - - [Parameter] - public WebColor BorderColor { get; set; } - - [Parameter] - public BorderStyle BorderStyle { get; set; } - - [Parameter] - public Unit BorderWidth { get; set; } - - [Parameter] - public string CssClass { get; set; } - - [Parameter] - public FontInfo Font { get; set; } = new FontInfo(); - - [Parameter] - public WebColor ForeColor { get; set; } - - [Parameter] - public Unit Height { get; set; } - - [Parameter] - public Unit Width { get; set; } - - protected string Style => this.ToStyle().NullIfEmpty(); - private async Task HandleChange(ListItem item, ChangeEventArgs e) { SelectedValue = item.Value; @@ -155,37 +107,6 @@ private async Task HandleChange(ListItem item, ChangeEventArgs e) await OnSelectedIndexChanged.InvokeAsync(e); } - private IEnumerable GetItems() - { - // Return static Items first - foreach (var item in StaticItems) - { - yield return item; - } - - // Then data-bound items - if (Items != null) - { - foreach (var dataItem in Items) - { - yield return new ListItem - { - Text = GetPropertyValue(dataItem, DataTextField), - Value = GetPropertyValue(dataItem, DataValueField) - }; - } - } - } - - private string GetPropertyValue(TItem item, string propertyName) - { - if (string.IsNullOrEmpty(propertyName)) - return item?.ToString() ?? string.Empty; - - var prop = typeof(TItem).GetProperty(propertyName); - return prop?.GetValue(item)?.ToString() ?? string.Empty; - } - private string GetInputId(int index) => $"{_groupName}_{index}"; } } diff --git a/src/BlazorWebFormsComponents/TemplateField.razor.cs b/src/BlazorWebFormsComponents/TemplateField.razor.cs index 2dd386859..864281486 100644 --- a/src/BlazorWebFormsComponents/TemplateField.razor.cs +++ b/src/BlazorWebFormsComponents/TemplateField.razor.cs @@ -12,9 +12,23 @@ public partial class TemplateField : BaseColumn /// [Parameter] public RenderFragment ItemTemplate { get; set; } + /// + /// The template to display when the row is in edit mode. + /// + [Parameter] public RenderFragment EditItemTemplate { get; set; } + public override RenderFragment Render(ItemType item) { return ItemTemplate(item); } + + public override RenderFragment RenderEdit(ItemType item) + { + if (EditItemTemplate != null) + { + return EditItemTemplate(item); + } + return Render(item); + } } } diff --git a/src/BlazorWebFormsComponents/TextBox.razor.cs b/src/BlazorWebFormsComponents/TextBox.razor.cs index f55da7429..724fd623f 100644 --- a/src/BlazorWebFormsComponents/TextBox.razor.cs +++ b/src/BlazorWebFormsComponents/TextBox.razor.cs @@ -1,5 +1,6 @@ using BlazorWebFormsComponents.Enums; using BlazorWebFormsComponents.Interfaces; +using BlazorWebFormsComponents.Validations; using Microsoft.AspNetCore.Components; using System; using System.Collections.Generic; @@ -8,6 +9,15 @@ namespace BlazorWebFormsComponents { public partial class TextBox : BaseStyledComponent, ITextComponent { + [Parameter] + public bool CausesValidation { get; set; } = true; + + [Parameter] + public string ValidationGroup { get; set; } + + [CascadingParameter(Name = "ValidationGroupCoordinator")] + protected ValidationGroupCoordinator Coordinator { get; set; } + [Parameter] public string Text { get; set; } = string.Empty; diff --git a/src/BlazorWebFormsComponents/TreeView.razor.cs b/src/BlazorWebFormsComponents/TreeView.razor.cs index 45cc78b28..ee0a0f928 100644 --- a/src/BlazorWebFormsComponents/TreeView.razor.cs +++ b/src/BlazorWebFormsComponents/TreeView.razor.cs @@ -10,7 +10,7 @@ namespace BlazorWebFormsComponents { - public partial class TreeView : BaseDataBoundComponent, IStyle + public partial class TreeView : BaseDataBoundComponent { [Parameter] @@ -37,20 +37,6 @@ public partial class TreeView : BaseDataBoundComponent, IStyle [Parameter] public bool UseAccessibilityFeatures { get; set; } = false; - #region IHasStyle - - [Parameter] public WebColor BackColor { get; set; } - [Parameter] public WebColor BorderColor { get; set; } - [Parameter] public BorderStyle BorderStyle { get; set; } - [Parameter] public Unit BorderWidth { get; set; } - [Parameter] public string CssClass { get; set; } - [Parameter] public WebColor ForeColor { get; set; } - [Parameter] public Unit Height { get; set; } - [Parameter] public Unit Width { get; set; } - [Parameter] public FontInfo Font { get; set; } = new FontInfo(); - - #endregion - #region Events protected override async Task OnAfterRenderAsync(bool firstRender) diff --git a/src/BlazorWebFormsComponents/Validations/AspNetValidationSummary.razor b/src/BlazorWebFormsComponents/Validations/AspNetValidationSummary.razor index 17b591a1c..0ec22f79a 100644 --- a/src/BlazorWebFormsComponents/Validations/AspNetValidationSummary.razor +++ b/src/BlazorWebFormsComponents/Validations/AspNetValidationSummary.razor @@ -1,11 +1,15 @@ @using BlazorWebFormsComponents.Enums @inherits BaseStyledComponent -@if (Enabled && Visible) +@if (Enabled && Visible && ShowSummary) {
        @if (IsValid) { + @if (!string.IsNullOrEmpty(HeaderText)) + { + @HeaderText + } @switch (DisplayMode) { case BulletListDisplayMode b: diff --git a/src/BlazorWebFormsComponents/Validations/AspNetValidationSummary.razor.cs b/src/BlazorWebFormsComponents/Validations/AspNetValidationSummary.razor.cs index 8623c8ad0..33634a8eb 100644 --- a/src/BlazorWebFormsComponents/Validations/AspNetValidationSummary.razor.cs +++ b/src/BlazorWebFormsComponents/Validations/AspNetValidationSummary.razor.cs @@ -16,9 +16,32 @@ public partial class AspNetValidationSummary : BaseStyledComponent, IDisposable [Parameter] public ValidationSummaryDisplayMode DisplayMode { get; set; } = ValidationSummaryDisplayMode.BulletList; - public bool IsValid => CurrentEditContext.GetValidationMessages().Any(); + [Parameter] public string HeaderText { get; set; } - public IEnumerable ValidationMessages => CurrentEditContext.GetValidationMessages().Select(x => x.Split(',')[1]); + [Parameter] public bool ShowSummary { get; set; } = true; + + [Parameter] public string ValidationGroup { get; set; } + + public bool IsValid => FilteredMessages.Any(); + + public IEnumerable ValidationMessages => FilteredMessages.Select(x => x.Split('\x1F')[0].Split(',')[1]); + + private IEnumerable FilteredMessages + { + get + { + var messages = CurrentEditContext.GetValidationMessages(); + if (!string.IsNullOrEmpty(ValidationGroup)) + { + messages = messages.Where(x => + { + var parts = x.Split('\x1F'); + return parts.Length > 1 && parts[1] == ValidationGroup; + }); + } + return messages; + } + } public AspNetValidationSummary() { diff --git a/src/BlazorWebFormsComponents/Validations/BaseValidator.razor b/src/BlazorWebFormsComponents/Validations/BaseValidator.razor index 397a4e3de..33a79cde5 100644 --- a/src/BlazorWebFormsComponents/Validations/BaseValidator.razor +++ b/src/BlazorWebFormsComponents/Validations/BaseValidator.razor @@ -1,9 +1,9 @@ @inherits BaseStyledComponent @typeparam Type -@if (Enabled && Visible && !IsValid) +@if (Enabled && Visible) { - + @if (string.IsNullOrWhiteSpace(Text)) { @ErrorMessage diff --git a/src/BlazorWebFormsComponents/Validations/BaseValidator.razor.cs b/src/BlazorWebFormsComponents/Validations/BaseValidator.razor.cs index bde38b2ec..15037ad60 100644 --- a/src/BlazorWebFormsComponents/Validations/BaseValidator.razor.cs +++ b/src/BlazorWebFormsComponents/Validations/BaseValidator.razor.cs @@ -6,6 +6,7 @@ using System.Linq.Expressions; using System.Reflection; using System.Threading.Tasks; +using Microsoft.JSInterop; namespace BlazorWebFormsComponents.Validations { @@ -25,9 +26,19 @@ public abstract partial class BaseValidator : BaseStyledComponent, IValida [Parameter] public string ValidationGroup { get; set; } [Parameter] public HorizontalAlign HorizontalAlign { get; set; } [Parameter] public VerticalAlign VerticalAlign { get; set; } + [Parameter] public ValidatorDisplay Display { get; set; } = ValidatorDisplay.Static; + [Parameter] public bool SetFocusOnError { get; set; } public abstract bool Validate(string value); + protected string DisplayStyle => Display switch + { + ValidatorDisplay.None => "display:none;", + ValidatorDisplay.Dynamic when IsValid => "display:none;", + ValidatorDisplay.Static when IsValid => "visibility:hidden;", + _ => "" + }; + protected StyleBuilder CalculatedStyle => this.ToStyle(); protected override void OnInitialized() @@ -100,7 +111,12 @@ private void EventHandler(object sender, ValidationRequestedEventArgs eventArgs) { IsValid = false; // Text is for validator, ErrorMessage is for validation summary - _messageStore.Add(fieldIdentifier, Text + "," + ErrorMessage); + _messageStore.Add(fieldIdentifier, Text + "," + ErrorMessage + "\x1F" + (ValidationGroup ?? "")); + + if (SetFocusOnError) + { + _ = JsRuntime.InvokeVoidAsync("bwfc.Validation.SetFocus", fieldIdentifier.FieldName); + } } CurrentEditContext.NotifyValidationStateChanged(); From 4dc464fb90a75cc19e0f86cf95dd99da5e8421e8 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Tue, 24 Feb 2026 12:57:08 -0500 Subject: [PATCH 20/26] fix: Menu JS interop crash, Calendar attribute rendering, Menu auto-ID generation - Add null safety and try/catch in Menu.js to prevent circuit crash - Fix Calendar.razor attribute rendering issue - Auto-generate Menu ID when not explicitly provided Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../inbox/copilot-directive-20260224-m8-scope.md | 4 ++++ src/BlazorWebFormsComponents/Calendar.razor | 2 +- src/BlazorWebFormsComponents/Menu.razor.cs | 11 +++++++++++ src/BlazorWebFormsComponents/wwwroot/Menu/Menu.js | 9 +++++++++ 4 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 .ai-team/decisions/inbox/copilot-directive-20260224-m8-scope.md diff --git a/.ai-team/decisions/inbox/copilot-directive-20260224-m8-scope.md b/.ai-team/decisions/inbox/copilot-directive-20260224-m8-scope.md new file mode 100644 index 000000000..2c3af642c --- /dev/null +++ b/.ai-team/decisions/inbox/copilot-directive-20260224-m8-scope.md @@ -0,0 +1,4 @@ +### 2026-02-24: User directive — M8 scope excludes version bump and release +**By:** Jeffrey T. Fritz (via Copilot) +**What:** Start on Forge's recommended Milestone 8 next steps EXCEPT the version bump to 1.0 and release. Focus on: Menu JS fix, Calendar fix, Menu auto-ID, formally defer Substitution/Xml, PagerSettings shared sub-component, doc polish. +**Why:** User request — captured for team memory diff --git a/src/BlazorWebFormsComponents/Calendar.razor b/src/BlazorWebFormsComponents/Calendar.razor index d311cb8ed..b066a7d6b 100644 --- a/src/BlazorWebFormsComponents/Calendar.razor +++ b/src/BlazorWebFormsComponents/Calendar.razor @@ -61,7 +61,7 @@ } @foreach (var day in GetDayHeaders()) { -
    + @GetDayName(day) ` tags. This caused `@(UseAccessibleHeader` to appear literally in server logs due to Razor parsing issues. Fixed by replacing with proper conditional attribute: `scope="@(UseAccessibleHeader ? "col" : null)"` -- Blazor omits the attribute entirely when value is null. File: `src/BlazorWebFormsComponents/Calendar.razor`. +- **Menu auto-ID generation (Bug 3):** Menu JS interop requires a DOM element ID, but when no `ID` parameter is provided, it passes an empty string causing null element lookup. Fixed by adding `OnParametersSet` override in `Menu.razor.cs` that auto-generates `menu_{GetHashCode():x}` when ID is null/empty. File: `src/BlazorWebFormsComponents/Menu.razor.cs`. \ No newline at end of file diff --git a/.ai-team/decisions/inbox/cyclops-menu-auto-id-pattern.md b/.ai-team/decisions/inbox/cyclops-menu-auto-id-pattern.md new file mode 100644 index 000000000..c50972a48 --- /dev/null +++ b/.ai-team/decisions/inbox/cyclops-menu-auto-id-pattern.md @@ -0,0 +1,18 @@ +# Decision: Menu auto-ID generation pattern + +**By:** Cyclops +**Date:** 2026-02-24 + +## What + +Menu component now auto-generates an ID (`menu_{GetHashCode():x}`) in `OnParametersSet` when no explicit `ID` parameter is provided. This ensures JS interop via `Sys.WebForms.Menu` always has a valid DOM element ID to target. + +Additionally, `Menu.js` now has null safety (early return if element not found) and a try/catch around the constructor to prevent unhandled JS exceptions from crashing the Blazor circuit. + +## Why + +The Menu component's JS interop depends on a DOM element ID to find and manipulate the menu element. Without an ID, `document.getElementById('')` returns null, causing `TypeError: Cannot read properties of null (reading 'tagName')`. This crashed the entire Blazor circuit in headless Chrome environments. + +## Impact + +Any component that uses JS interop via element IDs should consider auto-generating IDs when none are provided. This pattern (`$"componentname_{GetHashCode():x}"` in `OnParametersSet`) could be reused by other components with JS interop dependencies. diff --git a/README.md b/README.md index 7c8a4e8f5..1bb1f6c8e 100644 --- a/README.md +++ b/README.md @@ -50,21 +50,21 @@ There are a significant number of controls in ASP.NET Web Forms, and we will foc - [HiddenField](docs/EditorControls/HiddenField.md) - [Image](docs/EditorControls/Image.md) - [ImageButton](docs/EditorControls/ImageButton.md) - - [ImageMap](docs/EditorControls/ImageMap.md) + - [ImageMap](docs/NavigationControls/ImageMap.md) - [Label](docs/EditorControls/Label.md) - [LinkButton](docs/EditorControls/LinkButton.md) - [ListBox](docs/EditorControls/ListBox.md) - [Literal](docs/EditorControls/Literal.md) - [Localize](docs/EditorControls/Localize.md) - - MultiView + - [MultiView](docs/EditorControls/MultiView.md) - [Panel](docs/EditorControls/Panel.md) - [PlaceHolder](docs/EditorControls/PlaceHolder.md) - [RadioButton](docs/EditorControls/RadioButton.md) - [RadioButtonList](docs/EditorControls/RadioButtonList.md) - [Table](docs/EditorControls/Table.md) - [TextBox](docs/EditorControls/TextBox.md) - - View - - Xml + - [View](docs/EditorControls/MultiView.md) + - ~~Xml~~ — *Deferred ([details](docs/Migration/DeferredControls.md))* - Data Controls - [Chart](docs/DataControls/Chart.md) - [DataGrid](docs/DataControls/DataGrid.md) @@ -88,8 +88,8 @@ There are a significant number of controls in ASP.NET Web Forms, and we will foc - [SiteMapPath](docs/NavigationControls/SiteMapPath.md) - [TreeView](docs/NavigationControls/TreeView.md) - Login Controls - - ChangePassword - - CreateUserWizard + - [ChangePassword](docs/LoginControls/ChangePassword.md) + - [CreateUserWizard](docs/LoginControls/CreateUserWizard.md) - [Login](docs/LoginControls/Login.md) - [LoginName](docs/LoginControls/LoginName.md) - [LoginStatus](docs/LoginControls/LoginStatus.md) diff --git a/docs/DataControls/Chart.md b/docs/DataControls/Chart.md index 0e5a9307a..f06bf3773 100644 --- a/docs/DataControls/Chart.md +++ b/docs/DataControls/Chart.md @@ -19,7 +19,7 @@ Original Microsoft documentation: [System.Web.UI.DataVisualization.Charting.Char ## Features Supported in Blazor -### Chart Types (Phase 1 — 8 of 35) +### Chart Types (8 of 35) | SeriesChartType | Chart.js Mapping | Description | |-----------------|------------------|-------------| @@ -125,7 +125,7 @@ Original Microsoft documentation: [System.Web.UI.DataVisualization.Charting.Char ## Chart Type Gallery -Below is a visual reference for each of the 8 chart types supported in Phase 1. Use this gallery to choose the right chart type for your data and to see what the Blazor Chart component produces. +Below is a visual reference for each of the 8 supported chart types. Use this gallery to choose the right chart type for your data and to see what the Blazor Chart component produces. ### Column @@ -193,7 +193,7 @@ Stacks multiple series vertically within each category column. Use this when you ## Web Forms Features NOT Supported -### Chart Types Not Supported (Phase 1) +### Chart Types Not Supported 27 additional chart types from the `SeriesChartType` enum are defined but will throw `NotSupportedException` if used. The most commonly requested: diff --git a/docs/Migration/DeferredControls.md b/docs/Migration/DeferredControls.md index 8495bedba..31aa6ebdd 100644 --- a/docs/Migration/DeferredControls.md +++ b/docs/Migration/DeferredControls.md @@ -2,23 +2,23 @@ Some ASP.NET Web Forms controls have been **permanently deferred** from implementation in this library. This page documents each deferred control, explains why it's excluded, and provides recommended alternatives for your Blazor migration. -## Chart — Partially Implemented +## Chart — Implemented -!!! note "Chart is NOW IMPLEMENTED (Phase 1)" +!!! success "Chart is FULLY IMPLEMENTED" The [Chart component](../DataControls/Chart.md) has been implemented with Chart.js and supports 8 chart types: Column, Bar, Line, Pie, Area, Doughnut, Scatter (Point), and StackedColumn. See the [Chart documentation](../DataControls/Chart.md) for full details. ### Unsupported Chart Types -The following 27 chart types from the Web Forms `SeriesChartType` enum are **not yet supported** and will throw `NotSupportedException`. If your application uses any of these, you will need an alternative approach: +The following 27 chart types from the Web Forms `SeriesChartType` enum are **not supported** and will throw `NotSupportedException`. If your application uses any of these, you will need an alternative approach: | Chart Type | Alternative | |------------|-------------| | Stock, Candlestick | Use a dedicated financial charting library (e.g., [Lightweight Charts](https://github.com/nicksenger/lightweight-charts-blazor)) | | Bubble | Can be approximated with a scatter chart and custom point sizes via Chart.js plugins | -| Radar, Polar | Chart.js supports these natively — Phase 2/3 candidates | +| Radar, Polar | Chart.js supports these natively — consider a Chart.js plugin or custom integration | | Funnel, Pyramid | Use a dedicated visualization library or custom SVG | | Spline, SplineArea, SplineRange | Use `Line`/`Area` as an approximation; Chart.js tension options can create curved lines | -| StackedBar, StackedArea, StackedColumn100, StackedBar100, StackedArea100 | Use `StackedColumn` or `Bar` as a starting point; full stacking support is a Phase 2/3 candidate | +| StackedBar, StackedArea, StackedColumn100, StackedBar100, StackedArea100 | Use `StackedColumn` or `Bar` as a starting point; full stacking support may be added in a future release | | Range, RangeBar, RangeColumn | Use a dedicated charting library for range-style visualizations | | BoxPlot, ErrorBar | Use a statistical charting library | | Renko, ThreeLineBreak, Kagi, PointAndFigure | Specialized financial chart types — use a dedicated financial charting library | @@ -232,7 +232,7 @@ XSLT transforms via `` are a **legacy pattern with near-zero adoption** | Control | Status | Recommendation | |---------|--------|----------------| -| **Chart** | ✅ Partial (Phase 1) | [Implemented](../DataControls/Chart.md) with 8 chart types via Chart.js. Unsupported types need alternative libraries. | +| **Chart** | ✅ Complete | [Implemented](../DataControls/Chart.md) with 8 chart types via Chart.js. Unsupported types need alternative libraries. | | **Substitution** | ❌ Deferred | Not needed — Blazor renders dynamically by default | | **Xml** | ❌ Deferred | Use `XDocument` + data binding or `XslCompiledTransform` + `MarkupString` | diff --git a/mkdocs.yml b/mkdocs.yml index 06cd53661..fe0e7982a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -128,10 +128,9 @@ nav: - Getting started: Migration/readme.md - Migration Strategies: Migration/Strategies.md - Custom Controls: Migration/Custom-Controls.md - - Deferred Controls (Chart, Substitution, Xml): Migration/DeferredControls.md + - Deferred Controls: Migration/DeferredControls.md - Master Pages: Migration/MasterPages.md - .NET Standard to the Rescue: Migration/NET-Standard.md - - User Controls: Migration/User-Controls.md - - Deferred Controls: Migration/DeferredControls.md - Themes and Skins: Migration/ThemesAndSkins.md + - User Controls: Migration/User-Controls.md - Web Forms Application Migration Readiness: Migration/migration_readiness.md diff --git a/status.md b/status.md index b3091f115..801468623 100644 --- a/status.md +++ b/status.md @@ -1,13 +1,13 @@ ## Component Status Summary -| Category | Completed | In Progress | Not Started | Total | -|----------|-----------|-------------|-------------|-------| -| Editor Controls | 25 | 0 | 2 | 27 | -| Data Controls | 9 | 0 | 0 | 9 | -| Validation Controls | 7 | 0 | 0 | 7 | -| Navigation Controls | 3 | 0 | 0 | 3 | -| Login Controls | 7 | 0 | 0 | 7 | -| **TOTAL** | **51** | **0** | **2** | **53** | +| Category | Completed | In Progress | Not Started | Deferred | Total | +|----------|-----------|-------------|-------------|----------|-------| +| Editor Controls | 25 | 0 | 0 | 2 | 27 | +| Data Controls | 9 | 0 | 0 | 0 | 9 | +| Validation Controls | 7 | 0 | 0 | 0 | 7 | +| Navigation Controls | 3 | 0 | 0 | 0 | 3 | +| Login Controls | 7 | 0 | 0 | 0 | 7 | +| **TOTAL** | **51** | **0** | **0** | **2** | **53** | --- @@ -41,10 +41,10 @@ | 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 | +| Substitution | ⏸️ Deferred | Cache substitution pattern has no Blazor equivalent — deferred indefinitely | | Table | ✅ Complete | Includes TableRow, TableCell, TableHeaderCell, TableHeaderRow, TableFooterRow | | View | ✅ Complete | Used with MultiView | -| Xml | 🔴 Not Started | XML display/transform | +| Xml | ⏸️ Deferred | XSLT display/transform rarely used in modern apps — deferred indefinitely | ### ✅ Data Controls (9/9 - 100% Complete) @@ -56,7 +56,7 @@ | GridView | ✅ Complete | Documented | | ListView | ✅ Complete | Documented | | Repeater | ✅ Complete | Documented | -| Chart | ✅ Complete | Phase 1: 8 chart types via Chart.js, JS interop, documented | +| Chart | ✅ Complete | 8 chart types via Chart.js, JS interop, documented | | DataPager | ✅ Complete | Documented in DataPager.md | | DetailsView | ✅ Complete | Single-record display/edit, documented, tested, sample page exists | @@ -149,8 +149,8 @@ | ~~**MultiView/View**~~ | ~~Medium~~ | ~~Tab-like container~~ | ✅ Complete | | ~~**Table**~~ | ~~Low~~ | ~~HTML table wrapper~~ | ✅ Complete | | ~~**Localize**~~ | ~~Low~~ | ~~Localization~~ | ✅ Complete | -| **Xml** | Medium | XML transform | -| **Substitution** | N/A | Cache-related, may not apply | +| **Xml** | Medium | ⏸️ Deferred — XSLT display/transform rarely used in modern apps | +| **Substitution** | N/A | ⏸️ Deferred — Cache substitution pattern has no Blazor equivalent | | ~~**Chart**~~ | ~~Very High~~ | ~~Consider external library~~ | ✅ Complete | | ~~**DataGrid**~~ | ~~Medium~~ | ~~Legacy, use GridView~~ | ✅ Complete | @@ -159,11 +159,11 @@ | Metric | Manual Development | With Copilot Assistance | |--------|-------------------|------------------------| | ~~**High Priority (4)**~~ | ~~20-30 hours~~ | ~~10-15 hours~~ | ✅ Complete | -| **Medium Priority (3 remaining)** | ~12-20 hours | ~6-10 hours | -| **Nav & Data (1 remaining)** | ~16-24 hours | ~8-12 hours | -| **Login (1 remaining)** | ~16-24 hours | ~8-12 hours | -| **Lower Priority (3 remaining)** | Variable | Variable | -| **Total Remaining** | 5 components | 5 components | +| ~~**Medium Priority (3 remaining)**~~ | ~~12-20 hours~~ | ~~6-10 hours~~ | ✅ Complete | +| ~~**Nav & Data (1 remaining)**~~ | ~~16-24 hours~~ | ~~8-12 hours~~ | ✅ Complete | +| ~~**Login (1 remaining)**~~ | ~~16-24 hours~~ | ~~8-12 hours~~ | ✅ Complete | +| ~~**Lower Priority (3 remaining)**~~ | ~~Variable~~ | ~~Variable~~ | ✅ Complete (2 deferred) | +| **Total Remaining** | **0 components** | Substitution and Xml deferred indefinitely | --- @@ -213,7 +213,7 @@ 15. ~~**PasswordRecovery**~~ - ✅ Complete (Password reset flow) 16. ~~**CreateUserWizard**~~ - ✅ Complete -### Consider Deferring -- ~~**Chart**~~ - ✅ Complete (Phase 1 — 8 chart types via Chart.js) -- **Substitution** - Cache-related, may not apply to Blazor -- **Xml** - Low migration demand, XSLT is rarely used in modern apps +### Deferred +- ~~**Chart**~~ - ✅ Complete (8 chart types via Chart.js) +- **Substitution** - ⏸️ Deferred indefinitely — cache substitution pattern has no Blazor equivalent +- **Xml** - ⏸️ Deferred indefinitely — XSLT display/transform rarely used in modern apps From b1fdb21f78c7623c9e0db3d59e394426d4817b17 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Tue, 24 Feb 2026 13:01:23 -0500 Subject: [PATCH 22/26] docs: formally defer Substitution/Xml, polish documentation - Mark Substitution and Xml as Deferred in status.md - Create DeferredControls.md with migration guidance - Fix mkdocs.yml nav issues - Update README component stats - Remove Chart hedging language Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .ai-team/agents/beast/history.md | 2 ++ .../inbox/beast-defer-substitution-xml.md | 5 ++++ docs/DataControls/Chart.md | 24 +++++++++---------- 3 files changed, 19 insertions(+), 12 deletions(-) create mode 100644 .ai-team/decisions/inbox/beast-defer-substitution-xml.md diff --git a/.ai-team/agents/beast/history.md b/.ai-team/agents/beast/history.md index e3f7e3596..21512bf5e 100644 --- a/.ai-team/agents/beast/history.md +++ b/.ai-team/agents/beast/history.md @@ -56,3 +56,5 @@ Team update (2026-02-23): Login controls now inherit BaseStyledComponent update docs for outer style support decided by Rogue, Cyclops Team update (2026-02-23): Milestone 6 Work Plan ratified 54 WIs, Beast assigned branding (UI-11) and docs (UI-12) decided by Forge Team update (2026-02-23): Menu Orientation requires Razor local variable workaround document this pattern decided by Jubilee + +- **Milestone 8 release-readiness docs polish:** Formally deferred Substitution and Xml controls in `status.md` (changed from 🔴 Not Started to ⏸️ Deferred with rationale). Added Deferred column to summary table. Updated `docs/Migration/DeferredControls.md` to mark Chart as fully implemented (removed "Phase 1"/"Partial" hedging). Removed all "Phase 1"/"Phase 2/3" hedging from `docs/DataControls/Chart.md`. Fixed duplicate `DeferredControls.md` entry in `mkdocs.yml` and re-alphabetized Migration nav. Fixed broken `ImageMap` link in `README.md` (pointed to EditorControls, should be NavigationControls). Added missing doc links in README for MultiView, View, ChangePassword, CreateUserWizard. Marked Xml as deferred in README component list. diff --git a/.ai-team/decisions/inbox/beast-defer-substitution-xml.md b/.ai-team/decisions/inbox/beast-defer-substitution-xml.md new file mode 100644 index 000000000..7ab910195 --- /dev/null +++ b/.ai-team/decisions/inbox/beast-defer-substitution-xml.md @@ -0,0 +1,5 @@ +### Substitution and Xml formally deferred + +**By:** Beast +**What:** Substitution and Xml controls are now formally marked as "⏸️ Deferred" (not "Not Started") in status.md. DeferredControls.md already had migration guidance; status.md and README.md now reflect the permanent deferral. Chart is marked as fully complete with no "Phase 1" qualifier. +**Why:** These two controls are architecturally incompatible with Blazor (Substitution relies on output caching; Xml relies on XSLT transforms). Marking them as deferred rather than "Not Started" accurately communicates that they will not be implemented, and clears the remaining work count to 0. diff --git a/docs/DataControls/Chart.md b/docs/DataControls/Chart.md index f06bf3773..e8c087bf7 100644 --- a/docs/DataControls/Chart.md +++ b/docs/DataControls/Chart.md @@ -199,18 +199,18 @@ Stacks multiple series vertically within each category column. Use this when you | Chart Type | Status | Notes | |------------|--------|-------| -| `Stock` | Phase 2/3 | OHLC financial charts | -| `Candlestick` | Phase 2/3 | Financial candlestick charts | -| `Bubble` | Phase 2/3 | Bubble charts (sized scatter) | -| `Radar` | Phase 2/3 | Radar/spider charts | -| `Polar` | Phase 2/3 | Polar area charts | -| `Funnel` | Phase 2/3 | Funnel charts | -| `Pyramid` | Phase 2/3 | Pyramid charts | -| `Spline` | Phase 2/3 | Smooth line charts | -| `StackedBar` | Phase 2/3 | Stacked horizontal bars | -| `StackedArea` | Phase 2/3 | Stacked area charts | -| `Range` / `RangeColumn` | Phase 2/3 | Range charts | -| `BoxPlot` | Phase 2/3 | Statistical box plots | +| `Stock` | Not supported | OHLC financial charts | +| `Candlestick` | Not supported | Financial candlestick charts | +| `Bubble` | Not supported | Bubble charts (sized scatter) | +| `Radar` | Not supported | Radar/spider charts | +| `Polar` | Not supported | Polar area charts | +| `Funnel` | Not supported | Funnel charts | +| `Pyramid` | Not supported | Pyramid charts | +| `Spline` | Not supported | Smooth line charts | +| `StackedBar` | Not supported | Stacked horizontal bars | +| `StackedArea` | Not supported | Stacked area charts | +| `Range` / `RangeColumn` | Not supported | Range charts | +| `BoxPlot` | Not supported | Statistical box plots | ### Other Unsupported Features From 13ef190c941030ef1e3c5f8e9bd4562b749848f8 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Tue, 24 Feb 2026 13:07:41 -0500 Subject: [PATCH 23/26] feat: add shared PagerSettings sub-component for GridView/FormView/DetailsView - Add PagerButtons and PagerPosition enums - Add PagerSettings class with Web Forms-compatible properties - Add IPagerSettingsContainer interface - Add UiPagerSettings base component - Wire PagerSettings into GridView, FormView, and DetailsView - Follow existing style sub-component CascadingParameter pattern Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .ai-team/agents/cyclops/history.md | 3 +- .../inbox/cyclops-pagersettings-pattern.md | 5 ++ .../DetailsView.razor | 1 + .../DetailsView.razor.cs | 16 ++++- .../DetailsViewPagerSettings.razor | 1 + .../DetailsViewPagerSettings.razor.cs | 20 ++++++ .../Enums/PagerPosition.cs | 23 ++++++ src/BlazorWebFormsComponents/FormView.razor | 1 + .../FormView.razor.cs | 16 ++++- .../FormViewPagerSettings.razor | 1 + .../FormViewPagerSettings.razor.cs | 20 ++++++ src/BlazorWebFormsComponents/GridView.razor | 1 + .../GridView.razor.cs | 16 ++++- .../GridViewPagerSettings.razor | 1 + .../GridViewPagerSettings.razor.cs | 20 ++++++ .../Interfaces/IPagerSettingsContainer.cs | 13 ++++ src/BlazorWebFormsComponents/PagerSettings.cs | 71 +++++++++++++++++++ .../UiPagerSettings.cs | 69 ++++++++++++++++++ 18 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 .ai-team/decisions/inbox/cyclops-pagersettings-pattern.md create mode 100644 src/BlazorWebFormsComponents/DetailsViewPagerSettings.razor create mode 100644 src/BlazorWebFormsComponents/DetailsViewPagerSettings.razor.cs create mode 100644 src/BlazorWebFormsComponents/Enums/PagerPosition.cs create mode 100644 src/BlazorWebFormsComponents/FormViewPagerSettings.razor create mode 100644 src/BlazorWebFormsComponents/FormViewPagerSettings.razor.cs create mode 100644 src/BlazorWebFormsComponents/GridViewPagerSettings.razor create mode 100644 src/BlazorWebFormsComponents/GridViewPagerSettings.razor.cs create mode 100644 src/BlazorWebFormsComponents/Interfaces/IPagerSettingsContainer.cs create mode 100644 src/BlazorWebFormsComponents/PagerSettings.cs create mode 100644 src/BlazorWebFormsComponents/UiPagerSettings.cs diff --git a/.ai-team/agents/cyclops/history.md b/.ai-team/agents/cyclops/history.md index 8b25f3def..c7e57d0fb 100644 --- a/.ai-team/agents/cyclops/history.md +++ b/.ai-team/agents/cyclops/history.md @@ -68,4 +68,5 @@ Audited 13 controls. Found: AccessKey/ToolTip missing from base class (universal - **Menu JS interop crash (Bug 1):** `Menu.js` `Sys.WebForms.Menu` constructor crashes when `getElement()` returns null (e.g., headless Chrome timing). Fixed by adding null guard after `getElement()` (early return if element missing) and wrapping entire constructor body in try/catch to prevent unhandled exceptions from killing the Blazor circuit. File: `src/BlazorWebFormsComponents/wwwroot/Menu/Menu.js`. - **Calendar attribute rendering (Bug 2):** `Calendar.razor` line 64 used raw Razor expression injection to conditionally add `scope="col"` to `` tags. This caused `@(UseAccessibleHeader` to appear literally in server logs due to Razor parsing issues. Fixed by replacing with proper conditional attribute: `scope="@(UseAccessibleHeader ? "col" : null)"` -- Blazor omits the attribute entirely when value is null. File: `src/BlazorWebFormsComponents/Calendar.razor`. -- **Menu auto-ID generation (Bug 3):** Menu JS interop requires a DOM element ID, but when no `ID` parameter is provided, it passes an empty string causing null element lookup. Fixed by adding `OnParametersSet` override in `Menu.razor.cs` that auto-generates `menu_{GetHashCode():x}` when ID is null/empty. File: `src/BlazorWebFormsComponents/Menu.razor.cs`. \ No newline at end of file +- **Menu auto-ID generation (Bug 3):** Menu JS interop requires a DOM element ID, but when no `ID` parameter is provided, it passes an empty string causing null element lookup. Fixed by adding `OnParametersSet` override in `Menu.razor.cs` that auto-generates `menu_{GetHashCode():x}` when ID is null/empty. File: `src/BlazorWebFormsComponents/Menu.razor.cs`. +- **Shared PagerSettings sub-component:** Created `PagerSettings` class (plain C# POCO, not a Blazor component) with all 12 Web Forms PagerSettings properties (Mode, PageButtonCount, First/Last/Next/PreviousPageText, image URLs, Position, Visible). Created `PagerPosition` enum in `Enums/` (PagerButtons already existed). Created `IPagerSettingsContainer` interface in `Interfaces/`. Created `UiPagerSettings` abstract base component following the `UiTableItemStyle` CascadingParameter pattern but for settings instead of styles. Created 3 concrete sub-component pairs: `GridViewPagerSettings`, `FormViewPagerSettings`, `DetailsViewPagerSettings` — each inherits `UiPagerSettings` and uses `[CascadingParameter(Name = "ParentXxx")]` to set properties on the parent's `PagerSettings` instance. Wired into GridView, FormView, DetailsView: added `IPagerSettingsContainer` to each control's interface list, added `PagerSettings` property + `PagerSettingsContent` RenderFragment parameter, rendered `@PagerSettingsContent` inside existing `` block. Key files: `Enums/PagerPosition.cs`, `PagerSettings.cs`, `Interfaces/IPagerSettingsContainer.cs`, `UiPagerSettings.cs`, `GridViewPagerSettings.razor(.cs)`, `FormViewPagerSettings.razor(.cs)`, `DetailsViewPagerSettings.razor(.cs)`. \ No newline at end of file diff --git a/.ai-team/decisions/inbox/cyclops-pagersettings-pattern.md b/.ai-team/decisions/inbox/cyclops-pagersettings-pattern.md new file mode 100644 index 000000000..2b279b692 --- /dev/null +++ b/.ai-team/decisions/inbox/cyclops-pagersettings-pattern.md @@ -0,0 +1,5 @@ +### PagerSettings follows settings-not-style pattern + +**By:** Cyclops +**What:** PagerSettings is a plain C# class (not inheriting `Style`), unlike the existing `TableItemStyle` sub-components. The `UiPagerSettings` base component extends `ComponentBase` directly (not `UiStyle`) because PagerSettings has no visual style properties — it's pure configuration. The same CascadingParameter pattern is used (`IPagerSettingsContainer` interface, cascaded `"ParentXxx"` value), but the base class is simpler. The `PagerButtons` enum already existed; only `PagerPosition` was new. +**Why:** Future sub-components that configure behavior (not style) should follow this `UiPagerSettings` pattern rather than `UiTableItemStyle`. The distinction is: style sub-components inherit `UiStyle` and set visual properties; settings sub-components inherit `ComponentBase` and set configuration properties. diff --git a/src/BlazorWebFormsComponents/DetailsView.razor b/src/BlazorWebFormsComponents/DetailsView.razor index cbf1f0531..f645a8c6d 100644 --- a/src/BlazorWebFormsComponents/DetailsView.razor +++ b/src/BlazorWebFormsComponents/DetailsView.razor @@ -13,6 +13,7 @@ @FieldHeaderStyleContent @EmptyDataRowStyleContent @PagerStyleContent + @PagerSettingsContent @if (Visible) diff --git a/src/BlazorWebFormsComponents/DetailsView.razor.cs b/src/BlazorWebFormsComponents/DetailsView.razor.cs index 313139a0e..60481db2d 100644 --- a/src/BlazorWebFormsComponents/DetailsView.razor.cs +++ b/src/BlazorWebFormsComponents/DetailsView.razor.cs @@ -15,7 +15,7 @@ namespace BlazorWebFormsComponents /// Displays a single record from a data source in a table layout with one row per field. /// /// The type of the data items. - public partial class DetailsView : DataBoundComponent, IDetailsViewStyleContainer + public partial class DetailsView : DataBoundComponent, IDetailsViewStyleContainer, IPagerSettingsContainer { #region Properties @@ -201,6 +201,20 @@ public partial class DetailsView : DataBoundComponent, IDeta #endregion + #region PagerSettings + + /// + /// Gets the pager settings for the DetailsView. + /// + public PagerSettings PagerSettings { get; internal set; } = new PagerSettings(); + + /// + /// Content for the PagerSettings sub-component. + /// + [Parameter] public RenderFragment PagerSettingsContent { get; set; } + + #endregion + #region Templates /// diff --git a/src/BlazorWebFormsComponents/DetailsViewPagerSettings.razor b/src/BlazorWebFormsComponents/DetailsViewPagerSettings.razor new file mode 100644 index 000000000..23a8a310e --- /dev/null +++ b/src/BlazorWebFormsComponents/DetailsViewPagerSettings.razor @@ -0,0 +1 @@ +@inherits UiPagerSettings diff --git a/src/BlazorWebFormsComponents/DetailsViewPagerSettings.razor.cs b/src/BlazorWebFormsComponents/DetailsViewPagerSettings.razor.cs new file mode 100644 index 000000000..138bbfb9b --- /dev/null +++ b/src/BlazorWebFormsComponents/DetailsViewPagerSettings.razor.cs @@ -0,0 +1,20 @@ +using BlazorWebFormsComponents.Interfaces; +using Microsoft.AspNetCore.Components; + +namespace BlazorWebFormsComponents +{ + public partial class DetailsViewPagerSettings : UiPagerSettings + { + [CascadingParameter(Name = "ParentDetailsView")] + protected IPagerSettingsContainer ParentDetailsView { get; set; } + + protected override void OnInitialized() + { + if (ParentDetailsView != null) + { + theSettings = ParentDetailsView.PagerSettings; + } + base.OnInitialized(); + } + } +} diff --git a/src/BlazorWebFormsComponents/Enums/PagerPosition.cs b/src/BlazorWebFormsComponents/Enums/PagerPosition.cs new file mode 100644 index 000000000..d24487563 --- /dev/null +++ b/src/BlazorWebFormsComponents/Enums/PagerPosition.cs @@ -0,0 +1,23 @@ +namespace BlazorWebFormsComponents.Enums +{ + /// + /// Specifies the position of the pager in a data-bound control. + /// + public enum PagerPosition + { + /// + /// Pager is displayed at the bottom of the control. + /// + Bottom = 0, + + /// + /// Pager is displayed at the top of the control. + /// + Top = 1, + + /// + /// Pager is displayed at both the top and bottom of the control. + /// + TopAndBottom = 2 + } +} diff --git a/src/BlazorWebFormsComponents/FormView.razor b/src/BlazorWebFormsComponents/FormView.razor index e675681a0..d9de6475d 100644 --- a/src/BlazorWebFormsComponents/FormView.razor +++ b/src/BlazorWebFormsComponents/FormView.razor @@ -10,6 +10,7 @@ @FooterStyleContent @EmptyDataRowStyleContent @PagerStyleContent + @PagerSettingsContent diff --git a/src/BlazorWebFormsComponents/FormView.razor.cs b/src/BlazorWebFormsComponents/FormView.razor.cs index f700625fa..d6b3e07fa 100644 --- a/src/BlazorWebFormsComponents/FormView.razor.cs +++ b/src/BlazorWebFormsComponents/FormView.razor.cs @@ -10,7 +10,7 @@ namespace BlazorWebFormsComponents { - public partial class FormView : DataBoundComponent, IFormViewStyleContainer where ItemType : class, new() + public partial class FormView : DataBoundComponent, IFormViewStyleContainer, IPagerSettingsContainer where ItemType : class, new() { private static readonly Dictionary CommandNameModeLookup = new Dictionary { @@ -176,6 +176,20 @@ protected override async Task OnAfterRenderAsync(bool firstRender) #endregion + #region PagerSettings + + /// + /// Gets the pager settings for the FormView. + /// + public PagerSettings PagerSettings { get; internal set; } = new PagerSettings(); + + /// + /// Content for the PagerSettings sub-component. + /// + [Parameter] public RenderFragment PagerSettingsContent { get; set; } + + #endregion + #region FormView Events protected override Task OnInitializedAsync() diff --git a/src/BlazorWebFormsComponents/FormViewPagerSettings.razor b/src/BlazorWebFormsComponents/FormViewPagerSettings.razor new file mode 100644 index 000000000..23a8a310e --- /dev/null +++ b/src/BlazorWebFormsComponents/FormViewPagerSettings.razor @@ -0,0 +1 @@ +@inherits UiPagerSettings diff --git a/src/BlazorWebFormsComponents/FormViewPagerSettings.razor.cs b/src/BlazorWebFormsComponents/FormViewPagerSettings.razor.cs new file mode 100644 index 000000000..25a120ed4 --- /dev/null +++ b/src/BlazorWebFormsComponents/FormViewPagerSettings.razor.cs @@ -0,0 +1,20 @@ +using BlazorWebFormsComponents.Interfaces; +using Microsoft.AspNetCore.Components; + +namespace BlazorWebFormsComponents +{ + public partial class FormViewPagerSettings : UiPagerSettings + { + [CascadingParameter(Name = "ParentFormView")] + protected IPagerSettingsContainer ParentFormView { get; set; } + + protected override void OnInitialized() + { + if (ParentFormView != null) + { + theSettings = ParentFormView.PagerSettings; + } + base.OnInitialized(); + } + } +} diff --git a/src/BlazorWebFormsComponents/GridView.razor b/src/BlazorWebFormsComponents/GridView.razor index 5561038a3..18e6143db 100644 --- a/src/BlazorWebFormsComponents/GridView.razor +++ b/src/BlazorWebFormsComponents/GridView.razor @@ -13,6 +13,7 @@ @PagerStyleContent @EditRowStyleContent @SelectedRowStyleContent + @PagerSettingsContent @if (ColumnList.Any()) diff --git a/src/BlazorWebFormsComponents/GridView.razor.cs b/src/BlazorWebFormsComponents/GridView.razor.cs index 77a3f909a..01dcce181 100644 --- a/src/BlazorWebFormsComponents/GridView.razor.cs +++ b/src/BlazorWebFormsComponents/GridView.razor.cs @@ -13,7 +13,7 @@ namespace BlazorWebFormsComponents /// Blazor version of WebForms GridView control /// /// - public partial class GridView : DataBoundComponent, IRowCollection, IColumnCollection, IGridViewStyleContainer + public partial class GridView : DataBoundComponent, IRowCollection, IColumnCollection, IGridViewStyleContainer, IPagerSettingsContainer { /// @@ -172,6 +172,20 @@ public object SelectedValue #endregion + #region PagerSettings + + /// + /// Gets the pager settings for the GridView. + /// + public PagerSettings PagerSettings { get; internal set; } = new PagerSettings(); + + /// + /// Content for the PagerSettings sub-component. + /// + [Parameter] public RenderFragment PagerSettingsContent { get; set; } + + #endregion + #region Display Properties /// diff --git a/src/BlazorWebFormsComponents/GridViewPagerSettings.razor b/src/BlazorWebFormsComponents/GridViewPagerSettings.razor new file mode 100644 index 000000000..23a8a310e --- /dev/null +++ b/src/BlazorWebFormsComponents/GridViewPagerSettings.razor @@ -0,0 +1 @@ +@inherits UiPagerSettings diff --git a/src/BlazorWebFormsComponents/GridViewPagerSettings.razor.cs b/src/BlazorWebFormsComponents/GridViewPagerSettings.razor.cs new file mode 100644 index 000000000..b47515475 --- /dev/null +++ b/src/BlazorWebFormsComponents/GridViewPagerSettings.razor.cs @@ -0,0 +1,20 @@ +using BlazorWebFormsComponents.Interfaces; +using Microsoft.AspNetCore.Components; + +namespace BlazorWebFormsComponents +{ + public partial class GridViewPagerSettings : UiPagerSettings + { + [CascadingParameter(Name = "ParentGridView")] + protected IPagerSettingsContainer ParentGridView { get; set; } + + protected override void OnInitialized() + { + if (ParentGridView != null) + { + theSettings = ParentGridView.PagerSettings; + } + base.OnInitialized(); + } + } +} diff --git a/src/BlazorWebFormsComponents/Interfaces/IPagerSettingsContainer.cs b/src/BlazorWebFormsComponents/Interfaces/IPagerSettingsContainer.cs new file mode 100644 index 000000000..7cd2bda0c --- /dev/null +++ b/src/BlazorWebFormsComponents/Interfaces/IPagerSettingsContainer.cs @@ -0,0 +1,13 @@ +namespace BlazorWebFormsComponents.Interfaces +{ + /// + /// Interface for components that contain a PagerSettings configuration. + /// + public interface IPagerSettingsContainer + { + /// + /// Gets the pager settings for the control. + /// + PagerSettings PagerSettings { get; } + } +} diff --git a/src/BlazorWebFormsComponents/PagerSettings.cs b/src/BlazorWebFormsComponents/PagerSettings.cs new file mode 100644 index 000000000..5a159e91f --- /dev/null +++ b/src/BlazorWebFormsComponents/PagerSettings.cs @@ -0,0 +1,71 @@ +using BlazorWebFormsComponents.Enums; + +namespace BlazorWebFormsComponents +{ + /// + /// Represents the properties of the paging controls in a control that supports pagination. + /// Matches the System.Web.UI.WebControls.PagerSettings class from ASP.NET Web Forms. + /// + public class PagerSettings + { + /// + /// Gets or sets the mode of the pager buttons. + /// + public PagerButtons Mode { get; set; } = PagerButtons.Numeric; + + /// + /// Gets or sets the number of numeric page buttons to display in the pager. + /// + public int PageButtonCount { get; set; } = 10; + + /// + /// Gets or sets the text displayed for the first page button. + /// + public string FirstPageText { get; set; } = "..."; + + /// + /// Gets or sets the text displayed for the last page button. + /// + public string LastPageText { get; set; } = "..."; + + /// + /// Gets or sets the text displayed for the next page button. + /// + public string NextPageText { get; set; } = ">"; + + /// + /// Gets or sets the text displayed for the previous page button. + /// + public string PreviousPageText { get; set; } = "<"; + + /// + /// Gets or sets the URL of the image displayed for the first page button. + /// + public string FirstPageImageUrl { get; set; } + + /// + /// Gets or sets the URL of the image displayed for the last page button. + /// + public string LastPageImageUrl { get; set; } + + /// + /// Gets or sets the URL of the image displayed for the next page button. + /// + public string NextPageImageUrl { get; set; } + + /// + /// Gets or sets the URL of the image displayed for the previous page button. + /// + public string PreviousPageImageUrl { get; set; } + + /// + /// Gets or sets the position of the pager in the control. + /// + public PagerPosition Position { get; set; } = PagerPosition.Bottom; + + /// + /// Gets or sets whether the pager is visible. + /// + public bool Visible { get; set; } = true; + } +} diff --git a/src/BlazorWebFormsComponents/UiPagerSettings.cs b/src/BlazorWebFormsComponents/UiPagerSettings.cs new file mode 100644 index 000000000..c39379469 --- /dev/null +++ b/src/BlazorWebFormsComponents/UiPagerSettings.cs @@ -0,0 +1,69 @@ +using BlazorWebFormsComponents.Enums; +using Microsoft.AspNetCore.Components; + +namespace BlazorWebFormsComponents +{ + /// + /// Abstract base component for PagerSettings sub-components. + /// Follows the same CascadingParameter pattern as UiTableItemStyle. + /// + public abstract class UiPagerSettings : ComponentBase + { + protected PagerSettings theSettings { get; set; } + + [Parameter] + public PagerButtons Mode { get; set; } = PagerButtons.Numeric; + + [Parameter] + public int PageButtonCount { get; set; } = 10; + + [Parameter] + public string FirstPageText { get; set; } = "..."; + + [Parameter] + public string LastPageText { get; set; } = "..."; + + [Parameter] + public string NextPageText { get; set; } = ">"; + + [Parameter] + public string PreviousPageText { get; set; } = "<"; + + [Parameter] + public string FirstPageImageUrl { get; set; } + + [Parameter] + public string LastPageImageUrl { get; set; } + + [Parameter] + public string NextPageImageUrl { get; set; } + + [Parameter] + public string PreviousPageImageUrl { get; set; } + + [Parameter] + public PagerPosition Position { get; set; } = PagerPosition.Bottom; + + [Parameter] + public bool Visible { get; set; } = true; + + protected override void OnInitialized() + { + if (theSettings != null) + { + theSettings.Mode = Mode; + theSettings.PageButtonCount = PageButtonCount; + theSettings.FirstPageText = FirstPageText; + theSettings.LastPageText = LastPageText; + theSettings.NextPageText = NextPageText; + theSettings.PreviousPageText = PreviousPageText; + theSettings.FirstPageImageUrl = FirstPageImageUrl; + theSettings.LastPageImageUrl = LastPageImageUrl; + theSettings.NextPageImageUrl = NextPageImageUrl; + theSettings.PreviousPageImageUrl = PreviousPageImageUrl; + theSettings.Position = Position; + theSettings.Visible = Visible; + } + } + } +} From e9b9fd8b05d0d32e918ea90467cd4333e8e3be1a Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Tue, 24 Feb 2026 13:11:37 -0500 Subject: [PATCH 24/26] docs(ai-team): M8 session log and decision merge Session: 2026-02-24-m8-release-readiness Requested by: Jeffrey T. Fritz Changes: - Logged M8 session to .ai-team/log/2026-02-24-m8-release-readiness.md - Merged 18 decisions from inbox (Menu auto-ID, Substitution/Xml deferral, M8 scope, PagerSettings, M7 plan, integration tests, TreeView, GridView, DataGrid, ListView, FormView, DetailsView, validators, Menu improvements, Playwright patterns, rendermode fix) - Propagated team updates to all 6 agent histories - Deduplicated decisions.md (0 exact heading duplicates found) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .ai-team/agents/beast/history.md | 4 + .ai-team/agents/colossus/history.md | 3 + .ai-team/agents/cyclops/history.md | 4 +- .ai-team/agents/forge/history.md | 5 + .ai-team/agents/jubilee/history.md | 4 + .ai-team/agents/rogue/history.md | 3 + .ai-team/decisions.md | 588 +++++++++++++++++- .../inbox/beast-defer-substitution-xml.md | 5 - .../inbox/colossus-integration-tests.md | 57 -- ...olossus-playwright-text-locator-pattern.md | 37 -- .../copilot-directive-20260224-m8-scope.md | 4 - .../inbox/cyclops-datagrid-styles-events.md | 33 - .../inbox/cyclops-detailsview-formview.md | 28 - .../inbox/cyclops-gridview-display.md | 33 - .../inbox/cyclops-gridview-selection.md | 20 - .../inbox/cyclops-gridview-styles.md | 21 - .../decisions/inbox/cyclops-listview-crud.md | 23 - .../inbox/cyclops-menu-auto-id-pattern.md | 18 - .../inbox/cyclops-menu-improvements.md | 53 -- .../inbox/cyclops-menu-panel-login.md | 65 -- .../inbox/cyclops-pagersettings-pattern.md | 5 - .../inbox/cyclops-treeview-enhancement.md | 51 -- .../cyclops-validator-controltovalidate.md | 32 - .ai-team/decisions/inbox/forge-m7-plan.md | 59 -- .../decisions/inbox/jubilee-rendermode-fix.md | 19 - .../log/2026-02-24-m8-release-readiness.md | 34 + 26 files changed, 638 insertions(+), 570 deletions(-) delete mode 100644 .ai-team/decisions/inbox/beast-defer-substitution-xml.md delete mode 100644 .ai-team/decisions/inbox/colossus-integration-tests.md delete mode 100644 .ai-team/decisions/inbox/colossus-playwright-text-locator-pattern.md delete mode 100644 .ai-team/decisions/inbox/copilot-directive-20260224-m8-scope.md delete mode 100644 .ai-team/decisions/inbox/cyclops-datagrid-styles-events.md delete mode 100644 .ai-team/decisions/inbox/cyclops-detailsview-formview.md delete mode 100644 .ai-team/decisions/inbox/cyclops-gridview-display.md delete mode 100644 .ai-team/decisions/inbox/cyclops-gridview-selection.md delete mode 100644 .ai-team/decisions/inbox/cyclops-gridview-styles.md delete mode 100644 .ai-team/decisions/inbox/cyclops-listview-crud.md delete mode 100644 .ai-team/decisions/inbox/cyclops-menu-auto-id-pattern.md delete mode 100644 .ai-team/decisions/inbox/cyclops-menu-improvements.md delete mode 100644 .ai-team/decisions/inbox/cyclops-menu-panel-login.md delete mode 100644 .ai-team/decisions/inbox/cyclops-pagersettings-pattern.md delete mode 100644 .ai-team/decisions/inbox/cyclops-treeview-enhancement.md delete mode 100644 .ai-team/decisions/inbox/cyclops-validator-controltovalidate.md delete mode 100644 .ai-team/decisions/inbox/forge-m7-plan.md delete mode 100644 .ai-team/decisions/inbox/jubilee-rendermode-fix.md create mode 100644 .ai-team/log/2026-02-24-m8-release-readiness.md diff --git a/.ai-team/agents/beast/history.md b/.ai-team/agents/beast/history.md index 21512bf5e..c7e07b68d 100644 --- a/.ai-team/agents/beast/history.md +++ b/.ai-team/agents/beast/history.md @@ -58,3 +58,7 @@ Team update (2026-02-23): Menu Orientation requires Razor local variable workaround document this pattern decided by Jubilee - **Milestone 8 release-readiness docs polish:** Formally deferred Substitution and Xml controls in `status.md` (changed from 🔴 Not Started to ⏸️ Deferred with rationale). Added Deferred column to summary table. Updated `docs/Migration/DeferredControls.md` to mark Chart as fully implemented (removed "Phase 1"/"Partial" hedging). Removed all "Phase 1"/"Phase 2/3" hedging from `docs/DataControls/Chart.md`. Fixed duplicate `DeferredControls.md` entry in `mkdocs.yml` and re-alphabetized Migration nav. Fixed broken `ImageMap` link in `README.md` (pointed to EditorControls, should be NavigationControls). Added missing doc links in README for MultiView, View, ChangePassword, CreateUserWizard. Marked Xml as deferred in README component list. + + Team update (2026-02-24): Menu auto-ID pattern components with JS interop should auto-generate IDs decided by Cyclops + Team update (2026-02-24): M8 scope excludes version bump to 1.0 and release decided by Jeffrey T. Fritz + Team update (2026-02-24): PagerSettings shared sub-component created update docs when component stabilizes decided by Cyclops diff --git a/.ai-team/agents/colossus/history.md b/.ai-team/agents/colossus/history.md index b2cfd2658..c0a4355f4 100644 --- a/.ai-team/agents/colossus/history.md +++ b/.ai-team/agents/colossus/history.md @@ -31,3 +31,6 @@ Added 9 smoke tests and 9 interaction tests for M7 sample pages: GridView Select - For `
    ` containers with multiple `` labels (e.g., TreeView/Menu feedback panels), use `page.Locator("div").Filter(new() { HasTextString = "Target label:" }).Last` to match the specific container div. - When waiting for FormView to render its item template buttons, use a specific selector like `button:has-text('Edit')` instead of generic `button, input[type='submit']` — the latter matches sidebar/nav buttons that already exist, causing the wait to resolve prematurely before the FormView renders. - To avoid strict-mode violations when text appears in both rendered output AND code examples, target the specific rendered element (e.g., `page.Locator("td").Filter(new() { HasTextString = "Widget Catalog" }).First`) rather than using bare `text=` locators. + + Team update (2026-02-24): Menu auto-ID pattern Menu now auto-generates IDs, JS interop crash fixed decided by Cyclops + Team update (2026-02-24): M8 scope excludes version bump to 1.0 and release decided by Jeffrey T. Fritz diff --git a/.ai-team/agents/cyclops/history.md b/.ai-team/agents/cyclops/history.md index c7e57d0fb..b984ddbbb 100644 --- a/.ai-team/agents/cyclops/history.md +++ b/.ai-team/agents/cyclops/history.md @@ -69,4 +69,6 @@ Audited 13 controls. Found: AccessKey/ToolTip missing from base class (universal - **Menu JS interop crash (Bug 1):** `Menu.js` `Sys.WebForms.Menu` constructor crashes when `getElement()` returns null (e.g., headless Chrome timing). Fixed by adding null guard after `getElement()` (early return if element missing) and wrapping entire constructor body in try/catch to prevent unhandled exceptions from killing the Blazor circuit. File: `src/BlazorWebFormsComponents/wwwroot/Menu/Menu.js`. - **Calendar attribute rendering (Bug 2):** `Calendar.razor` line 64 used raw Razor expression injection to conditionally add `scope="col"` to `
    ` when columns existed, regardless of data. Now `ShouldRenderHeader = ShowHeader && (HasData || ShowHeaderWhenEmpty)`. With `ShowHeaderWhenEmpty=false` (default), the header is hidden when the data source is empty. This matches Web Forms behavior where `ShowHeaderWhenEmpty` was added in .NET 4.5 with default `false`. + +**Impact:** Existing GridViews with empty data will stop showing headers unless `ShowHeaderWhenEmpty="true"` is added. One test (`EmptyDataText.razor`) was updated accordingly. + +### 2. UseAccessibleHeader adds scope="col" to existing th elements + +The current GridView already renders `
    ` tags. This caused `@(UseAccessibleHeader` to appear literally in server logs due to Razor parsing issues. Fixed by replacing with proper conditional attribute: `scope="@(UseAccessibleHeader ? "col" : null)"` -- Blazor omits the attribute entirely when value is null. File: `src/BlazorWebFormsComponents/Calendar.razor`. - **Menu auto-ID generation (Bug 3):** Menu JS interop requires a DOM element ID, but when no `ID` parameter is provided, it passes an empty string causing null element lookup. Fixed by adding `OnParametersSet` override in `Menu.razor.cs` that auto-generates `menu_{GetHashCode():x}` when ID is null/empty. File: `src/BlazorWebFormsComponents/Menu.razor.cs`. -- **Shared PagerSettings sub-component:** Created `PagerSettings` class (plain C# POCO, not a Blazor component) with all 12 Web Forms PagerSettings properties (Mode, PageButtonCount, First/Last/Next/PreviousPageText, image URLs, Position, Visible). Created `PagerPosition` enum in `Enums/` (PagerButtons already existed). Created `IPagerSettingsContainer` interface in `Interfaces/`. Created `UiPagerSettings` abstract base component following the `UiTableItemStyle` CascadingParameter pattern but for settings instead of styles. Created 3 concrete sub-component pairs: `GridViewPagerSettings`, `FormViewPagerSettings`, `DetailsViewPagerSettings` — each inherits `UiPagerSettings` and uses `[CascadingParameter(Name = "ParentXxx")]` to set properties on the parent's `PagerSettings` instance. Wired into GridView, FormView, DetailsView: added `IPagerSettingsContainer` to each control's interface list, added `PagerSettings` property + `PagerSettingsContent` RenderFragment parameter, rendered `@PagerSettingsContent` inside existing `` block. Key files: `Enums/PagerPosition.cs`, `PagerSettings.cs`, `Interfaces/IPagerSettingsContainer.cs`, `UiPagerSettings.cs`, `GridViewPagerSettings.razor(.cs)`, `FormViewPagerSettings.razor(.cs)`, `DetailsViewPagerSettings.razor(.cs)`. \ No newline at end of file +- **Shared PagerSettings sub-component:** Created `PagerSettings` class (plain C# POCO, not a Blazor component) with all 12 Web Forms PagerSettings properties (Mode, PageButtonCount, First/Last/Next/PreviousPageText, image URLs, Position, Visible). Created `PagerPosition` enum in `Enums/` (PagerButtons already existed). Created `IPagerSettingsContainer` interface in `Interfaces/`. Created `UiPagerSettings` abstract base component following the `UiTableItemStyle` CascadingParameter pattern but for settings instead of styles. Created 3 concrete sub-component pairs: `GridViewPagerSettings`, `FormViewPagerSettings`, `DetailsViewPagerSettings` — each inherits `UiPagerSettings` and uses `[CascadingParameter(Name = "ParentXxx")]` to set properties on the parent's `PagerSettings` instance. Wired into GridView, FormView, DetailsView: added `IPagerSettingsContainer` to each control's interface list, added `PagerSettings` property + `PagerSettingsContent` RenderFragment parameter, rendered `@PagerSettingsContent` inside existing `` block. Key files: `Enums/PagerPosition.cs`, `PagerSettings.cs`, `Interfaces/IPagerSettingsContainer.cs`, `UiPagerSettings.cs`, `GridViewPagerSettings.razor(.cs)`, `FormViewPagerSettings.razor(.cs)`, `DetailsViewPagerSettings.razor(.cs)`. + Team update (2026-02-24): Substitution/Xml formally deferred no implementation needed decided by Beast + Team update (2026-02-24): M8 scope excludes version bump to 1.0 and release decided by Jeffrey T. Fritz diff --git a/.ai-team/agents/forge/history.md b/.ai-team/agents/forge/history.md index 1435e2d06..949c1bd93 100644 --- a/.ai-team/agents/forge/history.md +++ b/.ai-team/agents/forge/history.md @@ -84,3 +84,8 @@ Planned M7: "Control Depth & Navigation Overhaul" — 51 work items targeting ~1 - Re-audit must open the milestone — all planning-docs/ files are stale (pre-M6 numbers) 📌 Team update (2026-02-23): Milestone 7 planned — 51 WIs, ~138 gaps, "Control Depth & Navigation Overhaul". P0: GridView completion + re-audit. P1: TreeView, Menu, DetailsView, FormView, Validators. P2: ListView CRUD, DataGrid, Menu levels. — decided by Forge + + Team update (2026-02-24): Menu auto-ID pattern established components with JS interop should auto-generate IDs when none provided decided by Cyclops + Team update (2026-02-24): Substitution/Xml formally deferred in status.md and README decided by Beast + Team update (2026-02-24): M8 scope excludes version bump to 1.0 and release decided by Jeffrey T. Fritz + Team update (2026-02-24): PagerSettings shared sub-component created for GridView/FormView/DetailsView decided by Cyclops diff --git a/.ai-team/agents/jubilee/history.md b/.ai-team/agents/jubilee/history.md index 903170816..9a0f03cec 100644 --- a/.ai-team/agents/jubilee/history.md +++ b/.ai-team/agents/jubilee/history.md @@ -38,3 +38,7 @@ Chart: 8 basic + 4 advanced sample pages (DataBinding, MultiSeries, Styling, Cha Team update (2026-02-23): Label AssociatedControlID switches rendered element (label vs span) decided by Cyclops Team update (2026-02-23): Milestone 6 Work Plan ratified 54 WIs across P0/P1/P2 tiers decided by Forge Team update (2026-02-23): UI overhaul requested Jubilee is frontend lead (UI-1,3,4,5,6,7,10) decided by Jeffrey T. Fritz + + Team update (2026-02-24): Menu auto-ID pattern Menu now auto-generates IDs for JS interop decided by Cyclops + Team update (2026-02-24): M8 scope excludes version bump to 1.0 and release decided by Jeffrey T. Fritz + Team update (2026-02-24): PagerSettings shared sub-component created samples may need PagerSettings demos decided by Cyclops diff --git a/.ai-team/agents/rogue/history.md b/.ai-team/agents/rogue/history.md index 220a6cf50..da2c05db7 100644 --- a/.ai-team/agents/rogue/history.md +++ b/.ai-team/agents/rogue/history.md @@ -88,3 +88,6 @@ Wrote 41 bUnit tests across 6 new test files for P2 features: Team update (2026-02-23): CausesValidation/ValidationGroup added to CheckBox, RadioButton, TextBox decided by Cyclops Team update (2026-02-23): Label AssociatedControlID switches rendered element (label vs span) decided by Cyclops Team update (2026-02-23): Milestone 6 Work Plan ratified 54 WIs across P0/P1/P2 tiers decided by Forge + + Team update (2026-02-24): M8 scope excludes version bump to 1.0 and release decided by Jeffrey T. Fritz + Team update (2026-02-24): PagerSettings shared sub-component created may need bUnit tests decided by Cyclops diff --git a/.ai-team/decisions.md b/.ai-team/decisions.md index bfbf28a3a..89302b645 100644 --- a/.ai-team/decisions.md +++ b/.ai-team/decisions.md @@ -582,6 +582,7 @@ Suggested timeline: **By:** Cyclops **What:** Fixed `DetailsViewAutoField.GetValue()` to render `` elements when the DetailsView is in Edit or Insert mode, instead of always rendering plain text. Edit mode pre-fills the input with the current property value; Insert mode renders an empty input. ReadOnly mode continues to render plain text as before. **Why:** The `mode` parameter was being ignored — the method always rendered plain text regardless of mode. This broke the Edit workflow: clicking "Edit" switched the command row buttons correctly but fields remained non-editable. This matches ASP.NET Web Forms behavior where auto-generated fields become textboxes in edit/insert mode. + # Chart Visual Appearance Testing Patterns **By:** Colossus @@ -617,14 +618,12 @@ Chart.js uses JS interop and renders to ``, so traditional DOM assertion - Use `WaitUntilState.NetworkIdle` for tests that need Chart.js fully initialized (library verification, dataset verification). - Use `WaitUntilState.DOMContentLoaded` for basic canvas presence tests. - ### 2026-02-12: ChartSeries data binding extracts points via reflection **By:** Cyclops **What:** Fixed `ChartSeries.ToConfig()` to support data binding via `Items`, `XValueMember`, and `YValueMembers` parameters. When `Items` is provided with a non-empty `YValueMembers`, the method extracts `DataPoint` objects using reflection. The `YValueMembers` property supports comma-separated field names for multi-value charts. Type conversion handles `double`, `float`, `int`, `long`, `decimal`, `short`, `byte`, and falls back to `Convert.ToDouble()`. The manual `Points` collection is used as a fallback when `Items` is null/empty or `YValueMembers` is not specified. **Why:** Data-bound charts were rendering empty because `ToConfig()` only used the manual `Points` collection. Web Forms Chart supports data binding via `XValueMember`/`YValueMembers` properties, and this fix restores that capability for Blazor migration scenarios. - # Chart Component Implementation Decisions **By:** Cyclops @@ -668,7 +667,6 @@ Instead of passing the `Chart` component directly to `ChartConfigBuilder.BuildCo - `ChartLegend.razor`, `ChartLegend.razor.cs` - `ChartTitle.razor`, `ChartTitle.razor.cs` - ### 2026-02-13: Chart component Phase 1 gate review — CONDITIONAL APPROVAL **By:** Forge @@ -692,7 +690,6 @@ Instead of passing the `Chart` component directly to `ChartConfigBuilder.BuildCo **Why:** The component is architecturally sound and 90% complete. The data binding gap creates a docs-vs-reality mismatch that will frustrate migrating developers. Phase 2/3 features (more chart types, tooltips) are nice-to-have but not blocking for initial ship. - # Decision: Chart Component Architecture (Design Review) **By:** Forge @@ -755,7 +752,6 @@ Instead of passing the `Chart` component directly to `ChartConfigBuilder.BuildCo **Why:** Web Forms Chart uses these enums. Project convention requires every Web Forms enum to have a corresponding C# enum in `Enums/`. - # Chart Sample Pages — Feature-Rich Samples **By:** Jubilee @@ -802,7 +798,6 @@ Jeff requested rich samples covering all Chart features. These samples: - Demonstrate all available color palettes visually - Provide copy-paste ready code for common scenarios - # ChartSeries Data Binding Test Coverage **By:** Rogue @@ -1348,3 +1343,584 @@ All P2 features (WI-47 through WI-52) have been tested with 32 bUnit tests acros ## Impact Team should be aware that Login/ChangePassword/CreateUserWizard BaseStyledComponent inheritance was already in place — WI-52's implementation may have been a no-op or only required template changes to wire `Style`/`CssClass` to the outer element. + +### Substitution and Xml formally deferred + +**By:** Beast +**What:** Substitution and Xml controls are now formally marked as "ΓÅ╕∩╕Å Deferred" (not "Not Started") in status.md. DeferredControls.md already had migration guidance; status.md and README.md now reflect the permanent deferral. Chart is marked as fully complete with no "Phase 1" qualifier. +**Why:** These two controls are architecturally incompatible with Blazor (Substitution relies on output caching; Xml relies on XSLT transforms). Marking them as deferred rather than "Not Started" accurately communicates that they will not be implemented, and clears the remaining work count to 0. + +# Decision: M7 Integration Tests Added (WI-39 + WI-40) + +**Author:** Colossus +**Date:** 2026-02-24 +**Status:** Done + +## Context + +Milestone 7 added 9 new sample pages across GridView, TreeView, Menu, DetailsView, and FormView. Each page needed smoke tests (page loads without errors) and, where applicable, interaction tests (behaviors work). + +## What Was Added + +### Smoke Tests (ControlSampleTests.cs) + +Added `[InlineData]` entries to existing `[Theory]` methods: + +- **DataControl_Loads_WithoutErrors:** + - `/ControlSamples/GridView/Selection` + - `/ControlSamples/GridView/DisplayProperties` + - `/ControlSamples/FormView/Events` + - `/ControlSamples/FormView/Styles` + - `/ControlSamples/DetailsView/Styles` + - `/ControlSamples/DetailsView/Caption` + +- **NavigationControl_Loads_WithoutErrors:** + - `/ControlSamples/TreeView/Selection` + - `/ControlSamples/TreeView/ExpandCollapse` + +- **MenuControl_Loads_AndRendersContent:** + - `/ControlSamples/Menu/Selection` + +### Interaction Tests (InteractiveComponentTests.cs) + +Added 9 new `[Fact]` tests: + +| Test | What It Verifies | +|------|-----------------| +| `GridView_Selection_ClickSelect_HighlightsRow` | Click Select link ΓåÆ selected index updates, count increments | +| `GridView_DisplayProperties_RendersCaption` | Caption element, EmptyDataTemplate, ShowHeader/ShowFooter checkboxes | +| `TreeView_Selection_ClickNode_ShowsSelected` | Click node ΓåÆ selection text and count update | +| `TreeView_ExpandCollapse_ButtonsWork` | Expand All / Collapse All buttons, leaf node visibility, NodeIndent slider | +| `Menu_Selection_ClickItem_ShowsFeedback` | Click menu item ΓåÆ click count increments (no console error checks ΓÇö JS interop) | +| `DetailsView_Styles_RendersStyledTable` | Table renders, "Customer Details" header visible | +| `DetailsView_Caption_RendersCaptionElement` | `
    ` elements present, "Customer Record" text | +| `FormView_Events_ClickEdit_LogsEvent` | Click Edit → event log entries appear | +| `FormView_Styles_RendersStyledHeader` | "Widget Catalog" header text visible | + +## Patterns Used + +- Menu Selection test skips console error checks (JS interop produces expected errors) +- FormView tests use `WaitUntilState.DOMContentLoaded` (items bound in `OnAfterRenderAsync`) +- All other tests use `WaitUntilState.NetworkIdle` with 30s timeout +- Console error filtering: ISO 8601 timestamps + "Failed to load resource" + +## Build Verification + +`dotnet build samples/AfterBlazorServerSide.Tests/ -c Release` — succeeded with no errors. + +# Decision: Avoid bare `text=` locators in Playwright integration tests + +**Author:** Colossus +**Date:** 2025-07-24 +**Status:** Proposed + +## Context + +Five integration tests failed in CI (PR #343) because `page.Locator("text=Label:")` matches the *innermost* element containing the text. When markup uses `

    Label: value

    `, the locator returns the ``, excluding the sibling value text from `TextContentAsync()`. Additionally, bare `text=` locators cause strict-mode violations when the same text appears in both rendered output and code examples. + +## Decision + +All Playwright integration tests MUST use container-targeted locators instead of bare `text=` selectors when reading text content that includes a label and a value: + +```csharp +// ❌ BAD — matches , returns only label text +var info = page.Locator("text=Selected index:"); + +// ✅ GOOD — matches the parent

    , returns label + value +var info = page.Locator("p").Filter(new() { HasTextString = "Selected index:" }); +``` + +For elements that might appear in multiple places (rendered output + code examples), target the specific rendered element type: + +```csharp +// ❌ BAD — strict mode violation if text appears twice +var header = page.Locator("text=Widget Catalog"); + +// ✅ GOOD — targets only the rendered

    +var header = page.Locator("td").Filter(new() { HasTextString = "Widget Catalog" }).First; +``` + +## Consequences + +- Existing tests using bare `text=` locators for value extraction should be migrated. +- New tests must follow this pattern from the start. +- WaitForSelectorAsync calls should use specific selectors (e.g., `button:has-text('Edit')`) not generic element type selectors. + +### 2026-02-24: User directive — M8 scope excludes version bump and release +**By:** Jeffrey T. Fritz (via Copilot) +**What:** Start on Forge's recommended Milestone 8 next steps EXCEPT the version bump to 1.0 and release. Focus on: Menu JS fix, Calendar fix, Menu auto-ID, formally defer Substitution/Xml, PagerSettings shared sub-component, doc polish. +**Why:** User request — captured for team memory + +# DataGrid Style Sub-Components + Paging/Sorting Events + +**By:** Cyclops +**Date:** 2026-02-24 +**Work Items:** WI-44, WI-45 + +## Decision + +DataGrid style sub-components follow the exact same `IXxxStyleContainer` + `UiTableItemStyle` + `CascadingParameter` pattern used by GridView, DetailsView, and FormView. Paging and sorting events follow Web Forms DataGrid naming conventions (not GridView conventions). + +## Details — WI-44 (Style Sub-Components) + +- **Interface:** `IDataGridStyleContainer` with 7 TableItemStyle properties (AlternatingItemStyle, ItemStyle, HeaderStyle, FooterStyle, PagerStyle, SelectedItemStyle, EditItemStyle) +- **CascadingValue name:** `"ParentDataGrid"` (matching GridView's `"ParentGridView"` convention) +- **7 sub-component pairs:** DataGridAlternatingItemStyle, DataGridItemStyle, DataGridHeaderStyle, DataGridFooterStyle, DataGridPagerStyle, DataGridSelectedItemStyle, DataGridEditItemStyle +- **Style priority in GetRowStyle:** EditItemStyle > SelectedItemStyle > AlternatingItemStyle > ItemStyle (matches Web Forms precedence) +- **Display properties added:** Caption, CaptionAlign, CellPadding, CellSpacing, GridLines, UseAccessibleHeader +- **Template enhanced:** Paging UI (page links in tfoot), footer row, caption element, grid lines rules attribute, sortable header links + +## Details — WI-45 (Paging + Sorting Events) + +- **Events:** PageIndexChanged (DataGridPageChangedEventArgs), SortCommand (DataGridSortCommandEventArgs), ItemCreated (DataGridItemEventArgs), ItemDataBound (DataGridItemEventArgs), SelectedIndexChanged (EventCallback) +- **Event args:** DataGridPageChangedEventArgs (NewPageIndex), DataGridSortCommandEventArgs (SortExpression, CommandSource), DataGridItemEventArgs (Item) +- **Paging:** GoToPage(int) updates CurrentPageIndex and fires PageIndexChanged +- **Sorting:** Sort(string) fires SortCommand when AllowSorting is enabled via header links + +## Key Naming Difference: DataGrid vs GridView + +DataGrid uses Web Forms DataGrid naming (ItemStyle, AlternatingItemStyle, EditItemIndex, CurrentPageIndex) rather than GridView naming (RowStyle, AlternatingRowStyle, EditIndex, PageIndex). This matches the original ASP.NET Web Forms distinction between the two controls. + +## Why + +Consistency with existing GridView style pattern ensures predictable API. DataGrid-specific naming preserves Web Forms migration fidelity — developers migrating `` markup expect `ItemStyle` not `RowStyle`. + +# DetailsView + FormView Polish Decisions + +**By:** Cyclops +**Date:** Milestone 7 + +## WI-26: DetailsView Style Sub-Components + +**What:** Created `IDetailsViewStyleContainer` interface with 10 style properties and 10 sub-component pairs following the established GridView/Calendar pattern. DetailsView has two extra styles vs GridView: `CommandRowStyle` (for the Edit/Delete/New command row) and `FieldHeaderStyle` (for the left-side header cell in each data row). `InsertRowStyle` is separate from `EditRowStyle` to match Web Forms semantics where Insert and Edit modes can be styled independently. + +**Why:** Consistent with the GridView style sub-component architecture (WI-05). DetailsView has distinct row types (command row, field headers) that Web Forms styled separately. CascadingParameter name is "ParentDetailsView" to avoid collision with "ParentGridView". + +## WI-28: DetailsView Caption + PagerSettings + +**What:** Added `Caption` (string), `CaptionAlign` (TableCaptionAlign enum), and `PageCount` (computed int). Reuses existing `TableCaptionAlign` enum and `GetCaptionStyle()` pattern from GridView. `PageCount` is a read-only computed property (`Items.Count()`) since DetailsView shows one item per page. PagerSettings deferred to a future WI — the current implementation uses the existing PagerTemplate approach and inline numeric pager. + +**Why:** Caption/CaptionAlign match GridView's implementation exactly. PageCount is trivially derived from the data source. Full PagerSettings (Mode, Position, PageButtonCount, navigation text) is better as a dedicated sub-component in a follow-up WI to keep this change focused. + +## WI-31: FormView Remaining Events + +**What:** Added `ModeChanged` (fires after mode transitions), `ItemCommand` (fires for all command bubbling via `FormViewCommandEventArgs`), `ItemCreated` (fires on first render), `PageIndexChanging`/`PageIndexChanged` (with cancellation via `PageChangedEventArgs.Cancel`). Added "page" command handler supporting "next"/"prev"/"first"/"last"/numeric arguments. + +**Why:** Web Forms FormView fires ModeChanged after every mode switch. ItemCommand is the catch-all command handler that fires before specific handlers. ItemCreated maps to the initial data-bound lifecycle. Page events reuse the existing `PageChangedEventArgs` class (shared with GridView/DetailsView). + +## WI-33: FormView Style Sub-Components + Pager + Caption + +**What:** Created `IFormViewStyleContainer` interface with 7 style properties and 7 sub-component pairs. Added `PagerTemplate` (RenderFragment) that replaces the default numeric pager when set. Added `Caption`/`CaptionAlign` using the same pattern as DetailsView/GridView. `GetCurrentRowStyle()` resolves style based on `CurrentMode` (Edit→EditRowStyle, Insert→InsertRowStyle, default→RowStyle). + +**Why:** FormView doesn't have AlternatingRowStyle because it only displays one item at a time. The 7 styles (RowStyle, EditRowStyle, InsertRowStyle, HeaderStyle, FooterStyle, EmptyDataRowStyle, PagerStyle) cover all distinct visual regions. PagerTemplate enables custom pager markup, matching Web Forms' `` element. + +# GridView Display Properties — Decision Record + +**Author:** Cyclops +**Date:** WI-07 implementation +**Component:** GridView + +## Decisions + +### 1. ShowHeaderWhenEmpty defaults to false (breaking behavior change) + +Previously, GridView always rendered `
    ` in the header (not ``). Rather than changing the default to `` (which would be a larger breaking change), `UseAccessibleHeader=true` adds `scope="col"` to the existing `` elements for accessibility compliance. When false (default), `` renders without scope ΓÇö preserving existing HTML output. + +### 3. GridLines.None suppresses the rules attribute entirely + +When `GridLines=None` (default), `GetGridLinesRules()` returns `null`, so Blazor omits the `rules` attribute from the `` element. This matches Web Forms behavior where `GridLines.None` means no `rules` attribute is rendered. + +### 4. CellPadding/CellSpacing use -1 sentinel for "don't render" + +Following Web Forms convention, `-1` means the attribute is not rendered. Any value `>= 0` renders the corresponding `cellpadding`/`cellspacing` attribute on the `
    `. + +### 5. ShowFooter and paging share a single tfoot + +When both `ShowFooter=true` and `AllowPaging` with multiple pages, both the footer row and pager row render inside the same `` element. The footer row renders first, followed by the pager row. Footer row gets `FooterStyle` applied. + +### 6. EmptyDataTemplate takes precedence over EmptyDataText + +When both `EmptyDataTemplate` (RenderFragment) and `EmptyDataText` (string) are set, the template wins. This matches Web Forms behavior. + +### GridView Selection Support — Pattern Decisions + +**By:** Cyclops +**Date:** 2026-02-24 +**WI:** WI-02 + +**What:** +- `SelectedIndex` (int, default -1) follows the same pattern as `EditIndex` +- `SelectedRow` and `SelectedValue` are computed read-only properties (not parameters) +- `SelectedValue` uses reflection on `DataKeyNames` first key field, matching Web Forms behavior +- `AutoGenerateSelectButton` adds a "Select" link to the command column, rendered before Edit/Delete links +- `ShowCommandColumn` now includes `AutoGenerateSelectButton` in its check +- `GetRowStyle()` priority: EditRowStyle > SelectedRowStyle > AlternatingRowStyle > RowStyle (edit takes precedence over selection) +- `GridViewSelectedRowStyle` follows the existing `IGridViewStyleContainer` + CascadingParameter pattern (same as `GridViewEditRowStyle`, etc.) +- `GridViewSelectEventArgs` follows the same pattern as `GridViewEditEventArgs` (NewSelectedIndex + Cancel) + +**Why:** +- Selection mirrors the existing edit-mode pattern for consistency +- Edit takes priority over selection in styling because a row being edited is an active operation +- The `SelectedRowStyle` child component reuses the established `IGridViewStyleContainer` interface rather than creating a new one + +# GridView Style Sub-Components Pattern + +**By:** Cyclops +**Date:** 2026-02-24 +**Work Item:** WI-05 + +## Decision + +GridView style sub-components follow the same `IXxxStyleContainer` + `UiTableItemStyle` + `CascadingParameter` pattern used by Calendar and DataList. + +## Details + +- **Interface:** `IGridViewStyleContainer` with 8 TableItemStyle properties (RowStyle, AlternatingRowStyle, HeaderStyle, FooterStyle, EmptyDataRowStyle, PagerStyle, EditRowStyle, SelectedRowStyle) +- **CascadingValue name:** `"ParentGridView"` (matching Calendar's `"ParentCalendar"` and DataList's `"ParentDataList"` convention) +- **Style priority in GetRowStyle:** EditRowStyle > SelectedRowStyle > AlternatingRowStyle > RowStyle (matches Web Forms precedence) +- **Style application:** Inline `style` attribute via `TableItemStyle.ToString()` on `` elements, not CSS classes +- **EditRowStyle migration:** Changed from `[Parameter]` to `IGridViewStyleContainer` property with `internal set`, to be consistent with all other style properties and enable sub-component setting + +## Why + +This maintains consistency with the existing Calendar and DataList style patterns. The `CascadingParameter` + interface approach allows style sub-components to be declared as child elements in markup, exactly matching Web Forms `` syntax. + +# Decision: ListView CRUD Events Pattern (WI-41) + +**By:** Cyclops +**Date:** 2026-02-24 + +## What + +ListView CRUD events follow the same dual-event pattern as GridView and FormView: +- Pre-events (ItemEditing, ItemDeleting, ItemUpdating, ItemInserting, ItemCanceling) support `Cancel` bool +- Post-events (ItemDeleted, ItemInserted, ItemUpdated) carry `AffectedRows` + `Exception` +- `ItemCommand` fires for unrecognized commands (catch-all) +- `HandleCommand(string, object, int)` is the public routing method + +## Why + +Consistent with GridView's `EditRow`/`UpdateRow`/`DeleteRow`/`CancelEdit` and FormView's `HandleCommandArgs` patterns. ListView event args are intentionally simpler than FormView's (no OrderedDictionary) because the task spec said "don't over-engineer dictionaries if simpler patterns work." + +## Key Decisions + +1. **EmptyItemTemplate vs EmptyDataTemplate:** `EmptyItemTemplate` takes precedence when both are set. `EmptyDataTemplate` was the original, `EmptyItemTemplate` is the Web Forms ListView-specific name. +2. **ListViewCancelMode enum:** Created in `Enums/ListViewCancelMode.cs` — `CancelingEdit` (0) and `CancelingInsert` (1). Follows project enum convention with explicit int values. +3. **GetItemTemplate helper:** Returns EditItemTemplate when itemIndex matches EditIndex, otherwise delegates to alternating template logic. Used in both grouped and non-grouped rendering paths. +4. **InsertItemTemplate positioning:** Renders at top (before items) or bottom (after items) based on InsertItemPosition enum, only in the non-grouped (GroupItemCount == 0) path. + +# Decision: Menu auto-ID generation pattern + +**By:** Cyclops +**Date:** 2026-02-24 + +## What + +Menu component now auto-generates an ID (`menu_{GetHashCode():x}`) in `OnParametersSet` when no explicit `ID` parameter is provided. This ensures JS interop via `Sys.WebForms.Menu` always has a valid DOM element ID to target. + +Additionally, `Menu.js` now has null safety (early return if element not found) and a try/catch around the constructor to prevent unhandled JS exceptions from crashing the Blazor circuit. + +## Why + +The Menu component's JS interop depends on a DOM element ID to find and manipulate the menu element. Without an ID, `document.getElementById('')` returns null, causing `TypeError: Cannot read properties of null (reading 'tagName')`. This crashed the entire Blazor circuit in headless Chrome environments. + +## Impact + +Any component that uses JS interop via element IDs should consider auto-generating IDs when none are provided. This pattern (`$"componentname_{GetHashCode():x}"` in `OnParametersSet`) could be reused by other components with JS interop dependencies. + +# Decision: Menu Core Improvements (WI-19 + WI-21 + WI-23) + +**Author:** Cyclops +**Date:** 2026-02-24 +**Status:** Implemented +**Branch:** milestone7/feature-implementation + +## Context + +Menu component needed three improvements: base class upgrade for styling, selection tracking with events, and missing core properties. + +## Decisions + +### WI-19: Menu inherits BaseStyledComponent + +- Changed `Menu : BaseWebFormsComponent` → `Menu : BaseStyledComponent` +- Menu now gets BackColor, BorderColor, BorderStyle, BorderWidth, CssClass, Font, ForeColor, Height, Width from the base class +- `Menu.razor` root `
    ` renders `style="@Style"` and `class="@GetMenuCssClass()"` using the inherited `Style` property +- `GetMenuCssClass()` helper returns null when CssClass is empty (same pattern as Label) +- Existing sub-component styles (DynamicHoverStyle, StaticMenuItemStyle, etc.) remain unchanged ΓÇö they are non-parameter properties set by child sub-components, completely independent from `[Parameter]` base class styles +- MenuItem.razor still inherits BaseWebFormsComponent (no styling needed on individual items) + +### WI-21: Selection tracking and events + +- `SelectedItem` (MenuItem, read-only) ΓÇö set internally when a menu item is clicked +- `SelectedValue` (string, read-only) ΓÇö computed from `SelectedItem?.Value` +- `MenuItemClick` (EventCallback\) ΓÇö fires when any menu item is clicked +- `MenuItemDataBound` (EventCallback\) ΓÇö fires after each data-bound MenuItem is created +- Created `MenuEventArgs` class with `Item` property (follows TreeNodeEventArgs pattern) +- MenuItem calls `ParentMenu.NotifyItemClicked(this)` via `@onclick` handler +- `@onclick:preventDefault` only applies when `NavigateUrl` is empty (preserves navigation for link items) + +### WI-23: Core missing properties + +- `MaximumDynamicDisplayLevels` (int, default 3) ΓÇö limits depth of dynamic flyout menus +- `Target` (string) ΓÇö default link target for menu items; MenuItem has its own `Target` that overrides via `EffectiveTarget` +- `SkipLinkText` (string, default "Skip Navigation Links") ΓÇö rendered as `` after; matches Web Forms pattern +- `PathSeparator` (char, default '/') ΓÇö stored on Menu, used in MenuItem.ValuePath computation +- MenuItem gets `Value` (string) and `ValuePath` (string, computed) properties +- MenuItem `target` attribute changed from hardcoded `_blank` to `@EffectiveTarget` (item-level Target > Menu-level Target) + +## Files Changed + +- `src/BlazorWebFormsComponents/Menu.razor` ΓÇö BaseStyledComponent inherits, style/class on root, skip link +- `src/BlazorWebFormsComponents/Menu.razor.cs` ΓÇö Base class change, new properties, events, NotifyItemClicked +- `src/BlazorWebFormsComponents/MenuItem.razor` ΓÇö Click handler, EffectiveTarget +- `src/BlazorWebFormsComponents/MenuItem.razor.cs` ΓÇö Value, Target, ValuePath, EffectiveTarget, HandleClick +- `src/BlazorWebFormsComponents/MenuEventArgs.cs` ΓÇö New file + +## Risks + +- `MenuItemDataBound` fires with `null` Item during RenderTreeBuilder execution (component isn't materialized yet). Consumers should use this for counting/logging, not item manipulation. +- `MaximumDynamicDisplayLevels` property is declared but not yet enforced in rendering logic ΓÇö the JS interop and CSS-based flyout system would need updates to actually limit depth. + +# Decision: Menu Level Styles, Panel BackImageUrl, Login/ChangePassword Orientation + +**Author:** Cyclops +**Date:** 2026-02-24 +**Status:** Implemented +**WIs:** WI-47, WI-48, WI-49 + +## WI-47 ΓÇö Menu Level Styles + +### Decision +Created `MenuLevelStyle` as a standalone class (not a ComponentBase sub-component) with public constructor implementing `IStyle`. Level style collections are `List` parameters on Menu, not `RenderFragment` sub-components. + +### Rationale +- Level styles are positional (index-based), unlike named sub-components (StaticMenuItemStyle, DynamicMenuItemStyle) +- A `List` parameter is the natural API for ordered collections +- `MenuLevelStyle` needed a public constructor (unlike `Style`/`TableItemStyle` which have `internal` constructors) so users can instantiate them in code +- Follows the same `IStyle` contract so `ToStyle()` extension works for CSS generation + +### Style Resolution Order +MenuItem applies styles in this priority: +1. LevelSelectedStyles (if item is selected and entry exists at depth index) +2. LevelMenuItemStyles (if entry exists at depth index) +3. Falls back to static/dynamic CSS class styles from `