From 7347e01aca3981533ec5bccb7065a65feb94b362 Mon Sep 17 00:00:00 2001 From: edgett Date: Wed, 18 Mar 2026 09:22:20 -0400 Subject: [PATCH 001/188] Update sortable.js --- .../wwwroot/js/primitives/sortable.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/sortable.js b/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/sortable.js index d7305a975..87e19ef0a 100644 --- a/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/sortable.js +++ b/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/sortable.js @@ -24,7 +24,7 @@ async function loadSortable() { if (!sortableLoadPromise) { sortableLoadPromise = (async () => { // Resolve relative to this module's own URL - const libPath = new URL('../../lib/sortable/Sortable.min.js', import.meta.url).href; + const libPath = new URL('../../lib/sortable/sortable.min.js', import.meta.url).href; const mod = await import(libPath); return mod; })(); From e251a34219b26f727b415908a4a79e8e90b9b3c3 Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:00:02 +0800 Subject: [PATCH 002/188] feat: theme system, DataGrid virtual scroll & search, Menubar/NavigationMenu primitives (#269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add theme system with ThemeService, BbThemeSwitcher, and BbDarkModeToggle Add a complete theme system to the Components library: - ThemeService: manages dark mode, base color (5 gray scales), primary color (17 accents + Fuchsia), and border radius. Persists to localStorage, detects system color scheme preference, and applies via CSP-compliant JS. - BbThemeSwitcher: popover panel matching shadcn/ui website design with color grid (3-col labeled chips), radius picker (5 presets), and light/dark mode toggle buttons. - BbDarkModeToggle: standalone button showing current mode icon (sun/moon), with optional custom icons via RenderFragment, optional label, and configurable button variant/size. Works without themes.css. - themes.css: ships 5 base color palettes and 17 primary color overrides using data-attribute selectors on . - theme.js: ES module for DOM manipulation (no eval). - DI registration via optional configureTheme parameter on AddBlazorBlueprintComponents(). - Both components auto-initialize ThemeService on first render. Replaces demo ThemeService and DarkModeToggle with library components. * feat: update changelog with theme system details for ThemeService, BbThemeSwitcher, and BbDarkModeToggle * feat: ChartTooltip AppendToBody, DataView GridColumnMinWidth, Menubar focus fix C1: Add AppendToBody parameter to BbChartTooltip — maps to ECharts' tooltip.appendToBody option, preventing tooltip clipping by overflow containers. D1: Add GridColumnMinWidth parameter to BbDataView — when set, uses CSS repeat(auto-fill, minmax(value, 1fr)) instead of fixed breakpoint columns for adaptive grid layouts. E1: Fix Menubar initial focus — change from "first" (highlights first item immediately) to "container" (focuses the menu container without selecting an item). Matches Windows/macOS platform convention where menubar submenus don't highlight until ArrowDown. Add "container" initialFocus mode to menu-keyboard.js. * fix: revert Menubar initialFocus to "first" The "container" focus mode causes ArrowDown to not navigate to menu items. Revert to "first" which highlights the first item on open — this matches the existing working behavior. The "container" mode remains available in menu-keyboard.js for other use cases. * feat: DataGrid enhancements — OverscanCount, Striped, TableContainerClass, responsive pagination A1: Configurable OverscanCount parameter (default 5) replacing the hardcoded value on both Virtualize instances. A2: Striped rows via Striped and StripeClass parameters. When enabled, applies alternating row backgrounds that compose with RowClass. Works in both flat and hierarchy modes. A5: Mobile-responsive pagination — page size selector and first/last buttons hidden below sm breakpoint, page display hidden below lg. Previous/Next always visible. A7: TableContainerClass parameter for styling the inner scrollable div that wraps the table element. A8: Safari focus ring fix — already implemented (per-cell box-shadow in blazorblueprint-input.css), no changes needed. * feat: add Menubar and NavigationMenu headless primitives G1: Extract Menubar primitives into BlazorBlueprint.Primitives.Menubar. Headless components: BbMenubar, BbMenubarMenu, BbMenubarTrigger, BbMenubarContent, BbMenubarItem, BbMenubarCheckboxItem, BbMenubarLabel, BbMenubarSeparator. MenubarContext manages open/close state, menu registration, and horizontal navigation. Components layer refactored to wrap primitives with Tailwind styling. H1: Add NavigationMenu primitives into BlazorBlueprint.Primitives.NavigationMenu. Headless components: BbNavigationMenu, BbNavigationMenuItem, BbNavigationMenuTrigger, BbNavigationMenuContent, BbNavigationMenuList, BbNavigationMenuLink. NavigationMenuContext manages active item state, close timers, and trigger registration. Viewport and Indicator stay at Components layer (purely visual). Components layer retains existing JS interop for keyboard navigation. * feat: add Menubar and NavigationMenu primitive demo pages Add demo pages for the new headless primitives: - MenubarPrimitiveDemo: basic menubar with File/Edit menus, checkbox items with View panel toggles, accessibility documentation - NavigationMenuPrimitiveDemo: navigation with hover dropdowns, multi-column content panels, direct links Update sidebar navigation with Menubar and Navigation Menu entries in alphabetical order. Add cards to the Primitives index page. Verified all 28 primitive sidebar links match their @page routes. * fix: use Description parameter on KeyboardShortcutItem in primitive demos KeyboardShortcutItem renders description on the left and BbKbd keys on the right. The menubar and navigation menu demos had the content structure inverted. * feat: update DataGrid styling demo with Striped and TableContainerClass Update striped rows demo to use the built-in Striped parameter instead of the manual RowClass approach. Add TableContainerClass demo showing inner container border/radius customization. Update code examples. * feat: update DataGrid styling demo with Striped and TableContainerClass Add Theme System demo page at /components/theme with quick start guide, BbThemeSwitcher and BbDarkModeToggle live demos (default, with label, label-only, custom icons), configuration reference, and programmatic API examples. Added sidebar entry. Add adaptive auto-fill grid demo to DataView page showing GridColumnMinWidth="200px" with fluid column layout. Add BbChartTooltip.AppendToBody to BarChart API reference documenting the tooltip clipping fix for overflow containers. Update DataGrid styling demo: Striped Rows section now uses the built-in Striped parameter instead of RowClass workaround. Added TableContainerClass demo with custom border styling. Update changelog with all gap analysis implementation entries. * feat: add server-side virtual scroll to DataGrid Wire Blazor's native Virtualize ItemsProvider to BlazorBlueprint's DataGridRequest for true server-side infinite scroll. The Virtualize component requests only the visible rows plus overscan from the server as the user scrolls. - Add VirtualScrollHeight parameter (default "400px") for scroll container height in virtual+provider mode - Add VirtualItemsProviderAsync bridge that translates Blazor's ItemsProviderRequest into DataGridRequest with current sort/filter state - LoadFromProviderAsync delegates to Virtualize.RefreshDataAsync() when in virtual+provider mode - Skip empty state check when Virtualize drives data loading - Hide pagination footer in virtual+provider mode - Guard against grouped mode (not supported with virtual provider) - Add demo with 10,000 row simulated server dataset and code example * feat: add global search to DataGrid Add ShowSearch parameter that renders a debounced search input above the grid. Filters across all columns with Filterable=true using case-insensitive string matching (client-side). For server-side grids, SearchText is passed via DataGridRequest.SearchText. Parameters: ShowSearch, SearchText, SearchTextChanged, SearchPlaceholder, SearchDebounceMs (default 300ms). Search resets pagination to page 1. Composes with existing per-column filters (search runs after column filters). Localization key DataGrid.SearchPlaceholder added. Includes demo section and code example on the DataGrid demo page. Also includes server-side virtual scroll demo from previous work. * fix: global search matches both formatted and raw column values Search now checks both GetValue (formatted, e.g., "$113,876") and GetRawValue (unformatted, e.g., 113876) so users can search by either the displayed text or the underlying value. Also mark Salary column as Filterable in the global search demo. * feat: add server-side virtual scroll and global search to DataGrid * docs: add source code examples for Menubar and NavigationMenu primitive demos * chore: fix extra blank line in CHANGELOG.md --- CHANGELOG.md | 15 +- demos/BlazorBlueprint.Demo.Auto/App.razor | 6 +- demos/BlazorBlueprint.Demo.Server/App.razor | 6 +- .../DataGrid/custom-striped-rows.txt | 9 +- .../DataGrid/custom-table-container.txt | 9 + .../Components/DataGrid/global-search.txt | 24 + .../DataGrid/virtual-server-scroll.txt | 35 ++ .../Components/DataView/adaptive-grid.txt | 17 + .../CodeExamples/Primitives/Menubar/basic.txt | 36 ++ .../Primitives/Menubar/checkbox-items.txt | 27 + .../Primitives/NavigationMenu/basic.txt | 32 ++ .../Extensions/ServiceCollectionExtensions.cs | 11 +- .../Pages/Charts/BarChartDemo.razor | 13 + .../Pages/Components/DataGridDemo.razor | 104 ++++ .../Components/DataGridStylingDemo.razor | 35 +- .../Pages/Components/DataViewDemo.razor | 38 ++ .../Pages/Components/ThemeDemo.razor | 196 ++++++++ .../Pages/Primitives/Index.razor | 16 + .../Primitives/MenubarPrimitiveDemo.razor | 170 +++++++ .../NavigationMenuPrimitiveDemo.razor | 125 +++++ .../Services/ThemeService.cs | 128 ----- .../Shared/DarkModeToggle.razor | 44 -- .../Shared/DemoSidebar.razor | 15 + .../Shared/MainLayout.razor | 3 +- ...6-03-23-datagrid-virtual-items-provider.md | 348 +++++++++++++ .../Chart/Composables/BbChartTooltip.razor.cs | 14 +- .../Components/Chart/Models/EChartsTooltip.cs | 4 + .../Components/DataGrid/BbDataGrid.razor | 42 +- .../Components/DataGrid/BbDataGrid.razor.cs | 216 +++++++- .../Components/DataView/BbDataView.razor | 4 +- .../Components/DataView/BbDataView.razor.cs | 18 +- .../Components/Menubar/BbMenubar.razor | 54 +- .../Menubar/BbMenubarCheckboxItem.razor | 50 +- .../Components/Menubar/BbMenubarContent.razor | 120 +---- .../Components/Menubar/BbMenubarItem.razor | 41 +- .../Components/Menubar/BbMenubarLabel.razor | 4 +- .../Components/Menubar/BbMenubarMenu.razor | 49 +- .../Menubar/BbMenubarSeparator.razor | 2 +- .../Components/Menubar/BbMenubarTrigger.razor | 28 +- .../Components/Theme/BaseColor.cs | 23 + .../Components/Theme/BbDarkModeToggle.razor | 132 +++++ .../Components/Theme/BbThemeSwitcher.razor | 258 ++++++++++ .../Components/Theme/PrimaryColor.cs | 62 +++ .../Components/Theme/ThemeOptions.cs | 40 ++ .../Components/Theme/ThemeService.cs | 360 ++++++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 14 +- .../Localization/DefaultBbLocalizer.cs | 13 + .../wwwroot/css/themes.css | 466 ++++++++++++++++++ .../wwwroot/js/theme.js | 105 ++++ .../DataGrid/DataGridItemsProvider.cs | 6 + .../Primitives/Menubar/BbMenubar.razor | 31 ++ .../Menubar/BbMenubarCheckboxItem.razor | 56 +++ .../Primitives/Menubar/BbMenubarContent.razor | 154 ++++++ .../Primitives/Menubar/BbMenubarItem.razor | 50 ++ .../Primitives/Menubar/BbMenubarLabel.razor | 19 + .../Primitives/Menubar/BbMenubarMenu.razor | 33 ++ .../Menubar/BbMenubarSeparator.razor | 11 + .../Primitives/Menubar/BbMenubarTrigger.razor | 44 ++ .../Primitives/Menubar/MenubarContext.cs | 142 ++++++ .../NavigationMenu/BbNavigationMenu.razor | 57 +++ .../BbNavigationMenuContent.razor | 41 ++ .../NavigationMenu/BbNavigationMenuItem.razor | 40 ++ .../NavigationMenu/BbNavigationMenuLink.razor | 36 ++ .../NavigationMenu/BbNavigationMenuList.razor | 19 + .../BbNavigationMenuTrigger.razor | 78 +++ .../NavigationMenu/NavigationMenuContext.cs | 178 +++++++ .../wwwroot/js/primitives/menu-keyboard.js | 4 + ...entsApiSurfaceMatchesBaseline.verified.txt | 58 ++- ...ivesApiSurfaceMatchesBaseline.verified.txt | 74 +++ 69 files changed, 4182 insertions(+), 530 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/custom-table-container.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/global-search.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/virtual-server-scroll.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataView/adaptive-grid.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Primitives/Menubar/basic.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Primitives/Menubar/checkbox-items.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Primitives/NavigationMenu/basic.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/Pages/Components/ThemeDemo.razor create mode 100644 demos/BlazorBlueprint.Demo.Shared/Pages/Primitives/MenubarPrimitiveDemo.razor create mode 100644 demos/BlazorBlueprint.Demo.Shared/Pages/Primitives/NavigationMenuPrimitiveDemo.razor delete mode 100644 demos/BlazorBlueprint.Demo.Shared/Services/ThemeService.cs delete mode 100644 demos/BlazorBlueprint.Demo.Shared/Shared/DarkModeToggle.razor create mode 100644 docs/plans/2026-03-23-datagrid-virtual-items-provider.md create mode 100644 src/BlazorBlueprint.Components/Components/Theme/BaseColor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Theme/BbDarkModeToggle.razor create mode 100644 src/BlazorBlueprint.Components/Components/Theme/BbThemeSwitcher.razor create mode 100644 src/BlazorBlueprint.Components/Components/Theme/PrimaryColor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Theme/ThemeOptions.cs create mode 100644 src/BlazorBlueprint.Components/Components/Theme/ThemeService.cs create mode 100644 src/BlazorBlueprint.Components/wwwroot/css/themes.css create mode 100644 src/BlazorBlueprint.Components/wwwroot/js/theme.js create mode 100644 src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubar.razor create mode 100644 src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarCheckboxItem.razor create mode 100644 src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarContent.razor create mode 100644 src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarItem.razor create mode 100644 src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarLabel.razor create mode 100644 src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarMenu.razor create mode 100644 src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarSeparator.razor create mode 100644 src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarTrigger.razor create mode 100644 src/BlazorBlueprint.Primitives/Primitives/Menubar/MenubarContext.cs create mode 100644 src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenu.razor create mode 100644 src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuContent.razor create mode 100644 src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuItem.razor create mode 100644 src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuLink.razor create mode 100644 src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuList.razor create mode 100644 src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuTrigger.razor create mode 100644 src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/NavigationMenuContext.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d5675a35..f832aa7dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Sidebar: CSS custom property theming** — Replaced hardcoded Tailwind layout classes with ~70 CSS custom properties across 12 sidebar components (`BbSidebarMenuButton`, `BbSidebarGroup`, `BbSidebarGroupLabel`, `BbSidebarMenu`, `BbSidebarHeader`, `BbSidebarFooter`, `BbSidebarContent`, `BbSidebarHeaderContent`, `BbSidebarMenuSubButton`, `BbSidebarMenuBadge`, `BbSidebarMenuItem`, `BbSidebarMenuSub`). Consumers can now theme sidebar padding, gap, font-size, line-height, border-radius, height, icon-size, active state styling, and badge appearance by setting variables on `:root` — no `!important` or specificity battles needed. Added `data-sidebar` attributes to 10 components that were missing them, and `data-size` attributes to `BbSidebarMenuButton` and `BbSidebarMenuSubButton` for size variant CSS targeting. Collapsible icon-mode overrides preserved via CSS rules. Zero breaking changes — all defaults match previous hardcoded values. - **BbSidebarMenuButton: `OnClick` EventCallback** — New `OnClick` parameter for custom click handling (e.g. programmatic sidebar toggle). Fires after the existing collapsible toggle logic. -- **DataGrid: server-side virtual scroll and global search** — Added server-side virtual scrolling support and global search across all columns, matching both formatted and raw values. ### Fixed @@ -25,13 +24,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- **Theme system** — Added `ThemeService`, `BbThemeSwitcher`, and `BbDarkModeToggle` for runtime theme management and switching. -- **Menubar and NavigationMenu headless primitives** — New headless primitives with demo pages for building accessible menu bars and navigation menus. -- **DataGrid enhancements** — Added `OverscanCount` parameter for virtualization tuning, `Striped` parameter for alternating row styling, `TableContainerClass` for custom wrapper styling, and responsive pagination layout. -- **DataGrid: global search** — Search across all columns matching both formatted and raw column values. -- **DataGrid: server-side virtual scroll** — Virtual scrolling support for server-side data sources. -- **ChartTooltip: `AppendToBody`** — New parameter to render chart tooltips in a portal, preventing overflow clipping. -- **DataView: `GridColumnMinWidth`** — New parameter to control minimum column width in grid layout mode. +- **Theme system: ThemeService, BbThemeSwitcher, and BbDarkModeToggle** — Added a complete, opt-in theme system to the Components library. `ThemeService` manages dark mode, base color (Zinc, Slate, Stone, Gray, Neutral), primary accent color (17 options), and border radius — with localStorage persistence and OS color scheme detection. `BbThemeSwitcher` is a popover panel with a color grid, radius picker, and light/dark mode toggle matching the shadcn/ui website design. `BbDarkModeToggle` is a standalone button that toggles dark mode with customizable icons (via `LightIcon`/`DarkIcon` RenderFragments), optional label, and configurable button variant/size. Both components auto-initialize on first render — no manual setup needed. Ships `themes.css` with 5 base color palettes and 17 primary color overrides using data-attribute selectors, and a CSP-compliant JS module (no `eval`). Theme options configurable via `AddBlazorBlueprintComponents(configureTheme: ...)`. +- **ChartTooltip: AppendToBody parameter** — Added `AppendToBody` parameter to `BbChartTooltip` that maps to ECharts' `tooltip.appendToBody`, preventing tooltip clipping by parent elements with `overflow: hidden`. +- **DataView: GridColumnMinWidth parameter** — Added `GridColumnMinWidth` parameter to `BbDataView` for adaptive auto-fill grid layouts using CSS `repeat(auto-fill, minmax(value, 1fr))` instead of fixed breakpoint columns. +- **DataGrid enhancements** — Added `OverscanCount` parameter (configurable virtualization buffer, was hardcoded to 5), `Striped` and `StripeClass` parameters for alternating row backgrounds, `TableContainerClass` for styling the inner scrollable container, and mobile-responsive pagination (page size selector and first/last buttons hidden on small screens). +- **Menubar and NavigationMenu headless primitives** — Extracted headless primitive layers into `BlazorBlueprint.Primitives.Menubar` (8 components: BbMenubar, BbMenubarMenu, BbMenubarTrigger, BbMenubarContent, BbMenubarItem, BbMenubarCheckboxItem, BbMenubarLabel, BbMenubarSeparator) and `BlazorBlueprint.Primitives.NavigationMenu` (6 components: BbNavigationMenu, BbNavigationMenuItem, BbNavigationMenuTrigger, BbNavigationMenuContent, BbNavigationMenuList, BbNavigationMenuLink). Components layer refactored to wrap primitives with Tailwind styling. Added demo pages for both primitives with sidebar and index page entries. +- **DataGrid: server-side virtual scroll** — When both `Virtualize="true"` and `ItemsProvider` are set, Blazor's native `` component drives data requests on demand as the user scrolls, fetching only the visible rows plus overscan from the server. Adds `VirtualScrollHeight` parameter (default `"400px"`) for the scroll container. Pagination is automatically hidden in this mode. Sort and filter changes trigger a full refresh via `RefreshDataAsync()`. Grouped/hierarchy mode is not supported with virtual provider. +- **DataGrid: global search** — Added `ShowSearch` parameter that renders a debounced search input above the grid, filtering across all columns with `Filterable=true` using case-insensitive string matching. Searches both formatted values (e.g., `$113,876`) and raw values (e.g., `113876`). For server-side grids, search text is passed via `DataGridRequest.SearchText`. Additional parameters: `SearchText` (two-way bindable), `SearchTextChanged`, `SearchPlaceholder`, and `SearchDebounceMs` (default 300ms). Search resets pagination to page 1. ### Fixed diff --git a/demos/BlazorBlueprint.Demo.Auto/App.razor b/demos/BlazorBlueprint.Demo.Auto/App.razor index 4e6aaaa07..4f3b9f9f4 100644 --- a/demos/BlazorBlueprint.Demo.Auto/App.razor +++ b/demos/BlazorBlueprint.Demo.Auto/App.razor @@ -9,6 +9,7 @@ + @@ -23,9 +24,6 @@ - + diff --git a/demos/BlazorBlueprint.Demo.Server/App.razor b/demos/BlazorBlueprint.Demo.Server/App.razor index 356bf4955..7a0344e9e 100644 --- a/demos/BlazorBlueprint.Demo.Server/App.razor +++ b/demos/BlazorBlueprint.Demo.Server/App.razor @@ -9,6 +9,7 @@ + @@ -23,9 +24,6 @@ - + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/custom-striped-rows.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/custom-striped-rows.txt index ada08ea26..f4155473d 100644 --- a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/custom-striped-rows.txt +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/custom-striped-rows.txt @@ -1,9 +1,14 @@ - +@* Built-in striped rows with default styling *@ + + + +@* Custom stripe class *@ + + ... \ No newline at end of file diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/custom-table-container.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/custom-table-container.txt new file mode 100644 index 000000000..6d6165f3a --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/custom-table-container.txt @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/global-search.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/global-search.txt new file mode 100644 index 000000000..40b681a53 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/global-search.txt @@ -0,0 +1,24 @@ + + + + + + + + + +@* Customize placeholder and debounce *@ + + ... + + +@* Server-side: SearchText is passed via DataGridRequest.SearchText *@ + + ... + \ No newline at end of file diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/virtual-server-scroll.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/virtual-server-scroll.txt new file mode 100644 index 000000000..a280209da --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/virtual-server-scroll.txt @@ -0,0 +1,35 @@ + + + + + + + + + + +@code { + private async ValueTask> LoadPeopleAsync( + DataGridRequest request) + { + // Your server/API call here — only the requested window is fetched + var result = await MyApi.GetPeopleAsync( + skip: request.StartIndex, + take: request.Count ?? 50, + sort: request.SortDefinitions, + cancellationToken: request.CancellationToken); + + return new DataGridResult + { + Items = result.Items, + TotalItemCount = result.TotalCount + }; + } +} \ No newline at end of file diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataView/adaptive-grid.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataView/adaptive-grid.txt new file mode 100644 index 000000000..a3b46933e --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataView/adaptive-grid.txt @@ -0,0 +1,17 @@ + + + + + @product.Name + + + $@product.Price.ToString("F2") + + + + + + + + \ No newline at end of file diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Primitives/Menubar/basic.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Primitives/Menubar/basic.txt new file mode 100644 index 000000000..e09b635e0 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Primitives/Menubar/basic.txt @@ -0,0 +1,36 @@ +@using BlazorBlueprint.Primitives.Menubar + + + + + File + + + + New File + + + + Save + + + + + + Edit + + + Undo + + + Redo + + + + + +@code { + private string? lastAction; +} \ No newline at end of file diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Primitives/Menubar/checkbox-items.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Primitives/Menubar/checkbox-items.txt new file mode 100644 index 000000000..ed67d0b1b --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Primitives/Menubar/checkbox-items.txt @@ -0,0 +1,27 @@ +@using BlazorBlueprint.Primitives.Menubar + + + + View + + + Panels + + + @(showSidebar ? "✓ " : " ")Sidebar + + + @(showMinimap ? "✓ " : " ")Minimap + + + + + +@code { + private bool showSidebar = true; + private bool showMinimap; +} \ No newline at end of file diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Primitives/NavigationMenu/basic.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Primitives/NavigationMenu/basic.txt new file mode 100644 index 000000000..0bb79dd75 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Primitives/NavigationMenu/basic.txt @@ -0,0 +1,32 @@ +@using BlazorBlueprint.Primitives.NavigationMenu + + + + + + Getting Started + + +
+
+

Introduction

+

Learn the basics.

+
+
+

Installation

+

How to install and configure.

+
+
+
+
+ +
  • + + Documentation + +
  • +
    +
    \ No newline at end of file diff --git a/demos/BlazorBlueprint.Demo.Shared/Extensions/ServiceCollectionExtensions.cs b/demos/BlazorBlueprint.Demo.Shared/Extensions/ServiceCollectionExtensions.cs index 9387613fb..838df8702 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Extensions/ServiceCollectionExtensions.cs +++ b/demos/BlazorBlueprint.Demo.Shared/Extensions/ServiceCollectionExtensions.cs @@ -8,11 +8,12 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddBlazorBlueprintDemo(this IServiceCollection services) { - // Add all BlazorBlueprint services (Primitives + Components) - services.AddBlazorBlueprintComponents(); - - // Add theme service for dark mode management - services.AddScoped(); + // Add all BlazorBlueprint services (Primitives + Components + Theme) + services.AddBlazorBlueprintComponents(configureTheme: options => + { + options.DefaultBaseColor = BaseColor.Zinc; + options.DetectSystemPreference = true; + }); // Add collapsible state service for menu state persistence services.AddScoped(); diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Charts/BarChartDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Charts/BarChartDemo.razor index 3bae4130e..7eaeb2040 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Charts/BarChartDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Charts/BarChartDemo.razor @@ -371,6 +371,19 @@ CSS color for the bar. Use var(--chart-N) tokens.

    + +
    +

    BbChartTooltip.AppendToBody

    +

    + Type: bool? (default: null) +

    +

    + When true, renders the tooltip + outside the chart container by appending to <body>. + Prevents tooltip clipping by parent elements with overflow: hidden. + Applies to all chart types. +

    +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor index 5c66ca0ad..8ee3f0292 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor @@ -447,6 +447,29 @@ + +
    +
    +

    Global Search

    +

    + Add ShowSearch="true" to render a + debounced search input that filters across all columns with + Filterable=true. + For server-side grids, the search text is passed via + DataGridRequest.SearchText. +

    +
    + + + + + + + + + +
    +
    @@ -493,6 +516,36 @@
    + +
    +
    +

    Server-Side Virtual Scroll

    +

    + Combine Virtualize="true" with + ItemsProvider for true server-side + infinite scroll. The Virtualize component requests only the visible rows plus overscan + from the server as the user scrolls. Pagination is automatically hidden. + Use VirtualScrollHeight to set the + scroll container height. +

    +
    + + + + + + + + + +

    + Scrolling through 10,000 records — only visible rows are fetched from the simulated server. +

    + +
    +
    @@ -1169,6 +1222,9 @@ _ => BadgeVariant.Outline }; + // Server-side virtual scroll demo data (generated lazily) + private List? virtualServerPeople; + protected override void OnInitialized() { people = MockDataService.GeneratePersons(50); @@ -1176,6 +1232,54 @@ asyncPeople = MockDataService.GeneratePersons(100); } + private async ValueTask> VirtualServerProviderAsync( + BlazorBlueprint.Primitives.DataGrid.DataGridRequest request) + { + // Generate data on first request (simulates a large remote dataset) + virtualServerPeople ??= MockDataService.GeneratePersons(10_000); + + // Simulate network latency + await Task.Delay(30, request.CancellationToken); + + IEnumerable query = virtualServerPeople; + + // Apply sorting + var isFirst = true; + foreach (var sort in request.SortDefinitions) + { + Func selector = sort.ColumnId switch + { + "name" => p => p.Name, + "department" => p => p.Department, + "salary" => p => p.Salary, + _ => p => p.Id + }; + + if (isFirst) + { + query = sort.Direction == SortDirection.Ascending + ? query.OrderBy(selector) + : query.OrderByDescending(selector); + isFirst = false; + } + else if (query is IOrderedEnumerable ordered) + { + query = sort.Direction == SortDirection.Ascending + ? ordered.ThenBy(selector) + : ordered.ThenByDescending(selector); + } + } + + var total = virtualServerPeople.Count; + var items = query.Skip(request.StartIndex).Take(request.Count ?? 50).ToList(); + + return new BlazorBlueprint.Primitives.DataGrid.DataGridResult + { + Items = items, + TotalItemCount = total + }; + } + private async Task LoadStressTestData() { stressTestLoading = true; diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridStylingDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridStylingDemo.razor index 3b869adc1..dcf372d7f 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridStylingDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridStylingDemo.razor @@ -160,18 +160,19 @@
    - +

    Striped Rows

    - Use RowClass with Tailwind's - even: variant to alternate - row background colours. The hover effect remains visible on all rows. + Use the built-in Striped parameter + for alternating row backgrounds. Customize the stripe appearance with + StripeClass (defaults to + even:bg-muted/30 even:hover:bg-muted/70). + Composes with RowClass.

    - + @@ -182,6 +183,28 @@
    + +
    +
    +

    Table Container Styling

    +

    + Use TableContainerClass to style + the inner scrollable container that wraps the table. Useful for adding borders, + rounded corners, or constraining height. +

    +
    + + + + + + + + + +
    +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataViewDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataViewDemo.razor index 6b8b508c3..98658a142 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataViewDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataViewDemo.razor @@ -301,6 +301,44 @@
    + +
    +
    +

    Adaptive Auto-Fill Grid

    +

    + Use GridColumnMinWidth to create a + fluid grid that adapts to the container width. Uses CSS + repeat(auto-fill, minmax(value, 1fr)) + instead of fixed breakpoint columns. Resize the browser to see columns added or removed. +

    +
    + + + + +
    + @product.Name +
    + + @product.Name + + + $@product.Price.ToString("F2") + +
    +
    + + + + +
    + + +
    +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ThemeDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ThemeDemo.razor new file mode 100644 index 000000000..d0e2cf9ac --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ThemeDemo.razor @@ -0,0 +1,196 @@ +@page "/components/theme" + +Theme System - Blazor Blueprint + +
    +
    +

    Theme System

    +

    + Built-in theme management with dark mode, color palettes, border radius, and localStorage persistence. +

    +
    + +
    + + +
    +
    +

    Quick Start

    +

    + Two steps to add theming to your app. No manual initialization needed — the components + handle everything on first render. +

    +
    + +
    +
    +

    1. Add the theme CSS to your App.razor

    +
    <link href="_content/BlazorBlueprint.Components/css/themes.css" rel="stylesheet" />
    +
    +
    +

    2. Drop in the component

    +
    <BbThemeSwitcher />
    +@* or just dark mode: *@
    +<BbDarkModeToggle />
    +
    +
    +
    + + +
    +
    +

    Theme Switcher

    +

    + A popover panel with color selection (base grays + primary accents), border radius + presets, and a light/dark mode toggle. Click the paintbrush icon to open. +

    +
    + +
    + + Click the icon to open the theme panel +
    +
    + + +
    +
    +

    Dark Mode Toggle

    +

    + A standalone button that toggles dark mode. Shows a sun icon in light mode and + a moon icon in dark mode. Works without themes.css + — it only toggles the .dark class. +

    +
    + +
    +
    +

    Default (icon only)

    + +
    + +
    +

    With label

    + +
    + +
    +

    Label only, no icon

    + +
    + +
    +

    Custom icons

    + + + + + + + + +
    +
    +
    + + +
    +
    +

    Configuration

    +

    + Configure defaults in Program.cs + via the configureTheme parameter. +

    +
    + +
    +
    builder.Services.AddBlazorBlueprintComponents(
    +    configureTheme: options =>
    +    {
    +        options.DefaultBaseColor = BaseColor.Slate;
    +        options.DefaultPrimaryColor = PrimaryColor.Blue;
    +        options.DefaultRadius = 0.75;
    +        options.DefaultDarkMode = false;
    +        options.DetectSystemPreference = true;
    +        options.PersistToLocalStorage = true;
    +    });
    +
    +
    + + +
    +
    +

    Programmatic Access

    +

    + Inject ThemeService to read + or change the theme from code. +

    +
    + +
    +
    @@inject ThemeService ThemeService
    +
    +// Read current state
    +var isDark = ThemeService.IsDarkMode;
    +var baseColor = ThemeService.BaseColor;
    +var primaryColor = ThemeService.PrimaryColor;
    +var radius = ThemeService.Radius;
    +
    +// Change programmatically
    +await ThemeService.SetDarkModeAsync(true);
    +await ThemeService.SetBaseColorAsync(BaseColor.Slate);
    +await ThemeService.SetPrimaryColorAsync(PrimaryColor.Blue);
    +await ThemeService.SetRadiusAsync(0.75);
    +await ThemeService.ToggleDarkModeAsync();
    +
    +
    + + +
    +
    +

    API Reference

    +

    Component parameters.

    +
    +
    + + + Additional CSS classes for the trigger button. + + + Additional CSS classes for the popover content panel. + + + Horizontal alignment of the popover relative to the trigger. + + + + + + Custom icon to show when light mode is active. + + + Custom icon to show when dark mode is active. + + + Whether to show the mode icon. + + + Whether to show a text label indicating the current mode. + + + Custom label text for light mode. + + + Custom label text for dark mode. + + + Button styling variant. + + + Button size. + + +
    +
    +
    +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Primitives/Index.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Primitives/Index.razor index 9305a8f9a..519f930f9 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Primitives/Index.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Primitives/Index.razor @@ -156,6 +156,22 @@

    + + +

    Menubar

    +

    + Horizontal menubar with keyboard navigation and hover-to-switch +

    +
    + + + +

    Navigation Menu

    +

    + Site navigation with hover dropdowns and close timers +

    +
    +

    Popover

    diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Primitives/MenubarPrimitiveDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Primitives/MenubarPrimitiveDemo.razor new file mode 100644 index 000000000..8d95cfe55 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Primitives/MenubarPrimitiveDemo.razor @@ -0,0 +1,170 @@ +@page "/primitives/menubar" +Menubar Primitive - Blazor Blueprint + +
    +
    +
    +

    Menubar Primitive

    +

    + Headless menubar with keyboard navigation, ARIA semantics, and hover-to-switch behavior. +

    +
    +
    + +
    +
    +
    +

    Basic Menubar

    +

    + A headless menubar with two menus. All behavior (open/close, hover-switch, keyboard nav) is built in. + Style with your own CSS via class or inline styles. +

    +
    + +
    + + + + File + + + + New File + + + Open + + + + Save + + + + + + + Edit + + + + Undo + + + Redo + + + + + + @if (lastAction != null) + { +

    Last action: @lastAction

    + } +
    + + +
    + +
    +
    +

    Checkbox Items

    +

    + Checkbox items toggle state without closing the menu. Uses + role="menuitemcheckbox" and + aria-checked. +

    +
    + +
    + + + + View + + + + Panels + + + @(showSidebar ? "✓ " : " ")Sidebar + + + @(showMinimap ? "✓ " : " ")Minimap + + + + + +

    + Sidebar: @(showSidebar ? "visible" : "hidden") | Minimap: @(showMinimap ? "visible" : "hidden") +

    +
    + + +
    + + + + + + role="menubar" on the root container + + + role="menu" on content panels + + + role="menuitem" on items and triggers + + + role="menuitemcheckbox" with aria-checked on checkbox items + + + aria-haspopup="menu" and aria-expanded on triggers + + + role="separator" on separator elements + + + + + Arrow Down / Arrow Up + + + Arrow Right / Arrow Left + + + Enter / Space + + + Escape + + + Home / End + + + +
    +
    + +@code { + private string? lastAction; + private bool showSidebar = true; + private bool showMinimap; +} diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Primitives/NavigationMenuPrimitiveDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Primitives/NavigationMenuPrimitiveDemo.razor new file mode 100644 index 000000000..3a875d41a --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Primitives/NavigationMenuPrimitiveDemo.razor @@ -0,0 +1,125 @@ +@page "/primitives/navigation-menu" +Navigation Menu Primitive - Blazor Blueprint + +
    +
    +
    +

    Navigation Menu Primitive

    +

    + Headless navigation menu with hover-to-open dropdowns, close timers, and ARIA semantics. +

    +
    +
    + +
    +
    +
    +

    Basic Navigation Menu

    +

    + A headless navigation menu with dropdown items and direct links. + Hover opens dropdowns with a shared close timer to prevent flickering. + Navigation events automatically close open menus. +

    +
    + +
    + + + + + Getting Started + + +
    +
    +

    Introduction

    +

    Learn the basics of BlazorBlueprint primitives.

    +
    +
    +

    Installation

    +

    How to install and configure the library.

    +
    +
    +

    Architecture

    +

    Understand the two-layer component design.

    +
    +
    +
    +
    + + + + Components + + +
    +
    +

    Button

    +

    Displays a button or link.

    +
    +
    +

    Dialog

    +

    Modal dialog with overlay.

    +
    +
    +

    Tabs

    +

    Tabbed content interface.

    +
    +
    +

    Tooltip

    +

    Popup info on hover.

    +
    +
    +
    +
    + +
  • + + Documentation + +
  • +
    +
    +
    + + +
    + + + + + + Root renders as <nav> for semantic navigation + + + aria-haspopup="menu" and aria-expanded on triggers + + + role="menu" on content panels + + + role="menuitem" on navigation links + + + data-state="open|closed" for CSS animation hooks + + + + + Hover + + + Navigate + + + +
    +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Services/ThemeService.cs b/demos/BlazorBlueprint.Demo.Shared/Services/ThemeService.cs deleted file mode 100644 index e4b280a6d..000000000 --- a/demos/BlazorBlueprint.Demo.Shared/Services/ThemeService.cs +++ /dev/null @@ -1,128 +0,0 @@ -using Microsoft.JSInterop; - -namespace BlazorBlueprint.Demo.Services; - -/// -/// Service for managing dark mode theme state. -/// Handles toggling between light and dark themes with localStorage persistence. -/// -public class ThemeService -{ - private readonly IJSRuntime _jsRuntime; - private bool _isDarkMode; - private bool _isInitialized; - - /// - /// Event raised when the theme changes. - /// - public event Action? OnThemeChanged; - - /// - /// Gets whether dark mode is currently enabled. - /// - public bool IsDarkMode => _isDarkMode; - - public ThemeService(IJSRuntime jsRuntime) - { - _jsRuntime = jsRuntime; - } - - /// - /// Initializes the theme service by loading the saved preference from localStorage. - /// Should be called once during application startup. - /// - public async Task InitializeAsync() - { - if (_isInitialized) - { - return; - } - - try - { - // Try to load saved preference from localStorage - var savedTheme = await _jsRuntime.InvokeAsync("localStorage.getItem", "theme"); - - _isDarkMode = savedTheme == "dark"; - await ApplyThemeAsync(_isDarkMode); - - _isInitialized = true; - } - catch - { - // If localStorage is not available (SSR), default to light mode - _isDarkMode = false; - _isInitialized = true; - } - } - - /// - /// Toggles between light and dark mode. - /// - public async Task ToggleThemeAsync() - { - _isDarkMode = !_isDarkMode; - await ApplyThemeAsync(_isDarkMode); - await SaveThemeAsync(_isDarkMode); - - OnThemeChanged?.Invoke(); - } - - /// - /// Sets the theme to a specific mode. - /// - /// True for dark mode, false for light mode. - public async Task SetThemeAsync(bool isDark) - { - if (_isDarkMode == isDark) - { - return; - } - - _isDarkMode = isDark; - await ApplyThemeAsync(_isDarkMode); - await SaveThemeAsync(_isDarkMode); - - OnThemeChanged?.Invoke(); - } - - /// - /// Applies the theme by adding or removing the 'dark' class on the HTML element. - /// - private async Task ApplyThemeAsync(bool isDark) - { - try - { - if (isDark) - { - await _jsRuntime.InvokeVoidAsync("eval", - "document.documentElement.classList.add('dark')"); - } - else - { - await _jsRuntime.InvokeVoidAsync("eval", - "document.documentElement.classList.remove('dark')"); - } - } - catch - { - // Ignore errors during SSR - } - } - - /// - /// Saves the theme preference to localStorage. - /// - private async Task SaveThemeAsync(bool isDark) - { - try - { - var theme = isDark ? "dark" : "light"; - await _jsRuntime.InvokeVoidAsync("localStorage.setItem", "theme", theme); - } - catch - { - // Ignore errors if localStorage is not available - } - } -} diff --git a/demos/BlazorBlueprint.Demo.Shared/Shared/DarkModeToggle.razor b/demos/BlazorBlueprint.Demo.Shared/Shared/DarkModeToggle.razor deleted file mode 100644 index bd3e89a6a..000000000 --- a/demos/BlazorBlueprint.Demo.Shared/Shared/DarkModeToggle.razor +++ /dev/null @@ -1,44 +0,0 @@ -@using BlazorBlueprint.Demo.Services -@using BlazorBlueprint.Icons.Lucide.Components -@inject ThemeService ThemeService -@implements IDisposable - -
    - - - -
    - -@code { - private bool _isDarkMode; - - protected override async Task OnInitializedAsync() - { - // Initialize theme service - await ThemeService.InitializeAsync(); - _isDarkMode = ThemeService.IsDarkMode; - - // Subscribe to theme changes - ThemeService.OnThemeChanged += HandleThemeChanged; - } - - private async Task OnCheckedChanged(bool value) - { - _isDarkMode = value; - await ThemeService.SetThemeAsync(value); - } - - private void HandleThemeChanged() - { - _isDarkMode = ThemeService.IsDarkMode; - InvokeAsync(StateHasChanged); - } - - public void Dispose() - { - ThemeService.OnThemeChanged -= HandleThemeChanged; - } -} diff --git a/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor b/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor index 2959fe5c6..b80914070 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor @@ -132,6 +132,16 @@ Label + + + Menubar + + + + + Navigation Menu + + Popover @@ -707,6 +717,11 @@ Textarea + + + Theme + + Time Picker diff --git a/demos/BlazorBlueprint.Demo.Shared/Shared/MainLayout.razor b/demos/BlazorBlueprint.Demo.Shared/Shared/MainLayout.razor index 13ded5c0b..c2c5926d8 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Shared/MainLayout.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Shared/MainLayout.razor @@ -11,7 +11,8 @@
    - + +
    diff --git a/docs/plans/2026-03-23-datagrid-virtual-items-provider.md b/docs/plans/2026-03-23-datagrid-virtual-items-provider.md new file mode 100644 index 000000000..10665be88 --- /dev/null +++ b/docs/plans/2026-03-23-datagrid-virtual-items-provider.md @@ -0,0 +1,348 @@ +--- +title: "DataGrid Server-Side Infinite Scroll (Virtualized ItemsProvider)" +date: 2026-03-23 +branch: feat/datagrid-virtual-scroll +status: complete +author: claude +tags: [datagrid, virtualization, items-provider, infinite-scroll] +estimated_tasks: 8 +--- + +# DataGrid Server-Side Infinite Scroll + +## Context + +Currently `ItemsProvider` (server-side data) uses page-based pagination, and `Virtualize` (smooth scrolling) only works with client-side data. When `Virtualize=true` with `ItemsProvider`, the grid fetches **all** items from the server (`StartIndex=0, Count=null`) and virtualizes the DOM rendering — but the full dataset must still fit in memory. + +This plan enables true server-side infinite scroll: Blazor's `` drives data requests on demand as the user scrolls, fetching only the visible window plus overscan. + +> **CRITICAL RULE — Original Code Only** +> +> All implementations must be written from scratch, original to BlazorBlueprint. No code may be copied from any third-party codebase. + +--- + +## Architecture + +### Current Modes + +| Condition | Behavior | +|---|---| +| `Items` set, `Virtualize=false` | Client-side data, paginated | +| `Items` set, `Virtualize=true` | Client-side data, virtualized DOM (all items in memory) | +| `ItemsProvider` set, `Virtualize=false` | Server-side data, paginated via `StartIndex`/`Count` | +| `ItemsProvider` set, `Virtualize=true` | **Currently:** Server fetches ALL items, virtualizes DOM only | + +### New Mode + +| Condition | Behavior | +|---|---| +| `ItemsProvider` set, `Virtualize=true` | **New:** `` drives server requests as user scrolls | + +### Key Insight + +Blazor's native `` component already supports an `ItemsProvider` delegate that receives `ItemsProviderRequest` with `StartIndex` and `Count`. We create a **bridge method** that translates between Blazor's request type and BlazorBlueprint's `DataGridRequest`, injecting the current sort/filter/group state. + +This is cleaner than a custom scroll-based approach because Blazor handles all viewport tracking, request batching, and DOM recycling. + +--- + +## Implementation Steps + +### Step 1: Add VirtualScrollHeight Parameter + +**File:** `src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs` + +Add parameter after `OverscanCount`: + +```csharp +/// +/// CSS height for the scroll container when using virtualized ItemsProvider mode +/// (both and are set). +/// Required in this mode to give the Virtualize component a bounded scroll area. +/// Defaults to "400px". Accepts any CSS length value. +/// +[Parameter] +public string VirtualScrollHeight { get; set; } = "400px"; +``` + +**Estimated effort:** Trivial (5 min) + +--- + +### Step 2: Add Computed Property for Virtual+Provider Mode + +**File:** `src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs` + +Add a helper property to detect the combined mode: + +```csharp +/// +/// Whether the grid is in server-side virtual scroll mode (both Virtualize and ItemsProvider set). +/// +private bool IsVirtualizedProvider => Virtualize && ItemSize > 0 && ItemsProvider != null; +``` + +**Estimated effort:** Trivial (5 min) + +--- + +### Step 3: Create the Virtualize ItemsProvider Bridge + +**File:** `src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs` + +Add a field for the Virtualize component reference and the bridge method: + +```csharp +private Virtualize? _virtualizeRef; + +/// +/// Bridge between Blazor's ItemsProviderRequest and BlazorBlueprint's DataGridRequest. +/// Called by as the user scrolls. +/// +private async ValueTask> VirtualItemsProviderAsync( + ItemsProviderRequest request) +{ + var aggregateColumns = _columns + .Where(c => c.Aggregate != AggregateFunction.None) + .Select(c => c.ColumnId) + .ToList(); + + var dataGridRequest = new DataGridRequest + { + SortDefinitions = _gridState.Sorting.Definitions, + StartIndex = request.StartIndex, + Count = request.Count, + CancellationToken = request.CancellationToken, + Filters = _gridState.Filtering.Filters, + GroupDefinition = _gridState.Grouping.ActiveGroup, + AggregateColumns = aggregateColumns.Count > 0 ? aggregateColumns : null + }; + + var result = await ItemsProvider!(dataGridRequest); + + // Update pagination total for display purposes (e.g., "Showing X of Y") + _gridState.Pagination.TotalItems = result.TotalItemCount; + + return new ItemsProviderResult(result.Items, result.TotalItemCount); +} +``` + +**Key decisions:** +- `StartIndex` and `Count` come directly from Blazor's request — the Virtualize component manages windowing +- Sort/filter/group state is injected from the current grid state +- `TotalItemCount` flows back to update the pagination state (for info display) +- CancellationToken flows through so superseded requests are cancelled + +**Estimated effort:** Small (30 min) + +--- + +### Step 4: Modify LoadFromProviderAsync — Skip in Virtual+Provider Mode + +**File:** `src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs` + +In the existing `LoadFromProviderAsync()` method (line ~1966), add an early exit when in virtual+provider mode, since the Virtualize component drives data loading: + +```csharp +private async Task LoadFromProviderAsync() +{ + // In virtualized provider mode, Virtualize drives data loading. + // Just refresh the Virtualize component instead. + if (IsVirtualizedProvider) + { + if (_virtualizeRef != null) + { + await _virtualizeRef.RefreshDataAsync(); + } + return; + } + + // ... existing pagination-based loading logic unchanged ... +} +``` + +This ensures that when sort/filter changes trigger `ProcessDataAsync()` → `LoadFromProviderAsync()`, the Virtualize component is told to re-query rather than doing a manual fetch. + +**Estimated effort:** Small (15 min) + +--- + +### Step 5: Update the Razor Template + +**File:** `src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor` + +#### 5a. Scroll Container Height + +Wrap the table container with a height-constrained div when in virtual+provider mode: + +```razor +
    +``` + +#### 5b. Conditional Virtualize Rendering + +Replace the existing Virtualize block (lines 278-282) to handle both modes: + +```razor +@* Existing client-side virtualization with Items *@ +else if (Virtualize && ItemSize > 0 && !IsVirtualizedProvider) +{ + + @RenderDataRow(item) + +} +@* NEW: Server-side virtualization with ItemsProvider bridge *@ +else if (IsVirtualizedProvider) +{ + + @RenderDataRow(item) + +} +``` + +#### 5c. Hide Pagination in Virtual+Provider Mode + +Update the pagination condition (line 329): + +```razor +@if (ShowPagination && !IsLoading && !IsVirtualizedProvider && _processedData.Any()) +``` + +**Estimated effort:** Medium (45 min) — careful ordering of conditional blocks + +--- + +### Step 6: Handle Sort/Filter Refresh + +**File:** `src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs` + +The existing `HandleSortChange` and filter handling already call `ProcessDataAsync()` → `LoadFromProviderAsync()`. With Step 4's change, this now calls `_virtualizeRef.RefreshDataAsync()` in virtual+provider mode, which re-queries the bridge from `StartIndex=0`. No additional changes needed. + +However, verify that these methods work correctly: +- `HandleSortChange` — triggers data reload ✓ +- Filter changes (via column filter UI) — triggers data reload ✓ +- `HandlePageSizeChanged` — should be unreachable (pagination hidden) ✓ + +**Estimated effort:** Small (15 min) — verification and testing only + +--- + +### Step 7: Handle Grouped/Hierarchy Mode + +Grouped and hierarchy modes with server-side virtual scroll are **not supported** in this iteration. The `_groupedRenderItems` path stays on the existing client-side virtualization. + +Add a guard in the bridge method: + +```csharp +private async ValueTask> VirtualItemsProviderAsync( + ItemsProviderRequest request) +{ + // Grouping is not supported with virtualized provider mode + if (_groupByAccessor != null) + { + return new ItemsProviderResult(Array.Empty(), 0); + } + + // ... bridge logic ... +} +``` + +**Estimated effort:** Trivial (5 min) + +--- + +### Step 8: Demo Page and Testing + +**File:** `demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor` + +Add a new section demonstrating server-side virtual scroll. Use a simulated async provider that adds artificial delay: + +```razor + + + + ... + + + +@code { + private async ValueTask> VirtualProviderAsync(DataGridRequest request) + { + await Task.Delay(50); // Simulate network latency + var allData = MockDataService.GeneratePersons(10000); + + // Apply sort + var sorted = ApplySorting(allData, request.SortDefinitions); + + // Apply pagination window + var page = sorted.Skip(request.StartIndex).Take(request.Count ?? 50).ToList(); + + return new DataGridResult + { + Items = page, + TotalItemCount = allData.Count + }; + } +} +``` + +**Test scenarios:** +- Scroll through 10,000 items — only visible + overscan rows in DOM +- Sort a column — grid refreshes from top with new sort order +- Filter a column — grid refreshes with filtered count +- Verify pagination footer is hidden +- Verify `VirtualScrollHeight` constrains the container +- Verify empty state when provider returns 0 items +- Verify loading indicator during initial load + +**Estimated effort:** Medium (1-2 hours) + +--- + +## Execution Order + +| Step | Description | Dependencies | +|------|-------------|-------------| +| 1 | Add `VirtualScrollHeight` parameter | None | +| 2 | Add `IsVirtualizedProvider` property | None | +| 3 | Create bridge method | Steps 1, 2 | +| 4 | Modify `LoadFromProviderAsync` | Step 2 | +| 5 | Update razor template | Steps 2, 3 | +| 6 | Verify sort/filter refresh | Steps 4, 5 | +| 7 | Guard grouped mode | Step 3 | +| 8 | Demo page and testing | Steps 1-7 | + +--- + +## API Surface Changes + +New public parameters on `BbDataGrid`: + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `VirtualScrollHeight` | `string` | `"400px"` | CSS height for scroll container in virtual+provider mode | + +No changes to `DataGridRequest` or `DataGridResult` — existing `StartIndex`/`Count` fields are reused. + +--- + +## Risk Assessment + +| Risk | Mitigation | +|------|-----------| +| Blazor's `Virtualize` may make redundant requests during rapid scrolling | The `CancellationToken` flow through the bridge ensures superseded requests are cancelled. `OverscanCount` reduces request frequency. | +| Sort/filter change causes flicker as Virtualize re-queries from scratch | `RefreshDataAsync()` resets the scroll position and re-queries cleanly. Users expect a reset on filter change. | +| Grouped/hierarchy mode not supported | Guard with early return and clear documentation. Can be added later. | +| Large `TotalItemCount` causes memory issues in Virtualize component | Blazor's Virtualize handles this natively — it only tracks DOM for visible items, not all items. | +| Existing `Virtualize + Items` behavior must not change | The `IsVirtualizedProvider` condition is strict: requires `ItemsProvider != null`. Client-side virtualization is unchanged. | diff --git a/src/BlazorBlueprint.Components/Components/Chart/Composables/BbChartTooltip.razor.cs b/src/BlazorBlueprint.Components/Components/Chart/Composables/BbChartTooltip.razor.cs index 1b16b25b4..44e7e91b2 100644 --- a/src/BlazorBlueprint.Components/Components/Chart/Composables/BbChartTooltip.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Chart/Composables/BbChartTooltip.razor.cs @@ -68,6 +68,17 @@ public partial class BbChartTooltip : ComponentBase, IChartComponent, IDisposabl [Parameter] public string? TextColor { get; set; } + /// + /// Gets or sets whether the tooltip DOM node is appended to <body>. + /// + /// + /// When true, the tooltip is rendered outside the chart's container, preventing + /// clipping by parent elements with overflow: hidden. Maps to ECharts' + /// tooltip.appendToBody option. + /// + [Parameter] + public bool? AppendToBody { get; set; } + protected override void OnInitialized() => ParentChart?.RegisterComponent(this); @@ -84,7 +95,8 @@ void IChartComponent.ApplyTo(EChartsOption option) { Color = TextColor ?? "var(--popover-foreground)" }, - ExtraCssText = "border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -2px rgba(0,0,0,.1); pointer-events: none;" + ExtraCssText = "border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -2px rgba(0,0,0,.1); pointer-events: none;", + AppendToBody = AppendToBody }; if (trigger == "axis") diff --git a/src/BlazorBlueprint.Components/Components/Chart/Models/EChartsTooltip.cs b/src/BlazorBlueprint.Components/Components/Chart/Models/EChartsTooltip.cs index 9dfc6a604..dd72c031a 100644 --- a/src/BlazorBlueprint.Components/Components/Chart/Models/EChartsTooltip.cs +++ b/src/BlazorBlueprint.Components/Components/Chart/Models/EChartsTooltip.cs @@ -31,6 +31,10 @@ internal sealed class EChartsTooltipOption [JsonPropertyName("extraCssText")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ExtraCssText { get; set; } + + [JsonPropertyName("appendToBody")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? AppendToBody { get; set; } } internal sealed class EChartsAxisPointerOption diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor index ba54d2e16..f9d9b5d83 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor @@ -7,13 +7,22 @@
    - @if (Toolbar != null) + @if (ShowSearch || Toolbar != null) { -
    +
    + @if (ShowSearch) + { + + } @Toolbar
    } -
    +
    @if (IsLoading) { @if (LoadingTemplate != null) @@ -27,7 +36,7 @@
    } } - else if ((_groupedRenderItems == null || !_groupedRenderItems.Any()) && !_processedData.Any() && !_gridState.Filtering.HasFilters) + else if (!IsVirtualizedProvider && (_groupedRenderItems == null || !_groupedRenderItems.Any()) && !_processedData.Any() && !_gridState.Filtering.HasFilters) { @if (EmptyTemplate != null) { @@ -263,7 +272,7 @@ { if (Virtualize && ItemSize > 0 && _groupedRenderItemsList != null) { - + @RenderGroupedOrHierarchyItem(renderItem) } @@ -275,9 +284,17 @@ } } } + else if (IsVirtualizedProvider) + { + + @RenderDataRow(item) + + } else if (Virtualize && ItemSize > 0) { - + @RenderDataRow(item) } @@ -326,7 +343,7 @@ } - @if (ShowPagination && !IsLoading && _processedData.Any()) + @if (ShowPagination && !IsLoading && !IsVirtualizedProvider && _processedData.Any()) { - +
    - +
    - + - +
    @@ -395,7 +412,7 @@ private RenderFragment RenderDataRow => item => @ + Class="@ClassNames.cn("group/row bg-background", Striped ? StripeClass : null, RowClass?.Invoke(item))"> @foreach (var column in _cachedVisibleColumns) { var isSelectColumn = column.ColumnId == "__select"; @@ -573,6 +590,7 @@ { var item = renderItem.Item!; var rowClass = ClassNames.cn("group/row bg-background", + Striped ? StripeClass : null, !renderItem.MatchesFilter ? "opacity-50" : "", RowClass?.Invoke(item)); diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs index eaf8fb2a1..959d7b0ab 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs @@ -69,6 +69,13 @@ public partial class BbDataGrid : ComponentBase, IAsyncDisposable where T private readonly string gridId = Guid.NewGuid().ToString("N"); private bool jsInitialized; + // Search state + private string? _searchInputValue; + private CancellationTokenSource? _searchDebounceCts; + + // Virtualized provider state + private Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize? _virtualizeRef; + // Context menu state private BbContextMenu? rowContextMenu; private TData? contextMenuItem; @@ -89,6 +96,12 @@ public partial class BbDataGrid : ComponentBase, IAsyncDisposable where T private int _lastStateVersion; private int _lastGridStateVersion; + /// + /// Whether the grid is in server-side virtual scroll mode + /// (both Virtualize and ItemsProvider set with a valid ItemSize). + /// + private bool IsVirtualizedProvider => Virtualize && ItemSize > 0 && ItemsProvider != null; + [Inject] private IJSRuntime Js { get; set; } = null!; @@ -219,6 +232,44 @@ public partial class BbDataGrid : ComponentBase, IAsyncDisposable where T [Parameter] public Func? RowClass { get; set; } + /// + /// When true, applies alternating row background colors using . + /// Composes with — user-provided classes take precedence for conflicts. + /// + [Parameter] + public bool Striped { get; set; } + + /// + /// CSS classes applied to even rows when is true. + /// Defaults to "even:bg-muted/30 even:hover:bg-muted/70". + /// + [Parameter] + public string StripeClass { get; set; } = "even:bg-muted/30 even:hover:bg-muted/70"; + + /// + /// Number of extra items rendered outside the visible area when + /// is true. Higher values reduce blank flashes during fast scrolling at the cost + /// of more DOM nodes. Default is 5. + /// + [Parameter] + public int OverscanCount { get; set; } = 5; + + /// + /// CSS height for the scroll container when using virtualized server-side mode + /// (both and are set). + /// Required in this mode to give the Virtualize component a bounded scroll area. + /// Accepts any CSS length value. Default is "400px". + /// + [Parameter] + public string VirtualScrollHeight { get; set; } = "400px"; + + /// + /// Additional CSS classes applied to the inner scrollable container that wraps the + /// <table> element. Use this to control border radius, borders, max-height, etc. + /// + [Parameter] + public string? TableContainerClass { get; set; } + /// /// Whether to show the active-filter indicator bar below the header. /// When shown, it displays a count of active filters and a "Clear all" button. @@ -239,6 +290,40 @@ public partial class BbDataGrid : ComponentBase, IAsyncDisposable where T [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } + /// + /// When true, renders a built-in search input above the grid. + /// Filters across all columns with Filterable=true (client-side) + /// or passes to the via + /// (server-side). + /// + [Parameter] + public bool ShowSearch { get; set; } + + /// + /// The current global search text. Use with @bind-SearchText for two-way binding. + /// + [Parameter] + public string? SearchText { get; set; } + + /// + /// Callback invoked when the search text changes (after debounce). + /// + [Parameter] + public EventCallback SearchTextChanged { get; set; } + + /// + /// Placeholder text for the search input. + /// Defaults to the localized "DataGrid.SearchPlaceholder" string. + /// + [Parameter] + public string? SearchPlaceholder { get; set; } + + /// + /// Debounce delay in milliseconds for the search input. Default is 300. + /// + [Parameter] + public int SearchDebounceMs { get; set; } = 300; + /// /// Toolbar content rendered above the grid. Use for column visibility toggles, /// search inputs, or other controls that need grid context. @@ -944,7 +1029,7 @@ private void ProcessInMemoryData() var sorted = filtered.ApplyMultiSort( _gridState.Sorting.Definitions, columns); - var sortedList = sorted.ToList(); + var sortedList = ApplyGlobalSearch(sorted.ToList()).ToList(); if (_groupByAccessor != null) { @@ -975,7 +1060,8 @@ private void ProcessInMemoryData() var sorted = filtered.ApplyMultiSort( _gridState.Sorting.Definitions, columns); - var list = sorted as IList ?? sorted.ToList(); + var searched = ApplyGlobalSearch(sorted); + var list = searched as IList ?? searched.ToList(); if (_groupByAccessor != null) { @@ -1241,6 +1327,46 @@ private static bool TryConvertToDouble(object value, out double result) } } + /// + /// Bridge between Blazor's + /// and BlazorBlueprint's . Called by the Virtualize component as the user scrolls. + /// + private async ValueTask> VirtualItemsProviderAsync( + Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderRequest request) + { + // Grouping is not supported with virtualized provider mode + if (_groupByAccessor != null) + { + return new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult( + Array.Empty(), 0); + } + + var aggregateColumns = _columns + .Where(c => c.Aggregate != AggregateFunction.None) + .Select(c => c.ColumnId) + .ToList(); + + var dataGridRequest = new DataGridRequest + { + SortDefinitions = _gridState.Sorting.Definitions, + StartIndex = request.StartIndex, + Count = request.Count, + CancellationToken = request.CancellationToken, + Filters = _gridState.Filtering.Filters, + GroupDefinition = _gridState.Grouping.ActiveGroup, + AggregateColumns = aggregateColumns.Count > 0 ? aggregateColumns : null, + SearchText = SearchText + }; + + var result = await ItemsProvider!(dataGridRequest); + + // Update pagination total for info display (e.g., "Showing X of Y") + _gridState.Pagination.TotalItems = result.TotalItemCount; + + return new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult( + result.Items, result.TotalItemCount); + } + private void UpdateVirtualizationList() { if (Virtualize) @@ -1936,6 +2062,18 @@ internal async Task ToggleExpandAllAsync() private async Task LoadFromProviderAsync() { + // In virtualized provider mode, the Virtualize component drives data loading. + // Refresh it so it re-queries with the current sort/filter state. + if (IsVirtualizedProvider) + { + if (_virtualizeRef != null) + { + await _virtualizeRef.RefreshDataAsync(); + } + + return; + } + var oldCts = _loadCts; oldCts?.Cancel(); oldCts?.Dispose(); @@ -1961,7 +2099,8 @@ private async Task LoadFromProviderAsync() CancellationToken = token, Filters = _gridState.Filtering.Filters, GroupDefinition = _gridState.Grouping.ActiveGroup, - AggregateColumns = aggregateColumns.Count > 0 ? aggregateColumns : null + AggregateColumns = aggregateColumns.Count > 0 ? aggregateColumns : null, + SearchText = SearchText }; // Use grouped provider when grouping is active and provider is available @@ -2145,6 +2284,77 @@ private IQueryable ApplyColumnFilters(IQueryable queryable) return queryable; } + private async Task HandleSearchInput(string? value) + { + _searchInputValue = value; + + _searchDebounceCts?.Cancel(); + _searchDebounceCts?.Dispose(); + _searchDebounceCts = new CancellationTokenSource(); + + try + { + await Task.Delay(SearchDebounceMs, _searchDebounceCts.Token); + + SearchText = string.IsNullOrWhiteSpace(value) ? null : value; + await SearchTextChanged.InvokeAsync(SearchText); + + _gridState.Pagination.CurrentPage = 1; + await ProcessDataAsync(); + StateHasChanged(); + } + catch (TaskCanceledException) + { + // Debounce superseded + } + } + + private IEnumerable ApplyGlobalSearch(IEnumerable data) + { + if (string.IsNullOrWhiteSpace(SearchText)) + { + return data; + } + + var searchText = SearchText.Trim(); + var searchableColumns = _columns.Where(c => c.Filterable).ToList(); + + if (searchableColumns.Count == 0) + { + return data; + } + + return data.Where(item => + { + foreach (var column in searchableColumns) + { + // Check formatted value (e.g., "$113,876") + var value = column.GetValue(item); + if (value != null) + { + var str = value.ToString(); + if (str != null && str.Contains(searchText, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + // Also check raw value (e.g., 113876) for numeric/date columns with formatting + var rawValue = column.GetRawValue(item); + if (rawValue != null && !ReferenceEquals(rawValue, value)) + { + var rawStr = rawValue.ToString(); + if (rawStr != null && rawStr.Contains(searchText, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + + return false; + }); + } + private IEnumerable ApplyColumnFilters(IEnumerable data) { if (!_gridState.Filtering.HasFilters) diff --git a/src/BlazorBlueprint.Components/Components/DataView/BbDataView.razor b/src/BlazorBlueprint.Components/Components/DataView/BbDataView.razor index 179e09978..e65a445fe 100644 --- a/src/BlazorBlueprint.Components/Components/DataView/BbDataView.razor +++ b/src/BlazorBlueprint.Components/Components/DataView/BbDataView.razor @@ -142,7 +142,7 @@ @onscroll="HandleScroll" role="region" aria-live="polite"> -
    +
    @foreach (var item in _visibleData) { @if (EffectiveActiveTemplate != null) @@ -164,7 +164,7 @@ else { @* Regular pagination or ShowLoadMoreButton mode. *@ -
    +
    @foreach (var item in _visibleData) { @if (EffectiveActiveTemplate != null) diff --git a/src/BlazorBlueprint.Components/Components/DataView/BbDataView.razor.cs b/src/BlazorBlueprint.Components/Components/DataView/BbDataView.razor.cs index e3e1e7d33..640d3c25d 100644 --- a/src/BlazorBlueprint.Components/Components/DataView/BbDataView.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataView/BbDataView.razor.cs @@ -261,6 +261,16 @@ internal sealed class FieldData [Parameter] public string? ListClass { get; set; } + /// + /// Minimum column width for auto-fill grid layout. + /// When set, uses CSS repeat(auto-fill, minmax(value, 1fr)) instead of + /// fixed breakpoint columns. Accepts any CSS length (e.g., "160px", "10rem"). + /// Overrides the default responsive grid classes when set. + /// merges on top in both cases. + /// + [Parameter] + public string? GridColumnMinWidth { get; set; } + /// /// Event callback invoked when sorting changes. /// @@ -278,9 +288,15 @@ internal sealed class FieldData private string ContainerCssClass => ClassNames.cn("w-full space-y-4", Class); private string ItemContainerCssClass => _effectiveLayout == DataViewLayout.Grid - ? ClassNames.cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4", GridClass) + ? ClassNames.cn(GridColumnMinWidth is null + ? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4" + : "grid gap-4", GridClass) : ClassNames.cn("flex flex-col gap-2", ListClass); + private string? ItemContainerStyle => _effectiveLayout == DataViewLayout.Grid && GridColumnMinWidth is not null + ? $"grid-template-columns: repeat(auto-fill, minmax({GridColumnMinWidth}, 1fr))" + : null; + /// /// The resolved layout, accounting for which templates are available. /// If only one template is set the layout is locked to that mode regardless of the diff --git a/src/BlazorBlueprint.Components/Components/Menubar/BbMenubar.razor b/src/BlazorBlueprint.Components/Components/Menubar/BbMenubar.razor index f4b94f5eb..a19051c92 100644 --- a/src/BlazorBlueprint.Components/Components/Menubar/BbMenubar.razor +++ b/src/BlazorBlueprint.Components/Components/Menubar/BbMenubar.razor @@ -1,12 +1,14 @@ @namespace BlazorBlueprint.Components - + @code { + private BlazorBlueprint.Primitives.Menubar.BbMenubar? primitiveRef; + [Parameter] public string? Class { get; set; } @@ -19,52 +21,8 @@ [Parameter] public RenderFragment? ChildContent { get; set; } - internal string? ActiveMenu { get; private set; } - - private List _menus = new(); - - internal void RegisterMenu(BbMenubarMenu menu) - { - if (!_menus.Contains(menu)) - { - _menus.Add(menu); - } - } - - internal void UnregisterMenu(BbMenubarMenu menu) - { - _menus.Remove(menu); - } - - internal List GetMenus() => _menus; - - internal void SetActiveMenu(string? menuId) - { - ActiveMenu = menuId; - StateHasChanged(); - } - - internal void NavigateToNextMenu() - { - if (_menus.Count == 0 || ActiveMenu == null) return; - - var currentIndex = _menus.FindIndex(m => m.MenuId == ActiveMenu); - if (currentIndex == -1) return; - - var nextIndex = (currentIndex + 1) % _menus.Count; - _menus[nextIndex].Open(); - } - - internal void NavigateToPreviousMenu() - { - if (_menus.Count == 0 || ActiveMenu == null) return; - - var currentIndex = _menus.FindIndex(m => m.MenuId == ActiveMenu); - if (currentIndex == -1) return; - - var prevIndex = currentIndex == 0 ? _menus.Count - 1 : currentIndex - 1; - _menus[prevIndex].Open(); - } + internal BlazorBlueprint.Primitives.Menubar.MenubarContext? PrimitiveContext => + primitiveRef?.Context; private string CssClass => ClassNames.cn( "relative z-50 flex h-10 items-center space-x-1 rounded-md border bg-background p-1", diff --git a/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarCheckboxItem.razor b/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarCheckboxItem.razor index a425bb4c3..e4a201c6c 100644 --- a/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarCheckboxItem.razor +++ b/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarCheckboxItem.razor @@ -1,14 +1,10 @@ @namespace BlazorBlueprint.Components -@implements IMenubarItem -@implements IDisposable -
    + @if (Checked) { @@ -27,12 +23,9 @@ } @ChildContent -
    + @code { - [CascadingParameter] - public BbMenubarMenu? Menu { get; set; } - [Parameter] public string? Class { get; set; } @@ -48,37 +41,6 @@ [Parameter] public EventCallback CheckedChanged { get; set; } - private ElementReference _itemRef; - - public bool IsDisabled => Disabled; - - public async Task FocusAsync() - { - try - { - await _itemRef.FocusAsync(); - } - catch - { - // Ignore focus errors - } - } - - private async Task HandleClick() - { - if (!Disabled) - { - var newChecked = !Checked; - Checked = newChecked; - await CheckedChanged.InvokeAsync(newChecked); - // Checkbox items don't close the menu by default - } - } - - public void Dispose() - { - } - private string CssClass => ClassNames.cn( "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none", "focus:bg-accent focus:text-accent-foreground", diff --git a/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarContent.razor b/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarContent.razor index bc2f4e205..f5e98cad5 100644 --- a/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarContent.razor +++ b/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarContent.razor @@ -1,24 +1,8 @@ @namespace BlazorBlueprint.Components -@inject IJSRuntime JSRuntime -@implements IAsyncDisposable -@{ - var isOpen = Menu?.IsOpen == true; -} - -@if (isOpen) -{ -
    -} - + @code { [CascadingParameter] @@ -39,106 +23,6 @@ [Parameter] public bool Loop { get; set; } = true; - private ElementReference _contentRef; - private bool _wasClosed = true; - private IJSObjectReference? _menuKeyboardModule; - private DotNetObjectReference? _dotNetRef; - private readonly string _instanceId = Guid.NewGuid().ToString("N")[..8]; - private bool _disposed; - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (Menu?.IsOpen == true && _wasClosed) - { - _wasClosed = false; - try - { - _menuKeyboardModule ??= await JSRuntime.InvokeAsync( - "import", "./_content/BlazorBlueprint.Primitives/js/primitives/menu-keyboard.js"); - _dotNetRef ??= DotNetObjectReference.Create(this); - - await _menuKeyboardModule.InvokeVoidAsync("initialize", _contentRef, _dotNetRef, _instanceId, - new { mode = "menubar", loop = Loop, initialFocus = "first" }); - } - catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) - { - // Expected during circuit disconnect in Blazor Server - } - catch (InvalidOperationException) - { - // JS interop not available during prerendering - } - } - else if (Menu?.IsOpen != true && !_wasClosed) - { - _wasClosed = true; - await CleanupKeyboardAsync(); - } - } - - [JSInvokable] - public void JsOnEscapeKey() - { - if (_disposed) { return; } - Menu?.Close(); - } - - [JSInvokable] - public void JsOnNextMenu() - { - if (_disposed) { return; } - Context?.NavigateToNextMenu(); - } - - [JSInvokable] - public void JsOnPreviousMenu() - { - if (_disposed) { return; } - Context?.NavigateToPreviousMenu(); - } - - private async Task CleanupKeyboardAsync() - { - if (_menuKeyboardModule != null) - { - try - { - await _menuKeyboardModule.InvokeVoidAsync("dispose", _instanceId); - } - catch - { - // Cleanup may already be disposed - } - } - } - - private void HandleOverlayClick() - { - Menu?.Close(); - } - - public async ValueTask DisposeAsync() - { - GC.SuppressFinalize(this); - _disposed = true; - - await CleanupKeyboardAsync(); - - try - { - if (_menuKeyboardModule != null) - { - await _menuKeyboardModule.DisposeAsync(); - } - } - catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) - { - // Expected during circuit disconnect - } - - _dotNetRef?.Dispose(); - } - private string AlignClass => Align switch { MenubarContentAlign.Start => "left-0", diff --git a/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarItem.razor b/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarItem.razor index 67ff2eb8f..31b1a60af 100644 --- a/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarItem.razor +++ b/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarItem.razor @@ -1,17 +1,15 @@ @namespace BlazorBlueprint.Components @using BlazorBlueprint.Primitives.Services @inject IKeyboardShortcutService KeyboardShortcuts -@implements IMenubarItem @implements IDisposable @implements IAsyncDisposable - + @code { [CascadingParameter] @@ -40,11 +38,8 @@ [Parameter] public string? Shortcut { get; set; } - private ElementReference _itemRef; private IDisposable? shortcutRegistration; - public bool IsDisabled => Disabled; - protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender && !string.IsNullOrEmpty(Shortcut) && !Disabled) @@ -59,31 +54,11 @@ } } - public async Task FocusAsync() - { - try - { - await _itemRef.FocusAsync(); - } - catch - { - // Ignore focus errors - } - } - - private async Task HandleClick() - { - if (!Disabled) - { - await OnClick.InvokeAsync(); - Menu?.Close(); - } - } + private async Task HandleClick() => + await OnClick.InvokeAsync(); - public void Dispose() - { + public void Dispose() => shortcutRegistration?.Dispose(); - } public async ValueTask DisposeAsync() { diff --git a/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarLabel.razor b/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarLabel.razor index ee0f65fcf..31b37fbe3 100644 --- a/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarLabel.razor +++ b/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarLabel.razor @@ -1,8 +1,8 @@ @namespace BlazorBlueprint.Components -
    + @ChildContent -
    + @code { [Parameter] diff --git a/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarMenu.razor b/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarMenu.razor index b5540fb5b..e714b58a9 100644 --- a/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarMenu.razor +++ b/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarMenu.razor @@ -1,53 +1,34 @@ @namespace BlazorBlueprint.Components +@implements IDisposable
    - - @ChildContent - + + + @ChildContent + +
    @code { + private BlazorBlueprint.Primitives.Menubar.BbMenubarMenu? primitiveRef; + [CascadingParameter] public BbMenubar? Context { get; set; } [Parameter] public RenderFragment? ChildContent { get; set; } - internal string MenuId { get; } = Guid.NewGuid().ToString(); - internal bool IsOpen => Context?.ActiveMenu == MenuId; - - protected override void OnInitialized() - { - Context?.RegisterMenu(this); - } + internal BlazorBlueprint.Primitives.Menubar.MenubarMenuContext? MenuContext => + primitiveRef?.MenuContext; - internal void Open() - { - Context?.SetActiveMenu(MenuId); - } + internal bool IsOpen => MenuContext?.IsOpen == true; - internal void Close() - { - if (IsOpen) - { - Context?.SetActiveMenu(null); - } - } - - internal void Toggle() - { - if (IsOpen) - { - Close(); - } - else - { - Open(); - } - } + internal void Open() => MenuContext?.Open(); + internal void Close() => MenuContext?.Close(); + internal void Toggle() => MenuContext?.Toggle(); public void Dispose() { - Context?.UnregisterMenu(this); + // Primitive handles unregistration via its own Dispose } } diff --git a/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarSeparator.razor b/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarSeparator.razor index 2638edd89..94c83d688 100644 --- a/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarSeparator.razor +++ b/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarSeparator.razor @@ -1,6 +1,6 @@ @namespace BlazorBlueprint.Components - + @code { [Parameter] diff --git a/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarTrigger.razor b/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarTrigger.razor index 9bbdec90e..535a939fa 100644 --- a/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarTrigger.razor +++ b/src/BlazorBlueprint.Components/Components/Menubar/BbMenubarTrigger.razor @@ -1,20 +1,10 @@ @namespace BlazorBlueprint.Components - + @code { - [CascadingParameter] - public BbMenubarMenu? Menu { get; set; } - [CascadingParameter] public BbMenubar? Context { get; set; } @@ -24,20 +14,6 @@ [Parameter] public RenderFragment? ChildContent { get; set; } - private void HandleClick() - { - Menu?.Toggle(); - } - - private void HandleMouseEnter() - { - // If another menu is open, switch to this one on hover - if (Context?.ActiveMenu != null && Context.ActiveMenu != Menu?.MenuId) - { - Menu?.Open(); - } - } - private string CssClass => ClassNames.cn( "relative z-50 flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none", "focus:bg-accent focus:text-accent-foreground", diff --git a/src/BlazorBlueprint.Components/Components/Theme/BaseColor.cs b/src/BlazorBlueprint.Components/Components/Theme/BaseColor.cs new file mode 100644 index 000000000..1b62cd392 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Theme/BaseColor.cs @@ -0,0 +1,23 @@ +namespace BlazorBlueprint.Components; + +/// +/// The base (gray scale) color palette used for backgrounds, borders, and neutral UI elements. +/// Each value maps to a distinct set of OKLCH gray-scale CSS custom properties. +/// +public enum BaseColor +{ + /// Cool gray with a subtle blue undertone. Default for shadcn/ui. + Zinc, + + /// Cool blue-gray, slightly warmer than Zinc. + Slate, + + /// Pure, balanced gray with no color tint. + Gray, + + /// True neutral gray — identical lightness steps with zero chroma. + Neutral, + + /// Warm gray with a slight yellow/brown undertone. + Stone +} diff --git a/src/BlazorBlueprint.Components/Components/Theme/BbDarkModeToggle.razor b/src/BlazorBlueprint.Components/Components/Theme/BbDarkModeToggle.razor new file mode 100644 index 000000000..bc5ff5cf3 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Theme/BbDarkModeToggle.razor @@ -0,0 +1,132 @@ +@namespace BlazorBlueprint.Components +@using BlazorBlueprint.Icons.Lucide.Components +@inject ThemeService ThemeService +@inject IBbLocalizer Localizer +@implements IDisposable + + + @if (ShowIcon) + { + @if (ThemeService.IsDarkMode) + { + @if (DarkIcon is not null) + { + @DarkIcon + } + else + { + + } + } + else + { + @if (LightIcon is not null) + { + @LightIcon + } + else + { + + } + } + } + @if (ShowLabel) + { + @CurrentLabel + } + + +@code { + /// + /// Custom icon to show when light mode is active. Defaults to a sun icon. + /// + [Parameter] + public RenderFragment? LightIcon { get; set; } + + /// + /// Custom icon to show when dark mode is active. Defaults to a moon icon. + /// + [Parameter] + public RenderFragment? DarkIcon { get; set; } + + /// + /// Whether to show the mode icon. Defaults to true. + /// + [Parameter] + public bool ShowIcon { get; set; } = true; + + /// + /// Whether to show a text label indicating the current mode. Defaults to false. + /// + [Parameter] + public bool ShowLabel { get; set; } + + /// + /// Custom label for light mode. Defaults to the localized "Light" string. + /// + [Parameter] + public string? LightLabel { get; set; } + + /// + /// Custom label for dark mode. Defaults to the localized "Dark" string. + /// + [Parameter] + public string? DarkLabel { get; set; } + + /// + /// Additional CSS classes for the button. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Button variant styling. Defaults to . + /// + [Parameter] + public ButtonVariant Variant { get; set; } = ButtonVariant.Outline; + + /// + /// Button size. Defaults to . + /// + [Parameter] + public ButtonSize Size { get; set; } = ButtonSize.Icon; + + private string CurrentLabel => ThemeService.IsDarkMode + ? (DarkLabel ?? Localizer["Theme.Dark"]) + : (LightLabel ?? Localizer["Theme.Light"]); + + private string AriaLabel => ThemeService.IsDarkMode + ? Localizer["Theme.SwitchToLight"] + : Localizer["Theme.SwitchToDark"]; + + /// + protected override void OnInitialized() => + ThemeService.OnThemeChanged += HandleThemeChanged; + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && !ThemeService.IsInitialized) + { + await ThemeService.InitializeAsync(); + StateHasChanged(); + } + } + + private async Task OnClick() => + await ThemeService.ToggleDarkModeAsync(); + + private void HandleThemeChanged() => + InvokeAsync(StateHasChanged); + + /// + public void Dispose() + { + ThemeService.OnThemeChanged -= HandleThemeChanged; + GC.SuppressFinalize(this); + } +} diff --git a/src/BlazorBlueprint.Components/Components/Theme/BbThemeSwitcher.razor b/src/BlazorBlueprint.Components/Components/Theme/BbThemeSwitcher.razor new file mode 100644 index 000000000..4995e252e --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Theme/BbThemeSwitcher.razor @@ -0,0 +1,258 @@ +@namespace BlazorBlueprint.Components +@using BlazorBlueprint.Icons.Lucide.Components +@inject ThemeService ThemeService +@inject IBbLocalizer Localizer +@implements IDisposable + + + + + + + + + + @* ── Header ── *@ +
    +

    @Localizer["Theme.Switcher.Title"]

    +

    @Localizer["Theme.Switcher.Description"]

    +
    + + + + @* ── Color ── *@ +
    +

    @Localizer["Theme.Color"]

    +
    + @foreach (var color in allColors) + { + var isSelected = IsColorSelected(color); + + } +
    +
    + + + + @* ── Radius ── *@ +
    +

    @Localizer["Theme.Radius"]

    +
    + @foreach (var r in radiusOptions) + { + var isSelected = Math.Abs(ThemeService.Radius - r) < 0.001; + + } +
    +
    + + + + @* ── Mode ── *@ +
    +

    @Localizer["Theme.Mode"]

    +
    + + +
    +
    + +
    +
    + +@code { + private bool isOpen; + + /// + /// Additional CSS classes for the trigger button. + /// + [Parameter] + public string? TriggerClass { get; set; } + + /// + /// Additional CSS classes for the popover content panel. + /// + [Parameter] + public string? PopoverContentClass { get; set; } + + /// + /// Horizontal alignment of the popover relative to the trigger. + /// Defaults to . + /// + [Parameter] + public PopoverAlign Align { get; set; } = PopoverAlign.End; + + /// + /// Positioning strategy for the popover. Defaults to . + /// + [Parameter] + public PositioningStrategy Strategy { get; set; } = PositioningStrategy.Absolute; + + /// + protected override void OnInitialized() => + ThemeService.OnThemeChanged += HandleThemeChanged; + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && !ThemeService.IsInitialized) + { + await ThemeService.InitializeAsync(); + StateHasChanged(); + } + } + + /// + /// Determines if a color chip is the currently active selection. + /// Only one chip can be selected at a time: + /// - If the user picked a primary color (non-Default), that primary chip is selected. + /// - If PrimaryColor is Default, the current BaseColor chip is selected. + /// + private bool IsColorSelected(ColorInfo color) + { + if (color.IsBase) + { + // A base chip is selected only when PrimaryColor == Default AND it matches the active base + return ThemeService.PrimaryColor == PrimaryColor.Default + && ThemeService.BaseColor == color.BaseValue; + } + + // A primary chip is selected when it matches the active primary + return ThemeService.PrimaryColor == color.PrimaryValue; + } + + /// + /// When a base color is picked, set the base AND reset primary to Default + /// so the base's own accent is used. When a primary is picked, just set primary. + /// + private async Task OnColorSelected(ColorInfo color) + { + if (color.IsBase) + { + // Reset primary so the base's built-in accent is used + await ThemeService.SetPrimaryColorAsync(PrimaryColor.Default); + await ThemeService.SetBaseColorAsync(color.BaseValue); + } + else + { + await ThemeService.SetPrimaryColorAsync(color.PrimaryValue); + } + } + + private async Task OnRadiusSelected(double value) => + await ThemeService.SetRadiusAsync(value); + + private async Task OnModeSelected(bool dark) => + await ThemeService.SetDarkModeAsync(dark); + + private void HandleThemeChanged() => + InvokeAsync(StateHasChanged); + + /// + public void Dispose() + { + ThemeService.OnThemeChanged -= HandleThemeChanged; + GC.SuppressFinalize(this); + } + + // ── Data ────────────────────────────────────────────────────────────── + + private static readonly double[] radiusOptions = [0, 0.3, 0.5, 0.75, 1.0]; + + private static readonly ColorInfo[] allColors = + [ + // Base colors — selecting one resets primary to Default + new("Zinc", "oklch(0.552 0.016 285.94)", BaseColor.Zinc), + new("Slate", "oklch(0.554 0.046 257.42)", BaseColor.Slate), + new("Stone", "oklch(0.553 0.013 58.07)", BaseColor.Stone), + new("Gray", "oklch(0.551 0.027 264.36)", BaseColor.Gray), + new("Neutral", "oklch(0.556 0 0)", BaseColor.Neutral), + // Primary colors — selecting one overrides the accent only + new("Red", "oklch(0.577 0.245 27.33)", PrimaryColor.Red), + new("Rose", "oklch(0.585 0.22 3.96)", PrimaryColor.Rose), + new("Orange", "oklch(0.705 0.213 47.60)", PrimaryColor.Orange), + new("Amber", "oklch(0.769 0.188 70.08)", PrimaryColor.Amber), + new("Yellow", "oklch(0.852 0.199 91.94)", PrimaryColor.Yellow), + new("Lime", "oklch(0.768 0.233 130.85)", PrimaryColor.Lime), + new("Green", "oklch(0.596 0.145 163.23)", PrimaryColor.Green), + new("Emerald", "oklch(0.596 0.145 163.23)", PrimaryColor.Emerald), + new("Teal", "oklch(0.627 0.134 184.13)", PrimaryColor.Teal), + new("Cyan", "oklch(0.655 0.151 207.08)", PrimaryColor.Cyan), + new("Sky", "oklch(0.6 0.118 184.71)", PrimaryColor.Sky), + new("Blue", "oklch(0.546 0.245 262.88)", PrimaryColor.Blue), + new("Indigo", "oklch(0.488 0.243 264.38)", PrimaryColor.Indigo), + new("Violet", "oklch(0.541 0.281 293.01)", PrimaryColor.Violet), + new("Purple", "oklch(0.553 0.261 301.92)", PrimaryColor.Purple), + new("Fuchsia", "oklch(0.591 0.293 322.90)", PrimaryColor.Fuchsia), + new("Pink", "oklch(0.592 0.249 0.58)", PrimaryColor.Pink), + ]; + + private sealed class ColorInfo + { + public string Label { get; } + public string Swatch { get; } + public bool IsBase { get; } + public BaseColor BaseValue { get; } + public PrimaryColor PrimaryValue { get; } + + public ColorInfo(string label, string swatch, BaseColor baseValue) + { + Label = label; + Swatch = swatch; + IsBase = true; + BaseValue = baseValue; + } + + public ColorInfo(string label, string swatch, PrimaryColor primaryValue) + { + Label = label; + Swatch = swatch; + IsBase = false; + PrimaryValue = primaryValue; + } + } +} diff --git a/src/BlazorBlueprint.Components/Components/Theme/PrimaryColor.cs b/src/BlazorBlueprint.Components/Components/Theme/PrimaryColor.cs new file mode 100644 index 000000000..f18fc2b86 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Theme/PrimaryColor.cs @@ -0,0 +1,62 @@ +namespace BlazorBlueprint.Components; + +/// +/// The primary accent color used for buttons, links, focus rings, and interactive elements. +/// Each value maps to a distinct set of OKLCH CSS custom properties for --primary and --primary-foreground. +/// +public enum PrimaryColor +{ + /// Inherits the primary color from the base color palette (no override). + Default, + + /// Blue primary — oklch hue ~260. + Blue, + + /// Violet primary — oklch hue ~280. + Violet, + + /// Purple primary — oklch hue ~290. + Purple, + + /// Rose primary — oklch hue ~350. + Rose, + + /// Red primary — oklch hue ~25. + Red, + + /// Orange primary — oklch hue ~45. + Orange, + + /// Amber primary — oklch hue ~75. + Amber, + + /// Yellow primary — oklch hue ~90. + Yellow, + + /// Lime primary — oklch hue ~125. + Lime, + + /// Green primary — oklch hue ~145. + Green, + + /// Emerald primary — oklch hue ~160. + Emerald, + + /// Teal primary — oklch hue ~180. + Teal, + + /// Cyan primary — oklch hue ~200. + Cyan, + + /// Sky primary — oklch hue ~220. + Sky, + + /// Indigo primary — oklch hue ~270. + Indigo, + + /// Fuchsia primary — oklch hue ~320. + Fuchsia, + + /// Pink primary — oklch hue ~340. + Pink +} diff --git a/src/BlazorBlueprint.Components/Components/Theme/ThemeOptions.cs b/src/BlazorBlueprint.Components/Components/Theme/ThemeOptions.cs new file mode 100644 index 000000000..1ed07975d --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Theme/ThemeOptions.cs @@ -0,0 +1,40 @@ +namespace BlazorBlueprint.Components; + +/// +/// Configuration options for the BlazorBlueprint theme system. +/// +public class ThemeOptions +{ + /// + /// The default base (gray scale) color palette. Defaults to . + /// + public BaseColor DefaultBaseColor { get; set; } = BaseColor.Zinc; + + /// + /// The default primary accent color. Defaults to (inherits from base). + /// + public PrimaryColor DefaultPrimaryColor { get; set; } = PrimaryColor.Default; + + /// + /// Whether dark mode is enabled by default. Defaults to false. + /// When is true, the system preference takes precedence. + /// + public bool DefaultDarkMode { get; set; } + + /// + /// When true, detects the user's OS color scheme preference on first load. + /// This overrides when no saved preference exists. Defaults to true. + /// + public bool DetectSystemPreference { get; set; } = true; + + /// + /// The default border radius in rem. Defaults to 0.5 (matching shadcn/ui default). + /// Common values: 0, 0.3, 0.5, 0.75, 1.0. + /// + public double DefaultRadius { get; set; } = 0.5; + + /// + /// When true, persists theme preferences to localStorage. Defaults to true. + /// + public bool PersistToLocalStorage { get; set; } = true; +} diff --git a/src/BlazorBlueprint.Components/Components/Theme/ThemeService.cs b/src/BlazorBlueprint.Components/Components/Theme/ThemeService.cs new file mode 100644 index 000000000..9953198e2 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Theme/ThemeService.cs @@ -0,0 +1,360 @@ +using Microsoft.JSInterop; + +namespace BlazorBlueprint.Components; + +/// +/// Manages the application's visual theme — dark mode, base color, and primary color. +/// Persists preferences to localStorage and applies them to the DOM via a JS module. +/// +/// +/// +/// Register via +/// with an optional configuration action. +/// +/// +/// Call once after the first interactive render (typically in +/// OnAfterRenderAsync(firstRender)) to load saved preferences and apply the theme. +/// +/// +public class ThemeService : IAsyncDisposable +{ + private readonly IJSRuntime jsRuntime; + private readonly ThemeOptions options; + private IJSObjectReference? module; + private bool isDarkMode; + private BaseColor baseColor; + private PrimaryColor primaryColor; + private double radius; + private bool isInitialized; + + /// + /// Raised after any theme property changes. Subscribe to trigger StateHasChanged in consuming components. + /// + public event Action? OnThemeChanged; + + /// + /// Gets whether dark mode is currently active. + /// + public bool IsDarkMode => isDarkMode; + + /// + /// Gets the current base (gray scale) color. + /// + public BaseColor BaseColor => baseColor; + + /// + /// Gets the current primary accent color. + /// + public PrimaryColor PrimaryColor => primaryColor; + + /// + /// Gets the current border radius in rem. + /// + public double Radius => radius; + + /// + /// Gets whether the service has been initialized via . + /// + public bool IsInitialized => isInitialized; + + /// + /// Creates a new instance. + /// + /// The Blazor JS interop runtime. + /// Theme configuration options. + public ThemeService(IJSRuntime jsRuntime, ThemeOptions options) + { + this.jsRuntime = jsRuntime; + this.options = options; + isDarkMode = options.DefaultDarkMode; + baseColor = options.DefaultBaseColor; + primaryColor = options.DefaultPrimaryColor; + radius = options.DefaultRadius; + } + + /// + /// Loads saved preferences from localStorage (if enabled), detects system color scheme + /// preference, and applies the theme to the DOM. Safe to call multiple times — only the first + /// call performs initialization. + /// + public async Task InitializeAsync() + { + if (isInitialized) + { + return; + } + + try + { + module = await jsRuntime.InvokeAsync( + "import", "./_content/BlazorBlueprint.Components/js/theme.js"); + } + catch (JSDisconnectedException) + { + isInitialized = true; + return; + } + catch (InvalidOperationException) + { + // JS interop not available (SSR prerender) — keep defaults + isInitialized = true; + return; + } + + try + { + var saved = await module.InvokeAsync("loadTheme"); + if (saved is not null) + { + isDarkMode = saved.IsDarkMode; + baseColor = ParseEnum(saved.BaseColor, options.DefaultBaseColor); + primaryColor = ParseEnum(saved.PrimaryColor, options.DefaultPrimaryColor); + radius = saved.Radius ?? options.DefaultRadius; + } + else if (options.DetectSystemPreference) + { + isDarkMode = await module.InvokeAsync("getPrefersDark"); + } + + await ApplyAllAsync(); + } + catch (JSDisconnectedException) + { + // Circuit disconnected during init + } + + isInitialized = true; + } + + /// + /// Toggles between dark and light mode. + /// + public async Task ToggleDarkModeAsync() => + await SetDarkModeAsync(!isDarkMode); + + /// + /// Sets dark mode to the specified value. + /// + /// true for dark mode, false for light mode. + public async Task SetDarkModeAsync(bool value) + { + if (isDarkMode == value) + { + return; + } + + isDarkMode = value; + await ApplyDarkModeAsync(); + await SaveAsync(); + OnThemeChanged?.Invoke(); + } + + /// + /// Sets the base (gray scale) color palette. + /// + /// The base color to apply. + public async Task SetBaseColorAsync(BaseColor color) + { + if (baseColor == color) + { + return; + } + + baseColor = color; + await ApplyBaseColorAsync(); + await SaveAsync(); + OnThemeChanged?.Invoke(); + } + + /// + /// Sets the primary accent color. + /// + /// The primary color to apply. + public async Task SetPrimaryColorAsync(PrimaryColor color) + { + if (primaryColor == color) + { + return; + } + + primaryColor = color; + await ApplyPrimaryColorAsync(); + await SaveAsync(); + OnThemeChanged?.Invoke(); + } + + /// + /// Sets the border radius. + /// + /// The radius in rem (e.g., 0, 0.3, 0.5, 0.75, 1.0). + public async Task SetRadiusAsync(double value) + { + if (Math.Abs(radius - value) < 0.001) + { + return; + } + + radius = value; + await ApplyRadiusAsync(); + await SaveAsync(); + OnThemeChanged?.Invoke(); + } + + private async Task ApplyAllAsync() + { + if (module is null) + { + return; + } + + try + { + await module.InvokeVoidAsync("applyTheme", + isDarkMode, + baseColor.ToString().ToLowerInvariant(), + primaryColor.ToString().ToLowerInvariant(), + radius); + } + catch + { + // Ignore if JS is unavailable + } + } + + private async Task ApplyDarkModeAsync() + { + if (module is null) + { + return; + } + + try + { + await module.InvokeVoidAsync("applyDarkMode", isDarkMode); + } + catch + { + // Ignore if JS is unavailable + } + } + + private async Task ApplyBaseColorAsync() + { + if (module is null) + { + return; + } + + try + { + await module.InvokeVoidAsync("applyBaseColor", baseColor.ToString().ToLowerInvariant()); + } + catch + { + // Ignore if JS is unavailable + } + } + + private async Task ApplyPrimaryColorAsync() + { + if (module is null) + { + return; + } + + try + { + await module.InvokeVoidAsync("applyPrimaryColor", primaryColor.ToString().ToLowerInvariant()); + } + catch + { + // Ignore if JS is unavailable + } + } + + private async Task ApplyRadiusAsync() + { + if (module is null) + { + return; + } + + try + { + await module.InvokeVoidAsync("applyRadius", radius); + } + catch + { + // Ignore if JS is unavailable + } + } + + private async Task SaveAsync() + { + if (module is null || !options.PersistToLocalStorage) + { + return; + } + + try + { + await module.InvokeVoidAsync("saveTheme", + isDarkMode, + baseColor.ToString().ToLowerInvariant(), + primaryColor.ToString().ToLowerInvariant(), + radius); + } + catch + { + // Ignore if localStorage is unavailable + } + } + + private static TEnum ParseEnum(string? value, TEnum fallback) where TEnum : struct, Enum + { + if (string.IsNullOrEmpty(value)) + { + return fallback; + } + + return Enum.TryParse(value, ignoreCase: true, out var result) ? result : fallback; + } + + /// + public async ValueTask DisposeAsync() + { + if (module is not null) + { + try + { + await module.DisposeAsync(); + } + catch + { + // Ignore disposal errors during circuit disconnect + } + + module = null; + } + + GC.SuppressFinalize(this); + } + + /// + /// Represents saved theme state from localStorage. + /// + private sealed class ThemeState + { + /// Gets or sets whether dark mode is enabled. + public bool IsDarkMode { get; set; } + + /// Gets or sets the base color name. + public string? BaseColor { get; set; } + + /// Gets or sets the primary color name. + public string? PrimaryColor { get; set; } + + /// Gets or sets the border radius in rem. + public double? Radius { get; set; } + } +} diff --git a/src/BlazorBlueprint.Components/Extensions/ServiceCollectionExtensions.cs b/src/BlazorBlueprint.Components/Extensions/ServiceCollectionExtensions.cs index 76e152ad4..485ec1439 100644 --- a/src/BlazorBlueprint.Components/Extensions/ServiceCollectionExtensions.cs +++ b/src/BlazorBlueprint.Components/Extensions/ServiceCollectionExtensions.cs @@ -14,6 +14,7 @@ public static class ServiceCollectionExtensions ///
    /// The service collection. /// Optional action to configure localization keys via . + /// Optional action to configure the theme system via . /// The service collection for chaining. /// /// @@ -21,10 +22,15 @@ public static class ServiceCollectionExtensions /// To enable dynamic culture switching (e.g., per-circuit in Blazor Server), register /// your own implementation as scoped before calling this method. /// + /// + /// The is always registered (scoped). Use + /// to set defaults for dark mode, base color, primary color, and persistence behavior. + /// /// public static IServiceCollection AddBlazorBlueprintComponents( this IServiceCollection services, - Action? configureLocalizer = null) + Action? configureLocalizer = null, + Action? configureTheme = null) { // Register all primitive services (portal, focus, positioning, dropdown manager, keyboard shortcuts) services.AddBlazorBlueprintPrimitives(); @@ -36,6 +42,12 @@ public static IServiceCollection AddBlazorBlueprintComponents( // Register DialogService as scoped for programmatic confirm dialogs services.AddScoped(); + // Register ThemeService as scoped (each circuit/session gets its own theme state) + var themeOptions = new ThemeOptions(); + configureTheme?.Invoke(themeOptions); + services.TryAddSingleton(themeOptions); + services.TryAddScoped(); + // Register localizer (singleton by default; consumers can pre-register IBbLocalizer as scoped) var localizer = new DefaultBbLocalizer(); configureLocalizer?.Invoke(localizer); diff --git a/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs b/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs index f3a47ebca..2856704f7 100644 --- a/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs +++ b/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs @@ -93,6 +93,7 @@ public class DefaultBbLocalizer : IBbLocalizer ["DataGrid.FilterColumnSelectValue"] = "Select value...", ["DataGrid.FilterColumnClear"] = "Clear", ["DataGrid.FilterColumnApply"] = "Apply", + ["DataGrid.SearchPlaceholder"] = "Search...", // DataTable ["DataTable.Loading"] = "Loading...", @@ -257,6 +258,18 @@ public class DefaultBbLocalizer : IBbLocalizer ["TagInput.ClearAllTags"] = "Clear all tags", ["TagInput.TagSuggestions"] = "Tag suggestions", + // Theme + ["Theme.Switcher.Label"] = "Customize theme", + ["Theme.Switcher.Title"] = "Customize", + ["Theme.Switcher.Description"] = "Pick a color and radius for your components.", + ["Theme.Color"] = "Color", + ["Theme.Radius"] = "Radius", + ["Theme.Mode"] = "Mode", + ["Theme.Light"] = "Light", + ["Theme.Dark"] = "Dark", + ["Theme.SwitchToLight"] = "Switch to light mode", + ["Theme.SwitchToDark"] = "Switch to dark mode", + // Timeline ["Timeline.Timeline"] = "Timeline", }; diff --git a/src/BlazorBlueprint.Components/wwwroot/css/themes.css b/src/BlazorBlueprint.Components/wwwroot/css/themes.css new file mode 100644 index 000000000..88a90f292 --- /dev/null +++ b/src/BlazorBlueprint.Components/wwwroot/css/themes.css @@ -0,0 +1,466 @@ +/* BlazorBlueprint Theme System — Base & Primary Color Definitions + * Uses OKLCH color space. Scoped via data attributes on . + * + * Usage: + * + * + * These selectors override the user's theme.css :root block when data + * attributes are present, falling back gracefully when they are not. */ + +/* ════════════════════════════════════════════════════════════════════════════ + * BASE COLORS — Gray scale palettes + * Sets: background, foreground, card, popover, secondary, muted, accent, + * border, input, ring, sidebar-*, destructive, chart-* + * ════════════════════════════════════════════════════════════════════════════ */ + +/* ── Zinc (cool gray, subtle blue undertone) ────────────────────────────── */ +[data-base-color="zinc"] { + --background: oklch(1 0 0); + --foreground: oklch(0.1410 0.004 285.82); + --card: oklch(1 0 0); + --card-foreground: oklch(0.1410 0.004 285.82); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.1410 0.004 285.82); + --primary: oklch(0.2100 0.006 285.88); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.9670 0.001 286.38); + --secondary-foreground: oklch(0.2100 0.006 285.88); + --muted: oklch(0.9670 0.001 286.38); + --muted-foreground: oklch(0.5520 0.016 285.94); + --accent: oklch(0.9670 0.001 286.38); + --accent-foreground: oklch(0.2100 0.006 285.88); + --destructive: oklch(0.5770 0.245 27.33); + --destructive-foreground: oklch(1 0 0); + --border: oklch(0.9200 0.004 286.32); + --input: oklch(0.9200 0.004 286.32); + --ring: oklch(0.5520 0.016 285.94); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.714); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.1410 0.004 285.82); + --sidebar-primary: oklch(0.2100 0.006 285.88); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.9670 0.001 286.38); + --sidebar-accent-foreground: oklch(0.2100 0.006 285.88); + --sidebar-border: oklch(0.9200 0.004 286.32); + --sidebar-ring: oklch(0.5520 0.016 285.94); +} + +[data-base-color="zinc"].dark, +.dark[data-base-color="zinc"] { + --background: oklch(0.1410 0.004 285.82); + --foreground: oklch(0.985 0 0); + --card: oklch(0.1410 0.004 285.82); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.1410 0.004 285.82); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.2100 0.006 285.88); + --secondary: oklch(0.2740 0.006 286.03); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.2740 0.006 286.03); + --muted-foreground: oklch(0.7050 0.015 286.07); + --accent: oklch(0.2740 0.006 286.03); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --destructive-foreground: oklch(0.985 0 0); + --border: oklch(0.2740 0.006 286.03); + --input: oklch(0.2740 0.006 286.03); + --ring: oklch(0.5520 0.016 285.94); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.1410 0.004 285.82); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.2740 0.006 286.03); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.2740 0.006 286.03); + --sidebar-ring: oklch(0.5520 0.016 285.94); +} + +/* ── Slate (cool blue-gray) ─────────────────────────────────────────────── */ +[data-base-color="slate"] { + --background: oklch(1 0 0); + --foreground: oklch(0.129 0.042 264.695); + --card: oklch(1 0 0); + --card-foreground: oklch(0.129 0.042 264.695); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.129 0.042 264.695); + --primary: oklch(0.208 0.042 265.755); + --primary-foreground: oklch(0.984 0.003 247.858); + --secondary: oklch(0.968 0.007 247.896); + --secondary-foreground: oklch(0.208 0.042 265.755); + --muted: oklch(0.968 0.007 247.896); + --muted-foreground: oklch(0.554 0.046 257.417); + --accent: oklch(0.968 0.007 247.896); + --accent-foreground: oklch(0.208 0.042 265.755); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(1 0 0); + --border: oklch(0.929 0.013 255.508); + --input: oklch(0.929 0.013 255.508); + --ring: oklch(0.554 0.046 257.417); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.714); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.984 0.003 247.858); + --sidebar-foreground: oklch(0.129 0.042 264.695); + --sidebar-primary: oklch(0.208 0.042 265.755); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.968 0.007 247.896); + --sidebar-accent-foreground: oklch(0.208 0.042 265.755); + --sidebar-border: oklch(0.929 0.013 255.508); + --sidebar-ring: oklch(0.554 0.046 257.417); +} + +[data-base-color="slate"].dark, +.dark[data-base-color="slate"] { + --background: oklch(0.129 0.042 264.695); + --foreground: oklch(0.984 0.003 247.858); + --card: oklch(0.129 0.042 264.695); + --card-foreground: oklch(0.984 0.003 247.858); + --popover: oklch(0.129 0.042 264.695); + --popover-foreground: oklch(0.984 0.003 247.858); + --primary: oklch(0.984 0.003 247.858); + --primary-foreground: oklch(0.208 0.042 265.755); + --secondary: oklch(0.279 0.041 260.031); + --secondary-foreground: oklch(0.984 0.003 247.858); + --muted: oklch(0.279 0.041 260.031); + --muted-foreground: oklch(0.704 0.04 256.788); + --accent: oklch(0.279 0.041 260.031); + --accent-foreground: oklch(0.984 0.003 247.858); + --destructive: oklch(0.704 0.191 22.216); + --destructive-foreground: oklch(0.984 0.003 247.858); + --border: oklch(0.279 0.041 260.031); + --input: oklch(0.279 0.041 260.031); + --ring: oklch(0.554 0.046 257.417); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.129 0.042 264.695); + --sidebar-foreground: oklch(0.984 0.003 247.858); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.279 0.041 260.031); + --sidebar-accent-foreground: oklch(0.984 0.003 247.858); + --sidebar-border: oklch(0.279 0.041 260.031); + --sidebar-ring: oklch(0.554 0.046 257.417); +} + +/* ── Gray (pure balanced gray) ──────────────────────────────────────────── */ +[data-base-color="gray"] { + --background: oklch(1 0 0); + --foreground: oklch(0.13 0.028 261.692); + --card: oklch(1 0 0); + --card-foreground: oklch(0.13 0.028 261.692); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.13 0.028 261.692); + --primary: oklch(0.21 0.034 264.665); + --primary-foreground: oklch(0.985 0.002 247.839); + --secondary: oklch(0.967 0.003 264.542); + --secondary-foreground: oklch(0.21 0.034 264.665); + --muted: oklch(0.967 0.003 264.542); + --muted-foreground: oklch(0.551 0.027 264.364); + --accent: oklch(0.967 0.003 264.542); + --accent-foreground: oklch(0.21 0.034 264.665); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(1 0 0); + --border: oklch(0.928 0.006 264.531); + --input: oklch(0.928 0.006 264.531); + --ring: oklch(0.551 0.027 264.364); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.714); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0.002 247.839); + --sidebar-foreground: oklch(0.13 0.028 261.692); + --sidebar-primary: oklch(0.21 0.034 264.665); + --sidebar-primary-foreground: oklch(0.985 0.002 247.839); + --sidebar-accent: oklch(0.967 0.003 264.542); + --sidebar-accent-foreground: oklch(0.21 0.034 264.665); + --sidebar-border: oklch(0.928 0.006 264.531); + --sidebar-ring: oklch(0.551 0.027 264.364); +} + +[data-base-color="gray"].dark, +.dark[data-base-color="gray"] { + --background: oklch(0.13 0.028 261.692); + --foreground: oklch(0.985 0.002 247.839); + --card: oklch(0.13 0.028 261.692); + --card-foreground: oklch(0.985 0.002 247.839); + --popover: oklch(0.13 0.028 261.692); + --popover-foreground: oklch(0.985 0.002 247.839); + --primary: oklch(0.985 0.002 247.839); + --primary-foreground: oklch(0.21 0.034 264.665); + --secondary: oklch(0.274 0.032 261.523); + --secondary-foreground: oklch(0.985 0.002 247.839); + --muted: oklch(0.274 0.032 261.523); + --muted-foreground: oklch(0.707 0.022 261.325); + --accent: oklch(0.274 0.032 261.523); + --accent-foreground: oklch(0.985 0.002 247.839); + --destructive: oklch(0.704 0.191 22.216); + --destructive-foreground: oklch(0.985 0.002 247.839); + --border: oklch(0.274 0.032 261.523); + --input: oklch(0.274 0.032 261.523); + --ring: oklch(0.551 0.027 264.364); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.13 0.028 261.692); + --sidebar-foreground: oklch(0.985 0.002 247.839); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0.002 247.839); + --sidebar-accent: oklch(0.274 0.032 261.523); + --sidebar-accent-foreground: oklch(0.985 0.002 247.839); + --sidebar-border: oklch(0.274 0.032 261.523); + --sidebar-ring: oklch(0.551 0.027 264.364); +} + +/* ── Neutral (true neutral, zero chroma) ────────────────────────────────── */ +[data-base-color="neutral"] { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(1 0 0); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.714); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.556 0 0); +} + +[data-base-color="neutral"].dark, +.dark[data-base-color="neutral"] { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --destructive-foreground: oklch(0.985 0 0); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.145 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.556 0 0); +} + +/* ── Stone (warm gray, yellow/brown undertone) ──────────────────────────── */ +[data-base-color="stone"] { + --background: oklch(1 0 0); + --foreground: oklch(0.147 0.004 49.25); + --card: oklch(1 0 0); + --card-foreground: oklch(0.147 0.004 49.25); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.147 0.004 49.25); + --primary: oklch(0.216 0.006 56.043); + --primary-foreground: oklch(0.985 0.001 106.424); + --secondary: oklch(0.97 0.001 106.424); + --secondary-foreground: oklch(0.216 0.006 56.043); + --muted: oklch(0.97 0.001 106.424); + --muted-foreground: oklch(0.553 0.013 58.071); + --accent: oklch(0.97 0.001 106.424); + --accent-foreground: oklch(0.216 0.006 56.043); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(1 0 0); + --border: oklch(0.923 0.003 48.717); + --input: oklch(0.923 0.003 48.717); + --ring: oklch(0.553 0.013 58.071); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.714); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0.001 106.424); + --sidebar-foreground: oklch(0.147 0.004 49.25); + --sidebar-primary: oklch(0.216 0.006 56.043); + --sidebar-primary-foreground: oklch(0.985 0.001 106.424); + --sidebar-accent: oklch(0.97 0.001 106.424); + --sidebar-accent-foreground: oklch(0.216 0.006 56.043); + --sidebar-border: oklch(0.923 0.003 48.717); + --sidebar-ring: oklch(0.553 0.013 58.071); +} + +[data-base-color="stone"].dark, +.dark[data-base-color="stone"] { + --background: oklch(0.147 0.004 49.25); + --foreground: oklch(0.985 0.001 106.424); + --card: oklch(0.147 0.004 49.25); + --card-foreground: oklch(0.985 0.001 106.424); + --popover: oklch(0.147 0.004 49.25); + --popover-foreground: oklch(0.985 0.001 106.424); + --primary: oklch(0.985 0.001 106.424); + --primary-foreground: oklch(0.216 0.006 56.043); + --secondary: oklch(0.268 0.007 34.298); + --secondary-foreground: oklch(0.985 0.001 106.424); + --muted: oklch(0.268 0.007 34.298); + --muted-foreground: oklch(0.709 0.01 56.259); + --accent: oklch(0.268 0.007 34.298); + --accent-foreground: oklch(0.985 0.001 106.424); + --destructive: oklch(0.704 0.191 22.216); + --destructive-foreground: oklch(0.985 0.001 106.424); + --border: oklch(0.268 0.007 34.298); + --input: oklch(0.268 0.007 34.298); + --ring: oklch(0.553 0.013 58.071); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.147 0.004 49.25); + --sidebar-foreground: oklch(0.985 0.001 106.424); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0.001 106.424); + --sidebar-accent: oklch(0.268 0.007 34.298); + --sidebar-accent-foreground: oklch(0.985 0.001 106.424); + --sidebar-border: oklch(0.268 0.007 34.298); + --sidebar-ring: oklch(0.553 0.013 58.071); +} + + +/* ════════════════════════════════════════════════════════════════════════════ + * PRIMARY COLORS — Accent overrides + * Sets: --primary, --primary-foreground, --ring + * The "default" primary inherits from the base color (no attribute needed). + * ════════════════════════════════════════════════════════════════════════════ */ + +/* ── Blue ───────────────────────────────────────────────────────────────── */ +[data-primary-color="blue"] { --primary: oklch(0.546 0.245 262.88); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.546 0.245 262.88); } +.dark[data-primary-color="blue"], +[data-primary-color="blue"].dark { --primary: oklch(0.623 0.214 259.82); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.623 0.214 259.82); } + +/* ── Violet ─────────────────────────────────────────────────────────────── */ +[data-primary-color="violet"] { --primary: oklch(0.541 0.281 293.009); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.541 0.281 293.009); } +.dark[data-primary-color="violet"], +[data-primary-color="violet"].dark { --primary: oklch(0.654 0.236 292.756); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.654 0.236 292.756); } + +/* ── Purple ─────────────────────────────────────────────────────────────── */ +[data-primary-color="purple"] { --primary: oklch(0.553 0.261 301.924); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.553 0.261 301.924); } +.dark[data-primary-color="purple"], +[data-primary-color="purple"].dark { --primary: oklch(0.627 0.265 303.9); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.627 0.265 303.9); } + +/* ── Rose ───────────────────────────────────────────────────────────────── */ +[data-primary-color="rose"] { --primary: oklch(0.585 0.22 3.958); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.585 0.22 3.958); } +.dark[data-primary-color="rose"], +[data-primary-color="rose"].dark { --primary: oklch(0.645 0.246 16.439); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.645 0.246 16.439); } + +/* ── Red ────────────────────────────────────────────────────────────────── */ +[data-primary-color="red"] { --primary: oklch(0.577 0.245 27.325); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.577 0.245 27.325); } +.dark[data-primary-color="red"], +[data-primary-color="red"].dark { --primary: oklch(0.704 0.191 22.216); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.704 0.191 22.216); } + +/* ── Orange ─────────────────────────────────────────────────────────────── */ +[data-primary-color="orange"] { --primary: oklch(0.705 0.213 47.604); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.705 0.213 47.604); } +.dark[data-primary-color="orange"], +[data-primary-color="orange"].dark { --primary: oklch(0.769 0.188 70.08); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.769 0.188 70.08); } + +/* ── Amber ──────────────────────────────────────────────────────────────── */ +[data-primary-color="amber"] { --primary: oklch(0.769 0.188 70.08); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.769 0.188 70.08); } +.dark[data-primary-color="amber"], +[data-primary-color="amber"].dark { --primary: oklch(0.828 0.189 84.429); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.828 0.189 84.429); } + +/* ── Yellow ─────────────────────────────────────────────────────────────── */ +[data-primary-color="yellow"] { --primary: oklch(0.852 0.199 91.936); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.852 0.199 91.936); } +.dark[data-primary-color="yellow"], +[data-primary-color="yellow"].dark { --primary: oklch(0.905 0.182 98.111); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.905 0.182 98.111); } + +/* ── Lime ───────────────────────────────────────────────────────────────── */ +[data-primary-color="lime"] { --primary: oklch(0.768 0.233 130.85); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.768 0.233 130.85); } +.dark[data-primary-color="lime"], +[data-primary-color="lime"].dark { --primary: oklch(0.841 0.238 128.85); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.841 0.238 128.85); } + +/* ── Green ──────────────────────────────────────────────────────────────── */ +[data-primary-color="green"] { --primary: oklch(0.596 0.145 163.225); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.596 0.145 163.225); } +.dark[data-primary-color="green"], +[data-primary-color="green"].dark { --primary: oklch(0.696 0.17 162.48); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.696 0.17 162.48); } + +/* ── Emerald ────────────────────────────────────────────────────────────── */ +[data-primary-color="emerald"] { --primary: oklch(0.596 0.145 163.225); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.596 0.145 163.225); } +.dark[data-primary-color="emerald"], +[data-primary-color="emerald"].dark { --primary: oklch(0.696 0.17 162.48); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.696 0.17 162.48); } + +/* ── Teal ───────────────────────────────────────────────────────────────── */ +[data-primary-color="teal"] { --primary: oklch(0.627 0.134 184.128); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.627 0.134 184.128); } +.dark[data-primary-color="teal"], +[data-primary-color="teal"].dark { --primary: oklch(0.704 0.14 182.503); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.704 0.14 182.503); } + +/* ── Cyan ───────────────────────────────────────────────────────────────── */ +[data-primary-color="cyan"] { --primary: oklch(0.655 0.151 207.078); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.655 0.151 207.078); } +.dark[data-primary-color="cyan"], +[data-primary-color="cyan"].dark { --primary: oklch(0.715 0.143 215.221); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.715 0.143 215.221); } + +/* ── Sky ────────────────────────────────────────────────────────────────── */ +[data-primary-color="sky"] { --primary: oklch(0.6 0.118 184.714); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.6 0.118 184.714); } +.dark[data-primary-color="sky"], +[data-primary-color="sky"].dark { --primary: oklch(0.681 0.162 222.72); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.681 0.162 222.72); } + +/* ── Indigo ─────────────────────────────────────────────────────────────── */ +[data-primary-color="indigo"] { --primary: oklch(0.488 0.243 264.376); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.488 0.243 264.376); } +.dark[data-primary-color="indigo"], +[data-primary-color="indigo"].dark { --primary: oklch(0.585 0.233 277.117); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.585 0.233 277.117); } + +/* ── Fuchsia ─────────────────────────────────────────────────────────────── */ +[data-primary-color="fuchsia"] { --primary: oklch(0.591 0.293 322.896); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.591 0.293 322.896); } +.dark[data-primary-color="fuchsia"], +[data-primary-color="fuchsia"].dark { --primary: oklch(0.694 0.222 320.364); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.694 0.222 320.364); } + +/* ── Pink ───────────────────────────────────────────────────────────────── */ +[data-primary-color="pink"] { --primary: oklch(0.592 0.249 0.584); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.592 0.249 0.584); } +.dark[data-primary-color="pink"], +[data-primary-color="pink"].dark { --primary: oklch(0.699 0.199 356.048); --primary-foreground: oklch(0.15 0 0); --ring: oklch(0.699 0.199 356.048); } diff --git a/src/BlazorBlueprint.Components/wwwroot/js/theme.js b/src/BlazorBlueprint.Components/wwwroot/js/theme.js new file mode 100644 index 000000000..78cd8e630 --- /dev/null +++ b/src/BlazorBlueprint.Components/wwwroot/js/theme.js @@ -0,0 +1,105 @@ +/** + * Theme JavaScript module + * CSP-compliant DOM manipulation for dark mode, base color, primary color, and radius. + * No eval() — all operations use direct DOM APIs. + */ + +const STORAGE_KEY = 'bb-theme'; + +/** + * Apply the full theme to the document. + * @param {boolean} isDark + * @param {string} baseColor + * @param {string} primaryColor + * @param {number} radius + */ +export function applyTheme(isDark, baseColor, primaryColor, radius) { + applyDarkMode(isDark); + applyBaseColor(baseColor); + applyPrimaryColor(primaryColor); + applyRadius(radius); +} + +/** + * Apply or remove dark mode. + * @param {boolean} isDark + */ +export function applyDarkMode(isDark) { + const root = document.documentElement; + if (isDark) { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } +} + +/** + * Set the base color data attribute on the document element. + * Also removes any inline --primary/--primary-foreground/--ring overrides + * so the base color's built-in values take effect cleanly. + * @param {string} color - Lowercase base color name (e.g., "zinc", "slate"). + */ +export function applyBaseColor(color) { + const root = document.documentElement; + root.setAttribute('data-base-color', color); +} + +/** + * Set the primary color data attribute on the document element. + * @param {string} color - Lowercase primary color name (e.g., "blue", "default"). + */ +export function applyPrimaryColor(color) { + const root = document.documentElement; + if (color === 'default') { + root.removeAttribute('data-primary-color'); + } else { + root.setAttribute('data-primary-color', color); + } +} + +/** + * Set the --radius CSS custom property on the document element. + * @param {number} radius - Border radius in rem. + */ +export function applyRadius(radius) { + document.documentElement.style.setProperty('--radius', radius + 'rem'); +} + +/** + * Detect whether the user's OS prefers dark mode. + * @returns {boolean} + */ +export function getPrefersDark() { + return window.matchMedia('(prefers-color-scheme: dark)').matches; +} + +/** + * Load saved theme preferences from localStorage. + * @returns {{ isDarkMode: boolean, baseColor: string, primaryColor: string, radius: number } | null} + */ +export function loadTheme() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + return null; + } + return JSON.parse(raw); + } catch { + return null; + } +} + +/** + * Save theme preferences to localStorage. + * @param {boolean} isDarkMode + * @param {string} baseColor + * @param {string} primaryColor + * @param {number} radius + */ +export function saveTheme(isDarkMode, baseColor, primaryColor, radius) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ isDarkMode, baseColor, primaryColor, radius })); + } catch { + // localStorage unavailable — silently ignore + } +} diff --git a/src/BlazorBlueprint.Primitives/Primitives/DataGrid/DataGridItemsProvider.cs b/src/BlazorBlueprint.Primitives/Primitives/DataGrid/DataGridItemsProvider.cs index 55feec54f..6d7f19eb6 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/DataGrid/DataGridItemsProvider.cs +++ b/src/BlazorBlueprint.Primitives/Primitives/DataGrid/DataGridItemsProvider.cs @@ -56,6 +56,12 @@ public class DataGridRequest /// Server-side providers should compute these aggregates per group. ///
    public IReadOnlyList? AggregateColumns { get; init; } + + /// + /// Gets the global search text, or null if no search is active. + /// Server-side providers should filter across all searchable columns using this value. + /// + public string? SearchText { get; init; } } /// diff --git a/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubar.razor b/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubar.razor new file mode 100644 index 000000000..d631ff300 --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubar.razor @@ -0,0 +1,31 @@ +@namespace BlazorBlueprint.Primitives.Menubar + +
    + + @ChildContent + +
    + +@code { + private MenubarContext context = null!; + + /// + /// Child content (BbMenubarMenu components). + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Additional HTML attributes applied to the root element. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + /// + /// The menubar context, exposed for child components in the Components layer. + /// + public MenubarContext Context => context; + + protected override void OnInitialized() => + context = new MenubarContext(StateHasChanged); +} diff --git a/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarCheckboxItem.razor b/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarCheckboxItem.razor new file mode 100644 index 000000000..e7be1ce19 --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarCheckboxItem.razor @@ -0,0 +1,56 @@ +@namespace BlazorBlueprint.Primitives.Menubar + +
    + @ChildContent +
    + +@code { + /// + /// Child content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Whether the checkbox is checked. + /// + [Parameter] + public bool Checked { get; set; } + + /// + /// Callback invoked when the checked state changes. + /// + [Parameter] + public EventCallback CheckedChanged { get; set; } + + /// + /// Whether this item is disabled. + /// + [Parameter] + public bool Disabled { get; set; } + + /// + /// Additional HTML attributes. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private ElementReference itemRef; + + private async Task HandleClick() + { + if (!Disabled) + { + Checked = !Checked; + await CheckedChanged.InvokeAsync(Checked); + // Checkbox items don't close the menu + } + } +} diff --git a/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarContent.razor b/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarContent.razor new file mode 100644 index 000000000..9e431bb73 --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarContent.razor @@ -0,0 +1,154 @@ +@namespace BlazorBlueprint.Primitives.Menubar +@inject IJSRuntime JSRuntime +@implements IAsyncDisposable + +@{ + var isOpen = Menu?.IsOpen == true; +} + +@if (isOpen) +{ +
    +} +
    + @ChildContent +
    + +@code { + [CascadingParameter] + private MenubarMenuContext? Menu { get; set; } + + [CascadingParameter] + private MenubarContext? Context { get; set; } + + /// + /// Child content (menu items). + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Whether keyboard navigation wraps around. Default is true. + /// + [Parameter] + public bool Loop { get; set; } = true; + + /// + /// Additional HTML attributes. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private ElementReference contentRef; + private bool wasClosed = true; + private IJSObjectReference? menuKeyboardModule; + private DotNetObjectReference? dotNetRef; + private readonly string instanceId = Guid.NewGuid().ToString("N")[..8]; + private bool disposed; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (Menu?.IsOpen == true && wasClosed) + { + wasClosed = false; + try + { + menuKeyboardModule ??= await JSRuntime.InvokeAsync( + "import", "./_content/BlazorBlueprint.Primitives/js/primitives/menu-keyboard.js"); + dotNetRef ??= DotNetObjectReference.Create(this); + + await menuKeyboardModule.InvokeVoidAsync("initialize", contentRef, dotNetRef, instanceId, + new { mode = "menubar", loop = Loop, initialFocus = "first" }); + } + catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) + { + // Expected during circuit disconnect + } + catch (InvalidOperationException) + { + // JS interop not available during prerendering + } + } + else if (Menu?.IsOpen != true && !wasClosed) + { + wasClosed = true; + await CleanupKeyboardAsync(); + } + } + + /// Called from JS when Escape is pressed. + [JSInvokable] + public void JsOnEscapeKey() + { + if (!disposed) + { + Menu?.Close(); + } + } + + /// Called from JS when ArrowRight is pressed. + [JSInvokable] + public void JsOnNextMenu() + { + if (!disposed) + { + Context?.NavigateToNextMenu(); + } + } + + /// Called from JS when ArrowLeft is pressed. + [JSInvokable] + public void JsOnPreviousMenu() + { + if (!disposed) + { + Context?.NavigateToPreviousMenu(); + } + } + + private async Task CleanupKeyboardAsync() + { + if (menuKeyboardModule != null) + { + try + { + await menuKeyboardModule.InvokeVoidAsync("dispose", instanceId); + } + catch + { + // Cleanup may already be disposed + } + } + } + + private void HandleOverlayClick() => + Menu?.Close(); + + public async ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + disposed = true; + + await CleanupKeyboardAsync(); + + try + { + if (menuKeyboardModule != null) + { + await menuKeyboardModule.DisposeAsync(); + } + } + catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) + { + // Expected during circuit disconnect + } + + dotNetRef?.Dispose(); + } +} diff --git a/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarItem.razor b/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarItem.razor new file mode 100644 index 000000000..353f6eabf --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarItem.razor @@ -0,0 +1,50 @@ +@namespace BlazorBlueprint.Primitives.Menubar + +
    + @ChildContent +
    + +@code { + [CascadingParameter] + private MenubarMenuContext? Menu { get; set; } + + /// + /// Child content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Whether this item is disabled. + /// + [Parameter] + public bool Disabled { get; set; } + + /// + /// Callback invoked when the item is clicked (and not disabled). + /// + [Parameter] + public EventCallback OnClick { get; set; } + + /// + /// Additional HTML attributes. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private ElementReference itemRef; + + private async Task HandleClick() + { + if (!Disabled) + { + await OnClick.InvokeAsync(); + Menu?.Close(); + } + } +} diff --git a/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarLabel.razor b/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarLabel.razor new file mode 100644 index 000000000..f95c8097e --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarLabel.razor @@ -0,0 +1,19 @@ +@namespace BlazorBlueprint.Primitives.Menubar + +
    + @ChildContent +
    + +@code { + /// + /// Child content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Additional HTML attributes. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarMenu.razor b/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarMenu.razor new file mode 100644 index 000000000..a42d253a1 --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarMenu.razor @@ -0,0 +1,33 @@ +@namespace BlazorBlueprint.Primitives.Menubar +@implements IDisposable + + + @ChildContent + + +@code { + private MenubarMenuContext menuContext = null!; + + [CascadingParameter] + private MenubarContext? Context { get; set; } + + /// + /// Child content (trigger + content). + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// The per-menu context, exposed for Components layer access. + /// + public MenubarMenuContext MenuContext => menuContext; + + protected override void OnInitialized() + { + menuContext = new MenubarMenuContext(Context!); + Context?.RegisterMenu(menuContext); + } + + public void Dispose() => + Context?.UnregisterMenu(menuContext); +} diff --git a/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarSeparator.razor b/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarSeparator.razor new file mode 100644 index 000000000..748205143 --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarSeparator.razor @@ -0,0 +1,11 @@ +@namespace BlazorBlueprint.Primitives.Menubar + +
    + +@code { + /// + /// Additional HTML attributes. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarTrigger.razor b/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarTrigger.razor new file mode 100644 index 000000000..2fe4b980b --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Primitives/Menubar/BbMenubarTrigger.razor @@ -0,0 +1,44 @@ +@namespace BlazorBlueprint.Primitives.Menubar + + + +@code { + [CascadingParameter] + private MenubarMenuContext? Menu { get; set; } + + [CascadingParameter] + private MenubarContext? Context { get; set; } + + /// + /// Child content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Additional HTML attributes. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private void HandleClick() => + Menu?.Toggle(); + + private void HandleMouseEnter() + { + // If another menu is open, switch to this one on hover + if (Context?.ActiveMenu != null && Context.ActiveMenu != Menu?.MenuId) + { + Menu?.Open(); + } + } +} diff --git a/src/BlazorBlueprint.Primitives/Primitives/Menubar/MenubarContext.cs b/src/BlazorBlueprint.Primitives/Primitives/Menubar/MenubarContext.cs new file mode 100644 index 000000000..2eefdb13a --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Primitives/Menubar/MenubarContext.cs @@ -0,0 +1,142 @@ +namespace BlazorBlueprint.Primitives.Menubar; + +/// +/// Shared context cascaded from to coordinate open/close state +/// and horizontal keyboard navigation across menus. +/// +public class MenubarContext +{ + private readonly List menus = new(); + private readonly Action stateChanged; + + /// + /// The ID of the currently open menu, or null if all menus are closed. + /// + public string? ActiveMenu { get; private set; } + + /// + /// Creates a new . + /// + /// Callback invoked when active menu changes (triggers re-render). + public MenubarContext(Action stateChanged) + { + this.stateChanged = stateChanged; + } + + /// + /// Registers a menu with this context. + /// + public void RegisterMenu(MenubarMenuContext menu) + { + if (!menus.Contains(menu)) + { + menus.Add(menu); + } + } + + /// + /// Unregisters a menu from this context. + /// + public void UnregisterMenu(MenubarMenuContext menu) => + menus.Remove(menu); + + /// + /// Sets the active (open) menu. Pass null to close all menus. + /// + public void SetActiveMenu(string? menuId) + { + ActiveMenu = menuId; + stateChanged(); + } + + /// + /// Navigates to the next menu in the menubar (wraps around). + /// + public void NavigateToNextMenu() + { + if (menus.Count == 0 || ActiveMenu == null) + { + return; + } + + var currentIndex = menus.FindIndex(m => m.MenuId == ActiveMenu); + if (currentIndex == -1) + { + return; + } + + var nextIndex = (currentIndex + 1) % menus.Count; + menus[nextIndex].Open(); + } + + /// + /// Navigates to the previous menu in the menubar (wraps around). + /// + public void NavigateToPreviousMenu() + { + if (menus.Count == 0 || ActiveMenu == null) + { + return; + } + + var currentIndex = menus.FindIndex(m => m.MenuId == ActiveMenu); + if (currentIndex == -1) + { + return; + } + + var prevIndex = currentIndex == 0 ? menus.Count - 1 : currentIndex - 1; + menus[prevIndex].Open(); + } +} + +/// +/// Per-menu context holding the menu's identity and providing open/close methods. +/// +public class MenubarMenuContext +{ + private readonly MenubarContext parent; + + /// + /// Unique identifier for this menu. + /// + public string MenuId { get; } = Guid.NewGuid().ToString(); + + /// + /// Whether this menu is currently open. + /// + public bool IsOpen => parent.ActiveMenu == MenuId; + + /// + /// Creates a new . + /// + public MenubarMenuContext(MenubarContext parent) + { + this.parent = parent; + } + + /// Opens this menu (closes any other open menu). + public void Open() => parent.SetActiveMenu(MenuId); + + /// Closes this menu if it is currently open. + public void Close() + { + if (IsOpen) + { + parent.SetActiveMenu(null); + } + } + + /// Toggles this menu open/closed. + public void Toggle() + { + if (IsOpen) + { + Close(); + } + else + { + Open(); + } + } +} diff --git a/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenu.razor b/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenu.razor new file mode 100644 index 000000000..29030d07c --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenu.razor @@ -0,0 +1,57 @@ +@namespace BlazorBlueprint.Primitives.NavigationMenu +@using Microsoft.AspNetCore.Components.Routing +@inject NavigationManager NavigationManager +@implements IDisposable + + + +@code { + private NavigationMenuContext context = null!; + + /// + /// Child content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Enables keyboard navigation within dropdown menus. Default is false. + /// + [Parameter] + public bool EnableKeyboardNavigation { get; set; } + + /// + /// Additional HTML attributes. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + /// + /// The navigation menu context, exposed for Components layer. + /// + public NavigationMenuContext Context => context; + + protected override void OnInitialized() + { + context = new NavigationMenuContext(StateHasChanged, EnableKeyboardNavigation); + NavigationManager.LocationChanged += OnLocationChanged; + } + + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) + { + if (context.ActiveItem != null) + { + context.SetActiveItem(null); + } + } + + public void Dispose() + { + NavigationManager.LocationChanged -= OnLocationChanged; + context.Dispose(); + } +} diff --git a/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuContent.razor b/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuContent.razor new file mode 100644 index 000000000..4ddaaed90 --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuContent.razor @@ -0,0 +1,41 @@ +@namespace BlazorBlueprint.Primitives.NavigationMenu + +@if (Item?.IsOpen == true) +{ +
    + @ChildContent +
    +} + +@code { + [CascadingParameter] + private NavigationMenuItemContext? Item { get; set; } + + /// + /// Child content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Additional HTML attributes. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private void HandleMouseEnter() + { + if (Item != null) + { + Item.CancelCloseTimer(); + Item.Open(); + } + } + + private void HandleMouseLeave() => + Item?.StartCloseTimer(); +} diff --git a/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuItem.razor b/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuItem.razor new file mode 100644 index 000000000..80f095f25 --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuItem.razor @@ -0,0 +1,40 @@ +@namespace BlazorBlueprint.Primitives.NavigationMenu + +
  • + + @ChildContent + +
  • + +@code { + private NavigationMenuItemContext itemContext = null!; + + [CascadingParameter] + private NavigationMenuContext? MenuContext { get; set; } + + /// + /// Child content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Unique identifier for this menu item. + /// + [Parameter] + public string? Value { get; set; } + + /// + /// Additional HTML attributes. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + /// + /// The per-item context, exposed for Components layer. + /// + public NavigationMenuItemContext ItemContext => itemContext; + + protected override void OnInitialized() => + itemContext = new NavigationMenuItemContext(MenuContext!, Value); +} diff --git a/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuLink.razor b/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuLink.razor new file mode 100644 index 000000000..1c4b555fc --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuLink.razor @@ -0,0 +1,36 @@ +@namespace BlazorBlueprint.Primitives.NavigationMenu +@using Microsoft.AspNetCore.Components.Routing + + + @ChildContent + + +@code { + /// + /// The URL the link navigates to. + /// + [Parameter] + public string? Href { get; set; } + + /// + /// Child content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// How to match the URL for active state. Default is . + /// + [Parameter] + public NavLinkMatch Match { get; set; } = NavLinkMatch.Prefix; + + /// + /// Additional HTML attributes. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuList.razor b/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuList.razor new file mode 100644 index 000000000..212c5028e --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuList.razor @@ -0,0 +1,19 @@ +@namespace BlazorBlueprint.Primitives.NavigationMenu + +
      + @ChildContent +
    + +@code { + /// + /// Child content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Additional HTML attributes. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuTrigger.razor b/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuTrigger.razor new file mode 100644 index 000000000..2d37d86db --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/BbNavigationMenuTrigger.razor @@ -0,0 +1,78 @@ +@namespace BlazorBlueprint.Primitives.NavigationMenu + + + +@code { + private ElementReference buttonRef; + + [CascadingParameter] + private NavigationMenuItemContext? Item { get; set; } + + [CascadingParameter] + private NavigationMenuContext? Menu { get; set; } + + /// + /// Child content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Additional HTML attributes. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + /// + /// The button element reference, exposed for focus management. + /// + public ElementReference ButtonRef => buttonRef; + + protected override void OnInitialized() + { + if (Menu != null) + { + Menu.RegisterTrigger(buttonRef); + } + } + + protected override void OnAfterRender(bool firstRender) + { + if (firstRender && Menu != null) + { + // Update the ref now that the element is rendered + var index = Menu.TriggerCount - 1; + Menu.UpdateTriggerRef(index, buttonRef); + } + } + + private void HandleClick() + { + if (Item != null && !Item.IsOpen) + { + Item.Open(); + } + } + + private void HandleMouseEnter() + { + if (Item != null) + { + Item.CancelCloseTimer(); + Item.Open(); + } + } + + private void HandleMouseLeave() => + Item?.StartCloseTimer(); +} diff --git a/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/NavigationMenuContext.cs b/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/NavigationMenuContext.cs new file mode 100644 index 000000000..7e412fdab --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Primitives/NavigationMenu/NavigationMenuContext.cs @@ -0,0 +1,178 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Primitives.NavigationMenu; + +/// +/// Root context for the navigation menu. Manages which item is open, close timers, +/// and trigger registration for keyboard navigation. +/// +public class NavigationMenuContext : IDisposable +{ + private readonly Action stateChanged; + private readonly List triggerRefs = new(); + private CancellationTokenSource? closeTimerCts; + + /// + /// The value of the currently open menu item, or null if all are closed. + /// + public string? ActiveItem { get; private set; } + + /// + /// Whether keyboard navigation within dropdowns is enabled. + /// + public bool EnableKeyboardNavigation { get; } + + /// + /// Number of registered triggers. + /// + public int TriggerCount => triggerRefs.Count; + + /// + /// Creates a new . + /// + public NavigationMenuContext(Action stateChanged, bool enableKeyboardNavigation) + { + this.stateChanged = stateChanged; + EnableKeyboardNavigation = enableKeyboardNavigation; + } + + /// + /// Sets the active menu item and triggers a re-render. + /// + public void SetActiveItem(string? value) + { + ActiveItem = value; + stateChanged(); + } + + /// + /// Registers a trigger element and returns its index. + /// + public int RegisterTrigger(ElementReference triggerRef) + { + triggerRefs.Add(triggerRef); + return triggerRefs.Count - 1; + } + + /// + /// Updates the element reference at the given index (needed after first render). + /// + public void UpdateTriggerRef(int index, ElementReference triggerRef) + { + if (index >= 0 && index < triggerRefs.Count) + { + triggerRefs[index] = triggerRef; + } + } + + /// + /// Gets the trigger element at the specified index. + /// + public ElementReference? GetTriggerAt(int index) + { + if (index >= 0 && index < triggerRefs.Count) + { + return triggerRefs[index]; + } + + return null; + } + + /// + /// Starts a shared close timer. After the delay, closes all menus. + /// Cancel with . + /// + public async void StartCloseTimer() + { + CancelCloseTimer(); + closeTimerCts = new CancellationTokenSource(); + + try + { + await Task.Delay(150, closeTimerCts.Token); + SetActiveItem(null); + } + catch (TaskCanceledException) + { + // Timer was cancelled + } + } + + /// + /// Cancels any pending close timer. + /// + public void CancelCloseTimer() + { + closeTimerCts?.Cancel(); + closeTimerCts?.Dispose(); + closeTimerCts = null; + } + + /// + /// Disposes the close timer resources. + /// + public void Dispose() + { + closeTimerCts?.Cancel(); + closeTimerCts?.Dispose(); + GC.SuppressFinalize(this); + } +} + +/// +/// Per-item context for a navigation menu item. +/// +public class NavigationMenuItemContext +{ + private readonly NavigationMenuContext parent; + + /// + /// Unique value identifying this menu item. + /// + public string? Value { get; } + + /// + /// Whether this item's dropdown is currently open. + /// + public bool IsOpen => parent.ActiveItem == Value; + + /// + /// Creates a new . + /// + public NavigationMenuItemContext(NavigationMenuContext parent, string? value) + { + this.parent = parent; + Value = value; + } + + /// Sets this item as open. + public void Open() => parent.SetActiveItem(Value); + + /// Closes this item if open. + public void Close() + { + if (IsOpen) + { + parent.SetActiveItem(null); + } + } + + /// Toggles open/closed. + public void Toggle() + { + if (IsOpen) + { + Close(); + } + else + { + Open(); + } + } + + /// Starts the parent's shared close timer. + public void StartCloseTimer() => parent.StartCloseTimer(); + + /// Cancels the parent's close timer. + public void CancelCloseTimer() => parent.CancelCloseTimer(); +} diff --git a/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/menu-keyboard.js b/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/menu-keyboard.js index 96453f599..5e8c5e1f0 100644 --- a/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/menu-keyboard.js +++ b/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/menu-keyboard.js @@ -246,6 +246,10 @@ export function initialize(container, dotNetRef, instanceId, config) { navigateLast(container); }); }); + } else if (initialFocus === 'container') { + // Focus the container itself (enables keyboard events) without + // navigating to any item. ArrowDown will move to the first item. + focusWithDoubleRaf(container); } } diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 75eacbfe1..831172d7f 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -371,6 +371,7 @@ - ParentChart : BbChartBase [CascadingParameter] ### BbChartTooltip (BlazorBlueprint.Components) + - AppendToBody : Boolean? - BackgroundColor : String - BorderColor : String - Indicator : TooltipIndicator @@ -605,6 +606,17 @@ - CascadedEditContext : EditContext [CascadingParameter] - FieldIsInvalid : Boolean? [CascadingParameter] +### BbDarkModeToggle (BlazorBlueprint.Components) + - Class : String + - DarkIcon : RenderFragment + - DarkLabel : String + - LightIcon : RenderFragment + - LightLabel : String + - ShowIcon : Boolean + - ShowLabel : Boolean + - Size : ButtonSize + - Variant : ButtonVariant + ### BbDashboardGrid (BlazorBlueprint.Components) - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] - AllowDrag : Boolean @@ -831,21 +843,31 @@ - OnRowCollapse : EventCallback - OnRowExpand : EventCallback - OnSort : EventCallback> + - OverscanCount : Int32 - PageSizes : Int32[] - ParentValueSelector : Func - Reorderable : Boolean - Resizable : Boolean - RowClass : Func - RowContextMenu : RenderFragment> + - SearchDebounceMs : Int32 + - SearchPlaceholder : String + - SearchText : String + - SearchTextChanged : EventCallback - SelectedItemsChanged : EventCallback> - SelectionMode : DataTableSelectionMode - ShowExpandAll : Boolean - ShowFilterBar : Boolean - ShowPagination : Boolean + - ShowSearch : Boolean - State : DataGridState - StateChanged : EventCallback> - StickyHeader : Boolean + - StripeClass : String + - Striped : Boolean + - TableContainerClass : String - Toolbar : RenderFragment + - VirtualScrollHeight : String - Virtualize : Boolean ### BbDataTableColumn`2 (BlazorBlueprint.Components) @@ -917,6 +939,7 @@ - EnableInfiniteScroll : Boolean - Fields : RenderFragment - GridClass : String + - GridColumnMinWidth : String - GridTemplate : RenderFragment - InitialPageSize : Int32 - IsLoading : Boolean @@ -2085,7 +2108,6 @@ - ChildContent : RenderFragment - Class : String - Disabled : Boolean - - Menu : BbMenubarMenu [CascadingParameter] ### BbMenubarContent (BlazorBlueprint.Components) - Align : MenubarContentAlign @@ -2124,7 +2146,6 @@ - ChildContent : RenderFragment - Class : String - Context : BbMenubar [CascadingParameter] - - Menu : BbMenubarMenu [CascadingParameter] ### BbMultiSelectItem`1 (BlazorBlueprint.Components) - ChildContent : RenderFragment @@ -3040,6 +3061,12 @@ - CascadedEditContext : EditContext [CascadingParameter] - FieldIsInvalid : Boolean? [CascadingParameter] +### BbThemeSwitcher (BlazorBlueprint.Components) + - Align : PopoverAlign + - PopoverContentClass : String + - Strategy : PositioningStrategy + - TriggerClass : String + ### BbTimePicker (BlazorBlueprint.Components) - Class : String - Disabled : Boolean @@ -3451,6 +3478,13 @@ - Destructive = 2 - Outline = 3 +### BaseColor (BlazorBlueprint.Components) + - Zinc = 0 + - Slate = 1 + - Gray = 2 + - Neutral = 3 + - Stone = 4 + ### ButtonGroupOrientation (BlazorBlueprint.Components) - Horizontal = 0 - Vertical = 1 @@ -3737,6 +3771,26 @@ - Default = 0 - Icon = 1 +### PrimaryColor (BlazorBlueprint.Components) + - Default = 0 + - Blue = 1 + - Violet = 2 + - Purple = 3 + - Rose = 4 + - Red = 5 + - Orange = 6 + - Amber = 7 + - Yellow = 8 + - Lime = 9 + - Green = 10 + - Emerald = 11 + - Teal = 12 + - Cyan = 13 + - Sky = 14 + - Indigo = 15 + - Fuchsia = 16 + - Pink = 17 + ### RadarShape (BlazorBlueprint.Components) - Polygon = 0 - Circle = 1 diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt index 5a8d4e73e..5ea65a06c 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt @@ -378,6 +378,80 @@ - ChildContent : RenderFragment - For : String +### BbMenubar (BlazorBlueprint.Primitives.Menubar) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + +### BbMenubarCheckboxItem (BlazorBlueprint.Primitives.Menubar) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - Checked : Boolean + - CheckedChanged : EventCallback + - ChildContent : RenderFragment + - Disabled : Boolean + +### BbMenubarContent (BlazorBlueprint.Primitives.Menubar) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Loop : Boolean + - Context : MenubarContext [CascadingParameter] + - Menu : MenubarMenuContext [CascadingParameter] + +### BbMenubarItem (BlazorBlueprint.Primitives.Menubar) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Disabled : Boolean + - OnClick : EventCallback + - Menu : MenubarMenuContext [CascadingParameter] + +### BbMenubarLabel (BlazorBlueprint.Primitives.Menubar) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + +### BbMenubarMenu (BlazorBlueprint.Primitives.Menubar) + - ChildContent : RenderFragment + - Context : MenubarContext [CascadingParameter] + +### BbMenubarSeparator (BlazorBlueprint.Primitives.Menubar) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + +### BbMenubarTrigger (BlazorBlueprint.Primitives.Menubar) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Context : MenubarContext [CascadingParameter] + - Menu : MenubarMenuContext [CascadingParameter] + +### BbNavigationMenu (BlazorBlueprint.Primitives.NavigationMenu) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - EnableKeyboardNavigation : Boolean + +### BbNavigationMenuContent (BlazorBlueprint.Primitives.NavigationMenu) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Item : NavigationMenuItemContext [CascadingParameter] + +### BbNavigationMenuItem (BlazorBlueprint.Primitives.NavigationMenu) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Value : String + - MenuContext : NavigationMenuContext [CascadingParameter] + +### BbNavigationMenuLink (BlazorBlueprint.Primitives.NavigationMenu) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Href : String + - Match : NavLinkMatch + +### BbNavigationMenuList (BlazorBlueprint.Primitives.NavigationMenu) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + +### BbNavigationMenuTrigger (BlazorBlueprint.Primitives.NavigationMenu) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Item : NavigationMenuItemContext [CascadingParameter] + - Menu : NavigationMenuContext [CascadingParameter] + ### BbPopover (BlazorBlueprint.Primitives.Popover) - ChildContent : RenderFragment - DefaultOpen : Boolean From e7f52340ebd67e91b76a03b1c41ce84bcdaba403 Mon Sep 17 00:00:00 2001 From: Mathew Date: Thu, 26 Mar 2026 11:05:14 +0800 Subject: [PATCH 003/188] docs: release notes for Primitives v3.8.0 --- src/BlazorBlueprint.Primitives/RELEASE_NOTES.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md b/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md index c0d82dfd2..87d148ad3 100644 --- a/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md +++ b/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md @@ -1,5 +1,11 @@ -## What's New in v3.7.3 +## What's New in v3.8.0 -### Bug Fixes +### New Components -- **Filtering** — Date and DateTime filter comparisons now use whole-day semantics. Operators (Equals, NotEquals, GreaterThan, LessThan, Between) treat the selected date as representing the entire day rather than an exact midnight timestamp, fixing incorrect results when data contains non-midnight time components. +- **Menubar** — Full headless menubar primitive with `BbMenubar`, `BbMenubarMenu`, `BbMenubarTrigger`, `BbMenubarContent`, `BbMenubarItem`, `BbMenubarCheckboxItem`, `BbMenubarLabel`, and `BbMenubarSeparator`. Includes coordinated open/close state, hover-to-switch between menus, and horizontal arrow-key navigation via `MenubarContext`. +- **NavigationMenu** — Headless navigation menu primitive with `BbNavigationMenu`, `BbNavigationMenuList`, `BbNavigationMenuItem`, `BbNavigationMenuTrigger`, `BbNavigationMenuContent`, and `BbNavigationMenuLink`. Features hover-based open/close with configurable delay timers, automatic close on navigation, and optional keyboard navigation via `NavigationMenuContext`. + +### New Features + +- **DataGrid** — Added `SearchText` property to `DataGridRequest`, enabling server-side providers to filter across all searchable columns using a global search term. +- **Menu keyboard JS** — Added `container` initial focus mode that focuses the menu container itself without selecting an item, allowing ArrowDown to navigate to the first item. From 6e9a467a360e234b537ef758019412cbe1625135 Mon Sep 17 00:00:00 2001 From: Mathew Date: Thu, 26 Mar 2026 11:11:31 +0800 Subject: [PATCH 004/188] chore: bump BlazorBlueprint.Primitives to 3.8.0 --- .../BlazorBlueprint.Components.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj index ab2d7cf49..b215351ef 100644 --- a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj +++ b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj @@ -49,7 +49,7 @@ - + From 4b542db641dc6b64304f5c0d48a38c1339747574 Mon Sep 17 00:00:00 2001 From: Mathew Date: Thu, 26 Mar 2026 11:19:12 +0800 Subject: [PATCH 005/188] docs: release notes for Components v3.8.1 --- .../RELEASE_NOTES.md | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/BlazorBlueprint.Components/RELEASE_NOTES.md b/src/BlazorBlueprint.Components/RELEASE_NOTES.md index 317dc6cb4..df13733ce 100644 --- a/src/BlazorBlueprint.Components/RELEASE_NOTES.md +++ b/src/BlazorBlueprint.Components/RELEASE_NOTES.md @@ -1,19 +1,27 @@ -## What's New in v3.8.0 +## What's New in v3.8.1 -### New Features +### New Components -- **Sidebar CSS custom property theming** — All sidebar layout, sizing, and styling values are now driven by ~70 CSS custom properties with sensible defaults. Override variables on `:root` to fully theme the sidebar without `!important` or specificity battles. -- **BbSidebarMenuButton OnClick** — Added `OnClick` EventCallback for custom click handling alongside built-in collapsible toggle behavior. -- **Sidebar active state variables** — Active menu button appearance (background, color, shadow, font-weight) is now controlled via `--sidebar-menu-button-active-*` variables. -- **Sidebar badge theming** — Badge background and color are now configurable via `--sidebar-badge-bg` and `--sidebar-badge-color` variables. +- **ThemeService** — scoped service for managing dark mode, base color, primary color, and border radius with `localStorage` persistence and OS preference detection +- **BbThemeSwitcher** — interactive theme customization panel for switching primary color, base color, radius, and light/dark mode +- **BbDarkModeToggle** — button component for toggling between light and dark mode -### Bug Fixes +### New Features -- **BbSidebarMenuButton data-active** — Fixed `data-active` attribute rendering boolean `"True"` instead of lowercase `"true"`. -- **BbSidebarMenuButton size variants** — Fixed size variant switch comparing against wrong string values (`"small"`/`"large"` instead of `"sm"`/`"lg"` from `ToValue()`). +- **DataGrid** global search — new `ShowSearch` parameter renders a built-in debounced search input; filters across all `Filterable` columns client-side or passes `SearchText` to `ItemsProvider` via `DataGridRequest.SearchText` for server-side filtering +- **DataGrid** virtualized server-side scrolling — when both `Virtualize` and `ItemsProvider` are set, the grid uses Blazor's `Virtualize` component to stream rows on scroll instead of loading all data at once +- **DataGrid** `Striped` and `StripeClass` parameters for alternating row backgrounds +- **DataGrid** `OverscanCount` parameter to control how many extra rows are rendered outside the visible area during virtual scrolling +- **DataGrid** `VirtualScrollHeight` parameter to set the scroll container height in virtualized server-side mode +- **DataGrid** `TableContainerClass` parameter for styling the inner scrollable container +- **DataView** `GridColumnMinWidth` parameter — uses CSS `repeat(auto-fill, minmax(...))` for fluid grid layouts instead of fixed breakpoint columns +- **BbChartTooltip** `AppendToBody` parameter to render chart tooltips outside the chart container, preventing clipping by `overflow: hidden` parents +- **Theme system** CSS with OKLCH color definitions for 5 base color palettes (Zinc, Slate, Gray, Neutral, Stone) and 18 primary accent colors ### Improvements -- **Sidebar data attributes** — Added missing `data-sidebar` attributes to 10 sidebar components (`BbSidebarContent`, `BbSidebarFooter`, `BbSidebarHeader`, `BbSidebarHeaderContent`, `BbSidebarMenu`, `BbSidebarMenuBadge`, `BbSidebarMenuItem`, `BbSidebarMenuSub`, `BbSidebarMenuButton`, `BbSidebarMenuSubButton`) for consistent CSS targeting. -- **Sidebar data-size attributes** — Added `data-size` attributes to `BbSidebarMenuButton` and `BbSidebarMenuSubButton` for size-aware CSS styling. -- **Sidebar collapsible icon mode** — Icon-mode overrides for collapsed sidebar are now handled via CSS custom properties instead of inline Tailwind utilities. +- **Menubar** components now delegate to Primitives-layer counterparts for keyboard navigation, focus management, and ARIA semantics, significantly reducing duplicated logic +- **DataGrid** pagination controls are now responsive — page size selector, page display, and first/last buttons hide on smaller screens +- **Localization** keys added for DataGrid search placeholder and all Theme components +- **ThemeService** registered via `AddBlazorBlueprintComponents()` with optional `Action` configuration +- Bumped **BlazorBlueprint.Primitives** dependency to 3.8.0 From 6de73106ce5330a392b75a60190b7c24eb4ee0a1 Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:24:57 +0800 Subject: [PATCH 006/188] feat: add Required parameter to selection, picker, and form field components (#271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Required parameter to selection, picker, and form field components Adds the Required parameter to 10 base components (Select, NativeSelect, Combobox, DatePicker, TimePicker, InputOTP, ColorPicker, FileUpload, Checkbox, RadioGroup) and their 9 corresponding FormField wrappers. Native elements use the HTML required attribute; ARIA-based triggers use aria-required; Checkbox and RadioGroup pass Required to their Primitives. BREAKING: BbCheckbox no longer infers aria-required from CheckedExpression binding — consumers must set Required="true" explicitly. * feat: add Required parameter to Select Primitive Flows Required through SelectContext (matching the Disabled pattern) so BbSelectTrigger renders aria-required on the trigger button. Components layer BbSelect now passes Required to the Primitive instead of setting aria-required manually on BbSelectTrigger. * docs: update changelog with Select Primitive Required entry * docs: add Required to Select Primitive demo API reference --- CHANGELOG.md | 13 +++++++++++ .../Pages/Components/ColorPickerDemo.razor | 3 +++ .../Pages/Components/ComboboxDemo.razor | 3 +++ .../Pages/Components/DatePickerDemo.razor | 3 +++ .../Pages/Components/FileUploadDemo.razor | 3 +++ .../Components/FormFieldCheckboxDemo.razor | 3 +++ .../Components/FormFieldComboboxDemo.razor | 3 +++ .../Components/FormFieldDatePickerDemo.razor | 3 +++ .../Components/FormFieldFileUploadDemo.razor | 1 + .../Components/FormFieldInputOTPDemo.razor | 1 + .../FormFieldNativeSelectDemo.razor | 3 +++ .../Components/FormFieldRadioGroupDemo.razor | 3 +++ .../Components/FormFieldSelectDemo.razor | 3 +++ .../Components/FormFieldTimePickerDemo.razor | 3 +++ .../Pages/Components/InputOTPDemo.razor | 3 +++ .../Pages/Components/NativeSelectDemo.razor | 3 +++ .../Pages/Components/RadioGroupDemo.razor | 3 +++ .../Pages/Components/SelectDemo.razor | 3 +++ .../Pages/Components/TimePickerDemo.razor | 3 +++ .../Primitives/SelectPrimitiveDemo.razor | 3 +++ .../Components/Checkbox/BbCheckbox.razor | 2 +- .../Components/Checkbox/BbCheckbox.razor.cs | 6 +++++ .../ColorPicker/BbColorPicker.razor | 9 +++++++- .../Components/Combobox/BbCombobox.razor | 1 + .../Components/Combobox/BbCombobox.razor.cs | 6 +++++ .../Components/DatePicker/BbDatePicker.razor | 9 +++++++- .../Components/FileUpload/BbFileUpload.razor | 7 ++++++ .../BbFormFieldCheckbox.razor | 2 ++ .../BbFormFieldCheckbox.razor.cs | 6 +++++ .../BbFormFieldCombobox.razor | 1 + .../BbFormFieldCombobox.razor.cs | 6 +++++ .../BbFormFieldDatePicker.razor | 1 + .../BbFormFieldDatePicker.razor.cs | 6 +++++ .../BbFormFieldFileUpload.razor | 1 + .../BbFormFieldFileUpload.razor.cs | 6 +++++ .../BbFormFieldInputOTP.razor | 1 + .../BbFormFieldInputOTP.razor.cs | 6 +++++ .../BbFormFieldNativeSelect.razor | 1 + .../BbFormFieldNativeSelect.razor.cs | 6 +++++ .../BbFormFieldRadioGroup.razor | 1 + .../BbFormFieldRadioGroup.razor.cs | 6 +++++ .../FormFieldSelect/BbFormFieldSelect.razor | 1 + .../BbFormFieldSelect.razor.cs | 6 +++++ .../BbFormFieldTimePicker.razor | 1 + .../BbFormFieldTimePicker.razor.cs | 6 +++++ .../Components/InputOTP/BbInputOTP.razor | 7 ++++++ .../NativeSelect/BbNativeSelect.razor | 7 ++++++ .../Components/RadioGroup/BbRadioGroup.razor | 1 + .../RadioGroup/BbRadioGroup.razor.cs | 6 +++++ .../Components/Select/BbSelect.razor | 7 ++++++ .../Components/TimePicker/BbTimePicker.razor | 9 +++++++- .../Primitives/Select/BbSelect.razor | 11 ++++++++++ .../Primitives/Select/BbSelectTrigger.razor | 1 + .../Primitives/Select/SelectContext.cs | 22 +++++++++++++++++++ ...entsApiSurfaceMatchesBaseline.verified.txt | 19 ++++++++++++++++ ...ivesApiSurfaceMatchesBaseline.verified.txt | 1 + 56 files changed, 257 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f832aa7dd..87ac4b732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## 2026-03-26 + +### Added + +- **Required parameter expansion** — Added `Required` parameter to 10 base components (`BbSelect`, `BbNativeSelect`, `BbCombobox`, `BbDatePicker`, `BbTimePicker`, `BbInputOTP`, `BbColorPicker`, `BbFileUpload`, `BbCheckbox`, `BbRadioGroup`) and 9 FormField wrappers (`BbFormFieldSelect`, `BbFormFieldCombobox`, `BbFormFieldDatePicker`, `BbFormFieldTimePicker`, `BbFormFieldInputOTP`, `BbFormFieldCheckbox`, `BbFormFieldRadioGroup`, `BbFormFieldFileUpload`, `BbFormFieldNativeSelect`). Native elements use the HTML `required` attribute; ARIA-based triggers use `aria-required`; `BbCheckbox` and `BbRadioGroup` pass `Required` through to their Primitives. +- **Select Primitive: Required parameter** — Added `Required` parameter to the headless `BbSelect` Primitive, flowing through `SelectContext` to render `aria-required` on `BbSelectTrigger`. The Components layer `BbSelect` now passes `Required` to the Primitive instead of setting `aria-required` manually. + +### Changed + +- **BbCheckbox: explicit Required parameter** — `BbCheckbox` no longer infers `aria-required` from `CheckedExpression` binding. Consumers must set `Required="true"` explicitly. + +--- + ## 2026-03-25 ### Added diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ColorPickerDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ColorPickerDemo.razor index 7bd4615c1..efab90433 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ColorPickerDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ColorPickerDemo.razor @@ -241,6 +241,9 @@ Disables the picker. + + Whether the color picker is required. +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ComboboxDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ComboboxDemo.razor index da9a8f6f4..fa6637899 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ComboboxDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ComboboxDemo.razor @@ -567,6 +567,9 @@ Whether the combobox is disabled. + + Whether the combobox is required. + Whether to match dropdown width to the trigger element width. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DatePickerDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DatePickerDemo.razor index 9ca9d734e..b79b33e0b 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DatePickerDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DatePickerDemo.razor @@ -242,6 +242,9 @@ Whether the date picker is disabled. + + Whether the date picker is required. +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FileUploadDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FileUploadDemo.razor index 75ed45551..5f8394cd3 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FileUploadDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FileUploadDemo.razor @@ -205,6 +205,9 @@ Disables the upload. + + Whether the file upload is required. + Validation error callback. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldCheckboxDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldCheckboxDemo.razor index 6f6fbb972..bda30e494 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldCheckboxDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldCheckboxDemo.razor @@ -240,6 +240,9 @@ Whether the checkbox is disabled. + + Whether the checkbox is required in a form context. + Additional CSS classes applied to the inner Checkbox element. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldComboboxDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldComboboxDemo.razor index 8b93857ad..63e8575bb 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldComboboxDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldComboboxDemo.razor @@ -385,6 +385,9 @@ Whether the combobox is disabled. + + Whether the combobox is required. + Whether to match the dropdown width to the trigger element width. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldDatePickerDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldDatePickerDemo.razor index 5333a1402..fa97c29b5 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldDatePickerDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldDatePickerDemo.razor @@ -178,6 +178,9 @@ Whether the date picker is disabled. + + Whether the date picker is required. + Additional CSS classes applied to the inner DatePicker trigger button. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldFileUploadDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldFileUploadDemo.razor index ce54a1bc5..5e97e54eb 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldFileUploadDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldFileUploadDemo.razor @@ -121,6 +121,7 @@ Maximum number of files. Show image previews. Whether the upload is disabled. + Whether the file upload is required. Custom dropzone content. CSS classes for the inner FileUpload. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldInputOTPDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldInputOTPDemo.razor index 5959accc7..54a99e4d2 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldInputOTPDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldInputOTPDemo.razor @@ -125,6 +125,7 @@ Mask values with asterisks. Accepted character types. Whether the input is disabled. + Whether the input is required. CSS classes for each OTP input box.
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldNativeSelectDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldNativeSelectDemo.razor index 4da9bcfd1..2c1b19304 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldNativeSelectDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldNativeSelectDemo.razor @@ -182,6 +182,9 @@ Whether the select is disabled. + + Whether the select is required. + Size variant (Small, Default, Large). diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldRadioGroupDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldRadioGroupDemo.razor index 9b5660fdd..e7bd0bd74 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldRadioGroupDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldRadioGroupDemo.razor @@ -157,6 +157,9 @@ Whether the entire radio group is disabled. + + Whether the radio group is required. + Additional CSS classes applied to the inner RadioGroup element. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldSelectDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldSelectDemo.razor index 56d3aa022..965b51041 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldSelectDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldSelectDemo.razor @@ -439,6 +439,9 @@ Whether the select is disabled. + + Whether the select is required. + Additional CSS classes applied to the inner Select element. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldTimePickerDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldTimePickerDemo.razor index 9a3c7c337..c1ad07e68 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldTimePickerDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldTimePickerDemo.razor @@ -195,6 +195,9 @@ Whether the time picker is disabled. + + Whether the time picker is required. + Additional CSS classes applied to the inner TimePicker trigger button. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/InputOTPDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/InputOTPDemo.razor index 41a43d524..b83f52dcb 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/InputOTPDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/InputOTPDemo.razor @@ -303,6 +303,9 @@ Whether the OTP input is disabled. + + Whether the input is required. + Whether to show separators between digit groups. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/NativeSelectDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/NativeSelectDemo.razor index 9448ce4a3..0440f31fd 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/NativeSelectDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/NativeSelectDemo.razor @@ -247,6 +247,9 @@ Whether the select is disabled. + + Whether the select is required. + Size variant for the select. Options: Small, Default, Large. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/RadioGroupDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/RadioGroupDemo.razor index 318cafc22..a3ce29e82 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/RadioGroupDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/RadioGroupDemo.razor @@ -265,6 +265,9 @@ Disables all radio items in the group. + + Whether the radio group is required in a form context. + Additional CSS classes applied to the radio group container. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SelectDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SelectDemo.razor index e499866ae..eea99a387 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SelectDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SelectDemo.razor @@ -385,6 +385,9 @@ Whether the select is disabled. + + Whether the select is required. + Whether the dropdown is open. Supports two-way binding with @@bind-Open. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/TimePickerDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/TimePickerDemo.razor index 0860ae4f7..76dbeed0b 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/TimePickerDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/TimePickerDemo.razor @@ -250,6 +250,9 @@ Disables the picker and prevents interaction. + + Whether the time picker is required. +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Primitives/SelectPrimitiveDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Primitives/SelectPrimitiveDemo.razor index 7e501da93..98edc048e 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Primitives/SelectPrimitiveDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Primitives/SelectPrimitiveDemo.razor @@ -195,6 +195,9 @@ Whether the select is disabled. + + Whether the select is required. When true, sets aria-required="true" on the trigger. + Default CSS classes applied to all SelectItem children. Cascaded via context so items merge it with their own classes. diff --git a/src/BlazorBlueprint.Components/Components/Checkbox/BbCheckbox.razor b/src/BlazorBlueprint.Components/Components/Checkbox/BbCheckbox.razor index d001f296e..4fbe7a3ad 100644 --- a/src/BlazorBlueprint.Components/Components/Checkbox/BbCheckbox.razor +++ b/src/BlazorBlueprint.Components/Components/Checkbox/BbCheckbox.razor @@ -7,9 +7,9 @@ Indeterminate="@Indeterminate" IndeterminateChanged="@IndeterminateChanged" Disabled="@Disabled" + Required="@Required" AriaLabel="@AriaLabel" class="@CssClass" - aria-required="@(CheckedExpression != null ? "true" : null)" aria-invalid="@(IsInvalid ? "true" : null)" aria-describedby="@AriaDescribedBy"> diff --git a/src/BlazorBlueprint.Components/Components/Checkbox/BbCheckbox.razor.cs b/src/BlazorBlueprint.Components/Components/Checkbox/BbCheckbox.razor.cs index 57700943f..46f95b8e4 100644 --- a/src/BlazorBlueprint.Components/Components/Checkbox/BbCheckbox.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Checkbox/BbCheckbox.razor.cs @@ -96,6 +96,12 @@ public partial class BbCheckbox : ComponentBase [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the checkbox is required in a form context. + /// + [Parameter] + public bool Required { get; set; } + /// /// Gets or sets additional CSS classes to apply to the checkbox. /// diff --git a/src/BlazorBlueprint.Components/Components/ColorPicker/BbColorPicker.razor b/src/BlazorBlueprint.Components/Components/ColorPicker/BbColorPicker.razor index 449d13aad..d56931814 100644 --- a/src/BlazorBlueprint.Components/Components/ColorPicker/BbColorPicker.razor +++ b/src/BlazorBlueprint.Components/Components/ColorPicker/BbColorPicker.razor @@ -8,7 +8,8 @@ + Disabled="@Disabled" + aria-required="@(Required ? "true" : null)">
    @@ -206,6 +207,12 @@ [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the color picker is required. + /// + [Parameter] + public bool Required { get; set; } + /// /// Gets or sets additional CSS classes for the trigger button. /// diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor index 926b8c27d..926fd8c9c 100644 --- a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor @@ -9,6 +9,7 @@ aria-controls="@($"{Id}-listbox")" aria-haspopup="listbox" aria-describedby="@AriaDescribedBy" + aria-required="@(Required ? "true" : null)" disabled="@Disabled" class="@ButtonCssClass"> diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs index df0947476..2b3c6d618 100644 --- a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs @@ -187,6 +187,12 @@ protected override bool ShouldRender() [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the combobox is required. + /// + [Parameter] + public bool Required { get; set; } + /// /// Gets or sets the ID(s) of the element(s) that describe this combobox for accessibility. /// diff --git a/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePicker.razor b/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePicker.razor index 018e3d70b..f6aad2ab7 100644 --- a/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePicker.razor +++ b/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePicker.razor @@ -11,7 +11,8 @@ + Class="@ButtonCssClass" + aria-required="@(Required ? "true" : null)"> @if (Value.HasValue) { @@ -104,6 +105,12 @@ [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the date picker is required. + /// + [Parameter] + public bool Required { get; set; } + /// /// Additional CSS classes to apply to the trigger button. /// diff --git a/src/BlazorBlueprint.Components/Components/FileUpload/BbFileUpload.razor b/src/BlazorBlueprint.Components/Components/FileUpload/BbFileUpload.razor index 438625fd4..37c6b24b1 100644 --- a/src/BlazorBlueprint.Components/Components/FileUpload/BbFileUpload.razor +++ b/src/BlazorBlueprint.Components/Components/FileUpload/BbFileUpload.razor @@ -9,6 +9,7 @@ @* Dropzone *@
    + /// Gets or sets whether the file upload is required. + /// + [Parameter] + public bool Required { get; set; } + /// /// Callback when validation errors occur. /// diff --git a/src/BlazorBlueprint.Components/Components/FormFieldCheckbox/BbFormFieldCheckbox.razor b/src/BlazorBlueprint.Components/Components/FormFieldCheckbox/BbFormFieldCheckbox.razor index 72499b06b..4a67bd453 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldCheckbox/BbFormFieldCheckbox.razor +++ b/src/BlazorBlueprint.Components/Components/FormFieldCheckbox/BbFormFieldCheckbox.razor @@ -21,6 +21,7 @@ Indeterminate="@Indeterminate" IndeterminateChanged="@IndeterminateChanged" Disabled="@Disabled" + Required="@Required" AriaLabel="@AriaLabel" AriaDescribedBy="@DescribedById" Class="@InputClass" /> @@ -39,6 +40,7 @@ Indeterminate="@Indeterminate" IndeterminateChanged="@IndeterminateChanged" Disabled="@Disabled" + Required="@Required" AriaLabel="@AriaLabel" AriaDescribedBy="@DescribedById" Class="@InputClass" /> diff --git a/src/BlazorBlueprint.Components/Components/FormFieldCheckbox/BbFormFieldCheckbox.razor.cs b/src/BlazorBlueprint.Components/Components/FormFieldCheckbox/BbFormFieldCheckbox.razor.cs index f8ee787e3..67019fbc3 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldCheckbox/BbFormFieldCheckbox.razor.cs +++ b/src/BlazorBlueprint.Components/Components/FormFieldCheckbox/BbFormFieldCheckbox.razor.cs @@ -45,6 +45,12 @@ public partial class BbFormFieldCheckbox : FormFieldBase [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the checkbox is required in a form context. + /// + [Parameter] + public bool Required { get; set; } + /// /// Gets or sets additional CSS classes applied to the inner Checkbox element. /// diff --git a/src/BlazorBlueprint.Components/Components/FormFieldCombobox/BbFormFieldCombobox.razor b/src/BlazorBlueprint.Components/Components/FormFieldCombobox/BbFormFieldCombobox.razor index 086d3c805..aa74831e5 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldCombobox/BbFormFieldCombobox.razor +++ b/src/BlazorBlueprint.Components/Components/FormFieldCombobox/BbFormFieldCombobox.razor @@ -22,6 +22,7 @@ IsLoading="@IsLoading" EndOfListMessage="@EndOfListMessage" Disabled="@Disabled" + Required="@Required" AriaDescribedBy="@DescribedById" PopoverWidth="@PopoverWidth" MatchTriggerWidth="@MatchTriggerWidth" diff --git a/src/BlazorBlueprint.Components/Components/FormFieldCombobox/BbFormFieldCombobox.razor.cs b/src/BlazorBlueprint.Components/Components/FormFieldCombobox/BbFormFieldCombobox.razor.cs index 4cfbc946a..af3035c08 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldCombobox/BbFormFieldCombobox.razor.cs +++ b/src/BlazorBlueprint.Components/Components/FormFieldCombobox/BbFormFieldCombobox.razor.cs @@ -103,6 +103,12 @@ public partial class BbFormFieldCombobox : FormFieldBase [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the combobox is required. + /// + [Parameter] + public bool Required { get; set; } + /// /// Gets or sets additional CSS classes applied to the inner Combobox element. /// diff --git a/src/BlazorBlueprint.Components/Components/FormFieldDatePicker/BbFormFieldDatePicker.razor b/src/BlazorBlueprint.Components/Components/FormFieldDatePicker/BbFormFieldDatePicker.razor index 348d7a0b3..10b5d3d08 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldDatePicker/BbFormFieldDatePicker.razor +++ b/src/BlazorBlueprint.Components/Components/FormFieldDatePicker/BbFormFieldDatePicker.razor @@ -17,6 +17,7 @@ DisabledDates="@DisabledDates" FirstDayOfWeek="@FirstDayOfWeek" Disabled="@Disabled" + Required="@Required" Class="@InputClass" /> @if (!string.IsNullOrEmpty(HelperText) && !IsInvalid) { diff --git a/src/BlazorBlueprint.Components/Components/FormFieldDatePicker/BbFormFieldDatePicker.razor.cs b/src/BlazorBlueprint.Components/Components/FormFieldDatePicker/BbFormFieldDatePicker.razor.cs index 13ca8ba45..df166ad4a 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldDatePicker/BbFormFieldDatePicker.razor.cs +++ b/src/BlazorBlueprint.Components/Components/FormFieldDatePicker/BbFormFieldDatePicker.razor.cs @@ -92,6 +92,12 @@ public partial class BbFormFieldDatePicker : FormFieldBase [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the date picker is required. + /// + [Parameter] + public bool Required { get; set; } + /// /// Gets or sets additional CSS classes applied to the inner DatePicker trigger button. /// diff --git a/src/BlazorBlueprint.Components/Components/FormFieldFileUpload/BbFormFieldFileUpload.razor b/src/BlazorBlueprint.Components/Components/FormFieldFileUpload/BbFormFieldFileUpload.razor index d4f34f299..070b38884 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldFileUpload/BbFormFieldFileUpload.razor +++ b/src/BlazorBlueprint.Components/Components/FormFieldFileUpload/BbFormFieldFileUpload.razor @@ -16,6 +16,7 @@ MaxFileCount="@MaxFileCount" ShowPreview="@ShowPreview" Disabled="@Disabled" + Required="@Required" OnValidationError="@OnValidationError" DropzoneContent="@DropzoneContent" Class="@InputClass" /> diff --git a/src/BlazorBlueprint.Components/Components/FormFieldFileUpload/BbFormFieldFileUpload.razor.cs b/src/BlazorBlueprint.Components/Components/FormFieldFileUpload/BbFormFieldFileUpload.razor.cs index 7c4117544..d17c0586c 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldFileUpload/BbFormFieldFileUpload.razor.cs +++ b/src/BlazorBlueprint.Components/Components/FormFieldFileUpload/BbFormFieldFileUpload.razor.cs @@ -57,6 +57,12 @@ public partial class BbFormFieldFileUpload : FormFieldBase [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the file upload is required. + /// + [Parameter] + public bool Required { get; set; } + /// /// Gets or sets the callback when validation errors occur. /// diff --git a/src/BlazorBlueprint.Components/Components/FormFieldInputOTP/BbFormFieldInputOTP.razor b/src/BlazorBlueprint.Components/Components/FormFieldInputOTP/BbFormFieldInputOTP.razor index 02a95f397..0a91011b1 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldInputOTP/BbFormFieldInputOTP.razor +++ b/src/BlazorBlueprint.Components/Components/FormFieldInputOTP/BbFormFieldInputOTP.razor @@ -14,6 +14,7 @@ Length="@Length" OnComplete="@OnComplete" Disabled="@Disabled" + Required="@Required" ShowSeparator="@ShowSeparator" GroupSize="@GroupSize" Separator="@Separator" diff --git a/src/BlazorBlueprint.Components/Components/FormFieldInputOTP/BbFormFieldInputOTP.razor.cs b/src/BlazorBlueprint.Components/Components/FormFieldInputOTP/BbFormFieldInputOTP.razor.cs index ab583aaba..bb6932666 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldInputOTP/BbFormFieldInputOTP.razor.cs +++ b/src/BlazorBlueprint.Components/Components/FormFieldInputOTP/BbFormFieldInputOTP.razor.cs @@ -51,6 +51,12 @@ public partial class BbFormFieldInputOTP : FormFieldBase [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the input is required. + /// + [Parameter] + public bool Required { get; set; } + /// /// Gets or sets whether to show separators between groups. /// diff --git a/src/BlazorBlueprint.Components/Components/FormFieldNativeSelect/BbFormFieldNativeSelect.razor b/src/BlazorBlueprint.Components/Components/FormFieldNativeSelect/BbFormFieldNativeSelect.razor index adbfc6cf6..1344809f7 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldNativeSelect/BbFormFieldNativeSelect.razor +++ b/src/BlazorBlueprint.Components/Components/FormFieldNativeSelect/BbFormFieldNativeSelect.razor @@ -15,6 +15,7 @@ ValueExpression="@ValueExpression" Placeholder="@Placeholder" Disabled="@Disabled" + Required="@Required" Size="@Size" Id="@ControlId" Class="@InputClass"> diff --git a/src/BlazorBlueprint.Components/Components/FormFieldNativeSelect/BbFormFieldNativeSelect.razor.cs b/src/BlazorBlueprint.Components/Components/FormFieldNativeSelect/BbFormFieldNativeSelect.razor.cs index dbf2a491e..e9087ba38 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldNativeSelect/BbFormFieldNativeSelect.razor.cs +++ b/src/BlazorBlueprint.Components/Components/FormFieldNativeSelect/BbFormFieldNativeSelect.razor.cs @@ -65,6 +65,12 @@ public partial class BbFormFieldNativeSelect : FormFieldBase [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the select is required. + /// + [Parameter] + public bool Required { get; set; } + /// /// Gets or sets the size variant of the select. /// diff --git a/src/BlazorBlueprint.Components/Components/FormFieldRadioGroup/BbFormFieldRadioGroup.razor b/src/BlazorBlueprint.Components/Components/FormFieldRadioGroup/BbFormFieldRadioGroup.razor index 21c538d1c..e1835fde4 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldRadioGroup/BbFormFieldRadioGroup.razor +++ b/src/BlazorBlueprint.Components/Components/FormFieldRadioGroup/BbFormFieldRadioGroup.razor @@ -14,6 +14,7 @@ ValueChanged="HandleValueChanged" ValueExpression="@ValueExpression" Disabled="@Disabled" + Required="@Required" AriaLabel="@(AriaLabel ?? Label)" AriaDescribedBy="@DescribedById" Class="@InputClass"> diff --git a/src/BlazorBlueprint.Components/Components/FormFieldRadioGroup/BbFormFieldRadioGroup.razor.cs b/src/BlazorBlueprint.Components/Components/FormFieldRadioGroup/BbFormFieldRadioGroup.razor.cs index f40c0f580..51153a8ae 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldRadioGroup/BbFormFieldRadioGroup.razor.cs +++ b/src/BlazorBlueprint.Components/Components/FormFieldRadioGroup/BbFormFieldRadioGroup.razor.cs @@ -34,6 +34,12 @@ public partial class BbFormFieldRadioGroup : FormFieldBase [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the radio group is required. + /// + [Parameter] + public bool Required { get; set; } + /// /// Gets or sets additional CSS classes applied to the inner RadioGroup element. /// diff --git a/src/BlazorBlueprint.Components/Components/FormFieldSelect/BbFormFieldSelect.razor b/src/BlazorBlueprint.Components/Components/FormFieldSelect/BbFormFieldSelect.razor index bb98ecf68..47c1d7645 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldSelect/BbFormFieldSelect.razor +++ b/src/BlazorBlueprint.Components/Components/FormFieldSelect/BbFormFieldSelect.razor @@ -14,6 +14,7 @@ ValueChanged="HandleValueChanged" ValueExpression="@ValueExpression" Disabled="@Disabled" + Required="@Required" DisplayTextSelector="@GetEffectiveDisplayTextSelector()" Class="@InputClass"> diff --git a/src/BlazorBlueprint.Components/Components/FormFieldSelect/BbFormFieldSelect.razor.cs b/src/BlazorBlueprint.Components/Components/FormFieldSelect/BbFormFieldSelect.razor.cs index b83298ac4..0b07717da 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldSelect/BbFormFieldSelect.razor.cs +++ b/src/BlazorBlueprint.Components/Components/FormFieldSelect/BbFormFieldSelect.razor.cs @@ -38,6 +38,12 @@ public partial class BbFormFieldSelect : FormFieldBase [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the select is required. + /// + [Parameter] + public bool Required { get; set; } + /// /// Gets or sets the placeholder text displayed when no value is selected. /// diff --git a/src/BlazorBlueprint.Components/Components/FormFieldTimePicker/BbFormFieldTimePicker.razor b/src/BlazorBlueprint.Components/Components/FormFieldTimePicker/BbFormFieldTimePicker.razor index 929402c51..42614d873 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldTimePicker/BbFormFieldTimePicker.razor +++ b/src/BlazorBlueprint.Components/Components/FormFieldTimePicker/BbFormFieldTimePicker.razor @@ -17,6 +17,7 @@ MaxTime="@MaxTime" Placeholder="@Placeholder" Disabled="@Disabled" + Required="@Required" Class="@InputClass" /> @if (!string.IsNullOrEmpty(HelperText) && !IsInvalid) { diff --git a/src/BlazorBlueprint.Components/Components/FormFieldTimePicker/BbFormFieldTimePicker.razor.cs b/src/BlazorBlueprint.Components/Components/FormFieldTimePicker/BbFormFieldTimePicker.razor.cs index a227360be..ca77ff914 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldTimePicker/BbFormFieldTimePicker.razor.cs +++ b/src/BlazorBlueprint.Components/Components/FormFieldTimePicker/BbFormFieldTimePicker.razor.cs @@ -90,6 +90,12 @@ public partial class BbFormFieldTimePicker : FormFieldBase [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the time picker is required. + /// + [Parameter] + public bool Required { get; set; } + /// /// Gets or sets additional CSS classes applied to the inner TimePicker trigger button. /// diff --git a/src/BlazorBlueprint.Components/Components/InputOTP/BbInputOTP.razor b/src/BlazorBlueprint.Components/Components/InputOTP/BbInputOTP.razor index 34df2bbf3..f615d7a64 100644 --- a/src/BlazorBlueprint.Components/Components/InputOTP/BbInputOTP.razor +++ b/src/BlazorBlueprint.Components/Components/InputOTP/BbInputOTP.razor @@ -20,6 +20,7 @@ class="@ComputedInputClass" value="@GetValueAt(index)" disabled="@Disabled" + required="@Required" @oninput="@(e => HandleInput(index, e))" @onkeydown="@(e => HandleKeyDown(index, e))" @onfocus="@(() => HandleFocus(index))" /> @@ -93,6 +94,12 @@ [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the input is required. + /// + [Parameter] + public bool Required { get; set; } + /// /// Whether to show separators between groups. /// diff --git a/src/BlazorBlueprint.Components/Components/NativeSelect/BbNativeSelect.razor b/src/BlazorBlueprint.Components/Components/NativeSelect/BbNativeSelect.razor index 97fb26a04..281cf68c2 100644 --- a/src/BlazorBlueprint.Components/Components/NativeSelect/BbNativeSelect.razor +++ b/src/BlazorBlueprint.Components/Components/NativeSelect/BbNativeSelect.razor @@ -8,6 +8,7 @@ name="@EffectiveName" value="@CurrentStringValue" disabled="@Disabled" + required="@Required" data-bb-native-select @onchange="HandleChange"> @if (!string.IsNullOrEmpty(Placeholder)) @@ -64,6 +65,12 @@ [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the select is required. + /// + [Parameter] + public bool Required { get; set; } + /// /// The option elements to display. /// diff --git a/src/BlazorBlueprint.Components/Components/RadioGroup/BbRadioGroup.razor b/src/BlazorBlueprint.Components/Components/RadioGroup/BbRadioGroup.razor index 533c0ddad..23e55e0c5 100644 --- a/src/BlazorBlueprint.Components/Components/RadioGroup/BbRadioGroup.razor +++ b/src/BlazorBlueprint.Components/Components/RadioGroup/BbRadioGroup.razor @@ -5,6 +5,7 @@ Value="@Value" ValueChanged="@HandleValueChanged" Disabled="@Disabled" + Required="@Required" AriaLabel="@AriaLabel" class="@CssClass" aria-invalid="@(IsInvalid ? "true" : null)" diff --git a/src/BlazorBlueprint.Components/Components/RadioGroup/BbRadioGroup.razor.cs b/src/BlazorBlueprint.Components/Components/RadioGroup/BbRadioGroup.razor.cs index e9cfc6606..00b9126c5 100644 --- a/src/BlazorBlueprint.Components/Components/RadioGroup/BbRadioGroup.razor.cs +++ b/src/BlazorBlueprint.Components/Components/RadioGroup/BbRadioGroup.razor.cs @@ -80,6 +80,12 @@ public partial class BbRadioGroup : ComponentBase [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the radio group is required in a form context. + /// + [Parameter] + public bool Required { get; set; } + /// /// Gets or sets additional CSS classes to apply to the radio group container. /// diff --git a/src/BlazorBlueprint.Components/Components/Select/BbSelect.razor b/src/BlazorBlueprint.Components/Components/Select/BbSelect.razor index 53ec89b4a..bbd8537a0 100644 --- a/src/BlazorBlueprint.Components/Components/Select/BbSelect.razor +++ b/src/BlazorBlueprint.Components/Components/Select/BbSelect.razor @@ -10,6 +10,7 @@ ValueChanged="@HandleValueChanged" @bind-Open="IsOpen" Disabled="Disabled" + Required="Required" DisplayTextSelector="@GetDisplayTextSelector()" class="@CssClass"> @if (Options is not null) @@ -63,6 +64,12 @@ [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the select is required. + /// + [Parameter] + public bool Required { get; set; } + /// /// Gets or sets whether the dropdown is open (controlled mode). /// diff --git a/src/BlazorBlueprint.Components/Components/TimePicker/BbTimePicker.razor b/src/BlazorBlueprint.Components/Components/TimePicker/BbTimePicker.razor index 9aa8cfca6..99ba0b62a 100644 --- a/src/BlazorBlueprint.Components/Components/TimePicker/BbTimePicker.razor +++ b/src/BlazorBlueprint.Components/Components/TimePicker/BbTimePicker.razor @@ -7,7 +7,8 @@ + Disabled="@Disabled" + aria-required="@(Required ? "true" : null)"> @if (Value.HasValue) { @@ -251,6 +252,12 @@ [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the time picker is required. + /// + [Parameter] + public bool Required { get; set; } + /// /// Additional CSS classes for the trigger button. /// diff --git a/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelect.razor b/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelect.razor index c21f76ac0..24c9f51ea 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelect.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelect.razor @@ -61,6 +61,13 @@ [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets whether the select is required in a form context. + /// When true, sets aria-required="true" on the trigger for screen readers. + /// + [Parameter] + public bool Required { get; set; } + /// /// Controls whether the select dropdown is open (controlled mode). /// When null, the select manages its own open state (uncontrolled mode). @@ -110,6 +117,7 @@ // Sync initial value to context _context.State.Value = _state.Value; _context.State.Disabled = Disabled; + _context.State.Required = Required; // Set initial DisplayText using DisplayTextSelector or Value.ToString(). // Items will update this with correct display text once they register via ForceMount portal. _context.SetDisplayText(GetDisplayText(_state.Value)); @@ -139,6 +147,9 @@ _context.SetDisabled(Disabled); } + // Update required state + _context.SetRequired(Required); + // Update controlled value if it changed if (_state.IsControlled) { diff --git a/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectTrigger.razor b/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectTrigger.razor index f203ac96a..c7ee306ad 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectTrigger.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectTrigger.razor @@ -12,6 +12,7 @@ aria-controls="@_context.ContentId" aria-haspopup="listbox" aria-disabled="@_context.Disabled.ToString().ToLower()" + aria-required="@(_context.Required ? "true" : null)" disabled="@_context.Disabled" data-state="@(_context.IsOpen ? "open" : "closed")" @onclick="HandleClick" diff --git a/src/BlazorBlueprint.Primitives/Primitives/Select/SelectContext.cs b/src/BlazorBlueprint.Primitives/Primitives/Select/SelectContext.cs index 146c15cc2..4c422e7d5 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Select/SelectContext.cs +++ b/src/BlazorBlueprint.Primitives/Primitives/Select/SelectContext.cs @@ -57,6 +57,11 @@ public class SelectState /// Gets or sets whether the select is disabled. /// public bool Disabled { get; set; } + + /// + /// Gets or sets whether the select is required. + /// + public bool Required { get; set; } } /// @@ -127,6 +132,23 @@ public SelectContext() : base(new SelectState(), "select") /// public bool Disabled => State.Disabled; + /// + /// Gets whether the select is required. + /// + public bool Required => State.Required; + + /// + /// Sets the required state. + /// + /// Whether the select is required. + public void SetRequired(bool required) + { + if (State.Required != required) + { + UpdateState(state => state.Required = required); + } + } + /// /// Opens the select dropdown. /// diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 831172d7f..cc533a787 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -389,6 +389,7 @@ - Id : String - Indeterminate : Boolean - IndeterminateChanged : EventCallback + - Required : Boolean - CascadedEditContext : EditContext [CascadingParameter] ### BbCheckboxGroupItem`1 (BlazorBlueprint.Components) @@ -432,6 +433,7 @@ - Disabled : Boolean - Format : ColorFormat - PresetColors : String[] + - Required : Boolean - ShowAlpha : Boolean - ShowInputs : Boolean - ShowPresets : Boolean @@ -461,6 +463,7 @@ - Options : IEnumerable> - Placeholder : String - PopoverWidth : String + - Required : Boolean - SearchPlaceholder : String - SearchQuery : String - SearchQueryChanged : EventCallback @@ -967,6 +970,7 @@ - MaxDate : DateTime? - MinDate : DateTime? - Placeholder : String + - Required : Boolean - Value : DateTime? - ValueChanged : EventCallback - ValueExpression : Expression> @@ -1262,6 +1266,7 @@ - MaxFileSize : Int64 - Multiple : Boolean - OnValidationError : EventCallback + - Required : Boolean - ShowPreview : Boolean ### BbFill (BlazorBlueprint.Components) @@ -1309,6 +1314,7 @@ - InputClass : String - Label : String - Orientation : FieldOrientation + - Required : Boolean ### BbFormFieldCheckboxGroup`1 (BlazorBlueprint.Components) - AriaLabel : String @@ -1344,6 +1350,7 @@ - Orientation : FieldOrientation - Placeholder : String - PopoverWidth : String + - Required : Boolean - SearchPlaceholder : String - SearchQuery : String - SearchQueryChanged : EventCallback @@ -1390,6 +1397,7 @@ - MinDate : DateTime? - Orientation : FieldOrientation - Placeholder : String + - Required : Boolean - Value : DateTime? - ValueChanged : EventCallback - ValueExpression : Expression> @@ -1434,6 +1442,7 @@ - Multiple : Boolean - OnValidationError : EventCallback - Orientation : FieldOrientation + - Required : Boolean - ShowPreview : Boolean ### BbFormFieldInputOTP (BlazorBlueprint.Components) @@ -1451,6 +1460,7 @@ - Name : String - OnComplete : EventCallback - Orientation : FieldOrientation + - Required : Boolean - Separator : RenderFragment - ShowSeparator : Boolean - Size : InputOTPSize @@ -1544,6 +1554,7 @@ - Label : String - Orientation : FieldOrientation - Placeholder : String + - Required : Boolean - Size : NativeSelectSize - Value : TValue - ValueChanged : EventCallback @@ -1584,6 +1595,7 @@ - InputClass : String - Label : String - Orientation : FieldOrientation + - Required : Boolean - Value : TValue - ValueChanged : EventCallback - ValueExpression : Expression> @@ -1604,6 +1616,7 @@ - Options : IEnumerable> - Orientation : FieldOrientation - Placeholder : String + - Required : Boolean - Value : TValue - ValueChanged : EventCallback - ValueExpression : Expression> @@ -1682,6 +1695,7 @@ - MinuteStep : Int32 - Orientation : FieldOrientation - Placeholder : String + - Required : Boolean - ShowSeconds : Boolean - Value : TimeSpan? - ValueChanged : EventCallback @@ -1972,6 +1986,7 @@ - Mask : Boolean - Name : String - OnComplete : EventCallback + - Required : Boolean - Separator : RenderFragment - ShowSeparator : Boolean - Size : InputOTPSize @@ -2188,6 +2203,7 @@ - Id : String - Name : String - Placeholder : String + - Required : Boolean - Size : NativeSelectSize - Value : TValue - ValueChanged : EventCallback @@ -2488,6 +2504,7 @@ - ChildContent : RenderFragment - Class : String - Disabled : Boolean + - Required : Boolean - Value : TValue - ValueChanged : EventCallback - ValueExpression : Expression> @@ -2684,6 +2701,7 @@ - OpenChanged : EventCallback - Options : IEnumerable> - Placeholder : String + - Required : Boolean - Value : TValue - ValueChanged : EventCallback - ValueExpression : Expression> @@ -3075,6 +3093,7 @@ - MinTime : TimeSpan? - MinuteStep : Int32 - Placeholder : String + - Required : Boolean - ShowSeconds : Boolean - Value : TimeSpan? - ValueChanged : EventCallback diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt index 5ea65a06c..c23858b9b 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt @@ -577,6 +577,7 @@ - OnValueChange : EventCallback - Open : Boolean? - OpenChanged : EventCallback + - Required : Boolean - Value : TValue - ValueChanged : EventCallback From 2223223f5f039c0f4b8b288abc3967e0f2011886 Mon Sep 17 00:00:00 2001 From: Mathew Date: Thu, 26 Mar 2026 17:29:35 +0800 Subject: [PATCH 007/188] docs: release notes for Primitives v3.9.0 --- src/BlazorBlueprint.Primitives/RELEASE_NOTES.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md b/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md index 87d148ad3..9596171c0 100644 --- a/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md +++ b/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md @@ -1,11 +1,5 @@ -## What's New in v3.8.0 - -### New Components - -- **Menubar** — Full headless menubar primitive with `BbMenubar`, `BbMenubarMenu`, `BbMenubarTrigger`, `BbMenubarContent`, `BbMenubarItem`, `BbMenubarCheckboxItem`, `BbMenubarLabel`, and `BbMenubarSeparator`. Includes coordinated open/close state, hover-to-switch between menus, and horizontal arrow-key navigation via `MenubarContext`. -- **NavigationMenu** — Headless navigation menu primitive with `BbNavigationMenu`, `BbNavigationMenuList`, `BbNavigationMenuItem`, `BbNavigationMenuTrigger`, `BbNavigationMenuContent`, and `BbNavigationMenuLink`. Features hover-based open/close with configurable delay timers, automatic close on navigation, and optional keyboard navigation via `NavigationMenuContext`. +## What's New in v3.9.0 ### New Features -- **DataGrid** — Added `SearchText` property to `DataGridRequest`, enabling server-side providers to filter across all searchable columns using a global search term. -- **Menu keyboard JS** — Added `container` initial focus mode that focuses the menu container itself without selecting an item, allowing ArrowDown to navigate to the first item. +- **Select** — Added `Required` parameter to `BbSelect`. When true, sets `aria-required="true"` on the select trigger for improved form accessibility. From 70bf9428e9f3d75720de86792a5e726dbf315f74 Mon Sep 17 00:00:00 2001 From: Mathew Date: Thu, 26 Mar 2026 17:36:24 +0800 Subject: [PATCH 008/188] chore: bump BlazorBlueprint.Primitives to 3.9.0 --- .../BlazorBlueprint.Components.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj index b215351ef..1f34c68c5 100644 --- a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj +++ b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj @@ -49,7 +49,7 @@ - + From 782f847bf39ddcb1f9c6d655a83a852eb8c7fb0a Mon Sep 17 00:00:00 2001 From: Mathew Date: Thu, 26 Mar 2026 17:37:30 +0800 Subject: [PATCH 009/188] docs: release notes for Components v3.9.0 --- .../RELEASE_NOTES.md | 26 ++++--------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/src/BlazorBlueprint.Components/RELEASE_NOTES.md b/src/BlazorBlueprint.Components/RELEASE_NOTES.md index df13733ce..1db799e3f 100644 --- a/src/BlazorBlueprint.Components/RELEASE_NOTES.md +++ b/src/BlazorBlueprint.Components/RELEASE_NOTES.md @@ -1,27 +1,11 @@ -## What's New in v3.8.1 - -### New Components - -- **ThemeService** — scoped service for managing dark mode, base color, primary color, and border radius with `localStorage` persistence and OS preference detection -- **BbThemeSwitcher** — interactive theme customization panel for switching primary color, base color, radius, and light/dark mode -- **BbDarkModeToggle** — button component for toggling between light and dark mode +## What's New in v3.9.0 ### New Features -- **DataGrid** global search — new `ShowSearch` parameter renders a built-in debounced search input; filters across all `Filterable` columns client-side or passes `SearchText` to `ItemsProvider` via `DataGridRequest.SearchText` for server-side filtering -- **DataGrid** virtualized server-side scrolling — when both `Virtualize` and `ItemsProvider` are set, the grid uses Blazor's `Virtualize` component to stream rows on scroll instead of loading all data at once -- **DataGrid** `Striped` and `StripeClass` parameters for alternating row backgrounds -- **DataGrid** `OverscanCount` parameter to control how many extra rows are rendered outside the visible area during virtual scrolling -- **DataGrid** `VirtualScrollHeight` parameter to set the scroll container height in virtualized server-side mode -- **DataGrid** `TableContainerClass` parameter for styling the inner scrollable container -- **DataView** `GridColumnMinWidth` parameter — uses CSS `repeat(auto-fill, minmax(...))` for fluid grid layouts instead of fixed breakpoint columns -- **BbChartTooltip** `AppendToBody` parameter to render chart tooltips outside the chart container, preventing clipping by `overflow: hidden` parents -- **Theme system** CSS with OKLCH color definitions for 5 base color palettes (Zinc, Slate, Gray, Neutral, Stone) and 18 primary accent colors +- **Required parameter** added to selection and picker components: **BbCheckbox**, **BbCombobox**, **BbColorPicker**, **BbDatePicker**, **BbFileUpload**, **BbInputOTP**, **BbNativeSelect**, **BbRadioGroup**, **BbSelect**, and **BbTimePicker** now accept a `Required` bool parameter that sets the appropriate `aria-required` or `required` attribute for form validation and accessibility. +- **Required parameter** added to all corresponding **FormField** wrappers: **BbFormFieldCheckbox**, **BbFormFieldCombobox**, **BbFormFieldDatePicker**, **BbFormFieldFileUpload**, **BbFormFieldInputOTP**, **BbFormFieldNativeSelect**, **BbFormFieldRadioGroup**, **BbFormFieldSelect**, and **BbFormFieldTimePicker** now pass `Required` through to their inner components. ### Improvements -- **Menubar** components now delegate to Primitives-layer counterparts for keyboard navigation, focus management, and ARIA semantics, significantly reducing duplicated logic -- **DataGrid** pagination controls are now responsive — page size selector, page display, and first/last buttons hide on smaller screens -- **Localization** keys added for DataGrid search placeholder and all Theme components -- **ThemeService** registered via `AddBlazorBlueprintComponents()` with optional `Action` configuration -- Bumped **BlazorBlueprint.Primitives** dependency to 3.8.0 +- **BbCheckbox** no longer infers `aria-required` from `CheckedExpression`; use the explicit `Required` parameter instead for clearer, more predictable behavior. +- Bumped **BlazorBlueprint.Primitives** dependency to v3.9.0. From 25a6518ef2dc1cafc8dacc74373947812c0bece4 Mon Sep 17 00:00:00 2001 From: Mathew Date: Thu, 26 Mar 2026 17:51:20 +0800 Subject: [PATCH 010/188] chore: update project file comments and suppress NU5104 warning during development --- .../BlazorBlueprint.Components.csproj | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj index 1f34c68c5..7e83f1ef0 100644 --- a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj +++ b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj @@ -41,7 +41,16 @@ - + + + $(NoWarn);NU5104 + + From 5da78100c2f80548b9575186f6b001a4fcb4532a Mon Sep 17 00:00:00 2001 From: Mathew Date: Thu, 26 Mar 2026 17:52:49 +0800 Subject: [PATCH 011/188] docs: release notes for Components v3.9.0 --- src/BlazorBlueprint.Components/RELEASE_NOTES.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/BlazorBlueprint.Components/RELEASE_NOTES.md b/src/BlazorBlueprint.Components/RELEASE_NOTES.md index 1db799e3f..7f207a09b 100644 --- a/src/BlazorBlueprint.Components/RELEASE_NOTES.md +++ b/src/BlazorBlueprint.Components/RELEASE_NOTES.md @@ -1,11 +1,14 @@ ## What's New in v3.9.0 +### Breaking Changes + +- **BbCheckbox** no longer infers `aria-required` from `CheckedExpression` binding — set `Required="true"` explicitly instead. + ### New Features -- **Required parameter** added to selection and picker components: **BbCheckbox**, **BbCombobox**, **BbColorPicker**, **BbDatePicker**, **BbFileUpload**, **BbInputOTP**, **BbNativeSelect**, **BbRadioGroup**, **BbSelect**, and **BbTimePicker** now accept a `Required` bool parameter that sets the appropriate `aria-required` or `required` attribute for form validation and accessibility. -- **Required parameter** added to all corresponding **FormField** wrappers: **BbFormFieldCheckbox**, **BbFormFieldCombobox**, **BbFormFieldDatePicker**, **BbFormFieldFileUpload**, **BbFormFieldInputOTP**, **BbFormFieldNativeSelect**, **BbFormFieldRadioGroup**, **BbFormFieldSelect**, and **BbFormFieldTimePicker** now pass `Required` through to their inner components. +- **Required parameter** added to selection and picker components: **BbSelect**, **BbNativeSelect**, **BbCombobox**, **BbDatePicker**, **BbTimePicker**, **BbColorPicker**, **BbInputOTP**, **BbFileUpload**, **BbCheckbox**, and **BbRadioGroup** now accept a `Required` parameter that renders the appropriate `required` or `aria-required` attribute for form validation and accessibility. +- **Required parameter** added to all corresponding **FormField** wrappers: **BbFormFieldSelect**, **BbFormFieldNativeSelect**, **BbFormFieldCombobox**, **BbFormFieldDatePicker**, **BbFormFieldTimePicker**, **BbFormFieldFileUpload**, **BbFormFieldInputOTP**, **BbFormFieldCheckbox**, **BbFormFieldRadioGroup** now pass `Required` through to their inner components. ### Improvements -- **BbCheckbox** no longer infers `aria-required` from `CheckedExpression`; use the explicit `Required` parameter instead for clearer, more predictable behavior. - Bumped **BlazorBlueprint.Primitives** dependency to v3.9.0. From 1e40e058e9f202a5d9813187d17047d65eeeaeb0 Mon Sep 17 00:00:00 2001 From: Mathew Date: Fri, 27 Mar 2026 00:17:03 +0800 Subject: [PATCH 012/188] chore: bump BlazorBlueprint.Primitives to 3.9.1 --- .../BlazorBlueprint.Components.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj index 7e83f1ef0..90ff4beb9 100644 --- a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj +++ b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj @@ -58,7 +58,7 @@ - + From d8c59c3b12f721fd7b0a8bf988547edc4b94b953 Mon Sep 17 00:00:00 2001 From: Mathew Date: Fri, 27 Mar 2026 00:19:05 +0800 Subject: [PATCH 013/188] docs: release notes for Components v3.9.1 --- src/BlazorBlueprint.Components/RELEASE_NOTES.md | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/BlazorBlueprint.Components/RELEASE_NOTES.md b/src/BlazorBlueprint.Components/RELEASE_NOTES.md index 7f207a09b..33ca6ea82 100644 --- a/src/BlazorBlueprint.Components/RELEASE_NOTES.md +++ b/src/BlazorBlueprint.Components/RELEASE_NOTES.md @@ -1,14 +1,5 @@ -## What's New in v3.9.0 - -### Breaking Changes - -- **BbCheckbox** no longer infers `aria-required` from `CheckedExpression` binding — set `Required="true"` explicitly instead. - -### New Features - -- **Required parameter** added to selection and picker components: **BbSelect**, **BbNativeSelect**, **BbCombobox**, **BbDatePicker**, **BbTimePicker**, **BbColorPicker**, **BbInputOTP**, **BbFileUpload**, **BbCheckbox**, and **BbRadioGroup** now accept a `Required` parameter that renders the appropriate `required` or `aria-required` attribute for form validation and accessibility. -- **Required parameter** added to all corresponding **FormField** wrappers: **BbFormFieldSelect**, **BbFormFieldNativeSelect**, **BbFormFieldCombobox**, **BbFormFieldDatePicker**, **BbFormFieldTimePicker**, **BbFormFieldFileUpload**, **BbFormFieldInputOTP**, **BbFormFieldCheckbox**, **BbFormFieldRadioGroup** now pass `Required` through to their inner components. +## What's New in v3.9.1 ### Improvements -- Bumped **BlazorBlueprint.Primitives** dependency to v3.9.0. +- Bumped **BlazorBlueprint.Primitives** dependency to v3.9.1. From 20d2d61e2a3cb5cd3ba7827114d63d4f5a34c59f Mon Sep 17 00:00:00 2001 From: Mathew Date: Fri, 27 Mar 2026 00:34:09 +0800 Subject: [PATCH 014/188] chore: bump BlazorBlueprint.Primitives to 3.9.2 --- .../BlazorBlueprint.Components.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj index 90ff4beb9..ea04a27cf 100644 --- a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj +++ b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj @@ -58,7 +58,7 @@ - + From 2bb5bde8a095c8d212a34b9b221baab57a9ead4b Mon Sep 17 00:00:00 2001 From: Mathew Date: Fri, 27 Mar 2026 13:18:23 +0800 Subject: [PATCH 015/188] chore: update .gitignore to include published output and modify appsettings.json for Kestrel configuration --- .gitignore | 4 ++++ demos/BlazorBlueprint.Demo.Server/appsettings.json | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0b4db82f9..58dd0b3ff 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,9 @@ NUL .env.local .env.*.local +# Published output +publish/ + # Local test apps tests/BlazorBlueprint.IssueTester/ @@ -60,3 +63,4 @@ scripts/release-*.sh scripts/compare-theme.sh scripts/nuget-versions.sh scripts/run-ui-tests.sh +scripts/publish.sh diff --git a/demos/BlazorBlueprint.Demo.Server/appsettings.json b/demos/BlazorBlueprint.Demo.Server/appsettings.json index 10f68b8c8..cf4871a4f 100644 --- a/demos/BlazorBlueprint.Demo.Server/appsettings.json +++ b/demos/BlazorBlueprint.Demo.Server/appsettings.json @@ -5,5 +5,12 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:7172" + } + } + } } From 4d649c2c2ab2b740a232ed65ff109dffdd827639 Mon Sep 17 00:00:00 2001 From: Mathew Date: Fri, 27 Mar 2026 20:48:58 +0800 Subject: [PATCH 016/188] (chore) repo cleanup --- .gitignore | 11 +- ...6-03-23-datagrid-virtual-items-provider.md | 348 - tools/icon-generation/README.md | 80 - tools/icon-generation/build-icons.sh | 55 - tools/icon-generation/data/feather-icons.json | 892 --- tools/icon-generation/data/heroicons.json | 5214 --------------- tools/icon-generation/data/lucide.json | 5697 ----------------- tools/icon-generation/generate-feather.js | 103 - tools/icon-generation/generate-heroicons.js | 242 - tools/icon-generation/generate-lucide.js | 103 - 10 files changed, 1 insertion(+), 12744 deletions(-) delete mode 100644 docs/plans/2026-03-23-datagrid-virtual-items-provider.md delete mode 100644 tools/icon-generation/README.md delete mode 100644 tools/icon-generation/build-icons.sh delete mode 100644 tools/icon-generation/data/feather-icons.json delete mode 100644 tools/icon-generation/data/heroicons.json delete mode 100644 tools/icon-generation/data/lucide.json delete mode 100644 tools/icon-generation/generate-feather.js delete mode 100644 tools/icon-generation/generate-heroicons.js delete mode 100644 tools/icon-generation/generate-lucide.js diff --git a/.gitignore b/.gitignore index 58dd0b3ff..7c60ff327 100644 --- a/.gitignore +++ b/.gitignore @@ -54,13 +54,4 @@ tests/BlazorBlueprint.IssueTester/ # Development Tools .claude -.audits -.scratchpad.md -themes -docs -scripts/release.sh -scripts/release-*.sh -scripts/compare-theme.sh -scripts/nuget-versions.sh -scripts/run-ui-tests.sh -scripts/publish.sh +devkit/ diff --git a/docs/plans/2026-03-23-datagrid-virtual-items-provider.md b/docs/plans/2026-03-23-datagrid-virtual-items-provider.md deleted file mode 100644 index 10665be88..000000000 --- a/docs/plans/2026-03-23-datagrid-virtual-items-provider.md +++ /dev/null @@ -1,348 +0,0 @@ ---- -title: "DataGrid Server-Side Infinite Scroll (Virtualized ItemsProvider)" -date: 2026-03-23 -branch: feat/datagrid-virtual-scroll -status: complete -author: claude -tags: [datagrid, virtualization, items-provider, infinite-scroll] -estimated_tasks: 8 ---- - -# DataGrid Server-Side Infinite Scroll - -## Context - -Currently `ItemsProvider` (server-side data) uses page-based pagination, and `Virtualize` (smooth scrolling) only works with client-side data. When `Virtualize=true` with `ItemsProvider`, the grid fetches **all** items from the server (`StartIndex=0, Count=null`) and virtualizes the DOM rendering — but the full dataset must still fit in memory. - -This plan enables true server-side infinite scroll: Blazor's `` drives data requests on demand as the user scrolls, fetching only the visible window plus overscan. - -> **CRITICAL RULE — Original Code Only** -> -> All implementations must be written from scratch, original to BlazorBlueprint. No code may be copied from any third-party codebase. - ---- - -## Architecture - -### Current Modes - -| Condition | Behavior | -|---|---| -| `Items` set, `Virtualize=false` | Client-side data, paginated | -| `Items` set, `Virtualize=true` | Client-side data, virtualized DOM (all items in memory) | -| `ItemsProvider` set, `Virtualize=false` | Server-side data, paginated via `StartIndex`/`Count` | -| `ItemsProvider` set, `Virtualize=true` | **Currently:** Server fetches ALL items, virtualizes DOM only | - -### New Mode - -| Condition | Behavior | -|---|---| -| `ItemsProvider` set, `Virtualize=true` | **New:** `` drives server requests as user scrolls | - -### Key Insight - -Blazor's native `` component already supports an `ItemsProvider` delegate that receives `ItemsProviderRequest` with `StartIndex` and `Count`. We create a **bridge method** that translates between Blazor's request type and BlazorBlueprint's `DataGridRequest`, injecting the current sort/filter/group state. - -This is cleaner than a custom scroll-based approach because Blazor handles all viewport tracking, request batching, and DOM recycling. - ---- - -## Implementation Steps - -### Step 1: Add VirtualScrollHeight Parameter - -**File:** `src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs` - -Add parameter after `OverscanCount`: - -```csharp -/// -/// CSS height for the scroll container when using virtualized ItemsProvider mode -/// (both and are set). -/// Required in this mode to give the Virtualize component a bounded scroll area. -/// Defaults to "400px". Accepts any CSS length value. -/// -[Parameter] -public string VirtualScrollHeight { get; set; } = "400px"; -``` - -**Estimated effort:** Trivial (5 min) - ---- - -### Step 2: Add Computed Property for Virtual+Provider Mode - -**File:** `src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs` - -Add a helper property to detect the combined mode: - -```csharp -/// -/// Whether the grid is in server-side virtual scroll mode (both Virtualize and ItemsProvider set). -/// -private bool IsVirtualizedProvider => Virtualize && ItemSize > 0 && ItemsProvider != null; -``` - -**Estimated effort:** Trivial (5 min) - ---- - -### Step 3: Create the Virtualize ItemsProvider Bridge - -**File:** `src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs` - -Add a field for the Virtualize component reference and the bridge method: - -```csharp -private Virtualize? _virtualizeRef; - -/// -/// Bridge between Blazor's ItemsProviderRequest and BlazorBlueprint's DataGridRequest. -/// Called by as the user scrolls. -/// -private async ValueTask> VirtualItemsProviderAsync( - ItemsProviderRequest request) -{ - var aggregateColumns = _columns - .Where(c => c.Aggregate != AggregateFunction.None) - .Select(c => c.ColumnId) - .ToList(); - - var dataGridRequest = new DataGridRequest - { - SortDefinitions = _gridState.Sorting.Definitions, - StartIndex = request.StartIndex, - Count = request.Count, - CancellationToken = request.CancellationToken, - Filters = _gridState.Filtering.Filters, - GroupDefinition = _gridState.Grouping.ActiveGroup, - AggregateColumns = aggregateColumns.Count > 0 ? aggregateColumns : null - }; - - var result = await ItemsProvider!(dataGridRequest); - - // Update pagination total for display purposes (e.g., "Showing X of Y") - _gridState.Pagination.TotalItems = result.TotalItemCount; - - return new ItemsProviderResult(result.Items, result.TotalItemCount); -} -``` - -**Key decisions:** -- `StartIndex` and `Count` come directly from Blazor's request — the Virtualize component manages windowing -- Sort/filter/group state is injected from the current grid state -- `TotalItemCount` flows back to update the pagination state (for info display) -- CancellationToken flows through so superseded requests are cancelled - -**Estimated effort:** Small (30 min) - ---- - -### Step 4: Modify LoadFromProviderAsync — Skip in Virtual+Provider Mode - -**File:** `src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs` - -In the existing `LoadFromProviderAsync()` method (line ~1966), add an early exit when in virtual+provider mode, since the Virtualize component drives data loading: - -```csharp -private async Task LoadFromProviderAsync() -{ - // In virtualized provider mode, Virtualize drives data loading. - // Just refresh the Virtualize component instead. - if (IsVirtualizedProvider) - { - if (_virtualizeRef != null) - { - await _virtualizeRef.RefreshDataAsync(); - } - return; - } - - // ... existing pagination-based loading logic unchanged ... -} -``` - -This ensures that when sort/filter changes trigger `ProcessDataAsync()` → `LoadFromProviderAsync()`, the Virtualize component is told to re-query rather than doing a manual fetch. - -**Estimated effort:** Small (15 min) - ---- - -### Step 5: Update the Razor Template - -**File:** `src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor` - -#### 5a. Scroll Container Height - -Wrap the table container with a height-constrained div when in virtual+provider mode: - -```razor -
    -``` - -#### 5b. Conditional Virtualize Rendering - -Replace the existing Virtualize block (lines 278-282) to handle both modes: - -```razor -@* Existing client-side virtualization with Items *@ -else if (Virtualize && ItemSize > 0 && !IsVirtualizedProvider) -{ - - @RenderDataRow(item) - -} -@* NEW: Server-side virtualization with ItemsProvider bridge *@ -else if (IsVirtualizedProvider) -{ - - @RenderDataRow(item) - -} -``` - -#### 5c. Hide Pagination in Virtual+Provider Mode - -Update the pagination condition (line 329): - -```razor -@if (ShowPagination && !IsLoading && !IsVirtualizedProvider && _processedData.Any()) -``` - -**Estimated effort:** Medium (45 min) — careful ordering of conditional blocks - ---- - -### Step 6: Handle Sort/Filter Refresh - -**File:** `src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs` - -The existing `HandleSortChange` and filter handling already call `ProcessDataAsync()` → `LoadFromProviderAsync()`. With Step 4's change, this now calls `_virtualizeRef.RefreshDataAsync()` in virtual+provider mode, which re-queries the bridge from `StartIndex=0`. No additional changes needed. - -However, verify that these methods work correctly: -- `HandleSortChange` — triggers data reload ✓ -- Filter changes (via column filter UI) — triggers data reload ✓ -- `HandlePageSizeChanged` — should be unreachable (pagination hidden) ✓ - -**Estimated effort:** Small (15 min) — verification and testing only - ---- - -### Step 7: Handle Grouped/Hierarchy Mode - -Grouped and hierarchy modes with server-side virtual scroll are **not supported** in this iteration. The `_groupedRenderItems` path stays on the existing client-side virtualization. - -Add a guard in the bridge method: - -```csharp -private async ValueTask> VirtualItemsProviderAsync( - ItemsProviderRequest request) -{ - // Grouping is not supported with virtualized provider mode - if (_groupByAccessor != null) - { - return new ItemsProviderResult(Array.Empty(), 0); - } - - // ... bridge logic ... -} -``` - -**Estimated effort:** Trivial (5 min) - ---- - -### Step 8: Demo Page and Testing - -**File:** `demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor` - -Add a new section demonstrating server-side virtual scroll. Use a simulated async provider that adds artificial delay: - -```razor - - - - ... - - - -@code { - private async ValueTask> VirtualProviderAsync(DataGridRequest request) - { - await Task.Delay(50); // Simulate network latency - var allData = MockDataService.GeneratePersons(10000); - - // Apply sort - var sorted = ApplySorting(allData, request.SortDefinitions); - - // Apply pagination window - var page = sorted.Skip(request.StartIndex).Take(request.Count ?? 50).ToList(); - - return new DataGridResult - { - Items = page, - TotalItemCount = allData.Count - }; - } -} -``` - -**Test scenarios:** -- Scroll through 10,000 items — only visible + overscan rows in DOM -- Sort a column — grid refreshes from top with new sort order -- Filter a column — grid refreshes with filtered count -- Verify pagination footer is hidden -- Verify `VirtualScrollHeight` constrains the container -- Verify empty state when provider returns 0 items -- Verify loading indicator during initial load - -**Estimated effort:** Medium (1-2 hours) - ---- - -## Execution Order - -| Step | Description | Dependencies | -|------|-------------|-------------| -| 1 | Add `VirtualScrollHeight` parameter | None | -| 2 | Add `IsVirtualizedProvider` property | None | -| 3 | Create bridge method | Steps 1, 2 | -| 4 | Modify `LoadFromProviderAsync` | Step 2 | -| 5 | Update razor template | Steps 2, 3 | -| 6 | Verify sort/filter refresh | Steps 4, 5 | -| 7 | Guard grouped mode | Step 3 | -| 8 | Demo page and testing | Steps 1-7 | - ---- - -## API Surface Changes - -New public parameters on `BbDataGrid`: - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `VirtualScrollHeight` | `string` | `"400px"` | CSS height for scroll container in virtual+provider mode | - -No changes to `DataGridRequest` or `DataGridResult` — existing `StartIndex`/`Count` fields are reused. - ---- - -## Risk Assessment - -| Risk | Mitigation | -|------|-----------| -| Blazor's `Virtualize` may make redundant requests during rapid scrolling | The `CancellationToken` flow through the bridge ensures superseded requests are cancelled. `OverscanCount` reduces request frequency. | -| Sort/filter change causes flicker as Virtualize re-queries from scratch | `RefreshDataAsync()` resets the scroll position and re-queries cleanly. Users expect a reset on filter change. | -| Grouped/hierarchy mode not supported | Guard with early return and clear documentation. Can be added later. | -| Large `TotalItemCount` causes memory issues in Virtualize component | Blazor's Virtualize handles this natively — it only tracks DOM for visible items, not all items. | -| Existing `Virtualize + Items` behavior must not change | The `IsVirtualizedProvider` condition is strict: requires `ItemsProvider != null`. Client-side virtualization is unchanged. | diff --git a/tools/icon-generation/README.md b/tools/icon-generation/README.md deleted file mode 100644 index 623b0417e..000000000 --- a/tools/icon-generation/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# Icon Generation Tools - -This folder contains the source data and generation scripts used to produce the C# icon data files for BlazorBlueprint's icon libraries. - -## Overview - -BlazorBlueprint provides three icon library packages, each wrapping a popular open-source icon set: - -| Package | Icon Set | Icons | License | Source | -|---------|----------|-------|---------|--------| -| `BlazorBlueprint.Icons.Lucide` | [Lucide](https://lucide.dev/) | 1,640+ | ISC | [GitHub](https://github.com/lucide-icons/lucide) | -| `BlazorBlueprint.Icons.Heroicons` | [Heroicons](https://heroicons.com/) | 1,288 | MIT | [GitHub](https://github.com/tailwindlabs/heroicons) | -| `BlazorBlueprint.Icons.Feather` | [Feather](https://feathericons.com/) | 286 | MIT | [GitHub](https://github.com/feathericons/feather) | - -## Folder Structure - -``` -tools/icon-generation/ -├── README.md # This file -├── generate-lucide.js # Lucide icon generation script -├── generate-heroicons.js # Heroicons icon generation script -├── generate-feather.js # Feather icon generation script -└── data/ - ├── feather-icons.json # Feather icons in Iconify JSON format - ├── heroicons.json # Heroicons in Iconify JSON format - └── lucide.json # Lucide icons in Iconify JSON format -``` - -## Data Format - -The JSON files use the [Iconify JSON format](https://iconify.design/docs/types/iconify-json.html), which includes: - -- Icon metadata (name, author, license) -- SVG path data for each icon -- Default dimensions and attributes - -## Generation Scripts - -Each icon library has its own Node.js generation script that converts the JSON data into C# code: - -| Icon Library | Script | Output | -|--------------|--------|--------| -| Lucide | `tools/icon-generation/generate-lucide.js` | `src/BlazorBlueprint.Icons.Lucide/Data/LucideIconData.cs` | -| Heroicons | `tools/icon-generation/generate-heroicons.js` | `src/BlazorBlueprint.Icons.Heroicons/Data/HeroIconData.cs` | -| Feather | `tools/icon-generation/generate-feather.js` | `src/BlazorBlueprint.Icons.Feather/Data/FeatherIconData.cs` | - -### Running the Scripts - -All scripts are run from the `tools/icon-generation/` directory: - -```bash -cd tools/icon-generation - -# Generate one -node generate-lucide.js -node generate-heroicons.js -node generate-feather.js -``` - -## Updating Icons - -To update to a newer version of an icon set: - -1. **Download the latest Iconify JSON** from the icon set's repository or [Iconify](https://github.com/iconify/icon-sets) -2. **Replace the JSON file** in `tools/icon-generation/data/` -3. **Run the generation script** for that icon library -4. **Test** that the icons render correctly -5. **Commit** the updated JSON and generated C# files - -## Generated Code - -The generation scripts produce static C# classes with: - -- A dictionary mapping icon names to SVG path data -- `GetIcon(name)` - Retrieve SVG content by name -- `GetAvailableIcons()` - List all available icon names -- `IconExists(name)` - Check if an icon exists -- `IconCount` - Total number of icons - -For Heroicons, the generated code also includes variant support (Outline, Solid, Mini, Micro). diff --git a/tools/icon-generation/build-icons.sh b/tools/icon-generation/build-icons.sh deleted file mode 100644 index c2872e75d..000000000 --- a/tools/icon-generation/build-icons.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/bash - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -echo "What library do you want to build?" -echo "" -echo "0. All" -echo "---" -echo "1. Lucide" -echo "2. Heroicons" -echo "3. Feather" -echo "" -read -rp "Select an option: " choice - -build_lucide() { - echo "" - echo "Building Lucide icons..." - node "$SCRIPT_DIR/generate-lucide.js" -} - -build_heroicons() { - echo "" - echo "Building Heroicons..." - node "$SCRIPT_DIR/generate-heroicons.js" -} - -build_feather() { - echo "" - echo "Building Feather icons..." - node "$SCRIPT_DIR/generate-feather.js" -} - -case "$choice" in - 0) - build_lucide - build_heroicons - build_feather - ;; - 1) - build_lucide - ;; - 2) - build_heroicons - ;; - 3) - build_feather - ;; - *) - echo "Invalid option: $choice" - exit 1 - ;; -esac - -echo "" -echo "Done!" diff --git a/tools/icon-generation/data/feather-icons.json b/tools/icon-generation/data/feather-icons.json deleted file mode 100644 index 7986ec1e5..000000000 --- a/tools/icon-generation/data/feather-icons.json +++ /dev/null @@ -1,892 +0,0 @@ -{ - "prefix": "feather", - "info": { - "name": "Feather Icons", - "total": 286, - "author": { - "name": "Cole Bemis", - "url": "https://github.com/feathericons/feather" - }, - "license": { - "title": "MIT", - "spdx": "MIT", - "url": "https://github.com/feathericons/feather/blob/master/LICENSE" - }, - "samples": [ - "check-circle", - "award", - "home" - ], - "height": 24, - "tags": [ - "Precise Shapes", - "Has Padding", - "Uses Stroke" - ], - "palette": false, - "hidden": true - }, - "lastModified": 1722793403, - "icons": { - "activity": { - "body": "" - }, - "airplay": { - "body": "" - }, - "alert-circle": { - "body": "" - }, - "alert-octagon": { - "body": "" - }, - "alert-triangle": { - "body": "" - }, - "align-center": { - "body": "" - }, - "align-justify": { - "body": "" - }, - "align-left": { - "body": "" - }, - "align-right": { - "body": "" - }, - "anchor": { - "body": "" - }, - "aperture": { - "body": "" - }, - "archive": { - "body": "" - }, - "arrow-down": { - "body": "" - }, - "arrow-down-circle": { - "body": "" - }, - "arrow-down-left": { - "body": "" - }, - "arrow-down-right": { - "body": "" - }, - "arrow-left": { - "body": "" - }, - "arrow-left-circle": { - "body": "" - }, - "arrow-right": { - "body": "" - }, - "arrow-right-circle": { - "body": "" - }, - "arrow-up": { - "body": "" - }, - "arrow-up-circle": { - "body": "" - }, - "arrow-up-left": { - "body": "" - }, - "arrow-up-right": { - "body": "" - }, - "at-sign": { - "body": "" - }, - "award": { - "body": "" - }, - "bar-chart": { - "body": "" - }, - "bar-chart-2": { - "body": "" - }, - "battery": { - "body": "" - }, - "battery-charging": { - "body": "" - }, - "bell": { - "body": "" - }, - "bell-off": { - "body": "" - }, - "bluetooth": { - "body": "" - }, - "bold": { - "body": "" - }, - "book": { - "body": "" - }, - "book-open": { - "body": "" - }, - "bookmark": { - "body": "" - }, - "box": { - "body": "" - }, - "briefcase": { - "body": "" - }, - "calendar": { - "body": "" - }, - "camera": { - "body": "" - }, - "camera-off": { - "body": "" - }, - "cast": { - "body": "" - }, - "check": { - "body": "" - }, - "check-circle": { - "body": "" - }, - "check-square": { - "body": "" - }, - "chevron-down": { - "body": "" - }, - "chevron-left": { - "body": "" - }, - "chevron-right": { - "body": "" - }, - "chevron-up": { - "body": "" - }, - "chevrons-down": { - "body": "" - }, - "chevrons-left": { - "body": "" - }, - "chevrons-right": { - "body": "" - }, - "chevrons-up": { - "body": "" - }, - "chrome": { - "body": "" - }, - "circle": { - "body": "" - }, - "clipboard": { - "body": "" - }, - "clock": { - "body": "" - }, - "cloud": { - "body": "" - }, - "cloud-drizzle": { - "body": "" - }, - "cloud-lightning": { - "body": "" - }, - "cloud-off": { - "body": "" - }, - "cloud-rain": { - "body": "" - }, - "cloud-snow": { - "body": "" - }, - "code": { - "body": "" - }, - "codepen": { - "body": "" - }, - "codesandbox": { - "body": "" - }, - "coffee": { - "body": "" - }, - "columns": { - "body": "" - }, - "command": { - "body": "" - }, - "compass": { - "body": "" - }, - "copy": { - "body": "" - }, - "corner-down-left": { - "body": "" - }, - "corner-down-right": { - "body": "" - }, - "corner-left-down": { - "body": "" - }, - "corner-left-up": { - "body": "" - }, - "corner-right-down": { - "body": "" - }, - "corner-right-up": { - "body": "" - }, - "corner-up-left": { - "body": "" - }, - "corner-up-right": { - "body": "" - }, - "cpu": { - "body": "" - }, - "credit-card": { - "body": "" - }, - "crop": { - "body": "" - }, - "crosshair": { - "body": "" - }, - "database": { - "body": "" - }, - "delete": { - "body": "" - }, - "disc": { - "body": "" - }, - "divide": { - "body": "" - }, - "divide-circle": { - "body": "" - }, - "divide-square": { - "body": "" - }, - "dollar-sign": { - "body": "" - }, - "download": { - "body": "" - }, - "download-cloud": { - "body": "" - }, - "dribbble": { - "body": "" - }, - "droplet": { - "body": "" - }, - "edit": { - "body": "" - }, - "edit-2": { - "body": "" - }, - "edit-3": { - "body": "" - }, - "external-link": { - "body": "" - }, - "eye": { - "body": "" - }, - "eye-off": { - "body": "" - }, - "facebook": { - "body": "" - }, - "fast-forward": { - "body": "" - }, - "feather": { - "body": "" - }, - "figma": { - "body": "" - }, - "file": { - "body": "" - }, - "file-minus": { - "body": "" - }, - "file-plus": { - "body": "" - }, - "file-text": { - "body": "" - }, - "film": { - "body": "" - }, - "filter": { - "body": "" - }, - "flag": { - "body": "" - }, - "folder": { - "body": "" - }, - "folder-minus": { - "body": "" - }, - "folder-plus": { - "body": "" - }, - "framer": { - "body": "" - }, - "frown": { - "body": "" - }, - "gift": { - "body": "" - }, - "git-branch": { - "body": "" - }, - "git-commit": { - "body": "" - }, - "git-merge": { - "body": "" - }, - "git-pull-request": { - "body": "" - }, - "github": { - "body": "" - }, - "gitlab": { - "body": "" - }, - "globe": { - "body": "" - }, - "grid": { - "body": "" - }, - "hard-drive": { - "body": "" - }, - "hash": { - "body": "" - }, - "headphones": { - "body": "" - }, - "heart": { - "body": "" - }, - "help-circle": { - "body": "" - }, - "hexagon": { - "body": "" - }, - "home": { - "body": "" - }, - "image": { - "body": "" - }, - "inbox": { - "body": "" - }, - "info": { - "body": "" - }, - "instagram": { - "body": "" - }, - "italic": { - "body": "" - }, - "key": { - "body": "" - }, - "layers": { - "body": "" - }, - "layout": { - "body": "" - }, - "life-buoy": { - "body": "" - }, - "link": { - "body": "" - }, - "link-2": { - "body": "" - }, - "linkedin": { - "body": "" - }, - "list": { - "body": "" - }, - "loader": { - "body": "" - }, - "lock": { - "body": "" - }, - "log-in": { - "body": "" - }, - "log-out": { - "body": "" - }, - "mail": { - "body": "" - }, - "map": { - "body": "" - }, - "map-pin": { - "body": "" - }, - "maximize": { - "body": "" - }, - "maximize-2": { - "body": "" - }, - "meh": { - "body": "" - }, - "menu": { - "body": "" - }, - "message-circle": { - "body": "" - }, - "message-square": { - "body": "" - }, - "mic": { - "body": "" - }, - "mic-off": { - "body": "" - }, - "minimize": { - "body": "" - }, - "minimize-2": { - "body": "" - }, - "minus": { - "body": "" - }, - "minus-circle": { - "body": "" - }, - "minus-square": { - "body": "" - }, - "monitor": { - "body": "" - }, - "moon": { - "body": "" - }, - "more-horizontal": { - "body": "" - }, - "more-vertical": { - "body": "" - }, - "mouse-pointer": { - "body": "" - }, - "move": { - "body": "" - }, - "music": { - "body": "" - }, - "navigation": { - "body": "" - }, - "navigation-2": { - "body": "" - }, - "octagon": { - "body": "" - }, - "package": { - "body": "" - }, - "paperclip": { - "body": "" - }, - "pause": { - "body": "" - }, - "pause-circle": { - "body": "" - }, - "pen-tool": { - "body": "" - }, - "percent": { - "body": "" - }, - "phone": { - "body": "" - }, - "phone-call": { - "body": "" - }, - "phone-forwarded": { - "body": "" - }, - "phone-incoming": { - "body": "" - }, - "phone-missed": { - "body": "" - }, - "phone-off": { - "body": "" - }, - "phone-outgoing": { - "body": "" - }, - "pie-chart": { - "body": "" - }, - "play": { - "body": "" - }, - "play-circle": { - "body": "" - }, - "plus": { - "body": "" - }, - "plus-circle": { - "body": "" - }, - "plus-square": { - "body": "" - }, - "pocket": { - "body": "" - }, - "power": { - "body": "" - }, - "printer": { - "body": "" - }, - "radio": { - "body": "" - }, - "refresh-ccw": { - "body": "" - }, - "refresh-cw": { - "body": "" - }, - "repeat": { - "body": "" - }, - "rewind": { - "body": "" - }, - "rotate-ccw": { - "body": "" - }, - "rotate-cw": { - "body": "" - }, - "rss": { - "body": "" - }, - "save": { - "body": "" - }, - "scissors": { - "body": "" - }, - "search": { - "body": "" - }, - "send": { - "body": "" - }, - "server": { - "body": "" - }, - "settings": { - "body": "" - }, - "share": { - "body": "" - }, - "share-2": { - "body": "" - }, - "shield": { - "body": "" - }, - "shield-off": { - "body": "" - }, - "shopping-bag": { - "body": "" - }, - "shopping-cart": { - "body": "" - }, - "shuffle": { - "body": "" - }, - "sidebar": { - "body": "" - }, - "skip-back": { - "body": "" - }, - "skip-forward": { - "body": "" - }, - "slack": { - "body": "" - }, - "slash": { - "body": "" - }, - "sliders": { - "body": "" - }, - "smartphone": { - "body": "" - }, - "smile": { - "body": "" - }, - "speaker": { - "body": "" - }, - "square": { - "body": "" - }, - "star": { - "body": "" - }, - "stop-circle": { - "body": "" - }, - "sun": { - "body": "" - }, - "sunrise": { - "body": "" - }, - "sunset": { - "body": "" - }, - "tablet": { - "body": "" - }, - "tag": { - "body": "" - }, - "target": { - "body": "" - }, - "terminal": { - "body": "" - }, - "thermometer": { - "body": "" - }, - "thumbs-down": { - "body": "" - }, - "thumbs-up": { - "body": "" - }, - "toggle-left": { - "body": "" - }, - "toggle-right": { - "body": "" - }, - "tool": { - "body": "" - }, - "trash": { - "body": "" - }, - "trash-2": { - "body": "" - }, - "trello": { - "body": "" - }, - "trending-down": { - "body": "" - }, - "trending-up": { - "body": "" - }, - "triangle": { - "body": "" - }, - "truck": { - "body": "" - }, - "tv": { - "body": "" - }, - "twitch": { - "body": "" - }, - "twitter": { - "body": "" - }, - "type": { - "body": "" - }, - "umbrella": { - "body": "" - }, - "underline": { - "body": "" - }, - "unlock": { - "body": "" - }, - "upload": { - "body": "" - }, - "upload-cloud": { - "body": "" - }, - "user": { - "body": "" - }, - "user-check": { - "body": "" - }, - "user-minus": { - "body": "" - }, - "user-plus": { - "body": "" - }, - "user-x": { - "body": "" - }, - "users": { - "body": "" - }, - "video": { - "body": "" - }, - "video-off": { - "body": "" - }, - "voicemail": { - "body": "" - }, - "volume": { - "body": "" - }, - "volume-1": { - "body": "" - }, - "volume-2": { - "body": "" - }, - "volume-x": { - "body": "" - }, - "watch": { - "body": "" - }, - "wifi": { - "body": "" - }, - "wifi-off": { - "body": "" - }, - "wind": { - "body": "" - }, - "x": { - "body": "" - }, - "x-circle": { - "body": "" - }, - "x-octagon": { - "body": "" - }, - "x-square": { - "body": "" - }, - "youtube": { - "body": "" - }, - "zap": { - "body": "" - }, - "zap-off": { - "body": "" - }, - "zoom-in": { - "body": "" - }, - "zoom-out": { - "body": "" - } - }, - "width": 24, - "height": 24 -} \ No newline at end of file diff --git a/tools/icon-generation/data/heroicons.json b/tools/icon-generation/data/heroicons.json deleted file mode 100644 index 6c03e5fbe..000000000 --- a/tools/icon-generation/data/heroicons.json +++ /dev/null @@ -1,5214 +0,0 @@ -{ - "prefix": "heroicons", - "info": { - "name": "HeroIcons", - "total": 1288, - "version": "2.2.0", - "author": { - "name": "Refactoring UI Inc", - "url": "https://github.com/tailwindlabs/heroicons" - }, - "license": { - "title": "MIT", - "spdx": "MIT", - "url": "https://github.com/tailwindlabs/heroicons/blob/master/LICENSE" - }, - "samples": [ - "camera", - "building-library", - "receipt-refund", - "bookmark", - "cloud", - "folder" - ], - "category": "UI Other / Mixed Grid", - "tags": [ - "Has Padding" - ], - "palette": false - }, - "lastModified": 1758346379, - "icons": { - "academic-cap": { - "body": "" - }, - "academic-cap-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "academic-cap-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "academic-cap-solid": { - "body": "" - }, - "adjustments-horizontal": { - "body": "" - }, - "adjustments-horizontal-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "adjustments-horizontal-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "adjustments-horizontal-solid": { - "body": "" - }, - "adjustments-vertical": { - "body": "" - }, - "adjustments-vertical-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "adjustments-vertical-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "adjustments-vertical-solid": { - "body": "" - }, - "archive-box": { - "body": "" - }, - "archive-box-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "archive-box-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "archive-box-arrow-down": { - "body": "" - }, - "archive-box-arrow-down-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "archive-box-arrow-down-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "archive-box-arrow-down-solid": { - "body": "" - }, - "archive-box-solid": { - "body": "" - }, - "archive-box-x-mark": { - "body": "" - }, - "archive-box-x-mark-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "archive-box-x-mark-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "archive-box-x-mark-solid": { - "body": "" - }, - "arrow-down": { - "body": "" - }, - "arrow-down-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-down-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-down-circle": { - "body": "" - }, - "arrow-down-circle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-down-circle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-down-circle-solid": { - "body": "" - }, - "arrow-down-left": { - "body": "" - }, - "arrow-down-left-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-down-left-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-down-left-solid": { - "body": "" - }, - "arrow-down-on-square": { - "body": "" - }, - "arrow-down-on-square-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-down-on-square-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-down-on-square-solid": { - "body": "" - }, - "arrow-down-on-square-stack": { - "body": "" - }, - "arrow-down-on-square-stack-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-down-on-square-stack-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-down-on-square-stack-solid": { - "body": "" - }, - "arrow-down-right": { - "body": "" - }, - "arrow-down-right-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-down-right-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-down-right-solid": { - "body": "" - }, - "arrow-down-solid": { - "body": "" - }, - "arrow-down-tray": { - "body": "" - }, - "arrow-down-tray-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-down-tray-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-down-tray-solid": { - "body": "" - }, - "arrow-left": { - "body": "" - }, - "arrow-left-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-left-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-left-circle": { - "body": "" - }, - "arrow-left-circle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-left-circle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-left-circle-solid": { - "body": "" - }, - "arrow-left-end-on-rectangle": { - "body": "" - }, - "arrow-left-end-on-rectangle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-left-end-on-rectangle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-left-end-on-rectangle-solid": { - "body": "" - }, - "arrow-left-on-rectangle": { - "body": "" - }, - "arrow-left-on-rectangle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-left-on-rectangle-solid": { - "body": "" - }, - "arrow-left-solid": { - "body": "" - }, - "arrow-left-start-on-rectangle": { - "body": "" - }, - "arrow-left-start-on-rectangle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-left-start-on-rectangle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-left-start-on-rectangle-solid": { - "body": "" - }, - "arrow-long-down": { - "body": "" - }, - "arrow-long-down-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-long-down-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-long-down-solid": { - "body": "" - }, - "arrow-long-left": { - "body": "" - }, - "arrow-long-left-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-long-left-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-long-left-solid": { - "body": "" - }, - "arrow-long-right": { - "body": "" - }, - "arrow-long-right-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-long-right-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-long-right-solid": { - "body": "" - }, - "arrow-long-up": { - "body": "" - }, - "arrow-long-up-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-long-up-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-long-up-solid": { - "body": "" - }, - "arrow-path": { - "body": "" - }, - "arrow-path-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-path-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-path-rounded-square": { - "body": "" - }, - "arrow-path-rounded-square-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-path-rounded-square-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-path-rounded-square-solid": { - "body": "" - }, - "arrow-path-solid": { - "body": "" - }, - "arrow-right": { - "body": "" - }, - "arrow-right-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-right-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-right-circle": { - "body": "" - }, - "arrow-right-circle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-right-circle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-right-circle-solid": { - "body": "" - }, - "arrow-right-end-on-rectangle": { - "body": "" - }, - "arrow-right-end-on-rectangle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-right-end-on-rectangle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-right-end-on-rectangle-solid": { - "body": "" - }, - "arrow-right-on-rectangle": { - "body": "" - }, - "arrow-right-on-rectangle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-right-on-rectangle-solid": { - "body": "" - }, - "arrow-right-solid": { - "body": "" - }, - "arrow-right-start-on-rectangle": { - "body": "" - }, - "arrow-right-start-on-rectangle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-right-start-on-rectangle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-right-start-on-rectangle-solid": { - "body": "" - }, - "arrow-small-down": { - "body": "" - }, - "arrow-small-down-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-small-down-solid": { - "body": "" - }, - "arrow-small-left": { - "body": "" - }, - "arrow-small-left-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-small-left-solid": { - "body": "" - }, - "arrow-small-right": { - "body": "" - }, - "arrow-small-right-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-small-right-solid": { - "body": "" - }, - "arrow-small-up": { - "body": "" - }, - "arrow-small-up-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-small-up-solid": { - "body": "" - }, - "arrow-top-right-on-square": { - "body": "" - }, - "arrow-top-right-on-square-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-top-right-on-square-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-top-right-on-square-solid": { - "body": "" - }, - "arrow-trending-down": { - "body": "" - }, - "arrow-trending-down-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-trending-down-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-trending-down-solid": { - "body": "" - }, - "arrow-trending-up": { - "body": "" - }, - "arrow-trending-up-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-trending-up-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-trending-up-solid": { - "body": "" - }, - "arrow-turn-down-left": { - "body": "" - }, - "arrow-turn-down-left-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-turn-down-left-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-turn-down-left-solid": { - "body": "" - }, - "arrow-turn-down-right": { - "body": "" - }, - "arrow-turn-down-right-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-turn-down-right-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-turn-down-right-solid": { - "body": "" - }, - "arrow-turn-left-down": { - "body": "" - }, - "arrow-turn-left-down-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-turn-left-down-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-turn-left-down-solid": { - "body": "" - }, - "arrow-turn-left-up": { - "body": "" - }, - "arrow-turn-left-up-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-turn-left-up-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-turn-left-up-solid": { - "body": "" - }, - "arrow-turn-right-down": { - "body": "" - }, - "arrow-turn-right-down-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-turn-right-down-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-turn-right-down-solid": { - "body": "" - }, - "arrow-turn-right-up": { - "body": "" - }, - "arrow-turn-right-up-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-turn-right-up-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-turn-right-up-solid": { - "body": "" - }, - "arrow-turn-up-left": { - "body": "" - }, - "arrow-turn-up-left-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-turn-up-left-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-turn-up-left-solid": { - "body": "" - }, - "arrow-turn-up-right": { - "body": "" - }, - "arrow-turn-up-right-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-turn-up-right-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-turn-up-right-solid": { - "body": "" - }, - "arrow-up": { - "body": "" - }, - "arrow-up-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-up-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-up-circle": { - "body": "" - }, - "arrow-up-circle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-up-circle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-up-circle-solid": { - "body": "" - }, - "arrow-up-left": { - "body": "" - }, - "arrow-up-left-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-up-left-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-up-left-solid": { - "body": "" - }, - "arrow-up-on-square": { - "body": "" - }, - "arrow-up-on-square-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-up-on-square-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-up-on-square-solid": { - "body": "" - }, - "arrow-up-on-square-stack": { - "body": "" - }, - "arrow-up-on-square-stack-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-up-on-square-stack-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-up-on-square-stack-solid": { - "body": "" - }, - "arrow-up-right": { - "body": "" - }, - "arrow-up-right-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-up-right-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-up-right-solid": { - "body": "" - }, - "arrow-up-solid": { - "body": "" - }, - "arrow-up-tray": { - "body": "" - }, - "arrow-up-tray-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-up-tray-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-up-tray-solid": { - "body": "" - }, - "arrow-uturn-down": { - "body": "" - }, - "arrow-uturn-down-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-uturn-down-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-uturn-down-solid": { - "body": "" - }, - "arrow-uturn-left": { - "body": "" - }, - "arrow-uturn-left-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-uturn-left-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-uturn-left-solid": { - "body": "" - }, - "arrow-uturn-right": { - "body": "" - }, - "arrow-uturn-right-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-uturn-right-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-uturn-right-solid": { - "body": "" - }, - "arrow-uturn-up": { - "body": "" - }, - "arrow-uturn-up-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrow-uturn-up-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrow-uturn-up-solid": { - "body": "" - }, - "arrows-pointing-in": { - "body": "" - }, - "arrows-pointing-in-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrows-pointing-in-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrows-pointing-in-solid": { - "body": "" - }, - "arrows-pointing-out": { - "body": "" - }, - "arrows-pointing-out-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrows-pointing-out-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrows-pointing-out-solid": { - "body": "" - }, - "arrows-right-left": { - "body": "" - }, - "arrows-right-left-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrows-right-left-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrows-right-left-solid": { - "body": "" - }, - "arrows-up-down": { - "body": "" - }, - "arrows-up-down-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "arrows-up-down-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "arrows-up-down-solid": { - "body": "" - }, - "at-symbol": { - "body": "" - }, - "at-symbol-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "at-symbol-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "at-symbol-solid": { - "body": "" - }, - "backspace": { - "body": "" - }, - "backspace-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "backspace-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "backspace-solid": { - "body": "" - }, - "backward": { - "body": "" - }, - "backward-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "backward-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "backward-solid": { - "body": "" - }, - "banknotes": { - "body": "" - }, - "banknotes-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "banknotes-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "banknotes-solid": { - "body": "" - }, - "bars-2": { - "body": "" - }, - "bars-2-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bars-2-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bars-2-solid": { - "body": "" - }, - "bars-3": { - "body": "" - }, - "bars-3-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bars-3-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bars-3-bottom-left": { - "body": "" - }, - "bars-3-bottom-left-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bars-3-bottom-left-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bars-3-bottom-left-solid": { - "body": "" - }, - "bars-3-bottom-right": { - "body": "" - }, - "bars-3-bottom-right-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bars-3-bottom-right-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bars-3-bottom-right-solid": { - "body": "" - }, - "bars-3-center-left": { - "body": "" - }, - "bars-3-center-left-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bars-3-center-left-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bars-3-center-left-solid": { - "body": "" - }, - "bars-3-solid": { - "body": "" - }, - "bars-4": { - "body": "" - }, - "bars-4-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bars-4-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bars-4-solid": { - "body": "" - }, - "bars-arrow-down": { - "body": "" - }, - "bars-arrow-down-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bars-arrow-down-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bars-arrow-down-solid": { - "body": "" - }, - "bars-arrow-up": { - "body": "" - }, - "bars-arrow-up-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bars-arrow-up-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bars-arrow-up-solid": { - "body": "" - }, - "battery-0": { - "body": "" - }, - "battery-0-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "battery-0-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "battery-0-solid": { - "body": "" - }, - "battery-100": { - "body": "" - }, - "battery-100-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "battery-100-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "battery-100-solid": { - "body": "" - }, - "battery-50": { - "body": "" - }, - "battery-50-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "battery-50-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "battery-50-solid": { - "body": "" - }, - "beaker": { - "body": "" - }, - "beaker-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "beaker-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "beaker-solid": { - "body": "" - }, - "bell": { - "body": "" - }, - "bell-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bell-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bell-alert": { - "body": "" - }, - "bell-alert-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bell-alert-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bell-alert-solid": { - "body": "" - }, - "bell-slash": { - "body": "" - }, - "bell-slash-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bell-slash-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bell-slash-solid": { - "body": "" - }, - "bell-snooze": { - "body": "" - }, - "bell-snooze-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bell-snooze-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bell-snooze-solid": { - "body": "" - }, - "bell-solid": { - "body": "" - }, - "bold": { - "body": "" - }, - "bold-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bold-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bold-solid": { - "body": "" - }, - "bolt": { - "body": "" - }, - "bolt-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bolt-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bolt-slash": { - "body": "" - }, - "bolt-slash-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bolt-slash-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bolt-slash-solid": { - "body": "" - }, - "bolt-solid": { - "body": "" - }, - "book-open": { - "body": "" - }, - "book-open-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "book-open-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "book-open-solid": { - "body": "" - }, - "bookmark": { - "body": "" - }, - "bookmark-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bookmark-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bookmark-slash": { - "body": "" - }, - "bookmark-slash-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bookmark-slash-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bookmark-slash-solid": { - "body": "" - }, - "bookmark-solid": { - "body": "" - }, - "bookmark-square": { - "body": "" - }, - "bookmark-square-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bookmark-square-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bookmark-square-solid": { - "body": "" - }, - "briefcase": { - "body": "" - }, - "briefcase-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "briefcase-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "briefcase-solid": { - "body": "" - }, - "bug-ant": { - "body": "" - }, - "bug-ant-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "bug-ant-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "bug-ant-solid": { - "body": "" - }, - "building-library": { - "body": "" - }, - "building-library-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "building-library-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "building-library-solid": { - "body": "" - }, - "building-office": { - "body": "" - }, - "building-office-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "building-office-2": { - "body": "" - }, - "building-office-2-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "building-office-2-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "building-office-2-solid": { - "body": "" - }, - "building-office-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "building-office-solid": { - "body": "" - }, - "building-storefront": { - "body": "" - }, - "building-storefront-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "building-storefront-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "building-storefront-solid": { - "body": "" - }, - "cake": { - "body": "" - }, - "cake-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "cake-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "cake-solid": { - "body": "" - }, - "calculator": { - "body": "" - }, - "calculator-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "calculator-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "calculator-solid": { - "body": "" - }, - "calendar": { - "body": "" - }, - "calendar-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "calendar-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "calendar-date-range": { - "body": "" - }, - "calendar-date-range-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "calendar-date-range-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "calendar-date-range-solid": { - "body": "" - }, - "calendar-days": { - "body": "" - }, - "calendar-days-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "calendar-days-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "calendar-days-solid": { - "body": "" - }, - "calendar-solid": { - "body": "" - }, - "camera": { - "body": "" - }, - "camera-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "camera-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "camera-solid": { - "body": "" - }, - "chart-bar": { - "body": "" - }, - "chart-bar-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chart-bar-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chart-bar-solid": { - "body": "" - }, - "chart-bar-square": { - "body": "" - }, - "chart-bar-square-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chart-bar-square-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chart-bar-square-solid": { - "body": "" - }, - "chart-pie": { - "body": "" - }, - "chart-pie-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chart-pie-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chart-pie-solid": { - "body": "" - }, - "chat-bubble-bottom-center": { - "body": "" - }, - "chat-bubble-bottom-center-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chat-bubble-bottom-center-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chat-bubble-bottom-center-solid": { - "body": "" - }, - "chat-bubble-bottom-center-text": { - "body": "" - }, - "chat-bubble-bottom-center-text-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chat-bubble-bottom-center-text-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chat-bubble-bottom-center-text-solid": { - "body": "" - }, - "chat-bubble-left": { - "body": "" - }, - "chat-bubble-left-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chat-bubble-left-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chat-bubble-left-ellipsis": { - "body": "" - }, - "chat-bubble-left-ellipsis-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chat-bubble-left-ellipsis-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chat-bubble-left-ellipsis-solid": { - "body": "" - }, - "chat-bubble-left-right": { - "body": "" - }, - "chat-bubble-left-right-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chat-bubble-left-right-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chat-bubble-left-right-solid": { - "body": "" - }, - "chat-bubble-left-solid": { - "body": "" - }, - "chat-bubble-oval-left": { - "body": "" - }, - "chat-bubble-oval-left-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chat-bubble-oval-left-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chat-bubble-oval-left-ellipsis": { - "body": "" - }, - "chat-bubble-oval-left-ellipsis-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chat-bubble-oval-left-ellipsis-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chat-bubble-oval-left-ellipsis-solid": { - "body": "" - }, - "chat-bubble-oval-left-solid": { - "body": "" - }, - "check": { - "body": "" - }, - "check-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "check-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "check-badge": { - "body": "" - }, - "check-badge-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "check-badge-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "check-badge-solid": { - "body": "" - }, - "check-circle": { - "body": "" - }, - "check-circle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "check-circle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "check-circle-solid": { - "body": "" - }, - "check-solid": { - "body": "" - }, - "chevron-double-down": { - "body": "" - }, - "chevron-double-down-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chevron-double-down-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chevron-double-down-solid": { - "body": "" - }, - "chevron-double-left": { - "body": "" - }, - "chevron-double-left-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chevron-double-left-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chevron-double-left-solid": { - "body": "" - }, - "chevron-double-right": { - "body": "" - }, - "chevron-double-right-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chevron-double-right-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chevron-double-right-solid": { - "body": "" - }, - "chevron-double-up": { - "body": "" - }, - "chevron-double-up-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chevron-double-up-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chevron-double-up-solid": { - "body": "" - }, - "chevron-down": { - "body": "" - }, - "chevron-down-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chevron-down-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chevron-down-solid": { - "body": "" - }, - "chevron-left": { - "body": "" - }, - "chevron-left-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chevron-left-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chevron-left-solid": { - "body": "" - }, - "chevron-right": { - "body": "" - }, - "chevron-right-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chevron-right-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chevron-right-solid": { - "body": "" - }, - "chevron-up": { - "body": "" - }, - "chevron-up-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chevron-up-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chevron-up-down": { - "body": "" - }, - "chevron-up-down-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "chevron-up-down-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "chevron-up-down-solid": { - "body": "" - }, - "chevron-up-solid": { - "body": "" - }, - "circle-stack": { - "body": "" - }, - "circle-stack-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "circle-stack-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "circle-stack-solid": { - "body": "" - }, - "clipboard": { - "body": "" - }, - "clipboard-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "clipboard-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "clipboard-document": { - "body": "" - }, - "clipboard-document-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "clipboard-document-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "clipboard-document-check": { - "body": "" - }, - "clipboard-document-check-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "clipboard-document-check-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "clipboard-document-check-solid": { - "body": "" - }, - "clipboard-document-list": { - "body": "" - }, - "clipboard-document-list-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "clipboard-document-list-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "clipboard-document-list-solid": { - "body": "" - }, - "clipboard-document-solid": { - "body": "" - }, - "clipboard-solid": { - "body": "" - }, - "clock": { - "body": "" - }, - "clock-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "clock-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "clock-solid": { - "body": "" - }, - "cloud": { - "body": "" - }, - "cloud-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "cloud-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "cloud-arrow-down": { - "body": "" - }, - "cloud-arrow-down-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "cloud-arrow-down-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "cloud-arrow-down-solid": { - "body": "" - }, - "cloud-arrow-up": { - "body": "" - }, - "cloud-arrow-up-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "cloud-arrow-up-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "cloud-arrow-up-solid": { - "body": "" - }, - "cloud-solid": { - "body": "" - }, - "code-bracket": { - "body": "" - }, - "code-bracket-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "code-bracket-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "code-bracket-solid": { - "body": "" - }, - "code-bracket-square": { - "body": "" - }, - "code-bracket-square-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "code-bracket-square-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "code-bracket-square-solid": { - "body": "" - }, - "cog": { - "body": "" - }, - "cog-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "cog-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "cog-6-tooth": { - "body": "" - }, - "cog-6-tooth-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "cog-6-tooth-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "cog-6-tooth-solid": { - "body": "" - }, - "cog-8-tooth": { - "body": "" - }, - "cog-8-tooth-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "cog-8-tooth-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "cog-8-tooth-solid": { - "body": "" - }, - "cog-solid": { - "body": "" - }, - "command-line": { - "body": "" - }, - "command-line-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "command-line-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "command-line-solid": { - "body": "" - }, - "computer-desktop": { - "body": "" - }, - "computer-desktop-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "computer-desktop-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "computer-desktop-solid": { - "body": "" - }, - "cpu-chip": { - "body": "" - }, - "cpu-chip-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "cpu-chip-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "cpu-chip-solid": { - "body": "" - }, - "credit-card": { - "body": "" - }, - "credit-card-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "credit-card-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "credit-card-solid": { - "body": "" - }, - "cube": { - "body": "" - }, - "cube-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "cube-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "cube-solid": { - "body": "" - }, - "cube-transparent": { - "body": "" - }, - "cube-transparent-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "cube-transparent-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "cube-transparent-solid": { - "body": "" - }, - "currency-bangladeshi": { - "body": "" - }, - "currency-bangladeshi-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "currency-bangladeshi-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "currency-bangladeshi-solid": { - "body": "" - }, - "currency-dollar": { - "body": "" - }, - "currency-dollar-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "currency-dollar-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "currency-dollar-solid": { - "body": "" - }, - "currency-euro": { - "body": "" - }, - "currency-euro-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "currency-euro-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "currency-euro-solid": { - "body": "" - }, - "currency-pound": { - "body": "" - }, - "currency-pound-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "currency-pound-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "currency-pound-solid": { - "body": "" - }, - "currency-rupee": { - "body": "" - }, - "currency-rupee-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "currency-rupee-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "currency-rupee-solid": { - "body": "" - }, - "currency-yen": { - "body": "" - }, - "currency-yen-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "currency-yen-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "currency-yen-solid": { - "body": "" - }, - "cursor-arrow-rays": { - "body": "" - }, - "cursor-arrow-rays-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "cursor-arrow-rays-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "cursor-arrow-rays-solid": { - "body": "" - }, - "cursor-arrow-ripple": { - "body": "" - }, - "cursor-arrow-ripple-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "cursor-arrow-ripple-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "cursor-arrow-ripple-solid": { - "body": "" - }, - "device-phone-mobile": { - "body": "" - }, - "device-phone-mobile-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "device-phone-mobile-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "device-phone-mobile-solid": { - "body": "" - }, - "device-tablet": { - "body": "" - }, - "device-tablet-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "device-tablet-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "device-tablet-solid": { - "body": "" - }, - "divide": { - "body": "" - }, - "divide-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "divide-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "divide-solid": { - "body": "" - }, - "document": { - "body": "" - }, - "document-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "document-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "document-arrow-down": { - "body": "" - }, - "document-arrow-down-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "document-arrow-down-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "document-arrow-down-solid": { - "body": "" - }, - "document-arrow-up": { - "body": "" - }, - "document-arrow-up-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "document-arrow-up-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "document-arrow-up-solid": { - "body": "" - }, - "document-chart-bar": { - "body": "" - }, - "document-chart-bar-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "document-chart-bar-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "document-chart-bar-solid": { - "body": "" - }, - "document-check": { - "body": "" - }, - "document-check-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "document-check-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "document-check-solid": { - "body": "" - }, - "document-currency-bangladeshi": { - "body": "" - }, - "document-currency-bangladeshi-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "document-currency-bangladeshi-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "document-currency-bangladeshi-solid": { - "body": "" - }, - "document-currency-dollar": { - "body": "" - }, - "document-currency-dollar-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "document-currency-dollar-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "document-currency-dollar-solid": { - "body": "" - }, - "document-currency-euro": { - "body": "" - }, - "document-currency-euro-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "document-currency-euro-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "document-currency-euro-solid": { - "body": "" - }, - "document-currency-pound": { - "body": "" - }, - "document-currency-pound-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "document-currency-pound-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "document-currency-pound-solid": { - "body": "" - }, - "document-currency-rupee": { - "body": "" - }, - "document-currency-rupee-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "document-currency-rupee-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "document-currency-rupee-solid": { - "body": "" - }, - "document-currency-yen": { - "body": "" - }, - "document-currency-yen-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "document-currency-yen-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "document-currency-yen-solid": { - "body": "" - }, - "document-duplicate": { - "body": "" - }, - "document-duplicate-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "document-duplicate-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "document-duplicate-solid": { - "body": "" - }, - "document-magnifying-glass": { - "body": "" - }, - "document-magnifying-glass-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "document-magnifying-glass-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "document-magnifying-glass-solid": { - "body": "" - }, - "document-minus": { - "body": "" - }, - "document-minus-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "document-minus-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "document-minus-solid": { - "body": "" - }, - "document-plus": { - "body": "" - }, - "document-plus-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "document-plus-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "document-plus-solid": { - "body": "" - }, - "document-solid": { - "body": "" - }, - "document-text": { - "body": "" - }, - "document-text-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "document-text-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "document-text-solid": { - "body": "" - }, - "ellipsis-horizontal": { - "body": "" - }, - "ellipsis-horizontal-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "ellipsis-horizontal-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "ellipsis-horizontal-circle": { - "body": "" - }, - "ellipsis-horizontal-circle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "ellipsis-horizontal-circle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "ellipsis-horizontal-circle-solid": { - "body": "" - }, - "ellipsis-horizontal-solid": { - "body": "" - }, - "ellipsis-vertical": { - "body": "" - }, - "ellipsis-vertical-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "ellipsis-vertical-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "ellipsis-vertical-solid": { - "body": "" - }, - "envelope": { - "body": "" - }, - "envelope-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "envelope-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "envelope-open": { - "body": "" - }, - "envelope-open-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "envelope-open-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "envelope-open-solid": { - "body": "" - }, - "envelope-solid": { - "body": "" - }, - "equals": { - "body": "" - }, - "equals-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "equals-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "equals-solid": { - "body": "" - }, - "exclamation-circle": { - "body": "" - }, - "exclamation-circle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "exclamation-circle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "exclamation-circle-solid": { - "body": "" - }, - "exclamation-triangle": { - "body": "" - }, - "exclamation-triangle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "exclamation-triangle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "exclamation-triangle-solid": { - "body": "" - }, - "eye": { - "body": "" - }, - "eye-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "eye-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "eye-dropper": { - "body": "" - }, - "eye-dropper-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "eye-dropper-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "eye-dropper-solid": { - "body": "" - }, - "eye-slash": { - "body": "" - }, - "eye-slash-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "eye-slash-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "eye-slash-solid": { - "body": "" - }, - "eye-solid": { - "body": "" - }, - "face-frown": { - "body": "" - }, - "face-frown-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "face-frown-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "face-frown-solid": { - "body": "" - }, - "face-smile": { - "body": "" - }, - "face-smile-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "face-smile-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "face-smile-solid": { - "body": "" - }, - "film": { - "body": "" - }, - "film-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "film-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "film-solid": { - "body": "" - }, - "finger-print": { - "body": "" - }, - "finger-print-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "finger-print-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "finger-print-solid": { - "body": "" - }, - "fire": { - "body": "" - }, - "fire-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "fire-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "fire-solid": { - "body": "" - }, - "flag": { - "body": "" - }, - "flag-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "flag-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "flag-solid": { - "body": "" - }, - "folder": { - "body": "" - }, - "folder-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "folder-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "folder-arrow-down": { - "body": "" - }, - "folder-arrow-down-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "folder-arrow-down-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "folder-arrow-down-solid": { - "body": "" - }, - "folder-minus": { - "body": "" - }, - "folder-minus-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "folder-minus-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "folder-minus-solid": { - "body": "" - }, - "folder-open": { - "body": "" - }, - "folder-open-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "folder-open-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "folder-open-solid": { - "body": "" - }, - "folder-plus": { - "body": "" - }, - "folder-plus-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "folder-plus-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "folder-plus-solid": { - "body": "" - }, - "folder-solid": { - "body": "" - }, - "forward": { - "body": "" - }, - "forward-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "forward-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "forward-solid": { - "body": "" - }, - "funnel": { - "body": "" - }, - "funnel-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "funnel-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "funnel-solid": { - "body": "" - }, - "gif": { - "body": "" - }, - "gif-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "gif-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "gif-solid": { - "body": "" - }, - "gift": { - "body": "" - }, - "gift-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "gift-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "gift-solid": { - "body": "" - }, - "gift-top": { - "body": "" - }, - "gift-top-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "gift-top-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "gift-top-solid": { - "body": "" - }, - "globe-alt": { - "body": "" - }, - "globe-alt-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "globe-alt-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "globe-alt-solid": { - "body": "" - }, - "globe-americas": { - "body": "" - }, - "globe-americas-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "globe-americas-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "globe-americas-solid": { - "body": "" - }, - "globe-asia-australia": { - "body": "" - }, - "globe-asia-australia-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "globe-asia-australia-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "globe-asia-australia-solid": { - "body": "" - }, - "globe-europe-africa": { - "body": "" - }, - "globe-europe-africa-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "globe-europe-africa-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "globe-europe-africa-solid": { - "body": "" - }, - "h1": { - "body": "" - }, - "h1-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "h1-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "h1-solid": { - "body": "" - }, - "h2": { - "body": "" - }, - "h2-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "h2-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "h2-solid": { - "body": "" - }, - "h3": { - "body": "" - }, - "h3-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "h3-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "h3-solid": { - "body": "" - }, - "hand-raised": { - "body": "" - }, - "hand-raised-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "hand-raised-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "hand-raised-solid": { - "body": "" - }, - "hand-thumb-down": { - "body": "" - }, - "hand-thumb-down-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "hand-thumb-down-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "hand-thumb-down-solid": { - "body": "" - }, - "hand-thumb-up": { - "body": "" - }, - "hand-thumb-up-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "hand-thumb-up-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "hand-thumb-up-solid": { - "body": "" - }, - "hashtag": { - "body": "" - }, - "hashtag-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "hashtag-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "hashtag-solid": { - "body": "" - }, - "heart": { - "body": "" - }, - "heart-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "heart-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "heart-solid": { - "body": "" - }, - "home": { - "body": "" - }, - "home-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "home-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "home-modern": { - "body": "" - }, - "home-modern-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "home-modern-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "home-modern-solid": { - "body": "" - }, - "home-solid": { - "body": "" - }, - "identification": { - "body": "" - }, - "identification-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "identification-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "identification-solid": { - "body": "" - }, - "inbox": { - "body": "" - }, - "inbox-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "inbox-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "inbox-arrow-down": { - "body": "" - }, - "inbox-arrow-down-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "inbox-arrow-down-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "inbox-arrow-down-solid": { - "body": "" - }, - "inbox-solid": { - "body": "" - }, - "inbox-stack": { - "body": "" - }, - "inbox-stack-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "inbox-stack-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "inbox-stack-solid": { - "body": "" - }, - "information-circle": { - "body": "" - }, - "information-circle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "information-circle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "information-circle-solid": { - "body": "" - }, - "italic": { - "body": "" - }, - "italic-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "italic-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "italic-solid": { - "body": "" - }, - "key": { - "body": "" - }, - "key-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "key-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "key-solid": { - "body": "" - }, - "language": { - "body": "" - }, - "language-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "language-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "language-solid": { - "body": "" - }, - "lifebuoy": { - "body": "" - }, - "lifebuoy-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "lifebuoy-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "lifebuoy-solid": { - "body": "" - }, - "light-bulb": { - "body": "" - }, - "light-bulb-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "light-bulb-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "light-bulb-solid": { - "body": "" - }, - "link": { - "body": "" - }, - "link-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "link-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "link-slash": { - "body": "" - }, - "link-slash-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "link-slash-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "link-slash-solid": { - "body": "" - }, - "link-solid": { - "body": "" - }, - "list-bullet": { - "body": "" - }, - "list-bullet-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "list-bullet-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "list-bullet-solid": { - "body": "" - }, - "lock-closed": { - "body": "" - }, - "lock-closed-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "lock-closed-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "lock-closed-solid": { - "body": "" - }, - "lock-open": { - "body": "" - }, - "lock-open-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "lock-open-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "lock-open-solid": { - "body": "" - }, - "magnifying-glass": { - "body": "" - }, - "magnifying-glass-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "magnifying-glass-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "magnifying-glass-circle": { - "body": "" - }, - "magnifying-glass-circle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "magnifying-glass-circle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "magnifying-glass-circle-solid": { - "body": "" - }, - "magnifying-glass-minus": { - "body": "" - }, - "magnifying-glass-minus-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "magnifying-glass-minus-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "magnifying-glass-minus-solid": { - "body": "" - }, - "magnifying-glass-plus": { - "body": "" - }, - "magnifying-glass-plus-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "magnifying-glass-plus-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "magnifying-glass-plus-solid": { - "body": "" - }, - "magnifying-glass-solid": { - "body": "" - }, - "map": { - "body": "" - }, - "map-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "map-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "map-pin": { - "body": "" - }, - "map-pin-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "map-pin-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "map-pin-solid": { - "body": "" - }, - "map-solid": { - "body": "" - }, - "megaphone": { - "body": "" - }, - "megaphone-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "megaphone-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "megaphone-solid": { - "body": "" - }, - "microphone": { - "body": "" - }, - "microphone-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "microphone-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "microphone-solid": { - "body": "" - }, - "minus": { - "body": "" - }, - "minus-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "minus-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "minus-circle": { - "body": "" - }, - "minus-circle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "minus-circle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "minus-circle-solid": { - "body": "" - }, - "minus-small": { - "body": "" - }, - "minus-small-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "minus-small-solid": { - "body": "" - }, - "minus-solid": { - "body": "" - }, - "moon": { - "body": "" - }, - "moon-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "moon-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "moon-solid": { - "body": "" - }, - "musical-note": { - "body": "" - }, - "musical-note-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "musical-note-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "musical-note-solid": { - "body": "" - }, - "newspaper": { - "body": "" - }, - "newspaper-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "newspaper-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "newspaper-solid": { - "body": "" - }, - "no-symbol": { - "body": "" - }, - "no-symbol-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "no-symbol-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "no-symbol-solid": { - "body": "" - }, - "numbered-list": { - "body": "" - }, - "numbered-list-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "numbered-list-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "numbered-list-solid": { - "body": "" - }, - "paint-brush": { - "body": "" - }, - "paint-brush-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "paint-brush-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "paint-brush-solid": { - "body": "" - }, - "paper-airplane": { - "body": "" - }, - "paper-airplane-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "paper-airplane-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "paper-airplane-solid": { - "body": "" - }, - "paper-clip": { - "body": "" - }, - "paper-clip-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "paper-clip-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "paper-clip-solid": { - "body": "" - }, - "pause": { - "body": "" - }, - "pause-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "pause-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "pause-circle": { - "body": "" - }, - "pause-circle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "pause-circle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "pause-circle-solid": { - "body": "" - }, - "pause-solid": { - "body": "" - }, - "pencil": { - "body": "" - }, - "pencil-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "pencil-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "pencil-solid": { - "body": "" - }, - "pencil-square": { - "body": "" - }, - "pencil-square-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "pencil-square-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "pencil-square-solid": { - "body": "" - }, - "percent-badge": { - "body": "" - }, - "percent-badge-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "percent-badge-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "percent-badge-solid": { - "body": "" - }, - "phone": { - "body": "" - }, - "phone-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "phone-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "phone-arrow-down-left": { - "body": "" - }, - "phone-arrow-down-left-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "phone-arrow-down-left-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "phone-arrow-down-left-solid": { - "body": "" - }, - "phone-arrow-up-right": { - "body": "" - }, - "phone-arrow-up-right-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "phone-arrow-up-right-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "phone-arrow-up-right-solid": { - "body": "" - }, - "phone-solid": { - "body": "" - }, - "phone-x-mark": { - "body": "" - }, - "phone-x-mark-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "phone-x-mark-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "phone-x-mark-solid": { - "body": "" - }, - "photo": { - "body": "" - }, - "photo-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "photo-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "photo-solid": { - "body": "" - }, - "play": { - "body": "" - }, - "play-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "play-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "play-circle": { - "body": "" - }, - "play-circle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "play-circle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "play-circle-solid": { - "body": "" - }, - "play-pause": { - "body": "" - }, - "play-pause-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "play-pause-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "play-pause-solid": { - "body": "" - }, - "play-solid": { - "body": "" - }, - "plus": { - "body": "" - }, - "plus-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "plus-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "plus-circle": { - "body": "" - }, - "plus-circle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "plus-circle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "plus-circle-solid": { - "body": "" - }, - "plus-small": { - "body": "" - }, - "plus-small-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "plus-small-solid": { - "body": "" - }, - "plus-solid": { - "body": "" - }, - "power": { - "body": "" - }, - "power-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "power-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "power-solid": { - "body": "" - }, - "presentation-chart-bar": { - "body": "" - }, - "presentation-chart-bar-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "presentation-chart-bar-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "presentation-chart-bar-solid": { - "body": "" - }, - "presentation-chart-line": { - "body": "" - }, - "presentation-chart-line-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "presentation-chart-line-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "presentation-chart-line-solid": { - "body": "" - }, - "printer": { - "body": "" - }, - "printer-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "printer-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "printer-solid": { - "body": "" - }, - "puzzle-piece": { - "body": "" - }, - "puzzle-piece-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "puzzle-piece-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "puzzle-piece-solid": { - "body": "" - }, - "qr-code": { - "body": "" - }, - "qr-code-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "qr-code-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "qr-code-solid": { - "body": "" - }, - "question-mark-circle": { - "body": "" - }, - "question-mark-circle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "question-mark-circle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "question-mark-circle-solid": { - "body": "" - }, - "queue-list": { - "body": "" - }, - "queue-list-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "queue-list-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "queue-list-solid": { - "body": "" - }, - "radio": { - "body": "" - }, - "radio-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "radio-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "radio-solid": { - "body": "" - }, - "receipt-percent": { - "body": "" - }, - "receipt-percent-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "receipt-percent-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "receipt-percent-solid": { - "body": "" - }, - "receipt-refund": { - "body": "" - }, - "receipt-refund-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "receipt-refund-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "receipt-refund-solid": { - "body": "" - }, - "rectangle-group": { - "body": "" - }, - "rectangle-group-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "rectangle-group-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "rectangle-group-solid": { - "body": "" - }, - "rectangle-stack": { - "body": "" - }, - "rectangle-stack-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "rectangle-stack-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "rectangle-stack-solid": { - "body": "" - }, - "rocket-launch": { - "body": "" - }, - "rocket-launch-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "rocket-launch-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "rocket-launch-solid": { - "body": "" - }, - "rss": { - "body": "" - }, - "rss-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "rss-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "rss-solid": { - "body": "" - }, - "scale": { - "body": "" - }, - "scale-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "scale-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "scale-solid": { - "body": "" - }, - "scissors": { - "body": "" - }, - "scissors-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "scissors-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "scissors-solid": { - "body": "" - }, - "server": { - "body": "" - }, - "server-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "server-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "server-solid": { - "body": "" - }, - "server-stack": { - "body": "" - }, - "server-stack-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "server-stack-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "server-stack-solid": { - "body": "" - }, - "share": { - "body": "" - }, - "share-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "share-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "share-solid": { - "body": "" - }, - "shield-check": { - "body": "" - }, - "shield-check-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "shield-check-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "shield-check-solid": { - "body": "" - }, - "shield-exclamation": { - "body": "" - }, - "shield-exclamation-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "shield-exclamation-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "shield-exclamation-solid": { - "body": "" - }, - "shopping-bag": { - "body": "" - }, - "shopping-bag-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "shopping-bag-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "shopping-bag-solid": { - "body": "" - }, - "shopping-cart": { - "body": "" - }, - "shopping-cart-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "shopping-cart-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "shopping-cart-solid": { - "body": "" - }, - "signal": { - "body": "" - }, - "signal-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "signal-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "signal-slash": { - "body": "" - }, - "signal-slash-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "signal-slash-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "signal-slash-solid": { - "body": "" - }, - "signal-solid": { - "body": "" - }, - "slash": { - "body": "" - }, - "slash-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "slash-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "slash-solid": { - "body": "" - }, - "sparkles": { - "body": "" - }, - "sparkles-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "sparkles-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "sparkles-solid": { - "body": "" - }, - "speaker-wave": { - "body": "" - }, - "speaker-wave-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "speaker-wave-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "speaker-wave-solid": { - "body": "" - }, - "speaker-x-mark": { - "body": "" - }, - "speaker-x-mark-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "speaker-x-mark-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "speaker-x-mark-solid": { - "body": "" - }, - "square-2-stack": { - "body": "" - }, - "square-2-stack-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "square-2-stack-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "square-2-stack-solid": { - "body": "" - }, - "square-3-stack-3d": { - "body": "" - }, - "square-3-stack-3d-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "square-3-stack-3d-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "square-3-stack-3d-solid": { - "body": "" - }, - "squares-2x2": { - "body": "" - }, - "squares-2x2-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "squares-2x2-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "squares-2x2-solid": { - "body": "" - }, - "squares-plus": { - "body": "" - }, - "squares-plus-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "squares-plus-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "squares-plus-solid": { - "body": "" - }, - "star": { - "body": "" - }, - "star-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "star-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "star-solid": { - "body": "" - }, - "stop": { - "body": "" - }, - "stop-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "stop-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "stop-circle": { - "body": "" - }, - "stop-circle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "stop-circle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "stop-circle-solid": { - "body": "" - }, - "stop-solid": { - "body": "" - }, - "strikethrough": { - "body": "" - }, - "strikethrough-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "strikethrough-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "strikethrough-solid": { - "body": "" - }, - "sun": { - "body": "" - }, - "sun-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "sun-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "sun-solid": { - "body": "" - }, - "swatch": { - "body": "" - }, - "swatch-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "swatch-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "swatch-solid": { - "body": "" - }, - "table-cells": { - "body": "" - }, - "table-cells-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "table-cells-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "table-cells-solid": { - "body": "" - }, - "tag": { - "body": "" - }, - "tag-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "tag-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "tag-solid": { - "body": "" - }, - "ticket": { - "body": "" - }, - "ticket-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "ticket-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "ticket-solid": { - "body": "" - }, - "trash": { - "body": "" - }, - "trash-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "trash-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "trash-solid": { - "body": "" - }, - "trophy": { - "body": "" - }, - "trophy-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "trophy-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "trophy-solid": { - "body": "" - }, - "truck": { - "body": "" - }, - "truck-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "truck-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "truck-solid": { - "body": "" - }, - "tv": { - "body": "" - }, - "tv-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "tv-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "tv-solid": { - "body": "" - }, - "underline": { - "body": "" - }, - "underline-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "underline-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "underline-solid": { - "body": "" - }, - "user": { - "body": "" - }, - "user-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "user-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "user-circle": { - "body": "" - }, - "user-circle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "user-circle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "user-circle-solid": { - "body": "" - }, - "user-group": { - "body": "" - }, - "user-group-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "user-group-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "user-group-solid": { - "body": "" - }, - "user-minus": { - "body": "" - }, - "user-minus-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "user-minus-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "user-minus-solid": { - "body": "" - }, - "user-plus": { - "body": "" - }, - "user-plus-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "user-plus-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "user-plus-solid": { - "body": "" - }, - "user-solid": { - "body": "" - }, - "users": { - "body": "" - }, - "users-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "users-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "users-solid": { - "body": "" - }, - "variable": { - "body": "" - }, - "variable-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "variable-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "variable-solid": { - "body": "" - }, - "video-camera": { - "body": "" - }, - "video-camera-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "video-camera-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "video-camera-slash": { - "body": "" - }, - "video-camera-slash-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "video-camera-slash-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "video-camera-slash-solid": { - "body": "" - }, - "video-camera-solid": { - "body": "" - }, - "view-columns": { - "body": "" - }, - "view-columns-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "view-columns-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "view-columns-solid": { - "body": "" - }, - "viewfinder-circle": { - "body": "" - }, - "viewfinder-circle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "viewfinder-circle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "viewfinder-circle-solid": { - "body": "" - }, - "wallet": { - "body": "" - }, - "wallet-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "wallet-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "wallet-solid": { - "body": "" - }, - "wifi": { - "body": "" - }, - "wifi-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "wifi-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "wifi-solid": { - "body": "" - }, - "window": { - "body": "" - }, - "window-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "window-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "window-solid": { - "body": "" - }, - "wrench": { - "body": "" - }, - "wrench-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "wrench-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "wrench-screwdriver": { - "body": "" - }, - "wrench-screwdriver-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "wrench-screwdriver-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "wrench-screwdriver-solid": { - "body": "" - }, - "wrench-solid": { - "body": "" - }, - "x-circle": { - "body": "" - }, - "x-circle-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "x-circle-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "x-circle-solid": { - "body": "" - }, - "x-mark": { - "body": "" - }, - "x-mark-16-solid": { - "body": "", - "width": 16, - "height": 16 - }, - "x-mark-20-solid": { - "body": "", - "width": 20, - "height": 20 - }, - "x-mark-solid": { - "body": "" - } - }, - "aliases": { - "code-solid": { - "parent": "code-bracket-solid" - }, - "code-square-solid": { - "parent": "code-bracket-square-solid" - }, - "exclaimation-circle": { - "parent": "exclamation-circle" - }, - "exclaimation-circle-solid": { - "parent": "exclamation-circle-solid" - }, - "exclaimation-triangle": { - "parent": "exclamation-triangle" - }, - "exclaimation-triangle-solid": { - "parent": "exclamation-triangle-solid" - }, - "viewfinder-dot": { - "parent": "viewfinder-circle" - }, - "viewfinder-dot-20-solid": { - "parent": "viewfinder-circle-20-solid" - }, - "viewfinder-dot-solid": { - "parent": "viewfinder-circle-solid" - } - }, - "suffixes": { - "": "Outline 24x24", - "solid": "Solid 24x24", - "20-solid": "Solid 20x20", - "16-solid": "Solid 16x16" - }, - "width": 24, - "height": 24 -} \ No newline at end of file diff --git a/tools/icon-generation/data/lucide.json b/tools/icon-generation/data/lucide.json deleted file mode 100644 index 28b1c0313..000000000 --- a/tools/icon-generation/data/lucide.json +++ /dev/null @@ -1,5697 +0,0 @@ -{ - "prefix": "lucide", - "info": { - "name": "Lucide", - "total": 1640, - "author": { - "name": "Lucide Contributors", - "url": "https://github.com/lucide-icons/lucide" - }, - "license": { - "title": "ISC", - "spdx": "ISC", - "url": "https://github.com/lucide-icons/lucide/blob/main/LICENSE" - }, - "samples": [ - "circle-check", - "award", - "house", - "check", - "mountain", - "chevron-up" - ], - "height": 24, - "category": "UI 24px", - "tags": [ - "Precise Shapes", - "Has Padding", - "Uses Stroke" - ], - "palette": false - }, - "lastModified": 1761371515, - "icons": { - "a-arrow-down": { - "body": "" - }, - "a-arrow-up": { - "body": "" - }, - "a-large-small": { - "body": "" - }, - "accessibility": { - "body": "" - }, - "activity": { - "body": "" - }, - "air-vent": { - "body": "" - }, - "airplay": { - "body": "" - }, - "alarm-clock": { - "body": "" - }, - "alarm-clock-check": { - "body": "" - }, - "alarm-clock-minus": { - "body": "" - }, - "alarm-clock-off": { - "body": "" - }, - "alarm-clock-plus": { - "body": "" - }, - "alarm-smoke": { - "body": "" - }, - "album": { - "body": "" - }, - "align-center": { - "body": "", - "hidden": true - }, - "align-center-horizontal": { - "body": "" - }, - "align-center-vertical": { - "body": "" - }, - "align-end-horizontal": { - "body": "" - }, - "align-end-vertical": { - "body": "" - }, - "align-horizontal-distribute-center": { - "body": "" - }, - "align-horizontal-distribute-end": { - "body": "" - }, - "align-horizontal-distribute-start": { - "body": "" - }, - "align-horizontal-justify-center": { - "body": "" - }, - "align-horizontal-justify-end": { - "body": "" - }, - "align-horizontal-justify-start": { - "body": "" - }, - "align-horizontal-space-around": { - "body": "" - }, - "align-horizontal-space-between": { - "body": "" - }, - "align-justify": { - "body": "", - "hidden": true - }, - "align-left": { - "body": "", - "hidden": true - }, - "align-right": { - "body": "", - "hidden": true - }, - "align-start-horizontal": { - "body": "" - }, - "align-start-vertical": { - "body": "" - }, - "align-vertical-distribute-center": { - "body": "" - }, - "align-vertical-distribute-end": { - "body": "" - }, - "align-vertical-distribute-start": { - "body": "" - }, - "align-vertical-justify-center": { - "body": "" - }, - "align-vertical-justify-end": { - "body": "" - }, - "align-vertical-justify-start": { - "body": "" - }, - "align-vertical-space-around": { - "body": "" - }, - "align-vertical-space-between": { - "body": "" - }, - "ambulance": { - "body": "" - }, - "ampersand": { - "body": "" - }, - "ampersands": { - "body": "" - }, - "amphora": { - "body": "" - }, - "anchor": { - "body": "" - }, - "angry": { - "body": "" - }, - "annoyed": { - "body": "" - }, - "antenna": { - "body": "" - }, - "anvil": { - "body": "" - }, - "aperture": { - "body": "" - }, - "app-window": { - "body": "" - }, - "app-window-mac": { - "body": "" - }, - "apple": { - "body": "" - }, - "archive": { - "body": "" - }, - "archive-restore": { - "body": "" - }, - "archive-x": { - "body": "" - }, - "area-chart": { - "body": "", - "hidden": true - }, - "armchair": { - "body": "" - }, - "arrow-big-down": { - "body": "" - }, - "arrow-big-down-dash": { - "body": "" - }, - "arrow-big-left": { - "body": "" - }, - "arrow-big-left-dash": { - "body": "" - }, - "arrow-big-right": { - "body": "" - }, - "arrow-big-right-dash": { - "body": "" - }, - "arrow-big-up": { - "body": "" - }, - "arrow-big-up-dash": { - "body": "" - }, - "arrow-down": { - "body": "" - }, - "arrow-down-0-1": { - "body": "" - }, - "arrow-down-1-0": { - "body": "" - }, - "arrow-down-a-z": { - "body": "" - }, - "arrow-down-from-line": { - "body": "" - }, - "arrow-down-left": { - "body": "" - }, - "arrow-down-narrow-wide": { - "body": "" - }, - "arrow-down-right": { - "body": "" - }, - "arrow-down-to-dot": { - "body": "" - }, - "arrow-down-to-line": { - "body": "" - }, - "arrow-down-up": { - "body": "" - }, - "arrow-down-wide-narrow": { - "body": "" - }, - "arrow-down-z-a": { - "body": "" - }, - "arrow-left": { - "body": "" - }, - "arrow-left-from-line": { - "body": "" - }, - "arrow-left-right": { - "body": "" - }, - "arrow-left-to-line": { - "body": "" - }, - "arrow-right": { - "body": "" - }, - "arrow-right-from-line": { - "body": "" - }, - "arrow-right-left": { - "body": "" - }, - "arrow-right-to-line": { - "body": "" - }, - "arrow-up": { - "body": "" - }, - "arrow-up-0-1": { - "body": "" - }, - "arrow-up-1-0": { - "body": "" - }, - "arrow-up-a-z": { - "body": "" - }, - "arrow-up-down": { - "body": "" - }, - "arrow-up-from-dot": { - "body": "" - }, - "arrow-up-from-line": { - "body": "" - }, - "arrow-up-left": { - "body": "" - }, - "arrow-up-narrow-wide": { - "body": "" - }, - "arrow-up-right": { - "body": "" - }, - "arrow-up-to-line": { - "body": "" - }, - "arrow-up-wide-narrow": { - "body": "" - }, - "arrow-up-z-a": { - "body": "" - }, - "arrows-up-from-line": { - "body": "" - }, - "asterisk": { - "body": "" - }, - "at-sign": { - "body": "" - }, - "atom": { - "body": "" - }, - "audio-lines": { - "body": "" - }, - "audio-waveform": { - "body": "" - }, - "award": { - "body": "" - }, - "axe": { - "body": "" - }, - "axis-3d": { - "body": "" - }, - "baby": { - "body": "" - }, - "backpack": { - "body": "" - }, - "badge": { - "body": "" - }, - "badge-alert": { - "body": "" - }, - "badge-cent": { - "body": "" - }, - "badge-check": { - "body": "" - }, - "badge-dollar-sign": { - "body": "" - }, - "badge-euro": { - "body": "" - }, - "badge-indian-rupee": { - "body": "" - }, - "badge-info": { - "body": "" - }, - "badge-japanese-yen": { - "body": "" - }, - "badge-minus": { - "body": "" - }, - "badge-percent": { - "body": "" - }, - "badge-plus": { - "body": "" - }, - "badge-pound-sterling": { - "body": "" - }, - "badge-question-mark": { - "body": "" - }, - "badge-russian-ruble": { - "body": "" - }, - "badge-swiss-franc": { - "body": "" - }, - "badge-turkish-lira": { - "body": "" - }, - "badge-x": { - "body": "" - }, - "baggage-claim": { - "body": "" - }, - "ban": { - "body": "" - }, - "banana": { - "body": "" - }, - "bandage": { - "body": "" - }, - "banknote": { - "body": "" - }, - "banknote-arrow-down": { - "body": "" - }, - "banknote-arrow-up": { - "body": "" - }, - "banknote-x": { - "body": "" - }, - "bar-chart-3": { - "body": "", - "hidden": true - }, - "bar-chart-4": { - "body": "", - "hidden": true - }, - "bar-chart-big": { - "body": "", - "hidden": true - }, - "bar-chart-horizontal": { - "body": "", - "hidden": true - }, - "bar-chart-horizontal-big": { - "body": "", - "hidden": true - }, - "barcode": { - "body": "" - }, - "barrel": { - "body": "" - }, - "baseline": { - "body": "" - }, - "bath": { - "body": "" - }, - "battery": { - "body": "" - }, - "battery-charging": { - "body": "" - }, - "battery-full": { - "body": "" - }, - "battery-low": { - "body": "" - }, - "battery-medium": { - "body": "" - }, - "battery-plus": { - "body": "" - }, - "battery-warning": { - "body": "" - }, - "beaker": { - "body": "" - }, - "bean": { - "body": "" - }, - "bean-off": { - "body": "" - }, - "bed": { - "body": "" - }, - "bed-double": { - "body": "" - }, - "bed-single": { - "body": "" - }, - "beef": { - "body": "" - }, - "beer": { - "body": "" - }, - "beer-off": { - "body": "" - }, - "bell": { - "body": "" - }, - "bell-dot": { - "body": "" - }, - "bell-electric": { - "body": "" - }, - "bell-minus": { - "body": "" - }, - "bell-off": { - "body": "" - }, - "bell-plus": { - "body": "" - }, - "bell-ring": { - "body": "" - }, - "between-horizontal-end": { - "body": "" - }, - "between-horizontal-start": { - "body": "" - }, - "between-vertical-end": { - "body": "" - }, - "between-vertical-start": { - "body": "" - }, - "biceps-flexed": { - "body": "" - }, - "bike": { - "body": "" - }, - "binary": { - "body": "" - }, - "binoculars": { - "body": "" - }, - "biohazard": { - "body": "" - }, - "bird": { - "body": "" - }, - "birdhouse": { - "body": "" - }, - "bitcoin": { - "body": "" - }, - "blend": { - "body": "" - }, - "blinds": { - "body": "" - }, - "blocks": { - "body": "" - }, - "bluetooth": { - "body": "" - }, - "bluetooth-connected": { - "body": "" - }, - "bluetooth-off": { - "body": "" - }, - "bluetooth-searching": { - "body": "" - }, - "bold": { - "body": "" - }, - "bolt": { - "body": "" - }, - "bomb": { - "body": "" - }, - "bone": { - "body": "" - }, - "book": { - "body": "" - }, - "book-a": { - "body": "" - }, - "book-alert": { - "body": "" - }, - "book-audio": { - "body": "" - }, - "book-check": { - "body": "" - }, - "book-copy": { - "body": "" - }, - "book-dashed": { - "body": "" - }, - "book-down": { - "body": "" - }, - "book-headphones": { - "body": "" - }, - "book-heart": { - "body": "" - }, - "book-image": { - "body": "" - }, - "book-key": { - "body": "" - }, - "book-lock": { - "body": "" - }, - "book-marked": { - "body": "" - }, - "book-minus": { - "body": "" - }, - "book-open": { - "body": "" - }, - "book-open-check": { - "body": "" - }, - "book-open-text": { - "body": "" - }, - "book-plus": { - "body": "" - }, - "book-text": { - "body": "" - }, - "book-type": { - "body": "" - }, - "book-up": { - "body": "" - }, - "book-up-2": { - "body": "" - }, - "book-user": { - "body": "" - }, - "book-x": { - "body": "" - }, - "bookmark": { - "body": "" - }, - "bookmark-check": { - "body": "" - }, - "bookmark-minus": { - "body": "" - }, - "bookmark-plus": { - "body": "" - }, - "bookmark-x": { - "body": "" - }, - "boom-box": { - "body": "" - }, - "bot": { - "body": "" - }, - "bot-message-square": { - "body": "" - }, - "bot-off": { - "body": "" - }, - "bottle-wine": { - "body": "" - }, - "bow-arrow": { - "body": "" - }, - "box": { - "body": "" - }, - "boxes": { - "body": "" - }, - "braces": { - "body": "" - }, - "brackets": { - "body": "" - }, - "brain": { - "body": "" - }, - "brain-circuit": { - "body": "" - }, - "brain-cog": { - "body": "" - }, - "brick-wall": { - "body": "" - }, - "brick-wall-fire": { - "body": "" - }, - "brick-wall-shield": { - "body": "" - }, - "briefcase": { - "body": "" - }, - "briefcase-business": { - "body": "" - }, - "briefcase-conveyor-belt": { - "body": "" - }, - "briefcase-medical": { - "body": "" - }, - "bring-to-front": { - "body": "" - }, - "brush": { - "body": "" - }, - "brush-cleaning": { - "body": "" - }, - "bubbles": { - "body": "" - }, - "bug": { - "body": "" - }, - "bug-off": { - "body": "" - }, - "bug-play": { - "body": "" - }, - "building": { - "body": "" - }, - "building-2": { - "body": "" - }, - "bus": { - "body": "" - }, - "bus-front": { - "body": "" - }, - "cable": { - "body": "" - }, - "cable-car": { - "body": "" - }, - "cake": { - "body": "" - }, - "cake-slice": { - "body": "" - }, - "calculator": { - "body": "" - }, - "calendar": { - "body": "" - }, - "calendar-1": { - "body": "" - }, - "calendar-arrow-down": { - "body": "" - }, - "calendar-arrow-up": { - "body": "" - }, - "calendar-check": { - "body": "" - }, - "calendar-check-2": { - "body": "" - }, - "calendar-clock": { - "body": "" - }, - "calendar-cog": { - "body": "" - }, - "calendar-days": { - "body": "" - }, - "calendar-fold": { - "body": "" - }, - "calendar-heart": { - "body": "" - }, - "calendar-minus": { - "body": "" - }, - "calendar-minus-2": { - "body": "" - }, - "calendar-off": { - "body": "" - }, - "calendar-plus": { - "body": "" - }, - "calendar-plus-2": { - "body": "" - }, - "calendar-range": { - "body": "" - }, - "calendar-search": { - "body": "" - }, - "calendar-sync": { - "body": "" - }, - "calendar-x": { - "body": "" - }, - "calendar-x-2": { - "body": "" - }, - "camera": { - "body": "" - }, - "camera-off": { - "body": "" - }, - "candlestick-chart": { - "body": "", - "hidden": true - }, - "candy": { - "body": "" - }, - "candy-cane": { - "body": "" - }, - "candy-off": { - "body": "" - }, - "cannabis": { - "body": "" - }, - "captions": { - "body": "" - }, - "captions-off": { - "body": "" - }, - "car": { - "body": "" - }, - "car-front": { - "body": "" - }, - "car-taxi-front": { - "body": "" - }, - "caravan": { - "body": "" - }, - "card-sim": { - "body": "" - }, - "carrot": { - "body": "" - }, - "case-lower": { - "body": "" - }, - "case-sensitive": { - "body": "" - }, - "case-upper": { - "body": "" - }, - "cassette-tape": { - "body": "" - }, - "cast": { - "body": "" - }, - "castle": { - "body": "" - }, - "cat": { - "body": "" - }, - "cctv": { - "body": "" - }, - "chart-area": { - "body": "" - }, - "chart-bar": { - "body": "" - }, - "chart-bar-big": { - "body": "" - }, - "chart-bar-decreasing": { - "body": "" - }, - "chart-bar-increasing": { - "body": "" - }, - "chart-bar-stacked": { - "body": "" - }, - "chart-candlestick": { - "body": "" - }, - "chart-column": { - "body": "" - }, - "chart-column-big": { - "body": "" - }, - "chart-column-decreasing": { - "body": "" - }, - "chart-column-increasing": { - "body": "" - }, - "chart-column-stacked": { - "body": "" - }, - "chart-gantt": { - "body": "" - }, - "chart-line": { - "body": "" - }, - "chart-network": { - "body": "" - }, - "chart-no-axes-column": { - "body": "" - }, - "chart-no-axes-column-decreasing": { - "body": "" - }, - "chart-no-axes-column-increasing": { - "body": "" - }, - "chart-no-axes-combined": { - "body": "" - }, - "chart-no-axes-gantt": { - "body": "" - }, - "chart-pie": { - "body": "" - }, - "chart-scatter": { - "body": "" - }, - "chart-spline": { - "body": "" - }, - "check": { - "body": "" - }, - "check-check": { - "body": "" - }, - "check-line": { - "body": "" - }, - "chef-hat": { - "body": "" - }, - "cherry": { - "body": "" - }, - "chevron-down": { - "body": "" - }, - "chevron-first": { - "body": "" - }, - "chevron-last": { - "body": "" - }, - "chevron-left": { - "body": "" - }, - "chevron-right": { - "body": "" - }, - "chevron-up": { - "body": "" - }, - "chevrons-down": { - "body": "" - }, - "chevrons-down-up": { - "body": "" - }, - "chevrons-left": { - "body": "" - }, - "chevrons-left-right": { - "body": "" - }, - "chevrons-left-right-ellipsis": { - "body": "" - }, - "chevrons-right": { - "body": "" - }, - "chevrons-right-left": { - "body": "" - }, - "chevrons-up": { - "body": "" - }, - "chevrons-up-down": { - "body": "" - }, - "chrome": { - "body": "", - "hidden": true - }, - "chromium": { - "body": "" - }, - "church": { - "body": "" - }, - "cigarette": { - "body": "" - }, - "cigarette-off": { - "body": "" - }, - "circle": { - "body": "" - }, - "circle-alert": { - "body": "" - }, - "circle-arrow-down": { - "body": "" - }, - "circle-arrow-left": { - "body": "" - }, - "circle-arrow-out-down-left": { - "body": "" - }, - "circle-arrow-out-down-right": { - "body": "" - }, - "circle-arrow-out-up-left": { - "body": "" - }, - "circle-arrow-out-up-right": { - "body": "" - }, - "circle-arrow-right": { - "body": "" - }, - "circle-arrow-up": { - "body": "" - }, - "circle-check": { - "body": "" - }, - "circle-check-big": { - "body": "" - }, - "circle-chevron-down": { - "body": "" - }, - "circle-chevron-left": { - "body": "" - }, - "circle-chevron-right": { - "body": "" - }, - "circle-chevron-up": { - "body": "" - }, - "circle-dashed": { - "body": "" - }, - "circle-divide": { - "body": "" - }, - "circle-dollar-sign": { - "body": "" - }, - "circle-dot": { - "body": "" - }, - "circle-dot-dashed": { - "body": "" - }, - "circle-ellipsis": { - "body": "" - }, - "circle-equal": { - "body": "" - }, - "circle-fading-arrow-up": { - "body": "" - }, - "circle-fading-plus": { - "body": "" - }, - "circle-gauge": { - "body": "" - }, - "circle-minus": { - "body": "" - }, - "circle-off": { - "body": "" - }, - "circle-parking": { - "body": "" - }, - "circle-parking-off": { - "body": "" - }, - "circle-pause": { - "body": "" - }, - "circle-percent": { - "body": "" - }, - "circle-play": { - "body": "" - }, - "circle-plus": { - "body": "" - }, - "circle-pound-sterling": { - "body": "" - }, - "circle-power": { - "body": "" - }, - "circle-question-mark": { - "body": "" - }, - "circle-slash": { - "body": "" - }, - "circle-slash-2": { - "body": "" - }, - "circle-small": { - "body": "" - }, - "circle-star": { - "body": "" - }, - "circle-stop": { - "body": "" - }, - "circle-user": { - "body": "" - }, - "circle-user-round": { - "body": "" - }, - "circle-x": { - "body": "" - }, - "circuit-board": { - "body": "" - }, - "citrus": { - "body": "" - }, - "clapperboard": { - "body": "" - }, - "clipboard": { - "body": "" - }, - "clipboard-check": { - "body": "" - }, - "clipboard-clock": { - "body": "" - }, - "clipboard-copy": { - "body": "" - }, - "clipboard-list": { - "body": "" - }, - "clipboard-minus": { - "body": "" - }, - "clipboard-paste": { - "body": "" - }, - "clipboard-pen": { - "body": "" - }, - "clipboard-pen-line": { - "body": "" - }, - "clipboard-plus": { - "body": "" - }, - "clipboard-type": { - "body": "" - }, - "clipboard-x": { - "body": "" - }, - "clock": { - "body": "" - }, - "clock-1": { - "body": "" - }, - "clock-10": { - "body": "" - }, - "clock-11": { - "body": "" - }, - "clock-12": { - "body": "" - }, - "clock-2": { - "body": "" - }, - "clock-3": { - "body": "" - }, - "clock-4": { - "body": "" - }, - "clock-5": { - "body": "" - }, - "clock-6": { - "body": "" - }, - "clock-7": { - "body": "" - }, - "clock-8": { - "body": "" - }, - "clock-9": { - "body": "" - }, - "clock-alert": { - "body": "" - }, - "clock-arrow-down": { - "body": "" - }, - "clock-arrow-up": { - "body": "" - }, - "clock-fading": { - "body": "" - }, - "clock-plus": { - "body": "" - }, - "closed-caption": { - "body": "" - }, - "cloud": { - "body": "" - }, - "cloud-alert": { - "body": "" - }, - "cloud-check": { - "body": "" - }, - "cloud-cog": { - "body": "" - }, - "cloud-download": { - "body": "" - }, - "cloud-drizzle": { - "body": "" - }, - "cloud-fog": { - "body": "" - }, - "cloud-hail": { - "body": "" - }, - "cloud-lightning": { - "body": "" - }, - "cloud-moon": { - "body": "" - }, - "cloud-moon-rain": { - "body": "" - }, - "cloud-off": { - "body": "" - }, - "cloud-rain": { - "body": "" - }, - "cloud-rain-wind": { - "body": "" - }, - "cloud-snow": { - "body": "" - }, - "cloud-sun": { - "body": "" - }, - "cloud-sun-rain": { - "body": "" - }, - "cloud-upload": { - "body": "" - }, - "cloudy": { - "body": "" - }, - "clover": { - "body": "" - }, - "club": { - "body": "" - }, - "code": { - "body": "" - }, - "code-xml": { - "body": "" - }, - "codepen": { - "body": "" - }, - "codesandbox": { - "body": "" - }, - "coffee": { - "body": "" - }, - "cog": { - "body": "" - }, - "coins": { - "body": "" - }, - "columns-2": { - "body": "" - }, - "columns-3": { - "body": "" - }, - "columns-3-cog": { - "body": "" - }, - "columns-4": { - "body": "" - }, - "combine": { - "body": "" - }, - "command": { - "body": "" - }, - "compass": { - "body": "" - }, - "component": { - "body": "" - }, - "computer": { - "body": "" - }, - "concierge-bell": { - "body": "" - }, - "cone": { - "body": "" - }, - "construction": { - "body": "" - }, - "contact": { - "body": "" - }, - "contact-round": { - "body": "" - }, - "container": { - "body": "" - }, - "contrast": { - "body": "" - }, - "cookie": { - "body": "" - }, - "cooking-pot": { - "body": "" - }, - "copy": { - "body": "" - }, - "copy-check": { - "body": "" - }, - "copy-minus": { - "body": "" - }, - "copy-plus": { - "body": "" - }, - "copy-slash": { - "body": "" - }, - "copy-x": { - "body": "" - }, - "copyleft": { - "body": "" - }, - "copyright": { - "body": "" - }, - "corner-down-left": { - "body": "" - }, - "corner-down-right": { - "body": "" - }, - "corner-left-down": { - "body": "" - }, - "corner-left-up": { - "body": "" - }, - "corner-right-down": { - "body": "" - }, - "corner-right-up": { - "body": "" - }, - "corner-up-left": { - "body": "" - }, - "corner-up-right": { - "body": "" - }, - "cpu": { - "body": "" - }, - "creative-commons": { - "body": "" - }, - "credit-card": { - "body": "" - }, - "croissant": { - "body": "" - }, - "crop": { - "body": "" - }, - "cross": { - "body": "" - }, - "crosshair": { - "body": "" - }, - "crown": { - "body": "" - }, - "cuboid": { - "body": "" - }, - "cup-soda": { - "body": "" - }, - "currency": { - "body": "" - }, - "cylinder": { - "body": "" - }, - "dam": { - "body": "" - }, - "database": { - "body": "" - }, - "database-backup": { - "body": "" - }, - "database-zap": { - "body": "" - }, - "decimals-arrow-left": { - "body": "" - }, - "decimals-arrow-right": { - "body": "" - }, - "delete": { - "body": "" - }, - "dessert": { - "body": "" - }, - "diameter": { - "body": "" - }, - "diamond": { - "body": "" - }, - "diamond-minus": { - "body": "" - }, - "diamond-percent": { - "body": "" - }, - "diamond-plus": { - "body": "" - }, - "dice-1": { - "body": "" - }, - "dice-2": { - "body": "" - }, - "dice-3": { - "body": "" - }, - "dice-4": { - "body": "" - }, - "dice-5": { - "body": "" - }, - "dice-6": { - "body": "" - }, - "dices": { - "body": "" - }, - "diff": { - "body": "" - }, - "disc": { - "body": "" - }, - "disc-2": { - "body": "" - }, - "disc-3": { - "body": "" - }, - "disc-album": { - "body": "" - }, - "divide": { - "body": "" - }, - "dna": { - "body": "" - }, - "dna-off": { - "body": "" - }, - "dock": { - "body": "" - }, - "dog": { - "body": "" - }, - "dollar-sign": { - "body": "" - }, - "donut": { - "body": "" - }, - "door-closed": { - "body": "" - }, - "door-closed-locked": { - "body": "" - }, - "door-open": { - "body": "" - }, - "dot": { - "body": "" - }, - "download": { - "body": "" - }, - "drafting-compass": { - "body": "" - }, - "drama": { - "body": "" - }, - "dribbble": { - "body": "" - }, - "drill": { - "body": "" - }, - "drone": { - "body": "" - }, - "droplet": { - "body": "" - }, - "droplet-off": { - "body": "" - }, - "droplets": { - "body": "" - }, - "drum": { - "body": "" - }, - "drumstick": { - "body": "" - }, - "dumbbell": { - "body": "" - }, - "ear": { - "body": "" - }, - "ear-off": { - "body": "" - }, - "earth": { - "body": "" - }, - "earth-lock": { - "body": "" - }, - "eclipse": { - "body": "" - }, - "egg": { - "body": "" - }, - "egg-fried": { - "body": "" - }, - "egg-off": { - "body": "" - }, - "ellipsis": { - "body": "" - }, - "ellipsis-vertical": { - "body": "" - }, - "equal": { - "body": "" - }, - "equal-approximately": { - "body": "" - }, - "equal-not": { - "body": "" - }, - "eraser": { - "body": "" - }, - "ethernet-port": { - "body": "" - }, - "euro": { - "body": "" - }, - "ev-charger": { - "body": "" - }, - "expand": { - "body": "" - }, - "external-link": { - "body": "" - }, - "eye": { - "body": "" - }, - "eye-closed": { - "body": "" - }, - "eye-off": { - "body": "" - }, - "facebook": { - "body": "" - }, - "factory": { - "body": "" - }, - "fan": { - "body": "" - }, - "fast-forward": { - "body": "" - }, - "feather": { - "body": "" - }, - "fence": { - "body": "" - }, - "ferris-wheel": { - "body": "" - }, - "figma": { - "body": "" - }, - "file": { - "body": "" - }, - "file-archive": { - "body": "" - }, - "file-audio": { - "body": "" - }, - "file-audio-2": { - "body": "" - }, - "file-axis-3d": { - "body": "" - }, - "file-badge": { - "body": "" - }, - "file-badge-2": { - "body": "" - }, - "file-box": { - "body": "" - }, - "file-chart-column": { - "body": "" - }, - "file-chart-column-increasing": { - "body": "" - }, - "file-chart-line": { - "body": "" - }, - "file-chart-pie": { - "body": "" - }, - "file-check": { - "body": "" - }, - "file-check-2": { - "body": "" - }, - "file-clock": { - "body": "" - }, - "file-code": { - "body": "" - }, - "file-code-2": { - "body": "" - }, - "file-cog": { - "body": "" - }, - "file-diff": { - "body": "" - }, - "file-digit": { - "body": "" - }, - "file-down": { - "body": "" - }, - "file-heart": { - "body": "" - }, - "file-image": { - "body": "" - }, - "file-input": { - "body": "" - }, - "file-json": { - "body": "" - }, - "file-json-2": { - "body": "" - }, - "file-key": { - "body": "" - }, - "file-key-2": { - "body": "" - }, - "file-lock": { - "body": "" - }, - "file-lock-2": { - "body": "" - }, - "file-minus": { - "body": "" - }, - "file-minus-2": { - "body": "" - }, - "file-music": { - "body": "" - }, - "file-output": { - "body": "" - }, - "file-pen": { - "body": "" - }, - "file-pen-line": { - "body": "" - }, - "file-pie-chart": { - "body": "", - "hidden": true - }, - "file-play": { - "body": "" - }, - "file-plus": { - "body": "" - }, - "file-plus-2": { - "body": "" - }, - "file-question-mark": { - "body": "" - }, - "file-scan": { - "body": "" - }, - "file-search": { - "body": "" - }, - "file-search-2": { - "body": "" - }, - "file-sliders": { - "body": "" - }, - "file-spreadsheet": { - "body": "" - }, - "file-stack": { - "body": "" - }, - "file-symlink": { - "body": "" - }, - "file-terminal": { - "body": "" - }, - "file-text": { - "body": "" - }, - "file-type": { - "body": "" - }, - "file-type-2": { - "body": "" - }, - "file-up": { - "body": "" - }, - "file-user": { - "body": "" - }, - "file-video-camera": { - "body": "" - }, - "file-volume": { - "body": "" - }, - "file-volume-2": { - "body": "" - }, - "file-warning": { - "body": "" - }, - "file-x": { - "body": "" - }, - "file-x-2": { - "body": "" - }, - "files": { - "body": "" - }, - "film": { - "body": "" - }, - "filter": { - "body": "", - "hidden": true - }, - "filter-x": { - "body": "", - "hidden": true - }, - "fingerprint": { - "body": "" - }, - "fire-extinguisher": { - "body": "" - }, - "fish": { - "body": "" - }, - "fish-off": { - "body": "" - }, - "fish-symbol": { - "body": "" - }, - "flag": { - "body": "" - }, - "flag-off": { - "body": "" - }, - "flag-triangle-left": { - "body": "" - }, - "flag-triangle-right": { - "body": "" - }, - "flame": { - "body": "" - }, - "flame-kindling": { - "body": "" - }, - "flashlight": { - "body": "" - }, - "flashlight-off": { - "body": "" - }, - "flask-conical": { - "body": "" - }, - "flask-conical-off": { - "body": "" - }, - "flask-round": { - "body": "" - }, - "flip-horizontal": { - "body": "" - }, - "flip-horizontal-2": { - "body": "" - }, - "flip-vertical": { - "body": "" - }, - "flip-vertical-2": { - "body": "" - }, - "flower": { - "body": "" - }, - "flower-2": { - "body": "" - }, - "focus": { - "body": "" - }, - "fold-horizontal": { - "body": "" - }, - "fold-vertical": { - "body": "" - }, - "folder": { - "body": "" - }, - "folder-archive": { - "body": "" - }, - "folder-check": { - "body": "" - }, - "folder-clock": { - "body": "" - }, - "folder-closed": { - "body": "" - }, - "folder-code": { - "body": "" - }, - "folder-cog": { - "body": "" - }, - "folder-dot": { - "body": "" - }, - "folder-down": { - "body": "" - }, - "folder-git": { - "body": "" - }, - "folder-git-2": { - "body": "" - }, - "folder-heart": { - "body": "" - }, - "folder-input": { - "body": "" - }, - "folder-kanban": { - "body": "" - }, - "folder-key": { - "body": "" - }, - "folder-lock": { - "body": "" - }, - "folder-minus": { - "body": "" - }, - "folder-open": { - "body": "" - }, - "folder-open-dot": { - "body": "" - }, - "folder-output": { - "body": "" - }, - "folder-pen": { - "body": "" - }, - "folder-plus": { - "body": "" - }, - "folder-root": { - "body": "" - }, - "folder-search": { - "body": "" - }, - "folder-search-2": { - "body": "" - }, - "folder-symlink": { - "body": "" - }, - "folder-sync": { - "body": "" - }, - "folder-tree": { - "body": "" - }, - "folder-up": { - "body": "" - }, - "folder-x": { - "body": "" - }, - "folders": { - "body": "" - }, - "footprints": { - "body": "" - }, - "forklift": { - "body": "" - }, - "forward": { - "body": "" - }, - "frame": { - "body": "" - }, - "framer": { - "body": "" - }, - "frown": { - "body": "" - }, - "fuel": { - "body": "" - }, - "fullscreen": { - "body": "" - }, - "funnel": { - "body": "" - }, - "funnel-plus": { - "body": "" - }, - "funnel-x": { - "body": "" - }, - "gallery-horizontal": { - "body": "" - }, - "gallery-horizontal-end": { - "body": "" - }, - "gallery-thumbnails": { - "body": "" - }, - "gallery-vertical": { - "body": "" - }, - "gallery-vertical-end": { - "body": "" - }, - "gamepad": { - "body": "" - }, - "gamepad-2": { - "body": "" - }, - "gamepad-directional": { - "body": "" - }, - "gauge": { - "body": "" - }, - "gavel": { - "body": "" - }, - "gem": { - "body": "" - }, - "georgian-lari": { - "body": "" - }, - "ghost": { - "body": "" - }, - "gift": { - "body": "" - }, - "git-branch": { - "body": "" - }, - "git-branch-plus": { - "body": "" - }, - "git-commit-horizontal": { - "body": "" - }, - "git-commit-vertical": { - "body": "" - }, - "git-compare": { - "body": "" - }, - "git-compare-arrows": { - "body": "" - }, - "git-fork": { - "body": "" - }, - "git-graph": { - "body": "" - }, - "git-merge": { - "body": "" - }, - "git-pull-request": { - "body": "" - }, - "git-pull-request-arrow": { - "body": "" - }, - "git-pull-request-closed": { - "body": "" - }, - "git-pull-request-create": { - "body": "" - }, - "git-pull-request-create-arrow": { - "body": "" - }, - "git-pull-request-draft": { - "body": "" - }, - "github": { - "body": "" - }, - "gitlab": { - "body": "" - }, - "glass-water": { - "body": "" - }, - "glasses": { - "body": "" - }, - "globe": { - "body": "" - }, - "globe-lock": { - "body": "" - }, - "goal": { - "body": "" - }, - "gpu": { - "body": "" - }, - "graduation-cap": { - "body": "" - }, - "grape": { - "body": "" - }, - "grid-2x2": { - "body": "" - }, - "grid-2x2-check": { - "body": "" - }, - "grid-2x2-plus": { - "body": "" - }, - "grid-2x2-x": { - "body": "" - }, - "grid-3x2": { - "body": "" - }, - "grid-3x3": { - "body": "" - }, - "grip": { - "body": "" - }, - "grip-horizontal": { - "body": "" - }, - "grip-vertical": { - "body": "" - }, - "group": { - "body": "" - }, - "guitar": { - "body": "" - }, - "ham": { - "body": "" - }, - "hamburger": { - "body": "" - }, - "hammer": { - "body": "" - }, - "hand": { - "body": "" - }, - "hand-coins": { - "body": "" - }, - "hand-fist": { - "body": "" - }, - "hand-grab": { - "body": "" - }, - "hand-heart": { - "body": "" - }, - "hand-helping": { - "body": "" - }, - "hand-metal": { - "body": "" - }, - "hand-platter": { - "body": "" - }, - "handbag": { - "body": "" - }, - "handshake": { - "body": "" - }, - "hard-drive": { - "body": "" - }, - "hard-drive-download": { - "body": "" - }, - "hard-drive-upload": { - "body": "" - }, - "hard-hat": { - "body": "" - }, - "hash": { - "body": "" - }, - "hat-glasses": { - "body": "" - }, - "haze": { - "body": "" - }, - "hdmi-port": { - "body": "" - }, - "heading": { - "body": "" - }, - "heading-1": { - "body": "" - }, - "heading-2": { - "body": "" - }, - "heading-3": { - "body": "" - }, - "heading-4": { - "body": "" - }, - "heading-5": { - "body": "" - }, - "heading-6": { - "body": "" - }, - "headphone-off": { - "body": "" - }, - "headphones": { - "body": "" - }, - "headset": { - "body": "" - }, - "heart": { - "body": "" - }, - "heart-crack": { - "body": "" - }, - "heart-handshake": { - "body": "" - }, - "heart-minus": { - "body": "" - }, - "heart-off": { - "body": "" - }, - "heart-plus": { - "body": "" - }, - "heart-pulse": { - "body": "" - }, - "heater": { - "body": "" - }, - "hexagon": { - "body": "" - }, - "highlighter": { - "body": "" - }, - "history": { - "body": "" - }, - "hop": { - "body": "" - }, - "hop-off": { - "body": "" - }, - "hospital": { - "body": "" - }, - "hotel": { - "body": "" - }, - "hourglass": { - "body": "" - }, - "house": { - "body": "" - }, - "house-heart": { - "body": "" - }, - "house-plug": { - "body": "" - }, - "house-plus": { - "body": "" - }, - "house-wifi": { - "body": "" - }, - "ice-cream-bowl": { - "body": "" - }, - "ice-cream-cone": { - "body": "" - }, - "id-card": { - "body": "" - }, - "id-card-lanyard": { - "body": "" - }, - "image": { - "body": "" - }, - "image-down": { - "body": "" - }, - "image-minus": { - "body": "" - }, - "image-off": { - "body": "" - }, - "image-play": { - "body": "" - }, - "image-plus": { - "body": "" - }, - "image-up": { - "body": "" - }, - "image-upscale": { - "body": "" - }, - "images": { - "body": "" - }, - "import": { - "body": "" - }, - "inbox": { - "body": "" - }, - "indent-decrease": { - "body": "", - "hidden": true - }, - "indent-increase": { - "body": "", - "hidden": true - }, - "indian-rupee": { - "body": "" - }, - "infinity": { - "body": "" - }, - "info": { - "body": "" - }, - "inspection-panel": { - "body": "" - }, - "instagram": { - "body": "" - }, - "italic": { - "body": "" - }, - "iteration-ccw": { - "body": "" - }, - "iteration-cw": { - "body": "" - }, - "japanese-yen": { - "body": "" - }, - "joystick": { - "body": "" - }, - "kanban": { - "body": "" - }, - "kayak": { - "body": "" - }, - "key": { - "body": "" - }, - "key-round": { - "body": "" - }, - "key-square": { - "body": "" - }, - "keyboard": { - "body": "" - }, - "keyboard-music": { - "body": "" - }, - "keyboard-off": { - "body": "" - }, - "lamp": { - "body": "" - }, - "lamp-ceiling": { - "body": "" - }, - "lamp-desk": { - "body": "" - }, - "lamp-floor": { - "body": "" - }, - "lamp-wall-down": { - "body": "" - }, - "lamp-wall-up": { - "body": "" - }, - "land-plot": { - "body": "" - }, - "landmark": { - "body": "" - }, - "languages": { - "body": "" - }, - "laptop": { - "body": "" - }, - "laptop-minimal": { - "body": "" - }, - "laptop-minimal-check": { - "body": "" - }, - "lasso": { - "body": "" - }, - "lasso-select": { - "body": "" - }, - "laugh": { - "body": "" - }, - "layers": { - "body": "" - }, - "layers-2": { - "body": "" - }, - "layers-3": { - "body": "", - "hidden": true - }, - "layout-dashboard": { - "body": "" - }, - "layout-grid": { - "body": "" - }, - "layout-list": { - "body": "" - }, - "layout-panel-left": { - "body": "" - }, - "layout-panel-top": { - "body": "" - }, - "layout-template": { - "body": "" - }, - "leaf": { - "body": "" - }, - "leafy-green": { - "body": "" - }, - "lectern": { - "body": "" - }, - "letter-text": { - "body": "", - "hidden": true - }, - "library": { - "body": "" - }, - "library-big": { - "body": "" - }, - "life-buoy": { - "body": "" - }, - "ligature": { - "body": "" - }, - "lightbulb": { - "body": "" - }, - "lightbulb-off": { - "body": "" - }, - "line-chart": { - "body": "", - "hidden": true - }, - "line-squiggle": { - "body": "" - }, - "link": { - "body": "" - }, - "link-2": { - "body": "" - }, - "link-2-off": { - "body": "" - }, - "linkedin": { - "body": "" - }, - "list": { - "body": "" - }, - "list-check": { - "body": "" - }, - "list-checks": { - "body": "" - }, - "list-chevrons-down-up": { - "body": "" - }, - "list-chevrons-up-down": { - "body": "" - }, - "list-collapse": { - "body": "" - }, - "list-end": { - "body": "" - }, - "list-filter": { - "body": "" - }, - "list-filter-plus": { - "body": "" - }, - "list-indent-decrease": { - "body": "" - }, - "list-indent-increase": { - "body": "" - }, - "list-minus": { - "body": "" - }, - "list-music": { - "body": "" - }, - "list-ordered": { - "body": "" - }, - "list-plus": { - "body": "" - }, - "list-restart": { - "body": "" - }, - "list-start": { - "body": "" - }, - "list-todo": { - "body": "" - }, - "list-tree": { - "body": "" - }, - "list-video": { - "body": "" - }, - "list-x": { - "body": "" - }, - "loader": { - "body": "" - }, - "loader-circle": { - "body": "" - }, - "loader-pinwheel": { - "body": "" - }, - "locate": { - "body": "" - }, - "locate-fixed": { - "body": "" - }, - "locate-off": { - "body": "" - }, - "lock": { - "body": "" - }, - "lock-keyhole": { - "body": "" - }, - "lock-keyhole-open": { - "body": "" - }, - "lock-open": { - "body": "" - }, - "log-in": { - "body": "" - }, - "log-out": { - "body": "" - }, - "logs": { - "body": "" - }, - "lollipop": { - "body": "" - }, - "luggage": { - "body": "" - }, - "magnet": { - "body": "" - }, - "mail": { - "body": "" - }, - "mail-check": { - "body": "" - }, - "mail-minus": { - "body": "" - }, - "mail-open": { - "body": "" - }, - "mail-plus": { - "body": "" - }, - "mail-question-mark": { - "body": "" - }, - "mail-search": { - "body": "" - }, - "mail-warning": { - "body": "" - }, - "mail-x": { - "body": "" - }, - "mailbox": { - "body": "" - }, - "mails": { - "body": "" - }, - "map": { - "body": "" - }, - "map-minus": { - "body": "" - }, - "map-pin": { - "body": "" - }, - "map-pin-check": { - "body": "" - }, - "map-pin-check-inside": { - "body": "" - }, - "map-pin-house": { - "body": "" - }, - "map-pin-minus": { - "body": "" - }, - "map-pin-minus-inside": { - "body": "" - }, - "map-pin-off": { - "body": "" - }, - "map-pin-pen": { - "body": "" - }, - "map-pin-plus": { - "body": "" - }, - "map-pin-plus-inside": { - "body": "" - }, - "map-pin-x": { - "body": "" - }, - "map-pin-x-inside": { - "body": "" - }, - "map-pinned": { - "body": "" - }, - "map-plus": { - "body": "" - }, - "mars": { - "body": "" - }, - "mars-stroke": { - "body": "" - }, - "martini": { - "body": "" - }, - "maximize": { - "body": "" - }, - "maximize-2": { - "body": "" - }, - "medal": { - "body": "" - }, - "megaphone": { - "body": "" - }, - "megaphone-off": { - "body": "" - }, - "meh": { - "body": "" - }, - "memory-stick": { - "body": "" - }, - "menu": { - "body": "" - }, - "merge": { - "body": "" - }, - "message-circle": { - "body": "" - }, - "message-circle-code": { - "body": "" - }, - "message-circle-dashed": { - "body": "" - }, - "message-circle-heart": { - "body": "" - }, - "message-circle-more": { - "body": "" - }, - "message-circle-off": { - "body": "" - }, - "message-circle-plus": { - "body": "" - }, - "message-circle-question-mark": { - "body": "" - }, - "message-circle-reply": { - "body": "" - }, - "message-circle-warning": { - "body": "" - }, - "message-circle-x": { - "body": "" - }, - "message-square": { - "body": "" - }, - "message-square-code": { - "body": "" - }, - "message-square-dashed": { - "body": "" - }, - "message-square-diff": { - "body": "" - }, - "message-square-dot": { - "body": "" - }, - "message-square-heart": { - "body": "" - }, - "message-square-lock": { - "body": "" - }, - "message-square-more": { - "body": "" - }, - "message-square-off": { - "body": "" - }, - "message-square-plus": { - "body": "" - }, - "message-square-quote": { - "body": "" - }, - "message-square-reply": { - "body": "" - }, - "message-square-share": { - "body": "" - }, - "message-square-text": { - "body": "" - }, - "message-square-warning": { - "body": "" - }, - "message-square-x": { - "body": "" - }, - "messages-square": { - "body": "" - }, - "mic": { - "body": "" - }, - "mic-off": { - "body": "" - }, - "mic-vocal": { - "body": "" - }, - "microchip": { - "body": "" - }, - "microscope": { - "body": "" - }, - "microwave": { - "body": "" - }, - "milestone": { - "body": "" - }, - "milk": { - "body": "" - }, - "milk-off": { - "body": "" - }, - "minimize": { - "body": "" - }, - "minimize-2": { - "body": "" - }, - "minus": { - "body": "" - }, - "monitor": { - "body": "" - }, - "monitor-check": { - "body": "" - }, - "monitor-cloud": { - "body": "" - }, - "monitor-cog": { - "body": "" - }, - "monitor-dot": { - "body": "" - }, - "monitor-down": { - "body": "" - }, - "monitor-off": { - "body": "" - }, - "monitor-pause": { - "body": "" - }, - "monitor-play": { - "body": "" - }, - "monitor-smartphone": { - "body": "" - }, - "monitor-speaker": { - "body": "" - }, - "monitor-stop": { - "body": "" - }, - "monitor-up": { - "body": "" - }, - "monitor-x": { - "body": "" - }, - "moon": { - "body": "" - }, - "moon-star": { - "body": "" - }, - "motorbike": { - "body": "" - }, - "mountain": { - "body": "" - }, - "mountain-snow": { - "body": "" - }, - "mouse": { - "body": "" - }, - "mouse-off": { - "body": "" - }, - "mouse-pointer": { - "body": "" - }, - "mouse-pointer-2": { - "body": "" - }, - "mouse-pointer-ban": { - "body": "" - }, - "mouse-pointer-click": { - "body": "" - }, - "move": { - "body": "" - }, - "move-3d": { - "body": "" - }, - "move-diagonal": { - "body": "" - }, - "move-diagonal-2": { - "body": "" - }, - "move-down": { - "body": "" - }, - "move-down-left": { - "body": "" - }, - "move-down-right": { - "body": "" - }, - "move-horizontal": { - "body": "" - }, - "move-left": { - "body": "" - }, - "move-right": { - "body": "" - }, - "move-up": { - "body": "" - }, - "move-up-left": { - "body": "" - }, - "move-up-right": { - "body": "" - }, - "move-vertical": { - "body": "" - }, - "music": { - "body": "" - }, - "music-2": { - "body": "" - }, - "music-3": { - "body": "" - }, - "music-4": { - "body": "" - }, - "navigation": { - "body": "" - }, - "navigation-2": { - "body": "" - }, - "navigation-2-off": { - "body": "" - }, - "navigation-off": { - "body": "" - }, - "network": { - "body": "" - }, - "newspaper": { - "body": "" - }, - "nfc": { - "body": "" - }, - "non-binary": { - "body": "" - }, - "notebook": { - "body": "" - }, - "notebook-pen": { - "body": "" - }, - "notebook-tabs": { - "body": "" - }, - "notebook-text": { - "body": "" - }, - "notepad-text": { - "body": "" - }, - "notepad-text-dashed": { - "body": "" - }, - "nut": { - "body": "" - }, - "nut-off": { - "body": "" - }, - "octagon": { - "body": "" - }, - "octagon-alert": { - "body": "" - }, - "octagon-minus": { - "body": "" - }, - "octagon-pause": { - "body": "" - }, - "octagon-x": { - "body": "" - }, - "omega": { - "body": "" - }, - "option": { - "body": "" - }, - "orbit": { - "body": "" - }, - "origami": { - "body": "" - }, - "package": { - "body": "" - }, - "package-2": { - "body": "" - }, - "package-check": { - "body": "" - }, - "package-minus": { - "body": "" - }, - "package-open": { - "body": "" - }, - "package-plus": { - "body": "" - }, - "package-search": { - "body": "" - }, - "package-x": { - "body": "" - }, - "paint-bucket": { - "body": "" - }, - "paint-roller": { - "body": "" - }, - "paintbrush": { - "body": "" - }, - "paintbrush-vertical": { - "body": "" - }, - "palette": { - "body": "" - }, - "panda": { - "body": "" - }, - "panel-bottom": { - "body": "" - }, - "panel-bottom-close": { - "body": "" - }, - "panel-bottom-dashed": { - "body": "" - }, - "panel-bottom-open": { - "body": "" - }, - "panel-left": { - "body": "" - }, - "panel-left-close": { - "body": "" - }, - "panel-left-dashed": { - "body": "" - }, - "panel-left-open": { - "body": "" - }, - "panel-left-right-dashed": { - "body": "" - }, - "panel-right": { - "body": "" - }, - "panel-right-close": { - "body": "" - }, - "panel-right-dashed": { - "body": "" - }, - "panel-right-open": { - "body": "" - }, - "panel-top": { - "body": "" - }, - "panel-top-bottom-dashed": { - "body": "" - }, - "panel-top-close": { - "body": "" - }, - "panel-top-dashed": { - "body": "" - }, - "panel-top-open": { - "body": "" - }, - "panels-left-bottom": { - "body": "" - }, - "panels-right-bottom": { - "body": "" - }, - "panels-top-left": { - "body": "" - }, - "paperclip": { - "body": "" - }, - "parentheses": { - "body": "" - }, - "parking-meter": { - "body": "" - }, - "party-popper": { - "body": "" - }, - "pause": { - "body": "" - }, - "paw-print": { - "body": "" - }, - "pc-case": { - "body": "" - }, - "pen": { - "body": "" - }, - "pen-line": { - "body": "" - }, - "pen-off": { - "body": "" - }, - "pen-tool": { - "body": "" - }, - "pencil": { - "body": "" - }, - "pencil-line": { - "body": "" - }, - "pencil-off": { - "body": "" - }, - "pencil-ruler": { - "body": "" - }, - "pentagon": { - "body": "" - }, - "percent": { - "body": "" - }, - "person-standing": { - "body": "" - }, - "philippine-peso": { - "body": "" - }, - "phone": { - "body": "" - }, - "phone-call": { - "body": "" - }, - "phone-forwarded": { - "body": "" - }, - "phone-incoming": { - "body": "" - }, - "phone-missed": { - "body": "" - }, - "phone-off": { - "body": "" - }, - "phone-outgoing": { - "body": "" - }, - "pi": { - "body": "" - }, - "piano": { - "body": "" - }, - "pickaxe": { - "body": "" - }, - "picture-in-picture": { - "body": "" - }, - "picture-in-picture-2": { - "body": "" - }, - "pie-chart": { - "body": "", - "hidden": true - }, - "piggy-bank": { - "body": "" - }, - "pilcrow": { - "body": "" - }, - "pilcrow-left": { - "body": "" - }, - "pilcrow-right": { - "body": "" - }, - "pill": { - "body": "" - }, - "pill-bottle": { - "body": "" - }, - "pin": { - "body": "" - }, - "pin-off": { - "body": "" - }, - "pipette": { - "body": "" - }, - "pizza": { - "body": "" - }, - "plane": { - "body": "" - }, - "plane-landing": { - "body": "" - }, - "plane-takeoff": { - "body": "" - }, - "play": { - "body": "" - }, - "plug": { - "body": "" - }, - "plug-2": { - "body": "" - }, - "plug-zap": { - "body": "" - }, - "plus": { - "body": "" - }, - "pocket": { - "body": "" - }, - "pocket-knife": { - "body": "" - }, - "podcast": { - "body": "" - }, - "pointer": { - "body": "" - }, - "pointer-off": { - "body": "" - }, - "popcorn": { - "body": "" - }, - "popsicle": { - "body": "" - }, - "pound-sterling": { - "body": "" - }, - "power": { - "body": "" - }, - "power-off": { - "body": "" - }, - "presentation": { - "body": "" - }, - "printer": { - "body": "" - }, - "printer-check": { - "body": "" - }, - "projector": { - "body": "" - }, - "proportions": { - "body": "" - }, - "puzzle": { - "body": "" - }, - "pyramid": { - "body": "" - }, - "qr-code": { - "body": "" - }, - "quote": { - "body": "" - }, - "rabbit": { - "body": "" - }, - "radar": { - "body": "" - }, - "radiation": { - "body": "" - }, - "radical": { - "body": "" - }, - "radio": { - "body": "" - }, - "radio-receiver": { - "body": "" - }, - "radio-tower": { - "body": "" - }, - "radius": { - "body": "" - }, - "rail-symbol": { - "body": "" - }, - "rainbow": { - "body": "" - }, - "rat": { - "body": "" - }, - "ratio": { - "body": "" - }, - "receipt": { - "body": "" - }, - "receipt-cent": { - "body": "" - }, - "receipt-euro": { - "body": "" - }, - "receipt-indian-rupee": { - "body": "" - }, - "receipt-japanese-yen": { - "body": "" - }, - "receipt-pound-sterling": { - "body": "" - }, - "receipt-russian-ruble": { - "body": "" - }, - "receipt-swiss-franc": { - "body": "" - }, - "receipt-text": { - "body": "" - }, - "receipt-turkish-lira": { - "body": "" - }, - "rectangle-circle": { - "body": "" - }, - "rectangle-ellipsis": { - "body": "" - }, - "rectangle-goggles": { - "body": "" - }, - "rectangle-horizontal": { - "body": "" - }, - "rectangle-vertical": { - "body": "" - }, - "recycle": { - "body": "" - }, - "redo": { - "body": "" - }, - "redo-2": { - "body": "" - }, - "redo-dot": { - "body": "" - }, - "refresh-ccw": { - "body": "" - }, - "refresh-ccw-dot": { - "body": "" - }, - "refresh-cw": { - "body": "" - }, - "refresh-cw-off": { - "body": "" - }, - "refrigerator": { - "body": "" - }, - "regex": { - "body": "" - }, - "remove-formatting": { - "body": "" - }, - "repeat": { - "body": "" - }, - "repeat-1": { - "body": "" - }, - "repeat-2": { - "body": "" - }, - "replace": { - "body": "" - }, - "replace-all": { - "body": "" - }, - "reply": { - "body": "" - }, - "reply-all": { - "body": "" - }, - "rewind": { - "body": "" - }, - "ribbon": { - "body": "" - }, - "rocket": { - "body": "" - }, - "rocking-chair": { - "body": "" - }, - "roller-coaster": { - "body": "" - }, - "rose": { - "body": "" - }, - "rotate-3d": { - "body": "" - }, - "rotate-ccw": { - "body": "" - }, - "rotate-ccw-key": { - "body": "" - }, - "rotate-ccw-square": { - "body": "" - }, - "rotate-cw": { - "body": "" - }, - "rotate-cw-square": { - "body": "" - }, - "route": { - "body": "" - }, - "route-off": { - "body": "" - }, - "router": { - "body": "" - }, - "rows-2": { - "body": "" - }, - "rows-3": { - "body": "" - }, - "rows-4": { - "body": "" - }, - "rss": { - "body": "" - }, - "ruler": { - "body": "" - }, - "ruler-dimension-line": { - "body": "" - }, - "russian-ruble": { - "body": "" - }, - "sailboat": { - "body": "" - }, - "salad": { - "body": "" - }, - "sandwich": { - "body": "" - }, - "satellite": { - "body": "" - }, - "satellite-dish": { - "body": "" - }, - "saudi-riyal": { - "body": "" - }, - "save": { - "body": "" - }, - "save-all": { - "body": "" - }, - "save-off": { - "body": "" - }, - "scale": { - "body": "" - }, - "scale-3d": { - "body": "" - }, - "scaling": { - "body": "" - }, - "scan": { - "body": "" - }, - "scan-barcode": { - "body": "" - }, - "scan-eye": { - "body": "" - }, - "scan-face": { - "body": "" - }, - "scan-heart": { - "body": "" - }, - "scan-line": { - "body": "" - }, - "scan-qr-code": { - "body": "" - }, - "scan-search": { - "body": "" - }, - "scan-text": { - "body": "" - }, - "scatter-chart": { - "body": "", - "hidden": true - }, - "school": { - "body": "" - }, - "scissors": { - "body": "" - }, - "scissors-line-dashed": { - "body": "" - }, - "screen-share": { - "body": "" - }, - "screen-share-off": { - "body": "" - }, - "scroll": { - "body": "" - }, - "scroll-text": { - "body": "" - }, - "search": { - "body": "" - }, - "search-check": { - "body": "" - }, - "search-code": { - "body": "" - }, - "search-large": { - "body": "", - "width": 32, - "height": 32, - "hidden": true - }, - "search-slash": { - "body": "" - }, - "search-x": { - "body": "" - }, - "section": { - "body": "" - }, - "send": { - "body": "" - }, - "send-horizontal": { - "body": "" - }, - "send-to-back": { - "body": "" - }, - "separator-horizontal": { - "body": "" - }, - "separator-vertical": { - "body": "" - }, - "server": { - "body": "" - }, - "server-cog": { - "body": "" - }, - "server-crash": { - "body": "" - }, - "server-off": { - "body": "" - }, - "settings": { - "body": "" - }, - "settings-2": { - "body": "" - }, - "shapes": { - "body": "" - }, - "share": { - "body": "" - }, - "share-2": { - "body": "" - }, - "sheet": { - "body": "" - }, - "shell": { - "body": "" - }, - "shield": { - "body": "" - }, - "shield-alert": { - "body": "" - }, - "shield-ban": { - "body": "" - }, - "shield-check": { - "body": "" - }, - "shield-ellipsis": { - "body": "" - }, - "shield-half": { - "body": "" - }, - "shield-minus": { - "body": "" - }, - "shield-off": { - "body": "" - }, - "shield-plus": { - "body": "" - }, - "shield-question-mark": { - "body": "" - }, - "shield-user": { - "body": "" - }, - "shield-x": { - "body": "" - }, - "ship": { - "body": "" - }, - "ship-wheel": { - "body": "" - }, - "shirt": { - "body": "" - }, - "shopping-bag": { - "body": "" - }, - "shopping-basket": { - "body": "" - }, - "shopping-cart": { - "body": "" - }, - "shovel": { - "body": "" - }, - "shower-head": { - "body": "" - }, - "shredder": { - "body": "" - }, - "shrimp": { - "body": "" - }, - "shrink": { - "body": "" - }, - "shrub": { - "body": "" - }, - "shuffle": { - "body": "" - }, - "sigma": { - "body": "" - }, - "signal": { - "body": "" - }, - "signal-high": { - "body": "" - }, - "signal-low": { - "body": "" - }, - "signal-medium": { - "body": "" - }, - "signal-zero": { - "body": "" - }, - "signature": { - "body": "" - }, - "signpost": { - "body": "" - }, - "signpost-big": { - "body": "" - }, - "siren": { - "body": "" - }, - "skip-back": { - "body": "" - }, - "skip-forward": { - "body": "" - }, - "skull": { - "body": "" - }, - "slack": { - "body": "" - }, - "slash": { - "body": "" - }, - "slice": { - "body": "" - }, - "sliders-horizontal": { - "body": "" - }, - "sliders-vertical": { - "body": "" - }, - "smartphone": { - "body": "" - }, - "smartphone-charging": { - "body": "" - }, - "smartphone-nfc": { - "body": "" - }, - "smile": { - "body": "" - }, - "smile-plus": { - "body": "" - }, - "snail": { - "body": "" - }, - "snowflake": { - "body": "" - }, - "soap-dispenser-droplet": { - "body": "" - }, - "sofa": { - "body": "" - }, - "soup": { - "body": "" - }, - "space": { - "body": "" - }, - "spade": { - "body": "" - }, - "sparkle": { - "body": "" - }, - "sparkles": { - "body": "" - }, - "speaker": { - "body": "" - }, - "speech": { - "body": "" - }, - "spell-check": { - "body": "" - }, - "spell-check-2": { - "body": "" - }, - "spline": { - "body": "" - }, - "spline-pointer": { - "body": "" - }, - "split": { - "body": "" - }, - "spool": { - "body": "" - }, - "spotlight": { - "body": "" - }, - "spray-can": { - "body": "" - }, - "sprout": { - "body": "" - }, - "square": { - "body": "" - }, - "square-activity": { - "body": "" - }, - "square-arrow-down": { - "body": "" - }, - "square-arrow-down-left": { - "body": "" - }, - "square-arrow-down-right": { - "body": "" - }, - "square-arrow-left": { - "body": "" - }, - "square-arrow-out-down-left": { - "body": "" - }, - "square-arrow-out-down-right": { - "body": "" - }, - "square-arrow-out-up-left": { - "body": "" - }, - "square-arrow-out-up-right": { - "body": "" - }, - "square-arrow-right": { - "body": "" - }, - "square-arrow-up": { - "body": "" - }, - "square-arrow-up-left": { - "body": "" - }, - "square-arrow-up-right": { - "body": "" - }, - "square-asterisk": { - "body": "" - }, - "square-bottom-dashed-scissors": { - "body": "" - }, - "square-chart-gantt": { - "body": "" - }, - "square-check": { - "body": "" - }, - "square-check-big": { - "body": "" - }, - "square-chevron-down": { - "body": "" - }, - "square-chevron-left": { - "body": "" - }, - "square-chevron-right": { - "body": "" - }, - "square-chevron-up": { - "body": "" - }, - "square-code": { - "body": "" - }, - "square-dashed": { - "body": "" - }, - "square-dashed-bottom": { - "body": "" - }, - "square-dashed-bottom-code": { - "body": "" - }, - "square-dashed-kanban": { - "body": "" - }, - "square-dashed-mouse-pointer": { - "body": "" - }, - "square-dashed-top-solid": { - "body": "" - }, - "square-divide": { - "body": "" - }, - "square-dot": { - "body": "" - }, - "square-equal": { - "body": "" - }, - "square-function": { - "body": "" - }, - "square-kanban": { - "body": "" - }, - "square-library": { - "body": "" - }, - "square-m": { - "body": "" - }, - "square-menu": { - "body": "" - }, - "square-minus": { - "body": "" - }, - "square-mouse-pointer": { - "body": "" - }, - "square-parking": { - "body": "" - }, - "square-parking-off": { - "body": "" - }, - "square-pause": { - "body": "" - }, - "square-pen": { - "body": "" - }, - "square-percent": { - "body": "" - }, - "square-pi": { - "body": "" - }, - "square-pilcrow": { - "body": "" - }, - "square-play": { - "body": "" - }, - "square-plus": { - "body": "" - }, - "square-power": { - "body": "" - }, - "square-radical": { - "body": "" - }, - "square-round-corner": { - "body": "" - }, - "square-scissors": { - "body": "" - }, - "square-sigma": { - "body": "" - }, - "square-slash": { - "body": "" - }, - "square-split-horizontal": { - "body": "" - }, - "square-split-vertical": { - "body": "" - }, - "square-square": { - "body": "" - }, - "square-stack": { - "body": "" - }, - "square-star": { - "body": "" - }, - "square-stop": { - "body": "" - }, - "square-terminal": { - "body": "" - }, - "square-user": { - "body": "" - }, - "square-user-round": { - "body": "" - }, - "square-x": { - "body": "" - }, - "squares-exclude": { - "body": "" - }, - "squares-intersect": { - "body": "" - }, - "squares-subtract": { - "body": "" - }, - "squares-unite": { - "body": "" - }, - "squircle": { - "body": "" - }, - "squircle-dashed": { - "body": "" - }, - "squirrel": { - "body": "" - }, - "stamp": { - "body": "" - }, - "star": { - "body": "" - }, - "star-half": { - "body": "" - }, - "star-off": { - "body": "" - }, - "step-back": { - "body": "" - }, - "step-forward": { - "body": "" - }, - "stethoscope": { - "body": "" - }, - "sticker": { - "body": "" - }, - "sticky-note": { - "body": "" - }, - "store": { - "body": "" - }, - "stretch-horizontal": { - "body": "" - }, - "stretch-vertical": { - "body": "" - }, - "strikethrough": { - "body": "" - }, - "subscript": { - "body": "" - }, - "sun": { - "body": "" - }, - "sun-dim": { - "body": "" - }, - "sun-medium": { - "body": "" - }, - "sun-moon": { - "body": "" - }, - "sun-snow": { - "body": "" - }, - "sunrise": { - "body": "" - }, - "sunset": { - "body": "" - }, - "superscript": { - "body": "" - }, - "swatch-book": { - "body": "" - }, - "swiss-franc": { - "body": "" - }, - "switch-camera": { - "body": "" - }, - "sword": { - "body": "" - }, - "swords": { - "body": "" - }, - "syringe": { - "body": "" - }, - "table": { - "body": "" - }, - "table-2": { - "body": "" - }, - "table-cells-merge": { - "body": "" - }, - "table-cells-split": { - "body": "" - }, - "table-columns-split": { - "body": "" - }, - "table-of-contents": { - "body": "" - }, - "table-properties": { - "body": "" - }, - "table-rows-split": { - "body": "" - }, - "tablet": { - "body": "" - }, - "tablet-smartphone": { - "body": "" - }, - "tablets": { - "body": "" - }, - "tag": { - "body": "" - }, - "tags": { - "body": "" - }, - "tally-1": { - "body": "" - }, - "tally-2": { - "body": "" - }, - "tally-3": { - "body": "" - }, - "tally-4": { - "body": "" - }, - "tally-5": { - "body": "" - }, - "tangent": { - "body": "" - }, - "target": { - "body": "" - }, - "telescope": { - "body": "" - }, - "tent": { - "body": "" - }, - "tent-tree": { - "body": "" - }, - "terminal": { - "body": "" - }, - "test-tube": { - "body": "" - }, - "test-tube-diagonal": { - "body": "" - }, - "test-tubes": { - "body": "" - }, - "text": { - "body": "", - "hidden": true - }, - "text-align-center": { - "body": "" - }, - "text-align-end": { - "body": "" - }, - "text-align-justify": { - "body": "" - }, - "text-align-start": { - "body": "" - }, - "text-cursor": { - "body": "" - }, - "text-cursor-input": { - "body": "" - }, - "text-initial": { - "body": "" - }, - "text-quote": { - "body": "" - }, - "text-search": { - "body": "" - }, - "text-select": { - "body": "" - }, - "text-wrap": { - "body": "" - }, - "theater": { - "body": "" - }, - "thermometer": { - "body": "" - }, - "thermometer-snowflake": { - "body": "" - }, - "thermometer-sun": { - "body": "" - }, - "thumbs-down": { - "body": "" - }, - "thumbs-up": { - "body": "" - }, - "ticket": { - "body": "" - }, - "ticket-check": { - "body": "" - }, - "ticket-minus": { - "body": "" - }, - "ticket-percent": { - "body": "" - }, - "ticket-plus": { - "body": "" - }, - "ticket-slash": { - "body": "" - }, - "ticket-x": { - "body": "" - }, - "tickets": { - "body": "" - }, - "tickets-plane": { - "body": "" - }, - "timer": { - "body": "" - }, - "timer-off": { - "body": "" - }, - "timer-reset": { - "body": "" - }, - "toggle-left": { - "body": "" - }, - "toggle-right": { - "body": "" - }, - "toilet": { - "body": "" - }, - "tool-case": { - "body": "" - }, - "tornado": { - "body": "" - }, - "torus": { - "body": "" - }, - "touchpad": { - "body": "" - }, - "touchpad-off": { - "body": "" - }, - "tower-control": { - "body": "" - }, - "toy-brick": { - "body": "" - }, - "tractor": { - "body": "" - }, - "traffic-cone": { - "body": "" - }, - "train-front": { - "body": "" - }, - "train-front-tunnel": { - "body": "" - }, - "train-track": { - "body": "" - }, - "tram-front": { - "body": "" - }, - "transgender": { - "body": "" - }, - "trash": { - "body": "" - }, - "trash-2": { - "body": "" - }, - "tree-deciduous": { - "body": "" - }, - "tree-palm": { - "body": "" - }, - "tree-pine": { - "body": "" - }, - "trees": { - "body": "" - }, - "trello": { - "body": "" - }, - "trending-down": { - "body": "" - }, - "trending-up": { - "body": "" - }, - "trending-up-down": { - "body": "" - }, - "triangle": { - "body": "" - }, - "triangle-alert": { - "body": "" - }, - "triangle-dashed": { - "body": "" - }, - "triangle-right": { - "body": "" - }, - "trophy": { - "body": "" - }, - "truck": { - "body": "" - }, - "truck-electric": { - "body": "" - }, - "turkish-lira": { - "body": "" - }, - "turntable": { - "body": "" - }, - "turtle": { - "body": "" - }, - "tv": { - "body": "" - }, - "tv-minimal": { - "body": "" - }, - "tv-minimal-play": { - "body": "" - }, - "twitch": { - "body": "" - }, - "twitter": { - "body": "" - }, - "type": { - "body": "" - }, - "type-outline": { - "body": "" - }, - "umbrella": { - "body": "" - }, - "umbrella-off": { - "body": "" - }, - "underline": { - "body": "" - }, - "undo": { - "body": "" - }, - "undo-2": { - "body": "" - }, - "undo-dot": { - "body": "" - }, - "unfold-horizontal": { - "body": "" - }, - "unfold-vertical": { - "body": "" - }, - "ungroup": { - "body": "" - }, - "university": { - "body": "" - }, - "unlink": { - "body": "" - }, - "unlink-2": { - "body": "" - }, - "unplug": { - "body": "" - }, - "upload": { - "body": "" - }, - "usb": { - "body": "" - }, - "user": { - "body": "" - }, - "user-check": { - "body": "" - }, - "user-cog": { - "body": "" - }, - "user-lock": { - "body": "" - }, - "user-minus": { - "body": "" - }, - "user-pen": { - "body": "" - }, - "user-plus": { - "body": "" - }, - "user-round": { - "body": "" - }, - "user-round-check": { - "body": "" - }, - "user-round-cog": { - "body": "" - }, - "user-round-minus": { - "body": "" - }, - "user-round-pen": { - "body": "" - }, - "user-round-plus": { - "body": "" - }, - "user-round-search": { - "body": "" - }, - "user-round-x": { - "body": "" - }, - "user-search": { - "body": "" - }, - "user-star": { - "body": "" - }, - "user-x": { - "body": "" - }, - "users": { - "body": "" - }, - "users-round": { - "body": "" - }, - "utensils": { - "body": "" - }, - "utensils-crossed": { - "body": "" - }, - "utility-pole": { - "body": "" - }, - "variable": { - "body": "" - }, - "vault": { - "body": "" - }, - "vector-square": { - "body": "" - }, - "vegan": { - "body": "" - }, - "venetian-mask": { - "body": "" - }, - "venus": { - "body": "" - }, - "venus-and-mars": { - "body": "" - }, - "vibrate": { - "body": "" - }, - "vibrate-off": { - "body": "" - }, - "video": { - "body": "" - }, - "video-off": { - "body": "" - }, - "videotape": { - "body": "" - }, - "view": { - "body": "" - }, - "voicemail": { - "body": "" - }, - "volleyball": { - "body": "" - }, - "volume": { - "body": "" - }, - "volume-1": { - "body": "" - }, - "volume-2": { - "body": "" - }, - "volume-off": { - "body": "" - }, - "volume-x": { - "body": "" - }, - "vote": { - "body": "" - }, - "wallet": { - "body": "" - }, - "wallet-cards": { - "body": "" - }, - "wallet-minimal": { - "body": "" - }, - "wallpaper": { - "body": "" - }, - "wand": { - "body": "" - }, - "wand-sparkles": { - "body": "" - }, - "warehouse": { - "body": "" - }, - "washing-machine": { - "body": "" - }, - "watch": { - "body": "" - }, - "waves": { - "body": "" - }, - "waves-ladder": { - "body": "" - }, - "waypoints": { - "body": "" - }, - "webcam": { - "body": "" - }, - "webhook": { - "body": "" - }, - "webhook-off": { - "body": "" - }, - "weight": { - "body": "" - }, - "wheat": { - "body": "" - }, - "wheat-off": { - "body": "" - }, - "whole-word": { - "body": "" - }, - "wifi": { - "body": "" - }, - "wifi-cog": { - "body": "" - }, - "wifi-high": { - "body": "" - }, - "wifi-low": { - "body": "" - }, - "wifi-off": { - "body": "" - }, - "wifi-pen": { - "body": "" - }, - "wifi-sync": { - "body": "" - }, - "wifi-zero": { - "body": "" - }, - "wind": { - "body": "" - }, - "wind-arrow-down": { - "body": "" - }, - "wine": { - "body": "" - }, - "wine-off": { - "body": "" - }, - "workflow": { - "body": "" - }, - "worm": { - "body": "" - }, - "wrap-text": { - "body": "", - "hidden": true - }, - "wrench": { - "body": "" - }, - "x": { - "body": "" - }, - "youtube": { - "body": "" - }, - "zap": { - "body": "" - }, - "zap-off": { - "body": "" - }, - "zoom-in": { - "body": "" - }, - "zoom-out": { - "body": "" - } - }, - "aliases": { - "activity-square": { - "parent": "square-activity" - }, - "alarm-check": { - "parent": "alarm-clock-check" - }, - "alarm-minus": { - "parent": "alarm-clock-minus" - }, - "alarm-plus": { - "parent": "alarm-clock-plus" - }, - "alert-circle": { - "parent": "circle-alert" - }, - "alert-octagon": { - "parent": "octagon-alert" - }, - "alert-triangle": { - "parent": "triangle-alert" - }, - "align-horizonal-distribute-center": { - "parent": "align-horizontal-distribute-center" - }, - "align-horizonal-distribute-end": { - "parent": "align-horizontal-distribute-end" - }, - "align-horizonal-distribute-start": { - "parent": "align-horizontal-distribute-start" - }, - "arrow-down-01": { - "parent": "arrow-down-0-1" - }, - "arrow-down-10": { - "parent": "arrow-down-1-0" - }, - "arrow-down-az": { - "parent": "arrow-down-a-z" - }, - "arrow-down-circle": { - "parent": "circle-arrow-down" - }, - "arrow-down-left-from-circle": { - "parent": "circle-arrow-out-down-left" - }, - "arrow-down-left-from-square": { - "parent": "square-arrow-out-down-left" - }, - "arrow-down-left-square": { - "parent": "square-arrow-down-left" - }, - "arrow-down-right-from-circle": { - "parent": "circle-arrow-out-down-right" - }, - "arrow-down-right-from-square": { - "parent": "square-arrow-out-down-right" - }, - "arrow-down-right-square": { - "parent": "square-arrow-down-right" - }, - "arrow-down-square": { - "parent": "square-arrow-down" - }, - "arrow-down-za": { - "parent": "arrow-down-z-a" - }, - "arrow-left-circle": { - "parent": "circle-arrow-left" - }, - "arrow-left-square": { - "parent": "square-arrow-left" - }, - "arrow-right-circle": { - "parent": "circle-arrow-right" - }, - "arrow-right-square": { - "parent": "square-arrow-right" - }, - "arrow-up-01": { - "parent": "arrow-up-0-1" - }, - "arrow-up-10": { - "parent": "arrow-up-1-0" - }, - "arrow-up-az": { - "parent": "arrow-up-a-z" - }, - "arrow-up-circle": { - "parent": "circle-arrow-up" - }, - "arrow-up-left-from-circle": { - "parent": "circle-arrow-out-up-left" - }, - "arrow-up-left-from-square": { - "parent": "square-arrow-out-up-left" - }, - "arrow-up-left-square": { - "parent": "square-arrow-up-left" - }, - "arrow-up-right-from-circle": { - "parent": "circle-arrow-out-up-right" - }, - "arrow-up-right-from-square": { - "parent": "square-arrow-out-up-right" - }, - "arrow-up-right-square": { - "parent": "square-arrow-up-right" - }, - "arrow-up-square": { - "parent": "square-arrow-up" - }, - "arrow-up-za": { - "parent": "arrow-up-z-a" - }, - "asterisk-square": { - "parent": "square-asterisk" - }, - "axis-3-d": { - "parent": "axis-3d" - }, - "badge-help": { - "parent": "badge-question-mark" - }, - "bar-chart": { - "parent": "chart-no-axes-column-increasing" - }, - "bar-chart-2": { - "parent": "chart-no-axes-column" - }, - "between-horizonal-end": { - "parent": "between-horizontal-end" - }, - "between-horizonal-start": { - "parent": "between-horizontal-start" - }, - "book-template": { - "parent": "book-dashed" - }, - "box-select": { - "parent": "square-dashed" - }, - "check-circle": { - "parent": "circle-check-big" - }, - "check-circle-2": { - "parent": "circle-check" - }, - "check-square": { - "parent": "square-check-big" - }, - "check-square-2": { - "parent": "square-check" - }, - "chevron-down-circle": { - "parent": "circle-chevron-down" - }, - "chevron-down-square": { - "parent": "square-chevron-down" - }, - "chevron-left-circle": { - "parent": "circle-chevron-left" - }, - "chevron-left-square": { - "parent": "square-chevron-left" - }, - "chevron-right-circle": { - "parent": "circle-chevron-right" - }, - "chevron-right-square": { - "parent": "square-chevron-right" - }, - "chevron-up-circle": { - "parent": "circle-chevron-up" - }, - "chevron-up-square": { - "parent": "square-chevron-up" - }, - "circle-help": { - "parent": "circle-question-mark" - }, - "circle-slashed": { - "parent": "circle-slash-2" - }, - "clipboard-edit": { - "parent": "clipboard-pen" - }, - "clipboard-signature": { - "parent": "clipboard-pen-line" - }, - "code-2": { - "parent": "code-xml" - }, - "code-square": { - "parent": "square-code" - }, - "columns": { - "parent": "columns-2" - }, - "columns-settings": { - "parent": "columns-3-cog" - }, - "contact-2": { - "parent": "contact-round" - }, - "curly-braces": { - "parent": "braces" - }, - "divide-circle": { - "parent": "circle-divide" - }, - "divide-square": { - "parent": "square-divide" - }, - "dot-square": { - "parent": "square-dot" - }, - "download-cloud": { - "parent": "cloud-download" - }, - "edit": { - "parent": "square-pen" - }, - "edit-2": { - "parent": "pen" - }, - "edit-3": { - "parent": "pen-line" - }, - "equal-square": { - "parent": "square-equal" - }, - "file-axis-3-d": { - "parent": "file-axis-3d" - }, - "file-bar-chart": { - "parent": "file-chart-column-increasing" - }, - "file-bar-chart-2": { - "parent": "file-chart-column" - }, - "file-cog-2": { - "parent": "file-cog" - }, - "file-edit": { - "parent": "file-pen" - }, - "file-line-chart": { - "parent": "file-chart-line" - }, - "file-question": { - "parent": "file-question-mark" - }, - "file-signature": { - "parent": "file-pen-line" - }, - "file-video": { - "parent": "file-play" - }, - "file-video-2": { - "parent": "file-video-camera" - }, - "folder-cog-2": { - "parent": "folder-cog" - }, - "folder-edit": { - "parent": "folder-pen" - }, - "fork-knife": { - "parent": "utensils" - }, - "fork-knife-crossed": { - "parent": "utensils-crossed" - }, - "form-input": { - "parent": "rectangle-ellipsis" - }, - "function-square": { - "parent": "square-function" - }, - "gantt-chart": { - "parent": "chart-no-axes-gantt" - }, - "gantt-chart-square": { - "parent": "square-chart-gantt" - }, - "gauge-circle": { - "parent": "circle-gauge" - }, - "git-commit": { - "parent": "git-commit-horizontal" - }, - "globe-2": { - "parent": "earth" - }, - "grab": { - "parent": "hand-grab" - }, - "grid": { - "parent": "grid-3x3" - }, - "grid-2-x-2": { - "parent": "grid-2x2" - }, - "grid-2-x-2-check": { - "parent": "grid-2x2-check" - }, - "grid-2-x-2-plus": { - "parent": "grid-2x2-plus" - }, - "grid-2-x-2-x": { - "parent": "grid-2x2-x" - }, - "grid-3-x-3": { - "parent": "grid-3x3" - }, - "help-circle": { - "parent": "circle-question-mark" - }, - "helping-hand": { - "parent": "hand-helping" - }, - "home": { - "parent": "house" - }, - "ice-cream": { - "parent": "ice-cream-cone" - }, - "ice-cream-2": { - "parent": "ice-cream-bowl" - }, - "indent": { - "parent": "indent-increase" - }, - "inspect": { - "parent": "square-mouse-pointer" - }, - "jersey-pound": { - "parent": "japanese-yen" - }, - "kanban-square": { - "parent": "square-kanban" - }, - "kanban-square-dashed": { - "parent": "square-dashed-kanban" - }, - "laptop-2": { - "parent": "laptop-minimal" - }, - "layout": { - "parent": "panels-top-left" - }, - "library-square": { - "parent": "square-library" - }, - "loader-2": { - "parent": "loader-circle" - }, - "location-edit": { - "parent": "map-pin-pen" - }, - "m-square": { - "parent": "square-m" - }, - "mail-question": { - "parent": "mail-question-mark" - }, - "menu-square": { - "parent": "square-menu" - }, - "message-circle-question": { - "parent": "message-circle-question-mark" - }, - "mic-2": { - "parent": "mic-vocal" - }, - "minus-circle": { - "parent": "circle-minus" - }, - "minus-square": { - "parent": "square-minus" - }, - "more-horizontal": { - "parent": "ellipsis" - }, - "more-vertical": { - "parent": "ellipsis-vertical" - }, - "mouse-pointer-square": { - "parent": "square-mouse-pointer" - }, - "mouse-pointer-square-dashed": { - "parent": "square-dashed-mouse-pointer" - }, - "move-3-d": { - "parent": "move-3d" - }, - "outdent": { - "parent": "indent-decrease" - }, - "paintbrush-2": { - "parent": "paintbrush-vertical" - }, - "palmtree": { - "parent": "tree-palm" - }, - "panel-bottom-inactive": { - "parent": "panel-bottom-dashed" - }, - "panel-left-inactive": { - "parent": "panel-left-dashed" - }, - "panel-right-inactive": { - "parent": "panel-right-dashed" - }, - "panel-top-inactive": { - "parent": "panel-top-dashed" - }, - "panels-left-right": { - "parent": "columns-3" - }, - "panels-top-bottom": { - "parent": "rows-3" - }, - "parking-circle": { - "parent": "circle-parking" - }, - "parking-circle-off": { - "parent": "circle-parking-off" - }, - "parking-square": { - "parent": "square-parking" - }, - "parking-square-off": { - "parent": "square-parking-off" - }, - "pause-circle": { - "parent": "circle-pause" - }, - "pause-octagon": { - "parent": "octagon-pause" - }, - "pen-box": { - "parent": "square-pen" - }, - "pen-square": { - "parent": "square-pen" - }, - "percent-circle": { - "parent": "circle-percent" - }, - "percent-diamond": { - "parent": "diamond-percent" - }, - "percent-square": { - "parent": "square-percent" - }, - "pi-square": { - "parent": "square-pi" - }, - "pilcrow-square": { - "parent": "square-pilcrow" - }, - "play-circle": { - "parent": "circle-play" - }, - "play-square": { - "parent": "square-play" - }, - "plug-zap-2": { - "parent": "plug-zap" - }, - "plus-circle": { - "parent": "circle-plus" - }, - "plus-square": { - "parent": "square-plus" - }, - "power-circle": { - "parent": "circle-power" - }, - "power-square": { - "parent": "square-power" - }, - "rotate-3-d": { - "parent": "rotate-3d" - }, - "rows": { - "parent": "rows-2" - }, - "scale-3-d": { - "parent": "scale-3d" - }, - "school-2": { - "parent": "university" - }, - "scissors-square": { - "parent": "square-scissors" - }, - "scissors-square-dashed-bottom": { - "parent": "square-bottom-dashed-scissors" - }, - "send-horizonal": { - "parent": "send-horizontal" - }, - "shield-close": { - "parent": "shield-x" - }, - "shield-question": { - "parent": "shield-question-mark" - }, - "sidebar": { - "parent": "panel-left" - }, - "sidebar-close": { - "parent": "panel-left-close" - }, - "sidebar-open": { - "parent": "panel-left-open" - }, - "sigma-square": { - "parent": "square-sigma" - }, - "slash-square": { - "parent": "square-slash" - }, - "sliders": { - "parent": "sliders-vertical" - }, - "sort-asc": { - "parent": "arrow-up-narrow-wide" - }, - "sort-desc": { - "parent": "arrow-down-wide-narrow" - }, - "split-square-horizontal": { - "parent": "square-split-horizontal" - }, - "split-square-vertical": { - "parent": "square-split-vertical" - }, - "square-gantt": { - "parent": "square-chart-gantt" - }, - "square-gantt-chart": { - "parent": "square-chart-gantt" - }, - "square-kanban-dashed": { - "parent": "square-dashed-kanban" - }, - "stars": { - "parent": "sparkles" - }, - "stop-circle": { - "parent": "circle-stop" - }, - "subtitles": { - "parent": "captions" - }, - "table-config": { - "parent": "columns-3-cog" - }, - "terminal-square": { - "parent": "square-terminal" - }, - "test-tube-2": { - "parent": "test-tube-diagonal" - }, - "text-selection": { - "parent": "text-select" - }, - "train": { - "parent": "tram-front" - }, - "tv-2": { - "parent": "tv-minimal" - }, - "unlock": { - "parent": "lock-open" - }, - "unlock-keyhole": { - "parent": "lock-keyhole-open" - }, - "upload-cloud": { - "parent": "cloud-upload" - }, - "user-2": { - "parent": "user-round" - }, - "user-check-2": { - "parent": "user-round-check" - }, - "user-circle": { - "parent": "circle-user" - }, - "user-circle-2": { - "parent": "circle-user-round" - }, - "user-cog-2": { - "parent": "user-round-cog" - }, - "user-minus-2": { - "parent": "user-round-minus" - }, - "user-plus-2": { - "parent": "user-round-plus" - }, - "user-square": { - "parent": "square-user" - }, - "user-square-2": { - "parent": "square-user-round" - }, - "user-x-2": { - "parent": "user-round-x" - }, - "users-2": { - "parent": "users-round" - }, - "verified": { - "parent": "badge-check" - }, - "wallet-2": { - "parent": "wallet-minimal" - }, - "wand-2": { - "parent": "wand-sparkles" - }, - "x-circle": { - "parent": "circle-x" - }, - "x-octagon": { - "parent": "octagon-x" - }, - "x-square": { - "parent": "square-x" - } - }, - "width": 24, - "height": 24 -} \ No newline at end of file diff --git a/tools/icon-generation/generate-feather.js b/tools/icon-generation/generate-feather.js deleted file mode 100644 index 410f77bd7..000000000 --- a/tools/icon-generation/generate-feather.js +++ /dev/null @@ -1,103 +0,0 @@ -// Node.js script to convert feather.json (IconifyJSON format) to C# dictionary code - -const fs = require('fs'); -const path = require('path'); - -const jsonPath = path.join(__dirname, 'data/feather-icons.json'); -const outputPath = path.join(__dirname, '../../src/BlazorBlueprint.Icons.Feather/Data/FeatherIconData.cs'); - -console.log('Reading Feather icon data from', jsonPath); -const jsonContent = fs.readFileSync(jsonPath, 'utf8'); -const data = JSON.parse(jsonContent); - -const allIcons = data.icons; -const iconCount = Object.keys(allIcons).length; -console.log(`Found ${iconCount} icons`); - -// Helper function to escape C# strings -function escapeCSharp(str) { - return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); -} - -// Helper function to generate dictionary entries -function generateDictionaryEntries(icons, indent) { - const entries = []; - const sortedKeys = Object.keys(icons).sort(); - - for (let i = 0; i < sortedKeys.length; i++) { - const iconName = sortedKeys[i]; - const iconBody = icons[iconName].body; - const escapedBody = escapeCSharp(iconBody); - const comma = i < sortedKeys.length - 1 ? ',' : ''; - entries.push(`${indent}["${iconName}"] = "${escapedBody}"${comma}`); - } - - return entries.join('\n'); -} - -// Create Data directory if it doesn't exist -const dataDir = path.dirname(outputPath); -if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir, { recursive: true }); - console.log('Created Data directory'); -} - -// Build the C# file content -const lines = []; - -lines.push('// This file is auto-generated. Do not edit manually.'); -lines.push(`// Generated from feather.json on ${new Date().toISOString().split('T')[0]}`); -lines.push(''); -lines.push('namespace BlazorBlueprint.Icons.Feather.Data;'); -lines.push(''); -lines.push('/// '); -lines.push('/// Provides access to Feather icon SVG data.'); -lines.push(`/// Contains ${iconCount} icons from the Feather icon set.`); -lines.push('/// '); -lines.push('public static class FeatherIconData'); -lines.push('{'); -lines.push(' private static readonly IReadOnlyDictionary Icons = new Dictionary(StringComparer.OrdinalIgnoreCase)'); -lines.push(' {'); -lines.push(generateDictionaryEntries(allIcons, ' ')); -lines.push(' };'); -lines.push(''); - -// GetIcon method -lines.push(' /// '); -lines.push(' /// Retrieves the SVG content for the specified icon name.'); -lines.push(' /// '); -lines.push(' /// The name of the icon (case-insensitive).'); -lines.push(' /// The SVG path data for the icon, or null if not found.'); -lines.push(' public static string? GetIcon(string name) => Icons.TryGetValue(name, out var svg) ? svg : null;'); -lines.push(''); - -// GetAvailableIcons method -lines.push(' /// '); -lines.push(' /// Gets all available icon names.'); -lines.push(' /// '); -lines.push(' /// An enumerable collection of icon names.'); -lines.push(' public static IEnumerable GetAvailableIcons() => Icons.Keys;'); -lines.push(''); - -// IconExists method -lines.push(' /// '); -lines.push(' /// Checks if an icon with the specified name exists.'); -lines.push(' /// '); -lines.push(' /// The name of the icon (case-insensitive).'); -lines.push(' /// True if the icon exists, false otherwise.'); -lines.push(' public static bool IconExists(string name) => Icons.ContainsKey(name);'); -lines.push(''); - -// IconCount property -lines.push(' /// '); -lines.push(' /// Gets the total number of available icons.'); -lines.push(' /// '); -lines.push(' public static int IconCount => Icons.Count;'); -lines.push('}'); - -// Write to file -const outputContent = lines.join('\n'); -fs.writeFileSync(outputPath, outputContent, 'utf8'); - -console.log('\u2713 Generated C# file:', outputPath); -console.log('\u2713 Total icons:', iconCount); diff --git a/tools/icon-generation/generate-heroicons.js b/tools/icon-generation/generate-heroicons.js deleted file mode 100644 index 236f9f696..000000000 --- a/tools/icon-generation/generate-heroicons.js +++ /dev/null @@ -1,242 +0,0 @@ -// Node.js script to convert heroicons.json (IconifyJSON format) to C# dictionary code -// Heroicons have 4 variants: outline (default), solid, mini (20-solid), micro (16-solid) - -const fs = require('fs'); -const path = require('path'); - -const jsonPath = path.join(__dirname, 'data/heroicons.json'); -const outputPath = path.join(__dirname, '../../src/BlazorBlueprint.Icons.Heroicons/Data/HeroIconData.cs'); - -console.log('Reading Heroicons icon data from', jsonPath); -const jsonContent = fs.readFileSync(jsonPath, 'utf8'); -const data = JSON.parse(jsonContent); - -const allIcons = data.icons; -const totalIconCount = Object.keys(allIcons).length; -console.log(`Found ${totalIconCount} total icon entries (across all variants)`); - -// Group icons by variant based on suffix -const outlineIcons = {}; -const solidIcons = {}; -const miniIcons = {}; -const microIcons = {}; - -for (const [iconName, iconData] of Object.entries(allIcons)) { - const iconBody = iconData.body; - - if (iconName.endsWith('-16-solid')) { - // Micro variant (16x16) - const baseName = iconName.replace(/-16-solid$/, ''); - microIcons[baseName] = iconBody; - } else if (iconName.endsWith('-20-solid')) { - // Mini variant (20x20) - const baseName = iconName.replace(/-20-solid$/, ''); - miniIcons[baseName] = iconBody; - } else if (iconName.endsWith('-solid')) { - // Solid variant (24x24) - const baseName = iconName.replace(/-solid$/, ''); - solidIcons[baseName] = iconBody; - } else { - // Outline variant (24x24) - no suffix - outlineIcons[iconName] = iconBody; - } -} - -const outlineCount = Object.keys(outlineIcons).length; -const solidCount = Object.keys(solidIcons).length; -const miniCount = Object.keys(miniIcons).length; -const microCount = Object.keys(microIcons).length; - -console.log('Grouped icons by variant:'); -console.log(` Outline: ${outlineCount} icons`); -console.log(` Solid: ${solidCount} icons`); -console.log(` Mini: ${miniCount} icons`); -console.log(` Micro: ${microCount} icons`); - -// Helper function to escape C# strings -function escapeCSharp(str) { - return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); -} - -// Helper function to generate dictionary entries -function generateDictionaryEntries(icons, indent) { - const entries = []; - const sortedKeys = Object.keys(icons).sort(); - - for (let i = 0; i < sortedKeys.length; i++) { - const iconName = sortedKeys[i]; - const iconBody = icons[iconName]; - const escapedBody = escapeCSharp(iconBody); - const comma = i < sortedKeys.length - 1 ? ',' : ''; - entries.push(`${indent}["${iconName}"] = "${escapedBody}"${comma}`); - } - - return entries.join('\n'); -} - -// Create Data directory if it doesn't exist -const dataDir = path.dirname(outputPath); -if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir, { recursive: true }); - console.log('Created Data directory'); -} - -// Build the C# file content -const lines = []; - -lines.push('// This file is auto-generated. Do not edit manually.'); -lines.push(`// Generated from heroicons.json on ${new Date().toISOString().split('T')[0]}`); -lines.push(''); -lines.push('namespace BlazorBlueprint.Icons.Heroicons.Data;'); -lines.push(''); -lines.push('/// '); -lines.push('/// Icon variant for Heroicons.'); -lines.push('/// '); -lines.push('public enum HeroIconVariant'); -lines.push('{'); -lines.push(' /// Outline variant (24x24, stroke-based)'); -lines.push(' Outline,'); -lines.push(''); -lines.push(' /// Solid variant (24x24, filled)'); -lines.push(' Solid,'); -lines.push(''); -lines.push(' /// Mini variant (20x20, filled)'); -lines.push(' Mini,'); -lines.push(''); -lines.push(' /// Micro variant (16x16, filled)'); -lines.push(' Micro'); -lines.push('}'); -lines.push(''); -lines.push('/// '); -lines.push('/// Provides access to Heroicons SVG data.'); -lines.push(`/// Contains ${totalIconCount} total icons across 4 variants.`); -lines.push('/// '); -lines.push('public static class HeroIconData'); -lines.push('{'); - -// Outline dictionary -lines.push(' private static readonly IReadOnlyDictionary OutlineIcons = new Dictionary(StringComparer.OrdinalIgnoreCase)'); -lines.push(' {'); -lines.push(generateDictionaryEntries(outlineIcons, ' ')); -lines.push(' };'); -lines.push(''); - -// Solid dictionary -lines.push(' private static readonly IReadOnlyDictionary SolidIcons = new Dictionary(StringComparer.OrdinalIgnoreCase)'); -lines.push(' {'); -lines.push(generateDictionaryEntries(solidIcons, ' ')); -lines.push(' };'); -lines.push(''); - -// Mini dictionary -lines.push(' private static readonly IReadOnlyDictionary MiniIcons = new Dictionary(StringComparer.OrdinalIgnoreCase)'); -lines.push(' {'); -lines.push(generateDictionaryEntries(miniIcons, ' ')); -lines.push(' };'); -lines.push(''); - -// Micro dictionary -lines.push(' private static readonly IReadOnlyDictionary MicroIcons = new Dictionary(StringComparer.OrdinalIgnoreCase)'); -lines.push(' {'); -lines.push(generateDictionaryEntries(microIcons, ' ')); -lines.push(' };'); -lines.push(''); - -// GetIcon method -lines.push(' /// '); -lines.push(' /// Retrieves the SVG content for the specified icon name and variant.'); -lines.push(' /// '); -lines.push(' /// The name of the icon (case-insensitive).'); -lines.push(' /// The icon variant.'); -lines.push(' /// The SVG path data for the icon, or null if not found.'); -lines.push(' public static string? GetIcon(string name, HeroIconVariant variant)'); -lines.push(' {'); -lines.push(' var dictionary = variant switch'); -lines.push(' {'); -lines.push(' HeroIconVariant.Outline => OutlineIcons,'); -lines.push(' HeroIconVariant.Solid => SolidIcons,'); -lines.push(' HeroIconVariant.Mini => MiniIcons,'); -lines.push(' HeroIconVariant.Micro => MicroIcons,'); -lines.push(' _ => OutlineIcons'); -lines.push(' };'); -lines.push(''); -lines.push(' return dictionary.TryGetValue(name, out var svg) ? svg : null;'); -lines.push(' }'); -lines.push(''); - -// GetAvailableIcons method -lines.push(' /// '); -lines.push(' /// Gets all available icon names for a specific variant.'); -lines.push(' /// '); -lines.push(' /// The icon variant.'); -lines.push(' /// An enumerable collection of icon names.'); -lines.push(' public static IEnumerable GetAvailableIcons(HeroIconVariant variant)'); -lines.push(' {'); -lines.push(' return variant switch'); -lines.push(' {'); -lines.push(' HeroIconVariant.Outline => OutlineIcons.Keys,'); -lines.push(' HeroIconVariant.Solid => SolidIcons.Keys,'); -lines.push(' HeroIconVariant.Mini => MiniIcons.Keys,'); -lines.push(' HeroIconVariant.Micro => MicroIcons.Keys,'); -lines.push(' _ => OutlineIcons.Keys'); -lines.push(' };'); -lines.push(' }'); -lines.push(''); - -// IconExists method -lines.push(' /// '); -lines.push(' /// Checks if an icon with the specified name exists in the given variant.'); -lines.push(' /// '); -lines.push(' /// The name of the icon (case-insensitive).'); -lines.push(' /// The icon variant.'); -lines.push(' /// True if the icon exists, false otherwise.'); -lines.push(' public static bool IconExists(string name, HeroIconVariant variant)'); -lines.push(' {'); -lines.push(' return variant switch'); -lines.push(' {'); -lines.push(' HeroIconVariant.Outline => OutlineIcons.ContainsKey(name),'); -lines.push(' HeroIconVariant.Solid => SolidIcons.ContainsKey(name),'); -lines.push(' HeroIconVariant.Mini => MiniIcons.ContainsKey(name),'); -lines.push(' HeroIconVariant.Micro => MicroIcons.ContainsKey(name),'); -lines.push(' _ => OutlineIcons.ContainsKey(name)'); -lines.push(' };'); -lines.push(' }'); -lines.push(''); - -// IconCount properties -lines.push(' /// '); -lines.push(' /// Gets the total number of available icons across all variants.'); -lines.push(' /// '); -lines.push(' public static int TotalIconCount => OutlineIcons.Count + SolidIcons.Count + MiniIcons.Count + MicroIcons.Count;'); -lines.push(''); -lines.push(' /// '); -lines.push(' /// Gets the number of outline icons.'); -lines.push(' /// '); -lines.push(' public static int OutlineIconCount => OutlineIcons.Count;'); -lines.push(''); -lines.push(' /// '); -lines.push(' /// Gets the number of solid icons.'); -lines.push(' /// '); -lines.push(' public static int SolidIconCount => SolidIcons.Count;'); -lines.push(''); -lines.push(' /// '); -lines.push(' /// Gets the number of mini icons.'); -lines.push(' /// '); -lines.push(' public static int MiniIconCount => MiniIcons.Count;'); -lines.push(''); -lines.push(' /// '); -lines.push(' /// Gets the number of micro icons.'); -lines.push(' /// '); -lines.push(' public static int MicroIconCount => MicroIcons.Count;'); -lines.push('}'); - -// Write to file -const outputContent = lines.join('\n'); -fs.writeFileSync(outputPath, outputContent, 'utf8'); - -console.log('\u2713 Generated C# file:', outputPath); -console.log('\u2713 Total icons:', totalIconCount); -console.log(` - Outline: ${outlineCount}`); -console.log(` - Solid: ${solidCount}`); -console.log(` - Mini: ${miniCount}`); -console.log(` - Micro: ${microCount}`); diff --git a/tools/icon-generation/generate-lucide.js b/tools/icon-generation/generate-lucide.js deleted file mode 100644 index 07b6b2ae9..000000000 --- a/tools/icon-generation/generate-lucide.js +++ /dev/null @@ -1,103 +0,0 @@ -// Node.js script to convert lucide.json (IconifyJSON format) to C# dictionary code - -const fs = require('fs'); -const path = require('path'); - -const jsonPath = path.join(__dirname, 'data/lucide.json'); -const outputPath = path.join(__dirname, '../../src/BlazorBlueprint.Icons.Lucide/Data/LucideIconData.cs'); - -console.log('Reading Lucide icon data from', jsonPath); -const jsonContent = fs.readFileSync(jsonPath, 'utf8'); -const data = JSON.parse(jsonContent); - -const allIcons = data.icons; -const iconCount = Object.keys(allIcons).length; -console.log(`Found ${iconCount} icons`); - -// Helper function to escape C# strings -function escapeCSharp(str) { - return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); -} - -// Helper function to generate dictionary entries -function generateDictionaryEntries(icons, indent) { - const entries = []; - const sortedKeys = Object.keys(icons).sort(); - - for (let i = 0; i < sortedKeys.length; i++) { - const iconName = sortedKeys[i]; - const iconBody = icons[iconName].body; - const escapedBody = escapeCSharp(iconBody); - const comma = i < sortedKeys.length - 1 ? ',' : ''; - entries.push(`${indent}["${iconName}"] = "${escapedBody}"${comma}`); - } - - return entries.join('\n'); -} - -// Create Data directory if it doesn't exist -const dataDir = path.dirname(outputPath); -if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir, { recursive: true }); - console.log('Created Data directory'); -} - -// Build the C# file content -const lines = []; - -lines.push('// This file is auto-generated. Do not edit manually.'); -lines.push(`// Generated from lucide.json on ${new Date().toISOString().split('T')[0]}`); -lines.push(''); -lines.push('namespace BlazorBlueprint.Icons.Lucide.Data;'); -lines.push(''); -lines.push('/// '); -lines.push('/// Provides access to Lucide icon SVG data.'); -lines.push(`/// Contains ${iconCount} icons from the Lucide icon set.`); -lines.push('/// '); -lines.push('public static class LucideIconData'); -lines.push('{'); -lines.push(' private static readonly IReadOnlyDictionary Icons = new Dictionary(StringComparer.OrdinalIgnoreCase)'); -lines.push(' {'); -lines.push(generateDictionaryEntries(allIcons, ' ')); -lines.push(' };'); -lines.push(''); - -// GetIcon method -lines.push(' /// '); -lines.push(' /// Retrieves the SVG content for the specified icon name.'); -lines.push(' /// '); -lines.push(' /// The name of the icon (case-insensitive).'); -lines.push(' /// The SVG path data for the icon, or null if not found.'); -lines.push(' public static string? GetIcon(string name) => Icons.TryGetValue(name, out var svg) ? svg : null;'); -lines.push(''); - -// GetAvailableIcons method -lines.push(' /// '); -lines.push(' /// Gets all available icon names.'); -lines.push(' /// '); -lines.push(' /// An enumerable collection of icon names.'); -lines.push(' public static IEnumerable GetAvailableIcons() => Icons.Keys;'); -lines.push(''); - -// IconExists method -lines.push(' /// '); -lines.push(' /// Checks if an icon with the specified name exists.'); -lines.push(' /// '); -lines.push(' /// The name of the icon (case-insensitive).'); -lines.push(' /// True if the icon exists, false otherwise.'); -lines.push(' public static bool IconExists(string name) => Icons.ContainsKey(name);'); -lines.push(''); - -// IconCount property -lines.push(' /// '); -lines.push(' /// Gets the total number of available icons.'); -lines.push(' /// '); -lines.push(' public static int IconCount => Icons.Count;'); -lines.push('}'); - -// Write to file -const outputContent = lines.join('\n'); -fs.writeFileSync(outputPath, outputContent, 'utf8'); - -console.log('\u2713 Generated C# file:', outputPath); -console.log('\u2713 Total icons:', iconCount); From a56b9a2d1bd1cf7c20717d6081f87bb899f2fa66 Mon Sep 17 00:00:00 2001 From: Mathew Date: Fri, 27 Mar 2026 20:49:51 +0800 Subject: [PATCH 017/188] (chore) gitignore cleanup --- .gitignore | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.gitignore b/.gitignore index 7c60ff327..9fef345e6 100644 --- a/.gitignore +++ b/.gitignore @@ -46,12 +46,6 @@ NUL # Published output publish/ -# Local test apps -tests/BlazorBlueprint.IssueTester/ - -# Local LLM docs -.llms - # Development Tools .claude devkit/ From dabe8a5ac186188ee4beedb93a70bcd6f69fa913 Mon Sep 17 00:00:00 2001 From: Mathew Date: Mon, 30 Mar 2026 18:23:16 +0800 Subject: [PATCH 018/188] Implement feature X to enhance user experience and optimize performance --- .../Data/LucideIconData.cs | 390 +++++++++++------- 1 file changed, 239 insertions(+), 151 deletions(-) diff --git a/src/BlazorBlueprint.Icons.Lucide/Data/LucideIconData.cs b/src/BlazorBlueprint.Icons.Lucide/Data/LucideIconData.cs index 19fa6d067..81488fe28 100644 --- a/src/BlazorBlueprint.Icons.Lucide/Data/LucideIconData.cs +++ b/src/BlazorBlueprint.Icons.Lucide/Data/LucideIconData.cs @@ -1,11 +1,11 @@ // This file is auto-generated. Do not edit manually. -// Generated from lucide.json on 2026-02-19 +// Generated from lucide.json on 2026-03-27 namespace BlazorBlueprint.Icons.Lucide.Data; /// /// Provides access to Lucide icon SVG data. -/// Contains 1665 icons from the Lucide icon set. +/// Contains 1753 icons from the Lucide icon set. /// public static class LucideIconData { @@ -52,10 +52,10 @@ public static class LucideIconData ["align-vertical-space-around"] = "", ["align-vertical-space-between"] = "", ["ambulance"] = "", - ["ampersand"] = "", + ["ampersand"] = "", ["ampersands"] = "", ["amphora"] = "", - ["anchor"] = "", + ["anchor"] = "", ["angry"] = "", ["annoyed"] = "", ["antenna"] = "", @@ -69,14 +69,14 @@ public static class LucideIconData ["archive-x"] = "", ["area-chart"] = "", ["armchair"] = "", - ["arrow-big-down"] = "", - ["arrow-big-down-dash"] = "", - ["arrow-big-left"] = "", - ["arrow-big-left-dash"] = "", - ["arrow-big-right"] = "", - ["arrow-big-right-dash"] = "", - ["arrow-big-up"] = "", - ["arrow-big-up-dash"] = "", + ["arrow-big-down"] = "", + ["arrow-big-down-dash"] = "", + ["arrow-big-left"] = "", + ["arrow-big-left-dash"] = "", + ["arrow-big-right"] = "", + ["arrow-big-right-dash"] = "", + ["arrow-big-up"] = "", + ["arrow-big-up-dash"] = "", ["arrow-down"] = "", ["arrow-down-0-1"] = "", ["arrow-down-1-0"] = "", @@ -141,9 +141,10 @@ public static class LucideIconData ["badge-turkish-lira"] = "", ["badge-x"] = "", ["baggage-claim"] = "", - ["ban"] = "", + ["balloon"] = "", + ["ban"] = "", ["banana"] = "", - ["bandage"] = "", + ["bandage"] = "", ["banknote"] = "", ["banknote-arrow-down"] = "", ["banknote-arrow-up"] = "", @@ -171,10 +172,11 @@ public static class LucideIconData ["bed-double"] = "", ["bed-single"] = "", ["beef"] = "", + ["beef-off"] = "", ["beer"] = "", ["beer-off"] = "", ["bell"] = "", - ["bell-dot"] = "", + ["bell-dot"] = "", ["bell-electric"] = "", ["bell-minus"] = "", ["bell-off"] = "", @@ -214,7 +216,7 @@ public static class LucideIconData ["book-headphones"] = "", ["book-heart"] = "", ["book-image"] = "", - ["book-key"] = "", + ["book-key"] = "", ["book-lock"] = "", ["book-marked"] = "", ["book-minus"] = "", @@ -222,17 +224,18 @@ public static class LucideIconData ["book-open-check"] = "", ["book-open-text"] = "", ["book-plus"] = "", + ["book-search"] = "", ["book-text"] = "", ["book-type"] = "", ["book-up"] = "", ["book-up-2"] = "", ["book-user"] = "", ["book-x"] = "", - ["bookmark"] = "", - ["bookmark-check"] = "", - ["bookmark-minus"] = "", - ["bookmark-plus"] = "", - ["bookmark-x"] = "", + ["bookmark"] = "", + ["bookmark-check"] = "", + ["bookmark-minus"] = "", + ["bookmark-plus"] = "", + ["bookmark-x"] = "", ["boom-box"] = "", ["bot"] = "", ["bot-message-square"] = "", @@ -255,10 +258,10 @@ public static class LucideIconData ["briefcase-medical"] = "", ["bring-to-front"] = "", ["brush"] = "", - ["brush-cleaning"] = "", - ["bubbles"] = "", + ["brush-cleaning"] = "", + ["bubbles"] = "", ["bug"] = "", - ["bug-off"] = "", + ["bug-off"] = "", ["bug-play"] = "", ["building"] = "", ["building-2"] = "", @@ -278,7 +281,7 @@ public static class LucideIconData ["calendar-clock"] = "", ["calendar-cog"] = "", ["calendar-days"] = "", - ["calendar-fold"] = "", + ["calendar-fold"] = "", ["calendar-heart"] = "", ["calendar-minus"] = "", ["calendar-minus-2"] = "", @@ -290,6 +293,7 @@ public static class LucideIconData ["calendar-sync"] = "", ["calendar-x"] = "", ["calendar-x-2"] = "", + ["calendars"] = "", ["camera"] = "", ["camera-off"] = "", ["candlestick-chart"] = "", @@ -297,6 +301,7 @@ public static class LucideIconData ["candy-cane"] = "", ["candy-off"] = "", ["cannabis"] = "", + ["cannabis-off"] = "", ["captions"] = "", ["captions-off"] = "", ["car"] = "", @@ -313,6 +318,7 @@ public static class LucideIconData ["castle"] = "", ["cat"] = "", ["cctv"] = "", + ["cctv-off"] = "", ["chart-area"] = "", ["chart-bar"] = "", ["chart-bar-big"] = "", @@ -341,6 +347,12 @@ public static class LucideIconData ["check-line"] = "", ["chef-hat"] = "", ["cherry"] = "", + ["chess-bishop"] = "", + ["chess-king"] = "", + ["chess-knight"] = "", + ["chess-pawn"] = "", + ["chess-queen"] = "", + ["chess-rook"] = "", ["chevron-down"] = "", ["chevron-first"] = "", ["chevron-last"] = "", @@ -378,12 +390,12 @@ public static class LucideIconData ["circle-chevron-right"] = "", ["circle-chevron-up"] = "", ["circle-dashed"] = "", - ["circle-divide"] = "", + ["circle-divide"] = "", ["circle-dollar-sign"] = "", ["circle-dot"] = "", ["circle-dot-dashed"] = "", ["circle-ellipsis"] = "", - ["circle-equal"] = "", + ["circle-equal"] = "", ["circle-fading-arrow-up"] = "", ["circle-fading-plus"] = "", ["circle-gauge"] = "", @@ -393,22 +405,23 @@ public static class LucideIconData ["circle-parking-off"] = "", ["circle-pause"] = "", ["circle-percent"] = "", + ["circle-pile"] = "", ["circle-play"] = "", ["circle-plus"] = "", - ["circle-pound-sterling"] = "", - ["circle-power"] = "", + ["circle-pound-sterling"] = "", + ["circle-power"] = "", ["circle-question-mark"] = "", ["circle-slash"] = "", - ["circle-slash-2"] = "", + ["circle-slash-2"] = "", ["circle-small"] = "", - ["circle-star"] = "", + ["circle-star"] = "", ["circle-stop"] = "", ["circle-user"] = "", - ["circle-user-round"] = "", + ["circle-user-round"] = "", ["circle-x"] = "", ["circuit-board"] = "", ["citrus"] = "", - ["clapperboard"] = "", + ["clapperboard"] = "", ["clipboard"] = "", ["clipboard-check"] = "", ["clipboard-clock"] = "", @@ -416,33 +429,35 @@ public static class LucideIconData ["clipboard-list"] = "", ["clipboard-minus"] = "", ["clipboard-paste"] = "", - ["clipboard-pen"] = "", + ["clipboard-pen"] = "", ["clipboard-pen-line"] = "", ["clipboard-plus"] = "", ["clipboard-type"] = "", ["clipboard-x"] = "", - ["clock"] = "", - ["clock-1"] = "", - ["clock-10"] = "", - ["clock-11"] = "", - ["clock-12"] = "", - ["clock-2"] = "", - ["clock-3"] = "", - ["clock-4"] = "", - ["clock-5"] = "", - ["clock-6"] = "", - ["clock-7"] = "", - ["clock-8"] = "", - ["clock-9"] = "", + ["clock"] = "", + ["clock-1"] = "", + ["clock-10"] = "", + ["clock-11"] = "", + ["clock-12"] = "", + ["clock-2"] = "", + ["clock-3"] = "", + ["clock-4"] = "", + ["clock-5"] = "", + ["clock-6"] = "", + ["clock-7"] = "", + ["clock-8"] = "", + ["clock-9"] = "", ["clock-alert"] = "", ["clock-arrow-down"] = "", ["clock-arrow-up"] = "", + ["clock-check"] = "", ["clock-fading"] = "", ["clock-plus"] = "", ["closed-caption"] = "", ["cloud"] = "", - ["cloud-alert"] = "", - ["cloud-check"] = "", + ["cloud-alert"] = "", + ["cloud-backup"] = "", + ["cloud-check"] = "", ["cloud-cog"] = "", ["cloud-download"] = "", ["cloud-drizzle"] = "", @@ -451,14 +466,15 @@ public static class LucideIconData ["cloud-lightning"] = "", ["cloud-moon"] = "", ["cloud-moon-rain"] = "", - ["cloud-off"] = "", + ["cloud-off"] = "", ["cloud-rain"] = "", ["cloud-rain-wind"] = "", ["cloud-snow"] = "", ["cloud-sun"] = "", ["cloud-sun-rain"] = "", + ["cloud-sync"] = "", ["cloud-upload"] = "", - ["cloudy"] = "", + ["cloudy"] = "", ["clover"] = "", ["club"] = "", ["code"] = "", @@ -467,14 +483,14 @@ public static class LucideIconData ["codesandbox"] = "", ["coffee"] = "", ["cog"] = "", - ["coins"] = "", + ["coins"] = "", ["columns-2"] = "", ["columns-3"] = "", ["columns-3-cog"] = "", ["columns-4"] = "", ["combine"] = "", ["command"] = "", - ["compass"] = "", + ["compass"] = "", ["component"] = "", ["computer"] = "", ["concierge-bell"] = "", @@ -510,13 +526,14 @@ public static class LucideIconData ["cross"] = "", ["crosshair"] = "", ["crown"] = "", - ["cuboid"] = "", + ["cuboid"] = "", ["cup-soda"] = "", ["currency"] = "", ["cylinder"] = "", ["dam"] = "", ["database"] = "", ["database-backup"] = "", + ["database-search"] = "", ["database-zap"] = "", ["decimals-arrow-left"] = "", ["decimals-arrow-right"] = "", @@ -570,6 +587,7 @@ public static class LucideIconData ["egg"] = "", ["egg-fried"] = "", ["egg-off"] = "", + ["ellipse"] = "", ["ellipsis"] = "", ["ellipsis-vertical"] = "", ["equal"] = "", @@ -592,89 +610,101 @@ public static class LucideIconData ["fence"] = "", ["ferris-wheel"] = "", ["figma"] = "", - ["file"] = "", - ["file-archive"] = "", + ["file"] = "", + ["file-archive"] = "", ["file-audio"] = "", ["file-audio-2"] = "", - ["file-axis-3d"] = "", - ["file-badge"] = "", + ["file-axis-3d"] = "", + ["file-badge"] = "", ["file-badge-2"] = "", - ["file-box"] = "", - ["file-chart-column"] = "", - ["file-chart-column-increasing"] = "", - ["file-chart-line"] = "", - ["file-chart-pie"] = "", - ["file-check"] = "", + ["file-box"] = "", + ["file-braces"] = "", + ["file-braces-corner"] = "", + ["file-chart-column"] = "", + ["file-chart-column-increasing"] = "", + ["file-chart-line"] = "", + ["file-chart-pie"] = "", + ["file-check"] = "", ["file-check-2"] = "", - ["file-clock"] = "", - ["file-code"] = "", + ["file-check-corner"] = "", + ["file-clock"] = "", + ["file-code"] = "", ["file-code-2"] = "", - ["file-cog"] = "", - ["file-diff"] = "", - ["file-digit"] = "", - ["file-down"] = "", - ["file-heart"] = "", - ["file-image"] = "", - ["file-input"] = "", + ["file-code-corner"] = "", + ["file-cog"] = "", + ["file-diff"] = "", + ["file-digit"] = "", + ["file-down"] = "", + ["file-exclamation-point"] = "", + ["file-headphone"] = "", + ["file-heart"] = "", + ["file-image"] = "", + ["file-input"] = "", ["file-json"] = "", ["file-json-2"] = "", - ["file-key"] = "", + ["file-key"] = "", ["file-key-2"] = "", - ["file-lock"] = "", + ["file-lock"] = "", ["file-lock-2"] = "", - ["file-minus"] = "", + ["file-minus"] = "", ["file-minus-2"] = "", - ["file-music"] = "", - ["file-output"] = "", - ["file-pen"] = "", - ["file-pen-line"] = "", + ["file-minus-corner"] = "", + ["file-music"] = "", + ["file-output"] = "", + ["file-pen"] = "", + ["file-pen-line"] = "", ["file-pie-chart"] = "", - ["file-play"] = "", - ["file-plus"] = "", + ["file-play"] = "", + ["file-plus"] = "", ["file-plus-2"] = "", - ["file-question-mark"] = "", - ["file-scan"] = "", - ["file-search"] = "", + ["file-plus-corner"] = "", + ["file-question-mark"] = "", + ["file-scan"] = "", + ["file-search"] = "", ["file-search-2"] = "", - ["file-sliders"] = "", - ["file-spreadsheet"] = "", + ["file-search-corner"] = "", + ["file-signal"] = "", + ["file-sliders"] = "", + ["file-spreadsheet"] = "", ["file-stack"] = "", - ["file-symlink"] = "", - ["file-terminal"] = "", - ["file-text"] = "", - ["file-type"] = "", + ["file-symlink"] = "", + ["file-terminal"] = "", + ["file-text"] = "", + ["file-type"] = "", ["file-type-2"] = "", - ["file-up"] = "", - ["file-user"] = "", - ["file-video-camera"] = "", - ["file-volume"] = "", + ["file-type-corner"] = "", + ["file-up"] = "", + ["file-user"] = "", + ["file-video-camera"] = "", + ["file-volume"] = "", ["file-volume-2"] = "", ["file-warning"] = "", - ["file-x"] = "", + ["file-x"] = "", ["file-x-2"] = "", - ["files"] = "", + ["file-x-corner"] = "", + ["files"] = "", ["film"] = "", ["filter"] = "", ["filter-x"] = "", - ["fingerprint"] = "", + ["fingerprint-pattern"] = "", ["fire-extinguisher"] = "", ["fish"] = "", ["fish-off"] = "", ["fish-symbol"] = "", + ["fishing-hook"] = "", + ["fishing-rod"] = "", ["flag"] = "", ["flag-off"] = "", ["flag-triangle-left"] = "", ["flag-triangle-right"] = "", ["flame"] = "", ["flame-kindling"] = "", - ["flashlight"] = "", - ["flashlight-off"] = "", + ["flashlight"] = "", + ["flashlight-off"] = "", ["flask-conical"] = "", ["flask-conical-off"] = "", ["flask-round"] = "", - ["flip-horizontal"] = "", ["flip-horizontal-2"] = "", - ["flip-vertical"] = "", ["flip-vertical-2"] = "", ["flower"] = "", ["flower-2"] = "", @@ -691,11 +721,11 @@ public static class LucideIconData ["folder-dot"] = "", ["folder-down"] = "", ["folder-git"] = "", - ["folder-git-2"] = "", + ["folder-git-2"] = "", ["folder-heart"] = "", ["folder-input"] = "", ["folder-kanban"] = "", - ["folder-key"] = "", + ["folder-key"] = "", ["folder-lock"] = "", ["folder-minus"] = "", ["folder-open"] = "", @@ -713,7 +743,8 @@ public static class LucideIconData ["folder-x"] = "", ["folders"] = "", ["footprints"] = "", - ["forklift"] = "", + ["forklift"] = "", + ["form"] = "", ["forward"] = "", ["frame"] = "", ["framer"] = "", @@ -736,8 +767,9 @@ public static class LucideIconData ["gem"] = "", ["georgian-lari"] = "", ["ghost"] = "", - ["gift"] = "", - ["git-branch"] = "", + ["gift"] = "", + ["git-branch"] = "", + ["git-branch-minus"] = "", ["git-branch-plus"] = "", ["git-commit-horizontal"] = "", ["git-commit-vertical"] = "", @@ -746,6 +778,7 @@ public static class LucideIconData ["git-fork"] = "", ["git-graph"] = "", ["git-merge"] = "", + ["git-merge-conflict"] = "", ["git-pull-request"] = "", ["git-pull-request-arrow"] = "", ["git-pull-request-closed"] = "", @@ -758,8 +791,10 @@ public static class LucideIconData ["glasses"] = "", ["globe"] = "", ["globe-lock"] = "", + ["globe-off"] = "", + ["globe-x"] = "", ["goal"] = "", - ["gpu"] = "", + ["gpu"] = "", ["graduation-cap"] = "", ["grape"] = "", ["grid-2x2"] = "", @@ -786,13 +821,14 @@ public static class LucideIconData ["hand-platter"] = "", ["handbag"] = "", ["handshake"] = "", - ["hard-drive"] = "", + ["hard-drive"] = "", ["hard-drive-download"] = "", ["hard-drive-upload"] = "", ["hard-hat"] = "", ["hash"] = "", ["hat-glasses"] = "", ["haze"] = "", + ["hd"] = "", ["hdmi-port"] = "", ["heading"] = "", ["heading-1"] = "", @@ -812,6 +848,7 @@ public static class LucideIconData ["heart-plus"] = "", ["heart-pulse"] = "", ["heater"] = "", + ["helicopter"] = "", ["hexagon"] = "", ["highlighter"] = "", ["history"] = "", @@ -872,12 +909,13 @@ public static class LucideIconData ["laptop"] = "", ["laptop-minimal"] = "", ["laptop-minimal-check"] = "", - ["lasso"] = "", + ["lasso"] = "", ["lasso-select"] = "", ["laugh"] = "", ["layers"] = "", ["layers-2"] = "", ["layers-3"] = "", + ["layers-plus"] = "", ["layout-dashboard"] = "", ["layout-grid"] = "", ["layout-list"] = "", @@ -887,6 +925,8 @@ public static class LucideIconData ["leaf"] = "", ["leafy-green"] = "", ["lectern"] = "", + ["lens-concave"] = "", + ["lens-convex"] = "", ["letter-text"] = "", ["library"] = "", ["library-big"] = "", @@ -895,7 +935,9 @@ public static class LucideIconData ["lightbulb"] = "", ["lightbulb-off"] = "", ["line-chart"] = "", + ["line-dot-right-horizontal"] = "", ["line-squiggle"] = "", + ["line-style"] = "", ["link"] = "", ["link-2"] = "", ["link-2-off"] = "", @@ -960,6 +1002,7 @@ public static class LucideIconData ["map-pin-pen"] = "", ["map-pin-plus"] = "", ["map-pin-plus-inside"] = "", + ["map-pin-search"] = "", ["map-pin-x"] = "", ["map-pin-x-inside"] = "", ["map-pinned"] = "", @@ -973,10 +1016,11 @@ public static class LucideIconData ["megaphone"] = "", ["megaphone-off"] = "", ["meh"] = "", - ["memory-stick"] = "", + ["memory-stick"] = "", ["menu"] = "", ["merge"] = "", ["message-circle"] = "", + ["message-circle-check"] = "", ["message-circle-code"] = "", ["message-circle-dashed"] = "", ["message-circle-heart"] = "", @@ -988,8 +1032,9 @@ public static class LucideIconData ["message-circle-warning"] = "", ["message-circle-x"] = "", ["message-square"] = "", + ["message-square-check"] = "", ["message-square-code"] = "", - ["message-square-dashed"] = "", + ["message-square-dashed"] = "", ["message-square-diff"] = "", ["message-square-dot"] = "", ["message-square-heart"] = "", @@ -1004,25 +1049,28 @@ public static class LucideIconData ["message-square-warning"] = "", ["message-square-x"] = "", ["messages-square"] = "", + ["metronome"] = "", ["mic"] = "", ["mic-off"] = "", ["mic-vocal"] = "", - ["microchip"] = "", + ["microchip"] = "", ["microscope"] = "", ["microwave"] = "", - ["milestone"] = "", + ["milestone"] = "", ["milk"] = "", ["milk-off"] = "", ["minimize"] = "", ["minimize-2"] = "", ["minus"] = "", + ["mirror-rectangular"] = "", + ["mirror-round"] = "", ["monitor"] = "", ["monitor-check"] = "", ["monitor-cloud"] = "", ["monitor-cog"] = "", ["monitor-dot"] = "", ["monitor-down"] = "", - ["monitor-off"] = "", + ["monitor-off"] = "", ["monitor-pause"] = "", ["monitor-play"] = "", ["monitor-smartphone"] = "", @@ -1036,11 +1084,14 @@ public static class LucideIconData ["mountain"] = "", ["mountain-snow"] = "", ["mouse"] = "", + ["mouse-left"] = "", ["mouse-off"] = "", ["mouse-pointer"] = "", ["mouse-pointer-2"] = "", + ["mouse-pointer-2-off"] = "", ["mouse-pointer-ban"] = "", ["mouse-pointer-click"] = "", + ["mouse-right"] = "", ["move"] = "", ["move-3d"] = "", ["move-diagonal"] = "", @@ -1086,13 +1137,13 @@ public static class LucideIconData ["origami"] = "", ["package"] = "", ["package-2"] = "", - ["package-check"] = "", - ["package-minus"] = "", + ["package-check"] = "", + ["package-minus"] = "", ["package-open"] = "", - ["package-plus"] = "", - ["package-search"] = "", - ["package-x"] = "", - ["paint-bucket"] = "", + ["package-plus"] = "", + ["package-search"] = "", + ["package-x"] = "", + ["paint-bucket"] = "", ["paint-roller"] = "", ["paintbrush"] = "", ["paintbrush-vertical"] = "", @@ -1165,7 +1216,7 @@ public static class LucideIconData ["plane-landing"] = "", ["plane-takeoff"] = "", ["play"] = "", - ["plug"] = "", + ["plug"] = "", ["plug-2"] = "", ["plug-zap"] = "", ["plus"] = "", @@ -1182,6 +1233,7 @@ public static class LucideIconData ["presentation"] = "", ["printer"] = "", ["printer-check"] = "", + ["printer-x"] = "", ["projector"] = "", ["proportions"] = "", ["puzzle"] = "", @@ -1193,6 +1245,7 @@ public static class LucideIconData ["radiation"] = "", ["radical"] = "", ["radio"] = "", + ["radio-off"] = "", ["radio-receiver"] = "", ["radio-tower"] = "", ["radius"] = "", @@ -1200,16 +1253,16 @@ public static class LucideIconData ["rainbow"] = "", ["rat"] = "", ["ratio"] = "", - ["receipt"] = "", - ["receipt-cent"] = "", - ["receipt-euro"] = "", - ["receipt-indian-rupee"] = "", - ["receipt-japanese-yen"] = "", - ["receipt-pound-sterling"] = "", - ["receipt-russian-ruble"] = "", - ["receipt-swiss-franc"] = "", + ["receipt"] = "", + ["receipt-cent"] = "", + ["receipt-euro"] = "", + ["receipt-indian-rupee"] = "", + ["receipt-japanese-yen"] = "", + ["receipt-pound-sterling"] = "", + ["receipt-russian-ruble"] = "", + ["receipt-swiss-franc"] = "", ["receipt-text"] = "", - ["receipt-turkish-lira"] = "", + ["receipt-turkish-lira"] = "", ["rectangle-circle"] = "", ["rectangle-ellipsis"] = "", ["rectangle-goggles"] = "", @@ -1235,13 +1288,14 @@ public static class LucideIconData ["reply-all"] = "", ["rewind"] = "", ["ribbon"] = "", - ["rocket"] = "", - ["rocking-chair"] = "", + ["road"] = "", + ["rocket"] = "", + ["rocking-chair"] = "", ["roller-coaster"] = "", ["rose"] = "", ["rotate-3d"] = "", ["rotate-ccw"] = "", - ["rotate-ccw-key"] = "", + ["rotate-ccw-key"] = "", ["rotate-ccw-square"] = "", ["rotate-cw"] = "", ["rotate-cw-square"] = "", @@ -1253,7 +1307,7 @@ public static class LucideIconData ["rows-4"] = "", ["rss"] = "", ["ruler"] = "", - ["ruler-dimension-line"] = "", + ["ruler-dimension-line"] = "", ["russian-ruble"] = "", ["sailboat"] = "", ["salad"] = "", @@ -1264,7 +1318,7 @@ public static class LucideIconData ["save"] = "", ["save-all"] = "", ["save-off"] = "", - ["scale"] = "", + ["scale"] = "", ["scale-3d"] = "", ["scaling"] = "", ["scan"] = "", @@ -1277,14 +1331,16 @@ public static class LucideIconData ["scan-search"] = "", ["scan-text"] = "", ["scatter-chart"] = "", - ["school"] = "", + ["school"] = "", ["scissors"] = "", ["scissors-line-dashed"] = "", + ["scooter"] = "", ["screen-share"] = "", ["screen-share-off"] = "", ["scroll"] = "", ["scroll-text"] = "", ["search"] = "", + ["search-alert"] = "", ["search-check"] = "", ["search-code"] = "", ["search-large"] = "", @@ -1307,10 +1363,13 @@ public static class LucideIconData ["share-2"] = "", ["sheet"] = "", ["shell"] = "", + ["shelving-unit"] = "", ["shield"] = "", ["shield-alert"] = "", ["shield-ban"] = "", ["shield-check"] = "", + ["shield-cog"] = "", + ["shield-cog-corner"] = "", ["shield-ellipsis"] = "", ["shield-half"] = "", ["shield-minus"] = "", @@ -1327,7 +1386,7 @@ public static class LucideIconData ["shopping-cart"] = "", ["shovel"] = "", ["shower-head"] = "", - ["shredder"] = "", + ["shredder"] = "", ["shrimp"] = "", ["shrink"] = "", ["shrub"] = "", @@ -1339,7 +1398,7 @@ public static class LucideIconData ["signal-medium"] = "", ["signal-zero"] = "", ["signature"] = "", - ["signpost"] = "", + ["signpost"] = "", ["signpost-big"] = "", ["siren"] = "", ["skip-back"] = "", @@ -1359,6 +1418,7 @@ public static class LucideIconData ["snowflake"] = "", ["soap-dispenser-droplet"] = "", ["sofa"] = "", + ["solar-panel"] = "", ["soup"] = "", ["space"] = "", ["spade"] = "", @@ -1372,6 +1432,7 @@ public static class LucideIconData ["spline-pointer"] = "", ["split"] = "", ["spool"] = "", + ["sport-shoe"] = "", ["spotlight"] = "", ["spray-can"] = "", ["sprout"] = "", @@ -1386,11 +1447,15 @@ public static class LucideIconData ["square-arrow-out-up-left"] = "", ["square-arrow-out-up-right"] = "", ["square-arrow-right"] = "", + ["square-arrow-right-enter"] = "", + ["square-arrow-right-exit"] = "", ["square-arrow-up"] = "", ["square-arrow-up-left"] = "", ["square-arrow-up-right"] = "", ["square-asterisk"] = "", - ["square-bottom-dashed-scissors"] = "", + ["square-bottom-dashed-scissors"] = "", + ["square-centerline-dashed-horizontal"] = "", + ["square-centerline-dashed-vertical"] = "", ["square-chart-gantt"] = "", ["square-check"] = "", ["square-check-big"] = "", @@ -1427,7 +1492,7 @@ public static class LucideIconData ["square-power"] = "", ["square-radical"] = "", ["square-round-corner"] = "", - ["square-scissors"] = "", + ["square-scissors"] = "", ["square-sigma"] = "", ["square-slash"] = "", ["square-split-horizontal"] = "", @@ -1450,12 +1515,13 @@ public static class LucideIconData ["stamp"] = "", ["star"] = "", ["star-half"] = "", - ["star-off"] = "", + ["star-off"] = "", ["step-back"] = "", ["step-forward"] = "", ["stethoscope"] = "", - ["sticker"] = "", - ["sticky-note"] = "", + ["sticker"] = "", + ["sticky-note"] = "", + ["stone"] = "", ["store"] = "", ["stretch-horizontal"] = "", ["stretch-vertical"] = "", @@ -1517,9 +1583,9 @@ public static class LucideIconData ["theater"] = "", ["thermometer"] = "", ["thermometer-snowflake"] = "", - ["thermometer-sun"] = "", - ["thumbs-down"] = "", - ["thumbs-up"] = "", + ["thermometer-sun"] = "", + ["thumbs-down"] = "", + ["thumbs-up"] = "", ["ticket"] = "", ["ticket-check"] = "", ["ticket-minus"] = "", @@ -1527,8 +1593,8 @@ public static class LucideIconData ["ticket-plus"] = "", ["ticket-slash"] = "", ["ticket-x"] = "", - ["tickets"] = "", - ["tickets-plane"] = "", + ["tickets"] = "", + ["tickets-plane"] = "", ["timer"] = "", ["timer-off"] = "", ["timer-reset"] = "", @@ -1536,10 +1602,12 @@ public static class LucideIconData ["toggle-right"] = "", ["toilet"] = "", ["tool-case"] = "", + ["toolbox"] = "", ["tornado"] = "", ["torus"] = "", ["touchpad"] = "", ["touchpad-off"] = "", + ["towel-rack"] = "", ["tower-control"] = "", ["toy-brick"] = "", ["tractor"] = "", @@ -1594,13 +1662,15 @@ public static class LucideIconData ["user"] = "", ["user-check"] = "", ["user-cog"] = "", - ["user-lock"] = "", + ["user-key"] = "", + ["user-lock"] = "", ["user-minus"] = "", ["user-pen"] = "", ["user-plus"] = "", ["user-round"] = "", ["user-round-check"] = "", ["user-round-cog"] = "", + ["user-round-key"] = "", ["user-round-minus"] = "", ["user-round-pen"] = "", ["user-round-plus"] = "", @@ -1614,6 +1684,7 @@ public static class LucideIconData ["utensils"] = "", ["utensils-crossed"] = "", ["utility-pole"] = "", + ["van"] = "", ["variable"] = "", ["vault"] = "", ["vector-square"] = "", @@ -1645,12 +1716,15 @@ public static class LucideIconData ["washing-machine"] = "", ["watch"] = "", ["waves"] = "", + ["waves-arrow-down"] = "", + ["waves-arrow-up"] = "", ["waves-ladder"] = "", - ["waypoints"] = "", + ["waypoints"] = "", ["webcam"] = "", ["webhook"] = "", ["webhook-off"] = "", ["weight"] = "", + ["weight-tilde"] = "", ["wheat"] = "", ["wheat-off"] = "", ["whole-word"] = "", @@ -1671,9 +1745,23 @@ public static class LucideIconData ["wrap-text"] = "", ["wrench"] = "", ["x"] = "", + ["x-line-top"] = "", ["youtube"] = "", ["zap"] = "", ["zap-off"] = "", + ["zodiac-aquarius"] = "", + ["zodiac-aries"] = "", + ["zodiac-cancer"] = "", + ["zodiac-capricorn"] = "", + ["zodiac-gemini"] = "", + ["zodiac-leo"] = "", + ["zodiac-libra"] = "", + ["zodiac-ophiuchus"] = "", + ["zodiac-pisces"] = "", + ["zodiac-sagittarius"] = "", + ["zodiac-scorpio"] = "", + ["zodiac-taurus"] = "", + ["zodiac-virgo"] = "", ["zoom-in"] = "", ["zoom-out"] = "" }; From f970ae4711f45704b8f8cdc0d690e4e22d081f85 Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:10:00 +0800 Subject: [PATCH 019/188] fix: BbTagInput stale UI after tag removal (#282) * Implement feature X to enhance user experience and optimize performance * fix: track tag count in ShouldRender to fix stale UI after tag removal (#279) * docs: update changelog for 2026-03-30 and 2026-03-31 --- CHANGELOG.md | 16 + .../Components/TagInput/BbTagInput.razor.cs | 5 +- .../Data/LucideIconData.cs | 390 +++++++++++------- 3 files changed, 259 insertions(+), 152 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87ac4b732..2f6117f3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## 2026-03-31 + +### Fixed + +- **BbTagInput: stale UI after tag removal** — `ShouldRender()` did not track tag collection changes, so removing a tag only updated the bound state without re-rendering. The component now tracks tag count to trigger re-renders correctly. ([#279](https://github.com/blazorblueprintui/ui/issues/279)) + +--- + +## 2026-03-30 + +### Changed + +- **Lucide Icons: updated icon set** — Updated from 1,665 to 1,753 icons (88 new icons added). + +--- + ## 2026-03-26 ### Added diff --git a/src/BlazorBlueprint.Components/Components/TagInput/BbTagInput.razor.cs b/src/BlazorBlueprint.Components/Components/TagInput/BbTagInput.razor.cs index 4da3b0c7c..d469f104b 100644 --- a/src/BlazorBlueprint.Components/Components/TagInput/BbTagInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/TagInput/BbTagInput.razor.cs @@ -37,6 +37,7 @@ public partial class BbTagInput : ComponentBase, IAsyncDisposable private bool _lastSuggestionsOpen; private int _lastSuggestionIndex = -1; private bool _lastDisabled; + private int _lastTagCount; // ── Remove handler cache ─────────────────────────────────────────── private readonly Dictionary> _removeHandlerCache = new(); @@ -244,7 +245,8 @@ protected override bool ShouldRender() _lastInputText != _inputText || _lastSuggestionsOpen != _suggestionsOpen || _lastSuggestionIndex != _suggestionIndex || - _lastDisabled != Disabled; + _lastDisabled != Disabled || + _lastTagCount != _currentTags.Count; if (changed) { @@ -252,6 +254,7 @@ protected override bool ShouldRender() _lastSuggestionsOpen = _suggestionsOpen; _lastSuggestionIndex = _suggestionIndex; _lastDisabled = Disabled; + _lastTagCount = _currentTags.Count; return true; } diff --git a/src/BlazorBlueprint.Icons.Lucide/Data/LucideIconData.cs b/src/BlazorBlueprint.Icons.Lucide/Data/LucideIconData.cs index 19fa6d067..81488fe28 100644 --- a/src/BlazorBlueprint.Icons.Lucide/Data/LucideIconData.cs +++ b/src/BlazorBlueprint.Icons.Lucide/Data/LucideIconData.cs @@ -1,11 +1,11 @@ // This file is auto-generated. Do not edit manually. -// Generated from lucide.json on 2026-02-19 +// Generated from lucide.json on 2026-03-27 namespace BlazorBlueprint.Icons.Lucide.Data; /// /// Provides access to Lucide icon SVG data. -/// Contains 1665 icons from the Lucide icon set. +/// Contains 1753 icons from the Lucide icon set. /// public static class LucideIconData { @@ -52,10 +52,10 @@ public static class LucideIconData ["align-vertical-space-around"] = "", ["align-vertical-space-between"] = "", ["ambulance"] = "", - ["ampersand"] = "", + ["ampersand"] = "", ["ampersands"] = "", ["amphora"] = "", - ["anchor"] = "", + ["anchor"] = "", ["angry"] = "", ["annoyed"] = "", ["antenna"] = "", @@ -69,14 +69,14 @@ public static class LucideIconData ["archive-x"] = "", ["area-chart"] = "", ["armchair"] = "", - ["arrow-big-down"] = "", - ["arrow-big-down-dash"] = "", - ["arrow-big-left"] = "", - ["arrow-big-left-dash"] = "", - ["arrow-big-right"] = "", - ["arrow-big-right-dash"] = "", - ["arrow-big-up"] = "", - ["arrow-big-up-dash"] = "", + ["arrow-big-down"] = "", + ["arrow-big-down-dash"] = "", + ["arrow-big-left"] = "", + ["arrow-big-left-dash"] = "", + ["arrow-big-right"] = "", + ["arrow-big-right-dash"] = "", + ["arrow-big-up"] = "", + ["arrow-big-up-dash"] = "", ["arrow-down"] = "", ["arrow-down-0-1"] = "", ["arrow-down-1-0"] = "", @@ -141,9 +141,10 @@ public static class LucideIconData ["badge-turkish-lira"] = "", ["badge-x"] = "", ["baggage-claim"] = "", - ["ban"] = "", + ["balloon"] = "", + ["ban"] = "", ["banana"] = "", - ["bandage"] = "", + ["bandage"] = "", ["banknote"] = "", ["banknote-arrow-down"] = "", ["banknote-arrow-up"] = "", @@ -171,10 +172,11 @@ public static class LucideIconData ["bed-double"] = "", ["bed-single"] = "", ["beef"] = "", + ["beef-off"] = "", ["beer"] = "", ["beer-off"] = "", ["bell"] = "", - ["bell-dot"] = "", + ["bell-dot"] = "", ["bell-electric"] = "", ["bell-minus"] = "", ["bell-off"] = "", @@ -214,7 +216,7 @@ public static class LucideIconData ["book-headphones"] = "", ["book-heart"] = "", ["book-image"] = "", - ["book-key"] = "", + ["book-key"] = "", ["book-lock"] = "", ["book-marked"] = "", ["book-minus"] = "", @@ -222,17 +224,18 @@ public static class LucideIconData ["book-open-check"] = "", ["book-open-text"] = "", ["book-plus"] = "", + ["book-search"] = "", ["book-text"] = "", ["book-type"] = "", ["book-up"] = "", ["book-up-2"] = "", ["book-user"] = "", ["book-x"] = "", - ["bookmark"] = "", - ["bookmark-check"] = "", - ["bookmark-minus"] = "", - ["bookmark-plus"] = "", - ["bookmark-x"] = "", + ["bookmark"] = "", + ["bookmark-check"] = "", + ["bookmark-minus"] = "", + ["bookmark-plus"] = "", + ["bookmark-x"] = "", ["boom-box"] = "", ["bot"] = "", ["bot-message-square"] = "", @@ -255,10 +258,10 @@ public static class LucideIconData ["briefcase-medical"] = "", ["bring-to-front"] = "", ["brush"] = "", - ["brush-cleaning"] = "", - ["bubbles"] = "", + ["brush-cleaning"] = "", + ["bubbles"] = "", ["bug"] = "", - ["bug-off"] = "", + ["bug-off"] = "", ["bug-play"] = "", ["building"] = "", ["building-2"] = "", @@ -278,7 +281,7 @@ public static class LucideIconData ["calendar-clock"] = "", ["calendar-cog"] = "", ["calendar-days"] = "", - ["calendar-fold"] = "", + ["calendar-fold"] = "", ["calendar-heart"] = "", ["calendar-minus"] = "", ["calendar-minus-2"] = "", @@ -290,6 +293,7 @@ public static class LucideIconData ["calendar-sync"] = "", ["calendar-x"] = "", ["calendar-x-2"] = "", + ["calendars"] = "", ["camera"] = "", ["camera-off"] = "", ["candlestick-chart"] = "", @@ -297,6 +301,7 @@ public static class LucideIconData ["candy-cane"] = "", ["candy-off"] = "", ["cannabis"] = "", + ["cannabis-off"] = "", ["captions"] = "", ["captions-off"] = "", ["car"] = "", @@ -313,6 +318,7 @@ public static class LucideIconData ["castle"] = "", ["cat"] = "", ["cctv"] = "", + ["cctv-off"] = "", ["chart-area"] = "", ["chart-bar"] = "", ["chart-bar-big"] = "", @@ -341,6 +347,12 @@ public static class LucideIconData ["check-line"] = "", ["chef-hat"] = "", ["cherry"] = "", + ["chess-bishop"] = "", + ["chess-king"] = "", + ["chess-knight"] = "", + ["chess-pawn"] = "", + ["chess-queen"] = "", + ["chess-rook"] = "", ["chevron-down"] = "", ["chevron-first"] = "", ["chevron-last"] = "", @@ -378,12 +390,12 @@ public static class LucideIconData ["circle-chevron-right"] = "", ["circle-chevron-up"] = "", ["circle-dashed"] = "", - ["circle-divide"] = "", + ["circle-divide"] = "", ["circle-dollar-sign"] = "", ["circle-dot"] = "", ["circle-dot-dashed"] = "", ["circle-ellipsis"] = "", - ["circle-equal"] = "", + ["circle-equal"] = "", ["circle-fading-arrow-up"] = "", ["circle-fading-plus"] = "", ["circle-gauge"] = "", @@ -393,22 +405,23 @@ public static class LucideIconData ["circle-parking-off"] = "", ["circle-pause"] = "", ["circle-percent"] = "", + ["circle-pile"] = "", ["circle-play"] = "", ["circle-plus"] = "", - ["circle-pound-sterling"] = "", - ["circle-power"] = "", + ["circle-pound-sterling"] = "", + ["circle-power"] = "", ["circle-question-mark"] = "", ["circle-slash"] = "", - ["circle-slash-2"] = "", + ["circle-slash-2"] = "", ["circle-small"] = "", - ["circle-star"] = "", + ["circle-star"] = "", ["circle-stop"] = "", ["circle-user"] = "", - ["circle-user-round"] = "", + ["circle-user-round"] = "", ["circle-x"] = "", ["circuit-board"] = "", ["citrus"] = "", - ["clapperboard"] = "", + ["clapperboard"] = "", ["clipboard"] = "", ["clipboard-check"] = "", ["clipboard-clock"] = "", @@ -416,33 +429,35 @@ public static class LucideIconData ["clipboard-list"] = "", ["clipboard-minus"] = "", ["clipboard-paste"] = "", - ["clipboard-pen"] = "", + ["clipboard-pen"] = "", ["clipboard-pen-line"] = "", ["clipboard-plus"] = "", ["clipboard-type"] = "", ["clipboard-x"] = "", - ["clock"] = "", - ["clock-1"] = "", - ["clock-10"] = "", - ["clock-11"] = "", - ["clock-12"] = "", - ["clock-2"] = "", - ["clock-3"] = "", - ["clock-4"] = "", - ["clock-5"] = "", - ["clock-6"] = "", - ["clock-7"] = "", - ["clock-8"] = "", - ["clock-9"] = "", + ["clock"] = "", + ["clock-1"] = "", + ["clock-10"] = "", + ["clock-11"] = "", + ["clock-12"] = "", + ["clock-2"] = "", + ["clock-3"] = "", + ["clock-4"] = "", + ["clock-5"] = "", + ["clock-6"] = "", + ["clock-7"] = "", + ["clock-8"] = "", + ["clock-9"] = "", ["clock-alert"] = "", ["clock-arrow-down"] = "", ["clock-arrow-up"] = "", + ["clock-check"] = "", ["clock-fading"] = "", ["clock-plus"] = "", ["closed-caption"] = "", ["cloud"] = "", - ["cloud-alert"] = "", - ["cloud-check"] = "", + ["cloud-alert"] = "", + ["cloud-backup"] = "", + ["cloud-check"] = "", ["cloud-cog"] = "", ["cloud-download"] = "", ["cloud-drizzle"] = "", @@ -451,14 +466,15 @@ public static class LucideIconData ["cloud-lightning"] = "", ["cloud-moon"] = "", ["cloud-moon-rain"] = "", - ["cloud-off"] = "", + ["cloud-off"] = "", ["cloud-rain"] = "", ["cloud-rain-wind"] = "", ["cloud-snow"] = "", ["cloud-sun"] = "", ["cloud-sun-rain"] = "", + ["cloud-sync"] = "", ["cloud-upload"] = "", - ["cloudy"] = "", + ["cloudy"] = "", ["clover"] = "", ["club"] = "", ["code"] = "", @@ -467,14 +483,14 @@ public static class LucideIconData ["codesandbox"] = "", ["coffee"] = "", ["cog"] = "", - ["coins"] = "", + ["coins"] = "", ["columns-2"] = "", ["columns-3"] = "", ["columns-3-cog"] = "", ["columns-4"] = "", ["combine"] = "", ["command"] = "", - ["compass"] = "", + ["compass"] = "", ["component"] = "", ["computer"] = "", ["concierge-bell"] = "", @@ -510,13 +526,14 @@ public static class LucideIconData ["cross"] = "", ["crosshair"] = "", ["crown"] = "", - ["cuboid"] = "", + ["cuboid"] = "", ["cup-soda"] = "", ["currency"] = "", ["cylinder"] = "", ["dam"] = "", ["database"] = "", ["database-backup"] = "", + ["database-search"] = "", ["database-zap"] = "", ["decimals-arrow-left"] = "", ["decimals-arrow-right"] = "", @@ -570,6 +587,7 @@ public static class LucideIconData ["egg"] = "", ["egg-fried"] = "", ["egg-off"] = "", + ["ellipse"] = "", ["ellipsis"] = "", ["ellipsis-vertical"] = "", ["equal"] = "", @@ -592,89 +610,101 @@ public static class LucideIconData ["fence"] = "", ["ferris-wheel"] = "", ["figma"] = "", - ["file"] = "", - ["file-archive"] = "", + ["file"] = "", + ["file-archive"] = "", ["file-audio"] = "", ["file-audio-2"] = "", - ["file-axis-3d"] = "", - ["file-badge"] = "", + ["file-axis-3d"] = "", + ["file-badge"] = "", ["file-badge-2"] = "", - ["file-box"] = "", - ["file-chart-column"] = "", - ["file-chart-column-increasing"] = "", - ["file-chart-line"] = "", - ["file-chart-pie"] = "", - ["file-check"] = "", + ["file-box"] = "", + ["file-braces"] = "", + ["file-braces-corner"] = "", + ["file-chart-column"] = "", + ["file-chart-column-increasing"] = "", + ["file-chart-line"] = "", + ["file-chart-pie"] = "", + ["file-check"] = "", ["file-check-2"] = "", - ["file-clock"] = "", - ["file-code"] = "", + ["file-check-corner"] = "", + ["file-clock"] = "", + ["file-code"] = "", ["file-code-2"] = "", - ["file-cog"] = "", - ["file-diff"] = "", - ["file-digit"] = "", - ["file-down"] = "", - ["file-heart"] = "", - ["file-image"] = "", - ["file-input"] = "", + ["file-code-corner"] = "", + ["file-cog"] = "", + ["file-diff"] = "", + ["file-digit"] = "", + ["file-down"] = "", + ["file-exclamation-point"] = "", + ["file-headphone"] = "", + ["file-heart"] = "", + ["file-image"] = "", + ["file-input"] = "", ["file-json"] = "", ["file-json-2"] = "", - ["file-key"] = "", + ["file-key"] = "", ["file-key-2"] = "", - ["file-lock"] = "", + ["file-lock"] = "", ["file-lock-2"] = "", - ["file-minus"] = "", + ["file-minus"] = "", ["file-minus-2"] = "", - ["file-music"] = "", - ["file-output"] = "", - ["file-pen"] = "", - ["file-pen-line"] = "", + ["file-minus-corner"] = "", + ["file-music"] = "", + ["file-output"] = "", + ["file-pen"] = "", + ["file-pen-line"] = "", ["file-pie-chart"] = "", - ["file-play"] = "", - ["file-plus"] = "", + ["file-play"] = "", + ["file-plus"] = "", ["file-plus-2"] = "", - ["file-question-mark"] = "", - ["file-scan"] = "", - ["file-search"] = "", + ["file-plus-corner"] = "", + ["file-question-mark"] = "", + ["file-scan"] = "", + ["file-search"] = "", ["file-search-2"] = "", - ["file-sliders"] = "", - ["file-spreadsheet"] = "", + ["file-search-corner"] = "", + ["file-signal"] = "", + ["file-sliders"] = "", + ["file-spreadsheet"] = "", ["file-stack"] = "", - ["file-symlink"] = "", - ["file-terminal"] = "", - ["file-text"] = "", - ["file-type"] = "", + ["file-symlink"] = "", + ["file-terminal"] = "", + ["file-text"] = "", + ["file-type"] = "", ["file-type-2"] = "", - ["file-up"] = "", - ["file-user"] = "", - ["file-video-camera"] = "", - ["file-volume"] = "", + ["file-type-corner"] = "", + ["file-up"] = "", + ["file-user"] = "", + ["file-video-camera"] = "", + ["file-volume"] = "", ["file-volume-2"] = "", ["file-warning"] = "", - ["file-x"] = "", + ["file-x"] = "", ["file-x-2"] = "", - ["files"] = "", + ["file-x-corner"] = "", + ["files"] = "", ["film"] = "", ["filter"] = "", ["filter-x"] = "", - ["fingerprint"] = "", + ["fingerprint-pattern"] = "", ["fire-extinguisher"] = "", ["fish"] = "", ["fish-off"] = "", ["fish-symbol"] = "", + ["fishing-hook"] = "", + ["fishing-rod"] = "", ["flag"] = "", ["flag-off"] = "", ["flag-triangle-left"] = "", ["flag-triangle-right"] = "", ["flame"] = "", ["flame-kindling"] = "", - ["flashlight"] = "", - ["flashlight-off"] = "", + ["flashlight"] = "", + ["flashlight-off"] = "", ["flask-conical"] = "", ["flask-conical-off"] = "", ["flask-round"] = "", - ["flip-horizontal"] = "", ["flip-horizontal-2"] = "", - ["flip-vertical"] = "", ["flip-vertical-2"] = "", ["flower"] = "", ["flower-2"] = "", @@ -691,11 +721,11 @@ public static class LucideIconData ["folder-dot"] = "", ["folder-down"] = "", ["folder-git"] = "", - ["folder-git-2"] = "", + ["folder-git-2"] = "", ["folder-heart"] = "", ["folder-input"] = "", ["folder-kanban"] = "", - ["folder-key"] = "", + ["folder-key"] = "", ["folder-lock"] = "", ["folder-minus"] = "", ["folder-open"] = "", @@ -713,7 +743,8 @@ public static class LucideIconData ["folder-x"] = "", ["folders"] = "", ["footprints"] = "", - ["forklift"] = "", + ["forklift"] = "", + ["form"] = "", ["forward"] = "", ["frame"] = "", ["framer"] = "", @@ -736,8 +767,9 @@ public static class LucideIconData ["gem"] = "", ["georgian-lari"] = "", ["ghost"] = "", - ["gift"] = "", - ["git-branch"] = "", + ["gift"] = "", + ["git-branch"] = "", + ["git-branch-minus"] = "", ["git-branch-plus"] = "", ["git-commit-horizontal"] = "", ["git-commit-vertical"] = "", @@ -746,6 +778,7 @@ public static class LucideIconData ["git-fork"] = "", ["git-graph"] = "", ["git-merge"] = "", + ["git-merge-conflict"] = "", ["git-pull-request"] = "", ["git-pull-request-arrow"] = "", ["git-pull-request-closed"] = "", @@ -758,8 +791,10 @@ public static class LucideIconData ["glasses"] = "", ["globe"] = "", ["globe-lock"] = "", + ["globe-off"] = "", + ["globe-x"] = "", ["goal"] = "", - ["gpu"] = "", + ["gpu"] = "", ["graduation-cap"] = "", ["grape"] = "", ["grid-2x2"] = "", @@ -786,13 +821,14 @@ public static class LucideIconData ["hand-platter"] = "", ["handbag"] = "", ["handshake"] = "", - ["hard-drive"] = "", + ["hard-drive"] = "", ["hard-drive-download"] = "", ["hard-drive-upload"] = "", ["hard-hat"] = "", ["hash"] = "", ["hat-glasses"] = "", ["haze"] = "", + ["hd"] = "", ["hdmi-port"] = "", ["heading"] = "", ["heading-1"] = "", @@ -812,6 +848,7 @@ public static class LucideIconData ["heart-plus"] = "", ["heart-pulse"] = "", ["heater"] = "", + ["helicopter"] = "", ["hexagon"] = "", ["highlighter"] = "", ["history"] = "", @@ -872,12 +909,13 @@ public static class LucideIconData ["laptop"] = "", ["laptop-minimal"] = "", ["laptop-minimal-check"] = "", - ["lasso"] = "", + ["lasso"] = "", ["lasso-select"] = "", ["laugh"] = "", ["layers"] = "", ["layers-2"] = "", ["layers-3"] = "", + ["layers-plus"] = "", ["layout-dashboard"] = "", ["layout-grid"] = "", ["layout-list"] = "", @@ -887,6 +925,8 @@ public static class LucideIconData ["leaf"] = "", ["leafy-green"] = "", ["lectern"] = "", + ["lens-concave"] = "", + ["lens-convex"] = "", ["letter-text"] = "", ["library"] = "", ["library-big"] = "", @@ -895,7 +935,9 @@ public static class LucideIconData ["lightbulb"] = "", ["lightbulb-off"] = "", ["line-chart"] = "", + ["line-dot-right-horizontal"] = "", ["line-squiggle"] = "", + ["line-style"] = "", ["link"] = "", ["link-2"] = "", ["link-2-off"] = "", @@ -960,6 +1002,7 @@ public static class LucideIconData ["map-pin-pen"] = "", ["map-pin-plus"] = "", ["map-pin-plus-inside"] = "", + ["map-pin-search"] = "", ["map-pin-x"] = "", ["map-pin-x-inside"] = "", ["map-pinned"] = "", @@ -973,10 +1016,11 @@ public static class LucideIconData ["megaphone"] = "", ["megaphone-off"] = "", ["meh"] = "", - ["memory-stick"] = "", + ["memory-stick"] = "", ["menu"] = "", ["merge"] = "", ["message-circle"] = "", + ["message-circle-check"] = "", ["message-circle-code"] = "", ["message-circle-dashed"] = "", ["message-circle-heart"] = "", @@ -988,8 +1032,9 @@ public static class LucideIconData ["message-circle-warning"] = "", ["message-circle-x"] = "", ["message-square"] = "", + ["message-square-check"] = "", ["message-square-code"] = "", - ["message-square-dashed"] = "", + ["message-square-dashed"] = "", ["message-square-diff"] = "", ["message-square-dot"] = "", ["message-square-heart"] = "", @@ -1004,25 +1049,28 @@ public static class LucideIconData ["message-square-warning"] = "", ["message-square-x"] = "", ["messages-square"] = "", + ["metronome"] = "", ["mic"] = "", ["mic-off"] = "", ["mic-vocal"] = "", - ["microchip"] = "", + ["microchip"] = "", ["microscope"] = "", ["microwave"] = "", - ["milestone"] = "", + ["milestone"] = "", ["milk"] = "", ["milk-off"] = "", ["minimize"] = "", ["minimize-2"] = "", ["minus"] = "", + ["mirror-rectangular"] = "", + ["mirror-round"] = "", ["monitor"] = "", ["monitor-check"] = "", ["monitor-cloud"] = "", ["monitor-cog"] = "", ["monitor-dot"] = "", ["monitor-down"] = "", - ["monitor-off"] = "", + ["monitor-off"] = "", ["monitor-pause"] = "", ["monitor-play"] = "", ["monitor-smartphone"] = "", @@ -1036,11 +1084,14 @@ public static class LucideIconData ["mountain"] = "", ["mountain-snow"] = "", ["mouse"] = "", + ["mouse-left"] = "", ["mouse-off"] = "", ["mouse-pointer"] = "", ["mouse-pointer-2"] = "", + ["mouse-pointer-2-off"] = "", ["mouse-pointer-ban"] = "", ["mouse-pointer-click"] = "", + ["mouse-right"] = "", ["move"] = "", ["move-3d"] = "", ["move-diagonal"] = "", @@ -1086,13 +1137,13 @@ public static class LucideIconData ["origami"] = "", ["package"] = "", ["package-2"] = "", - ["package-check"] = "", - ["package-minus"] = "", + ["package-check"] = "", + ["package-minus"] = "", ["package-open"] = "", - ["package-plus"] = "", - ["package-search"] = "", - ["package-x"] = "", - ["paint-bucket"] = "", + ["package-plus"] = "", + ["package-search"] = "", + ["package-x"] = "", + ["paint-bucket"] = "", ["paint-roller"] = "", ["paintbrush"] = "", ["paintbrush-vertical"] = "", @@ -1165,7 +1216,7 @@ public static class LucideIconData ["plane-landing"] = "", ["plane-takeoff"] = "", ["play"] = "", - ["plug"] = "", + ["plug"] = "", ["plug-2"] = "", ["plug-zap"] = "", ["plus"] = "", @@ -1182,6 +1233,7 @@ public static class LucideIconData ["presentation"] = "", ["printer"] = "", ["printer-check"] = "", + ["printer-x"] = "", ["projector"] = "", ["proportions"] = "", ["puzzle"] = "", @@ -1193,6 +1245,7 @@ public static class LucideIconData ["radiation"] = "", ["radical"] = "", ["radio"] = "", + ["radio-off"] = "", ["radio-receiver"] = "", ["radio-tower"] = "", ["radius"] = "", @@ -1200,16 +1253,16 @@ public static class LucideIconData ["rainbow"] = "", ["rat"] = "", ["ratio"] = "", - ["receipt"] = "", - ["receipt-cent"] = "", - ["receipt-euro"] = "", - ["receipt-indian-rupee"] = "", - ["receipt-japanese-yen"] = "", - ["receipt-pound-sterling"] = "", - ["receipt-russian-ruble"] = "", - ["receipt-swiss-franc"] = "", + ["receipt"] = "", + ["receipt-cent"] = "", + ["receipt-euro"] = "", + ["receipt-indian-rupee"] = "", + ["receipt-japanese-yen"] = "", + ["receipt-pound-sterling"] = "", + ["receipt-russian-ruble"] = "", + ["receipt-swiss-franc"] = "", ["receipt-text"] = "", - ["receipt-turkish-lira"] = "", + ["receipt-turkish-lira"] = "", ["rectangle-circle"] = "", ["rectangle-ellipsis"] = "", ["rectangle-goggles"] = "", @@ -1235,13 +1288,14 @@ public static class LucideIconData ["reply-all"] = "", ["rewind"] = "", ["ribbon"] = "", - ["rocket"] = "", - ["rocking-chair"] = "", + ["road"] = "", + ["rocket"] = "", + ["rocking-chair"] = "", ["roller-coaster"] = "", ["rose"] = "", ["rotate-3d"] = "", ["rotate-ccw"] = "", - ["rotate-ccw-key"] = "", + ["rotate-ccw-key"] = "", ["rotate-ccw-square"] = "", ["rotate-cw"] = "", ["rotate-cw-square"] = "", @@ -1253,7 +1307,7 @@ public static class LucideIconData ["rows-4"] = "", ["rss"] = "", ["ruler"] = "", - ["ruler-dimension-line"] = "", + ["ruler-dimension-line"] = "", ["russian-ruble"] = "", ["sailboat"] = "", ["salad"] = "", @@ -1264,7 +1318,7 @@ public static class LucideIconData ["save"] = "", ["save-all"] = "", ["save-off"] = "", - ["scale"] = "", + ["scale"] = "", ["scale-3d"] = "", ["scaling"] = "", ["scan"] = "", @@ -1277,14 +1331,16 @@ public static class LucideIconData ["scan-search"] = "", ["scan-text"] = "", ["scatter-chart"] = "", - ["school"] = "", + ["school"] = "", ["scissors"] = "", ["scissors-line-dashed"] = "", + ["scooter"] = "", ["screen-share"] = "", ["screen-share-off"] = "", ["scroll"] = "", ["scroll-text"] = "", ["search"] = "", + ["search-alert"] = "", ["search-check"] = "", ["search-code"] = "", ["search-large"] = "", @@ -1307,10 +1363,13 @@ public static class LucideIconData ["share-2"] = "", ["sheet"] = "", ["shell"] = "", + ["shelving-unit"] = "", ["shield"] = "", ["shield-alert"] = "", ["shield-ban"] = "", ["shield-check"] = "", + ["shield-cog"] = "", + ["shield-cog-corner"] = "", ["shield-ellipsis"] = "", ["shield-half"] = "", ["shield-minus"] = "", @@ -1327,7 +1386,7 @@ public static class LucideIconData ["shopping-cart"] = "", ["shovel"] = "", ["shower-head"] = "", - ["shredder"] = "", + ["shredder"] = "", ["shrimp"] = "", ["shrink"] = "", ["shrub"] = "", @@ -1339,7 +1398,7 @@ public static class LucideIconData ["signal-medium"] = "", ["signal-zero"] = "", ["signature"] = "", - ["signpost"] = "", + ["signpost"] = "", ["signpost-big"] = "", ["siren"] = "", ["skip-back"] = "", @@ -1359,6 +1418,7 @@ public static class LucideIconData ["snowflake"] = "", ["soap-dispenser-droplet"] = "", ["sofa"] = "", + ["solar-panel"] = "", ["soup"] = "", ["space"] = "", ["spade"] = "", @@ -1372,6 +1432,7 @@ public static class LucideIconData ["spline-pointer"] = "", ["split"] = "", ["spool"] = "", + ["sport-shoe"] = "", ["spotlight"] = "", ["spray-can"] = "", ["sprout"] = "", @@ -1386,11 +1447,15 @@ public static class LucideIconData ["square-arrow-out-up-left"] = "", ["square-arrow-out-up-right"] = "", ["square-arrow-right"] = "", + ["square-arrow-right-enter"] = "", + ["square-arrow-right-exit"] = "", ["square-arrow-up"] = "", ["square-arrow-up-left"] = "", ["square-arrow-up-right"] = "", ["square-asterisk"] = "", - ["square-bottom-dashed-scissors"] = "", + ["square-bottom-dashed-scissors"] = "", + ["square-centerline-dashed-horizontal"] = "", + ["square-centerline-dashed-vertical"] = "", ["square-chart-gantt"] = "", ["square-check"] = "", ["square-check-big"] = "", @@ -1427,7 +1492,7 @@ public static class LucideIconData ["square-power"] = "", ["square-radical"] = "", ["square-round-corner"] = "", - ["square-scissors"] = "", + ["square-scissors"] = "", ["square-sigma"] = "", ["square-slash"] = "", ["square-split-horizontal"] = "", @@ -1450,12 +1515,13 @@ public static class LucideIconData ["stamp"] = "", ["star"] = "", ["star-half"] = "", - ["star-off"] = "", + ["star-off"] = "", ["step-back"] = "", ["step-forward"] = "", ["stethoscope"] = "", - ["sticker"] = "", - ["sticky-note"] = "", + ["sticker"] = "", + ["sticky-note"] = "", + ["stone"] = "", ["store"] = "", ["stretch-horizontal"] = "", ["stretch-vertical"] = "", @@ -1517,9 +1583,9 @@ public static class LucideIconData ["theater"] = "", ["thermometer"] = "", ["thermometer-snowflake"] = "", - ["thermometer-sun"] = "", - ["thumbs-down"] = "", - ["thumbs-up"] = "", + ["thermometer-sun"] = "", + ["thumbs-down"] = "", + ["thumbs-up"] = "", ["ticket"] = "", ["ticket-check"] = "", ["ticket-minus"] = "", @@ -1527,8 +1593,8 @@ public static class LucideIconData ["ticket-plus"] = "", ["ticket-slash"] = "", ["ticket-x"] = "", - ["tickets"] = "", - ["tickets-plane"] = "", + ["tickets"] = "", + ["tickets-plane"] = "", ["timer"] = "", ["timer-off"] = "", ["timer-reset"] = "", @@ -1536,10 +1602,12 @@ public static class LucideIconData ["toggle-right"] = "", ["toilet"] = "", ["tool-case"] = "", + ["toolbox"] = "", ["tornado"] = "", ["torus"] = "", ["touchpad"] = "", ["touchpad-off"] = "", + ["towel-rack"] = "", ["tower-control"] = "", ["toy-brick"] = "", ["tractor"] = "", @@ -1594,13 +1662,15 @@ public static class LucideIconData ["user"] = "", ["user-check"] = "", ["user-cog"] = "", - ["user-lock"] = "", + ["user-key"] = "", + ["user-lock"] = "", ["user-minus"] = "", ["user-pen"] = "", ["user-plus"] = "", ["user-round"] = "", ["user-round-check"] = "", ["user-round-cog"] = "", + ["user-round-key"] = "", ["user-round-minus"] = "", ["user-round-pen"] = "", ["user-round-plus"] = "", @@ -1614,6 +1684,7 @@ public static class LucideIconData ["utensils"] = "", ["utensils-crossed"] = "", ["utility-pole"] = "", + ["van"] = "", ["variable"] = "", ["vault"] = "", ["vector-square"] = "", @@ -1645,12 +1716,15 @@ public static class LucideIconData ["washing-machine"] = "", ["watch"] = "", ["waves"] = "", + ["waves-arrow-down"] = "", + ["waves-arrow-up"] = "", ["waves-ladder"] = "", - ["waypoints"] = "", + ["waypoints"] = "", ["webcam"] = "", ["webhook"] = "", ["webhook-off"] = "", ["weight"] = "", + ["weight-tilde"] = "", ["wheat"] = "", ["wheat-off"] = "", ["whole-word"] = "", @@ -1671,9 +1745,23 @@ public static class LucideIconData ["wrap-text"] = "", ["wrench"] = "", ["x"] = "", + ["x-line-top"] = "", ["youtube"] = "", ["zap"] = "", ["zap-off"] = "", + ["zodiac-aquarius"] = "", + ["zodiac-aries"] = "", + ["zodiac-cancer"] = "", + ["zodiac-capricorn"] = "", + ["zodiac-gemini"] = "", + ["zodiac-leo"] = "", + ["zodiac-libra"] = "", + ["zodiac-ophiuchus"] = "", + ["zodiac-pisces"] = "", + ["zodiac-sagittarius"] = "", + ["zodiac-scorpio"] = "", + ["zodiac-taurus"] = "", + ["zodiac-virgo"] = "", ["zoom-in"] = "", ["zoom-out"] = "" }; From c64227c3250c0c19b761536a58d20fb7ac961aa1 Mon Sep 17 00:00:00 2001 From: Mathew Date: Wed, 1 Apr 2026 21:10:52 +0800 Subject: [PATCH 020/188] chore: add external libs directory to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 9fef345e6..f7bbdc636 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ publish/ # Development Tools .claude devkit/ + +# External libs +pro/ From 8673213e7cfc61622c3622c2282c6c8f7dc6b6e1 Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:25:36 +0800 Subject: [PATCH 021/188] fix: BbFormFieldSelect label click opening dropdown (#283) * fix: remove label-to-trigger association in BbFormFieldSelect The `For` attribute on BbFieldLabel created a native HTML label-input association that forwarded clicks to the select trigger button, causing the dropdown to open on label click and select-all on a second click. Removing it matches BbFormFieldCombobox and BbFormFieldMultiSelect. * docs: update changelog for 2026-04-01 --- CHANGELOG.md | 8 ++++++++ .../Components/FormFieldSelect/BbFormFieldSelect.razor | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f6117f3d..238b9a63e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## 2026-04-01 + +### Fixed + +- **BbFormFieldSelect: label click opening dropdown** — The `For` attribute on the field label created a native HTML `
    @@ -736,6 +819,10 @@ // Compositional mode example private string? compositionalValue; + // Grouped examples + private string? groupedValue; + private string? groupedCustomLabelValue; + private static readonly Dictionary compositionalLabels = new() { ["blazor"] = "Blazor", diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldComboboxDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldComboboxDemo.razor index 63e8575bb..d6d594cdf 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldComboboxDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldComboboxDemo.razor @@ -129,6 +129,43 @@
    + +
    +
    +

    Grouped Items

    +

    + Use BbComboboxGroup to organize items + under labeled sections. Groups automatically hide when their items are filtered out. +

    +
    +
    + + + Gender + Nationality + Age + + + Company + Job Title + Department + + +
    +
    +

    Selected value: @(string.IsNullOrEmpty(_groupedValue) ? "(none)" : _groupedValue)

    +
    + +
    +
    @@ -417,6 +454,7 @@ @code { private string? _selectedFramework; private string? _compositionalValue; + private string? _groupedValue; private int? _selectedPriority; private Guid? _selectedProject; private bool _formSubmitted; diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor index 926fd8c9c..70aae1343 100644 --- a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor @@ -35,9 +35,9 @@ @EffectiveEmptyMessage - - @if (Options is not null) - { + @if (Options is not null) + { + @foreach (var option in Options) { var itemValue = option.Value; @@ -61,12 +61,12 @@ } - } - else if (ChildContent is not null) - { - @ChildContent - } - + + } + else if (ChildContent is not null) + { + @ChildContent + } diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxGroup.razor b/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxGroup.razor new file mode 100644 index 000000000..6412da334 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxGroup.razor @@ -0,0 +1,38 @@ +@namespace BlazorBlueprint.Components + + + @if (!string.IsNullOrWhiteSpace(Label)) + { +
    + @Label +
    + } + @ChildContent +
    + +@code { + /// + /// Gets or sets the label text displayed above the group items. + /// When the search query filters out all items in this group, the entire group (including the label) is hidden. + /// + [Parameter] + public string? Label { get; set; } + + /// + /// Gets or sets additional CSS classes to apply to the label element. + /// + [Parameter] + public string? LabelClass { get; set; } + + /// + /// Gets or sets the content to be rendered inside the group. + /// Typically contains components. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + private string LabelCssClass => ClassNames.cn( + "px-2 py-1.5 text-xs font-semibold text-muted-foreground", + LabelClass + ); +} diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index d85217424..945f299e1 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -442,6 +442,11 @@ - ValueExpression : Expression> - CascadedEditContext : EditContext [CascadingParameter] +### BbComboboxGroup (BlazorBlueprint.Components) + - ChildContent : RenderFragment + - Label : String + - LabelClass : String + ### BbComboboxItem`1 (BlazorBlueprint.Components) - ChildContent : RenderFragment - Disabled : Boolean From 788fe2125d882228666d0cb4b35ed12c8f92b172 Mon Sep 17 00:00:00 2001 From: Mathew Date: Tue, 7 Apr 2026 10:20:14 +0800 Subject: [PATCH 034/188] docs: release notes for Components v3.9.5 --- src/BlazorBlueprint.Components/RELEASE_NOTES.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/BlazorBlueprint.Components/RELEASE_NOTES.md b/src/BlazorBlueprint.Components/RELEASE_NOTES.md index 6cdfa40ba..72e5b9f1a 100644 --- a/src/BlazorBlueprint.Components/RELEASE_NOTES.md +++ b/src/BlazorBlueprint.Components/RELEASE_NOTES.md @@ -1,11 +1,9 @@ -## What's New in v3.9.3 +## What's New in v3.9.5 -### Bug Fixes +### New Components -- **BbFormFieldSelect** — Fixed label click incorrectly opening the dropdown by removing the `For` attribute from the field label -- **BbTagInput** — Fixed stale UI after tag removal by tracking tag count changes in the render-check logic -- **Border radius** — Aligned `--radius-md` and `--radius-sm` calculations with shadcn/ui docs, using proportional scaling (`0.8` / `0.6`) instead of fixed pixel offsets +- **BbComboboxGroup** — A new component for visually grouping combobox items under an optional label. Supports `Label`, `LabelClass`, and `ChildContent` parameters. ### Improvements -- Bumped **BlazorBlueprint.Primitives** dependency to v3.9.3 +- **BbCombobox** — When using `ChildContent` instead of the `Options` parameter, the wrapping `BbCommandGroup` is no longer automatically applied, allowing users to define their own grouping structure with `BbComboboxGroup`. From b16dae67b09a626fd17390ce9db263010808cfe9 Mon Sep 17 00:00:00 2001 From: Mathew Date: Tue, 7 Apr 2026 10:24:24 +0800 Subject: [PATCH 035/188] docs: add code example source files for grouped combobox demos --- .../Combobox/grouped-custom-label.txt | 21 ++++++++++++++++ .../Components/Combobox/grouped.txt | 25 +++++++++++++++++++ .../Components/FormFieldCombobox/grouped.txt | 24 ++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Combobox/grouped-custom-label.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Combobox/grouped.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/FormFieldCombobox/grouped.txt diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Combobox/grouped-custom-label.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Combobox/grouped-custom-label.txt new file mode 100644 index 000000000..475ef6b70 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Combobox/grouped-custom-label.txt @@ -0,0 +1,21 @@ + + + Mango + Pineapple + Papaya + + + Strawberry + Blueberry + Raspberry + + + +@code { + private string? groupedCustomLabelValue; +} diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Combobox/grouped.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Combobox/grouped.txt new file mode 100644 index 000000000..96621ffc2 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Combobox/grouped.txt @@ -0,0 +1,25 @@ + + + Gender + Nationality + Age + + + Company + Job Title + Department + + + Country + City + + + +@code { + private string? groupedValue; +} diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/FormFieldCombobox/grouped.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/FormFieldCombobox/grouped.txt new file mode 100644 index 000000000..58bffc46e --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/FormFieldCombobox/grouped.txt @@ -0,0 +1,24 @@ + + + Gender + Nationality + Age + + + Company + Job Title + Department + + + +@code { + private string? _groupedValue; +} From e69add427614314ec8d4696ddc728f2920e15890 Mon Sep 17 00:00:00 2001 From: Mathew Date: Fri, 10 Apr 2026 09:01:06 +0800 Subject: [PATCH 036/188] fix: BbFormFieldCombobox search filter bypassed when wrapper is used (#296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The form field wrapper unconditionally wired a local SearchQueryChanged handler to the inner BbCombobox, which made BbCombobox's auto-detection (`SearchQueryChanged.HasDelegate`) flip into external-filtering mode and install PassthroughFilter — bypassing the internal text filter entirely. The symptom was most visible inside an EditForm with a validator, where the extra re-renders also reset BbCommandInput's display value. Only wire the interception handler (and forward SearchQuery) when the consumer is actually binding SearchQueryChanged, matching the pattern used by BbFormFieldMultiSelect. --- .../BbFormFieldCombobox.razor | 4 ++-- .../BbFormFieldCombobox.razor.cs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/BlazorBlueprint.Components/Components/FormFieldCombobox/BbFormFieldCombobox.razor b/src/BlazorBlueprint.Components/Components/FormFieldCombobox/BbFormFieldCombobox.razor index aa74831e5..745310748 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldCombobox/BbFormFieldCombobox.razor +++ b/src/BlazorBlueprint.Components/Components/FormFieldCombobox/BbFormFieldCombobox.razor @@ -16,8 +16,8 @@ Placeholder="@Placeholder" SearchPlaceholder="@SearchPlaceholder" EmptyMessage="@EmptyMessage" - SearchQuery="@SearchQuery" - SearchQueryChanged="@HandleSearchQueryChanged" + SearchQuery="@InnerSearchQuery" + SearchQueryChanged="@InnerSearchQueryChanged" OnLoadMore="@OnLoadMore" IsLoading="@IsLoading" EndOfListMessage="@EndOfListMessage" diff --git a/src/BlazorBlueprint.Components/Components/FormFieldCombobox/BbFormFieldCombobox.razor.cs b/src/BlazorBlueprint.Components/Components/FormFieldCombobox/BbFormFieldCombobox.razor.cs index af3035c08..92f1a4cf7 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldCombobox/BbFormFieldCombobox.razor.cs +++ b/src/BlazorBlueprint.Components/Components/FormFieldCombobox/BbFormFieldCombobox.razor.cs @@ -138,6 +138,24 @@ public partial class BbFormFieldCombobox : FormFieldBase /// protected override LambdaExpression? GetFieldExpression() => ValueExpression; + /// + /// The search query forwarded to the inner combobox. Only surfaces the local + /// value when the consumer is actively binding it; + /// otherwise returns an empty string so the inner combobox can manage its own + /// search state without being clobbered by parent re-renders. + /// + private string InnerSearchQuery => SearchQueryChanged.HasDelegate ? SearchQuery : string.Empty; + + /// + /// The search query change callback forwarded to the inner combobox. Only wires + /// when the consumer is binding ; otherwise returns + /// the default (no delegate) so the inner combobox keeps its internal text filter + /// instead of switching into external-filtering mode. + /// + private EventCallback InnerSearchQueryChanged => SearchQueryChanged.HasDelegate + ? EventCallback.Factory.Create(this, HandleSearchQueryChanged) + : default; + private async Task HandleValueChanged(TValue? value) { Value = value; From 52a5266e29ec809272acb444d318894fbb7e7b13 Mon Sep 17 00:00:00 2001 From: Mathew Date: Fri, 10 Apr 2026 09:06:39 +0800 Subject: [PATCH 037/188] docs: release notes for Components v3.9.6 --- src/BlazorBlueprint.Components/RELEASE_NOTES.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/BlazorBlueprint.Components/RELEASE_NOTES.md b/src/BlazorBlueprint.Components/RELEASE_NOTES.md index 72e5b9f1a..9f8a2c242 100644 --- a/src/BlazorBlueprint.Components/RELEASE_NOTES.md +++ b/src/BlazorBlueprint.Components/RELEASE_NOTES.md @@ -1,9 +1,5 @@ -## What's New in v3.9.5 +## What's New in v3.9.6 -### New Components +### Bug Fixes -- **BbComboboxGroup** — A new component for visually grouping combobox items under an optional label. Supports `Label`, `LabelClass`, and `ChildContent` parameters. - -### Improvements - -- **BbCombobox** — When using `ChildContent` instead of the `Options` parameter, the wrapping `BbCommandGroup` is no longer automatically applied, allowing users to define their own grouping structure with `BbComboboxGroup`. +- **BbFormFieldCombobox** — Fixed search filtering being bypassed when the combobox is used through the form field wrapper without explicitly binding `SearchQuery`. The inner combobox now correctly manages its own search state when the consumer does not provide an external search binding. From 754f952fb41bbbbfd4c8ed005271f351214f00df Mon Sep 17 00:00:00 2001 From: Alan LECART Date: Thu, 16 Apr 2026 10:39:15 +0200 Subject: [PATCH 038/188] fix: update source property from Avatar component --- V3-MIGRATION-GUIDE.md | 4 ++-- src/BlazorBlueprint.Components/README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/V3-MIGRATION-GUIDE.md b/V3-MIGRATION-GUIDE.md index d7e592315..aac0a595a 100644 --- a/V3-MIGRATION-GUIDE.md +++ b/V3-MIGRATION-GUIDE.md @@ -1368,11 +1368,11 @@ A new component that renders child `Avatar` components with overlapping negative ```razor - + U1 - + U2 diff --git a/src/BlazorBlueprint.Components/README.md b/src/BlazorBlueprint.Components/README.md index 9277d7d49..e56c32d67 100644 --- a/src/BlazorBlueprint.Components/README.md +++ b/src/BlazorBlueprint.Components/README.md @@ -280,7 +280,7 @@ Convenience wrappers that combine a form control with `BbField` for label, descr ```razor - + JD ``` From 2c80efa9600cfa245261a4ac57cf19c2cd1e6063 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sat, 18 Apr 2026 13:01:32 +0800 Subject: [PATCH 039/188] fix: BbTabs now forwards AdditionalAttributes to primitive root (#293) Drops the redundant outer wrapper div and passes class + AdditionalAttributes straight to the Tabs primitive, matching the Accordion/Dialog wrapping pattern. Previously attributes like id and data-* set on BbTabs never reached the primitive's root element. --- .../Components/Tabs/BbTabs.razor | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/BlazorBlueprint.Components/Components/Tabs/BbTabs.razor b/src/BlazorBlueprint.Components/Components/Tabs/BbTabs.razor index 878ddf9b7..d93132ab7 100644 --- a/src/BlazorBlueprint.Components/Components/Tabs/BbTabs.razor +++ b/src/BlazorBlueprint.Components/Components/Tabs/BbTabs.razor @@ -5,16 +5,16 @@ Wraps the Tabs primitive with shadcn/ui styling. *@ -
    - - @ChildContent - -
    + + @ChildContent + @code { ///
    From 935d16d5ef8df53f848c95061226e8b5ed081814 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sat, 2 May 2026 14:47:02 +0800 Subject: [PATCH 040/188] fix(BbFilterBuilder): wrap condition rows on small screens (#311) Adds flex-wrap to the filter condition row and to the inner range/InLast sub-rows so the fixed-width selects/inputs wrap onto multiple lines instead of overflowing the parent card on mobile widths. --- .../Components/FilterBuilder/BbFilterCondition.razor | 4 ++-- .../Components/FilterBuilder/BbFilterCondition.razor.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/BlazorBlueprint.Components/Components/FilterBuilder/BbFilterCondition.razor b/src/BlazorBlueprint.Components/Components/FilterBuilder/BbFilterCondition.razor index a6dc2f293..b9006258c 100644 --- a/src/BlazorBlueprint.Components/Components/FilterBuilder/BbFilterCondition.razor +++ b/src/BlazorBlueprint.Components/Components/FilterBuilder/BbFilterCondition.razor @@ -31,7 +31,7 @@ break; case FilterFieldType.Number when FilterOperatorHelper.IsRangeOperator(Condition.Operator): -
    +
    +
    ? values) // CSS classes private string RowCssClass => ClassNames.cn( - "flex items-center gap-2", + "flex flex-wrap items-center", Context?.Compact == true ? "gap-1" : "gap-2" ); From 4ee59a158cd773db02505a152f6decb5a12b51a4 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sat, 2 May 2026 14:47:02 +0800 Subject: [PATCH 041/188] fix(BbDataTable): remove Phase 2 column-filter placeholder (#309) The toolbar's Filter popover shipped a "to be implemented in Phase 2" placeholder UI that consumers had no way to disable. Removed the popover and the unused DataTable.Filter / DataTable.FilterColumns localization keys. Updated XML docs to describe Filterable as scoping the global search and to point users at BbDataGrid for per-column filter UIs. --- .../Pages/Components/DataTableDemo.razor | 5 ++--- .../Components/DataTable/BbDataTable.razor.cs | 12 +++++++--- .../DataTable/BbDataTableColumn.razor.cs | 8 ++++++- .../DataTable/BbDataTableToolbar.razor | 22 ------------------- .../Localization/DefaultBbLocalizer.cs | 2 -- 5 files changed, 18 insertions(+), 31 deletions(-) diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataTableDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataTableDemo.razor index 3ede2a760..04beec833 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataTableDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataTableDemo.razor @@ -9,7 +9,8 @@
    @@ -417,8 +418,6 @@ new("Search", "Search...", "Placeholder for the toolbar search input"), new("Columns", "Columns", "Label for the column visibility button"), new("ToggleColumns", "Toggle columns", "Aria label for column toggle checkboxes"), - new("Filter", "Filter", "Label for the filter button"), - new("FilterColumns", "Filter columns", "Placeholder in the filter columns search"), new("SelectAllRows", "Select all rows", "Aria label for the header checkbox"), new("SelectThisRow", "Select this row", "Aria label for row checkboxes"), new("ClearSelection", "Clear selection", "Label for the clear selection action"), diff --git a/src/BlazorBlueprint.Components/Components/DataTable/BbDataTable.razor.cs b/src/BlazorBlueprint.Components/Components/DataTable/BbDataTable.razor.cs index 21001a2c9..4c77c4d18 100644 --- a/src/BlazorBlueprint.Components/Components/DataTable/BbDataTable.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataTable/BbDataTable.razor.cs @@ -6,7 +6,7 @@ namespace BlazorBlueprint.Components; /// /// A styled data table component that wraps the Table Primitive with automatic sorting, -/// filtering, pagination, and row selection capabilities. +/// pagination, row selection, and global search. /// /// The type of data items in the table. /// @@ -18,13 +18,19 @@ namespace BlazorBlueprint.Components; /// /// Features: /// - Declarative column API via DataTableColumn child components -/// - Automatic sorting, filtering, and pagination (hybrid mode with overrides) +/// - Automatic sorting and pagination (hybrid mode with overrides) +/// - Global search across columns via the toolbar /// - Row selection (single/multiple) with checkboxes -/// - Optional toolbar with global search and column visibility toggle +/// - Optional toolbar with column visibility toggle /// - Empty and loading state templates /// - Full shadcn styling with hover states and transitions /// - Accessibility support (ARIA attributes, keyboard navigation) /// +/// +/// For per-column filter UIs (operator + value editor per column, like Excel autofilter), +/// use instead. DataTable is intentionally a simpler +/// surface focused on global search. +/// /// /// /// diff --git a/src/BlazorBlueprint.Components/Components/DataTable/BbDataTableColumn.razor.cs b/src/BlazorBlueprint.Components/Components/DataTable/BbDataTableColumn.razor.cs index 87fa8a0b8..bb12fdfac 100644 --- a/src/BlazorBlueprint.Components/Components/DataTable/BbDataTableColumn.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataTable/BbDataTableColumn.razor.cs @@ -70,9 +70,15 @@ public partial class BbDataTableColumn : ComponentBase where TDat public bool Sortable { get; set; } /// - /// Gets or sets whether this column can be filtered. + /// Gets or sets whether this column is included in the toolbar's global search. + /// When at least one column has Filterable="true", the global search box + /// only probes those columns; otherwise it probes every column. /// Default is false. /// + /// + /// For per-column filter UIs (operator + value editor per column), + /// use which ships full column-filter support. + /// [Parameter] public bool Filterable { get; set; } diff --git a/src/BlazorBlueprint.Components/Components/DataTable/BbDataTableToolbar.razor b/src/BlazorBlueprint.Components/Components/DataTable/BbDataTableToolbar.razor index 741229c77..48795f8e9 100644 --- a/src/BlazorBlueprint.Components/Components/DataTable/BbDataTableToolbar.razor +++ b/src/BlazorBlueprint.Components/Components/DataTable/BbDataTableToolbar.razor @@ -9,28 +9,6 @@ Placeholder="@Localizer["DataTable.Search"]" Class="h-8 w-[150px] lg:w-[250px]" /> - @if (Columns.Any(c => c.Filterable)) - { - - - - - - - @Localizer["DataTable.Filter"] - - - -
    -
    @Localizer["DataTable.FilterColumns"]
    -
    - (Column filtering UI - To be implemented in Phase 2) -
    -
    -
    -
    - } - diff --git a/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs b/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs index 2856704f7..aa3ae97a4 100644 --- a/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs +++ b/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs @@ -107,8 +107,6 @@ public class DefaultBbLocalizer : IBbLocalizer ["DataTable.Search"] = "Search...", ["DataTable.Columns"] = "Columns", ["DataTable.ToggleColumns"] = "Toggle columns", - ["DataTable.Filter"] = "Filter", - ["DataTable.FilterColumns"] = "Filter columns", // DataView ["DataView.SearchPlaceholder"] = "Search...", From f5b52f253dfb6b107c68a454f282848a5096a9c5 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sat, 2 May 2026 15:09:18 +0800 Subject: [PATCH 042/188] feat(css): wrap component CSS in @layer bb cascade layer Wraps the Tailwind utility output and authored component styles inside a dedicated `bb` cascade layer declared as the strongest layer: @layer properties, theme, base, components, utilities, bb. Component utilities like .md:flex on the sidebar now win the cascade regardless of which tag a consumer's app.css loads in relative to ours, fixing the class of bug seen in #308 once and for all. @media (prefers-reduced-motion) and @keyframes stay at top level so they remain authoritative. tw-animate.css is imported unlayered because it uses @utility (a Tailwind v4 source directive that cannot be nested inside a layer() import); its animation utilities land in the standard utilities layer. Consumer override path: !important (which inverts layer priority) or authored unlayered CSS. --- .../wwwroot/css/blazorblueprint-input.css | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css b/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css index 75d8b00fe..5e5f44856 100644 --- a/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css +++ b/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css @@ -1,5 +1,16 @@ /* BlazorBlueprint Components - Pre-built Tailwind CSS */ -@import 'tailwindcss'; + +/* Cascade-layer order. `bb` is declared last so component utilities + (e.g. md:flex on the sidebar) always win at parity regardless of which + tag a consumer's app.css loads in relative to ours. Consumers + override with !important (which inverts layer priority) or unlayered CSS. */ +@layer properties, theme, base, components, utilities, bb; + +@import 'tailwindcss' layer(bb); +/* tw-animate.css uses @utility (a Tailwind v4 source directive) which cannot + be nested inside a layer. Imported unlayered; its utilities land in the + default `utilities` layer. Anything in tw-animate that absolutely must win + should use the doubled-data-attribute trick or live inside @layer bb below. */ @import './tw-animate.css'; /* Configure source paths for scanning Razor files */ @@ -196,6 +207,11 @@ --tracking-widest: calc(var(--tracking-normal) + 0.1em); } +/* All component utility/component layer blocks live inside `@layer bb` so they win + the cascade-layer parity against consumer Tailwind output. The inner @layer + declarations (base/components/utilities) become sublayers of bb. */ +@layer bb { + /* Required for keyboard navigation visual feedback in Select/Combobox/DropdownMenu */ @layer components { [role="option"][data-focused="true"] { @@ -745,7 +761,10 @@ } } -/* Accessibility — respect user's reduced-motion preference */ +} /* end @layer bb */ + +/* Accessibility — respect user's reduced-motion preference. + Left UNLAYERED on purpose so it cannot be defeated by any layered rule. */ @media (prefers-reduced-motion: reduce) { *, *::before, @@ -756,6 +775,8 @@ } } +@layer bb { + /* DynamicForm - Horizontal layout label width via CSS variable */ @layer components { [data-orientation="horizontal"] > [data-slot="field-label"] { @@ -818,7 +839,10 @@ } } -/* Alert countdown bar animation */ +} /* end @layer bb */ + +/* Alert countdown bar animation. Keyframes are not subject to cascade + layers, so left at top level. */ @keyframes bb-alert-countdown { from { width: 100%; } to { width: 0%; } From 417f8be1be7e7cf4ea82422ad6181bba577ce50b Mon Sep 17 00:00:00 2001 From: Mathew Date: Sat, 2 May 2026 17:02:57 +0800 Subject: [PATCH 043/188] chore: bump BlazorBlueprint.Primitives to 3.10.0 --- .../BlazorBlueprint.Components.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj index 7ae4c2dcf..a622ea1c6 100644 --- a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj +++ b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj @@ -58,7 +58,7 @@ - + From 5f7ce3d309e2eb83363135ccfd8ad385c6e37725 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sat, 2 May 2026 17:05:56 +0800 Subject: [PATCH 044/188] docs: release notes for Components v3.10.0 --- src/BlazorBlueprint.Components/RELEASE_NOTES.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/BlazorBlueprint.Components/RELEASE_NOTES.md b/src/BlazorBlueprint.Components/RELEASE_NOTES.md index 9f8a2c242..1a00d45a6 100644 --- a/src/BlazorBlueprint.Components/RELEASE_NOTES.md +++ b/src/BlazorBlueprint.Components/RELEASE_NOTES.md @@ -1,5 +1,14 @@ -## What's New in v3.9.6 +## What's New in v3.10.0 + +### Breaking Changes + +- **BbDataTable**: Removed the placeholder Filter popover from the toolbar along with the `DataTable.Filter` and `DataTable.FilterColumns` localization keys. Use `BbDataGrid` for per-column filter UIs. + +### Improvements + +- **CSS cascade**: Component styles now ship inside a dedicated `@layer bb` cascade layer declared as the strongest layer, so component utilities win the cascade regardless of consumer stylesheet load order. ### Bug Fixes -- **BbFormFieldCombobox** — Fixed search filtering being bypassed when the combobox is used through the form field wrapper without explicitly binding `SearchQuery`. The inner combobox now correctly manages its own search state when the consumer does not provide an external search binding. +- **BbTabs**: `class` and `AdditionalAttributes` (e.g. `id`, `data-*`) are now forwarded to the primitive root element; the redundant outer wrapper div has been dropped. +- **BbFilterBuilder**: Filter condition rows and inner range / InLast sub-rows now wrap onto multiple lines on small screens instead of overflowing the parent card. From a4ad3484508bed46646de97abc961421ec603601 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Thu, 14 May 2026 11:26:39 +0800 Subject: [PATCH 045/188] chore: cross-platform build fixes for macOS / .NET 8 SDK - Pin SDK to 8.0.421 via global.json so newer SDKs (9/10) don't break the Razor source generator on DashboardGridDemo - Mark auto-generated icon data files (Feather/Heroicons/Lucide) with // + #nullable enable so analyzers (CA1859) skip them - Fix CA1861 in DataGridHierarchyDemo by lifting inline string[] to a static readonly field - gitignore tailwindcss-macos binary alongside the existing Windows/Linux entries --- .gitignore | 1 + .../Pages/Components/DataGridHierarchyDemo.razor.cs | 9 +++++++-- global.json | 6 ++++++ .../Data/FeatherIconData.cs | 2 ++ src/BlazorBlueprint.Icons.Heroicons/Data/HeroIconData.cs | 2 ++ src/BlazorBlueprint.Icons.Lucide/Data/LucideIconData.cs | 2 ++ 6 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 global.json diff --git a/.gitignore b/.gitignore index f7bbdc636..29b6ce3df 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ src/BlazorBlueprint.Components/wwwroot/blazorblueprint.css node_modules/ tailwindcss.exe tailwindcss-linux +tailwindcss-macos # IDE .vscode/ diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridHierarchyDemo.razor.cs b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridHierarchyDemo.razor.cs index 742f8c1fd..22c86f3de 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridHierarchyDemo.razor.cs +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridHierarchyDemo.razor.cs @@ -1086,6 +1086,12 @@ private static void AssignIdsRecursive(List? places, ref int counter) private FilterDefinition largeOrgFilter = new(); private Func? largeOrgFilterPredicate; + private static readonly string[] largeOrgFilterDepartments = + { + "Executive", "Engineering", "Product", "Sales", "Marketing", + "Finance", "Human Resources", "Operations", "Legal", "Customer Success" + }; + private readonly FilterField[] largeOrgFields = { new() { Name = "Name", Label = "Name", Type = FilterFieldType.Text, Placeholder = "e.g. Smith" }, @@ -1094,8 +1100,7 @@ private static void AssignIdsRecursive(List? places, ref int counter) new() { Name = "Department", Label = "Department", Type = FilterFieldType.Enum, - Options = new[] { "Executive", "Engineering", "Product", "Sales", "Marketing", "Finance", "Human Resources", "Operations", "Legal", "Customer Success" } - .Select(d => new SelectOption(d, d)).ToArray() + Options = largeOrgFilterDepartments.Select(d => new SelectOption(d, d)).ToArray() } }; diff --git a/global.json b/global.json new file mode 100644 index 000000000..efae6fc44 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.421", + "rollForward": "latestFeature" + } +} diff --git a/src/BlazorBlueprint.Icons.Feather/Data/FeatherIconData.cs b/src/BlazorBlueprint.Icons.Feather/Data/FeatherIconData.cs index e516bdb56..6eb77eaa5 100644 --- a/src/BlazorBlueprint.Icons.Feather/Data/FeatherIconData.cs +++ b/src/BlazorBlueprint.Icons.Feather/Data/FeatherIconData.cs @@ -1,3 +1,5 @@ +// +#nullable enable // This file is auto-generated. Do not edit manually. // Generated from feather.json on 2026-02-19 diff --git a/src/BlazorBlueprint.Icons.Heroicons/Data/HeroIconData.cs b/src/BlazorBlueprint.Icons.Heroicons/Data/HeroIconData.cs index e2f960185..6dde2efdf 100644 --- a/src/BlazorBlueprint.Icons.Heroicons/Data/HeroIconData.cs +++ b/src/BlazorBlueprint.Icons.Heroicons/Data/HeroIconData.cs @@ -1,3 +1,5 @@ +// +#nullable enable // This file is auto-generated. Do not edit manually. // Generated from heroicons.json on 2026-02-19 diff --git a/src/BlazorBlueprint.Icons.Lucide/Data/LucideIconData.cs b/src/BlazorBlueprint.Icons.Lucide/Data/LucideIconData.cs index 81488fe28..3fff2dd48 100644 --- a/src/BlazorBlueprint.Icons.Lucide/Data/LucideIconData.cs +++ b/src/BlazorBlueprint.Icons.Lucide/Data/LucideIconData.cs @@ -1,3 +1,5 @@ +// +#nullable enable // This file is auto-generated. Do not edit manually. // Generated from lucide.json on 2026-03-27 From c82039efc842c98c5cf9a27a062c5c5357db53b0 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Thu, 14 May 2026 12:41:12 +0800 Subject: [PATCH 046/188] fix(css): scope @layer bb to authored CSS only (#318) v3.10.0 wrapped Tailwind's entire output in @layer bb to make BB component utilities win the cascade against consumer Tailwind output (fixing #308). The side effect was that consumer-applied utilities like grid-cols-* and col-span-* were also being defeated by BB's emitted utilities, squishing grid layouts on consumer sites and on our own docs. Drop `layer(bb)` from the Tailwind import so BB's bulk output now lives in Tailwind's native layers and ties with the consumer's at parity -- specificity and DOM order decide as normal. Keep @layer bb as the priority layer for BB's hand-authored attribute-selector component rules, which is what it should have been from the start. To preserve the #308 sidebar fix without relying on layer-wide priority, add a higher-specificity authored rule inside @layer bb: aside[data-variant][data-side] { display: none; } @media (min-width: 768px) { aside[data-variant][data-side] { display: flex; } } Specificity (0,2,1) beats class-based .md\:flex (0,1,0) regardless of which layer it lands in, so the sidebar's mobile/desktop split is now authoritative even if a consumer's app.css emits a competing .hidden or .md:flex rule. Verified against Kevin Mallinson's #308 repro repo with project refs to the patched library, and against the squished-grid scenario on the local demo site. Likely also resolves #314 (same root cause). --- .../wwwroot/css/blazorblueprint-input.css | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css b/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css index 5e5f44856..aae89bec3 100644 --- a/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css +++ b/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css @@ -1,12 +1,15 @@ /* BlazorBlueprint Components - Pre-built Tailwind CSS */ -/* Cascade-layer order. `bb` is declared last so component utilities - (e.g. md:flex on the sidebar) always win at parity regardless of which - tag a consumer's app.css loads in relative to ours. Consumers - override with !important (which inverts layer priority) or unlayered CSS. */ +/* Cascade-layer order. `bb` is declared last as a dedicated priority layer + for BB's hand-authored component CSS (the high-specificity attribute-selector + rules below) — those need to win against consumer Tailwind utilities and + preflight resets. Tailwind's bulk output is imported into its native layers + so BB's emitted utilities tie with the consumer's instead of beating them + (which was the cause of #318). Consumer overrides work normally via + specificity or by loading their stylesheet after ours. */ @layer properties, theme, base, components, utilities, bb; -@import 'tailwindcss' layer(bb); +@import 'tailwindcss'; /* tw-animate.css uses @utility (a Tailwind v4 source directive) which cannot be nested inside a layer. Imported unlayered; its utilities land in the default `utilities` layer. Anything in tw-animate that absolutely must win @@ -220,6 +223,18 @@ } } +/* BB Sidebar root visibility — needs to win against consumer Tailwind cascade. + The Sidebar
    + + + +
    +
    +
    + + + + +
    +
    +

    Font Awesome

    +

    2,066 icons (3 variants)

    +
    +
    +

    + Font Awesome Free with 3 variants (solid, regular, brand). Includes third-party logos (GitHub, Microsoft, etc.) not available in the other sets. +

    +
    + Browse Font Awesome + + + +
    +
    +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Shared/CommandSearch.razor b/demos/BlazorBlueprint.Demo.Shared/Shared/CommandSearch.razor index 6e47f569e..6576c6906 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Shared/CommandSearch.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Shared/CommandSearch.razor @@ -4,6 +4,8 @@ @using BlazorBlueprint.Icons.Heroicons.Data @using BlazorBlueprint.Icons.Feather.Components @using BlazorBlueprint.Icons.Feather.Data +@using BlazorBlueprint.Icons.FontAwesome.Components +@using BlazorBlueprint.Icons.FontAwesome.Data @using Microsoft.AspNetCore.Components.Web @using Microsoft.JSInterop @inject NavigationManager NavigationManager @@ -112,6 +114,22 @@ Feather + + + + + + + @FormatIconName(iconName) + Font Awesome + +
    @@ -218,6 +236,7 @@ private static readonly string[] AllLucideIcons = LucideIconData.GetAvailableIcons().OrderBy(x => x).ToArray(); private static readonly string[] AllHeroIcons = HeroIconData.GetAvailableIcons(HeroIconVariant.Outline).OrderBy(x => x).ToArray(); private static readonly string[] AllFeatherIcons = FeatherIconData.GetAvailableIcons().OrderBy(x => x).ToArray(); + private static readonly string[] AllFontAwesomeIcons = FontAwesomeIconData.GetAvailableIcons(FontAwesomeIconVariant.Solid).OrderBy(x => x).ToArray(); protected override async Task OnAfterRenderAsync(bool firstRender) { diff --git a/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor b/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor index b80914070..8395df8e0 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor @@ -875,6 +875,11 @@ Feather Icons + + + Font Awesome Icons + + diff --git a/src/BlazorBlueprint.Icons.FontAwesome/BlazorBlueprint.Icons.FontAwesome.csproj b/src/BlazorBlueprint.Icons.FontAwesome/BlazorBlueprint.Icons.FontAwesome.csproj new file mode 100644 index 000000000..883be8e84 --- /dev/null +++ b/src/BlazorBlueprint.Icons.FontAwesome/BlazorBlueprint.Icons.FontAwesome.csproj @@ -0,0 +1,42 @@ + + + + net8.0 + enable + enable + + + BlazorBlueprint.Icons.FontAwesome + BlazorBlueprint.Icons.FontAwesome + BlazorBlueprint.Icons.FontAwesome + Font Awesome Free icon library for BlazorBlueprint - 2066 icons across 3 variants (solid, regular, and brands) for Blazor applications. + blazor;icons;fontawesome;shadcn;svg;ui;components;blazor-components;tailwind + README.md + MIT + https://blazorblueprintui.com + https://github.com/blazorblueprintui/ui + David Ball + Copyright (c) 2025-present Mathew Taylor, David Ball + + + icons-fontawesome/v + beta.0 + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/BlazorBlueprint.Icons.FontAwesome/Components/FontAwesomeIcon.razor b/src/BlazorBlueprint.Icons.FontAwesome/Components/FontAwesomeIcon.razor new file mode 100644 index 000000000..da9022dc4 --- /dev/null +++ b/src/BlazorBlueprint.Icons.FontAwesome/Components/FontAwesomeIcon.razor @@ -0,0 +1,21 @@ +@namespace BlazorBlueprint.Icons.FontAwesome.Components + +@if (IconEntry is not null) +{ + + @((MarkupString)SvgBody) + +} +else +{ + @* Fallback for missing icons *@ + ⚠️ +} diff --git a/src/BlazorBlueprint.Icons.FontAwesome/Components/FontAwesomeIcon.razor.cs b/src/BlazorBlueprint.Icons.FontAwesome/Components/FontAwesomeIcon.razor.cs new file mode 100644 index 000000000..6cc60dd92 --- /dev/null +++ b/src/BlazorBlueprint.Icons.FontAwesome/Components/FontAwesomeIcon.razor.cs @@ -0,0 +1,112 @@ +using Microsoft.AspNetCore.Components; +using BlazorBlueprint.Icons.FontAwesome.Data; + +namespace BlazorBlueprint.Icons.FontAwesome.Components; + +/// +/// A Blazor component for rendering Font Awesome Free SVG icons. +/// Supports 3 variants: Solid, Regular, and Brands. +/// +public partial class FontAwesomeIcon : ComponentBase +{ + /// + /// The name of the icon to render (case-insensitive, kebab-case). + /// Example: "camera", "user", "github" + /// + [Parameter, EditorRequired] + public string Name { get; set; } = string.Empty; + + /// + /// The icon variant to render. + /// Default is Solid. + /// + [Parameter] + public FontAwesomeIconVariant Variant { get; set; } = FontAwesomeIconVariant.Solid; + + /// + /// The size of the icon in pixels (applies to width). + /// Height is scaled proportionally to preserve aspect ratio (Brands icons in particular are not square). + /// Default is 16px. + /// + [Parameter] + public int? Size { get; set; } + + /// + /// The color of the icon. Supports CSS color values. + /// Default is "currentColor" (inherits from parent). + /// Examples: "red", "#FF0000", "var(--primary)", "rgb(255, 0, 0)" + /// + [Parameter] + public string Color { get; set; } = "currentColor"; + + /// + /// Additional CSS classes to apply to the icon. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// ARIA label for accessibility (screen readers). + /// Recommended for icon-only buttons. + /// + [Parameter] + public string? AriaLabel { get; set; } + + /// + /// Additional HTML attributes to apply to the SVG element. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private const int DefaultSize = 16; + + /// + /// The icon entry (body + intrinsic width/height) for the current Name and Variant. + /// + private FontAwesomeIconEntry? IconEntry => FontAwesomeIconData.GetIcon(Name, Variant); + + /// + /// Icon SVG body with hardcoded fill/stroke attributes stripped, so the outer + /// <svg> element's fill (driven by the Color parameter) is honored. + /// + private string SvgBody => IconEntry is null + ? string.Empty + : System.Text.RegularExpressions.Regex.Replace( + IconEntry.Body, + @"\s+(stroke|fill)=""[^""]*""", + "", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + /// + /// The computed width in pixels. + /// + private int ComputedSize => Size ?? DefaultSize; + + /// + /// The computed height in pixels, scaled to preserve the icon's intrinsic aspect ratio. + /// + private int ComputedHeight + { + get + { + if (IconEntry is null || IconEntry.Width == 0) + { + return ComputedSize; + } + + return (int)Math.Round(ComputedSize * ((double)IconEntry.Height / IconEntry.Width)); + } + } + + /// + /// The viewBox derived from the icon's intrinsic width and height. + /// + private string ViewBox => IconEntry is null + ? "0 0 512 512" + : $"0 0 {IconEntry.Width} {IconEntry.Height}"; + + /// + /// The combined CSS class string. + /// + private string CssClass => string.IsNullOrEmpty(Class) ? string.Empty : Class; +} diff --git a/src/BlazorBlueprint.Icons.FontAwesome/Data/FontAwesomeIconData.cs b/src/BlazorBlueprint.Icons.FontAwesome/Data/FontAwesomeIconData.cs new file mode 100644 index 000000000..99f1a6ac8 --- /dev/null +++ b/src/BlazorBlueprint.Icons.FontAwesome/Data/FontAwesomeIconData.cs @@ -0,0 +1,2175 @@ +// +#nullable enable +// This file is auto-generated. Do not edit manually. +// Generated from fa6-solid.json, fa6-regular.json, fa6-brands.json on 2026-05-20 + +namespace BlazorBlueprint.Icons.FontAwesome.Data; + +/// +/// Icon variant for Font Awesome Free. +/// +public enum FontAwesomeIconVariant +{ + /// Solid variant (filled glyphs, the most common Font Awesome style) + Solid, + + /// Regular variant (outline glyphs, fewer icons available in the Free tier) + Regular, + + /// Brands variant (logos for third-party services and products) + Brands +} + +/// +/// A single Font Awesome icon entry: SVG body plus intrinsic dimensions used to build the viewBox. +/// +public sealed record FontAwesomeIconEntry(int Width, int Height, string Body); + +/// +/// Provides access to Font Awesome Free SVG data. +/// Contains 2066 total icons from the Font Awesome icon set across 3 variants. +/// +public static class FontAwesomeIconData +{ + private static readonly IReadOnlyDictionary SolidIcons = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["0"] = new FontAwesomeIconEntry(320, 512, ""), + ["1"] = new FontAwesomeIconEntry(256, 512, ""), + ["2"] = new FontAwesomeIconEntry(320, 512, ""), + ["3"] = new FontAwesomeIconEntry(320, 512, ""), + ["4"] = new FontAwesomeIconEntry(384, 512, ""), + ["5"] = new FontAwesomeIconEntry(320, 512, ""), + ["6"] = new FontAwesomeIconEntry(320, 512, ""), + ["7"] = new FontAwesomeIconEntry(320, 512, ""), + ["8"] = new FontAwesomeIconEntry(320, 512, ""), + ["9"] = new FontAwesomeIconEntry(320, 512, ""), + ["a"] = new FontAwesomeIconEntry(384, 512, ""), + ["address-book"] = new FontAwesomeIconEntry(512, 512, ""), + ["address-card"] = new FontAwesomeIconEntry(576, 512, ""), + ["align-center"] = new FontAwesomeIconEntry(448, 512, ""), + ["align-justify"] = new FontAwesomeIconEntry(448, 512, ""), + ["align-left"] = new FontAwesomeIconEntry(448, 512, ""), + ["align-right"] = new FontAwesomeIconEntry(448, 512, ""), + ["anchor"] = new FontAwesomeIconEntry(576, 512, ""), + ["anchor-circle-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["anchor-circle-exclamation"] = new FontAwesomeIconEntry(640, 512, ""), + ["anchor-circle-xmark"] = new FontAwesomeIconEntry(640, 512, ""), + ["anchor-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["angle-down"] = new FontAwesomeIconEntry(448, 512, ""), + ["angle-left"] = new FontAwesomeIconEntry(320, 512, ""), + ["angle-right"] = new FontAwesomeIconEntry(320, 512, ""), + ["angles-down"] = new FontAwesomeIconEntry(448, 512, ""), + ["angles-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["angles-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["angles-up"] = new FontAwesomeIconEntry(448, 512, ""), + ["angle-up"] = new FontAwesomeIconEntry(448, 512, ""), + ["ankh"] = new FontAwesomeIconEntry(320, 512, ""), + ["apple-whole"] = new FontAwesomeIconEntry(448, 512, ""), + ["archway"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrow-down"] = new FontAwesomeIconEntry(384, 512, ""), + ["arrow-down-1-9"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-down-9-1"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-down-a-z"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-down-long"] = new FontAwesomeIconEntry(384, 512, ""), + ["arrow-down-short-wide"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-down-up-across-line"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-down-up-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["arrow-down-wide-short"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-down-z-a"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-left"] = new FontAwesomeIconEntry(448, 512, ""), + ["arrow-left-long"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrow-pointer"] = new FontAwesomeIconEntry(320, 512, ""), + ["arrow-right"] = new FontAwesomeIconEntry(448, 512, ""), + ["arrow-right-arrow-left"] = new FontAwesomeIconEntry(448, 512, ""), + ["arrow-right-from-bracket"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrow-right-long"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrow-right-to-bracket"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrow-right-to-city"] = new FontAwesomeIconEntry(640, 512, ""), + ["arrow-rotate-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrow-rotate-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrows-down-to-line"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrows-down-to-people"] = new FontAwesomeIconEntry(640, 512, ""), + ["arrows-left-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrows-left-right-to-line"] = new FontAwesomeIconEntry(640, 512, ""), + ["arrows-rotate"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrows-spin"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrows-split-up-and-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrows-to-circle"] = new FontAwesomeIconEntry(640, 512, ""), + ["arrows-to-dot"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrows-to-eye"] = new FontAwesomeIconEntry(640, 512, ""), + ["arrows-turn-right"] = new FontAwesomeIconEntry(448, 512, ""), + ["arrows-turn-to-dots"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrows-up-down"] = new FontAwesomeIconEntry(320, 512, ""), + ["arrows-up-down-left-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrows-up-to-line"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-trend-down"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-trend-up"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-turn-down"] = new FontAwesomeIconEntry(384, 512, ""), + ["arrow-turn-up"] = new FontAwesomeIconEntry(384, 512, ""), + ["arrow-up"] = new FontAwesomeIconEntry(384, 512, ""), + ["arrow-up-1-9"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-up-9-1"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-up-a-z"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-up-from-bracket"] = new FontAwesomeIconEntry(448, 512, ""), + ["arrow-up-from-ground-water"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-up-from-water-pump"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-up-long"] = new FontAwesomeIconEntry(384, 512, ""), + ["arrow-up-right-dots"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-up-right-from-square"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrow-up-short-wide"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-up-wide-short"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-up-z-a"] = new FontAwesomeIconEntry(576, 512, ""), + ["asterisk"] = new FontAwesomeIconEntry(384, 512, ""), + ["at"] = new FontAwesomeIconEntry(512, 512, ""), + ["atom"] = new FontAwesomeIconEntry(512, 512, ""), + ["audio-description"] = new FontAwesomeIconEntry(576, 512, ""), + ["austral-sign"] = new FontAwesomeIconEntry(448, 512, ""), + ["award"] = new FontAwesomeIconEntry(384, 512, ""), + ["b"] = new FontAwesomeIconEntry(320, 512, ""), + ["baby"] = new FontAwesomeIconEntry(448, 512, ""), + ["baby-carriage"] = new FontAwesomeIconEntry(512, 512, ""), + ["backward"] = new FontAwesomeIconEntry(512, 512, ""), + ["backward-fast"] = new FontAwesomeIconEntry(512, 512, ""), + ["backward-step"] = new FontAwesomeIconEntry(320, 512, ""), + ["bacon"] = new FontAwesomeIconEntry(576, 512, ""), + ["bacteria"] = new FontAwesomeIconEntry(640, 512, ""), + ["bacterium"] = new FontAwesomeIconEntry(512, 512, ""), + ["bag-shopping"] = new FontAwesomeIconEntry(448, 512, ""), + ["bahai"] = new FontAwesomeIconEntry(576, 512, ""), + ["baht-sign"] = new FontAwesomeIconEntry(320, 512, ""), + ["ban"] = new FontAwesomeIconEntry(512, 512, ""), + ["bandage"] = new FontAwesomeIconEntry(640, 512, ""), + ["bangladeshi-taka-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["ban-smoking"] = new FontAwesomeIconEntry(512, 512, ""), + ["barcode"] = new FontAwesomeIconEntry(512, 512, ""), + ["bars"] = new FontAwesomeIconEntry(448, 512, ""), + ["bars-progress"] = new FontAwesomeIconEntry(512, 512, ""), + ["bars-staggered"] = new FontAwesomeIconEntry(512, 512, ""), + ["baseball"] = new FontAwesomeIconEntry(512, 512, ""), + ["baseball-bat-ball"] = new FontAwesomeIconEntry(512, 512, ""), + ["basketball"] = new FontAwesomeIconEntry(512, 512, ""), + ["basket-shopping"] = new FontAwesomeIconEntry(576, 512, ""), + ["bath"] = new FontAwesomeIconEntry(512, 512, ""), + ["battery-empty"] = new FontAwesomeIconEntry(576, 512, ""), + ["battery-full"] = new FontAwesomeIconEntry(576, 512, ""), + ["battery-half"] = new FontAwesomeIconEntry(576, 512, ""), + ["battery-quarter"] = new FontAwesomeIconEntry(576, 512, ""), + ["battery-three-quarters"] = new FontAwesomeIconEntry(576, 512, ""), + ["bed"] = new FontAwesomeIconEntry(640, 512, ""), + ["bed-pulse"] = new FontAwesomeIconEntry(640, 512, ""), + ["beer-mug-empty"] = new FontAwesomeIconEntry(512, 512, ""), + ["bell"] = new FontAwesomeIconEntry(448, 512, ""), + ["bell-concierge"] = new FontAwesomeIconEntry(512, 512, ""), + ["bell-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["bezier-curve"] = new FontAwesomeIconEntry(640, 512, ""), + ["bicycle"] = new FontAwesomeIconEntry(640, 512, ""), + ["binoculars"] = new FontAwesomeIconEntry(512, 512, ""), + ["biohazard"] = new FontAwesomeIconEntry(576, 512, ""), + ["bitcoin-sign"] = new FontAwesomeIconEntry(320, 512, ""), + ["blender"] = new FontAwesomeIconEntry(512, 512, ""), + ["blender-phone"] = new FontAwesomeIconEntry(576, 512, ""), + ["blog"] = new FontAwesomeIconEntry(512, 512, ""), + ["bold"] = new FontAwesomeIconEntry(384, 512, ""), + ["bolt"] = new FontAwesomeIconEntry(448, 512, ""), + ["bolt-lightning"] = new FontAwesomeIconEntry(384, 512, ""), + ["bomb"] = new FontAwesomeIconEntry(512, 512, ""), + ["bone"] = new FontAwesomeIconEntry(576, 512, ""), + ["bong"] = new FontAwesomeIconEntry(448, 512, ""), + ["book"] = new FontAwesomeIconEntry(448, 512, ""), + ["book-atlas"] = new FontAwesomeIconEntry(448, 512, ""), + ["book-bible"] = new FontAwesomeIconEntry(448, 512, ""), + ["book-bookmark"] = new FontAwesomeIconEntry(448, 512, ""), + ["book-journal-whills"] = new FontAwesomeIconEntry(448, 512, ""), + ["bookmark"] = new FontAwesomeIconEntry(384, 512, ""), + ["book-medical"] = new FontAwesomeIconEntry(448, 512, ""), + ["book-open"] = new FontAwesomeIconEntry(576, 512, ""), + ["book-open-reader"] = new FontAwesomeIconEntry(512, 512, ""), + ["book-quran"] = new FontAwesomeIconEntry(448, 512, ""), + ["book-skull"] = new FontAwesomeIconEntry(448, 512, ""), + ["book-tanakh"] = new FontAwesomeIconEntry(448, 512, ""), + ["border-all"] = new FontAwesomeIconEntry(448, 512, ""), + ["border-none"] = new FontAwesomeIconEntry(448, 512, ""), + ["border-top-left"] = new FontAwesomeIconEntry(448, 512, ""), + ["bore-hole"] = new FontAwesomeIconEntry(512, 512, ""), + ["bottle-droplet"] = new FontAwesomeIconEntry(320, 512, ""), + ["bottle-water"] = new FontAwesomeIconEntry(320, 512, ""), + ["bowl-food"] = new FontAwesomeIconEntry(512, 512, ""), + ["bowling-ball"] = new FontAwesomeIconEntry(512, 512, ""), + ["bowl-rice"] = new FontAwesomeIconEntry(512, 512, ""), + ["box"] = new FontAwesomeIconEntry(448, 512, ""), + ["box-archive"] = new FontAwesomeIconEntry(512, 512, ""), + ["boxes-packing"] = new FontAwesomeIconEntry(640, 512, ""), + ["boxes-stacked"] = new FontAwesomeIconEntry(576, 512, ""), + ["box-open"] = new FontAwesomeIconEntry(640, 512, ""), + ["box-tissue"] = new FontAwesomeIconEntry(512, 512, ""), + ["braille"] = new FontAwesomeIconEntry(640, 512, ""), + ["brain"] = new FontAwesomeIconEntry(512, 512, ""), + ["brazilian-real-sign"] = new FontAwesomeIconEntry(512, 512, ""), + ["bread-slice"] = new FontAwesomeIconEntry(512, 512, ""), + ["bridge"] = new FontAwesomeIconEntry(576, 512, ""), + ["bridge-circle-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["bridge-circle-exclamation"] = new FontAwesomeIconEntry(640, 512, ""), + ["bridge-circle-xmark"] = new FontAwesomeIconEntry(640, 512, ""), + ["bridge-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["bridge-water"] = new FontAwesomeIconEntry(576, 512, ""), + ["briefcase"] = new FontAwesomeIconEntry(512, 512, ""), + ["briefcase-medical"] = new FontAwesomeIconEntry(512, 512, ""), + ["broom"] = new FontAwesomeIconEntry(576, 512, ""), + ["broom-ball"] = new FontAwesomeIconEntry(576, 512, ""), + ["brush"] = new FontAwesomeIconEntry(384, 512, ""), + ["bucket"] = new FontAwesomeIconEntry(448, 512, ""), + ["bug"] = new FontAwesomeIconEntry(512, 512, ""), + ["bugs"] = new FontAwesomeIconEntry(576, 512, ""), + ["bug-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["building"] = new FontAwesomeIconEntry(384, 512, ""), + ["building-circle-arrow-right"] = new FontAwesomeIconEntry(640, 512, ""), + ["building-circle-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["building-circle-exclamation"] = new FontAwesomeIconEntry(640, 512, ""), + ["building-circle-xmark"] = new FontAwesomeIconEntry(640, 512, ""), + ["building-columns"] = new FontAwesomeIconEntry(512, 512, ""), + ["building-flag"] = new FontAwesomeIconEntry(640, 512, ""), + ["building-lock"] = new FontAwesomeIconEntry(576, 512, ""), + ["building-ngo"] = new FontAwesomeIconEntry(384, 512, ""), + ["building-shield"] = new FontAwesomeIconEntry(576, 512, ""), + ["building-un"] = new FontAwesomeIconEntry(384, 512, ""), + ["building-user"] = new FontAwesomeIconEntry(640, 512, ""), + ["building-wheat"] = new FontAwesomeIconEntry(640, 512, ""), + ["bullhorn"] = new FontAwesomeIconEntry(512, 512, ""), + ["bullseye"] = new FontAwesomeIconEntry(512, 512, ""), + ["burger"] = new FontAwesomeIconEntry(512, 512, ""), + ["burst"] = new FontAwesomeIconEntry(512, 512, ""), + ["bus"] = new FontAwesomeIconEntry(576, 512, ""), + ["business-time"] = new FontAwesomeIconEntry(640, 512, ""), + ["bus-simple"] = new FontAwesomeIconEntry(448, 512, ""), + ["c"] = new FontAwesomeIconEntry(384, 512, ""), + ["cable-car"] = new FontAwesomeIconEntry(512, 512, ""), + ["cake-candles"] = new FontAwesomeIconEntry(448, 512, ""), + ["calculator"] = new FontAwesomeIconEntry(384, 512, ""), + ["calendar"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-check"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-day"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-days"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-minus"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-plus"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-week"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-xmark"] = new FontAwesomeIconEntry(448, 512, ""), + ["camera"] = new FontAwesomeIconEntry(512, 512, ""), + ["camera-retro"] = new FontAwesomeIconEntry(512, 512, ""), + ["camera-rotate"] = new FontAwesomeIconEntry(640, 512, ""), + ["campground"] = new FontAwesomeIconEntry(576, 512, ""), + ["candy-cane"] = new FontAwesomeIconEntry(512, 512, ""), + ["cannabis"] = new FontAwesomeIconEntry(512, 512, ""), + ["capsules"] = new FontAwesomeIconEntry(576, 512, ""), + ["car"] = new FontAwesomeIconEntry(512, 512, ""), + ["caravan"] = new FontAwesomeIconEntry(640, 512, ""), + ["car-battery"] = new FontAwesomeIconEntry(512, 512, ""), + ["car-burst"] = new FontAwesomeIconEntry(640, 512, ""), + ["car-crash"] = new FontAwesomeIconEntry(640, 512, ""), + ["caret-down"] = new FontAwesomeIconEntry(320, 512, ""), + ["caret-left"] = new FontAwesomeIconEntry(256, 512, ""), + ["caret-right"] = new FontAwesomeIconEntry(256, 512, ""), + ["caret-up"] = new FontAwesomeIconEntry(320, 512, ""), + ["car-on"] = new FontAwesomeIconEntry(512, 512, ""), + ["car-rear"] = new FontAwesomeIconEntry(512, 512, ""), + ["carrot"] = new FontAwesomeIconEntry(512, 512, ""), + ["car-side"] = new FontAwesomeIconEntry(640, 512, ""), + ["cart-arrow-down"] = new FontAwesomeIconEntry(576, 512, ""), + ["cart-flatbed"] = new FontAwesomeIconEntry(640, 512, ""), + ["cart-flatbed-suitcase"] = new FontAwesomeIconEntry(640, 512, ""), + ["cart-plus"] = new FontAwesomeIconEntry(576, 512, ""), + ["cart-shopping"] = new FontAwesomeIconEntry(576, 512, ""), + ["car-tunnel"] = new FontAwesomeIconEntry(512, 512, ""), + ["cash-register"] = new FontAwesomeIconEntry(512, 512, ""), + ["cat"] = new FontAwesomeIconEntry(576, 512, ""), + ["cedi-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["cent-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["certificate"] = new FontAwesomeIconEntry(512, 512, ""), + ["chair"] = new FontAwesomeIconEntry(448, 512, ""), + ["chalkboard"] = new FontAwesomeIconEntry(576, 512, ""), + ["chalkboard-user"] = new FontAwesomeIconEntry(640, 512, ""), + ["champagne-glasses"] = new FontAwesomeIconEntry(640, 512, ""), + ["charging-station"] = new FontAwesomeIconEntry(576, 512, ""), + ["chart-area"] = new FontAwesomeIconEntry(512, 512, ""), + ["chart-bar"] = new FontAwesomeIconEntry(512, 512, ""), + ["chart-column"] = new FontAwesomeIconEntry(512, 512, ""), + ["chart-diagram"] = new FontAwesomeIconEntry(512, 512, ""), + ["chart-gantt"] = new FontAwesomeIconEntry(512, 512, ""), + ["chart-line"] = new FontAwesomeIconEntry(512, 512, ""), + ["chart-pie"] = new FontAwesomeIconEntry(576, 512, ""), + ["chart-simple"] = new FontAwesomeIconEntry(448, 512, ""), + ["check"] = new FontAwesomeIconEntry(448, 512, ""), + ["check-double"] = new FontAwesomeIconEntry(448, 512, ""), + ["check-to-slot"] = new FontAwesomeIconEntry(576, 512, ""), + ["cheese"] = new FontAwesomeIconEntry(512, 512, ""), + ["chess"] = new FontAwesomeIconEntry(512, 512, ""), + ["chess-bishop"] = new FontAwesomeIconEntry(320, 512, ""), + ["chess-board"] = new FontAwesomeIconEntry(448, 512, ""), + ["chess-king"] = new FontAwesomeIconEntry(448, 512, ""), + ["chess-knight"] = new FontAwesomeIconEntry(448, 512, ""), + ["chess-pawn"] = new FontAwesomeIconEntry(320, 512, ""), + ["chess-queen"] = new FontAwesomeIconEntry(512, 512, ""), + ["chess-rook"] = new FontAwesomeIconEntry(448, 512, ""), + ["chevron-down"] = new FontAwesomeIconEntry(512, 512, ""), + ["chevron-left"] = new FontAwesomeIconEntry(320, 512, ""), + ["chevron-right"] = new FontAwesomeIconEntry(320, 512, ""), + ["chevron-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["child"] = new FontAwesomeIconEntry(320, 512, ""), + ["child-combatant"] = new FontAwesomeIconEntry(576, 512, ""), + ["child-dress"] = new FontAwesomeIconEntry(320, 512, ""), + ["child-reaching"] = new FontAwesomeIconEntry(384, 512, ""), + ["children"] = new FontAwesomeIconEntry(640, 512, ""), + ["church"] = new FontAwesomeIconEntry(640, 512, ""), + ["circle"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-arrow-down"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-arrow-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-arrow-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-arrow-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-check"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-chevron-down"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-chevron-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-chevron-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-chevron-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-dollar-to-slot"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-dot"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-down"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-exclamation"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-h"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-half-stroke"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-info"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-minus"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-nodes"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-notch"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-pause"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-play"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-plus"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-question"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-radiation"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-stop"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-user"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-xmark"] = new FontAwesomeIconEntry(512, 512, ""), + ["city"] = new FontAwesomeIconEntry(640, 512, ""), + ["clapperboard"] = new FontAwesomeIconEntry(512, 512, ""), + ["clipboard"] = new FontAwesomeIconEntry(384, 512, ""), + ["clipboard-check"] = new FontAwesomeIconEntry(384, 512, ""), + ["clipboard-list"] = new FontAwesomeIconEntry(384, 512, ""), + ["clipboard-question"] = new FontAwesomeIconEntry(384, 512, ""), + ["clipboard-user"] = new FontAwesomeIconEntry(384, 512, ""), + ["clock"] = new FontAwesomeIconEntry(512, 512, ""), + ["clock-rotate-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["clone"] = new FontAwesomeIconEntry(512, 512, ""), + ["closed-captioning"] = new FontAwesomeIconEntry(576, 512, ""), + ["cloud"] = new FontAwesomeIconEntry(640, 512, ""), + ["cloud-arrow-down"] = new FontAwesomeIconEntry(640, 512, ""), + ["cloud-arrow-up"] = new FontAwesomeIconEntry(640, 512, ""), + ["cloud-bolt"] = new FontAwesomeIconEntry(512, 512, ""), + ["cloud-meatball"] = new FontAwesomeIconEntry(512, 512, ""), + ["cloud-moon"] = new FontAwesomeIconEntry(640, 512, ""), + ["cloud-moon-rain"] = new FontAwesomeIconEntry(576, 512, ""), + ["cloud-rain"] = new FontAwesomeIconEntry(512, 512, ""), + ["cloud-showers-heavy"] = new FontAwesomeIconEntry(512, 512, ""), + ["cloud-showers-water"] = new FontAwesomeIconEntry(576, 512, ""), + ["cloud-sun"] = new FontAwesomeIconEntry(640, 512, ""), + ["cloud-sun-rain"] = new FontAwesomeIconEntry(640, 512, ""), + ["clover"] = new FontAwesomeIconEntry(448, 512, ""), + ["code"] = new FontAwesomeIconEntry(640, 512, ""), + ["code-branch"] = new FontAwesomeIconEntry(448, 512, ""), + ["code-commit"] = new FontAwesomeIconEntry(640, 512, ""), + ["code-compare"] = new FontAwesomeIconEntry(512, 512, ""), + ["code-fork"] = new FontAwesomeIconEntry(448, 512, ""), + ["code-merge"] = new FontAwesomeIconEntry(448, 512, ""), + ["code-pull-request"] = new FontAwesomeIconEntry(512, 512, ""), + ["coins"] = new FontAwesomeIconEntry(512, 512, ""), + ["colon-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["comment"] = new FontAwesomeIconEntry(512, 512, ""), + ["comment-dollar"] = new FontAwesomeIconEntry(512, 512, ""), + ["comment-dots"] = new FontAwesomeIconEntry(512, 512, ""), + ["comment-medical"] = new FontAwesomeIconEntry(512, 512, ""), + ["comment-nodes"] = new FontAwesomeIconEntry(640, 512, ""), + ["comments"] = new FontAwesomeIconEntry(640, 512, ""), + ["comments-dollar"] = new FontAwesomeIconEntry(640, 512, ""), + ["comment-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["comment-sms"] = new FontAwesomeIconEntry(512, 512, ""), + ["compact-disc"] = new FontAwesomeIconEntry(512, 512, ""), + ["compass"] = new FontAwesomeIconEntry(512, 512, ""), + ["compass-drafting"] = new FontAwesomeIconEntry(512, 512, ""), + ["compress"] = new FontAwesomeIconEntry(448, 512, ""), + ["computer"] = new FontAwesomeIconEntry(640, 512, ""), + ["computer-mouse"] = new FontAwesomeIconEntry(384, 512, ""), + ["cookie"] = new FontAwesomeIconEntry(512, 512, ""), + ["cookie-bite"] = new FontAwesomeIconEntry(512, 512, ""), + ["copy"] = new FontAwesomeIconEntry(448, 512, ""), + ["copyright"] = new FontAwesomeIconEntry(512, 512, ""), + ["couch"] = new FontAwesomeIconEntry(640, 512, ""), + ["cow"] = new FontAwesomeIconEntry(640, 512, ""), + ["credit-card"] = new FontAwesomeIconEntry(576, 512, ""), + ["crop"] = new FontAwesomeIconEntry(512, 512, ""), + ["crop-simple"] = new FontAwesomeIconEntry(512, 512, ""), + ["cross"] = new FontAwesomeIconEntry(384, 512, ""), + ["crosshairs"] = new FontAwesomeIconEntry(512, 512, ""), + ["crow"] = new FontAwesomeIconEntry(640, 512, ""), + ["crown"] = new FontAwesomeIconEntry(576, 512, ""), + ["crutch"] = new FontAwesomeIconEntry(512, 512, ""), + ["cruzeiro-sign"] = new FontAwesomeIconEntry(448, 512, ""), + ["cube"] = new FontAwesomeIconEntry(512, 512, ""), + ["cubes"] = new FontAwesomeIconEntry(576, 512, ""), + ["cubes-stacked"] = new FontAwesomeIconEntry(448, 512, ""), + ["d"] = new FontAwesomeIconEntry(384, 512, ""), + ["database"] = new FontAwesomeIconEntry(448, 512, ""), + ["delete-left"] = new FontAwesomeIconEntry(576, 512, ""), + ["democrat"] = new FontAwesomeIconEntry(640, 512, ""), + ["desktop"] = new FontAwesomeIconEntry(576, 512, ""), + ["dharmachakra"] = new FontAwesomeIconEntry(512, 512, ""), + ["diagram-next"] = new FontAwesomeIconEntry(512, 512, ""), + ["diagram-predecessor"] = new FontAwesomeIconEntry(512, 512, ""), + ["diagram-project"] = new FontAwesomeIconEntry(576, 512, ""), + ["diagram-successor"] = new FontAwesomeIconEntry(512, 512, ""), + ["diamond"] = new FontAwesomeIconEntry(512, 512, ""), + ["diamond-turn-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["dice"] = new FontAwesomeIconEntry(640, 512, ""), + ["dice-d20"] = new FontAwesomeIconEntry(512, 512, ""), + ["dice-d6"] = new FontAwesomeIconEntry(448, 512, ""), + ["dice-five"] = new FontAwesomeIconEntry(448, 512, ""), + ["dice-four"] = new FontAwesomeIconEntry(448, 512, ""), + ["dice-one"] = new FontAwesomeIconEntry(448, 512, ""), + ["dice-six"] = new FontAwesomeIconEntry(448, 512, ""), + ["dice-three"] = new FontAwesomeIconEntry(448, 512, ""), + ["dice-two"] = new FontAwesomeIconEntry(448, 512, ""), + ["disease"] = new FontAwesomeIconEntry(512, 512, ""), + ["display"] = new FontAwesomeIconEntry(576, 512, ""), + ["divide"] = new FontAwesomeIconEntry(448, 512, ""), + ["dna"] = new FontAwesomeIconEntry(448, 512, ""), + ["dog"] = new FontAwesomeIconEntry(576, 512, ""), + ["dollar-sign"] = new FontAwesomeIconEntry(320, 512, ""), + ["dolly"] = new FontAwesomeIconEntry(576, 512, ""), + ["dong-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["door-closed"] = new FontAwesomeIconEntry(576, 512, ""), + ["door-open"] = new FontAwesomeIconEntry(576, 512, ""), + ["dove"] = new FontAwesomeIconEntry(512, 512, ""), + ["down-left-and-up-right-to-center"] = new FontAwesomeIconEntry(512, 512, ""), + ["download"] = new FontAwesomeIconEntry(512, 512, ""), + ["down-long"] = new FontAwesomeIconEntry(320, 512, ""), + ["dragon"] = new FontAwesomeIconEntry(640, 512, ""), + ["draw-polygon"] = new FontAwesomeIconEntry(448, 512, ""), + ["droplet"] = new FontAwesomeIconEntry(384, 512, ""), + ["droplet-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["drum"] = new FontAwesomeIconEntry(512, 512, ""), + ["drum-steelpan"] = new FontAwesomeIconEntry(576, 512, ""), + ["drumstick-bite"] = new FontAwesomeIconEntry(512, 512, ""), + ["dumbbell"] = new FontAwesomeIconEntry(640, 512, ""), + ["dumpster"] = new FontAwesomeIconEntry(576, 512, ""), + ["dumpster-fire"] = new FontAwesomeIconEntry(640, 512, ""), + ["dungeon"] = new FontAwesomeIconEntry(512, 512, ""), + ["e"] = new FontAwesomeIconEntry(320, 512, ""), + ["ear-deaf"] = new FontAwesomeIconEntry(512, 512, ""), + ["ear-listen"] = new FontAwesomeIconEntry(512, 512, ""), + ["earth-africa"] = new FontAwesomeIconEntry(512, 512, ""), + ["earth-americas"] = new FontAwesomeIconEntry(512, 512, ""), + ["earth-asia"] = new FontAwesomeIconEntry(512, 512, ""), + ["earth-europe"] = new FontAwesomeIconEntry(512, 512, ""), + ["earth-oceania"] = new FontAwesomeIconEntry(512, 512, ""), + ["egg"] = new FontAwesomeIconEntry(384, 512, ""), + ["eject"] = new FontAwesomeIconEntry(448, 512, ""), + ["elevator"] = new FontAwesomeIconEntry(512, 512, ""), + ["ellipsis"] = new FontAwesomeIconEntry(448, 512, ""), + ["ellipsis-vertical"] = new FontAwesomeIconEntry(128, 512, ""), + ["envelope"] = new FontAwesomeIconEntry(512, 512, ""), + ["envelope-circle-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["envelope-open"] = new FontAwesomeIconEntry(512, 512, ""), + ["envelope-open-text"] = new FontAwesomeIconEntry(512, 512, ""), + ["envelopes-bulk"] = new FontAwesomeIconEntry(640, 512, ""), + ["equals"] = new FontAwesomeIconEntry(448, 512, ""), + ["eraser"] = new FontAwesomeIconEntry(576, 512, ""), + ["ethernet"] = new FontAwesomeIconEntry(512, 512, ""), + ["euro-sign"] = new FontAwesomeIconEntry(320, 512, ""), + ["exclamation"] = new FontAwesomeIconEntry(128, 512, ""), + ["expand"] = new FontAwesomeIconEntry(448, 512, ""), + ["explosion"] = new FontAwesomeIconEntry(576, 512, ""), + ["eye"] = new FontAwesomeIconEntry(576, 512, ""), + ["eye-dropper"] = new FontAwesomeIconEntry(512, 512, ""), + ["eye-low-vision"] = new FontAwesomeIconEntry(640, 512, ""), + ["eye-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["f"] = new FontAwesomeIconEntry(320, 512, ""), + ["face-angry"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-dizzy"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-flushed"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-frown"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-frown-open"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grimace"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-beam"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-beam-sweat"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-hearts"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-squint"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-squint-tears"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-stars"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-tears"] = new FontAwesomeIconEntry(640, 512, ""), + ["face-grin-tongue"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-tongue-squint"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-tongue-wink"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-wide"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-wink"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-kiss"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-kiss-beam"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-kiss-wink-heart"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-laugh"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-laugh-beam"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-laugh-squint"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-laugh-wink"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-meh"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-meh-blank"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-rolling-eyes"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-sad-cry"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-sad-tear"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-smile"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-smile-beam"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-smile-wink"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-surprise"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-tired"] = new FontAwesomeIconEntry(512, 512, ""), + ["fan"] = new FontAwesomeIconEntry(512, 512, ""), + ["faucet"] = new FontAwesomeIconEntry(512, 512, ""), + ["faucet-drip"] = new FontAwesomeIconEntry(512, 512, ""), + ["fax"] = new FontAwesomeIconEntry(512, 512, ""), + ["feather"] = new FontAwesomeIconEntry(512, 512, ""), + ["feather-pointed"] = new FontAwesomeIconEntry(512, 512, ""), + ["ferry"] = new FontAwesomeIconEntry(576, 512, ""), + ["file"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-arrow-down"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-arrow-up"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-audio"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-circle-check"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-circle-exclamation"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-circle-minus"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-circle-plus"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-circle-question"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-circle-xmark"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-code"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-contract"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-csv"] = new FontAwesomeIconEntry(512, 512, ""), + ["file-excel"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-export"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-fragment"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-half-dashed"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-image"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-import"] = new FontAwesomeIconEntry(512, 512, ""), + ["file-invoice"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-invoice-dollar"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-lines"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-medical"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-pdf"] = new FontAwesomeIconEntry(512, 512, ""), + ["file-pen"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-powerpoint"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-prescription"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-shield"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-signature"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-video"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-waveform"] = new FontAwesomeIconEntry(448, 512, ""), + ["file-word"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-zipper"] = new FontAwesomeIconEntry(384, 512, ""), + ["fill"] = new FontAwesomeIconEntry(512, 512, ""), + ["fill-drip"] = new FontAwesomeIconEntry(576, 512, ""), + ["film"] = new FontAwesomeIconEntry(512, 512, ""), + ["filter"] = new FontAwesomeIconEntry(512, 512, ""), + ["filter-circle-dollar"] = new FontAwesomeIconEntry(576, 512, ""), + ["filter-circle-xmark"] = new FontAwesomeIconEntry(576, 512, ""), + ["fingerprint"] = new FontAwesomeIconEntry(512, 512, ""), + ["fire"] = new FontAwesomeIconEntry(448, 512, ""), + ["fire-burner"] = new FontAwesomeIconEntry(640, 512, ""), + ["fire-extinguisher"] = new FontAwesomeIconEntry(512, 512, ""), + ["fire-flame-curved"] = new FontAwesomeIconEntry(384, 512, ""), + ["fire-flame-simple"] = new FontAwesomeIconEntry(384, 512, ""), + ["fish"] = new FontAwesomeIconEntry(576, 512, ""), + ["fish-fins"] = new FontAwesomeIconEntry(576, 512, ""), + ["flag"] = new FontAwesomeIconEntry(448, 512, ""), + ["flag-checkered"] = new FontAwesomeIconEntry(448, 512, ""), + ["flag-usa"] = new FontAwesomeIconEntry(448, 512, ""), + ["flask"] = new FontAwesomeIconEntry(448, 512, ""), + ["flask-vial"] = new FontAwesomeIconEntry(640, 512, ""), + ["floppy-disk"] = new FontAwesomeIconEntry(448, 512, ""), + ["florin-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["folder"] = new FontAwesomeIconEntry(512, 512, ""), + ["folder-closed"] = new FontAwesomeIconEntry(512, 512, ""), + ["folder-minus"] = new FontAwesomeIconEntry(512, 512, ""), + ["folder-open"] = new FontAwesomeIconEntry(576, 512, ""), + ["folder-plus"] = new FontAwesomeIconEntry(512, 512, ""), + ["folder-tree"] = new FontAwesomeIconEntry(576, 512, ""), + ["font"] = new FontAwesomeIconEntry(448, 512, ""), + ["font-awesome"] = new FontAwesomeIconEntry(512, 512, ""), + ["football"] = new FontAwesomeIconEntry(512, 512, ""), + ["forward"] = new FontAwesomeIconEntry(512, 512, ""), + ["forward-fast"] = new FontAwesomeIconEntry(512, 512, ""), + ["forward-step"] = new FontAwesomeIconEntry(320, 512, ""), + ["franc-sign"] = new FontAwesomeIconEntry(320, 512, ""), + ["frog"] = new FontAwesomeIconEntry(576, 512, ""), + ["futbol"] = new FontAwesomeIconEntry(512, 512, ""), + ["g"] = new FontAwesomeIconEntry(448, 512, ""), + ["gamepad"] = new FontAwesomeIconEntry(640, 512, ""), + ["gas-pump"] = new FontAwesomeIconEntry(512, 512, ""), + ["gauge"] = new FontAwesomeIconEntry(512, 512, ""), + ["gauge-high"] = new FontAwesomeIconEntry(512, 512, ""), + ["gauge-simple"] = new FontAwesomeIconEntry(512, 512, ""), + ["gauge-simple-high"] = new FontAwesomeIconEntry(512, 512, ""), + ["gavel"] = new FontAwesomeIconEntry(512, 512, ""), + ["gear"] = new FontAwesomeIconEntry(512, 512, ""), + ["gears"] = new FontAwesomeIconEntry(640, 512, ""), + ["gem"] = new FontAwesomeIconEntry(512, 512, ""), + ["genderless"] = new FontAwesomeIconEntry(384, 512, ""), + ["ghost"] = new FontAwesomeIconEntry(384, 512, ""), + ["gift"] = new FontAwesomeIconEntry(512, 512, ""), + ["gifts"] = new FontAwesomeIconEntry(640, 512, ""), + ["glasses"] = new FontAwesomeIconEntry(576, 512, ""), + ["glass-water"] = new FontAwesomeIconEntry(384, 512, ""), + ["glass-water-droplet"] = new FontAwesomeIconEntry(384, 512, ""), + ["globe"] = new FontAwesomeIconEntry(512, 512, ""), + ["golf-ball-tee"] = new FontAwesomeIconEntry(384, 512, ""), + ["gopuram"] = new FontAwesomeIconEntry(512, 512, ""), + ["graduation-cap"] = new FontAwesomeIconEntry(640, 512, ""), + ["greater-than"] = new FontAwesomeIconEntry(384, 512, ""), + ["greater-than-equal"] = new FontAwesomeIconEntry(448, 512, ""), + ["grip"] = new FontAwesomeIconEntry(448, 512, ""), + ["grip-lines"] = new FontAwesomeIconEntry(448, 512, ""), + ["grip-lines-vertical"] = new FontAwesomeIconEntry(192, 512, ""), + ["grip-vertical"] = new FontAwesomeIconEntry(320, 512, ""), + ["group-arrows-rotate"] = new FontAwesomeIconEntry(512, 512, ""), + ["guarani-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["guitar"] = new FontAwesomeIconEntry(512, 512, ""), + ["gun"] = new FontAwesomeIconEntry(576, 512, ""), + ["h"] = new FontAwesomeIconEntry(384, 512, ""), + ["hammer"] = new FontAwesomeIconEntry(576, 512, ""), + ["hamsa"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-back-fist"] = new FontAwesomeIconEntry(448, 512, ""), + ["handcuffs"] = new FontAwesomeIconEntry(640, 512, ""), + ["hand-dots"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-fist"] = new FontAwesomeIconEntry(448, 512, ""), + ["hand-holding"] = new FontAwesomeIconEntry(576, 512, ""), + ["hand-holding-dollar"] = new FontAwesomeIconEntry(576, 512, ""), + ["hand-holding-droplet"] = new FontAwesomeIconEntry(576, 512, ""), + ["hand-holding-hand"] = new FontAwesomeIconEntry(576, 512, ""), + ["hand-holding-heart"] = new FontAwesomeIconEntry(576, 512, ""), + ["hand-holding-medical"] = new FontAwesomeIconEntry(576, 512, ""), + ["hand-lizard"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-middle-finger"] = new FontAwesomeIconEntry(448, 512, ""), + ["hand-peace"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-point-down"] = new FontAwesomeIconEntry(384, 512, ""), + ["hand-pointer"] = new FontAwesomeIconEntry(448, 512, ""), + ["hand-point-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-point-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-point-up"] = new FontAwesomeIconEntry(384, 512, ""), + ["hands"] = new FontAwesomeIconEntry(576, 512, ""), + ["hands-asl-interpreting"] = new FontAwesomeIconEntry(640, 512, ""), + ["hands-bound"] = new FontAwesomeIconEntry(640, 512, ""), + ["hands-bubbles"] = new FontAwesomeIconEntry(576, 512, ""), + ["hand-scissors"] = new FontAwesomeIconEntry(512, 512, ""), + ["hands-clapping"] = new FontAwesomeIconEntry(512, 512, ""), + ["handshake"] = new FontAwesomeIconEntry(640, 512, ""), + ["handshake-angle"] = new FontAwesomeIconEntry(640, 512, ""), + ["handshake-simple"] = new FontAwesomeIconEntry(640, 512, ""), + ["handshake-simple-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["handshake-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["hands-holding"] = new FontAwesomeIconEntry(640, 512, ""), + ["hands-holding-child"] = new FontAwesomeIconEntry(640, 512, ""), + ["hands-holding-circle"] = new FontAwesomeIconEntry(640, 512, ""), + ["hand-sparkles"] = new FontAwesomeIconEntry(640, 512, ""), + ["hand-spock"] = new FontAwesomeIconEntry(576, 512, ""), + ["hands-praying"] = new FontAwesomeIconEntry(640, 512, ""), + ["hanukiah"] = new FontAwesomeIconEntry(640, 512, ""), + ["hard-drive"] = new FontAwesomeIconEntry(512, 512, ""), + ["hashtag"] = new FontAwesomeIconEntry(448, 512, ""), + ["hat-cowboy"] = new FontAwesomeIconEntry(640, 512, ""), + ["hat-cowboy-side"] = new FontAwesomeIconEntry(640, 512, ""), + ["hat-wizard"] = new FontAwesomeIconEntry(512, 512, ""), + ["heading"] = new FontAwesomeIconEntry(448, 512, ""), + ["headphones"] = new FontAwesomeIconEntry(512, 512, ""), + ["headphones-simple"] = new FontAwesomeIconEntry(512, 512, ""), + ["headset"] = new FontAwesomeIconEntry(512, 512, ""), + ["head-side-cough"] = new FontAwesomeIconEntry(640, 512, ""), + ["head-side-cough-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["head-side-mask"] = new FontAwesomeIconEntry(576, 512, ""), + ["head-side-virus"] = new FontAwesomeIconEntry(512, 512, ""), + ["heart"] = new FontAwesomeIconEntry(512, 512, ""), + ["heart-circle-bolt"] = new FontAwesomeIconEntry(576, 512, ""), + ["heart-circle-check"] = new FontAwesomeIconEntry(576, 512, ""), + ["heart-circle-exclamation"] = new FontAwesomeIconEntry(576, 512, ""), + ["heart-circle-minus"] = new FontAwesomeIconEntry(576, 512, ""), + ["heart-circle-plus"] = new FontAwesomeIconEntry(576, 512, ""), + ["heart-circle-xmark"] = new FontAwesomeIconEntry(576, 512, ""), + ["heart-crack"] = new FontAwesomeIconEntry(512, 512, ""), + ["heart-pulse"] = new FontAwesomeIconEntry(512, 512, ""), + ["helicopter"] = new FontAwesomeIconEntry(640, 512, ""), + ["helicopter-symbol"] = new FontAwesomeIconEntry(512, 512, ""), + ["helmet-safety"] = new FontAwesomeIconEntry(576, 512, ""), + ["helmet-un"] = new FontAwesomeIconEntry(512, 512, ""), + ["hexagon-nodes"] = new FontAwesomeIconEntry(448, 512, ""), + ["hexagon-nodes-bolt"] = new FontAwesomeIconEntry(576, 512, ""), + ["highlighter"] = new FontAwesomeIconEntry(576, 512, ""), + ["hill-avalanche"] = new FontAwesomeIconEntry(576, 512, ""), + ["hill-rockslide"] = new FontAwesomeIconEntry(576, 512, ""), + ["hippo"] = new FontAwesomeIconEntry(640, 512, ""), + ["hockey-puck"] = new FontAwesomeIconEntry(512, 512, ""), + ["holly-berry"] = new FontAwesomeIconEntry(512, 512, ""), + ["horse"] = new FontAwesomeIconEntry(576, 512, ""), + ["horse-head"] = new FontAwesomeIconEntry(640, 512, ""), + ["hospital"] = new FontAwesomeIconEntry(640, 512, ""), + ["hospital-user"] = new FontAwesomeIconEntry(576, 512, ""), + ["hotdog"] = new FontAwesomeIconEntry(512, 512, ""), + ["hotel"] = new FontAwesomeIconEntry(512, 512, ""), + ["hot-tub-person"] = new FontAwesomeIconEntry(512, 512, ""), + ["hourglass"] = new FontAwesomeIconEntry(384, 512, ""), + ["hourglass-empty"] = new FontAwesomeIconEntry(384, 512, ""), + ["hourglass-end"] = new FontAwesomeIconEntry(384, 512, ""), + ["hourglass-half"] = new FontAwesomeIconEntry(384, 512, ""), + ["hourglass-start"] = new FontAwesomeIconEntry(384, 512, ""), + ["house"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-chimney"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-chimney-crack"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-chimney-medical"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-chimney-user"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-chimney-window"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-circle-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-circle-exclamation"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-circle-xmark"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-crack"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-fire"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-flag"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-flood-water"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-flood-water-circle-arrow-right"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-laptop"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-medical"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-medical-circle-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-medical-circle-exclamation"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-medical-circle-xmark"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-medical-flag"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-signal"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-tsunami"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-user"] = new FontAwesomeIconEntry(576, 512, ""), + ["hryvnia-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["hurricane"] = new FontAwesomeIconEntry(384, 512, ""), + ["i"] = new FontAwesomeIconEntry(320, 512, ""), + ["ice-cream"] = new FontAwesomeIconEntry(448, 512, ""), + ["icicles"] = new FontAwesomeIconEntry(512, 512, ""), + ["icons"] = new FontAwesomeIconEntry(512, 512, ""), + ["i-cursor"] = new FontAwesomeIconEntry(256, 512, ""), + ["id-badge"] = new FontAwesomeIconEntry(384, 512, ""), + ["id-card"] = new FontAwesomeIconEntry(576, 512, ""), + ["id-card-clip"] = new FontAwesomeIconEntry(576, 512, ""), + ["igloo"] = new FontAwesomeIconEntry(576, 512, ""), + ["image"] = new FontAwesomeIconEntry(512, 512, ""), + ["image-portrait"] = new FontAwesomeIconEntry(384, 512, ""), + ["images"] = new FontAwesomeIconEntry(576, 512, ""), + ["inbox"] = new FontAwesomeIconEntry(512, 512, ""), + ["indent"] = new FontAwesomeIconEntry(448, 512, ""), + ["indian-rupee-sign"] = new FontAwesomeIconEntry(320, 512, ""), + ["industry"] = new FontAwesomeIconEntry(576, 512, ""), + ["infinity"] = new FontAwesomeIconEntry(640, 512, ""), + ["info"] = new FontAwesomeIconEntry(192, 512, ""), + ["italic"] = new FontAwesomeIconEntry(384, 512, ""), + ["j"] = new FontAwesomeIconEntry(320, 512, ""), + ["jar"] = new FontAwesomeIconEntry(320, 512, ""), + ["jar-wheat"] = new FontAwesomeIconEntry(320, 512, ""), + ["jedi"] = new FontAwesomeIconEntry(576, 512, ""), + ["jet-fighter"] = new FontAwesomeIconEntry(640, 512, ""), + ["jet-fighter-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["joint"] = new FontAwesomeIconEntry(640, 512, ""), + ["jug-detergent"] = new FontAwesomeIconEntry(384, 512, ""), + ["k"] = new FontAwesomeIconEntry(320, 512, ""), + ["kaaba"] = new FontAwesomeIconEntry(576, 512, ""), + ["key"] = new FontAwesomeIconEntry(512, 512, ""), + ["keyboard"] = new FontAwesomeIconEntry(576, 512, ""), + ["khanda"] = new FontAwesomeIconEntry(512, 512, ""), + ["kip-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["kitchen-set"] = new FontAwesomeIconEntry(576, 512, ""), + ["kit-medical"] = new FontAwesomeIconEntry(576, 512, ""), + ["kiwi-bird"] = new FontAwesomeIconEntry(576, 512, ""), + ["l"] = new FontAwesomeIconEntry(320, 512, ""), + ["landmark"] = new FontAwesomeIconEntry(512, 512, ""), + ["landmark-dome"] = new FontAwesomeIconEntry(512, 512, ""), + ["landmark-flag"] = new FontAwesomeIconEntry(512, 512, ""), + ["land-mine-on"] = new FontAwesomeIconEntry(640, 512, ""), + ["language"] = new FontAwesomeIconEntry(640, 512, ""), + ["laptop"] = new FontAwesomeIconEntry(640, 512, ""), + ["laptop-code"] = new FontAwesomeIconEntry(640, 512, ""), + ["laptop-file"] = new FontAwesomeIconEntry(640, 512, ""), + ["laptop-medical"] = new FontAwesomeIconEntry(640, 512, ""), + ["lari-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["layer-group"] = new FontAwesomeIconEntry(576, 512, ""), + ["leaf"] = new FontAwesomeIconEntry(512, 512, ""), + ["left-long"] = new FontAwesomeIconEntry(512, 512, ""), + ["left-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["lemon"] = new FontAwesomeIconEntry(448, 512, ""), + ["less-than"] = new FontAwesomeIconEntry(384, 512, ""), + ["less-than-equal"] = new FontAwesomeIconEntry(448, 512, ""), + ["life-ring"] = new FontAwesomeIconEntry(512, 512, ""), + ["lightbulb"] = new FontAwesomeIconEntry(384, 512, ""), + ["lines-leaning"] = new FontAwesomeIconEntry(384, 512, ""), + ["link"] = new FontAwesomeIconEntry(640, 512, ""), + ["link-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["lira-sign"] = new FontAwesomeIconEntry(320, 512, ""), + ["list"] = new FontAwesomeIconEntry(512, 512, ""), + ["list-check"] = new FontAwesomeIconEntry(512, 512, ""), + ["list-ol"] = new FontAwesomeIconEntry(512, 512, ""), + ["list-ul"] = new FontAwesomeIconEntry(512, 512, ""), + ["litecoin-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["location-arrow"] = new FontAwesomeIconEntry(448, 512, ""), + ["location-crosshairs"] = new FontAwesomeIconEntry(512, 512, ""), + ["location-dot"] = new FontAwesomeIconEntry(384, 512, ""), + ["location-pin"] = new FontAwesomeIconEntry(384, 512, ""), + ["location-pin-lock"] = new FontAwesomeIconEntry(512, 512, ""), + ["lock"] = new FontAwesomeIconEntry(448, 512, ""), + ["lock-open"] = new FontAwesomeIconEntry(576, 512, ""), + ["locust"] = new FontAwesomeIconEntry(576, 512, ""), + ["lungs"] = new FontAwesomeIconEntry(640, 512, ""), + ["lungs-virus"] = new FontAwesomeIconEntry(640, 512, ""), + ["m"] = new FontAwesomeIconEntry(448, 512, ""), + ["magnet"] = new FontAwesomeIconEntry(448, 512, ""), + ["magnifying-glass"] = new FontAwesomeIconEntry(512, 512, ""), + ["magnifying-glass-arrow-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["magnifying-glass-chart"] = new FontAwesomeIconEntry(512, 512, ""), + ["magnifying-glass-dollar"] = new FontAwesomeIconEntry(512, 512, ""), + ["magnifying-glass-location"] = new FontAwesomeIconEntry(512, 512, ""), + ["magnifying-glass-minus"] = new FontAwesomeIconEntry(512, 512, ""), + ["magnifying-glass-plus"] = new FontAwesomeIconEntry(512, 512, ""), + ["manat-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["map"] = new FontAwesomeIconEntry(576, 512, ""), + ["map-location"] = new FontAwesomeIconEntry(576, 512, ""), + ["map-location-dot"] = new FontAwesomeIconEntry(576, 512, ""), + ["map-pin"] = new FontAwesomeIconEntry(320, 512, ""), + ["marker"] = new FontAwesomeIconEntry(512, 512, ""), + ["mars"] = new FontAwesomeIconEntry(448, 512, ""), + ["mars-and-venus"] = new FontAwesomeIconEntry(512, 512, ""), + ["mars-and-venus-burst"] = new FontAwesomeIconEntry(640, 512, ""), + ["mars-double"] = new FontAwesomeIconEntry(640, 512, ""), + ["mars-stroke"] = new FontAwesomeIconEntry(512, 512, ""), + ["mars-stroke-right"] = new FontAwesomeIconEntry(640, 512, ""), + ["mars-stroke-up"] = new FontAwesomeIconEntry(320, 512, ""), + ["martini-glass"] = new FontAwesomeIconEntry(512, 512, ""), + ["martini-glass-citrus"] = new FontAwesomeIconEntry(576, 512, ""), + ["martini-glass-empty"] = new FontAwesomeIconEntry(512, 512, ""), + ["mask"] = new FontAwesomeIconEntry(576, 512, ""), + ["mask-face"] = new FontAwesomeIconEntry(640, 512, ""), + ["masks-theater"] = new FontAwesomeIconEntry(640, 512, ""), + ["mask-ventilator"] = new FontAwesomeIconEntry(640, 512, ""), + ["mattress-pillow"] = new FontAwesomeIconEntry(640, 512, ""), + ["maximize"] = new FontAwesomeIconEntry(512, 512, ""), + ["medal"] = new FontAwesomeIconEntry(512, 512, ""), + ["memory"] = new FontAwesomeIconEntry(576, 512, ""), + ["menorah"] = new FontAwesomeIconEntry(640, 512, ""), + ["mercury"] = new FontAwesomeIconEntry(384, 512, ""), + ["message"] = new FontAwesomeIconEntry(512, 512, ""), + ["meteor"] = new FontAwesomeIconEntry(512, 512, ""), + ["microchip"] = new FontAwesomeIconEntry(512, 512, ""), + ["microphone"] = new FontAwesomeIconEntry(384, 512, ""), + ["microphone-lines"] = new FontAwesomeIconEntry(384, 512, ""), + ["microphone-lines-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["microphone-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["microscope"] = new FontAwesomeIconEntry(512, 512, ""), + ["mill-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["minimize"] = new FontAwesomeIconEntry(512, 512, ""), + ["minus"] = new FontAwesomeIconEntry(448, 512, ""), + ["mitten"] = new FontAwesomeIconEntry(448, 512, ""), + ["mobile"] = new FontAwesomeIconEntry(384, 512, ""), + ["mobile-button"] = new FontAwesomeIconEntry(384, 512, ""), + ["mobile-retro"] = new FontAwesomeIconEntry(320, 512, ""), + ["mobile-screen"] = new FontAwesomeIconEntry(384, 512, ""), + ["mobile-screen-button"] = new FontAwesomeIconEntry(384, 512, ""), + ["money-bill"] = new FontAwesomeIconEntry(576, 512, ""), + ["money-bill-1"] = new FontAwesomeIconEntry(576, 512, ""), + ["money-bill-1-wave"] = new FontAwesomeIconEntry(576, 512, ""), + ["money-bills"] = new FontAwesomeIconEntry(640, 512, ""), + ["money-bill-transfer"] = new FontAwesomeIconEntry(640, 512, ""), + ["money-bill-trend-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["money-bill-wave"] = new FontAwesomeIconEntry(576, 512, ""), + ["money-bill-wheat"] = new FontAwesomeIconEntry(512, 512, ""), + ["money-check"] = new FontAwesomeIconEntry(576, 512, ""), + ["money-check-dollar"] = new FontAwesomeIconEntry(576, 512, ""), + ["monument"] = new FontAwesomeIconEntry(384, 512, ""), + ["moon"] = new FontAwesomeIconEntry(384, 512, ""), + ["mortar-pestle"] = new FontAwesomeIconEntry(512, 512, ""), + ["mosque"] = new FontAwesomeIconEntry(640, 512, ""), + ["mosquito"] = new FontAwesomeIconEntry(640, 512, ""), + ["mosquito-net"] = new FontAwesomeIconEntry(640, 512, ""), + ["motorcycle"] = new FontAwesomeIconEntry(640, 512, ""), + ["mound"] = new FontAwesomeIconEntry(576, 512, ""), + ["mountain"] = new FontAwesomeIconEntry(512, 512, ""), + ["mountain-city"] = new FontAwesomeIconEntry(640, 512, ""), + ["mountain-sun"] = new FontAwesomeIconEntry(640, 512, ""), + ["mug-hot"] = new FontAwesomeIconEntry(512, 512, ""), + ["mug-saucer"] = new FontAwesomeIconEntry(640, 512, ""), + ["music"] = new FontAwesomeIconEntry(512, 512, ""), + ["n"] = new FontAwesomeIconEntry(384, 512, ""), + ["naira-sign"] = new FontAwesomeIconEntry(448, 512, ""), + ["network-wired"] = new FontAwesomeIconEntry(640, 512, ""), + ["neuter"] = new FontAwesomeIconEntry(384, 512, ""), + ["newspaper"] = new FontAwesomeIconEntry(512, 512, ""), + ["notdef"] = new FontAwesomeIconEntry(384, 512, ""), + ["not-equal"] = new FontAwesomeIconEntry(448, 512, ""), + ["notes-medical"] = new FontAwesomeIconEntry(512, 512, ""), + ["note-sticky"] = new FontAwesomeIconEntry(448, 512, ""), + ["o"] = new FontAwesomeIconEntry(448, 512, ""), + ["object-group"] = new FontAwesomeIconEntry(576, 512, ""), + ["object-ungroup"] = new FontAwesomeIconEntry(640, 512, ""), + ["oil-can"] = new FontAwesomeIconEntry(640, 512, ""), + ["oil-well"] = new FontAwesomeIconEntry(576, 512, ""), + ["om"] = new FontAwesomeIconEntry(512, 512, ""), + ["otter"] = new FontAwesomeIconEntry(640, 512, ""), + ["outdent"] = new FontAwesomeIconEntry(448, 512, ""), + ["p"] = new FontAwesomeIconEntry(320, 512, ""), + ["pager"] = new FontAwesomeIconEntry(512, 512, ""), + ["paintbrush"] = new FontAwesomeIconEntry(576, 512, ""), + ["paint-roller"] = new FontAwesomeIconEntry(512, 512, ""), + ["palette"] = new FontAwesomeIconEntry(512, 512, ""), + ["pallet"] = new FontAwesomeIconEntry(640, 512, ""), + ["panorama"] = new FontAwesomeIconEntry(640, 512, ""), + ["paperclip"] = new FontAwesomeIconEntry(448, 512, ""), + ["paper-plane"] = new FontAwesomeIconEntry(512, 512, ""), + ["parachute-box"] = new FontAwesomeIconEntry(512, 512, ""), + ["paragraph"] = new FontAwesomeIconEntry(448, 512, ""), + ["passport"] = new FontAwesomeIconEntry(448, 512, ""), + ["paste"] = new FontAwesomeIconEntry(512, 512, ""), + ["pause"] = new FontAwesomeIconEntry(320, 512, ""), + ["paw"] = new FontAwesomeIconEntry(512, 512, ""), + ["peace"] = new FontAwesomeIconEntry(512, 512, ""), + ["pen"] = new FontAwesomeIconEntry(512, 512, ""), + ["pencil"] = new FontAwesomeIconEntry(512, 512, ""), + ["pen-clip"] = new FontAwesomeIconEntry(512, 512, ""), + ["pen-fancy"] = new FontAwesomeIconEntry(512, 512, ""), + ["pen-nib"] = new FontAwesomeIconEntry(512, 512, ""), + ["pen-ruler"] = new FontAwesomeIconEntry(512, 512, ""), + ["pen-to-square"] = new FontAwesomeIconEntry(512, 512, ""), + ["people-arrows"] = new FontAwesomeIconEntry(640, 512, ""), + ["people-arrows-left-right"] = new FontAwesomeIconEntry(576, 512, ""), + ["people-carry-box"] = new FontAwesomeIconEntry(640, 512, ""), + ["people-group"] = new FontAwesomeIconEntry(640, 512, ""), + ["people-line"] = new FontAwesomeIconEntry(640, 512, ""), + ["people-pulling"] = new FontAwesomeIconEntry(576, 512, ""), + ["people-robbery"] = new FontAwesomeIconEntry(576, 512, ""), + ["people-roof"] = new FontAwesomeIconEntry(640, 512, ""), + ["pepper-hot"] = new FontAwesomeIconEntry(512, 512, ""), + ["percent"] = new FontAwesomeIconEntry(384, 512, ""), + ["person"] = new FontAwesomeIconEntry(320, 512, ""), + ["person-arrow-down-to-line"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-arrow-up-from-line"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-biking"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-booth"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-breastfeeding"] = new FontAwesomeIconEntry(448, 512, ""), + ["person-burst"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-cane"] = new FontAwesomeIconEntry(448, 512, ""), + ["person-chalkboard"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-circle-check"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-circle-exclamation"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-circle-minus"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-circle-plus"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-circle-question"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-circle-xmark"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-digging"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-dots-from-line"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-dress"] = new FontAwesomeIconEntry(320, 512, ""), + ["person-dress-burst"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-drowning"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-falling"] = new FontAwesomeIconEntry(512, 512, ""), + ["person-falling-burst"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-half-dress"] = new FontAwesomeIconEntry(320, 512, ""), + ["person-harassing"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-hiking"] = new FontAwesomeIconEntry(384, 512, ""), + ["person-military-pointing"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-military-rifle"] = new FontAwesomeIconEntry(512, 512, ""), + ["person-military-to-person"] = new FontAwesomeIconEntry(512, 512, ""), + ["person-praying"] = new FontAwesomeIconEntry(448, 512, ""), + ["person-pregnant"] = new FontAwesomeIconEntry(384, 512, ""), + ["person-rays"] = new FontAwesomeIconEntry(512, 512, ""), + ["person-rifle"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-running"] = new FontAwesomeIconEntry(448, 512, ""), + ["person-shelter"] = new FontAwesomeIconEntry(512, 512, ""), + ["person-skating"] = new FontAwesomeIconEntry(448, 512, ""), + ["person-skiing"] = new FontAwesomeIconEntry(512, 512, ""), + ["person-skiing-nordic"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-snowboarding"] = new FontAwesomeIconEntry(512, 512, ""), + ["person-swimming"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-through-window"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-walking"] = new FontAwesomeIconEntry(320, 512, ""), + ["person-walking-arrow-loop-left"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-walking-arrow-right"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-walking-dashed-line-arrow-right"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-walking-luggage"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-walking-with-cane"] = new FontAwesomeIconEntry(512, 512, ""), + ["peseta-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["peso-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["phone"] = new FontAwesomeIconEntry(512, 512, ""), + ["phone-flip"] = new FontAwesomeIconEntry(512, 512, ""), + ["phone-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["phone-volume"] = new FontAwesomeIconEntry(512, 512, ""), + ["photo-film"] = new FontAwesomeIconEntry(640, 512, ""), + ["piggy-bank"] = new FontAwesomeIconEntry(576, 512, ""), + ["pills"] = new FontAwesomeIconEntry(576, 512, ""), + ["pizza-slice"] = new FontAwesomeIconEntry(512, 512, ""), + ["place-of-worship"] = new FontAwesomeIconEntry(640, 512, ""), + ["plane"] = new FontAwesomeIconEntry(576, 512, ""), + ["plane-arrival"] = new FontAwesomeIconEntry(640, 512, ""), + ["plane-circle-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["plane-circle-exclamation"] = new FontAwesomeIconEntry(640, 512, ""), + ["plane-circle-xmark"] = new FontAwesomeIconEntry(640, 512, ""), + ["plane-departure"] = new FontAwesomeIconEntry(640, 512, ""), + ["plane-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["plane-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["plane-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["plant-wilt"] = new FontAwesomeIconEntry(512, 512, ""), + ["plate-wheat"] = new FontAwesomeIconEntry(512, 512, ""), + ["play"] = new FontAwesomeIconEntry(384, 512, ""), + ["plug"] = new FontAwesomeIconEntry(384, 512, ""), + ["plug-circle-bolt"] = new FontAwesomeIconEntry(576, 512, ""), + ["plug-circle-check"] = new FontAwesomeIconEntry(576, 512, ""), + ["plug-circle-exclamation"] = new FontAwesomeIconEntry(576, 512, ""), + ["plug-circle-minus"] = new FontAwesomeIconEntry(576, 512, ""), + ["plug-circle-plus"] = new FontAwesomeIconEntry(576, 512, ""), + ["plug-circle-xmark"] = new FontAwesomeIconEntry(576, 512, ""), + ["plus"] = new FontAwesomeIconEntry(448, 512, ""), + ["plus-minus"] = new FontAwesomeIconEntry(384, 512, ""), + ["podcast"] = new FontAwesomeIconEntry(448, 512, ""), + ["poo"] = new FontAwesomeIconEntry(512, 512, ""), + ["poop"] = new FontAwesomeIconEntry(512, 512, ""), + ["poo-storm"] = new FontAwesomeIconEntry(448, 512, ""), + ["power-off"] = new FontAwesomeIconEntry(512, 512, ""), + ["prescription"] = new FontAwesomeIconEntry(448, 512, ""), + ["prescription-bottle"] = new FontAwesomeIconEntry(384, 512, ""), + ["prescription-bottle-medical"] = new FontAwesomeIconEntry(384, 512, ""), + ["print"] = new FontAwesomeIconEntry(512, 512, ""), + ["pump-medical"] = new FontAwesomeIconEntry(448, 512, ""), + ["pump-soap"] = new FontAwesomeIconEntry(448, 512, ""), + ["puzzle-piece"] = new FontAwesomeIconEntry(512, 512, ""), + ["q"] = new FontAwesomeIconEntry(448, 512, ""), + ["qrcode"] = new FontAwesomeIconEntry(448, 512, ""), + ["question"] = new FontAwesomeIconEntry(320, 512, ""), + ["quote-left"] = new FontAwesomeIconEntry(448, 512, ""), + ["quote-right"] = new FontAwesomeIconEntry(448, 512, ""), + ["r"] = new FontAwesomeIconEntry(320, 512, ""), + ["radiation"] = new FontAwesomeIconEntry(512, 512, ""), + ["radio"] = new FontAwesomeIconEntry(512, 512, ""), + ["rainbow"] = new FontAwesomeIconEntry(640, 512, ""), + ["ranking-star"] = new FontAwesomeIconEntry(640, 512, ""), + ["receipt"] = new FontAwesomeIconEntry(384, 512, ""), + ["record-vinyl"] = new FontAwesomeIconEntry(512, 512, ""), + ["rectangle-ad"] = new FontAwesomeIconEntry(576, 512, ""), + ["rectangle-list"] = new FontAwesomeIconEntry(576, 512, ""), + ["rectangle-xmark"] = new FontAwesomeIconEntry(512, 512, ""), + ["recycle"] = new FontAwesomeIconEntry(512, 512, ""), + ["registered"] = new FontAwesomeIconEntry(512, 512, ""), + ["repeat"] = new FontAwesomeIconEntry(512, 512, ""), + ["reply"] = new FontAwesomeIconEntry(512, 512, ""), + ["reply-all"] = new FontAwesomeIconEntry(576, 512, ""), + ["republican"] = new FontAwesomeIconEntry(640, 512, ""), + ["restroom"] = new FontAwesomeIconEntry(640, 512, ""), + ["retweet"] = new FontAwesomeIconEntry(576, 512, ""), + ["ribbon"] = new FontAwesomeIconEntry(448, 512, ""), + ["right-from-bracket"] = new FontAwesomeIconEntry(512, 512, ""), + ["right-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["right-long"] = new FontAwesomeIconEntry(512, 512, ""), + ["right-to-bracket"] = new FontAwesomeIconEntry(512, 512, ""), + ["ring"] = new FontAwesomeIconEntry(512, 512, ""), + ["road"] = new FontAwesomeIconEntry(576, 512, ""), + ["road-barrier"] = new FontAwesomeIconEntry(640, 512, ""), + ["road-bridge"] = new FontAwesomeIconEntry(640, 512, ""), + ["road-circle-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["road-circle-exclamation"] = new FontAwesomeIconEntry(640, 512, ""), + ["road-circle-xmark"] = new FontAwesomeIconEntry(640, 512, ""), + ["road-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["road-spikes"] = new FontAwesomeIconEntry(640, 512, ""), + ["robot"] = new FontAwesomeIconEntry(640, 512, ""), + ["rocket"] = new FontAwesomeIconEntry(512, 512, ""), + ["rotate"] = new FontAwesomeIconEntry(512, 512, ""), + ["rotate-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["rotate-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["route"] = new FontAwesomeIconEntry(512, 512, ""), + ["rss"] = new FontAwesomeIconEntry(448, 512, ""), + ["ruble-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["rug"] = new FontAwesomeIconEntry(640, 512, ""), + ["ruler"] = new FontAwesomeIconEntry(512, 512, ""), + ["ruler-combined"] = new FontAwesomeIconEntry(512, 512, ""), + ["ruler-horizontal"] = new FontAwesomeIconEntry(640, 512, ""), + ["ruler-vertical"] = new FontAwesomeIconEntry(256, 512, ""), + ["rupee-sign"] = new FontAwesomeIconEntry(448, 512, ""), + ["rupiah-sign"] = new FontAwesomeIconEntry(512, 512, ""), + ["s"] = new FontAwesomeIconEntry(320, 512, ""), + ["sack-dollar"] = new FontAwesomeIconEntry(512, 512, ""), + ["sack-xmark"] = new FontAwesomeIconEntry(512, 512, ""), + ["sailboat"] = new FontAwesomeIconEntry(576, 512, ""), + ["satellite"] = new FontAwesomeIconEntry(512, 512, ""), + ["satellite-dish"] = new FontAwesomeIconEntry(512, 512, ""), + ["scale-balanced"] = new FontAwesomeIconEntry(640, 512, ""), + ["scale-unbalanced"] = new FontAwesomeIconEntry(640, 512, ""), + ["scale-unbalanced-flip"] = new FontAwesomeIconEntry(640, 512, ""), + ["school"] = new FontAwesomeIconEntry(640, 512, ""), + ["school-circle-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["school-circle-exclamation"] = new FontAwesomeIconEntry(640, 512, ""), + ["school-circle-xmark"] = new FontAwesomeIconEntry(640, 512, ""), + ["school-flag"] = new FontAwesomeIconEntry(576, 512, ""), + ["school-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["scissors"] = new FontAwesomeIconEntry(512, 512, ""), + ["screwdriver"] = new FontAwesomeIconEntry(512, 512, ""), + ["screwdriver-wrench"] = new FontAwesomeIconEntry(512, 512, ""), + ["scroll"] = new FontAwesomeIconEntry(576, 512, ""), + ["scroll-torah"] = new FontAwesomeIconEntry(640, 512, ""), + ["sd-card"] = new FontAwesomeIconEntry(384, 512, ""), + ["section"] = new FontAwesomeIconEntry(256, 512, ""), + ["seedling"] = new FontAwesomeIconEntry(512, 512, ""), + ["server"] = new FontAwesomeIconEntry(512, 512, ""), + ["shapes"] = new FontAwesomeIconEntry(512, 512, ""), + ["share"] = new FontAwesomeIconEntry(512, 512, ""), + ["share-from-square"] = new FontAwesomeIconEntry(576, 512, ""), + ["share-nodes"] = new FontAwesomeIconEntry(448, 512, ""), + ["sheet-plastic"] = new FontAwesomeIconEntry(384, 512, ""), + ["shekel-sign"] = new FontAwesomeIconEntry(448, 512, ""), + ["shield"] = new FontAwesomeIconEntry(512, 512, ""), + ["shield-blank"] = new FontAwesomeIconEntry(512, 512, ""), + ["shield-cat"] = new FontAwesomeIconEntry(512, 512, ""), + ["shield-dog"] = new FontAwesomeIconEntry(512, 512, ""), + ["shield-halved"] = new FontAwesomeIconEntry(512, 512, ""), + ["shield-heart"] = new FontAwesomeIconEntry(512, 512, ""), + ["shield-virus"] = new FontAwesomeIconEntry(512, 512, ""), + ["ship"] = new FontAwesomeIconEntry(576, 512, ""), + ["shirt"] = new FontAwesomeIconEntry(640, 512, ""), + ["shoe-prints"] = new FontAwesomeIconEntry(640, 512, ""), + ["shop"] = new FontAwesomeIconEntry(640, 512, ""), + ["shop-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["shop-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["shower"] = new FontAwesomeIconEntry(512, 512, ""), + ["shrimp"] = new FontAwesomeIconEntry(512, 512, ""), + ["shuffle"] = new FontAwesomeIconEntry(512, 512, ""), + ["shuttle-space"] = new FontAwesomeIconEntry(640, 512, ""), + ["signal"] = new FontAwesomeIconEntry(640, 512, ""), + ["signature"] = new FontAwesomeIconEntry(640, 512, ""), + ["sign-hanging"] = new FontAwesomeIconEntry(512, 512, ""), + ["signs-post"] = new FontAwesomeIconEntry(512, 512, ""), + ["sim-card"] = new FontAwesomeIconEntry(384, 512, ""), + ["sink"] = new FontAwesomeIconEntry(512, 512, ""), + ["sitemap"] = new FontAwesomeIconEntry(576, 512, ""), + ["skull"] = new FontAwesomeIconEntry(512, 512, ""), + ["skull-crossbones"] = new FontAwesomeIconEntry(448, 512, ""), + ["slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["sleigh"] = new FontAwesomeIconEntry(640, 512, ""), + ["sliders"] = new FontAwesomeIconEntry(512, 512, ""), + ["smog"] = new FontAwesomeIconEntry(640, 512, ""), + ["smoking"] = new FontAwesomeIconEntry(640, 512, ""), + ["snowflake"] = new FontAwesomeIconEntry(448, 512, ""), + ["snowman"] = new FontAwesomeIconEntry(512, 512, ""), + ["snowplow"] = new FontAwesomeIconEntry(640, 512, ""), + ["soap"] = new FontAwesomeIconEntry(512, 512, ""), + ["socks"] = new FontAwesomeIconEntry(512, 512, ""), + ["solar-panel"] = new FontAwesomeIconEntry(640, 512, ""), + ["sort"] = new FontAwesomeIconEntry(320, 512, ""), + ["sort-down"] = new FontAwesomeIconEntry(320, 512, ""), + ["sort-up"] = new FontAwesomeIconEntry(320, 512, ""), + ["spa"] = new FontAwesomeIconEntry(576, 512, ""), + ["spaghetti-monster-flying"] = new FontAwesomeIconEntry(640, 512, ""), + ["spell-check"] = new FontAwesomeIconEntry(576, 512, ""), + ["spider"] = new FontAwesomeIconEntry(512, 512, ""), + ["spinner"] = new FontAwesomeIconEntry(512, 512, ""), + ["splotch"] = new FontAwesomeIconEntry(512, 512, ""), + ["spoon"] = new FontAwesomeIconEntry(512, 512, ""), + ["spray-can"] = new FontAwesomeIconEntry(512, 512, ""), + ["spray-can-sparkles"] = new FontAwesomeIconEntry(512, 512, ""), + ["square"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-arrow-up-right"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-binary"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-caret-down"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-caret-left"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-caret-right"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-caret-up"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-check"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-envelope"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-full"] = new FontAwesomeIconEntry(512, 512, ""), + ["square-h"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-minus"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-nfi"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-parking"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-pen"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-person-confined"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-phone"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-phone-flip"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-plus"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-poll-horizontal"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-poll-vertical"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-root-variable"] = new FontAwesomeIconEntry(576, 512, ""), + ["square-rss"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-share-nodes"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-up-right"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-virus"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-xmark"] = new FontAwesomeIconEntry(448, 512, ""), + ["staff-aesculapius"] = new FontAwesomeIconEntry(384, 512, ""), + ["staff-snake"] = new FontAwesomeIconEntry(384, 512, ""), + ["stairs"] = new FontAwesomeIconEntry(576, 512, ""), + ["stamp"] = new FontAwesomeIconEntry(512, 512, ""), + ["stapler"] = new FontAwesomeIconEntry(640, 512, ""), + ["star"] = new FontAwesomeIconEntry(576, 512, ""), + ["star-and-crescent"] = new FontAwesomeIconEntry(512, 512, ""), + ["star-half"] = new FontAwesomeIconEntry(576, 512, ""), + ["star-half-stroke"] = new FontAwesomeIconEntry(576, 512, ""), + ["star-of-david"] = new FontAwesomeIconEntry(512, 512, ""), + ["star-of-life"] = new FontAwesomeIconEntry(512, 512, ""), + ["sterling-sign"] = new FontAwesomeIconEntry(320, 512, ""), + ["stethoscope"] = new FontAwesomeIconEntry(576, 512, ""), + ["stop"] = new FontAwesomeIconEntry(384, 512, ""), + ["stopwatch"] = new FontAwesomeIconEntry(448, 512, ""), + ["stopwatch-20"] = new FontAwesomeIconEntry(448, 512, ""), + ["store"] = new FontAwesomeIconEntry(576, 512, ""), + ["store-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["street-view"] = new FontAwesomeIconEntry(512, 512, ""), + ["strikethrough"] = new FontAwesomeIconEntry(512, 512, ""), + ["stroopwafel"] = new FontAwesomeIconEntry(512, 512, ""), + ["subscript"] = new FontAwesomeIconEntry(512, 512, ""), + ["suitcase"] = new FontAwesomeIconEntry(512, 512, ""), + ["suitcase-medical"] = new FontAwesomeIconEntry(512, 512, ""), + ["suitcase-rolling"] = new FontAwesomeIconEntry(384, 512, ""), + ["sun"] = new FontAwesomeIconEntry(512, 512, ""), + ["sun-plant-wilt"] = new FontAwesomeIconEntry(640, 512, ""), + ["superscript"] = new FontAwesomeIconEntry(512, 512, ""), + ["swatchbook"] = new FontAwesomeIconEntry(512, 512, ""), + ["synagogue"] = new FontAwesomeIconEntry(640, 512, ""), + ["syringe"] = new FontAwesomeIconEntry(512, 512, ""), + ["t"] = new FontAwesomeIconEntry(384, 512, ""), + ["table"] = new FontAwesomeIconEntry(512, 512, ""), + ["table-cells"] = new FontAwesomeIconEntry(512, 512, ""), + ["table-cells-column-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["table-cells-large"] = new FontAwesomeIconEntry(512, 512, ""), + ["table-cells-row-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["table-cells-row-unlock"] = new FontAwesomeIconEntry(640, 512, ""), + ["table-columns"] = new FontAwesomeIconEntry(512, 512, ""), + ["table-list"] = new FontAwesomeIconEntry(512, 512, ""), + ["tablet"] = new FontAwesomeIconEntry(448, 512, ""), + ["tablet-button"] = new FontAwesomeIconEntry(448, 512, ""), + ["table-tennis-paddle-ball"] = new FontAwesomeIconEntry(512, 512, ""), + ["tablets"] = new FontAwesomeIconEntry(640, 512, ""), + ["tablet-screen-button"] = new FontAwesomeIconEntry(448, 512, ""), + ["tachograph-digital"] = new FontAwesomeIconEntry(640, 512, ""), + ["tag"] = new FontAwesomeIconEntry(448, 512, ""), + ["tags"] = new FontAwesomeIconEntry(512, 512, ""), + ["tape"] = new FontAwesomeIconEntry(576, 512, ""), + ["tarp"] = new FontAwesomeIconEntry(576, 512, ""), + ["tarp-droplet"] = new FontAwesomeIconEntry(576, 512, ""), + ["taxi"] = new FontAwesomeIconEntry(512, 512, ""), + ["teeth"] = new FontAwesomeIconEntry(576, 512, ""), + ["teeth-open"] = new FontAwesomeIconEntry(576, 512, ""), + ["temperature-arrow-down"] = new FontAwesomeIconEntry(576, 512, ""), + ["temperature-arrow-up"] = new FontAwesomeIconEntry(576, 512, ""), + ["temperature-empty"] = new FontAwesomeIconEntry(320, 512, ""), + ["temperature-full"] = new FontAwesomeIconEntry(320, 512, ""), + ["temperature-half"] = new FontAwesomeIconEntry(320, 512, ""), + ["temperature-high"] = new FontAwesomeIconEntry(512, 512, ""), + ["temperature-low"] = new FontAwesomeIconEntry(512, 512, ""), + ["temperature-quarter"] = new FontAwesomeIconEntry(320, 512, ""), + ["temperature-three-quarters"] = new FontAwesomeIconEntry(320, 512, ""), + ["tenge-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["tent"] = new FontAwesomeIconEntry(576, 512, ""), + ["tent-arrow-down-to-line"] = new FontAwesomeIconEntry(640, 512, ""), + ["tent-arrow-left-right"] = new FontAwesomeIconEntry(576, 512, ""), + ["tent-arrows-down"] = new FontAwesomeIconEntry(576, 512, ""), + ["tent-arrow-turn-left"] = new FontAwesomeIconEntry(576, 512, ""), + ["tents"] = new FontAwesomeIconEntry(640, 512, ""), + ["terminal"] = new FontAwesomeIconEntry(576, 512, ""), + ["text-height"] = new FontAwesomeIconEntry(576, 512, ""), + ["text-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["text-width"] = new FontAwesomeIconEntry(448, 512, ""), + ["thermometer"] = new FontAwesomeIconEntry(512, 512, ""), + ["thumbs-down"] = new FontAwesomeIconEntry(512, 512, ""), + ["thumbs-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["thumbtack"] = new FontAwesomeIconEntry(384, 512, ""), + ["thumbtack-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["ticket"] = new FontAwesomeIconEntry(576, 512, ""), + ["ticket-simple"] = new FontAwesomeIconEntry(576, 512, ""), + ["timeline"] = new FontAwesomeIconEntry(640, 512, ""), + ["toggle-off"] = new FontAwesomeIconEntry(576, 512, ""), + ["toggle-on"] = new FontAwesomeIconEntry(576, 512, ""), + ["toilet"] = new FontAwesomeIconEntry(448, 512, ""), + ["toilet-paper"] = new FontAwesomeIconEntry(640, 512, ""), + ["toilet-paper-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["toilet-portable"] = new FontAwesomeIconEntry(320, 512, ""), + ["toilets-portable"] = new FontAwesomeIconEntry(576, 512, ""), + ["toolbox"] = new FontAwesomeIconEntry(512, 512, ""), + ["tooth"] = new FontAwesomeIconEntry(448, 512, ""), + ["torii-gate"] = new FontAwesomeIconEntry(512, 512, ""), + ["tornado"] = new FontAwesomeIconEntry(448, 512, ""), + ["tower-broadcast"] = new FontAwesomeIconEntry(576, 512, ""), + ["tower-cell"] = new FontAwesomeIconEntry(576, 512, ""), + ["tower-observation"] = new FontAwesomeIconEntry(512, 512, ""), + ["tractor"] = new FontAwesomeIconEntry(640, 512, ""), + ["trademark"] = new FontAwesomeIconEntry(640, 512, ""), + ["traffic-light"] = new FontAwesomeIconEntry(320, 512, ""), + ["trailer"] = new FontAwesomeIconEntry(640, 512, ""), + ["train"] = new FontAwesomeIconEntry(448, 512, ""), + ["train-subway"] = new FontAwesomeIconEntry(448, 512, ""), + ["train-tram"] = new FontAwesomeIconEntry(448, 512, ""), + ["transgender"] = new FontAwesomeIconEntry(512, 512, ""), + ["trash"] = new FontAwesomeIconEntry(448, 512, ""), + ["trash-arrow-up"] = new FontAwesomeIconEntry(448, 512, ""), + ["trash-can"] = new FontAwesomeIconEntry(448, 512, ""), + ["trash-can-arrow-up"] = new FontAwesomeIconEntry(448, 512, ""), + ["tree"] = new FontAwesomeIconEntry(448, 512, ""), + ["tree-city"] = new FontAwesomeIconEntry(640, 512, ""), + ["triangle-exclamation"] = new FontAwesomeIconEntry(512, 512, ""), + ["trophy"] = new FontAwesomeIconEntry(576, 512, ""), + ["trowel"] = new FontAwesomeIconEntry(512, 512, ""), + ["trowel-bricks"] = new FontAwesomeIconEntry(512, 512, ""), + ["truck"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-arrow-right"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-droplet"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-fast"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-field"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-field-un"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-front"] = new FontAwesomeIconEntry(512, 512, ""), + ["truck-medical"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-monster"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-moving"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-pickup"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-plane"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-ramp-box"] = new FontAwesomeIconEntry(640, 512, ""), + ["tty"] = new FontAwesomeIconEntry(512, 512, ""), + ["turkish-lira-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["turn-down"] = new FontAwesomeIconEntry(384, 512, ""), + ["turn-up"] = new FontAwesomeIconEntry(384, 512, ""), + ["tv"] = new FontAwesomeIconEntry(640, 512, ""), + ["u"] = new FontAwesomeIconEntry(384, 512, ""), + ["umbrella"] = new FontAwesomeIconEntry(576, 512, ""), + ["umbrella-beach"] = new FontAwesomeIconEntry(576, 512, ""), + ["underline"] = new FontAwesomeIconEntry(448, 512, ""), + ["universal-access"] = new FontAwesomeIconEntry(512, 512, ""), + ["unlock"] = new FontAwesomeIconEntry(448, 512, ""), + ["unlock-keyhole"] = new FontAwesomeIconEntry(448, 512, ""), + ["up-down"] = new FontAwesomeIconEntry(256, 512, ""), + ["up-down-left-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["upload"] = new FontAwesomeIconEntry(512, 512, ""), + ["up-long"] = new FontAwesomeIconEntry(320, 512, ""), + ["up-right-and-down-left-from-center"] = new FontAwesomeIconEntry(512, 512, ""), + ["up-right-from-square"] = new FontAwesomeIconEntry(512, 512, ""), + ["user"] = new FontAwesomeIconEntry(448, 512, ""), + ["user-astronaut"] = new FontAwesomeIconEntry(448, 512, ""), + ["user-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-clock"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-doctor"] = new FontAwesomeIconEntry(448, 512, ""), + ["user-gear"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-graduate"] = new FontAwesomeIconEntry(448, 512, ""), + ["user-group"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-injured"] = new FontAwesomeIconEntry(448, 512, ""), + ["user-large"] = new FontAwesomeIconEntry(512, 512, ""), + ["user-large-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-minus"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-ninja"] = new FontAwesomeIconEntry(448, 512, ""), + ["user-nurse"] = new FontAwesomeIconEntry(448, 512, ""), + ["user-pen"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-plus"] = new FontAwesomeIconEntry(640, 512, ""), + ["users"] = new FontAwesomeIconEntry(640, 512, ""), + ["users-between-lines"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-secret"] = new FontAwesomeIconEntry(448, 512, ""), + ["users-gear"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-shield"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["users-line"] = new FontAwesomeIconEntry(640, 512, ""), + ["users-rays"] = new FontAwesomeIconEntry(640, 512, ""), + ["users-rectangle"] = new FontAwesomeIconEntry(640, 512, ""), + ["users-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["users-viewfinder"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-tag"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-tie"] = new FontAwesomeIconEntry(448, 512, ""), + ["user-xmark"] = new FontAwesomeIconEntry(640, 512, ""), + ["utensils"] = new FontAwesomeIconEntry(448, 512, ""), + ["v"] = new FontAwesomeIconEntry(384, 512, ""), + ["van-shuttle"] = new FontAwesomeIconEntry(640, 512, ""), + ["vault"] = new FontAwesomeIconEntry(576, 512, ""), + ["vector-square"] = new FontAwesomeIconEntry(448, 512, ""), + ["venus"] = new FontAwesomeIconEntry(384, 512, ""), + ["venus-double"] = new FontAwesomeIconEntry(640, 512, ""), + ["venus-mars"] = new FontAwesomeIconEntry(640, 512, ""), + ["vest"] = new FontAwesomeIconEntry(448, 512, ""), + ["vest-patches"] = new FontAwesomeIconEntry(448, 512, ""), + ["vial"] = new FontAwesomeIconEntry(512, 512, ""), + ["vial-circle-check"] = new FontAwesomeIconEntry(512, 512, ""), + ["vials"] = new FontAwesomeIconEntry(512, 512, ""), + ["vial-virus"] = new FontAwesomeIconEntry(512, 512, ""), + ["video"] = new FontAwesomeIconEntry(576, 512, ""), + ["video-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["vihara"] = new FontAwesomeIconEntry(640, 512, ""), + ["virus"] = new FontAwesomeIconEntry(512, 512, ""), + ["virus-covid"] = new FontAwesomeIconEntry(512, 512, ""), + ["virus-covid-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["viruses"] = new FontAwesomeIconEntry(640, 512, ""), + ["virus-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["voicemail"] = new FontAwesomeIconEntry(640, 512, ""), + ["volcano"] = new FontAwesomeIconEntry(512, 512, ""), + ["volleyball"] = new FontAwesomeIconEntry(512, 512, ""), + ["volume-high"] = new FontAwesomeIconEntry(640, 512, ""), + ["volume-low"] = new FontAwesomeIconEntry(448, 512, ""), + ["volume-off"] = new FontAwesomeIconEntry(320, 512, ""), + ["volume-xmark"] = new FontAwesomeIconEntry(576, 512, ""), + ["vr-cardboard"] = new FontAwesomeIconEntry(640, 512, ""), + ["w"] = new FontAwesomeIconEntry(576, 512, ""), + ["walkie-talkie"] = new FontAwesomeIconEntry(384, 512, ""), + ["wallet"] = new FontAwesomeIconEntry(512, 512, ""), + ["wand-magic"] = new FontAwesomeIconEntry(512, 512, ""), + ["wand-magic-sparkles"] = new FontAwesomeIconEntry(576, 512, ""), + ["wand-sparkles"] = new FontAwesomeIconEntry(512, 512, ""), + ["warehouse"] = new FontAwesomeIconEntry(640, 512, ""), + ["water"] = new FontAwesomeIconEntry(576, 512, ""), + ["water-ladder"] = new FontAwesomeIconEntry(576, 512, ""), + ["wave-square"] = new FontAwesomeIconEntry(640, 512, ""), + ["web-awesome"] = new FontAwesomeIconEntry(640, 512, ""), + ["weight-hanging"] = new FontAwesomeIconEntry(512, 512, ""), + ["weight-scale"] = new FontAwesomeIconEntry(512, 512, ""), + ["wheat-awn"] = new FontAwesomeIconEntry(512, 512, ""), + ["wheat-awn-circle-exclamation"] = new FontAwesomeIconEntry(640, 512, ""), + ["wheelchair"] = new FontAwesomeIconEntry(512, 512, ""), + ["wheelchair-move"] = new FontAwesomeIconEntry(448, 512, ""), + ["whiskey-glass"] = new FontAwesomeIconEntry(512, 512, ""), + ["wifi"] = new FontAwesomeIconEntry(640, 512, ""), + ["wind"] = new FontAwesomeIconEntry(512, 512, ""), + ["window-maximize"] = new FontAwesomeIconEntry(512, 512, ""), + ["window-minimize"] = new FontAwesomeIconEntry(512, 512, ""), + ["window-restore"] = new FontAwesomeIconEntry(512, 512, ""), + ["wine-bottle"] = new FontAwesomeIconEntry(512, 512, ""), + ["wine-glass"] = new FontAwesomeIconEntry(320, 512, ""), + ["wine-glass-empty"] = new FontAwesomeIconEntry(320, 512, ""), + ["won-sign"] = new FontAwesomeIconEntry(512, 512, ""), + ["worm"] = new FontAwesomeIconEntry(512, 512, ""), + ["wrench"] = new FontAwesomeIconEntry(512, 512, ""), + ["x"] = new FontAwesomeIconEntry(384, 512, ""), + ["xmark"] = new FontAwesomeIconEntry(384, 512, ""), + ["xmarks-lines"] = new FontAwesomeIconEntry(640, 512, ""), + ["x-ray"] = new FontAwesomeIconEntry(512, 512, ""), + ["y"] = new FontAwesomeIconEntry(384, 512, ""), + ["yen-sign"] = new FontAwesomeIconEntry(320, 512, ""), + ["yin-yang"] = new FontAwesomeIconEntry(512, 512, ""), + ["z"] = new FontAwesomeIconEntry(384, 512, "") + }; + + private static readonly IReadOnlyDictionary RegularIcons = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["address-book"] = new FontAwesomeIconEntry(512, 512, ""), + ["address-card"] = new FontAwesomeIconEntry(576, 512, ""), + ["bell"] = new FontAwesomeIconEntry(448, 512, ""), + ["bell-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["bookmark"] = new FontAwesomeIconEntry(384, 512, ""), + ["building"] = new FontAwesomeIconEntry(384, 512, ""), + ["calendar"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-check"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-days"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-minus"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-plus"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-xmark"] = new FontAwesomeIconEntry(448, 512, ""), + ["chart-bar"] = new FontAwesomeIconEntry(512, 512, ""), + ["chess-bishop"] = new FontAwesomeIconEntry(320, 512, ""), + ["chess-king"] = new FontAwesomeIconEntry(448, 512, ""), + ["chess-knight"] = new FontAwesomeIconEntry(448, 512, ""), + ["chess-pawn"] = new FontAwesomeIconEntry(320, 512, ""), + ["chess-queen"] = new FontAwesomeIconEntry(512, 512, ""), + ["chess-rook"] = new FontAwesomeIconEntry(448, 512, ""), + ["circle"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-check"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-dot"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-down"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-pause"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-play"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-question"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-stop"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-user"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-xmark"] = new FontAwesomeIconEntry(512, 512, ""), + ["clipboard"] = new FontAwesomeIconEntry(384, 512, ""), + ["clock"] = new FontAwesomeIconEntry(512, 512, ""), + ["clone"] = new FontAwesomeIconEntry(512, 512, ""), + ["closed-captioning"] = new FontAwesomeIconEntry(576, 512, ""), + ["comment"] = new FontAwesomeIconEntry(512, 512, ""), + ["comment-dots"] = new FontAwesomeIconEntry(512, 512, ""), + ["comments"] = new FontAwesomeIconEntry(640, 512, ""), + ["compass"] = new FontAwesomeIconEntry(512, 512, ""), + ["copy"] = new FontAwesomeIconEntry(448, 512, ""), + ["copyright"] = new FontAwesomeIconEntry(512, 512, ""), + ["credit-card"] = new FontAwesomeIconEntry(576, 512, ""), + ["envelope"] = new FontAwesomeIconEntry(512, 512, ""), + ["envelope-open"] = new FontAwesomeIconEntry(512, 512, ""), + ["eye"] = new FontAwesomeIconEntry(576, 512, ""), + ["eye-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["face-angry"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-dizzy"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-flushed"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-frown"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-frown-open"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grimace"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-beam"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-beam-sweat"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-hearts"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-squint"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-squint-tears"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-stars"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-tears"] = new FontAwesomeIconEntry(640, 512, ""), + ["face-grin-tongue"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-tongue-squint"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-tongue-wink"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-wide"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-wink"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-kiss"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-kiss-beam"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-kiss-wink-heart"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-laugh"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-laugh-beam"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-laugh-squint"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-laugh-wink"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-meh"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-meh-blank"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-rolling-eyes"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-sad-cry"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-sad-tear"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-smile"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-smile-beam"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-smile-wink"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-surprise"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-tired"] = new FontAwesomeIconEntry(512, 512, ""), + ["file"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-audio"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-code"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-excel"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-image"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-lines"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-pdf"] = new FontAwesomeIconEntry(512, 512, ""), + ["file-powerpoint"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-video"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-word"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-zipper"] = new FontAwesomeIconEntry(384, 512, ""), + ["flag"] = new FontAwesomeIconEntry(448, 512, ""), + ["floppy-disk"] = new FontAwesomeIconEntry(448, 512, ""), + ["folder"] = new FontAwesomeIconEntry(512, 512, ""), + ["folder-closed"] = new FontAwesomeIconEntry(512, 512, ""), + ["folder-open"] = new FontAwesomeIconEntry(576, 512, ""), + ["font-awesome"] = new FontAwesomeIconEntry(512, 512, ""), + ["futbol"] = new FontAwesomeIconEntry(512, 512, ""), + ["gem"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-back-fist"] = new FontAwesomeIconEntry(448, 512, ""), + ["hand-lizard"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-peace"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-point-down"] = new FontAwesomeIconEntry(384, 512, ""), + ["hand-pointer"] = new FontAwesomeIconEntry(448, 512, ""), + ["hand-point-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-point-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-point-up"] = new FontAwesomeIconEntry(384, 512, ""), + ["hand-scissors"] = new FontAwesomeIconEntry(512, 512, ""), + ["handshake"] = new FontAwesomeIconEntry(640, 512, ""), + ["hand-spock"] = new FontAwesomeIconEntry(576, 512, ""), + ["hard-drive"] = new FontAwesomeIconEntry(512, 512, ""), + ["heart"] = new FontAwesomeIconEntry(512, 512, ""), + ["hospital"] = new FontAwesomeIconEntry(640, 512, ""), + ["hourglass"] = new FontAwesomeIconEntry(384, 512, ""), + ["hourglass-half"] = new FontAwesomeIconEntry(384, 512, ""), + ["id-badge"] = new FontAwesomeIconEntry(384, 512, ""), + ["id-card"] = new FontAwesomeIconEntry(576, 512, ""), + ["image"] = new FontAwesomeIconEntry(512, 512, ""), + ["images"] = new FontAwesomeIconEntry(576, 512, ""), + ["keyboard"] = new FontAwesomeIconEntry(576, 512, ""), + ["lemon"] = new FontAwesomeIconEntry(448, 512, ""), + ["life-ring"] = new FontAwesomeIconEntry(512, 512, ""), + ["lightbulb"] = new FontAwesomeIconEntry(384, 512, ""), + ["map"] = new FontAwesomeIconEntry(576, 512, ""), + ["message"] = new FontAwesomeIconEntry(512, 512, ""), + ["money-bill-1"] = new FontAwesomeIconEntry(576, 512, ""), + ["moon"] = new FontAwesomeIconEntry(384, 512, ""), + ["newspaper"] = new FontAwesomeIconEntry(512, 512, ""), + ["notdef"] = new FontAwesomeIconEntry(384, 512, ""), + ["note-sticky"] = new FontAwesomeIconEntry(448, 512, ""), + ["object-group"] = new FontAwesomeIconEntry(576, 512, ""), + ["object-ungroup"] = new FontAwesomeIconEntry(640, 512, ""), + ["paper-plane"] = new FontAwesomeIconEntry(512, 512, ""), + ["paste"] = new FontAwesomeIconEntry(512, 512, ""), + ["pen-to-square"] = new FontAwesomeIconEntry(512, 512, ""), + ["rectangle-list"] = new FontAwesomeIconEntry(576, 512, ""), + ["rectangle-xmark"] = new FontAwesomeIconEntry(512, 512, ""), + ["registered"] = new FontAwesomeIconEntry(512, 512, ""), + ["share-from-square"] = new FontAwesomeIconEntry(576, 512, ""), + ["snowflake"] = new FontAwesomeIconEntry(448, 512, ""), + ["square"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-caret-down"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-caret-left"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-caret-right"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-caret-up"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-check"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-full"] = new FontAwesomeIconEntry(512, 512, ""), + ["square-minus"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-plus"] = new FontAwesomeIconEntry(448, 512, ""), + ["star"] = new FontAwesomeIconEntry(576, 512, ""), + ["star-half"] = new FontAwesomeIconEntry(576, 512, ""), + ["star-half-stroke"] = new FontAwesomeIconEntry(576, 512, ""), + ["sun"] = new FontAwesomeIconEntry(512, 512, ""), + ["thumbs-down"] = new FontAwesomeIconEntry(512, 512, ""), + ["thumbs-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["trash-can"] = new FontAwesomeIconEntry(448, 512, ""), + ["user"] = new FontAwesomeIconEntry(448, 512, ""), + ["window-maximize"] = new FontAwesomeIconEntry(512, 512, ""), + ["window-minimize"] = new FontAwesomeIconEntry(512, 512, ""), + ["window-restore"] = new FontAwesomeIconEntry(512, 512, "") + }; + + private static readonly IReadOnlyDictionary BrandsIcons = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["42-group"] = new FontAwesomeIconEntry(640, 512, ""), + ["500px"] = new FontAwesomeIconEntry(448, 512, ""), + ["accessible-icon"] = new FontAwesomeIconEntry(448, 512, ""), + ["accusoft"] = new FontAwesomeIconEntry(640, 512, ""), + ["adn"] = new FontAwesomeIconEntry(496, 512, ""), + ["adversal"] = new FontAwesomeIconEntry(512, 512, ""), + ["affiliatetheme"] = new FontAwesomeIconEntry(512, 512, ""), + ["airbnb"] = new FontAwesomeIconEntry(448, 512, ""), + ["algolia"] = new FontAwesomeIconEntry(512, 512, ""), + ["alipay"] = new FontAwesomeIconEntry(448, 512, ""), + ["amazon"] = new FontAwesomeIconEntry(448, 512, ""), + ["amazon-pay"] = new FontAwesomeIconEntry(640, 512, ""), + ["amilia"] = new FontAwesomeIconEntry(448, 512, ""), + ["android"] = new FontAwesomeIconEntry(576, 512, ""), + ["angellist"] = new FontAwesomeIconEntry(448, 512, ""), + ["angrycreative"] = new FontAwesomeIconEntry(640, 512, ""), + ["angular"] = new FontAwesomeIconEntry(448, 512, ""), + ["apper"] = new FontAwesomeIconEntry(640, 512, ""), + ["apple"] = new FontAwesomeIconEntry(384, 512, ""), + ["apple-pay"] = new FontAwesomeIconEntry(640, 512, ""), + ["app-store"] = new FontAwesomeIconEntry(512, 512, ""), + ["app-store-ios"] = new FontAwesomeIconEntry(448, 512, ""), + ["artstation"] = new FontAwesomeIconEntry(512, 512, ""), + ["asymmetrik"] = new FontAwesomeIconEntry(576, 512, ""), + ["atlassian"] = new FontAwesomeIconEntry(512, 512, ""), + ["audible"] = new FontAwesomeIconEntry(640, 512, ""), + ["autoprefixer"] = new FontAwesomeIconEntry(640, 512, ""), + ["avianex"] = new FontAwesomeIconEntry(512, 512, ""), + ["aviato"] = new FontAwesomeIconEntry(640, 512, ""), + ["aws"] = new FontAwesomeIconEntry(640, 512, ""), + ["bandcamp"] = new FontAwesomeIconEntry(512, 512, ""), + ["battle-net"] = new FontAwesomeIconEntry(512, 512, ""), + ["behance"] = new FontAwesomeIconEntry(576, 512, ""), + ["bilibili"] = new FontAwesomeIconEntry(512, 512, ""), + ["bimobject"] = new FontAwesomeIconEntry(448, 512, ""), + ["bitbucket"] = new FontAwesomeIconEntry(512, 512, ""), + ["bitcoin"] = new FontAwesomeIconEntry(512, 512, ""), + ["bity"] = new FontAwesomeIconEntry(496, 512, ""), + ["blackberry"] = new FontAwesomeIconEntry(512, 512, ""), + ["black-tie"] = new FontAwesomeIconEntry(448, 512, ""), + ["blogger"] = new FontAwesomeIconEntry(448, 512, ""), + ["blogger-b"] = new FontAwesomeIconEntry(448, 512, ""), + ["bluesky"] = new FontAwesomeIconEntry(512, 512, ""), + ["bluetooth"] = new FontAwesomeIconEntry(448, 512, ""), + ["bluetooth-b"] = new FontAwesomeIconEntry(320, 512, ""), + ["bootstrap"] = new FontAwesomeIconEntry(576, 512, ""), + ["bots"] = new FontAwesomeIconEntry(640, 512, ""), + ["brave"] = new FontAwesomeIconEntry(448, 512, ""), + ["brave-reverse"] = new FontAwesomeIconEntry(448, 512, ""), + ["btc"] = new FontAwesomeIconEntry(384, 512, ""), + ["buffer"] = new FontAwesomeIconEntry(448, 512, ""), + ["buromobelexperte"] = new FontAwesomeIconEntry(448, 512, ""), + ["buy-n-large"] = new FontAwesomeIconEntry(576, 512, ""), + ["buysellads"] = new FontAwesomeIconEntry(448, 512, ""), + ["canadian-maple-leaf"] = new FontAwesomeIconEntry(512, 512, ""), + ["cc-amazon-pay"] = new FontAwesomeIconEntry(576, 512, ""), + ["cc-amex"] = new FontAwesomeIconEntry(576, 512, ""), + ["cc-apple-pay"] = new FontAwesomeIconEntry(576, 512, ""), + ["cc-diners-club"] = new FontAwesomeIconEntry(576, 512, ""), + ["cc-discover"] = new FontAwesomeIconEntry(576, 512, ""), + ["cc-jcb"] = new FontAwesomeIconEntry(576, 512, ""), + ["cc-mastercard"] = new FontAwesomeIconEntry(576, 512, ""), + ["cc-paypal"] = new FontAwesomeIconEntry(576, 512, ""), + ["cc-stripe"] = new FontAwesomeIconEntry(576, 512, ""), + ["cc-visa"] = new FontAwesomeIconEntry(576, 512, ""), + ["centercode"] = new FontAwesomeIconEntry(512, 512, ""), + ["centos"] = new FontAwesomeIconEntry(448, 512, ""), + ["chrome"] = new FontAwesomeIconEntry(512, 512, ""), + ["chromecast"] = new FontAwesomeIconEntry(512, 512, ""), + ["cloudflare"] = new FontAwesomeIconEntry(640, 512, ""), + ["cloudscale"] = new FontAwesomeIconEntry(448, 512, ""), + ["cloudsmith"] = new FontAwesomeIconEntry(512, 512, ""), + ["cloudversify"] = new FontAwesomeIconEntry(616, 512, ""), + ["cmplid"] = new FontAwesomeIconEntry(640, 512, ""), + ["codepen"] = new FontAwesomeIconEntry(512, 512, ""), + ["codiepie"] = new FontAwesomeIconEntry(472, 512, ""), + ["confluence"] = new FontAwesomeIconEntry(512, 512, ""), + ["connectdevelop"] = new FontAwesomeIconEntry(576, 512, ""), + ["contao"] = new FontAwesomeIconEntry(512, 512, ""), + ["cotton-bureau"] = new FontAwesomeIconEntry(512, 512, ""), + ["cpanel"] = new FontAwesomeIconEntry(640, 512, ""), + ["creative-commons"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-by"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-nc"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-nc-eu"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-nc-jp"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-nd"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-pd"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-pd-alt"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-remix"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-sa"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-sampling"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-sampling-plus"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-share"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-zero"] = new FontAwesomeIconEntry(496, 512, ""), + ["critical-role"] = new FontAwesomeIconEntry(448, 512, ""), + ["css"] = new FontAwesomeIconEntry(448, 512, ""), + ["css3"] = new FontAwesomeIconEntry(512, 512, ""), + ["css3-alt"] = new FontAwesomeIconEntry(384, 512, ""), + ["cuttlefish"] = new FontAwesomeIconEntry(440, 512, ""), + ["dailymotion"] = new FontAwesomeIconEntry(448, 512, ""), + ["d-and-d"] = new FontAwesomeIconEntry(576, 512, ""), + ["d-and-d-beyond"] = new FontAwesomeIconEntry(640, 512, ""), + ["dart-lang"] = new FontAwesomeIconEntry(512, 512, ""), + ["dashcube"] = new FontAwesomeIconEntry(448, 512, ""), + ["debian"] = new FontAwesomeIconEntry(448, 512, ""), + ["deezer"] = new FontAwesomeIconEntry(576, 512, ""), + ["delicious"] = new FontAwesomeIconEntry(448, 512, ""), + ["deploydog"] = new FontAwesomeIconEntry(512, 512, ""), + ["deskpro"] = new FontAwesomeIconEntry(480, 512, ""), + ["dev"] = new FontAwesomeIconEntry(448, 512, ""), + ["deviantart"] = new FontAwesomeIconEntry(320, 512, ""), + ["dhl"] = new FontAwesomeIconEntry(640, 512, ""), + ["diaspora"] = new FontAwesomeIconEntry(512, 512, ""), + ["digg"] = new FontAwesomeIconEntry(512, 512, ""), + ["digital-ocean"] = new FontAwesomeIconEntry(512, 512, ""), + ["discord"] = new FontAwesomeIconEntry(640, 512, ""), + ["discourse"] = new FontAwesomeIconEntry(448, 512, ""), + ["dochub"] = new FontAwesomeIconEntry(416, 512, ""), + ["docker"] = new FontAwesomeIconEntry(640, 512, ""), + ["draft2digital"] = new FontAwesomeIconEntry(480, 512, ""), + ["dribbble"] = new FontAwesomeIconEntry(512, 512, ""), + ["dropbox"] = new FontAwesomeIconEntry(528, 512, ""), + ["drupal"] = new FontAwesomeIconEntry(448, 512, ""), + ["dyalog"] = new FontAwesomeIconEntry(416, 512, ""), + ["earlybirds"] = new FontAwesomeIconEntry(480, 512, ""), + ["ebay"] = new FontAwesomeIconEntry(640, 512, ""), + ["edge"] = new FontAwesomeIconEntry(512, 512, ""), + ["edge-legacy"] = new FontAwesomeIconEntry(512, 512, ""), + ["elementor"] = new FontAwesomeIconEntry(512, 512, ""), + ["ello"] = new FontAwesomeIconEntry(496, 512, ""), + ["ember"] = new FontAwesomeIconEntry(640, 512, ""), + ["empire"] = new FontAwesomeIconEntry(496, 512, ""), + ["envira"] = new FontAwesomeIconEntry(448, 512, ""), + ["erlang"] = new FontAwesomeIconEntry(640, 512, ""), + ["ethereum"] = new FontAwesomeIconEntry(320, 512, ""), + ["etsy"] = new FontAwesomeIconEntry(384, 512, ""), + ["evernote"] = new FontAwesomeIconEntry(384, 512, ""), + ["expeditedssl"] = new FontAwesomeIconEntry(496, 512, ""), + ["facebook"] = new FontAwesomeIconEntry(512, 512, ""), + ["facebook-f"] = new FontAwesomeIconEntry(320, 512, ""), + ["facebook-messenger"] = new FontAwesomeIconEntry(512, 512, ""), + ["fantasy-flight-games"] = new FontAwesomeIconEntry(512, 512, ""), + ["fedex"] = new FontAwesomeIconEntry(640, 512, ""), + ["fedora"] = new FontAwesomeIconEntry(448, 512, ""), + ["figma"] = new FontAwesomeIconEntry(384, 512, ""), + ["files-pinwheel"] = new FontAwesomeIconEntry(512, 512, ""), + ["firefox"] = new FontAwesomeIconEntry(512, 512, ""), + ["firefox-browser"] = new FontAwesomeIconEntry(512, 512, ""), + ["firstdraft"] = new FontAwesomeIconEntry(384, 512, ""), + ["first-order"] = new FontAwesomeIconEntry(448, 512, ""), + ["first-order-alt"] = new FontAwesomeIconEntry(496, 512, ""), + ["flickr"] = new FontAwesomeIconEntry(448, 512, ""), + ["flipboard"] = new FontAwesomeIconEntry(448, 512, ""), + ["flutter"] = new FontAwesomeIconEntry(448, 512, ""), + ["fly"] = new FontAwesomeIconEntry(384, 512, ""), + ["font-awesome"] = new FontAwesomeIconEntry(512, 512, ""), + ["fonticons"] = new FontAwesomeIconEntry(448, 512, ""), + ["fonticons-fi"] = new FontAwesomeIconEntry(384, 512, ""), + ["fort-awesome"] = new FontAwesomeIconEntry(512, 512, ""), + ["fort-awesome-alt"] = new FontAwesomeIconEntry(512, 512, ""), + ["forumbee"] = new FontAwesomeIconEntry(448, 512, ""), + ["foursquare"] = new FontAwesomeIconEntry(368, 512, ""), + ["freebsd"] = new FontAwesomeIconEntry(448, 512, ""), + ["free-code-camp"] = new FontAwesomeIconEntry(576, 512, ""), + ["fulcrum"] = new FontAwesomeIconEntry(320, 512, ""), + ["galactic-republic"] = new FontAwesomeIconEntry(496, 512, ""), + ["galactic-senate"] = new FontAwesomeIconEntry(512, 512, ""), + ["get-pocket"] = new FontAwesomeIconEntry(448, 512, ""), + ["gg"] = new FontAwesomeIconEntry(512, 512, ""), + ["gg-circle"] = new FontAwesomeIconEntry(512, 512, ""), + ["git"] = new FontAwesomeIconEntry(512, 512, ""), + ["git-alt"] = new FontAwesomeIconEntry(448, 512, ""), + ["github"] = new FontAwesomeIconEntry(496, 512, ""), + ["github-alt"] = new FontAwesomeIconEntry(480, 512, ""), + ["gitkraken"] = new FontAwesomeIconEntry(592, 512, ""), + ["gitlab"] = new FontAwesomeIconEntry(512, 512, ""), + ["gitter"] = new FontAwesomeIconEntry(384, 512, ""), + ["glide"] = new FontAwesomeIconEntry(448, 512, ""), + ["glide-g"] = new FontAwesomeIconEntry(448, 512, ""), + ["gofore"] = new FontAwesomeIconEntry(400, 512, ""), + ["golang"] = new FontAwesomeIconEntry(640, 512, ""), + ["goodreads"] = new FontAwesomeIconEntry(448, 512, ""), + ["goodreads-g"] = new FontAwesomeIconEntry(384, 512, ""), + ["google"] = new FontAwesomeIconEntry(488, 512, ""), + ["google-drive"] = new FontAwesomeIconEntry(512, 512, ""), + ["google-pay"] = new FontAwesomeIconEntry(640, 512, ""), + ["google-play"] = new FontAwesomeIconEntry(512, 512, ""), + ["google-plus"] = new FontAwesomeIconEntry(512, 512, ""), + ["google-plus-g"] = new FontAwesomeIconEntry(640, 512, ""), + ["google-scholar"] = new FontAwesomeIconEntry(512, 512, ""), + ["google-wallet"] = new FontAwesomeIconEntry(448, 512, ""), + ["gratipay"] = new FontAwesomeIconEntry(496, 512, ""), + ["grav"] = new FontAwesomeIconEntry(512, 512, ""), + ["gripfire"] = new FontAwesomeIconEntry(384, 512, ""), + ["grunt"] = new FontAwesomeIconEntry(384, 512, ""), + ["guilded"] = new FontAwesomeIconEntry(448, 512, ""), + ["gulp"] = new FontAwesomeIconEntry(256, 512, ""), + ["hacker-news"] = new FontAwesomeIconEntry(448, 512, ""), + ["hackerrank"] = new FontAwesomeIconEntry(512, 512, ""), + ["hashnode"] = new FontAwesomeIconEntry(512, 512, ""), + ["hips"] = new FontAwesomeIconEntry(640, 512, ""), + ["hire-a-helper"] = new FontAwesomeIconEntry(512, 512, ""), + ["hive"] = new FontAwesomeIconEntry(512, 512, ""), + ["hooli"] = new FontAwesomeIconEntry(640, 512, ""), + ["hornbill"] = new FontAwesomeIconEntry(512, 512, ""), + ["hotjar"] = new FontAwesomeIconEntry(512, 512, ""), + ["houzz"] = new FontAwesomeIconEntry(448, 512, ""), + ["html5"] = new FontAwesomeIconEntry(384, 512, ""), + ["hubspot"] = new FontAwesomeIconEntry(512, 512, ""), + ["ideal"] = new FontAwesomeIconEntry(576, 512, ""), + ["imdb"] = new FontAwesomeIconEntry(448, 512, ""), + ["instagram"] = new FontAwesomeIconEntry(448, 512, ""), + ["instalod"] = new FontAwesomeIconEntry(512, 512, ""), + ["intercom"] = new FontAwesomeIconEntry(448, 512, ""), + ["internet-explorer"] = new FontAwesomeIconEntry(512, 512, ""), + ["invision"] = new FontAwesomeIconEntry(448, 512, ""), + ["ioxhost"] = new FontAwesomeIconEntry(640, 512, ""), + ["itch-io"] = new FontAwesomeIconEntry(512, 512, ""), + ["itunes"] = new FontAwesomeIconEntry(448, 512, ""), + ["itunes-note"] = new FontAwesomeIconEntry(384, 512, ""), + ["java"] = new FontAwesomeIconEntry(384, 512, ""), + ["jedi-order"] = new FontAwesomeIconEntry(448, 512, ""), + ["jenkins"] = new FontAwesomeIconEntry(512, 512, ""), + ["jira"] = new FontAwesomeIconEntry(496, 512, ""), + ["joget"] = new FontAwesomeIconEntry(496, 512, ""), + ["joomla"] = new FontAwesomeIconEntry(448, 512, ""), + ["js"] = new FontAwesomeIconEntry(448, 512, ""), + ["jsfiddle"] = new FontAwesomeIconEntry(576, 512, ""), + ["jxl"] = new FontAwesomeIconEntry(448, 512, ""), + ["kaggle"] = new FontAwesomeIconEntry(320, 512, ""), + ["keybase"] = new FontAwesomeIconEntry(448, 512, ""), + ["keycdn"] = new FontAwesomeIconEntry(512, 512, ""), + ["kickstarter"] = new FontAwesomeIconEntry(448, 512, ""), + ["kickstarter-k"] = new FontAwesomeIconEntry(448, 512, ""), + ["korvue"] = new FontAwesomeIconEntry(446, 512, ""), + ["laravel"] = new FontAwesomeIconEntry(512, 512, ""), + ["lastfm"] = new FontAwesomeIconEntry(512, 512, ""), + ["leanpub"] = new FontAwesomeIconEntry(576, 512, ""), + ["less"] = new FontAwesomeIconEntry(640, 512, ""), + ["letterboxd"] = new FontAwesomeIconEntry(640, 512, ""), + ["line"] = new FontAwesomeIconEntry(512, 512, ""), + ["linkedin"] = new FontAwesomeIconEntry(448, 512, ""), + ["linkedin-in"] = new FontAwesomeIconEntry(448, 512, ""), + ["linode"] = new FontAwesomeIconEntry(448, 512, ""), + ["linux"] = new FontAwesomeIconEntry(448, 512, ""), + ["lyft"] = new FontAwesomeIconEntry(512, 512, ""), + ["magento"] = new FontAwesomeIconEntry(448, 512, ""), + ["mailchimp"] = new FontAwesomeIconEntry(448, 512, ""), + ["mandalorian"] = new FontAwesomeIconEntry(448, 512, ""), + ["markdown"] = new FontAwesomeIconEntry(640, 512, ""), + ["mastodon"] = new FontAwesomeIconEntry(448, 512, ""), + ["maxcdn"] = new FontAwesomeIconEntry(512, 512, ""), + ["mdb"] = new FontAwesomeIconEntry(576, 512, ""), + ["medapps"] = new FontAwesomeIconEntry(320, 512, ""), + ["medium"] = new FontAwesomeIconEntry(640, 512, ""), + ["medrt"] = new FontAwesomeIconEntry(544, 512, ""), + ["meetup"] = new FontAwesomeIconEntry(512, 512, ""), + ["megaport"] = new FontAwesomeIconEntry(496, 512, ""), + ["mendeley"] = new FontAwesomeIconEntry(640, 512, ""), + ["meta"] = new FontAwesomeIconEntry(640, 512, ""), + ["microblog"] = new FontAwesomeIconEntry(448, 512, ""), + ["microsoft"] = new FontAwesomeIconEntry(448, 512, ""), + ["mintbit"] = new FontAwesomeIconEntry(512, 512, ""), + ["mix"] = new FontAwesomeIconEntry(448, 512, ""), + ["mixcloud"] = new FontAwesomeIconEntry(640, 512, ""), + ["mixer"] = new FontAwesomeIconEntry(512, 512, ""), + ["mizuni"] = new FontAwesomeIconEntry(496, 512, ""), + ["modx"] = new FontAwesomeIconEntry(448, 512, ""), + ["monero"] = new FontAwesomeIconEntry(496, 512, ""), + ["napster"] = new FontAwesomeIconEntry(496, 512, ""), + ["neos"] = new FontAwesomeIconEntry(512, 512, ""), + ["nfc-directional"] = new FontAwesomeIconEntry(512, 512, ""), + ["nfc-symbol"] = new FontAwesomeIconEntry(576, 512, ""), + ["nimblr"] = new FontAwesomeIconEntry(384, 512, ""), + ["node"] = new FontAwesomeIconEntry(640, 512, ""), + ["node-js"] = new FontAwesomeIconEntry(448, 512, ""), + ["npm"] = new FontAwesomeIconEntry(576, 512, ""), + ["ns8"] = new FontAwesomeIconEntry(640, 512, ""), + ["nutritionix"] = new FontAwesomeIconEntry(400, 512, ""), + ["octopus-deploy"] = new FontAwesomeIconEntry(512, 512, ""), + ["odnoklassniki"] = new FontAwesomeIconEntry(320, 512, ""), + ["odysee"] = new FontAwesomeIconEntry(512, 512, ""), + ["old-republic"] = new FontAwesomeIconEntry(496, 512, ""), + ["opencart"] = new FontAwesomeIconEntry(640, 512, ""), + ["openid"] = new FontAwesomeIconEntry(448, 512, ""), + ["opensuse"] = new FontAwesomeIconEntry(640, 512, ""), + ["opera"] = new FontAwesomeIconEntry(496, 512, ""), + ["optin-monster"] = new FontAwesomeIconEntry(576, 512, ""), + ["orcid"] = new FontAwesomeIconEntry(512, 512, ""), + ["osi"] = new FontAwesomeIconEntry(512, 512, ""), + ["padlet"] = new FontAwesomeIconEntry(640, 512, ""), + ["page4"] = new FontAwesomeIconEntry(496, 512, ""), + ["pagelines"] = new FontAwesomeIconEntry(384, 512, ""), + ["palfed"] = new FontAwesomeIconEntry(576, 512, ""), + ["patreon"] = new FontAwesomeIconEntry(512, 512, ""), + ["paypal"] = new FontAwesomeIconEntry(384, 512, ""), + ["perbyte"] = new FontAwesomeIconEntry(448, 512, ""), + ["periscope"] = new FontAwesomeIconEntry(448, 512, ""), + ["phabricator"] = new FontAwesomeIconEntry(496, 512, ""), + ["phoenix-framework"] = new FontAwesomeIconEntry(640, 512, ""), + ["phoenix-squadron"] = new FontAwesomeIconEntry(512, 512, ""), + ["php"] = new FontAwesomeIconEntry(640, 512, ""), + ["pied-piper"] = new FontAwesomeIconEntry(480, 512, ""), + ["pied-piper-alt"] = new FontAwesomeIconEntry(576, 512, ""), + ["pied-piper-hat"] = new FontAwesomeIconEntry(640, 512, ""), + ["pied-piper-pp"] = new FontAwesomeIconEntry(448, 512, ""), + ["pinterest"] = new FontAwesomeIconEntry(496, 512, ""), + ["pinterest-p"] = new FontAwesomeIconEntry(384, 512, ""), + ["pix"] = new FontAwesomeIconEntry(512, 512, ""), + ["pixiv"] = new FontAwesomeIconEntry(448, 512, ""), + ["playstation"] = new FontAwesomeIconEntry(576, 512, ""), + ["product-hunt"] = new FontAwesomeIconEntry(512, 512, ""), + ["pushed"] = new FontAwesomeIconEntry(432, 512, ""), + ["python"] = new FontAwesomeIconEntry(448, 512, ""), + ["qq"] = new FontAwesomeIconEntry(448, 512, ""), + ["quinscape"] = new FontAwesomeIconEntry(512, 512, ""), + ["quora"] = new FontAwesomeIconEntry(448, 512, ""), + ["raspberry-pi"] = new FontAwesomeIconEntry(407, 512, ""), + ["ravelry"] = new FontAwesomeIconEntry(512, 512, ""), + ["react"] = new FontAwesomeIconEntry(512, 512, ""), + ["reacteurope"] = new FontAwesomeIconEntry(576, 512, ""), + ["readme"] = new FontAwesomeIconEntry(576, 512, ""), + ["rebel"] = new FontAwesomeIconEntry(512, 512, ""), + ["reddit"] = new FontAwesomeIconEntry(512, 512, ""), + ["reddit-alien"] = new FontAwesomeIconEntry(512, 512, ""), + ["redhat"] = new FontAwesomeIconEntry(512, 512, ""), + ["red-river"] = new FontAwesomeIconEntry(448, 512, ""), + ["renren"] = new FontAwesomeIconEntry(512, 512, ""), + ["replyd"] = new FontAwesomeIconEntry(448, 512, ""), + ["researchgate"] = new FontAwesomeIconEntry(448, 512, ""), + ["resolving"] = new FontAwesomeIconEntry(496, 512, ""), + ["rev"] = new FontAwesomeIconEntry(448, 512, ""), + ["rocketchat"] = new FontAwesomeIconEntry(576, 512, ""), + ["rockrms"] = new FontAwesomeIconEntry(496, 512, ""), + ["r-project"] = new FontAwesomeIconEntry(581, 512, ""), + ["rust"] = new FontAwesomeIconEntry(512, 512, ""), + ["safari"] = new FontAwesomeIconEntry(512, 512, ""), + ["salesforce"] = new FontAwesomeIconEntry(640, 512, ""), + ["sass"] = new FontAwesomeIconEntry(640, 512, ""), + ["schlix"] = new FontAwesomeIconEntry(448, 512, ""), + ["screenpal"] = new FontAwesomeIconEntry(512, 512, ""), + ["scribd"] = new FontAwesomeIconEntry(384, 512, ""), + ["searchengin"] = new FontAwesomeIconEntry(460, 512, ""), + ["sellcast"] = new FontAwesomeIconEntry(448, 512, ""), + ["sellsy"] = new FontAwesomeIconEntry(640, 512, ""), + ["servicestack"] = new FontAwesomeIconEntry(496, 512, ""), + ["shirtsinbulk"] = new FontAwesomeIconEntry(448, 512, ""), + ["shoelace"] = new FontAwesomeIconEntry(512, 512, ""), + ["shopify"] = new FontAwesomeIconEntry(448, 512, ""), + ["shopware"] = new FontAwesomeIconEntry(512, 512, ""), + ["signal-messenger"] = new FontAwesomeIconEntry(512, 512, ""), + ["simplybuilt"] = new FontAwesomeIconEntry(512, 512, ""), + ["sistrix"] = new FontAwesomeIconEntry(448, 512, ""), + ["sith"] = new FontAwesomeIconEntry(448, 512, ""), + ["sitrox"] = new FontAwesomeIconEntry(448, 512, ""), + ["sketch"] = new FontAwesomeIconEntry(512, 512, ""), + ["skyatlas"] = new FontAwesomeIconEntry(640, 512, ""), + ["skype"] = new FontAwesomeIconEntry(448, 512, ""), + ["slack"] = new FontAwesomeIconEntry(448, 512, ""), + ["slideshare"] = new FontAwesomeIconEntry(512, 512, ""), + ["snapchat"] = new FontAwesomeIconEntry(512, 512, ""), + ["soundcloud"] = new FontAwesomeIconEntry(640, 512, ""), + ["sourcetree"] = new FontAwesomeIconEntry(448, 512, ""), + ["space-awesome"] = new FontAwesomeIconEntry(512, 512, ""), + ["speakap"] = new FontAwesomeIconEntry(448, 512, ""), + ["speaker-deck"] = new FontAwesomeIconEntry(512, 512, ""), + ["spotify"] = new FontAwesomeIconEntry(496, 512, ""), + ["square-behance"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-bluesky"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-dribbble"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-facebook"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-font-awesome"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-font-awesome-stroke"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-git"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-github"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-gitlab"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-google-plus"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-hacker-news"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-instagram"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-js"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-lastfm"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-letterboxd"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-odnoklassniki"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-pied-piper"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-pinterest"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-reddit"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-snapchat"] = new FontAwesomeIconEntry(448, 512, ""), + ["squarespace"] = new FontAwesomeIconEntry(512, 512, ""), + ["square-steam"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-threads"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-tumblr"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-twitter"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-upwork"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-viadeo"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-vimeo"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-web-awesome"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-web-awesome-stroke"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-whatsapp"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-xing"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-x-twitter"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-youtube"] = new FontAwesomeIconEntry(448, 512, ""), + ["stack-exchange"] = new FontAwesomeIconEntry(448, 512, ""), + ["stack-overflow"] = new FontAwesomeIconEntry(384, 512, ""), + ["stackpath"] = new FontAwesomeIconEntry(448, 512, ""), + ["staylinked"] = new FontAwesomeIconEntry(440, 512, ""), + ["steam"] = new FontAwesomeIconEntry(496, 512, ""), + ["steam-symbol"] = new FontAwesomeIconEntry(448, 512, ""), + ["sticker-mule"] = new FontAwesomeIconEntry(576, 512, ""), + ["strava"] = new FontAwesomeIconEntry(384, 512, ""), + ["stripe"] = new FontAwesomeIconEntry(640, 512, ""), + ["stripe-s"] = new FontAwesomeIconEntry(384, 512, ""), + ["stubber"] = new FontAwesomeIconEntry(448, 512, ""), + ["studiovinari"] = new FontAwesomeIconEntry(512, 512, ""), + ["stumbleupon"] = new FontAwesomeIconEntry(512, 512, ""), + ["stumbleupon-circle"] = new FontAwesomeIconEntry(496, 512, ""), + ["superpowers"] = new FontAwesomeIconEntry(448, 512, ""), + ["supple"] = new FontAwesomeIconEntry(640, 512, ""), + ["suse"] = new FontAwesomeIconEntry(640, 512, ""), + ["swift"] = new FontAwesomeIconEntry(448, 512, ""), + ["symfony"] = new FontAwesomeIconEntry(512, 512, ""), + ["teamspeak"] = new FontAwesomeIconEntry(576, 512, ""), + ["telegram"] = new FontAwesomeIconEntry(496, 512, ""), + ["tencent-weibo"] = new FontAwesomeIconEntry(384, 512, ""), + ["themeco"] = new FontAwesomeIconEntry(448, 512, ""), + ["themeisle"] = new FontAwesomeIconEntry(512, 512, ""), + ["the-red-yeti"] = new FontAwesomeIconEntry(512, 512, ""), + ["think-peaks"] = new FontAwesomeIconEntry(576, 512, ""), + ["threads"] = new FontAwesomeIconEntry(448, 512, ""), + ["tiktok"] = new FontAwesomeIconEntry(448, 512, ""), + ["trade-federation"] = new FontAwesomeIconEntry(496, 512, ""), + ["trello"] = new FontAwesomeIconEntry(448, 512, ""), + ["tumblr"] = new FontAwesomeIconEntry(320, 512, ""), + ["twitch"] = new FontAwesomeIconEntry(512, 512, ""), + ["twitter"] = new FontAwesomeIconEntry(512, 512, ""), + ["typo3"] = new FontAwesomeIconEntry(448, 512, ""), + ["uber"] = new FontAwesomeIconEntry(448, 512, ""), + ["ubuntu"] = new FontAwesomeIconEntry(576, 512, ""), + ["uikit"] = new FontAwesomeIconEntry(448, 512, ""), + ["umbraco"] = new FontAwesomeIconEntry(510, 512, ""), + ["uncharted"] = new FontAwesomeIconEntry(448, 512, ""), + ["uniregistry"] = new FontAwesomeIconEntry(384, 512, ""), + ["unity"] = new FontAwesomeIconEntry(448, 512, ""), + ["unsplash"] = new FontAwesomeIconEntry(448, 512, ""), + ["untappd"] = new FontAwesomeIconEntry(640, 512, ""), + ["ups"] = new FontAwesomeIconEntry(384, 512, ""), + ["upwork"] = new FontAwesomeIconEntry(641, 512, ""), + ["usb"] = new FontAwesomeIconEntry(640, 512, ""), + ["usps"] = new FontAwesomeIconEntry(576, 512, ""), + ["ussunnah"] = new FontAwesomeIconEntry(482, 512, ""), + ["vaadin"] = new FontAwesomeIconEntry(448, 512, ""), + ["viacoin"] = new FontAwesomeIconEntry(384, 512, ""), + ["viadeo"] = new FontAwesomeIconEntry(448, 512, ""), + ["viber"] = new FontAwesomeIconEntry(512, 512, ""), + ["vimeo"] = new FontAwesomeIconEntry(448, 512, ""), + ["vimeo-v"] = new FontAwesomeIconEntry(448, 512, ""), + ["vine"] = new FontAwesomeIconEntry(384, 512, ""), + ["vk"] = new FontAwesomeIconEntry(448, 512, ""), + ["vnv"] = new FontAwesomeIconEntry(640, 512, ""), + ["vuejs"] = new FontAwesomeIconEntry(448, 512, ""), + ["watchman-monitoring"] = new FontAwesomeIconEntry(512, 512, ""), + ["waze"] = new FontAwesomeIconEntry(512, 512, ""), + ["web-awesome"] = new FontAwesomeIconEntry(640, 512, ""), + ["webflow"] = new FontAwesomeIconEntry(640, 512, ""), + ["weebly"] = new FontAwesomeIconEntry(512, 512, ""), + ["weibo"] = new FontAwesomeIconEntry(512, 512, ""), + ["weixin"] = new FontAwesomeIconEntry(576, 512, ""), + ["whatsapp"] = new FontAwesomeIconEntry(448, 512, ""), + ["whmcs"] = new FontAwesomeIconEntry(448, 512, ""), + ["wikipedia-w"] = new FontAwesomeIconEntry(640, 512, ""), + ["windows"] = new FontAwesomeIconEntry(448, 512, ""), + ["wirsindhandwerk"] = new FontAwesomeIconEntry(512, 512, ""), + ["wix"] = new FontAwesomeIconEntry(640, 512, ""), + ["wizards-of-the-coast"] = new FontAwesomeIconEntry(640, 512, ""), + ["wodu"] = new FontAwesomeIconEntry(640, 512, ""), + ["wolf-pack-battalion"] = new FontAwesomeIconEntry(512, 512, ""), + ["wordpress"] = new FontAwesomeIconEntry(512, 512, ""), + ["wordpress-simple"] = new FontAwesomeIconEntry(512, 512, ""), + ["wpbeginner"] = new FontAwesomeIconEntry(512, 512, ""), + ["wpexplorer"] = new FontAwesomeIconEntry(512, 512, ""), + ["wpforms"] = new FontAwesomeIconEntry(448, 512, ""), + ["wpressr"] = new FontAwesomeIconEntry(496, 512, ""), + ["xbox"] = new FontAwesomeIconEntry(512, 512, ""), + ["xing"] = new FontAwesomeIconEntry(384, 512, ""), + ["x-twitter"] = new FontAwesomeIconEntry(512, 512, ""), + ["yahoo"] = new FontAwesomeIconEntry(512, 512, ""), + ["yammer"] = new FontAwesomeIconEntry(512, 512, ""), + ["yandex"] = new FontAwesomeIconEntry(256, 512, ""), + ["yandex-international"] = new FontAwesomeIconEntry(320, 512, ""), + ["yarn"] = new FontAwesomeIconEntry(496, 512, ""), + ["y-combinator"] = new FontAwesomeIconEntry(448, 512, ""), + ["yelp"] = new FontAwesomeIconEntry(384, 512, ""), + ["yoast"] = new FontAwesomeIconEntry(448, 512, ""), + ["youtube"] = new FontAwesomeIconEntry(576, 512, ""), + ["zhihu"] = new FontAwesomeIconEntry(640, 512, "") + }; + + /// + /// Retrieves the icon entry for the specified icon name and variant. + /// + public static FontAwesomeIconEntry? GetIcon(string name, FontAwesomeIconVariant variant) + { + var dictionary = variant switch + { + FontAwesomeIconVariant.Solid => SolidIcons, + FontAwesomeIconVariant.Regular => RegularIcons, + FontAwesomeIconVariant.Brands => BrandsIcons, + _ => SolidIcons + }; + + return dictionary.TryGetValue(name, out var entry) ? entry : null; + } + + /// + /// Gets all available icon names for a specific variant. + /// + public static IEnumerable GetAvailableIcons(FontAwesomeIconVariant variant) + { + return variant switch + { + FontAwesomeIconVariant.Solid => SolidIcons.Keys, + FontAwesomeIconVariant.Regular => RegularIcons.Keys, + FontAwesomeIconVariant.Brands => BrandsIcons.Keys, + _ => SolidIcons.Keys + }; + } + + /// + /// Checks whether an icon with the specified name exists in the given variant. + /// + public static bool IconExists(string name, FontAwesomeIconVariant variant) + { + return variant switch + { + FontAwesomeIconVariant.Solid => SolidIcons.ContainsKey(name), + FontAwesomeIconVariant.Regular => RegularIcons.ContainsKey(name), + FontAwesomeIconVariant.Brands => BrandsIcons.ContainsKey(name), + _ => SolidIcons.ContainsKey(name) + }; + } + + /// + /// Gets the total number of available icons across all variants. + /// + public static int TotalIconCount => SolidIcons.Count + RegularIcons.Count + BrandsIcons.Count; + + /// + /// Gets the number of Solid icons. + /// + public static int SolidIconCount => SolidIcons.Count; + + /// + /// Gets the number of Regular icons. + /// + public static int RegularIconCount => RegularIcons.Count; + + /// + /// Gets the number of Brands icons. + /// + public static int BrandsIconCount => BrandsIcons.Count; +} diff --git a/src/BlazorBlueprint.Icons.FontAwesome/GenerateIconData.ps1 b/src/BlazorBlueprint.Icons.FontAwesome/GenerateIconData.ps1 new file mode 100644 index 000000000..954580025 --- /dev/null +++ b/src/BlazorBlueprint.Icons.FontAwesome/GenerateIconData.ps1 @@ -0,0 +1,198 @@ +# PowerShell script to convert Iconify Font Awesome 6 JSON sets to C# dictionary code. +# Font Awesome Free has 3 variants distributed as separate Iconify icon sets: +# - fa6-solid.json -> FontAwesomeIconVariant.Solid +# - fa6-regular.json -> FontAwesomeIconVariant.Regular +# - fa6-brands.json -> FontAwesomeIconVariant.Brands +# +# Drop the three JSON files (from the @iconify-json/fa6-* npm packages, or +# https://github.com/iconify/icon-sets/tree/master/json) into +# tools/icon-generation/data/ before running this script. + +$dataRoot = Join-Path $PSScriptRoot "..\..\tools\icon-generation\data" +$outputPath = Join-Path $PSScriptRoot "Data\FontAwesomeIconData.cs" + +$variantFiles = [ordered]@{ + "Solid" = "fa6-solid.json" + "Regular" = "fa6-regular.json" + "Brands" = "fa6-brands.json" +} + +# Load each Iconify set and collect (name -> entry { width, height, body }). +function Read-IconifySet { + param([string]$jsonPath) + + if (!(Test-Path $jsonPath)) { + Write-Warning "Missing icon set: $jsonPath - this variant will be emitted as empty." + return @{ Icons = @{}; Count = 0 } + } + + $json = Get-Content -Path $jsonPath -Raw | ConvertFrom-Json + + # Iconify JSON exposes default dimensions at the top level; individual icons + # can override either with their own width/height fields. + $defaultWidth = if ($json.width) { [int]$json.width } else { 512 } + $defaultHeight = if ($json.height) { [int]$json.height } else { 512 } + + $icons = @{} + foreach ($prop in $json.icons.PSObject.Properties) { + $name = $prop.Name + $icon = $prop.Value + + $w = if ($icon.PSObject.Properties.Name -contains 'width' -and $icon.width) { [int]$icon.width } else { $defaultWidth } + $h = if ($icon.PSObject.Properties.Name -contains 'height' -and $icon.height) { [int]$icon.height } else { $defaultHeight } + + $icons[$name] = [pscustomobject]@{ + Width = $w + Height = $h + Body = $icon.body + } + } + + return @{ Icons = $icons; Count = $icons.Count } +} + +# Ensure the Data directory exists. +$dataDir = Join-Path $PSScriptRoot "Data" +if (!(Test-Path $dataDir)) { + New-Item -ItemType Directory -Path $dataDir | Out-Null +} + +# Read all three sets. +$variantData = [ordered]@{} +foreach ($variant in $variantFiles.Keys) { + $jsonPath = Join-Path $dataRoot $variantFiles[$variant] + Write-Host "Reading $variant from $jsonPath..." + $variantData[$variant] = Read-IconifySet -jsonPath $jsonPath + Write-Host " -> $($variantData[$variant].Count) icons" +} + +$totalCount = 0 +foreach ($v in $variantData.Values) { $totalCount += $v.Count } + +# Emit one dictionary block per variant. +function Write-IconDictionary { + param( + [System.Text.StringBuilder]$sb, + [hashtable]$icons, + [string]$indent + ) + + $sortedIcons = $icons.GetEnumerator() | Sort-Object Name + $count = $sortedIcons.Count + $i = 0 + + foreach ($entry in $sortedIcons) { + $iconName = $entry.Name + $icon = $entry.Value + + # Escape backslashes and double quotes for C# verbatim-free string literals. + $escapedBody = $icon.Body -replace '\\', '\\' -replace '"', '\"' + + $comma = if ($i -eq ($count - 1)) { "" } else { "," } + [void]$sb.AppendLine("$indent[`"$iconName`"] = new FontAwesomeIconEntry($($icon.Width), $($icon.Height), `"$escapedBody`")$comma") + $i++ + } +} + +$sb = New-Object System.Text.StringBuilder +[void]$sb.AppendLine("// This file is auto-generated. Do not edit manually.") +[void]$sb.AppendLine("// Generated from fa6-solid.json, fa6-regular.json, fa6-brands.json on $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") +[void]$sb.AppendLine("") +[void]$sb.AppendLine("namespace BlazorBlueprint.Icons.FontAwesome.Data;") +[void]$sb.AppendLine("") +[void]$sb.AppendLine("/// ") +[void]$sb.AppendLine("/// Icon variant for Font Awesome Free.") +[void]$sb.AppendLine("/// ") +[void]$sb.AppendLine("public enum FontAwesomeIconVariant") +[void]$sb.AppendLine("{") +[void]$sb.AppendLine(" /// Solid variant (filled glyphs, the most common Font Awesome style)") +[void]$sb.AppendLine(" Solid,") +[void]$sb.AppendLine("") +[void]$sb.AppendLine(" /// Regular variant (outline glyphs, fewer icons available in the Free tier)") +[void]$sb.AppendLine(" Regular,") +[void]$sb.AppendLine("") +[void]$sb.AppendLine(" /// Brands variant (logos for third-party services and products)") +[void]$sb.AppendLine(" Brands") +[void]$sb.AppendLine("}") +[void]$sb.AppendLine("") +[void]$sb.AppendLine("/// ") +[void]$sb.AppendLine("/// A single Font Awesome icon entry: SVG body plus intrinsic dimensions used to build the viewBox.") +[void]$sb.AppendLine("/// ") +[void]$sb.AppendLine("public sealed record FontAwesomeIconEntry(int Width, int Height, string Body);") +[void]$sb.AppendLine("") +[void]$sb.AppendLine("/// ") +[void]$sb.AppendLine("/// Provides access to Font Awesome Free SVG data.") +[void]$sb.AppendLine("/// Contains $totalCount total icons across 3 variants.") +[void]$sb.AppendLine("/// ") +[void]$sb.AppendLine("public static class FontAwesomeIconData") +[void]$sb.AppendLine("{") + +foreach ($variant in $variantData.Keys) { + $fieldName = "${variant}Icons" + [void]$sb.AppendLine(" private static readonly IReadOnlyDictionary $fieldName = new Dictionary(StringComparer.OrdinalIgnoreCase)") + [void]$sb.AppendLine(" {") + Write-IconDictionary -sb $sb -icons $variantData[$variant].Icons -indent " " + [void]$sb.AppendLine(" };") + [void]$sb.AppendLine("") +} + +[void]$sb.AppendLine(" /// ") +[void]$sb.AppendLine(" /// Retrieves the icon entry for the specified icon name and variant.") +[void]$sb.AppendLine(" /// ") +[void]$sb.AppendLine(" public static FontAwesomeIconEntry? GetIcon(string name, FontAwesomeIconVariant variant)") +[void]$sb.AppendLine(" {") +[void]$sb.AppendLine(" var dictionary = variant switch") +[void]$sb.AppendLine(" {") +[void]$sb.AppendLine(" FontAwesomeIconVariant.Solid => SolidIcons,") +[void]$sb.AppendLine(" FontAwesomeIconVariant.Regular => RegularIcons,") +[void]$sb.AppendLine(" FontAwesomeIconVariant.Brands => BrandsIcons,") +[void]$sb.AppendLine(" _ => SolidIcons") +[void]$sb.AppendLine(" };") +[void]$sb.AppendLine("") +[void]$sb.AppendLine(" return dictionary.TryGetValue(name, out var entry) ? entry : null;") +[void]$sb.AppendLine(" }") +[void]$sb.AppendLine("") +[void]$sb.AppendLine(" /// Gets all available icon names for a specific variant.") +[void]$sb.AppendLine(" public static IEnumerable GetAvailableIcons(FontAwesomeIconVariant variant)") +[void]$sb.AppendLine(" {") +[void]$sb.AppendLine(" return variant switch") +[void]$sb.AppendLine(" {") +[void]$sb.AppendLine(" FontAwesomeIconVariant.Solid => SolidIcons.Keys,") +[void]$sb.AppendLine(" FontAwesomeIconVariant.Regular => RegularIcons.Keys,") +[void]$sb.AppendLine(" FontAwesomeIconVariant.Brands => BrandsIcons.Keys,") +[void]$sb.AppendLine(" _ => SolidIcons.Keys") +[void]$sb.AppendLine(" };") +[void]$sb.AppendLine(" }") +[void]$sb.AppendLine("") +[void]$sb.AppendLine(" /// Checks whether an icon with the specified name exists in the given variant.") +[void]$sb.AppendLine(" public static bool IconExists(string name, FontAwesomeIconVariant variant)") +[void]$sb.AppendLine(" {") +[void]$sb.AppendLine(" return variant switch") +[void]$sb.AppendLine(" {") +[void]$sb.AppendLine(" FontAwesomeIconVariant.Solid => SolidIcons.ContainsKey(name),") +[void]$sb.AppendLine(" FontAwesomeIconVariant.Regular => RegularIcons.ContainsKey(name),") +[void]$sb.AppendLine(" FontAwesomeIconVariant.Brands => BrandsIcons.ContainsKey(name),") +[void]$sb.AppendLine(" _ => SolidIcons.ContainsKey(name)") +[void]$sb.AppendLine(" };") +[void]$sb.AppendLine(" }") +[void]$sb.AppendLine("") +[void]$sb.AppendLine(" /// Gets the total number of available icons across all variants.") +[void]$sb.AppendLine(" public static int TotalIconCount => SolidIcons.Count + RegularIcons.Count + BrandsIcons.Count;") +[void]$sb.AppendLine("") +[void]$sb.AppendLine(" /// Gets the number of Solid icons.") +[void]$sb.AppendLine(" public static int SolidIconCount => SolidIcons.Count;") +[void]$sb.AppendLine("") +[void]$sb.AppendLine(" /// Gets the number of Regular icons.") +[void]$sb.AppendLine(" public static int RegularIconCount => RegularIcons.Count;") +[void]$sb.AppendLine("") +[void]$sb.AppendLine(" /// Gets the number of Brands icons.") +[void]$sb.AppendLine(" public static int BrandsIconCount => BrandsIcons.Count;") +[void]$sb.AppendLine("}") + +$sb.ToString() | Out-File -FilePath $outputPath -Encoding UTF8 +Write-Host "" +Write-Host "Generated C# file: $outputPath" +Write-Host "Total icons: $totalCount" +foreach ($variant in $variantData.Keys) { + Write-Host " $variant`: $($variantData[$variant].Count)" +} diff --git a/src/BlazorBlueprint.Icons.FontAwesome/README.md b/src/BlazorBlueprint.Icons.FontAwesome/README.md new file mode 100644 index 000000000..1158b9d28 --- /dev/null +++ b/src/BlazorBlueprint.Icons.FontAwesome/README.md @@ -0,0 +1,293 @@ +# BlazorBlueprint.Icons.FontAwesome + +A comprehensive Font Awesome Free icon library for Blazor applications, providing 2,066 icons across 3 variants (Solid, Regular, Brands). + +> Only Font Awesome **Free** is supported. Font Awesome Pro requires a commercial license and cannot be redistributed via NuGet. + +## Features + +- **2,066 Icons**: Full Font Awesome 6 Free icon set across 3 variants +- **3 Variants**: Solid (filled), Regular (outline), Brands (third-party logos) +- **Aspect-Ratio Aware**: Per-icon `viewBox` is preserved, so non-square Brands icons (e.g. `github`, `twitter`) render at their correct proportions +- **React-Style API**: Familiar component-based API +- **Includes ARIA Attributes**: Customizable `aria-label` for accessibility +- **Tree-Shakeable**: Blazor assembly trimming removes unused icons at publish time +- **Type-Safe**: Full XML documentation and IntelliSense support +- **Themeable**: Icons inherit color from parent by default, supports CSS variables +- **Lightweight**: Static dictionary lookup with minimal overhead + +## Installation + +```bash +dotnet add package BlazorBlueprint.Icons.FontAwesome +``` + +## Basic Usage + +### Import the Namespace + +Add to `_Imports.razor`: + +```razor +@using BlazorBlueprint.Icons.FontAwesome.Components +@using BlazorBlueprint.Icons.FontAwesome.Data +``` + +### Render an Icon + +```razor +@* Default variant (Solid) *@ + +``` + +### Use Different Variants + +```razor +@* Solid variant — the largest set, filled glyphs (default) *@ + + +@* Regular variant — outline alternative (small curated subset in the Free tier) *@ + + +@* Brands variant — third-party logos *@ + +``` + +### Customize Size and Color + +```razor + +``` + +### Use with CSS Variables (Theming) + +```razor + +``` + +### Icon-Only Button (with Accessibility) + +```razor + +``` + +### Integration with BlazorBlueprint Button Component + +```razor + + + + + Download + +``` + +## Component API + +### Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `Name` | `string` | **(Required)** | Icon name (e.g., "camera", "house", "github"). Case-insensitive, kebab-case. | +| `Variant` | `FontAwesomeIconVariant` | `Solid` | Icon variant: Solid, Regular, or Brands | +| `Size` | `int?` | `16` | Icon width in pixels. Height is scaled proportionally to preserve aspect ratio. | +| `Color` | `string` | `"currentColor"` | Icon color (any CSS color value, inherits from parent by default) | +| `Class` | `string?` | `null` | Additional CSS classes | +| `AriaLabel` | `string?` | `null` | Accessibility label for screen readers | +| `AdditionalAttributes` | `Dictionary?` | `null` | Any additional SVG attributes | + +### Icon Variants + +```csharp +public enum FontAwesomeIconVariant +{ + Solid, // Filled glyphs — the largest set and the default + Regular, // Outline glyphs — small curated subset in the Free tier + Brands // Third-party logos (GitHub, Microsoft, Apple, etc.) +} +``` + +### Examples + +**Basic Icon (Solid):** +```razor + +``` + +**Brands Icon:** +```razor + +``` + +**Regular Icon with Custom Color:** +```razor + +``` + +**Custom Size:** +```razor + +``` + +**Icon with Custom CSS Classes:** +```razor + +``` + +**Accessible Icon-Only Button:** +```razor + +``` + +**Icon with Data Attributes:** +```razor + +``` + +## Icon Names + +All Font Awesome Free icons are available, with names matching the official Font Awesome naming (kebab-case). Common examples: + +- `house`, `user`, `gear`, `magnifying-glass` +- `arrow-left`, `arrow-right`, `arrow-up`, `arrow-down` +- `circle-check`, `circle-xmark`, `circle-exclamation` +- `heart`, `star`, `bell`, `bookmark` +- `github`, `microsoft`, `apple`, `google` (Brands) +- ... and 2,000+ more + +**Browse all icons:** [fontawesome.com/icons](https://fontawesome.com/icons) + +## Variant Guidelines + +### Solid (Default) +- **Style**: Filled paths +- **Coverage**: 1,400+ icons — the largest set in Free +- **Use case**: Primary UI, navigation, emphasis, the default for most applications + +### Regular +- **Style**: Outline / stroke-style paths +- **Coverage**: 160+ icons — a small curated subset; the Free tier ships far fewer Regular icons than Solid +- **Use case**: When you want a lighter visual weight than Solid + +### Brands +- **Style**: Filled logos at the artist-specified aspect ratio +- **Coverage**: 480+ third-party brand and product logos +- **Use case**: Social links, technology logos, payment provider icons +- **Note**: Brands icons are **not all square** — width and height vary per icon. The component preserves each icon's intrinsic viewBox and scales height accordingly. + +## Styling + +### Default Behavior + +Icons inherit `color` from their parent element by default: + +```razor +
    + +
    +``` + +### Explicit Color + +Override the inherited color: + +```razor + +``` + +### CSS Variables (Theming) + +Perfect for theme systems: + +```razor + + +``` + +### Tailwind CSS + +Use Tailwind utility classes: + +```razor + +``` + +## Accessibility + +### Decorative Icons (Next to Text) + +Icons next to text are decorative and don't need labels: + +```razor + +``` + +### Semantic Icons (Icon-Only) + +Icon-only elements require `AriaLabel`: + +```razor + +``` + +## Performance + +- **Bundle Size**: ~580 KB for the complete icon set across all 3 variants (before compression) +- **Brotli Compression**: Reduces size by ~70% in production +- **Assembly Trimming**: Unused icons automatically removed at publish time +- **Static Dictionary**: O(1) icon lookup with minimal memory overhead + +## Browser Support + +Works in all modern browsers that support: +- Blazor Server / WebAssembly / Hybrid +- SVG rendering +- CSS `currentColor` + +## Regenerating Icon Data + +The `Data/FontAwesomeIconData.cs` file is auto-generated from the Iconify JSON sets for Font Awesome 6 Free. To refresh: + +1. Download the latest sets from the `@iconify-json/fa6-*` npm packages, or [iconify/icon-sets](https://github.com/iconify/icon-sets/tree/master/json): + - `fa6-solid.json` + - `fa6-regular.json` + - `fa6-brands.json` +2. Place them in `tools/icon-generation/data/`. +3. Run: + +```powershell +./GenerateIconData.ps1 +``` + +## License + +The C# wrapper code is MIT licensed. + +Font Awesome Free icon artwork is licensed under the [Font Awesome Free License](https://fontawesome.com/license/free): +- Icons: CC BY 4.0 +- Fonts: SIL OFL 1.1 +- Code: MIT + +## Links + +- **Font Awesome**: [fontawesome.com](https://fontawesome.com/) +- **Icon Browser**: [fontawesome.com/icons](https://fontawesome.com/icons) +- **BlazorBlueprint**: [GitHub Repository](https://github.com/blazorblueprintui/ui) +- **Issues**: [Report a Bug](https://github.com/blazorblueprintui/ui/issues) + +## Contributing + +Contributions are welcome! Please open an issue or pull request on GitHub. + +--- + +Made with ❤️ by the BlazorBlueprint team From bea77aeffbb716ff719cc9413e35671e51bead06 Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Sat, 6 Jun 2026 08:46:10 +0100 Subject: [PATCH 062/188] fix(combobox): resolve trigger display text for pre-bound values in compositional mode (#337) In compositional mode (BbComboboxItem children, not Options) the trigger resolves its caption from an internal registry that's populated only when the items mount. Items live inside BbPopoverContent which doesn't mount until the popover first opens, so a pre-bound Value rendered placeholder until the user interacted with the dropdown. After commit 8ea4a8c4 flipped BbPopoverContent.ForceMount to false (a WASM perf win) this became the steady-state behaviour rather than a transient. This change closes the gap without undoing the perf fix: - Adds SelectedItemText parameter so callers that already know the display text for a pre-bound value (typical when the value comes from an API alongside its label) can render it on initial paint. Slotted in SelectedDisplayText after the registry so re-registration still corrects stale captions. - RegisterItem now marks a _triggerTextDirty flag and calls StateHasChanged when a freshly-registered item matches the current Value AND its text actually changed. ShouldRender treats the flag as a render trigger. Without this, items registering on first popover open did not cause the trigger to re-evaluate, so the placeholder stuck even after the registry was populated. - textChanged guard avoids spurious renders from the OnParametersSet- side RegisterItem call that re-registers identical text every parent cascade. Options mode is unaffected (it resolves synchronously from the Options collection and never hit this issue). BbPopoverContent.ForceMount stays false; BbComboboxItem is unchanged. --- .../Components/Combobox/BbCombobox.razor.cs | 75 ++++++++++++++++--- ...entsApiSurfaceMatchesBaseline.verified.txt | 1 + 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs index 2b3c6d618..b21bd2b99 100644 --- a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs @@ -65,11 +65,18 @@ public partial class BbCombobox : ComponentBase private bool _lastDisabled; private string _lastSearchQuery = string.Empty; + // Bypass for ShouldRender when the trigger needs to pick up a freshly-registered + // display text for the current selection. ComboboxItem children live inside the + // popover portal and only mount on first open, so their RegisterItem call + // arrives well after the surrounding render-skipping state has settled. + private bool _triggerTextDirty; + protected override bool ShouldRender() { - if (_parametersChanged) + if (_parametersChanged || _triggerTextDirty) { _parametersChanged = false; + _triggerTextDirty = false; _lastIsOpen = _isOpen; _lastValue = Value; _lastDisabled = Disabled; @@ -140,6 +147,24 @@ protected override bool ShouldRender() [Parameter] public string? Placeholder { get; set; } + /// + /// Gets or sets the display text shown in the trigger when a value is preselected + /// via in compositional mode and no matching + /// BbComboboxItem has registered yet. + /// + /// + /// Items live inside the popover portal and only mount when it opens, so the + /// internal text registry is empty on initial render. Set this when you already + /// know the display text for the preselected value (typically right after resolving + /// it from an API) so the trigger can render the correct caption before the user + /// has opened the popover. Options mode does not need this — it resolves the text + /// synchronously from the collection. Ignored once a matching + /// item registers, so re-registration (e.g. Text updated by the parent) still + /// corrects stale captions. + /// + [Parameter] + public string? SelectedItemText { get; set; } + /// /// Gets or sets the placeholder text shown in the search input. /// @@ -305,9 +330,10 @@ protected override void OnParametersSet() } /// - /// Gets the display text for the currently selected item. - /// Checks Options first (Options mode), then the item text registry (Compositional mode), - /// then falls back to the cached display text from the last selection. + /// Gets the display text for the currently selected item. Resolution order: + /// Options (Options mode) → registered items (Compositional mode, after first open) → + /// caller-supplied (covers the pre-mount gap) → + /// cached text from the last user selection → placeholder. /// private string SelectedDisplayText { @@ -318,21 +344,30 @@ private string SelectedDisplayText return EffectivePlaceholder; } - // Options mode: look up from Options collection + // Options mode: synchronous lookup from the Options collection. var selectedOption = Options?.FirstOrDefault(o => EqualityComparer.Default.Equals(o.Value, Value)); if (selectedOption is not null) { return selectedOption.Text; } - // Compositional mode: look up from registered items + // Compositional mode: a registered item wins over the caller-provided hint so + // that re-registration (e.g. Text updated by the parent) corrects stale captions. if (_itemTextRegistry.GetValueOrDefault(Value) is { } registryText) { return registryText; } - // Fallback: cached display text from last selection survives Options array changes - // during async filtering (e.g. selected option filtered out of current results). + // Caller-provided initial text — covers the "popover has never opened so + // children have not mounted" gap that the registry alone cannot fill. + if (!string.IsNullOrEmpty(SelectedItemText)) + { + return SelectedItemText; + } + + // Last-resort: cached text from a previous user selection, which survives + // Options array changes during async filtering (e.g. selected option filtered + // out of current results). return _selectedDisplayTextCache ?? EffectivePlaceholder; } } @@ -452,13 +487,31 @@ private async Task HandleSelect(SelectOption option) /// /// Registers an item's value and display text for trigger display text lookup. - /// Called by ComboboxItem on initialization. + /// Called by ComboboxItem on initialization and on parameter cascade. /// internal void RegisterItem(TValue value, string text) { - if (value is not null) + if (value is null) + { + return; + } + + // Only mark dirty when the text genuinely changed — without this guard the + // OnParametersSet-side RegisterItem call would queue a render on every parent + // cascade because identical text is re-registered each cycle. + var textChanged = !_itemTextRegistry.TryGetValue(value, out var existing) + || !string.Equals(existing, text, StringComparison.Ordinal); + + _itemTextRegistry[value] = text; + + // If the registered item is the current selection and its display text actually + // changed (covers first-mount and Text-updates), re-render so the trigger picks + // up the new caption. Items mount lazily inside the popover portal, so without + // this the trigger would stay on the placeholder until the user interacted. + if (textChanged && EqualityComparer.Default.Equals(value, Value)) { - _itemTextRegistry[value] = text; + _triggerTextDirty = true; + StateHasChanged(); } } diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index d7bb066c4..8ef54f271 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -472,6 +472,7 @@ - SearchPlaceholder : String - SearchQuery : String - SearchQueryChanged : EventCallback + - SelectedItemText : String - Value : TValue - ValueChanged : EventCallback - ValueExpression : Expression> From 7cb4730fcac6705b93b947310d34f5be5757343d Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Sat, 6 Jun 2026 09:29:33 +0100 Subject: [PATCH 063/188] fix(primitives): restore focus to trigger on intentional overlay close (#341) Select, DropdownMenu, and Popover now return focus to their trigger when closed via an intentional path (Escape or item selection), so keyboard users land back on the trigger instead of having focus fall to when the overlay content unmounts. External dismissals (click-outside, Tab) deliberately leave focus where the user moved it. - Add Close(bool restoreFocus = false) + a RestoreFocusOnClose state flag to the Select/DropdownMenu/Popover contexts; the flag is reset on Open - Content components capture the trigger ref before teardown and call IFocusManager.RestoreFocus after cleanup, so the unmounted overlay is never the active element on the next Tab - The selection path sets the flag directly since it closes via state mutation rather than Close() Verified in-browser across all three components: Escape and item selection restore focus to the trigger; click-outside does not. Also folds in an unrelated fix for a CS1998 build error in DataViewDemo (async items-provider with no await) that was breaking the demo host build under TreatWarningsAsErrors. --- .../Pages/Components/DataViewDemo.razor | 6 ++--- .../BbDropdownMenuCheckboxItem.razor | 3 ++- .../DropdownMenu/BbDropdownMenuContent.razor | 17 +++++++++++++- .../DropdownMenu/BbDropdownMenuItem.razor | 4 +++- .../DropdownMenu/DropdownMenuContext.cs | 18 ++++++++++++++- .../Primitives/Popover/BbPopoverContent.razor | 17 +++++++++++++- .../Primitives/Popover/PopoverContext.cs | 23 +++++++++++++++++-- .../Primitives/Select/BbSelectContent.razor | 19 ++++++++++++++- .../Primitives/Select/BbSelectTrigger.razor | 3 ++- .../Primitives/Select/SelectContext.cs | 21 ++++++++++++++++- 10 files changed, 118 insertions(+), 13 deletions(-) diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataViewDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataViewDemo.razor index 046474521..03f1eb822 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataViewDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataViewDemo.razor @@ -717,7 +717,7 @@ : name.Length > 0 ? name[0].ToString() : "?"; } - private async ValueTask> LoadPeopleAsync(DataViewRequest request) + private ValueTask> LoadPeopleAsync(DataViewRequest request) { IEnumerable query = asyncPeople; @@ -749,10 +749,10 @@ .Take(request.Count ?? materialized.Count) .ToList(); - return new DataViewResult + return ValueTask.FromResult(new DataViewResult { Items = items, TotalItemCount = materialized.Count - }; + }); } } diff --git a/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/BbDropdownMenuCheckboxItem.razor b/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/BbDropdownMenuCheckboxItem.razor index cb34d2ecd..71d8b6354 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/BbDropdownMenuCheckboxItem.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/BbDropdownMenuCheckboxItem.razor @@ -81,7 +81,8 @@ if (CloseOnSelect && Context != null) { - Context.Close(); + // Item activation is an intentional close — return focus to the trigger. + Context.Close(restoreFocus: true); } } diff --git a/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/BbDropdownMenuContent.razor b/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/BbDropdownMenuContent.razor index dcf5aaa47..ed39cf0e5 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/BbDropdownMenuContent.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/BbDropdownMenuContent.razor @@ -3,6 +3,7 @@ @using BlazorBlueprint.Primitives.Floating @using BlazorBlueprint.Primitives.Services @inject IJSRuntime JSRuntime +@inject IFocusManager FocusManager @implements IAsyncDisposable @* @@ -304,8 +305,21 @@ { if (!Context.IsOpen) { + // Capture both the trigger ref and the restore flag before teardown. + var trigger = Context.State.TriggerElement; + var shouldRestoreFocus = Context.State.RestoreFocusOnClose; + await CleanupAsync(); StateHasChanged(); + + // Restore focus to the trigger AFTER cleanup so the now-unmounted menu + // isn't the active element when the user next presses Tab. Only restore + // for intentional closes (Escape, item activation) — click-outside + // leaves focus where the user clicked. + if (shouldRestoreFocus && trigger.HasValue) + { + await FocusManager.RestoreFocus(trigger); + } } else { @@ -331,7 +345,8 @@ if (CloseOnEscape) { - Context.Close(); + // Escape is an intentional dismiss — return focus to the trigger. + Context.Close(restoreFocus: true); } } diff --git a/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/BbDropdownMenuItem.razor b/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/BbDropdownMenuItem.razor index 39d8ce199..fb76547aa 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/BbDropdownMenuItem.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/BbDropdownMenuItem.razor @@ -137,7 +137,9 @@ else if (CloseOnSelect && Context != null) { - Context.Close(); + // Item activation is an intentional close — return focus to the trigger + // so keyboard navigation (Tab) continues from the right place. + Context.Close(restoreFocus: true); } } diff --git a/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/DropdownMenuContext.cs b/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/DropdownMenuContext.cs index 98d23bfb3..27b47be50 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/DropdownMenuContext.cs +++ b/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/DropdownMenuContext.cs @@ -24,6 +24,14 @@ public class DropdownMenuState /// Used for keyboard navigation. /// public int FocusedIndex { get; set; } = -1; + + /// + /// Gets or sets whether focus should be returned to the trigger element when the + /// menu closes. Set to true by intentional close paths (item activation, + /// Escape) and left false for external dismissals (click-outside) where + /// focus is already where the user wants it. + /// + public bool RestoreFocusOnClose { get; set; } } /// @@ -76,18 +84,26 @@ public void Open(ElementReference? triggerElement = null) state.IsOpen = true; state.TriggerElement = triggerElement; state.FocusedIndex = -1; // Reset focus on open + state.RestoreFocusOnClose = false; // Cleared so the next Close() decides afresh }); } /// /// Closes the dropdown menu. /// - public void Close() + /// + /// When true, signals that focus should be returned to the trigger element + /// after the content tears down. Pass true for intentional close paths + /// (Escape, item activation) and leave false for external dismissals + /// (click-outside) where focus is already where the user wants it. + /// + public void Close(bool restoreFocus = false) { UpdateState(state => { state.IsOpen = false; state.FocusedIndex = -1; + state.RestoreFocusOnClose = restoreFocus; }); } diff --git a/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopoverContent.razor b/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopoverContent.razor index e3bfccf86..f0743ae74 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopoverContent.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopoverContent.razor @@ -3,6 +3,7 @@ @using BlazorBlueprint.Primitives.Floating @using BlazorBlueprint.Primitives.Services @inject IJSRuntime JSRuntime +@inject IFocusManager FocusManager @implements IAsyncDisposable @* @@ -255,8 +256,21 @@ { if (!Context.IsOpen) { + // Capture both the trigger ref and the restore flag before teardown. + var trigger = Context.State.TriggerElement; + var shouldRestoreFocus = Context.State.RestoreFocusOnClose; + await CleanupAsync(); StateHasChanged(); + + // Restore focus to the trigger AFTER cleanup so the now-unmounted popover + // isn't the active element when the user next presses Tab. Only restore + // for intentional closes (Escape) — click-outside leaves focus where the + // user clicked. + if (shouldRestoreFocus && trigger.HasValue) + { + await FocusManager.RestoreFocus(trigger); + } } else { @@ -282,7 +296,8 @@ // Close popover if configured if (CloseOnEscape) { - Context.Close(); + // Escape is an intentional dismiss — return focus to the trigger. + Context.Close(restoreFocus: true); } } } diff --git a/src/BlazorBlueprint.Primitives/Primitives/Popover/PopoverContext.cs b/src/BlazorBlueprint.Primitives/Primitives/Popover/PopoverContext.cs index e1bd2c86a..4f5054609 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Popover/PopoverContext.cs +++ b/src/BlazorBlueprint.Primitives/Primitives/Popover/PopoverContext.cs @@ -18,6 +18,14 @@ public class PopoverState /// Used for positioning and focus management. /// public ElementReference? TriggerElement { get; set; } + + /// + /// Gets or sets whether focus should be returned to the trigger element when the + /// popover closes. Set to true by intentional close paths (Escape) and left + /// false for external dismissals (click-outside) where focus is already + /// where the user wants it. + /// + public bool RestoreFocusOnClose { get; set; } } /// @@ -58,14 +66,25 @@ public void Open(ElementReference? triggerElement = null) { state.IsOpen = true; state.TriggerElement = triggerElement; + state.RestoreFocusOnClose = false; // Cleared so the next Close() decides afresh }); } /// /// Closes the popover. /// - public void Close() => - UpdateState(state => state.IsOpen = false); + /// + /// When true, signals that focus should be returned to the trigger element + /// after the content tears down. Pass true for intentional close paths + /// (Escape) and leave false for external dismissals (click-outside) where + /// focus is already where the user wants it. + /// + public void Close(bool restoreFocus = false) => + UpdateState(state => + { + state.IsOpen = false; + state.RestoreFocusOnClose = restoreFocus; + }); /// /// Toggles the popover open/closed state. diff --git a/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectContent.razor b/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectContent.razor index ac1265021..8928defd5 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectContent.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectContent.razor @@ -5,6 +5,7 @@ @using BlazorBlueprint.Primitives.Services @using Microsoft.JSInterop @inject IJSRuntime JS +@inject IFocusManager FocusManager @implements IAsyncDisposable @* @@ -212,8 +213,23 @@ // When context closes, clean up if (_context?.IsOpen == false && _isKeyboardSetup) { + // Capture both the trigger ref and the restore flag before teardown. + // State.TriggerElement is still populated at this point (Close() doesn't + // clear it), but we read it eagerly in case anything else mutates state. + var trigger = _context.State.TriggerElement; + var shouldRestoreFocus = _context.State.RestoreFocusOnClose; + await CleanupAsync(); StateHasChanged(); + + // Restore focus to the trigger AFTER cleanup so the now-unmounted listbox + // isn't the active element when the user next presses Tab. Only restore + // for intentional closes (Escape, selection) — click-outside and Tab + // leave focus where the user intentionally moved it. + if (shouldRestoreFocus && trigger.HasValue) + { + await FocusManager.RestoreFocus(trigger); + } } } catch (Exception ex) when (ex is ObjectDisposedException or TaskCanceledException) @@ -286,7 +302,8 @@ public void JsOnEscapeKey() { if (_disposed) { return; } - _context?.Close(); + // Escape is an intentional dismiss — return focus to the trigger. + _context?.Close(restoreFocus: true); } /// diff --git a/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectTrigger.razor b/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectTrigger.razor index c7ee306ad..7e3ab0c7c 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectTrigger.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectTrigger.razor @@ -103,7 +103,8 @@ _shouldPreventDefault = true; if (_context.IsOpen) { - _context.Close(); + // Escape is an intentional dismiss — return focus to the trigger. + _context.Close(restoreFocus: true); } break; default: diff --git a/src/BlazorBlueprint.Primitives/Primitives/Select/SelectContext.cs b/src/BlazorBlueprint.Primitives/Primitives/Select/SelectContext.cs index 4c422e7d5..caa6cd960 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Select/SelectContext.cs +++ b/src/BlazorBlueprint.Primitives/Primitives/Select/SelectContext.cs @@ -62,6 +62,14 @@ public class SelectState /// Gets or sets whether the select is required. /// public bool Required { get; set; } + + /// + /// Gets or sets whether focus should be returned to the trigger element when the + /// dropdown closes. Set to true by intentional close paths (item selection, + /// Escape) and left false for external dismissals (click-outside, Tab) where + /// focus is already on whatever the user moved to. + /// + public bool RestoreFocusOnClose { get; set; } } /// @@ -172,18 +180,26 @@ public void Open(ElementReference? triggerElement = null) state.IsOpen = true; state.TriggerElement = triggerElement; state.FocusedIndex = -1; // Reset focus on open + state.RestoreFocusOnClose = false; // Cleared so the next Close() decides afresh }); } /// /// Closes the select dropdown. /// - public void Close() + /// + /// When true, signals that focus should be returned to the trigger element + /// after the content tears down. Pass true for intentional close paths + /// (Escape, keyboard activation) and leave false for external dismissals + /// (click-outside, Tab) where focus is already where the user wants it. + /// + public void Close(bool restoreFocus = false) { UpdateState(state => { state.IsOpen = false; state.FocusedIndex = -1; + state.RestoreFocusOnClose = restoreFocus; }); } @@ -221,6 +237,9 @@ public void SelectValue(TValue? value, string? displayText) state.DisplayText = displayText; state.IsOpen = false; // Close after selection state.FocusedIndex = -1; + // Selection is an intentional close — return focus to the trigger so + // keyboard navigation (Tab) continues from the right place. + state.RestoreFocusOnClose = true; }); // Invoke value change callback From 58a06fca1a0e476f4ead94401492a13580ed8787 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Sat, 6 Jun 2026 16:38:23 +0800 Subject: [PATCH 064/188] chore: update .gitignore and changelog for recent changes --- .gitignore | 2 ++ CHANGELOG.md | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/.gitignore b/.gitignore index 6d35a2b04..ee83847c1 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,5 @@ devkit/ # External libs pro/ /.vscode-ai-images/ + +docs/notes/checkpoint.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c58f07c..167314f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## 2026-06-06 + +### Fixed + +- **BbCombobox: trigger showed placeholder for pre-bound values** — In compositional mode, when `Value` was bound before the dropdown was first opened, the combobox trigger displayed the placeholder instead of the selected item's text, because the item's text was only registered once the item mounted. The trigger now resolves its display text from a caller-supplied `SelectedItemText` parameter and from item registration, so pre-selected values render correctly on first paint. ([#337](https://github.com/blazorblueprintui/ui/pull/337)) +- **Select, DropdownMenu, Popover: keyboard focus lost after closing** — Closing one of these overlays by an intentional action (pressing Escape or selecting an item) left keyboard focus on the now-unmounted content, so focus fell back to the page root and a subsequent Tab resumed from the top of the page. Focus is now returned to the trigger on intentional close; external dismissals (click-outside, Tab) deliberately leave focus where the user moved it. ([#336](https://github.com/blazorblueprintui/ui/issues/336)) + +--- + +## 2026-05-28 + +### Added + +- **Font Awesome icon pack** — New `BlazorBlueprint.Icons.FontAwesome` package, joining the existing Lucide, Heroicons, and Feather icon packs. (PR [#333](https://github.com/blazorblueprintui/ui/pull/333), community contribution by [@djb-fnz](https://github.com/djb-fnz)) + +--- + +## 2026-05-27 + +### Added + +- **BbDataView: ItemsProvider for on-demand data loading** — New `ItemsProvider` parameter (`DataViewItemsProvider`) lets `BbDataView` fetch items asynchronously on demand — returning a `DataViewResult` for each requested range — instead of binding an entire in-memory collection, enabling server-side paging, filtering, and sorting. A demo example was added. (PR [#306](https://github.com/blazorblueprintui/ui/pull/306), community contribution by [@djb-fnz](https://github.com/djb-fnz)) + +--- + ## 2026-05-21 ### Fixed From bc1e4123855c1291f2fdf530c3f5f154f0ef59b5 Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:43:07 +0100 Subject: [PATCH 065/188] fix(primitives): reference-count body scroll lock so nested overlays restore page scroll (#329) (#342) --- .../wwwroot/js/primitives/portal.js | 57 +++++++++++++++---- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/portal.js b/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/portal.js index 338dfc3aa..625de1987 100644 --- a/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/portal.js +++ b/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/portal.js @@ -87,26 +87,59 @@ export function getComputedZIndex(element) { return isNaN(zIndex) ? 0 : zIndex; } +// ============================================================================ +// Body scroll lock (reference counted) +// Stacked/nested overlays (e.g. a Dialog opening an AlertDialog) each acquire a +// lock. We must only restore the body's original scroll state once the LAST lock +// is released — otherwise a nested overlay's cleanup, capturing the already-locked +// "hidden" state, would clobber the outer overlay's restore and leave the page +// permanently frozen regardless of disposal order. +// ============================================================================ + +let scrollLockCount = 0; +let savedScrollState = null; + /** - * Locks body scroll (useful for modals). - * @returns {Object} Object with cleanup method to restore scroll + * Locks body scroll (useful for modals). Reference counted so nested overlays + * share a single underlying lock. + * @returns {Object} Object with an apply() method that releases this lock */ export function lockBodyScroll() { - const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; - const originalOverflow = document.body.style.overflow; - const originalPaddingRight = document.body.style.paddingRight; + if (scrollLockCount === 0) { + // First lock: capture the true original state before mutating it. + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + savedScrollState = { + overflow: document.body.style.overflow, + paddingRight: document.body.style.paddingRight + }; + + document.body.style.overflow = 'hidden'; + + // Prevent layout shift by adding padding for scrollbar + if (scrollbarWidth > 0) { + document.body.style.paddingRight = `${scrollbarWidth}px`; + } + } - document.body.style.overflow = 'hidden'; + scrollLockCount++; - // Prevent layout shift by adding padding for scrollbar - if (scrollbarWidth > 0) { - document.body.style.paddingRight = `${scrollbarWidth}px`; - } + // Guard against this handle being released more than once (e.g. close + dispose). + let released = false; // Return cleanup function wrapped in object for C# interop const cleanup = () => { - document.body.style.overflow = originalOverflow; - document.body.style.paddingRight = originalPaddingRight; + if (released) { + return; + } + released = true; + scrollLockCount = Math.max(0, scrollLockCount - 1); + + // Only restore once every lock has been released. + if (scrollLockCount === 0 && savedScrollState) { + document.body.style.overflow = savedScrollState.overflow; + document.body.style.paddingRight = savedScrollState.paddingRight; + savedScrollState = null; + } }; return { From 6e299a896cdcc46156ab5081c2ed4dc3551ff9ac Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:43:22 +0100 Subject: [PATCH 066/188] fix(combobox): show selected label for pre-bound values in compositional mode (#340) (#343) --- .../Components/Combobox/BbCombobox.razor | 14 +++++ .../Components/Combobox/BbCombobox.razor.cs | 38 +++++++------- .../Combobox/BbComboboxConstants.cs | 16 ++++++ .../Components/Combobox/BbComboboxItem.razor | 51 ++++++++++--------- .../Combobox/BbComboboxItem.razor.cs | 9 ++++ ...entsApiSurfaceMatchesBaseline.verified.txt | 1 + 6 files changed, 88 insertions(+), 41 deletions(-) create mode 100644 src/BlazorBlueprint.Components/Components/Combobox/BbComboboxConstants.cs diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor index 70aae1343..15b3b5df7 100644 --- a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor @@ -72,5 +72,19 @@ + @* Hidden registration pass (compositional mode only). The interactive items above live + inside the popover portal, which doesn't mount until the first open — so for a pre-bound + Value the trigger would show the placeholder until the user opens the dropdown once. + Rendering the items again here, invisibly and non-interactively (see RegistrationOnly), + lets each one register its display text on initial load so the trigger resolves the + selected caption immediately. *@ + @if (Options is null && ChildContent is not null) + { + + }
    diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs index b21bd2b99..1decfad06 100644 --- a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs @@ -66,9 +66,10 @@ public partial class BbCombobox : ComponentBase private string _lastSearchQuery = string.Empty; // Bypass for ShouldRender when the trigger needs to pick up a freshly-registered - // display text for the current selection. ComboboxItem children live inside the - // popover portal and only mount on first open, so their RegisterItem call - // arrives well after the surrounding render-skipping state has settled. + // display text for the current selection. ComboboxItem children register their text on + // initial render via the hidden registration pass, but that happens after the trigger's + // first render — so RegisterItem arrives once the render-skipping state has already + // settled and must force a follow-up render. private bool _triggerTextDirty; protected override bool ShouldRender() @@ -153,14 +154,14 @@ protected override bool ShouldRender() /// BbComboboxItem has registered yet. /// /// - /// Items live inside the popover portal and only mount when it opens, so the - /// internal text registry is empty on initial render. Set this when you already - /// know the display text for the preselected value (typically right after resolving - /// it from an API) so the trigger can render the correct caption before the user - /// has opened the popover. Options mode does not need this — it resolves the text - /// synchronously from the collection. Ignored once a matching - /// item registers, so re-registration (e.g. Text updated by the parent) still - /// corrects stale captions. + /// Compositional items now register their text on initial render (via a hidden + /// registration pass), so the trigger resolves a pre-bound without + /// this in the common case. Set it only when the matching item isn't present in the + /// markup at first render — e.g. the selection is loaded asynchronously and added later — + /// so the trigger can still show a caption in the meantime. Options mode never needs this; + /// it resolves text synchronously from the collection. A registered + /// item always wins over this hint, so re-registration (e.g. Text updated by the parent) + /// still corrects stale captions. /// [Parameter] public string? SelectedItemText { get; set; } @@ -331,9 +332,9 @@ protected override void OnParametersSet() /// /// Gets the display text for the currently selected item. Resolution order: - /// Options (Options mode) → registered items (Compositional mode, after first open) → - /// caller-supplied (covers the pre-mount gap) → - /// cached text from the last user selection → placeholder. + /// Options (Options mode) → registered items (Compositional mode) → + /// caller-supplied (fallback when no matching item is in + /// the markup yet) → cached text from the last user selection → placeholder. /// private string SelectedDisplayText { @@ -358,8 +359,8 @@ private string SelectedDisplayText return registryText; } - // Caller-provided initial text — covers the "popover has never opened so - // children have not mounted" gap that the registry alone cannot fill. + // Caller-provided initial text — fallback when the selected value has no matching + // item in the markup yet (e.g. the selection is loaded asynchronously). if (!string.IsNullOrEmpty(SelectedItemText)) { return SelectedItemText; @@ -506,8 +507,9 @@ internal void RegisterItem(TValue value, string text) // If the registered item is the current selection and its display text actually // changed (covers first-mount and Text-updates), re-render so the trigger picks - // up the new caption. Items mount lazily inside the popover portal, so without - // this the trigger would stay on the placeholder until the user interacted. + // up the new caption. Registration happens after the trigger's first render (the + // hidden registration pass mounts children later in the same initial render cycle), + // so without this the trigger would stay on the placeholder. if (textChanged && EqualityComparer.Default.Equals(value, Value)) { _triggerTextDirty = true; diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxConstants.cs b/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxConstants.cs new file mode 100644 index 000000000..e20987911 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxConstants.cs @@ -0,0 +1,16 @@ +namespace BlazorBlueprint.Components; + +/// +/// Shared, type-independent constants for the Combobox component family. +/// +internal static class BbComboboxConstants +{ + /// + /// Name of the cascading flag the parent uses to mark its hidden, render-nothing + /// registration pass so BbComboboxItem children register their display text + /// on initial load without producing interactive DOM. Lives on a non-generic type so + /// it can be referenced from a + /// name without involving the parent's type parameter. + /// + public const string RegistrationScopeName = "BbComboboxRegistrationOnly"; +} diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxItem.razor b/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxItem.razor index 03bfebc16..c122ad67c 100644 --- a/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxItem.razor +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxItem.razor @@ -1,26 +1,31 @@ @namespace BlazorBlueprint.Components @typeparam TValue - - @if (ChildContent is not null) - { - @ChildContent - } - else - { - @Text - } - - - - +@* In the parent's hidden registration pass we register text (via lifecycle) but emit no + DOM — BbCommandItem needs a CommandContext that only exists inside the popover's BbCommand. *@ +@if (!RegistrationOnly) +{ + + @if (ChildContent is not null) + { + @ChildContent + } + else + { + @Text + } + + + + +} diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxItem.razor.cs b/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxItem.razor.cs index 9efb590f8..3a2a012b1 100644 --- a/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxItem.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxItem.razor.cs @@ -12,6 +12,15 @@ public partial class BbComboboxItem : ComponentBase, IDisposable [CascadingParameter] private BbCombobox? Parent { get; set; } + /// + /// When true, this item is part of the parent's hidden registration pass: it registers + /// its display text with the parent but renders no DOM. The parent renders the items a + /// second time, eagerly and invisibly, so their captions are known on initial load — + /// before the popover (and therefore the real, interactive items) has ever mounted. + /// + [CascadingParameter(Name = BbComboboxConstants.RegistrationScopeName)] + private bool RegistrationOnly { get; set; } + /// /// Gets or sets the value of this item. /// diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 8ef54f271..4b36f266d 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -453,6 +453,7 @@ - Text : String [EditorRequired] - Value : TValue [EditorRequired] - Parent : BbCombobox [CascadingParameter] + - RegistrationOnly : Boolean [CascadingParameter] ### BbCombobox`1 (BlazorBlueprint.Components) - ActiveClass : String From cfd52d3a8b3151f313c3f5ad930e4b1176f2e63d Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:43:37 +0100 Subject: [PATCH 067/188] docs: add render modes & interactive layouts guide (#339) (#344) --- README.md | 10 + .../Pages/Guides/RenderModesGuide.razor | 199 ++++++++++++++++++ .../Shared/DemoSidebar.razor | 5 + 3 files changed, 214 insertions(+) create mode 100644 demos/BlazorBlueprint.Demo.Shared/Pages/Guides/RenderModesGuide.razor diff --git a/README.md b/README.md index e7b4ec708..289c91512 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,16 @@ builder.Services.AddBlazorBlueprintComponents(); ``` +**6. Render modes** — Blazor Blueprint components handle user input and JavaScript interop, so they require an **interactive** render mode (`InteractiveServer`, `InteractiveWebAssembly`, or `InteractiveAuto`). The simplest setup is to enable interactivity globally on the router: + +```razor + + + +``` + +> **Important:** A routed page's layout inherits the page's render mode. If you use *per-page* interactivity (e.g. because some auth pages must stay static for `HttpContext`), `MainLayout` renders statically — so buttons, theme toggles, and providers placed in it won't respond. In that case, render the interactive layout chrome (and `BbPortalHost` / `BbToastProvider` / `BbDialogProvider`) as interactive *islands* with `@rendermode`. See the [Render Modes guide](https://blazorblueprintui.com/guides/render-modes) for the full pattern. + ## Components Blazor Blueprint includes **99 styled components** organized into the following categories. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Guides/RenderModesGuide.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Guides/RenderModesGuide.razor new file mode 100644 index 000000000..42026a269 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Guides/RenderModesGuide.razor @@ -0,0 +1,199 @@ +@page "/guides/render-modes" + +Render Modes - Blazor Blueprint + +
    +
    +

    Render Modes & Interactive Layouts

    +

    + Why buttons, toggles, and dialogs in your layout sometimes do nothing — and how to make + them work alongside static, HttpContext-dependent pages. +

    +
    + +
    + + +
    +

    Why interactivity matters

    +

    + Most Blazor Blueprint components respond to user input (clicks, typing, keyboard navigation) + or call JavaScript interop (positioning, scroll-lock, focus). All of that requires an + interactive render mode — + InteractiveServer, + InteractiveWebAssembly, or + InteractiveAuto. +

    +

    + Under static server-side rendering (SSR) — the .NET 8 default when no + interactive render mode is applied — a component's HTML is produced once and its event + handlers are never wired up. A BbButton + renders, looks correct, and silently does nothing when clicked. The same applies to + BbDarkModeToggle, + BbThemeSwitcher, and any overlay. +

    +
    + + +
    +

    The layout adopts the page's render mode

    +

    + This is the part that surprises people. A routed page's layout inherits the render + mode of the page being rendered. If the page is static (or you never set a global + interactive render mode), then MainLayout + — and everything in it — renders statically too. Buttons, theme toggles, the sidebar + trigger, and providers placed in the layout will not respond. +

    +
    +

    + You cannot fix this by adding + @@rendermode to the + @@layout directive or to the layout + component itself. The page content flows into the layout as + @@Body from a statically-rendered + parent, and Blazor does not allow that render-fragment to cross a static → interactive + boundary. Render mode is applied where a component is used, not where a layout is + defined. +

    +
    +
    + + +
    +

    Option 1 — Global interactivity (simplest)

    +

    + If you do not have pages that must render statically, apply an interactive + render mode globally on the router in App.razor. + This is what the Blazor Blueprint demo apps do, and it makes every component — including the + ones in your layout — interactive with no further work. +

    +
    +
    <!-- App.razor -->
    +<head>
    +    ...
    +    <HeadOutlet @@rendermode="InteractiveServer" />
    +</head>
    +<body>
    +    <Routes @@rendermode="InteractiveServer" />
    +    ...
    +</body>
    +
    +

    + With this in place, BbPortalHost, + BbToastProvider, and + BbDialogProvider in your + MainLayout work exactly as documented. +

    +
    + + +
    +

    Option 2 — Per-page interactivity with islands

    +

    + If some pages must stay static — for example, authentication pages that read or write + HttpContext, which is only available + during static rendering — do not set a global interactive mode. Instead: +

    +
      +
    1. Mark each interactive page with a per-page render mode.
    2. +
    3. + Move the interactive UI that lives in your layout (theme toggle, sidebar controls, your + own buttons) into small child components, and render those with + @@rendermode — creating an + interactivity island inside the otherwise static layout. +
    4. +
    + +

    Per-page interactivity:

    +
    +
    @@page "/dashboard"
    +@@rendermode InteractiveServer
    +
    +<BbButton OnClick="DoSomething">Works</BbButton>
    +
    + +

    An island for shared layout chrome:

    +
    +
    @@* InteractiveChrome.razor — the bits of the layout that need to be live *@@
    +<div class="flex items-center gap-2">
    +    <BbDarkModeToggle />
    +    <BbThemeSwitcher />
    +</div>
    +
    +
    +
    @@* MainLayout.razor — static layout, interactive islands *@@
    +@@inherits LayoutComponentBase
    +
    +<header>
    +    <InteractiveChrome @@rendermode="InteractiveServer" />
    +</header>
    +
    +<main>@@Body</main>
    +
    +<BbPortalHost @@rendermode="InteractiveServer" />
    +<BbToastProvider @@rendermode="InteractiveServer" />
    +<BbDialogProvider @@rendermode="InteractiveServer" />
    +
    +
    +

    + Caveats with islands. A component you mark with + @@rendermode can't receive + non-serializable parameters from its static parent, and you can't pass child + content (a RenderFragment) across + the boundary — which is exactly why @@Body + has to stay in the static layout. Islands work best for self-contained chrome. If your + layout is heavily interactive (a collapsible sidebar, command palette, etc.), Option 1 is + usually the better fit — and you can keep authentication on static endpoints or move + it to an interactivity-friendly auth flow (AuthenticationStateProvider) + instead of HttpContext. +

    +
    +
    + + +
    +

    A note on prerendering

    +

    + Interactive components still prerender statically first, then become + interactive once the circuit (Server) or runtime (WebAssembly) is ready. During that brief + first pass, event handlers aren't attached yet — this is expected and resolves on its own. + It only becomes a bug when the component never reaches interactivity, which is the + static-layout situation above. +

    +
    + + +
    +

    Troubleshooting checklist

    +
    +
    + 1. + A BbButton renders but its + OnClick never fires → the + component is rendering statically. Confirm its page (or the component itself) has an + interactive render mode. +
    +
    + 2. + It works only after adding a global + <Routes @@rendermode="InteractiveServer" /> + → your layout was static. Use Option 1, or move the interactive chrome into an island + (Option 2). +
    +
    + 3. + Overlays (Dialog, Sheet, Popover, Tooltip, Toast) don't appear → ensure + BbPortalHost is present and in an + interactive context, not a static layout. +
    +
    + 4. + Auth pages break under global interactivity → they likely depend on + HttpContext, which isn't + available in interactive renders. Keep those pages static and use Option 2 for the rest. +
    +
    +
    + +
    +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor b/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor index 8395df8e0..00b510530 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor @@ -937,6 +937,11 @@ Localization + + + Render Modes + + From cc90f4ebbfa24409b460087f1d97b377c159ec43 Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:43:54 +0100 Subject: [PATCH 068/188] feat(command): add ItemsProvider for server-side lazy loading in BbCommandVirtualizedGroup (#338) (#345) --- .../Components/Command/items-provider.txt | 47 ++++++ .../Pages/Components/CommandDemo.razor | 72 ++++++++ .../Command/BbCommandVirtualizedGroup.razor | 158 ++++++++++++++++-- .../Command/CommandItemsProvider.cs | 59 +++++++ ...entsApiSurfaceMatchesBaseline.verified.txt | 3 +- 5 files changed, 328 insertions(+), 11 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Command/items-provider.txt create mode 100644 src/BlazorBlueprint.Components/Components/Command/CommandItemsProvider.cs diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Command/items-provider.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Command/items-provider.txt new file mode 100644 index 000000000..b653902ce --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Command/items-provider.txt @@ -0,0 +1,47 @@ + + + + No records found. + + + @record.Name + #@record.Index + + + + + +@code { + private string? selected; + private void HandleSelect(string value) => selected = value; + + // ItemsProvider replaces the eager Items list: it is invoked as the user scrolls and + // whenever the search changes, so only the visible slice is ever fetched. Apply the + // search yourself (request.SearchText) and return the matching slice + total count. + private async ValueTask> LoadRecordsAsync( + CommandItemsProviderRequest request) + { + // In a real app this is a DB/API call, e.g.: + // var query = db.Records.Where(...); + // var total = await query.CountAsync(request.CancellationToken); + // var items = await query.Skip(request.StartIndex).Take(request.Count).ToListAsync(...); + var query = AllRecords.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(request.SearchText)) + { + query = query.Where(r => r.Name.Contains(request.SearchText, StringComparison.OrdinalIgnoreCase)); + } + + var matched = query.ToList(); + var slice = matched.Skip(request.StartIndex).Take(request.Count).ToList(); + + return new CommandItemsProviderResult<(string Name, int Index)> + { + Items = slice, + TotalItemCount = matched.Count, + }; + } +} diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CommandDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CommandDemo.razor index 893d1ba7c..cdfb4888e 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CommandDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CommandDemo.razor @@ -498,6 +498,48 @@ + +
    +

    Server-Side Lazy Loading (ItemsProvider)

    +

    + With EnableLazyLoading plus the + Items parameter you still have to + materialize the whole collection up front. Supply an + ItemsProvider instead to fetch only + the slice currently in view — ideal for data that lives in a database or API. The provider + receives the scroll range and the current search text and returns the matching slice plus a + total count. This example pages through 25,000 records (with a simulated + 150 ms latency) and never holds more than a screenful in the component. +

    + +
    +
    + + + + No records found. + + + @product.Name + #@product.Index + + + + +
    + @if (!string.IsNullOrEmpty(providerSelected)) + { +

    Selected: @providerSelected

    + } +
    + + +
    +

    Debounce

    @@ -717,6 +759,11 @@ .ToArray(); private static int TotalIconCount => _lucideIcons.Length + _featherIcons.Length + _heroIcons.Length; + // Stands in for a database/API for the ItemsProvider demo. The component never receives this list — + // only the slices the provider returns for the current scroll range + search. + private static readonly List<(string Name, int Index)> _allRecords = + Enumerable.Range(0, 25000).Select(i => ($"Record {i:00000}", i)).ToList(); + private IDisposable? _shortcutRegistration; // Independent state for each section @@ -730,6 +777,7 @@ private string? disabledSelected; private string? complexSelected; private string? lazySelected; + private string? providerSelected; // Individual handlers for each section private void HandlePaletteSelect(string value) @@ -745,6 +793,30 @@ private void HandleDisabledSelect(string value) => disabledSelected = value; private void HandleComplexSelect(string value) => complexSelected = value; private void HandleLazySelect(string value) => lazySelected = value; + private void HandleProviderSelect(string value) => providerSelected = value; + + // ItemsProvider for the server-side lazy-loading demo. In a real app this would query a DB or API; + // here it filters the in-memory stand-in, pages it, and adds a small delay to simulate latency. + private async ValueTask> LoadProductsAsync( + CommandItemsProviderRequest request) + { + await Task.Delay(150, request.CancellationToken); // simulate network/db latency + + IEnumerable<(string Name, int Index)> query = _allRecords; + if (!string.IsNullOrWhiteSpace(request.SearchText)) + { + query = query.Where(r => r.Name.Contains(request.SearchText, StringComparison.OrdinalIgnoreCase)); + } + + var matched = query.ToList(); + var slice = matched.Skip(request.StartIndex).Take(request.Count).ToList(); + + return new CommandItemsProviderResult<(string Name, int Index)> + { + Items = slice, + TotalItemCount = matched.Count, + }; + } protected override async Task OnAfterRenderAsync(bool firstRender) { diff --git a/src/BlazorBlueprint.Components/Components/Command/BbCommandVirtualizedGroup.razor b/src/BlazorBlueprint.Components/Components/Command/BbCommandVirtualizedGroup.razor index 2d94eff6d..0ba77ddab 100644 --- a/src/BlazorBlueprint.Components/Components/Command/BbCommandVirtualizedGroup.razor +++ b/src/BlazorBlueprint.Components/Components/Command/BbCommandVirtualizedGroup.razor @@ -26,7 +26,9 @@
    } - @if (_hasVisibleItems) + @* In provider mode the Virtualize stays in the tree even at count 0 (container hidden via + display:none above) so it remains mounted and re-queries the provider when the search changes. *@ + @if (_hasVisibleItems || UseProvider) { @if (EnableLazyLoading) { @@ -93,10 +95,25 @@ /// /// Gets or sets the collection of items to display. + /// Required unless is supplied (provider mode), in which case it is ignored. /// - [Parameter, EditorRequired] + [Parameter] public IReadOnlyList Items { get; set; } = Array.Empty(); + /// + /// Gets or sets an async callback that fetches items on demand for true lazy loading, + /// instead of materializing the whole collection into . + /// + /// + /// Requires to be true. The provider is invoked as the user + /// scrolls and whenever the search query changes; it is responsible for applying the search + /// () and returning the matching slice plus the + /// total matching count. When set, , , and the + /// component's built-in local filtering are not used. + /// + [Parameter] + public CommandItemsProvider? ItemsProvider { get; set; } + /// /// Gets or sets the template for rendering each item. /// @@ -155,6 +172,16 @@ private List? _lazyFilteredSource; // Full filtered list for lazy loading private int _lazyLoadedCount; // How many items currently loaded for lazy loading + // Provider mode (ItemsProvider supplied): the full set never lives in memory, so cache + // each slice the provider returns by absolute index for keyboard selection. + private CommandItemsProvider? _cachedProvider; + private readonly Dictionary _loadedItems = new(); + + /// + /// Whether items are sourced from rather than the in-memory . + /// + private bool UseProvider => ItemsProvider is not null; + private readonly struct IndexedItem { public readonly TItem Item; @@ -182,7 +209,15 @@ async Task IVirtualizedGroupHandler.SelectFocusedItemAsync() { - if (EnableLazyLoading) + if (UseProvider) + { + // The full set isn't in memory; the focused item must have been loaded into view to be focused. + if (_focusedIndex >= 0 && _loadedItems.TryGetValue(_focusedIndex, out var providerItem)) + { + await SelectItem(providerItem); + } + } + else if (EnableLazyLoading) { var source = _lazyFilteredSource ?? (IReadOnlyList)Items; if (source != null && _focusedIndex >= 0 && _focusedIndex < source.Count) @@ -246,6 +281,13 @@ protected override void OnInitialized() { + if (UseProvider && !EnableLazyLoading) + { + throw new InvalidOperationException( + $"{nameof(BbCommandVirtualizedGroup)}.{nameof(ItemsProvider)} requires {nameof(EnableLazyLoading)}=\"true\". " + + $"Set {nameof(EnableLazyLoading)} or supply {nameof(Items)} instead."); + } + if (Context != null) { Context.OnSearchChanged += HandleSearchChanged; @@ -253,8 +295,15 @@ Context.RegisterVirtualizedGroup(this); } _cachedItems = Items; + _cachedProvider = ItemsProvider; - if (EnableLazyLoading) + if (UseProvider) + { + // Counts and visibility are learned from the provider's first response; show the + // Virtualize so it can issue that first request. + _hasVisibleItems = true; + } + else if (EnableLazyLoading) { UpdateLazyFilteredSource(); } @@ -266,6 +315,24 @@ protected override async Task OnParametersSetAsync() { + if (UseProvider) + { + // Re-query if the provider delegate itself was swapped at runtime. + if (!ReferenceEquals(ItemsProvider, _cachedProvider)) + { + _cachedProvider = ItemsProvider; + _loadedItems.Clear(); + _lazyLoadedCount = 0; + _cachedSearchQuery = null; + _hasVisibleItems = true; + if (_virtualizeRef != null) + { + await _virtualizeRef.RefreshDataAsync(); + } + } + return; + } + // Only re-filter if Items reference actually changed if (!ReferenceEquals(Items, _cachedItems)) { @@ -427,9 +494,14 @@ _hasVisibleItems = _filteredCount > 0; } - private ValueTask> LoadItemsAsync( + private async ValueTask> LoadItemsAsync( Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderRequest request) { + if (UseProvider) + { + return await LoadFromProviderAsync(request); + } + // Determine the source list var source = _lazyFilteredSource ?? (IReadOnlyList)Items; var totalItems = source.Count; @@ -442,8 +514,8 @@ if (count <= 0) { - return ValueTask.FromResult(new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult( - Array.Empty(), totalItems)); + return new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult( + Array.Empty(), totalItems); } // Build the items for this batch @@ -457,15 +529,81 @@ // Track loaded count for keyboard navigation _lazyLoadedCount = Math.Max(_lazyLoadedCount, startIndex + count); - - return ValueTask.FromResult(new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult( - items, totalItems)); + return new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult( + items, totalItems); + } + + private async ValueTask> LoadFromProviderAsync( + Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderRequest request) + { + var searchText = Context?.SearchQuery; + var providerRequest = new CommandItemsProviderRequest + { + StartIndex = request.StartIndex, + Count = request.Count, + SearchText = string.IsNullOrWhiteSpace(searchText) ? null : searchText, + CancellationToken = request.CancellationToken, + }; + + CommandItemsProviderResult result; + try + { + result = await ItemsProvider!(providerRequest); + } + catch (OperationCanceledException) + { + // Request superseded (further scroll / new search) or component disposed — let Virtualize + // discard this batch without surfacing an error. + return new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult( + Array.Empty(), _filteredCount); + } + + var batch = result.Items as IList ?? result.Items.ToList(); + var indexed = new IndexedItem[batch.Count]; + for (int i = 0; i < batch.Count; i++) + { + var index = request.StartIndex + i; + indexed[i] = new IndexedItem(batch[i], index); + _loadedItems[index] = batch[i]; // cache by absolute index for keyboard selection + } + + _lazyLoadedCount = Math.Max(_lazyLoadedCount, request.StartIndex + batch.Count); + + // The provider is the source of truth for counts; reflect its total in the heading and + // visibility. Re-render the group (not just Virtualize) when those change. + var hadVisible = _hasVisibleItems; + if (_totalCount != result.TotalItemCount || _filteredCount != result.TotalItemCount || hadVisible != result.TotalItemCount > 0) + { + _totalCount = result.TotalItemCount; + _filteredCount = result.TotalItemCount; + _hasVisibleItems = result.TotalItemCount > 0; + StateHasChanged(); + } + + return new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult( + indexed, result.TotalItemCount); } private async void HandleSearchChanged() { _cachedSearchQuery = null; // Force rebuild + if (UseProvider) + { + // Drop the cached slices and optimistically re-show so the (kept-mounted) Virtualize + // re-queries the provider with the new search; the response resets the real count. + _loadedItems.Clear(); + _lazyLoadedCount = 0; + _hasVisibleItems = true; + _focusedIndex = -1; + StateHasChanged(); + if (_virtualizeRef != null) + { + await _virtualizeRef.RefreshDataAsync(); + } + return; + } + if (EnableLazyLoading) { UpdateLazyFilteredSource(); diff --git a/src/BlazorBlueprint.Components/Components/Command/CommandItemsProvider.cs b/src/BlazorBlueprint.Components/Components/Command/CommandItemsProvider.cs new file mode 100644 index 000000000..e56f048d3 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Command/CommandItemsProvider.cs @@ -0,0 +1,59 @@ +namespace BlazorBlueprint.Components; + +/// +/// Delegate for asynchronous, server-side data fetching in a . +/// Invoked as the user scrolls (and whenever the search query changes) when +/// EnableLazyLoading is true and an ItemsProvider is supplied — so the caller +/// never has to materialize the full collection up front. +/// +/// The type of data items. +/// The request describing the slice to fetch, the active search text, and a cancellation token. +/// A result containing the items for the requested slice and the total (filtered) count. +public delegate ValueTask> CommandItemsProvider( + CommandItemsProviderRequest request); + +/// +/// Describes the data request from to the items provider. +/// +public class CommandItemsProviderRequest +{ + /// + /// Gets the zero-based index of the first item to return. + /// + public int StartIndex { get; init; } + + /// + /// Gets the maximum number of items to return for this slice. + /// + public int Count { get; init; } + + /// + /// Gets the active search text the provider should filter by, or null when no search is active. + /// Filtering is the provider's responsibility — the component does not filter provider results locally. + /// + public string? SearchText { get; init; } + + /// + /// Gets the cancellation token for the request. Cancelled when the request is superseded + /// (e.g. the user keeps scrolling or changes the search) or the component is disposed. + /// + public CancellationToken CancellationToken { get; init; } +} + +/// +/// The result returned by a . +/// +/// The type of data items. +public class CommandItemsProviderResult +{ + /// + /// Gets the items for the requested slice. + /// + public required ICollection Items { get; init; } + + /// + /// Gets the total number of items matching the current search across all slices. + /// Used to size the scroll area and drive keyboard navigation. + /// + public int TotalItemCount { get; init; } +} diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 4b36f266d..d71107c8c 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -555,7 +555,8 @@ - ItemSearchText : Func - ItemTemplate : RenderFragment [EditorRequired] - ItemValue : Func [EditorRequired] - - Items : IReadOnlyList [EditorRequired] + - Items : IReadOnlyList + - ItemsProvider : CommandItemsProvider - LazyLoadBatchSize : Int32 - MaxDisplayCount : Int32 - Context : CommandContext [CascadingParameter] From d9eb8c11532b846731702b326e565d2497bc9ebf Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Sat, 6 Jun 2026 14:33:56 +0100 Subject: [PATCH 069/188] feat(datagrid): CellClass/HeaderClass on BbDataGridSelectColumn (#332) (#346) * feat(datagrid): support CellClass/HeaderClass on BbDataGridSelectColumn (#332) * docs(datagrid): add compact-rows demo + API reference for select column CellClass/HeaderClass (#332) --- .../Components/DataGrid/compact-rows.txt | 16 ++++++++++ .../Pages/Components/DataGridDemo.razor | 31 +++++++++++++++++++ .../Components/DataGrid/BbDataGrid.razor.cs | 8 +++-- .../DataGrid/BbDataGridSelectColumn.razor.cs | 18 +++++++++-- ...entsApiSurfaceMatchesBaseline.verified.txt | 2 ++ 5 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/compact-rows.txt diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/compact-rows.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/compact-rows.txt new file mode 100644 index 000000000..4f3aa20a9 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/compact-rows.txt @@ -0,0 +1,16 @@ + + + @* CellClass/HeaderClass override the column's default padding. Apply it to the + select column too, otherwise its padding forces a taller row than the rest. *@ + + + + + + + diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor index 8ee3f0292..022083cb6 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor @@ -149,6 +149,31 @@
    + +
    +
    +

    Compact Rows (CellClass / HeaderClass)

    +

    + Every column — including BbDataGridSelectColumn — + accepts CellClass and + HeaderClass. Apply a smaller padding such as + CellClass="p-1" to every column, the checkbox column + included, for a dense grid — the select column no longer forces a taller row. +

    +
    + + + + + + + + + + +
    +
    @@ -1062,6 +1087,12 @@ Column width for the checkbox column. Defaults to a compact checkbox width. + + Additional CSS classes applied to the selection cells. Use e.g. "p-1" to override the default padding for a compact row height. + + + Additional CSS classes applied to the header cell (the select-all checkbox). + Whether this column is pinned. Commonly set to BlazorBlueprint.Primitives.DataGrid.ColumnPinning.Left to keep the checkbox column visible when scrolling horizontally. diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs index 959d7b0ab..9e6d446b3 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs @@ -2843,7 +2843,9 @@ private string GetHeaderCellClass(IDataGridColumn column, bool isSelectCo if (isSelectColumn || isExpandColumn) { - return ClassNames.cn(baseClass, "w-12", pinnedClass, separatorClass); + // column.HeaderClass last so callers can override the baked-in width/padding + // (e.g. compact select column). cn() is tailwind-merge, so later classes win. + return ClassNames.cn(baseClass, "w-12", pinnedClass, separatorClass, column.HeaderClass); } var needsGroup = column.Sortable || column.Filterable || (Reorderable && column.Reorderable); @@ -2909,7 +2911,9 @@ private string GetCellClass(IDataGridColumn column, bool isSelectColumn, if (isSelectColumn || isExpandColumn) { - return ClassNames.cn(baseClass, "w-12", pinnedClass, separatorClass); + // column.CellClass last so callers can override the baked-in width/padding + // (e.g. CellClass="p-1" for a compact select column). cn() is tailwind-merge. + return ClassNames.cn(baseClass, "w-12", pinnedClass, separatorClass, column.CellClass); } var cellClass = column.CellClass; diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridSelectColumn.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridSelectColumn.razor.cs index 1f8cba0cc..1a42182b5 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridSelectColumn.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridSelectColumn.razor.cs @@ -26,6 +26,20 @@ public partial class BbDataGridSelectColumn : ComponentBase, IDataGridCol [Parameter] public ColumnPinning Pinned { get; set; } = ColumnPinning.None; + /// + /// Additional CSS classes for the selection cells. Useful for matching a compact row height — + /// e.g. CellClass="p-1" to override the default cell padding so the checkbox column + /// doesn't force a taller row than the rest of the grid. + /// + [Parameter] + public string? CellClass { get; set; } + + /// + /// Additional CSS classes for the header cell (the select-all checkbox). + /// + [Parameter] + public string? HeaderClass { get; set; } + /// /// The parent DataGrid component. Set via cascading parameter. /// @@ -58,9 +72,9 @@ public partial class BbDataGridSelectColumn : ComponentBase, IDataGridCol RenderFragment>? IDataGridColumn.HeaderTemplate => null; - string? IDataGridColumn.CellClass => null; + string? IDataGridColumn.CellClass => CellClass; - string? IDataGridColumn.HeaderClass => null; + string? IDataGridColumn.HeaderClass => HeaderClass; bool IDataGridColumn.NoWrap => false; diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index d71107c8c..d595316b8 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -780,6 +780,8 @@ - ParentGrid : BbDataGrid [CascadingParameter] ### BbDataGridSelectColumn`1 (BlazorBlueprint.Components) + - CellClass : String + - HeaderClass : String - Pinned : ColumnPinning - Width : String - ParentGrid : BbDataGrid [CascadingParameter] From 53b4aaa41629ade990efd0b779f93d520e2fc6af Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:38:35 +0100 Subject: [PATCH 070/188] fix(combobox,multiselect): restore focus to trigger on close so Tab continues (#349) --- .../Components/Combobox/BbCombobox.razor | 2 +- .../Components/Combobox/BbCombobox.razor.cs | 17 +++++++++++++- .../MultiSelect/BbMultiSelect.razor | 2 +- .../MultiSelect/BbMultiSelect.razor.cs | 22 ++++++++++++++++--- .../Components/Popover/BbPopover.razor | 9 ++++++++ .../Primitives/Popover/BbPopover.razor | 15 ++++++++++++- ...entsApiSurfaceMatchesBaseline.verified.txt | 1 + ...ivesApiSurfaceMatchesBaseline.verified.txt | 1 + 8 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor index 15b3b5df7..e39276c5a 100644 --- a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor @@ -3,7 +3,7 @@ @attribute [CascadingTypeParameter(nameof(TValue))]
    - + private bool _focusDone; + /// + /// Whether the next controlled close should return focus to the trigger. Set true on + /// selection (intentional close) and reset on open. Bound to the popover's RestoreFocusOnClose. + /// + private bool _restoreFocusOnClose; + /// /// Item text registry for compositional mode display text lookup. /// @@ -412,6 +418,12 @@ private async Task HandleContentReady() private async Task HandleOpenChanged(bool isOpen) { _isOpen = isOpen; + if (isOpen) + { + // Default to NOT restoring focus; only a selection-close opts in (see HandleSelect). + // This keeps click-outside dismissal leaving focus where the user clicked. + _restoreFocusOnClose = false; + } if (!isOpen) { _focusDone = false; // Reset for next open @@ -453,7 +465,10 @@ private async Task HandleSelect(SelectOption option) _editContext.NotifyFieldChanged(_fieldIdentifier); } - // Close the popover after selection + // Close the popover after selection — an intentional close, so return focus to the + // trigger (the popover content unmounts; without this, focus is lost to and the + // next Tab restarts from the top of the document). + _restoreFocusOnClose = true; _isOpen = false; // Note: _focusDone is reset by HandleOpenChanged } diff --git a/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor b/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor index 6e162c6ab..a2f612958 100644 --- a/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor +++ b/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor @@ -5,7 +5,7 @@
    - + : ComponentBase, IAsyncDisposable /// private bool _isOpen { get; set; } + /// + /// Whether the next close should return focus to the trigger. True for intentional + /// dismissals (Escape, the Close button); false for click-outside (focus stays where the + /// user clicked). Bound to the popover's RestoreFocusOnClose. + /// + private bool _restoreFocusOnClose; + /// /// Tracks the current search query for filtering. /// @@ -442,14 +449,23 @@ private void Open() return; } + _restoreFocusOnClose = false; // reset; intentional closes opt back in _isOpen = true; } /// - /// Closes the dropdown. + /// Closes the dropdown as an intentional dismissal (Escape, Close button), returning focus + /// to the trigger so keyboard navigation continues from the right place. + /// + private Task Close() => CloseCore(restoreFocus: true); + + /// + /// Closes the dropdown. controls whether focus returns to the + /// trigger — true for intentional dismissals, false for click-outside (leave focus where clicked). /// - private async Task Close() + private async Task CloseCore(bool restoreFocus) { + _restoreFocusOnClose = restoreFocus; _isOpen = false; _searchQuery = string.Empty; @@ -474,7 +490,7 @@ private EventCallback GetClickOutsideHandler() /// /// Handles click-outside events when AutoClose is enabled. /// - private async Task HandleClickOutside() => await Close(); + private async Task HandleClickOutside() => await CloseCore(restoreFocus: false); /// /// Handles the popover content ready event to focus the search input. diff --git a/src/BlazorBlueprint.Components/Components/Popover/BbPopover.razor b/src/BlazorBlueprint.Components/Components/Popover/BbPopover.razor index 4e6e963ed..2d2af20c4 100644 --- a/src/BlazorBlueprint.Components/Components/Popover/BbPopover.razor +++ b/src/BlazorBlueprint.Components/Components/Popover/BbPopover.razor @@ -9,6 +9,7 @@ OpenChanged="@OpenChanged" DefaultOpen="@DefaultOpen" OnOpenChange="@OnOpenChange" + RestoreFocusOnClose="@RestoreFocusOnClose" Modal="@Modal"> @ChildContent @@ -53,4 +54,12 @@ /// [Parameter] public bool Modal { get; set; } = true; + + /// + /// When the popover is closed via the controlled binding (consumer-driven, + /// e.g. after selecting an item), whether to return focus to the trigger. Defaults to false. + /// Click-outside and Escape dismissals are unaffected. + /// + [Parameter] + public bool RestoreFocusOnClose { get; set; } } diff --git a/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopover.razor b/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopover.razor index a606b3755..ed0f290b6 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopover.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopover.razor @@ -51,6 +51,16 @@ [Parameter] public bool Modal { get; set; } = true; + /// + /// When the popover is closed via the controlled binding (i.e. the consumer + /// set it to false — e.g. after selecting an item), whether to return focus to the trigger so + /// keyboard navigation (Tab) continues from the right place. Defaults to false to preserve + /// existing behavior. Dismissals that originate inside the popover (click-outside, Escape) are + /// unaffected — they already manage focus themselves. + /// + [Parameter] + public bool RestoreFocusOnClose { get; set; } + protected override void OnInitialized() { // Initialize controllable state @@ -92,7 +102,10 @@ } else { - _context.Close(); + // Parent-initiated close (consumer set Open=false). Honour the consumer's + // focus-restore intent — click-outside/Escape never reach here (they update + // the context first, so this runs only when the parent drives the close). + _context.Close(RestoreFocusOnClose); } _state.ControlledValue = Open.Value; diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index d595316b8..1aea11b98 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -2411,6 +2411,7 @@ - OnOpenChange : EventCallback - Open : Boolean? - OpenChanged : EventCallback + - RestoreFocusOnClose : Boolean ### BbPopoverContent (BlazorBlueprint.Components) - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt index c23858b9b..b8ca6d795 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt @@ -459,6 +459,7 @@ - OnOpenChange : EventCallback - Open : Boolean? - OpenChanged : EventCallback + - RestoreFocusOnClose : Boolean ### BbPopoverContent (BlazorBlueprint.Primitives.Popover) - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] From 13ab0e5ea14b3e2464c7ac3a8323d697d0b9c419 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Mon, 8 Jun 2026 21:44:16 +0800 Subject: [PATCH 071/188] docs: update CHANGELOG for unreleased fixes/features; add PR-comment and demo-example rules to CLAUDE.md --- CHANGELOG.md | 16 ++++++++++++++++ CLAUDE.md | 2 ++ 2 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 167314f2f..934f2acfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## 2026-06-08 + +### Fixed + +- **BbCombobox & BbMultiSelect: keyboard focus lost after selecting** — Selecting an item in `BbCombobox`, or closing `BbMultiSelect` via Escape or its Close button, left keyboard focus on the now-unmounted popover content, so focus fell back to the page root and the next Tab resumed from the top of the page. Focus is now returned to the trigger on these intentional closes — extending the behavior added for Select/DropdownMenu/Popover — while click-outside still leaves focus where the user clicked. A new `RestoreFocusOnClose` parameter on `BbPopover` carries the consumer's focus-restore intent. ([#349](https://github.com/blazorblueprintui/ui/pull/349)) + +--- + ## 2026-06-06 +### Added + +- **BbCommandVirtualizedGroup: ItemsProvider for server-side lazy loading** — New `ItemsProvider` parameter (`CommandItemsProvider`) lets the virtualized command group fetch only the slice currently in view on demand — with the active search delegated to the provider — instead of materializing the entire collection, so `EnableLazyLoading` now supports true server-side data. `Items` is no longer required when a provider is supplied. A demo example was added. ([#338](https://github.com/blazorblueprintui/ui/pull/338)) +- **BbDataGridSelectColumn: CellClass and HeaderClass** — The selection (checkbox) column now accepts `CellClass` and `HeaderClass`, matching property columns, so it can adopt compact padding (e.g. `CellClass="p-1"`) instead of forcing a taller row than the rest of the grid. A "Compact Rows" demo example was added. ([#332](https://github.com/blazorblueprintui/ui/pull/332)) +- **Render Modes guide** — New documentation guide, "Render Modes & Interactive Layouts," explaining why interactive components in a layout require an interactive render mode and how to combine per-page interactivity islands with static `HttpContext`-dependent pages, plus a README setup note. ([#339](https://github.com/blazorblueprintui/ui/pull/339)) + ### Fixed - **BbCombobox: trigger showed placeholder for pre-bound values** — In compositional mode, when `Value` was bound before the dropdown was first opened, the combobox trigger displayed the placeholder instead of the selected item's text, because the item's text was only registered once the item mounted. The trigger now resolves its display text from a caller-supplied `SelectedItemText` parameter and from item registration, so pre-selected values render correctly on first paint. ([#337](https://github.com/blazorblueprintui/ui/pull/337)) - **Select, DropdownMenu, Popover: keyboard focus lost after closing** — Closing one of these overlays by an intentional action (pressing Escape or selecting an item) left keyboard focus on the now-unmounted content, so focus fell back to the page root and a subsequent Tab resumed from the top of the page. Focus is now returned to the trigger on intentional close; external dismissals (click-outside, Tab) deliberately leave focus where the user moved it. ([#336](https://github.com/blazorblueprintui/ui/issues/336)) +- **BbCombobox: pre-bound value blank in compositional mode until opened** — Building on [#337](https://github.com/blazorblueprintui/ui/pull/337), compositional `BbComboboxItem` children now register their display text on initial render (via a hidden registration pass), so a value bound before the dropdown is first opened shows its label immediately on refresh instead of staying blank until the dropdown is opened and closed. ([#340](https://github.com/blazorblueprintui/ui/pull/340)) +- **Nested overlays froze the page** — The body scroll lock shared by Dialog, Sheet, Drawer, and AlertDialog was not reference-counted, so closing a nested overlay could re-apply a stale "locked" state and leave the page unscrollable until a refresh. The lock is now reference-counted — the original scroll state is captured once on the first lock and restored only when the last overlay closes (with a guard against double-release). ([#329](https://github.com/blazorblueprintui/ui/pull/329)) --- diff --git a/CLAUDE.md b/CLAUDE.md index b39d711f0..d1c8551ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Never commit to git unless explicitly instructed to - This application does not support hot-reload — rebuild to see changes - Do NOT add `Co-Authored-By` lines to commit messages +- Do NOT add "Generated with Claude" / AI-attribution footers to PR bodies or GitHub issue/PR comments +- When adding/changing public component API (new params, components, or features), add a demo example in `demos/BlazorBlueprint.Demo.Shared` (live example + `CodeExamples/.../*.txt` snippet + API Reference entry) — don't just temp-add an example to test and then revert it - Always create a new branch from `develop` when starting work, unless explicitly told otherwise --- From e2039fc2cf7e68620ed81bd779318c5e466ea291 Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:17:00 +0100 Subject: [PATCH 072/188] fix(collapsible,dropdown-menu): Space/Enter no longer double-toggles the trigger (#350) --- .../Collapsible/BbCollapsibleTrigger.razor | 4 ++- .../Collapsible/BbCollapsibleTrigger.razor.cs | 26 +++---------------- .../DropdownMenu/BbDropdownMenuTrigger.razor | 11 ++++---- 3 files changed, 11 insertions(+), 30 deletions(-) diff --git a/src/BlazorBlueprint.Primitives/Primitives/Collapsible/BbCollapsibleTrigger.razor b/src/BlazorBlueprint.Primitives/Primitives/Collapsible/BbCollapsibleTrigger.razor index dd221fd74..80f815076 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Collapsible/BbCollapsibleTrigger.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Collapsible/BbCollapsibleTrigger.razor @@ -10,12 +10,14 @@ } else { + @* A native diff --git a/src/BlazorBlueprint.Primitives/Primitives/Collapsible/BbCollapsibleTrigger.razor.cs b/src/BlazorBlueprint.Primitives/Primitives/Collapsible/BbCollapsibleTrigger.razor.cs index c339f6fcd..eff3f160b 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Collapsible/BbCollapsibleTrigger.razor.cs +++ b/src/BlazorBlueprint.Primitives/Primitives/Collapsible/BbCollapsibleTrigger.razor.cs @@ -116,27 +116,7 @@ private async Task HandleClick(MouseEventArgs args) } } - /// - /// Handles keyboard events to support keyboard navigation (Space/Enter keys). - /// - /// The keyboard event arguments. - /// A task that represents the asynchronous operation. - /// - /// Responds to Space and Enter keys for keyboard interaction. - /// - private async Task HandleKeyDown(KeyboardEventArgs args) - { - if (Context?.Disabled ?? true) - { - return; - } - - if (args.Key == " " || args.Key == "Enter") - { - if (Context?.Toggle != null) - { - await Context.Toggle.Invoke(); - } - } - } + // Note: no keydown handler. The rendered element is a native + } +
    + } +
    + + @if (!IsFloating) + { +
    + +
    + } +
    + +
    + @foreach (var panelId in group.PanelIds) + { + var panel = Dock.GetPanel(panelId); + if (panel is null) + { + continue; + } + + var isActive = group.ActivePanelId == panelId; +
    + @panel.ChildContent +
    + } +
    +
    diff --git a/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor.cs b/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor.cs new file mode 100644 index 000000000..ccf245ad2 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor.cs @@ -0,0 +1,82 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; + +namespace BlazorBlueprint.Components; + +/// +/// Renders a single dock tab group: a tab strip plus the active panel's content. +/// Used internally by ; not intended for direct consumption. +/// +public partial class BbDockTabGroup : ComponentBase +{ + [CascadingParameter] + private BbDock Dock { get; set; } = null!; + + /// + /// The tab group node to render. Typed as so the internal layout + /// model stays out of the public API; it is always an internal tab group node. + /// + [Parameter, EditorRequired] + public object Group { get; set; } = null!; + + /// + /// When set, this group is the root of a floating window with the given identifier. + /// The tab strip then acts as the window's drag handle. + /// + [Parameter] + public string? FloatingWindowId { get; set; } + + private DockTabGroupNode group => (DockTabGroupNode)Group; + + private bool IsFloating => FloatingWindowId is not null; + + private bool IsMaximized => Dock is not null && Dock.IsMaximized(group); + + /// + protected override void OnInitialized() + { + if (Dock is null) + { + throw new InvalidOperationException($"{nameof(BbDockTabGroup)} must be used within a {nameof(BbDock)}."); + } + } + + private async Task HandleTabPointerDown(BbDockPanel panel, PointerEventArgs e) + { + // Primary button only; let the click handler perform activation. + if (e.Button != 0) + { + return; + } + + await Dock.StartTabDragAsync(panel.Id, e); + } + + private async Task HandleStripPointerDown(PointerEventArgs e) + { + if (IsFloating && e.Button == 0 && FloatingWindowId is not null) + { + await Dock.StartWindowDragAsync(FloatingWindowId, e); + } + } + + private string RootClass => ClassNames.cn( + "flex h-full w-full min-w-0 flex-col overflow-hidden bg-background", + // Floating windows already have a bordered wrapper; docked groups draw their own + // outline so adjacent panels read as distinct, framed surfaces (VS-style). + IsFloating ? null : "border border-border/70"); + + private string StripClass => ClassNames.cn( + "flex h-8 shrink-0 items-stretch border-b border-border/60 bg-muted/50", + IsFloating ? "cursor-move" : null); + + private static string TabClass(bool isActive) => ClassNames.cn( + "group/tab relative flex h-full min-w-[88px] max-w-[200px] cursor-grab items-center gap-1.5 border-r border-border/40 px-2.5 text-xs transition-colors active:cursor-grabbing", + isActive + ? "z-10 -mb-px border-b border-background bg-background text-foreground after:absolute after:inset-x-0 after:top-0 after:h-[2px] after:bg-primary" + : "bg-transparent text-muted-foreground hover:bg-background/50 hover:text-foreground"); + + private static string CloseClass(bool isActive) => ClassNames.cn( + "ml-auto inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-sm transition-opacity hover:bg-foreground/10 hover:!opacity-100", + isActive ? "opacity-60" : "opacity-0 group-hover/tab:opacity-60"); +} diff --git a/src/BlazorBlueprint.Components/Components/Dock/DockModel.cs b/src/BlazorBlueprint.Components/Components/Dock/DockModel.cs new file mode 100644 index 000000000..15d8658a8 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dock/DockModel.cs @@ -0,0 +1,126 @@ +using System.Collections.Generic; + +namespace BlazorBlueprint.Components; + +/// +/// Orientation of a dock split node. +/// +internal enum DockOrientation +{ + /// Children are arranged left-to-right. + Horizontal, + + /// Children are arranged top-to-bottom. + Vertical +} + +/// +/// Base type for a node in the dock layout tree. A node is either a +/// (a horizontal/vertical split) or a +/// (a set of tabbed panels). +/// +internal abstract class DockNode +{ + /// Stable identity for the node, used for diffing and keying. + public string Id { get; init; } = Guid.NewGuid().ToString("N"); +} + +/// +/// A split container that arranges two or more child nodes along an axis. +/// +internal sealed class DockSplitNode : DockNode +{ + /// The axis along which children are arranged. + public DockOrientation Orientation { get; set; } + + /// The ordered child nodes. + public List Children { get; set; } = new(); + + /// The size of each child as a percentage of the split (parallel to ). + public List Sizes { get; set; } = new(); + + /// Returns a structural signature that changes whenever the split's shape changes. + public string Signature => $"{Orientation}:{string.Join(',', Children.Select(c => c.Id))}"; + + /// Ensures the list matches the number of children, distributing evenly. + public void NormalizeSizes() + { + if (Children.Count == 0) + { + Sizes.Clear(); + return; + } + + if (Sizes.Count != Children.Count) + { + var even = 100.0 / Children.Count; + Sizes = Enumerable.Repeat(even, Children.Count).ToList(); + return; + } + + var total = Sizes.Sum(); + if (total <= 0) + { + var even = 100.0 / Children.Count; + Sizes = Enumerable.Repeat(even, Children.Count).ToList(); + return; + } + + // Re-scale to total 100 so flex percentages stay sensible. + for (var i = 0; i < Sizes.Count; i++) + { + Sizes[i] = Sizes[i] / total * 100.0; + } + } +} + +/// +/// A group of panels rendered as tabs. Exactly one panel is active at a time. +/// +internal sealed class DockTabGroupNode : DockNode +{ + /// The ordered panel identifiers shown as tabs. + public List PanelIds { get; set; } = new(); + + /// The identifier of the currently active panel, if any. + public string? ActivePanelId { get; set; } + + /// Ensures references a panel that is still present. + public void EnsureActive() + { + if (PanelIds.Count == 0) + { + ActivePanelId = null; + return; + } + + if (ActivePanelId is null || !PanelIds.Contains(ActivePanelId)) + { + ActivePanelId = PanelIds[0]; + } + } +} + +/// +/// A free-floating window that hosts a tab group detached from the docked layout. +/// +internal sealed class DockFloatingWindow +{ + /// Stable identity for the window. + public string Id { get; init; } = Guid.NewGuid().ToString("N"); + + /// The tab group hosted by this window. + public DockTabGroupNode Group { get; set; } = new(); + + /// Horizontal offset (pixels) within the dock surface. + public double X { get; set; } + + /// Vertical offset (pixels) within the dock surface. + public double Y { get; set; } + + /// Window width in pixels. + public double Width { get; set; } = 360; + + /// Window height in pixels. + public double Height { get; set; } = 260; +} diff --git a/src/BlazorBlueprint.Components/Components/Dock/DockZone.cs b/src/BlazorBlueprint.Components/Components/Dock/DockZone.cs new file mode 100644 index 000000000..00ebcf304 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dock/DockZone.cs @@ -0,0 +1,33 @@ +namespace BlazorBlueprint.Components; + +/// +/// Identifies a region of a dock target. Used both for the initial placement of a +/// and for the drop zone resolved while dragging a tab. +/// +public enum DockZone +{ + /// + /// The center of the target. Dropping here adds the panel as a tab to the target group. + /// + Center, + + /// + /// The left edge of the target. Dropping here creates a horizontal split with the panel on the left. + /// + Left, + + /// + /// The right edge of the target. Dropping here creates a horizontal split with the panel on the right. + /// + Right, + + /// + /// The top edge of the target. Dropping here creates a vertical split with the panel on top. + /// + Top, + + /// + /// The bottom edge of the target. Dropping here creates a vertical split with the panel on the bottom. + /// + Bottom +} diff --git a/src/BlazorBlueprint.Components/wwwroot/js/dock.js b/src/BlazorBlueprint.Components/wwwroot/js/dock.js new file mode 100644 index 000000000..a3eeb4a9f --- /dev/null +++ b/src/BlazorBlueprint.Components/wwwroot/js/dock.js @@ -0,0 +1,409 @@ +// Docking interactions for BbDock. +// +// Handles three pointer-driven gestures: +// 1. Dragging a panel tab to re-dock it (as a tab or a split), to a dock edge, or out to a +// floating window. Draws a live drop indicator and reports the resolved drop to .NET. +// 2. Dragging a floating window by its tab strip to reposition it. +// 3. Dragging a floating window's resize grip to resize it. +// +// .NET owns the layout model; this module only computes drop targets and reports results. + +const docks = new Map(); +const DRAG_THRESHOLD = 4; +const ACCENT_BG = "rgba(59, 130, 246, 0.28)"; +const ACCENT_BORDER = "2px solid rgba(59, 130, 246, 0.9)"; + +export function initializeDock(dockId, rootEl, dotNetRef) { + if (!dockId || !rootEl || !dotNetRef) { + return; + } + docks.set(dockId, { rootEl, dotNetRef }); +} + +export function disposeDock(dockId) { + docks.delete(dockId); +} + +// ---------------------------------------------------------------- shared pointer session + +function attachSession(handlers) { + const onMove = (e) => handlers.move(e); + const onUp = (e) => { + cleanup(); + handlers.up(e); + }; + const cleanup = () => { + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerup", onUp); + document.removeEventListener("pointercancel", onUp); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + }; + + document.addEventListener("pointermove", onMove); + document.addEventListener("pointerup", onUp); + document.addEventListener("pointercancel", onUp); + document.body.style.userSelect = "none"; + return cleanup; +} + +// ---------------------------------------------------------------- tab drag → docking + +export function startTabDrag(dockId, title, clientX, clientY, pointerId) { + const dock = docks.get(dockId); + if (!dock) { + return; + } + + const state = { + dock, + title: title || "Panel", + pointerId, + startX: clientX, + startY: clientY, + active: false, + ghost: null, + indicator: null, + target: null + }; + + attachSession({ + move: (e) => { + if (e.pointerId !== pointerId) { + return; + } + onTabMove(state, e); + }, + up: (e) => { + if (e.pointerId !== pointerId) { + return; + } + onTabUp(state, e); + } + }); +} + +function onTabMove(state, e) { + if (!state.active) { + const dx = e.clientX - state.startX; + const dy = e.clientY - state.startY; + if (Math.hypot(dx, dy) < DRAG_THRESHOLD) { + return; + } + state.active = true; + state.ghost = createGhost(state.title); + document.body.style.cursor = "grabbing"; + } + + moveGhost(state.ghost, e.clientX, e.clientY); + state.target = computeTarget(state.dock, e.clientX, e.clientY); + drawIndicator(state, state.target); +} + +function onTabUp(state, e) { + removeEl(state.ghost); + removeEl(state.indicator); + state.ghost = null; + state.indicator = null; + + if (!state.active) { + // No movement past the threshold — treat as a click, not a drag. + return; + } + + const t = state.target || { type: "none" }; + let floatX = 0; + let floatY = 0; + + if (t.type === "float") { + const r = state.dock.rootEl.getBoundingClientRect(); + floatX = e.clientX - r.left - 24; + floatY = e.clientY - r.top - 12; + } + + state.dock.dotNetRef + .invokeMethodAsync("OnTabDropped", t.type, t.groupId || null, t.zone || "center", floatX, floatY, typeof t.index === "number" ? t.index : -1) + .catch(() => { }); +} + +function computeTarget(dock, x, y) { + const r = dock.rootEl.getBoundingClientRect(); + + if (x < r.left || x > r.right || y < r.top || y > r.bottom) { + return { type: "float" }; + } + + // Hovering a tab strip reorders within (or into) that group without changing the layout. + const stripEl = findClosestAt(dock, x, y, "[data-dock-tabstrip]"); + if (stripEl) { + return { + type: "reorder", + groupId: stripEl.getAttribute("data-dock-tabstrip"), + index: computeInsertIndex(stripEl, x) + }; + } + + // A band along the dock's outer border docks against the whole dock. + const edge = 26; + if (x - r.left < edge) { + return { type: "root", zone: "left" }; + } + if (r.right - x < edge) { + return { type: "root", zone: "right" }; + } + if (y - r.top < edge) { + return { type: "root", zone: "top" }; + } + if (r.bottom - y < edge) { + return { type: "root", zone: "bottom" }; + } + + const groupEl = findGroupAt(dock, x, y); + if (!groupEl) { + return { type: "float" }; + } + + const gid = groupEl.getAttribute("data-dock-group"); + const zone = zoneWithin(groupEl.getBoundingClientRect(), x, y); + return { type: "group", groupId: gid, zone }; +} + +function findGroupAt(dock, x, y) { + return findClosestAt(dock, x, y, "[data-dock-group]"); +} + +function findClosestAt(dock, x, y, selector) { + const els = document.elementsFromPoint(x, y); + for (const el of els) { + if (!el.closest) { + continue; + } + const match = el.closest(selector); + if (match && dock.rootEl.contains(match)) { + return match; + } + } + return null; +} + +// The insertion slot (0..tabCount) for a pointer x over a tab strip, using each tab's midpoint. +function computeInsertIndex(stripEl, x) { + const tabs = stripEl.querySelectorAll("[data-dock-tab]"); + for (let i = 0; i < tabs.length; i++) { + const tr = tabs[i].getBoundingClientRect(); + if (x < tr.left + tr.width / 2) { + return i; + } + } + return tabs.length; +} + +function zoneWithin(r, x, y) { + const rx = (x - r.left) / r.width; + const ry = (y - r.top) / r.height; + const m = 0.22; + const inMidX = rx > m && rx < 1 - m; + const inMidY = ry > m && ry < 1 - m; + + if (rx < m && inMidY) { + return "left"; + } + if (rx > 1 - m && inMidY) { + return "right"; + } + if (ry < m && inMidX) { + return "top"; + } + if (ry > 1 - m && inMidX) { + return "bottom"; + } + return "center"; +} + +function drawIndicator(state, t) { + if (!t || t.type === "none" || t.type === "float") { + if (state.indicator) { + state.indicator.style.display = "none"; + } + return; + } + + if (!state.indicator) { + const ind = document.createElement("div"); + ind.style.cssText = + "position:fixed;z-index:10001;pointer-events:none;border-radius:6px;box-sizing:border-box;" + + "transition:left .07s ease,top .07s ease,width .07s ease,height .07s ease;"; + ind.style.background = ACCENT_BG; + ind.style.border = ACCENT_BORDER; + document.body.appendChild(ind); + state.indicator = ind; + } + + const ind = state.indicator; + ind.style.display = "block"; + + let box; + if (t.type === "reorder") { + const stripEl = state.dock.rootEl.querySelector(`[data-dock-tabstrip="${t.groupId}"]`); + if (!stripEl) { + ind.style.display = "none"; + return; + } + box = insertionLineBox(stripEl, t.index); + // A solid caret line between tabs rather than a translucent fill. + ind.style.background = "rgba(59, 130, 246, 0.95)"; + ind.style.border = "none"; + } else { + ind.style.background = ACCENT_BG; + ind.style.border = ACCENT_BORDER; + if (t.type === "root") { + box = zoneBox(state.dock.rootEl.getBoundingClientRect(), t.zone, 0.3); + } else { + const gEl = state.dock.rootEl.querySelector(`[data-dock-group="${t.groupId}"]`); + if (!gEl) { + ind.style.display = "none"; + return; + } + box = zoneBox(gEl.getBoundingClientRect(), t.zone, 0.5); + } + } + + ind.style.left = `${box.left}px`; + ind.style.top = `${box.top}px`; + ind.style.width = `${box.width}px`; + ind.style.height = `${box.height}px`; +} + +function insertionLineBox(stripEl, index) { + const tabs = stripEl.querySelectorAll("[data-dock-tab]"); + const sr = stripEl.getBoundingClientRect(); + let lineX; + if (tabs.length === 0) { + lineX = sr.left; + } else if (index >= tabs.length) { + lineX = tabs[tabs.length - 1].getBoundingClientRect().right; + } else { + lineX = tabs[index].getBoundingClientRect().left; + } + return { left: lineX - 1, top: sr.top, width: 2, height: sr.height }; +} + +function zoneBox(r, zone, frac) { + switch (zone) { + case "left": + return { left: r.left, top: r.top, width: r.width * frac, height: r.height }; + case "right": + return { left: r.right - r.width * frac, top: r.top, width: r.width * frac, height: r.height }; + case "top": + return { left: r.left, top: r.top, width: r.width, height: r.height * frac }; + case "bottom": + return { left: r.left, top: r.bottom - r.height * frac, width: r.width, height: r.height * frac }; + default: + return { left: r.left, top: r.top, width: r.width, height: r.height }; + } +} + +function createGhost(title) { + const g = document.createElement("div"); + g.textContent = title; + g.style.cssText = + "position:fixed;z-index:10002;pointer-events:none;padding:4px 10px;font-size:12px;font-weight:500;" + + "border-radius:6px;background:#1f2937;color:#fff;box-shadow:0 6px 20px rgba(0,0,0,0.28);opacity:.92;" + + "white-space:nowrap;transform:translate(10px,10px);"; + document.body.appendChild(g); + return g; +} + +function moveGhost(g, x, y) { + if (g) { + g.style.left = `${x}px`; + g.style.top = `${y}px`; + } +} + +function removeEl(el) { + if (el && el.parentNode) { + el.parentNode.removeChild(el); + } +} + +// ---------------------------------------------------------------- floating window move + +export function startWindowDrag(dockId, windowId, clientX, clientY, pointerId) { + const dock = docks.get(dockId); + if (!dock) { + return; + } + + const winEl = dock.rootEl.querySelector(`[data-dock-window="${windowId}"]`); + if (!winEl) { + return; + } + + const rootRect = dock.rootEl.getBoundingClientRect(); + const startLeft = parseFloat(winEl.style.left) || 0; + const startTop = parseFloat(winEl.style.top) || 0; + const offsetX = clientX - (rootRect.left + startLeft); + const offsetY = clientY - (rootRect.top + startTop); + + attachSession({ + move: (e) => { + if (e.pointerId !== pointerId) { + return; + } + const left = Math.max(0, e.clientX - rootRect.left - offsetX); + const top = Math.max(0, e.clientY - rootRect.top - offsetY); + winEl.style.left = `${left}px`; + winEl.style.top = `${top}px`; + }, + up: (e) => { + if (e.pointerId !== pointerId) { + return; + } + const left = parseFloat(winEl.style.left) || 0; + const top = parseFloat(winEl.style.top) || 0; + dock.dotNetRef.invokeMethodAsync("OnWindowMoved", windowId, left, top).catch(() => { }); + } + }); + document.body.style.cursor = "move"; +} + +// ---------------------------------------------------------------- floating window resize + +export function startWindowResize(dockId, windowId, clientX, clientY, pointerId) { + const dock = docks.get(dockId); + if (!dock) { + return; + } + + const winEl = dock.rootEl.querySelector(`[data-dock-window="${windowId}"]`); + if (!winEl) { + return; + } + + const startWidth = winEl.offsetWidth; + const startHeight = winEl.offsetHeight; + const startX = clientX; + const startY = clientY; + + attachSession({ + move: (e) => { + if (e.pointerId !== pointerId) { + return; + } + const width = Math.max(180, startWidth + (e.clientX - startX)); + const height = Math.max(120, startHeight + (e.clientY - startY)); + winEl.style.width = `${width}px`; + winEl.style.height = `${height}px`; + }, + up: (e) => { + if (e.pointerId !== pointerId) { + return; + } + dock.dotNetRef + .invokeMethodAsync("OnWindowResized", windowId, winEl.offsetWidth, winEl.offsetHeight) + .catch(() => { }); + } + }); + document.body.style.cursor = "nwse-resize"; +} diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index d595316b8..c4068de11 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -1061,6 +1061,32 @@ - AsChild : Boolean - ChildContent : RenderFragment +### BbDock (BlazorBlueprint.Components) + - ChildContent : RenderFragment + - Class : String + - EmptyContent : RenderFragment + - OnLayoutChanged : EventCallback + - OnPanelClosed : EventCallback + +### BbDockNode (BlazorBlueprint.Components) + - Node : Object [EditorRequired] + +### BbDockPanel (BlazorBlueprint.Components) + - CanFloat : Boolean + - ChildContent : RenderFragment + - Closable : Boolean + - Icon : RenderFragment + - Id : String [EditorRequired] + - Order : Int32 + - Region : DockZone + - Title : String + - Dock : BbDock [CascadingParameter] + +### BbDockTabGroup (BlazorBlueprint.Components) + - FloatingWindowId : String + - Group : Object [EditorRequired] + - Dock : BbDock [CascadingParameter] + ### BbDrawer (BlazorBlueprint.Components) - ChildContent : RenderFragment - DefaultOpen : Boolean @@ -3611,6 +3637,13 @@ - ExtraLarge = 3 - Full = 4 +### DockZone (BlazorBlueprint.Components) + - Center = 0 + - Left = 1 + - Right = 2 + - Top = 3 + - Bottom = 4 + ### DrawerDirection (BlazorBlueprint.Components) - Top = 0 - Bottom = 1 From 6f0b543bb08333d92f8cef8c7ef316f57cc039ce Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:01:33 +0800 Subject: [PATCH 078/188] release: Primitives v3.11.0 + Components v3.11.0 (#351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: keep spinners animating under bb-no-animate (#330) The global bb-no-animate switch disabled all CSS animations via a universal selector, freezing loading spinners mid-rotation so they no longer signalled ongoing work. Exempt looping status indicators from the kill rule with :not() — .animate-spin (spinners) and .animate-pulse (skeletons) keep animating, while transitions and decorative entrance/exit animations remain disabled. Add .bb-animate-keep as a generic escape hatch. * fix: set data-active on sidebar menu links during navigation BbSidebarMenuButton and BbSidebarMenuSubButton delegated active-route detection to the internal NavLink but bound their data-active attribute to the static IsActive parameter. NavLink correctly set aria-current and appended ActiveClass, but that ActiveClass (data-[active=true]:...) is a Tailwind variant gated on data-active="true" — which never became true, so the active item was never highlighted on navigation. Both components now compute active state themselves: they inject NavigationManager, subscribe to LocationChanged, and match the current URL with NavLinkMatcher, a faithful copy of NavLink's matching algorithm so the Match parameter behaves identically. data-active, aria-current and styling all derive from a single ResolvedActive value, and the anchor branch renders a plain instead of . BbSidebarMenuButton also gains the data-[active=true]:* base styling it was previously missing. Fixes #331 * docs: update CHANGELOG for #331 sidebar data-active fix * Added ItemsProvider to BbDataView. Added example in demo page. (#306) * Add Font Awesome Icon Pack (#333) * Added support for font-awesome icons. * Updated csproj copyright. * Updated index card description. * docs: surface FontAwesome pack in root README, command palette, and package README - Root README: add FontAwesome to install commands, icon table, and credits - CommandSearch.razor: add Font Awesome group to the demo command palette - Icons.FontAwesome/README.md: flesh out to parity with Heroicons (Component API table, Variant Guidelines, Styling, Accessibility, Performance, Browser Support, Links, Contributing sections) --------- Co-authored-by: Mathew Taylor * fix(combobox): resolve trigger display text for pre-bound values in compositional mode (#337) In compositional mode (BbComboboxItem children, not Options) the trigger resolves its caption from an internal registry that's populated only when the items mount. Items live inside BbPopoverContent which doesn't mount until the popover first opens, so a pre-bound Value rendered placeholder until the user interacted with the dropdown. After commit 8ea4a8c4 flipped BbPopoverContent.ForceMount to false (a WASM perf win) this became the steady-state behaviour rather than a transient. This change closes the gap without undoing the perf fix: - Adds SelectedItemText parameter so callers that already know the display text for a pre-bound value (typical when the value comes from an API alongside its label) can render it on initial paint. Slotted in SelectedDisplayText after the registry so re-registration still corrects stale captions. - RegisterItem now marks a _triggerTextDirty flag and calls StateHasChanged when a freshly-registered item matches the current Value AND its text actually changed. ShouldRender treats the flag as a render trigger. Without this, items registering on first popover open did not cause the trigger to re-evaluate, so the placeholder stuck even after the registry was populated. - textChanged guard avoids spurious renders from the OnParametersSet- side RegisterItem call that re-registers identical text every parent cascade. Options mode is unaffected (it resolves synchronously from the Options collection and never hit this issue). BbPopoverContent.ForceMount stays false; BbComboboxItem is unchanged. * fix(primitives): restore focus to trigger on intentional overlay close (#341) Select, DropdownMenu, and Popover now return focus to their trigger when closed via an intentional path (Escape or item selection), so keyboard users land back on the trigger instead of having focus fall to when the overlay content unmounts. External dismissals (click-outside, Tab) deliberately leave focus where the user moved it. - Add Close(bool restoreFocus = false) + a RestoreFocusOnClose state flag to the Select/DropdownMenu/Popover contexts; the flag is reset on Open - Content components capture the trigger ref before teardown and call IFocusManager.RestoreFocus after cleanup, so the unmounted overlay is never the active element on the next Tab - The selection path sets the flag directly since it closes via state mutation rather than Close() Verified in-browser across all three components: Escape and item selection restore focus to the trigger; click-outside does not. Also folds in an unrelated fix for a CS1998 build error in DataViewDemo (async items-provider with no await) that was breaking the demo host build under TreatWarningsAsErrors. * chore: update .gitignore and changelog for recent changes * fix(primitives): reference-count body scroll lock so nested overlays restore page scroll (#329) (#342) * fix(combobox): show selected label for pre-bound values in compositional mode (#340) (#343) * docs: add render modes & interactive layouts guide (#339) (#344) * feat(command): add ItemsProvider for server-side lazy loading in BbCommandVirtualizedGroup (#338) (#345) * feat(datagrid): CellClass/HeaderClass on BbDataGridSelectColumn (#332) (#346) * feat(datagrid): support CellClass/HeaderClass on BbDataGridSelectColumn (#332) * docs(datagrid): add compact-rows demo + API reference for select column CellClass/HeaderClass (#332) * fix(combobox,multiselect): restore focus to trigger on close so Tab continues (#349) * docs: update CHANGELOG for unreleased fixes/features; add PR-comment and demo-example rules to CLAUDE.md * fix(collapsible,dropdown-menu): Space/Enter no longer double-toggles the trigger (#350) * docs: add #350 (Space/Enter trigger double-toggle fix) to CHANGELOG * docs: release notes for Primitives v3.11.0 * chore: bump BlazorBlueprint.Primitives to 3.11.0 * docs: release notes for Components v3.11.0 --------- Co-authored-by: djb-fnz --- .gitignore | 2 + BlazorBlueprint.sln | 15 + CHANGELOG.md | 51 + CLAUDE.md | 2 + README.md | 17 +- .../BlazorBlueprint.Demo.Shared.csproj | 1 + .../Components/Command/items-provider.txt | 47 + .../Components/DataGrid/compact-rows.txt | 16 + .../Components/DataView/items-provider.txt | 75 + .../Pages/Components/CommandDemo.razor | 72 + .../Pages/Components/DataGridDemo.razor | 31 + .../Pages/Components/DataViewDemo.razor | 91 +- .../Pages/Guides/RenderModesGuide.razor | 199 ++ .../Pages/Icons/FontAwesomeDemo.razor | 312 +++ .../Pages/Icons/Index.razor | 38 +- .../Shared/CommandSearch.razor | 19 + .../Shared/DemoSidebar.razor | 10 + .../BlazorBlueprint.Components.csproj | 2 +- .../Components/Combobox/BbCombobox.razor | 16 +- .../Components/Combobox/BbCombobox.razor.cs | 94 +- .../Combobox/BbComboboxConstants.cs | 16 + .../Components/Combobox/BbComboboxItem.razor | 51 +- .../Combobox/BbComboboxItem.razor.cs | 9 + .../Command/BbCommandVirtualizedGroup.razor | 158 +- .../Command/CommandItemsProvider.cs | 59 + .../Components/DataGrid/BbDataGrid.razor.cs | 8 +- .../DataGrid/BbDataGridSelectColumn.razor.cs | 18 +- .../Components/DataView/BbDataView.razor | 4 +- .../Components/DataView/BbDataView.razor.cs | 168 +- .../MultiSelect/BbMultiSelect.razor | 2 +- .../MultiSelect/BbMultiSelect.razor.cs | 22 +- .../Components/Popover/BbPopover.razor | 9 + .../Sidebar/BbSidebarMenuButton.razor | 65 +- .../Sidebar/BbSidebarMenuSubButton.razor | 43 +- .../Components/Sidebar/NavLinkMatcher.cs | 82 + .../RELEASE_NOTES.md | 15 +- .../wwwroot/css/blazorblueprint-input.css | 13 +- .../BlazorBlueprint.Icons.FontAwesome.csproj | 42 + .../Components/FontAwesomeIcon.razor | 21 + .../Components/FontAwesomeIcon.razor.cs | 112 + .../Data/FontAwesomeIconData.cs | 2175 +++++++++++++++++ .../GenerateIconData.ps1 | 198 ++ .../README.md | 293 +++ .../Collapsible/BbCollapsibleTrigger.razor | 4 +- .../Collapsible/BbCollapsibleTrigger.razor.cs | 26 +- .../DataView/DataViewItemsProvider.cs | 67 + .../BbDropdownMenuCheckboxItem.razor | 3 +- .../DropdownMenu/BbDropdownMenuContent.razor | 17 +- .../DropdownMenu/BbDropdownMenuItem.razor | 4 +- .../DropdownMenu/BbDropdownMenuTrigger.razor | 11 +- .../DropdownMenu/DropdownMenuContext.cs | 18 +- .../Primitives/Popover/BbPopover.razor | 15 +- .../Primitives/Popover/BbPopoverContent.razor | 17 +- .../Primitives/Popover/PopoverContext.cs | 23 +- .../Primitives/Select/BbSelectContent.razor | 19 +- .../Primitives/Select/BbSelectTrigger.razor | 3 +- .../Primitives/Select/SelectContext.cs | 21 +- .../RELEASE_NOTES.md | 12 +- .../wwwroot/js/primitives/portal.js | 57 +- ...entsApiSurfaceMatchesBaseline.verified.txt | 11 +- ...ivesApiSurfaceMatchesBaseline.verified.txt | 1 + 61 files changed, 4851 insertions(+), 171 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Command/items-provider.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/compact-rows.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataView/items-provider.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/Pages/Guides/RenderModesGuide.razor create mode 100644 demos/BlazorBlueprint.Demo.Shared/Pages/Icons/FontAwesomeDemo.razor create mode 100644 src/BlazorBlueprint.Components/Components/Combobox/BbComboboxConstants.cs create mode 100644 src/BlazorBlueprint.Components/Components/Command/CommandItemsProvider.cs create mode 100644 src/BlazorBlueprint.Components/Components/Sidebar/NavLinkMatcher.cs create mode 100644 src/BlazorBlueprint.Icons.FontAwesome/BlazorBlueprint.Icons.FontAwesome.csproj create mode 100644 src/BlazorBlueprint.Icons.FontAwesome/Components/FontAwesomeIcon.razor create mode 100644 src/BlazorBlueprint.Icons.FontAwesome/Components/FontAwesomeIcon.razor.cs create mode 100644 src/BlazorBlueprint.Icons.FontAwesome/Data/FontAwesomeIconData.cs create mode 100644 src/BlazorBlueprint.Icons.FontAwesome/GenerateIconData.ps1 create mode 100644 src/BlazorBlueprint.Icons.FontAwesome/README.md create mode 100644 src/BlazorBlueprint.Primitives/Primitives/DataView/DataViewItemsProvider.cs diff --git a/.gitignore b/.gitignore index 6d35a2b04..ee83847c1 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,5 @@ devkit/ # External libs pro/ /.vscode-ai-images/ + +docs/notes/checkpoint.md diff --git a/BlazorBlueprint.sln b/BlazorBlueprint.sln index 7cf2e7ff1..36226b91c 100644 --- a/BlazorBlueprint.sln +++ b/BlazorBlueprint.sln @@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorBlueprint.Icons.Heroi EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorBlueprint.Icons.Feather", "src\BlazorBlueprint.Icons.Feather\BlazorBlueprint.Icons.Feather.csproj", "{078BABD8-4ACB-4002-95E8-99AB3DA3EBEA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorBlueprint.Icons.FontAwesome", "src\BlazorBlueprint.Icons.FontAwesome\BlazorBlueprint.Icons.FontAwesome.csproj", "{A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorBlueprint.Demo.Shared", "demos\BlazorBlueprint.Demo.Shared\BlazorBlueprint.Demo.Shared.csproj", "{D1A2B3C4-E5F6-7890-ABCD-EF1234567890}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorBlueprint.Demo.Wasm", "demos\BlazorBlueprint.Demo.Wasm\BlazorBlueprint.Demo.Wasm.csproj", "{E2B3C4D5-F6A7-8901-BCDE-F12345678901}" @@ -113,6 +115,18 @@ Global {078BABD8-4ACB-4002-95E8-99AB3DA3EBEA}.Release|x64.Build.0 = Release|Any CPU {078BABD8-4ACB-4002-95E8-99AB3DA3EBEA}.Release|x86.ActiveCfg = Release|Any CPU {078BABD8-4ACB-4002-95E8-99AB3DA3EBEA}.Release|x86.Build.0 = Release|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Debug|x64.Build.0 = Debug|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Debug|x86.Build.0 = Debug|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Release|Any CPU.Build.0 = Release|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Release|x64.ActiveCfg = Release|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Release|x64.Build.0 = Release|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Release|x86.ActiveCfg = Release|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Release|x86.Build.0 = Release|Any CPU {D1A2B3C4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D1A2B3C4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU {D1A2B3C4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -184,6 +198,7 @@ Global {C5E92A4D-9E3B-4C1A-B8D4-E1F5A6C7B9D2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {6B3C876A-CB06-42DA-974E-6620581CE719} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {078BABD8-4ACB-4002-95E8-99AB3DA3EBEA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {D1A2B3C4-E5F6-7890-ABCD-EF1234567890} = {A39C23D2-F2C0-258D-165A-CF1E7FEE6E7B} {E2B3C4D5-F6A7-8901-BCDE-F12345678901} = {A39C23D2-F2C0-258D-165A-CF1E7FEE6E7B} {F3C4D5E6-A7B8-9012-CDEF-123456789012} = {A39C23D2-F2C0-258D-165A-CF1E7FEE6E7B} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f0f3ef47..c85055603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## 2026-06-08 + +### Fixed + +- **BbCombobox & BbMultiSelect: keyboard focus lost after selecting** — Selecting an item in `BbCombobox`, or closing `BbMultiSelect` via Escape or its Close button, left keyboard focus on the now-unmounted popover content, so focus fell back to the page root and the next Tab resumed from the top of the page. Focus is now returned to the trigger on these intentional closes — extending the behavior added for Select/DropdownMenu/Popover — while click-outside still leaves focus where the user clicked. A new `RestoreFocusOnClose` parameter on `BbPopover` carries the consumer's focus-restore intent. ([#349](https://github.com/blazorblueprintui/ui/pull/349)) +- **BbCollapsible & BbDropdownMenu: Space/Enter double-toggled the trigger** — Pressing Space or Enter on a `BbCollapsibleTrigger` (e.g. the docs "View Code" toggles) or a `BbDropdownMenuTrigger` opened then immediately closed it in a single press. The triggers render native `
    + +
    +
    +

    Compact Rows (CellClass / HeaderClass)

    +

    + Every column — including BbDataGridSelectColumn — + accepts CellClass and + HeaderClass. Apply a smaller padding such as + CellClass="p-1" to every column, the checkbox column + included, for a dense grid — the select column no longer forces a taller row. +

    +
    + + + + + + + + + + +
    +
    @@ -1062,6 +1087,12 @@ Column width for the checkbox column. Defaults to a compact checkbox width. + + Additional CSS classes applied to the selection cells. Use e.g. "p-1" to override the default padding for a compact row height. + + + Additional CSS classes applied to the header cell (the select-all checkbox). + Whether this column is pinned. Commonly set to BlazorBlueprint.Primitives.DataGrid.ColumnPinning.Left to keep the checkbox column visible when scrolling horizontally. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataViewDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataViewDemo.razor index 98658a142..03f1eb822 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataViewDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataViewDemo.razor @@ -1,5 +1,7 @@ @page "/components/dataview" @using BlazorBlueprint.Demo.Services +@using BlazorBlueprint.Primitives.DataView +@using BlazorBlueprint.Primitives.Table @inject MockDataService MockDataService Data View Component - Blazor Blueprint @@ -431,6 +433,45 @@
    + +
    +
    +

    Async Data Loading

    +

    + Use ItemsProvider for server-side data fetching. + The view passes the current pagination, sort, and search state and the provider returns the + matching page plus a total count. The built-in loading overlay is shown automatically while + the request is in flight, and rapid search keystrokes cancel the previous request. Provide + either Data or + ItemsProvider, not both. +

    +
    + + + + + @GetInitials(person.Name) + +
    +

    @person.Name

    + @person.Email + @person.Department · @person.Role +
    + + @person.Status + +
    +
    + + + + + + +
    + +
    +
    @@ -525,8 +566,13 @@
    - - The data source for the view. + + In-memory data source for the view. Mutually exclusive with ItemsProvider. + + + Async delegate for server-side data fetching. Receives the current pagination, + sort, search, and a cancellation token and returns the matching page plus a total + count. Mutually exclusive with Data. Template used to render each item in list layout. When set without GridTemplate the @@ -653,12 +699,14 @@ private List people = new(); private List emptyPeople = new(); private List products = new(); + private List asyncPeople = new(); private bool isLoading; protected override void OnInitialized() { people = MockDataService.GeneratePersons(500); products = MockDataService.GenerateProducts(60); + asyncPeople = MockDataService.GeneratePersons(200); } private static string GetInitials(string name) @@ -668,4 +716,43 @@ ? $"{parts[0][0]}{parts[^1][0]}" : name.Length > 0 ? name[0].ToString() : "?"; } + + private ValueTask> LoadPeopleAsync(DataViewRequest request) + { + IEnumerable query = asyncPeople; + + if (!string.IsNullOrWhiteSpace(request.SearchText)) + { + var search = request.SearchText; + query = query.Where(p => + p.Name.Contains(search, StringComparison.OrdinalIgnoreCase) || + p.Email.Contains(search, StringComparison.OrdinalIgnoreCase) || + p.Department.Contains(search, StringComparison.OrdinalIgnoreCase) || + p.Role.Contains(search, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrEmpty(request.SortField) && request.SortDirection != SortDirection.None) + { + var asc = request.SortDirection == SortDirection.Ascending; + query = request.SortField switch + { + "name" => asc ? query.OrderBy(p => p.Name) : query.OrderByDescending(p => p.Name), + "department" => asc ? query.OrderBy(p => p.Department) : query.OrderByDescending(p => p.Department), + "role" => asc ? query.OrderBy(p => p.Role) : query.OrderByDescending(p => p.Role), + _ => query + }; + } + + var materialized = query.ToList(); + var items = materialized + .Skip(request.StartIndex) + .Take(request.Count ?? materialized.Count) + .ToList(); + + return ValueTask.FromResult(new DataViewResult + { + Items = items, + TotalItemCount = materialized.Count + }); + } } diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Guides/RenderModesGuide.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Guides/RenderModesGuide.razor new file mode 100644 index 000000000..42026a269 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Guides/RenderModesGuide.razor @@ -0,0 +1,199 @@ +@page "/guides/render-modes" + +Render Modes - Blazor Blueprint + +
    +
    +

    Render Modes & Interactive Layouts

    +

    + Why buttons, toggles, and dialogs in your layout sometimes do nothing — and how to make + them work alongside static, HttpContext-dependent pages. +

    +
    + +
    + + +
    +

    Why interactivity matters

    +

    + Most Blazor Blueprint components respond to user input (clicks, typing, keyboard navigation) + or call JavaScript interop (positioning, scroll-lock, focus). All of that requires an + interactive render mode — + InteractiveServer, + InteractiveWebAssembly, or + InteractiveAuto. +

    +

    + Under static server-side rendering (SSR) — the .NET 8 default when no + interactive render mode is applied — a component's HTML is produced once and its event + handlers are never wired up. A BbButton + renders, looks correct, and silently does nothing when clicked. The same applies to + BbDarkModeToggle, + BbThemeSwitcher, and any overlay. +

    +
    + + +
    +

    The layout adopts the page's render mode

    +

    + This is the part that surprises people. A routed page's layout inherits the render + mode of the page being rendered. If the page is static (or you never set a global + interactive render mode), then MainLayout + — and everything in it — renders statically too. Buttons, theme toggles, the sidebar + trigger, and providers placed in the layout will not respond. +

    +
    +

    + You cannot fix this by adding + @@rendermode to the + @@layout directive or to the layout + component itself. The page content flows into the layout as + @@Body from a statically-rendered + parent, and Blazor does not allow that render-fragment to cross a static → interactive + boundary. Render mode is applied where a component is used, not where a layout is + defined. +

    +
    +
    + + +
    +

    Option 1 — Global interactivity (simplest)

    +

    + If you do not have pages that must render statically, apply an interactive + render mode globally on the router in App.razor. + This is what the Blazor Blueprint demo apps do, and it makes every component — including the + ones in your layout — interactive with no further work. +

    +
    +
    <!-- App.razor -->
    +<head>
    +    ...
    +    <HeadOutlet @@rendermode="InteractiveServer" />
    +</head>
    +<body>
    +    <Routes @@rendermode="InteractiveServer" />
    +    ...
    +</body>
    +
    +

    + With this in place, BbPortalHost, + BbToastProvider, and + BbDialogProvider in your + MainLayout work exactly as documented. +

    +
    + + +
    +

    Option 2 — Per-page interactivity with islands

    +

    + If some pages must stay static — for example, authentication pages that read or write + HttpContext, which is only available + during static rendering — do not set a global interactive mode. Instead: +

    +
      +
    1. Mark each interactive page with a per-page render mode.
    2. +
    3. + Move the interactive UI that lives in your layout (theme toggle, sidebar controls, your + own buttons) into small child components, and render those with + @@rendermode — creating an + interactivity island inside the otherwise static layout. +
    4. +
    + +

    Per-page interactivity:

    +
    +
    @@page "/dashboard"
    +@@rendermode InteractiveServer
    +
    +<BbButton OnClick="DoSomething">Works</BbButton>
    +
    + +

    An island for shared layout chrome:

    +
    +
    @@* InteractiveChrome.razor — the bits of the layout that need to be live *@@
    +<div class="flex items-center gap-2">
    +    <BbDarkModeToggle />
    +    <BbThemeSwitcher />
    +</div>
    +
    +
    +
    @@* MainLayout.razor — static layout, interactive islands *@@
    +@@inherits LayoutComponentBase
    +
    +<header>
    +    <InteractiveChrome @@rendermode="InteractiveServer" />
    +</header>
    +
    +<main>@@Body</main>
    +
    +<BbPortalHost @@rendermode="InteractiveServer" />
    +<BbToastProvider @@rendermode="InteractiveServer" />
    +<BbDialogProvider @@rendermode="InteractiveServer" />
    +
    +
    +

    + Caveats with islands. A component you mark with + @@rendermode can't receive + non-serializable parameters from its static parent, and you can't pass child + content (a RenderFragment) across + the boundary — which is exactly why @@Body + has to stay in the static layout. Islands work best for self-contained chrome. If your + layout is heavily interactive (a collapsible sidebar, command palette, etc.), Option 1 is + usually the better fit — and you can keep authentication on static endpoints or move + it to an interactivity-friendly auth flow (AuthenticationStateProvider) + instead of HttpContext. +

    +
    +
    + + +
    +

    A note on prerendering

    +

    + Interactive components still prerender statically first, then become + interactive once the circuit (Server) or runtime (WebAssembly) is ready. During that brief + first pass, event handlers aren't attached yet — this is expected and resolves on its own. + It only becomes a bug when the component never reaches interactivity, which is the + static-layout situation above. +

    +
    + + +
    +

    Troubleshooting checklist

    +
    +
    + 1. + A BbButton renders but its + OnClick never fires → the + component is rendering statically. Confirm its page (or the component itself) has an + interactive render mode. +
    +
    + 2. + It works only after adding a global + <Routes @@rendermode="InteractiveServer" /> + → your layout was static. Use Option 1, or move the interactive chrome into an island + (Option 2). +
    +
    + 3. + Overlays (Dialog, Sheet, Popover, Tooltip, Toast) don't appear → ensure + BbPortalHost is present and in an + interactive context, not a static layout. +
    +
    + 4. + Auth pages break under global interactivity → they likely depend on + HttpContext, which isn't + available in interactive renders. Keep those pages static and use Option 2 for the rest. +
    +
    +
    + +
    +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Icons/FontAwesomeDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Icons/FontAwesomeDemo.razor new file mode 100644 index 000000000..b37f31934 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Icons/FontAwesomeDemo.razor @@ -0,0 +1,312 @@ +@page "/icons/fontawesome" +@using BlazorBlueprint.Icons.FontAwesome.Components +@using BlazorBlueprint.Icons.FontAwesome.Data + +Font Awesome - Blazor Blueprint + +
    +
    +
    +
    + + + + + +

    Font Awesome

    +
    +

    + Browse and search through @TotalIconCount icons from Font Awesome Free, available in 3 variants. +

    +
    +
    + + +
    + + + +
    + + +
    +
    +
    + + + + + +
    +
    + @switch (selectedVariant) + { + case FontAwesomeIconVariant.Solid: +

    + Solid: Filled glyphs — the largest variant in Font Awesome Free and the default. Great for primary UI, navigation, and emphasis. +

    + break; + case FontAwesomeIconVariant.Regular: +

    + Regular: Outline glyphs. A small curated subset of icons; the Free tier ships far fewer Regular icons than Solid. +

    + break; + case FontAwesomeIconVariant.Brands: +

    + Brands: Logos for third-party services and products (GitHub, Twitter, etc.). Icons in this set are not all square — width/height vary per icon. +

    + break; + } +
    +
    +
    + + +
    +
    +
    + + +
    +
    + +
    + Showing @FilteredIcons.Count() of @CurrentVariantCount icons +
    +
    + + + @if (FilteredIcons.Any()) + { +
    + @foreach (var iconName in FilteredIcons) + { +
    +
    + +
    + + @iconName + +
    + } +
    + } + else + { +
    + +

    No icons found

    +

    + Try adjusting your search query +

    +
    + } + + +
    +

    Usage Examples

    + + +
    +

    Basic Usage - All Variants

    +
    +
    +
    + Solid: + + + +
    +
    + Regular: + + + +
    +
    + Brands: + + + +
    +
    +
    @@using BlazorBlueprint.Icons.FontAwesome.Components
    +
    +<FontAwesomeIcon Name="house" Variant="FontAwesomeIconVariant.Solid" />
    +<FontAwesomeIcon Name="heart" Variant="FontAwesomeIconVariant.Regular" />
    +<FontAwesomeIcon Name="github" Variant="FontAwesomeIconVariant.Brands" />
    +
    +
    + + +
    +

    Custom Sizes

    +
    +
    + + + + + +
    +
    <FontAwesomeIcon Name="camera" Variant="FontAwesomeIconVariant.Solid" Size="16" />
    +<FontAwesomeIcon Name="camera" Variant="FontAwesomeIconVariant.Solid" Size="32" />
    +<FontAwesomeIcon Name="camera" Variant="FontAwesomeIconVariant.Solid" Size="64" />
    +
    +
    + + +
    +

    Custom Colors

    +
    +
    + + + + + +
    +
    <FontAwesomeIcon Name="heart" Variant="FontAwesomeIconVariant.Solid" Size="32" Color="red" />
    +<FontAwesomeIcon Name="star" Variant="FontAwesomeIconVariant.Solid" Size="32" Color="gold" />
    +
    +
    + + +
    +

    Integration with Button Component

    +
    +
    + + + + + Download + + + + + + + Delete + + + + + + + Settings + + + + + + + Warning + +
    +
    <Button>
    +    <Icon>
    +        <FontAwesomeIcon Name="download" Variant="FontAwesomeIconVariant.Solid" Size="16" />
    +    </Icon>
    +    Download
    +</Button>
    +
    +
    + + +
    +

    When to Use Each Variant

    +
    +
    +
    + +
    +

    Solid

    +

    Default, primary UI, navigation, emphasis — by far the largest set

    +
    +
    +
    + +
    +

    Regular

    +

    Outline alternative for the small subset of icons available in the Free tier

    +
    +
    +
    + +
    +

    Brands

    +

    Third-party logos (GitHub, Microsoft, Apple, etc.) — width and height vary per icon

    +
    +
    +
    +
    +
    +
    +
    + +@code { + private string searchQuery = string.Empty; + private FontAwesomeIconVariant selectedVariant = FontAwesomeIconVariant.Solid; + private IEnumerable allIcons = Enumerable.Empty(); + + private int TotalIconCount => FontAwesomeIconData.TotalIconCount; + + private int CurrentVariantCount => selectedVariant switch + { + FontAwesomeIconVariant.Solid => FontAwesomeIconData.SolidIconCount, + FontAwesomeIconVariant.Regular => FontAwesomeIconData.RegularIconCount, + FontAwesomeIconVariant.Brands => FontAwesomeIconData.BrandsIconCount, + _ => 0 + }; + + private IEnumerable FilteredIcons + { + get + { + if (string.IsNullOrWhiteSpace(searchQuery)) + { + return allIcons; + } + + return allIcons.Where(icon => + icon.Contains(searchQuery, StringComparison.OrdinalIgnoreCase)); + } + } + + protected override void OnInitialized() + { + LoadIconsForVariant(); + } + + private void SelectVariant(FontAwesomeIconVariant variant) + { + selectedVariant = variant; + searchQuery = string.Empty; // Reset search when switching variants + LoadIconsForVariant(); + } + + private void LoadIconsForVariant() + { + allIcons = FontAwesomeIconData.GetAvailableIcons(selectedVariant).OrderBy(x => x).ToList(); + } +} diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Icons/Index.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Icons/Index.razor index af41b15e3..bf44b8cfa 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Icons/Index.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Icons/Index.razor @@ -7,7 +7,7 @@

    Icon Libraries

    - BlazorBlueprint offers three beautiful icon libraries to suit different design preferences. Browse and search through 3,200+ icons across Lucide, Heroicons, and Feather. + BlazorBlueprint offers four beautiful icon libraries to suit different design preferences. Browse and search through 5,000+ icons across Lucide, Heroicons, Feather, and Font Awesome.

    @@ -52,6 +52,15 @@ MIT Simple, lightweight projects + + + Font Awesome + + 2,066 + 3 (solid, regular, brands) + CC BY 4.0 / SIL OFL 1.1 / MIT (Free) + Broad coverage incl. third-party logos +
    @@ -141,6 +150,33 @@
    + + + +
    +
    +
    + + + + +
    +
    +

    Font Awesome

    +

    2,066 icons (3 variants)

    +
    +
    +

    + Font Awesome Free with 3 variants (solid, regular, brand). Includes third-party logos (GitHub, Microsoft, etc.) not available in the other sets. +

    +
    + Browse Font Awesome + + + +
    +
    +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Shared/CommandSearch.razor b/demos/BlazorBlueprint.Demo.Shared/Shared/CommandSearch.razor index 6e47f569e..6576c6906 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Shared/CommandSearch.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Shared/CommandSearch.razor @@ -4,6 +4,8 @@ @using BlazorBlueprint.Icons.Heroicons.Data @using BlazorBlueprint.Icons.Feather.Components @using BlazorBlueprint.Icons.Feather.Data +@using BlazorBlueprint.Icons.FontAwesome.Components +@using BlazorBlueprint.Icons.FontAwesome.Data @using Microsoft.AspNetCore.Components.Web @using Microsoft.JSInterop @inject NavigationManager NavigationManager @@ -112,6 +114,22 @@ Feather + + + + + + + @FormatIconName(iconName) + Font Awesome + + @@ -218,6 +236,7 @@ private static readonly string[] AllLucideIcons = LucideIconData.GetAvailableIcons().OrderBy(x => x).ToArray(); private static readonly string[] AllHeroIcons = HeroIconData.GetAvailableIcons(HeroIconVariant.Outline).OrderBy(x => x).ToArray(); private static readonly string[] AllFeatherIcons = FeatherIconData.GetAvailableIcons().OrderBy(x => x).ToArray(); + private static readonly string[] AllFontAwesomeIcons = FontAwesomeIconData.GetAvailableIcons(FontAwesomeIconVariant.Solid).OrderBy(x => x).ToArray(); protected override async Task OnAfterRenderAsync(bool firstRender) { diff --git a/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor b/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor index b80914070..00b510530 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor @@ -875,6 +875,11 @@ Feather Icons + + + Font Awesome Icons + + @@ -932,6 +937,11 @@ Localization + + + Render Modes + + diff --git a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj index b69708446..0b436d533 100644 --- a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj +++ b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj @@ -58,7 +58,7 @@ - + diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor index 70aae1343..e39276c5a 100644 --- a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor @@ -3,7 +3,7 @@ @attribute [CascadingTypeParameter(nameof(TValue))]
    - + + @* Hidden registration pass (compositional mode only). The interactive items above live + inside the popover portal, which doesn't mount until the first open — so for a pre-bound + Value the trigger would show the placeholder until the user opens the dropdown once. + Rendering the items again here, invisibly and non-interactively (see RegistrationOnly), + lets each one register its display text on initial load so the trigger resolves the + selected caption immediately. *@ + @if (Options is null && ChildContent is not null) + { + + }
    diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs index 2b3c6d618..9408128e0 100644 --- a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs @@ -65,11 +65,19 @@ public partial class BbCombobox : ComponentBase private bool _lastDisabled; private string _lastSearchQuery = string.Empty; + // Bypass for ShouldRender when the trigger needs to pick up a freshly-registered + // display text for the current selection. ComboboxItem children register their text on + // initial render via the hidden registration pass, but that happens after the trigger's + // first render — so RegisterItem arrives once the render-skipping state has already + // settled and must force a follow-up render. + private bool _triggerTextDirty; + protected override bool ShouldRender() { - if (_parametersChanged) + if (_parametersChanged || _triggerTextDirty) { _parametersChanged = false; + _triggerTextDirty = false; _lastIsOpen = _isOpen; _lastValue = Value; _lastDisabled = Disabled; @@ -140,6 +148,24 @@ protected override bool ShouldRender() [Parameter] public string? Placeholder { get; set; } + /// + /// Gets or sets the display text shown in the trigger when a value is preselected + /// via in compositional mode and no matching + /// BbComboboxItem has registered yet. + /// + /// + /// Compositional items now register their text on initial render (via a hidden + /// registration pass), so the trigger resolves a pre-bound without + /// this in the common case. Set it only when the matching item isn't present in the + /// markup at first render — e.g. the selection is loaded asynchronously and added later — + /// so the trigger can still show a caption in the meantime. Options mode never needs this; + /// it resolves text synchronously from the collection. A registered + /// item always wins over this hint, so re-registration (e.g. Text updated by the parent) + /// still corrects stale captions. + /// + [Parameter] + public string? SelectedItemText { get; set; } + /// /// Gets or sets the placeholder text shown in the search input. /// @@ -265,6 +291,12 @@ protected override bool ShouldRender() /// private bool _focusDone; + /// + /// Whether the next controlled close should return focus to the trigger. Set true on + /// selection (intentional close) and reset on open. Bound to the popover's RestoreFocusOnClose. + /// + private bool _restoreFocusOnClose; + /// /// Item text registry for compositional mode display text lookup. /// @@ -305,9 +337,10 @@ protected override void OnParametersSet() } /// - /// Gets the display text for the currently selected item. - /// Checks Options first (Options mode), then the item text registry (Compositional mode), - /// then falls back to the cached display text from the last selection. + /// Gets the display text for the currently selected item. Resolution order: + /// Options (Options mode) → registered items (Compositional mode) → + /// caller-supplied (fallback when no matching item is in + /// the markup yet) → cached text from the last user selection → placeholder. /// private string SelectedDisplayText { @@ -318,21 +351,30 @@ private string SelectedDisplayText return EffectivePlaceholder; } - // Options mode: look up from Options collection + // Options mode: synchronous lookup from the Options collection. var selectedOption = Options?.FirstOrDefault(o => EqualityComparer.Default.Equals(o.Value, Value)); if (selectedOption is not null) { return selectedOption.Text; } - // Compositional mode: look up from registered items + // Compositional mode: a registered item wins over the caller-provided hint so + // that re-registration (e.g. Text updated by the parent) corrects stale captions. if (_itemTextRegistry.GetValueOrDefault(Value) is { } registryText) { return registryText; } - // Fallback: cached display text from last selection survives Options array changes - // during async filtering (e.g. selected option filtered out of current results). + // Caller-provided initial text — fallback when the selected value has no matching + // item in the markup yet (e.g. the selection is loaded asynchronously). + if (!string.IsNullOrEmpty(SelectedItemText)) + { + return SelectedItemText; + } + + // Last-resort: cached text from a previous user selection, which survives + // Options array changes during async filtering (e.g. selected option filtered + // out of current results). return _selectedDisplayTextCache ?? EffectivePlaceholder; } } @@ -376,6 +418,12 @@ private async Task HandleContentReady() private async Task HandleOpenChanged(bool isOpen) { _isOpen = isOpen; + if (isOpen) + { + // Default to NOT restoring focus; only a selection-close opts in (see HandleSelect). + // This keeps click-outside dismissal leaving focus where the user clicked. + _restoreFocusOnClose = false; + } if (!isOpen) { _focusDone = false; // Reset for next open @@ -417,7 +465,10 @@ private async Task HandleSelect(SelectOption option) _editContext.NotifyFieldChanged(_fieldIdentifier); } - // Close the popover after selection + // Close the popover after selection — an intentional close, so return focus to the + // trigger (the popover content unmounts; without this, focus is lost to and the + // next Tab restarts from the top of the document). + _restoreFocusOnClose = true; _isOpen = false; // Note: _focusDone is reset by HandleOpenChanged } @@ -452,13 +503,32 @@ private async Task HandleSelect(SelectOption option) /// /// Registers an item's value and display text for trigger display text lookup. - /// Called by ComboboxItem on initialization. + /// Called by ComboboxItem on initialization and on parameter cascade. /// internal void RegisterItem(TValue value, string text) { - if (value is not null) + if (value is null) + { + return; + } + + // Only mark dirty when the text genuinely changed — without this guard the + // OnParametersSet-side RegisterItem call would queue a render on every parent + // cascade because identical text is re-registered each cycle. + var textChanged = !_itemTextRegistry.TryGetValue(value, out var existing) + || !string.Equals(existing, text, StringComparison.Ordinal); + + _itemTextRegistry[value] = text; + + // If the registered item is the current selection and its display text actually + // changed (covers first-mount and Text-updates), re-render so the trigger picks + // up the new caption. Registration happens after the trigger's first render (the + // hidden registration pass mounts children later in the same initial render cycle), + // so without this the trigger would stay on the placeholder. + if (textChanged && EqualityComparer.Default.Equals(value, Value)) { - _itemTextRegistry[value] = text; + _triggerTextDirty = true; + StateHasChanged(); } } diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxConstants.cs b/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxConstants.cs new file mode 100644 index 000000000..e20987911 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxConstants.cs @@ -0,0 +1,16 @@ +namespace BlazorBlueprint.Components; + +/// +/// Shared, type-independent constants for the Combobox component family. +/// +internal static class BbComboboxConstants +{ + /// + /// Name of the cascading flag the parent uses to mark its hidden, render-nothing + /// registration pass so BbComboboxItem children register their display text + /// on initial load without producing interactive DOM. Lives on a non-generic type so + /// it can be referenced from a + /// name without involving the parent's type parameter. + /// + public const string RegistrationScopeName = "BbComboboxRegistrationOnly"; +} diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxItem.razor b/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxItem.razor index 03bfebc16..c122ad67c 100644 --- a/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxItem.razor +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxItem.razor @@ -1,26 +1,31 @@ @namespace BlazorBlueprint.Components @typeparam TValue - - @if (ChildContent is not null) - { - @ChildContent - } - else - { - @Text - } - - - - +@* In the parent's hidden registration pass we register text (via lifecycle) but emit no + DOM — BbCommandItem needs a CommandContext that only exists inside the popover's BbCommand. *@ +@if (!RegistrationOnly) +{ + + @if (ChildContent is not null) + { + @ChildContent + } + else + { + @Text + } + + + + +} diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxItem.razor.cs b/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxItem.razor.cs index 9efb590f8..3a2a012b1 100644 --- a/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxItem.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbComboboxItem.razor.cs @@ -12,6 +12,15 @@ public partial class BbComboboxItem : ComponentBase, IDisposable [CascadingParameter] private BbCombobox? Parent { get; set; } + /// + /// When true, this item is part of the parent's hidden registration pass: it registers + /// its display text with the parent but renders no DOM. The parent renders the items a + /// second time, eagerly and invisibly, so their captions are known on initial load — + /// before the popover (and therefore the real, interactive items) has ever mounted. + /// + [CascadingParameter(Name = BbComboboxConstants.RegistrationScopeName)] + private bool RegistrationOnly { get; set; } + /// /// Gets or sets the value of this item. /// diff --git a/src/BlazorBlueprint.Components/Components/Command/BbCommandVirtualizedGroup.razor b/src/BlazorBlueprint.Components/Components/Command/BbCommandVirtualizedGroup.razor index 2d94eff6d..0ba77ddab 100644 --- a/src/BlazorBlueprint.Components/Components/Command/BbCommandVirtualizedGroup.razor +++ b/src/BlazorBlueprint.Components/Components/Command/BbCommandVirtualizedGroup.razor @@ -26,7 +26,9 @@ } - @if (_hasVisibleItems) + @* In provider mode the Virtualize stays in the tree even at count 0 (container hidden via + display:none above) so it remains mounted and re-queries the provider when the search changes. *@ + @if (_hasVisibleItems || UseProvider) { @if (EnableLazyLoading) { @@ -93,10 +95,25 @@ /// /// Gets or sets the collection of items to display. + /// Required unless is supplied (provider mode), in which case it is ignored. /// - [Parameter, EditorRequired] + [Parameter] public IReadOnlyList Items { get; set; } = Array.Empty(); + /// + /// Gets or sets an async callback that fetches items on demand for true lazy loading, + /// instead of materializing the whole collection into . + /// + /// + /// Requires to be true. The provider is invoked as the user + /// scrolls and whenever the search query changes; it is responsible for applying the search + /// () and returning the matching slice plus the + /// total matching count. When set, , , and the + /// component's built-in local filtering are not used. + /// + [Parameter] + public CommandItemsProvider? ItemsProvider { get; set; } + /// /// Gets or sets the template for rendering each item. /// @@ -155,6 +172,16 @@ private List? _lazyFilteredSource; // Full filtered list for lazy loading private int _lazyLoadedCount; // How many items currently loaded for lazy loading + // Provider mode (ItemsProvider supplied): the full set never lives in memory, so cache + // each slice the provider returns by absolute index for keyboard selection. + private CommandItemsProvider? _cachedProvider; + private readonly Dictionary _loadedItems = new(); + + /// + /// Whether items are sourced from rather than the in-memory . + /// + private bool UseProvider => ItemsProvider is not null; + private readonly struct IndexedItem { public readonly TItem Item; @@ -182,7 +209,15 @@ async Task IVirtualizedGroupHandler.SelectFocusedItemAsync() { - if (EnableLazyLoading) + if (UseProvider) + { + // The full set isn't in memory; the focused item must have been loaded into view to be focused. + if (_focusedIndex >= 0 && _loadedItems.TryGetValue(_focusedIndex, out var providerItem)) + { + await SelectItem(providerItem); + } + } + else if (EnableLazyLoading) { var source = _lazyFilteredSource ?? (IReadOnlyList)Items; if (source != null && _focusedIndex >= 0 && _focusedIndex < source.Count) @@ -246,6 +281,13 @@ protected override void OnInitialized() { + if (UseProvider && !EnableLazyLoading) + { + throw new InvalidOperationException( + $"{nameof(BbCommandVirtualizedGroup)}.{nameof(ItemsProvider)} requires {nameof(EnableLazyLoading)}=\"true\". " + + $"Set {nameof(EnableLazyLoading)} or supply {nameof(Items)} instead."); + } + if (Context != null) { Context.OnSearchChanged += HandleSearchChanged; @@ -253,8 +295,15 @@ Context.RegisterVirtualizedGroup(this); } _cachedItems = Items; + _cachedProvider = ItemsProvider; - if (EnableLazyLoading) + if (UseProvider) + { + // Counts and visibility are learned from the provider's first response; show the + // Virtualize so it can issue that first request. + _hasVisibleItems = true; + } + else if (EnableLazyLoading) { UpdateLazyFilteredSource(); } @@ -266,6 +315,24 @@ protected override async Task OnParametersSetAsync() { + if (UseProvider) + { + // Re-query if the provider delegate itself was swapped at runtime. + if (!ReferenceEquals(ItemsProvider, _cachedProvider)) + { + _cachedProvider = ItemsProvider; + _loadedItems.Clear(); + _lazyLoadedCount = 0; + _cachedSearchQuery = null; + _hasVisibleItems = true; + if (_virtualizeRef != null) + { + await _virtualizeRef.RefreshDataAsync(); + } + } + return; + } + // Only re-filter if Items reference actually changed if (!ReferenceEquals(Items, _cachedItems)) { @@ -427,9 +494,14 @@ _hasVisibleItems = _filteredCount > 0; } - private ValueTask> LoadItemsAsync( + private async ValueTask> LoadItemsAsync( Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderRequest request) { + if (UseProvider) + { + return await LoadFromProviderAsync(request); + } + // Determine the source list var source = _lazyFilteredSource ?? (IReadOnlyList)Items; var totalItems = source.Count; @@ -442,8 +514,8 @@ if (count <= 0) { - return ValueTask.FromResult(new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult( - Array.Empty(), totalItems)); + return new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult( + Array.Empty(), totalItems); } // Build the items for this batch @@ -457,15 +529,81 @@ // Track loaded count for keyboard navigation _lazyLoadedCount = Math.Max(_lazyLoadedCount, startIndex + count); - - return ValueTask.FromResult(new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult( - items, totalItems)); + return new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult( + items, totalItems); + } + + private async ValueTask> LoadFromProviderAsync( + Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderRequest request) + { + var searchText = Context?.SearchQuery; + var providerRequest = new CommandItemsProviderRequest + { + StartIndex = request.StartIndex, + Count = request.Count, + SearchText = string.IsNullOrWhiteSpace(searchText) ? null : searchText, + CancellationToken = request.CancellationToken, + }; + + CommandItemsProviderResult result; + try + { + result = await ItemsProvider!(providerRequest); + } + catch (OperationCanceledException) + { + // Request superseded (further scroll / new search) or component disposed — let Virtualize + // discard this batch without surfacing an error. + return new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult( + Array.Empty(), _filteredCount); + } + + var batch = result.Items as IList ?? result.Items.ToList(); + var indexed = new IndexedItem[batch.Count]; + for (int i = 0; i < batch.Count; i++) + { + var index = request.StartIndex + i; + indexed[i] = new IndexedItem(batch[i], index); + _loadedItems[index] = batch[i]; // cache by absolute index for keyboard selection + } + + _lazyLoadedCount = Math.Max(_lazyLoadedCount, request.StartIndex + batch.Count); + + // The provider is the source of truth for counts; reflect its total in the heading and + // visibility. Re-render the group (not just Virtualize) when those change. + var hadVisible = _hasVisibleItems; + if (_totalCount != result.TotalItemCount || _filteredCount != result.TotalItemCount || hadVisible != result.TotalItemCount > 0) + { + _totalCount = result.TotalItemCount; + _filteredCount = result.TotalItemCount; + _hasVisibleItems = result.TotalItemCount > 0; + StateHasChanged(); + } + + return new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult( + indexed, result.TotalItemCount); } private async void HandleSearchChanged() { _cachedSearchQuery = null; // Force rebuild + if (UseProvider) + { + // Drop the cached slices and optimistically re-show so the (kept-mounted) Virtualize + // re-queries the provider with the new search; the response resets the real count. + _loadedItems.Clear(); + _lazyLoadedCount = 0; + _hasVisibleItems = true; + _focusedIndex = -1; + StateHasChanged(); + if (_virtualizeRef != null) + { + await _virtualizeRef.RefreshDataAsync(); + } + return; + } + if (EnableLazyLoading) { UpdateLazyFilteredSource(); diff --git a/src/BlazorBlueprint.Components/Components/Command/CommandItemsProvider.cs b/src/BlazorBlueprint.Components/Components/Command/CommandItemsProvider.cs new file mode 100644 index 000000000..e56f048d3 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Command/CommandItemsProvider.cs @@ -0,0 +1,59 @@ +namespace BlazorBlueprint.Components; + +/// +/// Delegate for asynchronous, server-side data fetching in a . +/// Invoked as the user scrolls (and whenever the search query changes) when +/// EnableLazyLoading is true and an ItemsProvider is supplied — so the caller +/// never has to materialize the full collection up front. +/// +/// The type of data items. +/// The request describing the slice to fetch, the active search text, and a cancellation token. +/// A result containing the items for the requested slice and the total (filtered) count. +public delegate ValueTask> CommandItemsProvider( + CommandItemsProviderRequest request); + +/// +/// Describes the data request from to the items provider. +/// +public class CommandItemsProviderRequest +{ + /// + /// Gets the zero-based index of the first item to return. + /// + public int StartIndex { get; init; } + + /// + /// Gets the maximum number of items to return for this slice. + /// + public int Count { get; init; } + + /// + /// Gets the active search text the provider should filter by, or null when no search is active. + /// Filtering is the provider's responsibility — the component does not filter provider results locally. + /// + public string? SearchText { get; init; } + + /// + /// Gets the cancellation token for the request. Cancelled when the request is superseded + /// (e.g. the user keeps scrolling or changes the search) or the component is disposed. + /// + public CancellationToken CancellationToken { get; init; } +} + +/// +/// The result returned by a . +/// +/// The type of data items. +public class CommandItemsProviderResult +{ + /// + /// Gets the items for the requested slice. + /// + public required ICollection Items { get; init; } + + /// + /// Gets the total number of items matching the current search across all slices. + /// Used to size the scroll area and drive keyboard navigation. + /// + public int TotalItemCount { get; init; } +} diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs index 959d7b0ab..9e6d446b3 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs @@ -2843,7 +2843,9 @@ private string GetHeaderCellClass(IDataGridColumn column, bool isSelectCo if (isSelectColumn || isExpandColumn) { - return ClassNames.cn(baseClass, "w-12", pinnedClass, separatorClass); + // column.HeaderClass last so callers can override the baked-in width/padding + // (e.g. compact select column). cn() is tailwind-merge, so later classes win. + return ClassNames.cn(baseClass, "w-12", pinnedClass, separatorClass, column.HeaderClass); } var needsGroup = column.Sortable || column.Filterable || (Reorderable && column.Reorderable); @@ -2909,7 +2911,9 @@ private string GetCellClass(IDataGridColumn column, bool isSelectColumn, if (isSelectColumn || isExpandColumn) { - return ClassNames.cn(baseClass, "w-12", pinnedClass, separatorClass); + // column.CellClass last so callers can override the baked-in width/padding + // (e.g. CellClass="p-1" for a compact select column). cn() is tailwind-merge. + return ClassNames.cn(baseClass, "w-12", pinnedClass, separatorClass, column.CellClass); } var cellClass = column.CellClass; diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridSelectColumn.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridSelectColumn.razor.cs index 1f8cba0cc..1a42182b5 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridSelectColumn.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridSelectColumn.razor.cs @@ -26,6 +26,20 @@ public partial class BbDataGridSelectColumn : ComponentBase, IDataGridCol [Parameter] public ColumnPinning Pinned { get; set; } = ColumnPinning.None; + /// + /// Additional CSS classes for the selection cells. Useful for matching a compact row height — + /// e.g. CellClass="p-1" to override the default cell padding so the checkbox column + /// doesn't force a taller row than the rest of the grid. + /// + [Parameter] + public string? CellClass { get; set; } + + /// + /// Additional CSS classes for the header cell (the select-all checkbox). + /// + [Parameter] + public string? HeaderClass { get; set; } + /// /// The parent DataGrid component. Set via cascading parameter. /// @@ -58,9 +72,9 @@ public partial class BbDataGridSelectColumn : ComponentBase, IDataGridCol RenderFragment>? IDataGridColumn.HeaderTemplate => null; - string? IDataGridColumn.CellClass => null; + string? IDataGridColumn.CellClass => CellClass; - string? IDataGridColumn.HeaderClass => null; + string? IDataGridColumn.HeaderClass => HeaderClass; bool IDataGridColumn.NoWrap => false; diff --git a/src/BlazorBlueprint.Components/Components/DataView/BbDataView.razor b/src/BlazorBlueprint.Components/Components/DataView/BbDataView.razor index e65a445fe..85e8791f6 100644 --- a/src/BlazorBlueprint.Components/Components/DataView/BbDataView.razor +++ b/src/BlazorBlueprint.Components/Components/DataView/BbDataView.razor @@ -101,7 +101,7 @@ } @* ── Content ─────────────────────────────────────────────────── *@ - @if (IsLoading) + @if (IsLoading || _isProviderLoading) { @if (LoadingTemplate != null) { @@ -191,7 +191,7 @@ } @* ── Pagination – hidden in infinite scroll mode ─────────────── *@ - @if (ShowPagination && !EnableInfiniteScroll && !IsLoading && _filteredSortedData.Count > 0) + @if (ShowPagination && !EnableInfiniteScroll && !IsLoading && !_isProviderLoading && _filteredSortedData.Count > 0) { ? _lastItemsProvider; + private List _accumulatedProviderItems = new(); + // ShouldRender tracking fields private bool _parametersChanged; private IEnumerable? _lastData; @@ -95,9 +104,18 @@ internal sealed class FieldData /// /// Gets or sets the data source for the view. + /// Provide either or , not both. /// - [Parameter, EditorRequired] - public IEnumerable Data { get; set; } = Array.Empty(); + [Parameter] + public IEnumerable? Data { get; set; } + + /// + /// Gets or sets an async callback for server-side data loading. + /// Invoked whenever pagination, sort, or search state changes. + /// Provide either or , not both. + /// + [Parameter] + public DataViewItemsProvider? ItemsProvider { get; set; } /// /// Gets or sets the template used to render each item in list layout mode. @@ -332,7 +350,9 @@ private DataViewLayout _effectiveLayout /// True when there are more batched items to reveal in infinite scroll mode. /// private bool CanLoadMore => EnableInfiniteScroll - && _currentInfinitePage * _paginationState.PageSize < _filteredSortedData.Count; + && (ItemsProvider != null + ? _accumulatedProviderItems.Count < _paginationState.TotalItems + : _currentInfinitePage * _paginationState.PageSize < _filteredSortedData.Count); /// /// The list template in effect: the named parameter takes precedence over any @@ -368,8 +388,10 @@ protected override async Task OnParametersSetAsync() // Sync the backing field when the Layout parameter changes externally. currentLayout = Layout; - // Skip reprocessing when the data source has not changed. - if (ReferenceEquals(_lastData, Data) && _lastData != null) + // Skip reprocessing when neither data source has changed. + if (ReferenceEquals(_lastData, Data) + && ReferenceEquals(_lastItemsProvider, ItemsProvider) + && (_lastData != null || _lastItemsProvider != null)) { return; } @@ -411,6 +433,7 @@ internal void RegisterField(BbDataViewColumn field) }); _fieldsVersion++; + StateHasChanged(); } /// @@ -439,32 +462,108 @@ internal void SetGridTemplate(RenderFragment? template) private async Task ProcessDataAsync() { - var data = Data ?? Array.Empty(); + if (ItemsProvider != null) + { + if (EnableInfiniteScroll) + { + _accumulatedProviderItems.Clear(); + } - if (PreprocessData != null) + await LoadFromProviderAsync(); + } + else { - data = await PreprocessData(data); + var data = Data ?? Array.Empty(); + + if (PreprocessData != null) + { + data = await PreprocessData(data); + } + + var filtered = ApplyFiltering(data); + var sorted = ApplySorting(filtered); + + _filteredSortedData = sorted.ToList(); + _paginationState.TotalItems = _filteredSortedData.Count; + + if (EnableInfiniteScroll) + { + // Reveal items from pages 1..N; N is incremented by LoadMore / scroll. + _visibleData = _filteredSortedData + .Take(_currentInfinitePage * _paginationState.PageSize) + .ToList(); + } + else + { + _visibleData = _filteredSortedData + .Skip(_paginationState.StartIndex) + .Take(_paginationState.PageSize) + .ToList(); + } } + } + + private async Task LoadFromProviderAsync() + { + var oldCts = _loadCts; + oldCts?.Cancel(); + oldCts?.Dispose(); + _loadCts = new CancellationTokenSource(); + var token = _loadCts.Token; + + _isProviderLoading = true; + _providerLoadingVersion++; + StateHasChanged(); - var filtered = ApplyFiltering(data); - var sorted = ApplySorting(filtered); + try + { + var startIndex = EnableInfiniteScroll + ? _accumulatedProviderItems.Count + : _paginationState.StartIndex; + + var request = new DataViewRequest + { + StartIndex = startIndex, + Count = _paginationState.PageSize, + SortField = _sortingState.SortedColumn, + SortDirection = _sortingState.Direction, + SearchText = string.IsNullOrWhiteSpace(_searchValue) ? null : _searchValue, + CancellationToken = token + }; - _filteredSortedData = sorted.ToList(); - _paginationState.TotalItems = _filteredSortedData.Count; + var result = await ItemsProvider!(request); + + if (token.IsCancellationRequested) + { + return; + } - if (EnableInfiniteScroll) + if (EnableInfiniteScroll) + { + _accumulatedProviderItems.AddRange(result.Items); + _filteredSortedData = _accumulatedProviderItems; + _visibleData = _accumulatedProviderItems; + } + else + { + _filteredSortedData = result.Items.ToList(); + _visibleData = _filteredSortedData; + } + + _paginationState.TotalItems = result.TotalItemCount; + } + catch (OperationCanceledException) { - // Reveal items from pages 1..N; N is incremented by LoadMore / scroll. - _visibleData = _filteredSortedData - .Take(_currentInfinitePage * _paginationState.PageSize) - .ToList(); + // Superseded by a newer request — the new request manages loading state. + return; } - else + finally { - _visibleData = _filteredSortedData - .Skip(_paginationState.StartIndex) - .Take(_paginationState.PageSize) - .ToList(); + if (!token.IsCancellationRequested) + { + _isProviderLoading = false; + _providerLoadingVersion++; + } } } @@ -642,9 +741,19 @@ private async Task LoadMore() } _isLoadingMore = true; - _currentInfinitePage++; _infiniteScrollVersion++; - await ProcessDataAsync(); + + if (ItemsProvider != null) + { + // startIndex is derived from _accumulatedProviderItems.Count inside LoadFromProviderAsync + await LoadFromProviderAsync(); + } + else + { + _currentInfinitePage++; + await ProcessDataAsync(); + } + _isLoadingMore = false; StateHasChanged(); } @@ -679,6 +788,7 @@ protected override bool ShouldRender() { _parametersChanged = false; _lastData = Data; + _lastItemsProvider = ItemsProvider; _lastLayout = currentLayout; _lastIsLoading = IsLoading; _lastFieldsVersion = _fieldsVersion; @@ -687,6 +797,7 @@ protected override bool ShouldRender() _lastSlotVersion = _slotVersion; _lastInfiniteScrollVersion = _infiniteScrollVersion; _lastSortingVersion = _sortingVersion; + _lastProviderLoadingVersion = _providerLoadingVersion; return true; } @@ -699,10 +810,12 @@ protected override bool ShouldRender() var slotChanged = _lastSlotVersion != _slotVersion; var infiniteScrollChanged = _lastInfiniteScrollVersion != _infiniteScrollVersion; var sortingChanged = _lastSortingVersion != _sortingVersion; + var providerLoadingChanged = _lastProviderLoadingVersion != _providerLoadingVersion; - if (dataChanged || layoutChanged || loadingChanged || fieldsChanged || searchChanged || paginationChanged || slotChanged || infiniteScrollChanged || sortingChanged) + if (dataChanged || layoutChanged || loadingChanged || fieldsChanged || searchChanged || paginationChanged || slotChanged || infiniteScrollChanged || sortingChanged || providerLoadingChanged) { _lastData = Data; + _lastItemsProvider = ItemsProvider; _lastLayout = currentLayout; _lastIsLoading = IsLoading; _lastFieldsVersion = _fieldsVersion; @@ -711,6 +824,7 @@ protected override bool ShouldRender() _lastSlotVersion = _slotVersion; _lastInfiniteScrollVersion = _infiniteScrollVersion; _lastSortingVersion = _sortingVersion; + _lastProviderLoadingVersion = _providerLoadingVersion; return true; } @@ -721,6 +835,10 @@ protected override bool ShouldRender() public async ValueTask DisposeAsync() { + _loadCts?.Cancel(); + _loadCts?.Dispose(); + _loadCts = null; + if (_jsModule != null) { try diff --git a/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor b/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor index 6e162c6ab..a2f612958 100644 --- a/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor +++ b/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor @@ -5,7 +5,7 @@
    - + : ComponentBase, IAsyncDisposable ///
    private bool _isOpen { get; set; } + /// + /// Whether the next close should return focus to the trigger. True for intentional + /// dismissals (Escape, the Close button); false for click-outside (focus stays where the + /// user clicked). Bound to the popover's RestoreFocusOnClose. + /// + private bool _restoreFocusOnClose; + /// /// Tracks the current search query for filtering. /// @@ -442,14 +449,23 @@ private void Open() return; } + _restoreFocusOnClose = false; // reset; intentional closes opt back in _isOpen = true; } /// - /// Closes the dropdown. + /// Closes the dropdown as an intentional dismissal (Escape, Close button), returning focus + /// to the trigger so keyboard navigation continues from the right place. + /// + private Task Close() => CloseCore(restoreFocus: true); + + /// + /// Closes the dropdown. controls whether focus returns to the + /// trigger — true for intentional dismissals, false for click-outside (leave focus where clicked). /// - private async Task Close() + private async Task CloseCore(bool restoreFocus) { + _restoreFocusOnClose = restoreFocus; _isOpen = false; _searchQuery = string.Empty; @@ -474,7 +490,7 @@ private EventCallback GetClickOutsideHandler() /// /// Handles click-outside events when AutoClose is enabled. /// - private async Task HandleClickOutside() => await Close(); + private async Task HandleClickOutside() => await CloseCore(restoreFocus: false); /// /// Handles the popover content ready event to focus the search input. diff --git a/src/BlazorBlueprint.Components/Components/Popover/BbPopover.razor b/src/BlazorBlueprint.Components/Components/Popover/BbPopover.razor index 4e6e963ed..2d2af20c4 100644 --- a/src/BlazorBlueprint.Components/Components/Popover/BbPopover.razor +++ b/src/BlazorBlueprint.Components/Components/Popover/BbPopover.razor @@ -9,6 +9,7 @@ OpenChanged="@OpenChanged" DefaultOpen="@DefaultOpen" OnOpenChange="@OnOpenChange" + RestoreFocusOnClose="@RestoreFocusOnClose" Modal="@Modal"> @ChildContent @@ -53,4 +54,12 @@ /// [Parameter] public bool Modal { get; set; } = true; + + /// + /// When the popover is closed via the controlled binding (consumer-driven, + /// e.g. after selecting an item), whether to return focus to the trigger. Defaults to false. + /// Click-outside and Escape dismissals are unaffected. + /// + [Parameter] + public bool RestoreFocusOnClose { get; set; } } diff --git a/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarMenuButton.razor b/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarMenuButton.razor index 3e45c1c86..86bd13bc8 100644 --- a/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarMenuButton.razor +++ b/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarMenuButton.razor @@ -1,5 +1,6 @@ @namespace BlazorBlueprint.Components @using Microsoft.AspNetCore.Components.Routing +@implements IDisposable @* SidebarMenuButton component for clickable menu items with tooltip support. @@ -28,6 +29,9 @@ else [CascadingParameter] private BlazorBlueprint.Primitives.Collapsible.CollapsibleContext? CollapsibleContext { get; set; } + [Inject] + private NavigationManager NavigationManager { get; set; } = default!; + /// /// The button content. /// @@ -99,6 +103,39 @@ else return !string.IsNullOrEmpty(Tooltip) && Context != null && !Context.Open; } + private bool isActiveByLocation; + + /// + /// Whether the button should render as active — either explicitly via + /// or because its matches the current location. + /// + private bool ResolvedActive => IsActive || isActiveByLocation; + + protected override void OnInitialized() + { + NavigationManager.LocationChanged += OnLocationChanged; + } + + protected override void OnParametersSet() + { + isActiveByLocation = NavLinkMatcher.IsMatch(NavigationManager, NavigationManager.Uri, Href, Match); + } + + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) + { + var nowActive = NavLinkMatcher.IsMatch(NavigationManager, e.Location, Href, Match); + if (nowActive != isActiveByLocation) + { + isActiveByLocation = nowActive; + InvokeAsync(StateHasChanged); + } + } + + public void Dispose() + { + NavigationManager.LocationChanged -= OnLocationChanged; + } + private RenderFragment RenderButton() => __builder => { // Auto-detect: if Href is provided and AsChild wasn't explicitly set to Button, render as Anchor @@ -107,19 +144,17 @@ else if (shouldRenderAsAnchor) { - + @ChildContent - + } else { @@ -127,9 +162,9 @@ else class="@GetClasses()" data-sidebar="menu-button" data-size="@Size.ToValue()" - data-active="@(IsActive ? "true" : "false")" + data-active="@(ResolvedActive ? "true" : "false")" data-state="@(CollapsibleContext?.Open ?? false ? "open" : "closed")" - aria-current="@(IsActive ? "page" : null)" + aria-current="@(ResolvedActive ? "page" : null)" aria-expanded="@(CollapsibleContext?.Open ?? false)" @onclick="HandleClick" @attributes="AdditionalAttributes"> @@ -153,7 +188,7 @@ else private string GetClasses() { - var baseClasses = "group peer/menu-button flex w-full items-center overflow-hidden text-left outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:[&>span]:hidden [&>span:last-child]:truncate [&>svg]:shrink-0"; + var baseClasses = "group peer/menu-button flex w-full items-center overflow-hidden text-left outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:[&>span]:hidden [&>span:last-child]:truncate [&>svg]:shrink-0"; var variantClasses = Variant.ToValue() switch { diff --git a/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarMenuSubButton.razor b/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarMenuSubButton.razor index 6de83c392..f46083db8 100644 --- a/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarMenuSubButton.razor +++ b/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarMenuSubButton.razor @@ -1,5 +1,6 @@ @namespace BlazorBlueprint.Components @using Microsoft.AspNetCore.Components.Routing +@implements IDisposable @* SidebarMenuSubButton component for clickable submenu items. @@ -13,13 +14,13 @@ @if (shouldRenderAsAnchor) { - + @ChildContent - + } else { - } @@ -73,6 +74,42 @@ else [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } + [Inject] + private NavigationManager NavigationManager { get; set; } = default!; + + private bool isActiveByLocation; + + /// + /// Whether the button should render as active — either explicitly via + /// or because its matches the current location. + /// + private bool ResolvedActive => IsActive || isActiveByLocation; + + protected override void OnInitialized() + { + NavigationManager.LocationChanged += OnLocationChanged; + } + + protected override void OnParametersSet() + { + isActiveByLocation = NavLinkMatcher.IsMatch(NavigationManager, NavigationManager.Uri, Href, Match); + } + + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) + { + var nowActive = NavLinkMatcher.IsMatch(NavigationManager, e.Location, Href, Match); + if (nowActive != isActiveByLocation) + { + isActiveByLocation = nowActive; + InvokeAsync(StateHasChanged); + } + } + + public void Dispose() + { + NavigationManager.LocationChanged -= OnLocationChanged; + } + private string GetClasses() { var baseClasses = "flex w-full min-w-0 items-center overflow-hidden text-sidebar-foreground outline-none ring-sidebar-ring transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0 [&>svg]:text-muted-foreground hover:[&>svg]:text-sidebar-accent-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground"; diff --git a/src/BlazorBlueprint.Components/Components/Sidebar/NavLinkMatcher.cs b/src/BlazorBlueprint.Components/Components/Sidebar/NavLinkMatcher.cs new file mode 100644 index 000000000..d9018793f --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Sidebar/NavLinkMatcher.cs @@ -0,0 +1,82 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Routing; + +namespace BlazorBlueprint.Components; + +/// +/// Replicates the active-route matching used by so that sidebar +/// menu buttons can compute their own active state and expose it through a reliable +/// data-active attribute. The algorithm is intentionally identical to +/// so the Match parameter behaves exactly as users expect. +/// +internal static class NavLinkMatcher +{ + /// + /// Determines whether matches the current location, using the + /// same rules as . + /// + /// Used to resolve to an absolute URI. + /// The current absolute URI to match against. + /// The link target. A null or empty value never matches. + /// How the URL should be matched. + public static bool IsMatch( + NavigationManager navigationManager, + string currentUriAbsolute, + string? href, + NavLinkMatch match) + { + if (string.IsNullOrEmpty(href)) + { + return false; + } + + var hrefAbsolute = navigationManager.ToAbsoluteUri(href).AbsoluteUri; + + if (EqualsHrefExactlyOrIfTrailingSlashAdded(currentUriAbsolute, hrefAbsolute)) + { + return true; + } + + return match == NavLinkMatch.Prefix + && IsStrictlyPrefixWithSeparator(currentUriAbsolute, hrefAbsolute); + } + + private static bool EqualsHrefExactlyOrIfTrailingSlashAdded(string currentUriAbsolute, string hrefAbsolute) + { + if (string.Equals(currentUriAbsolute, hrefAbsolute, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Special case: a link to "/path/" is also active at "/path" (with no trailing + // slash), because servers commonly serve the same page for both. + if (currentUriAbsolute.Length == hrefAbsolute.Length - 1 + && hrefAbsolute[^1] == '/' + && hrefAbsolute.StartsWith(currentUriAbsolute, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return false; + } + + private static bool IsStrictlyPrefixWithSeparator(string value, string prefix) + { + var prefixLength = prefix.Length; + + if (value.Length <= prefixLength) + { + return false; + } + + // Only match when there is a separator character at the end of the prefix or + // right after it: "/abc" is a prefix of "/abc/def" but not "/abcdef". + return value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && (prefixLength == 0 + || !IsUnreservedCharacter(prefix[prefixLength - 1]) + || !IsUnreservedCharacter(value[prefixLength])); + } + + private static bool IsUnreservedCharacter(char c) + => char.IsLetterOrDigit(c) || c is '-' or '.' or '_' or '~'; +} diff --git a/src/BlazorBlueprint.Components/RELEASE_NOTES.md b/src/BlazorBlueprint.Components/RELEASE_NOTES.md index 489e57248..c6b1e830a 100644 --- a/src/BlazorBlueprint.Components/RELEASE_NOTES.md +++ b/src/BlazorBlueprint.Components/RELEASE_NOTES.md @@ -1,14 +1,19 @@ -## What's New in v3.10.2 +## What's New in v3.11.0 ### New Features -- **FilterBuilder**: Localized the `WHERE` label, `AND`/`OR` logical operators, and all operator labels via `IBbLocalizer` (#319). -- **BbDataGridColumnFilter**: Operator options are now localized through `IBbLocalizer` (#319). +- **BbDataView**: Added `ItemsProvider` for server-side / lazy data loading (#306). +- **BbCommandVirtualizedGroup**: Added `ItemsProvider` for server-side lazy loading (#345). +- **BbDataGridSelectColumn**: Added `CellClass` and `HeaderClass` parameters to style the selection cells and header (#346). +- **BbPopover**: Added `RestoreFocusOnClose` parameter to return focus to the trigger on controlled close (#349). ### Bug Fixes -- **BbToastProvider**: Stopped the empty toast container from blocking clicks to the UI behind it (#316). +- **Combobox / MultiSelect**: Restore focus to the trigger on close so Tab navigation continues (#349). +- **Combobox**: Show the selected item's label for pre-bound values in compositional mode (#337, #343). +- **BbSidebarMenuButton / BbSidebarMenuSubButton**: Set `data-active` on menu links during navigation (#324). +- **Spinner**: Keep spinners and pulse indicators animating under `bb-no-animate` (#330). ### Improvements -- Bumped the `BlazorBlueprint.Primitives` dependency to 3.10.2. +- Bumped the `BlazorBlueprint.Primitives` dependency to 3.11.0. diff --git a/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css b/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css index aae89bec3..06be288f6 100644 --- a/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css +++ b/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css @@ -765,11 +765,16 @@ } } -/* Global animation disable — add class="bb-no-animate" to to turn off all animations */ +/* Global animation disable — add class="bb-no-animate" to to turn off + transitions and decorative animations. + + Looping status indicators are exempt: spinners (.animate-spin) and pulse + (.animate-pulse) communicate ongoing work, not decoration, so freezing them + would misrepresent state. Add .bb-animate-keep to exempt anything else. */ @layer utilities { - .bb-no-animate *, - .bb-no-animate *::before, - .bb-no-animate *::after { + .bb-no-animate *:not(.animate-spin, .animate-pulse, .bb-animate-keep), + .bb-no-animate *:not(.animate-spin, .animate-pulse, .bb-animate-keep)::before, + .bb-no-animate *:not(.animate-spin, .animate-pulse, .bb-animate-keep)::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; diff --git a/src/BlazorBlueprint.Icons.FontAwesome/BlazorBlueprint.Icons.FontAwesome.csproj b/src/BlazorBlueprint.Icons.FontAwesome/BlazorBlueprint.Icons.FontAwesome.csproj new file mode 100644 index 000000000..883be8e84 --- /dev/null +++ b/src/BlazorBlueprint.Icons.FontAwesome/BlazorBlueprint.Icons.FontAwesome.csproj @@ -0,0 +1,42 @@ + + + + net8.0 + enable + enable + + + BlazorBlueprint.Icons.FontAwesome + BlazorBlueprint.Icons.FontAwesome + BlazorBlueprint.Icons.FontAwesome + Font Awesome Free icon library for BlazorBlueprint - 2066 icons across 3 variants (solid, regular, and brands) for Blazor applications. + blazor;icons;fontawesome;shadcn;svg;ui;components;blazor-components;tailwind + README.md + MIT + https://blazorblueprintui.com + https://github.com/blazorblueprintui/ui + David Ball + Copyright (c) 2025-present Mathew Taylor, David Ball + + + icons-fontawesome/v + beta.0 + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/BlazorBlueprint.Icons.FontAwesome/Components/FontAwesomeIcon.razor b/src/BlazorBlueprint.Icons.FontAwesome/Components/FontAwesomeIcon.razor new file mode 100644 index 000000000..da9022dc4 --- /dev/null +++ b/src/BlazorBlueprint.Icons.FontAwesome/Components/FontAwesomeIcon.razor @@ -0,0 +1,21 @@ +@namespace BlazorBlueprint.Icons.FontAwesome.Components + +@if (IconEntry is not null) +{ + + @((MarkupString)SvgBody) + +} +else +{ + @* Fallback for missing icons *@ + ⚠️ +} diff --git a/src/BlazorBlueprint.Icons.FontAwesome/Components/FontAwesomeIcon.razor.cs b/src/BlazorBlueprint.Icons.FontAwesome/Components/FontAwesomeIcon.razor.cs new file mode 100644 index 000000000..6cc60dd92 --- /dev/null +++ b/src/BlazorBlueprint.Icons.FontAwesome/Components/FontAwesomeIcon.razor.cs @@ -0,0 +1,112 @@ +using Microsoft.AspNetCore.Components; +using BlazorBlueprint.Icons.FontAwesome.Data; + +namespace BlazorBlueprint.Icons.FontAwesome.Components; + +/// +/// A Blazor component for rendering Font Awesome Free SVG icons. +/// Supports 3 variants: Solid, Regular, and Brands. +/// +public partial class FontAwesomeIcon : ComponentBase +{ + /// + /// The name of the icon to render (case-insensitive, kebab-case). + /// Example: "camera", "user", "github" + /// + [Parameter, EditorRequired] + public string Name { get; set; } = string.Empty; + + /// + /// The icon variant to render. + /// Default is Solid. + /// + [Parameter] + public FontAwesomeIconVariant Variant { get; set; } = FontAwesomeIconVariant.Solid; + + /// + /// The size of the icon in pixels (applies to width). + /// Height is scaled proportionally to preserve aspect ratio (Brands icons in particular are not square). + /// Default is 16px. + /// + [Parameter] + public int? Size { get; set; } + + /// + /// The color of the icon. Supports CSS color values. + /// Default is "currentColor" (inherits from parent). + /// Examples: "red", "#FF0000", "var(--primary)", "rgb(255, 0, 0)" + /// + [Parameter] + public string Color { get; set; } = "currentColor"; + + /// + /// Additional CSS classes to apply to the icon. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// ARIA label for accessibility (screen readers). + /// Recommended for icon-only buttons. + /// + [Parameter] + public string? AriaLabel { get; set; } + + /// + /// Additional HTML attributes to apply to the SVG element. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private const int DefaultSize = 16; + + /// + /// The icon entry (body + intrinsic width/height) for the current Name and Variant. + /// + private FontAwesomeIconEntry? IconEntry => FontAwesomeIconData.GetIcon(Name, Variant); + + /// + /// Icon SVG body with hardcoded fill/stroke attributes stripped, so the outer + /// <svg> element's fill (driven by the Color parameter) is honored. + /// + private string SvgBody => IconEntry is null + ? string.Empty + : System.Text.RegularExpressions.Regex.Replace( + IconEntry.Body, + @"\s+(stroke|fill)=""[^""]*""", + "", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + /// + /// The computed width in pixels. + /// + private int ComputedSize => Size ?? DefaultSize; + + /// + /// The computed height in pixels, scaled to preserve the icon's intrinsic aspect ratio. + /// + private int ComputedHeight + { + get + { + if (IconEntry is null || IconEntry.Width == 0) + { + return ComputedSize; + } + + return (int)Math.Round(ComputedSize * ((double)IconEntry.Height / IconEntry.Width)); + } + } + + /// + /// The viewBox derived from the icon's intrinsic width and height. + /// + private string ViewBox => IconEntry is null + ? "0 0 512 512" + : $"0 0 {IconEntry.Width} {IconEntry.Height}"; + + /// + /// The combined CSS class string. + /// + private string CssClass => string.IsNullOrEmpty(Class) ? string.Empty : Class; +} diff --git a/src/BlazorBlueprint.Icons.FontAwesome/Data/FontAwesomeIconData.cs b/src/BlazorBlueprint.Icons.FontAwesome/Data/FontAwesomeIconData.cs new file mode 100644 index 000000000..99f1a6ac8 --- /dev/null +++ b/src/BlazorBlueprint.Icons.FontAwesome/Data/FontAwesomeIconData.cs @@ -0,0 +1,2175 @@ +// +#nullable enable +// This file is auto-generated. Do not edit manually. +// Generated from fa6-solid.json, fa6-regular.json, fa6-brands.json on 2026-05-20 + +namespace BlazorBlueprint.Icons.FontAwesome.Data; + +/// +/// Icon variant for Font Awesome Free. +/// +public enum FontAwesomeIconVariant +{ + /// Solid variant (filled glyphs, the most common Font Awesome style) + Solid, + + /// Regular variant (outline glyphs, fewer icons available in the Free tier) + Regular, + + /// Brands variant (logos for third-party services and products) + Brands +} + +/// +/// A single Font Awesome icon entry: SVG body plus intrinsic dimensions used to build the viewBox. +/// +public sealed record FontAwesomeIconEntry(int Width, int Height, string Body); + +/// +/// Provides access to Font Awesome Free SVG data. +/// Contains 2066 total icons from the Font Awesome icon set across 3 variants. +/// +public static class FontAwesomeIconData +{ + private static readonly IReadOnlyDictionary SolidIcons = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["0"] = new FontAwesomeIconEntry(320, 512, ""), + ["1"] = new FontAwesomeIconEntry(256, 512, ""), + ["2"] = new FontAwesomeIconEntry(320, 512, ""), + ["3"] = new FontAwesomeIconEntry(320, 512, ""), + ["4"] = new FontAwesomeIconEntry(384, 512, ""), + ["5"] = new FontAwesomeIconEntry(320, 512, ""), + ["6"] = new FontAwesomeIconEntry(320, 512, ""), + ["7"] = new FontAwesomeIconEntry(320, 512, ""), + ["8"] = new FontAwesomeIconEntry(320, 512, ""), + ["9"] = new FontAwesomeIconEntry(320, 512, ""), + ["a"] = new FontAwesomeIconEntry(384, 512, ""), + ["address-book"] = new FontAwesomeIconEntry(512, 512, ""), + ["address-card"] = new FontAwesomeIconEntry(576, 512, ""), + ["align-center"] = new FontAwesomeIconEntry(448, 512, ""), + ["align-justify"] = new FontAwesomeIconEntry(448, 512, ""), + ["align-left"] = new FontAwesomeIconEntry(448, 512, ""), + ["align-right"] = new FontAwesomeIconEntry(448, 512, ""), + ["anchor"] = new FontAwesomeIconEntry(576, 512, ""), + ["anchor-circle-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["anchor-circle-exclamation"] = new FontAwesomeIconEntry(640, 512, ""), + ["anchor-circle-xmark"] = new FontAwesomeIconEntry(640, 512, ""), + ["anchor-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["angle-down"] = new FontAwesomeIconEntry(448, 512, ""), + ["angle-left"] = new FontAwesomeIconEntry(320, 512, ""), + ["angle-right"] = new FontAwesomeIconEntry(320, 512, ""), + ["angles-down"] = new FontAwesomeIconEntry(448, 512, ""), + ["angles-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["angles-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["angles-up"] = new FontAwesomeIconEntry(448, 512, ""), + ["angle-up"] = new FontAwesomeIconEntry(448, 512, ""), + ["ankh"] = new FontAwesomeIconEntry(320, 512, ""), + ["apple-whole"] = new FontAwesomeIconEntry(448, 512, ""), + ["archway"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrow-down"] = new FontAwesomeIconEntry(384, 512, ""), + ["arrow-down-1-9"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-down-9-1"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-down-a-z"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-down-long"] = new FontAwesomeIconEntry(384, 512, ""), + ["arrow-down-short-wide"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-down-up-across-line"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-down-up-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["arrow-down-wide-short"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-down-z-a"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-left"] = new FontAwesomeIconEntry(448, 512, ""), + ["arrow-left-long"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrow-pointer"] = new FontAwesomeIconEntry(320, 512, ""), + ["arrow-right"] = new FontAwesomeIconEntry(448, 512, ""), + ["arrow-right-arrow-left"] = new FontAwesomeIconEntry(448, 512, ""), + ["arrow-right-from-bracket"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrow-right-long"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrow-right-to-bracket"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrow-right-to-city"] = new FontAwesomeIconEntry(640, 512, ""), + ["arrow-rotate-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrow-rotate-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrows-down-to-line"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrows-down-to-people"] = new FontAwesomeIconEntry(640, 512, ""), + ["arrows-left-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrows-left-right-to-line"] = new FontAwesomeIconEntry(640, 512, ""), + ["arrows-rotate"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrows-spin"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrows-split-up-and-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrows-to-circle"] = new FontAwesomeIconEntry(640, 512, ""), + ["arrows-to-dot"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrows-to-eye"] = new FontAwesomeIconEntry(640, 512, ""), + ["arrows-turn-right"] = new FontAwesomeIconEntry(448, 512, ""), + ["arrows-turn-to-dots"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrows-up-down"] = new FontAwesomeIconEntry(320, 512, ""), + ["arrows-up-down-left-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrows-up-to-line"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-trend-down"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-trend-up"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-turn-down"] = new FontAwesomeIconEntry(384, 512, ""), + ["arrow-turn-up"] = new FontAwesomeIconEntry(384, 512, ""), + ["arrow-up"] = new FontAwesomeIconEntry(384, 512, ""), + ["arrow-up-1-9"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-up-9-1"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-up-a-z"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-up-from-bracket"] = new FontAwesomeIconEntry(448, 512, ""), + ["arrow-up-from-ground-water"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-up-from-water-pump"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-up-long"] = new FontAwesomeIconEntry(384, 512, ""), + ["arrow-up-right-dots"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-up-right-from-square"] = new FontAwesomeIconEntry(512, 512, ""), + ["arrow-up-short-wide"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-up-wide-short"] = new FontAwesomeIconEntry(576, 512, ""), + ["arrow-up-z-a"] = new FontAwesomeIconEntry(576, 512, ""), + ["asterisk"] = new FontAwesomeIconEntry(384, 512, ""), + ["at"] = new FontAwesomeIconEntry(512, 512, ""), + ["atom"] = new FontAwesomeIconEntry(512, 512, ""), + ["audio-description"] = new FontAwesomeIconEntry(576, 512, ""), + ["austral-sign"] = new FontAwesomeIconEntry(448, 512, ""), + ["award"] = new FontAwesomeIconEntry(384, 512, ""), + ["b"] = new FontAwesomeIconEntry(320, 512, ""), + ["baby"] = new FontAwesomeIconEntry(448, 512, ""), + ["baby-carriage"] = new FontAwesomeIconEntry(512, 512, ""), + ["backward"] = new FontAwesomeIconEntry(512, 512, ""), + ["backward-fast"] = new FontAwesomeIconEntry(512, 512, ""), + ["backward-step"] = new FontAwesomeIconEntry(320, 512, ""), + ["bacon"] = new FontAwesomeIconEntry(576, 512, ""), + ["bacteria"] = new FontAwesomeIconEntry(640, 512, ""), + ["bacterium"] = new FontAwesomeIconEntry(512, 512, ""), + ["bag-shopping"] = new FontAwesomeIconEntry(448, 512, ""), + ["bahai"] = new FontAwesomeIconEntry(576, 512, ""), + ["baht-sign"] = new FontAwesomeIconEntry(320, 512, ""), + ["ban"] = new FontAwesomeIconEntry(512, 512, ""), + ["bandage"] = new FontAwesomeIconEntry(640, 512, ""), + ["bangladeshi-taka-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["ban-smoking"] = new FontAwesomeIconEntry(512, 512, ""), + ["barcode"] = new FontAwesomeIconEntry(512, 512, ""), + ["bars"] = new FontAwesomeIconEntry(448, 512, ""), + ["bars-progress"] = new FontAwesomeIconEntry(512, 512, ""), + ["bars-staggered"] = new FontAwesomeIconEntry(512, 512, ""), + ["baseball"] = new FontAwesomeIconEntry(512, 512, ""), + ["baseball-bat-ball"] = new FontAwesomeIconEntry(512, 512, ""), + ["basketball"] = new FontAwesomeIconEntry(512, 512, ""), + ["basket-shopping"] = new FontAwesomeIconEntry(576, 512, ""), + ["bath"] = new FontAwesomeIconEntry(512, 512, ""), + ["battery-empty"] = new FontAwesomeIconEntry(576, 512, ""), + ["battery-full"] = new FontAwesomeIconEntry(576, 512, ""), + ["battery-half"] = new FontAwesomeIconEntry(576, 512, ""), + ["battery-quarter"] = new FontAwesomeIconEntry(576, 512, ""), + ["battery-three-quarters"] = new FontAwesomeIconEntry(576, 512, ""), + ["bed"] = new FontAwesomeIconEntry(640, 512, ""), + ["bed-pulse"] = new FontAwesomeIconEntry(640, 512, ""), + ["beer-mug-empty"] = new FontAwesomeIconEntry(512, 512, ""), + ["bell"] = new FontAwesomeIconEntry(448, 512, ""), + ["bell-concierge"] = new FontAwesomeIconEntry(512, 512, ""), + ["bell-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["bezier-curve"] = new FontAwesomeIconEntry(640, 512, ""), + ["bicycle"] = new FontAwesomeIconEntry(640, 512, ""), + ["binoculars"] = new FontAwesomeIconEntry(512, 512, ""), + ["biohazard"] = new FontAwesomeIconEntry(576, 512, ""), + ["bitcoin-sign"] = new FontAwesomeIconEntry(320, 512, ""), + ["blender"] = new FontAwesomeIconEntry(512, 512, ""), + ["blender-phone"] = new FontAwesomeIconEntry(576, 512, ""), + ["blog"] = new FontAwesomeIconEntry(512, 512, ""), + ["bold"] = new FontAwesomeIconEntry(384, 512, ""), + ["bolt"] = new FontAwesomeIconEntry(448, 512, ""), + ["bolt-lightning"] = new FontAwesomeIconEntry(384, 512, ""), + ["bomb"] = new FontAwesomeIconEntry(512, 512, ""), + ["bone"] = new FontAwesomeIconEntry(576, 512, ""), + ["bong"] = new FontAwesomeIconEntry(448, 512, ""), + ["book"] = new FontAwesomeIconEntry(448, 512, ""), + ["book-atlas"] = new FontAwesomeIconEntry(448, 512, ""), + ["book-bible"] = new FontAwesomeIconEntry(448, 512, ""), + ["book-bookmark"] = new FontAwesomeIconEntry(448, 512, ""), + ["book-journal-whills"] = new FontAwesomeIconEntry(448, 512, ""), + ["bookmark"] = new FontAwesomeIconEntry(384, 512, ""), + ["book-medical"] = new FontAwesomeIconEntry(448, 512, ""), + ["book-open"] = new FontAwesomeIconEntry(576, 512, ""), + ["book-open-reader"] = new FontAwesomeIconEntry(512, 512, ""), + ["book-quran"] = new FontAwesomeIconEntry(448, 512, ""), + ["book-skull"] = new FontAwesomeIconEntry(448, 512, ""), + ["book-tanakh"] = new FontAwesomeIconEntry(448, 512, ""), + ["border-all"] = new FontAwesomeIconEntry(448, 512, ""), + ["border-none"] = new FontAwesomeIconEntry(448, 512, ""), + ["border-top-left"] = new FontAwesomeIconEntry(448, 512, ""), + ["bore-hole"] = new FontAwesomeIconEntry(512, 512, ""), + ["bottle-droplet"] = new FontAwesomeIconEntry(320, 512, ""), + ["bottle-water"] = new FontAwesomeIconEntry(320, 512, ""), + ["bowl-food"] = new FontAwesomeIconEntry(512, 512, ""), + ["bowling-ball"] = new FontAwesomeIconEntry(512, 512, ""), + ["bowl-rice"] = new FontAwesomeIconEntry(512, 512, ""), + ["box"] = new FontAwesomeIconEntry(448, 512, ""), + ["box-archive"] = new FontAwesomeIconEntry(512, 512, ""), + ["boxes-packing"] = new FontAwesomeIconEntry(640, 512, ""), + ["boxes-stacked"] = new FontAwesomeIconEntry(576, 512, ""), + ["box-open"] = new FontAwesomeIconEntry(640, 512, ""), + ["box-tissue"] = new FontAwesomeIconEntry(512, 512, ""), + ["braille"] = new FontAwesomeIconEntry(640, 512, ""), + ["brain"] = new FontAwesomeIconEntry(512, 512, ""), + ["brazilian-real-sign"] = new FontAwesomeIconEntry(512, 512, ""), + ["bread-slice"] = new FontAwesomeIconEntry(512, 512, ""), + ["bridge"] = new FontAwesomeIconEntry(576, 512, ""), + ["bridge-circle-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["bridge-circle-exclamation"] = new FontAwesomeIconEntry(640, 512, ""), + ["bridge-circle-xmark"] = new FontAwesomeIconEntry(640, 512, ""), + ["bridge-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["bridge-water"] = new FontAwesomeIconEntry(576, 512, ""), + ["briefcase"] = new FontAwesomeIconEntry(512, 512, ""), + ["briefcase-medical"] = new FontAwesomeIconEntry(512, 512, ""), + ["broom"] = new FontAwesomeIconEntry(576, 512, ""), + ["broom-ball"] = new FontAwesomeIconEntry(576, 512, ""), + ["brush"] = new FontAwesomeIconEntry(384, 512, ""), + ["bucket"] = new FontAwesomeIconEntry(448, 512, ""), + ["bug"] = new FontAwesomeIconEntry(512, 512, ""), + ["bugs"] = new FontAwesomeIconEntry(576, 512, ""), + ["bug-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["building"] = new FontAwesomeIconEntry(384, 512, ""), + ["building-circle-arrow-right"] = new FontAwesomeIconEntry(640, 512, ""), + ["building-circle-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["building-circle-exclamation"] = new FontAwesomeIconEntry(640, 512, ""), + ["building-circle-xmark"] = new FontAwesomeIconEntry(640, 512, ""), + ["building-columns"] = new FontAwesomeIconEntry(512, 512, ""), + ["building-flag"] = new FontAwesomeIconEntry(640, 512, ""), + ["building-lock"] = new FontAwesomeIconEntry(576, 512, ""), + ["building-ngo"] = new FontAwesomeIconEntry(384, 512, ""), + ["building-shield"] = new FontAwesomeIconEntry(576, 512, ""), + ["building-un"] = new FontAwesomeIconEntry(384, 512, ""), + ["building-user"] = new FontAwesomeIconEntry(640, 512, ""), + ["building-wheat"] = new FontAwesomeIconEntry(640, 512, ""), + ["bullhorn"] = new FontAwesomeIconEntry(512, 512, ""), + ["bullseye"] = new FontAwesomeIconEntry(512, 512, ""), + ["burger"] = new FontAwesomeIconEntry(512, 512, ""), + ["burst"] = new FontAwesomeIconEntry(512, 512, ""), + ["bus"] = new FontAwesomeIconEntry(576, 512, ""), + ["business-time"] = new FontAwesomeIconEntry(640, 512, ""), + ["bus-simple"] = new FontAwesomeIconEntry(448, 512, ""), + ["c"] = new FontAwesomeIconEntry(384, 512, ""), + ["cable-car"] = new FontAwesomeIconEntry(512, 512, ""), + ["cake-candles"] = new FontAwesomeIconEntry(448, 512, ""), + ["calculator"] = new FontAwesomeIconEntry(384, 512, ""), + ["calendar"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-check"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-day"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-days"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-minus"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-plus"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-week"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-xmark"] = new FontAwesomeIconEntry(448, 512, ""), + ["camera"] = new FontAwesomeIconEntry(512, 512, ""), + ["camera-retro"] = new FontAwesomeIconEntry(512, 512, ""), + ["camera-rotate"] = new FontAwesomeIconEntry(640, 512, ""), + ["campground"] = new FontAwesomeIconEntry(576, 512, ""), + ["candy-cane"] = new FontAwesomeIconEntry(512, 512, ""), + ["cannabis"] = new FontAwesomeIconEntry(512, 512, ""), + ["capsules"] = new FontAwesomeIconEntry(576, 512, ""), + ["car"] = new FontAwesomeIconEntry(512, 512, ""), + ["caravan"] = new FontAwesomeIconEntry(640, 512, ""), + ["car-battery"] = new FontAwesomeIconEntry(512, 512, ""), + ["car-burst"] = new FontAwesomeIconEntry(640, 512, ""), + ["car-crash"] = new FontAwesomeIconEntry(640, 512, ""), + ["caret-down"] = new FontAwesomeIconEntry(320, 512, ""), + ["caret-left"] = new FontAwesomeIconEntry(256, 512, ""), + ["caret-right"] = new FontAwesomeIconEntry(256, 512, ""), + ["caret-up"] = new FontAwesomeIconEntry(320, 512, ""), + ["car-on"] = new FontAwesomeIconEntry(512, 512, ""), + ["car-rear"] = new FontAwesomeIconEntry(512, 512, ""), + ["carrot"] = new FontAwesomeIconEntry(512, 512, ""), + ["car-side"] = new FontAwesomeIconEntry(640, 512, ""), + ["cart-arrow-down"] = new FontAwesomeIconEntry(576, 512, ""), + ["cart-flatbed"] = new FontAwesomeIconEntry(640, 512, ""), + ["cart-flatbed-suitcase"] = new FontAwesomeIconEntry(640, 512, ""), + ["cart-plus"] = new FontAwesomeIconEntry(576, 512, ""), + ["cart-shopping"] = new FontAwesomeIconEntry(576, 512, ""), + ["car-tunnel"] = new FontAwesomeIconEntry(512, 512, ""), + ["cash-register"] = new FontAwesomeIconEntry(512, 512, ""), + ["cat"] = new FontAwesomeIconEntry(576, 512, ""), + ["cedi-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["cent-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["certificate"] = new FontAwesomeIconEntry(512, 512, ""), + ["chair"] = new FontAwesomeIconEntry(448, 512, ""), + ["chalkboard"] = new FontAwesomeIconEntry(576, 512, ""), + ["chalkboard-user"] = new FontAwesomeIconEntry(640, 512, ""), + ["champagne-glasses"] = new FontAwesomeIconEntry(640, 512, ""), + ["charging-station"] = new FontAwesomeIconEntry(576, 512, ""), + ["chart-area"] = new FontAwesomeIconEntry(512, 512, ""), + ["chart-bar"] = new FontAwesomeIconEntry(512, 512, ""), + ["chart-column"] = new FontAwesomeIconEntry(512, 512, ""), + ["chart-diagram"] = new FontAwesomeIconEntry(512, 512, ""), + ["chart-gantt"] = new FontAwesomeIconEntry(512, 512, ""), + ["chart-line"] = new FontAwesomeIconEntry(512, 512, ""), + ["chart-pie"] = new FontAwesomeIconEntry(576, 512, ""), + ["chart-simple"] = new FontAwesomeIconEntry(448, 512, ""), + ["check"] = new FontAwesomeIconEntry(448, 512, ""), + ["check-double"] = new FontAwesomeIconEntry(448, 512, ""), + ["check-to-slot"] = new FontAwesomeIconEntry(576, 512, ""), + ["cheese"] = new FontAwesomeIconEntry(512, 512, ""), + ["chess"] = new FontAwesomeIconEntry(512, 512, ""), + ["chess-bishop"] = new FontAwesomeIconEntry(320, 512, ""), + ["chess-board"] = new FontAwesomeIconEntry(448, 512, ""), + ["chess-king"] = new FontAwesomeIconEntry(448, 512, ""), + ["chess-knight"] = new FontAwesomeIconEntry(448, 512, ""), + ["chess-pawn"] = new FontAwesomeIconEntry(320, 512, ""), + ["chess-queen"] = new FontAwesomeIconEntry(512, 512, ""), + ["chess-rook"] = new FontAwesomeIconEntry(448, 512, ""), + ["chevron-down"] = new FontAwesomeIconEntry(512, 512, ""), + ["chevron-left"] = new FontAwesomeIconEntry(320, 512, ""), + ["chevron-right"] = new FontAwesomeIconEntry(320, 512, ""), + ["chevron-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["child"] = new FontAwesomeIconEntry(320, 512, ""), + ["child-combatant"] = new FontAwesomeIconEntry(576, 512, ""), + ["child-dress"] = new FontAwesomeIconEntry(320, 512, ""), + ["child-reaching"] = new FontAwesomeIconEntry(384, 512, ""), + ["children"] = new FontAwesomeIconEntry(640, 512, ""), + ["church"] = new FontAwesomeIconEntry(640, 512, ""), + ["circle"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-arrow-down"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-arrow-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-arrow-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-arrow-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-check"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-chevron-down"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-chevron-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-chevron-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-chevron-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-dollar-to-slot"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-dot"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-down"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-exclamation"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-h"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-half-stroke"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-info"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-minus"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-nodes"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-notch"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-pause"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-play"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-plus"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-question"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-radiation"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-stop"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-user"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-xmark"] = new FontAwesomeIconEntry(512, 512, ""), + ["city"] = new FontAwesomeIconEntry(640, 512, ""), + ["clapperboard"] = new FontAwesomeIconEntry(512, 512, ""), + ["clipboard"] = new FontAwesomeIconEntry(384, 512, ""), + ["clipboard-check"] = new FontAwesomeIconEntry(384, 512, ""), + ["clipboard-list"] = new FontAwesomeIconEntry(384, 512, ""), + ["clipboard-question"] = new FontAwesomeIconEntry(384, 512, ""), + ["clipboard-user"] = new FontAwesomeIconEntry(384, 512, ""), + ["clock"] = new FontAwesomeIconEntry(512, 512, ""), + ["clock-rotate-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["clone"] = new FontAwesomeIconEntry(512, 512, ""), + ["closed-captioning"] = new FontAwesomeIconEntry(576, 512, ""), + ["cloud"] = new FontAwesomeIconEntry(640, 512, ""), + ["cloud-arrow-down"] = new FontAwesomeIconEntry(640, 512, ""), + ["cloud-arrow-up"] = new FontAwesomeIconEntry(640, 512, ""), + ["cloud-bolt"] = new FontAwesomeIconEntry(512, 512, ""), + ["cloud-meatball"] = new FontAwesomeIconEntry(512, 512, ""), + ["cloud-moon"] = new FontAwesomeIconEntry(640, 512, ""), + ["cloud-moon-rain"] = new FontAwesomeIconEntry(576, 512, ""), + ["cloud-rain"] = new FontAwesomeIconEntry(512, 512, ""), + ["cloud-showers-heavy"] = new FontAwesomeIconEntry(512, 512, ""), + ["cloud-showers-water"] = new FontAwesomeIconEntry(576, 512, ""), + ["cloud-sun"] = new FontAwesomeIconEntry(640, 512, ""), + ["cloud-sun-rain"] = new FontAwesomeIconEntry(640, 512, ""), + ["clover"] = new FontAwesomeIconEntry(448, 512, ""), + ["code"] = new FontAwesomeIconEntry(640, 512, ""), + ["code-branch"] = new FontAwesomeIconEntry(448, 512, ""), + ["code-commit"] = new FontAwesomeIconEntry(640, 512, ""), + ["code-compare"] = new FontAwesomeIconEntry(512, 512, ""), + ["code-fork"] = new FontAwesomeIconEntry(448, 512, ""), + ["code-merge"] = new FontAwesomeIconEntry(448, 512, ""), + ["code-pull-request"] = new FontAwesomeIconEntry(512, 512, ""), + ["coins"] = new FontAwesomeIconEntry(512, 512, ""), + ["colon-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["comment"] = new FontAwesomeIconEntry(512, 512, ""), + ["comment-dollar"] = new FontAwesomeIconEntry(512, 512, ""), + ["comment-dots"] = new FontAwesomeIconEntry(512, 512, ""), + ["comment-medical"] = new FontAwesomeIconEntry(512, 512, ""), + ["comment-nodes"] = new FontAwesomeIconEntry(640, 512, ""), + ["comments"] = new FontAwesomeIconEntry(640, 512, ""), + ["comments-dollar"] = new FontAwesomeIconEntry(640, 512, ""), + ["comment-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["comment-sms"] = new FontAwesomeIconEntry(512, 512, ""), + ["compact-disc"] = new FontAwesomeIconEntry(512, 512, ""), + ["compass"] = new FontAwesomeIconEntry(512, 512, ""), + ["compass-drafting"] = new FontAwesomeIconEntry(512, 512, ""), + ["compress"] = new FontAwesomeIconEntry(448, 512, ""), + ["computer"] = new FontAwesomeIconEntry(640, 512, ""), + ["computer-mouse"] = new FontAwesomeIconEntry(384, 512, ""), + ["cookie"] = new FontAwesomeIconEntry(512, 512, ""), + ["cookie-bite"] = new FontAwesomeIconEntry(512, 512, ""), + ["copy"] = new FontAwesomeIconEntry(448, 512, ""), + ["copyright"] = new FontAwesomeIconEntry(512, 512, ""), + ["couch"] = new FontAwesomeIconEntry(640, 512, ""), + ["cow"] = new FontAwesomeIconEntry(640, 512, ""), + ["credit-card"] = new FontAwesomeIconEntry(576, 512, ""), + ["crop"] = new FontAwesomeIconEntry(512, 512, ""), + ["crop-simple"] = new FontAwesomeIconEntry(512, 512, ""), + ["cross"] = new FontAwesomeIconEntry(384, 512, ""), + ["crosshairs"] = new FontAwesomeIconEntry(512, 512, ""), + ["crow"] = new FontAwesomeIconEntry(640, 512, ""), + ["crown"] = new FontAwesomeIconEntry(576, 512, ""), + ["crutch"] = new FontAwesomeIconEntry(512, 512, ""), + ["cruzeiro-sign"] = new FontAwesomeIconEntry(448, 512, ""), + ["cube"] = new FontAwesomeIconEntry(512, 512, ""), + ["cubes"] = new FontAwesomeIconEntry(576, 512, ""), + ["cubes-stacked"] = new FontAwesomeIconEntry(448, 512, ""), + ["d"] = new FontAwesomeIconEntry(384, 512, ""), + ["database"] = new FontAwesomeIconEntry(448, 512, ""), + ["delete-left"] = new FontAwesomeIconEntry(576, 512, ""), + ["democrat"] = new FontAwesomeIconEntry(640, 512, ""), + ["desktop"] = new FontAwesomeIconEntry(576, 512, ""), + ["dharmachakra"] = new FontAwesomeIconEntry(512, 512, ""), + ["diagram-next"] = new FontAwesomeIconEntry(512, 512, ""), + ["diagram-predecessor"] = new FontAwesomeIconEntry(512, 512, ""), + ["diagram-project"] = new FontAwesomeIconEntry(576, 512, ""), + ["diagram-successor"] = new FontAwesomeIconEntry(512, 512, ""), + ["diamond"] = new FontAwesomeIconEntry(512, 512, ""), + ["diamond-turn-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["dice"] = new FontAwesomeIconEntry(640, 512, ""), + ["dice-d20"] = new FontAwesomeIconEntry(512, 512, ""), + ["dice-d6"] = new FontAwesomeIconEntry(448, 512, ""), + ["dice-five"] = new FontAwesomeIconEntry(448, 512, ""), + ["dice-four"] = new FontAwesomeIconEntry(448, 512, ""), + ["dice-one"] = new FontAwesomeIconEntry(448, 512, ""), + ["dice-six"] = new FontAwesomeIconEntry(448, 512, ""), + ["dice-three"] = new FontAwesomeIconEntry(448, 512, ""), + ["dice-two"] = new FontAwesomeIconEntry(448, 512, ""), + ["disease"] = new FontAwesomeIconEntry(512, 512, ""), + ["display"] = new FontAwesomeIconEntry(576, 512, ""), + ["divide"] = new FontAwesomeIconEntry(448, 512, ""), + ["dna"] = new FontAwesomeIconEntry(448, 512, ""), + ["dog"] = new FontAwesomeIconEntry(576, 512, ""), + ["dollar-sign"] = new FontAwesomeIconEntry(320, 512, ""), + ["dolly"] = new FontAwesomeIconEntry(576, 512, ""), + ["dong-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["door-closed"] = new FontAwesomeIconEntry(576, 512, ""), + ["door-open"] = new FontAwesomeIconEntry(576, 512, ""), + ["dove"] = new FontAwesomeIconEntry(512, 512, ""), + ["down-left-and-up-right-to-center"] = new FontAwesomeIconEntry(512, 512, ""), + ["download"] = new FontAwesomeIconEntry(512, 512, ""), + ["down-long"] = new FontAwesomeIconEntry(320, 512, ""), + ["dragon"] = new FontAwesomeIconEntry(640, 512, ""), + ["draw-polygon"] = new FontAwesomeIconEntry(448, 512, ""), + ["droplet"] = new FontAwesomeIconEntry(384, 512, ""), + ["droplet-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["drum"] = new FontAwesomeIconEntry(512, 512, ""), + ["drum-steelpan"] = new FontAwesomeIconEntry(576, 512, ""), + ["drumstick-bite"] = new FontAwesomeIconEntry(512, 512, ""), + ["dumbbell"] = new FontAwesomeIconEntry(640, 512, ""), + ["dumpster"] = new FontAwesomeIconEntry(576, 512, ""), + ["dumpster-fire"] = new FontAwesomeIconEntry(640, 512, ""), + ["dungeon"] = new FontAwesomeIconEntry(512, 512, ""), + ["e"] = new FontAwesomeIconEntry(320, 512, ""), + ["ear-deaf"] = new FontAwesomeIconEntry(512, 512, ""), + ["ear-listen"] = new FontAwesomeIconEntry(512, 512, ""), + ["earth-africa"] = new FontAwesomeIconEntry(512, 512, ""), + ["earth-americas"] = new FontAwesomeIconEntry(512, 512, ""), + ["earth-asia"] = new FontAwesomeIconEntry(512, 512, ""), + ["earth-europe"] = new FontAwesomeIconEntry(512, 512, ""), + ["earth-oceania"] = new FontAwesomeIconEntry(512, 512, ""), + ["egg"] = new FontAwesomeIconEntry(384, 512, ""), + ["eject"] = new FontAwesomeIconEntry(448, 512, ""), + ["elevator"] = new FontAwesomeIconEntry(512, 512, ""), + ["ellipsis"] = new FontAwesomeIconEntry(448, 512, ""), + ["ellipsis-vertical"] = new FontAwesomeIconEntry(128, 512, ""), + ["envelope"] = new FontAwesomeIconEntry(512, 512, ""), + ["envelope-circle-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["envelope-open"] = new FontAwesomeIconEntry(512, 512, ""), + ["envelope-open-text"] = new FontAwesomeIconEntry(512, 512, ""), + ["envelopes-bulk"] = new FontAwesomeIconEntry(640, 512, ""), + ["equals"] = new FontAwesomeIconEntry(448, 512, ""), + ["eraser"] = new FontAwesomeIconEntry(576, 512, ""), + ["ethernet"] = new FontAwesomeIconEntry(512, 512, ""), + ["euro-sign"] = new FontAwesomeIconEntry(320, 512, ""), + ["exclamation"] = new FontAwesomeIconEntry(128, 512, ""), + ["expand"] = new FontAwesomeIconEntry(448, 512, ""), + ["explosion"] = new FontAwesomeIconEntry(576, 512, ""), + ["eye"] = new FontAwesomeIconEntry(576, 512, ""), + ["eye-dropper"] = new FontAwesomeIconEntry(512, 512, ""), + ["eye-low-vision"] = new FontAwesomeIconEntry(640, 512, ""), + ["eye-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["f"] = new FontAwesomeIconEntry(320, 512, ""), + ["face-angry"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-dizzy"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-flushed"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-frown"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-frown-open"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grimace"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-beam"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-beam-sweat"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-hearts"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-squint"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-squint-tears"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-stars"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-tears"] = new FontAwesomeIconEntry(640, 512, ""), + ["face-grin-tongue"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-tongue-squint"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-tongue-wink"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-wide"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-wink"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-kiss"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-kiss-beam"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-kiss-wink-heart"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-laugh"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-laugh-beam"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-laugh-squint"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-laugh-wink"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-meh"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-meh-blank"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-rolling-eyes"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-sad-cry"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-sad-tear"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-smile"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-smile-beam"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-smile-wink"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-surprise"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-tired"] = new FontAwesomeIconEntry(512, 512, ""), + ["fan"] = new FontAwesomeIconEntry(512, 512, ""), + ["faucet"] = new FontAwesomeIconEntry(512, 512, ""), + ["faucet-drip"] = new FontAwesomeIconEntry(512, 512, ""), + ["fax"] = new FontAwesomeIconEntry(512, 512, ""), + ["feather"] = new FontAwesomeIconEntry(512, 512, ""), + ["feather-pointed"] = new FontAwesomeIconEntry(512, 512, ""), + ["ferry"] = new FontAwesomeIconEntry(576, 512, ""), + ["file"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-arrow-down"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-arrow-up"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-audio"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-circle-check"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-circle-exclamation"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-circle-minus"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-circle-plus"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-circle-question"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-circle-xmark"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-code"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-contract"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-csv"] = new FontAwesomeIconEntry(512, 512, ""), + ["file-excel"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-export"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-fragment"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-half-dashed"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-image"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-import"] = new FontAwesomeIconEntry(512, 512, ""), + ["file-invoice"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-invoice-dollar"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-lines"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-medical"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-pdf"] = new FontAwesomeIconEntry(512, 512, ""), + ["file-pen"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-powerpoint"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-prescription"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-shield"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-signature"] = new FontAwesomeIconEntry(576, 512, ""), + ["file-video"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-waveform"] = new FontAwesomeIconEntry(448, 512, ""), + ["file-word"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-zipper"] = new FontAwesomeIconEntry(384, 512, ""), + ["fill"] = new FontAwesomeIconEntry(512, 512, ""), + ["fill-drip"] = new FontAwesomeIconEntry(576, 512, ""), + ["film"] = new FontAwesomeIconEntry(512, 512, ""), + ["filter"] = new FontAwesomeIconEntry(512, 512, ""), + ["filter-circle-dollar"] = new FontAwesomeIconEntry(576, 512, ""), + ["filter-circle-xmark"] = new FontAwesomeIconEntry(576, 512, ""), + ["fingerprint"] = new FontAwesomeIconEntry(512, 512, ""), + ["fire"] = new FontAwesomeIconEntry(448, 512, ""), + ["fire-burner"] = new FontAwesomeIconEntry(640, 512, ""), + ["fire-extinguisher"] = new FontAwesomeIconEntry(512, 512, ""), + ["fire-flame-curved"] = new FontAwesomeIconEntry(384, 512, ""), + ["fire-flame-simple"] = new FontAwesomeIconEntry(384, 512, ""), + ["fish"] = new FontAwesomeIconEntry(576, 512, ""), + ["fish-fins"] = new FontAwesomeIconEntry(576, 512, ""), + ["flag"] = new FontAwesomeIconEntry(448, 512, ""), + ["flag-checkered"] = new FontAwesomeIconEntry(448, 512, ""), + ["flag-usa"] = new FontAwesomeIconEntry(448, 512, ""), + ["flask"] = new FontAwesomeIconEntry(448, 512, ""), + ["flask-vial"] = new FontAwesomeIconEntry(640, 512, ""), + ["floppy-disk"] = new FontAwesomeIconEntry(448, 512, ""), + ["florin-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["folder"] = new FontAwesomeIconEntry(512, 512, ""), + ["folder-closed"] = new FontAwesomeIconEntry(512, 512, ""), + ["folder-minus"] = new FontAwesomeIconEntry(512, 512, ""), + ["folder-open"] = new FontAwesomeIconEntry(576, 512, ""), + ["folder-plus"] = new FontAwesomeIconEntry(512, 512, ""), + ["folder-tree"] = new FontAwesomeIconEntry(576, 512, ""), + ["font"] = new FontAwesomeIconEntry(448, 512, ""), + ["font-awesome"] = new FontAwesomeIconEntry(512, 512, ""), + ["football"] = new FontAwesomeIconEntry(512, 512, ""), + ["forward"] = new FontAwesomeIconEntry(512, 512, ""), + ["forward-fast"] = new FontAwesomeIconEntry(512, 512, ""), + ["forward-step"] = new FontAwesomeIconEntry(320, 512, ""), + ["franc-sign"] = new FontAwesomeIconEntry(320, 512, ""), + ["frog"] = new FontAwesomeIconEntry(576, 512, ""), + ["futbol"] = new FontAwesomeIconEntry(512, 512, ""), + ["g"] = new FontAwesomeIconEntry(448, 512, ""), + ["gamepad"] = new FontAwesomeIconEntry(640, 512, ""), + ["gas-pump"] = new FontAwesomeIconEntry(512, 512, ""), + ["gauge"] = new FontAwesomeIconEntry(512, 512, ""), + ["gauge-high"] = new FontAwesomeIconEntry(512, 512, ""), + ["gauge-simple"] = new FontAwesomeIconEntry(512, 512, ""), + ["gauge-simple-high"] = new FontAwesomeIconEntry(512, 512, ""), + ["gavel"] = new FontAwesomeIconEntry(512, 512, ""), + ["gear"] = new FontAwesomeIconEntry(512, 512, ""), + ["gears"] = new FontAwesomeIconEntry(640, 512, ""), + ["gem"] = new FontAwesomeIconEntry(512, 512, ""), + ["genderless"] = new FontAwesomeIconEntry(384, 512, ""), + ["ghost"] = new FontAwesomeIconEntry(384, 512, ""), + ["gift"] = new FontAwesomeIconEntry(512, 512, ""), + ["gifts"] = new FontAwesomeIconEntry(640, 512, ""), + ["glasses"] = new FontAwesomeIconEntry(576, 512, ""), + ["glass-water"] = new FontAwesomeIconEntry(384, 512, ""), + ["glass-water-droplet"] = new FontAwesomeIconEntry(384, 512, ""), + ["globe"] = new FontAwesomeIconEntry(512, 512, ""), + ["golf-ball-tee"] = new FontAwesomeIconEntry(384, 512, ""), + ["gopuram"] = new FontAwesomeIconEntry(512, 512, ""), + ["graduation-cap"] = new FontAwesomeIconEntry(640, 512, ""), + ["greater-than"] = new FontAwesomeIconEntry(384, 512, ""), + ["greater-than-equal"] = new FontAwesomeIconEntry(448, 512, ""), + ["grip"] = new FontAwesomeIconEntry(448, 512, ""), + ["grip-lines"] = new FontAwesomeIconEntry(448, 512, ""), + ["grip-lines-vertical"] = new FontAwesomeIconEntry(192, 512, ""), + ["grip-vertical"] = new FontAwesomeIconEntry(320, 512, ""), + ["group-arrows-rotate"] = new FontAwesomeIconEntry(512, 512, ""), + ["guarani-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["guitar"] = new FontAwesomeIconEntry(512, 512, ""), + ["gun"] = new FontAwesomeIconEntry(576, 512, ""), + ["h"] = new FontAwesomeIconEntry(384, 512, ""), + ["hammer"] = new FontAwesomeIconEntry(576, 512, ""), + ["hamsa"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-back-fist"] = new FontAwesomeIconEntry(448, 512, ""), + ["handcuffs"] = new FontAwesomeIconEntry(640, 512, ""), + ["hand-dots"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-fist"] = new FontAwesomeIconEntry(448, 512, ""), + ["hand-holding"] = new FontAwesomeIconEntry(576, 512, ""), + ["hand-holding-dollar"] = new FontAwesomeIconEntry(576, 512, ""), + ["hand-holding-droplet"] = new FontAwesomeIconEntry(576, 512, ""), + ["hand-holding-hand"] = new FontAwesomeIconEntry(576, 512, ""), + ["hand-holding-heart"] = new FontAwesomeIconEntry(576, 512, ""), + ["hand-holding-medical"] = new FontAwesomeIconEntry(576, 512, ""), + ["hand-lizard"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-middle-finger"] = new FontAwesomeIconEntry(448, 512, ""), + ["hand-peace"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-point-down"] = new FontAwesomeIconEntry(384, 512, ""), + ["hand-pointer"] = new FontAwesomeIconEntry(448, 512, ""), + ["hand-point-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-point-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-point-up"] = new FontAwesomeIconEntry(384, 512, ""), + ["hands"] = new FontAwesomeIconEntry(576, 512, ""), + ["hands-asl-interpreting"] = new FontAwesomeIconEntry(640, 512, ""), + ["hands-bound"] = new FontAwesomeIconEntry(640, 512, ""), + ["hands-bubbles"] = new FontAwesomeIconEntry(576, 512, ""), + ["hand-scissors"] = new FontAwesomeIconEntry(512, 512, ""), + ["hands-clapping"] = new FontAwesomeIconEntry(512, 512, ""), + ["handshake"] = new FontAwesomeIconEntry(640, 512, ""), + ["handshake-angle"] = new FontAwesomeIconEntry(640, 512, ""), + ["handshake-simple"] = new FontAwesomeIconEntry(640, 512, ""), + ["handshake-simple-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["handshake-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["hands-holding"] = new FontAwesomeIconEntry(640, 512, ""), + ["hands-holding-child"] = new FontAwesomeIconEntry(640, 512, ""), + ["hands-holding-circle"] = new FontAwesomeIconEntry(640, 512, ""), + ["hand-sparkles"] = new FontAwesomeIconEntry(640, 512, ""), + ["hand-spock"] = new FontAwesomeIconEntry(576, 512, ""), + ["hands-praying"] = new FontAwesomeIconEntry(640, 512, ""), + ["hanukiah"] = new FontAwesomeIconEntry(640, 512, ""), + ["hard-drive"] = new FontAwesomeIconEntry(512, 512, ""), + ["hashtag"] = new FontAwesomeIconEntry(448, 512, ""), + ["hat-cowboy"] = new FontAwesomeIconEntry(640, 512, ""), + ["hat-cowboy-side"] = new FontAwesomeIconEntry(640, 512, ""), + ["hat-wizard"] = new FontAwesomeIconEntry(512, 512, ""), + ["heading"] = new FontAwesomeIconEntry(448, 512, ""), + ["headphones"] = new FontAwesomeIconEntry(512, 512, ""), + ["headphones-simple"] = new FontAwesomeIconEntry(512, 512, ""), + ["headset"] = new FontAwesomeIconEntry(512, 512, ""), + ["head-side-cough"] = new FontAwesomeIconEntry(640, 512, ""), + ["head-side-cough-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["head-side-mask"] = new FontAwesomeIconEntry(576, 512, ""), + ["head-side-virus"] = new FontAwesomeIconEntry(512, 512, ""), + ["heart"] = new FontAwesomeIconEntry(512, 512, ""), + ["heart-circle-bolt"] = new FontAwesomeIconEntry(576, 512, ""), + ["heart-circle-check"] = new FontAwesomeIconEntry(576, 512, ""), + ["heart-circle-exclamation"] = new FontAwesomeIconEntry(576, 512, ""), + ["heart-circle-minus"] = new FontAwesomeIconEntry(576, 512, ""), + ["heart-circle-plus"] = new FontAwesomeIconEntry(576, 512, ""), + ["heart-circle-xmark"] = new FontAwesomeIconEntry(576, 512, ""), + ["heart-crack"] = new FontAwesomeIconEntry(512, 512, ""), + ["heart-pulse"] = new FontAwesomeIconEntry(512, 512, ""), + ["helicopter"] = new FontAwesomeIconEntry(640, 512, ""), + ["helicopter-symbol"] = new FontAwesomeIconEntry(512, 512, ""), + ["helmet-safety"] = new FontAwesomeIconEntry(576, 512, ""), + ["helmet-un"] = new FontAwesomeIconEntry(512, 512, ""), + ["hexagon-nodes"] = new FontAwesomeIconEntry(448, 512, ""), + ["hexagon-nodes-bolt"] = new FontAwesomeIconEntry(576, 512, ""), + ["highlighter"] = new FontAwesomeIconEntry(576, 512, ""), + ["hill-avalanche"] = new FontAwesomeIconEntry(576, 512, ""), + ["hill-rockslide"] = new FontAwesomeIconEntry(576, 512, ""), + ["hippo"] = new FontAwesomeIconEntry(640, 512, ""), + ["hockey-puck"] = new FontAwesomeIconEntry(512, 512, ""), + ["holly-berry"] = new FontAwesomeIconEntry(512, 512, ""), + ["horse"] = new FontAwesomeIconEntry(576, 512, ""), + ["horse-head"] = new FontAwesomeIconEntry(640, 512, ""), + ["hospital"] = new FontAwesomeIconEntry(640, 512, ""), + ["hospital-user"] = new FontAwesomeIconEntry(576, 512, ""), + ["hotdog"] = new FontAwesomeIconEntry(512, 512, ""), + ["hotel"] = new FontAwesomeIconEntry(512, 512, ""), + ["hot-tub-person"] = new FontAwesomeIconEntry(512, 512, ""), + ["hourglass"] = new FontAwesomeIconEntry(384, 512, ""), + ["hourglass-empty"] = new FontAwesomeIconEntry(384, 512, ""), + ["hourglass-end"] = new FontAwesomeIconEntry(384, 512, ""), + ["hourglass-half"] = new FontAwesomeIconEntry(384, 512, ""), + ["hourglass-start"] = new FontAwesomeIconEntry(384, 512, ""), + ["house"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-chimney"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-chimney-crack"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-chimney-medical"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-chimney-user"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-chimney-window"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-circle-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-circle-exclamation"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-circle-xmark"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-crack"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-fire"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-flag"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-flood-water"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-flood-water-circle-arrow-right"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-laptop"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-medical"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-medical-circle-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-medical-circle-exclamation"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-medical-circle-xmark"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-medical-flag"] = new FontAwesomeIconEntry(640, 512, ""), + ["house-signal"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-tsunami"] = new FontAwesomeIconEntry(576, 512, ""), + ["house-user"] = new FontAwesomeIconEntry(576, 512, ""), + ["hryvnia-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["hurricane"] = new FontAwesomeIconEntry(384, 512, ""), + ["i"] = new FontAwesomeIconEntry(320, 512, ""), + ["ice-cream"] = new FontAwesomeIconEntry(448, 512, ""), + ["icicles"] = new FontAwesomeIconEntry(512, 512, ""), + ["icons"] = new FontAwesomeIconEntry(512, 512, ""), + ["i-cursor"] = new FontAwesomeIconEntry(256, 512, ""), + ["id-badge"] = new FontAwesomeIconEntry(384, 512, ""), + ["id-card"] = new FontAwesomeIconEntry(576, 512, ""), + ["id-card-clip"] = new FontAwesomeIconEntry(576, 512, ""), + ["igloo"] = new FontAwesomeIconEntry(576, 512, ""), + ["image"] = new FontAwesomeIconEntry(512, 512, ""), + ["image-portrait"] = new FontAwesomeIconEntry(384, 512, ""), + ["images"] = new FontAwesomeIconEntry(576, 512, ""), + ["inbox"] = new FontAwesomeIconEntry(512, 512, ""), + ["indent"] = new FontAwesomeIconEntry(448, 512, ""), + ["indian-rupee-sign"] = new FontAwesomeIconEntry(320, 512, ""), + ["industry"] = new FontAwesomeIconEntry(576, 512, ""), + ["infinity"] = new FontAwesomeIconEntry(640, 512, ""), + ["info"] = new FontAwesomeIconEntry(192, 512, ""), + ["italic"] = new FontAwesomeIconEntry(384, 512, ""), + ["j"] = new FontAwesomeIconEntry(320, 512, ""), + ["jar"] = new FontAwesomeIconEntry(320, 512, ""), + ["jar-wheat"] = new FontAwesomeIconEntry(320, 512, ""), + ["jedi"] = new FontAwesomeIconEntry(576, 512, ""), + ["jet-fighter"] = new FontAwesomeIconEntry(640, 512, ""), + ["jet-fighter-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["joint"] = new FontAwesomeIconEntry(640, 512, ""), + ["jug-detergent"] = new FontAwesomeIconEntry(384, 512, ""), + ["k"] = new FontAwesomeIconEntry(320, 512, ""), + ["kaaba"] = new FontAwesomeIconEntry(576, 512, ""), + ["key"] = new FontAwesomeIconEntry(512, 512, ""), + ["keyboard"] = new FontAwesomeIconEntry(576, 512, ""), + ["khanda"] = new FontAwesomeIconEntry(512, 512, ""), + ["kip-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["kitchen-set"] = new FontAwesomeIconEntry(576, 512, ""), + ["kit-medical"] = new FontAwesomeIconEntry(576, 512, ""), + ["kiwi-bird"] = new FontAwesomeIconEntry(576, 512, ""), + ["l"] = new FontAwesomeIconEntry(320, 512, ""), + ["landmark"] = new FontAwesomeIconEntry(512, 512, ""), + ["landmark-dome"] = new FontAwesomeIconEntry(512, 512, ""), + ["landmark-flag"] = new FontAwesomeIconEntry(512, 512, ""), + ["land-mine-on"] = new FontAwesomeIconEntry(640, 512, ""), + ["language"] = new FontAwesomeIconEntry(640, 512, ""), + ["laptop"] = new FontAwesomeIconEntry(640, 512, ""), + ["laptop-code"] = new FontAwesomeIconEntry(640, 512, ""), + ["laptop-file"] = new FontAwesomeIconEntry(640, 512, ""), + ["laptop-medical"] = new FontAwesomeIconEntry(640, 512, ""), + ["lari-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["layer-group"] = new FontAwesomeIconEntry(576, 512, ""), + ["leaf"] = new FontAwesomeIconEntry(512, 512, ""), + ["left-long"] = new FontAwesomeIconEntry(512, 512, ""), + ["left-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["lemon"] = new FontAwesomeIconEntry(448, 512, ""), + ["less-than"] = new FontAwesomeIconEntry(384, 512, ""), + ["less-than-equal"] = new FontAwesomeIconEntry(448, 512, ""), + ["life-ring"] = new FontAwesomeIconEntry(512, 512, ""), + ["lightbulb"] = new FontAwesomeIconEntry(384, 512, ""), + ["lines-leaning"] = new FontAwesomeIconEntry(384, 512, ""), + ["link"] = new FontAwesomeIconEntry(640, 512, ""), + ["link-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["lira-sign"] = new FontAwesomeIconEntry(320, 512, ""), + ["list"] = new FontAwesomeIconEntry(512, 512, ""), + ["list-check"] = new FontAwesomeIconEntry(512, 512, ""), + ["list-ol"] = new FontAwesomeIconEntry(512, 512, ""), + ["list-ul"] = new FontAwesomeIconEntry(512, 512, ""), + ["litecoin-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["location-arrow"] = new FontAwesomeIconEntry(448, 512, ""), + ["location-crosshairs"] = new FontAwesomeIconEntry(512, 512, ""), + ["location-dot"] = new FontAwesomeIconEntry(384, 512, ""), + ["location-pin"] = new FontAwesomeIconEntry(384, 512, ""), + ["location-pin-lock"] = new FontAwesomeIconEntry(512, 512, ""), + ["lock"] = new FontAwesomeIconEntry(448, 512, ""), + ["lock-open"] = new FontAwesomeIconEntry(576, 512, ""), + ["locust"] = new FontAwesomeIconEntry(576, 512, ""), + ["lungs"] = new FontAwesomeIconEntry(640, 512, ""), + ["lungs-virus"] = new FontAwesomeIconEntry(640, 512, ""), + ["m"] = new FontAwesomeIconEntry(448, 512, ""), + ["magnet"] = new FontAwesomeIconEntry(448, 512, ""), + ["magnifying-glass"] = new FontAwesomeIconEntry(512, 512, ""), + ["magnifying-glass-arrow-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["magnifying-glass-chart"] = new FontAwesomeIconEntry(512, 512, ""), + ["magnifying-glass-dollar"] = new FontAwesomeIconEntry(512, 512, ""), + ["magnifying-glass-location"] = new FontAwesomeIconEntry(512, 512, ""), + ["magnifying-glass-minus"] = new FontAwesomeIconEntry(512, 512, ""), + ["magnifying-glass-plus"] = new FontAwesomeIconEntry(512, 512, ""), + ["manat-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["map"] = new FontAwesomeIconEntry(576, 512, ""), + ["map-location"] = new FontAwesomeIconEntry(576, 512, ""), + ["map-location-dot"] = new FontAwesomeIconEntry(576, 512, ""), + ["map-pin"] = new FontAwesomeIconEntry(320, 512, ""), + ["marker"] = new FontAwesomeIconEntry(512, 512, ""), + ["mars"] = new FontAwesomeIconEntry(448, 512, ""), + ["mars-and-venus"] = new FontAwesomeIconEntry(512, 512, ""), + ["mars-and-venus-burst"] = new FontAwesomeIconEntry(640, 512, ""), + ["mars-double"] = new FontAwesomeIconEntry(640, 512, ""), + ["mars-stroke"] = new FontAwesomeIconEntry(512, 512, ""), + ["mars-stroke-right"] = new FontAwesomeIconEntry(640, 512, ""), + ["mars-stroke-up"] = new FontAwesomeIconEntry(320, 512, ""), + ["martini-glass"] = new FontAwesomeIconEntry(512, 512, ""), + ["martini-glass-citrus"] = new FontAwesomeIconEntry(576, 512, ""), + ["martini-glass-empty"] = new FontAwesomeIconEntry(512, 512, ""), + ["mask"] = new FontAwesomeIconEntry(576, 512, ""), + ["mask-face"] = new FontAwesomeIconEntry(640, 512, ""), + ["masks-theater"] = new FontAwesomeIconEntry(640, 512, ""), + ["mask-ventilator"] = new FontAwesomeIconEntry(640, 512, ""), + ["mattress-pillow"] = new FontAwesomeIconEntry(640, 512, ""), + ["maximize"] = new FontAwesomeIconEntry(512, 512, ""), + ["medal"] = new FontAwesomeIconEntry(512, 512, ""), + ["memory"] = new FontAwesomeIconEntry(576, 512, ""), + ["menorah"] = new FontAwesomeIconEntry(640, 512, ""), + ["mercury"] = new FontAwesomeIconEntry(384, 512, ""), + ["message"] = new FontAwesomeIconEntry(512, 512, ""), + ["meteor"] = new FontAwesomeIconEntry(512, 512, ""), + ["microchip"] = new FontAwesomeIconEntry(512, 512, ""), + ["microphone"] = new FontAwesomeIconEntry(384, 512, ""), + ["microphone-lines"] = new FontAwesomeIconEntry(384, 512, ""), + ["microphone-lines-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["microphone-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["microscope"] = new FontAwesomeIconEntry(512, 512, ""), + ["mill-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["minimize"] = new FontAwesomeIconEntry(512, 512, ""), + ["minus"] = new FontAwesomeIconEntry(448, 512, ""), + ["mitten"] = new FontAwesomeIconEntry(448, 512, ""), + ["mobile"] = new FontAwesomeIconEntry(384, 512, ""), + ["mobile-button"] = new FontAwesomeIconEntry(384, 512, ""), + ["mobile-retro"] = new FontAwesomeIconEntry(320, 512, ""), + ["mobile-screen"] = new FontAwesomeIconEntry(384, 512, ""), + ["mobile-screen-button"] = new FontAwesomeIconEntry(384, 512, ""), + ["money-bill"] = new FontAwesomeIconEntry(576, 512, ""), + ["money-bill-1"] = new FontAwesomeIconEntry(576, 512, ""), + ["money-bill-1-wave"] = new FontAwesomeIconEntry(576, 512, ""), + ["money-bills"] = new FontAwesomeIconEntry(640, 512, ""), + ["money-bill-transfer"] = new FontAwesomeIconEntry(640, 512, ""), + ["money-bill-trend-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["money-bill-wave"] = new FontAwesomeIconEntry(576, 512, ""), + ["money-bill-wheat"] = new FontAwesomeIconEntry(512, 512, ""), + ["money-check"] = new FontAwesomeIconEntry(576, 512, ""), + ["money-check-dollar"] = new FontAwesomeIconEntry(576, 512, ""), + ["monument"] = new FontAwesomeIconEntry(384, 512, ""), + ["moon"] = new FontAwesomeIconEntry(384, 512, ""), + ["mortar-pestle"] = new FontAwesomeIconEntry(512, 512, ""), + ["mosque"] = new FontAwesomeIconEntry(640, 512, ""), + ["mosquito"] = new FontAwesomeIconEntry(640, 512, ""), + ["mosquito-net"] = new FontAwesomeIconEntry(640, 512, ""), + ["motorcycle"] = new FontAwesomeIconEntry(640, 512, ""), + ["mound"] = new FontAwesomeIconEntry(576, 512, ""), + ["mountain"] = new FontAwesomeIconEntry(512, 512, ""), + ["mountain-city"] = new FontAwesomeIconEntry(640, 512, ""), + ["mountain-sun"] = new FontAwesomeIconEntry(640, 512, ""), + ["mug-hot"] = new FontAwesomeIconEntry(512, 512, ""), + ["mug-saucer"] = new FontAwesomeIconEntry(640, 512, ""), + ["music"] = new FontAwesomeIconEntry(512, 512, ""), + ["n"] = new FontAwesomeIconEntry(384, 512, ""), + ["naira-sign"] = new FontAwesomeIconEntry(448, 512, ""), + ["network-wired"] = new FontAwesomeIconEntry(640, 512, ""), + ["neuter"] = new FontAwesomeIconEntry(384, 512, ""), + ["newspaper"] = new FontAwesomeIconEntry(512, 512, ""), + ["notdef"] = new FontAwesomeIconEntry(384, 512, ""), + ["not-equal"] = new FontAwesomeIconEntry(448, 512, ""), + ["notes-medical"] = new FontAwesomeIconEntry(512, 512, ""), + ["note-sticky"] = new FontAwesomeIconEntry(448, 512, ""), + ["o"] = new FontAwesomeIconEntry(448, 512, ""), + ["object-group"] = new FontAwesomeIconEntry(576, 512, ""), + ["object-ungroup"] = new FontAwesomeIconEntry(640, 512, ""), + ["oil-can"] = new FontAwesomeIconEntry(640, 512, ""), + ["oil-well"] = new FontAwesomeIconEntry(576, 512, ""), + ["om"] = new FontAwesomeIconEntry(512, 512, ""), + ["otter"] = new FontAwesomeIconEntry(640, 512, ""), + ["outdent"] = new FontAwesomeIconEntry(448, 512, ""), + ["p"] = new FontAwesomeIconEntry(320, 512, ""), + ["pager"] = new FontAwesomeIconEntry(512, 512, ""), + ["paintbrush"] = new FontAwesomeIconEntry(576, 512, ""), + ["paint-roller"] = new FontAwesomeIconEntry(512, 512, ""), + ["palette"] = new FontAwesomeIconEntry(512, 512, ""), + ["pallet"] = new FontAwesomeIconEntry(640, 512, ""), + ["panorama"] = new FontAwesomeIconEntry(640, 512, ""), + ["paperclip"] = new FontAwesomeIconEntry(448, 512, ""), + ["paper-plane"] = new FontAwesomeIconEntry(512, 512, ""), + ["parachute-box"] = new FontAwesomeIconEntry(512, 512, ""), + ["paragraph"] = new FontAwesomeIconEntry(448, 512, ""), + ["passport"] = new FontAwesomeIconEntry(448, 512, ""), + ["paste"] = new FontAwesomeIconEntry(512, 512, ""), + ["pause"] = new FontAwesomeIconEntry(320, 512, ""), + ["paw"] = new FontAwesomeIconEntry(512, 512, ""), + ["peace"] = new FontAwesomeIconEntry(512, 512, ""), + ["pen"] = new FontAwesomeIconEntry(512, 512, ""), + ["pencil"] = new FontAwesomeIconEntry(512, 512, ""), + ["pen-clip"] = new FontAwesomeIconEntry(512, 512, ""), + ["pen-fancy"] = new FontAwesomeIconEntry(512, 512, ""), + ["pen-nib"] = new FontAwesomeIconEntry(512, 512, ""), + ["pen-ruler"] = new FontAwesomeIconEntry(512, 512, ""), + ["pen-to-square"] = new FontAwesomeIconEntry(512, 512, ""), + ["people-arrows"] = new FontAwesomeIconEntry(640, 512, ""), + ["people-arrows-left-right"] = new FontAwesomeIconEntry(576, 512, ""), + ["people-carry-box"] = new FontAwesomeIconEntry(640, 512, ""), + ["people-group"] = new FontAwesomeIconEntry(640, 512, ""), + ["people-line"] = new FontAwesomeIconEntry(640, 512, ""), + ["people-pulling"] = new FontAwesomeIconEntry(576, 512, ""), + ["people-robbery"] = new FontAwesomeIconEntry(576, 512, ""), + ["people-roof"] = new FontAwesomeIconEntry(640, 512, ""), + ["pepper-hot"] = new FontAwesomeIconEntry(512, 512, ""), + ["percent"] = new FontAwesomeIconEntry(384, 512, ""), + ["person"] = new FontAwesomeIconEntry(320, 512, ""), + ["person-arrow-down-to-line"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-arrow-up-from-line"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-biking"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-booth"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-breastfeeding"] = new FontAwesomeIconEntry(448, 512, ""), + ["person-burst"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-cane"] = new FontAwesomeIconEntry(448, 512, ""), + ["person-chalkboard"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-circle-check"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-circle-exclamation"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-circle-minus"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-circle-plus"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-circle-question"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-circle-xmark"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-digging"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-dots-from-line"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-dress"] = new FontAwesomeIconEntry(320, 512, ""), + ["person-dress-burst"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-drowning"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-falling"] = new FontAwesomeIconEntry(512, 512, ""), + ["person-falling-burst"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-half-dress"] = new FontAwesomeIconEntry(320, 512, ""), + ["person-harassing"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-hiking"] = new FontAwesomeIconEntry(384, 512, ""), + ["person-military-pointing"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-military-rifle"] = new FontAwesomeIconEntry(512, 512, ""), + ["person-military-to-person"] = new FontAwesomeIconEntry(512, 512, ""), + ["person-praying"] = new FontAwesomeIconEntry(448, 512, ""), + ["person-pregnant"] = new FontAwesomeIconEntry(384, 512, ""), + ["person-rays"] = new FontAwesomeIconEntry(512, 512, ""), + ["person-rifle"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-running"] = new FontAwesomeIconEntry(448, 512, ""), + ["person-shelter"] = new FontAwesomeIconEntry(512, 512, ""), + ["person-skating"] = new FontAwesomeIconEntry(448, 512, ""), + ["person-skiing"] = new FontAwesomeIconEntry(512, 512, ""), + ["person-skiing-nordic"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-snowboarding"] = new FontAwesomeIconEntry(512, 512, ""), + ["person-swimming"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-through-window"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-walking"] = new FontAwesomeIconEntry(320, 512, ""), + ["person-walking-arrow-loop-left"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-walking-arrow-right"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-walking-dashed-line-arrow-right"] = new FontAwesomeIconEntry(640, 512, ""), + ["person-walking-luggage"] = new FontAwesomeIconEntry(576, 512, ""), + ["person-walking-with-cane"] = new FontAwesomeIconEntry(512, 512, ""), + ["peseta-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["peso-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["phone"] = new FontAwesomeIconEntry(512, 512, ""), + ["phone-flip"] = new FontAwesomeIconEntry(512, 512, ""), + ["phone-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["phone-volume"] = new FontAwesomeIconEntry(512, 512, ""), + ["photo-film"] = new FontAwesomeIconEntry(640, 512, ""), + ["piggy-bank"] = new FontAwesomeIconEntry(576, 512, ""), + ["pills"] = new FontAwesomeIconEntry(576, 512, ""), + ["pizza-slice"] = new FontAwesomeIconEntry(512, 512, ""), + ["place-of-worship"] = new FontAwesomeIconEntry(640, 512, ""), + ["plane"] = new FontAwesomeIconEntry(576, 512, ""), + ["plane-arrival"] = new FontAwesomeIconEntry(640, 512, ""), + ["plane-circle-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["plane-circle-exclamation"] = new FontAwesomeIconEntry(640, 512, ""), + ["plane-circle-xmark"] = new FontAwesomeIconEntry(640, 512, ""), + ["plane-departure"] = new FontAwesomeIconEntry(640, 512, ""), + ["plane-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["plane-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["plane-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["plant-wilt"] = new FontAwesomeIconEntry(512, 512, ""), + ["plate-wheat"] = new FontAwesomeIconEntry(512, 512, ""), + ["play"] = new FontAwesomeIconEntry(384, 512, ""), + ["plug"] = new FontAwesomeIconEntry(384, 512, ""), + ["plug-circle-bolt"] = new FontAwesomeIconEntry(576, 512, ""), + ["plug-circle-check"] = new FontAwesomeIconEntry(576, 512, ""), + ["plug-circle-exclamation"] = new FontAwesomeIconEntry(576, 512, ""), + ["plug-circle-minus"] = new FontAwesomeIconEntry(576, 512, ""), + ["plug-circle-plus"] = new FontAwesomeIconEntry(576, 512, ""), + ["plug-circle-xmark"] = new FontAwesomeIconEntry(576, 512, ""), + ["plus"] = new FontAwesomeIconEntry(448, 512, ""), + ["plus-minus"] = new FontAwesomeIconEntry(384, 512, ""), + ["podcast"] = new FontAwesomeIconEntry(448, 512, ""), + ["poo"] = new FontAwesomeIconEntry(512, 512, ""), + ["poop"] = new FontAwesomeIconEntry(512, 512, ""), + ["poo-storm"] = new FontAwesomeIconEntry(448, 512, ""), + ["power-off"] = new FontAwesomeIconEntry(512, 512, ""), + ["prescription"] = new FontAwesomeIconEntry(448, 512, ""), + ["prescription-bottle"] = new FontAwesomeIconEntry(384, 512, ""), + ["prescription-bottle-medical"] = new FontAwesomeIconEntry(384, 512, ""), + ["print"] = new FontAwesomeIconEntry(512, 512, ""), + ["pump-medical"] = new FontAwesomeIconEntry(448, 512, ""), + ["pump-soap"] = new FontAwesomeIconEntry(448, 512, ""), + ["puzzle-piece"] = new FontAwesomeIconEntry(512, 512, ""), + ["q"] = new FontAwesomeIconEntry(448, 512, ""), + ["qrcode"] = new FontAwesomeIconEntry(448, 512, ""), + ["question"] = new FontAwesomeIconEntry(320, 512, ""), + ["quote-left"] = new FontAwesomeIconEntry(448, 512, ""), + ["quote-right"] = new FontAwesomeIconEntry(448, 512, ""), + ["r"] = new FontAwesomeIconEntry(320, 512, ""), + ["radiation"] = new FontAwesomeIconEntry(512, 512, ""), + ["radio"] = new FontAwesomeIconEntry(512, 512, ""), + ["rainbow"] = new FontAwesomeIconEntry(640, 512, ""), + ["ranking-star"] = new FontAwesomeIconEntry(640, 512, ""), + ["receipt"] = new FontAwesomeIconEntry(384, 512, ""), + ["record-vinyl"] = new FontAwesomeIconEntry(512, 512, ""), + ["rectangle-ad"] = new FontAwesomeIconEntry(576, 512, ""), + ["rectangle-list"] = new FontAwesomeIconEntry(576, 512, ""), + ["rectangle-xmark"] = new FontAwesomeIconEntry(512, 512, ""), + ["recycle"] = new FontAwesomeIconEntry(512, 512, ""), + ["registered"] = new FontAwesomeIconEntry(512, 512, ""), + ["repeat"] = new FontAwesomeIconEntry(512, 512, ""), + ["reply"] = new FontAwesomeIconEntry(512, 512, ""), + ["reply-all"] = new FontAwesomeIconEntry(576, 512, ""), + ["republican"] = new FontAwesomeIconEntry(640, 512, ""), + ["restroom"] = new FontAwesomeIconEntry(640, 512, ""), + ["retweet"] = new FontAwesomeIconEntry(576, 512, ""), + ["ribbon"] = new FontAwesomeIconEntry(448, 512, ""), + ["right-from-bracket"] = new FontAwesomeIconEntry(512, 512, ""), + ["right-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["right-long"] = new FontAwesomeIconEntry(512, 512, ""), + ["right-to-bracket"] = new FontAwesomeIconEntry(512, 512, ""), + ["ring"] = new FontAwesomeIconEntry(512, 512, ""), + ["road"] = new FontAwesomeIconEntry(576, 512, ""), + ["road-barrier"] = new FontAwesomeIconEntry(640, 512, ""), + ["road-bridge"] = new FontAwesomeIconEntry(640, 512, ""), + ["road-circle-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["road-circle-exclamation"] = new FontAwesomeIconEntry(640, 512, ""), + ["road-circle-xmark"] = new FontAwesomeIconEntry(640, 512, ""), + ["road-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["road-spikes"] = new FontAwesomeIconEntry(640, 512, ""), + ["robot"] = new FontAwesomeIconEntry(640, 512, ""), + ["rocket"] = new FontAwesomeIconEntry(512, 512, ""), + ["rotate"] = new FontAwesomeIconEntry(512, 512, ""), + ["rotate-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["rotate-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["route"] = new FontAwesomeIconEntry(512, 512, ""), + ["rss"] = new FontAwesomeIconEntry(448, 512, ""), + ["ruble-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["rug"] = new FontAwesomeIconEntry(640, 512, ""), + ["ruler"] = new FontAwesomeIconEntry(512, 512, ""), + ["ruler-combined"] = new FontAwesomeIconEntry(512, 512, ""), + ["ruler-horizontal"] = new FontAwesomeIconEntry(640, 512, ""), + ["ruler-vertical"] = new FontAwesomeIconEntry(256, 512, ""), + ["rupee-sign"] = new FontAwesomeIconEntry(448, 512, ""), + ["rupiah-sign"] = new FontAwesomeIconEntry(512, 512, ""), + ["s"] = new FontAwesomeIconEntry(320, 512, ""), + ["sack-dollar"] = new FontAwesomeIconEntry(512, 512, ""), + ["sack-xmark"] = new FontAwesomeIconEntry(512, 512, ""), + ["sailboat"] = new FontAwesomeIconEntry(576, 512, ""), + ["satellite"] = new FontAwesomeIconEntry(512, 512, ""), + ["satellite-dish"] = new FontAwesomeIconEntry(512, 512, ""), + ["scale-balanced"] = new FontAwesomeIconEntry(640, 512, ""), + ["scale-unbalanced"] = new FontAwesomeIconEntry(640, 512, ""), + ["scale-unbalanced-flip"] = new FontAwesomeIconEntry(640, 512, ""), + ["school"] = new FontAwesomeIconEntry(640, 512, ""), + ["school-circle-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["school-circle-exclamation"] = new FontAwesomeIconEntry(640, 512, ""), + ["school-circle-xmark"] = new FontAwesomeIconEntry(640, 512, ""), + ["school-flag"] = new FontAwesomeIconEntry(576, 512, ""), + ["school-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["scissors"] = new FontAwesomeIconEntry(512, 512, ""), + ["screwdriver"] = new FontAwesomeIconEntry(512, 512, ""), + ["screwdriver-wrench"] = new FontAwesomeIconEntry(512, 512, ""), + ["scroll"] = new FontAwesomeIconEntry(576, 512, ""), + ["scroll-torah"] = new FontAwesomeIconEntry(640, 512, ""), + ["sd-card"] = new FontAwesomeIconEntry(384, 512, ""), + ["section"] = new FontAwesomeIconEntry(256, 512, ""), + ["seedling"] = new FontAwesomeIconEntry(512, 512, ""), + ["server"] = new FontAwesomeIconEntry(512, 512, ""), + ["shapes"] = new FontAwesomeIconEntry(512, 512, ""), + ["share"] = new FontAwesomeIconEntry(512, 512, ""), + ["share-from-square"] = new FontAwesomeIconEntry(576, 512, ""), + ["share-nodes"] = new FontAwesomeIconEntry(448, 512, ""), + ["sheet-plastic"] = new FontAwesomeIconEntry(384, 512, ""), + ["shekel-sign"] = new FontAwesomeIconEntry(448, 512, ""), + ["shield"] = new FontAwesomeIconEntry(512, 512, ""), + ["shield-blank"] = new FontAwesomeIconEntry(512, 512, ""), + ["shield-cat"] = new FontAwesomeIconEntry(512, 512, ""), + ["shield-dog"] = new FontAwesomeIconEntry(512, 512, ""), + ["shield-halved"] = new FontAwesomeIconEntry(512, 512, ""), + ["shield-heart"] = new FontAwesomeIconEntry(512, 512, ""), + ["shield-virus"] = new FontAwesomeIconEntry(512, 512, ""), + ["ship"] = new FontAwesomeIconEntry(576, 512, ""), + ["shirt"] = new FontAwesomeIconEntry(640, 512, ""), + ["shoe-prints"] = new FontAwesomeIconEntry(640, 512, ""), + ["shop"] = new FontAwesomeIconEntry(640, 512, ""), + ["shop-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["shop-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["shower"] = new FontAwesomeIconEntry(512, 512, ""), + ["shrimp"] = new FontAwesomeIconEntry(512, 512, ""), + ["shuffle"] = new FontAwesomeIconEntry(512, 512, ""), + ["shuttle-space"] = new FontAwesomeIconEntry(640, 512, ""), + ["signal"] = new FontAwesomeIconEntry(640, 512, ""), + ["signature"] = new FontAwesomeIconEntry(640, 512, ""), + ["sign-hanging"] = new FontAwesomeIconEntry(512, 512, ""), + ["signs-post"] = new FontAwesomeIconEntry(512, 512, ""), + ["sim-card"] = new FontAwesomeIconEntry(384, 512, ""), + ["sink"] = new FontAwesomeIconEntry(512, 512, ""), + ["sitemap"] = new FontAwesomeIconEntry(576, 512, ""), + ["skull"] = new FontAwesomeIconEntry(512, 512, ""), + ["skull-crossbones"] = new FontAwesomeIconEntry(448, 512, ""), + ["slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["sleigh"] = new FontAwesomeIconEntry(640, 512, ""), + ["sliders"] = new FontAwesomeIconEntry(512, 512, ""), + ["smog"] = new FontAwesomeIconEntry(640, 512, ""), + ["smoking"] = new FontAwesomeIconEntry(640, 512, ""), + ["snowflake"] = new FontAwesomeIconEntry(448, 512, ""), + ["snowman"] = new FontAwesomeIconEntry(512, 512, ""), + ["snowplow"] = new FontAwesomeIconEntry(640, 512, ""), + ["soap"] = new FontAwesomeIconEntry(512, 512, ""), + ["socks"] = new FontAwesomeIconEntry(512, 512, ""), + ["solar-panel"] = new FontAwesomeIconEntry(640, 512, ""), + ["sort"] = new FontAwesomeIconEntry(320, 512, ""), + ["sort-down"] = new FontAwesomeIconEntry(320, 512, ""), + ["sort-up"] = new FontAwesomeIconEntry(320, 512, ""), + ["spa"] = new FontAwesomeIconEntry(576, 512, ""), + ["spaghetti-monster-flying"] = new FontAwesomeIconEntry(640, 512, ""), + ["spell-check"] = new FontAwesomeIconEntry(576, 512, ""), + ["spider"] = new FontAwesomeIconEntry(512, 512, ""), + ["spinner"] = new FontAwesomeIconEntry(512, 512, ""), + ["splotch"] = new FontAwesomeIconEntry(512, 512, ""), + ["spoon"] = new FontAwesomeIconEntry(512, 512, ""), + ["spray-can"] = new FontAwesomeIconEntry(512, 512, ""), + ["spray-can-sparkles"] = new FontAwesomeIconEntry(512, 512, ""), + ["square"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-arrow-up-right"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-binary"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-caret-down"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-caret-left"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-caret-right"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-caret-up"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-check"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-envelope"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-full"] = new FontAwesomeIconEntry(512, 512, ""), + ["square-h"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-minus"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-nfi"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-parking"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-pen"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-person-confined"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-phone"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-phone-flip"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-plus"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-poll-horizontal"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-poll-vertical"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-root-variable"] = new FontAwesomeIconEntry(576, 512, ""), + ["square-rss"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-share-nodes"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-up-right"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-virus"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-xmark"] = new FontAwesomeIconEntry(448, 512, ""), + ["staff-aesculapius"] = new FontAwesomeIconEntry(384, 512, ""), + ["staff-snake"] = new FontAwesomeIconEntry(384, 512, ""), + ["stairs"] = new FontAwesomeIconEntry(576, 512, ""), + ["stamp"] = new FontAwesomeIconEntry(512, 512, ""), + ["stapler"] = new FontAwesomeIconEntry(640, 512, ""), + ["star"] = new FontAwesomeIconEntry(576, 512, ""), + ["star-and-crescent"] = new FontAwesomeIconEntry(512, 512, ""), + ["star-half"] = new FontAwesomeIconEntry(576, 512, ""), + ["star-half-stroke"] = new FontAwesomeIconEntry(576, 512, ""), + ["star-of-david"] = new FontAwesomeIconEntry(512, 512, ""), + ["star-of-life"] = new FontAwesomeIconEntry(512, 512, ""), + ["sterling-sign"] = new FontAwesomeIconEntry(320, 512, ""), + ["stethoscope"] = new FontAwesomeIconEntry(576, 512, ""), + ["stop"] = new FontAwesomeIconEntry(384, 512, ""), + ["stopwatch"] = new FontAwesomeIconEntry(448, 512, ""), + ["stopwatch-20"] = new FontAwesomeIconEntry(448, 512, ""), + ["store"] = new FontAwesomeIconEntry(576, 512, ""), + ["store-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["street-view"] = new FontAwesomeIconEntry(512, 512, ""), + ["strikethrough"] = new FontAwesomeIconEntry(512, 512, ""), + ["stroopwafel"] = new FontAwesomeIconEntry(512, 512, ""), + ["subscript"] = new FontAwesomeIconEntry(512, 512, ""), + ["suitcase"] = new FontAwesomeIconEntry(512, 512, ""), + ["suitcase-medical"] = new FontAwesomeIconEntry(512, 512, ""), + ["suitcase-rolling"] = new FontAwesomeIconEntry(384, 512, ""), + ["sun"] = new FontAwesomeIconEntry(512, 512, ""), + ["sun-plant-wilt"] = new FontAwesomeIconEntry(640, 512, ""), + ["superscript"] = new FontAwesomeIconEntry(512, 512, ""), + ["swatchbook"] = new FontAwesomeIconEntry(512, 512, ""), + ["synagogue"] = new FontAwesomeIconEntry(640, 512, ""), + ["syringe"] = new FontAwesomeIconEntry(512, 512, ""), + ["t"] = new FontAwesomeIconEntry(384, 512, ""), + ["table"] = new FontAwesomeIconEntry(512, 512, ""), + ["table-cells"] = new FontAwesomeIconEntry(512, 512, ""), + ["table-cells-column-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["table-cells-large"] = new FontAwesomeIconEntry(512, 512, ""), + ["table-cells-row-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["table-cells-row-unlock"] = new FontAwesomeIconEntry(640, 512, ""), + ["table-columns"] = new FontAwesomeIconEntry(512, 512, ""), + ["table-list"] = new FontAwesomeIconEntry(512, 512, ""), + ["tablet"] = new FontAwesomeIconEntry(448, 512, ""), + ["tablet-button"] = new FontAwesomeIconEntry(448, 512, ""), + ["table-tennis-paddle-ball"] = new FontAwesomeIconEntry(512, 512, ""), + ["tablets"] = new FontAwesomeIconEntry(640, 512, ""), + ["tablet-screen-button"] = new FontAwesomeIconEntry(448, 512, ""), + ["tachograph-digital"] = new FontAwesomeIconEntry(640, 512, ""), + ["tag"] = new FontAwesomeIconEntry(448, 512, ""), + ["tags"] = new FontAwesomeIconEntry(512, 512, ""), + ["tape"] = new FontAwesomeIconEntry(576, 512, ""), + ["tarp"] = new FontAwesomeIconEntry(576, 512, ""), + ["tarp-droplet"] = new FontAwesomeIconEntry(576, 512, ""), + ["taxi"] = new FontAwesomeIconEntry(512, 512, ""), + ["teeth"] = new FontAwesomeIconEntry(576, 512, ""), + ["teeth-open"] = new FontAwesomeIconEntry(576, 512, ""), + ["temperature-arrow-down"] = new FontAwesomeIconEntry(576, 512, ""), + ["temperature-arrow-up"] = new FontAwesomeIconEntry(576, 512, ""), + ["temperature-empty"] = new FontAwesomeIconEntry(320, 512, ""), + ["temperature-full"] = new FontAwesomeIconEntry(320, 512, ""), + ["temperature-half"] = new FontAwesomeIconEntry(320, 512, ""), + ["temperature-high"] = new FontAwesomeIconEntry(512, 512, ""), + ["temperature-low"] = new FontAwesomeIconEntry(512, 512, ""), + ["temperature-quarter"] = new FontAwesomeIconEntry(320, 512, ""), + ["temperature-three-quarters"] = new FontAwesomeIconEntry(320, 512, ""), + ["tenge-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["tent"] = new FontAwesomeIconEntry(576, 512, ""), + ["tent-arrow-down-to-line"] = new FontAwesomeIconEntry(640, 512, ""), + ["tent-arrow-left-right"] = new FontAwesomeIconEntry(576, 512, ""), + ["tent-arrows-down"] = new FontAwesomeIconEntry(576, 512, ""), + ["tent-arrow-turn-left"] = new FontAwesomeIconEntry(576, 512, ""), + ["tents"] = new FontAwesomeIconEntry(640, 512, ""), + ["terminal"] = new FontAwesomeIconEntry(576, 512, ""), + ["text-height"] = new FontAwesomeIconEntry(576, 512, ""), + ["text-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["text-width"] = new FontAwesomeIconEntry(448, 512, ""), + ["thermometer"] = new FontAwesomeIconEntry(512, 512, ""), + ["thumbs-down"] = new FontAwesomeIconEntry(512, 512, ""), + ["thumbs-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["thumbtack"] = new FontAwesomeIconEntry(384, 512, ""), + ["thumbtack-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["ticket"] = new FontAwesomeIconEntry(576, 512, ""), + ["ticket-simple"] = new FontAwesomeIconEntry(576, 512, ""), + ["timeline"] = new FontAwesomeIconEntry(640, 512, ""), + ["toggle-off"] = new FontAwesomeIconEntry(576, 512, ""), + ["toggle-on"] = new FontAwesomeIconEntry(576, 512, ""), + ["toilet"] = new FontAwesomeIconEntry(448, 512, ""), + ["toilet-paper"] = new FontAwesomeIconEntry(640, 512, ""), + ["toilet-paper-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["toilet-portable"] = new FontAwesomeIconEntry(320, 512, ""), + ["toilets-portable"] = new FontAwesomeIconEntry(576, 512, ""), + ["toolbox"] = new FontAwesomeIconEntry(512, 512, ""), + ["tooth"] = new FontAwesomeIconEntry(448, 512, ""), + ["torii-gate"] = new FontAwesomeIconEntry(512, 512, ""), + ["tornado"] = new FontAwesomeIconEntry(448, 512, ""), + ["tower-broadcast"] = new FontAwesomeIconEntry(576, 512, ""), + ["tower-cell"] = new FontAwesomeIconEntry(576, 512, ""), + ["tower-observation"] = new FontAwesomeIconEntry(512, 512, ""), + ["tractor"] = new FontAwesomeIconEntry(640, 512, ""), + ["trademark"] = new FontAwesomeIconEntry(640, 512, ""), + ["traffic-light"] = new FontAwesomeIconEntry(320, 512, ""), + ["trailer"] = new FontAwesomeIconEntry(640, 512, ""), + ["train"] = new FontAwesomeIconEntry(448, 512, ""), + ["train-subway"] = new FontAwesomeIconEntry(448, 512, ""), + ["train-tram"] = new FontAwesomeIconEntry(448, 512, ""), + ["transgender"] = new FontAwesomeIconEntry(512, 512, ""), + ["trash"] = new FontAwesomeIconEntry(448, 512, ""), + ["trash-arrow-up"] = new FontAwesomeIconEntry(448, 512, ""), + ["trash-can"] = new FontAwesomeIconEntry(448, 512, ""), + ["trash-can-arrow-up"] = new FontAwesomeIconEntry(448, 512, ""), + ["tree"] = new FontAwesomeIconEntry(448, 512, ""), + ["tree-city"] = new FontAwesomeIconEntry(640, 512, ""), + ["triangle-exclamation"] = new FontAwesomeIconEntry(512, 512, ""), + ["trophy"] = new FontAwesomeIconEntry(576, 512, ""), + ["trowel"] = new FontAwesomeIconEntry(512, 512, ""), + ["trowel-bricks"] = new FontAwesomeIconEntry(512, 512, ""), + ["truck"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-arrow-right"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-droplet"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-fast"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-field"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-field-un"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-front"] = new FontAwesomeIconEntry(512, 512, ""), + ["truck-medical"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-monster"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-moving"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-pickup"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-plane"] = new FontAwesomeIconEntry(640, 512, ""), + ["truck-ramp-box"] = new FontAwesomeIconEntry(640, 512, ""), + ["tty"] = new FontAwesomeIconEntry(512, 512, ""), + ["turkish-lira-sign"] = new FontAwesomeIconEntry(384, 512, ""), + ["turn-down"] = new FontAwesomeIconEntry(384, 512, ""), + ["turn-up"] = new FontAwesomeIconEntry(384, 512, ""), + ["tv"] = new FontAwesomeIconEntry(640, 512, ""), + ["u"] = new FontAwesomeIconEntry(384, 512, ""), + ["umbrella"] = new FontAwesomeIconEntry(576, 512, ""), + ["umbrella-beach"] = new FontAwesomeIconEntry(576, 512, ""), + ["underline"] = new FontAwesomeIconEntry(448, 512, ""), + ["universal-access"] = new FontAwesomeIconEntry(512, 512, ""), + ["unlock"] = new FontAwesomeIconEntry(448, 512, ""), + ["unlock-keyhole"] = new FontAwesomeIconEntry(448, 512, ""), + ["up-down"] = new FontAwesomeIconEntry(256, 512, ""), + ["up-down-left-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["upload"] = new FontAwesomeIconEntry(512, 512, ""), + ["up-long"] = new FontAwesomeIconEntry(320, 512, ""), + ["up-right-and-down-left-from-center"] = new FontAwesomeIconEntry(512, 512, ""), + ["up-right-from-square"] = new FontAwesomeIconEntry(512, 512, ""), + ["user"] = new FontAwesomeIconEntry(448, 512, ""), + ["user-astronaut"] = new FontAwesomeIconEntry(448, 512, ""), + ["user-check"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-clock"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-doctor"] = new FontAwesomeIconEntry(448, 512, ""), + ["user-gear"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-graduate"] = new FontAwesomeIconEntry(448, 512, ""), + ["user-group"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-injured"] = new FontAwesomeIconEntry(448, 512, ""), + ["user-large"] = new FontAwesomeIconEntry(512, 512, ""), + ["user-large-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-lock"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-minus"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-ninja"] = new FontAwesomeIconEntry(448, 512, ""), + ["user-nurse"] = new FontAwesomeIconEntry(448, 512, ""), + ["user-pen"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-plus"] = new FontAwesomeIconEntry(640, 512, ""), + ["users"] = new FontAwesomeIconEntry(640, 512, ""), + ["users-between-lines"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-secret"] = new FontAwesomeIconEntry(448, 512, ""), + ["users-gear"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-shield"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["users-line"] = new FontAwesomeIconEntry(640, 512, ""), + ["users-rays"] = new FontAwesomeIconEntry(640, 512, ""), + ["users-rectangle"] = new FontAwesomeIconEntry(640, 512, ""), + ["users-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["users-viewfinder"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-tag"] = new FontAwesomeIconEntry(640, 512, ""), + ["user-tie"] = new FontAwesomeIconEntry(448, 512, ""), + ["user-xmark"] = new FontAwesomeIconEntry(640, 512, ""), + ["utensils"] = new FontAwesomeIconEntry(448, 512, ""), + ["v"] = new FontAwesomeIconEntry(384, 512, ""), + ["van-shuttle"] = new FontAwesomeIconEntry(640, 512, ""), + ["vault"] = new FontAwesomeIconEntry(576, 512, ""), + ["vector-square"] = new FontAwesomeIconEntry(448, 512, ""), + ["venus"] = new FontAwesomeIconEntry(384, 512, ""), + ["venus-double"] = new FontAwesomeIconEntry(640, 512, ""), + ["venus-mars"] = new FontAwesomeIconEntry(640, 512, ""), + ["vest"] = new FontAwesomeIconEntry(448, 512, ""), + ["vest-patches"] = new FontAwesomeIconEntry(448, 512, ""), + ["vial"] = new FontAwesomeIconEntry(512, 512, ""), + ["vial-circle-check"] = new FontAwesomeIconEntry(512, 512, ""), + ["vials"] = new FontAwesomeIconEntry(512, 512, ""), + ["vial-virus"] = new FontAwesomeIconEntry(512, 512, ""), + ["video"] = new FontAwesomeIconEntry(576, 512, ""), + ["video-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["vihara"] = new FontAwesomeIconEntry(640, 512, ""), + ["virus"] = new FontAwesomeIconEntry(512, 512, ""), + ["virus-covid"] = new FontAwesomeIconEntry(512, 512, ""), + ["virus-covid-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["viruses"] = new FontAwesomeIconEntry(640, 512, ""), + ["virus-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["voicemail"] = new FontAwesomeIconEntry(640, 512, ""), + ["volcano"] = new FontAwesomeIconEntry(512, 512, ""), + ["volleyball"] = new FontAwesomeIconEntry(512, 512, ""), + ["volume-high"] = new FontAwesomeIconEntry(640, 512, ""), + ["volume-low"] = new FontAwesomeIconEntry(448, 512, ""), + ["volume-off"] = new FontAwesomeIconEntry(320, 512, ""), + ["volume-xmark"] = new FontAwesomeIconEntry(576, 512, ""), + ["vr-cardboard"] = new FontAwesomeIconEntry(640, 512, ""), + ["w"] = new FontAwesomeIconEntry(576, 512, ""), + ["walkie-talkie"] = new FontAwesomeIconEntry(384, 512, ""), + ["wallet"] = new FontAwesomeIconEntry(512, 512, ""), + ["wand-magic"] = new FontAwesomeIconEntry(512, 512, ""), + ["wand-magic-sparkles"] = new FontAwesomeIconEntry(576, 512, ""), + ["wand-sparkles"] = new FontAwesomeIconEntry(512, 512, ""), + ["warehouse"] = new FontAwesomeIconEntry(640, 512, ""), + ["water"] = new FontAwesomeIconEntry(576, 512, ""), + ["water-ladder"] = new FontAwesomeIconEntry(576, 512, ""), + ["wave-square"] = new FontAwesomeIconEntry(640, 512, ""), + ["web-awesome"] = new FontAwesomeIconEntry(640, 512, ""), + ["weight-hanging"] = new FontAwesomeIconEntry(512, 512, ""), + ["weight-scale"] = new FontAwesomeIconEntry(512, 512, ""), + ["wheat-awn"] = new FontAwesomeIconEntry(512, 512, ""), + ["wheat-awn-circle-exclamation"] = new FontAwesomeIconEntry(640, 512, ""), + ["wheelchair"] = new FontAwesomeIconEntry(512, 512, ""), + ["wheelchair-move"] = new FontAwesomeIconEntry(448, 512, ""), + ["whiskey-glass"] = new FontAwesomeIconEntry(512, 512, ""), + ["wifi"] = new FontAwesomeIconEntry(640, 512, ""), + ["wind"] = new FontAwesomeIconEntry(512, 512, ""), + ["window-maximize"] = new FontAwesomeIconEntry(512, 512, ""), + ["window-minimize"] = new FontAwesomeIconEntry(512, 512, ""), + ["window-restore"] = new FontAwesomeIconEntry(512, 512, ""), + ["wine-bottle"] = new FontAwesomeIconEntry(512, 512, ""), + ["wine-glass"] = new FontAwesomeIconEntry(320, 512, ""), + ["wine-glass-empty"] = new FontAwesomeIconEntry(320, 512, ""), + ["won-sign"] = new FontAwesomeIconEntry(512, 512, ""), + ["worm"] = new FontAwesomeIconEntry(512, 512, ""), + ["wrench"] = new FontAwesomeIconEntry(512, 512, ""), + ["x"] = new FontAwesomeIconEntry(384, 512, ""), + ["xmark"] = new FontAwesomeIconEntry(384, 512, ""), + ["xmarks-lines"] = new FontAwesomeIconEntry(640, 512, ""), + ["x-ray"] = new FontAwesomeIconEntry(512, 512, ""), + ["y"] = new FontAwesomeIconEntry(384, 512, ""), + ["yen-sign"] = new FontAwesomeIconEntry(320, 512, ""), + ["yin-yang"] = new FontAwesomeIconEntry(512, 512, ""), + ["z"] = new FontAwesomeIconEntry(384, 512, "") + }; + + private static readonly IReadOnlyDictionary RegularIcons = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["address-book"] = new FontAwesomeIconEntry(512, 512, ""), + ["address-card"] = new FontAwesomeIconEntry(576, 512, ""), + ["bell"] = new FontAwesomeIconEntry(448, 512, ""), + ["bell-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["bookmark"] = new FontAwesomeIconEntry(384, 512, ""), + ["building"] = new FontAwesomeIconEntry(384, 512, ""), + ["calendar"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-check"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-days"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-minus"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-plus"] = new FontAwesomeIconEntry(448, 512, ""), + ["calendar-xmark"] = new FontAwesomeIconEntry(448, 512, ""), + ["chart-bar"] = new FontAwesomeIconEntry(512, 512, ""), + ["chess-bishop"] = new FontAwesomeIconEntry(320, 512, ""), + ["chess-king"] = new FontAwesomeIconEntry(448, 512, ""), + ["chess-knight"] = new FontAwesomeIconEntry(448, 512, ""), + ["chess-pawn"] = new FontAwesomeIconEntry(320, 512, ""), + ["chess-queen"] = new FontAwesomeIconEntry(512, 512, ""), + ["chess-rook"] = new FontAwesomeIconEntry(448, 512, ""), + ["circle"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-check"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-dot"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-down"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-pause"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-play"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-question"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-stop"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-user"] = new FontAwesomeIconEntry(512, 512, ""), + ["circle-xmark"] = new FontAwesomeIconEntry(512, 512, ""), + ["clipboard"] = new FontAwesomeIconEntry(384, 512, ""), + ["clock"] = new FontAwesomeIconEntry(512, 512, ""), + ["clone"] = new FontAwesomeIconEntry(512, 512, ""), + ["closed-captioning"] = new FontAwesomeIconEntry(576, 512, ""), + ["comment"] = new FontAwesomeIconEntry(512, 512, ""), + ["comment-dots"] = new FontAwesomeIconEntry(512, 512, ""), + ["comments"] = new FontAwesomeIconEntry(640, 512, ""), + ["compass"] = new FontAwesomeIconEntry(512, 512, ""), + ["copy"] = new FontAwesomeIconEntry(448, 512, ""), + ["copyright"] = new FontAwesomeIconEntry(512, 512, ""), + ["credit-card"] = new FontAwesomeIconEntry(576, 512, ""), + ["envelope"] = new FontAwesomeIconEntry(512, 512, ""), + ["envelope-open"] = new FontAwesomeIconEntry(512, 512, ""), + ["eye"] = new FontAwesomeIconEntry(576, 512, ""), + ["eye-slash"] = new FontAwesomeIconEntry(640, 512, ""), + ["face-angry"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-dizzy"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-flushed"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-frown"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-frown-open"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grimace"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-beam"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-beam-sweat"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-hearts"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-squint"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-squint-tears"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-stars"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-tears"] = new FontAwesomeIconEntry(640, 512, ""), + ["face-grin-tongue"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-tongue-squint"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-tongue-wink"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-wide"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-grin-wink"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-kiss"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-kiss-beam"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-kiss-wink-heart"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-laugh"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-laugh-beam"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-laugh-squint"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-laugh-wink"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-meh"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-meh-blank"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-rolling-eyes"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-sad-cry"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-sad-tear"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-smile"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-smile-beam"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-smile-wink"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-surprise"] = new FontAwesomeIconEntry(512, 512, ""), + ["face-tired"] = new FontAwesomeIconEntry(512, 512, ""), + ["file"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-audio"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-code"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-excel"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-image"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-lines"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-pdf"] = new FontAwesomeIconEntry(512, 512, ""), + ["file-powerpoint"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-video"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-word"] = new FontAwesomeIconEntry(384, 512, ""), + ["file-zipper"] = new FontAwesomeIconEntry(384, 512, ""), + ["flag"] = new FontAwesomeIconEntry(448, 512, ""), + ["floppy-disk"] = new FontAwesomeIconEntry(448, 512, ""), + ["folder"] = new FontAwesomeIconEntry(512, 512, ""), + ["folder-closed"] = new FontAwesomeIconEntry(512, 512, ""), + ["folder-open"] = new FontAwesomeIconEntry(576, 512, ""), + ["font-awesome"] = new FontAwesomeIconEntry(512, 512, ""), + ["futbol"] = new FontAwesomeIconEntry(512, 512, ""), + ["gem"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-back-fist"] = new FontAwesomeIconEntry(448, 512, ""), + ["hand-lizard"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-peace"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-point-down"] = new FontAwesomeIconEntry(384, 512, ""), + ["hand-pointer"] = new FontAwesomeIconEntry(448, 512, ""), + ["hand-point-left"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-point-right"] = new FontAwesomeIconEntry(512, 512, ""), + ["hand-point-up"] = new FontAwesomeIconEntry(384, 512, ""), + ["hand-scissors"] = new FontAwesomeIconEntry(512, 512, ""), + ["handshake"] = new FontAwesomeIconEntry(640, 512, ""), + ["hand-spock"] = new FontAwesomeIconEntry(576, 512, ""), + ["hard-drive"] = new FontAwesomeIconEntry(512, 512, ""), + ["heart"] = new FontAwesomeIconEntry(512, 512, ""), + ["hospital"] = new FontAwesomeIconEntry(640, 512, ""), + ["hourglass"] = new FontAwesomeIconEntry(384, 512, ""), + ["hourglass-half"] = new FontAwesomeIconEntry(384, 512, ""), + ["id-badge"] = new FontAwesomeIconEntry(384, 512, ""), + ["id-card"] = new FontAwesomeIconEntry(576, 512, ""), + ["image"] = new FontAwesomeIconEntry(512, 512, ""), + ["images"] = new FontAwesomeIconEntry(576, 512, ""), + ["keyboard"] = new FontAwesomeIconEntry(576, 512, ""), + ["lemon"] = new FontAwesomeIconEntry(448, 512, ""), + ["life-ring"] = new FontAwesomeIconEntry(512, 512, ""), + ["lightbulb"] = new FontAwesomeIconEntry(384, 512, ""), + ["map"] = new FontAwesomeIconEntry(576, 512, ""), + ["message"] = new FontAwesomeIconEntry(512, 512, ""), + ["money-bill-1"] = new FontAwesomeIconEntry(576, 512, ""), + ["moon"] = new FontAwesomeIconEntry(384, 512, ""), + ["newspaper"] = new FontAwesomeIconEntry(512, 512, ""), + ["notdef"] = new FontAwesomeIconEntry(384, 512, ""), + ["note-sticky"] = new FontAwesomeIconEntry(448, 512, ""), + ["object-group"] = new FontAwesomeIconEntry(576, 512, ""), + ["object-ungroup"] = new FontAwesomeIconEntry(640, 512, ""), + ["paper-plane"] = new FontAwesomeIconEntry(512, 512, ""), + ["paste"] = new FontAwesomeIconEntry(512, 512, ""), + ["pen-to-square"] = new FontAwesomeIconEntry(512, 512, ""), + ["rectangle-list"] = new FontAwesomeIconEntry(576, 512, ""), + ["rectangle-xmark"] = new FontAwesomeIconEntry(512, 512, ""), + ["registered"] = new FontAwesomeIconEntry(512, 512, ""), + ["share-from-square"] = new FontAwesomeIconEntry(576, 512, ""), + ["snowflake"] = new FontAwesomeIconEntry(448, 512, ""), + ["square"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-caret-down"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-caret-left"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-caret-right"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-caret-up"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-check"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-full"] = new FontAwesomeIconEntry(512, 512, ""), + ["square-minus"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-plus"] = new FontAwesomeIconEntry(448, 512, ""), + ["star"] = new FontAwesomeIconEntry(576, 512, ""), + ["star-half"] = new FontAwesomeIconEntry(576, 512, ""), + ["star-half-stroke"] = new FontAwesomeIconEntry(576, 512, ""), + ["sun"] = new FontAwesomeIconEntry(512, 512, ""), + ["thumbs-down"] = new FontAwesomeIconEntry(512, 512, ""), + ["thumbs-up"] = new FontAwesomeIconEntry(512, 512, ""), + ["trash-can"] = new FontAwesomeIconEntry(448, 512, ""), + ["user"] = new FontAwesomeIconEntry(448, 512, ""), + ["window-maximize"] = new FontAwesomeIconEntry(512, 512, ""), + ["window-minimize"] = new FontAwesomeIconEntry(512, 512, ""), + ["window-restore"] = new FontAwesomeIconEntry(512, 512, "") + }; + + private static readonly IReadOnlyDictionary BrandsIcons = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["42-group"] = new FontAwesomeIconEntry(640, 512, ""), + ["500px"] = new FontAwesomeIconEntry(448, 512, ""), + ["accessible-icon"] = new FontAwesomeIconEntry(448, 512, ""), + ["accusoft"] = new FontAwesomeIconEntry(640, 512, ""), + ["adn"] = new FontAwesomeIconEntry(496, 512, ""), + ["adversal"] = new FontAwesomeIconEntry(512, 512, ""), + ["affiliatetheme"] = new FontAwesomeIconEntry(512, 512, ""), + ["airbnb"] = new FontAwesomeIconEntry(448, 512, ""), + ["algolia"] = new FontAwesomeIconEntry(512, 512, ""), + ["alipay"] = new FontAwesomeIconEntry(448, 512, ""), + ["amazon"] = new FontAwesomeIconEntry(448, 512, ""), + ["amazon-pay"] = new FontAwesomeIconEntry(640, 512, ""), + ["amilia"] = new FontAwesomeIconEntry(448, 512, ""), + ["android"] = new FontAwesomeIconEntry(576, 512, ""), + ["angellist"] = new FontAwesomeIconEntry(448, 512, ""), + ["angrycreative"] = new FontAwesomeIconEntry(640, 512, ""), + ["angular"] = new FontAwesomeIconEntry(448, 512, ""), + ["apper"] = new FontAwesomeIconEntry(640, 512, ""), + ["apple"] = new FontAwesomeIconEntry(384, 512, ""), + ["apple-pay"] = new FontAwesomeIconEntry(640, 512, ""), + ["app-store"] = new FontAwesomeIconEntry(512, 512, ""), + ["app-store-ios"] = new FontAwesomeIconEntry(448, 512, ""), + ["artstation"] = new FontAwesomeIconEntry(512, 512, ""), + ["asymmetrik"] = new FontAwesomeIconEntry(576, 512, ""), + ["atlassian"] = new FontAwesomeIconEntry(512, 512, ""), + ["audible"] = new FontAwesomeIconEntry(640, 512, ""), + ["autoprefixer"] = new FontAwesomeIconEntry(640, 512, ""), + ["avianex"] = new FontAwesomeIconEntry(512, 512, ""), + ["aviato"] = new FontAwesomeIconEntry(640, 512, ""), + ["aws"] = new FontAwesomeIconEntry(640, 512, ""), + ["bandcamp"] = new FontAwesomeIconEntry(512, 512, ""), + ["battle-net"] = new FontAwesomeIconEntry(512, 512, ""), + ["behance"] = new FontAwesomeIconEntry(576, 512, ""), + ["bilibili"] = new FontAwesomeIconEntry(512, 512, ""), + ["bimobject"] = new FontAwesomeIconEntry(448, 512, ""), + ["bitbucket"] = new FontAwesomeIconEntry(512, 512, ""), + ["bitcoin"] = new FontAwesomeIconEntry(512, 512, ""), + ["bity"] = new FontAwesomeIconEntry(496, 512, ""), + ["blackberry"] = new FontAwesomeIconEntry(512, 512, ""), + ["black-tie"] = new FontAwesomeIconEntry(448, 512, ""), + ["blogger"] = new FontAwesomeIconEntry(448, 512, ""), + ["blogger-b"] = new FontAwesomeIconEntry(448, 512, ""), + ["bluesky"] = new FontAwesomeIconEntry(512, 512, ""), + ["bluetooth"] = new FontAwesomeIconEntry(448, 512, ""), + ["bluetooth-b"] = new FontAwesomeIconEntry(320, 512, ""), + ["bootstrap"] = new FontAwesomeIconEntry(576, 512, ""), + ["bots"] = new FontAwesomeIconEntry(640, 512, ""), + ["brave"] = new FontAwesomeIconEntry(448, 512, ""), + ["brave-reverse"] = new FontAwesomeIconEntry(448, 512, ""), + ["btc"] = new FontAwesomeIconEntry(384, 512, ""), + ["buffer"] = new FontAwesomeIconEntry(448, 512, ""), + ["buromobelexperte"] = new FontAwesomeIconEntry(448, 512, ""), + ["buy-n-large"] = new FontAwesomeIconEntry(576, 512, ""), + ["buysellads"] = new FontAwesomeIconEntry(448, 512, ""), + ["canadian-maple-leaf"] = new FontAwesomeIconEntry(512, 512, ""), + ["cc-amazon-pay"] = new FontAwesomeIconEntry(576, 512, ""), + ["cc-amex"] = new FontAwesomeIconEntry(576, 512, ""), + ["cc-apple-pay"] = new FontAwesomeIconEntry(576, 512, ""), + ["cc-diners-club"] = new FontAwesomeIconEntry(576, 512, ""), + ["cc-discover"] = new FontAwesomeIconEntry(576, 512, ""), + ["cc-jcb"] = new FontAwesomeIconEntry(576, 512, ""), + ["cc-mastercard"] = new FontAwesomeIconEntry(576, 512, ""), + ["cc-paypal"] = new FontAwesomeIconEntry(576, 512, ""), + ["cc-stripe"] = new FontAwesomeIconEntry(576, 512, ""), + ["cc-visa"] = new FontAwesomeIconEntry(576, 512, ""), + ["centercode"] = new FontAwesomeIconEntry(512, 512, ""), + ["centos"] = new FontAwesomeIconEntry(448, 512, ""), + ["chrome"] = new FontAwesomeIconEntry(512, 512, ""), + ["chromecast"] = new FontAwesomeIconEntry(512, 512, ""), + ["cloudflare"] = new FontAwesomeIconEntry(640, 512, ""), + ["cloudscale"] = new FontAwesomeIconEntry(448, 512, ""), + ["cloudsmith"] = new FontAwesomeIconEntry(512, 512, ""), + ["cloudversify"] = new FontAwesomeIconEntry(616, 512, ""), + ["cmplid"] = new FontAwesomeIconEntry(640, 512, ""), + ["codepen"] = new FontAwesomeIconEntry(512, 512, ""), + ["codiepie"] = new FontAwesomeIconEntry(472, 512, ""), + ["confluence"] = new FontAwesomeIconEntry(512, 512, ""), + ["connectdevelop"] = new FontAwesomeIconEntry(576, 512, ""), + ["contao"] = new FontAwesomeIconEntry(512, 512, ""), + ["cotton-bureau"] = new FontAwesomeIconEntry(512, 512, ""), + ["cpanel"] = new FontAwesomeIconEntry(640, 512, ""), + ["creative-commons"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-by"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-nc"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-nc-eu"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-nc-jp"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-nd"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-pd"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-pd-alt"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-remix"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-sa"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-sampling"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-sampling-plus"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-share"] = new FontAwesomeIconEntry(496, 512, ""), + ["creative-commons-zero"] = new FontAwesomeIconEntry(496, 512, ""), + ["critical-role"] = new FontAwesomeIconEntry(448, 512, ""), + ["css"] = new FontAwesomeIconEntry(448, 512, ""), + ["css3"] = new FontAwesomeIconEntry(512, 512, ""), + ["css3-alt"] = new FontAwesomeIconEntry(384, 512, ""), + ["cuttlefish"] = new FontAwesomeIconEntry(440, 512, ""), + ["dailymotion"] = new FontAwesomeIconEntry(448, 512, ""), + ["d-and-d"] = new FontAwesomeIconEntry(576, 512, ""), + ["d-and-d-beyond"] = new FontAwesomeIconEntry(640, 512, ""), + ["dart-lang"] = new FontAwesomeIconEntry(512, 512, ""), + ["dashcube"] = new FontAwesomeIconEntry(448, 512, ""), + ["debian"] = new FontAwesomeIconEntry(448, 512, ""), + ["deezer"] = new FontAwesomeIconEntry(576, 512, ""), + ["delicious"] = new FontAwesomeIconEntry(448, 512, ""), + ["deploydog"] = new FontAwesomeIconEntry(512, 512, ""), + ["deskpro"] = new FontAwesomeIconEntry(480, 512, ""), + ["dev"] = new FontAwesomeIconEntry(448, 512, ""), + ["deviantart"] = new FontAwesomeIconEntry(320, 512, ""), + ["dhl"] = new FontAwesomeIconEntry(640, 512, ""), + ["diaspora"] = new FontAwesomeIconEntry(512, 512, ""), + ["digg"] = new FontAwesomeIconEntry(512, 512, ""), + ["digital-ocean"] = new FontAwesomeIconEntry(512, 512, ""), + ["discord"] = new FontAwesomeIconEntry(640, 512, ""), + ["discourse"] = new FontAwesomeIconEntry(448, 512, ""), + ["dochub"] = new FontAwesomeIconEntry(416, 512, ""), + ["docker"] = new FontAwesomeIconEntry(640, 512, ""), + ["draft2digital"] = new FontAwesomeIconEntry(480, 512, ""), + ["dribbble"] = new FontAwesomeIconEntry(512, 512, ""), + ["dropbox"] = new FontAwesomeIconEntry(528, 512, ""), + ["drupal"] = new FontAwesomeIconEntry(448, 512, ""), + ["dyalog"] = new FontAwesomeIconEntry(416, 512, ""), + ["earlybirds"] = new FontAwesomeIconEntry(480, 512, ""), + ["ebay"] = new FontAwesomeIconEntry(640, 512, ""), + ["edge"] = new FontAwesomeIconEntry(512, 512, ""), + ["edge-legacy"] = new FontAwesomeIconEntry(512, 512, ""), + ["elementor"] = new FontAwesomeIconEntry(512, 512, ""), + ["ello"] = new FontAwesomeIconEntry(496, 512, ""), + ["ember"] = new FontAwesomeIconEntry(640, 512, ""), + ["empire"] = new FontAwesomeIconEntry(496, 512, ""), + ["envira"] = new FontAwesomeIconEntry(448, 512, ""), + ["erlang"] = new FontAwesomeIconEntry(640, 512, ""), + ["ethereum"] = new FontAwesomeIconEntry(320, 512, ""), + ["etsy"] = new FontAwesomeIconEntry(384, 512, ""), + ["evernote"] = new FontAwesomeIconEntry(384, 512, ""), + ["expeditedssl"] = new FontAwesomeIconEntry(496, 512, ""), + ["facebook"] = new FontAwesomeIconEntry(512, 512, ""), + ["facebook-f"] = new FontAwesomeIconEntry(320, 512, ""), + ["facebook-messenger"] = new FontAwesomeIconEntry(512, 512, ""), + ["fantasy-flight-games"] = new FontAwesomeIconEntry(512, 512, ""), + ["fedex"] = new FontAwesomeIconEntry(640, 512, ""), + ["fedora"] = new FontAwesomeIconEntry(448, 512, ""), + ["figma"] = new FontAwesomeIconEntry(384, 512, ""), + ["files-pinwheel"] = new FontAwesomeIconEntry(512, 512, ""), + ["firefox"] = new FontAwesomeIconEntry(512, 512, ""), + ["firefox-browser"] = new FontAwesomeIconEntry(512, 512, ""), + ["firstdraft"] = new FontAwesomeIconEntry(384, 512, ""), + ["first-order"] = new FontAwesomeIconEntry(448, 512, ""), + ["first-order-alt"] = new FontAwesomeIconEntry(496, 512, ""), + ["flickr"] = new FontAwesomeIconEntry(448, 512, ""), + ["flipboard"] = new FontAwesomeIconEntry(448, 512, ""), + ["flutter"] = new FontAwesomeIconEntry(448, 512, ""), + ["fly"] = new FontAwesomeIconEntry(384, 512, ""), + ["font-awesome"] = new FontAwesomeIconEntry(512, 512, ""), + ["fonticons"] = new FontAwesomeIconEntry(448, 512, ""), + ["fonticons-fi"] = new FontAwesomeIconEntry(384, 512, ""), + ["fort-awesome"] = new FontAwesomeIconEntry(512, 512, ""), + ["fort-awesome-alt"] = new FontAwesomeIconEntry(512, 512, ""), + ["forumbee"] = new FontAwesomeIconEntry(448, 512, ""), + ["foursquare"] = new FontAwesomeIconEntry(368, 512, ""), + ["freebsd"] = new FontAwesomeIconEntry(448, 512, ""), + ["free-code-camp"] = new FontAwesomeIconEntry(576, 512, ""), + ["fulcrum"] = new FontAwesomeIconEntry(320, 512, ""), + ["galactic-republic"] = new FontAwesomeIconEntry(496, 512, ""), + ["galactic-senate"] = new FontAwesomeIconEntry(512, 512, ""), + ["get-pocket"] = new FontAwesomeIconEntry(448, 512, ""), + ["gg"] = new FontAwesomeIconEntry(512, 512, ""), + ["gg-circle"] = new FontAwesomeIconEntry(512, 512, ""), + ["git"] = new FontAwesomeIconEntry(512, 512, ""), + ["git-alt"] = new FontAwesomeIconEntry(448, 512, ""), + ["github"] = new FontAwesomeIconEntry(496, 512, ""), + ["github-alt"] = new FontAwesomeIconEntry(480, 512, ""), + ["gitkraken"] = new FontAwesomeIconEntry(592, 512, ""), + ["gitlab"] = new FontAwesomeIconEntry(512, 512, ""), + ["gitter"] = new FontAwesomeIconEntry(384, 512, ""), + ["glide"] = new FontAwesomeIconEntry(448, 512, ""), + ["glide-g"] = new FontAwesomeIconEntry(448, 512, ""), + ["gofore"] = new FontAwesomeIconEntry(400, 512, ""), + ["golang"] = new FontAwesomeIconEntry(640, 512, ""), + ["goodreads"] = new FontAwesomeIconEntry(448, 512, ""), + ["goodreads-g"] = new FontAwesomeIconEntry(384, 512, ""), + ["google"] = new FontAwesomeIconEntry(488, 512, ""), + ["google-drive"] = new FontAwesomeIconEntry(512, 512, ""), + ["google-pay"] = new FontAwesomeIconEntry(640, 512, ""), + ["google-play"] = new FontAwesomeIconEntry(512, 512, ""), + ["google-plus"] = new FontAwesomeIconEntry(512, 512, ""), + ["google-plus-g"] = new FontAwesomeIconEntry(640, 512, ""), + ["google-scholar"] = new FontAwesomeIconEntry(512, 512, ""), + ["google-wallet"] = new FontAwesomeIconEntry(448, 512, ""), + ["gratipay"] = new FontAwesomeIconEntry(496, 512, ""), + ["grav"] = new FontAwesomeIconEntry(512, 512, ""), + ["gripfire"] = new FontAwesomeIconEntry(384, 512, ""), + ["grunt"] = new FontAwesomeIconEntry(384, 512, ""), + ["guilded"] = new FontAwesomeIconEntry(448, 512, ""), + ["gulp"] = new FontAwesomeIconEntry(256, 512, ""), + ["hacker-news"] = new FontAwesomeIconEntry(448, 512, ""), + ["hackerrank"] = new FontAwesomeIconEntry(512, 512, ""), + ["hashnode"] = new FontAwesomeIconEntry(512, 512, ""), + ["hips"] = new FontAwesomeIconEntry(640, 512, ""), + ["hire-a-helper"] = new FontAwesomeIconEntry(512, 512, ""), + ["hive"] = new FontAwesomeIconEntry(512, 512, ""), + ["hooli"] = new FontAwesomeIconEntry(640, 512, ""), + ["hornbill"] = new FontAwesomeIconEntry(512, 512, ""), + ["hotjar"] = new FontAwesomeIconEntry(512, 512, ""), + ["houzz"] = new FontAwesomeIconEntry(448, 512, ""), + ["html5"] = new FontAwesomeIconEntry(384, 512, ""), + ["hubspot"] = new FontAwesomeIconEntry(512, 512, ""), + ["ideal"] = new FontAwesomeIconEntry(576, 512, ""), + ["imdb"] = new FontAwesomeIconEntry(448, 512, ""), + ["instagram"] = new FontAwesomeIconEntry(448, 512, ""), + ["instalod"] = new FontAwesomeIconEntry(512, 512, ""), + ["intercom"] = new FontAwesomeIconEntry(448, 512, ""), + ["internet-explorer"] = new FontAwesomeIconEntry(512, 512, ""), + ["invision"] = new FontAwesomeIconEntry(448, 512, ""), + ["ioxhost"] = new FontAwesomeIconEntry(640, 512, ""), + ["itch-io"] = new FontAwesomeIconEntry(512, 512, ""), + ["itunes"] = new FontAwesomeIconEntry(448, 512, ""), + ["itunes-note"] = new FontAwesomeIconEntry(384, 512, ""), + ["java"] = new FontAwesomeIconEntry(384, 512, ""), + ["jedi-order"] = new FontAwesomeIconEntry(448, 512, ""), + ["jenkins"] = new FontAwesomeIconEntry(512, 512, ""), + ["jira"] = new FontAwesomeIconEntry(496, 512, ""), + ["joget"] = new FontAwesomeIconEntry(496, 512, ""), + ["joomla"] = new FontAwesomeIconEntry(448, 512, ""), + ["js"] = new FontAwesomeIconEntry(448, 512, ""), + ["jsfiddle"] = new FontAwesomeIconEntry(576, 512, ""), + ["jxl"] = new FontAwesomeIconEntry(448, 512, ""), + ["kaggle"] = new FontAwesomeIconEntry(320, 512, ""), + ["keybase"] = new FontAwesomeIconEntry(448, 512, ""), + ["keycdn"] = new FontAwesomeIconEntry(512, 512, ""), + ["kickstarter"] = new FontAwesomeIconEntry(448, 512, ""), + ["kickstarter-k"] = new FontAwesomeIconEntry(448, 512, ""), + ["korvue"] = new FontAwesomeIconEntry(446, 512, ""), + ["laravel"] = new FontAwesomeIconEntry(512, 512, ""), + ["lastfm"] = new FontAwesomeIconEntry(512, 512, ""), + ["leanpub"] = new FontAwesomeIconEntry(576, 512, ""), + ["less"] = new FontAwesomeIconEntry(640, 512, ""), + ["letterboxd"] = new FontAwesomeIconEntry(640, 512, ""), + ["line"] = new FontAwesomeIconEntry(512, 512, ""), + ["linkedin"] = new FontAwesomeIconEntry(448, 512, ""), + ["linkedin-in"] = new FontAwesomeIconEntry(448, 512, ""), + ["linode"] = new FontAwesomeIconEntry(448, 512, ""), + ["linux"] = new FontAwesomeIconEntry(448, 512, ""), + ["lyft"] = new FontAwesomeIconEntry(512, 512, ""), + ["magento"] = new FontAwesomeIconEntry(448, 512, ""), + ["mailchimp"] = new FontAwesomeIconEntry(448, 512, ""), + ["mandalorian"] = new FontAwesomeIconEntry(448, 512, ""), + ["markdown"] = new FontAwesomeIconEntry(640, 512, ""), + ["mastodon"] = new FontAwesomeIconEntry(448, 512, ""), + ["maxcdn"] = new FontAwesomeIconEntry(512, 512, ""), + ["mdb"] = new FontAwesomeIconEntry(576, 512, ""), + ["medapps"] = new FontAwesomeIconEntry(320, 512, ""), + ["medium"] = new FontAwesomeIconEntry(640, 512, ""), + ["medrt"] = new FontAwesomeIconEntry(544, 512, ""), + ["meetup"] = new FontAwesomeIconEntry(512, 512, ""), + ["megaport"] = new FontAwesomeIconEntry(496, 512, ""), + ["mendeley"] = new FontAwesomeIconEntry(640, 512, ""), + ["meta"] = new FontAwesomeIconEntry(640, 512, ""), + ["microblog"] = new FontAwesomeIconEntry(448, 512, ""), + ["microsoft"] = new FontAwesomeIconEntry(448, 512, ""), + ["mintbit"] = new FontAwesomeIconEntry(512, 512, ""), + ["mix"] = new FontAwesomeIconEntry(448, 512, ""), + ["mixcloud"] = new FontAwesomeIconEntry(640, 512, ""), + ["mixer"] = new FontAwesomeIconEntry(512, 512, ""), + ["mizuni"] = new FontAwesomeIconEntry(496, 512, ""), + ["modx"] = new FontAwesomeIconEntry(448, 512, ""), + ["monero"] = new FontAwesomeIconEntry(496, 512, ""), + ["napster"] = new FontAwesomeIconEntry(496, 512, ""), + ["neos"] = new FontAwesomeIconEntry(512, 512, ""), + ["nfc-directional"] = new FontAwesomeIconEntry(512, 512, ""), + ["nfc-symbol"] = new FontAwesomeIconEntry(576, 512, ""), + ["nimblr"] = new FontAwesomeIconEntry(384, 512, ""), + ["node"] = new FontAwesomeIconEntry(640, 512, ""), + ["node-js"] = new FontAwesomeIconEntry(448, 512, ""), + ["npm"] = new FontAwesomeIconEntry(576, 512, ""), + ["ns8"] = new FontAwesomeIconEntry(640, 512, ""), + ["nutritionix"] = new FontAwesomeIconEntry(400, 512, ""), + ["octopus-deploy"] = new FontAwesomeIconEntry(512, 512, ""), + ["odnoklassniki"] = new FontAwesomeIconEntry(320, 512, ""), + ["odysee"] = new FontAwesomeIconEntry(512, 512, ""), + ["old-republic"] = new FontAwesomeIconEntry(496, 512, ""), + ["opencart"] = new FontAwesomeIconEntry(640, 512, ""), + ["openid"] = new FontAwesomeIconEntry(448, 512, ""), + ["opensuse"] = new FontAwesomeIconEntry(640, 512, ""), + ["opera"] = new FontAwesomeIconEntry(496, 512, ""), + ["optin-monster"] = new FontAwesomeIconEntry(576, 512, ""), + ["orcid"] = new FontAwesomeIconEntry(512, 512, ""), + ["osi"] = new FontAwesomeIconEntry(512, 512, ""), + ["padlet"] = new FontAwesomeIconEntry(640, 512, ""), + ["page4"] = new FontAwesomeIconEntry(496, 512, ""), + ["pagelines"] = new FontAwesomeIconEntry(384, 512, ""), + ["palfed"] = new FontAwesomeIconEntry(576, 512, ""), + ["patreon"] = new FontAwesomeIconEntry(512, 512, ""), + ["paypal"] = new FontAwesomeIconEntry(384, 512, ""), + ["perbyte"] = new FontAwesomeIconEntry(448, 512, ""), + ["periscope"] = new FontAwesomeIconEntry(448, 512, ""), + ["phabricator"] = new FontAwesomeIconEntry(496, 512, ""), + ["phoenix-framework"] = new FontAwesomeIconEntry(640, 512, ""), + ["phoenix-squadron"] = new FontAwesomeIconEntry(512, 512, ""), + ["php"] = new FontAwesomeIconEntry(640, 512, ""), + ["pied-piper"] = new FontAwesomeIconEntry(480, 512, ""), + ["pied-piper-alt"] = new FontAwesomeIconEntry(576, 512, ""), + ["pied-piper-hat"] = new FontAwesomeIconEntry(640, 512, ""), + ["pied-piper-pp"] = new FontAwesomeIconEntry(448, 512, ""), + ["pinterest"] = new FontAwesomeIconEntry(496, 512, ""), + ["pinterest-p"] = new FontAwesomeIconEntry(384, 512, ""), + ["pix"] = new FontAwesomeIconEntry(512, 512, ""), + ["pixiv"] = new FontAwesomeIconEntry(448, 512, ""), + ["playstation"] = new FontAwesomeIconEntry(576, 512, ""), + ["product-hunt"] = new FontAwesomeIconEntry(512, 512, ""), + ["pushed"] = new FontAwesomeIconEntry(432, 512, ""), + ["python"] = new FontAwesomeIconEntry(448, 512, ""), + ["qq"] = new FontAwesomeIconEntry(448, 512, ""), + ["quinscape"] = new FontAwesomeIconEntry(512, 512, ""), + ["quora"] = new FontAwesomeIconEntry(448, 512, ""), + ["raspberry-pi"] = new FontAwesomeIconEntry(407, 512, ""), + ["ravelry"] = new FontAwesomeIconEntry(512, 512, ""), + ["react"] = new FontAwesomeIconEntry(512, 512, ""), + ["reacteurope"] = new FontAwesomeIconEntry(576, 512, ""), + ["readme"] = new FontAwesomeIconEntry(576, 512, ""), + ["rebel"] = new FontAwesomeIconEntry(512, 512, ""), + ["reddit"] = new FontAwesomeIconEntry(512, 512, ""), + ["reddit-alien"] = new FontAwesomeIconEntry(512, 512, ""), + ["redhat"] = new FontAwesomeIconEntry(512, 512, ""), + ["red-river"] = new FontAwesomeIconEntry(448, 512, ""), + ["renren"] = new FontAwesomeIconEntry(512, 512, ""), + ["replyd"] = new FontAwesomeIconEntry(448, 512, ""), + ["researchgate"] = new FontAwesomeIconEntry(448, 512, ""), + ["resolving"] = new FontAwesomeIconEntry(496, 512, ""), + ["rev"] = new FontAwesomeIconEntry(448, 512, ""), + ["rocketchat"] = new FontAwesomeIconEntry(576, 512, ""), + ["rockrms"] = new FontAwesomeIconEntry(496, 512, ""), + ["r-project"] = new FontAwesomeIconEntry(581, 512, ""), + ["rust"] = new FontAwesomeIconEntry(512, 512, ""), + ["safari"] = new FontAwesomeIconEntry(512, 512, ""), + ["salesforce"] = new FontAwesomeIconEntry(640, 512, ""), + ["sass"] = new FontAwesomeIconEntry(640, 512, ""), + ["schlix"] = new FontAwesomeIconEntry(448, 512, ""), + ["screenpal"] = new FontAwesomeIconEntry(512, 512, ""), + ["scribd"] = new FontAwesomeIconEntry(384, 512, ""), + ["searchengin"] = new FontAwesomeIconEntry(460, 512, ""), + ["sellcast"] = new FontAwesomeIconEntry(448, 512, ""), + ["sellsy"] = new FontAwesomeIconEntry(640, 512, ""), + ["servicestack"] = new FontAwesomeIconEntry(496, 512, ""), + ["shirtsinbulk"] = new FontAwesomeIconEntry(448, 512, ""), + ["shoelace"] = new FontAwesomeIconEntry(512, 512, ""), + ["shopify"] = new FontAwesomeIconEntry(448, 512, ""), + ["shopware"] = new FontAwesomeIconEntry(512, 512, ""), + ["signal-messenger"] = new FontAwesomeIconEntry(512, 512, ""), + ["simplybuilt"] = new FontAwesomeIconEntry(512, 512, ""), + ["sistrix"] = new FontAwesomeIconEntry(448, 512, ""), + ["sith"] = new FontAwesomeIconEntry(448, 512, ""), + ["sitrox"] = new FontAwesomeIconEntry(448, 512, ""), + ["sketch"] = new FontAwesomeIconEntry(512, 512, ""), + ["skyatlas"] = new FontAwesomeIconEntry(640, 512, ""), + ["skype"] = new FontAwesomeIconEntry(448, 512, ""), + ["slack"] = new FontAwesomeIconEntry(448, 512, ""), + ["slideshare"] = new FontAwesomeIconEntry(512, 512, ""), + ["snapchat"] = new FontAwesomeIconEntry(512, 512, ""), + ["soundcloud"] = new FontAwesomeIconEntry(640, 512, ""), + ["sourcetree"] = new FontAwesomeIconEntry(448, 512, ""), + ["space-awesome"] = new FontAwesomeIconEntry(512, 512, ""), + ["speakap"] = new FontAwesomeIconEntry(448, 512, ""), + ["speaker-deck"] = new FontAwesomeIconEntry(512, 512, ""), + ["spotify"] = new FontAwesomeIconEntry(496, 512, ""), + ["square-behance"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-bluesky"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-dribbble"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-facebook"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-font-awesome"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-font-awesome-stroke"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-git"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-github"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-gitlab"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-google-plus"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-hacker-news"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-instagram"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-js"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-lastfm"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-letterboxd"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-odnoklassniki"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-pied-piper"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-pinterest"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-reddit"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-snapchat"] = new FontAwesomeIconEntry(448, 512, ""), + ["squarespace"] = new FontAwesomeIconEntry(512, 512, ""), + ["square-steam"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-threads"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-tumblr"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-twitter"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-upwork"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-viadeo"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-vimeo"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-web-awesome"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-web-awesome-stroke"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-whatsapp"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-xing"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-x-twitter"] = new FontAwesomeIconEntry(448, 512, ""), + ["square-youtube"] = new FontAwesomeIconEntry(448, 512, ""), + ["stack-exchange"] = new FontAwesomeIconEntry(448, 512, ""), + ["stack-overflow"] = new FontAwesomeIconEntry(384, 512, ""), + ["stackpath"] = new FontAwesomeIconEntry(448, 512, ""), + ["staylinked"] = new FontAwesomeIconEntry(440, 512, ""), + ["steam"] = new FontAwesomeIconEntry(496, 512, ""), + ["steam-symbol"] = new FontAwesomeIconEntry(448, 512, ""), + ["sticker-mule"] = new FontAwesomeIconEntry(576, 512, ""), + ["strava"] = new FontAwesomeIconEntry(384, 512, ""), + ["stripe"] = new FontAwesomeIconEntry(640, 512, ""), + ["stripe-s"] = new FontAwesomeIconEntry(384, 512, ""), + ["stubber"] = new FontAwesomeIconEntry(448, 512, ""), + ["studiovinari"] = new FontAwesomeIconEntry(512, 512, ""), + ["stumbleupon"] = new FontAwesomeIconEntry(512, 512, ""), + ["stumbleupon-circle"] = new FontAwesomeIconEntry(496, 512, ""), + ["superpowers"] = new FontAwesomeIconEntry(448, 512, ""), + ["supple"] = new FontAwesomeIconEntry(640, 512, ""), + ["suse"] = new FontAwesomeIconEntry(640, 512, ""), + ["swift"] = new FontAwesomeIconEntry(448, 512, ""), + ["symfony"] = new FontAwesomeIconEntry(512, 512, ""), + ["teamspeak"] = new FontAwesomeIconEntry(576, 512, ""), + ["telegram"] = new FontAwesomeIconEntry(496, 512, ""), + ["tencent-weibo"] = new FontAwesomeIconEntry(384, 512, ""), + ["themeco"] = new FontAwesomeIconEntry(448, 512, ""), + ["themeisle"] = new FontAwesomeIconEntry(512, 512, ""), + ["the-red-yeti"] = new FontAwesomeIconEntry(512, 512, ""), + ["think-peaks"] = new FontAwesomeIconEntry(576, 512, ""), + ["threads"] = new FontAwesomeIconEntry(448, 512, ""), + ["tiktok"] = new FontAwesomeIconEntry(448, 512, ""), + ["trade-federation"] = new FontAwesomeIconEntry(496, 512, ""), + ["trello"] = new FontAwesomeIconEntry(448, 512, ""), + ["tumblr"] = new FontAwesomeIconEntry(320, 512, ""), + ["twitch"] = new FontAwesomeIconEntry(512, 512, ""), + ["twitter"] = new FontAwesomeIconEntry(512, 512, ""), + ["typo3"] = new FontAwesomeIconEntry(448, 512, ""), + ["uber"] = new FontAwesomeIconEntry(448, 512, ""), + ["ubuntu"] = new FontAwesomeIconEntry(576, 512, ""), + ["uikit"] = new FontAwesomeIconEntry(448, 512, ""), + ["umbraco"] = new FontAwesomeIconEntry(510, 512, ""), + ["uncharted"] = new FontAwesomeIconEntry(448, 512, ""), + ["uniregistry"] = new FontAwesomeIconEntry(384, 512, ""), + ["unity"] = new FontAwesomeIconEntry(448, 512, ""), + ["unsplash"] = new FontAwesomeIconEntry(448, 512, ""), + ["untappd"] = new FontAwesomeIconEntry(640, 512, ""), + ["ups"] = new FontAwesomeIconEntry(384, 512, ""), + ["upwork"] = new FontAwesomeIconEntry(641, 512, ""), + ["usb"] = new FontAwesomeIconEntry(640, 512, ""), + ["usps"] = new FontAwesomeIconEntry(576, 512, ""), + ["ussunnah"] = new FontAwesomeIconEntry(482, 512, ""), + ["vaadin"] = new FontAwesomeIconEntry(448, 512, ""), + ["viacoin"] = new FontAwesomeIconEntry(384, 512, ""), + ["viadeo"] = new FontAwesomeIconEntry(448, 512, ""), + ["viber"] = new FontAwesomeIconEntry(512, 512, ""), + ["vimeo"] = new FontAwesomeIconEntry(448, 512, ""), + ["vimeo-v"] = new FontAwesomeIconEntry(448, 512, ""), + ["vine"] = new FontAwesomeIconEntry(384, 512, ""), + ["vk"] = new FontAwesomeIconEntry(448, 512, ""), + ["vnv"] = new FontAwesomeIconEntry(640, 512, ""), + ["vuejs"] = new FontAwesomeIconEntry(448, 512, ""), + ["watchman-monitoring"] = new FontAwesomeIconEntry(512, 512, ""), + ["waze"] = new FontAwesomeIconEntry(512, 512, ""), + ["web-awesome"] = new FontAwesomeIconEntry(640, 512, ""), + ["webflow"] = new FontAwesomeIconEntry(640, 512, ""), + ["weebly"] = new FontAwesomeIconEntry(512, 512, ""), + ["weibo"] = new FontAwesomeIconEntry(512, 512, ""), + ["weixin"] = new FontAwesomeIconEntry(576, 512, ""), + ["whatsapp"] = new FontAwesomeIconEntry(448, 512, ""), + ["whmcs"] = new FontAwesomeIconEntry(448, 512, ""), + ["wikipedia-w"] = new FontAwesomeIconEntry(640, 512, ""), + ["windows"] = new FontAwesomeIconEntry(448, 512, ""), + ["wirsindhandwerk"] = new FontAwesomeIconEntry(512, 512, ""), + ["wix"] = new FontAwesomeIconEntry(640, 512, ""), + ["wizards-of-the-coast"] = new FontAwesomeIconEntry(640, 512, ""), + ["wodu"] = new FontAwesomeIconEntry(640, 512, ""), + ["wolf-pack-battalion"] = new FontAwesomeIconEntry(512, 512, ""), + ["wordpress"] = new FontAwesomeIconEntry(512, 512, ""), + ["wordpress-simple"] = new FontAwesomeIconEntry(512, 512, ""), + ["wpbeginner"] = new FontAwesomeIconEntry(512, 512, ""), + ["wpexplorer"] = new FontAwesomeIconEntry(512, 512, ""), + ["wpforms"] = new FontAwesomeIconEntry(448, 512, ""), + ["wpressr"] = new FontAwesomeIconEntry(496, 512, ""), + ["xbox"] = new FontAwesomeIconEntry(512, 512, ""), + ["xing"] = new FontAwesomeIconEntry(384, 512, ""), + ["x-twitter"] = new FontAwesomeIconEntry(512, 512, ""), + ["yahoo"] = new FontAwesomeIconEntry(512, 512, ""), + ["yammer"] = new FontAwesomeIconEntry(512, 512, ""), + ["yandex"] = new FontAwesomeIconEntry(256, 512, ""), + ["yandex-international"] = new FontAwesomeIconEntry(320, 512, ""), + ["yarn"] = new FontAwesomeIconEntry(496, 512, ""), + ["y-combinator"] = new FontAwesomeIconEntry(448, 512, ""), + ["yelp"] = new FontAwesomeIconEntry(384, 512, ""), + ["yoast"] = new FontAwesomeIconEntry(448, 512, ""), + ["youtube"] = new FontAwesomeIconEntry(576, 512, ""), + ["zhihu"] = new FontAwesomeIconEntry(640, 512, "") + }; + + /// + /// Retrieves the icon entry for the specified icon name and variant. + /// + public static FontAwesomeIconEntry? GetIcon(string name, FontAwesomeIconVariant variant) + { + var dictionary = variant switch + { + FontAwesomeIconVariant.Solid => SolidIcons, + FontAwesomeIconVariant.Regular => RegularIcons, + FontAwesomeIconVariant.Brands => BrandsIcons, + _ => SolidIcons + }; + + return dictionary.TryGetValue(name, out var entry) ? entry : null; + } + + /// + /// Gets all available icon names for a specific variant. + /// + public static IEnumerable GetAvailableIcons(FontAwesomeIconVariant variant) + { + return variant switch + { + FontAwesomeIconVariant.Solid => SolidIcons.Keys, + FontAwesomeIconVariant.Regular => RegularIcons.Keys, + FontAwesomeIconVariant.Brands => BrandsIcons.Keys, + _ => SolidIcons.Keys + }; + } + + /// + /// Checks whether an icon with the specified name exists in the given variant. + /// + public static bool IconExists(string name, FontAwesomeIconVariant variant) + { + return variant switch + { + FontAwesomeIconVariant.Solid => SolidIcons.ContainsKey(name), + FontAwesomeIconVariant.Regular => RegularIcons.ContainsKey(name), + FontAwesomeIconVariant.Brands => BrandsIcons.ContainsKey(name), + _ => SolidIcons.ContainsKey(name) + }; + } + + /// + /// Gets the total number of available icons across all variants. + /// + public static int TotalIconCount => SolidIcons.Count + RegularIcons.Count + BrandsIcons.Count; + + /// + /// Gets the number of Solid icons. + /// + public static int SolidIconCount => SolidIcons.Count; + + /// + /// Gets the number of Regular icons. + /// + public static int RegularIconCount => RegularIcons.Count; + + /// + /// Gets the number of Brands icons. + /// + public static int BrandsIconCount => BrandsIcons.Count; +} diff --git a/src/BlazorBlueprint.Icons.FontAwesome/GenerateIconData.ps1 b/src/BlazorBlueprint.Icons.FontAwesome/GenerateIconData.ps1 new file mode 100644 index 000000000..954580025 --- /dev/null +++ b/src/BlazorBlueprint.Icons.FontAwesome/GenerateIconData.ps1 @@ -0,0 +1,198 @@ +# PowerShell script to convert Iconify Font Awesome 6 JSON sets to C# dictionary code. +# Font Awesome Free has 3 variants distributed as separate Iconify icon sets: +# - fa6-solid.json -> FontAwesomeIconVariant.Solid +# - fa6-regular.json -> FontAwesomeIconVariant.Regular +# - fa6-brands.json -> FontAwesomeIconVariant.Brands +# +# Drop the three JSON files (from the @iconify-json/fa6-* npm packages, or +# https://github.com/iconify/icon-sets/tree/master/json) into +# tools/icon-generation/data/ before running this script. + +$dataRoot = Join-Path $PSScriptRoot "..\..\tools\icon-generation\data" +$outputPath = Join-Path $PSScriptRoot "Data\FontAwesomeIconData.cs" + +$variantFiles = [ordered]@{ + "Solid" = "fa6-solid.json" + "Regular" = "fa6-regular.json" + "Brands" = "fa6-brands.json" +} + +# Load each Iconify set and collect (name -> entry { width, height, body }). +function Read-IconifySet { + param([string]$jsonPath) + + if (!(Test-Path $jsonPath)) { + Write-Warning "Missing icon set: $jsonPath - this variant will be emitted as empty." + return @{ Icons = @{}; Count = 0 } + } + + $json = Get-Content -Path $jsonPath -Raw | ConvertFrom-Json + + # Iconify JSON exposes default dimensions at the top level; individual icons + # can override either with their own width/height fields. + $defaultWidth = if ($json.width) { [int]$json.width } else { 512 } + $defaultHeight = if ($json.height) { [int]$json.height } else { 512 } + + $icons = @{} + foreach ($prop in $json.icons.PSObject.Properties) { + $name = $prop.Name + $icon = $prop.Value + + $w = if ($icon.PSObject.Properties.Name -contains 'width' -and $icon.width) { [int]$icon.width } else { $defaultWidth } + $h = if ($icon.PSObject.Properties.Name -contains 'height' -and $icon.height) { [int]$icon.height } else { $defaultHeight } + + $icons[$name] = [pscustomobject]@{ + Width = $w + Height = $h + Body = $icon.body + } + } + + return @{ Icons = $icons; Count = $icons.Count } +} + +# Ensure the Data directory exists. +$dataDir = Join-Path $PSScriptRoot "Data" +if (!(Test-Path $dataDir)) { + New-Item -ItemType Directory -Path $dataDir | Out-Null +} + +# Read all three sets. +$variantData = [ordered]@{} +foreach ($variant in $variantFiles.Keys) { + $jsonPath = Join-Path $dataRoot $variantFiles[$variant] + Write-Host "Reading $variant from $jsonPath..." + $variantData[$variant] = Read-IconifySet -jsonPath $jsonPath + Write-Host " -> $($variantData[$variant].Count) icons" +} + +$totalCount = 0 +foreach ($v in $variantData.Values) { $totalCount += $v.Count } + +# Emit one dictionary block per variant. +function Write-IconDictionary { + param( + [System.Text.StringBuilder]$sb, + [hashtable]$icons, + [string]$indent + ) + + $sortedIcons = $icons.GetEnumerator() | Sort-Object Name + $count = $sortedIcons.Count + $i = 0 + + foreach ($entry in $sortedIcons) { + $iconName = $entry.Name + $icon = $entry.Value + + # Escape backslashes and double quotes for C# verbatim-free string literals. + $escapedBody = $icon.Body -replace '\\', '\\' -replace '"', '\"' + + $comma = if ($i -eq ($count - 1)) { "" } else { "," } + [void]$sb.AppendLine("$indent[`"$iconName`"] = new FontAwesomeIconEntry($($icon.Width), $($icon.Height), `"$escapedBody`")$comma") + $i++ + } +} + +$sb = New-Object System.Text.StringBuilder +[void]$sb.AppendLine("// This file is auto-generated. Do not edit manually.") +[void]$sb.AppendLine("// Generated from fa6-solid.json, fa6-regular.json, fa6-brands.json on $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") +[void]$sb.AppendLine("") +[void]$sb.AppendLine("namespace BlazorBlueprint.Icons.FontAwesome.Data;") +[void]$sb.AppendLine("") +[void]$sb.AppendLine("/// ") +[void]$sb.AppendLine("/// Icon variant for Font Awesome Free.") +[void]$sb.AppendLine("/// ") +[void]$sb.AppendLine("public enum FontAwesomeIconVariant") +[void]$sb.AppendLine("{") +[void]$sb.AppendLine(" /// Solid variant (filled glyphs, the most common Font Awesome style)") +[void]$sb.AppendLine(" Solid,") +[void]$sb.AppendLine("") +[void]$sb.AppendLine(" /// Regular variant (outline glyphs, fewer icons available in the Free tier)") +[void]$sb.AppendLine(" Regular,") +[void]$sb.AppendLine("") +[void]$sb.AppendLine(" /// Brands variant (logos for third-party services and products)") +[void]$sb.AppendLine(" Brands") +[void]$sb.AppendLine("}") +[void]$sb.AppendLine("") +[void]$sb.AppendLine("/// ") +[void]$sb.AppendLine("/// A single Font Awesome icon entry: SVG body plus intrinsic dimensions used to build the viewBox.") +[void]$sb.AppendLine("/// ") +[void]$sb.AppendLine("public sealed record FontAwesomeIconEntry(int Width, int Height, string Body);") +[void]$sb.AppendLine("") +[void]$sb.AppendLine("/// ") +[void]$sb.AppendLine("/// Provides access to Font Awesome Free SVG data.") +[void]$sb.AppendLine("/// Contains $totalCount total icons across 3 variants.") +[void]$sb.AppendLine("/// ") +[void]$sb.AppendLine("public static class FontAwesomeIconData") +[void]$sb.AppendLine("{") + +foreach ($variant in $variantData.Keys) { + $fieldName = "${variant}Icons" + [void]$sb.AppendLine(" private static readonly IReadOnlyDictionary $fieldName = new Dictionary(StringComparer.OrdinalIgnoreCase)") + [void]$sb.AppendLine(" {") + Write-IconDictionary -sb $sb -icons $variantData[$variant].Icons -indent " " + [void]$sb.AppendLine(" };") + [void]$sb.AppendLine("") +} + +[void]$sb.AppendLine(" /// ") +[void]$sb.AppendLine(" /// Retrieves the icon entry for the specified icon name and variant.") +[void]$sb.AppendLine(" /// ") +[void]$sb.AppendLine(" public static FontAwesomeIconEntry? GetIcon(string name, FontAwesomeIconVariant variant)") +[void]$sb.AppendLine(" {") +[void]$sb.AppendLine(" var dictionary = variant switch") +[void]$sb.AppendLine(" {") +[void]$sb.AppendLine(" FontAwesomeIconVariant.Solid => SolidIcons,") +[void]$sb.AppendLine(" FontAwesomeIconVariant.Regular => RegularIcons,") +[void]$sb.AppendLine(" FontAwesomeIconVariant.Brands => BrandsIcons,") +[void]$sb.AppendLine(" _ => SolidIcons") +[void]$sb.AppendLine(" };") +[void]$sb.AppendLine("") +[void]$sb.AppendLine(" return dictionary.TryGetValue(name, out var entry) ? entry : null;") +[void]$sb.AppendLine(" }") +[void]$sb.AppendLine("") +[void]$sb.AppendLine(" /// Gets all available icon names for a specific variant.") +[void]$sb.AppendLine(" public static IEnumerable GetAvailableIcons(FontAwesomeIconVariant variant)") +[void]$sb.AppendLine(" {") +[void]$sb.AppendLine(" return variant switch") +[void]$sb.AppendLine(" {") +[void]$sb.AppendLine(" FontAwesomeIconVariant.Solid => SolidIcons.Keys,") +[void]$sb.AppendLine(" FontAwesomeIconVariant.Regular => RegularIcons.Keys,") +[void]$sb.AppendLine(" FontAwesomeIconVariant.Brands => BrandsIcons.Keys,") +[void]$sb.AppendLine(" _ => SolidIcons.Keys") +[void]$sb.AppendLine(" };") +[void]$sb.AppendLine(" }") +[void]$sb.AppendLine("") +[void]$sb.AppendLine(" /// Checks whether an icon with the specified name exists in the given variant.") +[void]$sb.AppendLine(" public static bool IconExists(string name, FontAwesomeIconVariant variant)") +[void]$sb.AppendLine(" {") +[void]$sb.AppendLine(" return variant switch") +[void]$sb.AppendLine(" {") +[void]$sb.AppendLine(" FontAwesomeIconVariant.Solid => SolidIcons.ContainsKey(name),") +[void]$sb.AppendLine(" FontAwesomeIconVariant.Regular => RegularIcons.ContainsKey(name),") +[void]$sb.AppendLine(" FontAwesomeIconVariant.Brands => BrandsIcons.ContainsKey(name),") +[void]$sb.AppendLine(" _ => SolidIcons.ContainsKey(name)") +[void]$sb.AppendLine(" };") +[void]$sb.AppendLine(" }") +[void]$sb.AppendLine("") +[void]$sb.AppendLine(" /// Gets the total number of available icons across all variants.") +[void]$sb.AppendLine(" public static int TotalIconCount => SolidIcons.Count + RegularIcons.Count + BrandsIcons.Count;") +[void]$sb.AppendLine("") +[void]$sb.AppendLine(" /// Gets the number of Solid icons.") +[void]$sb.AppendLine(" public static int SolidIconCount => SolidIcons.Count;") +[void]$sb.AppendLine("") +[void]$sb.AppendLine(" /// Gets the number of Regular icons.") +[void]$sb.AppendLine(" public static int RegularIconCount => RegularIcons.Count;") +[void]$sb.AppendLine("") +[void]$sb.AppendLine(" /// Gets the number of Brands icons.") +[void]$sb.AppendLine(" public static int BrandsIconCount => BrandsIcons.Count;") +[void]$sb.AppendLine("}") + +$sb.ToString() | Out-File -FilePath $outputPath -Encoding UTF8 +Write-Host "" +Write-Host "Generated C# file: $outputPath" +Write-Host "Total icons: $totalCount" +foreach ($variant in $variantData.Keys) { + Write-Host " $variant`: $($variantData[$variant].Count)" +} diff --git a/src/BlazorBlueprint.Icons.FontAwesome/README.md b/src/BlazorBlueprint.Icons.FontAwesome/README.md new file mode 100644 index 000000000..1158b9d28 --- /dev/null +++ b/src/BlazorBlueprint.Icons.FontAwesome/README.md @@ -0,0 +1,293 @@ +# BlazorBlueprint.Icons.FontAwesome + +A comprehensive Font Awesome Free icon library for Blazor applications, providing 2,066 icons across 3 variants (Solid, Regular, Brands). + +> Only Font Awesome **Free** is supported. Font Awesome Pro requires a commercial license and cannot be redistributed via NuGet. + +## Features + +- **2,066 Icons**: Full Font Awesome 6 Free icon set across 3 variants +- **3 Variants**: Solid (filled), Regular (outline), Brands (third-party logos) +- **Aspect-Ratio Aware**: Per-icon `viewBox` is preserved, so non-square Brands icons (e.g. `github`, `twitter`) render at their correct proportions +- **React-Style API**: Familiar component-based API +- **Includes ARIA Attributes**: Customizable `aria-label` for accessibility +- **Tree-Shakeable**: Blazor assembly trimming removes unused icons at publish time +- **Type-Safe**: Full XML documentation and IntelliSense support +- **Themeable**: Icons inherit color from parent by default, supports CSS variables +- **Lightweight**: Static dictionary lookup with minimal overhead + +## Installation + +```bash +dotnet add package BlazorBlueprint.Icons.FontAwesome +``` + +## Basic Usage + +### Import the Namespace + +Add to `_Imports.razor`: + +```razor +@using BlazorBlueprint.Icons.FontAwesome.Components +@using BlazorBlueprint.Icons.FontAwesome.Data +``` + +### Render an Icon + +```razor +@* Default variant (Solid) *@ + +``` + +### Use Different Variants + +```razor +@* Solid variant — the largest set, filled glyphs (default) *@ + + +@* Regular variant — outline alternative (small curated subset in the Free tier) *@ + + +@* Brands variant — third-party logos *@ + +``` + +### Customize Size and Color + +```razor + +``` + +### Use with CSS Variables (Theming) + +```razor + +``` + +### Icon-Only Button (with Accessibility) + +```razor + +``` + +### Integration with BlazorBlueprint Button Component + +```razor + + + + + Download + +``` + +## Component API + +### Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `Name` | `string` | **(Required)** | Icon name (e.g., "camera", "house", "github"). Case-insensitive, kebab-case. | +| `Variant` | `FontAwesomeIconVariant` | `Solid` | Icon variant: Solid, Regular, or Brands | +| `Size` | `int?` | `16` | Icon width in pixels. Height is scaled proportionally to preserve aspect ratio. | +| `Color` | `string` | `"currentColor"` | Icon color (any CSS color value, inherits from parent by default) | +| `Class` | `string?` | `null` | Additional CSS classes | +| `AriaLabel` | `string?` | `null` | Accessibility label for screen readers | +| `AdditionalAttributes` | `Dictionary?` | `null` | Any additional SVG attributes | + +### Icon Variants + +```csharp +public enum FontAwesomeIconVariant +{ + Solid, // Filled glyphs — the largest set and the default + Regular, // Outline glyphs — small curated subset in the Free tier + Brands // Third-party logos (GitHub, Microsoft, Apple, etc.) +} +``` + +### Examples + +**Basic Icon (Solid):** +```razor + +``` + +**Brands Icon:** +```razor + +``` + +**Regular Icon with Custom Color:** +```razor + +``` + +**Custom Size:** +```razor + +``` + +**Icon with Custom CSS Classes:** +```razor + +``` + +**Accessible Icon-Only Button:** +```razor + +``` + +**Icon with Data Attributes:** +```razor + +``` + +## Icon Names + +All Font Awesome Free icons are available, with names matching the official Font Awesome naming (kebab-case). Common examples: + +- `house`, `user`, `gear`, `magnifying-glass` +- `arrow-left`, `arrow-right`, `arrow-up`, `arrow-down` +- `circle-check`, `circle-xmark`, `circle-exclamation` +- `heart`, `star`, `bell`, `bookmark` +- `github`, `microsoft`, `apple`, `google` (Brands) +- ... and 2,000+ more + +**Browse all icons:** [fontawesome.com/icons](https://fontawesome.com/icons) + +## Variant Guidelines + +### Solid (Default) +- **Style**: Filled paths +- **Coverage**: 1,400+ icons — the largest set in Free +- **Use case**: Primary UI, navigation, emphasis, the default for most applications + +### Regular +- **Style**: Outline / stroke-style paths +- **Coverage**: 160+ icons — a small curated subset; the Free tier ships far fewer Regular icons than Solid +- **Use case**: When you want a lighter visual weight than Solid + +### Brands +- **Style**: Filled logos at the artist-specified aspect ratio +- **Coverage**: 480+ third-party brand and product logos +- **Use case**: Social links, technology logos, payment provider icons +- **Note**: Brands icons are **not all square** — width and height vary per icon. The component preserves each icon's intrinsic viewBox and scales height accordingly. + +## Styling + +### Default Behavior + +Icons inherit `color` from their parent element by default: + +```razor +
    + +
    +``` + +### Explicit Color + +Override the inherited color: + +```razor + +``` + +### CSS Variables (Theming) + +Perfect for theme systems: + +```razor + + +``` + +### Tailwind CSS + +Use Tailwind utility classes: + +```razor + +``` + +## Accessibility + +### Decorative Icons (Next to Text) + +Icons next to text are decorative and don't need labels: + +```razor + +``` + +### Semantic Icons (Icon-Only) + +Icon-only elements require `AriaLabel`: + +```razor + +``` + +## Performance + +- **Bundle Size**: ~580 KB for the complete icon set across all 3 variants (before compression) +- **Brotli Compression**: Reduces size by ~70% in production +- **Assembly Trimming**: Unused icons automatically removed at publish time +- **Static Dictionary**: O(1) icon lookup with minimal memory overhead + +## Browser Support + +Works in all modern browsers that support: +- Blazor Server / WebAssembly / Hybrid +- SVG rendering +- CSS `currentColor` + +## Regenerating Icon Data + +The `Data/FontAwesomeIconData.cs` file is auto-generated from the Iconify JSON sets for Font Awesome 6 Free. To refresh: + +1. Download the latest sets from the `@iconify-json/fa6-*` npm packages, or [iconify/icon-sets](https://github.com/iconify/icon-sets/tree/master/json): + - `fa6-solid.json` + - `fa6-regular.json` + - `fa6-brands.json` +2. Place them in `tools/icon-generation/data/`. +3. Run: + +```powershell +./GenerateIconData.ps1 +``` + +## License + +The C# wrapper code is MIT licensed. + +Font Awesome Free icon artwork is licensed under the [Font Awesome Free License](https://fontawesome.com/license/free): +- Icons: CC BY 4.0 +- Fonts: SIL OFL 1.1 +- Code: MIT + +## Links + +- **Font Awesome**: [fontawesome.com](https://fontawesome.com/) +- **Icon Browser**: [fontawesome.com/icons](https://fontawesome.com/icons) +- **BlazorBlueprint**: [GitHub Repository](https://github.com/blazorblueprintui/ui) +- **Issues**: [Report a Bug](https://github.com/blazorblueprintui/ui/issues) + +## Contributing + +Contributions are welcome! Please open an issue or pull request on GitHub. + +--- + +Made with ❤️ by the BlazorBlueprint team diff --git a/src/BlazorBlueprint.Primitives/Primitives/Collapsible/BbCollapsibleTrigger.razor b/src/BlazorBlueprint.Primitives/Primitives/Collapsible/BbCollapsibleTrigger.razor index dd221fd74..80f815076 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Collapsible/BbCollapsibleTrigger.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Collapsible/BbCollapsibleTrigger.razor @@ -10,12 +10,14 @@ } else { + @* A native diff --git a/src/BlazorBlueprint.Primitives/Primitives/Collapsible/BbCollapsibleTrigger.razor.cs b/src/BlazorBlueprint.Primitives/Primitives/Collapsible/BbCollapsibleTrigger.razor.cs index c339f6fcd..eff3f160b 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Collapsible/BbCollapsibleTrigger.razor.cs +++ b/src/BlazorBlueprint.Primitives/Primitives/Collapsible/BbCollapsibleTrigger.razor.cs @@ -116,27 +116,7 @@ private async Task HandleClick(MouseEventArgs args) } } - /// - /// Handles keyboard events to support keyboard navigation (Space/Enter keys). - /// - /// The keyboard event arguments. - /// A task that represents the asynchronous operation. - /// - /// Responds to Space and Enter keys for keyboard interaction. - /// - private async Task HandleKeyDown(KeyboardEventArgs args) - { - if (Context?.Disabled ?? true) - { - return; - } - - if (args.Key == " " || args.Key == "Enter") - { - if (Context?.Toggle != null) - { - await Context.Toggle.Invoke(); - } - } - } + // Note: no keydown handler. The rendered element is a native
    public int FocusedIndex { get; set; } = -1; + + /// + /// Gets or sets whether focus should be returned to the trigger element when the + /// menu closes. Set to true by intentional close paths (item activation, + /// Escape) and left false for external dismissals (click-outside) where + /// focus is already where the user wants it. + /// + public bool RestoreFocusOnClose { get; set; } } /// @@ -76,18 +84,26 @@ public void Open(ElementReference? triggerElement = null) state.IsOpen = true; state.TriggerElement = triggerElement; state.FocusedIndex = -1; // Reset focus on open + state.RestoreFocusOnClose = false; // Cleared so the next Close() decides afresh }); } /// /// Closes the dropdown menu. /// - public void Close() + /// + /// When true, signals that focus should be returned to the trigger element + /// after the content tears down. Pass true for intentional close paths + /// (Escape, item activation) and leave false for external dismissals + /// (click-outside) where focus is already where the user wants it. + /// + public void Close(bool restoreFocus = false) { UpdateState(state => { state.IsOpen = false; state.FocusedIndex = -1; + state.RestoreFocusOnClose = restoreFocus; }); } diff --git a/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopover.razor b/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopover.razor index a606b3755..ed0f290b6 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopover.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopover.razor @@ -51,6 +51,16 @@ [Parameter] public bool Modal { get; set; } = true; + /// + /// When the popover is closed via the controlled binding (i.e. the consumer + /// set it to false — e.g. after selecting an item), whether to return focus to the trigger so + /// keyboard navigation (Tab) continues from the right place. Defaults to false to preserve + /// existing behavior. Dismissals that originate inside the popover (click-outside, Escape) are + /// unaffected — they already manage focus themselves. + /// + [Parameter] + public bool RestoreFocusOnClose { get; set; } + protected override void OnInitialized() { // Initialize controllable state @@ -92,7 +102,10 @@ } else { - _context.Close(); + // Parent-initiated close (consumer set Open=false). Honour the consumer's + // focus-restore intent — click-outside/Escape never reach here (they update + // the context first, so this runs only when the parent drives the close). + _context.Close(RestoreFocusOnClose); } _state.ControlledValue = Open.Value; diff --git a/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopoverContent.razor b/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopoverContent.razor index e3bfccf86..f0743ae74 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopoverContent.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopoverContent.razor @@ -3,6 +3,7 @@ @using BlazorBlueprint.Primitives.Floating @using BlazorBlueprint.Primitives.Services @inject IJSRuntime JSRuntime +@inject IFocusManager FocusManager @implements IAsyncDisposable @* @@ -255,8 +256,21 @@ { if (!Context.IsOpen) { + // Capture both the trigger ref and the restore flag before teardown. + var trigger = Context.State.TriggerElement; + var shouldRestoreFocus = Context.State.RestoreFocusOnClose; + await CleanupAsync(); StateHasChanged(); + + // Restore focus to the trigger AFTER cleanup so the now-unmounted popover + // isn't the active element when the user next presses Tab. Only restore + // for intentional closes (Escape) — click-outside leaves focus where the + // user clicked. + if (shouldRestoreFocus && trigger.HasValue) + { + await FocusManager.RestoreFocus(trigger); + } } else { @@ -282,7 +296,8 @@ // Close popover if configured if (CloseOnEscape) { - Context.Close(); + // Escape is an intentional dismiss — return focus to the trigger. + Context.Close(restoreFocus: true); } } } diff --git a/src/BlazorBlueprint.Primitives/Primitives/Popover/PopoverContext.cs b/src/BlazorBlueprint.Primitives/Primitives/Popover/PopoverContext.cs index e1bd2c86a..4f5054609 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Popover/PopoverContext.cs +++ b/src/BlazorBlueprint.Primitives/Primitives/Popover/PopoverContext.cs @@ -18,6 +18,14 @@ public class PopoverState /// Used for positioning and focus management. /// public ElementReference? TriggerElement { get; set; } + + /// + /// Gets or sets whether focus should be returned to the trigger element when the + /// popover closes. Set to true by intentional close paths (Escape) and left + /// false for external dismissals (click-outside) where focus is already + /// where the user wants it. + /// + public bool RestoreFocusOnClose { get; set; } } /// @@ -58,14 +66,25 @@ public void Open(ElementReference? triggerElement = null) { state.IsOpen = true; state.TriggerElement = triggerElement; + state.RestoreFocusOnClose = false; // Cleared so the next Close() decides afresh }); } /// /// Closes the popover. /// - public void Close() => - UpdateState(state => state.IsOpen = false); + /// + /// When true, signals that focus should be returned to the trigger element + /// after the content tears down. Pass true for intentional close paths + /// (Escape) and leave false for external dismissals (click-outside) where + /// focus is already where the user wants it. + /// + public void Close(bool restoreFocus = false) => + UpdateState(state => + { + state.IsOpen = false; + state.RestoreFocusOnClose = restoreFocus; + }); /// /// Toggles the popover open/closed state. diff --git a/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectContent.razor b/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectContent.razor index ac1265021..8928defd5 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectContent.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectContent.razor @@ -5,6 +5,7 @@ @using BlazorBlueprint.Primitives.Services @using Microsoft.JSInterop @inject IJSRuntime JS +@inject IFocusManager FocusManager @implements IAsyncDisposable @* @@ -212,8 +213,23 @@ // When context closes, clean up if (_context?.IsOpen == false && _isKeyboardSetup) { + // Capture both the trigger ref and the restore flag before teardown. + // State.TriggerElement is still populated at this point (Close() doesn't + // clear it), but we read it eagerly in case anything else mutates state. + var trigger = _context.State.TriggerElement; + var shouldRestoreFocus = _context.State.RestoreFocusOnClose; + await CleanupAsync(); StateHasChanged(); + + // Restore focus to the trigger AFTER cleanup so the now-unmounted listbox + // isn't the active element when the user next presses Tab. Only restore + // for intentional closes (Escape, selection) — click-outside and Tab + // leave focus where the user intentionally moved it. + if (shouldRestoreFocus && trigger.HasValue) + { + await FocusManager.RestoreFocus(trigger); + } } } catch (Exception ex) when (ex is ObjectDisposedException or TaskCanceledException) @@ -286,7 +302,8 @@ public void JsOnEscapeKey() { if (_disposed) { return; } - _context?.Close(); + // Escape is an intentional dismiss — return focus to the trigger. + _context?.Close(restoreFocus: true); } /// diff --git a/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectTrigger.razor b/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectTrigger.razor index c7ee306ad..7e3ab0c7c 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectTrigger.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectTrigger.razor @@ -103,7 +103,8 @@ _shouldPreventDefault = true; if (_context.IsOpen) { - _context.Close(); + // Escape is an intentional dismiss — return focus to the trigger. + _context.Close(restoreFocus: true); } break; default: diff --git a/src/BlazorBlueprint.Primitives/Primitives/Select/SelectContext.cs b/src/BlazorBlueprint.Primitives/Primitives/Select/SelectContext.cs index 4c422e7d5..caa6cd960 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Select/SelectContext.cs +++ b/src/BlazorBlueprint.Primitives/Primitives/Select/SelectContext.cs @@ -62,6 +62,14 @@ public class SelectState /// Gets or sets whether the select is required. /// public bool Required { get; set; } + + /// + /// Gets or sets whether focus should be returned to the trigger element when the + /// dropdown closes. Set to true by intentional close paths (item selection, + /// Escape) and left false for external dismissals (click-outside, Tab) where + /// focus is already on whatever the user moved to. + /// + public bool RestoreFocusOnClose { get; set; } } /// @@ -172,18 +180,26 @@ public void Open(ElementReference? triggerElement = null) state.IsOpen = true; state.TriggerElement = triggerElement; state.FocusedIndex = -1; // Reset focus on open + state.RestoreFocusOnClose = false; // Cleared so the next Close() decides afresh }); } /// /// Closes the select dropdown. /// - public void Close() + /// + /// When true, signals that focus should be returned to the trigger element + /// after the content tears down. Pass true for intentional close paths + /// (Escape, keyboard activation) and leave false for external dismissals + /// (click-outside, Tab) where focus is already where the user wants it. + /// + public void Close(bool restoreFocus = false) { UpdateState(state => { state.IsOpen = false; state.FocusedIndex = -1; + state.RestoreFocusOnClose = restoreFocus; }); } @@ -221,6 +237,9 @@ public void SelectValue(TValue? value, string? displayText) state.DisplayText = displayText; state.IsOpen = false; // Close after selection state.FocusedIndex = -1; + // Selection is an intentional close — return focus to the trigger so + // keyboard navigation (Tab) continues from the right place. + state.RestoreFocusOnClose = true; }); // Invoke value change callback diff --git a/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md b/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md index d6a3024f6..994e2dd47 100644 --- a/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md +++ b/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md @@ -1,5 +1,13 @@ -## What's New in v3.10.2 +## What's New in v3.11.0 ### New Features +- **BbDataView** — new `DataViewItemsProvider` delegate (with `DataViewRequest`/`DataViewResult`) for asynchronous server-side data fetching driven by pagination, sort, and search state. +- **BbPopover** — new `RestoreFocusOnClose` parameter that returns focus to the trigger when the popover is closed via the controlled `Open` binding. -- **FilterOperatorHelper** — `GetOperatorLabel` and `GetOperatorOptions` now accept an optional key resolver, allowing operator labels to be localized without the Primitives layer depending on the Components localizer +### Bug Fixes +- **Collapsible / DropdownMenu** — Space/Enter on the trigger no longer double-toggles (opening then closing in a single press). +- **Body scroll lock** — now reference-counted so nested/stacked overlays correctly restore page scroll once the last overlay closes. +- **Sortable** — corrected the library filename casing so it loads on case-sensitive filesystems. + +### Improvements +- **Select / Popover / DropdownMenu** — focus returns to the trigger on intentional close (Escape, item selection) so keyboard Tab navigation continues from the right place; click-outside dismissals leave focus where the user clicked. diff --git a/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/portal.js b/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/portal.js index 338dfc3aa..625de1987 100644 --- a/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/portal.js +++ b/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/portal.js @@ -87,26 +87,59 @@ export function getComputedZIndex(element) { return isNaN(zIndex) ? 0 : zIndex; } +// ============================================================================ +// Body scroll lock (reference counted) +// Stacked/nested overlays (e.g. a Dialog opening an AlertDialog) each acquire a +// lock. We must only restore the body's original scroll state once the LAST lock +// is released — otherwise a nested overlay's cleanup, capturing the already-locked +// "hidden" state, would clobber the outer overlay's restore and leave the page +// permanently frozen regardless of disposal order. +// ============================================================================ + +let scrollLockCount = 0; +let savedScrollState = null; + /** - * Locks body scroll (useful for modals). - * @returns {Object} Object with cleanup method to restore scroll + * Locks body scroll (useful for modals). Reference counted so nested overlays + * share a single underlying lock. + * @returns {Object} Object with an apply() method that releases this lock */ export function lockBodyScroll() { - const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; - const originalOverflow = document.body.style.overflow; - const originalPaddingRight = document.body.style.paddingRight; + if (scrollLockCount === 0) { + // First lock: capture the true original state before mutating it. + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + savedScrollState = { + overflow: document.body.style.overflow, + paddingRight: document.body.style.paddingRight + }; + + document.body.style.overflow = 'hidden'; + + // Prevent layout shift by adding padding for scrollbar + if (scrollbarWidth > 0) { + document.body.style.paddingRight = `${scrollbarWidth}px`; + } + } - document.body.style.overflow = 'hidden'; + scrollLockCount++; - // Prevent layout shift by adding padding for scrollbar - if (scrollbarWidth > 0) { - document.body.style.paddingRight = `${scrollbarWidth}px`; - } + // Guard against this handle being released more than once (e.g. close + dispose). + let released = false; // Return cleanup function wrapped in object for C# interop const cleanup = () => { - document.body.style.overflow = originalOverflow; - document.body.style.paddingRight = originalPaddingRight; + if (released) { + return; + } + released = true; + scrollLockCount = Math.max(0, scrollLockCount - 1); + + // Only restore once every lock has been released. + if (scrollLockCount === 0 && savedScrollState) { + document.body.style.overflow = savedScrollState.overflow; + document.body.style.paddingRight = savedScrollState.paddingRight; + savedScrollState = null; + } }; return { diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 945f299e1..1aea11b98 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -453,6 +453,7 @@ - Text : String [EditorRequired] - Value : TValue [EditorRequired] - Parent : BbCombobox [CascadingParameter] + - RegistrationOnly : Boolean [CascadingParameter] ### BbCombobox`1 (BlazorBlueprint.Components) - ActiveClass : String @@ -472,6 +473,7 @@ - SearchPlaceholder : String - SearchQuery : String - SearchQueryChanged : EventCallback + - SelectedItemText : String - Value : TValue - ValueChanged : EventCallback - ValueExpression : Expression> @@ -553,7 +555,8 @@ - ItemSearchText : Func - ItemTemplate : RenderFragment [EditorRequired] - ItemValue : Func [EditorRequired] - - Items : IReadOnlyList [EditorRequired] + - Items : IReadOnlyList + - ItemsProvider : CommandItemsProvider - LazyLoadBatchSize : Int32 - MaxDisplayCount : Int32 - Context : CommandContext [CascadingParameter] @@ -777,6 +780,8 @@ - ParentGrid : BbDataGrid [CascadingParameter] ### BbDataGridSelectColumn`1 (BlazorBlueprint.Components) + - CellClass : String + - HeaderClass : String - Pinned : ColumnPinning - Width : String - ParentGrid : BbDataGrid [CascadingParameter] @@ -944,7 +949,7 @@ ### BbDataView`1 (BlazorBlueprint.Components) - Class : String - - Data : IEnumerable [EditorRequired] + - Data : IEnumerable - EmptyTemplate : RenderFragment - EnableInfiniteScroll : Boolean - Fields : RenderFragment @@ -953,6 +958,7 @@ - GridTemplate : RenderFragment - InitialPageSize : Int32 - IsLoading : Boolean + - ItemsProvider : DataViewItemsProvider - Layout : DataViewLayout - ListClass : String - ListTemplate : RenderFragment @@ -2405,6 +2411,7 @@ - OnOpenChange : EventCallback - Open : Boolean? - OpenChanged : EventCallback + - RestoreFocusOnClose : Boolean ### BbPopoverContent (BlazorBlueprint.Components) - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt index c23858b9b..b8ca6d795 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt @@ -459,6 +459,7 @@ - OnOpenChange : EventCallback - Open : Boolean? - OpenChanged : EventCallback + - RestoreFocusOnClose : Boolean ### BbPopoverContent (BlazorBlueprint.Primitives.Popover) - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] From f7f7b184368f564e12248220fe8a29e28683ed9b Mon Sep 17 00:00:00 2001 From: David Ball Date: Wed, 10 Jun 2026 22:33:52 +0100 Subject: [PATCH 079/188] Added pinned tab support and changed maximize icon. --- .../Components/Dock/contextmenu.txt | 22 ++++ .../Pages/Components/DockDemo.razor | 47 ++++++++ .../Components/Dock/BbDock.razor.cs | 111 ++++++++++++++++++ .../Components/Dock/BbDockTabGroup.razor | 56 ++++++--- .../Components/Dock/BbDockTabGroup.razor.cs | 15 ++- .../wwwroot/js/dock.js | 2 +- 6 files changed, 235 insertions(+), 18 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dock/contextmenu.txt diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dock/contextmenu.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dock/contextmenu.txt new file mode 100644 index 000000000..75e63141b --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dock/contextmenu.txt @@ -0,0 +1,22 @@ + +
    + + +
    Right-click this tab to open the context menu.
    +
    + +
    Pin a couple of tabs, then try dragging to reorder.
    +
    + +
    Counter page.
    +
    + +
    Weather page.
    +
    + +
    Fetch data page.
    +
    +
    +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DockDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DockDemo.razor index c44ab510a..8d918a665 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DockDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DockDemo.razor @@ -25,6 +25,7 @@
  • Drag a floating window by its tab strip; resize it from the corner.
  • Maximize a group with the button in the tab bar.
  • Close a tab with its ✕, then reopen it (see below).
  • +
  • Right-click a tab to close others, close all, or pin it to the front.
  • @@ -182,6 +183,52 @@ $ _ + +
    +
    +

    Tab Context Menu & Pinning

    +

    + Right-click any tab for a context menu with Close, + Close Other Tabs (enabled only when the group has more than one tab), + Close All Tabs and Pin Tab. Pinned tabs show a pin + marker and sit at the front of the strip in the order they were pinned. Pinned tabs can + only be reordered amongst themselves, and unpinned tabs cannot be dragged ahead of them. +

    +
    + +
    + + + + +
    +

    Right-click any tab above to open its context menu.

    +

    Pin a couple of tabs, then drag to reorder — pinned tabs stay grouped at the front.

    +
    +
    +
    + + +
    Shared layout.
    +
    + + +
    Counter page.
    +
    + + +
    Weather page.
    +
    + + +
    Fetch data page.
    +
    +
    +
    + + +
    +
    diff --git a/src/BlazorBlueprint.Components/Components/Dock/BbDock.razor.cs b/src/BlazorBlueprint.Components/Components/Dock/BbDock.razor.cs index 60216944d..0d5affcbd 100644 --- a/src/BlazorBlueprint.Components/Components/Dock/BbDock.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Dock/BbDock.razor.cs @@ -49,6 +49,7 @@ public partial class BbDock : ComponentBase, IAsyncDisposable private readonly Dictionary panels = new(); private readonly List registrationOrder = new(); private readonly HashSet closedPanels = new(); + private readonly HashSet pinnedPanels = new(); private readonly List floatingWindows = new(); private DockNode? layout; @@ -474,15 +475,73 @@ private void ReorderPanel(string panelId, DockTabGroupNode target, int index) target.PanelIds.Insert(insert, panelId); } + // Pinned tabs occupy the front of the strip: a pinned tab cannot be reordered + // behind an unpinned one, and an unpinned tab cannot jump ahead of a pinned one. + // The drop index above is honoured within whichever region the tab belongs to. + EnforcePinOrder(target); + target.ActivePanelId = panelId; } + // ----------------------------------------------------------------- Pinning + + internal bool IsPinned(string panelId) => pinnedPanels.Contains(panelId); + + /// + /// The number of pinned panels at the front of the given group's tab strip. + /// + private int PinnedCount(DockTabGroupNode group) => + group.PanelIds.Count(pinnedPanels.Contains); + + /// + /// Stable-partitions a group's tabs so pinned tabs sit at the front in pin order, + /// followed by unpinned tabs in their existing order. + /// + private void EnforcePinOrder(DockTabGroupNode group) + { + if (group.PanelIds.Count < 2) + { + return; + } + + var pinnedHere = group.PanelIds.Where(pinnedPanels.Contains).ToList(); + if (pinnedHere.Count == 0) + { + return; + } + + var unpinnedHere = group.PanelIds.Where(id => !pinnedPanels.Contains(id)); + group.PanelIds = pinnedHere.Concat(unpinnedHere).ToList(); + } + + /// + /// Toggles the pinned state of a panel. Pinning moves the tab to the back of the pinned + /// queue at the front of its group; unpinning returns it to the front of the unpinned tabs. + /// + internal async Task TogglePinAsync(string panelId) + { + var group = FindGroupContaining(panelId); + if (group is null) + { + return; + } + + if (!pinnedPanels.Remove(panelId)) + { + pinnedPanels.Add(panelId); + } + + EnforcePinOrder(group); + await AfterMutateAsync(); + } + private void AddPanelToDefaultLocation(string panelId) { var existing = AllGroups().FirstOrDefault(); if (existing is not null) { existing.PanelIds.Add(panelId); + EnforcePinOrder(existing); existing.ActivePanelId = panelId; } else @@ -552,6 +611,7 @@ public async Task OnTabDropped(string targetType, string? groupId, string zone, { RemovePanelFromLayout(panelId); target.PanelIds.Add(panelId); + EnforcePinOrder(target); } target.ActivePanelId = panelId; } @@ -607,6 +667,7 @@ internal async Task ClosePanelAsync(string panelId) } RemovePanelFromLayout(panelId); + pinnedPanels.Remove(panelId); closedPanels.Add(panelId); if (OnPanelClosed.HasDelegate) @@ -617,6 +678,56 @@ internal async Task ClosePanelAsync(string panelId) await AfterMutateAsync(); } + /// + /// Closes every closable panel that shares a tab group with the given panel. + /// + internal Task CloseAllPanelsInGroupAsync(string panelId) + { + var group = FindGroupContaining(panelId); + return group is null + ? Task.CompletedTask + : CloseManyAsync(group.PanelIds.ToList()); + } + + /// + /// Closes every closable panel in the given panel's tab group except the panel itself. + /// + internal Task CloseOtherPanelsInGroupAsync(string panelId) + { + var group = FindGroupContaining(panelId); + return group is null + ? Task.CompletedTask + : CloseManyAsync(group.PanelIds.Where(id => id != panelId).ToList()); + } + + private async Task CloseManyAsync(IReadOnlyList panelIds) + { + var closedAny = false; + + foreach (var id in panelIds) + { + if (!panels.TryGetValue(id, out var panel) || !panel.Closable) + { + continue; + } + + RemovePanelFromLayout(id); + pinnedPanels.Remove(id); + closedPanels.Add(id); + closedAny = true; + + if (OnPanelClosed.HasDelegate) + { + await OnPanelClosed.InvokeAsync(id); + } + } + + if (closedAny) + { + await AfterMutateAsync(); + } + } + /// /// Reopens a previously closed panel, docking it into a sensible default location. /// diff --git a/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor b/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor index 1dd0b178b..db1568fa1 100644 --- a/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor +++ b/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor @@ -1,4 +1,5 @@ @namespace BlazorBlueprint.Components +@using BlazorBlueprint.Icons.Lucide.Components
    - @if (panel.Icon is not null) + @onclick="() => Dock.ActivatePanel(group, panel.Id)" + @oncontextmenu="e => HandleTabContextMenu(panel, e)" + @oncontextmenu:preventDefault="true" + @oncontextmenu:stopPropagation="true"> + @if (Dock.IsPinned(panel.Id)) + { + + } + else if (panel.Icon is not null) { @panel.Icon } @@ -45,25 +58,13 @@ @if (!IsFloating) { -
    +
    } @@ -84,4 +85,27 @@
    }
    + + + + @if (contextPanel is not null) + { + var panel = contextPanel; + var pinned = Dock.IsPinned(panel.Id); + + Close + + + Close Other Tabs + + + Close All Tabs + + + + @(pinned ? "Unpin Tab" : "Pin Tab") + + } + +
    diff --git a/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor.cs b/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor.cs index ccf245ad2..8a8a75de1 100644 --- a/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor.cs @@ -28,6 +28,9 @@ public partial class BbDockTabGroup : ComponentBase private DockTabGroupNode group => (DockTabGroupNode)Group; + private BbContextMenu? tabMenu; + private BbDockPanel? contextPanel; + private bool IsFloating => FloatingWindowId is not null; private bool IsMaximized => Dock is not null && Dock.IsMaximized(group); @@ -52,6 +55,16 @@ private async Task HandleTabPointerDown(BbDockPanel panel, PointerEventArgs e) await Dock.StartTabDragAsync(panel.Id, e); } + private async Task HandleTabContextMenu(BbDockPanel panel, MouseEventArgs e) + { + contextPanel = panel; + + if (tabMenu is not null) + { + await tabMenu.OpenAt(e.ClientX, e.ClientY); + } + } + private async Task HandleStripPointerDown(PointerEventArgs e) { if (IsFloating && e.Button == 0 && FloatingWindowId is not null) @@ -71,7 +84,7 @@ private async Task HandleStripPointerDown(PointerEventArgs e) IsFloating ? "cursor-move" : null); private static string TabClass(bool isActive) => ClassNames.cn( - "group/tab relative flex h-full min-w-[88px] max-w-[200px] cursor-grab items-center gap-1.5 border-r border-border/40 px-2.5 text-xs transition-colors active:cursor-grabbing", + "group/tab relative flex h-full min-w-[88px] max-w-[200px] cursor-default items-center gap-1.5 border-r border-border/40 px-2.5 text-xs transition-colors", isActive ? "z-10 -mb-px border-b border-background bg-background text-foreground after:absolute after:inset-x-0 after:top-0 after:h-[2px] after:bg-primary" : "bg-transparent text-muted-foreground hover:bg-background/50 hover:text-foreground"); diff --git a/src/BlazorBlueprint.Components/wwwroot/js/dock.js b/src/BlazorBlueprint.Components/wwwroot/js/dock.js index a3eeb4a9f..1305db023 100644 --- a/src/BlazorBlueprint.Components/wwwroot/js/dock.js +++ b/src/BlazorBlueprint.Components/wwwroot/js/dock.js @@ -92,7 +92,7 @@ function onTabMove(state, e) { } state.active = true; state.ghost = createGhost(state.title); - document.body.style.cursor = "grabbing"; + document.body.style.cursor = "default"; } moveGhost(state.ghost, e.clientX, e.clientY); From 9b61182914d5031946268912f59fb189a75a4f7d Mon Sep 17 00:00:00 2001 From: David Ball Date: Thu, 11 Jun 2026 01:25:35 +0100 Subject: [PATCH 080/188] Added horizontal resizable support for vertically stacked dock panels. --- .../Pages/Components/DockDemo.razor | 234 +++++++++--------- .../Resizable/BbResizableHandle.razor | 6 +- 2 files changed, 122 insertions(+), 118 deletions(-) diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DockDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DockDemo.razor index 8d918a665..8e30eb9d9 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DockDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DockDemo.razor @@ -28,118 +28,31 @@
  • Right-click a tab to close others, close all, or pin it to the front.
  • - - + +
    -

    IDE Workspace

    +

    Basic

    - A full layout with an explorer, tabbed editors, a bottom panel and an inspector — every - panel can be dragged, split, floated, maximized or closed. + Panels are placed by Region. Panels sharing a region become tabs in the same group.

    -
    - - - - -
    -
    Workspace
    - @foreach (var (name, icon) in explorerItems) - { -
    - - @name -
    - } -
    -
    -
    - - - - -
    - -

    3 results in 2 files

    -
    -
    Program.cs · line 12
    -
    README.md · line 4
    -
    -
    -
    -
    - - - - -
    @programSource
    -
    -
    - - - - -
    -

    Blazor Blueprint

    -

    A two-layer Blazor component library inspired by shadcn/ui and Radix.

    -

    Drag this tab around to see docking in action.

    -
    -
    -
    - - - - -
    $ dotnet build
    -  Determining projects to restore...
    -  Restored BlazorBlueprint.Components (in 412 ms).
    -  BlazorBlueprint.Components -> bin/Debug/net8.0/BlazorBlueprint.Components.dll
    -Build succeeded.
    -    0 Warning(s)
    -    0 Error(s)
    -$ _
    -
    +
    + + +
    Main editor content
    - - - - -
    -
    - - Unused variable temp - Program.cs:24 -
    -
    - - Missing XML comment - Dock.cs:11 -
    -
    -
    + +
    File tree
    - - - - -
    - @foreach (var (label, value) in properties) - { -
    - @label - @value -
    - } -
    -
    + +
    Build output
    -

    Layout changes: @layoutChanges

    - +
    @@ -152,7 +65,7 @@ $ _

    -
    +
    @@ -196,7 +109,7 @@ $ _

    -
    +
    @@ -252,7 +165,7 @@ $ _ }
    -
    +
    @@ -277,33 +190,122 @@ $ _
    - - + + +
    -

    Basic

    +

    IDE Workspace

    - Panels are placed by Region. Panels sharing a region become tabs in the same group. + A full layout with an explorer, tabbed editors, a bottom panel and an inspector — every + panel can be dragged, split, floated, maximized or closed.

    -
    - - -
    Main editor content
    +
    + + + + +
    +
    Workspace
    + @foreach (var (name, icon) in explorerItems) + { +
    + + @name +
    + } +
    +
    - -
    File tree
    + + + + +
    + +

    3 results in 2 files

    +
    +
    Program.cs · line 12
    +
    README.md · line 4
    +
    +
    +
    - -
    Build output
    + + + + +
    @programSource
    +
    +
    + + + + +
    +

    Blazor Blueprint

    +

    A two-layer Blazor component library inspired by shadcn/ui and Radix.

    +

    Drag this tab around to see docking in action.

    +
    +
    +
    + + + + +
    $ dotnet build
    +  Determining projects to restore...
    +  Restored BlazorBlueprint.Components (in 412 ms).
    +  BlazorBlueprint.Components -> bin/Debug/net8.0/BlazorBlueprint.Components.dll
    +Build succeeded.
    +    0 Warning(s)
    +    0 Error(s)
    +$ _
    +
    +
    + + + + +
    +
    + + Unused variable temp + Program.cs:24 +
    +
    + + Missing XML comment + Dock.cs:11 +
    +
    +
    +
    + + + + +
    + @foreach (var (label, value) in properties) + { +
    + @label + @value +
    + } +
    +
    +

    Layout changes: @layoutChanges

    - +
    + Tabs use role="tab" with aria-selected reflecting the active panel. diff --git a/src/BlazorBlueprint.Components/Components/Resizable/BbResizableHandle.razor b/src/BlazorBlueprint.Components/Components/Resizable/BbResizableHandle.razor index 7e642f9a2..f4127661b 100644 --- a/src/BlazorBlueprint.Components/Components/Resizable/BbResizableHandle.razor +++ b/src/BlazorBlueprint.Components/Components/Resizable/BbResizableHandle.razor @@ -52,8 +52,10 @@ IsHorizontal ? "w-px cursor-col-resize hover:bg-primary/50 active:bg-primary" : "h-px cursor-row-resize hover:bg-primary/50 active:bg-primary", - "after:absolute after:inset-y-0 after:left-1/2 after:-translate-x-1/2", - IsHorizontal ? "after:w-1" : "after:h-1", + "after:absolute", + IsHorizontal + ? "after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2" + : "after:inset-x-0 after:top-1/2 after:h-1 after:-translate-y-1/2", Class ); From dacfcf6e0cb746d108e8966016142b95e7997a45 Mon Sep 17 00:00:00 2001 From: David Ball Date: Thu, 11 Jun 2026 01:46:03 +0100 Subject: [PATCH 081/188] Added support for hiding tabs that don't fit into the tab strip when panel is too small. --- .../Components/Dock/BbDockTabGroup.razor | 70 +++++++++-- .../Components/Dock/BbDockTabGroup.razor.cs | 112 +++++++++++++++++- .../wwwroot/js/dock.js | 82 +++++++++++++ 3 files changed, 251 insertions(+), 13 deletions(-) diff --git a/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor b/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor index db1568fa1..f960bb324 100644 --- a/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor +++ b/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor @@ -4,7 +4,7 @@
    -
    +
    @foreach (var panelId in group.PanelIds) { var panel = Dock.GetPanel(panelId); @@ -14,9 +14,10 @@ } var isActive = group.ActivePanelId == panelId; + var isHidden = overflowPanelIds.Contains(panelId);
    -public partial class BbDockTabGroup : ComponentBase +public partial class BbDockTabGroup : ComponentBase, IAsyncDisposable { + [Inject] + private IJSRuntime JS { get; set; } = null!; + [CascadingParameter] private BbDock Dock { get; set; } = null!; @@ -31,10 +35,22 @@ public partial class BbDockTabGroup : ComponentBase private BbContextMenu? tabMenu; private BbDockPanel? contextPanel; + private ElementReference stripRef; + private IJSObjectReference? jsModule; + private DotNetObjectReference? dotNetRef; + private string? observedGroupId; + private string? lastTabSignature; + private List overflowPanelIds = new(); + private bool disposed; + private bool IsFloating => FloatingWindowId is not null; private bool IsMaximized => Dock is not null && Dock.IsMaximized(group); + // Changes whenever the set, order or active state of tabs changes, so the strip is + // re-measured for overflow even when its own size did not change. + private string TabSignature => $"{string.Join('|', group.PanelIds)}#{group.ActivePanelId}"; + /// protected override void OnInitialized() { @@ -44,6 +60,71 @@ protected override void OnInitialized() } } + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (disposed) + { + return; + } + + try + { + jsModule ??= await JS.InvokeAsync( + "import", "./_content/BlazorBlueprint.Components/js/dock.js"); + + // (Re)attach the overflow observer whenever this instance starts rendering a + // different tab group (Blazor reuses component instances across layout changes). + if (observedGroupId != group.Id) + { + if (observedGroupId is not null) + { + await jsModule.InvokeVoidAsync("disposeTabOverflow", observedGroupId); + } + + dotNetRef ??= DotNetObjectReference.Create(this); + observedGroupId = group.Id; + lastTabSignature = TabSignature; + await jsModule.InvokeVoidAsync("initTabOverflow", group.Id, stripRef, dotNetRef); + } + else if (lastTabSignature != TabSignature) + { + // Tabs were added, removed or reordered: re-measure without a size change. + lastTabSignature = TabSignature; + await jsModule.InvokeVoidAsync("remeasureTabOverflow", group.Id); + } + } + catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) + { + // Expected during circuit disconnect / prerender. + } + catch (InvalidOperationException) + { + // JS interop not available during prerendering. + } + } + + /// + /// Invoked from JS with the ids of tabs that no longer fit in the strip. These tabs are + /// hidden and surfaced through the overflow dropdown instead. + /// + /// The overflowing panel ids, in strip order. + [JSInvokable] + public void OnTabOverflowChanged(string[] ids) + { + if (disposed) + { + return; + } + + var next = ids.Where(id => group.PanelIds.Contains(id)).ToList(); + if (!next.SequenceEqual(overflowPanelIds)) + { + overflowPanelIds = next; + StateHasChanged(); + } + } + private async Task HandleTabPointerDown(BbDockPanel panel, PointerEventArgs e) { // Primary button only; let the click handler perform activation. @@ -83,8 +164,9 @@ private async Task HandleStripPointerDown(PointerEventArgs e) "flex h-8 shrink-0 items-stretch border-b border-border/60 bg-muted/50", IsFloating ? "cursor-move" : null); - private static string TabClass(bool isActive) => ClassNames.cn( + private static string TabClass(bool isActive, bool isHidden) => ClassNames.cn( "group/tab relative flex h-full min-w-[88px] max-w-[200px] cursor-default items-center gap-1.5 border-r border-border/40 px-2.5 text-xs transition-colors", + isHidden ? "hidden" : null, isActive ? "z-10 -mb-px border-b border-background bg-background text-foreground after:absolute after:inset-x-0 after:top-0 after:h-[2px] after:bg-primary" : "bg-transparent text-muted-foreground hover:bg-background/50 hover:text-foreground"); @@ -92,4 +174,30 @@ private static string TabClass(bool isActive) => ClassNames.cn( private static string CloseClass(bool isActive) => ClassNames.cn( "ml-auto inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-sm transition-opacity hover:bg-foreground/10 hover:!opacity-100", isActive ? "opacity-60" : "opacity-0 group-hover/tab:opacity-60"); + + /// + public async ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + disposed = true; + + if (jsModule is not null) + { + try + { + if (observedGroupId is not null) + { + await jsModule.InvokeVoidAsync("disposeTabOverflow", observedGroupId); + } + + await jsModule.DisposeAsync(); + } + catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) + { + // Expected during circuit disconnect. + } + } + + dotNetRef?.Dispose(); + } } diff --git a/src/BlazorBlueprint.Components/wwwroot/js/dock.js b/src/BlazorBlueprint.Components/wwwroot/js/dock.js index 1305db023..26057e2a1 100644 --- a/src/BlazorBlueprint.Components/wwwroot/js/dock.js +++ b/src/BlazorBlueprint.Components/wwwroot/js/dock.js @@ -24,6 +24,88 @@ export function disposeDock(dockId) { docks.delete(dockId); } +// ---------------------------------------------------------------- tab strip overflow + +// One entry per observed tab strip, keyed by the tab group's id. +const tabOverflowObservers = new Map(); + +// Sets up overflow tracking for a tab strip. Whenever the strip resizes, the set of tabs +// that no longer fit is recomputed and reported to .NET so it can hide them and surface an +// overflow ("…") dropdown instead of a scrollbar. +export function initTabOverflow(groupId, stripEl, dotNetRef) { + if (!groupId || !stripEl || !dotNetRef) { + return; + } + + disposeTabOverflow(groupId); + + const ro = new ResizeObserver(() => reportTabOverflow(groupId)); + ro.observe(stripEl); + + tabOverflowObservers.set(groupId, { observer: ro, stripEl, dotNetRef }); + reportTabOverflow(groupId); +} + +// Recomputes overflow for an already-observed strip (e.g. after tabs are added or removed). +export function remeasureTabOverflow(groupId) { + reportTabOverflow(groupId); +} + +export function disposeTabOverflow(groupId) { + const entry = tabOverflowObservers.get(groupId); + if (entry) { + entry.observer.disconnect(); + tabOverflowObservers.delete(groupId); + } +} + +function reportTabOverflow(groupId) { + const entry = tabOverflowObservers.get(groupId); + if (!entry) { + return; + } + + const ids = computeOverflowIds(entry.stripEl); + entry.dotNetRef.invokeMethodAsync("OnTabOverflowChanged", ids).catch(() => { }); +} + +// Returns the ids of tabs that do not fully fit within the strip's visible width, in order. +// Tabs may currently be hidden (display:none) from a previous pass, so each is temporarily +// forced visible for measurement and then restored — the natural widths stay stable, which +// prevents the hide/show feedback loop a naive measurement would cause. +function computeOverflowIds(stripEl) { + if (!stripEl) { + return []; + } + + const tabs = Array.from(stripEl.querySelectorAll("[data-dock-tab]")); + if (tabs.length === 0) { + return []; + } + + const saved = tabs.map((t) => t.style.display); + for (const t of tabs) { + t.style.display = "flex"; + } + + const available = stripEl.clientWidth; + const overflow = []; + let used = 0; + for (const t of tabs) { + used += t.offsetWidth; + // A 1px tolerance absorbs sub-pixel rounding so a tab that exactly fits is not hidden. + if (used > available + 1) { + overflow.push(t.getAttribute("data-dock-tab")); + } + } + + for (let i = 0; i < tabs.length; i++) { + tabs[i].style.display = saved[i]; + } + + return overflow; +} + // ---------------------------------------------------------------- shared pointer session function attachSession(handlers) { From dc224f0340715bd5f225a4c537534fea7d540eca Mon Sep 17 00:00:00 2001 From: David Ball Date: Thu, 11 Jun 2026 01:53:23 +0100 Subject: [PATCH 082/188] Swapped svg elements to Lucide icons. --- .../Components/Dock/BbDockTabGroup.razor | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor b/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor index f960bb324..3a40c3a6f 100644 --- a/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor +++ b/src/BlazorBlueprint.Components/Components/Dock/BbDockTabGroup.razor @@ -28,11 +28,8 @@ @oncontextmenu:stopPropagation="true"> @if (Dock.IsPinned(panel.Id)) { -
    internal sealed class DockFloatingWindow { + /// The default width (pixels) a floating window is created with, before constraints. + public const double DefaultWidth = 360; + + /// The default height (pixels) a floating window is created with, before constraints. + public const double DefaultHeight = 260; + + /// The smallest width (pixels) a floating window may be, regardless of panel constraints. + public const double MinFloatingWidth = 180; + + /// The smallest height (pixels) a floating window may be, regardless of panel constraints. + public const double MinFloatingHeight = 120; + /// Stable identity for the window. public string Id { get; init; } = Guid.NewGuid().ToString("N"); @@ -119,8 +131,8 @@ internal sealed class DockFloatingWindow public double Y { get; set; } /// Window width in pixels. - public double Width { get; set; } = 360; + public double Width { get; set; } = DefaultWidth; /// Window height in pixels. - public double Height { get; set; } = 260; + public double Height { get; set; } = DefaultHeight; } diff --git a/src/BlazorBlueprint.Components/wwwroot/js/dock.js b/src/BlazorBlueprint.Components/wwwroot/js/dock.js index 26057e2a1..01dacaa0e 100644 --- a/src/BlazorBlueprint.Components/wwwroot/js/dock.js +++ b/src/BlazorBlueprint.Components/wwwroot/js/dock.js @@ -452,7 +452,9 @@ export function startWindowDrag(dockId, windowId, clientX, clientY, pointerId) { // ---------------------------------------------------------------- floating window resize -export function startWindowResize(dockId, windowId, clientX, clientY, pointerId) { +// minWidth/minHeight default to the usable window minimum; a max of 0 (or omitted) means +// "no maximum". These mirror the per-panel size constraints enforced on the .NET side. +export function startWindowResize(dockId, windowId, clientX, clientY, pointerId, minWidth, minHeight, maxWidth, maxHeight) { const dock = docks.get(dockId); if (!dock) { return; @@ -463,6 +465,11 @@ export function startWindowResize(dockId, windowId, clientX, clientY, pointerId) return; } + const minW = minWidth > 0 ? minWidth : 180; + const minH = minHeight > 0 ? minHeight : 120; + const maxW = maxWidth > 0 ? maxWidth : Infinity; + const maxH = maxHeight > 0 ? maxHeight : Infinity; + const startWidth = winEl.offsetWidth; const startHeight = winEl.offsetHeight; const startX = clientX; @@ -473,8 +480,8 @@ export function startWindowResize(dockId, windowId, clientX, clientY, pointerId) if (e.pointerId !== pointerId) { return; } - const width = Math.max(180, startWidth + (e.clientX - startX)); - const height = Math.max(120, startHeight + (e.clientY - startY)); + const width = Math.min(maxW, Math.max(minW, startWidth + (e.clientX - startX))); + const height = Math.min(maxH, Math.max(minH, startHeight + (e.clientY - startY))); winEl.style.width = `${width}px`; winEl.style.height = `${height}px`; }, diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 242360180..8fd3dc608 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -1077,6 +1077,11 @@ - Closable : Boolean - Icon : RenderFragment - Id : String [EditorRequired] + - Locked : Boolean + - MaxHeight : Int32? + - MaxWidth : Int32? + - MinHeight : Int32? + - MinWidth : Int32? - Order : Int32 - Region : DockZone - Title : String From 0c9f6923b16717472b503a858983b06e455b13d1 Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:59:23 +0800 Subject: [PATCH 090/188] feat(theme-switcher): add Variant parameter to BbThemeSwitcher (#363) The trigger button was hardcoded to ButtonVariant.Outline, so the switcher couldn't be matched to surrounding UI. Added a Variant parameter (default Outline) bound to the trigger, mirroring the existing BbDarkModeToggle.Variant. Added a "Trigger variants" demo example and an API Reference entry; accepted the API surface snapshot. --- .../Pages/Components/ThemeDemo.razor | 27 ++++++++++++++++--- .../Components/Theme/BbThemeSwitcher.razor | 8 +++++- ...entsApiSurfaceMatchesBaseline.verified.txt | 1 + 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ThemeDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ThemeDemo.razor index d0e2cf9ac..e78d8500c 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ThemeDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ThemeDemo.razor @@ -46,9 +46,27 @@

    -
    - - Click the icon to open the theme panel +
    +
    + + Click the icon to open the theme panel +
    + +
    +

    Trigger variants

    +

    + The trigger button defaults to Outline. + Set Variant to match it to the + surrounding UI. +

    +
    + + + + +
    +
    <BbThemeSwitcher Variant="ButtonVariant.Ghost" />
    +
    @@ -153,6 +171,9 @@ await ThemeService.ToggleDarkModeAsync();
    + + Visual variant of the trigger button. + Additional CSS classes for the trigger button. diff --git a/src/BlazorBlueprint.Components/Components/Theme/BbThemeSwitcher.razor b/src/BlazorBlueprint.Components/Components/Theme/BbThemeSwitcher.razor index 4995e252e..ca2775d6a 100644 --- a/src/BlazorBlueprint.Components/Components/Theme/BbThemeSwitcher.razor +++ b/src/BlazorBlueprint.Components/Components/Theme/BbThemeSwitcher.razor @@ -6,7 +6,7 @@ - @@ -107,6 +107,12 @@ @code { private bool isOpen; + /// + /// The visual variant of the trigger button. Defaults to . + /// + [Parameter] + public ButtonVariant Variant { get; set; } = ButtonVariant.Outline; + /// /// Additional CSS classes for the trigger button. /// diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 1aea11b98..5c68e239b 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -3098,6 +3098,7 @@ - PopoverContentClass : String - Strategy : PositioningStrategy - TriggerClass : String + - Variant : ButtonVariant ### BbTimePicker (BlazorBlueprint.Components) - Class : String From 7e7b82202ca91ffe389dfcef52d733568c269043 Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:59:58 +0800 Subject: [PATCH 091/188] fix(input-otp): fire OnComplete when entering the OTP manually (#362) HandleInput gated the completion callback on `!newValue.Contains("")`, which is always false because every string contains the empty string, so OnComplete never fired when characters were typed one at a time (only the paste path worked). The check now mirrors the paste path and fires once every slot is filled. --- .../Components/InputOTP/BbInputOTP.razor | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/BlazorBlueprint.Components/Components/InputOTP/BbInputOTP.razor b/src/BlazorBlueprint.Components/Components/InputOTP/BbInputOTP.razor index f615d7a64..e92f8c872 100644 --- a/src/BlazorBlueprint.Components/Components/InputOTP/BbInputOTP.razor +++ b/src/BlazorBlueprint.Components/Components/InputOTP/BbInputOTP.razor @@ -275,14 +275,11 @@ await FocusInput(index + 1); } - // Check if complete - if (newValue.Length == Length && !newValue.Contains("")) + // Fire OnComplete once every slot is filled. + var completeValue = string.Join("", _values.Where(v => !string.IsNullOrEmpty(v))); + if (completeValue.Length == Length) { - var completeValue = string.Join("", _values.Where(v => !string.IsNullOrEmpty(v))); - if (completeValue.Length == Length) - { - await OnComplete.InvokeAsync(completeValue); - } + await OnComplete.InvokeAsync(completeValue); } } From c3edf93cde936e83241d852bd9c6c96478bb8c90 Mon Sep 17 00:00:00 2001 From: djb-fnz Date: Sun, 14 Jun 2026 07:07:00 +0100 Subject: [PATCH 092/188] feat(BbAccordionTrigger): allow custom icon. (#347) --- .../Components/Accordion/custom-icon.txt | 34 ++++++++++++ .../Pages/Components/AccordionDemo.razor | 52 +++++++++++++++++++ .../Accordion/BbAccordionTrigger.razor | 47 ++++++++++++----- ...entsApiSurfaceMatchesBaseline.verified.txt | 1 + 4 files changed, 121 insertions(+), 13 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Accordion/custom-icon.txt diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Accordion/custom-icon.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Accordion/custom-icon.txt new file mode 100644 index 000000000..49d24b0a7 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Accordion/custom-icon.txt @@ -0,0 +1,34 @@ + + @* Any icon component — the bool context is the open state for animations *@ + + + How do I use a Lucide icon? + + + + + + Pass any icon component to the Icon parameter. The context + parameter exposes the open state so you can animate it. + + + + @* Ad-hoc SVG content also works *@ + + + Can I use ad-hoc SVG? + + + + + + + + Yes. The Icon parameter accepts any markup, including raw SVG. + + + diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/AccordionDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/AccordionDemo.razor index 8c12fb998..87dbc7300 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/AccordionDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/AccordionDemo.razor @@ -194,6 +194,53 @@ +
    +
    +

    Custom Icon

    +

    + Replace the default chevron with any icon component + (<LucideIcon>, <FontAwesomeIcon>, + <FeatherIcon>, <HeroIcon>) or ad-hoc SVG. + The icon receives a bool context with the open state for animation. +

    +
    + + + + + How do I use a Lucide icon? + + + + + + Pass any icon component to the Icon parameter. The context + parameter exposes the open state so you can animate it. + + + + + + Can I use ad-hoc SVG? + + + + + + + + Yes. The Icon parameter accepts any markup, including raw SVG. + + + + + +
    + @@ -267,6 +314,11 @@ HTML heading element tag to wrap the trigger button. + + Custom icon rendered in place of the default chevron. Accepts any icon + component or ad-hoc SVG. The bool context is the open state. + When not set, the default rotating chevron is used. +
    diff --git a/src/BlazorBlueprint.Components/Components/Accordion/BbAccordionTrigger.razor b/src/BlazorBlueprint.Components/Components/Accordion/BbAccordionTrigger.razor index 52894444f..1858a1eff 100644 --- a/src/BlazorBlueprint.Components/Components/Accordion/BbAccordionTrigger.razor +++ b/src/BlazorBlueprint.Components/Components/Accordion/BbAccordionTrigger.razor @@ -8,19 +8,26 @@
    @ChildContent - - - + @if (Icon != null) + { + @Icon(Item?.IsOpen == true) + } + else + { + + + + }
    @@ -43,6 +50,20 @@ [Parameter] public string? Class { get; set; } + /// + /// Optional custom icon rendered at the end of the trigger in place of the default + /// chevron. Accepts any icon component (e.g. <LucideIcon>, + /// <FontAwesomeIcon>, <FeatherIcon>, <HeroIcon>) + /// or ad-hoc SVG/markup content. When not specified, the default chevron icon + /// (which rotates on open) is used. + /// + /// + /// The context parameter is a indicating whether the parent + /// accordion item is open, so the icon can be animated to reflect the open state. + /// + [Parameter] + public RenderFragment? Icon { get; set; } + ///
    /// The HTML element tag to use for the heading wrapper. /// Default is "h3". diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 5c68e239b..c88d84dd6 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -27,6 +27,7 @@ - As : String - ChildContent : RenderFragment - Class : String + - Icon : RenderFragment - Item : BbAccordionItem [CascadingParameter] ### BbAlert (BlazorBlueprint.Components) From 2306cef2ad08c27e9c1265bc1ce5b608c756d0b4 Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:49:00 +0800 Subject: [PATCH 093/188] fix(dialog): honor ShowClose on programmatically opened dialogs (#364) Dialogs opened via DialogService.OpenAsync render through ComponentDialog, which never rendered a close button, so the DialogOpenOptions.ShowClose option had no effect and component dialogs had no X affordance at all. ComponentDialog now renders a close (X) button gated on Options.ShowClose that dismisses the dialog with DialogResult.Cancel(), mirroring the declarative BbDialogContent's button (markup, styling and the Dialog.Close localization key). ShowClose is independent of PreventClose, which only governs Escape/backdrop dismissal. --- .../Pages/Components/DialogDemo.razor | 3 ++- .../Dialog/Internals/ComponentDialog.razor | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DialogDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DialogDemo.razor index 9835e243e..f394d137e 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DialogDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DialogDemo.razor @@ -459,7 +459,8 @@

    Use DialogService.Open<TComponent>() to render a fully custom dialog component. - Returns a DialogResult. + Returns a DialogResult. A close (×) button is shown by default; set + ShowClose = false in DialogOpenOptions to hide it.

    diff --git a/src/BlazorBlueprint.Components/Components/Dialog/Internals/ComponentDialog.razor b/src/BlazorBlueprint.Components/Components/Dialog/Internals/ComponentDialog.razor index 4b94fc7fb..8a07259f7 100644 --- a/src/BlazorBlueprint.Components/Components/Dialog/Internals/ComponentDialog.razor +++ b/src/BlazorBlueprint.Components/Components/Dialog/Internals/ComponentDialog.razor @@ -1,4 +1,7 @@ @using BlazorBlueprint.Components.Components.Dialog.Internals.Shared +@using BlazorBlueprint.Icons.Lucide.Components +@inject DialogService DialogService +@inject IBbLocalizer Localizer +@* Close (X) button — gated on the ShowClose option. Rendered last so it is not the first + focusable element when focus is trapped. Independent of PreventClose, which only governs + Escape/backdrop dismissal: an explicit X is always a deliberate close. *@ +@if (Dialog.Options.ShowClose) +{ + +} + @code { [Parameter, EditorRequired] public ComponentDialogData Dialog { get; set; } = default!; [Parameter] public string? TitleId { get; set; } [Parameter] public string? DescriptionId { get; set; } + + private void Close() + => DialogService.Resolve(Dialog.Id, DialogResult.Cancel()); } From e946ffc1f40eafc84c11854f98852fb616128740 Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:12:34 +0800 Subject: [PATCH 094/188] fix(sidebar): let explicit IsActive override auto location matching (#365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BbSidebarMenuButton and BbSidebarMenuSubButton resolved their active state as `IsActive || isActiveByLocation`, so an explicit IsActive="false" could never deactivate an item whose Href matched the current URL — most visibly with Href="/", which matches every route under the default Prefix match. This regressed in 3.11.0, when automatic data-active-on-navigation was added. IsActive is now bool? (default null): an explicitly supplied value wins, while null keeps the automatic Href/Match location matching. Existing markup (IsActive="true" / "false" / "@expr") is unaffected. --- .../Components/Sidebar/BbSidebarMenuButton.razor | 13 ++++++++----- .../Components/Sidebar/BbSidebarMenuSubButton.razor | 13 ++++++++----- ...ComponentsApiSurfaceMatchesBaseline.verified.txt | 4 ++-- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarMenuButton.razor b/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarMenuButton.razor index 86bd13bc8..e2347c525 100644 --- a/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarMenuButton.razor +++ b/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarMenuButton.razor @@ -57,10 +57,13 @@ else public SidebarMenuButtonVariant Variant { get; set; } = SidebarMenuButtonVariant.Default; /// - /// Whether this menu item is active/selected. + /// Explicitly controls whether this menu item is active/selected. When set, this value takes + /// precedence over automatic / location matching — so + /// false forces the item inactive even if its href matches the current URL. Leave + /// null (the default) to derive the active state from the current location. /// [Parameter] - public bool IsActive { get; set; } + public bool? IsActive { get; set; } /// /// The element type to render. Defaults to Button, but automatically switches to Anchor if Href is provided. @@ -106,10 +109,10 @@ else private bool isActiveByLocation; /// - /// Whether the button should render as active — either explicitly via - /// or because its matches the current location. + /// Whether the button should render as active. An explicit value wins; + /// otherwise the state is derived from whether matches the current location. /// - private bool ResolvedActive => IsActive || isActiveByLocation; + private bool ResolvedActive => IsActive ?? isActiveByLocation; protected override void OnInitialized() { diff --git a/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarMenuSubButton.razor b/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarMenuSubButton.razor index f46083db8..08cce8231 100644 --- a/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarMenuSubButton.razor +++ b/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarMenuSubButton.razor @@ -51,10 +51,13 @@ else public NavLinkMatch Match { get; set; } = NavLinkMatch.Prefix; /// - /// Whether this submenu item is active/selected. + /// Explicitly controls whether this submenu item is active/selected. When set, this value takes + /// precedence over automatic / location matching — so + /// false forces the item inactive even if its href matches the current URL. Leave + /// null (the default) to derive the active state from the current location. /// [Parameter] - public bool IsActive { get; set; } + public bool? IsActive { get; set; } /// /// Button size variant. @@ -80,10 +83,10 @@ else private bool isActiveByLocation; /// - /// Whether the button should render as active — either explicitly via - /// or because its matches the current location. + /// Whether the button should render as active. An explicit value wins; + /// otherwise the state is derived from whether matches the current location. /// - private bool ResolvedActive => IsActive || isActiveByLocation; + private bool ResolvedActive => IsActive ?? isActiveByLocation; protected override void OnInitialized() { diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index c88d84dd6..47fdbbbdd 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -2867,7 +2867,7 @@ - ChildContent : RenderFragment - Class : String - Href : String - - IsActive : Boolean + - IsActive : Boolean? - Match : NavLinkMatch - OnClick : EventCallback - Size : SidebarMenuButtonSize @@ -2902,7 +2902,7 @@ - ChildContent : RenderFragment - Class : String - Href : String - - IsActive : Boolean + - IsActive : Boolean? - Match : NavLinkMatch - Size : SidebarMenuSubButtonSize From 0a453125fac9e82ac913ac9b1887ef6bd787ef21 Mon Sep 17 00:00:00 2001 From: Mathew <51848714+mathewtaylor@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:49:17 +0800 Subject: [PATCH 095/188] feat(select-family): align trigger heights to h-10 and add MultiSelect SingleLine (#366) Trigger heights across the Select family were inconsistent: BbSelect (and BbInput/BbButton) use h-10 (40px), but BbCombobox used h-9 and BbMultiSelect used min-h-9 (36px). Bump both to the h-10 standard so they line up when placed together. Also add an opt-in SingleLine parameter to BbMultiSelect (default false keeps the current wrap-and-grow behaviour). When true the trigger stays one fixed- height row: overflowing tags are clipped via flex-nowrap/overflow-hidden while the "+N more" indicator and chevron are pinned to the right so they remain visible. Includes a side-by-side demo example and API reference entry. --- .../Components/MultiSelect/single-line.txt | 30 +++++++++++++ .../Pages/Components/MultiSelectDemo.razor | 43 +++++++++++++++++++ .../Components/Combobox/BbCombobox.razor.cs | 2 +- .../MultiSelect/BbMultiSelect.razor | 14 ++++-- .../MultiSelect/BbMultiSelect.razor.cs | 26 ++++++++++- ...entsApiSurfaceMatchesBaseline.verified.txt | 1 + 6 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/MultiSelect/single-line.txt diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/MultiSelect/single-line.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/MultiSelect/single-line.txt new file mode 100644 index 000000000..49b3436b5 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/MultiSelect/single-line.txt @@ -0,0 +1,30 @@ +@* Default: tags wrap onto more rows and the trigger grows to fit. *@ + + +@* SingleLine: one fixed-height row — overflowing tags are clipped while the + "+N more" indicator and chevron stay pinned and visible. *@ + + +@code { + private IEnumerable? selected = + new[] { "react", "vue", "angular", "blazor", "nextjs", "dotnet" }; + + private readonly SelectOption[] technologies = + [ + new("react", "React"), + new("vue", "Vue.js"), + new("angular", "Angular"), + new("blazor", "Blazor"), + new("nextjs", "Next.js"), + new("dotnet", ".NET"), + ]; +} diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/MultiSelectDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/MultiSelectDemo.razor index cf1f6eb30..0f11057f8 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/MultiSelectDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/MultiSelectDemo.razor @@ -89,6 +89,41 @@ + +
    +

    Single Line

    +

    + By default the trigger wraps tags onto additional rows and grows to fit. Set + SingleLine="true" to keep it one row at a fixed height — overflowing tags are + clipped while the "+N more" indicator and chevron stay pinned and visible. Both controls + below share the same selection. +

    + +
    +
    + Default (wraps & grows) + +
    +
    + SingleLine="true" + +
    +
    + + +
    +

    Non-String Value Types

    @@ -470,6 +505,10 @@ Maximum number of tags to display before showing "+N more". + + Keep the trigger on a single row at a fixed height, clipping overflowing tags + (the "+N more" indicator and chevron stay pinned). Default wraps and grows. + Whether the multiselect is disabled. @@ -540,6 +579,10 @@ // Country example private IEnumerable? selectedCountries; + // Single line example (shared by the wrap/single-line comparison) + private IEnumerable? singleLineValues = + new[] { "react", "vue", "angular", "blazor", "nextjs", "dotnet" }; + // Framework example private IEnumerable? selectedFrameworks; diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs index 9408128e0..677552b1f 100644 --- a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs @@ -494,7 +494,7 @@ private async Task HandleSelect(SelectOption option) "disabled:opacity-50 disabled:pointer-events-none", "border border-input bg-background hover:bg-accent hover:text-accent-foreground", _isOpen ? ActiveClass : null, - "h-9 px-3", + "h-10 px-3", string.IsNullOrWhiteSpace(Class) ? PopoverWidth : null, Class ); diff --git a/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor b/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor index a2f612958..e6a465efc 100644 --- a/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor +++ b/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor @@ -14,12 +14,12 @@ aria-describedby="@AriaDescribedBy" disabled="@Disabled" class="@TriggerCssClass"> -
    +
    @if (HasSelectedItems) { @foreach (var value in SelectedValues.Take(MaxDisplayTags)) { - + @GetDisplayText(value)
    -
    +
    + @if (HasSelectedItems && OverflowCount > 0 && SingleLine) + { + +@OverflowCount more + } @if (HasSelectedItems) {
    + +
    +
    +

    Per-Page Select-All

    +

    + By default, the header checkbox of a paginated grid opens a menu to select all rows on the + current page or across every page. Set + SelectAllScope="DataGridSelectAllScope.CurrentPage" + to remove that menu — the header checkbox then toggles only the current page's rows and leaves + selections on other pages untouched. Select some rows, change pages, and the previous page's + selection is preserved. +

    +
    + + + + + + + + +

    + Selected across all pages: @_perPageSelectedCount +

    + +
    +
    @@ -1096,6 +1125,9 @@ Whether this column is pinned. Commonly set to BlazorBlueprint.Primitives.DataGrid.ColumnPinning.Left to keep the checkbox column visible when scrolling horizontally. + + Select-all header behaviour when paginated. Prompt (default) shows a "this page / all items" menu across pages; CurrentPage removes the menu so the header checkbox toggles only the current page's rows, leaving other pages' selections intact. + @@ -1348,6 +1380,13 @@ _selectedPeople = selected; } + private int _perPageSelectedCount; + + private void HandlePerPageSelectionChanged(IReadOnlyCollection selected) + { + _perPageSelectedCount = selected.Count; + } + private async ValueTask> LoadPeopleAsync( BlazorBlueprint.Primitives.DataGrid.DataGridRequest request) { diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor index f9d9b5d83..44b3ff178 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor @@ -109,7 +109,7 @@ Class="pointer-events-none" /> - + @Localizer["DataGrid.SelectAllOnPage", _processedData.Count()] diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs index 9e6d446b3..6abb05087 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs @@ -2426,7 +2426,16 @@ private async Task HandleSelectAllChanged(bool isChecked) { if (!isChecked) { - await HandleClearSelection(); + // In current-page scope the header only governs the current page, so unchecking it + // deselects this page's rows and leaves selections on other pages intact. + if (SelectAllScope == DataGridSelectAllScope.CurrentPage) + { + await HandleDeselectCurrentPage(); + } + else + { + await HandleClearSelection(); + } return; } @@ -2440,6 +2449,21 @@ private async Task HandleSelectAllChanged(bool isChecked) await HandleSelectAllOnCurrentPage(); } + private async Task HandleDeselectCurrentPage() + { + _gridState.Selection.DeselectAll(_processedData); + _selectAllDropdownOpen = false; + _stateVersion++; + + if (SelectedItemsChanged.HasDelegate) + { + await SelectedItemsChanged.InvokeAsync(_gridState.Selection.SelectedItems); + } + + await NotifyStateChangedAsync(); + StateHasChanged(); + } + private async Task HandleSelectAllOnCurrentPage() { foreach (var item in _processedData) @@ -2459,6 +2483,26 @@ private async Task HandleSelectAllOnCurrentPage() StateHasChanged(); } + private async Task HandleSelectOnlyCurrentPage() + { + // From the multi-page menu, "select all on this page" is an exclusive choice: it replaces the + // entire selection (including rows on other pages) with just the current page's rows. This is + // distinct from the additive current-page header checkbox used by DataGridSelectAllScope.CurrentPage. + _gridState.Selection.Clear(); + _gridState.Selection.SelectAll(_processedData); + + _selectAllDropdownOpen = false; + _stateVersion++; + + if (SelectedItemsChanged.HasDelegate) + { + await SelectedItemsChanged.InvokeAsync(_gridState.Selection.SelectedItems); + } + + await NotifyStateChangedAsync(); + StateHasChanged(); + } + private async Task HandleSelectAllItems() { foreach (var item in _allSortedData) @@ -2546,8 +2590,14 @@ public async Task OnColumnReordered(string columnId, int newIndex) StateHasChanged(); } + private DataGridSelectAllScope SelectAllScope => + _columns.OfType>().FirstOrDefault()?.SelectAllScope + ?? DataGridSelectAllScope.Prompt; + private bool ShouldShowSelectAllPrompt() => - _allSortedData.Any() && _gridState.Pagination.TotalItems > _processedData.Count(); + SelectAllScope == DataGridSelectAllScope.Prompt + && _allSortedData.Any() + && _gridState.Pagination.TotalItems > _processedData.Count(); private async Task HandleRowSelectionChanged(TData item, bool isChecked) { diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridSelectColumn.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridSelectColumn.razor.cs index 1a42182b5..046e315af 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridSelectColumn.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridSelectColumn.razor.cs @@ -40,6 +40,15 @@ public partial class BbDataGridSelectColumn : ComponentBase, IDataGridCol [Parameter] public string? HeaderClass { get; set; } + /// + /// Controls the select-all header checkbox behaviour when the grid is paginated. + /// (the default) offers a "this page / all items" + /// menu across pages; removes the menu and makes + /// the header checkbox toggle only the current page's rows, leaving other pages' selections intact. + /// + [Parameter] + public DataGridSelectAllScope SelectAllScope { get; set; } = DataGridSelectAllScope.Prompt; + /// /// The parent DataGrid component. Set via cascading parameter. /// diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/DataGridSelectAllScope.cs b/src/BlazorBlueprint.Components/Components/DataGrid/DataGridSelectAllScope.cs new file mode 100644 index 000000000..314bcfdb5 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/DataGrid/DataGridSelectAllScope.cs @@ -0,0 +1,20 @@ +namespace BlazorBlueprint.Components; + +/// +/// Controls how the select-all header checkbox of a +/// behaves when the grid is paginated. +/// +public enum DataGridSelectAllScope +{ + /// + /// The default. When more than one page of data exists, clicking the header checkbox opens a + /// menu offering "select all on this page" and "select all items" (across every page). + /// + Prompt, + + /// + /// No menu is shown. The header checkbox toggles only the rows on the current page, leaving any + /// selections on other pages untouched. + /// + CurrentPage +} diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index b5ff786b4..f0b248cc4 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -784,6 +784,7 @@ - CellClass : String - HeaderClass : String - Pinned : ColumnPinning + - SelectAllScope : DataGridSelectAllScope - Width : String - ParentGrid : BbDataGrid [CascadingParameter] @@ -3589,6 +3590,10 @@ - None = 0 - Grid = 1 +### DataGridSelectAllScope (BlazorBlueprint.Components) + - Prompt = 0 + - CurrentPage = 1 + ### DataTableSelectionMode (BlazorBlueprint.Components) - None = 0 - Single = 1 From 2d7a91f5ae880b5e3d4f2e352898b366dede075a Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Sun, 14 Jun 2026 18:54:54 +0800 Subject: [PATCH 097/188] docs: update CHANGELOG for 2026-06-14 changes --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c85055603..d2f1289e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## 2026-06-14 + +### Added + +- **BbAccordionTrigger: custom icon** — New `Icon` parameter (`RenderFragment`) renders a custom icon in place of the default chevron; the `bool` context exposes the open state for animation, and it falls back to the chevron when unset. ([#347](https://github.com/blazorblueprintui/ui/pull/347)) +- **BbThemeSwitcher: Variant parameter** — The trigger button was hard-coded to `Outline`; a new `Variant` parameter (default `Outline`) lets it match the surrounding UI. ([#363](https://github.com/blazorblueprintui/ui/pull/363)) +- **BbCombobox & BbMultiSelect: consistent trigger height + MultiSelect single-line mode** — Combobox and MultiSelect triggers now use the `h-10` (40px) house height to align with `BbSelect`/`BbInput`/`BbButton` (were `h-9`/`min-h-9`). `BbMultiSelect` also gains a `SingleLine` option that keeps the trigger on one fixed-height row — overflowing tags clip while "+N more" and the chevron stay pinned. ([#366](https://github.com/blazorblueprintui/ui/pull/366)) +- **BbDataGridSelectColumn: SelectAllScope** — New `SelectAllScope` parameter; `CurrentPage` removes the cross-page select-all menu so the header checkbox toggles only the current page, leaving other pages' selections intact. ([#367](https://github.com/blazorblueprintui/ui/pull/367)) + +### Fixed + +- **BbInputOTP: OnComplete never fired when typing** — The completion check was gated on `!newValue.Contains("")`, which is always false, so `OnComplete` never fired on manual entry (paste worked). It now fires once every slot is filled. ([#362](https://github.com/blazorblueprintui/ui/pull/362)) +- **BbDialog: ShowClose ignored for programmatic dialogs** — Dialogs opened via `DialogService.OpenAsync` never rendered a close button, so `DialogOpenOptions.ShowClose` had no effect. They now render a close (×) button honoring `ShowClose`. ([#364](https://github.com/blazorblueprintui/ui/pull/364)) +- **BbSidebarMenuButton & BbSidebarMenuSubButton: stuck active with `Href="/"`** — Active state was `IsActive || location-match`, so an explicit `IsActive="false"` could never deactivate an item whose `Href` matched the URL (most visibly `Href="/"`). `IsActive` is now `bool?` — an explicit value wins; `null` keeps automatic location matching. ([#365](https://github.com/blazorblueprintui/ui/pull/365)) +- **BbDataGrid: "select all on this page" was additive** — In the paginated select-all menu, "select all on this page" now replaces the whole selection with just the current page instead of leaving other pages selected. ([#367](https://github.com/blazorblueprintui/ui/pull/367)) + +--- + ## 2026-06-08 ### Fixed From 749ba8e7d3506322cfefbd199f621481c50c1d6c Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Sun, 14 Jun 2026 19:03:19 +0800 Subject: [PATCH 098/188] chore: bump BlazorBlueprint.Primitives to 3.12.0 --- .../BlazorBlueprint.Components.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj index 0b436d533..bd6ba099a 100644 --- a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj +++ b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj @@ -58,7 +58,7 @@ - + From 74d72b8f305bb99344cdd7dfd8d1cd5012607cde Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Sun, 14 Jun 2026 19:26:37 +0800 Subject: [PATCH 099/188] docs: release notes for Components v3.12.0 --- .../RELEASE_NOTES.md | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/BlazorBlueprint.Components/RELEASE_NOTES.md b/src/BlazorBlueprint.Components/RELEASE_NOTES.md index c6b1e830a..f8b36ef58 100644 --- a/src/BlazorBlueprint.Components/RELEASE_NOTES.md +++ b/src/BlazorBlueprint.Components/RELEASE_NOTES.md @@ -1,19 +1,23 @@ -## What's New in v3.11.0 +## What's New in v3.12.0 + +### Breaking Changes + +- **BbSidebarMenuButton / BbSidebarMenuSubButton**: `IsActive` is now nullable (`bool?`) — update two-way bindings to a nullable field (#365). ### New Features -- **BbDataView**: Added `ItemsProvider` for server-side / lazy data loading (#306). -- **BbCommandVirtualizedGroup**: Added `ItemsProvider` for server-side lazy loading (#345). -- **BbDataGridSelectColumn**: Added `CellClass` and `HeaderClass` parameters to style the selection cells and header (#346). -- **BbPopover**: Added `RestoreFocusOnClose` parameter to return focus to the trigger on controlled close (#349). +- **BbDataGridSelectColumn**: Added `SelectAllScope` (with new `DataGridSelectAllScope` enum) to make the header checkbox toggle only the current page instead of prompting across pages (#367). +- **BbMultiSelect**: Added `SingleLine` to keep the trigger at a fixed height, clipping overflowing tags while pinning the "+N more" indicator and chevron (#366). +- **BbAccordionTrigger**: Added `Icon` (`RenderFragment`) to replace the default chevron with a custom icon, with the open state passed as context (#347). +- **BbThemeSwitcher**: Added `Variant` to set the trigger button's visual variant (defaults to `Outline`) (#363). ### Bug Fixes -- **Combobox / MultiSelect**: Restore focus to the trigger on close so Tab navigation continues (#349). -- **Combobox**: Show the selected item's label for pre-bound values in compositional mode (#337, #343). -- **BbSidebarMenuButton / BbSidebarMenuSubButton**: Set `data-active` on menu links during navigation (#324). -- **Spinner**: Keep spinners and pulse indicators animating under `bb-no-animate` (#330). +- **BbSidebarMenuButton / BbSidebarMenuSubButton**: An explicit `IsActive` now overrides automatic location matching, so `IsActive="false"` keeps an item inactive even when its href matches the URL (#365). +- **Dialog**: The close (X) button now honors `ShowClose` on programmatically opened dialogs (#364). +- **BbInputOTP**: `OnComplete` now fires when the final slot is filled by manual entry (#362). ### Improvements -- Bumped the `BlazorBlueprint.Primitives` dependency to 3.11.0. +- **BbCombobox / BbMultiSelect**: Aligned trigger heights to `h-10` to match other form controls (#366). +- **BbDataGrid**: "Select all on this page" from the multi-page menu is now an exclusive action that replaces the entire selection, including rows on other pages (#367). From e8643657f020c19797502ab519419668323cbfd9 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 17 Jun 2026 19:11:42 +0800 Subject: [PATCH 100/188] feat(calendar): make outside-month days clickable Previous/next-month "outside" days were marked disabled purely for belonging to another month, which set disabled on the button and blocked selection. Separate "outside the displayed month" from "actually disabled" so outside days keep their muted styling but are selectable. Selecting an outside day navigates the displayed month to bring it into view. Days that are genuinely disabled (Min/MaxDate, DisabledDates) remain non-interactive. --- .../Components/Calendar/BbCalendar.razor | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/BlazorBlueprint.Components/Components/Calendar/BbCalendar.razor b/src/BlazorBlueprint.Components/Components/Calendar/BbCalendar.razor index b3922a3ec..656cfd609 100644 --- a/src/BlazorBlueprint.Components/Components/Calendar/BbCalendar.razor +++ b/src/BlazorBlueprint.Components/Components/Calendar/BbCalendar.razor @@ -86,7 +86,8 @@ @foreach (var day in week) { var isSelected = day.HasValue && Selected.HasValue && day.Value.Date == Selected.Value.Date; - var isDisabled = day.HasValue && (IsDisabled(day.Value) || day.Value.Month != DisplayDate.Month); + var isOutside = day.HasValue && (day.Value.Month != DisplayDate.Month || day.Value.Year != DisplayDate.Year); + var isDisabled = day.HasValue && IsDisabled(day.Value); var isFocused = day.HasValue && _focusedDate.HasValue && day.Value.Date == _focusedDate.Value.Date; MonthNames is { Length: 12 } ? MonthNames[month - 1] @@ -611,6 +614,13 @@ _isKeyboardNavigating = false; _focusedDate = date; + // When an outside (previous/next month) day is selected, bring it into view + // by navigating the displayed month to match the selected date. + if (date.Month != DisplayDate.Month || date.Year != DisplayDate.Year) + { + DisplayDate = new DateTime(date.Year, date.Month, 1); + } + Selected = date; await SelectedChanged.InvokeAsync(date); await OnSelect.InvokeAsync(date); @@ -688,13 +698,15 @@ return string.IsNullOrEmpty(CellClass) ? baseClass : ClassNames.cn(baseClass, CellClass); } - private string GetDayClasses(DateTime date, bool isSelected, bool isDisabled, bool isFocused) + private string GetDayClasses(DateTime date, bool isSelected, bool isDisabled, bool isFocused, bool isOutside) { string baseClass; if (isSelected) baseClass = DaySelectedClasses; else if (isDisabled) baseClass = DayDisabledClasses; + else if (isOutside) + baseClass = DayOutsideClasses; else if (date.Date == DateTime.Today) baseClass = DayTodayClasses; else From ca4de17cbe1db094fd014d80c752e851b84217d0 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 17 Jun 2026 20:18:19 +0800 Subject: [PATCH 101/188] feat(calendar): focus active day so keyboard nav works on open The calendar's keydown handler lives on the grid, so arrow keys only work once a day has focus. Nothing focused a day when the calendar appeared, so in a date picker popover focus stayed on the trigger and arrow keys just scrolled the page. Add an opt-in AutoFocus parameter that focuses the active day on first render (for inline/revealed calendars) and a public FocusActiveDayAsync() method for overlays that position content asynchronously. Wire the date picker to call it from the popover's content-ready callback, re-arming on each open so reopening refocuses the active day. --- .../Components/Calendar/auto-focus.txt | 1 + .../Pages/Components/CalendarDemo.razor | 34 ++++++++++++++++++ .../Components/Calendar/BbCalendar.razor | 35 ++++++++++++++++++ .../Components/DatePicker/BbDatePicker.razor | 36 +++++++++++++++++-- ...entsApiSurfaceMatchesBaseline.verified.txt | 1 + 5 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Calendar/auto-focus.txt diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Calendar/auto-focus.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Calendar/auto-focus.txt new file mode 100644 index 000000000..44d0a26ae --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Calendar/auto-focus.txt @@ -0,0 +1 @@ + diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CalendarDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CalendarDemo.razor index 7d3d4a50e..aaa2a4c5f 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CalendarDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CalendarDemo.razor @@ -124,6 +124,35 @@
    + +
    +
    +

    Auto Focus

    +

    + Set AutoFocus to move keyboard focus to the active day as soon as the calendar + renders, so arrow keys work immediately without clicking first. Ideal when the calendar + appears in a popover or dialog (the date picker uses this internally). Leave it off for + always-visible inline calendars to avoid stealing focus on page load. +

    +
    +
    + + @(_showAutoFocusCalendar ? "Hide calendar" : "Show calendar") + + @if (_showAutoFocusCalendar) + { +
    + +
    +

    + Focus lands on the active day — use the arrow keys straight away. Selected: + @(_autoFocusDate?.ToString("MMMM d, yyyy") ?? "None") +

    + } +
    + +
    + Uses role="grid" for the calendar table @@ -179,6 +208,9 @@ Whether to show month/year dropdown selectors. When false, displays a simple text header. + + Whether to move keyboard focus to the active day when the calendar first renders, so arrow-key navigation works immediately. Useful for calendars shown in a popover or dialog. + Callback when a date is selected. @@ -210,6 +242,8 @@ private DateTime? _noWeekendsDate; private DateTime? _sundayStart; private DateTime? _mondayStart; + private DateTime? _autoFocusDate; + private bool _showAutoFocusCalendar; private DateTime _minDate = DateTime.Today; private DateTime _maxDate = DateTime.Today.AddDays(30); diff --git a/src/BlazorBlueprint.Components/Components/Calendar/BbCalendar.razor b/src/BlazorBlueprint.Components/Components/Calendar/BbCalendar.razor index 656cfd609..3b4ef2701 100644 --- a/src/BlazorBlueprint.Components/Components/Calendar/BbCalendar.razor +++ b/src/BlazorBlueprint.Components/Components/Calendar/BbCalendar.razor @@ -225,6 +225,17 @@ [Parameter] public bool ShowMonthYearDropdowns { get; set; } = true; + /// + /// Whether to move keyboard focus to the active day when the calendar first renders, + /// so arrow-key navigation works immediately. Best for calendars that become visible + /// on demand (e.g. revealed inline). For calendars inside a portal-based popover or + /// dialog, call from the container's content-ready + /// event instead, since first render happens before the overlay is positioned. + /// Leave false for always-visible inline calendars to avoid stealing focus on load. + /// + [Parameter] + public bool AutoFocus { get; set; } + /// /// Additional CSS classes to apply to the calendar. /// @@ -303,6 +314,30 @@ } } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + // When requested, move focus to the active day on first render so an inline + // calendar is immediately keyboard-navigable. Calendars rendered inside a + // portal-based popover/dialog should instead call FocusActiveDayAsync() once + // their container reports it is ready (the date picker does this). + if (firstRender && AutoFocus) + { + await FocusActiveDayAsync(); + } + } + + /// + /// Moves keyboard focus to the currently active day so the calendar is ready for + /// arrow-key navigation. Call this after the calendar has become visible — for + /// example from a popover's content-ready callback when embedding the calendar in + /// your own overlay. + /// + public async Task FocusActiveDayAsync() + { + _focusedDate ??= GetFirstEnabledDayOfMonth(); + await FocusCurrentButton(); + } + private void InitializeFocusedDate() { // Priority: Selected date > Today (if in current month) > First day of month diff --git a/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePicker.razor b/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePicker.razor index f6aad2ab7..1f0efdeda 100644 --- a/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePicker.razor +++ b/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePicker.razor @@ -8,7 +8,7 @@ Date Picker component - combines a button trigger with a calendar popover. *@ - + - - + /// Gets the computed CSS classes for the trigger button. ///
    diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index f0b248cc4..5fda6dda8 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -246,6 +246,7 @@ ### BbCalendar (BlazorBlueprint.Components) - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - AutoFocus : Boolean - CellClass : String - Class : String - CustomDayNames : String[] From 2f40351e61c8ef7c9f3a7e44274840685a477bea Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 17 Jun 2026 21:57:10 +0800 Subject: [PATCH 102/188] docs: changelog for calendar outside-day selection and keyboard focus --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f1289e4..1c181b8c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## 2026-06-17 + +### Added + +- **BbCalendar: selectable previous/next-month days** — "Outside" days shown for the adjacent months were rendered disabled purely for belonging to another month; they now keep their muted styling but are selectable. Clicking one selects that date and moves the view to its month. Dates disabled via `MinDate`/`MaxDate`/`DisabledDates` stay non-interactive. ([#370](https://github.com/blazorblueprintui/ui/pull/370)) +- **BbCalendar: `AutoFocus` parameter + `FocusActiveDayAsync()`** — New opt-in `AutoFocus` parameter moves focus to the active day on first render so arrow-key navigation works immediately (ideal for inline/revealed calendars), plus a public `FocusActiveDayAsync()` method for overlays that position their content asynchronously. ([#370](https://github.com/blazorblueprintui/ui/pull/370)) + +### Fixed + +- **BbDatePicker: keyboard navigation didn't work when the calendar opened** — The calendar grid's key handler only runs once a day has focus, but opening the date-picker popover left focus on the trigger, so arrow keys scrolled the page instead of moving between days. The date picker now focuses the active day once the popover is positioned (via the calendar's new `FocusActiveDayAsync()`), re-focusing on each open. ([#370](https://github.com/blazorblueprintui/ui/pull/370)) + +--- + ## 2026-06-14 ### Added From c02bd5dc9620ee6678ef65efa6ab91e6cb836f48 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 17 Jun 2026 22:12:35 +0800 Subject: [PATCH 103/188] docs: release notes for Components v3.12.1 --- .../RELEASE_NOTES.md | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/src/BlazorBlueprint.Components/RELEASE_NOTES.md b/src/BlazorBlueprint.Components/RELEASE_NOTES.md index f8b36ef58..64cb4f5aa 100644 --- a/src/BlazorBlueprint.Components/RELEASE_NOTES.md +++ b/src/BlazorBlueprint.Components/RELEASE_NOTES.md @@ -1,23 +1,10 @@ -## What's New in v3.12.0 - -### Breaking Changes - -- **BbSidebarMenuButton / BbSidebarMenuSubButton**: `IsActive` is now nullable (`bool?`) — update two-way bindings to a nullable field (#365). +## What's New in v3.12.1 ### New Features -- **BbDataGridSelectColumn**: Added `SelectAllScope` (with new `DataGridSelectAllScope` enum) to make the header checkbox toggle only the current page instead of prompting across pages (#367). -- **BbMultiSelect**: Added `SingleLine` to keep the trigger at a fixed height, clipping overflowing tags while pinning the "+N more" indicator and chevron (#366). -- **BbAccordionTrigger**: Added `Icon` (`RenderFragment`) to replace the default chevron with a custom icon, with the open state passed as context (#347). -- **BbThemeSwitcher**: Added `Variant` to set the trigger button's visual variant (defaults to `Outline`) (#363). +- **BbCalendar**: Outside-month (previous/next) days are now selectable; choosing one navigates the displayed month to bring it into view, while genuinely disabled days (Min/MaxDate, DisabledDates) stay non-interactive. +- **BbCalendar**: Added an opt-in `AutoFocus` parameter and a public `FocusActiveDayAsync()` method to move keyboard focus to the active day so arrow-key navigation works immediately. ### Bug Fixes -- **BbSidebarMenuButton / BbSidebarMenuSubButton**: An explicit `IsActive` now overrides automatic location matching, so `IsActive="false"` keeps an item inactive even when its href matches the URL (#365). -- **Dialog**: The close (X) button now honors `ShowClose` on programmatically opened dialogs (#364). -- **BbInputOTP**: `OnComplete` now fires when the final slot is filled by manual entry (#362). - -### Improvements - -- **BbCombobox / BbMultiSelect**: Aligned trigger heights to `h-10` to match other form controls (#366). -- **BbDataGrid**: "Select all on this page" from the multi-page menu is now an exclusive action that replaces the entire selection, including rows on other pages (#367). +- **BbDatePicker**: Focuses the active day when the popover opens, so arrow keys navigate the calendar right away instead of leaving focus on the trigger. From d2a8859e98c51d97cd18ea507d5f7ce2915b8881 Mon Sep 17 00:00:00 2001 From: garrenf <144177629+garrenf@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:06:18 -0500 Subject: [PATCH 104/188] feat: add BbCopyText component Introduce a new BbCopyText component (Razor + code-behind) that copies a provided Value to the clipboard via JS interop, displays a hover tooltip with changing icon/text state, and exposes Class and OnCopied parameters. Add demo page and example code snippets and register the new page in the demo sidebar. Also add localization entries for the component tooltips and ensure proper disposal of the clipboard JS module. --- .../Components/CopyText/basic.txt | 1 + .../Components/CopyText/custom-styling.txt | 5 + .../Components/CopyText/event-callback.txt | 7 + .../Pages/Components/CopyTextDemo.razor | 80 +++++++++++ .../Shared/DemoSidebar.razor | 5 + .../Components/CopyText/BbCopyText.razor | 28 ++++ .../Components/CopyText/BbCopyText.razor.cs | 129 ++++++++++++++++++ .../Localization/DefaultBbLocalizer.cs | 4 + 8 files changed, 259 insertions(+) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/basic.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/custom-styling.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/event-callback.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor create mode 100644 src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor create mode 100644 src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/basic.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/basic.txt new file mode 100644 index 000000000..b4423d567 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/basic.txt @@ -0,0 +1 @@ +Use code 20OFF to receive a 20% discount on your next order. diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/custom-styling.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/custom-styling.txt new file mode 100644 index 000000000..ba543ca6f --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/custom-styling.txt @@ -0,0 +1,5 @@ +Use code + + 20OFF + + to receive a 20% discount on your next order. diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/event-callback.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/event-callback.txt new file mode 100644 index 000000000..6e696620a --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/event-callback.txt @@ -0,0 +1,7 @@ +@inject ToastService ToastService + +Use code + + 20OFF + + to receive a 20% discount on your next order. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor new file mode 100644 index 000000000..ad63ccbfe --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor @@ -0,0 +1,80 @@ +@page "/components/copy-text" +@inject ToastService ToastService + +Copy Text Component - Blazor Blueprint + +
    +
    +
    +

    Copy Text Component

    +

    + Displays inline text that copies text to the clipboard when clicked. +

    +
    +
    + + +
    +
    +

    Basic Example

    +

    + A simple inline text that copies text to the clipboard when clicked. +

    +
    +
    + Use code 20OFF to receive a 20% discount on your next order. +
    + +
    + + +
    +
    +

    Custom Styling

    +

    + A custom-styled text. +

    +
    +
    + Use code 20OFF + to receive a 20% discount on your next order. +
    + +
    + + +
    +
    +

    Event Callback

    +

    + An example showing a toast when text is copied. +

    +
    +
    + Use code 20OFF + to receive a 20% discount on your next order. +
    + +
    + + +
    +
    +

    API Reference

    +

    Component properties and parameters.

    +
    +
    + + + The text to copy to clipboard when clicked. + + + Additional CSS classes to apply to the container. + + + Callback invoked when the user clicks the component to copy text. + + +
    +
    +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor b/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor index 00b510530..51bfe2456 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor @@ -332,6 +332,11 @@ Combobox + + + Copy Text + + Currency Input diff --git a/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor b/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor new file mode 100644 index 000000000..c792267c8 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor @@ -0,0 +1,28 @@ +@namespace BlazorBlueprint.Components +@using BlazorBlueprint.Icons.Lucide.Components + + + + @ChildContent + + + + + @CurrentTooltipText + + + + +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs b/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs new file mode 100644 index 000000000..2974726b3 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs @@ -0,0 +1,129 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace BlazorBlueprint.Components; + +/// +/// Displays a short, highlighted piece of text that copies a value +/// to the clipboard when clicked. +/// +public partial class BbCopyText : ComponentBase, IAsyncDisposable +{ + private readonly string tooltipId = $"bb-copytext-{Guid.NewGuid():N}"; + private IJSObjectReference? clipboardModule; + private bool isHovered; + private bool copied; + + [Inject] + private IJSRuntime JS { get; set; } = default!; + + [Inject] + private IBbLocalizer Localizer { get; set; } = default!; + + /// + /// Sets the value to be copied to the clipboard when clicked. + /// + [Parameter, EditorRequired] + public string? Value { get; set; } + + /// + /// Additional CSS classes to apply to the text. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Callback invoked when the user clicks the component to copy text. + /// + [Parameter] + public EventCallback OnCopied { get; set; } + + /// + /// Gets or sets additional HTML attributes to apply to the container. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private bool TooltipVisible => isHovered; + + private string CurrentIconName => copied ? "check" : "copy"; + + private string CurrentTooltipText => copied ? Localizer["CopyText.Copied"] : Localizer["CopyText.ClickToCopy"]; + + private string TooltipIconCssClass => copied ? "h-3 w-3 text-alert-success" : "h-3 w-3 text-primary"; + + private string TooltipTextCssClass => copied ? "text-alert-success" : "text-foreground"; + + private string? TextCssClass => ClassNames.cn( + "relative inline-flex gap-1 items-center cursor-pointer text-primary font-semibold", + Class); + + private string? TooltipCssClass => ClassNames.cn( + "pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 inline-flex " + + "-translate-x-1/2 items-center gap-1.5 whitespace-nowrap rounded-md border " + + "bg-popover px-2.5 py-1 text-xs font-medium shadow-md ", + TooltipVisible ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0"); + + private void HandleMouseEnter() + { + isHovered = true; + + if (copied) + { + copied = false; + } + } + + private void HandleMouseLeave() + { + isHovered = false; + } + + private async Task HandleClickAsync() + { + var success = await CopyToClipboardAsync(Value ?? string.Empty); + if (!success) + { + return; + } + + copied = true; + + if (OnCopied.HasDelegate) + { + await OnCopied.InvokeAsync(Value); + } + } + + private async Task CopyToClipboardAsync(string text) + { + try + { + clipboardModule ??= await JS.InvokeAsync( + "import", "./_content/BlazorBlueprint.Components/js/clipboard.js"); + return await clipboardModule.InvokeAsync("copyToClipboard", text); + } + catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) + { + return false; + } + } + + /// + public async ValueTask DisposeAsync() + { + if (clipboardModule is not null) + { + try + { + await clipboardModule.DisposeAsync(); + } + catch (JSDisconnectedException) + { + // Circuit already gone; nothing to clean up. + } + } + + GC.SuppressFinalize(this); + } +} diff --git a/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs b/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs index 02e4f014f..6b495a163 100644 --- a/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs +++ b/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs @@ -43,6 +43,10 @@ public class DefaultBbLocalizer : IBbLocalizer ["Command.CommandMenu"] = "Command menu", ["Command.CommandList"] = "Command list", + // CopyText + ["CopyText.Copied"] = "Copied!", + ["CopyText.ClickToCopy"] = "Click to copy", + // DashboardGrid ["DashboardGrid.Loading"] = "Loading dashboard", ["DashboardGrid.NoWidgets"] = "No widgets to display", From ac90247c7326487c8bf327d65af3633bcfc6a253 Mon Sep 17 00:00:00 2001 From: garrenf <144177629+garrenf@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:12:50 -0500 Subject: [PATCH 105/188] fix(copy-text): remove redundant code Removed 'TooltipVisible' which was pointing directly to isHovered. --- .../Components/CopyText/BbCopyText.razor.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs b/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs index 2974726b3..2441ac6b2 100644 --- a/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs +++ b/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs @@ -44,13 +44,11 @@ public partial class BbCopyText : ComponentBase, IAsyncDisposable [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } - private bool TooltipVisible => isHovered; - private string CurrentIconName => copied ? "check" : "copy"; private string CurrentTooltipText => copied ? Localizer["CopyText.Copied"] : Localizer["CopyText.ClickToCopy"]; - private string TooltipIconCssClass => copied ? "h-3 w-3 text-alert-success" : "h-3 w-3 text-primary"; + private string TooltipIconCssClass => copied && isHovered ? "h-3 w-3 text-alert-success" : "h-3 w-3 text-primary"; private string TooltipTextCssClass => copied ? "text-alert-success" : "text-foreground"; @@ -62,7 +60,7 @@ public partial class BbCopyText : ComponentBase, IAsyncDisposable "pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 inline-flex " + "-translate-x-1/2 items-center gap-1.5 whitespace-nowrap rounded-md border " + "bg-popover px-2.5 py-1 text-xs font-medium shadow-md ", - TooltipVisible ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0"); + isHovered ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0"); private void HandleMouseEnter() { From 1572faa255d37538608445ab2fb5c27b2dd1d2c2 Mon Sep 17 00:00:00 2001 From: garrenf <144177629+garrenf@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:14:31 -0500 Subject: [PATCH 106/188] fix(copy-text): added Copy Text to the components page --- .../Pages/Components/Index.razor | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/Index.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/Index.razor index 175862ee3..c04066f14 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/Index.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/Index.razor @@ -203,6 +203,14 @@

    + + +

    Copy Text

    +

    + Inline text that copies text to the clipboard when clicked +

    +
    +

    Command

    From 40f6d23b667e94ace14bff100566f118bfcf0563 Mon Sep 17 00:00:00 2001 From: Hogo <64896329+HugoVG@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:58:08 +0200 Subject: [PATCH 107/188] feat(Attachment): Added attachments components feat(Bubble): Added bubble components feat(Messages): Added message components feat(Marker): Added marker components --- .../Components/Attachment/basic.txt | 14 + .../Components/Attachment/group-trigger.txt | 30 ++ .../Attachment/sizes-orientation.txt | 27 ++ .../Components/Attachment/states.txt | 60 ++++ .../Components/Bubble/alignment.txt | 7 + .../Components/Bubble/interactive.txt | 7 + .../Components/Bubble/reactions.txt | 13 + .../Components/Bubble/variants.txt | 7 + .../CodeExamples/Components/Marker/border.txt | 18 ++ .../Components/Marker/interactive.txt | 12 + .../Components/Marker/variants.txt | 21 ++ .../Components/Marker/with-icon.txt | 18 ++ .../Components/Message/alignment.txt | 22 ++ .../CodeExamples/Components/Message/basic.txt | 13 + .../CodeExamples/Components/Message/group.txt | 24 ++ .../Components/Message/reactions.txt | 34 +++ .../Pages/Components/AttachmentDemo.razor | 263 ++++++++++++++++ .../Pages/Components/BubbleDemo.razor | 175 +++++++++++ .../Pages/Components/MarkerDemo.razor | 176 +++++++++++ .../Pages/Components/MessageDemo.razor | 283 ++++++++++++++++++ .../Components/Attachment/AttachmentEnums.cs | 85 ++++++ .../Components/Attachment/BbAttachment.razor | 10 + .../Attachment/BbAttachment.razor.cs | 64 ++++ .../Attachment/BbAttachmentAction.razor | 9 + .../Attachment/BbAttachmentAction.razor.cs | 39 +++ .../Attachment/BbAttachmentActions.razor | 5 + .../Attachment/BbAttachmentActions.razor.cs | 31 ++ .../Attachment/BbAttachmentContent.razor | 5 + .../Attachment/BbAttachmentContent.razor.cs | 29 ++ .../Attachment/BbAttachmentDescription.razor | 5 + .../BbAttachmentDescription.razor.cs | 33 ++ .../Attachment/BbAttachmentGroup.razor | 5 + .../Attachment/BbAttachmentGroup.razor.cs | 32 ++ .../Attachment/BbAttachmentMedia.razor | 8 + .../Attachment/BbAttachmentMedia.razor.cs | 43 +++ .../Attachment/BbAttachmentTitle.razor | 5 + .../Attachment/BbAttachmentTitle.razor.cs | 32 ++ .../Attachment/BbAttachmentTrigger.razor | 13 + .../Attachment/BbAttachmentTrigger.razor.cs | 115 +++++++ .../Components/Bubble/BbBubble.razor | 9 + .../Components/Bubble/BbBubble.razor.cs | 55 ++++ .../Components/Bubble/BbBubbleContent.razor | 14 + .../Bubble/BbBubbleContent.razor.cs | 147 +++++++++ .../Components/Bubble/BbBubbleGroup.razor | 5 + .../Components/Bubble/BbBubbleGroup.razor.cs | 29 ++ .../Components/Bubble/BbBubbleReactions.razor | 9 + .../Bubble/BbBubbleReactions.razor.cs | 46 +++ .../Components/Bubble/BubbleEnums.cs | 49 +++ .../Components/Bubble/BubbleVariant.cs | 42 +++ .../Components/Marker/BbMarker.razor | 15 + .../Components/Marker/BbMarker.razor.cs | 163 ++++++++++ .../Components/Marker/BbMarkerContent.razor | 5 + .../Marker/BbMarkerContent.razor.cs | 29 ++ .../Components/Marker/BbMarkerIcon.razor | 8 + .../Components/Marker/BbMarkerIcon.razor.cs | 29 ++ .../Components/Marker/MarkerVariant.cs | 22 ++ .../Components/Message/BbMessage.razor | 10 + .../Components/Message/BbMessage.razor.cs | 39 +++ .../Components/Message/BbMessageAvatar.razor | 5 + .../Message/BbMessageAvatar.razor.cs | 32 ++ .../Components/Message/BbMessageContent.razor | 5 + .../Message/BbMessageContent.razor.cs | 31 ++ .../Components/Message/BbMessageFooter.razor | 5 + .../Message/BbMessageFooter.razor.cs | 36 +++ .../Components/Message/BbMessageGroup.razor | 5 + .../Message/BbMessageGroup.razor.cs | 29 ++ .../Components/Message/BbMessageHeader.razor | 5 + .../Message/BbMessageHeader.razor.cs | 31 ++ .../Components/Message/MessageAlign.cs | 17 ++ 69 files changed, 2718 insertions(+) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Attachment/basic.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Attachment/group-trigger.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Attachment/sizes-orientation.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Attachment/states.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Bubble/alignment.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Bubble/interactive.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Bubble/reactions.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Bubble/variants.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Marker/border.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Marker/interactive.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Marker/variants.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Marker/with-icon.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Message/alignment.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Message/basic.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Message/group.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Message/reactions.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/Pages/Components/AttachmentDemo.razor create mode 100644 demos/BlazorBlueprint.Demo.Shared/Pages/Components/BubbleDemo.razor create mode 100644 demos/BlazorBlueprint.Demo.Shared/Pages/Components/MarkerDemo.razor create mode 100644 demos/BlazorBlueprint.Demo.Shared/Pages/Components/MessageDemo.razor create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/AttachmentEnums.cs create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/BbAttachment.razor create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/BbAttachment.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentAction.razor create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentAction.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentActions.razor create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentActions.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentContent.razor create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentContent.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentDescription.razor create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentDescription.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentGroup.razor create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentGroup.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentMedia.razor create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentMedia.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTitle.razor create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTitle.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTrigger.razor create mode 100644 src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTrigger.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Bubble/BbBubble.razor create mode 100644 src/BlazorBlueprint.Components/Components/Bubble/BbBubble.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Bubble/BbBubbleContent.razor create mode 100644 src/BlazorBlueprint.Components/Components/Bubble/BbBubbleContent.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Bubble/BbBubbleGroup.razor create mode 100644 src/BlazorBlueprint.Components/Components/Bubble/BbBubbleGroup.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Bubble/BbBubbleReactions.razor create mode 100644 src/BlazorBlueprint.Components/Components/Bubble/BbBubbleReactions.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Bubble/BubbleEnums.cs create mode 100644 src/BlazorBlueprint.Components/Components/Bubble/BubbleVariant.cs create mode 100644 src/BlazorBlueprint.Components/Components/Marker/BbMarker.razor create mode 100644 src/BlazorBlueprint.Components/Components/Marker/BbMarker.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Marker/BbMarkerContent.razor create mode 100644 src/BlazorBlueprint.Components/Components/Marker/BbMarkerContent.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Marker/BbMarkerIcon.razor create mode 100644 src/BlazorBlueprint.Components/Components/Marker/BbMarkerIcon.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Marker/MarkerVariant.cs create mode 100644 src/BlazorBlueprint.Components/Components/Message/BbMessage.razor create mode 100644 src/BlazorBlueprint.Components/Components/Message/BbMessage.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Message/BbMessageAvatar.razor create mode 100644 src/BlazorBlueprint.Components/Components/Message/BbMessageAvatar.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Message/BbMessageContent.razor create mode 100644 src/BlazorBlueprint.Components/Components/Message/BbMessageContent.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Message/BbMessageFooter.razor create mode 100644 src/BlazorBlueprint.Components/Components/Message/BbMessageFooter.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Message/BbMessageGroup.razor create mode 100644 src/BlazorBlueprint.Components/Components/Message/BbMessageGroup.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Message/BbMessageHeader.razor create mode 100644 src/BlazorBlueprint.Components/Components/Message/BbMessageHeader.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Message/MessageAlign.cs diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Attachment/basic.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Attachment/basic.txt new file mode 100644 index 000000000..f77bc7634 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Attachment/basic.txt @@ -0,0 +1,14 @@ + + + + + + sales-dashboard.pdf + PDF · 2.4 MB + + + + + + + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Attachment/group-trigger.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Attachment/group-trigger.txt new file mode 100644 index 000000000..d26cfcc37 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Attachment/group-trigger.txt @@ -0,0 +1,30 @@ + + + + + renderer.tsx + TSX · 12 KB + + + + + + briefing-notes.pdf + PDF · 1.4 MB + + + + + + + + research-summary.pdf + Open preview dialog + + + + + + + + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Attachment/sizes-orientation.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Attachment/sizes-orientation.txt new file mode 100644 index 000000000..2a6bbc249 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Attachment/sizes-orientation.txt @@ -0,0 +1,27 @@ + + + + + + Default attachment + PDF · 2.4 MB + + + + + + + + Small attachment + PDF · 2.4 MB + + + + + + + + Extra small attachment + PDF · 2.4 MB + + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Attachment/states.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Attachment/states.txt new file mode 100644 index 000000000..00cf2c491 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Attachment/states.txt @@ -0,0 +1,60 @@ + + + + + + selected-file.pdf + PDF · 2.4 MB + + + + + + + + + + + + + design-system.zip + Uploading · 64% + + + + + + + + + + + + + market-research.pdf + Processing document + + + + + + + + + + + + + financial-model.xlsx + Upload failed. Try again. + + + + + + + + uploaded-report.pdf + Uploaded · 1.8 MB + + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Bubble/alignment.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Bubble/alignment.txt new file mode 100644 index 000000000..6f113a07a --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Bubble/alignment.txt @@ -0,0 +1,7 @@ + + Start aligned bubble. + + + + End aligned bubble. + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Bubble/interactive.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Bubble/interactive.txt new file mode 100644 index 000000000..2e4abc709 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Bubble/interactive.txt @@ -0,0 +1,7 @@ + + I forgot my password + + + + Open troubleshooting guide + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Bubble/reactions.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Bubble/reactions.txt new file mode 100644 index 000000000..83d0f9d61 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Bubble/reactions.txt @@ -0,0 +1,13 @@ + + I can ship this by Friday. + + 👍🚀 + + + + + Perfect, let's do it. + + 🔥 + + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Bubble/variants.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Bubble/variants.txt new file mode 100644 index 000000000..1bfb90a39 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Bubble/variants.txt @@ -0,0 +1,7 @@ +Default +Secondary +Muted +Tinted +Outline +Destructive +Ghost bubble can use full width for assistant markdown or rich output. diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Marker/border.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Marker/border.txt new file mode 100644 index 000000000..c6272fb45 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Marker/border.txt @@ -0,0 +1,18 @@ + + + + + Switched to release-candidate + + + + + + Reviewed 8 related files + + + + + + Opened implementation notes + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Marker/interactive.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Marker/interactive.txt new file mode 100644 index 000000000..ab7c9cbed --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Marker/interactive.txt @@ -0,0 +1,12 @@ + + + + + View pull request + + + + + + Retry failed upload + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Marker/variants.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Marker/variants.txt new file mode 100644 index 000000000..b033334c8 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Marker/variants.txt @@ -0,0 +1,21 @@ + + + + + Switched to a new branch + + + + + + Thinking... + + + Conversation compacted + + + + + + Searching... + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Marker/with-icon.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Marker/with-icon.txt new file mode 100644 index 000000000..6a635bad5 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Marker/with-icon.txt @@ -0,0 +1,18 @@ + + + + + Switched to release-candidate + + + + + + Explored 4 files + + + + + + Syncing completed + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Message/alignment.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Message/alignment.txt new file mode 100644 index 000000000..fc04ee1e7 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Message/alignment.txt @@ -0,0 +1,22 @@ + + + CN + + + + This message is aligned to the start. + + + + + + + + This message is aligned to the end. + + Delivered + + + ME + + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Message/basic.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Message/basic.txt new file mode 100644 index 000000000..26692d3b1 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Message/basic.txt @@ -0,0 +1,13 @@ + + + + AI + + + + Assistant + + How can I help you today? + + + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Message/group.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Message/group.txt new file mode 100644 index 000000000..f332596c8 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Message/group.txt @@ -0,0 +1,24 @@ + + + + AI + + + + I checked the deployment logs. + + + + + + + AI + + + + The failure came from a missing env variable. + + Read 2m ago + + + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Message/reactions.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Message/reactions.txt new file mode 100644 index 000000000..5e415b259 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Message/reactions.txt @@ -0,0 +1,34 @@ + + + + CN + + + + + Without choosing sides + + ↘️ + + + ↗️ + + + ↙️ + + + ↖️ + + + + + + + + Without choosing sides + + ↘️ + + + + diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/AttachmentDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/AttachmentDemo.razor new file mode 100644 index 000000000..a620c2987 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/AttachmentDemo.razor @@ -0,0 +1,263 @@ +@page "/components/attachment" + +Attachment Component - Blazor Blueprint + +
    +
    +
    +

    Attachment Component

    +

    + Displays files or images with metadata, upload states, actions, and optional trigger overlays. +

    +
    +
    + +
    +
    +

    Basic Attachment

    +

    + Compose media, content, and actions for a file row. +

    +
    +
    +
    + + + + + + sales-dashboard.pdf + PDF · 2.4 MB + + + + + + + +
    +
    + +
    + +
    +
    +

    States

    +

    + Visual state support for upload lifecycle. +

    +
    +
    +
    + + + + + + selected-file.pdf + PDF · 2.4 MB + + + + + + + + + + + + + design-system.zip + Uploading · 64% + + + + + + + + + + + + + market-research.pdf + Processing document + + + + + + + + + + + + + financial-model.xlsx + Upload failed. Try again. + + + + + + + + + + + + + uploaded-report.pdf + Uploaded · 1.8 MB + + + + + + + +
    +
    + +
    + +
    +
    +

    Sizes and Orientation

    +

    + Use Size and Orientation for different contexts. +

    +
    +
    +
    + + + + + + Default attachment + PDF · 2.4 MB + + + + + + + + Small attachment + PDF · 2.4 MB + + + + + + + + Extra small attachment + PDF · 2.4 MB + + +
    +
    + +
    + +
    +
    +

    Group and Trigger

    +

    + Use a horizontal group and full-card trigger overlays while keeping actions clickable. +

    +
    +
    + + + + + + + renderer.tsx + TSX · 12 KB + + + + + + + + briefing-notes.pdf + PDF · 1.4 MB + + + + + + + + + Dialogs + + + + + + + + + + + + + + research-summary.pdf + Open preview dialog + + + + + + + + +
    + +
    + + + + + Label icon-only attachment actions with descriptive aria-label values. + + + BbAttachmentTrigger should always include an aria-label because it has no + visible text. + + + Include failure reason text in BbAttachmentDescription for error states. + + + + +
    +
    +

    API Reference

    +

    Component properties and parameters.

    +
    + + + + Upload state. Options: Idle, Uploading, Processing, Error, Done. + + + Card sizing option. Options: Default, Sm, Xs. + + + Layout direction. Options: Horizontal, Vertical. + + +
    +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/BubbleDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/BubbleDemo.razor new file mode 100644 index 000000000..992e0b0eb --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/BubbleDemo.razor @@ -0,0 +1,175 @@ +@page "/components/bubble" + +Bubble Component - Blazor Blueprint + +
    +
    +
    +

    Bubble Component

    +

    + Displays conversational content surfaces with variants, alignment, and reactions. +

    +
    +
    + +
    +
    +
    + + Hey there! what's up? + + + + Hey! Want to see chat bubbles? + + + + I can group messages, switch sides, and keep the whole thread easy + to scan. + + + 👍 + + + +
    +
    + +
    + +
    +
    +

    Variants

    +

    + Seven variants ranging from strong primary to unframed ghost content. +

    +
    +
    +
    + + Default + + + Secondary + + + Muted + +
    + + Tinted + +
    + + Outline + + + Destructive + + + Ghost bubble can use full width for assistant markdown or rich output. + + +
    +
    + +
    + +
    +
    +

    Alignment

    +

    + Align bubbles to the start or end of the conversation. +

    +
    +
    +
    + + Start aligned bubble. + + + End aligned bubble. + +
    +
    + +
    + +
    +
    +

    Reactions

    +

    + Anchor reaction rows above or below the bubble edge. +

    +
    +
    +
    + + I can ship this by Friday. + + 👍🚀 + + + + Perfect, let's do it. + + 🔥 + + +
    +
    + +
    + +
    +
    +

    Interactive Bubble Content

    +

    + Render content as anchors or buttons with AsChild. +

    +
    +
    + + I forgot my password + + + Open troubleshooting guide + +
    + +
    + + + + + For decorative emoji reaction rows, use role="img" and a descriptive + aria-label. + + + Interactive bubbles should render as native links or buttons. + + + Do not rely on bubble color alone to communicate meaning. + + + + +
    +
    +

    API Reference

    +

    Component properties and parameters.

    +
    + + + + Visual style. Options: Default, Secondary, Muted, Tinted, Outline, Ghost, Destructive. + + + Bubble alignment. Options: Start, End. + + +
    +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/MarkerDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/MarkerDemo.razor new file mode 100644 index 000000000..c9a0086c5 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/MarkerDemo.razor @@ -0,0 +1,176 @@ +@page "/components/marker" + +Marker Component - Blazor Blueprint + +
    +
    +
    +

    Marker Component

    +

    + Displays inline status notes, bordered rows, and labeled separators in conversations. +

    +
    +
    + +
    +
    +

    Variants

    +

    + Use Variant to switch marker layout styles. +

    +
    +
    +
    + + + + + Switched to a new branch + + + + + + Thinking... + + + Conversation compacted + + + + + + Searching... + +
    +
    + +
    + +
    +
    +

    Interactive Markers

    +

    + Render markers as links or buttons with AsChild. +

    +
    +
    + + + + + View pull request + + + + + + Retry failed upload + +
    + +
    + +
    +
    +

    Borders

    +

    + Use the border variant for status rows that + should keep the default marker alignment while separating the next row. + Render markers as links or buttons with . +

    +
    +
    +
    + + + + + Switched to release-candidate + + + + + + Reviewed 8 related files + + + + + + Opened implementation notes + +
    +
    + +
    + +
    +
    +

    With Icon

    +

    + Use MarkerIcon to render an icon alongside the + content. Use flex-col to stack the icon above + the content. +

    +
    +
    +
    + + + + + Switched to release-candidate + + + + + + Explored 4 files + + + + + + Syncing completed + +
    +
    + +
    + + + + + Set role="status" on in-progress markers so updates are announced. + + + BbMarkerIcon is decorative; keep meaning in visible marker text. + + + For interactive markers, use AsChild="a" or AsChild="button" for native + semantics. + + + + +
    +
    +

    API Reference

    +

    Component properties and parameters.

    +
    + + + + Marker visual style. Options: Default, Border, Separator. + + + Render as another element type such as "a" or "button". + + + Additional CSS classes to apply to marker root. + + +
    +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/MessageDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/MessageDemo.razor new file mode 100644 index 000000000..cd67d137f --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/MessageDemo.razor @@ -0,0 +1,283 @@ +@page "/components/message" + +Message Component - Blazor Blueprint + +
    +
    +
    +

    Message Component

    +

    + Lays out avatar, content, header, and footer for conversational message rows. + +

    +
    +
    + +
    +
    +
    + + + + ME + + + + + Deploying to prod real quick. + + + + + + + + CN + + + + + It's 4:55 PM. On a Friday. + + + + + + + + ME + + + + + It's a one-line change. + + + + + + + + + It's always a one-line change 😭. + + + + + + + CN + + + + + Alright, let me take a look. + + 👍 + + + + + + + + Production went down + + + + + + CN is typing... + + + +
    +
    +
    + +
    +
    +

    Basic Composition

    +

    + Build a message using avatar and content slots. +

    +
    +
    +
    + + + + AI + + + + Assistant + + How can I help you today? + + + +
    +
    + +
    + +
    +
    +

    Alignment

    +

    + Use Align for sender and receiver rows. +

    +
    +
    +
    + + + + CN + + + + + This message is aligned to the start. + + + + + + + + ME + + + + + This message is aligned to the end. + + Delivered + + +
    +
    + +
    + +
    +
    +

    Message Group

    +

    + Group consecutive rows from the same sender. +

    +
    +
    +
    + + + + + AI + + + + + I checked the deployment logs. + + + + + + + AI + + + + + The failure came from a missing env variable. + + Read 2m ago + + + +
    +
    + +
    + +
    +
    +

    Reactions

    +

    + Reactions can be put on any bubble, and can be anchored to the top or bottom of the bubble edge. +

    +
    +
    +
    + + + + CN + + + + + Without choosing sides + + ↘️ + + + ↗️ + + + ↙️ + + + ↖️ + + + + + + + + Without choosing sides + + ↘️ + + + + +
    +
    + +
    + + + + + Message is a layout wrapper; place semantic roles on the content it contains. + + + Label icon-only action controls in message footers with aria-label. + + + Pair status updates with a marker using role="status". + + + + +
    +
    +

    API Reference

    +

    Component properties and parameters.

    +
    + + + + Message row alignment. Options: Start, End. + + + Additional CSS classes for the message root. + + +
    +
    diff --git a/src/BlazorBlueprint.Components/Components/Attachment/AttachmentEnums.cs b/src/BlazorBlueprint.Components/Components/Attachment/AttachmentEnums.cs new file mode 100644 index 000000000..cf99867ee --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/AttachmentEnums.cs @@ -0,0 +1,85 @@ +namespace BlazorBlueprint.Components; + +/// +/// Defines upload lifecycle states for an attachment. +/// +public enum AttachmentState +{ + /// + /// Attachment is idle and ready. + /// + Idle, + + /// + /// Attachment is currently uploading. + /// + Uploading, + + /// + /// Attachment is being processed after upload. + /// + Processing, + + /// + /// Attachment upload or processing failed. + /// + Error, + + /// + /// Attachment is fully uploaded and available. + /// + Done +} + +/// +/// Defines size options for attachment cards. +/// +public enum AttachmentSize +{ + /// + /// Default attachment size. + /// + Default, + + /// + /// Small attachment size. + /// + Sm, + + /// + /// Extra small attachment size. + /// + Xs +} + +/// +/// Defines orientation of media and content in an attachment. +/// +public enum AttachmentOrientation +{ + /// + /// Media and content are arranged horizontally. + /// + Horizontal, + + /// + /// Media and content are stacked vertically. + /// + Vertical +} + +/// +/// Defines media rendering modes for attachment media slot. +/// +public enum AttachmentMediaVariant +{ + /// + /// Icon-style media container. + /// + Icon, + + /// + /// Image preview media container. + /// + Image +} diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachment.razor b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachment.razor new file mode 100644 index 000000000..d37ba418a --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachment.razor @@ -0,0 +1,10 @@ +@namespace BlazorBlueprint.Components + +
    + @ChildContent +
    diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachment.razor.cs b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachment.razor.cs new file mode 100644 index 000000000..805ed1672 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachment.razor.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Displays a file or image attachment card with media, metadata, actions, and trigger overlay support. +/// +public partial class BbAttachment : ComponentBase +{ + /// + /// Gets or sets attachment upload state. + /// + [Parameter] + public AttachmentState State { get; set; } = AttachmentState.Done; + + /// + /// Gets or sets attachment size. + /// + [Parameter] + public AttachmentSize Size { get; set; } = AttachmentSize.Default; + + /// + /// Gets or sets layout orientation. + /// + [Parameter] + public AttachmentOrientation Orientation { get; set; } = AttachmentOrientation.Horizontal; + + /// + /// Gets or sets custom classes for the attachment root. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets attachment content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for the attachment root. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn( + "group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-2xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/30 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed", + Orientation == AttachmentOrientation.Vertical ? "w-24 flex-col has-data-[slot=attachment-content]:w-30" : "min-w-40 items-center", + Size switch + { + AttachmentSize.Sm => "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5", + AttachmentSize.Xs => "gap-1.5 rounded-xl text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1", + _ => "gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2", + }, + State switch + { + AttachmentState.Uploading => "border-primary/40 bg-primary/5", + AttachmentState.Processing => "border-primary/40 bg-primary/5", + AttachmentState.Error => "border-destructive/40 bg-destructive/5", + _ => null + }, + Class + ); +} diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentAction.razor b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentAction.razor new file mode 100644 index 000000000..ad324ef5b --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentAction.razor @@ -0,0 +1,9 @@ +@namespace BlazorBlueprint.Components + + + @ChildContent + diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentAction.razor.cs b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentAction.razor.cs new file mode 100644 index 000000000..078497171 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentAction.razor.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Action control for an attachment, rendered as a button. +/// +public partial class BbAttachmentAction : ComponentBase +{ + /// + /// Gets or sets the button variant for the action. + /// + [Parameter] + public ButtonVariant Variant { get; set; } = ButtonVariant.Ghost; + + /// + /// Gets or sets the button size for the action. + /// + [Parameter] + public ButtonSize Size { get; set; } = ButtonSize.IconSmall; + + /// + /// Gets or sets additional classes for the action button. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets action content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for the underlying button. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentActions.razor b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentActions.razor new file mode 100644 index 000000000..6822f5a03 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentActions.razor @@ -0,0 +1,5 @@ +@namespace BlazorBlueprint.Components + +
    + @ChildContent +
    diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentActions.razor.cs b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentActions.razor.cs new file mode 100644 index 000000000..0bc09400b --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentActions.razor.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Container for one or more attachment action controls. +/// +public partial class BbAttachmentActions : ComponentBase +{ + /// + /// Gets or sets custom classes for actions container. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets action controls. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for actions container. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn( + "relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1", + Class); +} diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentContent.razor b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentContent.razor new file mode 100644 index 000000000..749ecb02e --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentContent.razor @@ -0,0 +1,5 @@ +@namespace BlazorBlueprint.Components + +
    + @ChildContent +
    diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentContent.razor.cs b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentContent.razor.cs new file mode 100644 index 000000000..faecd015f --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentContent.razor.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Content wrapper for attachment title and description. +/// +public partial class BbAttachmentContent : ComponentBase +{ + /// + /// Gets or sets custom classes for content wrapper. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets content for attachment metadata. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for content wrapper. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn("max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1", Class); +} diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentDescription.razor b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentDescription.razor new file mode 100644 index 000000000..b4828aee7 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentDescription.razor @@ -0,0 +1,5 @@ +@namespace BlazorBlueprint.Components + +
    + @ChildContent +
    diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentDescription.razor.cs b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentDescription.razor.cs new file mode 100644 index 000000000..19ad52189 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentDescription.razor.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Description slot for attachment metadata such as file size or status. +/// +public partial class BbAttachmentDescription : ComponentBase +{ + /// + /// Gets or sets custom classes for description text. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets description content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for description element. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn( + "mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80", + "w-full", + Class + ); +} diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentGroup.razor b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentGroup.razor new file mode 100644 index 000000000..79c64cae0 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentGroup.razor @@ -0,0 +1,5 @@ +@namespace BlazorBlueprint.Components + +
    + @ChildContent +
    diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentGroup.razor.cs b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentGroup.razor.cs new file mode 100644 index 000000000..91841a1c6 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentGroup.razor.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Lays out attachments in a horizontally scrollable snapping row. +/// +public partial class BbAttachmentGroup : ComponentBase +{ + /// + /// Gets or sets custom classes for the group container. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets attachment items. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for the group container. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn( + "flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start", + Class + ); +} diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentMedia.razor b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentMedia.razor new file mode 100644 index 000000000..85887b32c --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentMedia.razor @@ -0,0 +1,8 @@ +@namespace BlazorBlueprint.Components + +
    + @ChildContent +
    diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentMedia.razor.cs b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentMedia.razor.cs new file mode 100644 index 000000000..8c232357f --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentMedia.razor.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Media slot for an attachment card. +/// +public partial class BbAttachmentMedia : ComponentBase +{ + /// + /// Gets or sets media variant. + /// + [Parameter] + public AttachmentMediaVariant Variant { get; set; } = AttachmentMediaVariant.Icon; + + /// + /// Gets or sets custom classes for the media slot. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets media content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for the media element. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + [CascadingParameter] private BbAttachment? Attachment { get; set; } + + private string CssClass => ClassNames.cn( + "relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5", + Variant == AttachmentMediaVariant.Icon + ? "opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover" + : null, + Class + ); +} diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTitle.razor b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTitle.razor new file mode 100644 index 000000000..de1f6d5a1 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTitle.razor @@ -0,0 +1,5 @@ +@namespace BlazorBlueprint.Components + +
    + @ChildContent +
    diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTitle.razor.cs b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTitle.razor.cs new file mode 100644 index 000000000..ae689275a --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTitle.razor.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Title slot that displays the attachment name. +/// +public partial class BbAttachmentTitle : ComponentBase +{ + /// + /// Gets or sets custom classes for title text. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets title content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for title element. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn( + "block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer", + Class + ); +} diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTrigger.razor b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTrigger.razor new file mode 100644 index 000000000..eed2bb4a1 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTrigger.razor @@ -0,0 +1,13 @@ +@namespace BlazorBlueprint.Components + +@if (!string.IsNullOrEmpty(AsChild)) +{ + +} +else +{ + +} diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTrigger.razor.cs b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTrigger.razor.cs new file mode 100644 index 000000000..979aacd26 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTrigger.razor.cs @@ -0,0 +1,115 @@ +using Microsoft.AspNetCore.Components; +using System.Globalization; + +namespace BlazorBlueprint.Components; + +/// +/// Full-card trigger overlay for an attachment card. +/// +public partial class BbAttachmentTrigger : ComponentBase +{ + /// + /// Gets or sets an element type to render as (for example, "a" or "button"). + /// + [Parameter] + public string? AsChild { get; set; } + + /// + /// Gets or sets href when rendering as an anchor. + /// + [Parameter] + public string? Href { get; set; } + + /// + /// Gets or sets custom classes for the trigger element. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Captures additional attributes for the trigger element. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn( + "absolute inset-0 z-10 outline-none", + Class + ); + + private Type GetElementType() + { + return AsChild?.ToLower(CultureInfo.InvariantCulture) switch + { + "a" => typeof(AnchorElement), + _ => typeof(ButtonElement) + }; + } + + private Dictionary GetElementAttributes() + { + var attributes = new Dictionary + { + ["class"] = CssClass, + ["data-slot"] = "attachment-trigger" + }; + + if (!string.IsNullOrWhiteSpace(Href) && string.Equals(AsChild, "a", StringComparison.OrdinalIgnoreCase)) + { + attributes["href"] = Href; + } + + if (AdditionalAttributes != null) + { + foreach (var attribute in AdditionalAttributes) + { + attributes[attribute.Key] = attribute.Value; + } + } + + return attributes; + } + + private sealed class AnchorElement : ComponentBase + { + [Parameter] public string? Class { get; set; } + [Parameter] public string? DataSlot { get; set; } + [Parameter] public string? Href { get; set; } + + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? Attributes { get; set; } + + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) + { + builder.OpenElement(0, "a"); + builder.AddAttribute(1, "class", Class); + builder.AddAttribute(2, "data-slot", DataSlot); + if (!string.IsNullOrWhiteSpace(Href)) + { + builder.AddAttribute(3, "href", Href); + } + + builder.AddMultipleAttributes(4, Attributes); + builder.CloseElement(); + } + } + + private sealed class ButtonElement : ComponentBase + { + [Parameter] public string? Class { get; set; } + [Parameter] public string? DataSlot { get; set; } + + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? Attributes { get; set; } + + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) + { + builder.OpenElement(0, "button"); + builder.AddAttribute(1, "type", "button"); + builder.AddAttribute(2, "class", Class); + builder.AddAttribute(3, "data-slot", DataSlot); + builder.AddMultipleAttributes(4, Attributes); + builder.CloseElement(); + } + } +} diff --git a/src/BlazorBlueprint.Components/Components/Bubble/BbBubble.razor b/src/BlazorBlueprint.Components/Components/Bubble/BbBubble.razor new file mode 100644 index 000000000..441b6086f --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Bubble/BbBubble.razor @@ -0,0 +1,9 @@ +@namespace BlazorBlueprint.Components + +
    + @ChildContent +
    diff --git a/src/BlazorBlueprint.Components/Components/Bubble/BbBubble.razor.cs b/src/BlazorBlueprint.Components/Components/Bubble/BbBubble.razor.cs new file mode 100644 index 000000000..c21e052b4 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Bubble/BbBubble.razor.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Displays framed or unframed conversational bubble content. +/// +public partial class BbBubble : ComponentBase +{ + /// + /// Gets or sets bubble visual variant. + /// + [Parameter] + public BubbleVariant Variant { get; set; } = BubbleVariant.Default; + + /// + /// Gets or sets inline alignment for this bubble row. + /// + [Parameter] + public BubbleAlign Align { get; set; } = BubbleAlign.Start; + + /// + /// Gets or sets custom classes for bubble root. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets bubble content and optional reactions. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for bubble root. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn( + "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full", + Variant switch + { + BubbleVariant.Secondary => "*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]", + BubbleVariant.Muted => "*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]", + BubbleVariant.Tinted => "*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-primary-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]", + BubbleVariant.Outline => "*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30", + BubbleVariant.Ghost => "border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50", + BubbleVariant.Destructive => "*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30", + _ => "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80" + }, + Align == BubbleAlign.End ? "items-end" : "items-start", + Class + ); +} diff --git a/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleContent.razor b/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleContent.razor new file mode 100644 index 000000000..83fdcef9a --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleContent.razor @@ -0,0 +1,14 @@ +@namespace BlazorBlueprint.Components + +@if (!string.IsNullOrEmpty(AsChild)) +{ + +} +else +{ +
    + @ChildContent +
    +} diff --git a/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleContent.razor.cs b/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleContent.razor.cs new file mode 100644 index 000000000..4ee8734bb --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleContent.razor.cs @@ -0,0 +1,147 @@ +using Microsoft.AspNetCore.Components; +using System.Globalization; + +namespace BlazorBlueprint.Components; + +/// +/// Content surface wrapper for . +/// +public partial class BbBubbleContent : ComponentBase +{ + /// + /// Gets or sets an element type to render as (for example, "a" or "button"). + /// + [Parameter] + public string? AsChild { get; set; } + + /// + /// Gets or sets href when rendering an anchor element. + /// + [Parameter] + public string? Href { get; set; } + + /// + /// Gets or sets custom classes for the bubble content. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets content inside the bubble surface. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for the content element. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn( + "w-fit max-w-full min-w-0 overflow-hidden rounded-3xl border border-transparent px-3 py-2.5 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/30 group-data-[variant=ghost]/bubble:border-0", + Class + ); + + private Type GetElementType() + { + return AsChild?.ToLower(CultureInfo.InvariantCulture) switch + { + "a" => typeof(AnchorElement), + "button" => typeof(ButtonElement), + _ => typeof(DivElement) + }; + } + + private Dictionary GetElementAttributes() + { + var attributes = new Dictionary + { + ["class"] = CssClass, + ["data-slot"] = "bubble-content", + ["ChildContent"] = (object?)ChildContent! + }; + + if (!string.IsNullOrWhiteSpace(Href) && string.Equals(AsChild, "a", StringComparison.OrdinalIgnoreCase)) + { + attributes["href"] = Href; + } + + if (AdditionalAttributes != null) + { + foreach (var attribute in AdditionalAttributes) + { + attributes[attribute.Key] = attribute.Value; + } + } + + return attributes; + } + + private sealed class DivElement : ComponentBase + { + [Parameter] public string? Class { get; set; } + [Parameter] public string? DataSlot { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? Attributes { get; set; } + + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", Class); + builder.AddAttribute(2, "data-slot", DataSlot); + builder.AddMultipleAttributes(3, Attributes); + builder.AddContent(4, ChildContent); + builder.CloseElement(); + } + } + + private sealed class AnchorElement : ComponentBase + { + [Parameter] public string? Class { get; set; } + [Parameter] public string? DataSlot { get; set; } + [Parameter] public string? Href { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? Attributes { get; set; } + + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) + { + builder.OpenElement(0, "a"); + builder.AddAttribute(1, "class", Class); + builder.AddAttribute(2, "data-slot", DataSlot); + if (!string.IsNullOrWhiteSpace(Href)) + { + builder.AddAttribute(3, "href", Href); + } + + builder.AddMultipleAttributes(4, Attributes); + builder.AddContent(5, ChildContent); + builder.CloseElement(); + } + } + + private sealed class ButtonElement : ComponentBase + { + [Parameter] public string? Class { get; set; } + [Parameter] public string? DataSlot { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? Attributes { get; set; } + + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) + { + builder.OpenElement(0, "button"); + builder.AddAttribute(1, "type", "button"); + builder.AddAttribute(2, "class", Class); + builder.AddAttribute(3, "data-slot", DataSlot); + builder.AddMultipleAttributes(4, Attributes); + builder.AddContent(5, ChildContent); + builder.CloseElement(); + } + } +} diff --git a/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleGroup.razor b/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleGroup.razor new file mode 100644 index 000000000..a40a1399f --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleGroup.razor @@ -0,0 +1,5 @@ +@namespace BlazorBlueprint.Components + +
    + @ChildContent +
    diff --git a/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleGroup.razor.cs b/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleGroup.razor.cs new file mode 100644 index 000000000..ab36c4788 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleGroup.razor.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Groups consecutive bubbles from the same sender. +/// +public partial class BbBubbleGroup : ComponentBase +{ + /// + /// Gets or sets custom classes for the bubble group. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets grouped bubble content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for the group container. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn("flex min-w-0 flex-col gap-2", Class); +} diff --git a/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleReactions.razor b/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleReactions.razor new file mode 100644 index 000000000..58dcfcf26 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleReactions.razor @@ -0,0 +1,9 @@ +@namespace BlazorBlueprint.Components + +
    + @ChildContent +
    diff --git a/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleReactions.razor.cs b/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleReactions.razor.cs new file mode 100644 index 000000000..1cdbb1622 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleReactions.razor.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Displays reaction chips anchored to a bubble surface. +/// +public partial class BbBubbleReactions : ComponentBase +{ + /// + /// Gets or sets which side of the bubble to anchor to. + /// + [Parameter] + public BubbleReactionsSide Side { get; set; } = BubbleReactionsSide.Bottom; + + /// + /// Gets or sets horizontal alignment along the bubble edge. + /// + [Parameter] + public BubbleReactionsAlign Align { get; set; } = BubbleReactionsAlign.End; + + /// + /// Gets or sets custom classes for the reactions row. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets reaction content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for the reactions container. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn( + "absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0", + Side == BubbleReactionsSide.Top ? "top-0 -translate-y-3/4" : "bottom-0 translate-y-3/4", + Align == BubbleReactionsAlign.Start ? "left-3" : "right-3", + Class + ); +} diff --git a/src/BlazorBlueprint.Components/Components/Bubble/BubbleEnums.cs b/src/BlazorBlueprint.Components/Components/Bubble/BubbleEnums.cs new file mode 100644 index 000000000..42d31066a --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Bubble/BubbleEnums.cs @@ -0,0 +1,49 @@ +namespace BlazorBlueprint.Components; + +/// +/// Defines inline alignment options for bubble rows. +/// +public enum BubbleAlign +{ + /// + /// Align bubble to the start. + /// + Start, + + /// + /// Align bubble to the end. + /// + End +} + +/// +/// Defines side anchoring options for bubble reactions. +/// +public enum BubbleReactionsSide +{ + /// + /// Anchor reactions above bubble content. + /// + Top, + + /// + /// Anchor reactions below bubble content. + /// + Bottom +} + +/// +/// Defines horizontal alignment options for bubble reactions. +/// +public enum BubbleReactionsAlign +{ + /// + /// Align reactions to the start edge. + /// + Start, + + /// + /// Align reactions to the end edge. + /// + End +} diff --git a/src/BlazorBlueprint.Components/Components/Bubble/BubbleVariant.cs b/src/BlazorBlueprint.Components/Components/Bubble/BubbleVariant.cs new file mode 100644 index 000000000..554511d50 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Bubble/BubbleVariant.cs @@ -0,0 +1,42 @@ +namespace BlazorBlueprint.Components; + +/// +/// Defines visual variants for a bubble surface. +/// +public enum BubbleVariant +{ + /// + /// Strong primary bubble. + /// + Default, + + /// + /// Secondary neutral bubble. + /// + Secondary, + + /// + /// Lower-emphasis muted bubble. + /// + Muted, + + /// + /// Subtle primary-tinted bubble. + /// + Tinted, + + /// + /// Bordered bubble treatment. + /// + Outline, + + /// + /// Unframed bubble content. + /// + Ghost, + + /// + /// Destructive bubble for failed actions. + /// + Destructive +} diff --git a/src/BlazorBlueprint.Components/Components/Marker/BbMarker.razor b/src/BlazorBlueprint.Components/Components/Marker/BbMarker.razor new file mode 100644 index 000000000..f25fcfe65 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Marker/BbMarker.razor @@ -0,0 +1,15 @@ +@namespace BlazorBlueprint.Components + +@if (!string.IsNullOrEmpty(AsChild)) +{ + +} +else +{ +
    + @ChildContent +
    +} \ No newline at end of file diff --git a/src/BlazorBlueprint.Components/Components/Marker/BbMarker.razor.cs b/src/BlazorBlueprint.Components/Components/Marker/BbMarker.razor.cs new file mode 100644 index 000000000..f0f2364b2 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Marker/BbMarker.razor.cs @@ -0,0 +1,163 @@ +using Microsoft.AspNetCore.Components; +using System.Globalization; + +namespace BlazorBlueprint.Components; + +/// +/// Displays an inline conversation marker such as status updates, separators, or bordered notes. +/// +public partial class BbMarker : ComponentBase +{ + /// + /// Gets or sets the marker visual variant. + /// + [Parameter] + public MarkerVariant Variant { get; set; } = MarkerVariant.Default; + + /// + /// Gets or sets the element type to render as (for example, "a" or "button"). + /// + [Parameter] + public string? AsChild { get; set; } + + /// + /// Gets or sets the href when rendering as an anchor. + /// + [Parameter] + public string? Href { get; set; } + + /// + /// Gets or sets additional CSS classes to apply to the marker root. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets marker content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures unmatched attributes for the rendered element. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn( + "group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground", + Variant switch + { + MarkerVariant.Border => "border-b border-border pb-2", + MarkerVariant.Separator => "w-full items-center justify-center text-xs uppercase tracking-wide", + _ => null + }, + Variant == MarkerVariant.Separator + ? "before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border" + : null, + Class + ); + + private Type GetElementType() + { + return AsChild?.ToLower(CultureInfo.InvariantCulture) switch + { + "a" => typeof(AnchorElement), + "button" => typeof(ButtonElement), + _ => typeof(DivElement) + }; + } + + private Dictionary GetElementAttributes() + { + var attributes = new Dictionary + { + ["class"] = CssClass, + ["data-slot"] = "marker", + ["data-variant"] = Variant.ToString().ToLowerInvariant(), + ["ChildContent"] = ChildContent! + }; + + if (!string.IsNullOrWhiteSpace(Href) && string.Equals(AsChild, "a", StringComparison.OrdinalIgnoreCase)) + { + attributes["href"] = Href; + } + + if (AdditionalAttributes != null) + { + foreach (var attribute in AdditionalAttributes) + { + attributes[attribute.Key] = attribute.Value; + } + } + + return attributes; + } + + private sealed class DivElement : ComponentBase + { + [Parameter] public string? @class { get; set; } + [Parameter] public string? DataSlot { get; set; } + [Parameter] public string? DataVariant { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? Attributes { get; set; } + + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", @class); + builder.AddAttribute(2, "data-slot", DataSlot); + builder.AddAttribute(3, "data-variant", DataVariant); + builder.AddMultipleAttributes(4, Attributes); + builder.AddContent(5, ChildContent); + builder.CloseElement(); + } + } + + private sealed class AnchorElement : ComponentBase + { + [Parameter] public string? @class { get; set; } + [Parameter] public string? DataSlot { get; set; } + [Parameter] public string? DataVariant { get; set; } + [Parameter] public string? href { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? Attributes { get; set; } + + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) + { + builder.OpenElement(0, "a"); + builder.AddAttribute(1, "class", @class); + builder.AddAttribute(2, "data-slot", DataSlot); + builder.AddAttribute(3, "data-variant", DataVariant); + if (!string.IsNullOrWhiteSpace(href)) + { + builder.AddAttribute(4, "href", href); + } + + builder.AddMultipleAttributes(5, Attributes); + builder.AddContent(6, ChildContent); + builder.CloseElement(); + } + } + + private sealed class ButtonElement : ComponentBase + { + [Parameter] public string? @class { get; set; } + [Parameter] public string? DataSlot { get; set; } + [Parameter] public string? DataVariant { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? Attributes { get; set; } + + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) + { + builder.OpenElement(0, "button"); + builder.AddAttribute(1, "type", "button"); + builder.AddAttribute(2, "class", @class); + builder.AddAttribute(3, "data-slot", DataSlot); + builder.AddAttribute(4, "data-variant", DataVariant); + builder.AddMultipleAttributes(5, Attributes); + builder.AddContent(6, ChildContent); + builder.CloseElement(); + } + } +} diff --git a/src/BlazorBlueprint.Components/Components/Marker/BbMarkerContent.razor b/src/BlazorBlueprint.Components/Components/Marker/BbMarkerContent.razor new file mode 100644 index 000000000..967c8d527 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Marker/BbMarkerContent.razor @@ -0,0 +1,5 @@ +@namespace BlazorBlueprint.Components + + + @ChildContent + \ No newline at end of file diff --git a/src/BlazorBlueprint.Components/Components/Marker/BbMarkerContent.razor.cs b/src/BlazorBlueprint.Components/Components/Marker/BbMarkerContent.razor.cs new file mode 100644 index 000000000..83c4f16af --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Marker/BbMarkerContent.razor.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Text content slot for . +/// +public partial class BbMarkerContent : ComponentBase +{ + /// + /// Gets or sets custom classes for marker text content. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets marker text or rich content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for the content element. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn("min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", Class); +} diff --git a/src/BlazorBlueprint.Components/Components/Marker/BbMarkerIcon.razor b/src/BlazorBlueprint.Components/Components/Marker/BbMarkerIcon.razor new file mode 100644 index 000000000..b10500381 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Marker/BbMarkerIcon.razor @@ -0,0 +1,8 @@ +@namespace BlazorBlueprint.Components + + \ No newline at end of file diff --git a/src/BlazorBlueprint.Components/Components/Marker/BbMarkerIcon.razor.cs b/src/BlazorBlueprint.Components/Components/Marker/BbMarkerIcon.razor.cs new file mode 100644 index 000000000..5479c9912 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Marker/BbMarkerIcon.razor.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Decorative icon slot for . +/// +public partial class BbMarkerIcon : ComponentBase +{ + /// + /// Gets or sets custom classes for the icon container. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets icon content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for the icon container. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn("size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4", Class); +} diff --git a/src/BlazorBlueprint.Components/Components/Marker/MarkerVariant.cs b/src/BlazorBlueprint.Components/Components/Marker/MarkerVariant.cs new file mode 100644 index 000000000..18b73819d --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Marker/MarkerVariant.cs @@ -0,0 +1,22 @@ +namespace BlazorBlueprint.Components; + +/// +/// Defines visual variants for the Marker component. +/// +public enum MarkerVariant +{ + /// + /// Default inline marker for status or note content. + /// + Default, + + /// + /// Marker with a bottom border used to separate rows. + /// + Border, + + /// + /// Labeled separator marker with decorative lines. + /// + Separator +} \ No newline at end of file diff --git a/src/BlazorBlueprint.Components/Components/Message/BbMessage.razor b/src/BlazorBlueprint.Components/Components/Message/BbMessage.razor new file mode 100644 index 000000000..a58b4db09 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Message/BbMessage.razor @@ -0,0 +1,10 @@ +@namespace BlazorBlueprint.Components + + +
    + @ChildContent +
    +
    \ No newline at end of file diff --git a/src/BlazorBlueprint.Components/Components/Message/BbMessage.razor.cs b/src/BlazorBlueprint.Components/Components/Message/BbMessage.razor.cs new file mode 100644 index 000000000..0ab2b15c1 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Message/BbMessage.razor.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Displays a single conversation row with optional avatar, header, content, and footer slots. +/// +public partial class BbMessage : ComponentBase +{ + /// + /// Gets or sets message alignment within the transcript. + /// + [Parameter] + public MessageAlign Align { get; set; } = MessageAlign.Start; + + /// + /// Gets or sets custom classes for the message row. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets message row content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for the message row. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn( + "group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse", + Align == MessageAlign.End ? "justify-end" : "justify-start", + Class + ); +} diff --git a/src/BlazorBlueprint.Components/Components/Message/BbMessageAvatar.razor b/src/BlazorBlueprint.Components/Components/Message/BbMessageAvatar.razor new file mode 100644 index 000000000..8b3f29141 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Message/BbMessageAvatar.razor @@ -0,0 +1,5 @@ +@namespace BlazorBlueprint.Components + +
    + @ChildContent +
    diff --git a/src/BlazorBlueprint.Components/Components/Message/BbMessageAvatar.razor.cs b/src/BlazorBlueprint.Components/Components/Message/BbMessageAvatar.razor.cs new file mode 100644 index 000000000..51b702f0d --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Message/BbMessageAvatar.razor.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Avatar slot for a message row. +/// +public partial class BbMessageAvatar : ComponentBase +{ + /// + /// Gets or sets custom classes for the avatar slot. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets avatar content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for the avatar container. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => + ClassNames.cn( + "flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8", + Class); +} diff --git a/src/BlazorBlueprint.Components/Components/Message/BbMessageContent.razor b/src/BlazorBlueprint.Components/Components/Message/BbMessageContent.razor new file mode 100644 index 000000000..fdecd0a54 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Message/BbMessageContent.razor @@ -0,0 +1,5 @@ +@namespace BlazorBlueprint.Components + +
    + @ChildContent +
    diff --git a/src/BlazorBlueprint.Components/Components/Message/BbMessageContent.razor.cs b/src/BlazorBlueprint.Components/Components/Message/BbMessageContent.razor.cs new file mode 100644 index 000000000..f5a796648 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Message/BbMessageContent.razor.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Wraps the content surface, header, and footer for a message row. +/// +public partial class BbMessageContent : ComponentBase +{ + /// + /// Gets or sets custom classes for the content wrapper. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets message content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for the content container. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn( + "flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end", + Class); +} diff --git a/src/BlazorBlueprint.Components/Components/Message/BbMessageFooter.razor b/src/BlazorBlueprint.Components/Components/Message/BbMessageFooter.razor new file mode 100644 index 000000000..dd4214bb7 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Message/BbMessageFooter.razor @@ -0,0 +1,5 @@ +@namespace BlazorBlueprint.Components + +
    + @ChildContent +
    diff --git a/src/BlazorBlueprint.Components/Components/Message/BbMessageFooter.razor.cs b/src/BlazorBlueprint.Components/Components/Message/BbMessageFooter.razor.cs new file mode 100644 index 000000000..51311dbc8 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Message/BbMessageFooter.razor.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Footer slot displayed below a message surface. +/// +public partial class BbMessageFooter : ComponentBase +{ + /// + /// Gets or sets custom classes for the footer. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets footer content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for the footer container. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + [CascadingParameter] + private BbMessage? Message { get; set; } + + private string CssClass => ClassNames.cn( + "flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end", + Message?.Align == MessageAlign.End ? "self-end text-right" : "self-start text-left", + Class + ); +} diff --git a/src/BlazorBlueprint.Components/Components/Message/BbMessageGroup.razor b/src/BlazorBlueprint.Components/Components/Message/BbMessageGroup.razor new file mode 100644 index 000000000..5e345d5c6 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Message/BbMessageGroup.razor @@ -0,0 +1,5 @@ +@namespace BlazorBlueprint.Components + +
    + @ChildContent +
    \ No newline at end of file diff --git a/src/BlazorBlueprint.Components/Components/Message/BbMessageGroup.razor.cs b/src/BlazorBlueprint.Components/Components/Message/BbMessageGroup.razor.cs new file mode 100644 index 000000000..47114f147 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Message/BbMessageGroup.razor.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Groups consecutive message rows from the same sender. +/// +public partial class BbMessageGroup : ComponentBase +{ + /// + /// Gets or sets custom classes for the message group. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets grouped message content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for the group container. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn("flex min-w-0 flex-col gap-2", Class); +} diff --git a/src/BlazorBlueprint.Components/Components/Message/BbMessageHeader.razor b/src/BlazorBlueprint.Components/Components/Message/BbMessageHeader.razor new file mode 100644 index 000000000..3ec92abe4 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Message/BbMessageHeader.razor @@ -0,0 +1,5 @@ +@namespace BlazorBlueprint.Components + +
    + @ChildContent +
    diff --git a/src/BlazorBlueprint.Components/Components/Message/BbMessageHeader.razor.cs b/src/BlazorBlueprint.Components/Components/Message/BbMessageHeader.razor.cs new file mode 100644 index 000000000..803620d2d --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Message/BbMessageHeader.razor.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Components; + +/// +/// Header slot displayed above a message surface. +/// +public partial class BbMessageHeader : ComponentBase +{ + /// + /// Gets or sets custom classes for the message header. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets header content. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Captures additional attributes for the header container. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn( + "flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0", + Class); +} diff --git a/src/BlazorBlueprint.Components/Components/Message/MessageAlign.cs b/src/BlazorBlueprint.Components/Components/Message/MessageAlign.cs new file mode 100644 index 000000000..adbde0c07 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Message/MessageAlign.cs @@ -0,0 +1,17 @@ +namespace BlazorBlueprint.Components; + +/// +/// Defines inline alignment options for message rows. +/// +public enum MessageAlign +{ + /// + /// Align message content to the start of the conversation. + /// + Start, + + /// + /// Align message content to the end of the conversation. + /// + End +} \ No newline at end of file From 014dd0b9f4c4e787f455b0714df903e703f170b6 Mon Sep 17 00:00:00 2001 From: Hogo <64896329+HugoVG@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:13:15 +0200 Subject: [PATCH 108/188] feat: Add sidebar items for components --- .../Shared/DemoSidebar.razor | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor b/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor index 00b510530..c7b8d1e38 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor @@ -267,6 +267,11 @@ Aspect Ratio + + + Attachment + + Avatar @@ -282,6 +287,11 @@ Breadcrumb + + + Bubble + + Button @@ -567,6 +577,11 @@ Label + + + Marker + + Markdown Editor @@ -582,6 +597,11 @@ Menubar + + + Message + + Multi Select From 1ad589f8a545ae1d404388e5047709b937347b7f Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Thu, 2 Jul 2026 15:11:19 +0800 Subject: [PATCH 109/188] fix(floating): render portal position with invariant culture Coordinates are doubles; locales using a decimal comma (e.g. de-DE) produced invalid CSS like 'left: 123,45px', pinning floating content to the viewport edge. Fixes #374 --- .../Primitives/Floating/BbFloatingPortal.razor | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/BlazorBlueprint.Primitives/Primitives/Floating/BbFloatingPortal.razor b/src/BlazorBlueprint.Primitives/Primitives/Floating/BbFloatingPortal.razor index 032515896..e2babce2a 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Floating/BbFloatingPortal.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Floating/BbFloatingPortal.razor @@ -406,7 +406,10 @@ // After positioning, include stored position so it survives Blazor re-renders // This prevents the content from "closing" when parent components re-render - return $"position: {Strategy.ToValue()}; z-index: {ZIndex}; left: {_positionLeft}px; top: {_positionTop}px; opacity: 1; visibility: visible;"; + // Invariant culture so decimal coordinates always use a dot — locales with a + // decimal comma (e.g. de-DE) would otherwise produce invalid CSS (issue #374) + return FormattableString.Invariant( + $"position: {Strategy.ToValue()}; z-index: {ZIndex}; left: {_positionLeft}px; top: {_positionTop}px; opacity: 1; visibility: visible;"); } private string GetMergedStyle() From 25ce9a5d8ebcfa1c8deca1df7c116530311213fe Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Thu, 2 Jul 2026 15:17:23 +0800 Subject: [PATCH 110/188] fix(copy-text): keyboard activation, a11y, demo docs, and polish - Activate copy on Enter/Space via @onkeydown (matches BbRating/BbFileUpload precedent) - Show tooltip on focus/blur so keyboard users get the copy feedback - Add AriaLabel parameter; default aria-label to localized Click to copy - Drop aria-describedby that pointed at an aria-hidden element; tooltip is now decorative (aria-hidden) - Early-return when Value is null/empty (no copy, no Copied! state) - Broaden DisposeAsync catch to JSDisconnectedException/TaskCanceledException/ObjectDisposedException - Move ChildContent into code-behind; remove trailing space in tooltip class; add transition utilities so tooltip animates - Demo: LocalizationSection for CopyText.Copied/CopyText.ClickToCopy; API Reference rows for ChildContent, AriaLabel, AdditionalAttributes - Accept API surface snapshot for new BbCopyText component --- .../Pages/Components/CopyTextDemo.razor | 22 ++++++++ .../Components/CopyText/BbCopyText.razor | 13 ++--- .../Components/CopyText/BbCopyText.razor.cs | 54 ++++++++++++++++--- ...entsApiSurfaceMatchesBaseline.verified.txt | 8 +++ 4 files changed, 83 insertions(+), 14 deletions(-) diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor index ad63ccbfe..2a7142191 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor @@ -68,13 +68,35 @@ The text to copy to clipboard when clicked. + + The content displayed inside the copy text element. + Additional CSS classes to apply to the container. + + ARIA label for the copy button. Defaults to the localized "Click to copy" text. + Callback invoked when the user clicks the component to copy text. + + Additional HTML attributes applied to the container element. +
    + + +{ + opts.CopyText.ClickToCopy = ""Zum Kopieren klicken""; + opts.CopyText.Copied = ""Kopiert!""; +});")" />
    diff --git a/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor b/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor index c792267c8..74fca5c65 100644 --- a/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor +++ b/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor @@ -4,17 +4,18 @@ @ChildContent - @@ -22,7 +23,3 @@ - -@code { - [Parameter] public RenderFragment? ChildContent { get; set; } -} diff --git a/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs b/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs index 2441ac6b2..d6398ebaf 100644 --- a/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs +++ b/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; using Microsoft.JSInterop; namespace BlazorBlueprint.Components; @@ -9,7 +10,6 @@ namespace BlazorBlueprint.Components; ///
    public partial class BbCopyText : ComponentBase, IAsyncDisposable { - private readonly string tooltipId = $"bb-copytext-{Guid.NewGuid():N}"; private IJSObjectReference? clipboardModule; private bool isHovered; private bool copied; @@ -26,12 +26,25 @@ public partial class BbCopyText : ComponentBase, IAsyncDisposable [Parameter, EditorRequired] public string? Value { get; set; } + /// + /// Gets or sets the content displayed inside the copy text element. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + /// /// Additional CSS classes to apply to the text. /// [Parameter] public string? Class { get; set; } + /// + /// Gets or sets the ARIA label for the copy button. + /// Defaults to the localized "Click to copy" text. + /// + [Parameter] + public string? AriaLabel { get; set; } + /// /// Callback invoked when the user clicks the component to copy text. /// @@ -59,10 +72,31 @@ public partial class BbCopyText : ComponentBase, IAsyncDisposable private string? TooltipCssClass => ClassNames.cn( "pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 inline-flex " + "-translate-x-1/2 items-center gap-1.5 whitespace-nowrap rounded-md border " + - "bg-popover px-2.5 py-1 text-xs font-medium shadow-md ", + "bg-popover px-2.5 py-1 text-xs font-medium shadow-md " + + "transition-all duration-150 ease-out", isHovered ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0"); private void HandleMouseEnter() + { + ShowTooltip(); + } + + private void HandleMouseLeave() + { + isHovered = false; + } + + private void HandleFocus() + { + ShowTooltip(); + } + + private void HandleBlur() + { + isHovered = false; + } + + private void ShowTooltip() { isHovered = true; @@ -72,14 +106,22 @@ private void HandleMouseEnter() } } - private void HandleMouseLeave() + private async Task HandleKeyDownAsync(KeyboardEventArgs e) { - isHovered = false; + if (e.Key is "Enter" or " ") + { + await HandleClickAsync(); + } } private async Task HandleClickAsync() { - var success = await CopyToClipboardAsync(Value ?? string.Empty); + if (string.IsNullOrEmpty(Value)) + { + return; + } + + var success = await CopyToClipboardAsync(Value); if (!success) { return; @@ -116,7 +158,7 @@ public async ValueTask DisposeAsync() { await clipboardModule.DisposeAsync(); } - catch (JSDisconnectedException) + catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) { // Circuit already gone; nothing to clean up. } diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 5fda6dda8..7fa648f3b 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -597,6 +597,14 @@ - ChildContent : RenderFragment - Class : String +### BbCopyText (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - AriaLabel : String + - ChildContent : RenderFragment + - Class : String + - OnCopied : EventCallback + - Value : String [EditorRequired] + ### BbCurrencyInput (BlazorBlueprint.Components) - AllowNegative : Boolean - AriaDescribedBy : String From 94848ac4e6cd736849099b1a6db43fe7605fc1b6 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Thu, 2 Jul 2026 15:18:58 +0800 Subject: [PATCH 111/188] feat(datagrid): add CellClassFunc for conditional per-cell styling Adds a Func CellClassFunc parameter to BbDataGridPropertyColumn, BbDataGridTemplateColumn, and BbDataGridHierarchyColumn. The delegate computes extra CSS classes per cell from the row's data item and composes with the static CellClass, enabling conditional formatting without replacing cell content via CellTemplate. Closes #353 --- .../DataGrid/conditional-cell-formatting.txt | 20 +++++++++++++++ .../Pages/Components/DataGridDemo.razor | 6 +++++ .../Components/DataGridStylingDemo.razor | 25 +++++++++++++++++++ .../Components/DataGrid/BbDataGrid.razor | 4 +-- .../Components/DataGrid/BbDataGrid.razor.cs | 5 ++-- .../BbDataGridHierarchyColumn.razor.cs | 10 ++++++++ .../BbDataGridPropertyColumn.razor.cs | 10 ++++++++ .../BbDataGridTemplateColumn.razor.cs | 10 ++++++++ .../Primitives/DataGrid/IDataGridColumn.cs | 7 ++++++ ...entsApiSurfaceMatchesBaseline.verified.txt | 3 +++ ...ivesApiSurfaceMatchesBaseline.verified.txt | 1 + 11 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/conditional-cell-formatting.txt diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/conditional-cell-formatting.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/conditional-cell-formatting.txt new file mode 100644 index 000000000..a938aea91 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/conditional-cell-formatting.txt @@ -0,0 +1,20 @@ + + + + + + + + + +@code { + private List people = new(); + + protected override void OnInitialized() + { + people = MockDataService.GeneratePersons(50); + } +} diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor index cc3b5590c..4408b8e02 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor @@ -1011,6 +1011,9 @@ Additional CSS classes applied to cells in this column. + + Computes additional CSS classes for a cell from the row's data item, applied in addition to CellClass. Use for conditional per-cell formatting. + Additional CSS classes applied to the header cell of this column. @@ -1074,6 +1077,9 @@ Additional CSS classes applied to cells in this column. + + Computes additional CSS classes for a cell from the row's data item, applied in addition to CellClass. Use for conditional per-cell formatting. + Additional CSS classes applied to the header cell of this column. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridStylingDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridStylingDemo.razor index dcf372d7f..265b59bea 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridStylingDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridStylingDemo.razor @@ -121,6 +121,31 @@ + +
    +
    +

    Conditional Cell Formatting

    +

    + Use CellClassFunc to compute + cell classes from the row's data item — style individual cells without replacing + their content via CellTemplate. + Combines with the static CellClass. +

    +
    + + + + + + + + + +
    +
    diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor index 44b3ff178..5b8b00505 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor @@ -423,7 +423,7 @@ @if (isExpandColumn) @@ -608,7 +608,7 @@ @if (isSelectColumn) diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs index 6abb05087..f01087967 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs @@ -2939,7 +2939,7 @@ private int GetGroupLabelColSpan(DataGridGroupRow group) } private string GetCellClass(IDataGridColumn column, bool isSelectColumn, - bool isExpandColumn, bool isLastLeft, bool isFirstRight) + bool isExpandColumn, bool isLastLeft, bool isFirstRight, TData? item = null) { var baseClass = "p-4 align-middle transition-colors"; @@ -2967,11 +2967,12 @@ private string GetCellClass(IDataGridColumn column, bool isSelectColumn, } var cellClass = column.CellClass; + var perItemClass = item != null ? column.CellClassFunc?.Invoke(item) : null; var overflowClass = HasTableFixed() ? "overflow-hidden" : ""; var noWrapClass = column.NoWrap ? "whitespace-nowrap overflow-hidden text-ellipsis" : ""; - return ClassNames.cn(baseClass, cellClass, overflowClass, noWrapClass, pinnedClass, separatorClass); + return ClassNames.cn(baseClass, cellClass, perItemClass, overflowClass, noWrapClass, pinnedClass, separatorClass); } private string? GetColumnWidthStyle(IDataGridColumn column) diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridHierarchyColumn.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridHierarchyColumn.razor.cs index 3674a6780..51d068d03 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridHierarchyColumn.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridHierarchyColumn.razor.cs @@ -92,6 +92,14 @@ public partial class BbDataGridHierarchyColumn : ComponentBase, ID [Parameter] public string? CellClass { get; set; } + /// + /// Computes additional CSS classes for a cell from the row's data item, + /// applied in addition to . Use for conditional + /// per-cell formatting, e.g. CellClassFunc="@(o => o.Total < 0 ? "text-destructive" : null)". + /// + [Parameter] + public Func? CellClassFunc { get; set; } + /// /// Additional CSS classes for the header cell. /// @@ -155,6 +163,8 @@ public partial class BbDataGridHierarchyColumn : ComponentBase, ID ColumnPinning IDataGridColumn.Pinned => Pinned; RenderFragment>? IDataGridColumn.HeaderTemplate => null; string? IDataGridColumn.CellClass => CellClass; + + Func? IDataGridColumn.CellClassFunc => CellClassFunc; string? IDataGridColumn.HeaderClass => HeaderClass; bool IDataGridColumn.NoWrap => NoWrap; AggregateFunction IDataGridColumn.Aggregate => AggregateFunction.None; diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs index 06bc1392b..c31f85f9b 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs @@ -96,6 +96,14 @@ public partial class BbDataGridPropertyColumn : ComponentBase, IDa [Parameter] public string? CellClass { get; set; } + /// + /// Computes additional CSS classes for a cell from the row's data item, + /// applied in addition to . Use for conditional + /// per-cell formatting, e.g. CellClassFunc="@(o => o.Total < 0 ? "text-destructive" : null)". + /// + [Parameter] + public Func? CellClassFunc { get; set; } + /// /// Additional CSS classes for the header cell. /// @@ -186,6 +194,8 @@ public partial class BbDataGridPropertyColumn : ComponentBase, IDa string? IDataGridColumn.CellClass => CellClass; + Func? IDataGridColumn.CellClassFunc => CellClassFunc; + string? IDataGridColumn.HeaderClass => HeaderClass; bool IDataGridColumn.NoWrap => NoWrap; diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridTemplateColumn.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridTemplateColumn.razor.cs index a9909eb2c..924b392dc 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridTemplateColumn.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridTemplateColumn.razor.cs @@ -97,6 +97,14 @@ public partial class BbDataGridTemplateColumn : ComponentBase, IDataGridC [Parameter] public string? CellClass { get; set; } + /// + /// Computes additional CSS classes for a cell from the row's data item, + /// applied in addition to . Use for conditional + /// per-cell formatting, e.g. CellClassFunc="@(o => o.Total < 0 ? "text-destructive" : null)". + /// + [Parameter] + public Func? CellClassFunc { get; set; } + /// /// Additional CSS classes for the header cell. /// @@ -190,6 +198,8 @@ public partial class BbDataGridTemplateColumn : ComponentBase, IDataGridC string? IDataGridColumn.CellClass => CellClass; + Func? IDataGridColumn.CellClassFunc => CellClassFunc; + string? IDataGridColumn.HeaderClass => HeaderClass; bool IDataGridColumn.NoWrap => NoWrap; diff --git a/src/BlazorBlueprint.Primitives/Primitives/DataGrid/IDataGridColumn.cs b/src/BlazorBlueprint.Primitives/Primitives/DataGrid/IDataGridColumn.cs index 972c74307..06da412f9 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/DataGrid/IDataGridColumn.cs +++ b/src/BlazorBlueprint.Primitives/Primitives/DataGrid/IDataGridColumn.cs @@ -117,6 +117,13 @@ public interface IDataGridColumn where TData : class /// public string? CellClass { get; } + /// + /// Gets a callback that computes additional CSS classes for a cell based on the row's + /// data item. Applied in addition to , so static and per-row + /// classes can be combined. Returns null when no per-row styling is needed. + /// + public Func? CellClassFunc => null; + /// /// Gets additional CSS classes for the header cell. /// diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 5fda6dda8..a5dfee166 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -738,6 +738,7 @@ ### BbDataGridHierarchyColumn`2 (BlazorBlueprint.Components) - CellClass : String + - CellClassFunc : Func - CellTemplate : RenderFragment> - FilterOptions : IEnumerable> - FilterType : FilterFieldType? @@ -762,6 +763,7 @@ - Aggregate : AggregateFunction - AggregateFormat : String - CellClass : String + - CellClassFunc : Func - CellTemplate : RenderFragment - FilterOptions : IEnumerable> - FilterType : FilterFieldType? @@ -793,6 +795,7 @@ - Aggregate : AggregateFunction - AggregateFormat : String - CellClass : String + - CellClassFunc : Func - ChildContent : RenderFragment - FilterBy : Expression> - FilterOptions : IEnumerable> diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt index b8ca6d795..a2abf47ab 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt @@ -1108,6 +1108,7 @@ - Aggregate : AggregateFunction { get; } - AggregateFormat : String { get; } - CellClass : String { get; } + - CellClassFunc : Func { get; } - CellTemplate : RenderFragment> { get; } - ColumnId : String { get; } - Filterable : Boolean { get; } From 30d62f756102ec1d4fb9dce8636b0753ab00133c Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Thu, 2 Jul 2026 15:19:21 +0800 Subject: [PATCH 112/188] feat(BbDateRangePicker): rename DisplayButtons to ShowButtons, auto-apply closes popover, forward params in form field - Rename DisplayButtons -> ShowButtons to match ShowTwoMonths/ShowPresets convention - AutoApply now applies and closes the popover once a complete valid range is selected - ShowButtons=false implies auto-apply (no Apply button to confirm with), for both day selection and quick-pick presets - Track ShowButtons/AutoApply in ShouldRender so toggling them re-renders - Forward ShowButtons/AutoApply through BbFormFieldDateRangePicker - Add XML docs, demo example, code snippet, and API reference entries - Accept API surface snapshot (new params on BbDateRangePicker + BbFormFieldDateRangePicker) - Strip reformat-only hunks from the diff --- .../Components/DateRangePicker/auto-apply.txt | 5 + .../display-buttons-auto-apply.txt | 1 - .../Components/DateRangePickerDemo.razor | 31 ++--- .../FormFieldDateRangePickerDemo.razor | 2 + .../DateRangePicker/BbDateRangePicker.razor | 112 ++++++++---------- .../BbDateRangePicker.razor.cs | 102 ++++++++-------- .../BbFormFieldDateRangePicker.razor | 2 + .../BbFormFieldDateRangePicker.razor.cs | 16 +++ ...entsApiSurfaceMatchesBaseline.verified.txt | 4 + 9 files changed, 152 insertions(+), 123 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DateRangePicker/auto-apply.txt delete mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DateRangePicker/display-buttons-auto-apply.txt diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DateRangePicker/auto-apply.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DateRangePicker/auto-apply.txt new file mode 100644 index 000000000..888b8bd83 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DateRangePicker/auto-apply.txt @@ -0,0 +1,5 @@ + + +@code { + private DateRange? _range; +} diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DateRangePicker/display-buttons-auto-apply.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DateRangePicker/display-buttons-auto-apply.txt deleted file mode 100644 index b0af8bdc4..000000000 --- a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DateRangePicker/display-buttons-auto-apply.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DateRangePickerDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DateRangePickerDemo.razor index 66055ea2e..c1e88c8a9 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DateRangePickerDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DateRangePickerDemo.razor @@ -213,25 +213,22 @@
    - +
    -

    Buttons reset and apply hidden and auto apply

    +

    Auto-apply without buttons

    - The user doesn't have to click on Apply to validate the selection. + With AutoApply the selection is applied and the popover closes as soon as a complete range is picked — no Apply click required. ShowButtons="false" hides the Clear/Apply footer buttons (and implies auto-apply).

    - -

    - @if (_autoApply != null) - { - @($"Selected: {_autoApply.Start} - {_autoApply.End}") - } -

    - + + @if (_autoApply != null) + { +

    Selected: @_autoApply.Start.ToShortDateString() - @_autoApply.End.ToShortDateString()

    + } +
    -
    @@ -300,6 +297,12 @@ Show quick select presets. + + Show the Clear and Apply buttons in the popover footer. When false, completed selections are applied automatically. + + + Apply the selection and close the popover as soon as a complete range is selected, without requiring the Apply button. + Custom list of quick-pick presets. Supports built-in presets via implicit conversion from DateRangePreset and custom entries via DateRangeQuickPick.Custom(label, rangeFactory). When null, the default built-in presets are shown. @@ -383,6 +386,7 @@ private DateRange? _customFormat; private DateRange? _placeholder; private DateRange? _mondayFirst; + private DateRange? _autoApply; private readonly List _fullyCustomPresets = new() { @@ -458,7 +462,4 @@ return new DateRange(quarterStart, quarterStart.AddMonths(3).AddDays(-1)); }), }; - - - private DateRange? _autoApply; } diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldDateRangePickerDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldDateRangePickerDemo.razor index 30ae3d810..b017dcc26 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldDateRangePickerDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldDateRangePickerDemo.razor @@ -126,6 +126,8 @@ Maximum days in the range. Show two months side by side. Show quick-pick presets. + Show the Clear and Apply buttons. When false, completed selections are applied automatically. + Apply the selection and close the popover as soon as a complete range is selected. Whether the picker is disabled. CSS classes for the inner trigger. diff --git a/src/BlazorBlueprint.Components/Components/DateRangePicker/BbDateRangePicker.razor b/src/BlazorBlueprint.Components/Components/DateRangePicker/BbDateRangePicker.razor index dbc757267..caaea28a6 100644 --- a/src/BlazorBlueprint.Components/Components/DateRangePicker/BbDateRangePicker.razor +++ b/src/BlazorBlueprint.Components/Components/DateRangePicker/BbDateRangePicker.razor @@ -4,9 +4,9 @@ - + Class="@ButtonCssClass" + Disabled="@Disabled"> + @if (Value != null) { @FormatRange(Value) @@ -38,8 +38,7 @@
    @* Desktop: button sidebar *@ diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/MarkerDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/MarkerDemo.razor index c9a0086c5..0c47a9489 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/MarkerDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/MarkerDemo.razor @@ -56,13 +56,13 @@

    - + View pull request - + @@ -76,9 +76,8 @@

    Borders

    - Use the border variant for status rows that - should keep the default marker alignment while separating the next row. - Render markers as links or buttons with . + Use the Border variant for status rows that + should keep the default marker alignment while adding a divider before the next row.

    @@ -149,8 +148,8 @@ BbMarkerIcon is decorative; keep meaning in visible marker text. - For interactive markers, use AsChild="a" or AsChild="button" for native - semantics. + For interactive markers, use AsChild="MarkerElement.Anchor" or + AsChild="MarkerElement.Button" for native semantics. @@ -165,8 +164,12 @@ Marker visual style. Options: Default, Border, Separator. - - Render as another element type such as "a" or "button". + + Element type to render. Options: Div, Anchor, Button. Automatically switches to Anchor when Href is + set. + + + Link target when rendering as an anchor. Additional CSS classes to apply to marker root. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/MessageDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/MessageDemo.razor index cd67d137f..cae8eb51c 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/MessageDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/MessageDemo.razor @@ -86,8 +86,8 @@ - - CN is typing... + + CN is typing... diff --git a/src/BlazorBlueprint.Components/Components/Attachment/AttachmentEnums.cs b/src/BlazorBlueprint.Components/Components/Attachment/AttachmentEnums.cs index cf99867ee..513f18f8a 100644 --- a/src/BlazorBlueprint.Components/Components/Attachment/AttachmentEnums.cs +++ b/src/BlazorBlueprint.Components/Components/Attachment/AttachmentEnums.cs @@ -83,3 +83,19 @@ public enum AttachmentMediaVariant /// Image } + +/// +/// Element type to render for . +/// +public enum AttachmentTriggerElement +{ + /// + /// Render as a button element for actions. + /// + Button, + + /// + /// Render as an anchor element for navigation links. + /// + Anchor +} diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentMedia.razor.cs b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentMedia.razor.cs index 8c232357f..50d0997f0 100644 --- a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentMedia.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentMedia.razor.cs @@ -31,13 +31,11 @@ public partial class BbAttachmentMedia : ComponentBase [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } - [CascadingParameter] private BbAttachment? Attachment { get; set; } - private string CssClass => ClassNames.cn( - "relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5", + "relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5", Variant == AttachmentMediaVariant.Icon - ? "opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover" - : null, + ? "opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100" + : "*:[img]:aspect-square *:[img]:w-full *:[img]:object-cover", Class ); } diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTrigger.razor b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTrigger.razor index eed2bb4a1..d1aba90fc 100644 --- a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTrigger.razor +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTrigger.razor @@ -1,8 +1,11 @@ @namespace BlazorBlueprint.Components -@if (!string.IsNullOrEmpty(AsChild)) +@if (ResolvedElement == AttachmentTriggerElement.Anchor) { - + } else { diff --git a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTrigger.razor.cs b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTrigger.razor.cs index 979aacd26..63bdaeba3 100644 --- a/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTrigger.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Attachment/BbAttachmentTrigger.razor.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Components; -using System.Globalization; namespace BlazorBlueprint.Components; @@ -9,10 +8,10 @@ namespace BlazorBlueprint.Components; public partial class BbAttachmentTrigger : ComponentBase { /// - /// Gets or sets an element type to render as (for example, "a" or "button"). + /// Gets or sets the element type to render. Defaults to Button, but automatically switches to Anchor when is provided. /// [Parameter] - public string? AsChild { get; set; } + public AttachmentTriggerElement AsChild { get; set; } = AttachmentTriggerElement.Button; /// /// Gets or sets href when rendering as an anchor. @@ -32,84 +31,11 @@ public partial class BbAttachmentTrigger : ComponentBase [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } + private AttachmentTriggerElement ResolvedElement => + AsChild == AttachmentTriggerElement.Button && !string.IsNullOrEmpty(Href) ? AttachmentTriggerElement.Anchor : AsChild; + private string CssClass => ClassNames.cn( "absolute inset-0 z-10 outline-none", Class ); - - private Type GetElementType() - { - return AsChild?.ToLower(CultureInfo.InvariantCulture) switch - { - "a" => typeof(AnchorElement), - _ => typeof(ButtonElement) - }; - } - - private Dictionary GetElementAttributes() - { - var attributes = new Dictionary - { - ["class"] = CssClass, - ["data-slot"] = "attachment-trigger" - }; - - if (!string.IsNullOrWhiteSpace(Href) && string.Equals(AsChild, "a", StringComparison.OrdinalIgnoreCase)) - { - attributes["href"] = Href; - } - - if (AdditionalAttributes != null) - { - foreach (var attribute in AdditionalAttributes) - { - attributes[attribute.Key] = attribute.Value; - } - } - - return attributes; - } - - private sealed class AnchorElement : ComponentBase - { - [Parameter] public string? Class { get; set; } - [Parameter] public string? DataSlot { get; set; } - [Parameter] public string? Href { get; set; } - - [Parameter(CaptureUnmatchedValues = true)] - public Dictionary? Attributes { get; set; } - - protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) - { - builder.OpenElement(0, "a"); - builder.AddAttribute(1, "class", Class); - builder.AddAttribute(2, "data-slot", DataSlot); - if (!string.IsNullOrWhiteSpace(Href)) - { - builder.AddAttribute(3, "href", Href); - } - - builder.AddMultipleAttributes(4, Attributes); - builder.CloseElement(); - } - } - - private sealed class ButtonElement : ComponentBase - { - [Parameter] public string? Class { get; set; } - [Parameter] public string? DataSlot { get; set; } - - [Parameter(CaptureUnmatchedValues = true)] - public Dictionary? Attributes { get; set; } - - protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) - { - builder.OpenElement(0, "button"); - builder.AddAttribute(1, "type", "button"); - builder.AddAttribute(2, "class", Class); - builder.AddAttribute(3, "data-slot", DataSlot); - builder.AddMultipleAttributes(4, Attributes); - builder.CloseElement(); - } - } } diff --git a/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleContent.razor b/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleContent.razor index 83fdcef9a..118976c45 100644 --- a/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleContent.razor +++ b/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleContent.razor @@ -1,8 +1,22 @@ @namespace BlazorBlueprint.Components -@if (!string.IsNullOrEmpty(AsChild)) +@if (ResolvedElement == BubbleContentElement.Anchor) { - + + @ChildContent + +} +else if (ResolvedElement == BubbleContentElement.Button) +{ + } else { diff --git a/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleContent.razor.cs b/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleContent.razor.cs index 4ee8734bb..0e9ce2a9a 100644 --- a/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleContent.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Bubble/BbBubbleContent.razor.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Components; -using System.Globalization; namespace BlazorBlueprint.Components; @@ -9,10 +8,10 @@ namespace BlazorBlueprint.Components; public partial class BbBubbleContent : ComponentBase { /// - /// Gets or sets an element type to render as (for example, "a" or "button"). + /// Gets or sets the element type to render. Defaults to Div, but automatically switches to Anchor when is provided. /// [Parameter] - public string? AsChild { get; set; } + public BubbleContentElement AsChild { get; set; } = BubbleContentElement.Div; /// /// Gets or sets href when rendering an anchor element. @@ -38,110 +37,11 @@ public partial class BbBubbleContent : ComponentBase [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } + private BubbleContentElement ResolvedElement => + AsChild == BubbleContentElement.Div && !string.IsNullOrEmpty(Href) ? BubbleContentElement.Anchor : AsChild; + private string CssClass => ClassNames.cn( "w-fit max-w-full min-w-0 overflow-hidden rounded-3xl border border-transparent px-3 py-2.5 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/30 group-data-[variant=ghost]/bubble:border-0", Class ); - - private Type GetElementType() - { - return AsChild?.ToLower(CultureInfo.InvariantCulture) switch - { - "a" => typeof(AnchorElement), - "button" => typeof(ButtonElement), - _ => typeof(DivElement) - }; - } - - private Dictionary GetElementAttributes() - { - var attributes = new Dictionary - { - ["class"] = CssClass, - ["data-slot"] = "bubble-content", - ["ChildContent"] = (object?)ChildContent! - }; - - if (!string.IsNullOrWhiteSpace(Href) && string.Equals(AsChild, "a", StringComparison.OrdinalIgnoreCase)) - { - attributes["href"] = Href; - } - - if (AdditionalAttributes != null) - { - foreach (var attribute in AdditionalAttributes) - { - attributes[attribute.Key] = attribute.Value; - } - } - - return attributes; - } - - private sealed class DivElement : ComponentBase - { - [Parameter] public string? Class { get; set; } - [Parameter] public string? DataSlot { get; set; } - [Parameter] public RenderFragment? ChildContent { get; set; } - - [Parameter(CaptureUnmatchedValues = true)] - public Dictionary? Attributes { get; set; } - - protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) - { - builder.OpenElement(0, "div"); - builder.AddAttribute(1, "class", Class); - builder.AddAttribute(2, "data-slot", DataSlot); - builder.AddMultipleAttributes(3, Attributes); - builder.AddContent(4, ChildContent); - builder.CloseElement(); - } - } - - private sealed class AnchorElement : ComponentBase - { - [Parameter] public string? Class { get; set; } - [Parameter] public string? DataSlot { get; set; } - [Parameter] public string? Href { get; set; } - [Parameter] public RenderFragment? ChildContent { get; set; } - - [Parameter(CaptureUnmatchedValues = true)] - public Dictionary? Attributes { get; set; } - - protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) - { - builder.OpenElement(0, "a"); - builder.AddAttribute(1, "class", Class); - builder.AddAttribute(2, "data-slot", DataSlot); - if (!string.IsNullOrWhiteSpace(Href)) - { - builder.AddAttribute(3, "href", Href); - } - - builder.AddMultipleAttributes(4, Attributes); - builder.AddContent(5, ChildContent); - builder.CloseElement(); - } - } - - private sealed class ButtonElement : ComponentBase - { - [Parameter] public string? Class { get; set; } - [Parameter] public string? DataSlot { get; set; } - [Parameter] public RenderFragment? ChildContent { get; set; } - - [Parameter(CaptureUnmatchedValues = true)] - public Dictionary? Attributes { get; set; } - - protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) - { - builder.OpenElement(0, "button"); - builder.AddAttribute(1, "type", "button"); - builder.AddAttribute(2, "class", Class); - builder.AddAttribute(3, "data-slot", DataSlot); - builder.AddMultipleAttributes(4, Attributes); - builder.AddContent(5, ChildContent); - builder.CloseElement(); - } - } } diff --git a/src/BlazorBlueprint.Components/Components/Bubble/BubbleEnums.cs b/src/BlazorBlueprint.Components/Components/Bubble/BubbleEnums.cs index 42d31066a..6952af96e 100644 --- a/src/BlazorBlueprint.Components/Components/Bubble/BubbleEnums.cs +++ b/src/BlazorBlueprint.Components/Components/Bubble/BubbleEnums.cs @@ -47,3 +47,24 @@ public enum BubbleReactionsAlign /// End } + +/// +/// Element type to render for . +/// +public enum BubbleContentElement +{ + /// + /// Render as a div element for static content. + /// + Div, + + /// + /// Render as an anchor element for navigation links. + /// + Anchor, + + /// + /// Render as a button element for actions. + /// + Button +} diff --git a/src/BlazorBlueprint.Components/Components/Marker/BbMarker.razor b/src/BlazorBlueprint.Components/Components/Marker/BbMarker.razor index f25fcfe65..881f79ee6 100644 --- a/src/BlazorBlueprint.Components/Components/Marker/BbMarker.razor +++ b/src/BlazorBlueprint.Components/Components/Marker/BbMarker.razor @@ -1,8 +1,24 @@ @namespace BlazorBlueprint.Components -@if (!string.IsNullOrEmpty(AsChild)) +@if (ResolvedElement == MarkerElement.Anchor) { - + + @ChildContent + +} +else if (ResolvedElement == MarkerElement.Button) +{ + } else { @@ -12,4 +28,4 @@ else @attributes="AdditionalAttributes"> @ChildContent
    -} \ No newline at end of file +} diff --git a/src/BlazorBlueprint.Components/Components/Marker/BbMarker.razor.cs b/src/BlazorBlueprint.Components/Components/Marker/BbMarker.razor.cs index f0f2364b2..853840727 100644 --- a/src/BlazorBlueprint.Components/Components/Marker/BbMarker.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Marker/BbMarker.razor.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Components; -using System.Globalization; namespace BlazorBlueprint.Components; @@ -15,10 +14,10 @@ public partial class BbMarker : ComponentBase public MarkerVariant Variant { get; set; } = MarkerVariant.Default; /// - /// Gets or sets the element type to render as (for example, "a" or "button"). + /// Gets or sets the element type to render. Defaults to Div, but automatically switches to Anchor when is provided. /// [Parameter] - public string? AsChild { get; set; } + public MarkerElement AsChild { get; set; } = MarkerElement.Div; /// /// Gets or sets the href when rendering as an anchor. @@ -44,6 +43,9 @@ public partial class BbMarker : ComponentBase [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } + private MarkerElement ResolvedElement => + AsChild == MarkerElement.Div && !string.IsNullOrEmpty(Href) ? MarkerElement.Anchor : AsChild; + private string CssClass => ClassNames.cn( "group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground", Variant switch @@ -57,107 +59,4 @@ public partial class BbMarker : ComponentBase : null, Class ); - - private Type GetElementType() - { - return AsChild?.ToLower(CultureInfo.InvariantCulture) switch - { - "a" => typeof(AnchorElement), - "button" => typeof(ButtonElement), - _ => typeof(DivElement) - }; - } - - private Dictionary GetElementAttributes() - { - var attributes = new Dictionary - { - ["class"] = CssClass, - ["data-slot"] = "marker", - ["data-variant"] = Variant.ToString().ToLowerInvariant(), - ["ChildContent"] = ChildContent! - }; - - if (!string.IsNullOrWhiteSpace(Href) && string.Equals(AsChild, "a", StringComparison.OrdinalIgnoreCase)) - { - attributes["href"] = Href; - } - - if (AdditionalAttributes != null) - { - foreach (var attribute in AdditionalAttributes) - { - attributes[attribute.Key] = attribute.Value; - } - } - - return attributes; - } - - private sealed class DivElement : ComponentBase - { - [Parameter] public string? @class { get; set; } - [Parameter] public string? DataSlot { get; set; } - [Parameter] public string? DataVariant { get; set; } - [Parameter] public RenderFragment? ChildContent { get; set; } - [Parameter(CaptureUnmatchedValues = true)] public Dictionary? Attributes { get; set; } - - protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) - { - builder.OpenElement(0, "div"); - builder.AddAttribute(1, "class", @class); - builder.AddAttribute(2, "data-slot", DataSlot); - builder.AddAttribute(3, "data-variant", DataVariant); - builder.AddMultipleAttributes(4, Attributes); - builder.AddContent(5, ChildContent); - builder.CloseElement(); - } - } - - private sealed class AnchorElement : ComponentBase - { - [Parameter] public string? @class { get; set; } - [Parameter] public string? DataSlot { get; set; } - [Parameter] public string? DataVariant { get; set; } - [Parameter] public string? href { get; set; } - [Parameter] public RenderFragment? ChildContent { get; set; } - [Parameter(CaptureUnmatchedValues = true)] public Dictionary? Attributes { get; set; } - - protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) - { - builder.OpenElement(0, "a"); - builder.AddAttribute(1, "class", @class); - builder.AddAttribute(2, "data-slot", DataSlot); - builder.AddAttribute(3, "data-variant", DataVariant); - if (!string.IsNullOrWhiteSpace(href)) - { - builder.AddAttribute(4, "href", href); - } - - builder.AddMultipleAttributes(5, Attributes); - builder.AddContent(6, ChildContent); - builder.CloseElement(); - } - } - - private sealed class ButtonElement : ComponentBase - { - [Parameter] public string? @class { get; set; } - [Parameter] public string? DataSlot { get; set; } - [Parameter] public string? DataVariant { get; set; } - [Parameter] public RenderFragment? ChildContent { get; set; } - [Parameter(CaptureUnmatchedValues = true)] public Dictionary? Attributes { get; set; } - - protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) - { - builder.OpenElement(0, "button"); - builder.AddAttribute(1, "type", "button"); - builder.AddAttribute(2, "class", @class); - builder.AddAttribute(3, "data-slot", DataSlot); - builder.AddAttribute(4, "data-variant", DataVariant); - builder.AddMultipleAttributes(5, Attributes); - builder.AddContent(6, ChildContent); - builder.CloseElement(); - } - } } diff --git a/src/BlazorBlueprint.Components/Components/Marker/BbMarkerContent.razor b/src/BlazorBlueprint.Components/Components/Marker/BbMarkerContent.razor index 967c8d527..d129372d0 100644 --- a/src/BlazorBlueprint.Components/Components/Marker/BbMarkerContent.razor +++ b/src/BlazorBlueprint.Components/Components/Marker/BbMarkerContent.razor @@ -2,4 +2,4 @@ @ChildContent - \ No newline at end of file + diff --git a/src/BlazorBlueprint.Components/Components/Marker/BbMarkerIcon.razor b/src/BlazorBlueprint.Components/Components/Marker/BbMarkerIcon.razor index b10500381..f4e1b9b30 100644 --- a/src/BlazorBlueprint.Components/Components/Marker/BbMarkerIcon.razor +++ b/src/BlazorBlueprint.Components/Components/Marker/BbMarkerIcon.razor @@ -5,4 +5,4 @@ class="@CssClass" @attributes="AdditionalAttributes"> @ChildContent - \ No newline at end of file + diff --git a/src/BlazorBlueprint.Components/Components/Marker/MarkerElement.cs b/src/BlazorBlueprint.Components/Components/Marker/MarkerElement.cs new file mode 100644 index 000000000..06e9876cd --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Marker/MarkerElement.cs @@ -0,0 +1,22 @@ +namespace BlazorBlueprint.Components; + +/// +/// Element type to render for . +/// +public enum MarkerElement +{ + /// + /// Render as a div element for static markers. + /// + Div, + + /// + /// Render as an anchor element for navigation links. + /// + Anchor, + + /// + /// Render as a button element for actions. + /// + Button +} diff --git a/src/BlazorBlueprint.Components/Components/Marker/MarkerVariant.cs b/src/BlazorBlueprint.Components/Components/Marker/MarkerVariant.cs index 18b73819d..a70504571 100644 --- a/src/BlazorBlueprint.Components/Components/Marker/MarkerVariant.cs +++ b/src/BlazorBlueprint.Components/Components/Marker/MarkerVariant.cs @@ -19,4 +19,4 @@ public enum MarkerVariant /// Labeled separator marker with decorative lines. /// Separator -} \ No newline at end of file +} diff --git a/src/BlazorBlueprint.Components/Components/Message/BbMessage.razor b/src/BlazorBlueprint.Components/Components/Message/BbMessage.razor index a58b4db09..cdc33d02d 100644 --- a/src/BlazorBlueprint.Components/Components/Message/BbMessage.razor +++ b/src/BlazorBlueprint.Components/Components/Message/BbMessage.razor @@ -1,10 +1,8 @@ @namespace BlazorBlueprint.Components - -
    - @ChildContent -
    -
    \ No newline at end of file +
    + @ChildContent +
    diff --git a/src/BlazorBlueprint.Components/Components/Message/BbMessageFooter.razor.cs b/src/BlazorBlueprint.Components/Components/Message/BbMessageFooter.razor.cs index 51311dbc8..68028fc6f 100644 --- a/src/BlazorBlueprint.Components/Components/Message/BbMessageFooter.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Message/BbMessageFooter.razor.cs @@ -25,12 +25,8 @@ public partial class BbMessageFooter : ComponentBase [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } - [CascadingParameter] - private BbMessage? Message { get; set; } - private string CssClass => ClassNames.cn( - "flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end", - Message?.Align == MessageAlign.End ? "self-end text-right" : "self-start text-left", + "flex max-w-full min-w-0 items-center self-start px-3 text-left text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end group-data-[align=end]/message:self-end group-data-[align=end]/message:text-right", Class ); } diff --git a/src/BlazorBlueprint.Components/Components/Message/BbMessageGroup.razor b/src/BlazorBlueprint.Components/Components/Message/BbMessageGroup.razor index 5e345d5c6..7f75cfeec 100644 --- a/src/BlazorBlueprint.Components/Components/Message/BbMessageGroup.razor +++ b/src/BlazorBlueprint.Components/Components/Message/BbMessageGroup.razor @@ -2,4 +2,4 @@
    @ChildContent -
    \ No newline at end of file +
    diff --git a/src/BlazorBlueprint.Components/Components/Message/MessageAlign.cs b/src/BlazorBlueprint.Components/Components/Message/MessageAlign.cs index adbde0c07..094bb00c8 100644 --- a/src/BlazorBlueprint.Components/Components/Message/MessageAlign.cs +++ b/src/BlazorBlueprint.Components/Components/Message/MessageAlign.cs @@ -14,4 +14,4 @@ public enum MessageAlign /// Align message content to the end of the conversation. /// End -} \ No newline at end of file +} diff --git a/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css b/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css index 06be288f6..833fa9b5f 100644 --- a/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css +++ b/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css @@ -19,6 +19,10 @@ /* Configure source paths for scanning Razor files */ @source "../../Components"; +/* Safelist the bare chat utilities so consumers of the prebuilt CSS can + apply them directly (component sources only use the variant forms). */ +@source inline("shimmer scroll-fade-x"); + /* Component structural variables & default theme tokens */ /* Alert defaults let users drop in a raw tweakcn/shadcn theme without defining the BB-specific --alert-* variables. Un-layered :root in the user's theme @@ -867,3 +871,46 @@ from { width: 100%; } to { width: 0%; } } + +/* Chat utilities (Attachment/Marker/Message). @utility cannot be nested + inside a layer; these land in the `utilities` layer like tw-animate's. */ + +/* Animated gradient text sweep for in-progress labels + (e.g. uploading attachment titles, "is typing..." markers). */ +@utility shimmer { + color: transparent; + background: linear-gradient( + 90deg, + var(--muted-foreground) 0%, + var(--foreground) 50%, + var(--muted-foreground) 100% + ) + 0 0 / 200% 100%; + background-clip: text; + -webkit-background-clip: text; + animation: bb-shimmer 2s linear infinite; +} + +/* Fades out the horizontal edges of a scroll container + (e.g. attachment group rows). */ +@utility scroll-fade-x { + mask-image: linear-gradient( + to right, + transparent, + black 1.5rem, + black calc(100% - 1.5rem), + transparent + ); + -webkit-mask-image: linear-gradient( + to right, + transparent, + black 1.5rem, + black calc(100% - 1.5rem), + transparent + ); +} + +@keyframes bb-shimmer { + from { background-position: 200% 0; } + to { background-position: -200% 0; } +} diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 5fda6dda8..8bf5f7f46 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -121,6 +121,58 @@ - Class : String - Ratio : Double +### BbAttachment (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Class : String + - Orientation : AttachmentOrientation + - Size : AttachmentSize + - State : AttachmentState + +### BbAttachmentAction (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Class : String + - Size : ButtonSize + - Variant : ButtonVariant + +### BbAttachmentActions (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Class : String + +### BbAttachmentContent (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Class : String + +### BbAttachmentDescription (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Class : String + +### BbAttachmentGroup (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Class : String + +### BbAttachmentMedia (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Class : String + - Variant : AttachmentMediaVariant + +### BbAttachmentTitle (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Class : String + +### BbAttachmentTrigger (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - AsChild : AttachmentTriggerElement + - Class : String + - Href : String + ### BbAvatar (BlazorBlueprint.Components) - ChildContent : RenderFragment - Class : String @@ -208,6 +260,32 @@ - ChildContent : RenderFragment - Class : String +### BbBubble (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - Align : BubbleAlign + - ChildContent : RenderFragment + - Class : String + - Variant : BubbleVariant + +### BbBubbleContent (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - AsChild : BubbleContentElement + - ChildContent : RenderFragment + - Class : String + - Href : String + +### BbBubbleGroup (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Class : String + +### BbBubbleReactions (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - Align : BubbleReactionsAlign + - ChildContent : RenderFragment + - Class : String + - Side : BubbleReactionsSide + ### BbButton (BlazorBlueprint.Components) - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] - AriaLabel : String @@ -2108,6 +2186,24 @@ - Value : String - ValueChanged : EventCallback +### BbMarker (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - AsChild : MarkerElement + - ChildContent : RenderFragment + - Class : String + - Href : String + - Variant : MarkerVariant + +### BbMarkerContent (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Class : String + +### BbMarkerIcon (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Class : String + ### BbMaskedInput (BlazorBlueprint.Components) - AriaDescribedBy : String - AriaInvalid : Boolean? @@ -2178,6 +2274,37 @@ - Class : String - Context : BbMenubar [CascadingParameter] +### BbMessage (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - Align : MessageAlign + - ChildContent : RenderFragment + - Class : String + +### BbMessageAvatar (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Class : String + +### BbMessageContent (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Class : String + +### BbMessageFooter (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Class : String + +### BbMessageGroup (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Class : String + +### BbMessageHeader (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - ChildContent : RenderFragment + - Class : String + ### BbMultiSelectItem`1 (BlazorBlueprint.Components) - ChildContent : RenderFragment - Disabled : Boolean @@ -3492,6 +3619,30 @@ - Warning = 3 - Danger = 4 +### AttachmentMediaVariant (BlazorBlueprint.Components) + - Icon = 0 + - Image = 1 + +### AttachmentOrientation (BlazorBlueprint.Components) + - Horizontal = 0 + - Vertical = 1 + +### AttachmentSize (BlazorBlueprint.Components) + - Default = 0 + - Sm = 1 + - Xs = 2 + +### AttachmentState (BlazorBlueprint.Components) + - Idle = 0 + - Uploading = 1 + - Processing = 2 + - Error = 3 + - Done = 4 + +### AttachmentTriggerElement (BlazorBlueprint.Components) + - Button = 0 + - Anchor = 1 + ### AvatarSize (BlazorBlueprint.Components) - Small = 0 - Default = 1 @@ -3523,6 +3674,32 @@ - Neutral = 3 - Stone = 4 +### BubbleAlign (BlazorBlueprint.Components) + - Start = 0 + - End = 1 + +### BubbleContentElement (BlazorBlueprint.Components) + - Div = 0 + - Anchor = 1 + - Button = 2 + +### BubbleReactionsAlign (BlazorBlueprint.Components) + - Start = 0 + - End = 1 + +### BubbleReactionsSide (BlazorBlueprint.Components) + - Top = 0 + - Bottom = 1 + +### BubbleVariant (BlazorBlueprint.Components) + - Default = 0 + - Secondary = 1 + - Muted = 2 + - Tinted = 3 + - Outline = 4 + - Ghost = 5 + - Destructive = 6 + ### ButtonGroupOrientation (BlazorBlueprint.Components) - Horizontal = 0 - Vertical = 1 @@ -3788,6 +3965,16 @@ - Dashed = 1 - Dotted = 2 +### MarkerElement (BlazorBlueprint.Components) + - Div = 0 + - Anchor = 1 + - Button = 2 + +### MarkerVariant (BlazorBlueprint.Components) + - Default = 0 + - Border = 1 + - Separator = 2 + ### MaskPreset (BlazorBlueprint.Components) - Custom = 0 - Phone = 1 @@ -3804,6 +3991,10 @@ - Center = 1 - End = 2 +### MessageAlign (BlazorBlueprint.Components) + - Start = 0 + - End = 1 + ### NativeSelectSize (BlazorBlueprint.Components) - Small = 0 - Default = 1 From aa59eaf15427a3b0431305edd2294252cb9fb9d8 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Thu, 2 Jul 2026 15:29:56 +0800 Subject: [PATCH 117/188] fix(sidebar): fire OnClick when menu button renders as anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BbSidebarMenuButton auto-renders as an when Href is set, but only the
    + +
    +
    +

    Custom Day Content

    +

    + Use DayTemplate to render custom content inside each day cell. The template + context exposes Date, IsSelected, IsDisabled, + IsOutsideMonth and IsToday. Here, days with events get a dot marker. +

    +
    +
    + + +
    + @context.Date.Day + @if (HasEvent(context.Date)) + { + + } +
    +
    +
    +
    +

    + Selected: @(_dayTemplateDate?.ToString("MMMM d, yyyy") ?? "None") +

    + +
    + + +
    +
    +

    Per-Day Styling

    +

    + Use DayClassFunc to apply additional CSS classes to individual day buttons. + Here, weekends are tinted with the destructive color. +

    +
    +
    + +
    + +
    + Uses role="grid" for the calendar table @@ -211,6 +255,12 @@ Whether to move keyboard focus to the active day when the calendar first renders, so arrow-key navigation works immediately. Useful for calendars shown in a popover or dialog. + + Template for rendering custom content inside each day button. The context exposes Date, IsSelected, IsDisabled, IsOutsideMonth, IsToday and IsInRange. When null, the day number is rendered. + + + Function returning additional CSS classes for a specific day button, composed with the built-in day classes and DayClass. + Callback when a date is selected. @@ -244,6 +294,16 @@ private DateTime? _mondayStart; private DateTime? _autoFocusDate; private bool _showAutoFocusCalendar; + private DateTime? _dayTemplateDate; + private DateTime? _dayClassDate; + + private static readonly HashSet EventDays = new() { 3, 8, 12, 17, 21, 26 }; + + private bool HasEvent(DateTime date) => + date.Month == DateTime.Today.Month && date.Year == DateTime.Today.Year && EventDays.Contains(date.Day); + + private string? GetWeekendDayClass(DateTime date) => + date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday ? "text-destructive" : null; private DateTime _minDate = DateTime.Today; private DateTime _maxDate = DateTime.Today.AddDays(30); diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DatePickerDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DatePickerDemo.razor index b79b33e0b..9c005de8e 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DatePickerDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DatePickerDemo.razor @@ -138,6 +138,30 @@ + +
    +
    +

    Per-Day Customization

    +

    + Use DayTemplate to render custom content inside each calendar day (e.g. a dot + marker on days with data) and DayClassFunc to style individual days + (e.g. tint weekends). Both are forwarded to the underlying calendar. +

    +
    + + +
    + @context.Date.Day + @if (HasEvent(context.Date)) + { + + } +
    +
    +
    + +
    +
    @@ -239,6 +263,12 @@ Function to determine if a date should be disabled. + + Template for rendering custom content inside each calendar day button. When null, the day number is rendered. + + + Function returning additional CSS classes for a specific calendar day button. + Whether the date picker is disabled. @@ -276,6 +306,15 @@ private DateTime? _noWeekendsDate; private DateTime? _sundayStart; private DateTime? _mondayStart; + private DateTime? _customDayDate; + + private static readonly HashSet EventDays = new() { 3, 8, 12, 17, 21, 26 }; + + private bool HasEvent(DateTime date) => + date.Month == DateTime.Today.Month && date.Year == DateTime.Today.Year && EventDays.Contains(date.Day); + + private string? GetWeekendDayClass(DateTime date) => + date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday ? "text-destructive" : null; private bool IsWeekend(DateTime date) { diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DateRangePickerDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DateRangePickerDemo.razor index c1e88c8a9..0f03cb314 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DateRangePickerDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DateRangePickerDemo.razor @@ -229,6 +229,28 @@
    + +
    +
    +

    Per-Day Customization

    +

    + Use DayTemplate to render custom content inside each calendar day — the context includes range state via IsInRange — and DayClassFunc to style individual days. +

    +
    + + +
    + @context.Date.Day + @if (HasEvent(context.Date)) + { + + } +
    +
    +
    + +
    +
    @@ -291,6 +313,12 @@ Function to disable specific dates. + + Template for rendering custom content inside each calendar day button. The context includes range state via IsInRange. When null, the day number is rendered. + + + Function returning additional CSS classes for a specific calendar day button. + Show two calendars side by side. @@ -387,6 +415,15 @@ private DateRange? _placeholder; private DateRange? _mondayFirst; private DateRange? _autoApply; + private DateRange? _customDayRange; + + private static readonly HashSet EventDays = new() { 3, 8, 12, 17, 21, 26 }; + + private bool HasEvent(DateTime date) => + date.Month == DateTime.Today.Month && date.Year == DateTime.Today.Year && EventDays.Contains(date.Day); + + private string? GetWeekendDayClass(DateTime date) => + date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday ? "text-destructive" : null; private readonly List _fullyCustomPresets = new() { diff --git a/src/BlazorBlueprint.Components/Components/Calendar/BbCalendar.razor b/src/BlazorBlueprint.Components/Components/Calendar/BbCalendar.razor index 3b4ef2701..1414e4348 100644 --- a/src/BlazorBlueprint.Components/Components/Calendar/BbCalendar.razor +++ b/src/BlazorBlueprint.Components/Components/Calendar/BbCalendar.razor @@ -102,7 +102,21 @@ @onclick="@(() => SelectDate(day.Value))" @onclick:stopPropagation="true" @onfocus="@(() => HandleDayFocus(day.Value))"> - @day.Value.Day + @if (DayTemplate != null) + { + @DayTemplate(new CalendarDayContext + { + Date = day.Value, + IsSelected = isSelected, + IsDisabled = isDisabled, + IsOutsideMonth = isOutside, + IsToday = day.Value.Date == DateTime.Today + }) + } + else + { + @day.Value.Day + } } @@ -248,6 +262,20 @@ [Parameter] public string? DayClass { get; set; } + /// + /// Function returning additional CSS classes for a specific day button, + /// composed with the built-in day classes and . + /// + [Parameter] + public Func? DayClassFunc { get; set; } + + /// + /// Optional template for rendering custom content inside each day button. + /// When null, the day number is rendered. + /// + [Parameter] + public RenderFragment? DayTemplate { get; set; } + /// /// Additional CSS classes to apply to day cells (td elements). /// @@ -753,7 +781,13 @@ baseClass = ClassNames.cn(baseClass, "ring-2 ring-ring ring-offset-2"); } - return string.IsNullOrEmpty(DayClass) ? baseClass : ClassNames.cn(baseClass, DayClass); + var customClass = DayClassFunc?.Invoke(date); + if (string.IsNullOrEmpty(DayClass) && string.IsNullOrEmpty(customClass)) + { + return baseClass; + } + + return ClassNames.cn(baseClass, DayClass, customClass); } #endregion diff --git a/src/BlazorBlueprint.Components/Components/Calendar/CalendarDayContext.cs b/src/BlazorBlueprint.Components/Components/Calendar/CalendarDayContext.cs new file mode 100644 index 000000000..625cfaab7 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Calendar/CalendarDayContext.cs @@ -0,0 +1,43 @@ +namespace BlazorBlueprint.Components; + +/// +/// Context provided to the DayTemplate of calendar-based components +/// (BbCalendar, BbDatePicker, BbDateRangePicker), +/// describing the day being rendered. +/// +public class CalendarDayContext +{ + /// + /// The date of the day being rendered. + /// + public DateTime Date { get; init; } + + /// + /// Whether the day is currently selected. In range selection this is true + /// for the range start and end dates. + /// + public bool IsSelected { get; init; } + + /// + /// Whether the day is disabled. + /// + public bool IsDisabled { get; init; } + + /// + /// Whether the day belongs to the previous or next month relative to the + /// displayed month. + /// + public bool IsOutsideMonth { get; init; } + + /// + /// Whether the day is today. + /// + public bool IsToday { get; init; } + + /// + /// Whether the day falls within the selected range. Only set in range + /// selection (e.g. BbDateRangePicker); always false for single-date + /// calendars. + /// + public bool IsInRange { get; init; } +} diff --git a/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePicker.razor b/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePicker.razor index 1f0efdeda..ef86e2595 100644 --- a/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePicker.razor +++ b/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePicker.razor @@ -31,7 +31,9 @@ MinDate="@MinDate" MaxDate="@MaxDate" DisabledDates="@DisabledDates" - FirstDayOfWeek="@FirstDayOfWeek" /> + FirstDayOfWeek="@FirstDayOfWeek" + DayClassFunc="@DayClassFunc" + DayTemplate="@DayTemplate" /> @@ -96,6 +98,20 @@ [Parameter] public Func? DisabledDates { get; set; } + /// + /// Function returning additional CSS classes for a specific day button in the + /// calendar, composed with the built-in day classes. + /// + [Parameter] + public Func? DayClassFunc { get; set; } + + /// + /// Optional template for rendering custom content inside each calendar day button. + /// When null, the day number is rendered. + /// + [Parameter] + public RenderFragment? DayTemplate { get; set; } + /// /// The first day of the week. Defaults to the current culture's first day of week. /// diff --git a/src/BlazorBlueprint.Components/Components/DateRangePicker/BbDateRangePicker.razor b/src/BlazorBlueprint.Components/Components/DateRangePicker/BbDateRangePicker.razor index caaea28a6..e28dc9ebe 100644 --- a/src/BlazorBlueprint.Components/Components/DateRangePicker/BbDateRangePicker.razor +++ b/src/BlazorBlueprint.Components/Components/DateRangePicker/BbDateRangePicker.razor @@ -228,7 +228,22 @@ data-drp-day-btn disabled="@isDisabled" @onclick="@(() => HandleDateClick(dateValue))"> - @dateValue.Day + @if (DayTemplate != null) + { + @DayTemplate(new CalendarDayContext + { + Date = dateValue, + IsSelected = isRangeStart || isRangeEnd, + IsDisabled = isDisabled, + IsOutsideMonth = false, + IsToday = isToday, + IsInRange = isInRange + }) + } + else + { + @dateValue.Day + } } diff --git a/src/BlazorBlueprint.Components/Components/DateRangePicker/BbDateRangePicker.razor.cs b/src/BlazorBlueprint.Components/Components/DateRangePicker/BbDateRangePicker.razor.cs index dac40f657..d7234e1cf 100644 --- a/src/BlazorBlueprint.Components/Components/DateRangePicker/BbDateRangePicker.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DateRangePicker/BbDateRangePicker.razor.cs @@ -73,6 +73,21 @@ public partial class BbDateRangePicker : ComponentBase [Parameter] public Func? DisabledDates { get; set; } + /// + /// Function returning additional CSS classes for a specific day button in the + /// calendars, composed with the built-in day classes. + /// + [Parameter] + public Func? DayClassFunc { get; set; } + + /// + /// Optional template for rendering custom content inside each calendar day button. + /// The context includes range state via . + /// When null, the day number is rendered. + /// + [Parameter] + public RenderFragment? DayTemplate { get; set; } + /// /// The minimum number of days that must be selected. /// @@ -623,29 +638,32 @@ private static string GetCellClass(DateTime? day, bool isInRange, bool isRangeSt return CellDefault; } - private static string GetDayClass(DateTime date, bool isDisabled, bool isInRange, bool isRangeStart, bool isRangeEnd, bool isToday) + private string GetDayClass(DateTime date, bool isDisabled, bool isInRange, bool isRangeStart, bool isRangeEnd, bool isToday) { + string baseClass; if (isDisabled) { - return DayDisabled; + baseClass = DayDisabled; } - - if (isRangeStart || isRangeEnd) + else if (isRangeStart || isRangeEnd) { - return DayRangeEndpoint; + baseClass = DayRangeEndpoint; } - - if (isInRange) + else if (isInRange) { - return DayInRange; + baseClass = DayInRange; } - - if (isToday) + else if (isToday) + { + baseClass = DayToday; + } + else { - return DayToday; + baseClass = DayDefault; } - return DayDefault; + var customClass = DayClassFunc?.Invoke(date); + return string.IsNullOrEmpty(customClass) ? baseClass : ClassNames.cn(baseClass, customClass); } /// diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index c15d550a4..96df725ec 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -251,6 +251,8 @@ - Class : String - CustomDayNames : String[] - DayClass : String + - DayClassFunc : Func + - DayTemplate : RenderFragment - DisabledDates : Func - FirstDayOfWeek : DayOfWeek? - MaxDate : DateTime? @@ -991,6 +993,8 @@ - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] - Class : String - DateFormat : String + - DayClassFunc : Func + - DayTemplate : RenderFragment - Disabled : Boolean - DisabledDates : Func - FirstDayOfWeek : DayOfWeek? @@ -1008,6 +1012,8 @@ - Class : String - CustomDayNames : String[] - DateFormat : String + - DayClassFunc : Func + - DayTemplate : RenderFragment - Disabled : Boolean - DisabledDates : Func - FirstDayOfWeek : DayOfWeek? From 4f08dd2267ba86c835303a9c0fedcd2b6286972f Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Thu, 2 Jul 2026 15:37:12 +0800 Subject: [PATCH 119/188] fix(forms): emit full model path in name attributes for SSR form posts Input components derived their name attribute from FieldIdentifier.FieldName, which is only the leaf property name ("Username"). Native Blazor inputs render the full expression path ("Input.Username"), which [SupplyParameterFromForm] requires to bind the model on enhanced/SSR form submissions. - New internal ExpressionPathFormatter walks the ValueExpression member chain (including list/array indexers) to produce the full path, falling back to FieldIdentifier.FieldName for unsupported shapes. - InputValidationBehavior.GetEffectiveName now returns the full path, fixing BbInput, BbTextarea, BbInputField, BbInputGroupInput/Textarea, BbNumericInput, BbCurrencyInput, BbMaskedInput and the FormField wrappers built on them. - BbCheckbox posted no value at all (it renders as a button): it now renders a hidden native checkbox mirroring the checked state, named via the new Name parameter or auto-derived from CheckedExpression. BbFormFieldCheckbox forwards the new Name parameter. Fixes #381 --- .../Components/Checkbox/BbCheckbox.razor | 14 +++ .../Components/Checkbox/BbCheckbox.razor.cs | 24 ++++ .../BbFormFieldCheckbox.razor | 2 + .../BbFormFieldCheckbox.razor.cs | 7 ++ .../Internal/ExpressionPathFormatter.cs | 107 ++++++++++++++++++ .../Internal/InputValidationBehavior.cs | 11 +- ...entsApiSurfaceMatchesBaseline.verified.txt | 2 + 7 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 src/BlazorBlueprint.Components/Internal/ExpressionPathFormatter.cs diff --git a/src/BlazorBlueprint.Components/Components/Checkbox/BbCheckbox.razor b/src/BlazorBlueprint.Components/Components/Checkbox/BbCheckbox.razor index 4fbe7a3ad..625e8eec7 100644 --- a/src/BlazorBlueprint.Components/Components/Checkbox/BbCheckbox.razor +++ b/src/BlazorBlueprint.Components/Components/Checkbox/BbCheckbox.razor @@ -14,3 +14,17 @@ aria-describedby="@AriaDescribedBy"> +@if (EffectiveName is not null) +{ + @* Hidden native checkbox mirroring the checked state so the value is included in + form posts (the visible checkbox is a button and posts nothing). Posts "true" + when checked; absent when unchecked, which model-binds bool as false. *@ + +} diff --git a/src/BlazorBlueprint.Components/Components/Checkbox/BbCheckbox.razor.cs b/src/BlazorBlueprint.Components/Components/Checkbox/BbCheckbox.razor.cs index 46f95b8e4..fd49783da 100644 --- a/src/BlazorBlueprint.Components/Components/Checkbox/BbCheckbox.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Checkbox/BbCheckbox.razor.cs @@ -38,6 +38,7 @@ public partial class BbCheckbox : ComponentBase private FieldIdentifier _fieldIdentifier; private EditContext? _editContext; private string? generatedId; + private string? formattedName; /// /// Gets or sets the cascaded EditContext from a parent EditForm. @@ -147,6 +148,28 @@ public partial class BbCheckbox : ComponentBase [Parameter] public Expression>? CheckedExpression { get; set; } + /// + /// Gets or sets the HTML name attribute used for form submission. + /// + /// + /// When inside an EditForm and not explicitly set, the name is automatically + /// derived from (e.g. "Input.RememberMe") so the + /// checkbox posts a value that [SupplyParameterFromForm] can bind on + /// SSR/enhanced form submissions. A hidden native checkbox mirrors the checked + /// state for the actual form post. + /// + [Parameter] + public string? Name { get; set; } + + /// + /// Gets the effective name attribute, falling back to the CheckedExpression's + /// member path when inside an EditForm. + /// + private string? EffectiveName => + Name ?? (_editContext != null && _fieldIdentifier.FieldName != null + ? formattedName ?? _fieldIdentifier.FieldName + : null); + /// /// Gets whether the checkbox is in an invalid state (for validation). /// @@ -208,6 +231,7 @@ protected override void OnParametersSet() { _editContext = CascadedEditContext; _fieldIdentifier = FieldIdentifier.Create(CheckedExpression); + formattedName = ExpressionPathFormatter.FormatLambda(CheckedExpression); } } } diff --git a/src/BlazorBlueprint.Components/Components/FormFieldCheckbox/BbFormFieldCheckbox.razor b/src/BlazorBlueprint.Components/Components/FormFieldCheckbox/BbFormFieldCheckbox.razor index 4a67bd453..12bfb2716 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldCheckbox/BbFormFieldCheckbox.razor +++ b/src/BlazorBlueprint.Components/Components/FormFieldCheckbox/BbFormFieldCheckbox.razor @@ -18,6 +18,7 @@ Checked="@Checked" CheckedChanged="HandleCheckedChanged" CheckedExpression="@CheckedExpression" + Name="@Name" Indeterminate="@Indeterminate" IndeterminateChanged="@IndeterminateChanged" Disabled="@Disabled" @@ -37,6 +38,7 @@ Checked="@Checked" CheckedChanged="HandleCheckedChanged" CheckedExpression="@CheckedExpression" + Name="@Name" Indeterminate="@Indeterminate" IndeterminateChanged="@IndeterminateChanged" Disabled="@Disabled" diff --git a/src/BlazorBlueprint.Components/Components/FormFieldCheckbox/BbFormFieldCheckbox.razor.cs b/src/BlazorBlueprint.Components/Components/FormFieldCheckbox/BbFormFieldCheckbox.razor.cs index 67019fbc3..f47a6b79a 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldCheckbox/BbFormFieldCheckbox.razor.cs +++ b/src/BlazorBlueprint.Components/Components/FormFieldCheckbox/BbFormFieldCheckbox.razor.cs @@ -27,6 +27,13 @@ public partial class BbFormFieldCheckbox : FormFieldBase [Parameter] public Expression>? CheckedExpression { get; set; } + /// + /// Gets or sets the HTML name attribute. Passed through to the inner Checkbox; + /// auto-derived from CheckedExpression inside an EditForm when not set. + /// + [Parameter] + public string? Name { get; set; } + /// /// Gets or sets whether the checkbox is in an indeterminate state. /// diff --git a/src/BlazorBlueprint.Components/Internal/ExpressionPathFormatter.cs b/src/BlazorBlueprint.Components/Internal/ExpressionPathFormatter.cs new file mode 100644 index 000000000..0c79269b1 --- /dev/null +++ b/src/BlazorBlueprint.Components/Internal/ExpressionPathFormatter.cs @@ -0,0 +1,107 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace BlazorBlueprint.Components; + +/// +/// Formats a value expression (e.g. () => Input.Username) into the dotted member +/// path used for the HTML name attribute ("Input.Username"), matching the behavior of +/// Blazor's built-in InputBase<T> so that [SupplyParameterFromForm] +/// model binding works with enhanced/SSR form posts. +/// +internal static class ExpressionPathFormatter +{ + /// + /// Formats the member path of a value expression, or returns null when the + /// expression shape is not a plain member/indexer chain (callers should fall + /// back to ). + /// + public static string? FormatLambda(LambdaExpression? expression) + { + if (expression is null) + { + return null; + } + + // Collected inner-most first, then reversed. Indexer segments render as "[n]" + // and attach to the preceding member without a dot separator. + var segments = new List(); + var node = expression.Body; + + while (node is not null) + { + switch (node) + { + case MemberExpression member: + segments.Add(member.Member.Name); + node = member.Expression; + break; + + case BinaryExpression { NodeType: ExpressionType.ArrayIndex } arrayIndex: + if (!TryEvaluateIndex(arrayIndex.Right, out var arrayIdx)) + { + return null; + } + segments.Add($"[{arrayIdx}]"); + node = arrayIndex.Left; + break; + + case MethodCallExpression { Method.Name: "get_Item", Arguments.Count: 1 } call when call.Object is not null: + if (!TryEvaluateIndex(call.Arguments[0], out var itemIdx)) + { + return null; + } + segments.Add($"[{itemIdx}]"); + node = call.Object; + break; + + case ConstantExpression: + // Root of the chain: the component instance or a closure class. + node = null; + break; + + default: + // Unsupported shape (casts, method calls, etc.) — let callers fall back. + return null; + } + } + + if (segments.Count == 0) + { + return null; + } + + segments.Reverse(); + + var result = new System.Text.StringBuilder(); + foreach (var segment in segments) + { + if (result.Length > 0 && segment[0] != '[') + { + result.Append('.'); + } + result.Append(segment); + } + + return result.ToString(); + } + + private static bool TryEvaluateIndex(Expression indexExpression, out object? value) + { + switch (indexExpression) + { + case ConstantExpression constant: + value = constant.Value; + return true; + + // Captured loop variable: a field access on a closure constant. + case MemberExpression { Expression: ConstantExpression closure, Member: FieldInfo field }: + value = field.GetValue(closure.Value); + return true; + + default: + value = null; + return false; + } + } +} diff --git a/src/BlazorBlueprint.Components/Internal/InputValidationBehavior.cs b/src/BlazorBlueprint.Components/Internal/InputValidationBehavior.cs index 472cfcc25..eabdd4e1d 100644 --- a/src/BlazorBlueprint.Components/Internal/InputValidationBehavior.cs +++ b/src/BlazorBlueprint.Components/Internal/InputValidationBehavior.cs @@ -11,6 +11,7 @@ internal sealed class InputValidationBehavior { private FieldIdentifier fieldIdentifier; private EditContext? editContext; + private string? formattedName; /// /// Gets whether the field has validation errors in the current EditContext. @@ -28,10 +29,15 @@ public bool IsInvalid } /// - /// Gets the effective name attribute, falling back to the FieldIdentifier name when inside an EditForm. + /// Gets the effective name attribute, falling back to the full member path of the + /// value expression when inside an EditForm (e.g. "Input.Username" — matching + /// InputBase so [SupplyParameterFromForm] binds on SSR form posts), then to the + /// FieldIdentifier's leaf field name. /// public string? GetEffectiveName(string? name) => - name ?? (editContext != null && fieldIdentifier.FieldName != null ? fieldIdentifier.FieldName : null); + name ?? (editContext != null && fieldIdentifier.FieldName != null + ? formattedName ?? fieldIdentifier.FieldName + : null); /// /// Gets the effective aria-invalid value combining manual AriaInvalid, parent field state, and EditContext validation. @@ -70,6 +76,7 @@ public void Update(EditContext? cascadedEditContext, Expression>? val { editContext = cascadedEditContext; fieldIdentifier = FieldIdentifier.Create(valueExpression); + formattedName = ExpressionPathFormatter.FormatLambda(valueExpression); } } } diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 0cd127624..73ff97cca 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -469,6 +469,7 @@ - Id : String - Indeterminate : Boolean - IndeterminateChanged : EventCallback + - Name : String - Required : Boolean - CascadedEditContext : EditContext [CascadingParameter] @@ -1451,6 +1452,7 @@ - IndeterminateChanged : EventCallback - InputClass : String - Label : String + - Name : String - Orientation : FieldOrientation - Required : Boolean From b03e77740de5aebeec71915882e0270aeee8a523 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Thu, 2 Jul 2026 15:41:46 +0800 Subject: [PATCH 120/188] feat(theme-switcher): independent base and primary color selection Split the BbThemeSwitcher color grid into separate Base color and Primary color sections, each with its own selection indicator. Selecting a base color no longer resets the primary color, and the base color's indicator is no longer lost when a primary is picked. - New ThemeSwitcherColorLayout enum (Split, Combined) and ColorLayout parameter. Split is the default; Combined preserves the legacy single-grid behavior (base selection resets primary) for anyone relying on it. - Split layout adds a "Default" primary chip that restores the base palette's own accent; its swatch reflects the active base color. - New localizer keys: Theme.BaseColor, Theme.PrimaryColor, Theme.Default. - Demo: Color layout example, API Reference entries (ColorLayout, Strategy), and a LocalizationSection for theme switcher strings. Closes #373 --- .../Pages/Components/ThemeDemo.razor | 56 +++++ .../Components/Theme/BbThemeSwitcher.razor | 193 +++++++++++------- .../Theme/ThemeSwitcherColorLayout.cs | 20 ++ .../Localization/DefaultBbLocalizer.cs | 3 + ...entsApiSurfaceMatchesBaseline.verified.txt | 5 + 5 files changed, 199 insertions(+), 78 deletions(-) create mode 100644 src/BlazorBlueprint.Components/Components/Theme/ThemeSwitcherColorLayout.cs diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ThemeDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ThemeDemo.razor index e78d8500c..6fa8c1b68 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ThemeDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/ThemeDemo.razor @@ -43,6 +43,8 @@

    A popover panel with color selection (base grays + primary accents), border radius presets, and a light/dark mode toggle. Click the paintbrush icon to open. + By default, base and primary colors are shown as separate sections, each with its + own selection indicator — selecting one never resets the other.

    @@ -52,6 +54,27 @@ Click the icon to open the theme panel
    +
    +

    Color layout

    +

    + ColorLayout controls how + colors are presented. The default, + ThemeSwitcherColorLayout.Split, + shows independent Base color and Primary color sections. + Combined restores the legacy + single grid, where picking a base color resets the primary color to its default. +

    +
    + + Combined (legacy) layout +
    +
    <!-- Split layout (default) -->
    +<BbThemeSwitcher />
    +
    +<!-- Combined (legacy) layout -->
    +<BbThemeSwitcher ColorLayout="ThemeSwitcherColorLayout.Combined" />
    +
    +

    Trigger variants

    @@ -183,6 +206,14 @@ await ThemeService.ToggleDarkModeAsync(); Horizontal alignment of the popover relative to the trigger. + + Positioning strategy for the popover. + + + Layout of the color selection area. Split shows separate base and primary + color sections with independent selection indicators; Combined shows the + legacy single grid where selecting a base color resets the primary color. + @@ -213,5 +244,30 @@ await ThemeService.ToggleDarkModeAsync();

    + + +{ + localizer.Set(""Theme.BaseColor"", ""Grundfarbe""); + localizer.Set(""Theme.PrimaryColor"", ""Primärfarbe""); + localizer.Set(""Theme.Default"", ""Standard""); +});")" />
    diff --git a/src/BlazorBlueprint.Components/Components/Theme/BbThemeSwitcher.razor b/src/BlazorBlueprint.Components/Components/Theme/BbThemeSwitcher.razor index ca2775d6a..0634cc61c 100644 --- a/src/BlazorBlueprint.Components/Components/Theme/BbThemeSwitcher.razor +++ b/src/BlazorBlueprint.Components/Components/Theme/BbThemeSwitcher.razor @@ -23,35 +23,60 @@ - @* ── Color ── *@ -
    -

    @Localizer["Theme.Color"]

    -
    - @foreach (var color in allColors) - { - var isSelected = IsColorSelected(color); - - } + @if (ColorLayout == ThemeSwitcherColorLayout.Combined) + { + @* ── Color (combined grid, single indicator) ── *@ +
    +

    @Localizer["Theme.Color"]

    +
    + @foreach (var color in baseColors) + { + @ColorChip(color.Label, color.Swatch, + ThemeService.PrimaryColor == PrimaryColor.Default && ThemeService.BaseColor == color.Value, + () => OnCombinedBaseSelectedAsync(color.Value)) + } + @foreach (var color in primaryColors) + { + @ColorChip(color.Label, color.Swatch, + ThemeService.PrimaryColor == color.Value, + () => ThemeService.SetPrimaryColorAsync(color.Value)) + } +
    +
    + } + else + { + @* ── Base color ── *@ +
    +

    @Localizer["Theme.BaseColor"]

    +
    + @foreach (var color in baseColors) + { + @ColorChip(color.Label, color.Swatch, + ThemeService.BaseColor == color.Value, + () => ThemeService.SetBaseColorAsync(color.Value)) + } +
    -
    + + + + @* ── Primary color ── *@ +
    +

    @Localizer["Theme.PrimaryColor"]

    +
    + @ColorChip(Localizer["Theme.Default"], CurrentBaseSwatch, + ThemeService.PrimaryColor == PrimaryColor.Default, + () => ThemeService.SetPrimaryColorAsync(PrimaryColor.Default)) + @foreach (var color in primaryColors) + { + @ColorChip(color.Label, color.Swatch, + ThemeService.PrimaryColor == color.Value, + () => ThemeService.SetPrimaryColorAsync(color.Value)) + } +
    +
    + } @@ -138,6 +163,15 @@ [Parameter] public PositioningStrategy Strategy { get; set; } = PositioningStrategy.Absolute; + /// + /// Layout of the color selection area. Defaults to , + /// which shows separate base color and primary color sections with independent selection + /// indicators. Use for the legacy single-grid + /// layout where selecting a base color resets the primary color to . + /// + [Parameter] + public ThemeSwitcherColorLayout ColorLayout { get; set; } = ThemeSwitcherColorLayout.Split; + /// protected override void OnInitialized() => ThemeService.OnThemeChanged += HandleThemeChanged; @@ -153,40 +187,39 @@ } /// - /// Determines if a color chip is the currently active selection. - /// Only one chip can be selected at a time: - /// - If the user picked a primary color (non-Default), that primary chip is selected. - /// - If PrimaryColor is Default, the current BaseColor chip is selected. + /// Renders a single color chip button with an optional selected indicator. /// - private bool IsColorSelected(ColorInfo color) + private RenderFragment ColorChip(string label, string swatch, bool isSelected, Func onClick) => __builder => { - if (color.IsBase) - { - // A base chip is selected only when PrimaryColor == Default AND it matches the active base - return ThemeService.PrimaryColor == PrimaryColor.Default - && ThemeService.BaseColor == color.BaseValue; - } - - // A primary chip is selected when it matches the active primary - return ThemeService.PrimaryColor == color.PrimaryValue; - } + + }; /// - /// When a base color is picked, set the base AND reset primary to Default - /// so the base's own accent is used. When a primary is picked, just set primary. + /// Combined layout only: when a base color is picked, set the base AND reset primary + /// to Default so the base's own accent is used. /// - private async Task OnColorSelected(ColorInfo color) + private async Task OnCombinedBaseSelectedAsync(BaseColor value) { - if (color.IsBase) - { - // Reset primary so the base's built-in accent is used - await ThemeService.SetPrimaryColorAsync(PrimaryColor.Default); - await ThemeService.SetBaseColorAsync(color.BaseValue); - } - else - { - await ThemeService.SetPrimaryColorAsync(color.PrimaryValue); - } + await ThemeService.SetPrimaryColorAsync(PrimaryColor.Default); + await ThemeService.SetBaseColorAsync(value); } private async Task OnRadiusSelected(double value) => @@ -209,15 +242,17 @@ private static readonly double[] radiusOptions = [0, 0.3, 0.5, 0.75, 1.0]; - private static readonly ColorInfo[] allColors = + private static readonly ColorInfo[] baseColors = [ - // Base colors — selecting one resets primary to Default new("Zinc", "oklch(0.552 0.016 285.94)", BaseColor.Zinc), new("Slate", "oklch(0.554 0.046 257.42)", BaseColor.Slate), new("Stone", "oklch(0.553 0.013 58.07)", BaseColor.Stone), new("Gray", "oklch(0.551 0.027 264.36)", BaseColor.Gray), new("Neutral", "oklch(0.556 0 0)", BaseColor.Neutral), - // Primary colors — selecting one overrides the accent only + ]; + + private static readonly ColorInfo[] primaryColors = + [ new("Red", "oklch(0.577 0.245 27.33)", PrimaryColor.Red), new("Rose", "oklch(0.585 0.22 3.96)", PrimaryColor.Rose), new("Orange", "oklch(0.705 0.213 47.60)", PrimaryColor.Orange), @@ -237,28 +272,30 @@ new("Pink", "oklch(0.592 0.249 0.58)", PrimaryColor.Pink), ]; - private sealed class ColorInfo + /// + /// Swatch of the currently active base color — used for the "Default" primary chip, + /// since the Default primary inherits its accent from the base palette. + /// + private string CurrentBaseSwatch { - public string Label { get; } - public string Swatch { get; } - public bool IsBase { get; } - public BaseColor BaseValue { get; } - public PrimaryColor PrimaryValue { get; } - - public ColorInfo(string label, string swatch, BaseColor baseValue) + get { - Label = label; - Swatch = swatch; - IsBase = true; - BaseValue = baseValue; - } + foreach (var color in baseColors) + { + if (color.Value == ThemeService.BaseColor) + { + return color.Swatch; + } + } - public ColorInfo(string label, string swatch, PrimaryColor primaryValue) - { - Label = label; - Swatch = swatch; - IsBase = false; - PrimaryValue = primaryValue; + return baseColors[0].Swatch; } } + + private sealed class ColorInfo(string label, string swatch, TColor value) where TColor : struct, Enum + { + public string Label { get; } = label; + public string Swatch { get; } = swatch; + public TColor Value { get; } = value; + } } diff --git a/src/BlazorBlueprint.Components/Components/Theme/ThemeSwitcherColorLayout.cs b/src/BlazorBlueprint.Components/Components/Theme/ThemeSwitcherColorLayout.cs new file mode 100644 index 000000000..5cd173bc2 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Theme/ThemeSwitcherColorLayout.cs @@ -0,0 +1,20 @@ +namespace BlazorBlueprint.Components; + +/// +/// Controls how BbThemeSwitcher presents its color choices. +/// +public enum ThemeSwitcherColorLayout +{ + /// + /// Base and primary colors are shown in separate sections, each with its own + /// independent selection indicator. Selecting one never resets the other. + /// + Split, + + /// + /// Base and primary colors are shown in a single combined grid with one selection + /// indicator. Selecting a base color resets the primary color to + /// (legacy behavior). + /// + Combined +} diff --git a/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs b/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs index cafef2b7a..d809ba77f 100644 --- a/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs +++ b/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs @@ -306,6 +306,9 @@ public class DefaultBbLocalizer : IBbLocalizer ["Theme.Switcher.Title"] = "Customize", ["Theme.Switcher.Description"] = "Pick a color and radius for your components.", ["Theme.Color"] = "Color", + ["Theme.BaseColor"] = "Base color", + ["Theme.PrimaryColor"] = "Primary color", + ["Theme.Default"] = "Default", ["Theme.Radius"] = "Radius", ["Theme.Mode"] = "Mode", ["Theme.Light"] = "Light", diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 0cd127624..5c2d3cd20 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -3272,6 +3272,7 @@ ### BbThemeSwitcher (BlazorBlueprint.Components) - Align : PopoverAlign + - ColorLayout : ThemeSwitcherColorLayout - PopoverContentClass : String - Strategy : PositioningStrategy - TriggerClass : String @@ -4183,6 +4184,10 @@ - Outline = 1 - Secondary = 2 +### ThemeSwitcherColorLayout (BlazorBlueprint.Components) + - Split = 0 + - Combined = 1 + ### TimeFormat (BlazorBlueprint.Components) - Hour12 = 0 - Hour24 = 1 From e82970895cbf23dcbf8f0bc254e01811b91ee776 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Thu, 2 Jul 2026 15:42:00 +0800 Subject: [PATCH 121/188] fix(popover): apply pointer-events guard to AsChild triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The non-AsChild popover trigger button sets pointer-events:none while the popover is open so a single click can't both close it (via click-outside) and re-open it (via the trigger's click handler) during Blazor Server event routing. The AsChild path — used by BbDatePicker and BbDateRangePicker — was missing that guard. TriggerContext gains an opt-in SuppressPointerEventsWhenOpen flag, set by BbPopoverTrigger and honored by BbButton, restoring parity between the two trigger modes. Related to #371 --- split-layout-popover.png | Bin 0 -> 35936 bytes .../Components/Button/BbButton.razor.cs | 3 +++ .../Primitives/Popover/BbPopoverTrigger.razor | 4 +++- .../Utilities/TriggerContext.cs | 9 +++++++++ 4 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 split-layout-popover.png diff --git a/split-layout-popover.png b/split-layout-popover.png new file mode 100644 index 0000000000000000000000000000000000000000..1dbd181178d7781f10ead8802643a509ec28f848 GIT binary patch literal 35936 zcmbTeWmp_tw=D`mg1ZwuBq6vv1P>4_xCaRC5}a<_-Q6w0-66QUySp^54cvOaE%(@d z_PMuz@HEY;u3D>B%{k^6V-lh$FNKamf&v2rgD(A5{5uQ`tO^Xwn_oz8fKQMQvQA)N zuwbOcMO0kVPcjf(aWx48i_Xs8)CSb!nc2G}^=hwVNT}HnG#J~v5V$05ot-f;7D&c4 zKfThzJ=9!$Q%>dDr`<_e^Dv1`Z(2`qG~GMe+uLJ9>uxvAFETYT+1uM;ffvD}s;#X( z*H=(bSpU26l;t|UDN9zZAoJ}DLUu((MOoP>7Q9le_Y^xDTWwu!xSSk18d^+l*z51# zbRicPdjyG+eSK&mSQ1Gy@$vTy8tka3G&Dx5rKJp?)`yk?Pcea`{YBs^^73#=aU-z3 z)pV@T5Mj)2iG3+K{QU2ES}jdYeiD(U0T*J17A?@_*=o~Ko=Gl-<=+Q0D0Yvx@DT|; zphp3V1SKSkk^>J%Sj6npdywyh;#VkqPEPbavZxr<1+IUf=+^&Xpc_8e(6H-je|&m+ znz(NB`nU4U@vPV74l?ldPK{x&Qw3piX&lbZPNkY{sbzJlcQ(2mUQc~?TbtcMuQn_9 zXRF$h_$#e$SDSus$Fl^Dk$j^g=Zb>uq)Z7VrI~bi*4wSMt-^_TrM4OU!R zZP%)Qoe5t(-W~^`w@QhNzvFktI|a`UB?LV`(px^
    + +
    +
    +

    Manual Entry

    +

    + Set Editable to let users type + the date directly — the calendar stays available via the toggle button. Typed text is parsed + on blur or Enter against InputFormats + (falling back to DateFormat), and ISO + format (yyyy-MM-dd) is always accepted. + Invalid text reverts to the current value. +

    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +

    Selected values:

    +

    @(_editableDate?.ToString("yyyy-MM-dd") ?? "(none)") · @(_editableDateDmy?.ToString("yyyy-MM-dd") ?? "(none)") · @(_editableDateMdy?.ToString("yyyy-MM-dd") ?? "(none)")

    +
    +
    + +
    +
    @@ -269,6 +309,12 @@ Function returning additional CSS classes for a specific calendar day button. + + Renders the trigger as a text input so the date can be typed directly, with a calendar toggle alongside. Typed text is parsed on blur or Enter; invalid or out-of-range text reverts to the current value. + + + Date formats accepted when typing in Editable mode, in priority order (e.g. "dd/MM/yyyy"). Falls back to DateFormat when null. ISO format (yyyy-MM-dd) is always accepted in addition. + Whether the date picker is disabled. @@ -284,6 +330,7 @@ Labels="@(new LocalizationSection.LabelInfo[] { new("Placeholder", "Pick a date", "Placeholder text when no date is selected"), + new("OpenCalendar", "Open calendar", "Aria-label for the calendar toggle button in Editable mode"), })" CultureAware="true" CultureDescription="The internal calendar uses CultureInfo.CurrentCulture for month/day names. The DateFormat parameter accepts standard .NET format strings that respect the current culture." @@ -307,6 +354,9 @@ private DateTime? _sundayStart; private DateTime? _mondayStart; private DateTime? _customDayDate; + private DateTime? _editableDate; + private DateTime? _editableDateDmy; + private DateTime? _editableDateMdy; private static readonly HashSet EventDays = new() { 3, 8, 12, 17, 21, 26 }; diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldDatePickerDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldDatePickerDemo.razor index 8fd6ca9e8..e143aac28 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldDatePickerDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldDatePickerDemo.razor @@ -176,6 +176,12 @@ The first day of the week. Defaults to current culture setting. + + Lets the date be typed directly into a text input, with a calendar toggle alongside. Typed text parses on blur or Enter; ISO (yyyy-MM-dd) is always accepted. + + + Date formats accepted when typing in Editable mode. Falls back to DateFormat when null. + Whether the date picker is disabled. diff --git a/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePicker.razor b/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePicker.razor index ef86e2595..b33769c81 100644 --- a/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePicker.razor +++ b/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePicker.razor @@ -10,19 +10,31 @@ - - - @if (Value.HasValue) - { - @Value.Value.ToString(DateFormat) - } - else - { - @EffectivePlaceholder - } - + @if (Editable) + { + + } + else + { + + + @if (Value.HasValue) + { + @Value.Value.ToString(DateFormat) + } + else + { + @EffectivePlaceholder + } + + } @code { + private const string IsoInputFormat = "yyyy-MM-dd"; + private bool _isOpen; private BbCalendar? _calendar; private bool _focusDone; private FieldIdentifier _fieldIdentifier; private EditContext? _editContext; + private string? _inputText; + private DateTime? _lastSyncedValue; + private bool _inputTextInitialized; [CascadingParameter] private EditContext? CascadedEditContext { get; set; } @@ -118,6 +135,26 @@ [Parameter] public DayOfWeek? FirstDayOfWeek { get; set; } + /// + /// When true, the trigger renders as a text input so the date can be typed + /// directly, with a calendar toggle button alongside. Typed text is parsed on + /// blur or Enter against (or + /// when unset); ISO format (yyyy-MM-dd) is always accepted. Text that doesn't + /// parse — or parses to a date excluded by MinDate/MaxDate/DisabledDates — + /// reverts to the current value. + /// + [Parameter] + public bool Editable { get; set; } + + /// + /// The date formats accepted when typing in mode, in + /// priority order (e.g. new[] { "dd/MM/yyyy", "d/M/yyyy" }). Parsed with + /// the current culture. When null, is used. ISO format + /// (yyyy-MM-dd) is always accepted in addition to these. + /// + [Parameter] + public string[]? InputFormats { get; set; } + /// /// Whether the date picker is disabled. /// @@ -151,19 +188,96 @@ _editContext = CascadedEditContext; _fieldIdentifier = FieldIdentifier.Create(ValueExpression); } + + // Sync the editable input text when Value changes externally (initial bind, + // programmatic set, or calendar selection) without clobbering in-progress typing. + if (!_inputTextInitialized || Value != _lastSyncedValue) + { + _inputTextInitialized = true; + _lastSyncedValue = Value; + _inputText = Value?.ToString(DateFormat); + } } private async Task HandleDateSelected(DateTime? date) + { + await SetValueAsync(date); + _isOpen = false; + } + + private async Task SetValueAsync(DateTime? date) { Value = date; + _lastSyncedValue = date; + _inputText = date?.ToString(DateFormat); await ValueChanged.InvokeAsync(date); if (_editContext != null && ValueExpression != null && _fieldIdentifier.FieldName != null) { _editContext.NotifyFieldChanged(_fieldIdentifier); } + } - _isOpen = false; + private async Task HandleInputCommit(string? text) + { + var trimmed = text?.Trim(); + + if (string.IsNullOrEmpty(trimmed)) + { + await SetValueAsync(null); + return; + } + + if (TryParseInput(trimmed, out var date) && IsSelectable(date)) + { + await SetValueAsync(date); + return; + } + + // Unparseable or not selectable: revert the input to the current value. + // First align the render tree with the DOM's rejected text and flush, + // otherwise the diff sees no change and leaves the rejected text visible. + _inputText = trimmed; + StateHasChanged(); + await Task.Yield(); + _inputText = Value?.ToString(DateFormat); + StateHasChanged(); + } + + /// + /// Parses typed text against (or + /// when unset) using the current culture, always accepting ISO yyyy-MM-dd as well. + /// + private bool TryParseInput(string text, out DateTime date) + { + var formats = InputFormats is { Length: > 0 } ? InputFormats : new[] { DateFormat }; + + foreach (var format in formats) + { + if (DateTime.TryParseExact(text, format, System.Globalization.CultureInfo.CurrentCulture, + System.Globalization.DateTimeStyles.None, out date)) + { + return true; + } + } + + return DateTime.TryParseExact(text, IsoInputFormat, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out date); + } + + private bool IsSelectable(DateTime date) + { + if (MinDate.HasValue && date.Date < MinDate.Value.Date) + { + return false; + } + + if (MaxDate.HasValue && date.Date > MaxDate.Value.Date) + { + return false; + } + + return DisabledDates?.Invoke(date) != true; } private void HandleOpenChanged(bool isOpen) diff --git a/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePickerInput.razor b/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePickerInput.razor new file mode 100644 index 000000000..0b2760573 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePickerInput.razor @@ -0,0 +1,133 @@ +@namespace BlazorBlueprint.Components +@using BlazorBlueprint.Icons.Lucide.Components +@using BlazorBlueprint.Primitives.Utilities +@inject IBbLocalizer Localizer + +
    + + +
    + +@code { + private ElementReference _rootRef; + private TriggerContext? _previousTriggerContext; + + [CascadingParameter(Name = "TriggerContext")] + private TriggerContext? TriggerContext { get; set; } + + /// + /// The current text shown in the input. + /// + [Parameter] + public string? Text { get; set; } + + /// + /// Placeholder text shown when the input is empty. + /// + [Parameter] + public string? Placeholder { get; set; } + + /// + /// Whether the input and calendar toggle are disabled. + /// + [Parameter] + public bool Disabled { get; set; } + + /// + /// Whether the field is required (sets aria-required). + /// + [Parameter] + public bool Required { get; set; } + + /// + /// Whether the field is in an invalid state (sets aria-invalid). + /// + [Parameter] + public bool IsInvalid { get; set; } + + /// + /// Additional CSS classes applied to the wrapper element. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Invoked when the user commits the typed text (blur or Enter). + /// + [Parameter] + public EventCallback OnCommit { get; set; } + + private string WrapperCssClass => ClassNames.cn( + "flex h-10 w-[280px] items-center rounded-md border border-input bg-background text-base md:text-sm", + "ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2", + "transition-colors", + "aria-[invalid=true]:border-destructive", + Disabled ? "cursor-not-allowed opacity-50" : null, + Class + ); + + private string ToggleCssClass => ClassNames.cn( + "inline-flex h-full items-center justify-center px-3 text-muted-foreground", + "hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + "disabled:pointer-events-none", + // Mirror the pointer-events guard trigger buttons apply while the popover is + // open, so one click can't close it via click-outside and immediately re-open it. + // Scoped to the toggle only — the input must stay editable while open. + TriggerContext is { IsOpen: true, SuppressPointerEventsWhenOpen: true } ? "pointer-events-none" : null + ); + + private async Task HandleChange(ChangeEventArgs args) + { + await OnCommit.InvokeAsync(args.Value?.ToString()); + } + + private Task HandleKeyDown(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs args) + { + // Escape closes the calendar without touching the typed text; commit itself + // rides on the native change event (fires on both Enter and blur). + if (args.Key == "Escape" && TriggerContext?.IsOpen == true) + { + TriggerContext.Close?.Invoke(); + } + + return Task.CompletedTask; + } + + private void HandleToggleClick() + { + TriggerContext?.Toggle?.Invoke(); + } + + /// + /// Registers the wrapper element with the trigger context so the popover + /// positions against the whole input group. Re-registers when the context + /// changes (same pattern as BbButton). + /// + protected override void OnAfterRender(bool firstRender) + { + if (TriggerContext?.SetTriggerElement != null && + (firstRender || TriggerContext != _previousTriggerContext)) + { + TriggerContext.SetTriggerElement.Invoke(_rootRef); + _previousTriggerContext = TriggerContext; + } + } +} diff --git a/src/BlazorBlueprint.Components/Components/FormFieldDatePicker/BbFormFieldDatePicker.razor b/src/BlazorBlueprint.Components/Components/FormFieldDatePicker/BbFormFieldDatePicker.razor index 10b5d3d08..950cb71b2 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldDatePicker/BbFormFieldDatePicker.razor +++ b/src/BlazorBlueprint.Components/Components/FormFieldDatePicker/BbFormFieldDatePicker.razor @@ -16,6 +16,8 @@ MaxDate="@MaxDate" DisabledDates="@DisabledDates" FirstDayOfWeek="@FirstDayOfWeek" + Editable="@Editable" + InputFormats="@InputFormats" Disabled="@Disabled" Required="@Required" Class="@InputClass" /> diff --git a/src/BlazorBlueprint.Components/Components/FormFieldDatePicker/BbFormFieldDatePicker.razor.cs b/src/BlazorBlueprint.Components/Components/FormFieldDatePicker/BbFormFieldDatePicker.razor.cs index df166ad4a..5b82666ea 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldDatePicker/BbFormFieldDatePicker.razor.cs +++ b/src/BlazorBlueprint.Components/Components/FormFieldDatePicker/BbFormFieldDatePicker.razor.cs @@ -55,6 +55,20 @@ public partial class BbFormFieldDatePicker : FormFieldBase [Parameter] public string DateFormat { get; set; } = "d"; + /// + /// When true, the date can be typed directly into a text input, with a calendar + /// toggle alongside. See . + /// + [Parameter] + public bool Editable { get; set; } + + /// + /// The date formats accepted when typing in Editable mode; ISO (yyyy-MM-dd) is + /// always accepted. See . + /// + [Parameter] + public string[]? InputFormats { get; set; } + /// /// Gets or sets the placeholder text when no date is selected. /// diff --git a/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs b/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs index 639372517..7eda01e23 100644 --- a/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs +++ b/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs @@ -124,6 +124,7 @@ public class DefaultBbLocalizer : IBbLocalizer // DatePicker ["DatePicker.Placeholder"] = "Pick a date", + ["DatePicker.OpenCalendar"] = "Open calendar", // DateRangePicker ["DateRangePicker.Placeholder"] = "Select date range", diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 068044b4a..7d22cbd86 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -1076,7 +1076,9 @@ - DayTemplate : RenderFragment - Disabled : Boolean - DisabledDates : Func + - Editable : Boolean - FirstDayOfWeek : DayOfWeek? + - InputFormats : String[] - MaxDate : DateTime? - MinDate : DateTime? - Placeholder : String @@ -1086,6 +1088,16 @@ - ValueExpression : Expression> - CascadedEditContext : EditContext [CascadingParameter] +### BbDatePickerInput (BlazorBlueprint.Components) + - Class : String + - Disabled : Boolean + - IsInvalid : Boolean + - OnCommit : EventCallback + - Placeholder : String + - Required : Boolean + - Text : String + - TriggerContext : TriggerContext [CascadingParameter] + ### BbDateRangePicker (BlazorBlueprint.Components) - AutoApply : Boolean - Class : String @@ -1574,10 +1586,12 @@ - DateFormat : String - Disabled : Boolean - DisabledDates : Func + - Editable : Boolean - ErrorText : String - FirstDayOfWeek : DayOfWeek? - HelperText : String - InputClass : String + - InputFormats : String[] - Label : String - MaxDate : DateTime? - MinDate : DateTime? From 1f8feebd46882c4d269d1810e8510607a13c64f3 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Thu, 2 Jul 2026 16:39:03 +0800 Subject: [PATCH 126/188] docs: refresh README component catalog and remove stray image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove split-layout-popover.png (temporary screenshot committed by mistake at the repo root) - README: add the components shipped this cycle — Dock, Event Calendar, Date Time Picker (+ form field), Copy Text, the new Chat & AI family (Attachment, Bubble, Marker, Message), and the previously unlisted Theme Switcher; note manual date entry on Date Picker and per-day calendar customization - README: correct counts (110 styled components, 260+ localizable strings) - CHANGELOG: note the README refresh --- CHANGELOG.md | 4 ++++ README.md | 29 ++++++++++++++++++++++++----- split-layout-popover.png | Bin 35936 -> 0 bytes 3 files changed, 28 insertions(+), 5 deletions(-) delete mode 100644 split-layout-popover.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 24b8385af..faa1009c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **BbSidebarMenuButton: OnClick never fired for navigation items** — The anchor render branch (used whenever `Href` is set) didn't wire the click handler, so `OnClick` silently never fired — breaking patterns like closing the mobile sidebar after tapping a nav link. ([#386](https://github.com/blazorblueprintui/ui/pull/386)) - **WebView2: JSException crash on page reload** — Component disposal during a WebView2 page reload throws `JSException` ("JS object instance with ID N does not exist") rather than Server's `JSDisconnectedException`. All JS-interop dispose paths in Components (and four missed in Primitives) now include `JSException` in their catch filters, following the pattern established in #232. Reported by @SimonDD7. ([#384](https://github.com/blazorblueprintui/ui/pull/384)) +### Changed + +- **README refreshed** — Component catalog updated with the new additions (Dock, Event Calendar, Date Time Picker, Copy Text, the Chat & AI family, Theme Switcher) and corrected counts (110 styled components, 260+ localizable strings). ([#395](https://github.com/blazorblueprintui/ui/pull/395)) + --- ## 2026-06-17 diff --git a/README.md b/README.md index 289c91512..4c3817260 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ builder.Services.AddBlazorBlueprintComponents(); ## Components -Blazor Blueprint includes **99 styled components** organized into the following categories. +Blazor Blueprint includes **110 styled components** organized into the following categories. ### Enterprise Components @@ -179,6 +179,8 @@ Production-ready components for complex data-driven applications: | **Filter Builder** | Visual query builder for constructing complex filter expressions with AND/OR logic, nested condition groups, and type-aware operators. Pairs with DataGrid for interactive data exploration. | | **Form Wizard** | Multi-step form wizard with progress indicators, per-step validation, optional/skippable steps, and navigation controls. | | **Chart** | 11 chart types (Area, Bar, Candlestick, Funnel, Gauge, Heatmap, Line, Pie, Radar, Radial Bar, Scatter) built on Apache ECharts with a declarative composition API and automatic theme integration. | +| **Dock** | IDE-style docking layout — drag-and-drop panels between regions, pinning, maximize, close/reopen, pop-out floating panels, and tab-strip overflow. | +| **Event Calendar** | Agenda/event calendar with Month, Week, and Agenda views, generic over your own event model, with per-event templates and styling. | | **Rich Text Editor** | WYSIWYG editor with formatting toolbar and HTML output. | | **Markdown Editor** | Toolbar formatting with split-pane live preview. | @@ -188,14 +190,15 @@ Production-ready components for complex data-driven applications: |-----------|-------------| | **Button** | Multiple variants (default, destructive, outline, secondary, ghost, link) with loading state and icon support | | **Button Group** | Visually group related buttons with connected styling | -| **Calendar** | Interactive calendar with date constraints and range selection | +| **Calendar** | Interactive calendar with date constraints, range selection, and per-day templates/styling | | **Checkbox** | Checkbox with indeterminate state and ARIA attributes | | **Checkbox Group** | Group of checkboxes with select-all support | | **Color Picker** | Color selection with swatches and custom input | | **Combobox** | Searchable autocomplete dropdown | | **Currency Input** | Currency-formatted numeric input with locale support | -| **Date Picker** | Date picker with popover calendar and formatting options | -| **Date Range Picker** | Dual-calendar range selection | +| **Date Picker** | Date picker with popover calendar, optional manual text entry with configurable input formats, and formatting options | +| **Date Range Picker** | Dual-calendar range selection with quick-select presets and optional auto-apply | +| **Date Time Picker** | Combined date and time selection in one popover — calendar plus 12/24h time panel with optional seconds | | **Dynamic Form** | Schema-driven form rendering — generates complete forms from a definition with automatic input selection, validation, conditional visibility, and layout customization | | **Field** | Combines label, control, description, and error for structured forms | | **Filter Builder** | Visual query builder for data filter expressions with AND/OR logic, condition groups, and two-way binding | @@ -206,6 +209,7 @@ Production-ready components for complex data-driven applications: | **Form Field Currency Input** | Pre-configured currency input field with built-in label, description, and validation | | **Form Field Date Picker** | Pre-configured date picker field with built-in label, description, and validation | | **Form Field Date Range Picker** | Pre-configured date range picker field with built-in label, description, and manual validation | +| **Form Field Date Time Picker** | Pre-configured date-time picker field with built-in label, description, and validation | | **Form Field File Upload** | Pre-configured file upload field with built-in label, description, and manual validation | | **Form Field Input** | Pre-configured input field with built-in label, description, and validation | | **Form Field Input OTP** | Pre-configured OTP input field with built-in label, description, and validation | @@ -253,6 +257,7 @@ Production-ready components for complex data-driven applications: | **Card** | Container with header, content, footer, and action areas | | **Carousel** | Slideshow for cycling through content | | **Collapsible** | Expandable/collapsible panels | +| **Dock** | IDE-style docking layout with drag-and-drop panels, pinning, maximize, pop-out floating windows, and tab overflow | | **Item** | Flexible list items with media, content, and actions | | **Navigation Menu** | Horizontal navigation with dropdown menus | | **Pagination** | Page navigation with first/previous/next/last controls and page size selection | @@ -290,6 +295,7 @@ Production-ready components for complex data-driven applications: | **DataGrid** | Enterprise data grid with sorting, per-column filtering, row grouping with aggregates, hierarchical tree data, selection, expandable rows, row virtualization, context menu, pinned columns, column reordering/resizing/visibility, and state persistence | | **DataTable** | Tables with sorting, filtering, pagination, and row selection | | **DataView** | Displays data using templates in a grid or list layout with sorting, filtering, pagination, and infinite scrolling | +| **Event Calendar** | Month, Week, and Agenda views over your own event model with per-event templates, styling, and click callbacks | | **Markdown Editor** | Toolbar formatting with live preview | | **Rich Text Editor** | WYSIWYG editor with formatting toolbar and HTML output | | **Tree View** | Hierarchical data display with selection, checkboxes, lazy loading, drag-and-drop, search filtering, and data-driven or declarative modes | @@ -301,13 +307,26 @@ Production-ready components for complex data-driven applications: | **Alert** | Callout messages with dismissible variants | | **Avatar** | User avatars with fallback and group support | | **Badge** | Status badges and labels | +| **Copy Text** | Click-to-copy text with tooltip feedback and copied-state indicator | | **Empty** | Empty state placeholder with icon, title, and description | | **Kbd** | Keyboard shortcut display | | **Progress** | Progress bar indicator | | **Skeleton** | Loading placeholders | | **Spinner** | Loading spinner with size variants | +| **Theme Switcher** | Theme customization popover — light/dark mode, independent base and primary colors, and radius, with persistence | | **Typography** | Consistent text styling (H1–H4, paragraph, lead, muted, blockquote, inline code, etc.) | +### Chat & AI + +Building blocks for chat and AI-agent interfaces: + +| Component | Description | +|-----------|-------------| +| **Attachment** | File attachment chips with upload states (uploading, processing, error, done), previews, and actions | +| **Bubble** | Message bubbles with tinted/outlined variants, reactions, and attachment slots | +| **Marker** | Inline status and tool-call markers (e.g. "searching the web…") with an animated shimmer effect | +| **Message** | Chat message rows with avatar, content, and footer, aligned per role | + ## Primitives Blazor Blueprint's **26 headless primitives** provide behavior, ARIA attributes, and keyboard support without any styling. They handle all the complex interaction logic — focus trapping, ARIA attributes, keyboard shortcuts, portal rendering — while giving you complete control over appearance. @@ -405,7 +424,7 @@ Apply the `.dark` class to your `` element. All components automatically s ## Localization -All component chrome strings (button labels, placeholders, ARIA labels, status messages) are localizable via the `IBbLocalizer` interface. The built-in `DefaultBbLocalizer` provides English defaults for all 189 strings. +All component chrome strings (button labels, placeholders, ARIA labels, status messages) are localizable via the `IBbLocalizer` interface. The built-in `DefaultBbLocalizer` provides English defaults for all 260+ strings. ### Quick Start diff --git a/split-layout-popover.png b/split-layout-popover.png deleted file mode 100644 index 1dbd181178d7781f10ead8802643a509ec28f848..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35936 zcmbTeWmp_tw=D`mg1ZwuBq6vv1P>4_xCaRC5}a<_-Q6w0-66QUySp^54cvOaE%(@d z_PMuz@HEY;u3D>B%{k^6V-lh$FNKamf&v2rgD(A5{5uQ`tO^Xwn_oz8fKQMQvQA)N zuwbOcMO0kVPcjf(aWx48i_Xs8)CSb!nc2G}^=hwVNT}HnG#J~v5V$05ot-f;7D&c4 zKfThzJ=9!$Q%>dDr`<_e^Dv1`Z(2`qG~GMe+uLJ9>uxvAFETYT+1uM;ffvD}s;#X( z*H=(bSpU26l;t|UDN9zZAoJ}DLUu((MOoP>7Q9le_Y^xDTWwu!xSSk18d^+l*z51# zbRicPdjyG+eSK&mSQ1Gy@$vTy8tka3G&Dx5rKJp?)`yk?Pcea`{YBs^^73#=aU-z3 z)pV@T5Mj)2iG3+K{QU2ES}jdYeiD(U0T*J17A?@_*=o~Ko=Gl-<=+Q0D0Yvx@DT|; zphp3V1SKSkk^>J%Sj6npdywyh;#VkqPEPbavZxr<1+IUf=+^&Xpc_8e(6H-je|&m+ znz(NB`nU4U@vPV74l?ldPK{x&Qw3piX&lbZPNkY{sbzJlcQ(2mUQc~?TbtcMuQn_9 zXRF$h_$#e$SDSus$Fl^Dk$j^g=Zb>uq)Z7VrI~bi*4wSMt-^_TrM4OU!R zZP%)Qoe5t(-W~^`w@QhNzvFktI|a`UB?LV`(px^
    + +
    +

    Disabling the Keyboard Shortcut

    +

    + Ctrl+B / Cmd+B toggles the sidebar by default. Set + EnableToggleShortcut="false" when those keys conflict with the page, + such as a rich-text editor where they mean bold. The parameter is reactive, so it can be bound to state. +

    + +
    + EnableToggleShortcut: +
    + + +
    + Toggle, then press Ctrl/Cmd + B +
    + +
    +
    + +
    + + + +
    + +
    + + Editor + + Shortcut @(shortcutEnabled ? "enabled" : "disabled") + + +
    +
    + + + + + + + Documents + + + + + + Drafts + + + + + + Archive + + + + +
    + + +
    + +

    Shortcut Opt-Out

    +
    +
    +
    +

    + With the shortcut enabled, Ctrl/Cmd + B toggles this sidebar. + With it disabled, this sidebar ignores the key entirely. +

    +

    + Each provider only claims the shortcut for itself. In an app with a single + provider, disabling it also lets Ctrl/Cmd + B through to the page, so a + rich-text editor receives it as bold. This page hosts many sidebar examples + that each still claim the key, so that fall-through is not visible here. +

    +
    +
    +
    +
    +
    +
    +
    + + +
    + @@ -1229,7 +1326,7 @@ - + Ctrl+B / Cmd+B @@ -1272,12 +1369,24 @@ Left Side to render (Left, Right) - + CookieKey string "sidebar:state" Cookie key for persistence + + EnableToggleShortcut + bool + true + Whether Ctrl/Cmd + B toggles the sidebar + + + HeightClass + string + "min-h-screen" + CSS class controlling container height +
    @@ -1379,6 +1488,7 @@ @code { private bool isBasicCollapsible = true; + private bool shortcutEnabled = true; private string selectedVersion = "v1.0.1"; private string GetRandomIcon(int index) diff --git a/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarProvider.razor b/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarProvider.razor index fa6b3d616..27583c814 100644 --- a/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarProvider.razor +++ b/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarProvider.razor @@ -47,6 +47,14 @@ [Parameter] public string? CookieKey { get; set; } = "sidebar:state"; + /// + /// Whether the Ctrl/Cmd + B keyboard shortcut toggles the sidebar. + /// Set to false when the shortcut conflicts with page content, such as a + /// rich-text editor where Ctrl/Cmd + B means bold. + /// + [Parameter] + public bool EnableToggleShortcut { get; set; } = true; + /// /// CSS class for controlling the container height. /// Defaults to "min-h-screen" to fill viewport and grow with content. diff --git a/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarProvider.razor.cs b/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarProvider.razor.cs index 9be4a3bbf..5eccfc255 100644 --- a/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarProvider.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarProvider.razor.cs @@ -9,6 +9,8 @@ public partial class BbSidebarProvider private SidebarContext Context { get; set; } = new(); private IJSObjectReference? _module; private DotNetObjectReference? _dotNetRef; + private bool lastToggleShortcutEnabled = true; + private int instanceId; [Inject] private IJSRuntime JSRuntime { get; set; } = default!; @@ -55,7 +57,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender) ); // Set up mobile detection and keyboard shortcuts - await _module.InvokeVoidAsync("initializeSidebar", _dotNetRef, CookieKey); + lastToggleShortcutEnabled = EnableToggleShortcut; + instanceId = await _module.InvokeAsync("initializeSidebar", _dotNetRef, EnableToggleShortcut); // Subscribe to state changes for persistence Context.StateChanged += OnStateChanged; @@ -75,6 +78,24 @@ protected override async Task OnAfterRenderAsync(bool firstRender) StateHasChanged(); } } + else if (_module != null && lastToggleShortcutEnabled != EnableToggleShortcut) + { + // Keep the shortcut in sync when the parameter changes after the first render + lastToggleShortcutEnabled = EnableToggleShortcut; + + try + { + await _module.InvokeVoidAsync("setToggleShortcutEnabled", instanceId, EnableToggleShortcut); + } + catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) + { + // Expected during circuit disconnect in Blazor Server + } + catch (InvalidOperationException) + { + // JS interop not available + } + } } private async void OnStateChanged(object? sender, EventArgs e) @@ -135,7 +156,11 @@ public async ValueTask DisposeAsync() { try { - await _module.InvokeVoidAsync("cleanup"); + if (instanceId != 0) + { + await _module.InvokeVoidAsync("cleanup", instanceId); + } + await _module.DisposeAsync(); } catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException) diff --git a/src/BlazorBlueprint.Components/wwwroot/js/sidebar.js b/src/BlazorBlueprint.Components/wwwroot/js/sidebar.js index 182e2896c..41a00a7ae 100644 --- a/src/BlazorBlueprint.Components/wwwroot/js/sidebar.js +++ b/src/BlazorBlueprint.Components/wwwroot/js/sidebar.js @@ -1,29 +1,52 @@ /** * Sidebar JavaScript module * Handles mobile detection, keyboard shortcuts, and state persistence + * + * ES modules are singletons, so every SidebarProvider on the page shares this + * module. State is therefore held per instance rather than at module level, and + * the keydown listener and ResizeObserver are shared and fan out to instances. */ const MOBILE_BREAKPOINT = 768; -let dotNetRef = null; -let cookieKey = null; +let nextInstanceId = 1; +const instances = new Map(); + let resizeObserver = null; let keyboardHandler = null; /** * Initialize sidebar with mobile detection and keyboard shortcuts * @param {DotNetObject} componentRef - Reference to the SidebarProvider component - * @param {string} key - Cookie key for state persistence + * @param {boolean} enableToggleShortcut - Whether Ctrl/Cmd + B toggles the sidebar + * @returns {number} Instance id, passed back to setToggleShortcutEnabled and cleanup */ -export function initializeSidebar(componentRef, key) { - dotNetRef = componentRef; - cookieKey = key; +export function initializeSidebar(componentRef, enableToggleShortcut) { + const instanceId = nextInstanceId++; + + instances.set(instanceId, { + dotNetRef: componentRef, + shortcutEnabled: enableToggleShortcut !== false + }); - // Set up mobile detection - setupMobileDetection(); + attachSharedListeners(); - // Set up keyboard shortcuts - setupKeyboardShortcuts(); + // Push the current mobile state to the new instance + notifyMobile(componentRef); + + return instanceId; +} + +/** + * Enable or disable the toggle shortcut after initialization + * @param {number} instanceId - Instance id returned by initializeSidebar + * @param {boolean} enabled - Whether Ctrl/Cmd + B toggles the sidebar + */ +export function setToggleShortcutEnabled(instanceId, enabled) { + const instance = instances.get(instanceId); + if (instance) { + instance.shortcutEnabled = enabled !== false; + } } /** @@ -48,42 +71,72 @@ export function saveSidebarState(key, value) { } /** - * Set up mobile detection using ResizeObserver + * Attach the shared keydown listener and ResizeObserver on first use */ -function setupMobileDetection() { - if (!dotNetRef) return; - - const checkMobile = () => { - const isMobile = window.innerWidth < MOBILE_BREAKPOINT; - dotNetRef.invokeMethodAsync('OnMobileChange', isMobile); - }; +function attachSharedListeners() { + if (!keyboardHandler) { + keyboardHandler = (e) => { + // Check for Ctrl+B or Cmd+B + if (!((e.ctrlKey || e.metaKey) && e.key === 'b')) { + return; + } + + let handled = false; + + for (const instance of instances.values()) { + if (!instance.shortcutEnabled) { + continue; + } + + handled = true; + invoke(instance.dotNetRef, 'OnToggleShortcut'); + } + + // Only swallow the key when something acted on it, so that a page + // with the shortcut disabled still gets its native Ctrl/Cmd + B + if (handled) { + e.preventDefault(); + } + }; + + document.addEventListener('keydown', keyboardHandler); + } - // Initial check - checkMobile(); + if (!resizeObserver) { + resizeObserver = new ResizeObserver(() => { + for (const instance of instances.values()) { + notifyMobile(instance.dotNetRef); + } + }); - // Listen for resize events - resizeObserver = new ResizeObserver(() => { - checkMobile(); - }); + resizeObserver.observe(document.body); + } +} - resizeObserver.observe(document.body); +/** + * Send the current mobile state to a single instance + * @param {DotNetObject} dotNetRef - Reference to the SidebarProvider component + */ +function notifyMobile(dotNetRef) { + invoke(dotNetRef, 'OnMobileChange', window.innerWidth < MOBILE_BREAKPOINT); } /** - * Set up keyboard shortcuts for toggling sidebar + * Invoke a .NET method, ignoring failures from a disposed component or circuit. + * Without this a single torn-down instance would break the shared fan-out loop. + * @param {DotNetObject} dotNetRef - Reference to the SidebarProvider component + * @param {string} method - JSInvokable method name + * @param {...any} args - Arguments to forward */ -function setupKeyboardShortcuts() { - if (!dotNetRef) return; - - keyboardHandler = (e) => { - // Check for Ctrl+B or Cmd+B - if ((e.ctrlKey || e.metaKey) && e.key === 'b') { - e.preventDefault(); - dotNetRef.invokeMethodAsync('OnToggleShortcut'); +function invoke(dotNetRef, method, ...args) { + try { + const result = dotNetRef.invokeMethodAsync(method, ...args); + if (result && typeof result.catch === 'function') { + result.catch(() => { }); } - }; - - document.addEventListener('keydown', keyboardHandler); + } catch { + // Instance is gone; cleanup will remove it + } } /** @@ -119,9 +172,16 @@ function setCookie(name, value, days) { } /** - * Cleanup event listeners and observers + * Remove an instance, tearing down the shared listeners once the last one goes + * @param {number} instanceId - Instance id returned by initializeSidebar */ -export function cleanup() { +export function cleanup(instanceId) { + instances.delete(instanceId); + + if (instances.size > 0) { + return; + } + if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; @@ -131,7 +191,4 @@ export function cleanup() { document.removeEventListener('keydown', keyboardHandler); keyboardHandler = null; } - - dotNetRef = null; - cookieKey = null; } diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 7d22cbd86..60f4b6791 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -3175,6 +3175,7 @@ - ChildContent : RenderFragment - CookieKey : String - DefaultOpen : Boolean + - EnableToggleShortcut : Boolean - HeightClass : String - Side : SidebarSide - Variant : SidebarVariant From 969605b4383b0cfdafb1aac167ac3f7dc4321b9f Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 15 Jul 2026 12:53:14 +0800 Subject: [PATCH 132/188] chore(demo): remove dead sidebar JS files Neither file is imported anywhere; the demo loads the library's module from _content/BlazorBlueprint.Components/js/sidebar.js. - wwwroot/js/sidebar.js was a byte-identical copy of the library module - wwwroot/js/sidebar-persistence.js was an abandoned localStorage/cookie implementation still using pre-rename "shadcn-blazor:" storage keys --- .../wwwroot/js/sidebar-persistence.js | 169 ------------------ .../wwwroot/js/sidebar.js | 137 -------------- 2 files changed, 306 deletions(-) delete mode 100644 demos/BlazorBlueprint.Demo.Shared/wwwroot/js/sidebar-persistence.js delete mode 100644 demos/BlazorBlueprint.Demo.Shared/wwwroot/js/sidebar.js diff --git a/demos/BlazorBlueprint.Demo.Shared/wwwroot/js/sidebar-persistence.js b/demos/BlazorBlueprint.Demo.Shared/wwwroot/js/sidebar-persistence.js deleted file mode 100644 index c1e986bcb..000000000 --- a/demos/BlazorBlueprint.Demo.Shared/wwwroot/js/sidebar-persistence.js +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Sidebar State Persistence Module - * Handles saving and restoring sidebar state using localStorage with cookie fallback - */ - -const STORAGE_KEY = 'shadcn-blazor:sidebar:state'; -const COOKIE_NAME = 'shadcn-blazor-sidebar-state'; -const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year in seconds - -/** - * Gets the sidebar state from storage - * @param {string} key - Optional custom storage key - * @returns {object|null} The sidebar state object or null if not found - */ -export function getSidebarState(key = STORAGE_KEY) { - try { - // Try localStorage first - if (typeof localStorage !== 'undefined') { - const stored = localStorage.getItem(key); - if (stored) { - return JSON.parse(stored); - } - } - - // Fallback to cookie - const cookieValue = getCookie(COOKIE_NAME); - if (cookieValue) { - return JSON.parse(decodeURIComponent(cookieValue)); - } - - return null; - } catch (error) { - console.error('SidebarPersistence: Error reading state', error); - return null; - } -} - -/** - * Sets the sidebar state in storage - * @param {object} state - The sidebar state object to save - * @param {string} key - Optional custom storage key - */ -export function setSidebarState(state, key = STORAGE_KEY) { - try { - const stateJson = JSON.stringify(state); - - // Try localStorage first - if (typeof localStorage !== 'undefined') { - localStorage.setItem(key, stateJson); - } - - // Also save to cookie as fallback - setCookie(COOKIE_NAME, encodeURIComponent(stateJson), COOKIE_MAX_AGE); - } catch (error) { - console.error('SidebarPersistence: Error saving state', error); - - // If localStorage fails, try cookie only - try { - const stateJson = JSON.stringify(state); - setCookie(COOKIE_NAME, encodeURIComponent(stateJson), COOKIE_MAX_AGE); - } catch (cookieError) { - console.error('SidebarPersistence: Error saving to cookie', cookieError); - } - } -} - -/** - * Clears the saved sidebar state - * @param {string} key - Optional custom storage key - */ -export function clearSidebarState(key = STORAGE_KEY) { - try { - // Clear localStorage - if (typeof localStorage !== 'undefined') { - localStorage.removeItem(key); - } - - // Clear cookie - deleteCookie(COOKIE_NAME); - } catch (error) { - console.error('SidebarPersistence: Error clearing state', error); - } -} - -/** - * Gets a cookie value by name - * @param {string} name - Cookie name - * @returns {string|null} Cookie value or null if not found - */ -function getCookie(name) { - if (typeof document === 'undefined') { - return null; - } - - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - - if (parts.length === 2) { - return parts.pop().split(';').shift(); - } - - return null; -} - -/** - * Sets a cookie - * @param {string} name - Cookie name - * @param {string} value - Cookie value - * @param {number} maxAge - Max age in seconds - */ -function setCookie(name, value, maxAge) { - if (typeof document === 'undefined') { - return; - } - - let cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax`; - - // Add Secure flag if on HTTPS - if (window.location.protocol === 'https:') { - cookie += '; Secure'; - } - - document.cookie = cookie; -} - -/** - * Deletes a cookie - * @param {string} name - Cookie name - */ -function deleteCookie(name) { - if (typeof document === 'undefined') { - return; - } - - document.cookie = `${name}=; path=/; max-age=0`; -} - -/** - * Checks if localStorage is available - * @returns {boolean} True if localStorage is available - */ -export function isLocalStorageAvailable() { - try { - if (typeof localStorage === 'undefined') { - return false; - } - - const testKey = '__shadcn_blazor_test__'; - localStorage.setItem(testKey, 'test'); - localStorage.removeItem(testKey); - return true; - } catch (error) { - return false; - } -} - -/** - * Gets the storage type being used - * @returns {string} 'localStorage', 'cookie', or 'none' - */ -export function getStorageType() { - if (isLocalStorageAvailable()) { - return 'localStorage'; - } else if (typeof document !== 'undefined') { - return 'cookie'; - } else { - return 'none'; - } -} diff --git a/demos/BlazorBlueprint.Demo.Shared/wwwroot/js/sidebar.js b/demos/BlazorBlueprint.Demo.Shared/wwwroot/js/sidebar.js deleted file mode 100644 index 182e2896c..000000000 --- a/demos/BlazorBlueprint.Demo.Shared/wwwroot/js/sidebar.js +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Sidebar JavaScript module - * Handles mobile detection, keyboard shortcuts, and state persistence - */ - -const MOBILE_BREAKPOINT = 768; - -let dotNetRef = null; -let cookieKey = null; -let resizeObserver = null; -let keyboardHandler = null; - -/** - * Initialize sidebar with mobile detection and keyboard shortcuts - * @param {DotNetObject} componentRef - Reference to the SidebarProvider component - * @param {string} key - Cookie key for state persistence - */ -export function initializeSidebar(componentRef, key) { - dotNetRef = componentRef; - cookieKey = key; - - // Set up mobile detection - setupMobileDetection(); - - // Set up keyboard shortcuts - setupKeyboardShortcuts(); -} - -/** - * Get sidebar state from cookie - * @param {string} key - Cookie key - * @returns {boolean|null} The saved state or null if not found - */ -export function getSidebarState(key) { - const value = getCookie(key); - if (value === 'true') return true; - if (value === 'false') return false; - return null; -} - -/** - * Save sidebar state to cookie - * @param {string} key - Cookie key - * @param {boolean} value - State to save - */ -export function saveSidebarState(key, value) { - setCookie(key, value.toString(), 7); // 7 days expiration -} - -/** - * Set up mobile detection using ResizeObserver - */ -function setupMobileDetection() { - if (!dotNetRef) return; - - const checkMobile = () => { - const isMobile = window.innerWidth < MOBILE_BREAKPOINT; - dotNetRef.invokeMethodAsync('OnMobileChange', isMobile); - }; - - // Initial check - checkMobile(); - - // Listen for resize events - resizeObserver = new ResizeObserver(() => { - checkMobile(); - }); - - resizeObserver.observe(document.body); -} - -/** - * Set up keyboard shortcuts for toggling sidebar - */ -function setupKeyboardShortcuts() { - if (!dotNetRef) return; - - keyboardHandler = (e) => { - // Check for Ctrl+B or Cmd+B - if ((e.ctrlKey || e.metaKey) && e.key === 'b') { - e.preventDefault(); - dotNetRef.invokeMethodAsync('OnToggleShortcut'); - } - }; - - document.addEventListener('keydown', keyboardHandler); -} - -/** - * Get cookie value - * @param {string} name - Cookie name - * @returns {string|null} Cookie value or null - */ -function getCookie(name) { - const nameEQ = name + "="; - const ca = document.cookie.split(';'); - for (let i = 0; i < ca.length; i++) { - let c = ca[i]; - while (c.charAt(0) === ' ') c = c.substring(1, c.length); - if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length); - } - return null; -} - -/** - * Set cookie value - * @param {string} name - Cookie name - * @param {string} value - Cookie value - * @param {number} days - Expiration in days - */ -function setCookie(name, value, days) { - let expires = ""; - if (days) { - const date = new Date(); - date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); - expires = "; expires=" + date.toUTCString(); - } - document.cookie = name + "=" + (value || "") + expires + "; path=/; SameSite=Lax"; -} - -/** - * Cleanup event listeners and observers - */ -export function cleanup() { - if (resizeObserver) { - resizeObserver.disconnect(); - resizeObserver = null; - } - - if (keyboardHandler) { - document.removeEventListener('keydown', keyboardHandler); - keyboardHandler = null; - } - - dotNetRef = null; - cookieKey = null; -} From ecbb3f1225386f5cf4f47ecb611d5a29766642c2 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 15 Jul 2026 14:16:19 +0800 Subject: [PATCH 133/188] chore(ci): auto-remove "working on it" label when an issue closes --- .../workflows/remove-working-on-it-label.yml | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/remove-working-on-it-label.yml diff --git a/.github/workflows/remove-working-on-it-label.yml b/.github/workflows/remove-working-on-it-label.yml new file mode 100644 index 000000000..bbac0e9c2 --- /dev/null +++ b/.github/workflows/remove-working-on-it-label.yml @@ -0,0 +1,21 @@ +name: Remove "working on it" label on close + +on: + issues: + types: [closed] + +permissions: + issues: write + +jobs: + remove-label: + if: contains(github.event.issue.labels.*.name, 'working on it') + runs-on: ubuntu-latest + steps: + - name: Remove the label + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh issue edit "${{ github.event.issue.number }}" \ + --repo "${{ github.repository }}" \ + --remove-label "working on it" From 2d25367e81afc878bb8433ecf574d0579f4e4c99 Mon Sep 17 00:00:00 2001 From: Hogo <64896329+HugoVG@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:25:11 +0200 Subject: [PATCH 134/188] refactor: allow ability to set the Id of the file input in FileUpload --- .../Components/FileUpload/BbFileUpload.razor | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/BlazorBlueprint.Components/Components/FileUpload/BbFileUpload.razor b/src/BlazorBlueprint.Components/Components/FileUpload/BbFileUpload.razor index 37c6b24b1..d4fa70daa 100644 --- a/src/BlazorBlueprint.Components/Components/FileUpload/BbFileUpload.razor +++ b/src/BlazorBlueprint.Components/Components/FileUpload/BbFileUpload.razor @@ -1,7 +1,6 @@ @namespace BlazorBlueprint.Components @using BlazorBlueprint.Icons.Lucide.Components @using Microsoft.AspNetCore.Components.Forms -@using Microsoft.JSInterop @inject IJSRuntime JSRuntime @implements IAsyncDisposable @@ -21,7 +20,8 @@ multiple="@Multiple" disabled="@Disabled" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer" - id="@_inputId" /> + @attributes="AdditionalAttributes" + id="@InputId" /> @if (DropzoneContent != null) { @@ -126,7 +126,6 @@ @code { private InputFile? _inputFile; private ElementReference _dropzoneRef; - private string _inputId = $"file-upload-{Guid.NewGuid():N}"; private bool _isDragging; private bool _jsInitialized; private List _files = new(); @@ -134,6 +133,12 @@ private IJSObjectReference? _jsModule; private IJSObjectReference? _jsCleanup; + /// + /// Gets or sets the ID of the file input element. If not provided, a unique ID will be generated. + /// + [Parameter] + public string InputId { get; set; } = $"file-upload-{Guid.NewGuid():N}"; + /// /// Gets or sets the selected files. /// @@ -200,6 +205,12 @@ [Parameter] public RenderFragment? DropzoneContent { get; set; } + /// + /// Gets or sets additional HTML attributes to apply to the file input element. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + /// /// Additional CSS classes. /// From 314b5089ca0b880f62ca7e4738c20189cf3b9e08 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 15 Jul 2026 14:49:23 +0800 Subject: [PATCH 135/188] fix(css): restore border colour utilities defeated by bb layer (#398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shadcn-style reset `* { border-color: var(--border) }` lived inside `@layer bb`, so it resolved to the `bb.utilities` sublayer. `bb` is declared last and is therefore the highest-priority layer, and cascade layer priority beats specificity unconditionally — so a universal `*` selector defeated every `.border-*` utility in the plain `utilities` layer. This was dormant until c82039ef (#318) dropped `layer(bb)` from the Tailwind import. Before that, Tailwind's generated utilities also landed in `bb.utilities`, tying with the reset in the same layer, so specificity decided and the class won. Afterwards the utilities moved to plain `utilities` while the hand-authored reset stayed behind in `bb`, and started winning: <=3.10.0 utilities: bb.utilities | reset: bb.utilities -> class wins (ok) 3.10.1+ utilities: utilities | reset: bb.utilities -> reset wins (bug) The blast radius was every border colour in the library, not just Alert: border-primary, border-alert-*/30 and consumer border utilities all flattened to --border. Alert variants were simply the most visible symptom. Move the two global rules (`*` and `body`) out of `bb` into `base`. They are resets, not overrides — defaults that utilities are meant to beat — which is where the demo's own app.css has always put them. The authored component rules that genuinely need to win (the #308/#318 sidebar selectors) stay in `bb`. Also fix the demo's @source paths, which were off by one level and silently resolved to a non-existent demos/src, so the demo's Tailwind never scanned the library sources. Tailwind does not error on a missing @source. The bare `Icons` path never existed either (icon packages are split per set), and its only literal utility is already emitted from Components, so it is dropped. Verified in the browser on the Alert demo: all five variants now resolve distinct border colours in both light and dark mode, the reset still applies to unstyled borders, and the sidebar mobile/desktop split is unaffected. --- .../wwwroot/css/app-input.css | 15 +++++++--- .../wwwroot/css/blazorblueprint-input.css | 30 +++++++++++-------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/demos/BlazorBlueprint.Demo.Shared/wwwroot/css/app-input.css b/demos/BlazorBlueprint.Demo.Shared/wwwroot/css/app-input.css index 3cbdd4e3f..41d61acc3 100644 --- a/demos/BlazorBlueprint.Demo.Shared/wwwroot/css/app-input.css +++ b/demos/BlazorBlueprint.Demo.Shared/wwwroot/css/app-input.css @@ -4,12 +4,19 @@ /* Import new OKLCH single-theme file */ @import '../styles/theme.css'; -/* Configure source paths for scanning Razor files */ +/* Configure source paths for scanning Razor files. + Paths are relative to this file (demos//wwwroot/css), so reaching the + repo-root `src` takes four levels, not three. The three-level form silently + resolved to a non-existent `demos/src` — Tailwind does not error on a missing + @source, it just scans nothing — so the library sources were never scanned here. + The icon packages are deliberately absent: they are split per-icon-set + (Icons.Lucide/.Feather/.Heroicons/.FontAwesome), so the old bare `Icons` path + never existed either, and their only literal utility (text-destructive) is + already emitted from Components. */ @source "../../Pages"; @source "../../Shared"; -@source "../../../src/BlazorBlueprint.Components"; -@source "../../../src/BlazorBlueprint.Primitives"; -@source "../../../src/BlazorBlueprint.Icons"; +@source "../../../../src/BlazorBlueprint.Components"; +@source "../../../../src/BlazorBlueprint.Primitives"; /* Component structural variables (not part of replaceable theme) */ @layer base { diff --git a/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css b/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css index 833fa9b5f..7ef64e183 100644 --- a/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css +++ b/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css @@ -214,6 +214,24 @@ --tracking-widest: calc(var(--tracking-normal) + 0.1em); } +/* Global theme defaults. These are resets rather than overrides, so they belong in + `base` where border/background utilities beat them normally on specificity. They + must stay OUT of `@layer bb`: as `bb.utilities` the universal `*` selector + outranked every border-* utility (layer priority ignores specificity), flattening + all border colours to --border — see #398. */ +@layer base { + * { + border-color: var(--border); + } + + body { + background-color: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + letter-spacing: var(--tracking-normal, 0); + } +} + /* All component utility/component layer blocks live inside `@layer bb` so they win the cascade-layer parity against consumer Tailwind output. The inner @layer declarations (base/components/utilities) become sublayers of bb. */ @@ -473,19 +491,7 @@ -/* Global theme overrides - utilities layer ensures these override Tailwind's reset */ @layer utilities { - * { - border-color: var(--border); - } - - body { - background-color: var(--background); - color: var(--foreground); - font-family: var(--font-sans); - letter-spacing: var(--tracking-normal, 0); - } - /* Grid animation utilities for Accordion/Collapsible */ .grid-rows-\[0fr\] { grid-template-rows: 0fr; From 14d46e75429404384b92a5d3bf451b7ef27003ae Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 15 Jul 2026 15:12:37 +0800 Subject: [PATCH 136/188] fix(theme): restore theme when Blazor re-merges the document (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThemeService applies the theme to from JS only — dark mode as a `dark` class, base/primary colour as data attributes, radius as an inline custom property. The server never renders any of it, because the preference lives in localStorage and is only known client-side. Blazor's enhanced page refresh merges the freshly server-rendered document into the live DOM and syncs 's attributes. Since the server-rendered carries none of the theme state, blazor.web.js strips it — confirmed by catching `removeAttribute("class")` on documentElement with a blazor.web.js stack. dotnet watch triggers exactly that refresh on every hot reload, so the theme reset mid-session. The document is not reloaded (a JS global set beforehand survives), so the circuit lives on, ThemeService.isInitialized stays true, and OnAfterRenderAsync(firstRender) never fires again — nothing puts the theme back. That also left C# and the DOM disagreeing: isDarkMode stayed true while the DOM went light, so the toggle's first click set state to false and removed an already-absent class, appearing to do nothing. Hence "ignores local storage" and "overrides it regardless" in the report — the preference was never actually lost, just never re-applied. Dark mode was the visible symptom; base colour and radius were wiped too. theme.js now tracks the state the document is meant to have and restores it via a MutationObserver on when something external clears it. Re-applying is gated on genuine drift, so our own writes cannot loop and user-initiated changes pass through untouched. JS-only — no public API change. Enhanced navigation between pages was checked and does not take this path; the theme already survived link clicks and a normal page reload. Verified against dotnet watch end to end: class, data-base-color and --radius all survive a hot reload that previously reset them, and the toggle flips on a single click again. --- .../wwwroot/js/theme.js | 156 +++++++++++++++--- 1 file changed, 135 insertions(+), 21 deletions(-) diff --git a/src/BlazorBlueprint.Components/wwwroot/js/theme.js b/src/BlazorBlueprint.Components/wwwroot/js/theme.js index 78cd8e630..846a18bf0 100644 --- a/src/BlazorBlueprint.Components/wwwroot/js/theme.js +++ b/src/BlazorBlueprint.Components/wwwroot/js/theme.js @@ -6,6 +6,122 @@ const STORAGE_KEY = 'bb-theme'; +/** + * The theme state the document is meant to have. + * + * Everything below is applied to , which the server never renders with these + * attributes — the theme is only known client-side (localStorage). Blazor's enhanced + * page refresh merges the freshly server-rendered document into the live DOM and + * syncs 's attributes, so it strips class/data-base-color/--radius wholesale. + * `dotnet watch` triggers exactly that on every hot reload, which reset the theme to + * light mid-session and left the C# ThemeService state disagreeing with the DOM (#400). + * + * Tracking the intended state lets us put it back when something external clears it. + * @type {{ isDark: boolean, baseColor: string|null, primaryColor: string|null, radius: number|null } | null} + */ +let desired = null; + +/** @type {MutationObserver | null} */ +let guard = null; + +/** Whether we are mid-write, so the guard ignores our own mutations. */ +let writing = false; + +/** + * Write the desired state to the document. + */ +function writeToDom() { + if (!desired) { + return; + } + + writing = true; + try { + const root = document.documentElement; + + if (desired.isDark) { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + + if (desired.baseColor !== null) { + root.setAttribute('data-base-color', desired.baseColor); + } + + if (desired.primaryColor !== null) { + if (desired.primaryColor === 'default') { + root.removeAttribute('data-primary-color'); + } else { + root.setAttribute('data-primary-color', desired.primaryColor); + } + } + + if (desired.radius !== null) { + root.style.setProperty('--radius', desired.radius + 'rem'); + } + } finally { + writing = false; + } +} + +/** + * Whether the document has drifted from the desired state. + * @returns {boolean} + */ +function hasDrifted() { + if (!desired) { + return false; + } + + const root = document.documentElement; + + if (root.classList.contains('dark') !== desired.isDark) { + return true; + } + + if (desired.baseColor !== null && root.getAttribute('data-base-color') !== desired.baseColor) { + return true; + } + + if (desired.primaryColor !== null) { + const current = root.getAttribute('data-primary-color'); + const expected = desired.primaryColor === 'default' ? null : desired.primaryColor; + if (current !== expected) { + return true; + } + } + + if (desired.radius !== null && root.style.getPropertyValue('--radius') !== desired.radius + 'rem') { + return true; + } + + return false; +} + +/** + * Watch and restore the theme if something external resets it. Idempotent — + * the observer is created once per document. Re-applying only happens on genuine + * drift, so our own writes cannot cause a feedback loop. + */ +function startGuard() { + if (guard || typeof MutationObserver === 'undefined') { + return; + } + + guard = new MutationObserver(() => { + if (writing || !hasDrifted()) { + return; + } + writeToDom(); + }); + + guard.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class', 'data-base-color', 'data-primary-color', 'style'] + }); +} + /** * Apply the full theme to the document. * @param {boolean} isDark @@ -14,10 +130,9 @@ const STORAGE_KEY = 'bb-theme'; * @param {number} radius */ export function applyTheme(isDark, baseColor, primaryColor, radius) { - applyDarkMode(isDark); - applyBaseColor(baseColor); - applyPrimaryColor(primaryColor); - applyRadius(radius); + desired = { isDark, baseColor, primaryColor, radius }; + writeToDom(); + startGuard(); } /** @@ -25,23 +140,21 @@ export function applyTheme(isDark, baseColor, primaryColor, radius) { * @param {boolean} isDark */ export function applyDarkMode(isDark) { - const root = document.documentElement; - if (isDark) { - root.classList.add('dark'); - } else { - root.classList.remove('dark'); - } + desired = desired ?? { isDark, baseColor: null, primaryColor: null, radius: null }; + desired.isDark = isDark; + writeToDom(); + startGuard(); } /** * Set the base color data attribute on the document element. - * Also removes any inline --primary/--primary-foreground/--ring overrides - * so the base color's built-in values take effect cleanly. * @param {string} color - Lowercase base color name (e.g., "zinc", "slate"). */ export function applyBaseColor(color) { - const root = document.documentElement; - root.setAttribute('data-base-color', color); + desired = desired ?? { isDark: document.documentElement.classList.contains('dark'), baseColor: color, primaryColor: null, radius: null }; + desired.baseColor = color; + writeToDom(); + startGuard(); } /** @@ -49,12 +162,10 @@ export function applyBaseColor(color) { * @param {string} color - Lowercase primary color name (e.g., "blue", "default"). */ export function applyPrimaryColor(color) { - const root = document.documentElement; - if (color === 'default') { - root.removeAttribute('data-primary-color'); - } else { - root.setAttribute('data-primary-color', color); - } + desired = desired ?? { isDark: document.documentElement.classList.contains('dark'), baseColor: null, primaryColor: color, radius: null }; + desired.primaryColor = color; + writeToDom(); + startGuard(); } /** @@ -62,7 +173,10 @@ export function applyPrimaryColor(color) { * @param {number} radius - Border radius in rem. */ export function applyRadius(radius) { - document.documentElement.style.setProperty('--radius', radius + 'rem'); + desired = desired ?? { isDark: document.documentElement.classList.contains('dark'), baseColor: null, primaryColor: null, radius }; + desired.radius = radius; + writeToDom(); + startGuard(); } /** From fecbdf52dee278f0720181b433e56b775bd1b571 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 15 Jul 2026 15:59:19 +0800 Subject: [PATCH 137/188] docs(release): document Font Awesome and correct the release guide (#401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Icons.FontAwesome was absent from RELEASE.md entirely — not in the package list, the tag conventions, or the NuGet links — which is the likeliest reason it was never released. The package itself needs no changes: the csproj is configured identically to Lucide's, it packs cleanly, and the devkit release script already has fontawesome wired into its package table. Only the tag and the release run are missing, and those are a maintainer action. The guide had drifted well past that omission and documented a pipeline that does not exist: - Claimed tag pushes trigger .github/workflows/nuget-publish.yml, and that ci.yml runs on all PRs. Neither workflow exists; there is no CI publishing. Releases are run locally. - Told you to run ./scripts/release-primitives.sh and friends. No per-package scripts exist; the real entry points are devkit/scripts/release.sh and devkit/scripts/release-icons.sh. - Described adding NUGET_API_KEY to GitHub Secrets. The scripts read it from the environment. - Said five packages; there are six. Rewritten against the actual scripts: local interactive releases from develop, NUGET_API_KEY from the environment, --dry-run to preview, the develop -> main PR the scripts open, and the real flags for both. Adds a note that an untagged package can only ever produce 0.0.0-beta.0.x, which is the trap Font Awesome fell into, plus troubleshooting for it. devkit is gitignored and maintainer-only, so the guide now says so rather than implying contributors can release. --- RELEASE.md | 293 +++++++++++++++++++---------------------------------- 1 file changed, 103 insertions(+), 190 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 268782dd8..f9c420a00 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,59 +1,99 @@ # Release Guide -This document describes the automated NuGet publishing system for Blazor Blueprint. +This document describes how Blazor Blueprint packages are published to NuGet. ## Overview -Blazor Blueprint uses a **monorepo with independent package versioning**. Each of the five packages can be released independently with its own version number: +Blazor Blueprint uses a **monorepo with independent package versioning**. Each of the six packages can be released independently with its own version number: - **BlazorBlueprint.Primitives** - Headless UI primitives - **BlazorBlueprint.Components** - Styled components - **BlazorBlueprint.Icons.Lucide** - Lucide icon library - **BlazorBlueprint.Icons.Heroicons** - Heroicons library - **BlazorBlueprint.Icons.Feather** - Feather icon library +- **BlazorBlueprint.Icons.FontAwesome** - Font Awesome Free icon library + +## Prerequisites + +Releases are **run locally by a maintainer** — there is no CI publishing pipeline. Pushing a tag does not publish anything on its own; the release scripts do the packing, the NuGet push, and the tagging together. + +You need: + +1. **The `devkit` checkout** — the release scripts live in the private `devkit` repo, checked out at `devkit/` in the repo root (it is gitignored). Maintainers only; external contributors cannot run releases. +2. **A NuGet API key** exported as `NUGET_API_KEY`: + + ```bash + # ~/.bashrc.local + export NUGET_API_KEY="your-key" + ``` + + Create the key at https://www.nuget.org/account/apikeys with "Push" permission, scoped to `BlazorBlueprint.*`. +3. **To be on the `develop` branch**, up to date with origin. The scripts enforce this and open the `develop` → `main` PR for you at the end. ## Quick Start -To release a package, simply run the appropriate script: +Both scripts are interactive — they show you what changed, prompt for versions, and summarise before doing anything. ```bash -./scripts/release-primitives.sh 1.0.0-beta.4 -./scripts/release-components.sh 1.1.0-beta.2 -./scripts/release-icons-lucide.sh 1.0.3 -./scripts/release-icons-heroicons.sh 1.0.0-beta.1 -./scripts/release-icons-feather.sh 1.0.0-beta.1 +# Primitives and/or Components +./devkit/scripts/release.sh + +# Icon packages (Lucide, Heroicons, Feather, Font Awesome) +./devkit/scripts/release-icons.sh ``` -That's it! The rest is automated. +Use `--dry-run` on either to walk through every step without executing any git, build, NuGet, or PR operation. It is the safest way to check what a release would do: + +```bash +./devkit/scripts/release-icons.sh --dry-run +``` ## How It Works -### 1. Release Scripts (`scripts/`) +### `release.sh` — Primitives and Components + +Releases either package, or both in a single run. It builds, packs, and publishes locally, and polls NuGet for availability when Components depends on a freshly released Primitives. Creates the `develop` → `main` PR when done. + +Flags: + +| Flag | Effect | +|------|--------| +| `--dry-run` | Walk through all steps without executing any | +| `--skip-notes` | Use existing `RELEASE_NOTES.md` as-is instead of regenerating | +| `--skip-tests` | Skip API surface tests (for re-releases where code hasn't changed) | +| `--clear-cache` | Clear the NuGet HTTP cache before building, when a freshly published package isn't resolving | -Each package has a dedicated release script that: +### `release-icons.sh` — Icon packages -1. **Validates** the version format (semantic versioning) -2. **Checks** for uncommitted changes (prevents dirty releases) -3. **Confirms** with you before proceeding -4. **Creates** a git tag (e.g., `primitives/v1.0.0-beta.4`) -5. **Pushes** the tag to GitHub +Detects which icon packages have changed since their last tagged release, prompts for version bumps, then builds, packs, pushes to NuGet, tags, and creates the `develop` → `main` PR. -### 2. Git Tag Naming Convention +Flags: -Tags follow the pattern: `/v` +| Flag | Effect | +|------|--------| +| `--dry-run` | Walk through all steps without executing any | +| `--clear-cache` | Clear the NuGet HTTP cache before building | -Examples: -- `primitives/v1.0.0-beta.4` -- `components/v1.1.0-beta.2` -- `icons-lucide/v1.0.3` -- `icons-heroicons/v1.0.0-beta.1` -- `icons-feather/v1.0.0-beta.1` +### Git Tag Naming Convention -### 3. MinVer Versioning +Tags follow the pattern `/v` and are created by the release scripts — you do not normally tag by hand. -Each project uses [MinVer](https://github.com/adamralph/minver) to automatically calculate the package version from git tags. +| Package | Tag prefix | +|---------|-----------| +| Primitives | `primitives/v` | +| Components | `components/v` | +| Icons.Lucide | `icons-lucide/v` | +| Icons.Heroicons | `icons-heroicons/v` | +| Icons.Feather | `icons-feather/v` | +| Icons.FontAwesome | `icons-fontawesome/v` | -**Configuration** (in each `.csproj` file): +Examples: `primitives/v3.13.0`, `components/v3.13.0`, `icons-lucide/v2.0.1`, `icons-fontawesome/v2.0.0` + +### MinVer Versioning + +Each project uses [MinVer](https://github.com/adamralph/minver) to calculate the package version from git tags. + +**Configuration** (in each `.csproj`): ```xml primitives/v beta.0 @@ -65,20 +105,7 @@ Each project uses [MinVer](https://github.com/adamralph/minver) to automatically - Tag `components/v2.0.0` → Version `2.0.0` - No matching tag → Version `0.0.0-beta.0.` -### 4. GitHub Actions Automation - -When a tag is pushed, GitHub Actions automatically: - -1. **Detects** which package to build (from tag prefix) -2. **Restores** dependencies -3. **Builds** the project (Release configuration) -4. **Packs** the NuGet package (MinVer sets version automatically) -5. **Verifies** the package version matches the tag -6. **Publishes** to NuGet.org - -**Workflows:** -- `.github/workflows/nuget-publish.yml` - Triggered by tag pushes -- `.github/workflows/ci.yml` - Runs on all PRs and pushes to main +That last case matters: a package with no tag of its own can only ever produce a `0.0.0-beta.0.x` version, whatever the rest of the repo is versioned at. If a package has never been released, check for its tag first. ## Versioning Strategy @@ -87,11 +114,12 @@ When a tag is pushed, GitHub Actions automatically: Each package can have a different version number: ``` -BlazorBlueprint.Primitives 1.2.0 -BlazorBlueprint.Components 1.1.5 -BlazorBlueprint.Icons.Lucide 1.0.3 -BlazorBlueprint.Icons.Heroicons 1.0.0-beta.1 -BlazorBlueprint.Icons.Feather 1.0.0-beta.1 +BlazorBlueprint.Primitives 3.13.0 +BlazorBlueprint.Components 3.13.0 +BlazorBlueprint.Icons.Lucide 2.0.1 +BlazorBlueprint.Icons.Heroicons 2.0.0 +BlazorBlueprint.Icons.Feather 2.0.0 +BlazorBlueprint.Icons.FontAwesome 2.0.0 ``` This allows you to: @@ -108,58 +136,23 @@ Follow [Semantic Versioning](https://semver.org/): - **Patch** (1.0.0 → 1.0.1): Bug fixes (backward compatible) - **Pre-release** (1.0.0-beta.1): Beta versions -### Beta Releases - -For beta versions, use the format: `X.Y.Z-beta.N` - -Examples: -- `1.0.0-beta.1` - First beta -- `1.0.0-beta.2` - Second beta -- `1.0.0` - Stable release - ## Release Checklist Before releasing a package: -1. ✅ **All changes committed** - No uncommitted files -2. ✅ **Tests passing** - Run `dotnet build` and verify -3. ✅ **README updated** - Document new features/changes -4. ✅ **Version decided** - Choose appropriate semantic version -5. ✅ **NUGET_API_KEY configured** - Required for first release - -## Setting Up NuGet API Key - -### First-Time Setup (Repository Owner) - -1. **Get NuGet API key:** - - Go to https://www.nuget.org/account/apikeys - - Create a new API key with "Push" permissions - - Scope it to the BlazorBlueprint.* packages - -2. **Add to GitHub Secrets:** - - Go to repository Settings → Secrets and variables → Actions - - Click "New repository secret" - - Name: `NUGET_API_KEY` - - Value: (paste your NuGet API key) - -3. **Test the workflow:** - ```bash - ./scripts/release-primitives.sh 1.0.0-beta.1 - ``` +1. ✅ **On `develop`, up to date** - the scripts refuse to run otherwise +2. ✅ **All changes committed** - no uncommitted files +3. ✅ **Tests passing** - `./scripts/run-tests.sh` +4. ✅ **README updated** - document new features/changes +5. ✅ **`NUGET_API_KEY` exported** - required for every release, not just the first ## Monitoring Releases -### GitHub Actions Dashboard - -Monitor releases at: https://github.com/blazorblueprintui/ui/actions - -Each release creates a workflow run showing: -- Build logs -- Pack output -- Publish status -- Direct link to NuGet package +Check what is currently live: -### NuGet.org +```bash +./devkit/scripts/nuget-versions.sh +``` Packages appear at: - https://www.nuget.org/packages/BlazorBlueprint.Primitives @@ -167,6 +160,7 @@ Packages appear at: - https://www.nuget.org/packages/BlazorBlueprint.Icons.Lucide - https://www.nuget.org/packages/BlazorBlueprint.Icons.Heroicons - https://www.nuget.org/packages/BlazorBlueprint.Icons.Feather +- https://www.nuget.org/packages/BlazorBlueprint.Icons.FontAwesome **Note:** It may take 5-10 minutes for packages to appear on NuGet.org after publishing. @@ -180,122 +174,41 @@ git add . git commit -m "Your commit message" ``` -### Version format error +### Script says "Must be on the develop branch" -Ensure version follows semantic versioning: -- ✅ `1.0.0` -- ✅ `1.0.0-beta.1` -- ✅ `2.1.3-alpha.2` -- ❌ `v1.0.0` (no 'v' prefix) -- ❌ `1.0` (must have three parts) - -### Tag already exists - -Delete the tag if you need to recreate it: +Releases run from `develop`; the script opens the `develop` → `main` PR itself. ```bash -# Example for primitives -git tag -d primitives/v1.0.0-beta.4 -git push origin :refs/tags/primitives/v1.0.0-beta.4 - -# Example for icons -git tag -d icons-lucide/v1.0.3 -git push origin :refs/tags/icons-lucide/v1.0.3 +git checkout develop +git pull origin develop ``` -### Package version mismatch - -This usually means the git tag doesn't match the MinVer configuration. - -Check: -1. Tag format: `icons-lucide/v1.0.3` (note the prefix) -2. MinVerTagPrefix in `.csproj`: `icons-lucide/v` +### `NUGET_API_KEY environment variable is not set` -### GitHub Actions failing +Export it as described in [Prerequisites](#prerequisites). It is read from the environment on every run, so a new shell needs it too. -Check: -1. NUGET_API_KEY secret is configured -2. Build succeeds locally: `dotnet build -c Release` -3. Workflow logs for specific errors +### Package version is `0.0.0-beta.0.x` -## Manual Release (Fallback) - -If automation fails, you can release manually: +There is no git tag for that package's prefix, so MinVer has nothing to derive from. Check with: ```bash -# Build and pack -dotnet pack src/BlazorBlueprint.Primitives/BlazorBlueprint.Primitives.csproj -c Release -o ./packages - -# Publish to NuGet -dotnet nuget push ./packages/BlazorBlueprint.Primitives.*.nupkg --api-key YOUR_API_KEY --source https://api.nuget.org/v3/index.json +git tag --list 'icons-fontawesome/v*' ``` -**Note:** Manual releases won't have the git tag versioning benefits. - -## Best Practices - -1. **Release often** - Small, frequent releases are better than large infrequent ones -2. **Test before releasing** - Always run `dotnet build` locally first -3. **Document changes** - Update README or CHANGELOG for significant changes -4. **Use pre-release versions** - Use `-beta.X` suffix until stable -5. **Coordinate dependencies** - If Components depends on new Primitives features, release Primitives first - -## Development Workflow - -### Working on a feature - -```bash -git checkout -b feature/my-new-feature -# Make changes -dotnet build -git commit -m "Add new feature" -git push origin feature/my-new-feature -``` +### Tag already exists -### Releasing the feature +Delete the tag if you need to recreate it: ```bash -git checkout main -git merge feature/my-new-feature -git push origin main +# Example for primitives +git tag -d primitives/v1.0.0-beta.4 +git push origin :refs/tags/primitives/v1.0.0-beta.4 -# Release with new version -./scripts/release-primitives.sh 1.1.0 +# Example for icons +git tag -d icons-lucide/v1.0.3 +git push origin :refs/tags/icons-lucide/v1.0.3 ``` -## CI/CD Pipeline - -### Continuous Integration (`.github/workflows/ci.yml`) - -Runs on every PR and push to main: -- Builds all projects -- Runs tests (if any) -- Creates NuGet packages (as artifacts) -- Verifies package creation - -**Purpose:** Ensure code quality and catch issues early - -### Continuous Deployment (`.github/workflows/nuget-publish.yml`) - -Triggered by tag pushes: -- Builds specific package -- Publishes to NuGet.org -- Only runs for tagged releases - -**Purpose:** Automate releases and reduce human error - -## Additional Resources - -- [MinVer Documentation](https://github.com/adamralph/minver) -- [Semantic Versioning](https://semver.org/) -- [NuGet Package Publishing](https://learn.microsoft.com/en-us/nuget/nuget-org/publish-a-package) -- [GitHub Actions Documentation](https://docs.github.com/en/actions) - ---- - -**Questions or Issues?** +### Package version mismatch -If you encounter problems with the release system, check: -1. This guide's Troubleshooting section -2. GitHub Actions workflow logs -3. Open an issue on the repository +This usually means the git tag doesn't match the MinVer configuration in the `.csproj`. From 7f6940e6c89f78a10b4e5719a8a52fc937a0965f Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 15 Jul 2026 19:15:58 +0800 Subject: [PATCH 138/188] feat(datagrid): runtime grouping, RefreshDataAsync, and live collection updates (#402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the two enhancements requested in #402, and fixes the grouping state bugs found while scoping them. Fixed — grouping state was not the source of truth. ProcessGroupedData grouped by a private compiled delegate (_groupByAccessor) set only from the GroupBy parameter or BbDataGridGroupColumn, and never resolved GroupDefinition.ColumnId back to a column. Anything that set the group definition without going through markup silently diverged: the snapshot, HasGrouping and DataGridRequest.GroupDefinition all reported a grouping while the grid rendered flat rows. That made State.Grouping.SetGroup(...) inert and, more visibly, meant Save()/Restore() never round-tripped grouping. DataGridGroupState.ActiveGroup is now the source of truth, resolved against registered columns via IDataGridColumn.GetRawValue, with the markup-configured accessor kept as an override for expressions that have no matching column. Fixed — grouping changes made directly against the state object went undetected. DataGridGroupState now carries a Version counter that the grid observes, alongside the existing DataGridState.Version. Fixed — a group definition targeting a column that had not registered yet resolved to nothing and never recovered, because OnColumnRegistered only reprocessed for aggregates. It now also reprocesses when grouping is active but not yet applied. Added — Groupable on property and template columns. When set, an ellipsis menu appears in the column header offering "Group by X" / "Remove grouping", and the grid regroups at runtime. Opt-in, matching Sortable and Filterable. Template columns require SortBy, since the group key is read from it. Also exposed imperatively as GroupByColumnAsync(columnId, direction) and ClearGroupingAsync(). Single-level only — nested grouping is planned separately. Added — public RefreshDataAsync(). The grid detects a new data set by reference, so mutating a collection in place rendered stale rows with a stale TotalItems while still repainting, which read as correctly wired. This is the explicit escape hatch, mirroring QuickGrid. Added — INotifyCollectionChanged support. An ObservableCollection passed to Items is subscribed to and refreshes automatically, unsubscribing on dispose and on source swap. This is what #402 asked for via @bind-Items; a two-way bind cannot work here, because ItemsChanged would only fire if the grid itself mutated the collection, which it never does. Note — grouping + Virtualize + ItemsProvider still renders an empty grid, but the header menu now suppresses the action in that mode and the bail logs a warning naming the column and the way out, rather than failing silently. Behaviour change: DataGridState.Reset() now genuinely clears grouping applied via the GroupBy parameter. It previously cleared ActiveGroup while the grid kept grouping by the stale accessor. --- .../DataGrid/interactive-grouping.txt | 40 +++ .../DataGrid/live-collection-updates.txt | 44 +++ .../Pages/Components/DataGridDemo.razor | 119 +++++++- .../Components/DataGrid/BbDataGrid.razor | 35 +++ .../Components/DataGrid/BbDataGrid.razor.cs | 281 ++++++++++++++++-- .../BbDataGridPropertyColumn.razor.cs | 9 + .../BbDataGridTemplateColumn.razor.cs | 9 + .../Components/DataGrid/DataGridLog.cs | 17 ++ .../Localization/DefaultBbLocalizer.cs | 3 + .../Primitives/DataGrid/DataGridGroupState.cs | 9 + .../Primitives/DataGrid/IDataGridColumn.cs | 7 + ...entsApiSurfaceMatchesBaseline.verified.txt | 2 + ...ivesApiSurfaceMatchesBaseline.verified.txt | 1 + 13 files changed, 546 insertions(+), 30 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/interactive-grouping.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/live-collection-updates.txt create mode 100644 src/BlazorBlueprint.Components/Components/DataGrid/DataGridLog.cs diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/interactive-grouping.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/interactive-grouping.txt new file mode 100644 index 000000000..800111476 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/interactive-grouping.txt @@ -0,0 +1,40 @@ +@* Mark columns Groupable to let users group by them from the column header menu. *@ + + +
    + + Group by Department + + + Clear grouping + +
    +
    + + @* No Groupable flag — this column shows no menu. *@ + + + + + + + + +
    + +@code { + private BbDataGrid? grid; + private List people = new(); + + // Grouping can also be driven from code, without the header menu: + // await grid.GroupByColumnAsync("department"); + // await grid.GroupByColumnAsync("status", SortDirection.Descending); + // await grid.ClearGroupingAsync(); + // + // One column is grouped at a time — grouping by another replaces the current grouping. + // The active group is held on DataGridState.Grouping, so it is captured by Save() + // and reapplied by Restore(). +} diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/live-collection-updates.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/live-collection-updates.txt new file mode 100644 index 000000000..5d361e1cc --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/live-collection-updates.txt @@ -0,0 +1,44 @@ +@* The grid detects a new data set by reference. Mutating a collection in place is not a + reference change, so it does not re-render on its own. There are two ways to handle it. *@ + +@* Option 1 — ObservableCollection. The grid subscribes and refreshes automatically. *@ + + + + Add row + + + + + + + + +@code { + private readonly ObservableCollection livePeople = new(); + + private void AddPerson() => livePeople.Add(new Person { Name = "New Person" }); +} + +@* Option 2 — a plain List, refreshed explicitly after mutating it. *@ + + + + + + +@code { + private BbDataGrid? grid; + private List people = new(); + + private async Task AddPersonAsync() + { + people.Add(new Person { Name = "New Person" }); + + // Without this the grid keeps rendering the rows it processed earlier. + await grid!.RefreshDataAsync(); + } + + // RefreshDataAsync also forces an ItemsProvider grid to re-fetch, which is useful + // after saving a change upstream. +} diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor index 4408b8e02..07e38f74b 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor @@ -667,9 +667,10 @@

    State Persistence

    Use Save() to capture a serializable - snapshot of sort order, column widths, column visibility, and page size. + snapshot of sort order, column widths, column visibility, page size, and the active grouping. Use Restore(snapshot) to reapply it. Snapshots are plain JSON-serializable objects — store them in localStorage, a database, or user preferences. + Try grouping by Department from its header menu, saving, ungrouping, then restoring.

    - + @@ -817,6 +818,76 @@ + +
    +
    +

    Interactive Grouping

    +

    + Mark a column Groupable to let users group by it + at runtime. An ellipsis menu appears in the column header with a "Group by" action, and the grid + regroups without a page refresh. Only one column is grouped at a time — choosing another replaces + the current grouping. Columns left without the flag (like Name) offer no menu. +

    +
    + + +
    + + Group by Department + + + Clear grouping + +
    +
    + + + + + + + +
    + +
    + + +
    +
    +

    Live Collection Updates

    +

    + The grid detects a new data set by reference, so mutating a collection in place does not re-render + on its own. Bind an ObservableCollection and the grid + subscribes to it, re-rendering as rows are added or removed. For a plain + List, call + RefreshDataAsync() on the grid after mutating it instead. +

    +
    + + +
    + + Add row + + + Remove last + + @livePeople.Count rows +
    +
    + + + + + +
    + +
    + Uses role="grid" with aria-rowcount for screen readers @@ -856,7 +927,7 @@
    - The data source for the grid. Supports IQueryable for LINQ-composed sorting and pagination, or IEnumerable for in-memory processing. Mutually exclusive with ItemsProvider. + The data source for the grid. Supports IQueryable for LINQ-composed sorting and pagination, or IEnumerable for in-memory processing. Mutually exclusive with ItemsProvider. A new data set is detected by reference, so assigning a different collection re-renders automatically; mutating the same collection in place needs either a RefreshDataAsync() call or an INotifyCollectionChanged source such as ObservableCollection, which the grid subscribes to. Async delegate for server-side data fetching. Receives sort definitions, pagination parameters, and a cancellation token. Mutually exclusive with Items. @@ -981,6 +1052,15 @@ Public method. Collapses all groups, hiding their data rows. Call via a component reference (@@ref). + + Public method. Groups rows by the given column id, replacing any active grouping. The direction orders the group keys and defaults to ascending. Call via a component reference (@@ref). + + + Public method. Clears any active grouping, returning the grid to flat rows. Call via a component reference (@@ref). + + + Public method. Re-reads the data source and re-renders. Call after mutating the Items collection in place, which the grid cannot detect by reference, or to force an ItemsProvider re-fetch. Collections implementing INotifyCollectionChanged (such as ObservableCollection) are subscribed to and refreshed automatically, so they do not need this. Call via a component reference (@@ref). + @@ -1032,6 +1112,9 @@ Whether this column supports per-column filtering. When true, a filter icon appears in the column header that opens a filter popover. The filter field type is auto-inferred from the property type. + + Whether users can group rows by this column at runtime. When true, an ellipsis menu appears in the column header offering a "Group by" action. One column is grouped at a time, so grouping by another column replaces the current grouping. + Override the auto-inferred filter field type. Use when the default inference is not correct (e.g., use FilterFieldType.Enum for string properties with known values). @@ -1098,6 +1181,9 @@ Whether this column supports per-column filtering. Requires FilterBy to be set. + + Whether users can group rows by this column at runtime, via an ellipsis menu in the column header. Requires SortBy to be set, since the group key is read from it. + Expression used to build filter Where clauses. Required when Filterable is true. @@ -1206,6 +1292,9 @@ @code { private BbDataGrid? groupingGrid; + private BbDataGrid? interactiveGroupingGrid; + private readonly System.Collections.ObjectModel.ObservableCollection livePeople = new(); + private int nextLivePersonId = 1; private List people = new(); private List manyPeople = new(); private List stressTestPeople = new(); @@ -1299,6 +1388,30 @@ people = MockDataService.GeneratePersons(50); manyPeople = MockDataService.GeneratePersons(1000); asyncPeople = MockDataService.GeneratePersons(100); + + foreach (var person in MockDataService.GeneratePersons(12)) + { + livePeople.Add(person); + } + + nextLivePersonId = livePeople.Count + 1; + } + + // The grid subscribes to ObservableCollection, so adding and removing here re-renders + // it without reassigning Items or calling RefreshDataAsync. + private void AddLivePerson() + { + var person = MockDataService.GeneratePersons(1)[0]; + person.Name = $"New Person {nextLivePersonId++}"; + livePeople.Add(person); + } + + private void RemoveLivePerson() + { + if (livePeople.Count > 0) + { + livePeople.RemoveAt(livePeople.Count - 1); + } } private async ValueTask> VirtualServerProviderAsync( diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor index 5b8b00505..114d48ec6 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor @@ -230,6 +230,41 @@
    } + @if (CanGroupColumn(column)) + { + var menuColumn = column; + var isGroupedByColumn = IsGroupedBy(menuColumn.ColumnId); +
    + + + + + + + + @if (isGroupedByColumn) + { + + + @Localizer["DataGrid.UngroupColumn"] + + } + else + { + + + @Localizer["DataGrid.GroupByColumn", menuColumn.Title ?? string.Empty] + + } + + +
    + } @if (Resizable && column.Resizable) { diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs index e160983e0..97bcff283 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs @@ -1,3 +1,4 @@ +using System.Collections.Specialized; using System.Linq.Expressions; using BlazorBlueprint.Primitives; using BlazorBlueprint.Primitives.DataGrid; @@ -29,6 +30,7 @@ public partial class BbDataGrid : ComponentBase, IAsyncDisposable where T private readonly Dictionary _filterPopoverOpen = new(); private bool _needsDataRefresh = true; private bool columnStateInitialized; + private readonly Dictionary _headerMenuOpen = new(); // Grouping/hierarchy state private List>? _groupedRenderItems; @@ -41,6 +43,11 @@ public partial class BbDataGrid : ComponentBase, IAsyncDisposable where T private RenderFragment>? _groupColumnHeaderTemplate; private Expression>? _lastGroupBy; private List? _allGroupKeys; + private int _lastGroupingVersion; + private bool _virtualGroupingWarned; + + // Live collection tracking for Items sources implementing INotifyCollectionChanged + private INotifyCollectionChanged? _observedItems; // Hierarchy state private HierarchyManager? _hierarchyManager; @@ -112,6 +119,14 @@ public partial class BbDataGrid : ComponentBase, IAsyncDisposable where T /// The data source for the grid. Can be IQueryable<TData> or IEnumerable<TData>. /// Mutually exclusive with . /// + /// + /// The grid detects a new data set by reference, so assigning a different collection + /// instance re-renders automatically. Mutating the same collection in place (for example + /// List<T>.Add) is not detectable by reference, so it does not re-render on its own — + /// either call afterwards, or pass a collection implementing + /// (such as ObservableCollection<T>), which + /// the grid subscribes to and refreshes from automatically. + /// [Parameter] public IEnumerable? Items { get; set; } @@ -665,8 +680,7 @@ protected override async Task OnParametersSetAsync() _groupByAccessor = null; _groupByColumnId = null; _groupByColumnTitle = null; - _gridState.Grouping.ClearGroup(); - _groupedRenderItems = null; + ApplyGroupDefinition(null); } // Initialize hierarchy when hierarchy params are set @@ -696,15 +710,75 @@ protected override async Task OnParametersSetAsync() _lastHierarchyFilterMode = HierarchyFilterMode; } + // Detect grouping changes applied directly to the state object, e.g. by Restore() + // or by a consumer calling State.Grouping.SetGroup(...). + var groupingChanged = _gridState.Grouping.Version != _lastGroupingVersion; + if (groupingChanged) + { + _lastGroupingVersion = _gridState.Grouping.Version; + } + + UpdateItemsSubscription(); + // Only reprocess data when something meaningful changed var itemsChanged = !ReferenceEquals(_lastItems, Items); - if (itemsChanged || itemFilterChanged || filterModeChanged || _needsDataRefresh || externalStateChanged) + if (itemsChanged || itemFilterChanged || filterModeChanged || groupingChanged + || _needsDataRefresh || externalStateChanged) { _needsDataRefresh = false; await ProcessDataAsync(); } } + /// + /// Subscribes to the current collection when it supports change + /// notification, so in-place mutations refresh the grid without a reference swap. + /// Unsubscribes from any previously observed collection. + /// + private void UpdateItemsSubscription() + { + var incoming = Items as INotifyCollectionChanged; + if (ReferenceEquals(_observedItems, incoming)) + { + return; + } + + if (_observedItems != null) + { + _observedItems.CollectionChanged -= HandleItemsCollectionChanged; + } + + _observedItems = incoming; + + if (_observedItems != null) + { + _observedItems.CollectionChanged += HandleItemsCollectionChanged; + } + } + + private void HandleItemsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => + _ = RefreshFromCollectionChangedAsync(); + + /// + /// Refreshes in response to a collection-changed notification. The notification may be + /// raised off the renderer's synchronization context, so the refresh is marshalled onto it. + /// + private async Task RefreshFromCollectionChangedAsync() + { + try + { + await InvokeAsync(RefreshDataAsync); + } + catch (ObjectDisposedException) + { + // The grid was disposed while a collection change was in flight. + } + catch (Exception ex) + { + await DispatchExceptionAsync(ex); + } + } + protected override async Task OnAfterRenderAsync(bool firstRender) { if (_needsDataRefresh) @@ -826,9 +900,12 @@ private void OnColumnRegistered() { _columnsVersion++; - // When grouping is active, aggregates computed before all columns registered - // will be empty — mark for reprocessing so aggregates are recomputed. - if (_groupedRenderItems != null && _columns.Any(c => c.Aggregate != AggregateFunction.None)) + // Columns register during render, after data was processed. When grouping is active + // that leaves two things stale: a group definition targeting a column that had not + // registered yet resolves to nothing, and aggregates computed without every column + // come out empty. Reprocess so the newly registered column is accounted for. + if (_gridState.Grouping.ActiveGroup != null + && (_groupedRenderItems == null || _columns.Any(c => c.Aggregate != AggregateFunction.None))) { _needsDataRefresh = true; } @@ -847,15 +924,105 @@ internal void SetGrouping(BbDataGridGroupColumn gro _groupsCollapsedByDefault = groupColumn.CollapsedByDefault; _groupColumnHeaderTemplate = groupColumn.HeaderTemplate; - _gridState.Grouping.SetGroup(new GroupDefinition + ApplyGroupDefinition(new GroupDefinition { ColumnId = _groupByColumnId, GroupSortDirection = groupColumn.GroupSortDirection }); + } + + /// + /// Describes the resolved grouping for a data pass: how to compute each row's group key, + /// and how to label the resulting group header rows. + /// + private readonly record struct GroupResolution( + Func Accessor, + string ColumnId, + string? Title); + + /// + /// Resolves the active group definition into an accessor for computing group keys. + /// is the source of truth, so grouping chosen + /// at runtime or restored from a snapshot resolves against the registered columns rather + /// than only against a markup-configured expression. + /// Returns null when no grouping is active or the target column is not registered. + /// + private GroupResolution? ResolveGrouping() + { + var activeGroup = _gridState.Grouping.ActiveGroup; + if (activeGroup == null) + { + return null; + } + + // GroupBy / BbDataGridGroupColumn supply their own accessor, which may group by an + // arbitrary expression that has no corresponding column. + if (_groupByAccessor != null && activeGroup.ColumnId == _groupByColumnId) + { + return new GroupResolution(_groupByAccessor, activeGroup.ColumnId, _groupByColumnTitle); + } + + foreach (var column in _columns) + { + if (column.ColumnId == activeGroup.ColumnId) + { + return new GroupResolution(column.GetRawValue, column.ColumnId, column.Title); + } + } + + return null; + } + + /// + /// Applies a group definition to the grid state, keeping the observed grouping version in + /// sync so the grid's own changes are not re-detected as external ones on the next pass. + /// + /// The definition to apply, or null to clear grouping. + private void ApplyGroupDefinition(GroupDefinition? definition) + { + if (definition == null) + { + _gridState.Grouping.ClearGroup(); + } + else + { + _gridState.Grouping.SetGroup(definition); + } + _lastGroupingVersion = _gridState.Grouping.Version; + _groupedRenderItems = null; _needsDataRefresh = true; } + /// + /// Whether grouping can be applied in the grid's current data mode. Virtualized provider + /// mode cannot group client-side, so the header menu's group action is suppressed there + /// unless a supplies groups from the server. + /// + private bool SupportsGrouping => !IsVirtualizedProvider || GroupedItemsProvider != null; + + private bool CanGroupColumn(IDataGridColumn column) => column.Groupable && SupportsGrouping; + + private bool IsGroupedBy(string columnId) => _gridState.Grouping.ActiveGroup?.ColumnId == columnId; + + private bool GetHeaderMenuOpen(string columnId) => + _headerMenuOpen.TryGetValue(columnId, out var open) && open; + + private void SetHeaderMenuOpen(string columnId, bool open) => + _headerMenuOpen[columnId] = open; + + private async Task HandleGroupByColumnAsync(string columnId) + { + _headerMenuOpen[columnId] = false; + await GroupByColumnAsync(columnId); + } + + private async Task HandleUngroupColumnAsync(string columnId) + { + _headerMenuOpen[columnId] = false; + await ClearGroupingAsync(); + } + /// /// Gets all registered columns (for child components like column visibility toggle). /// @@ -970,13 +1137,56 @@ private void InitializeGroupBy() _groupsCollapsedByDefault = GroupsCollapsedByDefault; _lastGroupBy = GroupBy; - _gridState.Grouping.SetGroup(new GroupDefinition + ApplyGroupDefinition(new GroupDefinition { ColumnId = _groupByColumnId, GroupSortDirection = SortDirection.Ascending }); + } - _needsDataRefresh = true; + /// + /// Instructs the grid to re-read its data source and re-render. + /// + /// + /// Call this after mutating the collection in place — for example + /// List<T>.Add or Remove — which the grid cannot detect by reference, + /// or to force an re-fetch. Collections implementing + /// are refreshed automatically and do not need this. + /// + public async Task RefreshDataAsync() + { + _needsDataRefresh = false; + await ProcessDataAsync(); + StateHasChanged(); + } + + /// + /// Groups rows by the specified column, replacing any currently active grouping. + /// + /// The to group by. + /// The direction group keys are ordered in. Default is ascending. + public async Task GroupByColumnAsync(string columnId, SortDirection direction = SortDirection.Ascending) + { + ArgumentException.ThrowIfNullOrEmpty(columnId); + + ApplyGroupDefinition(new GroupDefinition + { + ColumnId = columnId, + GroupSortDirection = direction + }); + + await RefreshDataAsync(); + await NotifyStateChangedAsync(); + } + + /// + /// Clears any active grouping, returning the grid to flat rows. + /// + public async Task ClearGroupingAsync() + { + ApplyGroupDefinition(null); + await RefreshDataAsync(); + await NotifyStateChangedAsync(); } private void InitializeColumnState() @@ -1031,9 +1241,9 @@ private void ProcessInMemoryData() var sortedList = ApplyGlobalSearch(sorted.ToList()).ToList(); - if (_groupByAccessor != null) + if (ResolveGrouping() is { } queryableGrouping) { - ProcessGroupedData(sortedList); + ProcessGroupedData(sortedList, queryableGrouping); return; } @@ -1063,9 +1273,9 @@ private void ProcessInMemoryData() var searched = ApplyGlobalSearch(sorted); var list = searched as IList ?? searched.ToList(); - if (_groupByAccessor != null) + if (ResolveGrouping() is { } grouping) { - ProcessGroupedData(list); + ProcessGroupedData(list, grouping); return; } @@ -1106,10 +1316,10 @@ private void ProcessInMemoryData() UpdateVirtualizationList(); } - private void ProcessGroupedData(IList sortedData) + private void ProcessGroupedData(IList sortedData, GroupResolution grouping) { var groupDef = _gridState.Grouping.ActiveGroup; - var accessor = _groupByAccessor!; + var accessor = grouping.Accessor; // Group the sorted data var groups = sortedData @@ -1147,8 +1357,8 @@ private void ProcessGroupedData(IList sortedData) var groupRow = new DataGridGroupRow { Key = groupKey, - ColumnId = _groupByColumnId ?? "group", - ColumnTitle = _groupByColumnTitle, + ColumnId = grouping.ColumnId, + ColumnTitle = grouping.Title, ItemCount = items.Count, Items = items, Aggregates = aggregates @@ -1334,9 +1544,16 @@ private static bool TryConvertToDouble(object value, out double result) private async ValueTask> VirtualItemsProviderAsync( Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderRequest request) { - // Grouping is not supported with virtualized provider mode - if (_groupByAccessor != null) + // Grouping is not supported in virtualized provider mode without a GroupedItemsProvider. + if (_gridState.Grouping.ActiveGroup != null) { + if (!_virtualGroupingWarned) + { + _virtualGroupingWarned = true; + DataGridLog.GroupingUnsupportedInVirtualizedProvider( + Logger, _gridState.Grouping.ActiveGroup.ColumnId); + } + return new Microsoft.AspNetCore.Components.Web.Virtualization.ItemsProviderResult( Array.Empty(), 0); } @@ -2087,9 +2304,11 @@ private async Task LoadFromProviderAsync() .Select(c => c.ColumnId) .ToList(); + var grouping = ResolveGrouping(); + // When grouping client-side from a flat provider, fetch all items so // ProcessGroupedData can correctly paginate across groups. - var isClientSideGrouping = _groupByAccessor != null && GroupedItemsProvider == null; + var isClientSideGrouping = grouping != null && GroupedItemsProvider == null; var request = new DataGridRequest { @@ -2104,13 +2323,13 @@ private async Task LoadFromProviderAsync() }; // Use grouped provider when grouping is active and provider is available - if (_groupByAccessor != null && GroupedItemsProvider != null) + if (grouping != null && GroupedItemsProvider != null) { var groupedResult = await GroupedItemsProvider(request); if (!token.IsCancellationRequested) { - BuildRenderItemsFromGroupedResult(groupedResult); + BuildRenderItemsFromGroupedResult(groupedResult, grouping.Value); } } else @@ -2119,11 +2338,11 @@ private async Task LoadFromProviderAsync() if (!token.IsCancellationRequested) { - if (_groupByAccessor != null) + if (grouping is { } providerGrouping) { // Group client-side from flat provider results var items = result.Items as IList ?? result.Items.ToList(); - ProcessGroupedData(items); + ProcessGroupedData(items, providerGrouping); } else { @@ -2141,7 +2360,8 @@ private async Task LoadFromProviderAsync() } } - private void BuildRenderItemsFromGroupedResult(DataGridGroupedResult groupedResult) + private void BuildRenderItemsFromGroupedResult( + DataGridGroupedResult groupedResult, GroupResolution grouping) { var allRenderItems = new List>(); var allDataItems = new List(); @@ -2154,8 +2374,8 @@ private void BuildRenderItemsFromGroupedResult(DataGridGroupedResult grou var groupRow = new DataGridGroupRow { Key = groupKey, - ColumnId = _groupByColumnId ?? "group", - ColumnTitle = _groupByColumnTitle, + ColumnId = grouping.ColumnId, + ColumnTitle = grouping.Title, ItemCount = group.ItemCount > 0 ? group.ItemCount : items.Count, Items = items, Aggregates = group.Aggregates ?? new Dictionary() @@ -3106,6 +3326,13 @@ protected override bool ShouldRender() public async ValueTask DisposeAsync() { GC.SuppressFinalize(this); + + if (_observedItems != null) + { + _observedItems.CollectionChanged -= HandleItemsCollectionChanged; + _observedItems = null; + } + _loadCts?.Cancel(); _loadCts?.Dispose(); diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs index c31f85f9b..b0545ebdf 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs @@ -53,6 +53,13 @@ public partial class BbDataGridPropertyColumn : ComponentBase, IDa [Parameter] public bool Sortable { get; set; } + /// + /// Whether rows can be grouped by this column from the column header menu. Default is false. + /// When true, an ellipsis menu appears in the column header offering a "Group by" action. + /// + [Parameter] + public bool Groupable { get; set; } + /// /// Whether this column is visible. Default is true. /// @@ -173,6 +180,8 @@ public partial class BbDataGridPropertyColumn : ComponentBase, IDa bool IDataGridColumn.Filterable => Filterable; + bool IDataGridColumn.Groupable => Groupable; + bool IDataGridColumn.Visible => Visible; string? IDataGridColumn.Width => Width; diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridTemplateColumn.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridTemplateColumn.razor.cs index 924b392dc..be2b244fa 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridTemplateColumn.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridTemplateColumn.razor.cs @@ -54,6 +54,13 @@ public partial class BbDataGridTemplateColumn : ComponentBase, IDataGridC [Parameter] public Expression>? SortBy { get; set; } + /// + /// Whether rows can be grouped by this column from the column header menu. Default is false. + /// Requires to be set when true, since the group key is taken from it. + /// + [Parameter] + public bool Groupable { get; set; } + /// /// Whether this column is visible. Default is true. /// @@ -174,6 +181,8 @@ public partial class BbDataGridTemplateColumn : ComponentBase, IDataGridC bool IDataGridColumn.Filterable => Filterable && FilterBy != null; + bool IDataGridColumn.Groupable => Groupable && SortBy != null; + bool IDataGridColumn.Visible => Visible; string? IDataGridColumn.Width => Width; diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/DataGridLog.cs b/src/BlazorBlueprint.Components/Components/DataGrid/DataGridLog.cs new file mode 100644 index 000000000..2a4030f1f --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/DataGrid/DataGridLog.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Logging; + +namespace BlazorBlueprint.Components; + +/// +/// Source-generated log messages for the DataGrid. Declared outside the generic grid component +/// so the generated methods do not need to be nested inside a generic containing type. +/// +internal static partial class DataGridLog +{ + [LoggerMessage( + Level = LogLevel.Warning, + Message = "DataGrid is grouped by column '{ColumnId}' while using Virtualize with an ItemsProvider, " + + "a combination that cannot group client-side, so no rows will render. Supply a " + + "GroupedItemsProvider to group server-side, or turn off Virtualize.")] + public static partial void GroupingUnsupportedInVirtualizedProvider(ILogger logger, string columnId); +} diff --git a/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs b/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs index 7eda01e23..e0f4a5d24 100644 --- a/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs +++ b/src/BlazorBlueprint.Components/Localization/DefaultBbLocalizer.cs @@ -76,6 +76,9 @@ public class DefaultBbLocalizer : IBbLocalizer ["DataGrid.ExpandGroup"] = "Expand group", ["DataGrid.CollapseGroup"] = "Collapse group", ["DataGrid.FilterPlaceholder"] = "Filter {0}", + ["DataGrid.ColumnMenu"] = "{0} column options", + ["DataGrid.GroupByColumn"] = "Group by {0}", + ["DataGrid.UngroupColumn"] = "Remove grouping", ["DataGrid.PinnedColumnTooltip"] = "This column is pinned and cannot be moved", ["DataGrid.ActiveFilters"] = "{0} active filter(s)", ["DataGrid.ClearAll"] = "Clear all", diff --git a/src/BlazorBlueprint.Primitives/Primitives/DataGrid/DataGridGroupState.cs b/src/BlazorBlueprint.Primitives/Primitives/DataGrid/DataGridGroupState.cs index 56f162e08..8098af46d 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/DataGrid/DataGridGroupState.cs +++ b/src/BlazorBlueprint.Primitives/Primitives/DataGrid/DataGridGroupState.cs @@ -23,6 +23,12 @@ public class DataGridGroupState /// public IReadOnlyCollection CollapsedKeys => collapsedKeys; + /// + /// Gets a version counter that increments whenever the active group definition changes. + /// Used by the grid component to detect grouping changes made directly against this state. + /// + public int Version { get; private set; } + /// /// Sets the active group definition. /// @@ -31,6 +37,7 @@ public void SetGroup(GroupDefinition? group) { ActiveGroup = group; collapsedKeys.Clear(); + Version++; } /// @@ -40,6 +47,7 @@ public void ClearGroup() { ActiveGroup = null; collapsedKeys.Clear(); + Version++; } /// @@ -85,5 +93,6 @@ public void Clear() { collapsedKeys.Clear(); ActiveGroup = null; + Version++; } } diff --git a/src/BlazorBlueprint.Primitives/Primitives/DataGrid/IDataGridColumn.cs b/src/BlazorBlueprint.Primitives/Primitives/DataGrid/IDataGridColumn.cs index 06da412f9..09a0e63ac 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/DataGrid/IDataGridColumn.cs +++ b/src/BlazorBlueprint.Primitives/Primitives/DataGrid/IDataGridColumn.cs @@ -29,6 +29,13 @@ public interface IDataGridColumn where TData : class /// public bool Filterable { get; } + /// + /// Gets whether the user can group rows by this column from the column header menu. + /// Default is false. The group key is taken from , so a column + /// must expose a meaningful raw value to be groupable. + /// + public bool Groupable => false; + /// /// Gets whether this column is currently visible. /// diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 60f4b6791..9071052b1 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -858,6 +858,7 @@ - FilterType : FilterFieldType? - Filterable : Boolean - Format : String + - Groupable : Boolean - HeaderClass : String - Hideable : Boolean - Id : String @@ -890,6 +891,7 @@ - FilterOptions : IEnumerable> - FilterType : FilterFieldType? - Filterable : Boolean + - Groupable : Boolean - HeaderClass : String - HeaderTemplate : RenderFragment - Hideable : Boolean diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt index a2abf47ab..2a2cec7fa 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt @@ -1112,6 +1112,7 @@ - CellTemplate : RenderFragment> { get; } - ColumnId : String { get; } - Filterable : Boolean { get; } + - Groupable : Boolean { get; } - HeaderClass : String { get; } - HeaderTemplate : RenderFragment> { get; } - Hideable : Boolean { get; } From b3b69088e91aae0157c413342cedfda5d1500638 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 15 Jul 2026 19:16:54 +0800 Subject: [PATCH 139/188] docs(changelog): add 2026-07-15 DataGrid grouping and refresh entry (#411) --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index faa1009c1..718d2977e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## 2026-07-15 + +### Added + +- **BbDataGrid: runtime grouping (`Groupable`)** — Property and template columns accept `Groupable`; when set, an ellipsis menu appears in the column header offering "Group by X" / "Remove grouping", and the grid regroups without a page refresh. Opt-in, matching `Sortable`/`Filterable` (template columns require `SortBy`, since the group key is read from it). Also exposed imperatively as `GroupByColumnAsync(columnId, direction)` and `ClearGroupingAsync()`. Single-level only — grouping by another column replaces the current grouping. ([#411](https://github.com/blazorblueprintui/ui/pull/411)) +- **BbDataGrid: RefreshDataAsync() & ObservableCollection support** — The grid detects a new data set by reference, so mutating the `Items` collection in place never re-rendered. New public `RefreshDataAsync()` re-reads the data source (and forces an `ItemsProvider` re-fetch), and an `Items` collection implementing `INotifyCollectionChanged` — such as `ObservableCollection` — is now subscribed to and refreshed automatically. The reference-swap contract is documented on the parameter. ([#411](https://github.com/blazorblueprintui/ui/pull/411)) + +### Fixed + +- **BbDataGrid: grouping state was not the source of truth** — Grouping was computed from a private compiled delegate set only by the `GroupBy` parameter or `BbDataGridGroupColumn`; `GroupDefinition.ColumnId` was never resolved back to a column. Any grouping applied outside markup silently diverged — the snapshot, `HasGrouping` and `DataGridRequest.GroupDefinition` reported a grouping while the grid rendered flat rows. Most visibly, **`Save()`/`Restore()` never round-tripped grouping**, and `State.Grouping.SetGroup(...)` was inert. `DataGridGroupState.ActiveGroup` is now the source of truth, resolved against registered columns, with the markup-configured accessor kept as an override. `DataGridGroupState` also gained a `Version` counter so changes made directly against the state object are detected, and a group definition targeting a not-yet-registered column now reprocesses once that column registers instead of resolving to nothing forever. ([#411](https://github.com/blazorblueprintui/ui/pull/411)) +- **BbDataGrid: in-place Items mutations rendered stale rows** — Reprocessing was gated on a reference comparison while rendering was not, so adding or removing an item without reassigning the collection repainted the grid with the previously processed rows and a stale total — wrong, but indistinguishable from correctly wired. See `RefreshDataAsync()` above. ([#411](https://github.com/blazorblueprintui/ui/pull/411)) +- **BbDataGrid: grouping with a virtualized provider failed silently** — Grouping combined with `Virtualize` and an `ItemsProvider` renders an empty grid (unsupported without a `GroupedItemsProvider`). Still unsupported, but the column header menu now suppresses the group action in that mode, and the empty result logs a warning naming the column and the ways out rather than rendering nothing with no explanation. ([#411](https://github.com/blazorblueprintui/ui/pull/411)) + +### Changed + +- **BbDataGrid: `DataGridState.Reset()` now clears grouping applied via `GroupBy`** — `Reset()` cleared the active group definition while the grid carried on grouping by its stale internal accessor. It now genuinely ungroups, consistent with how `Reset()` already clears sorting and filters. ([#411](https://github.com/blazorblueprintui/ui/pull/411)) + +--- + ## 2026-07-02 ### Added From e7f4d67acc0dc425416983e1989e72805ce6d946 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 15 Jul 2026 19:44:02 +0800 Subject: [PATCH 140/188] docs(demo): show descending group ordering in the interactive grouping example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GroupByColumnAsync takes a SortDirection ordering the group headers, but no demo exercised it — the header menu only groups ascending. The example now groups by the same column in both directions so the contrast is visible, and notes that the direction orders the groups themselves rather than the rows within them. --- .../Components/DataGrid/interactive-grouping.txt | 14 +++++++++----- .../Pages/Components/DataGridDemo.razor | 11 ++++++++++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/interactive-grouping.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/interactive-grouping.txt index 800111476..10e8aac1c 100644 --- a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/interactive-grouping.txt +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/interactive-grouping.txt @@ -2,9 +2,14 @@
    + @* SortDirection orders the group headers — ascending is the default. *@ - Group by Department + Group by Department (A–Z) + + + Group by Department (Z–A) @@ -29,10 +34,9 @@ private BbDataGrid? grid; private List people = new(); - // Grouping can also be driven from code, without the header menu: - // await grid.GroupByColumnAsync("department"); - // await grid.GroupByColumnAsync("status", SortDirection.Descending); - // await grid.ClearGroupingAsync(); + // The header menu always groups ascending. Pass a SortDirection to order the group + // headers the other way — this affects the order of the groups themselves, not the + // rows within them, which follow the column's own sort. // // One column is grouped at a time — grouping by another replaces the current grouping. // The active group is held on DataGridState.Grouping, so it is captured by Save() diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor index 07e38f74b..34266f924 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor @@ -827,6 +827,11 @@ at runtime. An ellipsis menu appears in the column header with a "Group by" action, and the grid regroups without a page refresh. Only one column is grouped at a time — choosing another replaces the current grouping. Columns left without the flag (like Name) offer no menu. + Grouping can also be driven from code with + GroupByColumnAsync, which takes a + SortDirection controlling the order of the group + headers themselves — the buttons below group by the same column in each direction. Sorting + within each group stays under the column's own sort.

    @@ -834,7 +839,11 @@
    - Group by Department + Group by Department (A–Z) + + + Group by Department (Z–A) From 6b2df64b598e9979827744c6a04f0b0ef7db5647 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 15 Jul 2026 20:40:04 +0800 Subject: [PATCH 141/188] review: align file input id with the library's Id convention, add demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames InputId to Id and adopts the EffectiveId pattern used across the library, plus the demo and API-surface updates the new parameters need. Id rather than InputId: 8+ components already expose `public string? Id`, and Blazor matches component parameters case-insensitively — so with Id, the natural `` binds to the parameter. With InputId it was captured by the new AdditionalAttributes dictionary and then overridden by the id that follows the splat, so it silently rendered the generated id instead. Before this PR that same markup threw; silently ignoring it is the worse failure mode, and it is the first thing someone reaching for this feature tries. EffectiveId rather than a parameter initializer: matches the 23 files already using `Id ?? (generatedId ??= ...)`, and avoids an explicitly-passed null blanking the id. The generated value stays stable across re-renders. AdditionalAttributes keeps targeting the input, now documented — Class styles the container while unmatched attributes land on the input, and that split is worth stating rather than leaving to be discovered. Adds a "Trigger From Your Own Control" demo showing the label-for case that motivated the PR, its code snippet, and API Reference entries for both parameters. Accepts the API surface snapshot, which the new parameters changed. --- .../Components/FileUpload/label-trigger.txt | 18 +++++++++++ .../Pages/Components/FileUploadDemo.razor | 31 +++++++++++++++++++ .../Components/FileUpload/BbFileUpload.razor | 20 ++++++++++-- ...entsApiSurfaceMatchesBaseline.verified.txt | 2 ++ 4 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/FileUpload/label-trigger.txt diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/FileUpload/label-trigger.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/FileUpload/label-trigger.txt new file mode 100644 index 000000000..3eba18c09 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/FileUpload/label-trigger.txt @@ -0,0 +1,18 @@ +@* Set Id, then point a
    + +
    +
    +

    Trigger From Your Own Control

    +

    + Set Id and point a + <label for="..."> at it to open the file + dialog from anywhere on the page — useful when the dropzone is hidden, for example a chat + composer with an attach button. Clicking the label below opens the dialog for the dropzone + underneath it. When Id is not set, a unique + one is generated. +

    +
    + + + + + + +
    + @@ -187,6 +212,9 @@ Selected files. + + The HTML id of the underlying file input. Point a <label for="..."> at it to open the file dialog from your own control, which works even when the dropzone is hidden. When not set, a unique id is generated. + Allow multiple files. @@ -214,6 +242,9 @@ Custom dropzone content. + + Unmatched attributes, applied to the underlying <input type="file"> rather than the container — so form and accessibility attributes such as name or aria-label reach the element that holds the files. Use Class to style the container. + Clears all files and validation errors, resetting the component. diff --git a/src/BlazorBlueprint.Components/Components/FileUpload/BbFileUpload.razor b/src/BlazorBlueprint.Components/Components/FileUpload/BbFileUpload.razor index d4fa70daa..b80d92d53 100644 --- a/src/BlazorBlueprint.Components/Components/FileUpload/BbFileUpload.razor +++ b/src/BlazorBlueprint.Components/Components/FileUpload/BbFileUpload.razor @@ -21,7 +21,7 @@ disabled="@Disabled" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer" @attributes="AdditionalAttributes" - id="@InputId" /> + id="@EffectiveId" /> @if (DropzoneContent != null) { @@ -126,6 +126,7 @@ @code { private InputFile? _inputFile; private ElementReference _dropzoneRef; + private string? _generatedId; private bool _isDragging; private bool _jsInitialized; private List _files = new(); @@ -134,10 +135,15 @@ private IJSObjectReference? _jsCleanup; /// - /// Gets or sets the ID of the file input element. If not provided, a unique ID will be generated. + /// Gets or sets the HTML id attribute for the file input element. /// + /// + /// Used to associate the input with a label element via the label's 'for' attribute, so an + /// external control can open the file dialog — useful when the dropzone itself is hidden. + /// When not set, a unique id is generated. + /// [Parameter] - public string InputId { get; set; } = $"file-upload-{Guid.NewGuid():N}"; + public string? Id { get; set; } /// /// Gets or sets the selected files. @@ -208,6 +214,12 @@ /// /// Gets or sets additional HTML attributes to apply to the file input element. /// + /// + /// Attributes are applied to the underlying <input type="file"> rather than the + /// container, so form and accessibility attributes such as name or aria-label + /// reach the element that actually holds the files. Use to style the + /// container, and to set the input's id. + /// [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } @@ -467,6 +479,8 @@ return $"{bytes / (1024.0 * 1024 * 1024):F1} GB"; } + private string EffectiveId => Id ?? (_generatedId ??= $"file-upload-{Guid.NewGuid():N}"); + private string ContainerClass => ClassNames.cn( "w-full", Class diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 7d22cbd86..4ab754e9a 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -1454,11 +1454,13 @@ ### BbFileUpload (BlazorBlueprint.Components) - Accept : String + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] - Class : String - Disabled : Boolean - DropzoneContent : RenderFragment - Files : IReadOnlyList - FilesChanged : EventCallback> + - Id : String - MaxFileCount : Int32 - MaxFileSize : Int64 - Multiple : Boolean From 7dc5d65b4cff8c813829eae45dd76a3f7b6eaf86 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 15 Jul 2026 22:36:17 +0800 Subject: [PATCH 142/188] docs(changelog): add sidebar shortcut, border utilities, and theme merge fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the PRs merged on 2026-07-15 that the existing entry missed: #403 (EnableToggleShortcut, plus the shared-JS-state fix it surfaced), #407 (border colour utilities defeated by the bb cascade layer since 3.10.1), and #408 (theme stripped by Blazor's document merge on hot reload). Excludes #405 (CI label workflow) and #409 (RELEASE.md corrections and Font Awesome release groundwork) as internal — neither changes the library. The Font Awesome release itself will warrant an entry when it is tagged. --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 718d2977e..0d4b9fe9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **BbDataGrid: runtime grouping (`Groupable`)** — Property and template columns accept `Groupable`; when set, an ellipsis menu appears in the column header offering "Group by X" / "Remove grouping", and the grid regroups without a page refresh. Opt-in, matching `Sortable`/`Filterable` (template columns require `SortBy`, since the group key is read from it). Also exposed imperatively as `GroupByColumnAsync(columnId, direction)` and `ClearGroupingAsync()`. Single-level only — grouping by another column replaces the current grouping. ([#411](https://github.com/blazorblueprintui/ui/pull/411)) - **BbDataGrid: RefreshDataAsync() & ObservableCollection support** — The grid detects a new data set by reference, so mutating the `Items` collection in place never re-rendered. New public `RefreshDataAsync()` re-reads the data source (and forces an `ItemsProvider` re-fetch), and an `Items` collection implementing `INotifyCollectionChanged` — such as `ObservableCollection` — is now subscribed to and refreshed automatically. The reference-swap contract is documented on the parameter. ([#411](https://github.com/blazorblueprintui/ui/pull/411)) +- **BbSidebarProvider: EnableToggleShortcut** — `Ctrl`/`Cmd` + `B` was registered unconditionally, with no way out for apps whose content wants those keys — a rich-text editor's bold command being the obvious clash. The new `EnableToggleShortcut` parameter defaults to `true`, so existing usage is unchanged. The handler bails before `preventDefault()`, so disabling genuinely releases the key to the page rather than swallowing it, and the parameter is reactive: changes after the first render sync to JS instead of only applying at startup. ([#403](https://github.com/blazorblueprintui/ui/pull/403)) ### Fixed +- **All `border-*` colour utilities were dead since 3.10.1** — The shadcn-style `* { border-color: var(--border) }` reset sat inside `@layer bb`, which is declared last and therefore wins; because cascade-layer priority beats specificity unconditionally, that universal selector defeated every `.border-*` class in the plain `utilities` layer. Harmless while Tailwind's utilities were imported into `bb` too (same layer, so specificity decided), it became a bug in 3.10.1 when the #318 grid fix dropped `layer(bb)` from the import and left the hand-authored reset behind. Alert variants losing their colours was the visible symptom rather than the scope. ([#407](https://github.com/blazorblueprintui/ui/pull/407)) +- **Theme was lost whenever Blazor re-merged the document** — `ThemeService` applies the theme to `` from JS only, since the preference lives in localStorage and is unknown server-side. Blazor's enhanced page refresh merges the freshly server-rendered document into the live DOM and syncs ``'s attributes, stripping the theme. Under `dotnet watch` that fires on every hot reload, and because the document is not reloaded the circuit survives — so `OnAfterRenderAsync(firstRender)` never runs again and nothing put the theme back. ([#408](https://github.com/blazorblueprintui/ui/pull/408)) +- **BbSidebarProvider: multiple providers on a page shared JS state** — ES modules are singletons, so the module-level handler and .NET reference in `sidebar.js` were shared by every provider on the page. Only the last one to initialise responded to the toggle shortcut — receiving one invocation per provider — only it received `OnMobileChange`, and `cleanup()` removed just that one listener, leaking the rest on dispose. State now lives keyed by an instance id, with one shared keydown listener and `ResizeObserver` fanning out to registered instances. Mostly latent in real apps, which wrap everything in a single provider. ([#403](https://github.com/blazorblueprintui/ui/pull/403)) - **BbDataGrid: grouping state was not the source of truth** — Grouping was computed from a private compiled delegate set only by the `GroupBy` parameter or `BbDataGridGroupColumn`; `GroupDefinition.ColumnId` was never resolved back to a column. Any grouping applied outside markup silently diverged — the snapshot, `HasGrouping` and `DataGridRequest.GroupDefinition` reported a grouping while the grid rendered flat rows. Most visibly, **`Save()`/`Restore()` never round-tripped grouping**, and `State.Grouping.SetGroup(...)` was inert. `DataGridGroupState.ActiveGroup` is now the source of truth, resolved against registered columns, with the markup-configured accessor kept as an override. `DataGridGroupState` also gained a `Version` counter so changes made directly against the state object are detected, and a group definition targeting a not-yet-registered column now reprocesses once that column registers instead of resolving to nothing forever. ([#411](https://github.com/blazorblueprintui/ui/pull/411)) - **BbDataGrid: in-place Items mutations rendered stale rows** — Reprocessing was gated on a reference comparison while rendering was not, so adding or removing an item without reassigning the collection repainted the grid with the previously processed rows and a stale total — wrong, but indistinguishable from correctly wired. See `RefreshDataAsync()` above. ([#411](https://github.com/blazorblueprintui/ui/pull/411)) - **BbDataGrid: grouping with a virtualized provider failed silently** — Grouping combined with `Virtualize` and an `ItemsProvider` renders an empty grid (unsupported without a `GroupedItemsProvider`). Still unsupported, but the column header menu now suppresses the group action in that mode, and the empty result logs a warning naming the column and the ways out rather than rendering nothing with no explanation. ([#411](https://github.com/blazorblueprintui/ui/pull/411)) From 8fdf020c620f250a65f6d2fd7787254f0d6dccbc Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 15 Jul 2026 22:42:24 +0800 Subject: [PATCH 143/188] docs: release notes for Primitives v3.14.0 --- src/BlazorBlueprint.Primitives/RELEASE_NOTES.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md b/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md index ac72d8ec4..2b435af5d 100644 --- a/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md +++ b/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md @@ -1,9 +1,5 @@ -## What's New in v3.13.0 +## What's New in v3.14.0 ### New Features -- **DataGrid** — added `CellClassFunc` on `IDataGridColumn` to compute conditional per-cell CSS classes from the row's data item, combined with the static `CellClass`. - -### Bug Fixes -- **Floating/Popover positioning** — portal coordinates now render with invariant culture, fixing invalid CSS in locales that use a decimal comma (e.g. de-DE). -- **Popover** — `AsChild` triggers now apply a pointer-events guard while open (via new `TriggerContext.SuppressPointerEventsWhenOpen`), so a single click can no longer close and immediately re-open the overlay. -- **JS interop disposal** — swallow `JSException` on dispose paths in **Sortable**, **TreeView**, **FocusManager**, and **PositioningService** to prevent errors during WebView2 reloads. +- **DataGrid** — added `Groupable` on `IDataGridColumn` so columns can opt into runtime grouping from the column header menu (defaults to `false`; the group key comes from the column's raw value). +- **DataGrid** — added `DataGridGroupState.Version`, a counter that increments whenever the active group definition changes, letting the grid detect grouping changes made directly against the state. From 65c94b2e1a443fbae6669a828990a94634dcf19b Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 15 Jul 2026 22:46:34 +0800 Subject: [PATCH 144/188] chore: bump BlazorBlueprint.Primitives to 3.14.0 --- .../BlazorBlueprint.Components.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj index 7e0cc4073..ff2ce419a 100644 --- a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj +++ b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj @@ -58,7 +58,7 @@ - + From 4f88bb0db920281c275e3b6b27b0197d01fc1ba7 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 15 Jul 2026 22:54:49 +0800 Subject: [PATCH 145/188] docs: release notes for Components v3.14.0 --- .../RELEASE_NOTES.md | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/src/BlazorBlueprint.Components/RELEASE_NOTES.md b/src/BlazorBlueprint.Components/RELEASE_NOTES.md index 0883e8993..93cb20795 100644 --- a/src/BlazorBlueprint.Components/RELEASE_NOTES.md +++ b/src/BlazorBlueprint.Components/RELEASE_NOTES.md @@ -1,23 +1,18 @@ -## What's New in v3.13.0 - -### New Components -- **BbDock** — IDE-style docking container whose panels can be dragged, re-docked as tabs or splits, detached into floating windows, maximized, pinned, closed and reopened, with min/max size constraints. -- **BbEventCalendar** — generic event calendar with month, week and agenda views, per-event coloring and templating, two-way bindable `View`/`CurrentDate`, built-in navigation, and event/date/range callbacks. -- **BbDateTimePicker** & **BbFormFieldDateTimePicker** — combined date and time picker with calendar plus hour/minute/second and AM/PM steppers, 12/24h formats, Now/Clear actions and EditForm integration. -- **BbCopyText** — copy-to-clipboard component with a hover tooltip that reflects copy state and an `OnCopied` callback. -- **BbMessage**, **BbBubble**, **BbAttachment** & **BbMarker** — chat and messaging component families for building conversation UIs (message groups, chat bubbles with reactions, file attachments, and markers). +## What's New in v3.14.0 ### New Features -- **BbDatePicker** — manual date entry via `Editable` and `InputFormats`, letting users type dates that are parsed against configured formats (ISO always accepted) and reverted when invalid. -- **BbThemeSwitcher** — independent base and primary color selection via the new `ColorLayout` (`Split`/`Combined`); selecting a base color no longer resets the primary. -- **BbCalendar**, **BbDatePicker** & **BbDateRangePicker** — per-day customization through `DayTemplate` (custom day content via `CalendarDayContext`) and `DayClassFunc` (conditional per-day CSS). -- **BbDataGrid** — `CellClassFunc` on property, template and hierarchy columns for conditional per-cell styling from the row's data. -- **BbDateRangePicker** — `ShowButtons` and `AutoApply` parameters; `AutoApply` applies and closes the popover once a complete valid range is selected. -- **Input components** — public `FocusAsync()` method and `Element` reference on `BbInput`, `BbTextarea`, `BbInputField`, `BbInputGroupInput`, `BbInputGroupTextarea`, `BbNumericInput`, `BbCurrencyInput`, `BbMaskedInput`, `BbTagInput` and the FormField wrappers. -- **BbCheckbox** — new `Name` parameter (forwarded by `BbFormFieldCheckbox`) for form submission. +- **BbDataGrid** — runtime grouping via the new `Groupable` parameter on property and template columns; a header ellipsis menu offers "Group by" / "Remove grouping", also available imperatively through `GroupByColumnAsync(columnId, direction)` and `ClearGroupingAsync()`. +- **BbDataGrid** — public `RefreshDataAsync()` to re-process the current data after in-place collection mutations, mirroring QuickGrid. +- **BbDataGrid** — `INotifyCollectionChanged` support: an `ObservableCollection` passed to `Items` refreshes the grid automatically, with subscriptions cleaned up on dispose and source swap. +- **BbSidebarProvider** — new `EnableToggleShortcut` parameter (default `true`) to opt out of the Ctrl/Cmd+B sidebar shortcut so the keys can reach the page (e.g. a rich-text editor's bold command); reactive after first render. ### Bug Fixes -- **Forms** — input components now emit the full model path (e.g. `Input.Username`) in the `name` attribute so `[SupplyParameterFromForm]` binds on SSR/enhanced form submissions; `BbCheckbox` now renders a hidden native checkbox so it posts a value. -- **BbPopover** — pointer-events open-guard is now applied to `AsChild` triggers. -- **BbSidebar** — menu buttons now fire `OnClick` when rendered as an anchor. -- **JS interop** — swallow `JSException` on dispose paths to avoid errors during WebView2 reloads. +- **BbDataGrid** — grouping set programmatically through the state object now actually applies and round-trips through `Save()`/`Restore()`; previously only markup-configured grouping took effect while the state reported a grouping the grid ignored. +- **BbDataGrid** — group definitions targeting a column that registers later no longer silently resolve to nothing, and `Reset()` now genuinely clears grouping applied via the `GroupBy` parameter. +- **Theming** — the applied theme (dark mode class, base/primary color attributes, radius) is now restored when Blazor's enhanced page refresh re-merges the document and strips it from `` (e.g. on every `dotnet watch` hot reload); the dark-mode toggle no longer needs two clicks afterwards. +- **CSS** — border color utilities (`border-primary`, `border-alert-*/30`, and consumer `.border-*` classes) are no longer flattened to the default border color; the global border reset moved from the `bb` cascade layer to `base` so utilities win again. +- **BbSidebar** — multiple `BbSidebarProvider` instances on one page now each receive the toggle shortcut and mobile-change notifications; previously module-level JS state meant only the last-initialized provider responded and disposal leaked listeners. + +### Improvements +- **BbDataGrid** — grouping combined with `Virtualize` + `ItemsProvider` (unsupported) now hides the header group action and logs a warning naming the column instead of silently rendering an empty grid. +- Bumped the `BlazorBlueprint.Primitives` dependency to 3.14.0. From 7db962a5a9dc76ac191e8ffe459f8a12bc5571c3 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Thu, 16 Jul 2026 12:45:37 +0800 Subject: [PATCH 146/188] fix(input): guard IME composition across text, numeric, tag, and search inputs (#414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in the library tracked IME composition, so every input that round-tripped through C# on each keystroke corrupted Korean/Japanese/Chinese text: typing 안녕 produced ㅇ안안ㄴ녕. Writing element.value mid-composition resets the IME's composition buffer, and Blazor's renderer assigns element.value whenever a bound field changes. Two distinct bug classes, both fixed here: - Value write-back — a per-keystroke field update re-renders and reassigns element.value. Affects BbInput, BbTextarea, BbInputField, BbInputGroupInput/ Textarea (text-input.js), BbNumericInput/BbCurrencyInput (numeric-input.js), BbTagInput, BbMarkdownEditor, BbMaskedInput, BbCombobox/BbFormFieldCombobox (BbCommandInput), and BbMultiSelect/BbFormFieldMultiSelect. - Key hijacking — Enter commits a composition and Space/Arrows drive the candidate list, but handlers bound to them fired while the IME still owned the keystroke. multiselect.js was worst: capture-phase with an unconditional preventDefault, which blocked the IME commit outright. New composition-guard.js centralises both. Callers that own their listeners check isComposing and flush via onFlush; components whose handlers are Blazor's own pass `suppress`, which stops the event at the element — Blazor delegates input/keydown to a document-level bubble listener, so this needs no C# restructuring — and re-dispatches one synthetic input on compositionend. A blur reset keeps an interrupted composition (no compositionend) from latching the guard and wedging the field. BbMaskedInput necessarily changes behaviour: masking rewrites the field, so it is deferred until the IME commits rather than applied per keystroke. Also fixes a non-IME bug found alongside: numeric-input.js tested digits with an ASCII range check, so full-width digits from a Japanese IME in 全角 mode were silently deleted. They now fold to ASCII (123 -> 123, -12.5 -> -12.5). JS-only guard plus two small interop hookups — no public API change, so no snapshot churn and no new demo example. --- .../Components/Command/BbCommandInput.razor | 47 ++++++ .../MaskedInput/BbMaskedInput.razor.cs | 24 ++++ .../wwwroot/js/composition-guard.js | 135 ++++++++++++++++++ .../wwwroot/js/markdown-editor.js | 28 ++++ .../wwwroot/js/multiselect.js | 18 +++ .../wwwroot/js/numeric-input.js | 43 +++++- .../wwwroot/js/tag-input.js | 16 +++ .../wwwroot/js/text-input.js | 16 +++ 8 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 src/BlazorBlueprint.Components/wwwroot/js/composition-guard.js diff --git a/src/BlazorBlueprint.Components/Components/Command/BbCommandInput.razor b/src/BlazorBlueprint.Components/Components/Command/BbCommandInput.razor index f45857aae..f2dd96092 100644 --- a/src/BlazorBlueprint.Components/Components/Command/BbCommandInput.razor +++ b/src/BlazorBlueprint.Components/Components/Command/BbCommandInput.razor @@ -1,5 +1,6 @@ @namespace BlazorBlueprint.Components @implements IDisposable +@inject IJSRuntime JSRuntime
    @code { + private static readonly string[] GuardSuppressedEvents = ["input", "keydown"]; + private CancellationTokenSource? _debounceCts; private string _displayValue = string.Empty; private bool _isDebouncing; @@ -76,6 +79,8 @@ private ElementReference _inputRef; private bool _previousAutoFocus; private bool _shouldFocus; + private IJSObjectReference? _guardModule; + private readonly string _guardId = Guid.NewGuid().ToString("N"); /// /// Focuses the input element. @@ -118,6 +123,29 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { + if (firstRender) + { + // HandleInput/HandleKeyDown are Blazor-delegated, so the guard suppresses the + // events at the element rather than the component checking a flag: input would + // otherwise write _displayValue back mid-composition, and Enter/Arrow would + // select a list item while the IME still owns the keystroke. + try + { + _guardModule = await JSRuntime.InvokeAsync( + "import", "./_content/BlazorBlueprint.Components/js/composition-guard.js"); + await _guardModule.InvokeVoidAsync( + "attach", _inputRef, _guardId, new { suppress = GuardSuppressedEvents }); + } + catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) + { + // Expected during circuit disconnect + } + catch (InvalidOperationException) + { + // JS interop not available during prerendering + } + } + // Focus when AutoFocus becomes true (either on first render or when popover opens) if (_shouldFocus) { @@ -198,6 +226,25 @@ { _debounceCts?.Cancel(); _debounceCts?.Dispose(); + _ = DisposeGuardAsync(); GC.SuppressFinalize(this); } + + private async Task DisposeGuardAsync() + { + if (_guardModule == null) + { + return; + } + + try + { + await _guardModule.InvokeVoidAsync("detach", _guardId); + await _guardModule.DisposeAsync(); + } + catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException) + { + // Expected during circuit disconnect + } + } } diff --git a/src/BlazorBlueprint.Components/Components/MaskedInput/BbMaskedInput.razor.cs b/src/BlazorBlueprint.Components/Components/MaskedInput/BbMaskedInput.razor.cs index 4b062d9a3..5a24ba47a 100644 --- a/src/BlazorBlueprint.Components/Components/MaskedInput/BbMaskedInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/MaskedInput/BbMaskedInput.razor.cs @@ -11,11 +11,15 @@ namespace BlazorBlueprint.Components; /// public partial class BbMaskedInput : ComponentBase, IAsyncDisposable { + private static readonly string[] GuardSuppressedEvents = ["input"]; + private ElementReference _inputRef; private MaskProcessor? _processor; private string _displayValue = string.Empty; private IJSObjectReference? _jsModule; private bool _jsModuleLoaded; + private IJSObjectReference? _guardModule; + private readonly string _guardId = Guid.NewGuid().ToString("N"); private string? _generatedId; private readonly InputValidationBehavior validation = new(); @@ -212,6 +216,14 @@ protected override async Task OnAfterRenderAsync(bool firstRender) _jsModule = await JSRuntime.InvokeAsync( "import", "./_content/BlazorBlueprint.Components/js/masked-input.js"); _jsModuleLoaded = true; + + // Applying the mask means rewriting the element's value, which resets an + // in-progress IME composition. Suppressing input while composing defers the + // whole HandleInput pass — masking included — until the IME commits. + _guardModule = await JSRuntime.InvokeAsync( + "import", "./_content/BlazorBlueprint.Components/js/composition-guard.js"); + await _guardModule.InvokeVoidAsync( + "attach", _inputRef, _guardId, new { suppress = GuardSuppressedEvents }); } catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) { @@ -428,6 +440,18 @@ private void HandleBlur(FocusEventArgs args) public async ValueTask DisposeAsync() { GC.SuppressFinalize(this); + if (_guardModule != null) + { + try + { + await _guardModule.InvokeVoidAsync("detach", _guardId); + await _guardModule.DisposeAsync(); + } + catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException) + { + // Expected during circuit disconnect + } + } if (_jsModule != null) { try diff --git a/src/BlazorBlueprint.Components/wwwroot/js/composition-guard.js b/src/BlazorBlueprint.Components/wwwroot/js/composition-guard.js new file mode 100644 index 000000000..1f9aecebe --- /dev/null +++ b/src/BlazorBlueprint.Components/wwwroot/js/composition-guard.js @@ -0,0 +1,135 @@ +/** + * IME composition guard. + * + * While an IME is composing (Korean 안녕, Japanese かんじ, Chinese pinyin) the browser + * fires `input` and `keydown` for every intermediate step. Acting on those events + * breaks composition in two distinct ways: + * + * 1. Assigning `element.value` mid-composition resets the IME's composition buffer — + * even when the assigned string is identical to what the element already holds. + * Blazor's renderer assigns `element.value` whenever a bound field changes, so any + * per-keystroke C# round-trip corrupts composition (typing 안녕 yields ㅇ안안ㄴ녕). + * 2. Enter/Space/Arrow are how the user commits a composition or picks a candidate. + * Handlers bound to those keys fire while the IME still owns the keystroke. + * + * Both are fixed the same way: do nothing while composing, act once on `compositionend`. + * + * Two consumers, two entry points: + * + * - A JS module that owns its own listeners passes `onFlush` and checks `isComposing` + * from inside its handlers. + * - A component whose handlers are Blazor's own (`@oninput`, `@onkeydown`, `@bind`) + * passes `suppress`. Blazor delegates bubbling events to a single document-level + * listener, so stopping propagation at the element keeps those handlers from firing + * without the component moving any logic into JS. This works for `input` and + * `keydown` specifically: Blazor registers its non-bubbling set (`change`, `blur`, + * `focus`, ...) with `capture: true` on `document`, which would run before us, but + * `input` and `keydown` are not in that set and are listened for on the bubble phase. + */ + +const instances = new Map(); + +/** + * Creates a composition guard bound to an element. + * @param {HTMLElement} element - The element to guard. + * @param {object} [options] - Configuration. + * @param {Function} [options.onFlush] - Invoked once after composition ends. For callers + * that own their listeners; do not combine with a `suppress` of the same event. + * @param {string[]} [options.suppress] - Event names to keep from reaching Blazor's + * document-level listeners while composing. Only `input` and `keydown` are meaningful. + * @returns {{isComposing: boolean, dispose: Function}} + */ +export function createCompositionGuard(element, options = {}) { + if (!element) { + return { isComposing: false, dispose() {} }; + } + + const { onFlush, suppress = [] } = options; + const state = { isComposing: false }; + + const handleCompositionStart = () => { + state.isComposing = true; + }; + + const handleCompositionEnd = () => { + state.isComposing = false; + + // Browsers disagree on whether the final `input` fires before or after + // `compositionend`, so the last one may have been suppressed above. Re-dispatching + // guarantees Blazor sees exactly one post-composition `input` under either ordering; + // a duplicate is harmless because the value is unchanged and the diff emits no edit. + if (suppress.includes('input')) { + element.dispatchEvent(new Event('input', { bubbles: true })); + } + + if (onFlush) { + onFlush(); + } + }; + + // A composition belongs to the focused element, so focus leaving it ends the composition + // whether or not the IME said so. Without this, an interrupted composition (tab-switch is + // the usual way) would latch isComposing and wedge the field for good. Normal blurs are + // already preceded by compositionend, so this only fires in the pathological case. + const handleBlur = () => { + if (state.isComposing) { + handleCompositionEnd(); + } + }; + + // keyCode 229 is the pre-`isComposing` signal for "this keystroke belongs to the IME". + const shouldSuppress = (e) => state.isComposing || e.isComposing === true || e.keyCode === 229; + + const handleSuppressed = (e) => { + if (shouldSuppress(e)) { + e.stopPropagation(); + } + }; + + element.addEventListener('compositionstart', handleCompositionStart); + element.addEventListener('compositionend', handleCompositionEnd); + element.addEventListener('blur', handleBlur); + + for (const name of suppress) { + element.addEventListener(name, handleSuppressed, true); + } + + return { + get isComposing() { + return state.isComposing; + }, + dispose() { + element.removeEventListener('compositionstart', handleCompositionStart); + element.removeEventListener('compositionend', handleCompositionEnd); + element.removeEventListener('blur', handleBlur); + for (const name of suppress) { + element.removeEventListener(name, handleSuppressed, true); + } + } + }; +} + +/** + * Attaches a guard keyed by instance id, for components that have no JS module of their + * own and drive this directly from C#. + * @param {HTMLElement} element - The element to guard. + * @param {string} instanceId - Unique ID for this instance. + * @param {object} [options] - Same shape as createCompositionGuard's options. + */ +export function attach(element, instanceId, options = {}) { + detach(instanceId); + const guard = createCompositionGuard(element, options); + instances.set(instanceId, guard); +} + +/** + * Removes a guard previously attached with attach(). + * @param {string} instanceId - The instance to detach. + */ +export function detach(instanceId) { + const guard = instances.get(instanceId); + if (guard) { + guard.dispose(); + instances.delete(instanceId); + } +} diff --git a/src/BlazorBlueprint.Components/wwwroot/js/markdown-editor.js b/src/BlazorBlueprint.Components/wwwroot/js/markdown-editor.js index fc04a2916..f55856133 100644 --- a/src/BlazorBlueprint.Components/wwwroot/js/markdown-editor.js +++ b/src/BlazorBlueprint.Components/wwwroot/js/markdown-editor.js @@ -1,8 +1,13 @@ /** * Markdown Editor JavaScript module * Handles textarea text selection, cursor positioning, text insertion, and undo/redo + * + * List continuation, undo snapshots, and the component's @oninput/@onkeydown are all held + * back while an IME is composing. See composition-guard.js for why. */ +import { createCompositionGuard } from './composition-guard.js'; + // Store references for editor data (history, dotNetRef, etc.) const editorMap = new WeakMap(); @@ -353,6 +358,7 @@ export function focusTextarea(textarea) { // Store references for cleanup const listenerMap = new WeakMap(); const inputListenerMap = new WeakMap(); +const guardMap = new WeakMap(); /** * Initialize list continuation behavior and undo/redo on textarea @@ -375,6 +381,13 @@ export function initializeListContinuation(textarea, dotNetRef) { saveState(textarea); const handler = (e) => { + // The Enter that commits a composition must not continue a list. Chrome happens to + // report key "Process" mid-composition and would fall through anyway, but Firefox + // and Safari report "Enter" with isComposing set. + if (guard.isComposing || e.isComposing === true || e.keyCode === 229) { + return; + } + // Intercept Ctrl+Z/Y for undo/redo (prevent browser's native undo) if (e.ctrlKey || e.metaKey) { if (e.key === 'z' || e.key === 'Z') { @@ -470,6 +483,13 @@ export function initializeListContinuation(textarea, dotNetRef) { // Add input listener for auto-scroll and state saving on typing const inputHandler = () => { + // Snapshotting each composition step would make Ctrl+Z walk back through jamo + // rather than words. Scrolling to the cursor stays live. + if (guard.isComposing) { + requestAnimationFrame(() => scrollToCursor(textarea)); + return; + } + // Use requestAnimationFrame to ensure scroll happens after DOM update requestAnimationFrame(() => { scrollToCursor(textarea); @@ -479,6 +499,9 @@ export function initializeListContinuation(textarea, dotNetRef) { }; textarea.addEventListener('input', inputHandler); inputListenerMap.set(textarea, inputHandler); + + const guard = createCompositionGuard(textarea, { suppress: ['input', 'keydown'] }); + guardMap.set(textarea, guard); } /** @@ -497,6 +520,11 @@ export function disposeListContinuation(textarea) { textarea.removeEventListener('input', inputHandler); inputListenerMap.delete(textarea); } + const guard = guardMap.get(textarea); + if (guard) { + guard.dispose(); + guardMap.delete(textarea); + } // Clean up editor data (history, dotNetRef) editorMap.delete(textarea); } diff --git a/src/BlazorBlueprint.Components/wwwroot/js/multiselect.js b/src/BlazorBlueprint.Components/wwwroot/js/multiselect.js index cac677719..980857e34 100644 --- a/src/BlazorBlueprint.Components/wwwroot/js/multiselect.js +++ b/src/BlazorBlueprint.Components/wwwroot/js/multiselect.js @@ -4,6 +4,11 @@ // - Space toggles checkbox without closing // - Enter toggles checkbox and closes // - Preserves selection state on items +// +// Navigation keys and the search input's @bind are both held back while an IME is +// composing. See composition-guard.js for why. + +import { createCompositionGuard } from './composition-guard.js'; let multiSelectStates = new Map(); @@ -66,6 +71,14 @@ export function setupMultiSelectInput(inputElement, dotNetRef, inputId, contentI // Handle keyboard navigation const keyHandler = (e) => { + // While the IME is composing, every key below belongs to it: Space is the Japanese + // conversion key, Enter commits, Arrows walk the candidate list, and Escape cancels. + // This handler is capture-phase and preventDefaults unconditionally, so without this + // guard it would block the IME outright rather than merely fire alongside it. + if (guard.isComposing || e.isComposing === true || e.keyCode === 229) { + return; + } + const options = getVisibleOptions(); // Only handle keys if there are visible options @@ -165,11 +178,14 @@ export function setupMultiSelectInput(inputElement, dotNetRef, inputId, contentI }; inputElement.addEventListener('input', inputHandler); + const guard = createCompositionGuard(inputElement, { suppress: ['input'] }); + // Store state and handlers for cleanup multiSelectStates.set(inputId, { state, keyHandler, inputHandler, + guard, inputElement }); @@ -185,6 +201,7 @@ export function removeMultiSelectInput(inputId) { if (stored) { stored.inputElement.removeEventListener('keydown', stored.keyHandler, true); stored.inputElement.removeEventListener('input', stored.inputHandler); + stored.guard.dispose(); multiSelectStates.delete(inputId); } } @@ -196,6 +213,7 @@ export function disposeAll() { multiSelectStates.forEach((stored, inputId) => { stored.inputElement.removeEventListener('keydown', stored.keyHandler, true); stored.inputElement.removeEventListener('input', stored.inputHandler); + stored.guard.dispose(); }); multiSelectStates.clear(); } diff --git a/src/BlazorBlueprint.Components/wwwroot/js/numeric-input.js b/src/BlazorBlueprint.Components/wwwroot/js/numeric-input.js index 8c07bc318..e443f890c 100644 --- a/src/BlazorBlueprint.Components/wwwroot/js/numeric-input.js +++ b/src/BlazorBlueprint.Components/wwwroot/js/numeric-input.js @@ -7,10 +7,35 @@ * - JsOnBlur(value) — called on blur (always) * - JsOnFocus() — called on focus (always) * - JsOnKeyDown(key) — called for step keys (ArrowUp/Down, PageUp/Down, Home/End) + * + * Sanitization and interop are held back while an IME is composing, then flushed once on + * compositionend. See composition-guard.js for why. */ +import { createCompositionGuard } from './composition-guard.js'; + const instances = new Map(); +/** + * Folds full-width forms to their ASCII equivalents, one character in one character out so + * cursor offsets survive. A Japanese IME in 全角 mode emits 0-9 for the digit keys, which + * would otherwise fail the ASCII range test below and be stripped as garbage. + * @param {string} ch - A single character. + * @returns {string} The ASCII equivalent, or the original character. + */ +const foldFullWidth = (ch) => { + const code = ch.charCodeAt(0); + if (code >= 0xff10 && code <= 0xff19) { + return String.fromCharCode(code - 0xff10 + 0x30); + } + switch (code) { + case 0xff0e: return '.'; + case 0xff0c: return ','; + case 0xff0d: return '-'; + default: return ch; + } +}; + /** * Initializes JS event handling for a numeric input element. * @param {HTMLElement} element - The input element. @@ -52,7 +77,7 @@ export function initialize(element, dotNetRef, instanceId, config) { let removed = 0; for (let i = 0; i < raw.length; i++) { - const ch = raw[i]; + const ch = foldFullWidth(raw[i]); const decSep = cfg.decimalSeparator || '.'; if (ch >= '0' && ch <= '9') { sanitized += ch; @@ -87,6 +112,12 @@ export function initialize(element, dotNetRef, instanceId, config) { }; const handleInput = () => { + // sanitizeInput writes element.value, which would reset the composition buffer; + // guard.onFlush re-runs this once the IME commits. + if (guard.isComposing) { + return; + } + sanitizeInput(); const value = element.value; @@ -111,12 +142,20 @@ export function initialize(element, dotNetRef, instanceId, config) { }; const handleKeyDown = (e) => { + // Arrow/Home/End drive the IME candidate list while composing — stepping the value + // here would steal them and preventDefault the IME's own handling. + if (guard.isComposing || e.isComposing === true || e.keyCode === 229) { + return; + } + if (stepKeySet.has(e.key)) { e.preventDefault(); dotNetRef.invokeMethodAsync('JsOnKeyDown', e.key).catch(() => {}); } }; + const guard = createCompositionGuard(element, { onFlush: handleInput }); + element.addEventListener('input', handleInput); element.addEventListener('blur', handleBlur); element.addEventListener('focus', handleFocus); @@ -128,6 +167,7 @@ export function initialize(element, dotNetRef, instanceId, config) { handleBlur, handleFocus, handleKeyDown, + guard, element }); } @@ -158,6 +198,7 @@ export function dispose(instanceId) { stored.element.removeEventListener('blur', stored.handleBlur); stored.element.removeEventListener('focus', stored.handleFocus); stored.element.removeEventListener('keydown', stored.handleKeyDown); + stored.guard.dispose(); if (stored.state.debounceTimer) { clearTimeout(stored.state.debounceTimer); diff --git a/src/BlazorBlueprint.Components/wwwroot/js/tag-input.js b/src/BlazorBlueprint.Components/wwwroot/js/tag-input.js index cac52c55f..1afdf9b63 100644 --- a/src/BlazorBlueprint.Components/wwwroot/js/tag-input.js +++ b/src/BlazorBlueprint.Components/wwwroot/js/tag-input.js @@ -1,8 +1,13 @@ /** * Tag Input JavaScript interop module. * Handles trigger key preventDefault, paste event reading, and container click-to-focus. + * + * Trigger keys and the component's @oninput are both held back while an IME is composing. + * See composition-guard.js for why. */ +import { createCompositionGuard } from './composition-guard.js'; + const instances = new Map(); /** @@ -20,6 +25,13 @@ export function initialize(containerEl, inputEl, dotNetRef, instanceId, config) } const handleKeyDown = (e) => { + // Every key below belongs to the IME while it is composing: Enter and the delimiters + // commit the composition, Arrows drive the candidate list, and Escape cancels it. + // Acting on any of them would commit a half-composed tag or fight the candidate window. + if (guard.isComposing || e.isComposing === true || e.keyCode === 229) { + return; + } + const key = e.key; // Check if the key is a configured trigger @@ -87,6 +99,8 @@ export function initialize(containerEl, inputEl, dotNetRef, instanceId, config) inputEl.focus(); }; + const guard = createCompositionGuard(inputEl, { suppress: ['input'] }); + // Use capture phase for keydown to preventDefault before browser defaults inputEl.addEventListener('keydown', handleKeyDown, true); inputEl.addEventListener('paste', handlePaste); @@ -96,6 +110,7 @@ export function initialize(containerEl, inputEl, dotNetRef, instanceId, config) handleKeyDown, handlePaste, handleContainerClick, + guard, inputEl, containerEl }); @@ -125,5 +140,6 @@ export function dispose(instanceId) { stored.inputEl.removeEventListener('keydown', stored.handleKeyDown, true); stored.inputEl.removeEventListener('paste', stored.handlePaste); stored.containerEl.removeEventListener('click', stored.handleContainerClick); + stored.guard.dispose(); instances.delete(instanceId); } diff --git a/src/BlazorBlueprint.Components/wwwroot/js/text-input.js b/src/BlazorBlueprint.Components/wwwroot/js/text-input.js index e719de6ef..bb2c3aab4 100644 --- a/src/BlazorBlueprint.Components/wwwroot/js/text-input.js +++ b/src/BlazorBlueprint.Components/wwwroot/js/text-input.js @@ -7,8 +7,13 @@ * For inputs this fires on blur and Enter; for textareas it fires on blur only. * - immediate: JS batches calls via requestAnimationFrame. * - debounced: JS debounces calls via setTimeout. + * + * All modes hold interop back while an IME is composing, then flush once on + * compositionend. See composition-guard.js for why. */ +import { createCompositionGuard } from './composition-guard.js'; + const instances = new Map(); /** @@ -78,12 +83,19 @@ export function initialize(element, dotNetRef, instanceId, config) { const handleInput = () => { const value = element.value; + // The counter tracks the element, not the bound value, so it stays live while composing. updateCharacterCount(); if (config.mode === 'onchange') { return; } + // Interop here would write the bound value back mid-composition; guard.onFlush + // re-runs this once the IME commits. + if (guard.isComposing) { + return; + } + if (config.mode === 'immediate') { if (state.rafId) { cancelAnimationFrame(state.rafId); @@ -112,6 +124,8 @@ export function initialize(element, dotNetRef, instanceId, config) { callOnChange(element.value); }; + const guard = createCompositionGuard(element, { onFlush: handleInput }); + element.addEventListener('input', handleInput); element.addEventListener('change', handleChange); @@ -119,6 +133,7 @@ export function initialize(element, dotNetRef, instanceId, config) { state, handleInput, handleChange, + guard, element }; @@ -178,6 +193,7 @@ export function dispose(instanceId) { stored.element.removeEventListener('input', stored.handleInput); stored.element.removeEventListener('change', stored.handleChange); + stored.guard.dispose(); if (stored.handleBlur) { stored.element.removeEventListener('blur', stored.handleBlur); From 77b1507d98586fc0f7d92baa5d3d616954575f75 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Thu, 16 Jul 2026 12:46:48 +0800 Subject: [PATCH 147/188] docs(changelog): add 2026-07-16 IME composition guard entry (#415) --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d4b9fe9b..33a9922e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## 2026-07-16 + +### Fixed + +- **IME composition was corrupted across every input that updates per keystroke** — Nothing in the library tracked composition, so typing `안녕` with a Korean IME produced `ㅇ안안ㄴ녕`. Assigning `element.value` mid-composition resets the IME's composition buffer *even when the assigned string matches what the element already holds*, and Blazor's renderer assigns `element.value` whenever a bound field changes — diffing against the previous render tree, never reading the DOM. So any per-keystroke C# round-trip corrupted composition. `UpdateTiming.OnChange`/`Debounced` escaped it only by making no interop call during typing. Affects `BbInput`, `BbTextarea`, `BbInputField`, `BbInputGroupInput`/`Textarea`, `BbNumericInput`, `BbCurrencyInput`, `BbTagInput`, `BbMarkdownEditor`, `BbMaskedInput`, `BbCombobox`/`BbFormFieldCombobox`, and `BbMultiSelect`/`BbFormFieldMultiSelect`. A new shared `composition-guard.js` holds interop back while composing and flushes once on `compositionend`; an interrupted composition resets on blur rather than wedging the field. `BbDatePickerInput` (binds `@onchange`) and `BbRichTextEditor` (Quill owns its DOM) were never affected. ([#415](https://github.com/blazorblueprintui/ui/pull/415)) +- **Enter, Space and Arrow keys were hijacked from the IME** — Separate from the value corruption above, and unfixed by it: Enter commits a composition and Space/Arrows drive the candidate list, but handlers bound to those keys fired while the IME still owned the keystroke. `BbCombobox` selected the focused item and closed the popover on the Enter that merely confirmed a syllable; `BbTagInput` committed a half-composed tag; `BbMarkdownEditor` continued a list; and `BbMultiSelect` was worst — a capture-phase handler with an unconditional `preventDefault` that **blocked the IME commit outright**, with Space (the Japanese conversion key) toggling a checkbox instead. All key handling now stands down while composing. ([#415](https://github.com/blazorblueprintui/ui/pull/415)) +- **BbNumericInput / BbCurrencyInput: full-width digits were silently deleted** — Input sanitizing tested digits with an ASCII range check (`ch >= '0' && ch <= '9'`), and full-width digits (`1` = U+FF11) sort above `'9'`, so a Japanese IME in 全角 mode had legitimate numeric input erased keystroke by keystroke. Reproduces without any IME composition involved. Full-width digits, decimal point and minus now fold to their ASCII equivalents (`123` → `123`, `-12.5` → `-12.5`); genuine garbage is still stripped. ([#415](https://github.com/blazorblueprintui/ui/pull/415)) + +### Changed + +- **BbMaskedInput: the mask is applied when the IME commits, not per keystroke** — Masking rewrites the field, which is precisely what resets a composition buffer, so a guard that skips the write is not enough — the mask itself has to wait. Raw text now sits unmasked while the IME composes and is masked once on commit. `'A'` and `'*'` mask positions accept CJK (both map to `char.IsLetter`/`char.IsLetterOrDigit`, and CJK characters are Unicode category `Lo`), so this is reachable with masks like `AAA-9999`. ASCII typing is unchanged and still masks on every keystroke. ([#415](https://github.com/blazorblueprintui/ui/pull/415)) + +--- + ## 2026-07-15 ### Added From d25c115599c1c383ac88901b21263a9c7ec7766a Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Thu, 16 Jul 2026 14:51:05 +0800 Subject: [PATCH 148/188] chore: bump BlazorBlueprint.Primitives to 3.14.1 --- .../BlazorBlueprint.Components.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj index ff2ce419a..d94e89a86 100644 --- a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj +++ b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj @@ -58,7 +58,7 @@ - + From 8442bd59c5b8d1c91b911dcdd7e435ec3ad01372 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Thu, 16 Jul 2026 14:53:00 +0800 Subject: [PATCH 149/188] docs: release notes for Components v3.14.1 --- .../RELEASE_NOTES.md | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/BlazorBlueprint.Components/RELEASE_NOTES.md b/src/BlazorBlueprint.Components/RELEASE_NOTES.md index 93cb20795..9fb9b979b 100644 --- a/src/BlazorBlueprint.Components/RELEASE_NOTES.md +++ b/src/BlazorBlueprint.Components/RELEASE_NOTES.md @@ -1,18 +1,15 @@ -## What's New in v3.14.0 - -### New Features -- **BbDataGrid** — runtime grouping via the new `Groupable` parameter on property and template columns; a header ellipsis menu offers "Group by" / "Remove grouping", also available imperatively through `GroupByColumnAsync(columnId, direction)` and `ClearGroupingAsync()`. -- **BbDataGrid** — public `RefreshDataAsync()` to re-process the current data after in-place collection mutations, mirroring QuickGrid. -- **BbDataGrid** — `INotifyCollectionChanged` support: an `ObservableCollection` passed to `Items` refreshes the grid automatically, with subscriptions cleaned up on dispose and source swap. -- **BbSidebarProvider** — new `EnableToggleShortcut` parameter (default `true`) to opt out of the Ctrl/Cmd+B sidebar shortcut so the keys can reach the page (e.g. a rich-text editor's bold command); reactive after first render. +## What's New in v3.14.1 ### Bug Fixes -- **BbDataGrid** — grouping set programmatically through the state object now actually applies and round-trips through `Save()`/`Restore()`; previously only markup-configured grouping took effect while the state reported a grouping the grid ignored. -- **BbDataGrid** — group definitions targeting a column that registers later no longer silently resolve to nothing, and `Reset()` now genuinely clears grouping applied via the `GroupBy` parameter. -- **Theming** — the applied theme (dark mode class, base/primary color attributes, radius) is now restored when Blazor's enhanced page refresh re-merges the document and strips it from `` (e.g. on every `dotnet watch` hot reload); the dark-mode toggle no longer needs two clicks afterwards. -- **CSS** — border color utilities (`border-primary`, `border-alert-*/30`, and consumer `.border-*` classes) are no longer flattened to the default border color; the global border reset moved from the `bb` cascade layer to `base` so utilities win again. -- **BbSidebar** — multiple `BbSidebarProvider` instances on one page now each receive the toggle shortcut and mobile-change notifications; previously module-level JS state meant only the last-initialized provider responded and disposal leaked listeners. +- **BbInput**, **BbTextarea**, **BbInputField**, **BbInputGroupInput**, **BbInputGroupTextarea** — IME composition (Korean, Japanese, Chinese) is no longer corrupted by per-keystroke value round-trips; interop is held back while composing and flushed once the IME commits, so typing 안녕 no longer yields ㅇ안안ㄴ녕. +- **BbNumericInput**, **BbCurrencyInput** — input sanitization no longer resets the composition buffer mid-composition, and Arrow/Home/End keys reach the IME candidate list instead of stepping the value. +- **BbNumericInput**, **BbCurrencyInput** — full-width digits and separators (`0`-`9`, `.`, `,`, `-`) emitted by a Japanese IME in 全角 mode are folded to their ASCII equivalents instead of being stripped as invalid input. +- **BbTagInput** — Enter, delimiter, Arrow, and Escape keys no longer commit a half-composed tag or fight the IME candidate window while composing. +- **BbMultiSelect** — Space, Enter, Arrow, and Escape now reach the IME during composition rather than being swallowed by the option-navigation handler. +- **BbCommandInput** — Enter and Arrow keys no longer select a list item while the IME still owns the keystroke, and the display value is no longer written back mid-composition. +- **BbMaskedInput** — mask application is deferred until the IME commits, so masking no longer resets an in-progress composition. +- **BbMarkdownEditor** — the Enter that commits a composition no longer continues a list, and undo snapshots are no longer taken per composition step, so Ctrl+Z walks back through words rather than individual jamo. ### Improvements -- **BbDataGrid** — grouping combined with `Virtualize` + `ItemsProvider` (unsupported) now hides the header group action and logs a warning naming the column instead of silently rendering an empty grid. -- Bumped the `BlazorBlueprint.Primitives` dependency to 3.14.0. +- Composition handling is interrupt-safe: a composition abandoned by focus loss (e.g. tab-switch) no longer latches and wedges the field. +- Bumped the `BlazorBlueprint.Primitives` dependency to 3.14.1. From 3944b6c5c200c974e199f91e4973cbfc302cd3dd Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Sun, 19 Jul 2026 11:02:20 +0800 Subject: [PATCH 150/188] =?UTF-8?q?fix(portal):=20recover=20content-only?= =?UTF-8?q?=20updates=20dropped=20in=20Server=20render=E2=86=92ACK=20windo?= =?UTF-8?q?w=20(#418)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Blazor Server, BbCategoryPortalHost dropped portal content-only notifications (RefreshPortal, same portal id) that arrived while _isRendering was latched across the render→ACK round-trip, and the post-render catch-up only re-rendered on portal key-set changes — so a Dialog/Sheet whose owner updated state faster than one SignalR ACK froze on stale content. Regression of the #118/#119 class reintroduced by #122. Record a deferred re-render for content-only drops and flush it through a coalesced, yield-deferred pass: - _pendingRerender recovers the dropped update - _isFlushing suppresses refreshes raised by the flush's own render so a nested same-category portal (e.g. Select-in-Popover) cannot loop - _flushScheduled + Task.Yield run the flush as a fresh dispatcher work item, never a synchronous nested re-render (which stack-overflows on WebAssembly), with a disposed guard on the fire-and-forget continuation Structural (register/unregister) recovery stays synchronous, unchanged. --- CHANGELOG.md | 8 ++ .../Services/BbCategoryPortalHost.razor | 94 +++++++++++++++++-- 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a9922e2..213de3be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## 2026-07-18 + +### Fixed + +- **Blazor Server: Dialog/Sheet inner content froze on fast async updates** — On the Server circuit, a `BbDialog`/`BbSheet` rendered through the default portal could permanently freeze its inner content (e.g. a spinner that never resolved to the loaded view) when an owner-driven state change completed faster than one SignalR render-batch acknowledgement. `BbCategoryPortalHost` guards render re-entrancy with an `_isRendering` flag that stays set from `BuildRenderTree` until `OnAfterRenderAsync`; on Server that span covers a full client→server ACK round-trip. A portal **content-only** notification (`RefreshPortal` — same portal id, as raised by `BbDialogPortal.OnParametersSet` on every owner re-render) that landed inside that window was dropped, and the post-render catch-up only re-rendered when the portal **key set** changed — so a content-only drop was never recovered and the portaled DOM stayed on stale content until some unrelated event forced a render. WebAssembly/prerender were unaffected because there the render batch is applied in-process and the window is only microseconds. This was a regression of the #118/#119 class, reintroduced when [#122](https://github.com/blazorblueprintui/ui/pull/122) replaced the old `_pendingRerender` recovery with a key-set-only check. The host now records a deferred re-render for content-only drops and flushes it through a coalesced, yield-deferred pass — recovering the update without re-entering the render synchronously (a synchronous re-render here stack-overflows on WebAssembly when a nested same-category portal, e.g. a Select inside a Popover, re-raises `RefreshPortal` on every render), and suppressing refreshes raised by the flush's own render so nested overlays cannot loop. ([#418](https://github.com/blazorblueprintui/ui/issues/418)) + +--- + ## 2026-07-16 ### Fixed diff --git a/src/BlazorBlueprint.Primitives/Services/BbCategoryPortalHost.razor b/src/BlazorBlueprint.Primitives/Services/BbCategoryPortalHost.razor index 431c497b9..1fb802f2d 100644 --- a/src/BlazorBlueprint.Primitives/Services/BbCategoryPortalHost.razor +++ b/src/BlazorBlueprint.Primitives/Services/BbCategoryPortalHost.razor @@ -7,10 +7,12 @@ @* Renders portals for a specific category at the document body level. Only re-renders when its own category's portals change. *@ @{ - // Guard: ignore RefreshPortal calls from nested FloatingPortals during our render. - // Without this, a FloatingPortal inside another portal (e.g., Combobox in Dialog) - // would trigger OnPortalsCategoryChanged during our render, scheduling another render, - // creating an infinite async loop. + // Open the render cycle. While _isRendering is set, RefreshPortal notifications are not + // acted on immediately (see HandleCategoryChanged) — a nested FloatingPortal inside our + // content (e.g. a Select in a Dialog or Popover) raises RefreshPortal from its + // OnParametersSet during this render, and re-rendering in response would loop. The guard + // stays set until OnAfterRenderAsync, which recovers any notification deferred in the + // meantime (including content-only updates that land in the Blazor Server ACK-wait window). _isRendering = true; // Track which portals are being rendered in this cycle _renderedThisCycle.Clear(); @@ -33,7 +35,11 @@ private HashSet _renderedThisCycle = new(); private IJSObjectReference? _portalModule; private bool _isRendering; + private bool _pendingRerender; + private bool _isFlushing; + private bool _flushScheduled; private bool _isFirstCategoryHost; + private bool _disposed; protected override void OnInitialized() { @@ -62,12 +68,27 @@ if (_isRendering) { - // During rendering, a nested FloatingPortal's OnParametersSet calls - // RefreshPortal, which fires this callback. Scheduling a re-render here - // would create an infinite loop. RefreshPortal is always redundant during - // rendering because the host is already rendering all portal content. - // Structural changes (register/unregister from OnAfterRenderAsync of nested - // components) are detected after the render cycle completes. + // A notification arrived while our render cycle is still open. The cycle stays + // open from BuildRenderTree until OnAfterRenderAsync, and on Blazor Server that + // span includes the wait for the client to acknowledge the render batch — a full + // network round-trip. Two very different notifications land in this window: + // + // 1. Reentrant RefreshPortal calls from nested portals' OnParametersSet, raised + // synchronously while we render their content (e.g. a Select inside a Dialog + // or Popover). These are redundant — we are already drawing current content. + // 2. Genuine content-only updates from an owner component whose fast async + // handler completed inside the ACK-wait window (issue #418). These must be + // re-rendered or the portaled DOM freezes on stale content. + // + // We cannot render immediately (that would re-enter the current render), so we mark + // a deferred re-render and reconcile it in OnAfterRenderAsync, which recovers case 2. + // To keep case 1 from looping, we do NOT arm the flag while a flush re-render is in + // progress: a nested same-category portal re-raises RefreshPortal on every render, so + // a flush-provoked notification must not schedule yet another flush. + if (!_isFlushing) + { + _pendingRerender = true; + } return; } @@ -115,14 +136,67 @@ .Select(kvp => kvp.Key) .ToHashSet(); + // Structural changes (register/unregister) are flushed synchronously here, exactly as + // before — this path is bounded because the portal set converges quickly. if (!_renderedThisCycle.SetEquals(currentPortalKeys)) { await InvokeAsync(StateHasChanged); } + + // Content-only recovery for the Server ACK-wait drop (issue #418) is flushed via a + // deferred, single-flight re-render — never a synchronous one. On WebAssembly + // OnAfterRender runs inline at the end of the render, so re-rendering synchronously here + // would recurse until the stack overflows whenever a nested portal (e.g. a Select in a + // Popover) re-raises RefreshPortal on every render. Yielding first breaks that chain. + if (_pendingRerender) + { + _pendingRerender = false; + ScheduleContentFlush(); + } + } + + /// + /// Schedules a single deferred re-render to pick up a content-only portal update that was + /// dropped during the render cycle (see ). Coalesced via + /// so concurrent notifications collapse into one re-render, and + /// gated by so the re-render's own reentrant RefreshPortal calls do + /// not schedule another flush. + /// + private void ScheduleContentFlush() + { + if (_flushScheduled) + { + return; + } + + _flushScheduled = true; + _ = InvokeAsync(async () => + { + // Run as a fresh dispatcher work item rather than nested in the render that + // scheduled us. + await Task.Yield(); + if (_disposed) + { + return; + } + + _isFlushing = true; + try + { + StateHasChanged(); + } + finally + { + _isFlushing = false; + _flushScheduled = false; + } + }); } public async ValueTask DisposeAsync() { + _disposed = true; + if (_isFirstCategoryHost) { PortalService.UnregisterHost(); From 3e2fadffe36c6c6972f6d6858abb50117843037f Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Sun, 19 Jul 2026 11:03:17 +0800 Subject: [PATCH 151/188] docs(changelog): link PR #419 for the #418 portal fix --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 213de3be4..ba03d0ab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed -- **Blazor Server: Dialog/Sheet inner content froze on fast async updates** — On the Server circuit, a `BbDialog`/`BbSheet` rendered through the default portal could permanently freeze its inner content (e.g. a spinner that never resolved to the loaded view) when an owner-driven state change completed faster than one SignalR render-batch acknowledgement. `BbCategoryPortalHost` guards render re-entrancy with an `_isRendering` flag that stays set from `BuildRenderTree` until `OnAfterRenderAsync`; on Server that span covers a full client→server ACK round-trip. A portal **content-only** notification (`RefreshPortal` — same portal id, as raised by `BbDialogPortal.OnParametersSet` on every owner re-render) that landed inside that window was dropped, and the post-render catch-up only re-rendered when the portal **key set** changed — so a content-only drop was never recovered and the portaled DOM stayed on stale content until some unrelated event forced a render. WebAssembly/prerender were unaffected because there the render batch is applied in-process and the window is only microseconds. This was a regression of the #118/#119 class, reintroduced when [#122](https://github.com/blazorblueprintui/ui/pull/122) replaced the old `_pendingRerender` recovery with a key-set-only check. The host now records a deferred re-render for content-only drops and flushes it through a coalesced, yield-deferred pass — recovering the update without re-entering the render synchronously (a synchronous re-render here stack-overflows on WebAssembly when a nested same-category portal, e.g. a Select inside a Popover, re-raises `RefreshPortal` on every render), and suppressing refreshes raised by the flush's own render so nested overlays cannot loop. ([#418](https://github.com/blazorblueprintui/ui/issues/418)) +- **Blazor Server: Dialog/Sheet inner content froze on fast async updates** — On the Server circuit, a `BbDialog`/`BbSheet` rendered through the default portal could permanently freeze its inner content (e.g. a spinner that never resolved to the loaded view) when an owner-driven state change completed faster than one SignalR render-batch acknowledgement. `BbCategoryPortalHost` guards render re-entrancy with an `_isRendering` flag that stays set from `BuildRenderTree` until `OnAfterRenderAsync`; on Server that span covers a full client→server ACK round-trip. A portal **content-only** notification (`RefreshPortal` — same portal id, as raised by `BbDialogPortal.OnParametersSet` on every owner re-render) that landed inside that window was dropped, and the post-render catch-up only re-rendered when the portal **key set** changed — so a content-only drop was never recovered and the portaled DOM stayed on stale content until some unrelated event forced a render. WebAssembly/prerender were unaffected because there the render batch is applied in-process and the window is only microseconds. This was a regression of the #118/#119 class, reintroduced when [#122](https://github.com/blazorblueprintui/ui/pull/122) replaced the old `_pendingRerender` recovery with a key-set-only check. The host now records a deferred re-render for content-only drops and flushes it through a coalesced, yield-deferred pass — recovering the update without re-entering the render synchronously (a synchronous re-render here stack-overflows on WebAssembly when a nested same-category portal, e.g. a Select inside a Popover, re-raises `RefreshPortal` on every render), and suppressing refreshes raised by the flush's own render so nested overlays cannot loop. ([#418](https://github.com/blazorblueprintui/ui/issues/418), [#419](https://github.com/blazorblueprintui/ui/pull/419)) --- From 4a9a661efa45c7c1fe6989619fa02a0b3b4c1157 Mon Sep 17 00:00:00 2001 From: Jiri Beloch <749071+whis@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:17:29 +0200 Subject: [PATCH 152/188] Add mouse wheel support for numeric input adjustments --- .../wwwroot/js/numeric-input.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/BlazorBlueprint.Components/wwwroot/js/numeric-input.js b/src/BlazorBlueprint.Components/wwwroot/js/numeric-input.js index e443f890c..8ead24566 100644 --- a/src/BlazorBlueprint.Components/wwwroot/js/numeric-input.js +++ b/src/BlazorBlueprint.Components/wwwroot/js/numeric-input.js @@ -7,6 +7,7 @@ * - JsOnBlur(value) — called on blur (always) * - JsOnFocus() — called on focus (always) * - JsOnKeyDown(key) — called for step keys (ArrowUp/Down, PageUp/Down, Home/End) + * - Mouse wheel on focused input is mapped to ArrowUp/Down in the same key callback. * * Sanitization and interop are held back while an IME is composing, then flushed once on * compositionend. See composition-guard.js for why. @@ -154,12 +155,24 @@ export function initialize(element, dotNetRef, instanceId, config) { } }; + const handleWheel = (e) => { + if (document.activeElement !== element || e.deltaY === 0) { + return; + } + + e.preventDefault(); + + const key = e.deltaY < 0 ? 'ArrowUp' : 'ArrowDown'; + dotNetRef.invokeMethodAsync('JsOnKeyDown', key).catch(() => {}); + }; + const guard = createCompositionGuard(element, { onFlush: handleInput }); element.addEventListener('input', handleInput); element.addEventListener('blur', handleBlur); element.addEventListener('focus', handleFocus); element.addEventListener('keydown', handleKeyDown); + element.addEventListener('wheel', handleWheel, { passive: false }); instances.set(instanceId, { state, @@ -167,6 +180,7 @@ export function initialize(element, dotNetRef, instanceId, config) { handleBlur, handleFocus, handleKeyDown, + handleWheel, guard, element }); @@ -198,6 +212,7 @@ export function dispose(instanceId) { stored.element.removeEventListener('blur', stored.handleBlur); stored.element.removeEventListener('focus', stored.handleFocus); stored.element.removeEventListener('keydown', stored.handleKeyDown); + stored.element.removeEventListener('wheel', stored.handleWheel); stored.guard.dispose(); if (stored.state.debounceTimer) { From 439a49679c8d46c686e3abe21cc96bb71cd7e84c Mon Sep 17 00:00:00 2001 From: David Ball Date: Mon, 20 Jul 2026 07:21:49 +0100 Subject: [PATCH 153/188] fix(deps): resolve AngleSharp vulnerability (GHSA-pgww-w46g-26qg) HtmlSanitizer 9.0.892 hard-pins AngleSharp to exactly [0.17.1], which nuget audit flags as vulnerable (GHSA-pgww-w46g-26qg, Moderate). The exact pin means the transitive AngleSharp cannot be overridden with a direct PackageReference. Bump HtmlSanitizer to 9.1.949-beta, whose dependency chain pins AngleSharp 1.5.1 (past the 1.0.0 fix). The Sanitize API used by BbRichTextEditor and BbMarkdownEditor is unchanged. --- .../BlazorBlueprint.Components.csproj | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj index d94e89a86..a154c7a3a 100644 --- a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj +++ b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj @@ -31,7 +31,10 @@ - + + From ab84a612f7b70070ad8d0351ac8e849585da097a Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Mon, 20 Jul 2026 21:09:33 +0800 Subject: [PATCH 154/188] docs(changelog): record the HtmlSanitizer bump for the AngleSharp advisory (#422) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba03d0ab7..bc3b1676b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## 2026-07-20 + +### Fixed + +- **AngleSharp vulnerability inherited through HtmlSanitizer (GHSA-pgww-w46g-26qg)** — Assemblies referencing `BlazorBlueprint.Components` with NuGet audit enabled had begun to fail their builds. `HtmlSanitizer` 9.0.892 hard-pins `AngleSharp` to exactly `[0.17.1]` (and `AngleSharp.Css` to `[0.17.0]`), which the advisory flags as Moderate. Because the pin is exact, the transitive `AngleSharp` cannot be lifted out with a direct `PackageReference` — attempting it raises `NU1608` against both `HtmlSanitizer` and `AngleSharp.Css`, and leaves 9.0.892 running against an `AngleSharp` major version it was never compiled for. 9.0.892 is also the newest release on the stable line, so no stable version resolves the advisory. `HtmlSanitizer` is therefore bumped to **9.1.949-beta**, whose dependency chain resolves `AngleSharp` 1.5.1 — past the 1.0.0 fix — clearing the advisory. The library's only use of the package is `new HtmlSanitizer()` and `Sanitize(string)` in `BbRichTextEditor` and `BbMarkdownEditor`; sanitizer output was compared across 41 inputs (XSS vectors, Quill rich-text markup, Markdig output, malformed and non-ASCII HTML) and is byte-identical between the two versions, so no behavioural change is expected. Note that this makes `HtmlSanitizer` — and `AngleSharp.Css` 1.0.0-beta.216 — a **prerelease** transitive dependency of Components; it will be moved back to the 9.1.x stable line as soon as one is published. ([#422](https://github.com/blazorblueprintui/ui/pull/422)) + +--- + ## 2026-07-18 ### Fixed From 8acf6f2d61eff305eebc9a341203cef9c8364034 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Mon, 20 Jul 2026 21:12:46 +0800 Subject: [PATCH 155/188] docs(changelog): add BbFileUpload Id and attribute splatting (#406); link #426 --- CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc3b1676b..2b1ceae4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed -- **AngleSharp vulnerability inherited through HtmlSanitizer (GHSA-pgww-w46g-26qg)** — Assemblies referencing `BlazorBlueprint.Components` with NuGet audit enabled had begun to fail their builds. `HtmlSanitizer` 9.0.892 hard-pins `AngleSharp` to exactly `[0.17.1]` (and `AngleSharp.Css` to `[0.17.0]`), which the advisory flags as Moderate. Because the pin is exact, the transitive `AngleSharp` cannot be lifted out with a direct `PackageReference` — attempting it raises `NU1608` against both `HtmlSanitizer` and `AngleSharp.Css`, and leaves 9.0.892 running against an `AngleSharp` major version it was never compiled for. 9.0.892 is also the newest release on the stable line, so no stable version resolves the advisory. `HtmlSanitizer` is therefore bumped to **9.1.949-beta**, whose dependency chain resolves `AngleSharp` 1.5.1 — past the 1.0.0 fix — clearing the advisory. The library's only use of the package is `new HtmlSanitizer()` and `Sanitize(string)` in `BbRichTextEditor` and `BbMarkdownEditor`; sanitizer output was compared across 41 inputs (XSS vectors, Quill rich-text markup, Markdig output, malformed and non-ASCII HTML) and is byte-identical between the two versions, so no behavioural change is expected. Note that this makes `HtmlSanitizer` — and `AngleSharp.Css` 1.0.0-beta.216 — a **prerelease** transitive dependency of Components; it will be moved back to the 9.1.x stable line as soon as one is published. ([#422](https://github.com/blazorblueprintui/ui/pull/422)) +- **AngleSharp vulnerability inherited through HtmlSanitizer (GHSA-pgww-w46g-26qg)** — Assemblies referencing `BlazorBlueprint.Components` with NuGet audit enabled had begun to fail their builds. `HtmlSanitizer` 9.0.892 hard-pins `AngleSharp` to exactly `[0.17.1]` (and `AngleSharp.Css` to `[0.17.0]`), which the advisory flags as Moderate. Because the pin is exact, the transitive `AngleSharp` cannot be lifted out with a direct `PackageReference` — attempting it raises `NU1608` against both `HtmlSanitizer` and `AngleSharp.Css`, and leaves 9.0.892 running against an `AngleSharp` major version it was never compiled for. 9.0.892 is also the newest release on the stable line, so no stable version resolves the advisory. `HtmlSanitizer` is therefore bumped to **9.1.949-beta**, whose dependency chain resolves `AngleSharp` 1.5.1 — past the 1.0.0 fix — clearing the advisory. The library's only use of the package is `new HtmlSanitizer()` and `Sanitize(string)` in `BbRichTextEditor` and `BbMarkdownEditor`; sanitizer output was compared across 41 inputs (XSS vectors, Quill rich-text markup, Markdig output, malformed and non-ASCII HTML) and is byte-identical between the two versions, so no behavioural change is expected. Note that this makes `HtmlSanitizer` — and `AngleSharp.Css` 1.0.0-beta.216 — a **prerelease** transitive dependency of Components; it will be moved back to the 9.1.x stable line as soon as one is published, tracked in [#426](https://github.com/blazorblueprintui/ui/issues/426). ([#422](https://github.com/blazorblueprintui/ui/pull/422)) + +--- + +## 2026-07-19 + +### Added + +- **BbFileUpload: `Id`** — The underlying `
    + + Name +
    + + + @* Sorting and the Groupable ellipsis menu are still rendered by the grid *@ + + +
    + + Department +
    +
    +
    + @* Icon-only header — keep Title set for the column chooser, sr-only text for screen readers *@ + + + + Email + + + + +
    + + Salary +
    +
    +
    + + diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor index 34266f924..621353217 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor @@ -1097,6 +1097,9 @@ Custom template for rendering the cell content. Overrides the default value display. + + Custom header content, e.g. an icon next to (or instead of) the title. It replaces the title text only — the grid still renders the sort indicator, filter icon, pin icon, column menu and resize handle around it, so Sortable and Groupable columns keep every affordance. Keep Title set for icon-only headers, since the column chooser and column menu use it, and include sr-only text in the template — the header cell is announced from its own content. + Additional CSS classes applied to cells in this column. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridStylingDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridStylingDemo.razor index 265b59bea..4a1767d02 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridStylingDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridStylingDemo.razor @@ -96,6 +96,60 @@
    + +
    +
    +

    Property Column Header Template

    +

    + BbDataGridPropertyColumn also accepts a + HeaderTemplate, so you can put an icon or richer + markup in the header without giving up type-safe binding. The template replaces the title + text only — the grid keeps rendering its own sort indicator, filter icon and column menu + around it, so the Department column below still sorts and still offers "Group by". Keep + Title meaningful for icon-only headers — it is what + the column chooser and the column menu display — and add + sr-only text inside the template, since the header + cell is announced from its own content. +

    +
    + + + + +
    + + Name +
    +
    +
    + + +
    + + Department +
    +
    +
    + + + + Email + + + + +
    + + Salary +
    +
    +
    +
    +
    + +
    +
    diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs index b0545ebdf..610da3cfd 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs @@ -164,6 +164,19 @@ public partial class BbDataGridPropertyColumn : ComponentBase, IDa [Parameter] public RenderFragment? CellTemplate { get; set; } + /// + /// Custom header content. If provided, replaces the header title text only — the grid + /// still renders its own sort indicator, filter icon, pin icon, column menu and resize + /// handle around it, so a or column keeps + /// every affordance. Use it to show an icon or richer markup instead of + /// . Set as well when the content is icon-only, so + /// the column chooser and the column menu still have readable text, and include screen-reader + /// text (e.g. a sr-only span) in the template — the header cell is announced from its + /// own content. + /// + [Parameter] + public RenderFragment? HeaderTemplate { get; set; } + /// /// The parent DataGrid component. Set via cascading parameter. /// @@ -199,7 +212,10 @@ public partial class BbDataGridPropertyColumn : ComponentBase, IDa ? context => CellTemplate(context.Item) : null; - RenderFragment>? IDataGridColumn.HeaderTemplate => null; + RenderFragment>? IDataGridColumn.HeaderTemplate => + HeaderTemplate != null + ? _ => HeaderTemplate + : null; string? IDataGridColumn.CellClass => CellClass; diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 1c04d7172..c5c34e180 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -860,6 +860,7 @@ - Format : String - Groupable : Boolean - HeaderClass : String + - HeaderTemplate : RenderFragment - Hideable : Boolean - Id : String - NoWrap : Boolean From d362492bb62c06af4c1ae545bcd83a72e0963081 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Tue, 21 Jul 2026 10:15:55 +0800 Subject: [PATCH 157/188] fix(tooltip): warn when an AsChild trigger's child ignores TriggerContext BbTooltipTrigger.AsChild defaults to true in the Components layer, and in that mode the trigger renders no element and no handlers at all - only a cascading TriggerContext that the child is expected to consume and wire the hover/focus behaviour up from. BbButton does that; plain markup, text and a bare LucideIcon do not, so those produced a trigger with nothing listening for hover and a tooltip that could never open, with no exception, no console error and no visual clue. The trigger now reports it: on first render, an AsChild trigger whose context was never touched logs an ILogger warning naming both ways out. Consumption is recorded by TriggerContext itself - reading any member marks it, which covers every child that applies the id or aria attributes as it renders - plus a public NotifyConsumed() for a custom child that only touches the context inside event handlers. The warning is gated on the host reporting the Development environment, resolved once by name from whichever environment abstraction the render mode registers. The AsChild default is deliberately unchanged; flipping it would add a wrapping span to every existing correct usage and is held for the next major. Documents the contract on the AsChild xmldoc in both layers, and adds a demo section covering the plain-content/icon case that this trap catches. Refs #425 --- CHANGELOG.md | 8 ++ .../Components/Tooltip/plain-content.txt | 32 ++++++++ .../Pages/Components/TooltipDemo.razor | 59 ++++++++++++++ .../Components/Tooltip/BbTooltipTrigger.razor | 16 +++- .../Primitives/Tooltip/BbTooltipTrigger.razor | 62 ++++++++++++++- .../Utilities/DevelopmentEnvironment.cs | 73 +++++++++++++++++ .../Utilities/TriggerContext.cs | 78 +++++++++++++++---- 7 files changed, 308 insertions(+), 20 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Tooltip/plain-content.txt create mode 100644 src/BlazorBlueprint.Primitives/Utilities/DevelopmentEnvironment.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b1ceae4c..aae322cb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## 2026-07-21 + +### Fixed + +- **BbTooltipTrigger: an `AsChild` child that ignores the trigger context now says so** — `BbTooltipTrigger.AsChild` defaults to `true` in the Components layer, and in that mode the trigger renders *no element and no handlers at all* — only a cascading `TriggerContext` that the child is expected to consume and wire the hover/focus behaviour up from. `BbButton` does exactly that, which is why the documented `` composition works. Anything that does not — plain markup, text, or a bare ``, which is the natural thing to reach for in a table cell or beside a field label — produced a trigger with nothing listening for hover, so the tooltip could never open. No exception, no console error, no visual clue: the markup looked right and simply did nothing, and every example on the demo page either set `AsChild="false"` or wrapped a `BbButton`, so nothing on the page contradicted it. The trigger now reports the case: on first render, an `AsChild` trigger whose context was never touched by any child logs a warning through `ILogger` naming both ways out (`AsChild="false"`, or a child that consumes the context). Consumption is recorded by the context itself — reading any of its members marks it, which covers every child that applies the id or aria attributes as it renders, and a custom child that only touches the context inside event handlers can call the new `TriggerContext.NotifyConsumed()` to acknowledge it — so legitimate compositions, including a tooltip trigger nested inside a dialog or popover trigger, stay silent. The warning is gated on the host application reporting the `Development` environment (resolved once, by name, from whichever environment abstraction the render mode registers), so it costs a cached boolean read in production and never reaches anyone's telemetry. The default is deliberately left at `true` for now: flipping it would add a wrapping `span` to every existing correct usage, which is a breaking change held for the next major. ([#425](https://github.com/blazorblueprintui/ui/issues/425)) + +--- + ## 2026-07-20 ### Fixed diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Tooltip/plain-content.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Tooltip/plain-content.txt new file mode 100644 index 000000000..a73cead6a --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Tooltip/plain-content.txt @@ -0,0 +1,32 @@ +@* Plain content does not consume TriggerContext, so the trigger + has to render its own element: AsChild="false". *@ + + + + + + Remote — no desk assigned + + + + + + 82% used + + + 410 GB of 500 GB + + + +@* The default, AsChild="true", is for a child that consumes + TriggerContext and wires the hover/focus behaviour up itself. *@ + + + + + + + + Additional information + + diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/TooltipDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/TooltipDemo.razor index 6cd23769e..1bd9c7554 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/TooltipDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/TooltipDemo.razor @@ -64,6 +64,59 @@
    + +
    +
    +

    Plain Content and Icons

    +

    + AsChild defaults to + true, which renders no element of its own — + the child is handed a TriggerContext and is + expected to attach the hover and focus behaviour itself. Anything that does not consume that context — + text, plain markup, or a bare LucideIcon — needs + AsChild="false" so the trigger renders its own + span with the handlers attached. +

    +
    + +
    +
    + Works from home + + + + + + Remote — no desk assigned + + +
    + +
    + Storage + + + 82% used + + + 410 GB of 500 GB + + +
    +
    + + + +
    + Which mode do I want? Reach for the default + AsChild="true" when the child is a component built to act as a trigger — + BbButton is the common one — because it keeps the DOM flat and lets the child own its own + focus ring, aria attributes and styling. Use AsChild="false" for everything else. Getting it + wrong used to do nothing at all; a trigger whose child never consumes the context now logs a warning while + the app runs in the Development environment. +
    +
    +
    @@ -383,6 +436,12 @@ + + When true, the trigger renders no element of its own and the child must consume the cascaded + TriggerContext to wire up hover and focus — BbButton does. Set false for + plain markup, text or a bare icon, so the trigger renders its own span with the + handlers attached. + Whether to add tabindex="0" for keyboard focus. Set false if child is already focusable. diff --git a/src/BlazorBlueprint.Components/Components/Tooltip/BbTooltipTrigger.razor b/src/BlazorBlueprint.Components/Components/Tooltip/BbTooltipTrigger.razor index 6ade72e4d..afb6248cd 100644 --- a/src/BlazorBlueprint.Components/Components/Tooltip/BbTooltipTrigger.razor +++ b/src/BlazorBlueprint.Components/Components/Tooltip/BbTooltipTrigger.razor @@ -37,10 +37,20 @@ else public string? Class { get; set; } /// - /// When true, the trigger does not render its own span element. - /// Instead, it passes trigger behavior via TriggerContext to child components. - /// Use this when you want a custom component to act as the trigger. + /// When true (the default), the trigger renders no element of its own. It renders only the + /// cascading TriggerContext, and the child is responsible for consuming that context + /// and wiring the tooltip up — applying the trigger id and aria attributes to its own element, + /// and calling the context's hover, focus and SetTriggerElement callbacks. + /// BbButton does this, which is why it can be dropped straight into a trigger. /// + /// + /// Because no element and no handlers are rendered in this mode, a child that ignores the + /// context leaves nothing listening for hover or focus and the tooltip can never open. Plain + /// markup, text, or a bare icon such as LucideIcon therefore needs + /// AsChild="false", which wraps the content in a span carrying the handlers. + /// An unconsumed context is reported as a warning through ILogger when the app runs in + /// the Development environment, so this no longer fails silently. + /// [Parameter] public bool AsChild { get; set; } = true; diff --git a/src/BlazorBlueprint.Primitives/Primitives/Tooltip/BbTooltipTrigger.razor b/src/BlazorBlueprint.Primitives/Primitives/Tooltip/BbTooltipTrigger.razor index 7c528a359..88525ce7d 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Tooltip/BbTooltipTrigger.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Tooltip/BbTooltipTrigger.razor @@ -1,5 +1,8 @@ @namespace BlazorBlueprint.Primitives.Tooltip @using BlazorBlueprint.Primitives.Utilities +@using Microsoft.Extensions.Logging +@inject ILogger Logger +@inject IServiceProvider Services @* Tooltip trigger - shows tooltip on hover/focus *@ @if (AsChild) @@ -44,10 +47,22 @@ else public RenderFragment? ChildContent { get; set; } /// - /// When true, the trigger does not render its own span element. - /// Instead, it passes trigger behavior via TriggerContext to child components. - /// The child component must consume TriggerContext and apply hover/focus behavior. + /// When true, the trigger renders no element of its own. It renders only the cascading + /// , and the child component is responsible for + /// consuming that context and wiring the tooltip up — applying the trigger id and aria + /// attributes to its own element, and calling OnMouseEnter/OnMouseLeave, + /// OnFocus/OnBlur and SetTriggerElement. /// + /// + /// Because no element and no handlers are rendered in this mode, a child that ignores the + /// context leaves nothing listening for hover or focus and the tooltip can never open. + /// Plain markup, text, or a bare icon (for example LucideIcon) needs + /// AsChild="false" so the trigger renders its own span with the handlers + /// attached. Use AsChild="true" only with a child that consumes + /// , such as a component built for the trigger role. + /// An unconsumed context is reported as a warning through ILogger when the app runs + /// in the Development environment. + /// [Parameter] public bool AsChild { get; set; } = false; @@ -173,5 +188,46 @@ else // propagate this to the context so Content can position relative to it. Context.SetTriggerElement(_asChildTriggerRef.Value); } + + if (firstRender) + { + WarnIfTriggerContextUnconsumed(); + } } + + /// + /// Development-time diagnostic for the silent failure mode of AsChild: the trigger renders + /// no element and no handlers, so a child that never consumes the cascaded TriggerContext + /// leaves the tooltip with nothing to open it — and nothing to indicate why. + /// + /// + /// Consumption is recorded by the context itself, which any read of its members marks (see + /// TriggerContext.NotifyConsumed). Children consume it while they render, which happens + /// within the same render batch, so the answer is settled by the time this runs. Emitted at + /// most once per trigger, and only when the host application reports the Development + /// environment. + /// + private void WarnIfTriggerContextUnconsumed() + { + if (!AsChild || _cachedTriggerContext is null || _cachedTriggerContext.WasConsumed) + { + return; + } + + if (!DevelopmentEnvironment.IsDevelopment(Services)) + { + return; + } + + LogUnconsumedTriggerContext(Logger, null); + } + + private static readonly Action LogUnconsumedTriggerContext = + LoggerMessage.Define(LogLevel.Warning, new EventId(1, "TooltipTriggerContextUnconsumed"), + "TooltipTrigger rendered with AsChild=true, but no child consumed the cascaded TriggerContext. " + + "In this mode the trigger renders no element and no hover/focus handlers, so nothing can open this tooltip. " + + "Set AsChild=\"false\" to have the trigger render its own wrapper element - which is what plain markup, text " + + "or a bare icon such as LucideIcon needs - or use a child that consumes TriggerContext, such as a Button. " + + "A custom child that only touches the context inside event handlers can call TriggerContext.NotifyConsumed() " + + "to acknowledge it. This warning is only emitted in the Development environment."); } diff --git a/src/BlazorBlueprint.Primitives/Utilities/DevelopmentEnvironment.cs b/src/BlazorBlueprint.Primitives/Utilities/DevelopmentEnvironment.cs new file mode 100644 index 000000000..7f5b8c87b --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Utilities/DevelopmentEnvironment.cs @@ -0,0 +1,73 @@ +using System.Reflection; + +namespace BlazorBlueprint.Primitives.Utilities; + +/// +/// Detects whether the host application is running in the Development environment, +/// so diagnostics that only help while building an app can be suppressed everywhere else. +/// +/// +/// The environment abstraction differs per render mode and neither type is referenced by +/// this package: Blazor Server (and any generic host) registers +/// Microsoft.Extensions.Hosting.IHostEnvironment, while Blazor WebAssembly registers +/// Microsoft.AspNetCore.Components.WebAssembly.Hosting.IWebAssemblyHostEnvironment. +/// Both are resolved by name against the app's own service provider rather than taking a +/// package dependency on either. The answer is fixed for the lifetime of the process, so it +/// is resolved once and cached; every subsequent check is a field read. +/// +/// When neither type can be found — a trimmed publish, or a host that registers neither +/// service — the result is false, so diagnostics stay silent. That is the safe +/// direction: a missed development warning is an inconvenience, a warning logged in +/// production is noise in someone else's telemetry. +/// +/// +internal static class DevelopmentEnvironment +{ + private const string DevelopmentEnvironmentName = "Development"; + + private const string HostEnvironmentTypeName = + "Microsoft.Extensions.Hosting.IHostEnvironment, Microsoft.Extensions.Hosting.Abstractions"; + + private const string WebAssemblyHostEnvironmentTypeName = + "Microsoft.AspNetCore.Components.WebAssembly.Hosting.IWebAssemblyHostEnvironment, Microsoft.AspNetCore.Components.WebAssembly"; + + private static bool? cachedIsDevelopment; + + /// + /// Returns true when the host application's environment is "Development". + /// + /// The application's service provider. + public static bool IsDevelopment(IServiceProvider services) + { + if (cachedIsDevelopment.HasValue) + { + return cachedIsDevelopment.Value; + } + + var environmentName = + ReadEnvironmentName(services, HostEnvironmentTypeName, "EnvironmentName") + ?? ReadEnvironmentName(services, WebAssemblyHostEnvironmentTypeName, "Environment"); + + var isDevelopment = string.Equals(environmentName, DevelopmentEnvironmentName, StringComparison.OrdinalIgnoreCase); + cachedIsDevelopment = isDevelopment; + return isDevelopment; + } + + private static string? ReadEnvironmentName(IServiceProvider services, string assemblyQualifiedTypeName, string propertyName) + { + var environmentType = Type.GetType(assemblyQualifiedTypeName, throwOnError: false); + if (environmentType is null) + { + return null; + } + + var environment = services.GetService(environmentType); + if (environment is null) + { + return null; + } + + var property = environmentType.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + return property?.GetValue(environment) as string; + } +} diff --git a/src/BlazorBlueprint.Primitives/Utilities/TriggerContext.cs b/src/BlazorBlueprint.Primitives/Utilities/TriggerContext.cs index d1630f674..6a0014635 100644 --- a/src/BlazorBlueprint.Primitives/Utilities/TriggerContext.cs +++ b/src/BlazorBlueprint.Primitives/Utilities/TriggerContext.cs @@ -12,83 +12,105 @@ namespace BlazorBlueprint.Primitives.Utilities; /// 1. Accept [CascadingParameter(Name = "TriggerContext")] TriggerContext? TriggerContext /// 2. Call TriggerContext?.Toggle() on click /// 3. Apply aria attributes from TriggerContext to their rendered element +/// +/// A trigger rendered with AsChild=true renders no element of its own, so a child that ignores +/// this context leaves the overlay with nothing to open it. Reading any member of this context +/// records that a child consumed it (see ), which lets triggers +/// surface that mistake as a development-time warning instead of failing silently. /// public class TriggerContext { + private readonly string? triggerId; + private readonly bool isOpen; + private readonly Action? toggle; + private readonly Action? open; + private readonly Action? close; + private readonly string? ariaHasPopup; + private readonly string? ariaControls; + private readonly Func? onKeyDown; + private readonly Action? onMouseEnter; + private readonly Action? onMouseLeave; + private readonly Action? onFocus; + private readonly Action? onBlur; + private readonly Action? setTriggerElement; + private readonly bool suppressPointerEventsWhenOpen; + + private bool consumed; + /// /// The unique ID for the trigger element. /// Should be applied as the 'id' attribute on the child element. /// - public string? TriggerId { get; init; } + public string? TriggerId { get => Consume(triggerId); init => triggerId = value; } /// /// Whether the associated overlay (dialog, dropdown, etc.) is currently open. /// Should be used to set aria-expanded attribute. /// - public bool IsOpen { get; init; } + public bool IsOpen { get => Consume(isOpen); init => isOpen = value; } /// /// Action to toggle the associated overlay open/closed. /// Should be invoked on click. /// - public Action? Toggle { get; init; } + public Action? Toggle { get => Consume(toggle); init => toggle = value; } /// /// Action to open the associated overlay. /// Used for hover-triggered components like HoverCard. /// - public Action? Open { get; init; } + public Action? Open { get => Consume(open); init => open = value; } /// /// Action to close the associated overlay. /// Used for hover-triggered components and explicit close. /// - public Action? Close { get; init; } + public Action? Close { get => Consume(close); init => close = value; } /// /// The value for aria-haspopup attribute. /// Common values: "dialog", "menu", "listbox", "true". /// - public string? AriaHasPopup { get; init; } + public string? AriaHasPopup { get => Consume(ariaHasPopup); init => ariaHasPopup = value; } /// /// The ID of the content element that this trigger controls. /// Should be applied as aria-controls attribute. /// - public string? AriaControls { get; init; } + public string? AriaControls { get => Consume(ariaControls); init => ariaControls = value; } /// /// Keyboard event handler for triggers that need keyboard support. /// Used by DropdownMenuTrigger for arrow key navigation. /// - public Func? OnKeyDown { get; init; } + public Func? OnKeyDown { get => Consume(onKeyDown); init => onKeyDown = value; } /// /// Mouse enter handler for hover-triggered components. /// - public Action? OnMouseEnter { get; init; } + public Action? OnMouseEnter { get => Consume(onMouseEnter); init => onMouseEnter = value; } /// /// Mouse leave handler for hover-triggered components. /// - public Action? OnMouseLeave { get; init; } + public Action? OnMouseLeave { get => Consume(onMouseLeave); init => onMouseLeave = value; } /// /// Focus handler for focus-triggered components. /// - public Action? OnFocus { get; init; } + public Action? OnFocus { get => Consume(onFocus); init => onFocus = value; } /// /// Blur handler for focus-triggered components. /// - public Action? OnBlur { get; init; } + public Action? OnBlur { get => Consume(onBlur); init => onBlur = value; } /// /// Action to register the trigger element reference for positioning. /// Child components should call this with their ElementReference after rendering. /// Used by components that need to position content relative to the trigger (DropdownMenu, Popover, etc.). /// - public Action? SetTriggerElement { get; init; } + public Action? SetTriggerElement { get => Consume(setTriggerElement); init => setTriggerElement = value; } /// /// When true, the child trigger element should apply pointer-events: none @@ -97,5 +119,33 @@ public class TriggerContext /// click-outside detection and immediately re-open it via the trigger's click /// handler (Blazor Server can deliver both for one click). /// - public bool SuppressPointerEventsWhenOpen { get; init; } + public bool SuppressPointerEventsWhenOpen { get => Consume(suppressPointerEventsWhenOpen); init => suppressPointerEventsWhenOpen = value; } + + /// + /// Records that a child component has taken responsibility for this trigger context. + /// + /// + /// Reading any member of this context already marks it as consumed, which covers the + /// usual case of a child that applies the id and aria attributes while it renders. + /// Call this explicitly from a custom trigger child that only touches the context from + /// inside event handlers, so it is not mistaken for a child that ignores the context + /// altogether. Calling it more than once is harmless. + /// + public void NotifyConsumed() + { + consumed = true; + } + + /// + /// Whether any child has read from — or explicitly acknowledged — this context. + /// Used by triggers to warn during development about an AsChild child that never + /// wires the trigger behavior up, which would otherwise fail silently. + /// + internal bool WasConsumed => consumed; + + private T Consume(T value) + { + consumed = true; + return value; + } } From fb5d21f2f1bfae0b60d49ea49735f60cadce95d4 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Tue, 21 Jul 2026 10:21:12 +0800 Subject: [PATCH 158/188] fix(datagrid): keep late-registering columns and add explicit column Order (#424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Columns register from their own OnInitialized, so column order was really component-initialisation order. A column produced indirectly — rendered by a wrapper component rather than declared inline — initialises a render pass later than the columns beside it and drifted to the end of the grid on an interactive circuit. Deriving with @inherits was unaffected; only wrapping. Worse, the grid initialised its column state from whichever columns had registered by the first render pass to see any, then latched. Because GetVisibleColumns renders strictly what that state lists, every column arriving after that point was dropped with no header, no cells and no error — a column behind an await disappeared entirely. InitializeColumnState now merges late arrivals into the existing state via a new DataGridColumnState.SyncColumns, which places each new entry next to its neighbours and leaves existing entries' order, width and visibility alone so a user's own reordering survives. BbDataGridPropertyColumn, BbDataGridTemplateColumn and BbDataGridHierarchyColumn gain an Order parameter that positions a column explicitly, as a zero-based index among the data columns. Columns without Order keep their registration order and ordered columns are inserted at their index, so a grid that sets Order nowhere is laid out exactly as before; the select and expand columns keep their fixed leading positions and are not counted by the index. --- CHANGELOG.md | 8 + .../Components/DataGrid/column-order.txt | 18 +++ .../Pages/Components/DataGridDemo.razor | 33 ++++ .../Shared/SalaryColumn.razor | 13 ++ .../Components/DataGrid/BbDataGrid.razor.cs | 146 +++++++++++++++--- .../BbDataGridHierarchyColumn.razor.cs | 29 ++++ .../BbDataGridPropertyColumn.razor.cs | 30 ++++ .../BbDataGridTemplateColumn.razor.cs | 30 ++++ .../DataGrid/DataGridColumnState.cs | 65 ++++++++ .../Primitives/DataGrid/IDataGridColumn.cs | 7 + ...entsApiSurfaceMatchesBaseline.verified.txt | 3 + ...ivesApiSurfaceMatchesBaseline.verified.txt | 1 + 12 files changed, 364 insertions(+), 19 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/column-order.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/Shared/SalaryColumn.razor diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b1ceae4c..61deb8b29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## 2026-07-21 + +### Fixed + +- **BbDataGrid: a column produced indirectly moved to the end, or vanished from the grid entirely** — Columns register themselves with the grid from their own `OnInitialized`, and the grid appended each one to a list, so column order was really *component-initialisation* order. On an interactive circuit that stops matching declaration order the moment a column is produced indirectly: a column rendered by a wrapper component initialises a render pass later than the columns declared beside it, and so drifted to the end of the grid. (Deriving the column from `BbDataGridPropertyColumn` with `@inherits` was never affected — only wrapping.) Worse, the grid initialises its column state — the ordered, per-column visibility record that the header and body are rendered from — from whichever columns had registered by the first render pass to see any, and then latched. Every column arriving after that point was missing from that record and was therefore dropped from the grid: no header, no cells, no console error, no exception. A column behind an `await` — a wrapper that loads lookup data before rendering its inner column, say — reliably disappeared, and prerender and the interactive circuit disagreed about the columns, so the grid visibly reshuffled as the page came alive. Late registrations are now merged into the existing column state instead of ignored, each one placed next to its neighbours rather than appended, so it appears where it belongs; a user's own reordering and visibility choices are left untouched. Alongside that, `BbDataGridPropertyColumn`, `BbDataGridTemplateColumn` and `BbDataGridHierarchyColumn` gain an **`Order`** parameter that positions a column explicitly, as a zero-based index among the data columns, for cases where initialisation order cannot match declaration order. Columns that leave `Order` unset keep their existing registration order and each column that sets it is inserted at that index — so a grid that sets `Order` nowhere is laid out exactly as before — and the select and expand columns keep their fixed leading positions regardless. ([#424](https://github.com/blazorblueprintui/ui/issues/424)) + +--- + ## 2026-07-20 ### Fixed diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/column-order.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/column-order.txt new file mode 100644 index 000000000..c81547c8a --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/column-order.txt @@ -0,0 +1,18 @@ +@* SalaryColumn.razor — a column wrapped in a reusable component *@ + + +@code { + [Parameter] + public int? Order { get; set; } +} + +@* The grid: SalaryColumn is declared third, and Order="2" keeps it there *@ + + + + + + + + diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor index 34266f924..e8a6922cc 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor @@ -418,6 +418,33 @@
    + +
    +
    +

    Explicit Column Order

    +

    + Columns take their position from the order their components initialize in, which for a column + written directly in the grid's markup is the order it was declared. A column produced indirectly + initializes later than the columns around it and would otherwise drift to the end — the + <SalaryColumn /> below is a reusable + component that renders a BbDataGridPropertyColumn + of its own. Set Order on such a column to pin it + to a zero-based index among the data columns. Columns that leave + Order unset keep their declared order around it, + so nothing else needs to change. +

    +
    + + + + + + + + + +
    +
    @@ -1091,6 +1118,9 @@ Whether this column is visible in the grid. + + Explicit position for this column, as a zero-based index among the grid's data columns. When not set, the column keeps its registration order — the order its component initializes in, which for a column written directly in the grid's markup is the order it was declared. Set this on a column produced indirectly, by a wrapper component or by a fragment that only renders after an await, where initialization order does not match declaration order. Columns without Order are laid out first in registration order, then each column that sets it is inserted at that index, lowest value first; an index past the end appends, and columns sharing an Order keep their registration order. Select and expand columns hold their fixed leading positions and are not counted by this index. Read when the column registers with the grid. + Column width (e.g., "200px", "20%", "auto"). @@ -1163,6 +1193,9 @@ Whether this column is visible in the grid. + + Explicit position for this column, as a zero-based index among the grid's data columns. When not set, the column keeps its registration order — the order its component initializes in, which for a column written directly in the grid's markup is the order it was declared. Set this on a column produced indirectly, by a wrapper component or by a fragment that only renders after an await, where initialization order does not match declaration order. Columns without Order are laid out first in registration order, then each column that sets it is inserted at that index, lowest value first; an index past the end appends, and columns sharing an Order keep their registration order. Select and expand columns hold their fixed leading positions and are not counted by this index. Read when the column registers with the grid. + Column width (e.g., "200px", "20%", "auto"). diff --git a/demos/BlazorBlueprint.Demo.Shared/Shared/SalaryColumn.razor b/demos/BlazorBlueprint.Demo.Shared/Shared/SalaryColumn.razor new file mode 100644 index 000000000..c37dede93 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/Shared/SalaryColumn.razor @@ -0,0 +1,13 @@ +@* A DataGrid column wrapped in a reusable component. Because the column is produced by this + component rather than written inline, it registers with the grid after the columns declared + around it, so the consumer pins its position with Order. *@ + + +@code { + /// + /// Zero-based position for the wrapped column among the grid's data columns. + /// + [Parameter] + public int? Order { get; set; } +} diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs index 97bcff283..bec46c514 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs @@ -20,6 +20,11 @@ namespace BlazorBlueprint.Components; public partial class BbDataGrid : ComponentBase, IAsyncDisposable where TData : class { private DataGridState _gridState = new(); + + // Columns in the order their components registered, which is the order they initialize in. + // _columns is derived from this by RebuildColumnOrder and is the display order everything + // else reads. + private readonly List> _registeredColumns = new(); private readonly List> _columns = new(); private IEnumerable _processedData = Array.Empty(); private IEnumerable _allSortedData = Array.Empty(); @@ -30,6 +35,7 @@ public partial class BbDataGrid : ComponentBase, IAsyncDisposable where T private readonly Dictionary _filterPopoverOpen = new(); private bool _needsDataRefresh = true; private bool columnStateInitialized; + private int columnStateSyncedVersion = -1; private readonly Dictionary _headerMenuOpen = new(); // Grouping/hierarchy state @@ -846,8 +852,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) ///
    internal void RegisterColumn(BbDataGridPropertyColumn column) { - _columns.Add(column); - OnColumnRegistered(); + AddColumn(column); } /// @@ -855,18 +860,15 @@ internal void RegisterColumn(BbDataGridPropertyColumn colum /// internal void RegisterColumn(BbDataGridTemplateColumn column) { - _columns.Add(column); - OnColumnRegistered(); + AddColumn(column); } /// - /// Registers a select column. + /// Registers a select column. Always laid out first, ahead of every data column. /// internal void RegisterColumn(BbDataGridSelectColumn column) { - // Insert select column at the beginning - _columns.Insert(0, column); - OnColumnRegistered(); + AddColumn(column); } /// @@ -874,21 +876,17 @@ internal void RegisterColumn(BbDataGridSelectColumn column) /// internal void RegisterHierarchyColumnDef(IDataGridColumn column) { - _columns.Add(column); - OnColumnRegistered(); + AddColumn(column); } /// - /// Registers an expand column. Inserted after the select column (if present), - /// or at position 0. + /// Registers an expand column. Laid out after the select column (if present), + /// or first when there is none. /// internal void RegisterColumn(BbDataGridExpandColumn column) { _expandColumn = column; - var selectIndex = _columns.FindIndex(c => c.ColumnId == "__select"); - var insertIndex = selectIndex >= 0 ? selectIndex + 1 : 0; - _columns.Insert(insertIndex, column); - OnColumnRegistered(); + AddColumn(column); if (column.DetailRows != null) { @@ -896,6 +894,91 @@ internal void RegisterColumn(BbDataGridExpandColumn column) } } + /// + /// Records a column in registration order and recomputes the display order. + /// + private void AddColumn(IDataGridColumn column) + { + _registeredColumns.Add(column); + RebuildColumnOrder(); + OnColumnRegistered(); + } + + /// + /// Recomputes (the display order) from the registration order. + /// + /// + /// Columns that leave Order unset keep their registration order, so a grid where no + /// column sets it is laid out exactly as it was before Order existed. Columns that do + /// set it are then inserted at that index, lowest value first, with ties falling back to + /// registration order. The select and expand columns hold fixed leading positions and take + /// no part in the index. + /// + private void RebuildColumnOrder() + { + BbDataGridSelectColumn? selectColumn = null; + BbDataGridExpandColumn? expandColumn = null; + var result = new List>(_registeredColumns.Count); + List>? ordered = null; + + foreach (var column in _registeredColumns) + { + if (column is BbDataGridSelectColumn select && selectColumn == null) + { + selectColumn = select; + continue; + } + + if (column is BbDataGridExpandColumn expand && expandColumn == null) + { + expandColumn = expand; + continue; + } + + if (column.Order == null) + { + result.Add(column); + } + else + { + ordered ??= new List>(); + ordered.Add(column); + } + } + + if (ordered != null) + { + // OrderBy is stable, so columns sharing an Order stay in registration order. Each + // insert is forced past the previous one so the second of a tied pair lands after + // the first rather than displacing it. + var lastIndex = -1; + foreach (var column in ordered.OrderBy(c => c.Order!.Value)) + { + var index = Math.Clamp(column.Order!.Value, 0, result.Count); + if (index <= lastIndex) + { + index = Math.Min(lastIndex + 1, result.Count); + } + + result.Insert(index, column); + lastIndex = index; + } + } + + if (expandColumn != null) + { + result.Insert(0, expandColumn); + } + + if (selectColumn != null) + { + result.Insert(0, selectColumn); + } + + _columns.Clear(); + _columns.AddRange(result); + } + private void OnColumnRegistered() { _columnsVersion++; @@ -1189,15 +1272,40 @@ public async Task ClearGroupingAsync() await NotifyStateChangedAsync(); } + /// + /// Keeps the column state's entry list in step with the registered columns. + /// + /// + /// Columns register from their own OnInitialized, and a column produced indirectly — + /// by a wrapper component, or a fragment that only renders after an await — registers in a + /// later render pass than the columns declared alongside it. The first pass to see any column + /// initializes the state, and because + /// renders strictly what the state lists, every column + /// arriving after that used to be dropped from the grid without a warning. Anything that + /// arrives late is therefore merged into the existing state rather than ignored. + /// private void InitializeColumnState() { - if (columnStateInitialized || _columns.Count == 0) + if (_columns.Count == 0) + { + return; + } + + if (!columnStateInitialized) + { + _gridState.Columns.Initialize(_columns.Select(c => (c.ColumnId, c.Visible))); + columnStateInitialized = true; + columnStateSyncedVersion = _columnsVersion; + return; + } + + if (columnStateSyncedVersion == _columnsVersion) { return; } - _gridState.Columns.Initialize(_columns.Select(c => (c.ColumnId, c.Visible))); - columnStateInitialized = true; + columnStateSyncedVersion = _columnsVersion; + _gridState.Columns.SyncColumns(_columns.Select(c => (c.ColumnId, c.Visible))); } private async Task ProcessDataAsync() diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridHierarchyColumn.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridHierarchyColumn.razor.cs index 51d068d03..a2417777b 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridHierarchyColumn.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridHierarchyColumn.razor.cs @@ -56,6 +56,34 @@ public partial class BbDataGridHierarchyColumn : ComponentBase, ID [Parameter] public bool Visible { get; set; } = true; + /// + /// Explicit position for this column, as a zero-based index among the grid's data columns. + /// When not set (the default), the column keeps its registration order — the order in which + /// its component initializes, which for a column written directly in the grid's markup is + /// the order it was declared in. + /// + /// + /// Set this on a column produced indirectly — by a wrapper component, or by a fragment that + /// only renders after an await — where initialization order does not match declaration order. + /// + /// Columns that leave Order unset are laid out first, in registration order. Each + /// column that sets it is then inserted at that index, lowest value first; an index past the + /// end appends. Two columns sharing an Order keep their registration order relative to + /// each other. Because unset columns retain their relative positions, a grid where no column + /// sets Order is laid out exactly as it would be without this parameter. + /// + /// + /// and + /// keep their fixed leading positions and are not counted by this index. + /// + /// + /// The value is read when the column registers with the grid; changing it afterwards has no + /// effect on an already rendered grid. + /// + /// + [Parameter] + public int? Order { get; set; } + /// /// Column width. /// @@ -156,6 +184,7 @@ public partial class BbDataGridHierarchyColumn : ComponentBase, ID bool IDataGridColumn.Sortable => Sortable; bool IDataGridColumn.Filterable => Filterable; bool IDataGridColumn.Visible => Visible; + int? IDataGridColumn.Order => Order; string? IDataGridColumn.Width => Width; bool IDataGridColumn.Hideable => Hideable; bool IDataGridColumn.Resizable => Resizable; diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs index b0545ebdf..6bbc3cb50 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs @@ -66,6 +66,34 @@ public partial class BbDataGridPropertyColumn : ComponentBase, IDa [Parameter] public bool Visible { get; set; } = true; + /// + /// Explicit position for this column, as a zero-based index among the grid's data columns. + /// When not set (the default), the column keeps its registration order — the order in which + /// its component initializes, which for a column written directly in the grid's markup is + /// the order it was declared in. + /// + /// + /// Set this on a column produced indirectly — by a wrapper component, or by a fragment that + /// only renders after an await — where initialization order does not match declaration order. + /// + /// Columns that leave Order unset are laid out first, in registration order. Each + /// column that sets it is then inserted at that index, lowest value first; an index past the + /// end appends. Two columns sharing an Order keep their registration order relative to + /// each other. Because unset columns retain their relative positions, a grid where no column + /// sets Order is laid out exactly as it would be without this parameter. + /// + /// + /// and + /// keep their fixed leading positions and are not counted by this index. + /// + /// + /// The value is read when the column registers with the grid; changing it afterwards has no + /// effect on an already rendered grid. + /// + /// + [Parameter] + public int? Order { get; set; } + /// /// Column width (e.g., "200px", "20%", "auto"). /// @@ -184,6 +212,8 @@ public partial class BbDataGridPropertyColumn : ComponentBase, IDa bool IDataGridColumn.Visible => Visible; + int? IDataGridColumn.Order => Order; + string? IDataGridColumn.Width => Width; bool IDataGridColumn.Hideable => Hideable; diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridTemplateColumn.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridTemplateColumn.razor.cs index be2b244fa..f9644a608 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridTemplateColumn.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridTemplateColumn.razor.cs @@ -67,6 +67,34 @@ public partial class BbDataGridTemplateColumn : ComponentBase, IDataGridC [Parameter] public bool Visible { get; set; } = true; + /// + /// Explicit position for this column, as a zero-based index among the grid's data columns. + /// When not set (the default), the column keeps its registration order — the order in which + /// its component initializes, which for a column written directly in the grid's markup is + /// the order it was declared in. + /// + /// + /// Set this on a column produced indirectly — by a wrapper component, or by a fragment that + /// only renders after an await — where initialization order does not match declaration order. + /// + /// Columns that leave Order unset are laid out first, in registration order. Each + /// column that sets it is then inserted at that index, lowest value first; an index past the + /// end appends. Two columns sharing an Order keep their registration order relative to + /// each other. Because unset columns retain their relative positions, a grid where no column + /// sets Order is laid out exactly as it would be without this parameter. + /// + /// + /// and + /// keep their fixed leading positions and are not counted by this index. + /// + /// + /// The value is read when the column registers with the grid; changing it afterwards has no + /// effect on an already rendered grid. + /// + /// + [Parameter] + public int? Order { get; set; } + /// /// Column width (e.g., "200px", "20%", "auto"). /// @@ -185,6 +213,8 @@ public partial class BbDataGridTemplateColumn : ComponentBase, IDataGridC bool IDataGridColumn.Visible => Visible; + int? IDataGridColumn.Order => Order; + string? IDataGridColumn.Width => Width; bool IDataGridColumn.Hideable => Hideable; diff --git a/src/BlazorBlueprint.Primitives/Primitives/DataGrid/DataGridColumnState.cs b/src/BlazorBlueprint.Primitives/Primitives/DataGrid/DataGridColumnState.cs index c02a04f45..fae11bd73 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/DataGrid/DataGridColumnState.cs +++ b/src/BlazorBlueprint.Primitives/Primitives/DataGrid/DataGridColumnState.cs @@ -49,6 +49,71 @@ public void Initialize(IEnumerable<(string ColumnId, bool Visible)> columns) NormalizeOrders(); } + /// + /// Adds entries for columns that are not tracked yet, leaving every existing entry's + /// visibility, width, and relative order untouched. + /// + /// + /// Use this instead of once the state has been initialized, so a + /// column that registers late — from a wrapper component or an asynchronously rendered + /// fragment — still gets an entry and stays visible. Each new entry is placed immediately + /// after the nearest preceding tracked column in , so the new + /// column lands in its intended position rather than being appended to the end. + /// Unlike , this does not renumber existing entries and does not + /// remove entries for columns missing from , so a user's column + /// reordering and visibility choices survive. + /// + /// The column IDs in display order, and their initial visibility. + /// True if any entry was added. + public bool SyncColumns(IEnumerable<(string ColumnId, bool Visible)> columns) + { + var tracked = new Dictionary(entries.Count, StringComparer.Ordinal); + foreach (var entry in entries) + { + tracked[entry.ColumnId] = entry; + } + + var added = false; + ColumnStateEntry? previous = null; + + foreach (var (id, visible) in columns) + { + if (tracked.TryGetValue(id, out var existing)) + { + previous = existing; + continue; + } + + var insertOrder = previous != null ? previous.Order + 1 : 0; + foreach (var entry in entries) + { + if (entry.Order >= insertOrder) + { + entry.Order++; + } + } + + var created = new ColumnStateEntry + { + ColumnId = id, + Visible = visible, + Order = insertOrder + }; + + entries.Add(created); + tracked[id] = created; + previous = created; + added = true; + } + + if (added) + { + NormalizeOrders(); + } + + return added; + } + /// /// Sets the visibility of a column. /// diff --git a/src/BlazorBlueprint.Primitives/Primitives/DataGrid/IDataGridColumn.cs b/src/BlazorBlueprint.Primitives/Primitives/DataGrid/IDataGridColumn.cs index 09a0e63ac..70e98017a 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/DataGrid/IDataGridColumn.cs +++ b/src/BlazorBlueprint.Primitives/Primitives/DataGrid/IDataGridColumn.cs @@ -41,6 +41,13 @@ public interface IDataGridColumn where TData : class ///
    public bool Visible { get; } + /// + /// Gets the explicit position of this column as a zero-based index among the grid's + /// data columns, or null to keep the column in registration order. + /// Default is null. + /// + public int? Order => null; + /// /// Gets the column width (e.g., "200px", "20%", "auto"). /// diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 1c04d7172..5cf143b03 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -838,6 +838,7 @@ - Id : String - IndentSize : Int32 - NoWrap : Boolean + - Order : Int32? - Pinned : ColumnPinning - Property : Expression> [EditorRequired] - Reorderable : Boolean @@ -863,6 +864,7 @@ - Hideable : Boolean - Id : String - NoWrap : Boolean + - Order : Int32? - Pinned : ColumnPinning - Property : Expression> [EditorRequired] - Reorderable : Boolean @@ -897,6 +899,7 @@ - Hideable : Boolean - Id : String - NoWrap : Boolean + - Order : Int32? - Pinned : ColumnPinning - Reorderable : Boolean - Resizable : Boolean diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt index 2a2cec7fa..ba8909634 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt @@ -1117,6 +1117,7 @@ - HeaderTemplate : RenderFragment> { get; } - Hideable : Boolean { get; } - NoWrap : Boolean { get; } + - Order : Int32? { get; } - Pinned : ColumnPinning { get; } - Reorderable : Boolean { get; } - Resizable : Boolean { get; } From 13d1680d9f79fff7c635c6f8318ab82f22dd792a Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Tue, 21 Jul 2026 15:04:11 +0800 Subject: [PATCH 159/188] feat(numeric-input): gate wheel stepping behind EnableWheelStep and accumulate delta (#421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wheel stepping was unconditional: it called preventDefault whenever the input was focused and the pointer was over it, so a long form silently stopped scrolling and changed a number instead, with no way to opt out. And every wheel event was one step, so a single trackpad flick (~25 momentum events) moved the value 25 steps and cost 25 interop round-trips on a Server circuit. - New EnableWheelStep parameter on BbNumericInput, BbCurrencyInput and both FormField wrappers, defaulting to false. When off no wheel listener is attached at all, so nothing calls preventDefault and page scrolling is unchanged. Follows the BbSidebarProvider.EnableToggleShortcut precedent (#403), including reactivity: setWheelStepEnabled attaches/removes the listener when the parameter changes after the first render. - Wheel distance is accumulated against a 100px threshold — one detent on a standard mouse wheel, normalised from Firefox line/page delta modes — with the remainder carried between events and the accumulator reset after a 200ms idle gap. A discrete notch still steps once immediately; the 25-event momentum burst now steps once instead of 25 times. Keeps the contributor's reuse of JsOnKeyDown (clamping/min/max/step stay in C#), { passive: false }, and listener removal on dispose. Adds a live demo example, code snippet and API Reference entries, and updates the API surface baseline. --- CHANGELOG.md | 1 + .../Components/NumericInput/wheel-step.txt | 21 +++ .../Pages/Components/CurrencyInputDemo.razor | 4 + .../FormFieldCurrencyInputDemo.razor | 1 + .../FormFieldNumericInputDemo.razor | 4 + .../Pages/Components/NumericInputDemo.razor | 61 +++++++++ .../CurrencyInput/BbCurrencyInput.razor.cs | 33 ++++- .../BbFormFieldCurrencyInput.razor | 1 + .../BbFormFieldCurrencyInput.razor.cs | 7 + .../BbFormFieldNumericInput.razor | 1 + .../BbFormFieldNumericInput.razor.cs | 7 + .../NumericInput/BbNumericInput.razor.cs | 33 ++++- .../wwwroot/js/numeric-input.js | 121 ++++++++++++++++-- ...entsApiSurfaceMatchesBaseline.verified.txt | 4 + 14 files changed, 289 insertions(+), 10 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/NumericInput/wheel-step.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f4b83769..94203bcf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - **BbDataGridPropertyColumn: `HeaderTemplate`** — The property column explicitly returned `null` for the column interface's header-template slot, so its header was locked to plain text from `Title` (or the name inferred from `Property`). Wanting an icon in the header — or any markup at all — meant abandoning the property column for a `BbDataGridTemplateColumn` and hand-rolling `SortBy`, `FilterBy` and the cell rendering that the property expression had been providing for free. `HeaderTemplate` now fills that slot. It replaces the **title text only**, not the whole header cell: the grid keeps rendering the sort indicator, filter icon, pin icon, `Groupable` ellipsis menu and resize handle around the supplied content, so a `Sortable`/`Groupable` column with an icon header still sorts on click, still shows its arrow and priority badge, and still offers "Group by" with nothing extra from the consumer. The parameter matches `BbDataGridTemplateColumn.HeaderTemplate` in both shape (`RenderFragment`) and behaviour, so the two column types stay consistent. For icon-only headers, keep `Title` set — the column chooser and the column menu's labels read from it — and include screen-reader text in the template, since the `` carries no `aria-label` and is announced from its own content. ([#423](https://github.com/blazorblueprintui/ui/issues/423)) +- **BbNumericInput / BbCurrencyInput: `EnableWheelStep`** — Scrolling the mouse wheel over a focused numeric input can now step its value. The wheel is mapped onto `ArrowUp`/`ArrowDown` and pushed through the same `JsOnKeyDown` path the arrow keys already use, so `Min`, `Max`, `Step` and every clamping rule stay in one place in C# rather than being reimplemented in JS. It is **opt-in and off by default**, because stepping can only work by calling `preventDefault()` on the wheel event, which takes the scroll away from the page: a long form whose pointer happens to cross a focused numeric input would otherwise stop scrolling and quietly change a number instead, with nothing on screen to explain it. With `EnableWheelStep` unset no wheel listener is attached at all — not an attached listener that returns early — so scrolling is exactly what it was for everyone who does not opt in. That follows the opt-out precedent set by `BbSidebarProvider.EnableToggleShortcut` in [#403](https://github.com/blazorblueprintui/ui/pull/403), and matches it on reactivity too: changing the parameter after the first render attaches or removes the listener instead of only applying at startup. Wheel distance is **accumulated** rather than counted one step per event, because a trackpad emits dozens of momentum events for a single flick — a measured 25-event macOS momentum burst (`deltaY` tapering −12 → −1, 163px in total) stepped the value 25 times, which on a Blazor Server circuit was also 25 `invokeMethodAsync` round-trips for one gesture; it now steps once, for one round-trip. The threshold is 100px, one detent on a standard mouse wheel in Chrome, Edge and Safari and normalised from Firefox's line and page delta modes, so a discrete wheel notch still steps once and does so immediately; the remainder is carried between events, and the accumulator resets after a 200ms idle gap so two deliberate flicks are not summed into one step. The parameter is available on `BbNumericInput`, `BbCurrencyInput`, `BbFormFieldNumericInput` and `BbFormFieldCurrencyInput`. Contributed by [@whis](https://github.com/whis). ([#421](https://github.com/blazorblueprintui/ui/pull/421)) ### Fixed diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/NumericInput/wheel-step.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/NumericInput/wheel-step.txt new file mode 100644 index 000000000..807991e54 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/NumericInput/wheel-step.txt @@ -0,0 +1,21 @@ + + + + + + + + + + + + + +@code { + private int quantity = 10; + private decimal price = 19.99m; + private bool wheelEnabled = true; +} diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CurrencyInputDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CurrencyInputDemo.razor index c6ad6e023..5b4bc0bd6 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CurrencyInputDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CurrencyInputDemo.razor @@ -338,6 +338,10 @@ Debounce delay in milliseconds. + + Whether scrolling the mouse wheel over the focused input steps the value. Opt-in, because stepping + calls preventDefault on the wheel event and so takes the scroll away from the page. Reactive. + Disables the input. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldCurrencyInputDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldCurrencyInputDemo.razor index 0ba55c7e6..223063a37 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldCurrencyInputDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldCurrencyInputDemo.razor @@ -149,6 +149,7 @@ Use thousand separators. Whether the input is disabled. Whether the input is required. + Whether scrolling the mouse wheel over the focused input steps the value. Opt-in, because stepping calls preventDefault on the wheel event and so takes the scroll away from the page. CSS classes for the inner input. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldNumericInputDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldNumericInputDemo.razor index dd5a5f444..73c58432b 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldNumericInputDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/FormFieldNumericInputDemo.razor @@ -227,6 +227,10 @@ Debounce delay in milliseconds. + + Whether scrolling the mouse wheel over the focused input steps the value. Opt-in, because stepping + calls preventDefault on the wheel event and so takes the scroll away from the page. + HTML name attribute. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/NumericInputDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/NumericInputDemo.razor index f7630ece0..685bb64e9 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/NumericInputDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/NumericInputDemo.razor @@ -73,6 +73,61 @@ + +
    +
    +

    Mouse Wheel Stepping

    +

    + Set EnableWheelStep="true" and scrolling the + wheel over the input steps its value while the input is focused — up increments, down decrements — + through the same path as the arrow keys, so Min, + Max and + Step all apply unchanged. +

    +

    + It is opt-in and off by default, because stepping means calling + preventDefault on the wheel event — taking the + scroll away from the page. On a long form that would mean the page silently stops scrolling, and a number + quietly changes instead, every time the pointer crosses a focused input. With the parameter unset no wheel + listener is attached at all, so scrolling is untouched. Turn it on where the trade is worth making, such as + a compact numeric-only editor. +

    +

    + Wheel distance is accumulated rather than counted per event, so one mouse detent steps once immediately + while a trackpad flick — which emits dozens of small deltas — steps once or twice rather than dozens of times. +

    +
    + +
    + EnableWheelStep: +
    + + +
    + The parameter is reactive — toggle it, then scroll over the focused input +
    + +
    + +
    +

    Value: @_wheelValue (min: 0, max: 100, step: 5)

    + +
    +
    @@ -281,6 +336,10 @@ Debounce delay in milliseconds. + + Whether scrolling the mouse wheel over the focused input steps the value. Opt-in, because stepping + calls preventDefault on the wheel event and so takes the scroll away from the page. Reactive. + Disables the input. @@ -332,6 +391,8 @@ private double _doubleValue = 0.0; private int _constrainedValue = 50; private int _steppedValue = 0; + private int _wheelValue = 25; + private bool _wheelStepEnabled = true; private int _buttonValue = 1; private decimal _decimalValue = 99.99m; private int _positiveValue = 0; diff --git a/src/BlazorBlueprint.Components/Components/CurrencyInput/BbCurrencyInput.razor.cs b/src/BlazorBlueprint.Components/Components/CurrencyInput/BbCurrencyInput.razor.cs index 590db3ee9..f5ff3f691 100644 --- a/src/BlazorBlueprint.Components/Components/CurrencyInput/BbCurrencyInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/CurrencyInput/BbCurrencyInput.razor.cs @@ -17,6 +17,7 @@ public partial class BbCurrencyInput : ComponentBase private string instanceId = Guid.NewGuid().ToString("N"); private string? generatedId; private bool jsInitialized; + private bool lastWheelStepEnabled; private bool disposed; private string editingValue = string.Empty; private bool isEditing; @@ -166,6 +167,16 @@ public partial class BbCurrencyInput : ComponentBase [Parameter] public int DebounceInterval { get; set; } = 500; + /// + /// Gets or sets whether scrolling the mouse wheel over the focused input steps the value. + /// Defaults to false: stepping has to call preventDefault on the wheel event, + /// which takes the scroll away from the page, so a long form would stop scrolling — and + /// silently change an amount instead — whenever the pointer passed over a focused input. + /// Enable it where that trade is worth making, such as a compact numeric-only editor. + /// + [Parameter] + public bool EnableWheelStep { get; set; } + private CurrencyDefinition Currency => currency ??= CurrencyCatalog.GetCurrency(CurrencyCode); private CultureInfo CultureInfo @@ -209,6 +220,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) jsModule = await JSRuntime.InvokeAsync( "import", "./_content/BlazorBlueprint.Components/js/numeric-input.js"); dotNetRef = DotNetObjectReference.Create(this); + lastWheelStepEnabled = EnableWheelStep; await jsModule.InvokeVoidAsync("initialize", inputRef, dotNetRef, instanceId, GetJsConfig()); jsInitialized = true; } @@ -221,6 +233,24 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // JS interop not available during prerendering } } + else if (jsInitialized && jsModule != null && lastWheelStepEnabled != EnableWheelStep) + { + // Keep wheel stepping in sync when the parameter changes after the first render + lastWheelStepEnabled = EnableWheelStep; + + try + { + await jsModule.InvokeVoidAsync("setWheelStepEnabled", instanceId, EnableWheelStep); + } + catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) + { + // Expected during circuit disconnect + } + catch (InvalidOperationException) + { + // JS interop not available + } + } } /// @@ -233,7 +263,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender) stepKeys = new[] { "ArrowUp", "ArrowDown" }, allowDecimal = true, allowNegative = AllowNegative, - decimalSeparator = CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator + decimalSeparator = CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator, + enableWheelStep = EnableWheelStep }; private void NotifyFieldChanged() => validation.NotifyFieldChanged(); diff --git a/src/BlazorBlueprint.Components/Components/FormFieldCurrencyInput/BbFormFieldCurrencyInput.razor b/src/BlazorBlueprint.Components/Components/FormFieldCurrencyInput/BbFormFieldCurrencyInput.razor index c5656e962..a50b1e97e 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldCurrencyInput/BbFormFieldCurrencyInput.razor +++ b/src/BlazorBlueprint.Components/Components/FormFieldCurrencyInput/BbFormFieldCurrencyInput.razor @@ -22,6 +22,7 @@ Required="@Required" DisableDebounce="@DisableDebounce" DebounceInterval="@DebounceInterval" + EnableWheelStep="@EnableWheelStep" Id="@ControlId" AriaLabel="@AriaLabel" AriaDescribedBy="@DescribedById" diff --git a/src/BlazorBlueprint.Components/Components/FormFieldCurrencyInput/BbFormFieldCurrencyInput.razor.cs b/src/BlazorBlueprint.Components/Components/FormFieldCurrencyInput/BbFormFieldCurrencyInput.razor.cs index ff0124084..62b728fc3 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldCurrencyInput/BbFormFieldCurrencyInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/FormFieldCurrencyInput/BbFormFieldCurrencyInput.razor.cs @@ -99,6 +99,13 @@ public partial class BbFormFieldCurrencyInput : FormFieldBase [Parameter] public int DebounceInterval { get; set; } = 500; + /// + /// Gets or sets whether scrolling the mouse wheel over the focused input steps the value. + /// Defaults to false; see . + /// + [Parameter] + public bool EnableWheelStep { get; set; } + /// /// Gets or sets additional CSS classes applied to the inner CurrencyInput element. /// diff --git a/src/BlazorBlueprint.Components/Components/FormFieldNumericInput/BbFormFieldNumericInput.razor b/src/BlazorBlueprint.Components/Components/FormFieldNumericInput/BbFormFieldNumericInput.razor index 8665735bd..0639d701c 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldNumericInput/BbFormFieldNumericInput.razor +++ b/src/BlazorBlueprint.Components/Components/FormFieldNumericInput/BbFormFieldNumericInput.razor @@ -25,6 +25,7 @@ Format="@Format" DisableDebounce="@DisableDebounce" DebounceInterval="@DebounceInterval" + EnableWheelStep="@EnableWheelStep" Id="@ControlId" AriaLabel="@AriaLabel" AriaDescribedBy="@DescribedById" diff --git a/src/BlazorBlueprint.Components/Components/FormFieldNumericInput/BbFormFieldNumericInput.razor.cs b/src/BlazorBlueprint.Components/Components/FormFieldNumericInput/BbFormFieldNumericInput.razor.cs index b84f5c015..e137d484f 100644 --- a/src/BlazorBlueprint.Components/Components/FormFieldNumericInput/BbFormFieldNumericInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/FormFieldNumericInput/BbFormFieldNumericInput.razor.cs @@ -131,6 +131,13 @@ public partial class BbFormFieldNumericInput : FormFieldBase where TValu [Parameter] public int DebounceInterval { get; set; } = 500; + /// + /// Gets or sets whether scrolling the mouse wheel over the focused input steps the value. + /// Defaults to false; see . + /// + [Parameter] + public bool EnableWheelStep { get; set; } + /// /// Gets or sets additional CSS classes applied to the inner NumericInput element. /// diff --git a/src/BlazorBlueprint.Components/Components/NumericInput/BbNumericInput.razor.cs b/src/BlazorBlueprint.Components/Components/NumericInput/BbNumericInput.razor.cs index 881f031ea..b5aca6a17 100644 --- a/src/BlazorBlueprint.Components/Components/NumericInput/BbNumericInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/NumericInput/BbNumericInput.razor.cs @@ -19,6 +19,7 @@ public partial class BbNumericInput : ComponentBase where TValue : struc private string instanceId = Guid.NewGuid().ToString("N"); private string? generatedId; private bool jsInitialized; + private bool lastWheelStepEnabled; private bool disposed; private string editingValue = string.Empty; private bool isEditing; @@ -175,6 +176,16 @@ public partial class BbNumericInput : ComponentBase where TValue : struc [Parameter] public int DebounceInterval { get; set; } = 500; + /// + /// Gets or sets whether scrolling the mouse wheel over the focused input steps the value. + /// Defaults to false: stepping has to call preventDefault on the wheel event, + /// which takes the scroll away from the page, so a long form would stop scrolling — and + /// silently change a number instead — whenever the pointer passed over a focused input. + /// Enable it where that trade is worth making, such as a compact numeric-only editor. + /// + [Parameter] + public bool EnableWheelStep { get; set; } + /// protected override void OnParametersSet() { @@ -193,6 +204,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) jsModule = await JSRuntime.InvokeAsync( "import", "./_content/BlazorBlueprint.Components/js/numeric-input.js"); dotNetRef = DotNetObjectReference.Create(this); + lastWheelStepEnabled = EnableWheelStep; await jsModule.InvokeVoidAsync("initialize", inputRef, dotNetRef, instanceId, GetJsConfig()); jsInitialized = true; } @@ -205,6 +217,24 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // JS interop not available during prerendering } } + else if (jsInitialized && jsModule != null && lastWheelStepEnabled != EnableWheelStep) + { + // Keep wheel stepping in sync when the parameter changes after the first render + lastWheelStepEnabled = EnableWheelStep; + + try + { + await jsModule.InvokeVoidAsync("setWheelStepEnabled", instanceId, EnableWheelStep); + } + catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) + { + // Expected during circuit disconnect + } + catch (InvalidOperationException) + { + // JS interop not available + } + } } /// @@ -217,7 +247,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender) stepKeys = new[] { "ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End" }, allowDecimal = IsFloatingPoint, allowNegative = AllowNegative, - decimalSeparator = CultureInfo.InvariantCulture.NumberFormat.NumberDecimalSeparator + decimalSeparator = CultureInfo.InvariantCulture.NumberFormat.NumberDecimalSeparator, + enableWheelStep = EnableWheelStep }; private void NotifyFieldChanged() => validation.NotifyFieldChanged(); diff --git a/src/BlazorBlueprint.Components/wwwroot/js/numeric-input.js b/src/BlazorBlueprint.Components/wwwroot/js/numeric-input.js index 8ead24566..c8b71c769 100644 --- a/src/BlazorBlueprint.Components/wwwroot/js/numeric-input.js +++ b/src/BlazorBlueprint.Components/wwwroot/js/numeric-input.js @@ -7,7 +7,10 @@ * - JsOnBlur(value) — called on blur (always) * - JsOnFocus() — called on focus (always) * - JsOnKeyDown(key) — called for step keys (ArrowUp/Down, PageUp/Down, Home/End) - * - Mouse wheel on focused input is mapped to ArrowUp/Down in the same key callback. + * + * When config.enableWheelStep is true, wheel movement over the focused input is accumulated + * and mapped to ArrowUp/Down through the same key callback. It is opt-in because stepping + * requires preventDefault, which takes the scroll away from the page. * * Sanitization and interop are held back while an IME is composing, then flushed once on * compositionend. See composition-guard.js for why. @@ -17,6 +20,39 @@ import { createCompositionGuard } from './composition-guard.js'; const instances = new Map(); +/** + * Accumulated wheel distance, in pixels, that equals one step. Chrome, Edge and Safari + * report one mouse-wheel detent as deltaY ±100, so a discrete notch still steps once + * immediately; a trackpad, which emits dozens of small deltas per flick, no longer does. + */ +const WheelStepThreshold = 100; + +/** + * Idle gap after which the accumulator resets, so two deliberate flicks are not summed + * into one step. Comfortably longer than the ~16ms spacing inside a momentum burst. + */ +const WheelIdleResetMs = 200; + +/** + * Ceiling on the steps a single wheel event may produce. Guards against an outsized delta + * (a page-mode wheel, or a synthetic event) firing an unbounded burst of interop calls. + */ +const WheelMaxStepsPerEvent = 10; + +/** + * Converts a wheel event's delta to pixels so the threshold above means the same thing + * everywhere. Firefox reports line mode (3 lines per detent) and, rarely, page mode. + * @param {WheelEvent} e - The wheel event. + * @returns {number} deltaY in pixels. + */ +const normalizeWheelDelta = (e) => { + switch (e.deltaMode) { + case 1: return e.deltaY * (WheelStepThreshold / 3); + case 2: return e.deltaY * WheelStepThreshold; + default: return e.deltaY; + } +}; + /** * Folds full-width forms to their ASCII equivalents, one character in one character out so * cursor offsets survive. A Japanese IME in 全角 mode emits 0-9 for the digit keys, which @@ -49,6 +85,7 @@ const foldFullWidth = (ch) => { * @param {boolean} config.allowDecimal - Whether decimal points are allowed. * @param {boolean} config.allowNegative - Whether negative sign is allowed. * @param {string} config.decimalSeparator - The decimal separator character used for input sanitization (e.g. '.'). + * @param {boolean} config.enableWheelStep - Whether the wheel steps the value while focused. Off by default. */ export function initialize(element, dotNetRef, instanceId, config) { if (!element || !dotNetRef) { @@ -59,7 +96,9 @@ export function initialize(element, dotNetRef, instanceId, config) { element, dotNetRef, config, - debounceTimer: null + debounceTimer: null, + wheelAccumulator: 0, + wheelLastEventAt: 0 }; const stepKeySet = new Set(config.stepKeys || []); @@ -155,15 +194,45 @@ export function initialize(element, dotNetRef, instanceId, config) { } }; + /** + * Steps the value when the accumulated wheel distance crosses one detent, reusing the + * keyboard step path so clamping, min/max and step all stay in one place in C#. + */ const handleWheel = (e) => { if (document.activeElement !== element || e.deltaY === 0) { return; } + // The gesture is ours for as long as the input holds focus — let go of the accumulator + // rather than the scroll, so a gesture never scrolls the page halfway through a step. e.preventDefault(); - const key = e.deltaY < 0 ? 'ArrowUp' : 'ArrowDown'; - dotNetRef.invokeMethodAsync('JsOnKeyDown', key).catch(() => {}); + const delta = normalizeWheelDelta(e); + const now = Date.now(); + + // A new gesture starts clean: an idle gap, or a reversal of direction. + if (now - state.wheelLastEventAt > WheelIdleResetMs || + (state.wheelAccumulator !== 0 && Math.sign(delta) !== Math.sign(state.wheelAccumulator))) { + state.wheelAccumulator = 0; + } + + state.wheelLastEventAt = now; + state.wheelAccumulator += delta; + + let steps = Math.trunc(state.wheelAccumulator / WheelStepThreshold); + if (steps === 0) { + return; + } + + // Carry the remainder so a burst of sub-threshold events still adds up over time. + state.wheelAccumulator -= steps * WheelStepThreshold; + + steps = Math.max(-WheelMaxStepsPerEvent, Math.min(WheelMaxStepsPerEvent, steps)); + + const key = steps < 0 ? 'ArrowUp' : 'ArrowDown'; + for (let i = 0; i < Math.abs(steps); i++) { + dotNetRef.invokeMethodAsync('JsOnKeyDown', key).catch(() => {}); + } }; const guard = createCompositionGuard(element, { onFlush: handleInput }); @@ -172,18 +241,52 @@ export function initialize(element, dotNetRef, instanceId, config) { element.addEventListener('blur', handleBlur); element.addEventListener('focus', handleFocus); element.addEventListener('keydown', handleKeyDown); - element.addEventListener('wheel', handleWheel, { passive: false }); - instances.set(instanceId, { + const stored = { state, handleInput, handleBlur, handleFocus, handleKeyDown, handleWheel, + wheelAttached: false, guard, element - }); + }; + + instances.set(instanceId, stored); + + // Opt-in only: with wheel stepping off no listener exists at all, so nothing calls + // preventDefault and page scrolling over the input is exactly as it was. + setWheelStepEnabled(instanceId, config.enableWheelStep === true); +} + +/** + * Enables or disables wheel stepping after initialization, attaching or removing the + * listener so the disabled state costs nothing and never intercepts a scroll. + * @param {string} instanceId - The instance to update. + * @param {boolean} enabled - Whether the wheel steps the value while the input is focused. + */ +export function setWheelStepEnabled(instanceId, enabled) { + const stored = instances.get(instanceId); + if (!stored) { + return; + } + + const shouldAttach = enabled === true; + if (shouldAttach === stored.wheelAttached) { + return; + } + + if (shouldAttach) { + stored.element.addEventListener('wheel', stored.handleWheel, { passive: false }); + } else { + stored.element.removeEventListener('wheel', stored.handleWheel); + } + + stored.wheelAttached = shouldAttach; + stored.state.wheelAccumulator = 0; + stored.state.wheelLastEventAt = 0; } /** @@ -212,7 +315,9 @@ export function dispose(instanceId) { stored.element.removeEventListener('blur', stored.handleBlur); stored.element.removeEventListener('focus', stored.handleFocus); stored.element.removeEventListener('keydown', stored.handleKeyDown); - stored.element.removeEventListener('wheel', stored.handleWheel); + if (stored.wheelAttached) { + stored.element.removeEventListener('wheel', stored.handleWheel); + } stored.guard.dispose(); if (stored.state.debounceTimer) { diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index abeb7b1ff..cbcb48750 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -696,6 +696,7 @@ - DebounceInterval : Int32 - DisableDebounce : Boolean - Disabled : Boolean + - EnableWheelStep : Boolean - Id : String - Max : Decimal? - Min : Decimal? @@ -1572,6 +1573,7 @@ - DebounceInterval : Int32 - DisableDebounce : Boolean - Disabled : Boolean + - EnableWheelStep : Boolean - ErrorText : String - HelperText : String - InputClass : String @@ -1801,6 +1803,7 @@ - DecimalPlaces : Int32? - DisableDebounce : Boolean - Disabled : Boolean + - EnableWheelStep : Boolean - ErrorText : String - Format : String - HelperText : String @@ -2544,6 +2547,7 @@ - DecimalPlaces : Int32? - DisableDebounce : Boolean - Disabled : Boolean + - EnableWheelStep : Boolean - Format : String - Id : String - Max : TValue? From d7f9b65a26621c4e760fbf56ed3361deb18fe5b1 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Tue, 21 Jul 2026 15:07:25 +0800 Subject: [PATCH 160/188] fix(datagrid): name the header cell from Title when a HeaderTemplate is supplied (#432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BbDataGridHeaderCell set aria-sort on the but never aria-label, so the cell's accessible name came entirely from its own content. That is right for the default header — its content is the title text — but a HeaderTemplate replaces that text with arbitrary markup, and the icon-only case the template exists to serve contributes no text at all, leaving the column unnamed. Title was already on the column and already used for the column chooser, column menu, filter and group-by labels, but never reached the header cell. The grid now supplies the name itself: while a HeaderTemplate is in play and Title is non-empty, the header cell is labelled with the title. The rule is deliberately narrow because aria-label replaces content rather than adding to it — a plain text header is untouched and keeps being named from its own text, and a template with no title is left alone. Where a template renders its own text the title wins, which keeps the header agreeing with the rest of the grid and means an existing sr-only workaround is announced once, not twice. Applies identically to BbDataGridPropertyColumn.HeaderTemplate and BbDataGridTemplateColumn.HeaderTemplate. Underneath, the Primitives-layer BbDataGridHeaderCell gains an AriaLabel parameter for the same purpose; unset, it preserves naming from content. aria-sort and header click/keyboard sorting are unaffected. --- CHANGELOG.md | 3 ++- .../Components/DataGrid/header-template.txt | 9 +++++++ .../property-column-header-template.txt | 4 +-- .../Primitives/DataGrid/selectable.txt | 3 ++- .../Pages/Components/DataGridDemo.razor | 4 +-- .../Components/DataGridStylingDemo.razor | 22 ++++++++++++---- .../Primitives/DataGridPrimitiveDemo.razor | 6 ++++- .../Components/DataGrid/BbDataGrid.razor | 1 + .../Components/DataGrid/BbDataGrid.razor.cs | 25 +++++++++++++++++++ .../BbDataGridPropertyColumn.razor.cs | 15 ++++++++--- .../BbDataGridTemplateColumn.razor.cs | 14 ++++++++++- .../DataGrid/BbDataGridHeaderCell.razor | 19 ++++++++++++++ ...ivesApiSurfaceMatchesBaseline.verified.txt | 1 + 13 files changed, 109 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f4b83769..2b31ec96a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- **BbDataGridPropertyColumn: `HeaderTemplate`** — The property column explicitly returned `null` for the column interface's header-template slot, so its header was locked to plain text from `Title` (or the name inferred from `Property`). Wanting an icon in the header — or any markup at all — meant abandoning the property column for a `BbDataGridTemplateColumn` and hand-rolling `SortBy`, `FilterBy` and the cell rendering that the property expression had been providing for free. `HeaderTemplate` now fills that slot. It replaces the **title text only**, not the whole header cell: the grid keeps rendering the sort indicator, filter icon, pin icon, `Groupable` ellipsis menu and resize handle around the supplied content, so a `Sortable`/`Groupable` column with an icon header still sorts on click, still shows its arrow and priority badge, and still offers "Group by" with nothing extra from the consumer. The parameter matches `BbDataGridTemplateColumn.HeaderTemplate` in both shape (`RenderFragment`) and behaviour, so the two column types stay consistent. For icon-only headers, keep `Title` set — the column chooser and the column menu's labels read from it — and include screen-reader text in the template, since the `` carries no `aria-label` and is announced from its own content. ([#423](https://github.com/blazorblueprintui/ui/issues/423)) +- **BbDataGridPropertyColumn: `HeaderTemplate`** — The property column explicitly returned `null` for the column interface's header-template slot, so its header was locked to plain text from `Title` (or the name inferred from `Property`). Wanting an icon in the header — or any markup at all — meant abandoning the property column for a `BbDataGridTemplateColumn` and hand-rolling `SortBy`, `FilterBy` and the cell rendering that the property expression had been providing for free. `HeaderTemplate` now fills that slot. It replaces the **title text only**, not the whole header cell: the grid keeps rendering the sort indicator, filter icon, pin icon, `Groupable` ellipsis menu and resize handle around the supplied content, so a `Sortable`/`Groupable` column with an icon header still sorts on click, still shows its arrow and priority badge, and still offers "Group by" with nothing extra from the consumer. The parameter matches `BbDataGridTemplateColumn.HeaderTemplate` in both shape (`RenderFragment`) and behaviour, so the two column types stay consistent. For icon-only headers, keep `Title` set — the column chooser, the column menu's labels and (as of [#432](https://github.com/blazorblueprintui/ui/issues/432), below) the header cell's own accessible name all read from it. ([#423](https://github.com/blazorblueprintui/ui/issues/423)) ### Fixed - **BbTooltipTrigger: an `AsChild` child that ignores the trigger context now says so** — `BbTooltipTrigger.AsChild` defaults to `true` in the Components layer, and in that mode the trigger renders *no element and no handlers at all* — only a cascading `TriggerContext` that the child is expected to consume and wire the hover/focus behaviour up from. `BbButton` does exactly that, which is why the documented `` composition works. Anything that does not — plain markup, text, or a bare ``, which is the natural thing to reach for in a table cell or beside a field label — produced a trigger with nothing listening for hover, so the tooltip could never open. No exception, no console error, no visual clue: the markup looked right and simply did nothing, and every example on the demo page either set `AsChild="false"` or wrapped a `BbButton`, so nothing on the page contradicted it. The trigger now reports the case: on first render, an `AsChild` trigger whose context was never touched by any child logs a warning through `ILogger` naming both ways out (`AsChild="false"`, or a child that consumes the context). Consumption is recorded by the context itself — reading any of its members marks it, which covers every child that applies the id or aria attributes as it renders, and a custom child that only touches the context inside event handlers can call the new `TriggerContext.NotifyConsumed()` to acknowledge it — so legitimate compositions, including a tooltip trigger nested inside a dialog or popover trigger, stay silent. The warning is gated on the host application reporting the `Development` environment (resolved once, by name, from whichever environment abstraction the render mode registers), so it costs a cached boolean read in production and never reaches anyone's telemetry. The default is deliberately left at `true` for now: flipping it would add a wrapping `span` to every existing correct usage, which is a breaking change held for the next major. ([#425](https://github.com/blazorblueprintui/ui/issues/425)) - **BbDataGrid: a column produced indirectly moved to the end, or vanished from the grid entirely** — Columns register themselves with the grid from their own `OnInitialized`, and the grid appended each one to a list, so column order was really *component-initialisation* order. On an interactive circuit that stops matching declaration order the moment a column is produced indirectly: a column rendered by a wrapper component initialises a render pass later than the columns declared beside it, and so drifted to the end of the grid. (Deriving the column from `BbDataGridPropertyColumn` with `@inherits` was never affected — only wrapping.) Worse, the grid initialises its column state — the ordered, per-column visibility record that the header and body are rendered from — from whichever columns had registered by the first render pass to see any, and then latched. Every column arriving after that point was missing from that record and was therefore dropped from the grid: no header, no cells, no console error, no exception. A column behind an `await` — a wrapper that loads lookup data before rendering its inner column, say — reliably disappeared, and prerender and the interactive circuit disagreed about the columns, so the grid visibly reshuffled as the page came alive. Late registrations are now merged into the existing column state instead of ignored, each one placed next to its neighbours rather than appended, so it appears where it belongs; a user's own reordering and visibility choices are left untouched. Alongside that, `BbDataGridPropertyColumn`, `BbDataGridTemplateColumn` and `BbDataGridHierarchyColumn` gain an **`Order`** parameter that positions a column explicitly, as a zero-based index among the data columns, for cases where initialisation order cannot match declaration order. Columns that leave `Order` unset keep their existing registration order and each column that sets it is inserted at that index — so a grid that sets `Order` nowhere is laid out exactly as before — and the select and expand columns keep their fixed leading positions regardless. ([#424](https://github.com/blazorblueprintui/ui/issues/424)) +- **BbDataGrid: an icon-only `HeaderTemplate` left the column unnamed for screen readers** — `BbDataGridHeaderCell` set `aria-sort` on the `` but never `aria-label`, so the cell's accessible name came entirely from its own content. That is exactly right for the default header, whose content *is* the title text — but a `HeaderTemplate` replaces that text with arbitrary markup, and the case the template exists to serve, an icon on its own, contributes no text at all. `Title` was already sitting on the column and was already being used for the column chooser, the column menu, the filter button and the "Group by" item, yet it never reached the header cell, so the column announced as empty and the only clue was that there was none: nothing in the UI hints that a column has no name. The documented workaround was to hand-write an `sr-only` span inside every such template, which worked but put the burden on the consumer for something the grid already knew and failed silently the first time anyone forgot. The grid now supplies the name itself: while a `HeaderTemplate` is in play and `Title` is non-empty, the header cell is labelled with the title, so an icon-only header is announced correctly with nothing extra from the consumer. The rule is deliberately narrow, because `aria-label` *replaces* an element's content as its accessible name rather than adding to it — a plain text header is left untouched and keeps being announced from its own text, gaining neither a redundant nor a conflicting label, and a column with a template but no title is left alone too. Where a template renders its own text, the title now wins the announcement; that is the intended trade, since `Title` is already the canonical name for the column everywhere else in the grid and the header agreeing with the column chooser and the column menu is worth more than echoing decorative header markup. It also means the existing `sr-only` workaround degrades gracefully instead of double-announcing — a template that already carries screen-reader text is announced once, by title, so nobody has to go and unpick theirs. This applies identically to `BbDataGridPropertyColumn.HeaderTemplate` and `BbDataGridTemplateColumn.HeaderTemplate`, which have the same shape and the same exposure. Underneath, the Primitives-layer `BbDataGridHeaderCell` gains an **`AriaLabel`** parameter carrying the same guidance, so a grid assembled directly from the primitives — where a checkbox-only or icon-only header cell hits the identical gap — can name its header cells too; leaving it unset preserves naming from content, so no existing markup changes. `aria-sort`, keyboard sorting and the header's click behaviour are unaffected. ([#432](https://github.com/blazorblueprintui/ui/issues/432)) --- diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/header-template.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/header-template.txt index dfc26ab25..eaade892e 100644 --- a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/header-template.txt +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/header-template.txt @@ -26,5 +26,14 @@ + @* Icon-only header — the grid labels the cell from Title, so it announces as "Actions" *@ + + + + + + Edit + + \ No newline at end of file diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/property-column-header-template.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/property-column-header-template.txt index dbc292de0..84ed25cf1 100644 --- a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/property-column-header-template.txt +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/property-column-header-template.txt @@ -17,11 +17,11 @@
    - @* Icon-only header — keep Title set for the column chooser, sr-only text for screen readers *@ + @* Icon-only header — Title feeds the column chooser and the header's own aria-label, + so this column is announced as "Email" with no sr-only markup of your own *@ - Email - + @* AriaLabel names a header cell whose content carries no text of its own *@ + - Custom header content, e.g. an icon next to (or instead of) the title. It replaces the title text only — the grid still renders the sort indicator, filter icon, pin icon, column menu and resize handle around it, so Sortable and Groupable columns keep every affordance. Keep Title set for icon-only headers, since the column chooser and column menu use it, and include sr-only text in the template — the header cell is announced from its own content. + Custom header content, e.g. an icon next to (or instead of) the title. It replaces the title text only — the grid still renders the sort indicator, filter icon, pin icon, column menu and resize handle around it, so Sortable and Groupable columns keep every affordance. While a template is supplied the grid sets aria-label on the header cell from Title, so an icon-only header is still announced and no sr-only markup of your own is needed. That label replaces the template's own text as the accessible name, keeping the header consistent with the column chooser and column menu; keep Title meaningful for this reason. If Title is blank no label is applied and the cell is announced from the template's content. Additional CSS classes applied to cells in this column. @@ -1185,7 +1185,7 @@ Custom cell template. The context provides the data item for the current row. - Custom header template. Replaces the default title text while preserving sort icons and resize handles. + Custom header template. Replaces the default title text while preserving sort icons and resize handles. While a template is supplied the grid sets aria-label on the header cell from Title, so an icon-only header is still announced and no sr-only markup of your own is needed. That label replaces the template's own text as the accessible name, keeping the header consistent with the column chooser and column menu; keep Title meaningful for this reason. If Title is blank no label is applied and the cell is announced from the template's content. Whether this column can be sorted. Requires SortBy to be set. diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridStylingDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridStylingDemo.razor index 4a1767d02..5132e87ef 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridStylingDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridStylingDemo.razor @@ -59,6 +59,9 @@ Use HeaderTemplate on a BbDataGridTemplateColumn to replace the default header text with custom content. Sort icons and resize handles are preserved. + While a template is supplied the grid labels the header cell with the column's + Title, so the icon-only Actions header below + is still announced as "Actions" to a screen reader.

    @@ -91,6 +94,14 @@ + + + + + + Edit + + @@ -106,10 +117,12 @@ markup in the header without giving up type-safe binding. The template replaces the title text only — the grid keeps rendering its own sort indicator, filter icon and column menu around it, so the Department column below still sorts and still offers "Group by". Keep - Title meaningful for icon-only headers — it is what - the column chooser and the column menu display — and add - sr-only text inside the template, since the header - cell is announced from its own content. + Title meaningful for icon-only headers — as well as + feeding the column chooser and the column menu, it is what the grid uses to label the header cell + itself. While a HeaderTemplate is supplied the grid + sets aria-label on the header from the title, so the + icon-only Email column below is announced as "Email" without any + sr-only markup of your own.

    @@ -133,7 +146,6 @@ - Email - + @* AriaLabel names a header cell whose content carries no text of its own *@ + Whether this column can be reordered. When true, renders draggable="true" and data-column-id on the header cell. + + Explicit accessible name for the header cell, rendered as aria-label on the <th>. Leave null whenever the cell's own content already reads as text — with no label the cell is named from that content, which is what an ordinary text header wants. Set it for a header that carries no text of its own, such as an icon-only header, which would otherwise leave the column unnamed. Because aria-label replaces content rather than adding to it, a value set here silences any text the cell does render. + Additional CSS classes for the header cell. diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor index 114d48ec6..fa8e1e224 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor @@ -89,6 +89,7 @@ ColumnId="@column.ColumnId" Sortable="@column.Sortable" Reorderable="@(Reorderable && column.Reorderable)" + AriaLabel="@GetHeaderAriaLabel(column)" Class="@GetHeaderCellClass(column, isSelectColumn, isExpandColumn, isLastLeft, isFirstRight)" style="@GetColumnStyle(column, _cachedVisibleColumns)" data-pinned="@(column.Pinned != ColumnPinning.None ? "true" : null)"> diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs index bec46c514..9a192f0a3 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs @@ -3197,6 +3197,31 @@ private void ApplyItemKeyComparer() private bool HasTableFixed() => Resizable || _columns.Any(c => c.Pinned != ColumnPinning.None); + /// + /// Computes the accessible name for a column's header cell, returning null when the cell + /// should keep being named from its own rendered content. + /// + /// + /// A header cell has no aria-label by default, so assistive technology names it from + /// its content — which is exactly right for the default header, where the content is the + /// column's title text. A HeaderTemplate replaces that text with arbitrary markup, and + /// the case the template exists to serve — an icon on its own — contributes no text at all, + /// leaving the column silently unnamed. So the title is supplied as an explicit label only + /// when a template is in play and a title is actually available to fall back to. + /// + /// Because aria-label overrides content rather than adding to it, a template that does + /// render its own text is announced as Title rather than as that text. That is + /// deliberate: Title is already the canonical name for the column everywhere else in + /// the grid — the column chooser, the column menu, the filter and group-by labels — so the + /// header now agrees with them instead of drifting. It also means a template that already + /// carries hand-written screen-reader-only text is announced once, not twice. + /// + /// + private static string? GetHeaderAriaLabel(IDataGridColumn column) => + column.HeaderTemplate != null && !string.IsNullOrWhiteSpace(column.Title) + ? column.Title + : null; + private string GetHeaderCellClass(IDataGridColumn column, bool isSelectColumn, bool isExpandColumn, bool isLastLeft, bool isFirstRight) { diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs index ceb92ceb4..3628b2078 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridPropertyColumn.razor.cs @@ -197,11 +197,18 @@ public partial class BbDataGridPropertyColumn : ComponentBase, IDa /// still renders its own sort indicator, filter icon, pin icon, column menu and resize /// handle around it, so a or column keeps /// every affordance. Use it to show an icon or richer markup instead of - /// . Set as well when the content is icon-only, so - /// the column chooser and the column menu still have readable text, and include screen-reader - /// text (e.g. a sr-only span) in the template — the header cell is announced from its - /// own content. + /// . /// + /// + /// While a template is supplied, the grid names the header cell with the column's title + /// (aria-label), so an icon-only header is still announced — no sr-only span of + /// your own is needed, and one that is already there is not announced twice. That label + /// replaces the template's own text rather than adding to it, which keeps the header agreeing + /// with the column chooser, the column menu and the filter labels, all of which already use + /// the title. Leave set to something meaningful for this reason; if it is + /// blank, no label is applied and the cell falls back to being announced from the template's + /// content. + /// [Parameter] public RenderFragment? HeaderTemplate { get; set; } diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridTemplateColumn.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridTemplateColumn.razor.cs index f9644a608..52e39b6ca 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridTemplateColumn.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGridTemplateColumn.razor.cs @@ -36,8 +36,20 @@ public partial class BbDataGridTemplateColumn : ComponentBase, IDataGridC public RenderFragment? ChildContent { get; set; } /// - /// Custom header template. + /// Custom header content. If provided, replaces the header title text only — the grid still + /// renders its own sort indicator, filter icon, pin icon, column menu and resize handle + /// around it. Use it to show an icon or richer markup instead of . /// + /// + /// While a template is supplied, the grid names the header cell with the column's title + /// (aria-label), so an icon-only header is still announced — no sr-only span of + /// your own is needed, and one that is already there is not announced twice. That label + /// replaces the template's own text rather than adding to it, which keeps the header agreeing + /// with the column chooser, the column menu and the filter labels, all of which already use + /// the title. Leave set to something meaningful for this reason; if it is + /// blank, no label is applied and the cell falls back to being announced from the template's + /// content. + /// [Parameter] public RenderFragment? HeaderTemplate { get; set; } diff --git a/src/BlazorBlueprint.Primitives/Primitives/DataGrid/BbDataGridHeaderCell.razor b/src/BlazorBlueprint.Primitives/Primitives/DataGrid/BbDataGridHeaderCell.razor index 0fb13c91e..fa9450540 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/DataGrid/BbDataGridHeaderCell.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/DataGrid/BbDataGridHeaderCell.razor @@ -4,6 +4,7 @@ + /// Explicit accessible name for the header cell, rendered as aria-label on the + /// <th>. Leave this null (the default) whenever already + /// renders readable text: with no aria-label the cell is named from its own content, + /// which is the desired behaviour for an ordinary text header. + /// + /// + /// Set this only for a header whose content carries no text of its own — an icon-only header, + /// for instance — which would otherwise leave the column unnamed for assistive technology. + /// Because aria-label replaces the element's content as the accessible name, + /// setting it on a header that does render text will silence that text, so a value supplied + /// here should match what a sighted user understands the column to be. + /// + [Parameter] + public string? AriaLabel { get; set; } + /// /// Additional CSS classes. /// @@ -54,6 +71,8 @@ [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } + private string? EffectiveAriaLabel => string.IsNullOrWhiteSpace(AriaLabel) ? null : AriaLabel; + private bool IsSortable => Sortable && !string.IsNullOrEmpty(ColumnId); private bool IsReorderable => Reorderable && !string.IsNullOrEmpty(ColumnId); diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt index ba8909634..8d13a08b3 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt @@ -181,6 +181,7 @@ ### BbDataGridHeaderCell`1 (BlazorBlueprint.Primitives.DataGrid) - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - AriaLabel : String - ChildContent : RenderFragment - Class : String - ColumnId : String From 1c56940528d96b72d00a441e8c8af60dae6b0878 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Tue, 21 Jul 2026 15:09:12 +0800 Subject: [PATCH 161/188] fix(hovercard): warn when an AsChild trigger's child ignores TriggerContext BbHoverCardTrigger.AsChild defaults to true in the Components layer, and in that mode the trigger renders no element and no handlers at all - only a cascading TriggerContext that the child is expected to consume and wire the hover/focus behaviour up from. BbButton does that; plain markup, text and a bare LucideIcon do not, so those produced a trigger with nothing listening for mouseenter and a hover card that could never open, with no exception, no console error and no visual clue. Identical in shape and in failure to the tooltip case fixed in #425, which deliberately left this one alone to stay scoped. The trigger now reports it, reusing the machinery #425 put in place: on first render, an AsChild trigger whose context was never touched logs an ILogger warning naming both ways out. Consumption is recorded by TriggerContext itself - reading any member marks it, which covers every child that applies the id or aria attributes as it renders - plus the public NotifyConsumed() for a custom child that only touches the context inside event handlers. The warning is gated on the host reporting the Development environment. Unlike the tooltip trigger, this one deliberately builds a fresh context per render so the child sees current IsOpen and id values, so the most recently cascaded instance is retained for the check rather than caching the context itself. Behaviour is otherwise unchanged. The AsChild default is deliberately unchanged; flipping it would add a wrapping div to every existing correct usage and is tracked in #428. Documents the contract on the AsChild xmldoc in both layers, and adds a demo section plus code example covering the plain-content/icon case. Refs #433 --- CHANGELOG.md | 1 + .../Components/HoverCard/plain-content.txt | 35 +++++++++ .../Pages/Components/HoverCardDemo.razor | 63 +++++++++++++++- .../HoverCard/BbHoverCardTrigger.razor | 16 ++++- .../HoverCard/BbHoverCardTrigger.razor | 71 +++++++++++++++++-- 5 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/HoverCard/plain-content.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f4b83769..4bf80a7a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - **BbTooltipTrigger: an `AsChild` child that ignores the trigger context now says so** — `BbTooltipTrigger.AsChild` defaults to `true` in the Components layer, and in that mode the trigger renders *no element and no handlers at all* — only a cascading `TriggerContext` that the child is expected to consume and wire the hover/focus behaviour up from. `BbButton` does exactly that, which is why the documented `` composition works. Anything that does not — plain markup, text, or a bare ``, which is the natural thing to reach for in a table cell or beside a field label — produced a trigger with nothing listening for hover, so the tooltip could never open. No exception, no console error, no visual clue: the markup looked right and simply did nothing, and every example on the demo page either set `AsChild="false"` or wrapped a `BbButton`, so nothing on the page contradicted it. The trigger now reports the case: on first render, an `AsChild` trigger whose context was never touched by any child logs a warning through `ILogger` naming both ways out (`AsChild="false"`, or a child that consumes the context). Consumption is recorded by the context itself — reading any of its members marks it, which covers every child that applies the id or aria attributes as it renders, and a custom child that only touches the context inside event handlers can call the new `TriggerContext.NotifyConsumed()` to acknowledge it — so legitimate compositions, including a tooltip trigger nested inside a dialog or popover trigger, stay silent. The warning is gated on the host application reporting the `Development` environment (resolved once, by name, from whichever environment abstraction the render mode registers), so it costs a cached boolean read in production and never reaches anyone's telemetry. The default is deliberately left at `true` for now: flipping it would add a wrapping `span` to every existing correct usage, which is a breaking change held for the next major. ([#425](https://github.com/blazorblueprintui/ui/issues/425)) +- **BbHoverCardTrigger: the same silent `AsChild` no-op now warns as well** — `BbHoverCardTrigger` has exactly the shape [#425](https://github.com/blazorblueprintui/ui/issues/425) described for the tooltip, and exactly the same failure. `AsChild` defaults to `true` in the Components layer, and in that mode the trigger renders *no element and no handlers at all* — only a cascading `TriggerContext` that the child is expected to consume and wire the hover, focus and element-reference callbacks up from. `BbButton` does that, which is why `` works. Anything that does not — an avatar image, a `@username` span, a bare `` next to a name — left the hover card with nothing listening for `mouseenter`, so it could never open, and said nothing about it: no exception, no console error, no visual clue, just markup that looked correct and did nothing on hover. The trigger now reports it, using the machinery [#425](https://github.com/blazorblueprintui/ui/issues/425) already put in place: on first render, an `AsChild` trigger whose cascaded context was never touched by any child logs a warning through `ILogger` naming both ways out (`AsChild="false"` for plain content, or a child that consumes the context). Consumption is recorded by the context itself — reading any of its members marks it, which covers every child that applies the trigger id or aria attributes as it renders, and a custom child that only touches the context from inside event handlers can call `TriggerContext.NotifyConsumed()` to acknowledge it — so correct compositions stay silent, including a hover card trigger nested inside a dialog, popover or dropdown trigger, where reading the parent context to merge its click and aria behaviour marks that parent consumed too. The warning is gated on the host application reporting the `Development` environment, resolved once by name from whichever environment abstraction the render mode registers, so it costs a cached boolean read in production and never reaches anyone's telemetry. The `AsChild` default is deliberately left at `true`: flipping it would add a wrapping `div` to every existing correct usage, and that breaking change is tracked separately for the next major. The hover card demo page gains a plain-content and icon section covering the case, and the `AsChild` documentation in both layers now spells the contract out instead of describing it as merely "passing trigger behavior to child components". ([#433](https://github.com/blazorblueprintui/ui/issues/433)) - **BbDataGrid: a column produced indirectly moved to the end, or vanished from the grid entirely** — Columns register themselves with the grid from their own `OnInitialized`, and the grid appended each one to a list, so column order was really *component-initialisation* order. On an interactive circuit that stops matching declaration order the moment a column is produced indirectly: a column rendered by a wrapper component initialises a render pass later than the columns declared beside it, and so drifted to the end of the grid. (Deriving the column from `BbDataGridPropertyColumn` with `@inherits` was never affected — only wrapping.) Worse, the grid initialises its column state — the ordered, per-column visibility record that the header and body are rendered from — from whichever columns had registered by the first render pass to see any, and then latched. Every column arriving after that point was missing from that record and was therefore dropped from the grid: no header, no cells, no console error, no exception. A column behind an `await` — a wrapper that loads lookup data before rendering its inner column, say — reliably disappeared, and prerender and the interactive circuit disagreed about the columns, so the grid visibly reshuffled as the page came alive. Late registrations are now merged into the existing column state instead of ignored, each one placed next to its neighbours rather than appended, so it appears where it belongs; a user's own reordering and visibility choices are left untouched. Alongside that, `BbDataGridPropertyColumn`, `BbDataGridTemplateColumn` and `BbDataGridHierarchyColumn` gain an **`Order`** parameter that positions a column explicitly, as a zero-based index among the data columns, for cases where initialisation order cannot match declaration order. Columns that leave `Order` unset keep their existing registration order and each column that sets it is inserted at that index — so a grid that sets `Order` nowhere is laid out exactly as before — and the select and expand columns keep their fixed leading positions regardless. ([#424](https://github.com/blazorblueprintui/ui/issues/424)) --- diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/HoverCard/plain-content.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/HoverCard/plain-content.txt new file mode 100644 index 000000000..253d1203c --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/HoverCard/plain-content.txt @@ -0,0 +1,35 @@ +@* Plain content does not consume TriggerContext, so the trigger + has to render its own element: AsChild="false". *@ + + + + + +
    +

    Verified account

    +

    + Identity confirmed on 4 March 2026. +

    +
    +
    +
    + + + + 3 open issues + + +

    Two bugs and one feature request.

    +
    +
    + +@* The default, AsChild="true", is for a child that consumes + TriggerContext and wires the hover/focus behaviour up itself. *@ + + + @johndoe + + +

    User profile info

    +
    +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/HoverCardDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/HoverCardDemo.razor index 4d0d1e7c2..c0319c0c0 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/HoverCardDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/HoverCardDemo.razor @@ -66,6 +66,64 @@ + +
    +
    +

    Plain Content and Icons

    +

    + AsChild defaults to + true, which renders no element of its own — + the child is handed a TriggerContext and is + expected to attach the hover and focus behaviour itself. Anything that does not consume that context — + text, plain markup, or a bare LucideIcon — needs + AsChild="false" so the trigger renders its own + div with the handlers attached. +

    +
    + +
    +
    + Ada Lovelace + + + + + +
    +

    Verified account

    +

    + Identity confirmed on 4 March 2026. +

    +
    +
    +
    +
    + +
    + Repository + + + 3 open issues + + +

    Two bugs and one feature request.

    +
    +
    +
    +
    + + + +
    + Which mode do I want? Reach for the default + AsChild="true" when the child is a component built to act as a trigger — + BbButton is the common one — because it keeps the DOM flat and lets the child own its own + focus ring, aria attributes and styling. Use AsChild="false" for everything else. Getting it + wrong used to do nothing at all; a trigger whose child never consumes the context now logs a warning while + the app runs in the Development environment. +
    +
    +

    Default

    @@ -289,7 +347,10 @@ - When true, the trigger does not render its own element. Instead, it passes trigger behavior via TriggerContext to child components. + When true, the trigger renders no element of its own and the child must consume the cascaded + TriggerContext to wire up hover and focus — BbButton does. Set false for + plain markup, text or a bare icon, so the trigger renders its own div with the + handlers attached. Additional CSS classes to apply to the trigger. diff --git a/src/BlazorBlueprint.Components/Components/HoverCard/BbHoverCardTrigger.razor b/src/BlazorBlueprint.Components/Components/HoverCard/BbHoverCardTrigger.razor index f3494a90e..c106dfb6f 100644 --- a/src/BlazorBlueprint.Components/Components/HoverCard/BbHoverCardTrigger.razor +++ b/src/BlazorBlueprint.Components/Components/HoverCard/BbHoverCardTrigger.razor @@ -17,10 +17,20 @@ public RenderFragment? ChildContent { get; set; } ///

    - /// When true, the trigger does not render its own div element. - /// Instead, it passes trigger behavior via TriggerContext to child components. - /// Use this when you want a custom component to act as the trigger. + /// When true (the default), the trigger renders no element of its own. It renders only the + /// cascading TriggerContext, and the child is responsible for consuming that context + /// and wiring the hover card up — applying the trigger id and aria attributes to its own + /// element, and calling the context's hover, focus and SetTriggerElement callbacks. + /// BbButton does this, which is why it can be dropped straight into a trigger. /// + /// + /// Because no element and no handlers are rendered in this mode, a child that ignores the + /// context leaves nothing listening for hover or focus and the hover card can never open. Plain + /// markup, text, or a bare icon such as LucideIcon therefore needs + /// AsChild="false", which wraps the content in a div carrying the handlers. + /// An unconsumed context is reported as a warning through ILogger when the app runs in + /// the Development environment, so this no longer fails silently. + /// [Parameter] public bool AsChild { get; set; } = true; diff --git a/src/BlazorBlueprint.Primitives/Primitives/HoverCard/BbHoverCardTrigger.razor b/src/BlazorBlueprint.Primitives/Primitives/HoverCard/BbHoverCardTrigger.razor index 56790b00f..cd7e5877a 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/HoverCard/BbHoverCardTrigger.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/HoverCard/BbHoverCardTrigger.razor @@ -1,7 +1,10 @@ @namespace BlazorBlueprint.Primitives.HoverCard @using System.Timers @using BlazorBlueprint.Primitives.Utilities +@using Microsoft.Extensions.Logging @implements IDisposable +@inject ILogger Logger +@inject IServiceProvider Services @* HoverCardTrigger primitive - trigger element that opens on hover *@ @if (AsChild) @@ -51,10 +54,22 @@ else public RenderFragment? ChildContent { get; set; } /// - /// When true, the trigger does not render its own div element. - /// Instead, it passes trigger behavior via TriggerContext to child components. - /// The child component must consume TriggerContext and apply hover/focus behavior. + /// When true, the trigger renders no element of its own. It renders only the cascading + /// , and the child component is responsible for + /// consuming that context and wiring the hover card up — applying the trigger id and aria + /// attributes to its own element, and calling OnMouseEnter/OnMouseLeave, + /// OnFocus/OnBlur and SetTriggerElement. /// + /// + /// Because no element and no handlers are rendered in this mode, a child that ignores the + /// context leaves nothing listening for hover or focus and the hover card can never open. + /// Plain markup, text, or a bare icon (for example LucideIcon) needs + /// AsChild="false" so the trigger renders its own div with the handlers + /// attached. Use AsChild="true" only with a child that consumes + /// , such as a component built for the trigger role. + /// An unconsumed context is reported as a warning through ILogger when the app runs + /// in the Development environment. + /// [Parameter] public bool AsChild { get; set; } = false; @@ -69,12 +84,19 @@ else /// private ElementReference? _asChildTriggerRef; + /// + /// The context handed to the child on the most recent render, kept so its consumption can be + /// inspected afterwards. A fresh context is built per render — deliberately, so the child sees + /// current values for IsOpen and the ids — and this always points at the one currently cascaded. + /// + private TriggerContext? lastTriggerContext; + /// /// Context passed to child components when AsChild is true. /// When a parent TriggerContext exists (e.g., from DialogTrigger), merges parent click/aria /// behavior with hover card hover/focus behavior so nested triggers work correctly. /// - private TriggerContext TriggerContext => new() + private TriggerContext TriggerContext => lastTriggerContext = new() { TriggerId = ParentTriggerContext?.TriggerId ?? Context.TriggerId, IsOpen = ParentTriggerContext?.IsOpen ?? Context.IsOpen, @@ -167,8 +189,49 @@ else // propagate this to the context so Content can position relative to it. Context.SetTriggerElement(_asChildTriggerRef.Value); } + + if (firstRender) + { + WarnIfTriggerContextUnconsumed(); + } + } + + /// + /// Development-time diagnostic for the silent failure mode of AsChild: the trigger renders + /// no element and no handlers, so a child that never consumes the cascaded TriggerContext + /// leaves the hover card with nothing to open it — and nothing to indicate why. + /// + /// + /// Consumption is recorded by the context itself, which any read of its members marks (see + /// TriggerContext.NotifyConsumed). Children consume it while they render, which happens + /// within the same render batch, so the answer is settled by the time this runs. Emitted at + /// most once per trigger, and only when the host application reports the Development + /// environment. + /// + private void WarnIfTriggerContextUnconsumed() + { + if (!AsChild || lastTriggerContext is null || lastTriggerContext.WasConsumed) + { + return; + } + + if (!DevelopmentEnvironment.IsDevelopment(Services)) + { + return; + } + + LogUnconsumedTriggerContext(Logger, null); } + private static readonly Action LogUnconsumedTriggerContext = + LoggerMessage.Define(LogLevel.Warning, new EventId(1, "HoverCardTriggerContextUnconsumed"), + "HoverCardTrigger rendered with AsChild=true, but no child consumed the cascaded TriggerContext. " + + "In this mode the trigger renders no element and no hover/focus handlers, so nothing can open this hover card. " + + "Set AsChild=\"false\" to have the trigger render its own wrapper element - which is what plain markup, text " + + "or a bare icon such as LucideIcon needs - or use a child that consumes TriggerContext, such as a Button. " + + "A custom child that only touches the context inside event handlers can call TriggerContext.NotifyConsumed() " + + "to acknowledge it. This warning is only emitted in the Development environment."); + private void HandleMouseEnter() { CancelCloseTimer(); From aea76c267616bdac26f1db7efcca9bb6c05e7390 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Tue, 21 Jul 2026 15:11:47 +0800 Subject: [PATCH 162/188] fix(DataGrid): stop rightward column drags overshooting by one (#434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drop handler took the target's index across the rendered header cells — a list that still contains the column being dragged — while DataGridColumnState.ReorderColumn lifts that column out first and treats its index as an insertion point into what remains. Moving rightwards, the removal shifts everything after the old position down by one, so the index arrived one too high and the column landed a position too far right. The clamp inside ReorderColumn masked the symptom for drops past the final column. JS now reports the drop as the gesture it is — which column received it and which side the pointer was released over — and BbDataGrid.OnColumnReordered resolves that against the entry list with the dragged column already excluded. Naming a column rather than a slot also removes a second mismatch: the header row is not a one-to-one view of the column order, since hidden columns have no header cell and pinned columns are re-partitioned to the edges of the row. ReorderColumn is unchanged; its remove-then-insert convention is now documented on the method. Adds a DataGrid demo example combining reordering with pinned columns and a selection column. --- CHANGELOG.md | 1 + .../DataGrid/reorderable-pinned-columns.txt | 25 ++++++++++ .../Pages/Components/DataGridDemo.razor | 41 +++++++++++++++ .../Components/DataGrid/BbDataGrid.razor.cs | 50 +++++++++++++++++-- .../wwwroot/js/datagrid-columns.js | 22 ++++---- .../DataGrid/DataGridColumnState.cs | 11 +++- 6 files changed, 137 insertions(+), 13 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/reorderable-pinned-columns.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f4b83769..18c6de058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **BbTooltipTrigger: an `AsChild` child that ignores the trigger context now says so** — `BbTooltipTrigger.AsChild` defaults to `true` in the Components layer, and in that mode the trigger renders *no element and no handlers at all* — only a cascading `TriggerContext` that the child is expected to consume and wire the hover/focus behaviour up from. `BbButton` does exactly that, which is why the documented `` composition works. Anything that does not — plain markup, text, or a bare ``, which is the natural thing to reach for in a table cell or beside a field label — produced a trigger with nothing listening for hover, so the tooltip could never open. No exception, no console error, no visual clue: the markup looked right and simply did nothing, and every example on the demo page either set `AsChild="false"` or wrapped a `BbButton`, so nothing on the page contradicted it. The trigger now reports the case: on first render, an `AsChild` trigger whose context was never touched by any child logs a warning through `ILogger` naming both ways out (`AsChild="false"`, or a child that consumes the context). Consumption is recorded by the context itself — reading any of its members marks it, which covers every child that applies the id or aria attributes as it renders, and a custom child that only touches the context inside event handlers can call the new `TriggerContext.NotifyConsumed()` to acknowledge it — so legitimate compositions, including a tooltip trigger nested inside a dialog or popover trigger, stay silent. The warning is gated on the host application reporting the `Development` environment (resolved once, by name, from whichever environment abstraction the render mode registers), so it costs a cached boolean read in production and never reaches anyone's telemetry. The default is deliberately left at `true` for now: flipping it would add a wrapping `span` to every existing correct usage, which is a breaking change held for the next major. ([#425](https://github.com/blazorblueprintui/ui/issues/425)) - **BbDataGrid: a column produced indirectly moved to the end, or vanished from the grid entirely** — Columns register themselves with the grid from their own `OnInitialized`, and the grid appended each one to a list, so column order was really *component-initialisation* order. On an interactive circuit that stops matching declaration order the moment a column is produced indirectly: a column rendered by a wrapper component initialises a render pass later than the columns declared beside it, and so drifted to the end of the grid. (Deriving the column from `BbDataGridPropertyColumn` with `@inherits` was never affected — only wrapping.) Worse, the grid initialises its column state — the ordered, per-column visibility record that the header and body are rendered from — from whichever columns had registered by the first render pass to see any, and then latched. Every column arriving after that point was missing from that record and was therefore dropped from the grid: no header, no cells, no console error, no exception. A column behind an `await` — a wrapper that loads lookup data before rendering its inner column, say — reliably disappeared, and prerender and the interactive circuit disagreed about the columns, so the grid visibly reshuffled as the page came alive. Late registrations are now merged into the existing column state instead of ignored, each one placed next to its neighbours rather than appended, so it appears where it belongs; a user's own reordering and visibility choices are left untouched. Alongside that, `BbDataGridPropertyColumn`, `BbDataGridTemplateColumn` and `BbDataGridHierarchyColumn` gain an **`Order`** parameter that positions a column explicitly, as a zero-based index among the data columns, for cases where initialisation order cannot match declaration order. Columns that leave `Order` unset keep their existing registration order and each column that sets it is inserted at that index — so a grid that sets `Order` nowhere is laid out exactly as before — and the select and expand columns keep their fixed leading positions regardless. ([#424](https://github.com/blazorblueprintui/ui/issues/424)) +- **BbDataGrid: dragging a column rightwards dropped it one position too far** — Dragging a header cell to the right overshot the drop indicator by exactly one column: dropping `Name` onto the right half of `Department` in a `Name, Email, Department, Role, Salary` grid landed it *after* `Role`, and dropping onto the last column pushed it to the very end regardless of which half of the cell was released over. Dragging leftwards was correct, which is what made the fault look so arbitrary in use. The two halves of the reorder had disagreed about whether the dragged column was still in the list. The `drop` handler in `datagrid-columns.js` took the target's index across the rendered header cells — a list that still contains the column being dragged — while `DataGridColumnState.ReorderColumn` lifts that column out *first* and treats the index it is given as an insertion point into what remains. Moving rightwards, the removal shifts everything after the old position down by one, so the index arrived one too high. The clamp inside `ReorderColumn` masked the symptom in exactly one case, the drop past the final column, which is why the grid appeared to behave at the right-hand edge. The JS layer now reports the drop as the gesture it actually is — *which* column received it and *which side* of that column the pointer was released over — and `BbDataGrid.OnColumnReordered` resolves that to a position against the entry list with the dragged column already excluded. Naming a column instead of a slot also removes a second, quieter mismatch: the header row is not a one-to-one view of the column order, because hidden columns have no header cell at all and pinned columns are re-partitioned to the edges of the row irrespective of their stored order, so a header-cell index was never safely translatable in the first place. A grid carrying a right-pinned column now places a drop at the end of the unpinned run correctly rather than parking the column behind the pinned one. Pinned, select and expand columns are untouched by any of this: they remain undraggable and refuse drops, and `ReorderColumn` itself is unchanged, so the programmatic reordering used by `DataGridContext.ReorderColumn` keeps its existing semantics — now spelled out on the method, since the remove-then-insert convention is the thing that was easy to get wrong. The DataGrid demo gains a reordering example that combines pinned columns with a selection column, so the component-level drag path is exercised alongside the positional special cases rather than only in isolation. ([#434](https://github.com/blazorblueprintui/ui/issues/434)) --- diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/reorderable-pinned-columns.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/reorderable-pinned-columns.txt new file mode 100644 index 000000000..693b3bf7e --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/DataGrid/reorderable-pinned-columns.txt @@ -0,0 +1,25 @@ +
    + + + + + + + + + + + Actions + + +
    + Edit +
    +
    +
    +
    +
    +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor index ba97e0056..220071e2b 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DataGridDemo.razor @@ -418,6 +418,47 @@
    + +
    +
    +

    Reordering With Pinned And Selection Columns

    +

    + Reordering coexists with pinning and the selection column. Pinned columns are never draggable and + never accept a drop, so they stay anchored to the edges of the grid — only the unpinned columns + in the middle can be rearranged. The selection checkbox column is likewise fixed in place. + Drag Email, Department, Role or + Age to reorder them between the pinned Name and Actions columns. +

    +
    +
    + + + + + + + + + + + Actions + + +
    + Edit +
    +
    +
    +
    +
    +
    + +
    +
    diff --git a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs index bec46c514..ff219e164 100644 --- a/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs +++ b/src/BlazorBlueprint.Components/Components/DataGrid/BbDataGrid.razor.cs @@ -2901,11 +2901,55 @@ public async Task OnResizeCompleted(string resizedColumnId, Dictionary - /// Called from JS when a column is reordered via drag-and-drop. - /// + /// Called from JS when a column is dropped onto another header cell during a reorder drag. + /// + /// The column being dragged. + /// The column whose header cell received the drop. + /// + /// True when the pointer was released on the right half of the target (drop after it), + /// false for the left half (drop before it). + /// + /// + /// + /// This method is the single owner of the conversion from a drop gesture into a + /// position. Keep it that way: the JS layer deliberately + /// reports only which column was dropped on and on which side of it, never a + /// positional index. A header-cell index cannot be translated safely, because the header row is + /// not a one-to-one view of the column order — hidden columns have no header cell at all, and + /// pinned columns are re-partitioned to the edges of the row by + /// PartitionByPinning regardless of their stored order. + /// + /// + /// has remove-then-insert semantics: the dragged + /// column is lifted out of the order first, and its newIndex argument is the insertion + /// point in what remains. The index is therefore resolved here against the entry list with the + /// dragged column already excluded. Resolving it against a list that still contained the dragged + /// column is what made rightward drags land one position too far to the right (issue #434). + /// + /// [JSInvokable] - public async Task OnColumnReordered(string columnId, int newIndex) + public async Task OnColumnReordered(string columnId, string targetColumnId, bool placeAfter) { + if (string.IsNullOrEmpty(columnId) || columnId == targetColumnId) + { + return; + } + + // The order the columns will be in once the dragged one is lifted out. + var remaining = _gridState.Columns.Entries + .Where(e => e.ColumnId != columnId) + .OrderBy(e => e.Order) + .Select(e => e.ColumnId) + .ToList(); + + var targetIndex = remaining.IndexOf(targetColumnId); + if (targetIndex < 0) + { + return; + } + + var newIndex = placeAfter ? targetIndex + 1 : targetIndex; + _gridState.Columns.ReorderColumn(columnId, newIndex); _stateVersion++; diff --git a/src/BlazorBlueprint.Components/wwwroot/js/datagrid-columns.js b/src/BlazorBlueprint.Components/wwwroot/js/datagrid-columns.js index 89ef1ed8c..a1d90fce0 100644 --- a/src/BlazorBlueprint.Components/wwwroot/js/datagrid-columns.js +++ b/src/BlazorBlueprint.Components/wwwroot/js/datagrid-columns.js @@ -328,19 +328,23 @@ export function setupDraggableHeaders(gridId, reorderableColumnIds) { // Do not allow dropping onto a pinned column if (th.getAttribute('data-pinned') === 'true') return; - // Determine target index from visible header cells - const headerCells = Array.from( - table.querySelectorAll('thead th[data-column-id]') - ); - const targetIndex = headerCells.indexOf(th); - - // Adjust based on drop position (before or after the target) + const targetColumnId = th.getAttribute('data-column-id'); + if (!targetColumnId || targetColumnId === state.dragColumnId) return; + + // Report the drop as a *gesture* — "put the dragged column before / after + // this column" — and let Blazor resolve it to a position in the column + // state. Deliberately do NOT send a header-cell index: the header row is + // not a 1:1 view of the column order (hidden columns are absent from the + // DOM, pinned columns are re-partitioned to the edges of the row), and the + // dragged column is still in the DOM here while the .NET side removes it + // before re-inserting. Sending a raw index is what made rightward drags + // overshoot by one. See BbDataGrid.OnColumnReordered for the resolution. const rect = th.getBoundingClientRect(); const midX = rect.left + rect.width / 2; - const adjustedIndex = e.clientX < midX ? targetIndex : targetIndex + 1; + const placeAfter = e.clientX >= midX; state.dotNetRef.invokeMethodAsync('OnColumnReordered', - state.dragColumnId, adjustedIndex).catch(() => { }); + state.dragColumnId, targetColumnId, placeAfter).catch(() => { }); // Don't null dragColumnId/dragTh here — dragend always fires after drop // and handles cleanup + opacity reset. diff --git a/src/BlazorBlueprint.Primitives/Primitives/DataGrid/DataGridColumnState.cs b/src/BlazorBlueprint.Primitives/Primitives/DataGrid/DataGridColumnState.cs index fae11bd73..611fc17da 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/DataGrid/DataGridColumnState.cs +++ b/src/BlazorBlueprint.Primitives/Primitives/DataGrid/DataGridColumnState.cs @@ -140,7 +140,16 @@ public void SetWidth(string columnId, string? width) /// Moves a column to a new position in the order. /// /// The column ID to move. - /// The new zero-based position. + /// + /// The new zero-based position, interpreted with remove-then-insert semantics: the column is + /// lifted out of the order first, and is the insertion point in the + /// remaining entries. Callers that derive the index from a list which still contains the moved + /// column must subtract one when moving rightwards, or the column overshoots by a position. + /// + /// + /// Positions cover every tracked column, including hidden ones. An index taken from the rendered + /// header row is therefore not interchangeable with an index here. + /// public void ReorderColumn(string columnId, int newIndex) { var entry = entries.FirstOrDefault(e => e.ColumnId == columnId); From 24bd5694db19ed2018b17cce28e73fcf914de753 Mon Sep 17 00:00:00 2001 From: "C. Holmes" Date: Sat, 25 Jul 2026 12:13:01 +0200 Subject: [PATCH 163/188] fix(ColorPicker): use InvariantCulture for numeric formatting to support locale with comma decimal separator --- .../Components/ColorPicker/BbColorPicker.razor | 9 +++++---- .../Components/ColorPicker/ColorUtils.cs | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/BlazorBlueprint.Components/Components/ColorPicker/BbColorPicker.razor b/src/BlazorBlueprint.Components/Components/ColorPicker/BbColorPicker.razor index d56931814..0eee7d1dc 100644 --- a/src/BlazorBlueprint.Components/Components/ColorPicker/BbColorPicker.razor +++ b/src/BlazorBlueprint.Components/Components/ColorPicker/BbColorPicker.razor @@ -1,6 +1,7 @@ @namespace BlazorBlueprint.Components @using Microsoft.AspNetCore.Components.Forms @using System.Linq.Expressions +@using System.Globalization @inject IJSRuntime JS @implements IAsyncDisposable @@ -21,9 +22,9 @@ @* Color Area (Saturation/Brightness) *@
    + style="background: linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(@_hue.ToString(CultureInfo.InvariantCulture), 100%, 50%));">
    + style="left: @((_saturation * 100).ToString("0.##", CultureInfo.InvariantCulture))%; top: @(((1 - _brightness) * 100).ToString("0.##", CultureInfo.InvariantCulture))%;">
    @@ -33,7 +34,7 @@ class="relative h-3 w-full rounded-md cursor-pointer touch-none" style="background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%);">
    + style="left: @((_hue / 360 * 100).ToString("0.##", CultureInfo.InvariantCulture))%;">
    @@ -46,7 +47,7 @@ class="relative h-3 w-full rounded-md cursor-pointer touch-none" style="background: linear-gradient(to right, transparent, @CurrentColorWithoutAlpha), url('data:image/svg+xml,');">
    + style="left: @((_alpha * 100).ToString("0.##", CultureInfo.InvariantCulture))%;">
    diff --git a/src/BlazorBlueprint.Components/Components/ColorPicker/ColorUtils.cs b/src/BlazorBlueprint.Components/Components/ColorPicker/ColorUtils.cs index 4090f462f..b0a2aee28 100644 --- a/src/BlazorBlueprint.Components/Components/ColorPicker/ColorUtils.cs +++ b/src/BlazorBlueprint.Components/Components/ColorPicker/ColorUtils.cs @@ -235,7 +235,7 @@ public static string ToRgbString(int r, int g, int b, int? a = null) { if (a.HasValue && a.Value < 255) { - return $"rgba({r}, {g}, {b}, {a.Value / 255.0:F2})"; + return $"rgba({r}, {g}, {b}, {(a.Value / 255.0).ToString("F2", CultureInfo.InvariantCulture)})"; } return $"rgb({r}, {g}, {b})"; } @@ -247,7 +247,7 @@ public static string ToHslString(double h, double s, double l, int? a = null) { if (a.HasValue && a.Value < 255) { - return $"hsla({h:F0}, {s * 100:F0}%, {l * 100:F0}%, {a.Value / 255.0:F2})"; + return $"hsla({h:F0}, {s * 100:F0}%, {l * 100:F0}%, {(a.Value / 255.0).ToString("F2", CultureInfo.InvariantCulture)})"; } return $"hsl({h:F0}, {s * 100:F0}%, {l * 100:F0}%)"; } From 4193b3e63f900b841d8942ddd4dac951c349b9bd Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Mon, 3 Aug 2026 13:55:16 +0800 Subject: [PATCH 164/188] fix(ColorPicker): use fixed-point format for hue and invariant culture in ToHslString Follow-up to the culture-invariance fix: - _hue used the default "G" format, which emits scientific notation below 1e-5 (e.g. 9.9E-06) and produces invalid CSS. Use "0.##" to match the saturation, brightness and alpha positioning. - ToHslString formatted h/s/l on the current culture. F0 emits no decimal separator so this was not a live bug, but it leaves the function consistently invariant. --- .../Components/ColorPicker/BbColorPicker.razor | 2 +- .../Components/ColorPicker/ColorUtils.cs | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/BlazorBlueprint.Components/Components/ColorPicker/BbColorPicker.razor b/src/BlazorBlueprint.Components/Components/ColorPicker/BbColorPicker.razor index 0eee7d1dc..7886b43f4 100644 --- a/src/BlazorBlueprint.Components/Components/ColorPicker/BbColorPicker.razor +++ b/src/BlazorBlueprint.Components/Components/ColorPicker/BbColorPicker.razor @@ -22,7 +22,7 @@ @* Color Area (Saturation/Brightness) *@
    + style="background: linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(@_hue.ToString("0.##", CultureInfo.InvariantCulture), 100%, 50%));">
    diff --git a/src/BlazorBlueprint.Components/Components/ColorPicker/ColorUtils.cs b/src/BlazorBlueprint.Components/Components/ColorPicker/ColorUtils.cs index b0a2aee28..3ce78b98e 100644 --- a/src/BlazorBlueprint.Components/Components/ColorPicker/ColorUtils.cs +++ b/src/BlazorBlueprint.Components/Components/ColorPicker/ColorUtils.cs @@ -245,10 +245,14 @@ public static string ToRgbString(int r, int g, int b, int? a = null) /// public static string ToHslString(double h, double s, double l, int? a = null) { + var hue = h.ToString("F0", CultureInfo.InvariantCulture); + var sat = (s * 100).ToString("F0", CultureInfo.InvariantCulture); + var lum = (l * 100).ToString("F0", CultureInfo.InvariantCulture); + if (a.HasValue && a.Value < 255) { - return $"hsla({h:F0}, {s * 100:F0}%, {l * 100:F0}%, {(a.Value / 255.0).ToString("F2", CultureInfo.InvariantCulture)})"; + return $"hsla({hue}, {sat}%, {lum}%, {(a.Value / 255.0).ToString("F2", CultureInfo.InvariantCulture)})"; } - return $"hsl({h:F0}, {s * 100:F0}%, {l * 100:F0}%)"; + return $"hsl({hue}, {sat}%, {lum}%)"; } } From 123ff452dbefdf0b94c0337ea75adf306f690959 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Mon, 3 Aug 2026 14:08:43 +0800 Subject: [PATCH 165/188] fix(RangeSlider): use InvariantCulture for tick mark positioning Tick marks interpolated a raw double into an inline style attribute, so the percentage was formatted with the current culture. Under locales using a comma decimal separator this emitted invalid CSS (left: 33,333333333333336%) and every tick collapsed to the left edge. Also switches the start/end/range percentages from the default "G" format to "0.##". They were already invariant, but G emits scientific notation below 1e-5, so a thumb just above Min on a large range rendered left: 1E-07% - also invalid CSS. --- .../Components/RangeSlider/BbRangeSlider.razor | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/BlazorBlueprint.Components/Components/RangeSlider/BbRangeSlider.razor b/src/BlazorBlueprint.Components/Components/RangeSlider/BbRangeSlider.razor index 3ea7a82db..fdded4b8b 100644 --- a/src/BlazorBlueprint.Components/Components/RangeSlider/BbRangeSlider.razor +++ b/src/BlazorBlueprint.Components/Components/RangeSlider/BbRangeSlider.razor @@ -60,7 +60,7 @@ { var tickPercent = ((tick - Min) / (Max - Min)) * 100;
    + style="left: @(tickPercent.ToString("0.##", CultureInfo.InvariantCulture))%; transform: translateX(-50%);">
    } } @@ -184,9 +184,9 @@ private double StartPercentage => ((StartValue - Min) / (Max - Min)) * 100; private double EndPercentage => ((EndValue - Min) / (Max - Min)) * 100; - private string StartPercentageValue => $"{StartPercentage.ToString(CultureInfo.InvariantCulture)}%"; - private string EndPercentageValue => $"{EndPercentage.ToString(CultureInfo.InvariantCulture)}%"; - private string RangePercentageValue => $"{(EndPercentage - StartPercentage).ToString(CultureInfo.InvariantCulture)}%"; + private string StartPercentageValue => $"{StartPercentage.ToString("0.##", CultureInfo.InvariantCulture)}%"; + private string EndPercentageValue => $"{EndPercentage.ToString("0.##", CultureInfo.InvariantCulture)}%"; + private string RangePercentageValue => $"{(EndPercentage - StartPercentage).ToString("0.##", CultureInfo.InvariantCulture)}%"; protected override void OnInitialized() { From 9c29e891cac712f95159c619059b067bd516426e Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Mon, 3 Aug 2026 14:18:50 +0800 Subject: [PATCH 166/188] docs(changelog): add entries for the ColorPicker and RangeSlider locale fixes Both #436 and #444 landed without a CHANGELOG entry. --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94203bcf2..bbe36b06b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## 2026-08-03 + +### Fixed + +- **BbColorPicker: the picker was unusable in locales that write decimals with a comma** — The component built its inline styles by interpolating raw `double` values straight into the style string, and Razor renders a bare `@someDouble` through the *current* culture. Under `fr-FR`, `de-DE`, `es-ES`, `pt-BR` and every other comma-decimal locale, the saturation/brightness gradient came out as `hsl(200,5, 100%, 50%)` and the thumb positions as `left: 33,333333333333336%` — the extra comma turns each into a syntax error, so the browser discarded the declaration outright. The result was a color area with no hue gradient and hue, saturation and alpha handles all pinned to the left edge: the picker rendered, took clicks, and simply never reflected the color. Nothing logged, because invalid CSS is dropped silently. All five style interpolations, plus the alpha term in `ToRgbString` and `ToHslString`, now format through `CultureInfo.InvariantCulture` so the separator is a dot regardless of the ambient culture. The parsing side was already invariant — `ParseHex` reads through `NumberStyles.HexNumber` with `InvariantCulture` — so only output was affected. Hue additionally formats as `"0.##"` rather than the default `"G"`, which switches to scientific notation below 1e-5 and would emit `hsl(9.9E-06, …)`, invalid for the same reason. Contributed by [@CholmesFr](https://github.com/CholmesFr). ([#436](https://github.com/blazorblueprintui/ui/pull/436)) +- **BbRangeSlider: tick marks collapsed onto the left edge in the same comma-decimal locales** — The identical bug, in the one place in the slider that had been missed: `StartPercentageValue`, `EndPercentageValue` and `RangePercentageValue` already formatted through `CultureInfo.InvariantCulture`, but the tick-mark loop interpolated its computed percentage directly, so a tick at one third rendered `left: 33,333333333333336%` and every tick stacked at position zero while the thumbs and the active range — which went through the invariant properties — sat correctly. Only sliders that set `TickValues` with `ShowTickMarks` were affected, which is why it survived alongside code that had already been fixed. The tick percentage now formats through `InvariantCulture`. The three existing percentage properties are also switched from the default `"G"` format to `"0.##"`: they were never affected by the separator, but `G` emits scientific notation below 1e-5, so a thumb resting just above `Min` on a wide range (`Min="0" Max="1000000000"`, value `1`) rendered `left: 1E-07%` — invalid CSS by a different route. A sweep of the component and primitive layers found no other raw numeric interpolation into a style attribute. ([#443](https://github.com/blazorblueprintui/ui/issues/443)) + +--- + ## 2026-07-21 ### Added From 929b18b09ea1618e793a7ac0949ecf9589dbc1fe Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Mon, 3 Aug 2026 16:25:27 +0800 Subject: [PATCH 167/188] fix(select,popover,dropdown): stop a close/teardown race killing the circuit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CleanupAsync has two callers — the context state-change handler and DisposeAsync — so a close racing a teardown puts both in flight. Each block checked its field for null, awaited, then dereferenced the field again past the await; the second caller nulled it meanwhile, so the first resumed onto null. NullReferenceException matches none of the JS-interop catch filters guarding those calls, so it escaped CleanupAsync, escaped an unguarded await in DisposeAsync, and reached the renderer's disposal queue, where an unhandled exception tears down the whole Blazor Server circuit. Each block now takes ownership of what it releases, clearing the field before the first await. DisposeAsync guards its cleanup call and returns early when already disposed. BbDropdownMenuContent also read Context.ContentId through a null! cascading parameter that is absent once its provider is gone — a second route to the same crash. --- CHANGELOG.md | 1 + .../DropdownMenu/BbDropdownMenuContent.razor | 51 ++++++++++++++----- .../Primitives/Popover/BbPopoverContent.razor | 33 +++++++++--- .../Primitives/Select/BbSelectContent.razor | 38 +++++++++++--- 4 files changed, 95 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db8391d8b..d0315f5a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - **BbColorPicker: the picker was unusable in locales that write decimals with a comma** — The component built its inline styles by interpolating raw `double` values straight into the style string, and Razor renders a bare `@someDouble` through the *current* culture. Under `fr-FR`, `de-DE`, `es-ES`, `pt-BR` and every other comma-decimal locale, the saturation/brightness gradient came out as `hsl(200,5, 100%, 50%)` and the thumb positions as `left: 33,333333333333336%` — the extra comma turns each into a syntax error, so the browser discarded the declaration outright. The result was a color area with no hue gradient and hue, saturation and alpha handles all pinned to the left edge: the picker rendered, took clicks, and simply never reflected the color. Nothing logged, because invalid CSS is dropped silently. All five style interpolations, plus the alpha term in `ToRgbString` and `ToHslString`, now format through `CultureInfo.InvariantCulture` so the separator is a dot regardless of the ambient culture. The parsing side was already invariant — `ParseHex` reads through `NumberStyles.HexNumber` with `InvariantCulture` — so only output was affected. Hue additionally formats as `"0.##"` rather than the default `"G"`, which switches to scientific notation below 1e-5 and would emit `hsl(9.9E-06, …)`, invalid for the same reason. Contributed by [@CholmesFr](https://github.com/CholmesFr). ([#436](https://github.com/blazorblueprintui/ui/pull/436)) +- **BbSelect, BbPopover and BbDropdownMenu: closing one as its page was torn down could kill the Blazor Server circuit** — `CleanupAsync` in each of these primitives has two callers: the context state-change handler that runs when the overlay closes, and `DisposeAsync`. A close racing a teardown — navigating away from a page with an open select, or a conditional render removing one — puts both in flight at once, and each block checked its field for null, awaited, and then dereferenced the *field* again on the far side of that await. The second caller ran to completion while the first was suspended and nulled the field, so the first resumed and dereferenced null. The resulting `NullReferenceException` matched none of the `JSDisconnectedException`/`JSException`/`TaskCanceledException`/`ObjectDisposedException` filters guarding those calls, so it escaped `CleanupAsync`, escaped an unguarded `await CleanupAsync()` in `DisposeAsync`, and surfaced inside the renderer's disposal queue — where an unhandled exception is not a logged error but a dead circuit, giving every user on that page the reconnect banner. It was rare and non-deterministic by nature, needing the two calls to interleave on a live circuit: one production app saw 16 occurrences across 10 users in four months. Each cleanup block now takes ownership of what it is about to release, clearing the field *before* the first await, so a second caller finds nothing to do instead of racing for the same reference; `DisposeAsync` additionally guards its cleanup call and returns early if already disposed, so a teardown-time failure can no longer reach the renderer whatever its cause. `BbDropdownMenuContent` also read `Context.ContentId` through a cascading parameter declared non-null that is genuinely absent once its provider is gone — a second route to the same crash, now read defensively. Reported with a production stack trace by [@cscaminaci](https://github.com/cscaminaci). ([#441](https://github.com/blazorblueprintui/ui/issues/441)) - **BbRangeSlider: tick marks collapsed onto the left edge in the same comma-decimal locales** — The identical bug, in the one place in the slider that had been missed: `StartPercentageValue`, `EndPercentageValue` and `RangePercentageValue` already formatted through `CultureInfo.InvariantCulture`, but the tick-mark loop interpolated its computed percentage directly, so a tick at one third rendered `left: 33,333333333333336%` and every tick stacked at position zero while the thumbs and the active range — which went through the invariant properties — sat correctly. Only sliders that set `TickValues` with `ShowTickMarks` were affected, which is why it survived alongside code that had already been fixed. The tick percentage now formats through `InvariantCulture`. The three existing percentage properties are also switched from the default `"G"` format to `"0.##"`: they were never affected by the separator, but `G` emits scientific notation below 1e-5, so a thumb resting just above `Min` on a wide range (`Min="0" Max="1000000000"`, value `1`) rendered `left: 1E-07%` — invalid CSS by a different route. A sweep of the component and primitive layers found no other raw numeric interpolation into a style attribute. ([#443](https://github.com/blazorblueprintui/ui/issues/443)) --- diff --git a/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/BbDropdownMenuContent.razor b/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/BbDropdownMenuContent.razor index ed39cf0e5..fcbd441c9 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/BbDropdownMenuContent.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/DropdownMenu/BbDropdownMenuContent.razor @@ -350,27 +350,39 @@ } } + // Cleanup has two callers — the close path and DisposeAsync — and a close racing a teardown + // puts both in flight at once. Each block therefore takes ownership of what it is about to + // release, clearing the field before the first await, so the second caller sees nothing to do + // rather than dereferencing a field the first one nulled while suspended. Reading the field + // after an await instead threw a NullReferenceException, which no filter here catches, so it + // escaped DisposeAsync into the renderer's disposal queue and took the circuit down (#441). private async Task CleanupAsync() { - if (_clickOutsideCleanup != null) + var clickOutsideCleanup = _clickOutsideCleanup; + if (clickOutsideCleanup != null) { + _clickOutsideCleanup = null; + try { - await _clickOutsideCleanup.InvokeVoidAsync("dispose"); - await _clickOutsideCleanup.DisposeAsync(); + await clickOutsideCleanup.InvokeVoidAsync("dispose"); + await clickOutsideCleanup.DisposeAsync(); } catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException) { // Cleanup may already be disposed or circuit disconnected } - _clickOutsideCleanup = null; } - if (_menuKeyboardModule != null) + // Context is a cascading parameter declared non-null, but it is genuinely absent when the + // content is torn down after its provider, so read the id defensively rather than trusting + // the annotation on a path that must not throw. + var contentId = Context?.ContentId; + if (_menuKeyboardModule != null && contentId != null) { try { - await _menuKeyboardModule.InvokeVoidAsync("dispose", Context.ContentId); + await _menuKeyboardModule.InvokeVoidAsync("dispose", contentId); } catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException) { @@ -378,33 +390,46 @@ } } - if (_matchWidthCleanup != null) + var matchWidthCleanup = _matchWidthCleanup; + if (matchWidthCleanup != null) { + _matchWidthCleanup = null; + try { - await _matchWidthCleanup.InvokeVoidAsync("dispose"); - await _matchWidthCleanup.DisposeAsync(); + await matchWidthCleanup.InvokeVoidAsync("dispose"); + await matchWidthCleanup.DisposeAsync(); } catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException) { // Cleanup may already be disposed or circuit disconnected } - _matchWidthCleanup = null; } // Dispose DotNetObjectReference to prevent stale callbacks - if (_dotNetRef != null) + var dotNetRef = _dotNetRef; + if (dotNetRef != null) { - _dotNetRef.Dispose(); _dotNetRef = null; + dotNetRef.Dispose(); } } public async ValueTask DisposeAsync() { + if (_disposed) { return; } _disposed = true; - await CleanupAsync(); + try + { + await CleanupAsync(); + } + catch (Exception) + { + // Disposal runs inside the renderer's disposal queue, where anything that escapes + // is unhandled and tears down the whole circuit. A teardown-time cleanup failure is + // not worth that, so it stops here — the races it can hit are handled above. + } if (_clickOutsideModule != null) { diff --git a/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopoverContent.razor b/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopoverContent.razor index f0743ae74..5478e500c 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopoverContent.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopoverContent.razor @@ -302,36 +302,55 @@ } } + // Cleanup has two callers — the close path and DisposeAsync — and a close racing a teardown + // puts both in flight at once. It therefore takes ownership of what it is about to release, + // clearing the field before the first await, so the second caller sees nothing to do rather + // than dereferencing a field the first one nulled while suspended. Reading the field after + // an await instead threw a NullReferenceException, which no filter here catches, so it + // escaped DisposeAsync into the renderer's disposal queue and took the circuit down (#441). private async Task CleanupAsync() { // Remove click-outside listener by invoking dispose method - if (_clickOutsideCleanup != null) + var clickOutsideCleanup = _clickOutsideCleanup; + if (clickOutsideCleanup != null) { + _clickOutsideCleanup = null; + try { - await _clickOutsideCleanup.InvokeVoidAsync("dispose"); - await _clickOutsideCleanup.DisposeAsync(); + await clickOutsideCleanup.InvokeVoidAsync("dispose"); + await clickOutsideCleanup.DisposeAsync(); } catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException) { // Cleanup may already be disposed or circuit disconnected } - _clickOutsideCleanup = null; } // Dispose DotNetObjectReference to prevent stale callbacks - if (_dotNetRef != null) + var dotNetRef = _dotNetRef; + if (dotNetRef != null) { - _dotNetRef.Dispose(); _dotNetRef = null; + dotNetRef.Dispose(); } } public async ValueTask DisposeAsync() { + if (_disposed) { return; } _disposed = true; - await CleanupAsync(); + try + { + await CleanupAsync(); + } + catch (Exception) + { + // Disposal runs inside the renderer's disposal queue, where anything that escapes + // is unhandled and tears down the whole circuit. A teardown-time cleanup failure is + // not worth that, so it stops here — the races it can hit are handled above. + } if (_clickOutsideModule != null) { diff --git a/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectContent.razor b/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectContent.razor index 8928defd5..dbd29fcf9 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectContent.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Select/BbSelectContent.razor @@ -251,35 +251,47 @@ return null; } + // Cleanup has two callers — the context state-change handler when the listbox closes, and + // DisposeAsync when the component is torn down — and a close racing a teardown puts both in + // flight at once. Each block therefore takes ownership of what it is about to release, + // clearing the field before the first await, so the second caller sees nothing to do rather + // than dereferencing a field the first one nulled while suspended. Reading the field after + // an await instead threw a NullReferenceException, which no filter here catches, so it + // escaped DisposeAsync into the renderer's disposal queue and took the circuit down (#441). private async Task CleanupAsync() { // Clean up keyboard navigation - if (_jsModule != null && _context != null && _isKeyboardSetup) + var keyboardModule = _isKeyboardSetup ? _jsModule : null; + var contentId = _context?.ContentId; + if (keyboardModule != null && contentId != null) { + _isKeyboardSetup = false; + try { - await _jsModule.InvokeVoidAsync("cleanupKeyboardNavigation", _context.ContentId); + await keyboardModule.InvokeVoidAsync("cleanupKeyboardNavigation", contentId); } catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException) { // JS module may be disposed or circuit disconnected } - _isKeyboardSetup = false; } // Clean up click-outside listener - if (_clickOutsideCleanup != null) + var clickOutsideCleanup = _clickOutsideCleanup; + if (clickOutsideCleanup != null) { + _clickOutsideCleanup = null; + try { - await _clickOutsideCleanup.InvokeVoidAsync("dispose"); - await _clickOutsideCleanup.DisposeAsync(); + await clickOutsideCleanup.InvokeVoidAsync("dispose"); + await clickOutsideCleanup.DisposeAsync(); } catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException) { // Cleanup may already be disposed or circuit disconnected } - _clickOutsideCleanup = null; } } @@ -319,6 +331,7 @@ public async ValueTask DisposeAsync() { + if (_disposed) { return; } _disposed = true; if (_context != null) @@ -326,7 +339,16 @@ _context.OnStateChanged -= HandleContextStateChanged; } - await CleanupAsync(); + try + { + await CleanupAsync(); + } + catch (Exception) + { + // Disposal runs inside the renderer's disposal queue, where anything that escapes + // is unhandled and tears down the whole circuit. A teardown-time cleanup failure is + // not worth that, so it stops here — the races it can hit are handled above. + } if (_clickOutsideModule != null) { From aeda7f0a541b241013741344b4fc690b08baa5e0 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Mon, 3 Aug 2026 17:24:10 +0800 Subject: [PATCH 168/188] docs(sidebar): document the open-state contract and fix wrong parameter types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar has no Open/OpenChanged pair — state is owned by SidebarProvider and reached through the cascaded SidebarContext — but the context, BbSidebar and BbSidebarTrigger were all undocumented, so there was nothing on the page to redirect someone looking for those parameters. Adds reference tables for all three plus a Controlling the Sidebar section and code example covering DefaultOpen, SidebarTrigger and the cascaded context, including the StateChanged subscription and why IsOpen rather than Open is the value to read. Also corrects BbSidebarMenuButton, where Size, Variant and AsChild were documented as string with lowercase defaults when all three are enums, and IsActive as bool when it is bool?; adds the undocumented Href, Match and OnClick. --- CHANGELOG.md | 1 + .../Sidebar/programmatic-control.txt | 50 +++++ .../Pages/Components/SidebarDemo.razor | 200 ++++++++++++++++-- 3 files changed, 238 insertions(+), 13 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sidebar/programmatic-control.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index d0315f5a1..27f04411a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - **BbColorPicker: the picker was unusable in locales that write decimals with a comma** — The component built its inline styles by interpolating raw `double` values straight into the style string, and Razor renders a bare `@someDouble` through the *current* culture. Under `fr-FR`, `de-DE`, `es-ES`, `pt-BR` and every other comma-decimal locale, the saturation/brightness gradient came out as `hsl(200,5, 100%, 50%)` and the thumb positions as `left: 33,333333333333336%` — the extra comma turns each into a syntax error, so the browser discarded the declaration outright. The result was a color area with no hue gradient and hue, saturation and alpha handles all pinned to the left edge: the picker rendered, took clicks, and simply never reflected the color. Nothing logged, because invalid CSS is dropped silently. All five style interpolations, plus the alpha term in `ToRgbString` and `ToHslString`, now format through `CultureInfo.InvariantCulture` so the separator is a dot regardless of the ambient culture. The parsing side was already invariant — `ParseHex` reads through `NumberStyles.HexNumber` with `InvariantCulture` — so only output was affected. Hue additionally formats as `"0.##"` rather than the default `"G"`, which switches to scientific notation below 1e-5 and would emit `hsl(9.9E-06, …)`, invalid for the same reason. Contributed by [@CholmesFr](https://github.com/CholmesFr). ([#436](https://github.com/blazorblueprintui/ui/pull/436)) +- **Sidebar docs: the API reference was incomplete where it mattered most, and wrong in four places** — Someone reaching for `Open`/`OpenChanged` on `BbSidebar` found the parameters absent from the component and absent from the source, with nothing on the page explaining what to use instead. Open state is owned by `BbSidebarProvider` and reached through the cascaded `SidebarContext`, but the context was not documented at all — neither were `BbSidebar` itself nor `BbSidebarTrigger`, the component that answers the question. The reference now covers all three, including the context's full control surface (`IsOpen`, `Open`, `OpenMobile`, `IsMobile`, `Side`, `ToggleSidebar()`, `SetOpen(bool)`, `SetOpenMobile(bool)` and the `StateChanged` event), with a note on why `IsOpen` is the one to read: it resolves to the mobile drawer state below the mobile breakpoint and the desktop state above it, so code that reads `Open` directly is subtly wrong on a phone. A new "Controlling the Sidebar" section and code example show the three routes — `DefaultOpen` for the initial value, `BbSidebarTrigger` from markup, and the cascaded context from your own component, including the `StateChanged` subscription needed if your own markup reflects the open state. Separately, four of the five documented `BbSidebarMenuButton` parameters had the wrong type: `Size`, `Variant` and `AsChild` were listed as `string` with lowercase string defaults when all three are enums (`SidebarMenuButtonSize`, `SidebarMenuButtonVariant`, `SidebarMenuButtonElement`), so following the docs produced a compile error rather than a wrong result; `IsActive` was listed as `bool` when it is `bool?`, where leaving it null is meaningful because active state is then derived from `Href` and `Match`. Those are corrected, and the undocumented `Href`, `Match` and `OnClick` added. ([#442](https://github.com/blazorblueprintui/ui/issues/442)) - **BbSelect, BbPopover and BbDropdownMenu: closing one as its page was torn down could kill the Blazor Server circuit** — `CleanupAsync` in each of these primitives has two callers: the context state-change handler that runs when the overlay closes, and `DisposeAsync`. A close racing a teardown — navigating away from a page with an open select, or a conditional render removing one — puts both in flight at once, and each block checked its field for null, awaited, and then dereferenced the *field* again on the far side of that await. The second caller ran to completion while the first was suspended and nulled the field, so the first resumed and dereferenced null. The resulting `NullReferenceException` matched none of the `JSDisconnectedException`/`JSException`/`TaskCanceledException`/`ObjectDisposedException` filters guarding those calls, so it escaped `CleanupAsync`, escaped an unguarded `await CleanupAsync()` in `DisposeAsync`, and surfaced inside the renderer's disposal queue — where an unhandled exception is not a logged error but a dead circuit, giving every user on that page the reconnect banner. It was rare and non-deterministic by nature, needing the two calls to interleave on a live circuit: one production app saw 16 occurrences across 10 users in four months. Each cleanup block now takes ownership of what it is about to release, clearing the field *before* the first await, so a second caller finds nothing to do instead of racing for the same reference; `DisposeAsync` additionally guards its cleanup call and returns early if already disposed, so a teardown-time failure can no longer reach the renderer whatever its cause. `BbDropdownMenuContent` also read `Context.ContentId` through a cascading parameter declared non-null that is genuinely absent once its provider is gone — a second route to the same crash, now read defensively. Reported with a production stack trace by [@cscaminaci](https://github.com/cscaminaci). ([#441](https://github.com/blazorblueprintui/ui/issues/441)) - **BbRangeSlider: tick marks collapsed onto the left edge in the same comma-decimal locales** — The identical bug, in the one place in the slider that had been missed: `StartPercentageValue`, `EndPercentageValue` and `RangePercentageValue` already formatted through `CultureInfo.InvariantCulture`, but the tick-mark loop interpolated its computed percentage directly, so a tick at one third rendered `left: 33,333333333333336%` and every tick stacked at position zero while the thumbs and the active range — which went through the invariant properties — sat correctly. Only sliders that set `TickValues` with `ShowTickMarks` were affected, which is why it survived alongside code that had already been fixed. The tick percentage now formats through `InvariantCulture`. The three existing percentage properties are also switched from the default `"G"` format to `"0.##"`: they were never affected by the separator, but `G` emits scientific notation below 1e-5, so a thumb resting just above `Min` on a wide range (`Min="0" Max="1000000000"`, value `1`) rendered `left: 1E-07%` — invalid CSS by a different route. A sweep of the component and primitive layers found no other raw numeric interpolation into a style attribute. ([#443](https://github.com/blazorblueprintui/ui/issues/443)) diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sidebar/programmatic-control.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sidebar/programmatic-control.txt new file mode 100644 index 000000000..10e1fd4ee --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sidebar/programmatic-control.txt @@ -0,0 +1,50 @@ + + + + + + ... + + + + + + + + + + + + +

    Currently @(Sidebar.IsOpen ? "open" : "closed")

    + +@code { + [CascadingParameter] + private SidebarContext Sidebar { get; set; } = null!; +} + + + +@implements IDisposable + +@code { + [CascadingParameter] + private SidebarContext Sidebar { get; set; } = null!; + + protected override void OnInitialized() + => Sidebar.StateChanged += OnSidebarChanged; + + private void OnSidebarChanged(object? sender, EventArgs e) + => InvokeAsync(StateHasChanged); + + public void Dispose() + => Sidebar.StateChanged -= OnSidebarChanged; +} + + diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SidebarDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SidebarDemo.razor index f8e0adfb8..41cd70e53 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SidebarDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SidebarDemo.razor @@ -1333,6 +1333,26 @@ +
    +

    Controlling the Sidebar

    +

    + BbSidebar has no + Open / + OpenChanged parameter pair. The open state is owned by + BbSidebarProvider and reached through the cascaded + SidebarContext — set the starting value with + DefaultOpen, toggle from markup with + BbSidebarTrigger, and drive it from your own code by + taking the context as a [CascadingParameter]. +

    +

    + Prefer IsOpen over + Open when reading: it resolves to the mobile drawer state + below the mobile breakpoint and the desktop state above it, so it stays correct on both. +

    + +
    +

    API Reference

    @@ -1392,6 +1412,142 @@
    +
    +

    Sidebar

    +

    The sidebar panel itself. Open state lives on the provider, not here — see SidebarContext below.

    +
    + + + + + + + + + + + + + + + + + + + + + + + +
    PropTypeDefaultDescription
    CollapsiblebooltrueWhether the sidebar can collapse. Set false to pin it open.
    Classstring?nullAdditional CSS classes
    +
    +
    + +
    +

    SidebarTrigger

    +

    Toggles the sidebar. This is the supported way to open and close it from markup.

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    PropTypeDefaultDescription
    OnClickEventCallback<MouseEventArgs>Invoked in addition to toggling, for observing the toggle
    ChildContentRenderFragment?nullReplaces the default toggle icon
    Classstring?nullAdditional CSS classes
    +
    +
    + +
    +

    SidebarContext

    +

    + Cascaded by SidebarProvider. Take it with a + [CascadingParameter] to read or change the open state from your own code. + There is no Open/OpenChanged parameter pair on + Sidebar — state is owned by the provider and reached through this context. +

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    MemberTypeDescription
    IsOpenboolEffective open state — resolves to the mobile or desktop value for the current viewport. Read this one unless you specifically need the other two.
    OpenboolDesktop open state (read-only)
    OpenMobileboolMobile drawer open state (read-only)
    IsMobileboolWhether the viewport is currently below the mobile breakpoint
    SideSidebarSideSide the sidebar renders on
    ToggleSidebar()voidToggles whichever state applies to the current viewport
    SetOpen(bool)voidSets the desktop open state
    SetOpenMobile(bool)voidSets the mobile drawer open state
    StateChangedevent EventHandler?Raised whenever any of the above changes — subscribe to re-render on toggle, and unsubscribe on dispose
    +
    +
    +

    SidebarMenuButton

    Clickable menu item with tooltip support.

    @@ -1414,27 +1570,45 @@ Size - string - "default" - Size variant (sm, default, lg) + SidebarMenuButtonSize + Default + Size variant (Small, Default, Large) Variant - string - "default" - Style variant (default, outline) + SidebarMenuButtonVariant + Default + Style variant (Default, Outline) IsActive - bool - false - Active/selected state + bool? + null + Active/selected state. Leave unset to derive it from Href and Match. - + AsChild - string - "button" - Element type (button, a) + SidebarMenuButtonElement + Button + Element to render (Button, Anchor) + + + Href + string? + null + Navigation target. Setting it renders an anchor and drives IsActive from the current route. + + + Match + NavLinkMatch + Prefix + How Href is matched against the route for active state + + + OnClick + EventCallback<MouseEventArgs> + — + Invoked when the button is clicked From 65182010dc6a4556b48e40982cd954ca319e6bd7 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Mon, 3 Aug 2026 17:53:19 +0800 Subject: [PATCH 169/188] feat(sidebar): add Open/OpenChanged and OpenMobile/OpenMobileChanged to the provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BbSidebarProvider was the only stateful component in the library offering DefaultOpen without a controlled counterpart. Twelve components expose Open alongside OpenChanged and none expose one without the other, so reaching for @bind-Open on a sidebar was applying the library's own convention and finding it absent — the sidebar even binds Open/OpenChanged on the BbSheet it uses for its mobile drawer. Open (bool?) with OpenChanged follows the same nullable discriminator as its siblings: null keeps today's behaviour exactly, with the provider owning state seeded from DefaultOpen and persisted via CookieKey; bound, the consumer's value is the source of truth and the trigger, rail and Ctrl/Cmd+B raise the callback rather than mutating behind the binding. The sidebar carries two independent states, so Open is the desktop one — matching what DefaultOpen has always meant and what the cookie stores — and the mobile drawer gets its own OpenMobile/OpenMobileChanged pair. Either may be controlled without the other. Binding Open turns cookie persistence off, both read and write: with the value owned outside the component a cookie competes with the binding on reload rather than restoring it. Also subscribes to context changes on the prerender and disconnected-circuit init paths, which previously left the provider unsubscribed and so unable to persist state or raise the new callbacks. --- CHANGELOG.md | 4 + .../Sidebar/programmatic-control.txt | 84 +++++++++--- .../Pages/Components/SidebarDemo.razor | 109 ++++++++++++++-- .../Sidebar/BbSidebarProvider.razor | 43 ++++++ .../Sidebar/BbSidebarProvider.razor.cs | 123 +++++++++++++++--- ...entsApiSurfaceMatchesBaseline.verified.txt | 4 + 6 files changed, 320 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27f04411a..dbe2f829a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## 2026-08-03 +### Added + +- **BbSidebarProvider: `Open`, `OpenChanged`, `OpenMobile` and `OpenMobileChanged`** — The sidebar was the one stateful component in the library offering the uncontrolled half of the open-state contract without the controlled half. Twelve components — `BbDialog`, `BbSheet`, `BbPopover`, `BbTooltip`, `BbDrawer`, `BbCollapsible`, `BbDropdownMenu`, `BbHoverCard`, `BbContextMenu`, `BbAlertDialog`, `BbCommandDialog` and `BbSelectValue` — expose `Open` alongside `OpenChanged`, and none expose one without the other, so anyone reaching for `@bind-Open` on a sidebar was applying a convention the rest of the library had taught them and finding only `DefaultOpen`. The sidebar even consumed the pattern internally, rendering its mobile drawer as a `BbSheet` with `Open`/`OpenChanged` bound. Driving it from outside meant taking the cascaded `SidebarContext` as a `[CascadingParameter]`, subscribing to `StateChanged` and calling `InvokeAsync(StateHasChanged)` by hand — workable, and still supported for read-only access, but a lot of ceremony next to a binding. The provider now takes **`Open`** (`bool?`) with **`OpenChanged`**, following the same nullable discriminator as its siblings: leave it null and nothing changes — the provider owns the state, seeded from `DefaultOpen` and persisted through `CookieKey` exactly as before — bind it and the consumer's value becomes the source of truth, with the trigger, the rail and the Ctrl/Cmd + B shortcut all raising the callback instead of changing state behind the binding. Because the sidebar carries **two** independent states, `Open` is the *desktop* one, matching what `DefaultOpen` has always meant and what the cookie has always stored; the mobile drawer gets its own **`OpenMobile`**/**`OpenMobileChanged`** pair, and either may be controlled without the other. Binding `Open` **turns cookie persistence off**, both the read on startup and the write on change: with the value owned outside the component the cookie would compete with the binding on reload rather than restore it, and a consumer who has taken ownership of the state is the one who should decide where it lives. The whole addition is opt-in and non-breaking — an existing `BbSidebarProvider` that sets neither parameter behaves identically. Prompted by [@muheebthewizard](https://github.com/muheebthewizard) going looking for parameters that should have been there. ([#442](https://github.com/blazorblueprintui/ui/issues/442)) + ### Fixed - **BbColorPicker: the picker was unusable in locales that write decimals with a comma** — The component built its inline styles by interpolating raw `double` values straight into the style string, and Razor renders a bare `@someDouble` through the *current* culture. Under `fr-FR`, `de-DE`, `es-ES`, `pt-BR` and every other comma-decimal locale, the saturation/brightness gradient came out as `hsl(200,5, 100%, 50%)` and the thumb positions as `left: 33,333333333333336%` — the extra comma turns each into a syntax error, so the browser discarded the declaration outright. The result was a color area with no hue gradient and hue, saturation and alpha handles all pinned to the left edge: the picker rendered, took clicks, and simply never reflected the color. Nothing logged, because invalid CSS is dropped silently. All five style interpolations, plus the alpha term in `ToRgbString` and `ToHslString`, now format through `CultureInfo.InvariantCulture` so the separator is a dot regardless of the ambient culture. The parsing side was already invariant — `ParseHex` reads through `NumberStyles.HexNumber` with `InvariantCulture` — so only output was affected. Hue additionally formats as `"0.##"` rather than the default `"G"`, which switches to scientific notation below 1e-5 and would emit `hsl(9.9E-06, …)`, invalid for the same reason. Contributed by [@CholmesFr](https://github.com/CholmesFr). ([#436](https://github.com/blazorblueprintui/ui/pull/436)) diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sidebar/programmatic-control.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sidebar/programmatic-control.txt index 10e1fd4ee..f4fe4f133 100644 --- a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sidebar/programmatic-control.txt +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sidebar/programmatic-control.txt @@ -1,7 +1,7 @@ - + - ... @@ -12,21 +12,73 @@ - - - - -

    Currently @(Sidebar.IsOpen ? "open" : "closed")

    + + + + + ... + + + +

    Sidebar is @(_sidebarOpen ? "open" : "closed")

    +
    +
    @code { - [CascadingParameter] - private SidebarContext Sidebar { get; set; } = null!; + private bool _sidebarOpen = true; } - + + + + + ... + + +@code { + private bool _sidebarOpen = true; + + private async Task OnSidebarToggled(bool open) + { + _sidebarOpen = open; + await Prefs.SaveSidebarState(open); // your own persistence + } +} + + + + + + ... + + +@code { + private bool _sidebarOpen = true; + private bool _drawerOpen; +} + + + + + + +

    Currently @(Sidebar.IsOpen ? "open" : "closed")

    @implements IDisposable @@ -34,6 +86,7 @@ [CascadingParameter] private SidebarContext Sidebar { get; set; } = null!; + // Only needed if your own markup reflects the open state, as the

    does. protected override void OnInitialized() => Sidebar.StateChanged += OnSidebarChanged; @@ -43,8 +96,3 @@ public void Dispose() => Sidebar.StateChanged -= OnSidebarChanged; } - - diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SidebarDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SidebarDemo.razor index 41cd70e53..9da39b1f7 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SidebarDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SidebarDemo.razor @@ -1336,20 +1336,78 @@

    Controlling the Sidebar

    - BbSidebar has no - Open / - OpenChanged parameter pair. The open state is owned by - BbSidebarProvider and reached through the cascaded - SidebarContext — set the starting value with - DefaultOpen, toggle from markup with - BbSidebarTrigger, and drive it from your own code by - taking the context as a [CascadingParameter]. + Open state lives on BbSidebarProvider, not on + BbSidebar. Leave it alone and the provider owns it — + DefaultOpen seeds it, + CookieKey persists it, and + BbSidebarTrigger toggles it. Bind + @@bind-Open and your value becomes the source of truth + instead, the same controlled/uncontrolled pattern as Dialog, Sheet, Popover and the rest of the library.

    - Prefer IsOpen over - Open when reading: it resolves to the mobile drawer state - below the mobile breakpoint and the desktop state above it, so it stays correct on both. + Binding Open turns cookie persistence off + — both the read on startup and the write on change. With the state owned outside the component, a cookie would + compete with your bound value on reload rather than restore it, so persist it yourself if you need it to survive.

    +

    + Open is the desktop state, matching + DefaultOpen. Below the mobile breakpoint the sidebar is a + drawer with its own state — bind @@bind-OpenMobile as well + if you need both, and when merely reading from the cascaded + SidebarContext, prefer + IsOpen over + Open: it resolves to whichever applies to the current + viewport, so it stays correct on both. +

    + +
    + @@bind-Open: +
    + + +
    + Bound value: @_controlledOpen — the sidebar's own trigger updates it too +
    + +
    + + + + + Controlled + + + + Home + + + Settings + + + + + + + +
    + +

    + Toggle from either side — the buttons above set the bound value, the trigger raises + OpenChanged, and both stay in sync. +

    +
    +
    +
    +
    +
    @@ -1375,7 +1433,31 @@ DefaultOpen bool true - Initial open state + Initial desktop open state, for uncontrolled usage. Ignored when Open is bound. + + + Open + bool? + null + Desktop open state, for controlled usage with @@bind-Open. Leave null and the provider owns the state. Binding it turns cookie persistence off — see below. + + + OpenChanged + EventCallback<bool> + — + Raised when the desktop state changes, from the trigger, the rail, the keyboard shortcut or SetOpen + + + OpenMobile + bool? + null + Mobile drawer state, for controlled usage with @@bind-OpenMobile. Independent of Open. + + + OpenMobileChanged + EventCallback<bool> + — + Raised when the mobile drawer state changes Variant @@ -1393,7 +1475,7 @@ CookieKey string "sidebar:state" - Cookie key for persistence + Cookie key for persistence. Set null to disable. Ignored while Open is bound. EnableToggleShortcut @@ -1662,6 +1744,7 @@ @code { private bool isBasicCollapsible = true; + private bool _controlledOpen = true; private bool shortcutEnabled = true; private string selectedVersion = "v1.0.1"; diff --git a/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarProvider.razor b/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarProvider.razor index 27583c814..1dc773959 100644 --- a/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarProvider.razor +++ b/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarProvider.razor @@ -24,10 +24,53 @@ /// /// Default open state for the sidebar on desktop. + /// Used when is not bound; ignored in controlled mode. /// [Parameter] public bool DefaultOpen { get; set; } = true; + /// + /// The desktop open state, for controlled usage. + /// Leave null (the default) and the provider owns the state itself, seeded from + /// and persisted through . Bind it — + /// typically with @bind-Open — and your value becomes the source of truth instead. + /// + /// Controlled mode turns cookie persistence off, both the read on startup and the write on + /// change: with the state owned outside the component, a cookie would compete with the bound + /// value on reload rather than restore it. Persist it yourself if you need it to survive. + /// + /// + /// This is the desktop state specifically, matching . The mobile + /// drawer is separate — see . + /// + /// + [Parameter] + public bool? Open { get; set; } + + /// + /// Invoked when the desktop open state changes, whether from the trigger, the rail, the + /// Ctrl/Cmd + B shortcut or . + /// Use with @bind-Open for two-way binding. + /// + [Parameter] + public EventCallback OpenChanged { get; set; } + + /// + /// The mobile drawer open state, for controlled usage. + /// Independent of : below the mobile breakpoint the sidebar renders as a + /// drawer with its own open state, so binding one does not bind the other. Leave null + /// to let the provider own it. + /// + [Parameter] + public bool? OpenMobile { get; set; } + + /// + /// Invoked when the mobile drawer open state changes. + /// Use with @bind-OpenMobile for two-way binding. + /// + [Parameter] + public EventCallback OpenMobileChanged { get; set; } + /// /// The sidebar variant/style. /// diff --git a/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarProvider.razor.cs b/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarProvider.razor.cs index 5eccfc255..b57b2b9af 100644 --- a/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarProvider.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Sidebar/BbSidebarProvider.razor.cs @@ -12,6 +12,31 @@ public partial class BbSidebarProvider private bool lastToggleShortcutEnabled = true; private int instanceId; + // Last values pushed to the parent, so a context change only raises a callback for the state + // that actually moved. Toggling the desktop sidebar must not fire OpenMobileChanged. + private bool lastNotifiedOpen; + private bool lastNotifiedOpenMobile; + private bool initialized; + + /// + /// Whether the desktop open state is owned by the consumer rather than this provider. + /// Both halves of the binding are required: a value with no callback could never be updated + /// from inside, leaving the sidebar unable to respond to its own trigger. + /// + private bool IsOpenControlled => Open.HasValue && OpenChanged.HasDelegate; + + /// + /// Whether the mobile drawer state is owned by the consumer. Independent of + /// — one may be controlled without the other. + /// + private bool IsOpenMobileControlled => OpenMobile.HasValue && OpenMobileChanged.HasDelegate; + + /// + /// Cookie persistence is suppressed while the desktop state is controlled: the consumer owns + /// the value, so a restored cookie would fight the bound value on the next load. + /// + private bool ShouldPersist => !string.IsNullOrEmpty(CookieKey) && !IsOpenControlled; + [Inject] private IJSRuntime JSRuntime { get; set; } = default!; @@ -20,6 +45,23 @@ protected override void OnParametersSet() // Update context when parameters change Context.SetVariant(Variant); Context.SetSide(Side); + + // Push controlled values down. SetOpen/SetOpenMobile no-op when the value is unchanged, + // so the callback raised below cannot bounce back into an update loop. + if (initialized) + { + if (IsOpenControlled && Open!.Value != Context.Open) + { + lastNotifiedOpen = Open.Value; + Context.SetOpen(Open.Value); + } + + if (IsOpenMobileControlled && OpenMobile!.Value != Context.OpenMobile) + { + lastNotifiedOpenMobile = OpenMobile.Value; + Context.SetOpenMobile(OpenMobile.Value); + } + } } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -35,12 +77,14 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // Create a reference to this component for JS callbacks _dotNetRef = DotNetObjectReference.Create(this); - // Initialize sidebar state from cookie if persistence is enabled + // Initialize sidebar state from cookie if persistence is enabled. + // Skipped in controlled mode — the bound value wins, so reading a cookie here + // would only produce a flash of the wrong state before the parent's value applies. bool? savedOpen = null; - if (!string.IsNullOrEmpty(CookieKey)) + if (ShouldPersist) { // Use JsonElement because JS returns bool|null and InvokeAsync can't handle null - var result = await _module.InvokeAsync("getSidebarState", CookieKey); + var result = await _module.InvokeAsync("getSidebarState", CookieKey!); savedOpen = result.ValueKind switch { JsonValueKind.True => true, @@ -49,32 +93,25 @@ protected override async Task OnAfterRenderAsync(bool firstRender) }; } - // Initialize context with saved state or defaults - Context.Initialize( - open: savedOpen ?? DefaultOpen, - variant: Variant, - side: Side - ); + // Initialize context: controlled value first, then cookie, then DefaultOpen + InitializeContext(savedOpen ?? DefaultOpen); // Set up mobile detection and keyboard shortcuts lastToggleShortcutEnabled = EnableToggleShortcut; instanceId = await _module.InvokeAsync("initializeSidebar", _dotNetRef, EnableToggleShortcut); - // Subscribe to state changes for persistence - Context.StateChanged += OnStateChanged; - StateHasChanged(); } catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) { // Expected during circuit disconnect in Blazor Server - Context.Initialize(open: DefaultOpen, variant: Variant, side: Side); + InitializeContext(DefaultOpen); StateHasChanged(); } catch (InvalidOperationException) { // JS interop not available during prerendering - Context.Initialize(open: DefaultOpen, variant: Variant, side: Side); + InitializeContext(DefaultOpen); StateHasChanged(); } } @@ -98,16 +135,70 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } } + /// + /// Seeds the context and subscribes for change notification. Called from every initialization + /// path — including the prerender and disconnected-circuit fallbacks, which previously left the + /// provider unsubscribed and so unable to persist state or raise the change callbacks. + /// + /// + /// Desktop state to use when is not bound: the persisted cookie value if one + /// was read, otherwise . A bound value takes precedence over both. + /// + private void InitializeContext(bool uncontrolledOpen) + { + var open = IsOpenControlled ? Open!.Value : uncontrolledOpen; + + Context.Initialize(open: open, variant: Variant, side: Side); + + if (IsOpenMobileControlled) + { + Context.SetOpenMobile(OpenMobile!.Value); + } + + lastNotifiedOpen = Context.Open; + lastNotifiedOpenMobile = Context.OpenMobile; + + if (!initialized) + { + Context.StateChanged += OnStateChanged; + initialized = true; + } + } + private async void OnStateChanged(object? sender, EventArgs e) { try { + // Raise the binding callbacks before persisting, so a consumer sees the change at the + // same point they would from any other component in the library. Each is compared + // against the last value pushed, so toggling the desktop sidebar does not also raise + // OpenMobileChanged, and a re-entrant update from the parent settles rather than loops. + if (Context.Open != lastNotifiedOpen) + { + lastNotifiedOpen = Context.Open; + + if (OpenChanged.HasDelegate) + { + await OpenChanged.InvokeAsync(Context.Open); + } + } + + if (Context.OpenMobile != lastNotifiedOpenMobile) + { + lastNotifiedOpenMobile = Context.OpenMobile; + + if (OpenMobileChanged.HasDelegate) + { + await OpenMobileChanged.InvokeAsync(Context.OpenMobile); + } + } + // Persist sidebar state to cookie when it changes - if (_module != null && !string.IsNullOrEmpty(CookieKey)) + if (_module != null && ShouldPersist) { try { - await _module.InvokeVoidAsync("saveSidebarState", CookieKey, Context.Open); + await _module.InvokeVoidAsync("saveSidebarState", CookieKey!, Context.Open); } catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) { diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index cbcb48750..090e7e73a 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -3189,6 +3189,10 @@ - DefaultOpen : Boolean - EnableToggleShortcut : Boolean - HeightClass : String + - Open : Boolean? + - OpenChanged : EventCallback + - OpenMobile : Boolean? + - OpenMobileChanged : EventCallback - Side : SidebarSide - Variant : SidebarVariant From 01e56beed2ec8cc733dc79f8fa083ac829a00f78 Mon Sep 17 00:00:00 2001 From: Adrian Cockburn <49987+netclectic@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:24:15 +0100 Subject: [PATCH 170/188] fix(DashboardGrid): stop stale JS->.NET callbacks to disposed grids On Blazor Server, navigation away from a dashboard page, circuit reconnects, and grid re-initialisation logged bursts of JsOnBreakpointChanged/JsOnCompactComplete "no tracked object" errors. - initializeDashboardGrid now disposes a prior instance for the same id (the teardown disposeDashboardGrid already performs) instead of stacking observers on the stale reference - the ResizeObserver (watching document.body) and MutationObserver now self-dispose once the grid element leaves the DOM - the eight invokeMethodAsync call sites share a new invokeSafely helper that drops disposed-reference/JSDisconnectedException rejections and logs everything else as before Closes #440 --- CHANGELOG.md | 1 + .../wwwroot/js/dashboard-grid.js | 71 +++++++++++++------ 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbe2f829a..bdb074cc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Sidebar docs: the API reference was incomplete where it mattered most, and wrong in four places** — Someone reaching for `Open`/`OpenChanged` on `BbSidebar` found the parameters absent from the component and absent from the source, with nothing on the page explaining what to use instead. Open state is owned by `BbSidebarProvider` and reached through the cascaded `SidebarContext`, but the context was not documented at all — neither were `BbSidebar` itself nor `BbSidebarTrigger`, the component that answers the question. The reference now covers all three, including the context's full control surface (`IsOpen`, `Open`, `OpenMobile`, `IsMobile`, `Side`, `ToggleSidebar()`, `SetOpen(bool)`, `SetOpenMobile(bool)` and the `StateChanged` event), with a note on why `IsOpen` is the one to read: it resolves to the mobile drawer state below the mobile breakpoint and the desktop state above it, so code that reads `Open` directly is subtly wrong on a phone. A new "Controlling the Sidebar" section and code example show the three routes — `DefaultOpen` for the initial value, `BbSidebarTrigger` from markup, and the cascaded context from your own component, including the `StateChanged` subscription needed if your own markup reflects the open state. Separately, four of the five documented `BbSidebarMenuButton` parameters had the wrong type: `Size`, `Variant` and `AsChild` were listed as `string` with lowercase string defaults when all three are enums (`SidebarMenuButtonSize`, `SidebarMenuButtonVariant`, `SidebarMenuButtonElement`), so following the docs produced a compile error rather than a wrong result; `IsActive` was listed as `bool` when it is `bool?`, where leaving it null is meaningful because active state is then derived from `Href` and `Match`. Those are corrected, and the undocumented `Href`, `Match` and `OnClick` added. ([#442](https://github.com/blazorblueprintui/ui/issues/442)) - **BbSelect, BbPopover and BbDropdownMenu: closing one as its page was torn down could kill the Blazor Server circuit** — `CleanupAsync` in each of these primitives has two callers: the context state-change handler that runs when the overlay closes, and `DisposeAsync`. A close racing a teardown — navigating away from a page with an open select, or a conditional render removing one — puts both in flight at once, and each block checked its field for null, awaited, and then dereferenced the *field* again on the far side of that await. The second caller ran to completion while the first was suspended and nulled the field, so the first resumed and dereferenced null. The resulting `NullReferenceException` matched none of the `JSDisconnectedException`/`JSException`/`TaskCanceledException`/`ObjectDisposedException` filters guarding those calls, so it escaped `CleanupAsync`, escaped an unguarded `await CleanupAsync()` in `DisposeAsync`, and surfaced inside the renderer's disposal queue — where an unhandled exception is not a logged error but a dead circuit, giving every user on that page the reconnect banner. It was rare and non-deterministic by nature, needing the two calls to interleave on a live circuit: one production app saw 16 occurrences across 10 users in four months. Each cleanup block now takes ownership of what it is about to release, clearing the field *before* the first await, so a second caller finds nothing to do instead of racing for the same reference; `DisposeAsync` additionally guards its cleanup call and returns early if already disposed, so a teardown-time failure can no longer reach the renderer whatever its cause. `BbDropdownMenuContent` also read `Context.ContentId` through a cascading parameter declared non-null that is genuinely absent once its provider is gone — a second route to the same crash, now read defensively. Reported with a production stack trace by [@cscaminaci](https://github.com/cscaminaci). ([#441](https://github.com/blazorblueprintui/ui/issues/441)) - **BbRangeSlider: tick marks collapsed onto the left edge in the same comma-decimal locales** — The identical bug, in the one place in the slider that had been missed: `StartPercentageValue`, `EndPercentageValue` and `RangePercentageValue` already formatted through `CultureInfo.InvariantCulture`, but the tick-mark loop interpolated its computed percentage directly, so a tick at one third rendered `left: 33,333333333333336%` and every tick stacked at position zero while the thumbs and the active range — which went through the invariant properties — sat correctly. Only sliders that set `TickValues` with `ShowTickMarks` were affected, which is why it survived alongside code that had already been fixed. The tick percentage now formats through `InvariantCulture`. The three existing percentage properties are also switched from the default `"G"` format to `"0.##"`: they were never affected by the separator, but `G` emits scientific notation below 1e-5, so a thumb resting just above `Min` on a wide range (`Min="0" Max="1000000000"`, value `1`) rendered `left: 1E-07%` — invalid CSS by a different route. A sweep of the component and primitive layers found no other raw numeric interpolation into a style attribute. ([#443](https://github.com/blazorblueprintui/ui/issues/443)) +- **BbDashboardGrid: stale JS→.NET callbacks to disposed grids flooded the browser console on Blazor Server** — Every navigation away from a page hosting a grid, every circuit reconnect, and every grid re-initialisation logged a burst of `JsOnBreakpointChanged failed` / `JsOnCompactComplete failed` errors — `There is no tracked object with id 'N'. Perhaps the DotNetObjectReference instance was already disposed.` Three compounding causes in `dashboard-grid.js`. The breakpoint `ResizeObserver` watched `document.body` rather than the grid, so it survived the grid's removal from the page and kept invoking the (now disposed) reference on any subsequent body resize, anywhere in the app. `initializeDashboardGrid` overwrote the instance map entry for an id without tearing the previous instance down, stacking a second pair of observers on the stale reference whenever a grid re-initialised. And every callback site logged every rejection with `console.error`, so each of these expected disposal races surfaced as an error even though nothing was actually wrong. Re-initialisation now disposes the existing instance first — `disposeDashboardGrid` already did exactly the right teardown, it was just never called on that path. Both observers now self-dispose once the grid element is no longer connected to the document, which is what actually stops the stale breakpoint callbacks after navigation; the observer deliberately still watches `document.body`, since recalculating breakpoints on body resizes that do not originate from the grid (a sidebar collapse, say) is current behaviour consumers may rely on — only the orphaned-after-disposal case is removed. And the eight `invokeMethodAsync` call sites now go through a shared `invokeSafely` helper that drops disposed-reference and `JSDisconnectedException` rejections silently while logging everything else exactly as before, so a genuine JS→.NET failure stays visible. Contributed by [@netclectic](https://github.com/netclectic). ([#440](https://github.com/blazorblueprintui/ui/issues/440)) --- diff --git a/src/BlazorBlueprint.Components/wwwroot/js/dashboard-grid.js b/src/BlazorBlueprint.Components/wwwroot/js/dashboard-grid.js index 2d1a56e52..0cf9220ca 100644 --- a/src/BlazorBlueprint.Components/wwwroot/js/dashboard-grid.js +++ b/src/BlazorBlueprint.Components/wwwroot/js/dashboard-grid.js @@ -4,14 +4,49 @@ const instances = new Map(); const DRAG_THRESHOLD = 5; +// True when a JS->.NET callback failed because the .NET side is gone — the +// grid's DotNetObjectReference was disposed or the circuit died. That is a +// stale callback racing disposal, not a failure worth reporting. +function isDisposedReferenceError(err) { + const msg = (err && (err.message || err.toString())) || ''; + return msg.includes('no tracked object') + || msg.includes('DotNetObjectReference') + || msg.includes('JSDisconnectedException'); +} + +// Invoke a .NET grid callback. Stale-callback rejections (disposed reference +// or dead circuit) are dropped silently; every other rejection is logged, +// keeping genuine JS->.NET failures visible. +function invokeSafely(state, method, ...args) { + return state.dotNetRef.invokeMethodAsync(method, ...args) + .catch(err => { + if (isDisposedReferenceError(err)) return; + console.error(`${method} failed:`, err); + }); +} + +// The observers can outlive the grid — the resize observer watches +// document.body, not the grid element, and keeps firing after navigation. +// Once the grid element leaves the DOM the instance is orphaned: dispose it +// so its observers stop and no stale .NET callbacks fire. +function disposeIfOrphaned(state) { + if (state.gridEl && state.gridEl.isConnected) return false; + disposeDashboardGrid(state.instanceId); + return true; +} + export function initializeDashboardGrid(dotNetRef, instanceId, options) { if (!dotNetRef) return; + // Dispose the existing instance if re-initializing + if (instances.has(instanceId)) disposeDashboardGrid(instanceId); + const gridEl = document.querySelector(`[data-dashboard-id="${instanceId}"]`); if (!gridEl) return; const state = { dotNetRef, + instanceId, options, gridEl, isDragging: false, @@ -106,6 +141,7 @@ export function disposeDashboardGrid(instanceId) { function setupMutationObserver(state) { state.mutationObserver = new MutationObserver((mutations) => { + if (disposeIfOrphaned(state)) return; if (state.isDragging || state.isResizing) return; // Only react if widget elements were added or removed @@ -175,8 +211,7 @@ function runCompactAndReveal(state) { colSpan: p.colSpan, rowSpan: p.rowSpan })); - state.dotNetRef.invokeMethodAsync('JsOnCompactComplete', dtos) - .catch(err => console.error('JsOnCompactComplete failed:', err)); + invokeSafely(state, 'JsOnCompactComplete', dtos); } // --- Resize Observer for responsive breakpoints --- @@ -235,8 +270,7 @@ function setupResizeObserver(instanceId, state) { const isInitial = state.currentBreakpoint === undefined; const prevBp = state.currentBreakpoint; state.currentBreakpoint = bp; - state.dotNetRef.invokeMethodAsync('JsOnBreakpointChanged', bp) - .catch(err => console.error('JsOnBreakpointChanged failed:', err)); + invokeSafely(state, 'JsOnBreakpointChanged', bp); // Re-compact widgets for the new column count (skip initial detection) if (!isInitial) { @@ -247,6 +281,7 @@ function setupResizeObserver(instanceId, state) { checkBreakpoint(); state.resizeObserver = new ResizeObserver(() => { + if (disposeIfOrphaned(state)) return; checkBreakpoint(); syncGridGuide(state); }); @@ -308,8 +343,7 @@ function syncPositionsToNet(state, positions) { colSpan: p.colSpan, rowSpan: p.rowSpan })); - state.dotNetRef.invokeMethodAsync('JsOnCompactComplete', dtos) - .catch(err => console.error('JsOnCompactComplete (breakpoint) failed:', err)); + invokeSafely(state, 'JsOnCompactComplete', dtos); } // --- Event Listeners --- @@ -565,9 +599,7 @@ function finishDrag(state) { } if (changes.length > 0) { - state.dotNetRef.invokeMethodAsync('JsOnLayoutResolved', - state.activeWidgetId, changes) - .catch(err => console.error('JsOnLayoutResolved failed:', err)); + invokeSafely(state, 'JsOnLayoutResolved', state.activeWidgetId, changes); announceChange(state, `Dashboard layout updated`); } } @@ -724,9 +756,8 @@ function finishResize(state) { if (resizeChanged) { // Send resize event for the resized widget - state.dotNetRef.invokeMethodAsync('JsOnWidgetResizeEnd', - state.activeWidgetId, resized.col, resized.row, resized.colSpan, resized.rowSpan) - .catch(err => console.error('JsOnWidgetResizeEnd failed:', err)); + invokeSafely(state, 'JsOnWidgetResizeEnd', + state.activeWidgetId, resized.col, resized.row, resized.colSpan, resized.rowSpan); // Send layout changes for all displaced widgets const changes = []; @@ -739,9 +770,7 @@ function finishResize(state) { } } if (changes.length > 0) { - state.dotNetRef.invokeMethodAsync('JsOnLayoutResolved', - state.activeWidgetId, changes) - .catch(err => console.error('JsOnLayoutResolved (resize) failed:', err)); + invokeSafely(state, 'JsOnLayoutResolved', state.activeWidgetId, changes); } announceChange(state, `Widget resized to ${resized.colSpan} columns, ${resized.rowSpan} rows`); @@ -812,8 +841,7 @@ function onKeyDown(instanceId, state, e) { applyLayout(grid, resolved); // Send resize for the resized widget - state.dotNetRef.invokeMethodAsync('JsOnWidgetResizeEnd', widgetId, col, row, newColSpan, newRowSpan) - .catch(err => console.error('JsOnWidgetResizeEnd (keyboard) failed:', err)); + invokeSafely(state, 'JsOnWidgetResizeEnd', widgetId, col, row, newColSpan, newRowSpan); // Send position changes for displaced widgets const changes = []; @@ -826,8 +854,7 @@ function onKeyDown(instanceId, state, e) { } } if (changes.length > 0) { - state.dotNetRef.invokeMethodAsync('JsOnLayoutResolved', widgetId, changes) - .catch(err => console.error('JsOnLayoutResolved (keyboard resize) failed:', err)); + invokeSafely(state, 'JsOnLayoutResolved', widgetId, changes); } announceChange(state, `Widget resized to ${newColSpan} columns, ${newRowSpan} rows`); } @@ -882,8 +909,7 @@ function onKeyDown(instanceId, state, e) { } } if (changes.length > 0) { - state.dotNetRef.invokeMethodAsync('JsOnLayoutResolved', widgetId, changes) - .catch(err => console.error('JsOnLayoutResolved (keyboard) failed:', err)); + invokeSafely(state, 'JsOnLayoutResolved', widgetId, changes); } announceChange(state, `Widget moved to column ${newCol}, row ${newRow}`); } @@ -933,8 +959,7 @@ function onWidgetFocusOut(instanceId, state, e) { if (changes.length > 0) { applyLayout(grid, positions); - state.dotNetRef.invokeMethodAsync('JsOnLayoutResolved', '', changes) - .catch(err => console.error('JsOnLayoutResolved (compact on blur) failed:', err)); + invokeSafely(state, 'JsOnLayoutResolved', '', changes); } } From db8787b989a25bae255763371f000f4b9da5acfb Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Tue, 4 Aug 2026 12:35:14 +0800 Subject: [PATCH 171/188] docs: trim CLAUDE.md to what a session can't derive from the codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes content a fresh session reconstructs with a few tool calls, so every session stops paying for it in context: - projects table (ls src/ demos/ tests/ plus the .csproj files) - JS interop paths (ls) - DI service list (grep the registration extension) - versioning section (MinVerTagPrefix is in each .csproj, and the list was missing the three icon packages) - most of Code Style — .editorconfig and Directory.Build.props enforce braces, naming, indent, Allman, var preference and CS8600-CS8625 as build errors - the three standard dotnet build/test invocations - "Current Branch: v3", which was stale: the branch is develop and v3 has shipped Keeps the gotchas and non-derivable conventions: the snapshot accept workflow, Tailwind building during dotnet build, demo host ports, ProjectReference-locally/PackageReference-when-packing, ComponentBase with no custom base class, text inputs not using InputBase, PortalHost in the root layout, and the private-field camelCase rule — kept precisely because .editorconfig does not enforce it and it differs from the common C# _camelCase default. 5,424 -> 3,093 chars. --- CLAUDE.md | 64 +++++-------------------------------------------------- 1 file changed, 5 insertions(+), 59 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d1c8551ec..61adb9c4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,20 +18,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Build & Test Commands ```bash -# Build the Components project (most common during development) -dotnet build src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj --nologo --verbosity quiet - -# Build entire solution -dotnet build - -# Run tests (xunit + Verify snapshot tests) -dotnet test tests/BlazorBlueprint.Tests/BlazorBlueprint.Tests.csproj --verbosity normal - # Accept new API surface snapshots after reviewing .received.txt files # (copy .received.txt → .verified.txt in tests/BlazorBlueprint.Tests/ApiSurface/) ./scripts/run-tests.sh --accept -# Run demo apps +# Demo apps — each host is pinned to its own port dotnet run --project demos/BlazorBlueprint.Demo.Server # port 7172 dotnet run --project demos/BlazorBlueprint.Demo.Wasm # port 7173 dotnet run --project demos/BlazorBlueprint.Demo.Auto # port 7174 @@ -43,39 +34,14 @@ dotnet run --project demos/BlazorBlueprint.Demo.Auto # port 7174 ## Architecture -**Two-layer component library** inspired by shadcn/ui and Radix UI, targeting .NET 8 Blazor (Server, WASM, and Auto render modes): - -### Projects - -| Project | Purpose | -|---------|---------| -| `BlazorBlueprint.Primitives` | Headless, unstyled components with accessibility/keyboard/ARIA. No CSS. | -| `BlazorBlueprint.Components` | Styled components built on Primitives. Ships pre-built Tailwind CSS. | -| `BlazorBlueprint.Icons.*` | Three icon packages: Lucide, Heroicons, Feather | -| `BlazorBlueprint.Demo.Shared` | Shared demo pages/layouts (Razor Class Library) | -| `BlazorBlueprint.Demo.Server/Wasm/Auto` | Thin host projects per render mode | -| `BlazorBlueprint.Tests` | API surface snapshot tests (Verify + xunit) | +**Two-layer component library** inspired by shadcn/ui and Radix UI, targeting .NET 8 Blazor (Server, WASM, and Auto render modes): `Primitives` are headless and unstyled, `Components` are styled and built on top of them. ### Dependency flow `Components` → `Primitives` + `Icons.Lucide` (ProjectReference locally, PackageReference when packing for NuGet). ### Component structure -Each component lives in its own folder under `Components/` or `Primitives/` with: -- `ComponentName.razor` — markup -- `ComponentName.razor.cs` — code-behind (partial class) -- Enum/type files (e.g., `ButtonVariant.cs`, `ButtonSize.cs`) - Components inherit directly from `ComponentBase` (no custom base class). Text inputs (`Input`, `Textarea`, `InputGroupInput`, `InputGroupTextarea`) implement their own `Value`/`ValueChanged` pattern rather than `InputBase`. -### JS Interop -- Primitives JS: `src/BlazorBlueprint.Primitives/wwwroot/js/primitives/` (focus, positioning, portal, scroll-lock, keyboard shortcuts, resize, dismiss) -- Components JS: `src/BlazorBlueprint.Components/wwwroot/js/` (file-upload, slider, resizable, sidebar, etc.) - -### Services (registered via DI) -- `AddBlazorBlueprintComponents()` — registers everything (Components + Primitives) -- `AddBlazorBlueprintPrimitives()` — registers only Primitives services -- Key services: `IPortalService`, `IFocusManager`, `IPositioningService`, `DropdownManagerService`, `IKeyboardShortcutService`, `ToastService` - ### Overlay pattern Overlay components (Dialog, Sheet, Popover, Tooltip, etc.) render through `` which must be placed in the root layout. Uses `IPortalService` + `FloatingPortal` + `IPositioningService` for positioning. @@ -91,28 +57,8 @@ Tests use **Verify** (snapshot testing) to detect unintended public API changes. --- -## Code Style (enforced at build — TreatWarningsAsErrors) - -- **Braces required** on all control flow (IDE0011 enforced as error) -- **Private fields**: `camelCase` (no underscore prefix) -- **Public members/types**: `PascalCase` -- **Constants**: `PascalCase` -- **Interfaces**: `I` prefix -- **Nullable reference types** enabled with null diagnostics as errors (CS8600-CS8625) -- Prefer `var` when type is apparent -- Allman-style braces (new line before `{`) -- Indent: 4 spaces for `.cs`/`.razor`, 2 spaces for `.csproj`/`.json`/`.js`/`.css`/`.yml` - ---- - -## Versioning - -Uses **MinVer** with git tags. Tag prefixes: -- Components: `components/v` (e.g., `components/v3.0.0`) -- Primitives: `primitives/v` (e.g., `primitives/v2.3.2`) - ---- +## Code Style -## Current Branch: v3 +Style is enforced mechanically by `.editorconfig` + `TreatWarningsAsErrors` — the build is the source of truth, so it isn't restated here. The one convention the config does *not* enforce: -Active development branch for v3 with breaking changes. See `V3-MIGRATION-GUIDE.md` for details. Key v3 changes: namespace flattening, Combobox/MultiSelect API redesign, trigger `AsChild` defaults, and new usability parameters across components. +- **Private fields**: `camelCase` with **no underscore prefix** (deliberately unlike the common C# `_camelCase` default) From ed482d30ddeb27903e03d7ace3200d77fd3bb876 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Tue, 4 Aug 2026 13:26:58 +0800 Subject: [PATCH 172/188] fix(copytext): render the tooltip through the portal so containers can't clip it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BbCopyText rendered its tooltip as a nested absolutely-positioned span rather than through the portal every other overlay uses. Absolute positioning is still subject to the overflow of its containing block — z-index exempts an element from stacking, not from clipping — so any ancestor with overflow: hidden|auto cut the tooltip off at its edge. Now rendered through BbFloatingPortal, the same infrastructure behind BbTooltip, so it escapes the container entirely. Uses the fixed positioning strategy rather than absolute: the portal wrapper is itself position: fixed, so a document-relative coordinate gets applied as if viewport-relative and lands off-screen by the page's scroll offset. Verified in the browser — with absolute the tooltip rendered at top: -361 against an anchor at 273; with fixed it sits 8px above the anchor and above the scroll container's top edge. Show/hide triggers, copied state and localized strings are unchanged. The opacity/translate transition went with the old markup since the portal mounts on open rather than keeping a transparent copy in the layout. Brings BbCopyText under the same requirement as the library's other overlays. --- CHANGELOG.md | 1 + .../CopyText/scrollable-container.txt | 12 +++++++++++ .../Pages/Components/CopyTextDemo.razor | 21 +++++++++++++++++++ .../Components/CopyText/BbCopyText.razor | 20 ++++++++++++++++-- .../Components/CopyText/BbCopyText.razor.cs | 15 +++++++------ 5 files changed, 61 insertions(+), 8 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/scrollable-container.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index dbe2f829a..4d3ce8cd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **BbColorPicker: the picker was unusable in locales that write decimals with a comma** — The component built its inline styles by interpolating raw `double` values straight into the style string, and Razor renders a bare `@someDouble` through the *current* culture. Under `fr-FR`, `de-DE`, `es-ES`, `pt-BR` and every other comma-decimal locale, the saturation/brightness gradient came out as `hsl(200,5, 100%, 50%)` and the thumb positions as `left: 33,333333333333336%` — the extra comma turns each into a syntax error, so the browser discarded the declaration outright. The result was a color area with no hue gradient and hue, saturation and alpha handles all pinned to the left edge: the picker rendered, took clicks, and simply never reflected the color. Nothing logged, because invalid CSS is dropped silently. All five style interpolations, plus the alpha term in `ToRgbString` and `ToHslString`, now format through `CultureInfo.InvariantCulture` so the separator is a dot regardless of the ambient culture. The parsing side was already invariant — `ParseHex` reads through `NumberStyles.HexNumber` with `InvariantCulture` — so only output was affected. Hue additionally formats as `"0.##"` rather than the default `"G"`, which switches to scientific notation below 1e-5 and would emit `hsl(9.9E-06, …)`, invalid for the same reason. Contributed by [@CholmesFr](https://github.com/CholmesFr). ([#436](https://github.com/blazorblueprintui/ui/pull/436)) - **Sidebar docs: the API reference was incomplete where it mattered most, and wrong in four places** — Someone reaching for `Open`/`OpenChanged` on `BbSidebar` found the parameters absent from the component and absent from the source, with nothing on the page explaining what to use instead. Open state is owned by `BbSidebarProvider` and reached through the cascaded `SidebarContext`, but the context was not documented at all — neither were `BbSidebar` itself nor `BbSidebarTrigger`, the component that answers the question. The reference now covers all three, including the context's full control surface (`IsOpen`, `Open`, `OpenMobile`, `IsMobile`, `Side`, `ToggleSidebar()`, `SetOpen(bool)`, `SetOpenMobile(bool)` and the `StateChanged` event), with a note on why `IsOpen` is the one to read: it resolves to the mobile drawer state below the mobile breakpoint and the desktop state above it, so code that reads `Open` directly is subtly wrong on a phone. A new "Controlling the Sidebar" section and code example show the three routes — `DefaultOpen` for the initial value, `BbSidebarTrigger` from markup, and the cascaded context from your own component, including the `StateChanged` subscription needed if your own markup reflects the open state. Separately, four of the five documented `BbSidebarMenuButton` parameters had the wrong type: `Size`, `Variant` and `AsChild` were listed as `string` with lowercase string defaults when all three are enums (`SidebarMenuButtonSize`, `SidebarMenuButtonVariant`, `SidebarMenuButtonElement`), so following the docs produced a compile error rather than a wrong result; `IsActive` was listed as `bool` when it is `bool?`, where leaving it null is meaningful because active state is then derived from `Href` and `Match`. Those are corrected, and the undocumented `Href`, `Match` and `OnClick` added. ([#442](https://github.com/blazorblueprintui/ui/issues/442)) +- **BbCopyText: the tooltip was clipped inside scrollable containers** — The component rendered its tooltip as a nested, absolutely-positioned `span` rather than through the portal every other overlay in the library uses. Absolute positioning is still subject to the overflow of its containing block — `z-index` does not exempt it from clipping, only from stacking — so any ancestor with `overflow: hidden` or `overflow: auto` cut the tooltip off at its edge, which in practice meant a copyable id inside a scrolling table or panel showed a sliver of a tooltip or none at all. It now renders through `BbFloatingPortal`, the same infrastructure behind `BbTooltip`, so it escapes the container entirely and behaves the way the reporter reasonably expected it to. Positioning uses the fixed strategy: the portal wrapper is itself `position: fixed`, so an absolute (document-relative) coordinate would be applied as though it were viewport-relative and place the tooltip off-screen by the page's scroll offset — a difference invisible on an unscrolled page and obvious on a scrolled one. The show/hide triggers, the copied state and the localized strings are unchanged; the opacity-and-translate transition went away with the old markup, since the portal mounts the tooltip on open rather than keeping a transparent copy in the layout. Note that this brings `BbCopyText` under the same requirement as the library's other overlays: `` must be present in the root layout. The demo page gains a scrollable-container example covering the reported case. Reported by [@SimonLiebers-Dev](https://github.com/SimonLiebers-Dev). ([#452](https://github.com/blazorblueprintui/ui/issues/452)) - **BbSelect, BbPopover and BbDropdownMenu: closing one as its page was torn down could kill the Blazor Server circuit** — `CleanupAsync` in each of these primitives has two callers: the context state-change handler that runs when the overlay closes, and `DisposeAsync`. A close racing a teardown — navigating away from a page with an open select, or a conditional render removing one — puts both in flight at once, and each block checked its field for null, awaited, and then dereferenced the *field* again on the far side of that await. The second caller ran to completion while the first was suspended and nulled the field, so the first resumed and dereferenced null. The resulting `NullReferenceException` matched none of the `JSDisconnectedException`/`JSException`/`TaskCanceledException`/`ObjectDisposedException` filters guarding those calls, so it escaped `CleanupAsync`, escaped an unguarded `await CleanupAsync()` in `DisposeAsync`, and surfaced inside the renderer's disposal queue — where an unhandled exception is not a logged error but a dead circuit, giving every user on that page the reconnect banner. It was rare and non-deterministic by nature, needing the two calls to interleave on a live circuit: one production app saw 16 occurrences across 10 users in four months. Each cleanup block now takes ownership of what it is about to release, clearing the field *before* the first await, so a second caller finds nothing to do instead of racing for the same reference; `DisposeAsync` additionally guards its cleanup call and returns early if already disposed, so a teardown-time failure can no longer reach the renderer whatever its cause. `BbDropdownMenuContent` also read `Context.ContentId` through a cascading parameter declared non-null that is genuinely absent once its provider is gone — a second route to the same crash, now read defensively. Reported with a production stack trace by [@cscaminaci](https://github.com/cscaminaci). ([#441](https://github.com/blazorblueprintui/ui/issues/441)) - **BbRangeSlider: tick marks collapsed onto the left edge in the same comma-decimal locales** — The identical bug, in the one place in the slider that had been missed: `StartPercentageValue`, `EndPercentageValue` and `RangePercentageValue` already formatted through `CultureInfo.InvariantCulture`, but the tick-mark loop interpolated its computed percentage directly, so a tick at one third rendered `left: 33,333333333333336%` and every tick stacked at position zero while the thumbs and the active range — which went through the invariant properties — sat correctly. Only sliders that set `TickValues` with `ShowTickMarks` were affected, which is why it survived alongside code that had already been fixed. The tick percentage now formats through `InvariantCulture`. The three existing percentage properties are also switched from the default `"G"` format to `"0.##"`: they were never affected by the separator, but `G` emits scientific notation below 1e-5, so a thumb resting just above `Min` on a wide range (`Min="0" Max="1000000000"`, value `1`) rendered `left: 1E-07%` — invalid CSS by a different route. A sweep of the component and primitive layers found no other raw numeric interpolation into a style attribute. ([#443](https://github.com/blazorblueprintui/ui/issues/443)) diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/scrollable-container.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/scrollable-container.txt new file mode 100644 index 000000000..a035b0265 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/scrollable-container.txt @@ -0,0 +1,12 @@ + + +
    +
    Order ORD-10432 shipped on Tuesday.
    +
    Order ORD-10433 is awaiting payment.
    +
    Order ORD-10434 was refunded.
    +
    diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor index 2a7142191..faaa2d0ca 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor @@ -57,6 +57,27 @@
    + +
    +
    +

    Inside a Scrollable Container

    +

    + The tooltip renders through the portal, so an ancestor with + overflow: auto or + overflow: hidden does not clip it. Hover the + first row — the tooltip escapes the scroll box rather than being cut off at its edge. +

    +
    +
    +
    Order ORD-10432 shipped on Tuesday.
    +
    Order ORD-10433 is awaiting payment.
    +
    Order ORD-10434 was refunded.
    +
    Order ORD-10435 is in transit.
    +
    Order ORD-10436 was delivered.
    +
    + +
    +
    diff --git a/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor b/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor index 74fca5c65..7c36b73e6 100644 --- a/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor +++ b/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor @@ -1,7 +1,9 @@ @namespace BlazorBlueprint.Components @using BlazorBlueprint.Icons.Lucide.Components +@using BlazorBlueprint.Primitives.Floating - @ChildContent + +@* Rendered through the portal rather than as a nested absolutely-positioned span, so an + ancestor with overflow: hidden|auto cannot clip it. Absolute positioning is still subject + to the overflow of its containing block — z-index does not exempt it — which is why the + tooltip disappeared inside scrollable containers. *@ + - + diff --git a/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs b/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs index d6398ebaf..6f4060335 100644 --- a/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs +++ b/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs @@ -11,6 +11,8 @@ namespace BlazorBlueprint.Components; public partial class BbCopyText : ComponentBase, IAsyncDisposable { private IJSObjectReference? clipboardModule; + private ElementReference anchorRef; + private readonly string portalId = $"copytext-portal-{Guid.NewGuid():N}"; private bool isHovered; private bool copied; @@ -69,12 +71,13 @@ public partial class BbCopyText : ComponentBase, IAsyncDisposable "relative inline-flex gap-1 items-center cursor-pointer text-primary font-semibold", Class); - private string? TooltipCssClass => ClassNames.cn( - "pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 inline-flex " + - "-translate-x-1/2 items-center gap-1.5 whitespace-nowrap rounded-md border " + - "bg-popover px-2.5 py-1 text-xs font-medium shadow-md " + - "transition-all duration-150 ease-out", - isHovered ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0"); + // Positioning, offset and z-index now come from the floating portal, so only the visual + // chrome is left here. The opacity/translate pair went with it: the portal mounts the + // tooltip when it opens rather than keeping a transparent copy in the layout. With no + // state left to vary on, this is a constant rather than a computed class string. + private const string TooltipCssClass = + "pointer-events-none inline-flex items-center gap-1.5 whitespace-nowrap " + + "rounded-md border bg-popover px-2.5 py-1 text-xs font-medium shadow-md"; private void HandleMouseEnter() { From b5f5a2f576f68be6b94b876ed1950c657996235c Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Tue, 4 Aug 2026 16:51:58 +0800 Subject: [PATCH 173/188] fix(a11y): give inputs a visible focus indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The text-input family set focus-visible:outline-none (or focus:outline-none) and drew nothing in its place — the browser's native ring was removed and never replaced, so a focused input looked identical to an unfocused one. A WCAG 2.4.7 failure, and per discussion #355 the reason three people picked a different library. An omission, not a decision: BbButton, BbCheckbox, BbSwitch, BbTabs, BbToggle, BbSlider, BbCalendar, BbRadioGroupItem and BbPaginationLink had all along used the ring-2/ring-ring/ring-offset-2 pattern. It now applies to the input family too. BbTextarea was a third case — it indicated focus by tinting the border via focus-visible:border-ring — and now matches. BbDrawerItem, BbDatePickerInput and BbResponsiveNavTrigger had the ring but not the offset. Left deliberately alone: sidebar components ring in ring-sidebar-ring, their own theme token, and skip the offset so it doesn't bleed into adjacent rows; event-calendar chips skip it for density; BbBubbleContent keeps its softer inline treatment; overlay containers and roving-focus menu items keep outline-none, being focused programmatically or indicating state through data-[highlighted]. Verified in a browser: computed box-shadow goes from none to a 2px offset plus 2px ring in the theme colour, on both BbInput and BbTextarea. --- CHANGELOG.md | 1 + .../Components/Combobox/BbCombobox.razor.cs | 2 +- .../Components/CurrencyInput/BbCurrencyInput.razor.cs | 2 +- .../Components/DatePicker/BbDatePickerInput.razor | 2 +- .../Components/Drawer/BbDrawerItem.razor | 2 +- .../Components/Input/BbInput.razor.cs | 2 +- .../Components/InputField/BbInputField.razor.cs | 2 +- .../Components/InputGroup/BbInputGroupInput.razor.cs | 2 +- .../Components/InputGroup/BbInputGroupTextarea.razor.cs | 2 +- .../Components/InputOTP/BbInputOTP.razor | 2 +- .../Components/MaskedInput/BbMaskedInput.razor.cs | 2 +- .../Components/MultiSelect/BbMultiSelect.razor.cs | 4 ++-- .../Components/NativeSelect/BbNativeSelect.razor | 2 +- .../Components/NumericInput/BbNumericInput.razor.cs | 5 +++-- .../Components/ResponsiveNav/BbResponsiveNavTrigger.razor | 2 +- .../Components/Select/BbSelectTrigger.razor | 2 +- .../Components/Textarea/BbTextarea.razor.cs | 2 +- 17 files changed, 20 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d3ce8cd6..bed9c5870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **BbColorPicker: the picker was unusable in locales that write decimals with a comma** — The component built its inline styles by interpolating raw `double` values straight into the style string, and Razor renders a bare `@someDouble` through the *current* culture. Under `fr-FR`, `de-DE`, `es-ES`, `pt-BR` and every other comma-decimal locale, the saturation/brightness gradient came out as `hsl(200,5, 100%, 50%)` and the thumb positions as `left: 33,333333333333336%` — the extra comma turns each into a syntax error, so the browser discarded the declaration outright. The result was a color area with no hue gradient and hue, saturation and alpha handles all pinned to the left edge: the picker rendered, took clicks, and simply never reflected the color. Nothing logged, because invalid CSS is dropped silently. All five style interpolations, plus the alpha term in `ToRgbString` and `ToHslString`, now format through `CultureInfo.InvariantCulture` so the separator is a dot regardless of the ambient culture. The parsing side was already invariant — `ParseHex` reads through `NumberStyles.HexNumber` with `InvariantCulture` — so only output was affected. Hue additionally formats as `"0.##"` rather than the default `"G"`, which switches to scientific notation below 1e-5 and would emit `hsl(9.9E-06, …)`, invalid for the same reason. Contributed by [@CholmesFr](https://github.com/CholmesFr). ([#436](https://github.com/blazorblueprintui/ui/pull/436)) - **Sidebar docs: the API reference was incomplete where it mattered most, and wrong in four places** — Someone reaching for `Open`/`OpenChanged` on `BbSidebar` found the parameters absent from the component and absent from the source, with nothing on the page explaining what to use instead. Open state is owned by `BbSidebarProvider` and reached through the cascaded `SidebarContext`, but the context was not documented at all — neither were `BbSidebar` itself nor `BbSidebarTrigger`, the component that answers the question. The reference now covers all three, including the context's full control surface (`IsOpen`, `Open`, `OpenMobile`, `IsMobile`, `Side`, `ToggleSidebar()`, `SetOpen(bool)`, `SetOpenMobile(bool)` and the `StateChanged` event), with a note on why `IsOpen` is the one to read: it resolves to the mobile drawer state below the mobile breakpoint and the desktop state above it, so code that reads `Open` directly is subtly wrong on a phone. A new "Controlling the Sidebar" section and code example show the three routes — `DefaultOpen` for the initial value, `BbSidebarTrigger` from markup, and the cascaded context from your own component, including the `StateChanged` subscription needed if your own markup reflects the open state. Separately, four of the five documented `BbSidebarMenuButton` parameters had the wrong type: `Size`, `Variant` and `AsChild` were listed as `string` with lowercase string defaults when all three are enums (`SidebarMenuButtonSize`, `SidebarMenuButtonVariant`, `SidebarMenuButtonElement`), so following the docs produced a compile error rather than a wrong result; `IsActive` was listed as `bool` when it is `bool?`, where leaving it null is meaningful because active state is then derived from `Href` and `Match`. Those are corrected, and the undocumented `Href`, `Match` and `OnClick` added. ([#442](https://github.com/blazorblueprintui/ui/issues/442)) +- **Inputs had no visible focus indicator at all** — The text-input family set `focus-visible:outline-none` (or `focus:outline-none`) and drew nothing in its place, so the browser's native focus ring was removed and never replaced: a focused input looked exactly like an unfocused one. That is a plain [WCAG 2.4.7](https://www.w3.org/WAI/WCAG21/Understanding/focus-visible.html) failure for anyone navigating by keyboard, and three people in [discussion #355](https://github.com/blazorblueprintui/ui/discussions/355) reported it as the reason they chose a different library. It was an omission rather than a design decision — `BbButton`, `BbCheckbox`, `BbSwitch`, `BbTabs`, `BbToggle`, `BbSlider`, `BbCalendar`, `BbRadioGroupItem` and `BbPaginationLink` had all along used `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`, and the input family simply never got it. That pattern is now applied to `BbInput`, `BbInputField`, `BbInputGroupInput`, `BbInputGroupTextarea`, `BbNumericInput` (both the field and its stepper buttons), `BbCurrencyInput`, `BbMaskedInput`, `BbCombobox`, `BbMultiSelect` (both the trigger and the tag-remove button), `BbSelectTrigger`, `BbNativeSelect` and `BbInputOTP`. `BbTextarea` was a third case — it did indicate focus, but by tinting the border via `focus-visible:border-ring`, so the library gave three different answers to the same question; it now matches everything else. `BbDrawerItem`, `BbDatePickerInput` and `BbResponsiveNavTrigger` had the ring but were missing `ring-offset-2`, and are brought in line too. Deliberate variations are left alone: the sidebar components ring in `ring-sidebar-ring`, their own theme token, and omit the offset because it would bleed into adjacent rows; the event-calendar chips and day numbers likewise skip the offset for density; `BbBubbleContent` keeps its softer inline treatment for links and buttons inside a chat bubble; and overlay containers and roving-focus menu items keep `outline-none` on purpose, since they are focused programmatically or indicate selection through `data-[highlighted]` styling instead. Reported by [@eldo-xy](https://github.com/eldo-xy), [@JaweedSaleem](https://github.com/JaweedSaleem) and [@HugoVG](https://github.com/HugoVG). ([#457](https://github.com/blazorblueprintui/ui/issues/457)) - **BbCopyText: the tooltip was clipped inside scrollable containers** — The component rendered its tooltip as a nested, absolutely-positioned `span` rather than through the portal every other overlay in the library uses. Absolute positioning is still subject to the overflow of its containing block — `z-index` does not exempt it from clipping, only from stacking — so any ancestor with `overflow: hidden` or `overflow: auto` cut the tooltip off at its edge, which in practice meant a copyable id inside a scrolling table or panel showed a sliver of a tooltip or none at all. It now renders through `BbFloatingPortal`, the same infrastructure behind `BbTooltip`, so it escapes the container entirely and behaves the way the reporter reasonably expected it to. Positioning uses the fixed strategy: the portal wrapper is itself `position: fixed`, so an absolute (document-relative) coordinate would be applied as though it were viewport-relative and place the tooltip off-screen by the page's scroll offset — a difference invisible on an unscrolled page and obvious on a scrolled one. The show/hide triggers, the copied state and the localized strings are unchanged; the opacity-and-translate transition went away with the old markup, since the portal mounts the tooltip on open rather than keeping a transparent copy in the layout. Note that this brings `BbCopyText` under the same requirement as the library's other overlays: `` must be present in the root layout. The demo page gains a scrollable-container example covering the reported case. Reported by [@SimonLiebers-Dev](https://github.com/SimonLiebers-Dev). ([#452](https://github.com/blazorblueprintui/ui/issues/452)) - **BbSelect, BbPopover and BbDropdownMenu: closing one as its page was torn down could kill the Blazor Server circuit** — `CleanupAsync` in each of these primitives has two callers: the context state-change handler that runs when the overlay closes, and `DisposeAsync`. A close racing a teardown — navigating away from a page with an open select, or a conditional render removing one — puts both in flight at once, and each block checked its field for null, awaited, and then dereferenced the *field* again on the far side of that await. The second caller ran to completion while the first was suspended and nulled the field, so the first resumed and dereferenced null. The resulting `NullReferenceException` matched none of the `JSDisconnectedException`/`JSException`/`TaskCanceledException`/`ObjectDisposedException` filters guarding those calls, so it escaped `CleanupAsync`, escaped an unguarded `await CleanupAsync()` in `DisposeAsync`, and surfaced inside the renderer's disposal queue — where an unhandled exception is not a logged error but a dead circuit, giving every user on that page the reconnect banner. It was rare and non-deterministic by nature, needing the two calls to interleave on a live circuit: one production app saw 16 occurrences across 10 users in four months. Each cleanup block now takes ownership of what it is about to release, clearing the field *before* the first await, so a second caller finds nothing to do instead of racing for the same reference; `DisposeAsync` additionally guards its cleanup call and returns early if already disposed, so a teardown-time failure can no longer reach the renderer whatever its cause. `BbDropdownMenuContent` also read `Context.ContentId` through a cascading parameter declared non-null that is genuinely absent once its provider is gone — a second route to the same crash, now read defensively. Reported with a production stack trace by [@cscaminaci](https://github.com/cscaminaci). ([#441](https://github.com/blazorblueprintui/ui/issues/441)) - **BbRangeSlider: tick marks collapsed onto the left edge in the same comma-decimal locales** — The identical bug, in the one place in the slider that had been missed: `StartPercentageValue`, `EndPercentageValue` and `RangePercentageValue` already formatted through `CultureInfo.InvariantCulture`, but the tick-mark loop interpolated its computed percentage directly, so a tick at one third rendered `left: 33,333333333333336%` and every tick stacked at position zero while the thumbs and the active range — which went through the invariant properties — sat correctly. Only sliders that set `TickValues` with `ShowTickMarks` were affected, which is why it survived alongside code that had already been fixed. The tick percentage now formats through `InvariantCulture`. The three existing percentage properties are also switched from the default `"G"` format to `"0.##"`: they were never affected by the separator, but `G` emits scientific notation below 1e-5, so a thumb resting just above `Min` on a wide range (`Min="0" Max="1000000000"`, value `1`) rendered `left: 1E-07%` — invalid CSS by a different route. A sweep of the component and primitive layers found no other raw numeric interpolation into a style attribute. ([#443](https://github.com/blazorblueprintui/ui/issues/443)) diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs index 677552b1f..aaff420f0 100644 --- a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs @@ -490,7 +490,7 @@ private async Task HandleSelect(SelectOption option) /// private string ButtonCssClass => ClassNames.cn( "inline-flex items-center justify-between rounded-md text-sm font-medium", - "transition-colors focus-visible:outline-none", + "transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "disabled:opacity-50 disabled:pointer-events-none", "border border-input bg-background hover:bg-accent hover:text-accent-foreground", _isOpen ? ActiveClass : null, diff --git a/src/BlazorBlueprint.Components/Components/CurrencyInput/BbCurrencyInput.razor.cs b/src/BlazorBlueprint.Components/Components/CurrencyInput/BbCurrencyInput.razor.cs index f5ff3f691..f7db8fb69 100644 --- a/src/BlazorBlueprint.Components/Components/CurrencyInput/BbCurrencyInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/CurrencyInput/BbCurrencyInput.razor.cs @@ -296,7 +296,7 @@ private string FormatCurrency(decimal value) private string CssClass => ClassNames.cn( "flex h-10 w-full border border-input bg-background px-3 py-2 text-base", "placeholder:text-muted-foreground", - "focus-visible:outline-none", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "disabled:cursor-not-allowed disabled:opacity-50", "aria-[invalid=true]:border-destructive", "transition-colors", diff --git a/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePickerInput.razor b/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePickerInput.razor index 0b2760573..667e623d7 100644 --- a/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePickerInput.razor +++ b/src/BlazorBlueprint.Components/Components/DatePicker/BbDatePickerInput.razor @@ -86,7 +86,7 @@ private string ToggleCssClass => ClassNames.cn( "inline-flex h-full items-center justify-center px-3 text-muted-foreground", - "hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + "hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "disabled:pointer-events-none", // Mirror the pointer-events guard trigger buttons apply while the popover is // open, so one click can't close it via click-outside and immediately re-open it. diff --git a/src/BlazorBlueprint.Components/Components/Drawer/BbDrawerItem.razor b/src/BlazorBlueprint.Components/Components/Drawer/BbDrawerItem.razor index 085211f90..17bc256a6 100644 --- a/src/BlazorBlueprint.Components/Components/Drawer/BbDrawerItem.razor +++ b/src/BlazorBlueprint.Components/Components/Drawer/BbDrawerItem.razor @@ -37,7 +37,7 @@ private string CssClass => ClassNames.cn( "flex w-full items-center rounded-md px-4 py-3 text-sm font-medium transition-colors", - "hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + "hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "disabled:pointer-events-none disabled:opacity-50", Class ); diff --git a/src/BlazorBlueprint.Components/Components/Input/BbInput.razor.cs b/src/BlazorBlueprint.Components/Components/Input/BbInput.razor.cs index 4685edf27..601f550cb 100644 --- a/src/BlazorBlueprint.Components/Components/Input/BbInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Input/BbInput.razor.cs @@ -236,7 +236,7 @@ public partial class BbInput : ComponentBase "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base", "file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground", "placeholder:text-muted-foreground", - "focus-visible:outline-none", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "disabled:cursor-not-allowed disabled:opacity-50", // aria-invalid state styling (destructive error colors) "aria-[invalid=true]:border-destructive", diff --git a/src/BlazorBlueprint.Components/Components/InputField/BbInputField.razor.cs b/src/BlazorBlueprint.Components/Components/InputField/BbInputField.razor.cs index 8a0a4f6ab..b38d29cf3 100644 --- a/src/BlazorBlueprint.Components/Components/InputField/BbInputField.razor.cs +++ b/src/BlazorBlueprint.Components/Components/InputField/BbInputField.razor.cs @@ -297,7 +297,7 @@ private string DisplayValue "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base", "file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground", "placeholder:text-muted-foreground", - "focus-visible:outline-none", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "disabled:cursor-not-allowed disabled:opacity-50", "aria-[invalid=true]:border-destructive", "transition-colors", diff --git a/src/BlazorBlueprint.Components/Components/InputGroup/BbInputGroupInput.razor.cs b/src/BlazorBlueprint.Components/Components/InputGroup/BbInputGroupInput.razor.cs index 8a727c2a4..96b7d7d43 100644 --- a/src/BlazorBlueprint.Components/Components/InputGroup/BbInputGroupInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/InputGroup/BbInputGroupInput.razor.cs @@ -171,7 +171,7 @@ public partial class BbInputGroupInput : ComponentBase "flex-1 bg-transparent px-3 py-2 text-base", "border-0 rounded-none", // No border or radius for seamless integration "placeholder:text-muted-foreground", - "focus-visible:outline-none", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "disabled:cursor-not-allowed disabled:opacity-50", // File input styling "file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground", diff --git a/src/BlazorBlueprint.Components/Components/InputGroup/BbInputGroupTextarea.razor.cs b/src/BlazorBlueprint.Components/Components/InputGroup/BbInputGroupTextarea.razor.cs index 97da139ad..b47d62da5 100644 --- a/src/BlazorBlueprint.Components/Components/InputGroup/BbInputGroupTextarea.razor.cs +++ b/src/BlazorBlueprint.Components/Components/InputGroup/BbInputGroupTextarea.razor.cs @@ -170,7 +170,7 @@ public partial class BbInputGroupTextarea : ComponentBase "flex-1 bg-transparent px-3 py-2 text-base min-h-[60px]", "border-0 rounded-none", // No border or radius for seamless integration "placeholder:text-muted-foreground", - "focus-visible:outline-none", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "disabled:cursor-not-allowed disabled:opacity-50", "resize-none", // Prevent resize for cleaner appearance // Medium screens and up: smaller text diff --git a/src/BlazorBlueprint.Components/Components/InputOTP/BbInputOTP.razor b/src/BlazorBlueprint.Components/Components/InputOTP/BbInputOTP.razor index e92f8c872..b249252ea 100644 --- a/src/BlazorBlueprint.Components/Components/InputOTP/BbInputOTP.razor +++ b/src/BlazorBlueprint.Components/Components/InputOTP/BbInputOTP.razor @@ -397,7 +397,7 @@ private string ComputedInputClass => ClassNames.cn( "flex items-center justify-center rounded-md border border-input bg-background text-center font-medium", - "focus:outline-none", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "disabled:cursor-not-allowed disabled:opacity-50", SizeClasses, InputClass diff --git a/src/BlazorBlueprint.Components/Components/MaskedInput/BbMaskedInput.razor.cs b/src/BlazorBlueprint.Components/Components/MaskedInput/BbMaskedInput.razor.cs index 5a24ba47a..d8837f553 100644 --- a/src/BlazorBlueprint.Components/Components/MaskedInput/BbMaskedInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/MaskedInput/BbMaskedInput.razor.cs @@ -468,7 +468,7 @@ public async ValueTask DisposeAsync() private string CssClass => ClassNames.cn( "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base", "placeholder:text-muted-foreground", - "focus-visible:outline-none", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "disabled:cursor-not-allowed disabled:opacity-50", "aria-[invalid=true]:border-destructive", "transition-colors", diff --git a/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor.cs b/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor.cs index e5c6c8721..083760e72 100644 --- a/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor.cs +++ b/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor.cs @@ -836,7 +836,7 @@ protected override bool ShouldRender() /// private string TriggerCssClass => ClassNames.cn( "inline-flex items-center justify-between rounded-md text-sm font-medium", - "transition-colors focus-visible:outline-none", + "transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "disabled:opacity-50 disabled:pointer-events-none", "border border-input bg-background hover:bg-accent hover:text-accent-foreground", _isOpen ? ActiveClass : null, @@ -865,7 +865,7 @@ protected override bool ShouldRender() /// Gets the CSS class for the tag remove button. /// private static string TagRemoveButtonCssClass => - "ml-0.5 rounded-full outline-none hover:bg-secondary-foreground/20"; + "ml-0.5 rounded-full outline-none hover:bg-secondary-foreground/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"; /// /// Gets the CSS class for the dropdown item. diff --git a/src/BlazorBlueprint.Components/Components/NativeSelect/BbNativeSelect.razor b/src/BlazorBlueprint.Components/Components/NativeSelect/BbNativeSelect.razor index 281cf68c2..44135b471 100644 --- a/src/BlazorBlueprint.Components/Components/NativeSelect/BbNativeSelect.razor +++ b/src/BlazorBlueprint.Components/Components/NativeSelect/BbNativeSelect.razor @@ -162,7 +162,7 @@ private string CssClass => ClassNames.cn( "flex w-full rounded-md border border-input bg-background py-2", - "focus:outline-none", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "disabled:cursor-not-allowed disabled:opacity-50", "appearance-none pr-8", SizeClasses, diff --git a/src/BlazorBlueprint.Components/Components/NumericInput/BbNumericInput.razor.cs b/src/BlazorBlueprint.Components/Components/NumericInput/BbNumericInput.razor.cs index b5aca6a17..7ba477177 100644 --- a/src/BlazorBlueprint.Components/Components/NumericInput/BbNumericInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/NumericInput/BbNumericInput.razor.cs @@ -296,7 +296,8 @@ private string DisplayValue private string CssClass => ClassNames.cn( "flex h-10 w-full border border-input bg-background px-3 py-2 text-base", "placeholder:text-muted-foreground", - ShowButtons ? "rounded-l-md focus-visible:outline-none" : "rounded-md focus-visible:outline-none", + ShowButtons ? "rounded-l-md" : "rounded-md", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "disabled:cursor-not-allowed disabled:opacity-50", "aria-[invalid=true]:border-destructive", "transition-colors", @@ -308,7 +309,7 @@ private string DisplayValue private static string ButtonClass => ClassNames.cn( "flex items-center justify-center w-8 h-5 border border-input bg-background", "hover:bg-accent hover:text-accent-foreground", - "focus-visible:outline-none", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "disabled:cursor-not-allowed disabled:opacity-50", "first:border-b-0", "first:rounded-tr-md last:rounded-br-md", diff --git a/src/BlazorBlueprint.Components/Components/ResponsiveNav/BbResponsiveNavTrigger.razor b/src/BlazorBlueprint.Components/Components/ResponsiveNav/BbResponsiveNavTrigger.razor index 0b1a384d2..5fd94da2d 100644 --- a/src/BlazorBlueprint.Components/Components/ResponsiveNav/BbResponsiveNavTrigger.razor +++ b/src/BlazorBlueprint.Components/Components/ResponsiveNav/BbResponsiveNavTrigger.razor @@ -64,7 +64,7 @@ private string GetClasses() { return ClassNames.cn( - "inline-flex items-center justify-center rounded-md p-2 text-foreground hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring md:hidden", + "inline-flex items-center justify-center rounded-md p-2 text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 md:hidden", Class ); } diff --git a/src/BlazorBlueprint.Components/Components/Select/BbSelectTrigger.razor b/src/BlazorBlueprint.Components/Components/Select/BbSelectTrigger.razor index 73ce57891..935512b07 100644 --- a/src/BlazorBlueprint.Components/Components/Select/BbSelectTrigger.razor +++ b/src/BlazorBlueprint.Components/Components/Select/BbSelectTrigger.razor @@ -54,7 +54,7 @@ "bg-background px-3 py-2 text-sm", "placeholder:text-muted-foreground", "hover:bg-accent hover:text-accent-foreground", - "focus:outline-none", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "disabled:cursor-not-allowed disabled:opacity-50", IsOpen ? ActiveClass : null, Class diff --git a/src/BlazorBlueprint.Components/Components/Textarea/BbTextarea.razor.cs b/src/BlazorBlueprint.Components/Components/Textarea/BbTextarea.razor.cs index cf3bcfc82..28f7a0c59 100644 --- a/src/BlazorBlueprint.Components/Components/Textarea/BbTextarea.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Textarea/BbTextarea.razor.cs @@ -250,7 +250,7 @@ public partial class BbTextarea : ComponentBase "bg-transparent dark:bg-input/30 px-3 py-2 text-base shadow-xs", "placeholder:text-muted-foreground", // Focus states - "outline-none focus-visible:border-ring", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", // Error states (aria-invalid) "aria-[invalid=true]:border-destructive", // Disabled state From 6039374f0e454bf533b9515695f756f249411a79 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Tue, 4 Aug 2026 17:10:41 +0800 Subject: [PATCH 174/188] fix(a11y): hug the ring on inputs instead of offsetting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library's form rows leave a 3px gap between a label and its control, while ring-offset-2 extends 4px — so the offset ring overlapped the label by a pixel on every labelled field. Measured across 11 labelled textareas: all 11 overlapping before, none after. Drops ring-offset-2 from the input family only. Buttons, checkboxes, switches, tabs, toggles, sliders and the calendar keep theirs — they are usually standalone with margin around them, whereas inputs sit in labelled rows with tight vertical rhythm. shadcn/ui splits it the same way. Still a clear WCAG 2.4.7 pass: a 2px ring in the theme colour against the control's border. --- CHANGELOG.md | 2 +- .../Components/Combobox/BbCombobox.razor.cs | 2 +- .../Components/CurrencyInput/BbCurrencyInput.razor.cs | 2 +- .../Components/Input/BbInput.razor.cs | 2 +- .../Components/InputField/BbInputField.razor.cs | 2 +- .../Components/InputGroup/BbInputGroupInput.razor.cs | 2 +- .../Components/InputGroup/BbInputGroupTextarea.razor.cs | 2 +- .../Components/InputOTP/BbInputOTP.razor | 2 +- .../Components/MaskedInput/BbMaskedInput.razor.cs | 2 +- .../Components/MultiSelect/BbMultiSelect.razor.cs | 4 ++-- .../Components/NativeSelect/BbNativeSelect.razor | 2 +- .../Components/NumericInput/BbNumericInput.razor.cs | 4 ++-- .../Components/Select/BbSelectTrigger.razor | 2 +- .../Components/Textarea/BbTextarea.razor.cs | 2 +- 14 files changed, 16 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bed9c5870..c2562be12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **BbColorPicker: the picker was unusable in locales that write decimals with a comma** — The component built its inline styles by interpolating raw `double` values straight into the style string, and Razor renders a bare `@someDouble` through the *current* culture. Under `fr-FR`, `de-DE`, `es-ES`, `pt-BR` and every other comma-decimal locale, the saturation/brightness gradient came out as `hsl(200,5, 100%, 50%)` and the thumb positions as `left: 33,333333333333336%` — the extra comma turns each into a syntax error, so the browser discarded the declaration outright. The result was a color area with no hue gradient and hue, saturation and alpha handles all pinned to the left edge: the picker rendered, took clicks, and simply never reflected the color. Nothing logged, because invalid CSS is dropped silently. All five style interpolations, plus the alpha term in `ToRgbString` and `ToHslString`, now format through `CultureInfo.InvariantCulture` so the separator is a dot regardless of the ambient culture. The parsing side was already invariant — `ParseHex` reads through `NumberStyles.HexNumber` with `InvariantCulture` — so only output was affected. Hue additionally formats as `"0.##"` rather than the default `"G"`, which switches to scientific notation below 1e-5 and would emit `hsl(9.9E-06, …)`, invalid for the same reason. Contributed by [@CholmesFr](https://github.com/CholmesFr). ([#436](https://github.com/blazorblueprintui/ui/pull/436)) - **Sidebar docs: the API reference was incomplete where it mattered most, and wrong in four places** — Someone reaching for `Open`/`OpenChanged` on `BbSidebar` found the parameters absent from the component and absent from the source, with nothing on the page explaining what to use instead. Open state is owned by `BbSidebarProvider` and reached through the cascaded `SidebarContext`, but the context was not documented at all — neither were `BbSidebar` itself nor `BbSidebarTrigger`, the component that answers the question. The reference now covers all three, including the context's full control surface (`IsOpen`, `Open`, `OpenMobile`, `IsMobile`, `Side`, `ToggleSidebar()`, `SetOpen(bool)`, `SetOpenMobile(bool)` and the `StateChanged` event), with a note on why `IsOpen` is the one to read: it resolves to the mobile drawer state below the mobile breakpoint and the desktop state above it, so code that reads `Open` directly is subtly wrong on a phone. A new "Controlling the Sidebar" section and code example show the three routes — `DefaultOpen` for the initial value, `BbSidebarTrigger` from markup, and the cascaded context from your own component, including the `StateChanged` subscription needed if your own markup reflects the open state. Separately, four of the five documented `BbSidebarMenuButton` parameters had the wrong type: `Size`, `Variant` and `AsChild` were listed as `string` with lowercase string defaults when all three are enums (`SidebarMenuButtonSize`, `SidebarMenuButtonVariant`, `SidebarMenuButtonElement`), so following the docs produced a compile error rather than a wrong result; `IsActive` was listed as `bool` when it is `bool?`, where leaving it null is meaningful because active state is then derived from `Href` and `Match`. Those are corrected, and the undocumented `Href`, `Match` and `OnClick` added. ([#442](https://github.com/blazorblueprintui/ui/issues/442)) -- **Inputs had no visible focus indicator at all** — The text-input family set `focus-visible:outline-none` (or `focus:outline-none`) and drew nothing in its place, so the browser's native focus ring was removed and never replaced: a focused input looked exactly like an unfocused one. That is a plain [WCAG 2.4.7](https://www.w3.org/WAI/WCAG21/Understanding/focus-visible.html) failure for anyone navigating by keyboard, and three people in [discussion #355](https://github.com/blazorblueprintui/ui/discussions/355) reported it as the reason they chose a different library. It was an omission rather than a design decision — `BbButton`, `BbCheckbox`, `BbSwitch`, `BbTabs`, `BbToggle`, `BbSlider`, `BbCalendar`, `BbRadioGroupItem` and `BbPaginationLink` had all along used `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`, and the input family simply never got it. That pattern is now applied to `BbInput`, `BbInputField`, `BbInputGroupInput`, `BbInputGroupTextarea`, `BbNumericInput` (both the field and its stepper buttons), `BbCurrencyInput`, `BbMaskedInput`, `BbCombobox`, `BbMultiSelect` (both the trigger and the tag-remove button), `BbSelectTrigger`, `BbNativeSelect` and `BbInputOTP`. `BbTextarea` was a third case — it did indicate focus, but by tinting the border via `focus-visible:border-ring`, so the library gave three different answers to the same question; it now matches everything else. `BbDrawerItem`, `BbDatePickerInput` and `BbResponsiveNavTrigger` had the ring but were missing `ring-offset-2`, and are brought in line too. Deliberate variations are left alone: the sidebar components ring in `ring-sidebar-ring`, their own theme token, and omit the offset because it would bleed into adjacent rows; the event-calendar chips and day numbers likewise skip the offset for density; `BbBubbleContent` keeps its softer inline treatment for links and buttons inside a chat bubble; and overlay containers and roving-focus menu items keep `outline-none` on purpose, since they are focused programmatically or indicate selection through `data-[highlighted]` styling instead. Reported by [@eldo-xy](https://github.com/eldo-xy), [@JaweedSaleem](https://github.com/JaweedSaleem) and [@HugoVG](https://github.com/HugoVG). ([#457](https://github.com/blazorblueprintui/ui/issues/457)) +- **Inputs had no visible focus indicator at all** — The text-input family set `focus-visible:outline-none` (or `focus:outline-none`) and drew nothing in its place, so the browser's native focus ring was removed and never replaced: a focused input looked exactly like an unfocused one. That is a plain [WCAG 2.4.7](https://www.w3.org/WAI/WCAG21/Understanding/focus-visible.html) failure for anyone navigating by keyboard, and three people in [discussion #355](https://github.com/blazorblueprintui/ui/discussions/355) reported it as the reason they chose a different library. It was an omission rather than a design decision — `BbButton`, `BbCheckbox`, `BbSwitch`, `BbTabs`, `BbToggle`, `BbSlider`, `BbCalendar`, `BbRadioGroupItem` and `BbPaginationLink` had all along used `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`, and the input family simply never got it. Inputs now use the same ring **without** `ring-offset-2`: the library's form rows leave only a 3px gap between a label and its control, and an offset ring extends 4px, so the offset version overlapped the label by a pixel on every labelled field. Hugging the border keeps the ring clear of the label and, on a bordered control, reads better than a detached halo — buttons and the other standalone controls keep their offset, the same split shadcn/ui makes. The ring is applied to `BbInput`, `BbInputField`, `BbInputGroupInput`, `BbInputGroupTextarea`, `BbNumericInput` (both the field and its stepper buttons), `BbCurrencyInput`, `BbMaskedInput`, `BbCombobox`, `BbMultiSelect` (both the trigger and the tag-remove button), `BbSelectTrigger`, `BbNativeSelect` and `BbInputOTP`. `BbTextarea` was a third case — it did indicate focus, but by tinting the border via `focus-visible:border-ring`, so the library gave three different answers to the same question; it now matches everything else. `BbDrawerItem`, `BbDatePickerInput` and `BbResponsiveNavTrigger` had the ring but were missing `ring-offset-2`, and are brought in line too. Deliberate variations are left alone: the sidebar components ring in `ring-sidebar-ring`, their own theme token, and omit the offset because it would bleed into adjacent rows; the event-calendar chips and day numbers likewise skip the offset for density; `BbBubbleContent` keeps its softer inline treatment for links and buttons inside a chat bubble; and overlay containers and roving-focus menu items keep `outline-none` on purpose, since they are focused programmatically or indicate selection through `data-[highlighted]` styling instead. Reported by [@eldo-xy](https://github.com/eldo-xy), [@JaweedSaleem](https://github.com/JaweedSaleem) and [@HugoVG](https://github.com/HugoVG). ([#457](https://github.com/blazorblueprintui/ui/issues/457)) - **BbCopyText: the tooltip was clipped inside scrollable containers** — The component rendered its tooltip as a nested, absolutely-positioned `span` rather than through the portal every other overlay in the library uses. Absolute positioning is still subject to the overflow of its containing block — `z-index` does not exempt it from clipping, only from stacking — so any ancestor with `overflow: hidden` or `overflow: auto` cut the tooltip off at its edge, which in practice meant a copyable id inside a scrolling table or panel showed a sliver of a tooltip or none at all. It now renders through `BbFloatingPortal`, the same infrastructure behind `BbTooltip`, so it escapes the container entirely and behaves the way the reporter reasonably expected it to. Positioning uses the fixed strategy: the portal wrapper is itself `position: fixed`, so an absolute (document-relative) coordinate would be applied as though it were viewport-relative and place the tooltip off-screen by the page's scroll offset — a difference invisible on an unscrolled page and obvious on a scrolled one. The show/hide triggers, the copied state and the localized strings are unchanged; the opacity-and-translate transition went away with the old markup, since the portal mounts the tooltip on open rather than keeping a transparent copy in the layout. Note that this brings `BbCopyText` under the same requirement as the library's other overlays: `` must be present in the root layout. The demo page gains a scrollable-container example covering the reported case. Reported by [@SimonLiebers-Dev](https://github.com/SimonLiebers-Dev). ([#452](https://github.com/blazorblueprintui/ui/issues/452)) - **BbSelect, BbPopover and BbDropdownMenu: closing one as its page was torn down could kill the Blazor Server circuit** — `CleanupAsync` in each of these primitives has two callers: the context state-change handler that runs when the overlay closes, and `DisposeAsync`. A close racing a teardown — navigating away from a page with an open select, or a conditional render removing one — puts both in flight at once, and each block checked its field for null, awaited, and then dereferenced the *field* again on the far side of that await. The second caller ran to completion while the first was suspended and nulled the field, so the first resumed and dereferenced null. The resulting `NullReferenceException` matched none of the `JSDisconnectedException`/`JSException`/`TaskCanceledException`/`ObjectDisposedException` filters guarding those calls, so it escaped `CleanupAsync`, escaped an unguarded `await CleanupAsync()` in `DisposeAsync`, and surfaced inside the renderer's disposal queue — where an unhandled exception is not a logged error but a dead circuit, giving every user on that page the reconnect banner. It was rare and non-deterministic by nature, needing the two calls to interleave on a live circuit: one production app saw 16 occurrences across 10 users in four months. Each cleanup block now takes ownership of what it is about to release, clearing the field *before* the first await, so a second caller finds nothing to do instead of racing for the same reference; `DisposeAsync` additionally guards its cleanup call and returns early if already disposed, so a teardown-time failure can no longer reach the renderer whatever its cause. `BbDropdownMenuContent` also read `Context.ContentId` through a cascading parameter declared non-null that is genuinely absent once its provider is gone — a second route to the same crash, now read defensively. Reported with a production stack trace by [@cscaminaci](https://github.com/cscaminaci). ([#441](https://github.com/blazorblueprintui/ui/issues/441)) - **BbRangeSlider: tick marks collapsed onto the left edge in the same comma-decimal locales** — The identical bug, in the one place in the slider that had been missed: `StartPercentageValue`, `EndPercentageValue` and `RangePercentageValue` already formatted through `CultureInfo.InvariantCulture`, but the tick-mark loop interpolated its computed percentage directly, so a tick at one third rendered `left: 33,333333333333336%` and every tick stacked at position zero while the thumbs and the active range — which went through the invariant properties — sat correctly. Only sliders that set `TickValues` with `ShowTickMarks` were affected, which is why it survived alongside code that had already been fixed. The tick percentage now formats through `InvariantCulture`. The three existing percentage properties are also switched from the default `"G"` format to `"0.##"`: they were never affected by the separator, but `G` emits scientific notation below 1e-5, so a thumb resting just above `Min` on a wide range (`Min="0" Max="1000000000"`, value `1`) rendered `left: 1E-07%` — invalid CSS by a different route. A sweep of the component and primitive layers found no other raw numeric interpolation into a style attribute. ([#443](https://github.com/blazorblueprintui/ui/issues/443)) diff --git a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs index aaff420f0..d634af222 100644 --- a/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Combobox/BbCombobox.razor.cs @@ -490,7 +490,7 @@ private async Task HandleSelect(SelectOption option) /// private string ButtonCssClass => ClassNames.cn( "inline-flex items-center justify-between rounded-md text-sm font-medium", - "transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:opacity-50 disabled:pointer-events-none", "border border-input bg-background hover:bg-accent hover:text-accent-foreground", _isOpen ? ActiveClass : null, diff --git a/src/BlazorBlueprint.Components/Components/CurrencyInput/BbCurrencyInput.razor.cs b/src/BlazorBlueprint.Components/Components/CurrencyInput/BbCurrencyInput.razor.cs index f7db8fb69..e335d8396 100644 --- a/src/BlazorBlueprint.Components/Components/CurrencyInput/BbCurrencyInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/CurrencyInput/BbCurrencyInput.razor.cs @@ -296,7 +296,7 @@ private string FormatCurrency(decimal value) private string CssClass => ClassNames.cn( "flex h-10 w-full border border-input bg-background px-3 py-2 text-base", "placeholder:text-muted-foreground", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:cursor-not-allowed disabled:opacity-50", "aria-[invalid=true]:border-destructive", "transition-colors", diff --git a/src/BlazorBlueprint.Components/Components/Input/BbInput.razor.cs b/src/BlazorBlueprint.Components/Components/Input/BbInput.razor.cs index 601f550cb..a798ed9dd 100644 --- a/src/BlazorBlueprint.Components/Components/Input/BbInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Input/BbInput.razor.cs @@ -236,7 +236,7 @@ public partial class BbInput : ComponentBase "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base", "file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground", "placeholder:text-muted-foreground", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:cursor-not-allowed disabled:opacity-50", // aria-invalid state styling (destructive error colors) "aria-[invalid=true]:border-destructive", diff --git a/src/BlazorBlueprint.Components/Components/InputField/BbInputField.razor.cs b/src/BlazorBlueprint.Components/Components/InputField/BbInputField.razor.cs index b38d29cf3..c36273811 100644 --- a/src/BlazorBlueprint.Components/Components/InputField/BbInputField.razor.cs +++ b/src/BlazorBlueprint.Components/Components/InputField/BbInputField.razor.cs @@ -297,7 +297,7 @@ private string DisplayValue "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base", "file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground", "placeholder:text-muted-foreground", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:cursor-not-allowed disabled:opacity-50", "aria-[invalid=true]:border-destructive", "transition-colors", diff --git a/src/BlazorBlueprint.Components/Components/InputGroup/BbInputGroupInput.razor.cs b/src/BlazorBlueprint.Components/Components/InputGroup/BbInputGroupInput.razor.cs index 96b7d7d43..0b3d99b8b 100644 --- a/src/BlazorBlueprint.Components/Components/InputGroup/BbInputGroupInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/InputGroup/BbInputGroupInput.razor.cs @@ -171,7 +171,7 @@ public partial class BbInputGroupInput : ComponentBase "flex-1 bg-transparent px-3 py-2 text-base", "border-0 rounded-none", // No border or radius for seamless integration "placeholder:text-muted-foreground", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:cursor-not-allowed disabled:opacity-50", // File input styling "file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground", diff --git a/src/BlazorBlueprint.Components/Components/InputGroup/BbInputGroupTextarea.razor.cs b/src/BlazorBlueprint.Components/Components/InputGroup/BbInputGroupTextarea.razor.cs index b47d62da5..9d38123d8 100644 --- a/src/BlazorBlueprint.Components/Components/InputGroup/BbInputGroupTextarea.razor.cs +++ b/src/BlazorBlueprint.Components/Components/InputGroup/BbInputGroupTextarea.razor.cs @@ -170,7 +170,7 @@ public partial class BbInputGroupTextarea : ComponentBase "flex-1 bg-transparent px-3 py-2 text-base min-h-[60px]", "border-0 rounded-none", // No border or radius for seamless integration "placeholder:text-muted-foreground", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:cursor-not-allowed disabled:opacity-50", "resize-none", // Prevent resize for cleaner appearance // Medium screens and up: smaller text diff --git a/src/BlazorBlueprint.Components/Components/InputOTP/BbInputOTP.razor b/src/BlazorBlueprint.Components/Components/InputOTP/BbInputOTP.razor index b249252ea..7d6362c58 100644 --- a/src/BlazorBlueprint.Components/Components/InputOTP/BbInputOTP.razor +++ b/src/BlazorBlueprint.Components/Components/InputOTP/BbInputOTP.razor @@ -397,7 +397,7 @@ private string ComputedInputClass => ClassNames.cn( "flex items-center justify-center rounded-md border border-input bg-background text-center font-medium", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:cursor-not-allowed disabled:opacity-50", SizeClasses, InputClass diff --git a/src/BlazorBlueprint.Components/Components/MaskedInput/BbMaskedInput.razor.cs b/src/BlazorBlueprint.Components/Components/MaskedInput/BbMaskedInput.razor.cs index d8837f553..3782d73ad 100644 --- a/src/BlazorBlueprint.Components/Components/MaskedInput/BbMaskedInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/MaskedInput/BbMaskedInput.razor.cs @@ -468,7 +468,7 @@ public async ValueTask DisposeAsync() private string CssClass => ClassNames.cn( "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base", "placeholder:text-muted-foreground", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:cursor-not-allowed disabled:opacity-50", "aria-[invalid=true]:border-destructive", "transition-colors", diff --git a/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor.cs b/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor.cs index 083760e72..ee1278ec4 100644 --- a/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor.cs +++ b/src/BlazorBlueprint.Components/Components/MultiSelect/BbMultiSelect.razor.cs @@ -836,7 +836,7 @@ protected override bool ShouldRender() /// private string TriggerCssClass => ClassNames.cn( "inline-flex items-center justify-between rounded-md text-sm font-medium", - "transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:opacity-50 disabled:pointer-events-none", "border border-input bg-background hover:bg-accent hover:text-accent-foreground", _isOpen ? ActiveClass : null, @@ -865,7 +865,7 @@ protected override bool ShouldRender() /// Gets the CSS class for the tag remove button. /// private static string TagRemoveButtonCssClass => - "ml-0.5 rounded-full outline-none hover:bg-secondary-foreground/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"; + "ml-0.5 rounded-full outline-none hover:bg-secondary-foreground/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; /// /// Gets the CSS class for the dropdown item. diff --git a/src/BlazorBlueprint.Components/Components/NativeSelect/BbNativeSelect.razor b/src/BlazorBlueprint.Components/Components/NativeSelect/BbNativeSelect.razor index 44135b471..78480c823 100644 --- a/src/BlazorBlueprint.Components/Components/NativeSelect/BbNativeSelect.razor +++ b/src/BlazorBlueprint.Components/Components/NativeSelect/BbNativeSelect.razor @@ -162,7 +162,7 @@ private string CssClass => ClassNames.cn( "flex w-full rounded-md border border-input bg-background py-2", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:cursor-not-allowed disabled:opacity-50", "appearance-none pr-8", SizeClasses, diff --git a/src/BlazorBlueprint.Components/Components/NumericInput/BbNumericInput.razor.cs b/src/BlazorBlueprint.Components/Components/NumericInput/BbNumericInput.razor.cs index 7ba477177..50e5f2414 100644 --- a/src/BlazorBlueprint.Components/Components/NumericInput/BbNumericInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/NumericInput/BbNumericInput.razor.cs @@ -297,7 +297,7 @@ private string DisplayValue "flex h-10 w-full border border-input bg-background px-3 py-2 text-base", "placeholder:text-muted-foreground", ShowButtons ? "rounded-l-md" : "rounded-md", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:cursor-not-allowed disabled:opacity-50", "aria-[invalid=true]:border-destructive", "transition-colors", @@ -309,7 +309,7 @@ private string DisplayValue private static string ButtonClass => ClassNames.cn( "flex items-center justify-center w-8 h-5 border border-input bg-background", "hover:bg-accent hover:text-accent-foreground", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:cursor-not-allowed disabled:opacity-50", "first:border-b-0", "first:rounded-tr-md last:rounded-br-md", diff --git a/src/BlazorBlueprint.Components/Components/Select/BbSelectTrigger.razor b/src/BlazorBlueprint.Components/Components/Select/BbSelectTrigger.razor index 935512b07..857cf431f 100644 --- a/src/BlazorBlueprint.Components/Components/Select/BbSelectTrigger.razor +++ b/src/BlazorBlueprint.Components/Components/Select/BbSelectTrigger.razor @@ -54,7 +54,7 @@ "bg-background px-3 py-2 text-sm", "placeholder:text-muted-foreground", "hover:bg-accent hover:text-accent-foreground", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:cursor-not-allowed disabled:opacity-50", IsOpen ? ActiveClass : null, Class diff --git a/src/BlazorBlueprint.Components/Components/Textarea/BbTextarea.razor.cs b/src/BlazorBlueprint.Components/Components/Textarea/BbTextarea.razor.cs index 28f7a0c59..a2a6b8f6f 100644 --- a/src/BlazorBlueprint.Components/Components/Textarea/BbTextarea.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Textarea/BbTextarea.razor.cs @@ -250,7 +250,7 @@ public partial class BbTextarea : ComponentBase "bg-transparent dark:bg-input/30 px-3 py-2 text-base shadow-xs", "placeholder:text-muted-foreground", // Focus states - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", // Error states (aria-invalid) "aria-[invalid=true]:border-destructive", // Disabled state From 332e9fc70da482e0c849561dca8a8f4f5020cf4a Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 5 Aug 2026 11:59:44 +0800 Subject: [PATCH 175/188] test: add convention guards for culture-invariant styles and focus indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both bugs this session were omissions in markup, not logic errors, so unit tests of the behaviour would have passed while the defects remained. These scan component source as text instead. CultureInvariantStyleTests — flags a numeric value interpolated into an inline style without InvariantCulture. Razor renders a bare @value with CurrentCulture, so a comma-decimal locale produces invalid CSS that the browser discards silently. That shipped twice, in BbColorPicker (#436) and BbRangeSlider (#443) a fortnight apart; the second was only found by sweeping the tree by hand. FocusIndicatorTests — flags a component that removes the focus outline without drawing a replacement, a WCAG 2.4.7 failure that the whole input family carried until #457. Accepts any recognised affordance, not just a ring: menu items indicate focus with a background change, which is correct for a roving-focus list. Overlay panels are exempt by suffix since they are focused programmatically. A short allowlist covers the rest, each entry carrying a reason, and a second test fails if an entry stops being needed — a stale exemption hides a regression. Verified by mutation: reintroducing the RangeSlider interpolation and removing BbInput's ring each turn the relevant test red. No new dependencies; these read the working tree, so SourceTree locates the repo root by walking up from the test assembly. --- .../Conventions/CultureInvariantStyleTests.cs | 96 ++++++++++++ .../Conventions/FocusIndicatorTests.cs | 145 ++++++++++++++++++ .../Conventions/SourceTree.cs | 82 ++++++++++ 3 files changed, 323 insertions(+) create mode 100644 tests/BlazorBlueprint.Tests/Conventions/CultureInvariantStyleTests.cs create mode 100644 tests/BlazorBlueprint.Tests/Conventions/FocusIndicatorTests.cs create mode 100644 tests/BlazorBlueprint.Tests/Conventions/SourceTree.cs diff --git a/tests/BlazorBlueprint.Tests/Conventions/CultureInvariantStyleTests.cs b/tests/BlazorBlueprint.Tests/Conventions/CultureInvariantStyleTests.cs new file mode 100644 index 000000000..48291dbe6 --- /dev/null +++ b/tests/BlazorBlueprint.Tests/Conventions/CultureInvariantStyleTests.cs @@ -0,0 +1,96 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace BlazorBlueprint.Tests.Conventions; + +/// +/// Guards against numeric values reaching an inline style attribute through the current +/// culture. +/// +/// Razor renders a bare @someDouble with , +/// so in a locale that writes decimals with a comma the markup becomes left: 33,33%. That is +/// a CSS syntax error, and the browser drops the whole declaration silently — no exception, no +/// console warning, just an element that does not move. +/// +/// +/// This has shipped twice: BbColorPicker (#436) and BbRangeSlider (#443), a fortnight +/// apart. The second was found only by sweeping the tree by hand after fixing the first, which is +/// precisely the kind of check worth automating. +/// +/// +public class CultureInvariantStyleTests +{ + /// + /// An interpolation sitting immediately before a CSS unit — @(x * 100)%, @Foo px. + /// The unit must not be followed by a word character, or @ItemContainerStyle matches as + /// @It + the em unit. + /// + private const string Unit = @"(?:%|px|deg|em|rem|vh|vw|fr)(?![\w-])"; + + private static readonly Regex Interpolation = new( + @"@\((?[^()]*(?:\([^()]*\)[^()]*)*)\)" + Unit + @"|@(?[A-Za-z_][\w.]*)" + Unit, + RegexOptions.Compiled); + + [Fact] + public void NumericValuesInStyleAttributesAreCultureInvariant() + { + var violations = new List(); + + foreach (var file in SourceTree.ComponentSources.Where(f => f.Extension == ".razor")) + { + var lines = File.ReadAllLines(file.FullName); + + for (var i = 0; i < lines.Length; i++) + { + // Only inline styles matter. The same value in a class attribute or as text content + // is not parsed as CSS, so a comma there is harmless. + if (!lines[i].Contains("style=", StringComparison.Ordinal)) + { + continue; + } + + foreach (Match match in Interpolation.Matches(lines[i])) + { + var expression = match.Groups["expr"].Success + ? match.Groups["expr"].Value + : match.Groups["expr2"].Value; + + if (expression.Contains("InvariantCulture", StringComparison.Ordinal)) + { + continue; + } + + violations.Add( + $"{SourceTree.RelativePath(file)}:{i + 1} -> @{expression.Trim()}"); + } + } + } + + Assert.True(violations.Count == 0, BuildMessage(violations)); + } + + private static string BuildMessage(IReadOnlyCollection violations) + { + var message = new StringBuilder() + .AppendLine(CultureInfo.InvariantCulture, + $"{violations.Count} numeric value(s) reach an inline style through the current culture.") + .AppendLine() + .AppendLine("Razor renders a bare @value with CurrentCulture, so a comma-decimal locale") + .AppendLine("produces invalid CSS (left: 33,33%) which the browser discards in silence.") + .AppendLine() + .AppendLine("Format through InvariantCulture instead:") + .AppendLine(" style=\"left: @(pct.ToString(\"0.##\", CultureInfo.InvariantCulture))%\"") + .AppendLine() + .AppendLine("Prefer \"0.##\" over the default \"G\" format: G switches to scientific") + .AppendLine("notation below 1e-5, which is invalid CSS by a different route.") + .AppendLine(); + + foreach (var violation in violations) + { + message.AppendLine(" " + violation); + } + + return message.ToString(); + } +} diff --git a/tests/BlazorBlueprint.Tests/Conventions/FocusIndicatorTests.cs b/tests/BlazorBlueprint.Tests/Conventions/FocusIndicatorTests.cs new file mode 100644 index 000000000..7c2e8fff5 --- /dev/null +++ b/tests/BlazorBlueprint.Tests/Conventions/FocusIndicatorTests.cs @@ -0,0 +1,145 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace BlazorBlueprint.Tests.Conventions; + +/// +/// Guards against a component removing the browser's focus outline without drawing a replacement. +/// +/// outline-none suppresses the user agent's focus ring. Without something in its place a +/// focused control is indistinguishable from an unfocused one, which fails +/// WCAG 2.4.7. +/// The whole text-input family shipped that way until #457 — reported in discussion #355, where +/// three people said it decided a library choice against this one. +/// +/// +/// The rule deliberately accepts any recognised affordance, not just a ring: menu items indicate +/// focus with a background change, which is the correct pattern for a roving-focus list. +/// +/// +public class FocusIndicatorTests +{ + private static readonly Regex RemovesOutline = + new(@"(?:focus(?:-visible)?:)?outline-none", RegexOptions.Compiled); + + /// + /// A ring, a ring-coloured border, or a background/text change on focus or on the + /// highlighted/selected state of a list item. + /// + private static readonly Regex HasIndicator = new( + @"focus(?:-visible|-within)?:(?:ring-[1-9]|border-ring|bg-|text-)" + + @"|data-\[(?:focused|highlighted)[^\]]*\]:(?:bg-|text-|ring-)", + RegexOptions.Compiled); + + /// + /// Options in a listbox drive their own selected styling, so aria-selected is itself + /// evidence of an affordance. + /// + private static readonly Regex ManagesSelection = + new(@"aria-selected|data-\[state=selected\]", RegexOptions.Compiled); + + /// + /// Overlay panels are focused programmatically when they open. A ring drawn round the whole + /// panel on every open would be wrong, so outline-none is correct for these by suffix. + /// + private static readonly string[] ContainerSuffixes = + ["Content", "Overlay", "Portal", "Provider", "Host"]; + + /// + /// Components exempted by name, each for a stated reason. Keep this list short — an allowlist + /// nobody trusts is an allowlist that gets muted. Entries marked #459 are real gaps awaiting + /// that issue; they are listed rather than silently skipped so removing them is a visible edit. + /// + private static readonly Dictionary Allowed = new(StringComparer.Ordinal) + { + ["BbInputGroup"] = "Wrapper element; the inner input carries the ring.", + ["BbSidebarInset"] = "Layout container for page content, not a control.", + ["BbDashboardWidget"] = "Widget shell; the focusable controls inside it carry their own.", + ["BbDataGrid"] = "Grid container; header cells and rows manage their own focus.", + ["BbAttachmentTrigger"] = "Known gap, tracked in #459.", + ["BbCommandInput"] = "Known gap, tracked in #459.", + }; + + [Fact] + public void ComponentsThatRemoveTheOutlineProvideAFocusIndicator() + { + var violations = new List(); + + foreach (var (component, text) in SourceTree.ByComponent()) + { + if (!RemovesOutline.IsMatch(text)) + { + continue; + } + + if (HasIndicator.IsMatch(text) || ManagesSelection.IsMatch(text)) + { + continue; + } + + if (ContainerSuffixes.Any(s => component.EndsWith(s, StringComparison.Ordinal))) + { + continue; + } + + if (Allowed.ContainsKey(component)) + { + continue; + } + + violations.Add(component); + } + + Assert.True(violations.Count == 0, BuildMessage(violations)); + } + + /// + /// An allowlist entry that no longer applies is worse than none: it hides a regression behind a + /// stale exemption. If a component here has since gained an indicator, delete its entry. + /// + [Fact] + public void AllowlistHasNoStaleEntries() + { + var byComponent = SourceTree.ByComponent() + .ToDictionary(x => x.Component, x => x.Text, StringComparer.Ordinal); + + var stale = Allowed.Keys + .Where(c => !byComponent.TryGetValue(c, out var text) + || !RemovesOutline.IsMatch(text) + || HasIndicator.IsMatch(text) + || ManagesSelection.IsMatch(text)) + .ToList(); + + Assert.True(stale.Count == 0, + "These allowlist entries no longer need an exemption — the component has gained a focus " + + "indicator, or no longer removes the outline, or no longer exists. Remove them from " + + $"{nameof(Allowed)}:{Environment.NewLine} " + string.Join($"{Environment.NewLine} ", stale)); + } + + private static string BuildMessage(IReadOnlyCollection violations) + { + var message = new StringBuilder() + .AppendLine(CultureInfo.InvariantCulture, + $"{violations.Count} component(s) remove the focus outline without replacing it.") + .AppendLine() + .AppendLine("A focused control then looks identical to an unfocused one, which fails WCAG 2.4.7.") + .AppendLine() + .AppendLine("Add the library's ring:") + .AppendLine(" focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2") + .AppendLine() + .AppendLine("Drop ring-offset-2 for controls that sit directly under a label — form rows") + .AppendLine("leave a 3px gap and an offset ring extends 4px, so it overlaps the label.") + .AppendLine() + .AppendLine("A background change on focus counts too, and is the right pattern for menu items.") + .AppendLine("If the component genuinely needs no indicator, add it to the allowlist with a reason.") + .AppendLine(); + + foreach (var violation in violations) + { + message.AppendLine(" " + violation); + } + + return message.ToString(); + } +} diff --git a/tests/BlazorBlueprint.Tests/Conventions/SourceTree.cs b/tests/BlazorBlueprint.Tests/Conventions/SourceTree.cs new file mode 100644 index 000000000..72869ae39 --- /dev/null +++ b/tests/BlazorBlueprint.Tests/Conventions/SourceTree.cs @@ -0,0 +1,82 @@ +using System.Reflection; + +namespace BlazorBlueprint.Tests.Conventions; + +/// +/// Locates the repository's source directories from the test assembly's location. +/// +/// The convention tests read component markup as text rather than exercising rendered output, +/// because the defects they guard against are omissions in markup — a missing CSS class, a value +/// interpolated without a culture — that a behavioural test cannot see. The logic is correct in +/// both cases; the string is not. +/// +/// +internal static class SourceTree +{ + private static readonly Lazy RepoRootLazy = new(FindRepoRoot); + + /// Every .razor and .cs file in the two component libraries. + internal static IReadOnlyList ComponentSources { get; } = EnumerateSources(); + + private static DirectoryInfo FindRepoRoot() + { + var dir = new DirectoryInfo(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!); + + while (dir is not null) + { + if (Directory.Exists(Path.Combine(dir.FullName, "src", "BlazorBlueprint.Components"))) + { + return dir; + } + + dir = dir.Parent; + } + + throw new InvalidOperationException( + "Could not locate the repository root by walking up from the test assembly. " + + "These tests read component source as text, so they need the working tree — " + + "they cannot run against packaged assemblies alone."); + } + + private static List EnumerateSources() + { + string[] roots = + [ + Path.Combine("src", "BlazorBlueprint.Components", "Components"), + Path.Combine("src", "BlazorBlueprint.Primitives", "Primitives"), + ]; + + var files = new List(); + + foreach (var relative in roots) + { + var root = new DirectoryInfo(Path.Combine(RepoRootLazy.Value.FullName, relative)); + if (!root.Exists) + { + continue; + } + + files.AddRange(root + .EnumerateFiles("*", SearchOption.AllDirectories) + .Where(f => f.Extension is ".razor" or ".cs")); + } + + return files; + } + + /// + /// Groups source files by component, so a component split across Foo.razor and + /// Foo.razor.cs is judged on both halves together. Markup lives in one file and the + /// class strings in the other, so inspecting either alone gives the wrong answer. + /// + internal static IEnumerable<(string Component, string Text)> ByComponent() + { + return ComponentSources + .GroupBy(f => f.Name.Split('.')[0], StringComparer.Ordinal) + .Select(g => (g.Key, string.Join("\n", g.Select(f => File.ReadAllText(f.FullName))))); + } + + /// Path relative to the repository root, for readable assertion messages. + internal static string RelativePath(FileInfo file) => + Path.GetRelativePath(RepoRootLazy.Value.FullName, file.FullName); +} From 4b69a95458d401a922a017ffdf08fdf98ba3a0ec Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 5 Aug 2026 13:43:26 +0800 Subject: [PATCH 176/188] fix(charts): plot scatter, line and area against a real X value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BbScatter, BbLine and BbArea emitted a flat list of Y values, which leaves ECharts to derive each point's X from its index. That is correct for a category axis and wrong for a value axis, which ignores the data handed to it — so X values passed to BbXAxis via DataKey were dropped and points were plotted against ordinal position regardless. The two halves hid each other: the axis looked configured, the labels came from the right property, and the result was a Y-over-position plot wearing X's labels. No arrangement of the existing parameters produced a genuine X:Y plot, including the one advertised in BbScatter's own XML documentation. Add XDataKey to the three cartesian series, emitting explicit [x, y] pairs through a shared SeriesBase.GetPointData helper. Unset, behaviour is unchanged — the flat list is still emitted, so categorical charts and the composite bar-plus-scatter overlay are untouched. Rows missing either coordinate yield a null entry rather than a partial pair, which ECharts would render against a coerced zero. The list stays parallel to the source data rather than compacted, because SymbolSizeKey zips against it by index; compacting would misassign every bubble size after the first gap. BbBar is deliberately excluded: its HasNegativeValues/BuildPerItemData path inspects each datum as a scalar and would misread a pair. BbCandlestick and BbHeatmap already build their own pair data. Also add BbXAxis.Scale, mirroring the existing BbYAxis.Scale. A value axis includes zero by default, so the first genuine numeric X axis put heights of 160-190 in the last sixth of the plot. Opt-in on both axes, since suppressing zero exaggerates small differences. Refs #439 --- CHANGELOG.md | 12 ++ .../Charts/AreaChart/numeric-x.txt | 6 + .../Charts/LineChart/numeric-x.txt | 6 + .../Charts/ScatterChart/bubble.txt | 6 +- .../Charts/ScatterChart/categorical.txt | 6 + .../Charts/ScatterChart/custom-size.txt | 6 +- .../Charts/ScatterChart/default.txt | 6 +- .../Charts/ScatterChart/labels.txt | 6 +- .../Charts/ScatterChart/multiple.txt | 8 +- .../Charts/ScatterChart/small.txt | 6 +- .../Pages/Charts/AreaChartDemo.razor | 58 ++++++++ .../Pages/Charts/LineChartDemo.razor | 58 ++++++++ .../Pages/Charts/ScatterChartDemo.razor | 129 +++++++++++++----- .../Chart/Composables/BbXAxis.razor.cs | 14 ++ .../Components/Chart/Series/BbArea.razor.cs | 14 +- .../Components/Chart/Series/BbLine.razor.cs | 14 +- .../Chart/Series/BbScatter.razor.cs | 24 +++- .../Components/Chart/Series/SeriesBase.cs | 50 +++++++ ...entsApiSurfaceMatchesBaseline.verified.txt | 4 + 19 files changed, 378 insertions(+), 55 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/AreaChart/numeric-x.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/LineChart/numeric-x.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/categorical.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fceceed2..18bb115ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## 2026-08-05 + +### Fixed + +- **Scatter, line and area series could not plot against a real X value** — Reported in [#439](https://github.com/blazorblueprintui/ui/issues/439), where every scatter example on the docs site appeared to compare a Y value against another Y value rather than X against Y. It did, and the cause was not the demo data. `BbScatter`, `BbLine` and `BbArea` emitted a flat list of Y values, which leaves ECharts to derive each point's X from its *index*. That is right for a category axis, and it is why the ordinary categorical chart has always looked correct. It is wrong for a value axis: an axis of `Type="AxisType.Value"` ignores the `data` it is given, so the X values passed to `BbXAxis` via `DataKey` were dropped and the points were plotted against ordinal position regardless. The two halves failed in a way that hid each other — the axis looked configured, the labels came from the right property, and the plot was a Y-over-position chart wearing X's labels. There was no arrangement of the existing parameters that produced a genuine X:Y plot, which also made the answer to the reporter's follow-up question "you can't", and made the example in `BbScatter`'s own XML documentation — which advertised exactly that arrangement — wrong. The three series now take **`XDataKey`**, naming the property that holds each point's X value, and emit explicit `[x, y]` pairs. Leave it unset and nothing changes: the flat list is still emitted, categorical charts are untouched, and the composite bar-plus-scatter overlay keeps working as before. Rows missing either coordinate become a null entry rather than a partial pair, which ECharts would otherwise render against a coerced zero; the returned list stays parallel to the source data rather than being compacted, because `SymbolSizeKey` zips against it by index and a compacted list would have silently misassigned every bubble size after the first gap. + +### Added + +- **`BbXAxis.Scale`** — `BbYAxis` has carried `Scale` for some time; the X axis had no equivalent, which went unnoticed while no chart in the library used a numeric X axis. Plotting the first genuine one surfaced it immediately: a value axis includes zero by default, so heights of 160-190 occupied the last sixth of the plot and the correlation they were meant to show was squeezed into a corner. Set it to scale the axis to the data range instead. It is deliberately opt-in on both axes — suppressing zero exaggerates small differences, so it should be a decision rather than a default, and it is the wrong choice wherever the distance from zero is part of what the reader should take away. + +--- + ## 2026-08-03 ### Added diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/AreaChart/numeric-x.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/AreaChart/numeric-x.txt new file mode 100644 index 000000000..36795a553 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/AreaChart/numeric-x.txt @@ -0,0 +1,6 @@ + + + + + + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/LineChart/numeric-x.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/LineChart/numeric-x.txt new file mode 100644 index 000000000..747069d1d --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/LineChart/numeric-x.txt @@ -0,0 +1,6 @@ + + + + + + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/bubble.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/bubble.txt index ae00868ee..bbedd9ba2 100644 --- a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/bubble.txt +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/bubble.txt @@ -1,7 +1,7 @@ - - + + - \ No newline at end of file diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/categorical.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/categorical.txt new file mode 100644 index 000000000..29752b3e8 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/categorical.txt @@ -0,0 +1,6 @@ + + + + + + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/custom-size.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/custom-size.txt index c4f62966c..6c8a28aea 100644 --- a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/custom-size.txt +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/custom-size.txt @@ -1,6 +1,6 @@ - - + + - + \ No newline at end of file diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/default.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/default.txt index 0c2b833f7..d612ec8cf 100644 --- a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/default.txt +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/default.txt @@ -1,6 +1,6 @@ - - + + - + \ No newline at end of file diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/labels.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/labels.txt index 51338c203..bbfa01cb5 100644 --- a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/labels.txt +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/labels.txt @@ -1,7 +1,7 @@ - - + + - \ No newline at end of file diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/multiple.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/multiple.txt index 206498c17..a6da25e91 100644 --- a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/multiple.txt +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/multiple.txt @@ -1,8 +1,8 @@ - - + + - - + + \ No newline at end of file diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/small.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/small.txt index ec1dcfc45..bd1dcf7d0 100644 --- a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/small.txt +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Charts/ScatterChart/small.txt @@ -1,6 +1,6 @@ - - + + - + \ No newline at end of file diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Charts/AreaChartDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Charts/AreaChartDemo.razor index 08217e404..34dfa6c7a 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Charts/AreaChartDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Charts/AreaChartDemo.razor @@ -322,6 +322,33 @@
    + + +
    +
    +
    +

    Area Chart - Numeric X Axis

    +

    Load measured at irregular intervals

    +
    +
    + + + + + + +
    +
    +
    + Spacing reflects the gaps between readings +
    +
    + XDataKey plots each point at its own X coordinate +
    +
    +
    + +
    @@ -330,6 +357,20 @@

    API Reference

    +
    +

    Area.XDataKey

    +

    + Type: string? +

    +

    + Property name holding each point's X value, plotting it at its own coordinate on a + Value, + Time or + Log axis. Leave it unset for the + usual categorical area, where points are spaced evenly and the X axis supplies its own labels. +

    +
    +

    Area.Curve

    @@ -398,6 +439,23 @@ public int Mobile { get; set; } } + public class ReadingData + { + public int Elapsed { get; set; } + public int Value { get; set; } + } + + private readonly List _readingData = + [ + new() { Elapsed = 0, Value = 12 }, + new() { Elapsed = 5, Value = 34 }, + new() { Elapsed = 8, Value = 28 }, + new() { Elapsed = 20, Value = 61 }, + new() { Elapsed = 24, Value = 55 }, + new() { Elapsed = 45, Value = 78 }, + new() { Elapsed = 60, Value = 72 } + ]; + private string _selectedRange = "90d"; private readonly SelectOption[] _rangeOptions = diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Charts/LineChartDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Charts/LineChartDemo.razor index 23b3d336c..f81a78167 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Charts/LineChartDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Charts/LineChartDemo.razor @@ -210,6 +210,33 @@

    + + +
    +
    +
    +

    Line Chart - Numeric X Axis

    +

    Readings at irregular intervals

    +
    +
    + + + + + + +
    +
    +
    + Spacing reflects the gaps between readings +
    +
    + XDataKey plots each point at its own X coordinate +
    +
    +
    + +
    @@ -218,6 +245,20 @@

    API Reference

    +
    +

    Line.XDataKey

    +

    + Type: string? (default: null) +

    +

    + Property name holding each point's X value, plotting it at its own coordinate on a + Value, + Time or + Log axis. Leave it unset for the + usual categorical line, where points are spaced evenly and the X axis supplies its own labels. +

    +
    +

    Line.Curve

    @@ -296,6 +337,23 @@ public int Mobile { get; set; } } + public class ReadingData + { + public int Elapsed { get; set; } + public int Value { get; set; } + } + + private readonly List _readingData = + [ + new() { Elapsed = 0, Value = 12 }, + new() { Elapsed = 5, Value = 34 }, + new() { Elapsed = 8, Value = 28 }, + new() { Elapsed = 20, Value = 61 }, + new() { Elapsed = 24, Value = 55 }, + new() { Elapsed = 45, Value = 78 }, + new() { Elapsed = 60, Value = 72 } + ]; + private readonly List _monthlyData = [ new() { Month = "Jan", Desktop = 186, Mobile = 80 }, diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Charts/ScatterChartDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Charts/ScatterChartDemo.razor index cef34842f..ea699c5f0 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Charts/ScatterChartDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Charts/ScatterChartDemo.razor @@ -22,15 +22,15 @@

    - - + + - +
    - Default scatter with category axis + True X:Y scatter on a value axis
    Showing height vs weight correlation @@ -48,12 +48,12 @@
    - - + + - - + +
    @@ -76,10 +76,10 @@
    - - + + - +
    @@ -102,10 +102,10 @@
    - - + + - +
    @@ -128,10 +128,10 @@
    - - + + - +
    @@ -154,10 +154,10 @@
    - - + + - +
    @@ -171,6 +171,32 @@
    + +
    +
    +
    +

    Categorical X Axis

    +

    Sales by region

    +
    +
    + + + + + + +
    +
    +
    + Omit XDataKey for discrete categories +
    +
    + Points sit at evenly spaced positions labelled by the axis +
    +
    +
    + +
    @@ -178,6 +204,32 @@

    API Reference

    +
    +

    Scatter.XDataKey

    +

    + Type: string? (default: null) +

    +

    + Property name holding each point's X value, plotting it at its own coordinate on a + Value, + Time or + Log axis. Leave it unset for a + categorical scatter, where points are spaced evenly and the X axis supplies its own labels. +

    +
    + +
    +

    XAxis.Scale / YAxis.Scale

    +

    + Type: bool (default: false) +

    +

    + Scales a value axis to the data range instead of including zero. Without it, heights of + 160-190 sit in the last sixth of the plot. Leave it off wherever the distance from zero + is part of what the reader should see. +

    +
    +

    Scatter.SymbolSize

    @@ -214,22 +266,37 @@ @code { public class ScatterData { - public string Height { get; set; } = ""; + public int Height { get; set; } public int Weight { get; set; } public int Score { get; set; } } private readonly List _scatterData = [ - new() { Height = "160", Weight = 55, Score = 8 }, - new() { Height = "165", Weight = 62, Score = 12 }, - new() { Height = "170", Weight = 68, Score = 15 }, - new() { Height = "172", Weight = 71, Score = 10 }, - new() { Height = "175", Weight = 75, Score = 18 }, - new() { Height = "178", Weight = 80, Score = 22 }, - new() { Height = "180", Weight = 82, Score = 14 }, - new() { Height = "183", Weight = 88, Score = 20 }, - new() { Height = "185", Weight = 90, Score = 16 }, - new() { Height = "190", Weight = 95, Score = 25 } + new() { Height = 160, Weight = 55, Score = 8 }, + new() { Height = 165, Weight = 62, Score = 12 }, + new() { Height = 170, Weight = 68, Score = 15 }, + new() { Height = 172, Weight = 71, Score = 10 }, + new() { Height = 175, Weight = 75, Score = 18 }, + new() { Height = 178, Weight = 80, Score = 22 }, + new() { Height = 180, Weight = 82, Score = 14 }, + new() { Height = 183, Weight = 88, Score = 20 }, + new() { Height = 185, Weight = 90, Score = 16 }, + new() { Height = 190, Weight = 95, Score = 25 } + ]; + + public class RegionData + { + public string Region { get; set; } = ""; + public int Sales { get; set; } + } + + private readonly List _regionData = + [ + new() { Region = "North", Sales = 42 }, + new() { Region = "South", Sales = 68 }, + new() { Region = "East", Sales = 55 }, + new() { Region = "West", Sales = 73 }, + new() { Region = "Central", Sales = 61 } ]; } diff --git a/src/BlazorBlueprint.Components/Components/Chart/Composables/BbXAxis.razor.cs b/src/BlazorBlueprint.Components/Components/Chart/Composables/BbXAxis.razor.cs index 5c6b11e26..b50635d56 100644 --- a/src/BlazorBlueprint.Components/Components/Chart/Composables/BbXAxis.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Chart/Composables/BbXAxis.razor.cs @@ -160,6 +160,19 @@ public partial class BbXAxis : ComponentBase, IChartComponent, IDisposable [Parameter] public object? Max { get; set; } + ///

    + /// Gets or sets whether the axis scale should auto-fit to the data range. + /// + /// + /// Applies to value axes only; category axes ignore it. A value axis includes zero by default, + /// which is the honest choice for magnitudes but wastes the plot area when the data sits far from + /// it — heights of 160-190 against an axis starting at 0 occupy the last sixth of the chart. Set + /// this for those, and leave it off wherever the distance from zero is part of what the reader + /// should see. Ignored when both and are set. + /// + [Parameter] + public bool? Scale { get; set; } + protected override void OnInitialized() => ParentChart?.RegisterComponent(this); @@ -180,6 +193,7 @@ void IChartComponent.ApplyTo(EChartsOption option) Name = Name, Min = Min, Max = Max, + Scale = Scale, Z = LabelInside ? 10 : null, AxisLine = new EChartsAxisLineOption { diff --git a/src/BlazorBlueprint.Components/Components/Chart/Series/BbArea.razor.cs b/src/BlazorBlueprint.Components/Components/Chart/Series/BbArea.razor.cs index de327e660..96ccaa856 100644 --- a/src/BlazorBlueprint.Components/Components/Chart/Series/BbArea.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Chart/Series/BbArea.razor.cs @@ -34,6 +34,18 @@ namespace BlazorBlueprint.Components; /// public partial class BbArea : SeriesBase { + /// + /// Gets or sets the property holding each point's X value. + /// + /// + /// Set this to plot against a numeric or time X axis, where points sit at their own X coordinate + /// rather than at evenly spaced positions — irregular sampling intervals, for instance. Leave it + /// unset for the usual categorical area, where the X axis supplies labels through + /// . + /// + [Parameter] + public string? XDataKey { get; set; } + /// /// Gets or sets the curve interpolation type. /// @@ -74,7 +86,7 @@ internal override EChartsSeriesOption BuildSeriesCore() { Type = "line", Name = GetResolvedName(), - Data = GetSeriesData(), + Data = GetPointData(XDataKey), ShowSymbol = ShowDots, AreaStyle = new EChartsAreaStyleOption { diff --git a/src/BlazorBlueprint.Components/Components/Chart/Series/BbLine.razor.cs b/src/BlazorBlueprint.Components/Components/Chart/Series/BbLine.razor.cs index ae975056d..4c3d96a11 100644 --- a/src/BlazorBlueprint.Components/Components/Chart/Series/BbLine.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Chart/Series/BbLine.razor.cs @@ -27,6 +27,18 @@ namespace BlazorBlueprint.Components; /// public partial class BbLine : SeriesBase { + /// + /// Gets or sets the property holding each point's X value. + /// + /// + /// Set this to plot against a numeric or time X axis, where points sit at their own X coordinate + /// rather than at evenly spaced positions — irregular sampling intervals, for instance. Leave it + /// unset for the usual categorical line, where the X axis supplies labels through + /// . + /// + [Parameter] + public string? XDataKey { get; set; } + /// /// Gets or sets the curve interpolation type. /// @@ -69,7 +81,7 @@ internal override EChartsSeriesOption BuildSeriesCore() { Type = "line", Name = GetResolvedName(), - Data = GetSeriesData(), + Data = GetPointData(XDataKey), ShowSymbol = ShowDots, SymbolSize = ShowDots ? DotSize : 0, LineStyle = new EChartsLineStyleOption diff --git a/src/BlazorBlueprint.Components/Components/Chart/Series/BbScatter.razor.cs b/src/BlazorBlueprint.Components/Components/Chart/Series/BbScatter.razor.cs index 62d1fa139..1e20b17a0 100644 --- a/src/BlazorBlueprint.Components/Components/Chart/Series/BbScatter.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Chart/Series/BbScatter.razor.cs @@ -17,14 +17,32 @@ namespace BlazorBlueprint.Components; /// /// /// <BbScatterChart Data="@data"> -/// <BbXAxis DataKey="x" Type="AxisType.Value" /> +/// <BbXAxis Type="AxisType.Value" /> /// <BbYAxis /> -/// <BbScatter DataKey="y" Name="Series A" SymbolSize="12" /> +/// <BbScatter XDataKey="x" DataKey="y" Name="Series A" SymbolSize="12" /> /// </BbScatterChart> /// /// public partial class BbScatter : SeriesBase { + /// + /// Gets or sets the property holding each point's X value. + /// + /// + /// + /// Set this for a true X:Y scatter — each point is plotted at its own X coordinate on a + /// , or axis. + /// + /// + /// Leave it unset for a categorical scatter, where points are plotted against the position of + /// their row and the X axis supplies its own labels through . + /// Note that a value axis ignores those labels, so numeric X values are only honoured when they + /// are supplied here — on the series, not on the axis. + /// + /// + [Parameter] + public string? XDataKey { get; set; } + /// /// Gets or sets the size of scatter symbols in pixels. /// @@ -67,7 +85,7 @@ internal override EChartsSeriesOption BuildSeriesCore() { var resolvedColor = GetResolvedColor(); var effectiveColor = GetResolvedFillColor() ?? resolvedColor; - var rawData = GetSeriesData(); + var rawData = GetPointData(XDataKey); var series = new EChartsSeriesOption { diff --git a/src/BlazorBlueprint.Components/Components/Chart/Series/SeriesBase.cs b/src/BlazorBlueprint.Components/Components/Chart/Series/SeriesBase.cs index a24d07fe4..84a0f12d3 100644 --- a/src/BlazorBlueprint.Components/Components/Chart/Series/SeriesBase.cs +++ b/src/BlazorBlueprint.Components/Components/Chart/Series/SeriesBase.cs @@ -135,6 +135,56 @@ private protected virtual void ApplyToOption(EChartsOption option) return DataExtractor.ExtractValues(ParentChart?.Data, DataKey); } + /// + /// Extracts data for this series as explicit [x, y] pairs when + /// is supplied, falling back to the flat list when it is not. + /// + /// + /// The property holding each point's X value, or for positional X. + /// + /// + /// + /// A flat list of Y values leaves ECharts to derive X from the point's *index*, which is correct + /// on a category axis but silently discards real X values on a , + /// or axis — the axis ignores its own + /// data, so the points end up plotted against ordinal position. Emitting pairs is what + /// makes a genuine X:Y plot expressible. + /// + /// + /// A row missing either side yields a null entry rather than a partial pair, which ECharts would + /// render against a coerced zero. Null entries are skipped when drawn, and — because callers such + /// as per-point symbol sizing zip this list against the source data by index — the returned list + /// stays parallel to the chart's data rather than being compacted. + /// + /// + /// A list of two-element [x, y] arrays, or the flat Y list. + protected List GetPointData(string? xDataKey) + { + if (string.IsNullOrEmpty(xDataKey)) + { + return GetSeriesData(); + } + + if (string.IsNullOrEmpty(DataKey)) + { + return []; + } + + var xValues = DataExtractor.ExtractValues(ParentChart?.Data, xDataKey); + var yValues = DataExtractor.ExtractValues(ParentChart?.Data, DataKey); + var count = Math.Min(xValues.Count, yValues.Count); + var points = new List(count); + + for (var i = 0; i < count; i++) + { + points.Add(xValues[i] == null || yValues[i] == null + ? null + : new[] { xValues[i], yValues[i] }); + } + + return points; + } + /// /// Resolves the color for this series by checking the explicit Color parameter first, /// then the ChartConfig, returning null if neither is set. diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 090e7e73a..75a772fc1 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -104,6 +104,7 @@ - StackGroup : String - Stacked : Boolean - StrokeWidth : Int32 + - XDataKey : String ### BbAreaChart (BlazorBlueprint.Components) - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] @@ -2299,6 +2300,7 @@ - StackGroup : String - Stacked : Boolean - StrokeWidth : Int32 + - XDataKey : String ### BbLineChart (BlazorBlueprint.Components) - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] @@ -2915,6 +2917,7 @@ - Stacked : Boolean - SymbolSize : Int32 - SymbolSizeKey : String + - XDataKey : String ### BbScatterChart (BlazorBlueprint.Components) - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] @@ -3688,6 +3691,7 @@ - Max : Object - Min : Object - Name : String + - Scale : Boolean? - Show : Boolean - ShowAxisLine : Boolean - ShowGrid : Boolean From 5e0965cbe33a8f3204988d704a9e2b8c38485770 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 5 Aug 2026 13:54:44 +0800 Subject: [PATCH 177/188] chore(deps): move HtmlSanitizer off the prerelease line to stable 9.1.982 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 9.1 beta pin was deliberate but temporary. The reason for being on 9.1 is unchanged: the 9.0.x stable line hard-pins AngleSharp to exactly [0.17.1], which carries GHSA-pgww-w46g-26qg, and an exact pin cannot be lifted from here without NU1608 — a hard error under TreatWarningsAsErrors. What changed is that 9.1 now has a stable release, so that fix no longer costs a prerelease. HtmlSanitizer and AngleSharp.Css were both prerelease transitive dependencies of a published package, which resolves fine for consumers but can trip supply-chain policies that ban prereleases. Resolved graph is now HtmlSanitizer 9.1.982, AngleSharp 1.7.0 and AngleSharp.Css 1.0.1 — no prerelease, no vulnerable package. Sanitiser output compared across the same 41 inputs used to verify the original move (XSS vectors, Quill markup, Markdig output, malformed and non-ASCII HTML): byte-identical between 9.1.949-beta and 9.1.982, with no executable vector surviving either. The library only touches new HtmlSanitizer() and Sanitize(string), both unchanged. Closes #426 --- CHANGELOG.md | 12 ++++++++---- .../BlazorBlueprint.Components.csproj | 8 ++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18bb115ff..06be344aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## 2026-08-05 -### Fixed - -- **Scatter, line and area series could not plot against a real X value** — Reported in [#439](https://github.com/blazorblueprintui/ui/issues/439), where every scatter example on the docs site appeared to compare a Y value against another Y value rather than X against Y. It did, and the cause was not the demo data. `BbScatter`, `BbLine` and `BbArea` emitted a flat list of Y values, which leaves ECharts to derive each point's X from its *index*. That is right for a category axis, and it is why the ordinary categorical chart has always looked correct. It is wrong for a value axis: an axis of `Type="AxisType.Value"` ignores the `data` it is given, so the X values passed to `BbXAxis` via `DataKey` were dropped and the points were plotted against ordinal position regardless. The two halves failed in a way that hid each other — the axis looked configured, the labels came from the right property, and the plot was a Y-over-position chart wearing X's labels. There was no arrangement of the existing parameters that produced a genuine X:Y plot, which also made the answer to the reporter's follow-up question "you can't", and made the example in `BbScatter`'s own XML documentation — which advertised exactly that arrangement — wrong. The three series now take **`XDataKey`**, naming the property that holds each point's X value, and emit explicit `[x, y]` pairs. Leave it unset and nothing changes: the flat list is still emitted, categorical charts are untouched, and the composite bar-plus-scatter overlay keeps working as before. Rows missing either coordinate become a null entry rather than a partial pair, which ECharts would otherwise render against a coerced zero; the returned list stays parallel to the source data rather than being compacted, because `SymbolSizeKey` zips against it by index and a compacted list would have silently misassigned every bubble size after the first gap. - ### Added - **`BbXAxis.Scale`** — `BbYAxis` has carried `Scale` for some time; the X axis had no equivalent, which went unnoticed while no chart in the library used a numeric X axis. Plotting the first genuine one surfaced it immediately: a value axis includes zero by default, so heights of 160-190 occupied the last sixth of the plot and the correlation they were meant to show was squeezed into a corner. Set it to scale the axis to the data range instead. It is deliberately opt-in on both axes — suppressing zero exaggerates small differences, so it should be a decision rather than a default, and it is the wrong choice wherever the distance from zero is part of what the reader should take away. +### Changed + +- **`HtmlSanitizer` moved from the 9.1 prerelease line to stable 9.1.982** — The beta pin was deliberate but always temporary, and it is now unnecessary. The reason for being on 9.1 at all has not changed: the 9.0.x stable line hard-pins `AngleSharp` to exactly `[0.17.1]`, which carries [GHSA-pgww-w46g-26qg](https://github.com/advisories/GHSA-pgww-w46g-26qg), and because the pin is exact the transitive cannot be lifted from here without `NU1608` — a hard error under this repo's `TreatWarningsAsErrors`. What has changed is that 9.1 now has a stable release, so that fix no longer costs us a prerelease. `HtmlSanitizer` and `AngleSharp.Css` were both prerelease transitive dependencies of a package published to NuGet, which resolves fine for consumers but can trip supply-chain policies that ban prereleases outright. The resolved graph is now `HtmlSanitizer` 9.1.982, `AngleSharp` 1.7.0 and `AngleSharp.Css` 1.0.1 — no prerelease anywhere, and no vulnerable package. Sanitiser output was compared across the same 41 inputs used to verify the original move — XSS vectors, Quill rich-text markup, Markdig output, malformed and non-ASCII HTML — and is byte-identical between 9.1.949-beta and 9.1.982, with no executable vector surviving either. The comment above the `PackageReference` has been rewritten: it existed to stop someone reverting to 9.0.x by mistake, which is still worth preventing, but it no longer describes a beta pin that no longer exists. + +### Fixed + +- **Scatter, line and area series could not plot against a real X value** — Reported in [#439](https://github.com/blazorblueprintui/ui/issues/439), where every scatter example on the docs site appeared to compare a Y value against another Y value rather than X against Y. It did, and the cause was not the demo data. `BbScatter`, `BbLine` and `BbArea` emitted a flat list of Y values, which leaves ECharts to derive each point's X from its *index*. That is right for a category axis, and it is why the ordinary categorical chart has always looked correct. It is wrong for a value axis: an axis of `Type="AxisType.Value"` ignores the `data` it is given, so the X values passed to `BbXAxis` via `DataKey` were dropped and the points were plotted against ordinal position regardless. The two halves failed in a way that hid each other — the axis looked configured, the labels came from the right property, and the plot was a Y-over-position chart wearing X's labels. There was no arrangement of the existing parameters that produced a genuine X:Y plot, which also made the answer to the reporter's follow-up question "you can't", and made the example in `BbScatter`'s own XML documentation — which advertised exactly that arrangement — wrong. The three series now take **`XDataKey`**, naming the property that holds each point's X value, and emit explicit `[x, y]` pairs. Leave it unset and nothing changes: the flat list is still emitted, categorical charts are untouched, and the composite bar-plus-scatter overlay keeps working as before. Rows missing either coordinate become a null entry rather than a partial pair, which ECharts would otherwise render against a coerced zero; the returned list stays parallel to the source data rather than being compacted, because `SymbolSizeKey` zips against it by index and a compacted list would have silently misassigned every bubble size after the first gap. + --- ## 2026-08-03 diff --git a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj index a154c7a3a..054b93487 100644 --- a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj +++ b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj @@ -31,10 +31,10 @@ - - + + From 8d4d31957d903a598736ec08980201de11eb1b50 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 5 Aug 2026 14:57:03 +0800 Subject: [PATCH 178/188] fix(currency-input): resolve focus, sanitise and parse through one culture Focusing a BbCurrencyInput multiplied its value by 100, compounding on every visit: 999,99 came back as 99.999,00. Three steps read three different cultures for the same value: FormatCurrency / TryParseValue -> the currency's culture (EUR = de-DE, comma decimal, dot group separator) JsOnFocus -> invariant, so the field showed 999.99 GetJsConfig -> the ambient culture's separator On Blazor Server the ambient culture is the server's, not the user's, so the sanitiser was told "dot" for every visitor regardless of locale and left the dot in place. On blur TryParseValue read 999.99 back through de-DE, stripped the dot as a group separator, and parsed 99999. Each step was individually defensible; they just disagreed about what a dot meant. Resolve all three through the currency's culture so the string written on focus is the string the parser expects on blur. Not locale-specific to the reporter: any currency whose culture disagrees with the server's on the decimal separator was affected. Verified across USD, EUR, JPY (zero decimal places) and a currency using the Arabic decimal separator, each stable across repeated focus/blur. Refs #438 --- CHANGELOG.md | 1 + .../CurrencyInput/BbCurrencyInput.razor.cs | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06be344aa..9be62436f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **BbCurrencyInput multiplied its value by 100 every time the field was focused** — Reported in [#438](https://github.com/blazorblueprintui/ui/issues/438) against `CurrencyCode="EUR"`: focus the field, leave it, and `999,99` came back as `99.999,00`, compounding on every visit. The component was reading three different cultures for one value. `FormatCurrency` and `TryParseValue` used the **currency's** culture — `EUR` maps to `de-DE`, so comma decimal and dot group separator. `JsOnFocus` swapped the display to an **invariant** string for editing, producing `999.99` with a dot. `GetJsConfig` then handed the JS sanitiser the **ambient** culture's separator, which on Blazor Server is the *server's* rather than the user's — so on the docs site it was a dot for everyone regardless of their locale, and the sanitiser happily left the dot in place. On blur, `TryParseValue` read that `999.99` back through `de-DE`, stripped the dot as a *group* separator, and parsed `99999`. Nothing was wrong with any single step; they simply disagreed about what a dot meant. All three now resolve through the currency's culture, so the string written on focus is the string the parser expects on blur. The reporter's own diagnosis — a thousands separator being misread — turned out to be exactly right, just one layer further down than the parse routine they suspected. Note this was never locale-specific to the reporter: any currency whose culture disagrees with the server's on the decimal separator was affected, which on a `de-DE`-backed `EUR` field is every user of a default-configured Blazor Server app. Verified across USD, EUR, JPY (no decimal places) and a currency using the Arabic decimal separator `٫`, each stable across repeated focus/blur cycles. - **Scatter, line and area series could not plot against a real X value** — Reported in [#439](https://github.com/blazorblueprintui/ui/issues/439), where every scatter example on the docs site appeared to compare a Y value against another Y value rather than X against Y. It did, and the cause was not the demo data. `BbScatter`, `BbLine` and `BbArea` emitted a flat list of Y values, which leaves ECharts to derive each point's X from its *index*. That is right for a category axis, and it is why the ordinary categorical chart has always looked correct. It is wrong for a value axis: an axis of `Type="AxisType.Value"` ignores the `data` it is given, so the X values passed to `BbXAxis` via `DataKey` were dropped and the points were plotted against ordinal position regardless. The two halves failed in a way that hid each other — the axis looked configured, the labels came from the right property, and the plot was a Y-over-position chart wearing X's labels. There was no arrangement of the existing parameters that produced a genuine X:Y plot, which also made the answer to the reporter's follow-up question "you can't", and made the example in `BbScatter`'s own XML documentation — which advertised exactly that arrangement — wrong. The three series now take **`XDataKey`**, naming the property that holds each point's X value, and emit explicit `[x, y]` pairs. Leave it unset and nothing changes: the flat list is still emitted, categorical charts are untouched, and the composite bar-plus-scatter overlay keeps working as before. Rows missing either coordinate become a null entry rather than a partial pair, which ECharts would otherwise render against a coerced zero; the returned list stays parallel to the source data rather than being compacted, because `SymbolSizeKey` zips against it by index and a compacted list would have silently misassigned every bubble size after the first gap. --- diff --git a/src/BlazorBlueprint.Components/Components/CurrencyInput/BbCurrencyInput.razor.cs b/src/BlazorBlueprint.Components/Components/CurrencyInput/BbCurrencyInput.razor.cs index e335d8396..6361da137 100644 --- a/src/BlazorBlueprint.Components/Components/CurrencyInput/BbCurrencyInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/CurrencyInput/BbCurrencyInput.razor.cs @@ -256,6 +256,12 @@ protected override async Task OnAfterRenderAsync(bool firstRender) /// /// Builds the JS configuration object from current parameters. /// + /// + /// The separator handed to the JS sanitiser must be the one will read + /// back, which is the currency's — see . Using the ambient culture + /// here made the two disagree whenever they differed, and on Blazor Server the ambient culture is + /// the server's rather than the user's, so they differed for everyone. + /// private object GetJsConfig() => new { disableDebounce = DisableDebounce, @@ -263,7 +269,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) stepKeys = new[] { "ArrowUp", "ArrowDown" }, allowDecimal = true, allowNegative = AllowNegative, - decimalSeparator = CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator, + decimalSeparator = CultureInfo.NumberFormat.NumberDecimalSeparator, enableWheelStep = EnableWheelStep }; @@ -373,8 +379,11 @@ public void JsOnFocus() { if (disposed) { return; } - // Show raw number without formatting for easier editing - editingValue = Value.ToString($"F{Currency.DecimalPlaces}", CultureInfo.InvariantCulture); + // Show the raw number without group separators for easier editing. Formatted through the + // currency's culture, not the invariant one: TryParseValue reads this string back through + // that same culture on blur, so an invariant "999.99" against a de-DE parse would have its + // dot stripped as a group separator and come back as 99999. + editingValue = Value.ToString($"F{Currency.DecimalPlaces}", CultureInfo); isEditing = true; StateHasChanged(); } @@ -410,7 +419,7 @@ private async Task SetValue(decimal value) if (clampedValue != Value) { Value = clampedValue; - editingValue = clampedValue.ToString($"F{Currency.DecimalPlaces}", CultureInfo.InvariantCulture); + editingValue = clampedValue.ToString($"F{Currency.DecimalPlaces}", CultureInfo); await ValueChanged.InvokeAsync(clampedValue); NotifyFieldChanged(); } From e4c28cc91299947cd288311b6941c6c07ccd153b Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 5 Aug 2026 15:08:15 +0800 Subject: [PATCH 179/188] feat(copy-text): add ValueFunc for copy-time value resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Value had to be a string already in hand, so text only known at copy time — derived from state that moves, or expensive enough that computing it every render is wasted — had to be recomputed on each render and held in a field. ValueFunc is a Func evaluated when the user copies. Naming follows the convention already used for this shape (CellClassFunc, DayClassFunc). Value still wins when set. The precedence test is emptiness rather than null: Value is a string, and a bound-but-unset one reaches the empty string naturally, which a literal first-non-null reading would let win and silently copy nothing. Value loses [EditorRequired] — supplying only a func is now a complete configuration, and the analyzer would otherwise nag everyone who does. Resolved once per click rather than per render, so an expensive func is not called speculatively and OnCopied cannot report a different string from the one placed on the clipboard. The async counterpart is deliberately excluded: awaiting a consumer task before writing spends the transient user activation clipboard writes require. Tracked in #466. Refs #453 --- CHANGELOG.md | 2 + .../Components/CopyText/value-func.txt | 19 +++++++ .../Pages/Components/CopyTextDemo.razor | 49 ++++++++++++++++++- .../Components/CopyText/BbCopyText.razor.cs | 46 +++++++++++++++-- ...entsApiSurfaceMatchesBaseline.verified.txt | 3 +- 5 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/value-func.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 06be344aa..60b5cafb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`BbXAxis.Scale`** — `BbYAxis` has carried `Scale` for some time; the X axis had no equivalent, which went unnoticed while no chart in the library used a numeric X axis. Plotting the first genuine one surfaced it immediately: a value axis includes zero by default, so heights of 160-190 occupied the last sixth of the plot and the correlation they were meant to show was squeezed into a corner. Set it to scale the axis to the data range instead. It is deliberately opt-in on both axes — suppressing zero exaggerates small differences, so it should be a decision rather than a default, and it is the wrong choice wherever the distance from zero is part of what the reader should take away. +- **`BbCopyText.ValueFunc`** — Requested in [#453](https://github.com/blazorblueprintui/ui/issues/453) by [@DCarlson12](https://github.com/DCarlson12): some text is only known at copy time, derived from state that moves or expensive enough that computing it for every render is wasted. `Value` had to be a string already in hand, so the only way to get a fresh one was to recompute it on every render and hold it in a field. `ValueFunc` is a `Func` evaluated when the user actually copies. Naming follows the convention the library already uses for this shape — `CellClassFunc`, `DayClassFunc`. `Value` still wins when set, and the precedence test is deliberately **emptiness rather than null**: `Value` is a `string`, and a bound-but-unset one reaches the empty string naturally, which under a literal first-non-null reading would win and silently copy nothing. `Value` also loses its `[EditorRequired]` attribute, since supplying only a func is now a complete configuration and the analyzer would otherwise nag everyone who does. The value is resolved once per click rather than per render, so an expensive func is not called speculatively and `OnCopied` cannot report a different string from the one placed on the clipboard. The asynchronous counterpart is deliberately **not** part of this: awaiting a consumer's task before writing spends the transient user activation clipboard writes require, and several browsers — Safari most strictly — then reject the write, which the existing fallback silently swallows. That needs a JS-layer change handing the promise to `ClipboardItem`, tracked in [#466](https://github.com/blazorblueprintui/ui/issues/466). + ### Changed - **`HtmlSanitizer` moved from the 9.1 prerelease line to stable 9.1.982** — The beta pin was deliberate but always temporary, and it is now unnecessary. The reason for being on 9.1 at all has not changed: the 9.0.x stable line hard-pins `AngleSharp` to exactly `[0.17.1]`, which carries [GHSA-pgww-w46g-26qg](https://github.com/advisories/GHSA-pgww-w46g-26qg), and because the pin is exact the transitive cannot be lifted from here without `NU1608` — a hard error under this repo's `TreatWarningsAsErrors`. What has changed is that 9.1 now has a stable release, so that fix no longer costs us a prerelease. `HtmlSanitizer` and `AngleSharp.Css` were both prerelease transitive dependencies of a package published to NuGet, which resolves fine for consumers but can trip supply-chain policies that ban prereleases outright. The resolved graph is now `HtmlSanitizer` 9.1.982, `AngleSharp` 1.7.0 and `AngleSharp.Css` 1.0.1 — no prerelease anywhere, and no vulnerable package. Sanitiser output was compared across the same 41 inputs used to verify the original move — XSS vectors, Quill rich-text markup, Markdig output, malformed and non-ASCII HTML — and is byte-identical between 9.1.949-beta and 9.1.982, with no executable vector surviving either. The comment above the `PackageReference` has been rewritten: it existed to stop someone reverting to 9.0.x by mistake, which is still worth preventing, but it no longer describes a beta pin that no longer exists. diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/value-func.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/value-func.txt new file mode 100644 index 000000000..42495d7f2 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/value-func.txt @@ -0,0 +1,19 @@ +Environment: @_environment + + Change + + +Connection token: + + @_environment-<generated at copy time> + + +@code { + private static readonly string[] Environments = ["dev", "staging", "prod"]; + + private string _environment = Environments[0]; + + private void CycleEnvironment() => + _environment = Environments[(Array.IndexOf(Environments, _environment) + 1) % Environments.Length]; +} diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor index faaa2d0ca..fcb1e5a24 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor @@ -78,6 +78,36 @@
    + +
    +
    +

    Value From a Function

    +

    + ValueFunc is evaluated when the + user copies, not on every render — for text derived from state that moves, or expensive + enough that computing it up front would be wasted. Copy the token below, change the + selection, and copy again. +

    +
    +
    +
    + Environment: + @_environment + + Change + +
    +
    + Connection token: + + @_environment-<generated at copy time> + +
    +
    + +
    +
    @@ -87,7 +117,15 @@
    - The text to copy to clipboard when clicked. + The text to copy to clipboard when clicked. Takes precedence over + ValueFunc when non-empty. + + + A function producing the text to copy, evaluated at click time rather than on every + render. Used when Value is + null or empty — the test is emptiness, not null, so a bound-but-unset + Value falls through to the + function instead of copying nothing. The content displayed inside the copy text element. @@ -121,3 +159,12 @@ opts.CopyText.Copied = ""Kopiert!""; });")" />
    + +@code { + private static readonly string[] Environments = ["dev", "staging", "prod"]; + + private string _environment = Environments[0]; + + private void CycleEnvironment() => + _environment = Environments[(Array.IndexOf(Environments, _environment) + 1) % Environments.Length]; +} diff --git a/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs b/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs index 6f4060335..088e7817d 100644 --- a/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs +++ b/src/BlazorBlueprint.Components/Components/CopyText/BbCopyText.razor.cs @@ -25,9 +25,37 @@ public partial class BbCopyText : ComponentBase, IAsyncDisposable /// /// Sets the value to be copied to the clipboard when clicked. /// - [Parameter, EditorRequired] + /// + /// Takes precedence over when non-empty. Not EditorRequired, because + /// supplying instead is a complete configuration. + /// + [Parameter] public string? Value { get; set; } + /// + /// Gets or sets a function producing the value to copy, evaluated at click time. + /// + /// + /// + /// Use this where the text is not known up front — derived from state that moves, or expensive + /// enough that computing it for every render would be wasteful. It is only called when the user + /// actually copies. + /// + /// + /// wins when it is a non-empty string. The test is emptiness rather than null + /// so that an unset-but-bound Value — the empty string, which a plain string binding + /// reaches naturally — falls through to the function instead of silently copying nothing. + /// + /// + /// This is the synchronous form deliberately. An asynchronous counterpart cannot simply await a + /// consumer's task before writing: clipboard writes require transient user activation, and several + /// browsers treat awaiting as spending it. Tracked separately in + /// #466. + /// + /// + [Parameter] + public Func? ValueFunc { get; set; } + /// /// Gets or sets the content displayed inside the copy text element. /// @@ -117,14 +145,24 @@ private async Task HandleKeyDownAsync(KeyboardEventArgs e) } } + /// + /// Resolves the text to copy: when non-empty, otherwise . + /// + private string? ResolveValue() => + !string.IsNullOrEmpty(Value) ? Value : ValueFunc?.Invoke(); + private async Task HandleClickAsync() { - if (string.IsNullOrEmpty(Value)) + // Resolved once per click, not per render — ValueFunc may be expensive, and copying then + // reporting two different strings through OnCopied would be worse than either. + var value = ResolveValue(); + + if (string.IsNullOrEmpty(value)) { return; } - var success = await CopyToClipboardAsync(Value); + var success = await CopyToClipboardAsync(value); if (!success) { return; @@ -134,7 +172,7 @@ private async Task HandleClickAsync() if (OnCopied.HasDelegate) { - await OnCopied.InvokeAsync(Value); + await OnCopied.InvokeAsync(value); } } diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 75a772fc1..d3a2a4eb5 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -685,7 +685,8 @@ - ChildContent : RenderFragment - Class : String - OnCopied : EventCallback - - Value : String [EditorRequired] + - Value : String + - ValueFunc : Func ### BbCurrencyInput (BlazorBlueprint.Components) - AllowNegative : Boolean From c8eee9902feccb62fff57c065c9fbfc182ac7cc5 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 5 Aug 2026 14:59:38 +0800 Subject: [PATCH 180/188] fix(numeric-input): size the stepper buttons from the field, not a constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ButtonClass was static, so it could not observe anything about the instance — including Class, which is the only way to size this component since it has no size parameter. Each button was h-5, and two of them summed to exactly the input's default h-10. The pieces lined up by coincidence rather than by construction, so the mismatch only surfaced once a consumer changed the height: Class="h-8" shrank the field to 32px through TailwindMerge while the stepper stayed at 40px, overhanging it at both ends with the rounded corners no longer meeting the input's border. Buttons now take half the stepper column via flex-1, and the row is items-stretch rather than items-center, so the column sizes itself from the field whatever sets that height. min-h-0 lets them shrink past the chevron's intrinsic height. BbCurrencyInput shares the markup shape but renders a currency symbol rather than stepper buttons, so it is unaffected. Refs #465 --- CHANGELOG.md | 1 + .../NumericInput/BbNumericInput.razor.cs | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 376a607f5..72a62456f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - **BbCurrencyInput multiplied its value by 100 every time the field was focused** — Reported in [#438](https://github.com/blazorblueprintui/ui/issues/438) against `CurrencyCode="EUR"`: focus the field, leave it, and `999,99` came back as `99.999,00`, compounding on every visit. The component was reading three different cultures for one value. `FormatCurrency` and `TryParseValue` used the **currency's** culture — `EUR` maps to `de-DE`, so comma decimal and dot group separator. `JsOnFocus` swapped the display to an **invariant** string for editing, producing `999.99` with a dot. `GetJsConfig` then handed the JS sanitiser the **ambient** culture's separator, which on Blazor Server is the *server's* rather than the user's — so on the docs site it was a dot for everyone regardless of their locale, and the sanitiser happily left the dot in place. On blur, `TryParseValue` read that `999.99` back through `de-DE`, stripped the dot as a *group* separator, and parsed `99999`. Nothing was wrong with any single step; they simply disagreed about what a dot meant. All three now resolve through the currency's culture, so the string written on focus is the string the parser expects on blur. The reporter's own diagnosis — a thousands separator being misread — turned out to be exactly right, just one layer further down than the parse routine they suspected. Note this was never locale-specific to the reporter: any currency whose culture disagrees with the server's on the decimal separator was affected, which on a `de-DE`-backed `EUR` field is every user of a default-configured Blazor Server app. Verified across USD, EUR, JPY (no decimal places) and a currency using the Arabic decimal separator `٫`, each stable across repeated focus/blur cycles. +- **BbNumericInput: the stepper buttons kept a fixed height when the field was resized** — Reported as part of [#328](https://github.com/blazorblueprintui/ui/issues/328) and split out as [#465](https://github.com/blazorblueprintui/ui/issues/465). `ButtonClass` was a **static** member, so it could not observe anything about the instance — including `Class`, which is the only way to size this component since it has no size parameter. Each button was `h-5`, and two of them summed to exactly the input's default `h-10`; the pieces lined up by coincidence rather than by construction, which is why the mismatch only appeared once a consumer changed the height. Passing `Class="h-8"` shrank the field to 32px through TailwindMerge while the stepper stayed at 40px, overhanging the field at both ends with its rounded corners no longer meeting the input's border. The buttons now take half the stepper column via `flex-1` instead of a fixed height, and the row is `items-stretch` rather than `items-center`, so the column sizes itself from the field rather than from a height of its own — whatever sets that height. `min-h-0` lets them shrink past the chevron's intrinsic height for genuinely small fields. `BbCurrencyInput` was checked for the same shape and is unaffected: it renders a currency symbol rather than stepper buttons. - **Scatter, line and area series could not plot against a real X value** — Reported in [#439](https://github.com/blazorblueprintui/ui/issues/439), where every scatter example on the docs site appeared to compare a Y value against another Y value rather than X against Y. It did, and the cause was not the demo data. `BbScatter`, `BbLine` and `BbArea` emitted a flat list of Y values, which leaves ECharts to derive each point's X from its *index*. That is right for a category axis, and it is why the ordinary categorical chart has always looked correct. It is wrong for a value axis: an axis of `Type="AxisType.Value"` ignores the `data` it is given, so the X values passed to `BbXAxis` via `DataKey` were dropped and the points were plotted against ordinal position regardless. The two halves failed in a way that hid each other — the axis looked configured, the labels came from the right property, and the plot was a Y-over-position chart wearing X's labels. There was no arrangement of the existing parameters that produced a genuine X:Y plot, which also made the answer to the reporter's follow-up question "you can't", and made the example in `BbScatter`'s own XML documentation — which advertised exactly that arrangement — wrong. The three series now take **`XDataKey`**, naming the property that holds each point's X value, and emit explicit `[x, y]` pairs. Leave it unset and nothing changes: the flat list is still emitted, categorical charts are untouched, and the composite bar-plus-scatter overlay keeps working as before. Rows missing either coordinate become a null entry rather than a partial pair, which ECharts would otherwise render against a coerced zero; the returned list stays parallel to the source data rather than being compacted, because `SymbolSizeKey` zips against it by index and a compacted list would have silently misassigned every bubble size after the first gap. --- diff --git a/src/BlazorBlueprint.Components/Components/NumericInput/BbNumericInput.razor.cs b/src/BlazorBlueprint.Components/Components/NumericInput/BbNumericInput.razor.cs index 50e5f2414..fcf0c9ea4 100644 --- a/src/BlazorBlueprint.Components/Components/NumericInput/BbNumericInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/NumericInput/BbNumericInput.razor.cs @@ -289,7 +289,9 @@ private string DisplayValue private static string InputMode => IsFloatingPoint ? "decimal" : "numeric"; private string ContainerClass => ClassNames.cn( - "flex items-center", + // items-stretch, not items-center: the stepper column sizes itself from the row rather than + // from a height of its own, so it tracks whatever the input resolves to. + "flex items-stretch", ShowButtons ? "rounded-md" : null ); @@ -306,8 +308,19 @@ private string DisplayValue Class ); + /// + /// Classes for the increment/decrement buttons. + /// + /// + /// Each button takes half the stepper column via flex-1 rather than a fixed height. It was + /// h-5, which summed across the two buttons to exactly the input's default h-10 — so + /// they lined up until something changed the input's height, and sizing is done entirely through + /// since there is no size parameter. A consumer passing h-8 got a 40px + /// stepper beside a 32px field, overhanging it at both ends with the rounded corners no longer + /// meeting the input's border. min-h-0 lets them shrink past the icon's intrinsic height. + /// private static string ButtonClass => ClassNames.cn( - "flex items-center justify-center w-8 h-5 border border-input bg-background", + "flex flex-1 min-h-0 items-center justify-center w-8 border border-input bg-background", "hover:bg-accent hover:text-accent-foreground", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "disabled:cursor-not-allowed disabled:opacity-50", From 8af7e9187649a414b685b95852be390dbf065278 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 5 Aug 2026 15:42:36 +0800 Subject: [PATCH 181/188] fix(demo): correct ButtonSize.Sm -> ButtonSize.Small in CopyText demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ValueFunc demo added in #469 used a ButtonSize value that does not exist, breaking the build of every demo host. It got through because the demo build output was piped through tail -2, which shows MSBuild's elapsed-time line on failure as well as success, and the server was then started with --no-build — so the browser check ran against the previous build rather than this markup. Verified this time by exit code across all three demo hosts (Server, Wasm and Auto) rather than by tail, and by exercising the demo in a browser: the button renders at 36px, cycling the environment updates the copied value, and copying reaches the "Copied!" state. --- .../CodeExamples/Components/CopyText/value-func.txt | 2 +- .../Pages/Components/CopyTextDemo.razor | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/value-func.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/value-func.txt index 42495d7f2..3295130e8 100644 --- a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/value-func.txt +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/value-func.txt @@ -1,5 +1,5 @@ Environment: @_environment - + Change diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor index fcb1e5a24..1cd9f7818 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor @@ -93,7 +93,7 @@
    Environment: @_environment - + Change
    From a9dc7afa4cadd80946cbb8633c877d348c5bfed4 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 5 Aug 2026 15:42:36 +0800 Subject: [PATCH 182/188] fix(demo): correct ButtonSize.Sm -> ButtonSize.Small in CopyText demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ValueFunc demo added in #469 used a ButtonSize value that does not exist, breaking the build of every demo host. It got through because the demo build output was piped through tail -2, which shows MSBuild's elapsed-time line on failure as well as success, and the server was then started with --no-build — so the browser check ran against the previous build rather than this markup. Verified this time by exit code across all three demo hosts (Server, Wasm and Auto) rather than by tail, and by exercising the demo in a browser: the button renders at 36px, cycling the environment updates the copied value, and copying reaches the "Copied!" state. --- .../CodeExamples/Components/CopyText/value-func.txt | 2 +- .../Pages/Components/CopyTextDemo.razor | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/value-func.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/value-func.txt index 42495d7f2..3295130e8 100644 --- a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/value-func.txt +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/CopyText/value-func.txt @@ -1,5 +1,5 @@ Environment: @_environment - + Change diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor index fcb1e5a24..1cd9f7818 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/CopyTextDemo.razor @@ -93,7 +93,7 @@
    Environment: @_environment - + Change
    From 80e6e3d215f863f1481c6bbc4d86712bfc1565e3 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 5 Aug 2026 16:27:11 +0800 Subject: [PATCH 183/188] fix(floating-portal): hide through JS, since showing went through JS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BbCopyText tooltips stayed on screen once shown and accumulated one per hover; a later scroll then scattered them over unrelated text, because each sits at the coordinates it was given. The failure is at the boundary between Blazor's rendering and JS's. SetupPositioningAsync shows content by calling applyPosition(makeVisible: true), which writes visibility, opacity and pointer-events onto the element with !important — the style attribute belongs to JS from then on. Blazor diffs against what it last rendered, not against the DOM, and its markup for a closed portal is byte-identical to the markup it rendered before the portal opened. So on close it correctly concludes nothing changed, emits no style update, and the element keeps the visible values JS left behind. Nothing in the close path was broken: isHovered reached false, the component re-rendered, data-state flipped to "closed", the auto-update subscription was disposed, and a MutationObserver recorded no further writes. The style simply had no owner willing to reset it. Add IPositioningService.HidePositionAsync, mirroring GetInitialStyle()'s hidden state, and call it from HideAsync — the single teardown both the ForceMount and standard lifecycles route through. Hiding is now symmetric with showing. Components whose markup changes elsewhere while open — BbTooltip among them — were never affected: any other difference gives Blazor a reason to re-emit the attribute. That is why this looked component-specific rather than structural. --- CHANGELOG.md | 1 + .../Floating/BbFloatingPortal.razor | 19 +++++++++++++++ .../Services/IPositioningService.cs | 13 ++++++++++ .../Services/PositioningService.cs | 7 ++++++ .../wwwroot/js/primitives/positioning.js | 24 +++++++++++++++++++ ...ivesApiSurfaceMatchesBaseline.verified.txt | 1 + 6 files changed, 65 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72a62456f..09b7579f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **BbCopyText: tooltips stayed on screen once shown, accumulating one per hover** — Hovering a copyable value left its tooltip behind permanently; hovering several left several, and because each sits at the coordinates it was given, a subsequent scroll scattered them across unrelated text. The cause sits at the boundary between Blazor's rendering and JS's. `BbFloatingPortal` shows floating content by calling into `positioning.js`, which writes `visibility`, `opacity` and `pointer-events` straight onto the element with `!important` — the style attribute is JS's from that moment on. Blazor, though, diffs against what it last *rendered*, not against what the DOM actually holds. Its markup for a closed portal is byte-identical to the markup it rendered before the portal opened, so on close it correctly concludes nothing changed and emits no style update at all. The element keeps the visible values JS left on it. Nothing was broken in the close path itself: `isHovered` reached `false`, the component re-rendered, `data-state` duly flipped to `"closed"`, the auto-update subscription was disposed, and a `MutationObserver` recorded no further writes. The style simply had no owner willing to reset it. Hiding is now explicit and symmetric with showing: a new `IPositioningService.HidePositionAsync` writes the off-screen hidden state back through the same JS path, mirroring `GetInitialStyle()`. Components whose markup changes elsewhere while open — `BbTooltip` among them — were never affected, because any other difference gives Blazor a reason to re-emit the attribute; that is why this looked component-specific rather than structural. - **BbCurrencyInput multiplied its value by 100 every time the field was focused** — Reported in [#438](https://github.com/blazorblueprintui/ui/issues/438) against `CurrencyCode="EUR"`: focus the field, leave it, and `999,99` came back as `99.999,00`, compounding on every visit. The component was reading three different cultures for one value. `FormatCurrency` and `TryParseValue` used the **currency's** culture — `EUR` maps to `de-DE`, so comma decimal and dot group separator. `JsOnFocus` swapped the display to an **invariant** string for editing, producing `999.99` with a dot. `GetJsConfig` then handed the JS sanitiser the **ambient** culture's separator, which on Blazor Server is the *server's* rather than the user's — so on the docs site it was a dot for everyone regardless of their locale, and the sanitiser happily left the dot in place. On blur, `TryParseValue` read that `999.99` back through `de-DE`, stripped the dot as a *group* separator, and parsed `99999`. Nothing was wrong with any single step; they simply disagreed about what a dot meant. All three now resolve through the currency's culture, so the string written on focus is the string the parser expects on blur. The reporter's own diagnosis — a thousands separator being misread — turned out to be exactly right, just one layer further down than the parse routine they suspected. Note this was never locale-specific to the reporter: any currency whose culture disagrees with the server's on the decimal separator was affected, which on a `de-DE`-backed `EUR` field is every user of a default-configured Blazor Server app. Verified across USD, EUR, JPY (no decimal places) and a currency using the Arabic decimal separator `٫`, each stable across repeated focus/blur cycles. - **BbNumericInput: the stepper buttons kept a fixed height when the field was resized** — Reported as part of [#328](https://github.com/blazorblueprintui/ui/issues/328) and split out as [#465](https://github.com/blazorblueprintui/ui/issues/465). `ButtonClass` was a **static** member, so it could not observe anything about the instance — including `Class`, which is the only way to size this component since it has no size parameter. Each button was `h-5`, and two of them summed to exactly the input's default `h-10`; the pieces lined up by coincidence rather than by construction, which is why the mismatch only appeared once a consumer changed the height. Passing `Class="h-8"` shrank the field to 32px through TailwindMerge while the stepper stayed at 40px, overhanging the field at both ends with its rounded corners no longer meeting the input's border. The buttons now take half the stepper column via `flex-1` instead of a fixed height, and the row is `items-stretch` rather than `items-center`, so the column sizes itself from the field rather than from a height of its own — whatever sets that height. `min-h-0` lets them shrink past the chevron's intrinsic height for genuinely small fields. `BbCurrencyInput` was checked for the same shape and is unaffected: it renders a currency symbol rather than stepper buttons. - **Scatter, line and area series could not plot against a real X value** — Reported in [#439](https://github.com/blazorblueprintui/ui/issues/439), where every scatter example on the docs site appeared to compare a Y value against another Y value rather than X against Y. It did, and the cause was not the demo data. `BbScatter`, `BbLine` and `BbArea` emitted a flat list of Y values, which leaves ECharts to derive each point's X from its *index*. That is right for a category axis, and it is why the ordinary categorical chart has always looked correct. It is wrong for a value axis: an axis of `Type="AxisType.Value"` ignores the `data` it is given, so the X values passed to `BbXAxis` via `DataKey` were dropped and the points were plotted against ordinal position regardless. The two halves failed in a way that hid each other — the axis looked configured, the labels came from the right property, and the plot was a Y-over-position chart wearing X's labels. There was no arrangement of the existing parameters that produced a genuine X:Y plot, which also made the answer to the reporter's follow-up question "you can't", and made the example in `BbScatter`'s own XML documentation — which advertised exactly that arrangement — wrong. The three series now take **`XDataKey`**, naming the property that holds each point's X value, and emit explicit `[x, y]` pairs. Leave it unset and nothing changes: the flat list is still emitted, categorical charts are untouched, and the composite bar-plus-scatter overlay keeps working as before. Rows missing either coordinate become a null entry rather than a partial pair, which ECharts would otherwise render against a coerced zero; the returned list stays parallel to the source data rather than being compacted, because `SymbolSizeKey` zips against it by index and a compacted list would have silently misassigned every bubble size after the first gap. diff --git a/src/BlazorBlueprint.Primitives/Primitives/Floating/BbFloatingPortal.razor b/src/BlazorBlueprint.Primitives/Primitives/Floating/BbFloatingPortal.razor index e2babce2a..50188f689 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Floating/BbFloatingPortal.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Floating/BbFloatingPortal.razor @@ -437,6 +437,25 @@ _positioningCleanup = null; } + // Hand the style attribute back, explicitly. SetupPositioningAsync showed this element + // through JS — writing visibility, opacity and pointer-events with !important — and Blazor + // diffs against what it last rendered rather than against the DOM. Where a component's + // closed markup is byte-identical to its pre-open markup (nothing else about it changed + // while open), the close produces no style update at all and the element stays visible with + // the values JS left behind. Rendering the hidden style is not enough on its own; it has to + // be written back the same way it was overwritten. + if (_isPositioned) + { + try + { + await PositioningService.HidePositionAsync(_portalContentRef); + } + catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException) + { + // Circuit gone or element already torn down — nothing left to hide. + } + } + _isPositioned = false; } diff --git a/src/BlazorBlueprint.Primitives/Services/IPositioningService.cs b/src/BlazorBlueprint.Primitives/Services/IPositioningService.cs index 541a8e620..34cdf613d 100644 --- a/src/BlazorBlueprint.Primitives/Services/IPositioningService.cs +++ b/src/BlazorBlueprint.Primitives/Services/IPositioningService.cs @@ -27,6 +27,19 @@ public Task ComputePositionAsync( /// Whether to make the element visible after positioning. public Task ApplyPositionAsync(ElementReference floating, PositionResult position, bool makeVisible = false); + /// + /// Returns a floating element to its hidden, off-screen state. + /// + /// + /// The counterpart to with makeVisible: true, which writes + /// visibility, opacity and pointer-events onto the element with !important and so takes the + /// style attribute out of Blazor's hands. Blazor diffs against what it last rendered rather than + /// what the DOM holds, so a component whose closed markup matches its pre-open markup emits no + /// style update on close and those visible values would otherwise persist. + /// + /// The element to hide. + public Task HidePositionAsync(ElementReference floating); + /// /// Sets up auto-update for dynamic positioning (e.g., on scroll/resize). /// diff --git a/src/BlazorBlueprint.Primitives/Services/PositioningService.cs b/src/BlazorBlueprint.Primitives/Services/PositioningService.cs index 62f4a2064..0f8921180 100644 --- a/src/BlazorBlueprint.Primitives/Services/PositioningService.cs +++ b/src/BlazorBlueprint.Primitives/Services/PositioningService.cs @@ -87,6 +87,13 @@ public async Task ApplyPositionAsync(ElementReference floating, PositionResult p await module.InvokeVoidAsync("applyPosition", floating, position, makeVisible); } + /// + public async Task HidePositionAsync(ElementReference floating) + { + var module = await GetModuleAsync(); + await module.InvokeVoidAsync("hidePosition", floating); + } + /// public async Task AutoUpdateAsync( ElementReference reference, diff --git a/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/positioning.js b/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/positioning.js index ae8d2cb16..862f778c0 100644 --- a/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/positioning.js +++ b/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/positioning.js @@ -180,6 +180,30 @@ export function applyPosition(floating, position, makeVisible = false) { * @param {Object} options - Positioning options * @returns {Object} Disposable object with id and apply() method for cleanup */ +/** + * Returns a floating element to its hidden, off-screen state. + * + * The counterpart to applyPosition(..., makeVisible = true). That call writes position, + * visibility, opacity and pointer-events straight onto the element — several with + * `!important` — which takes ownership of the style attribute away from Blazor. Blazor + * diffs against what it last *rendered*, not what the DOM holds, so a component whose + * closed markup is byte-identical to its pre-open markup emits no style update on close + * and the visible values written here would survive indefinitely. + * + * Mirrors the hidden style in BbFloatingPortal.GetInitialStyle(). + * + * @param {HTMLElement} floating - The floating element to hide. + */ +export function hidePosition(floating) { + if (!floating) return; + + floating.style.setProperty('visibility', 'hidden', 'important'); + floating.style.setProperty('opacity', '0', 'important'); + floating.style.setProperty('pointer-events', 'none', 'important'); + floating.style.top = '-9999px'; + floating.style.left = '-9999px'; +} + export async function autoUpdate(reference, floating, options = {}) { try { const lib = await loadFloatingUI(); diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt index 8d13a08b3..baaff66a7 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt @@ -1174,6 +1174,7 @@ - ApplyPositionAsync(ElementReference floating, PositionResult position, Boolean makeVisible) : Task - AutoUpdateAsync(ElementReference reference, ElementReference floating, PositioningOptions options) : Task - ComputePositionAsync(ElementReference reference, ElementReference floating, PositioningOptions options) : Task + - HidePositionAsync(ElementReference floating) : Task ### IColumnDefinition (BlazorBlueprint.Primitives.Table) - CanSort : Boolean { get; } From cbc28a781d877c231435dbd27446e892e7312312 Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 5 Aug 2026 16:49:03 +0800 Subject: [PATCH 184/188] fix(popover): watch for Escape at the document, not on the content element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a popover and pressing Escape left it open, and the trigger could not be clicked to recover either: BbPopoverTrigger sets pointer-events: none on itself while open to guard against double-toggling. Clicking elsewhere was the only way out, and from the keyboard there was none. CloseOnEscape has always defaulted to true and its handler was correct — it just never ran. It is bound with @onkeydown on the content element, which only sees the key once focus is inside the popover, and opening one does not move focus. Focus stays on the trigger, which sits in a different part of the DOM now that content renders through the portal, so the keydown bubbled nowhere near the handler. Watch for Escape at the document while open, using the onEscapeKey helper already present in click-outside.js and until now unused. Registered alongside the click-outside listener and torn down with it, taking ownership of the field before the first await so a close racing a teardown cannot double-dispose (#441). The element-level handler stays. The document listener fires on bubble, so where focus genuinely is inside the popover the original handler runs first and this one finds the popover already closed. BbSelect was never affected — it routes Escape through its keyboard-navigation module, which is document-level for the same reason. --- CHANGELOG.md | 1 + .../Primitives/Popover/BbPopoverContent.razor | 77 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b7579f7..dc2415c48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **BbPopover: Escape did nothing, and the trigger could not be clicked to recover** — Opening a popover and pressing Escape left it open. Worse than a no-op, because `BbPopoverTrigger` sets `pointer-events: none` on itself while open — a deliberate guard against double-toggling — so the trigger could not be clicked either. The only way out was to click somewhere else on the page, and for anyone working from the keyboard there was no way out at all. `CloseOnEscape` has always defaulted to `true` and the handler behind it was correct; it simply never ran. It is bound with `@onkeydown` on the content element, which only observes the key once focus is inside the popover — and opening one does not move focus. Focus stays on the trigger, which lives in an entirely different part of the DOM now that content renders through the portal, so the keydown bubbled nowhere near the handler. `BbPopoverContent` now watches for Escape at the document while open, using the `onEscapeKey` helper that has been sitting unused in `click-outside.js`, alongside the click-outside listener it already registers there and torn down with it. The element-level handler stays: the document listener fires on bubble, so when focus genuinely is inside the popover the original handler still runs first and the new one finds the popover already closed. `BbSelect` was never affected — it routes Escape through its keyboard-navigation module, which is document-level for the same reason. - **BbCopyText: tooltips stayed on screen once shown, accumulating one per hover** — Hovering a copyable value left its tooltip behind permanently; hovering several left several, and because each sits at the coordinates it was given, a subsequent scroll scattered them across unrelated text. The cause sits at the boundary between Blazor's rendering and JS's. `BbFloatingPortal` shows floating content by calling into `positioning.js`, which writes `visibility`, `opacity` and `pointer-events` straight onto the element with `!important` — the style attribute is JS's from that moment on. Blazor, though, diffs against what it last *rendered*, not against what the DOM actually holds. Its markup for a closed portal is byte-identical to the markup it rendered before the portal opened, so on close it correctly concludes nothing changed and emits no style update at all. The element keeps the visible values JS left on it. Nothing was broken in the close path itself: `isHovered` reached `false`, the component re-rendered, `data-state` duly flipped to `"closed"`, the auto-update subscription was disposed, and a `MutationObserver` recorded no further writes. The style simply had no owner willing to reset it. Hiding is now explicit and symmetric with showing: a new `IPositioningService.HidePositionAsync` writes the off-screen hidden state back through the same JS path, mirroring `GetInitialStyle()`. Components whose markup changes elsewhere while open — `BbTooltip` among them — were never affected, because any other difference gives Blazor a reason to re-emit the attribute; that is why this looked component-specific rather than structural. - **BbCurrencyInput multiplied its value by 100 every time the field was focused** — Reported in [#438](https://github.com/blazorblueprintui/ui/issues/438) against `CurrencyCode="EUR"`: focus the field, leave it, and `999,99` came back as `99.999,00`, compounding on every visit. The component was reading three different cultures for one value. `FormatCurrency` and `TryParseValue` used the **currency's** culture — `EUR` maps to `de-DE`, so comma decimal and dot group separator. `JsOnFocus` swapped the display to an **invariant** string for editing, producing `999.99` with a dot. `GetJsConfig` then handed the JS sanitiser the **ambient** culture's separator, which on Blazor Server is the *server's* rather than the user's — so on the docs site it was a dot for everyone regardless of their locale, and the sanitiser happily left the dot in place. On blur, `TryParseValue` read that `999.99` back through `de-DE`, stripped the dot as a *group* separator, and parsed `99999`. Nothing was wrong with any single step; they simply disagreed about what a dot meant. All three now resolve through the currency's culture, so the string written on focus is the string the parser expects on blur. The reporter's own diagnosis — a thousands separator being misread — turned out to be exactly right, just one layer further down than the parse routine they suspected. Note this was never locale-specific to the reporter: any currency whose culture disagrees with the server's on the decimal separator was affected, which on a `de-DE`-backed `EUR` field is every user of a default-configured Blazor Server app. Verified across USD, EUR, JPY (no decimal places) and a currency using the Arabic decimal separator `٫`, each stable across repeated focus/blur cycles. - **BbNumericInput: the stepper buttons kept a fixed height when the field was resized** — Reported as part of [#328](https://github.com/blazorblueprintui/ui/issues/328) and split out as [#465](https://github.com/blazorblueprintui/ui/issues/465). `ButtonClass` was a **static** member, so it could not observe anything about the instance — including `Class`, which is the only way to size this component since it has no size parameter. Each button was `h-5`, and two of them summed to exactly the input's default `h-10`; the pieces lined up by coincidence rather than by construction, which is why the mismatch only appeared once a consumer changed the height. Passing `Class="h-8"` shrank the field to 32px through TailwindMerge while the stepper stayed at 40px, overhanging the field at both ends with its rounded corners no longer meeting the input's border. The buttons now take half the stepper column via `flex-1` instead of a fixed height, and the row is `items-stretch` rather than `items-center`, so the column sizes itself from the field rather than from a height of its own — whatever sets that height. `min-h-0` lets them shrink past the chevron's intrinsic height for genuinely small fields. `BbCurrencyInput` was checked for the same shape and is unaffected: it renders a currency symbol rather than stepper buttons. diff --git a/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopoverContent.razor b/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopoverContent.razor index 5478e500c..1387d07bf 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopoverContent.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Popover/BbPopoverContent.razor @@ -140,6 +140,7 @@ private IJSObjectReference? _clickOutsideModule; private IJSObjectReference? _clickOutsideCleanup; + private IJSObjectReference? _escapeKeyCleanup; private DotNetObjectReference? _dotNetRef; private bool _disposed = false; private string _portalId = ""; @@ -186,6 +187,17 @@ await SetupClickOutsideAsync(); } + // Escape has to be watched at the document, not on the content element. The element + // handler below only sees the key when focus is already inside the popover, and opening + // a popover does not move focus — it stays on the trigger, which lives in a different + // subtree entirely since the content renders through the portal. So the keydown never + // bubbled anywhere near the content and Escape did nothing, leaving the popover open with + // its trigger pointer-events-disabled and therefore no way to close it from the keyboard. + if (CloseOnEscape) + { + await SetupEscapeKeyAsync(); + } + // Notify subscribers that content is ready (for focus management, etc.) Context.NotifyContentReady(); @@ -225,6 +237,53 @@ } } + private async Task SetupEscapeKeyAsync() + { + try + { + _clickOutsideModule ??= await JSRuntime.InvokeAsync( + "import", "./_content/BlazorBlueprint.Primitives/js/primitives/click-outside.js"); + + _dotNetRef ??= DotNetObjectReference.Create(this); + + _escapeKeyCleanup = await _clickOutsideModule.InvokeAsync( + "onEscapeKey", + _dotNetRef, + "JsOnEscapeKey"); + } + catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException) + { + // Expected during circuit disconnect or module loading failure + } + catch (InvalidOperationException) + { + // JS interop not available during prerendering + } + } + + /// + /// Called from JavaScript when Escape is pressed anywhere in the document. + /// + /// + /// The document listener fires on bubble, so when focus is inside the popover the + /// element-level handler has already closed it and the + /// guard below makes this a no-op rather than a second dismissal. + /// + [JSInvokable] + [EditorBrowsable(EditorBrowsableState.Never)] + public async Task JsOnEscapeKey() + { + if (_disposed || !Context.IsOpen || !CloseOnEscape) return; + + if (OnEscapeKeyDown.HasDelegate) + { + await OnEscapeKeyDown.InvokeAsync(new KeyboardEventArgs { Key = "Escape" }); + } + + // Escape is an intentional dismiss — return focus to the trigger. + Context.Close(restoreFocus: true); + } + [JSInvokable] [EditorBrowsable(EditorBrowsableState.Never)] public async Task JsOnClickOutside() @@ -327,6 +386,24 @@ } } + // Same for the document-level Escape listener. Taking ownership of the field before the + // first await keeps a close racing a teardown from both disposing it (#441). + var escapeKeyCleanup = _escapeKeyCleanup; + if (escapeKeyCleanup != null) + { + _escapeKeyCleanup = null; + + try + { + await escapeKeyCleanup.InvokeVoidAsync("dispose"); + await escapeKeyCleanup.DisposeAsync(); + } + catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException) + { + // Cleanup may already be disposed or circuit disconnected + } + } + // Dispose DotNetObjectReference to prevent stale callbacks var dotNetRef = _dotNetRef; if (dotNetRef != null) From c35f75563822180ad4ffe0c4a555a14f93a2c95b Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 5 Aug 2026 17:34:00 +0800 Subject: [PATCH 185/188] docs: release notes for Primitives v3.15.0 --- .../RELEASE_NOTES.md | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md b/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md index 2b435af5d..661d25b57 100644 --- a/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md +++ b/src/BlazorBlueprint.Primitives/RELEASE_NOTES.md @@ -1,5 +1,25 @@ -## What's New in v3.14.0 +## What's New in v3.15.0 + +### Breaking Changes +- **IPositioningService** — added `HidePositionAsync`, which has no default implementation; custom implementations of the interface must add it. ### New Features -- **DataGrid** — added `Groupable` on `IDataGridColumn` so columns can opt into runtime grouping from the column header menu (defaults to `false`; the group key comes from the column's raw value). -- **DataGrid** — added `DataGridGroupState.Version`, a counter that increments whenever the active group definition changes, letting the grid detect grouping changes made directly against the state. +- **DataGrid** — added `Order` on `IDataGridColumn`, an explicit zero-based position among the grid's data columns (defaults to `null`, keeping registration order). +- **DataGrid** — added `DataGridColumnState.SyncColumns`, which adds entries for newly registered columns while preserving existing visibility, width, and user reordering. +- **DataGridHeaderCell** — added `AriaLabel` for headers whose content carries no text of its own, such as icon-only headers. +- **TriggerContext** — added `NotifyConsumed()`, so a custom `AsChild` trigger child that only touches the context inside event handlers can acknowledge it. +- **PositioningService** — added `HidePositionAsync`, returning a floating element to its hidden state through the same JS path that made it visible. + +### Bug Fixes +- **Popover** — Escape is now watched at the document instead of on the content element, so it closes the popover even though focus stays on the trigger. +- **FloatingPortal** — overlays are now hidden through JS, matching how they were shown; previously a closed overlay whose markup was otherwise unchanged could stay visible. +- **Popover**, **Select**, **DropdownMenu** — a close racing a teardown no longer throws out of disposal and take down the Blazor Server circuit. +- **DataGrid** — rightward column drags no longer overshoot by one position. +- **DataGrid** — columns registering after initialization are kept and stay visible, rather than being dropped. +- **DataGrid** — a header cell supplying a `HeaderTemplate` can now be given an accessible name, so the column is not left unnamed for assistive technology. +- **PortalHost** — content-only portal updates arriving in the Blazor Server render-to-acknowledgement window are recovered instead of leaving stale content on screen. +- **PortalHost** — the deferred content flush now yields before re-rendering, avoiding a stack overflow on WebAssembly when a nested portal refreshes on every render. + +### Improvements +- **TooltipTrigger**, **HoverCardTrigger** — log a warning in the Development environment when `AsChild="true"` is used with a child that never consumes the cascaded `TriggerContext`, which would otherwise leave the overlay silently unopenable. +- **TooltipTrigger**, **HoverCardTrigger** — expanded `AsChild` documentation covering what the child is responsible for and when to leave it `false`. From c4cfef657713889a32f23c3700745d49297ee91f Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 5 Aug 2026 17:44:36 +0800 Subject: [PATCH 186/188] chore: bump BlazorBlueprint.Primitives to 3.15.0 --- .../BlazorBlueprint.Components.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj index 054b93487..67e5829f8 100644 --- a/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj +++ b/src/BlazorBlueprint.Components/BlazorBlueprint.Components.csproj @@ -61,7 +61,7 @@ - + From bf32aa4a8f176182ff097058c508eda053f25d0e Mon Sep 17 00:00:00 2001 From: Mathew Taylor Date: Wed, 5 Aug 2026 18:44:41 +0800 Subject: [PATCH 187/188] docs: release notes for Components v3.15.0 --- .../RELEASE_NOTES.md | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/src/BlazorBlueprint.Components/RELEASE_NOTES.md b/src/BlazorBlueprint.Components/RELEASE_NOTES.md index 9fb9b979b..205a0f27f 100644 --- a/src/BlazorBlueprint.Components/RELEASE_NOTES.md +++ b/src/BlazorBlueprint.Components/RELEASE_NOTES.md @@ -1,15 +1,31 @@ -## What's New in v3.14.1 +## What's New in v3.15.0 + +### Breaking Changes +- **BbDataGrid** — the JS-invokable `OnColumnReordered` now takes `(columnId, targetColumnId, placeAfter)` instead of `(columnId, newIndex)`; the drop gesture is resolved to a position on the .NET side. + +### New Features +- **BbSidebarProvider** — new `Open`/`OpenChanged` and `OpenMobile`/`OpenMobileChanged` for controlled (`@bind-Open`) sidebar state; cookie persistence is suppressed while the desktop state is bound. +- **BbCopyText** — new `ValueFunc` resolves the copied text at click time, for values that are derived or expensive to compute. `Value` is no longer `EditorRequired` and wins when non-empty. +- **BbNumericInput**, **BbCurrencyInput**, **BbFormFieldNumericInput**, **BbFormFieldCurrencyInput** — new opt-in `EnableWheelStep` steps the value from the mouse wheel while the input is focused, accumulating delta so a trackpad flick doesn't step wildly. +- **BbDataGridPropertyColumn**, **BbDataGridTemplateColumn**, **BbDataGridHierarchyColumn** — new `Order` sets an explicit column position, for columns produced by wrappers or async fragments where registration order doesn't match declaration order. +- **BbDataGridPropertyColumn** — new `HeaderTemplate` replaces the header title text while keeping the sort indicator, filter icon, pin icon, column menu and resize handle. +- **BbLine**, **BbArea**, **BbScatter** — new `XDataKey` plots each point at its own X coordinate on a value, time or log axis instead of at its ordinal position. +- **BbXAxis** — new `Scale` lets a value axis auto-fit to the data range rather than always including zero. +- **BbFileUpload** — new `Id` for associating an external `