Parcourir la source

Productize QuickGrid (#46573)

Nick Stanton il y a 3 ans
Parent
commit
e29fbdf88c
46 fichiers modifiés avec 2458 ajouts et 1 suppressions
  1. 41 0
      AspNetCore.sln
  2. 2 0
      eng/ProjectReferences.props
  3. 2 0
      eng/TrimmableProjects.props
  4. 3 1
      src/Components/Components.slnf
  5. 22 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter/src/EntityFrameworkAdapterServiceCollectionExtensions.cs
  6. 20 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter/src/EntityFrameworkAsyncQueryExecutor.cs
  7. 15 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter/src/Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter.csproj
  8. 1 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter/src/PublicAPI.Shipped.txt
  9. 3 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter/src/PublicAPI.Unshipped.txt
  10. 35 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Columns/Align.cs
  11. 54 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Columns/ColumnBase.razor
  12. 115 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Columns/ColumnBase.razor.cs
  13. 30 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Columns/ColumnBase.razor.css
  14. 185 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Columns/GridSort.cs
  15. 80 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Columns/PropertyColumn.cs
  16. 20 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Columns/SortedProperty.cs
  17. 31 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Columns/TemplateColumn.cs
  18. 13 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/GridItemsProvider.cs
  19. 70 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/GridItemsProviderRequest.cs
  20. 42 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/GridItemsProviderResult.cs
  21. 36 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/IAsyncQueryExecutor.cs
  22. 58 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Infrastructure/AsyncQueryExecutorSupplier.cs
  23. 62 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Infrastructure/ColumnsCollectedNotifier.cs
  24. 30 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Infrastructure/Defer.cs
  25. 34 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Infrastructure/EventCallbackSubscribable.cs
  26. 44 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Infrastructure/EventCallbackSubscriber.cs
  27. 12 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Infrastructure/EventHandlers.cs
  28. 17 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Infrastructure/InternalGridContext.cs
  29. 24 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Microsoft.AspNetCore.Components.QuickGrid.csproj
  30. 81 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Pagination/PaginationState.cs
  31. 28 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Pagination/Paginator.razor
  32. 52 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Pagination/Paginator.razor.cs
  33. 49 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Pagination/Paginator.razor.css
  34. 1 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/PublicAPI.Shipped.txt
  35. 150 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/PublicAPI.Unshipped.txt
  36. 94 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor
  37. 431 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor.cs
  38. 97 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor.css
  39. 52 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor.js
  40. 27 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/SortDirection.cs
  41. 81 0
      src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Themes/Default.css
  42. 0 0
      src/Components/Web.JS/dist/Release/blazor.server.js
  43. 119 0
      src/Components/test/E2ETest/Tests/QuickGridTest.cs
  44. 1 0
      src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj
  45. 1 0
      src/Components/test/testassets/BasicTestApp/Index.razor
  46. 93 0
      src/Components/test/testassets/BasicTestApp/QuickGridTest/SampleQuickGridComponent.razor

+ 41 - 0
AspNetCore.sln

@@ -1764,6 +1764,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Server
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Http.Generators", "src\Http\Http.Extensions\gen\Microsoft.AspNetCore.Http.Generators.csproj", "{4730F56D-24EF-4BB2-AA75-862E31205F3A}"
 EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "QuickGrid", "QuickGrid", "{C406D9E0-1585-43F9-AA8F-D468AF84A996}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.QuickGrid", "src\Components\QuickGrid\Microsoft.AspNetCore.Components.QuickGrid\src\Microsoft.AspNetCore.Components.QuickGrid.csproj", "{7757E360-40F5-4C90-9D7F-E6B0E62BA9E2}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter", "src\Components\QuickGrid\Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter\src\Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter.csproj", "{F0BF2260-5AE2-4248-81DE-AC5B9FC6A931}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -10589,6 +10595,38 @@ Global
 		{4730F56D-24EF-4BB2-AA75-862E31205F3A}.Release|x64.Build.0 = Release|Any CPU
 		{4730F56D-24EF-4BB2-AA75-862E31205F3A}.Release|x86.ActiveCfg = Release|Any CPU
 		{4730F56D-24EF-4BB2-AA75-862E31205F3A}.Release|x86.Build.0 = Release|Any CPU
+		{7757E360-40F5-4C90-9D7F-E6B0E62BA9E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{7757E360-40F5-4C90-9D7F-E6B0E62BA9E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{7757E360-40F5-4C90-9D7F-E6B0E62BA9E2}.Debug|arm64.ActiveCfg = Debug|Any CPU
+		{7757E360-40F5-4C90-9D7F-E6B0E62BA9E2}.Debug|arm64.Build.0 = Debug|Any CPU
+		{7757E360-40F5-4C90-9D7F-E6B0E62BA9E2}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{7757E360-40F5-4C90-9D7F-E6B0E62BA9E2}.Debug|x64.Build.0 = Debug|Any CPU
+		{7757E360-40F5-4C90-9D7F-E6B0E62BA9E2}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{7757E360-40F5-4C90-9D7F-E6B0E62BA9E2}.Debug|x86.Build.0 = Debug|Any CPU
+		{7757E360-40F5-4C90-9D7F-E6B0E62BA9E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{7757E360-40F5-4C90-9D7F-E6B0E62BA9E2}.Release|Any CPU.Build.0 = Release|Any CPU
+		{7757E360-40F5-4C90-9D7F-E6B0E62BA9E2}.Release|arm64.ActiveCfg = Release|Any CPU
+		{7757E360-40F5-4C90-9D7F-E6B0E62BA9E2}.Release|arm64.Build.0 = Release|Any CPU
+		{7757E360-40F5-4C90-9D7F-E6B0E62BA9E2}.Release|x64.ActiveCfg = Release|Any CPU
+		{7757E360-40F5-4C90-9D7F-E6B0E62BA9E2}.Release|x64.Build.0 = Release|Any CPU
+		{7757E360-40F5-4C90-9D7F-E6B0E62BA9E2}.Release|x86.ActiveCfg = Release|Any CPU
+		{7757E360-40F5-4C90-9D7F-E6B0E62BA9E2}.Release|x86.Build.0 = Release|Any CPU
+		{F0BF2260-5AE2-4248-81DE-AC5B9FC6A931}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{F0BF2260-5AE2-4248-81DE-AC5B9FC6A931}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{F0BF2260-5AE2-4248-81DE-AC5B9FC6A931}.Debug|arm64.ActiveCfg = Debug|Any CPU
+		{F0BF2260-5AE2-4248-81DE-AC5B9FC6A931}.Debug|arm64.Build.0 = Debug|Any CPU
+		{F0BF2260-5AE2-4248-81DE-AC5B9FC6A931}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{F0BF2260-5AE2-4248-81DE-AC5B9FC6A931}.Debug|x64.Build.0 = Debug|Any CPU
+		{F0BF2260-5AE2-4248-81DE-AC5B9FC6A931}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{F0BF2260-5AE2-4248-81DE-AC5B9FC6A931}.Debug|x86.Build.0 = Debug|Any CPU
+		{F0BF2260-5AE2-4248-81DE-AC5B9FC6A931}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{F0BF2260-5AE2-4248-81DE-AC5B9FC6A931}.Release|Any CPU.Build.0 = Release|Any CPU
+		{F0BF2260-5AE2-4248-81DE-AC5B9FC6A931}.Release|arm64.ActiveCfg = Release|Any CPU
+		{F0BF2260-5AE2-4248-81DE-AC5B9FC6A931}.Release|arm64.Build.0 = Release|Any CPU
+		{F0BF2260-5AE2-4248-81DE-AC5B9FC6A931}.Release|x64.ActiveCfg = Release|Any CPU
+		{F0BF2260-5AE2-4248-81DE-AC5B9FC6A931}.Release|x64.Build.0 = Release|Any CPU
+		{F0BF2260-5AE2-4248-81DE-AC5B9FC6A931}.Release|x86.ActiveCfg = Release|Any CPU
+		{F0BF2260-5AE2-4248-81DE-AC5B9FC6A931}.Release|x86.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -11460,6 +11498,9 @@ Global
 		{10173568-A65E-44E5-8C6F-4AA49D0577A1} = {F057512B-55BF-4A8B-A027-A0505F8BA10C}
 		{97C7D2A4-87E5-4A4A-A170-D736427D5C21} = {F057512B-55BF-4A8B-A027-A0505F8BA10C}
 		{4730F56D-24EF-4BB2-AA75-862E31205F3A} = {225AEDCF-7162-4A86-AC74-06B84660B379}
+		{C406D9E0-1585-43F9-AA8F-D468AF84A996} = {60D51C98-2CC0-40DF-B338-44154EFEE2FF}
+		{7757E360-40F5-4C90-9D7F-E6B0E62BA9E2} = {C406D9E0-1585-43F9-AA8F-D468AF84A996}
+		{F0BF2260-5AE2-4248-81DE-AC5B9FC6A931} = {C406D9E0-1585-43F9-AA8F-D468AF84A996}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F}

+ 2 - 0
eng/ProjectReferences.props

@@ -141,6 +141,8 @@
     <ProjectReferenceProvider Include="Microsoft.AspNetCore.Components" ProjectPath="$(RepoRoot)src\Components\Components\src\Microsoft.AspNetCore.Components.csproj" />
     <ProjectReferenceProvider Include="Microsoft.AspNetCore.Components.CustomElements" ProjectPath="$(RepoRoot)src\Components\CustomElements\src\Microsoft.AspNetCore.Components.CustomElements.csproj" />
     <ProjectReferenceProvider Include="Microsoft.AspNetCore.Components.Forms" ProjectPath="$(RepoRoot)src\Components\Forms\src\Microsoft.AspNetCore.Components.Forms.csproj" />
+    <ProjectReferenceProvider Include="Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter" ProjectPath="$(RepoRoot)src\Components\QuickGrid\Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter\src\Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter.csproj" />
+    <ProjectReferenceProvider Include="Microsoft.AspNetCore.Components.QuickGrid" ProjectPath="$(RepoRoot)src\Components\QuickGrid\Microsoft.AspNetCore.Components.QuickGrid\src\Microsoft.AspNetCore.Components.QuickGrid.csproj" />
     <ProjectReferenceProvider Include="Microsoft.AspNetCore.Components.Server" ProjectPath="$(RepoRoot)src\Components\Server\src\Microsoft.AspNetCore.Components.Server.csproj" />
     <ProjectReferenceProvider Include="Microsoft.Authentication.WebAssembly.Msal" ProjectPath="$(RepoRoot)src\Components\WebAssembly\Authentication.Msal\src\Microsoft.Authentication.WebAssembly.Msal.csproj" />
     <ProjectReferenceProvider Include="Microsoft.JSInterop.WebAssembly" ProjectPath="$(RepoRoot)src\Components\WebAssembly\JSInterop\src\Microsoft.JSInterop.WebAssembly.csproj" />

+ 2 - 0
eng/TrimmableProjects.props

@@ -88,6 +88,8 @@
     <TrimmableProject Include="Microsoft.AspNetCore.Components" />
     <TrimmableProject Include="Microsoft.AspNetCore.Components.CustomElements" />
     <TrimmableProject Include="Microsoft.AspNetCore.Components.Forms" />
+    <TrimmableProject Include="Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter" />
+    <TrimmableProject Include="Microsoft.AspNetCore.Components.QuickGrid" />
     <TrimmableProject Include="Microsoft.Authentication.WebAssembly.Msal" />
     <TrimmableProject Include="Microsoft.JSInterop.WebAssembly" />
     <TrimmableProject Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" />

+ 3 - 1
src/Components/Components.slnf

@@ -14,6 +14,8 @@
       "src\\Components\\CustomElements\\src\\Microsoft.AspNetCore.Components.CustomElements.csproj",
       "src\\Components\\Forms\\src\\Microsoft.AspNetCore.Components.Forms.csproj",
       "src\\Components\\Forms\\test\\Microsoft.AspNetCore.Components.Forms.Tests.csproj",
+      "src\\Components\\QuickGrid\\Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter\\src\\Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter.csproj",
+      "src\\Components\\QuickGrid\\Microsoft.AspNetCore.Components.QuickGrid\\src\\Microsoft.AspNetCore.Components.QuickGrid.csproj",
       "src\\Components\\Samples\\BlazorServerApp\\BlazorServerApp.csproj",
       "src\\Components\\Server\\src\\Microsoft.AspNetCore.Components.Server.csproj",
       "src\\Components\\Server\\test\\Microsoft.AspNetCore.Components.Server.Tests.csproj",
@@ -142,4 +144,4 @@
       "src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj"
     ]
   }
-}
+}

+ 22 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter/src/EntityFrameworkAdapterServiceCollectionExtensions.cs

@@ -0,0 +1,22 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Components.QuickGrid;
+using Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+/// <summary>
+/// Provides extension methods to configure <see cref="IAsyncQueryExecutor"/> on a <see cref="IServiceCollection"/>.
+/// </summary>
+public static class EntityFrameworkAdapterServiceCollectionExtensions
+{
+    /// <summary>
+    /// Registers an Entity Framework aware implementation of <see cref="IAsyncQueryExecutor"/>.
+    /// </summary>
+    /// <param name="services">The <see cref="IServiceCollection"/>.</param>
+    public static void AddQuickGridEntityFrameworkAdapter(this IServiceCollection services)
+    {
+        services.AddSingleton<IAsyncQueryExecutor, EntityFrameworkAsyncQueryExecutor>();
+    }
+}

+ 20 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter/src/EntityFrameworkAsyncQueryExecutor.cs

@@ -0,0 +1,20 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Linq;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Query;
+
+namespace Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter;
+
+internal sealed class EntityFrameworkAsyncQueryExecutor : IAsyncQueryExecutor
+{
+    public bool IsSupported<T>(IQueryable<T> queryable)
+        => queryable.Provider is IAsyncQueryProvider;
+
+    public Task<int> CountAsync<T>(IQueryable<T> queryable)
+        => queryable.CountAsync();
+
+    public Task<T[]> ToArrayAsync<T>(IQueryable<T> queryable)
+        => queryable.ToArrayAsync();
+}

+ 15 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter/src/Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter.csproj

@@ -0,0 +1,15 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+    <Description>Provides an Entity Framework Core adapter for the <a href="https://www.nuget.org/packages/Microsoft.AspNetCore.Components.QuickGrid">Microsoft.AspNetCore.Components.QuickGrid</a> package.</Description>
+    <GenerateDocumentationFile>true</GenerateDocumentationFile>
+    <IsTrimmable>true</IsTrimmable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Reference Include="Microsoft.EntityFrameworkCore" />
+    <Reference Include="Microsoft.AspNetCore.Components.QuickGrid" />
+  </ItemGroup>
+  
+</Project>

+ 1 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter/src/PublicAPI.Shipped.txt

@@ -0,0 +1 @@
+#nullable enable

+ 3 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter/src/PublicAPI.Unshipped.txt

@@ -0,0 +1,3 @@
+#nullable enable
+Microsoft.Extensions.DependencyInjection.EntityFrameworkAdapterServiceCollectionExtensions
+static Microsoft.Extensions.DependencyInjection.EntityFrameworkAdapterServiceCollectionExtensions.AddQuickGridEntityFrameworkAdapter(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> void

+ 35 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Columns/Align.cs

@@ -0,0 +1,35 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components.QuickGrid;
+
+/// <summary>
+/// Describes alignment for a <see cref="QuickGrid{TGridItem}"/> column.
+/// </summary>
+public enum Align
+{
+    /// <summary>
+    /// Justifies the content against the start of the container.
+    /// </summary>
+    Start,
+
+    /// <summary>
+    /// Justifies the content at the center of the container.
+    /// </summary>
+    Center,
+
+    /// <summary>
+    /// Justifies the content at the end of the container.
+    /// </summary>
+    End,
+
+    /// <summary>
+    /// Justifies the content against the left of the container.
+    /// </summary>
+    Left,
+
+    /// <summary>
+    /// Justifies the content at the right of the container.
+    /// </summary>
+    Right,
+}

+ 54 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Columns/ColumnBase.razor

@@ -0,0 +1,54 @@
+@using Microsoft.AspNetCore.Components.Rendering
+@using Microsoft.AspNetCore.Components.Web
+@using Microsoft.AspNetCore.Components.Web.Virtualization
+@namespace Microsoft.AspNetCore.Components.QuickGrid
+@typeparam TGridItem
+@{
+    InternalGridContext.Grid.AddColumn(this, InitialSortDirection, IsDefaultSortColumn);
+}
+
+@code
+{
+    private void RenderDefaultHeaderContent(RenderTreeBuilder __builder)
+    {
+        @if (HeaderTemplate is not null)
+        {
+            @HeaderTemplate(this)
+        }
+        else
+        {
+            @if (ColumnOptions is not null && (Align != Align.Right || Align != Align.End))
+            {
+                <button class="col-options-button" @onclick="@(() => Grid.ShowColumnOptionsAsync(this))"></button>
+            }
+
+            if (Sortable.HasValue ? Sortable.Value : IsSortableByDefault())
+            {
+                <button class="col-title" @onclick="@(() => Grid.SortByColumnAsync(this))">
+                    <div class="col-title-text">@Title</div>
+                    <div class="sort-indicator" aria-hidden="true"></div>
+                </button>
+            }
+            else
+            {
+                <div class="col-title">
+                    <div class="col-title-text">@Title</div>
+                </div>
+            }
+
+            @if (ColumnOptions is not null && (Align == Align.Right || Align == Align.End))
+            {
+                <button class="col-options-button" @onclick="@(() => Grid.ShowColumnOptionsAsync(this))"></button>
+            }
+        }
+    }
+
+    internal void RenderPlaceholderContent(RenderTreeBuilder __builder, PlaceholderContext placeholderContext)
+    {
+        // Blank if no placeholder template was supplied, as it's enough to style with CSS by default
+        if (PlaceholderTemplate is not null)
+        {
+            @PlaceholderTemplate(placeholderContext)
+        }
+    }
+}

+ 115 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Columns/ColumnBase.razor.cs

@@ -0,0 +1,115 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Components.QuickGrid.Infrastructure;
+using Microsoft.AspNetCore.Components.Rendering;
+using Microsoft.AspNetCore.Components.Web.Virtualization;
+
+namespace Microsoft.AspNetCore.Components.QuickGrid;
+
+/// <summary>
+/// An abstract base class for columns in a <see cref="QuickGrid{TGridItem}"/>.
+/// </summary>
+/// <typeparam name="TGridItem">The type of data represented by each row in the grid.</typeparam>
+public abstract partial class ColumnBase<TGridItem>
+{
+    [CascadingParameter] internal InternalGridContext<TGridItem> InternalGridContext { get; set; } = default!;
+
+    /// <summary>
+    /// Title text for the column. This is rendered automatically if <see cref="HeaderTemplate" /> is not used.
+    /// </summary>
+    [Parameter] public string? Title { get; set; }
+
+    /// <summary>
+    /// An optional CSS class name. If specified, this is included in the class attribute of table header and body cells
+    /// for this column.
+    /// </summary>
+    [Parameter] public string? Class { get; set; }
+
+    /// <summary>
+    /// If specified, controls the justification of table header and body cells for this column.
+    /// </summary>
+    [Parameter] public Align Align { get; set; }
+
+    /// <summary>
+    /// An optional template for this column's header cell. If not specified, the default header template
+    /// includes the <see cref="Title" /> along with any applicable sort indicators and options buttons.
+    /// </summary>
+    [Parameter] public RenderFragment<ColumnBase<TGridItem>>? HeaderTemplate { get; set; }
+
+    /// <summary>
+    /// If specified, indicates that this column has this associated options UI. A button to display this
+    /// UI will be included in the header cell by default.
+    ///
+    /// If <see cref="HeaderTemplate" /> is used, it is left up to that template to render any relevant
+    /// "show options" UI and invoke the grid's <see cref="QuickGrid{TGridItem}.ShowColumnOptionsAsync(ColumnBase{TGridItem})" />).
+    /// </summary>
+    [Parameter] public RenderFragment? ColumnOptions { get; set; }
+
+    /// <summary>
+    /// Indicates whether the data should be sortable by this column.
+    ///
+    /// The default value may vary according to the column type (for example, a <see cref="TemplateColumn{TGridItem}" />
+    /// is sortable by default if any <see cref="TemplateColumn{TGridItem}.SortBy" /> parameter is specified).
+    /// </summary>
+    [Parameter] public bool? Sortable { get; set; }
+
+    /// <summary>
+    /// Specifies sorting rules for a column.
+    /// </summary>
+    public abstract GridSort<TGridItem>? SortBy { get; set; }
+
+    /// <summary>
+    /// Indicates which direction to sort in
+    /// if <see cref="IsDefaultSortColumn"/> is true.
+    /// </summary>
+    [Parameter] public SortDirection InitialSortDirection { get; set; } = default;
+
+    /// <summary>
+    /// Indicates whether this column should be sorted by default.
+    /// </summary>
+    [Parameter] public bool IsDefaultSortColumn { get; set; } = false;
+
+    /// <summary>
+    /// If specified, virtualized grids will use this template to render cells whose data has not yet been loaded.
+    /// </summary>
+    [Parameter] public RenderFragment<PlaceholderContext>? PlaceholderTemplate { get; set; }
+
+    /// <summary>
+    /// Gets a reference to the enclosing <see cref="QuickGrid{TGridItem}" />.
+    /// </summary>
+    public QuickGrid<TGridItem> Grid => InternalGridContext.Grid;
+
+    /// <summary>
+    /// Overridden by derived components to provide rendering logic for the column's cells.
+    /// </summary>
+    /// <param name="builder">The current <see cref="RenderTreeBuilder" />.</param>
+    /// <param name="item">The data for the row being rendered.</param>
+    protected internal abstract void CellContent(RenderTreeBuilder builder, TGridItem item);
+
+    /// <summary>
+    /// Gets or sets a <see cref="RenderFragment" /> that will be rendered for this column's header cell.
+    /// This allows derived components to change the header output. However, derived components are then
+    /// responsible for using <see cref="HeaderTemplate" /> within that new output if they want to continue
+    /// respecting that option.
+    /// </summary>
+    protected internal RenderFragment HeaderContent { get; protected set; }
+
+    /// <summary>
+    /// Get a value indicating whether this column should act as sortable if no value was set for the
+    /// <see cref="ColumnBase{TGridItem}.Sortable" /> parameter. The default behavior is not to be
+    /// sortable unless <see cref="ColumnBase{TGridItem}.Sortable" /> is true.
+    ///
+    /// Derived components may override this to implement alternative default sortability rules.
+    /// </summary>
+    /// <returns>True if the column should be sortable by default, otherwise false.</returns>
+    protected virtual bool IsSortableByDefault() => false;
+
+    /// <summary>
+    /// Constructs an instance of <see cref="ColumnBase{TGridItem}" />.
+    /// </summary>
+    public ColumnBase()
+    {
+        HeaderContent = RenderDefaultHeaderContent;
+    }
+}

+ 30 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Columns/ColumnBase.razor.css

@@ -0,0 +1,30 @@
+/* Contains the title text and sort indicator, and expands to fill as much of the col width as it can */
+.col-title {
+    display: flex; /* So that we can make col-title-text expand as much as possible, and still hide overflow with ellipsis */
+    min-width: 0px;
+    flex-grow: 1;
+    padding: 0;
+}
+
+/* If the column is sortable, its title is rendered as a button element for accessibility and to support navigation by tab */
+button.col-title {
+    border: none;
+    background: none;
+    position: relative;
+    cursor: pointer;
+}
+
+.col-justify-center .col-title {
+    justify-content: center;
+}
+
+.col-justify-end .col-title {
+    flex-direction: row-reverse; /* For end-justified cols, the sort indicator should appear before the title text */
+}
+
+/* We put the column title text in its own element primarily so that it can use text-overflow: ellipsis */
+.col-title-text {
+    text-overflow: ellipsis;
+    overflow: hidden;
+    white-space: nowrap;
+}

+ 185 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Columns/GridSort.cs

@@ -0,0 +1,185 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Linq;
+using System.Linq.Expressions;
+
+namespace Microsoft.AspNetCore.Components.QuickGrid;
+
+/// <summary>
+/// Represents a sort order specification used within <see cref="QuickGrid{TGridItem}"/>.
+/// </summary>
+/// <typeparam name="TGridItem">The type of data represented by each row in the grid.</typeparam>
+public sealed class GridSort<TGridItem>
+{
+    private const string ExpressionNotRepresentableMessage = "The supplied expression can't be represented as a property name for sorting. Only simple member expressions, such as @(x => x.SomeProperty), can be converted to property names.";
+
+    private readonly Func<IQueryable<TGridItem>, bool, IOrderedQueryable<TGridItem>> _first;
+    private List<Func<IOrderedQueryable<TGridItem>, bool, IOrderedQueryable<TGridItem>>>? _then;
+
+    private (LambdaExpression, bool) _firstExpression;
+    private List<(LambdaExpression, bool)>? _thenExpressions;
+
+    private IReadOnlyCollection<SortedProperty>? _cachedPropertyListAscending;
+    private IReadOnlyCollection<SortedProperty>? _cachedPropertyListDescending;
+
+    internal GridSort(Func<IQueryable<TGridItem>, bool, IOrderedQueryable<TGridItem>> first, (LambdaExpression, bool) firstExpression)
+    {
+        _first = first;
+        _firstExpression = firstExpression;
+        _then = default;
+        _thenExpressions = default;
+    }
+
+    /// <summary>
+    /// Produces a <see cref="GridSort{T}"/> instance that sorts according to the specified <paramref name="expression"/>, ascending.
+    /// </summary>
+    /// <typeparam name="U">The type of the expression's value.</typeparam>
+    /// <param name="expression">An expression defining how a set of <typeparamref name="TGridItem"/> instances are to be sorted.</param>
+    /// <returns>A <see cref="GridSort{T}"/> instance representing the specified sorting rule.</returns>
+    public static GridSort<TGridItem> ByAscending<U>(Expression<Func<TGridItem, U>> expression)
+        => new((queryable, asc) => asc ? queryable.OrderBy(expression) : queryable.OrderByDescending(expression),
+            (expression, true));
+
+    /// <summary>
+    /// Produces a <see cref="GridSort{T}"/> instance that sorts according to the specified <paramref name="expression"/>, descending.
+    /// </summary>
+    /// <typeparam name="U">The type of the expression's value.</typeparam>
+    /// <param name="expression">An expression defining how a set of <typeparamref name="TGridItem"/> instances are to be sorted.</param>
+    /// <returns>A <see cref="GridSort{T}"/> instance representing the specified sorting rule.</returns>
+    public static GridSort<TGridItem> ByDescending<U>(Expression<Func<TGridItem, U>> expression)
+        => new((queryable, asc) => asc ? queryable.OrderByDescending(expression) : queryable.OrderBy(expression),
+            (expression, false));
+
+    /// <summary>
+    /// Updates a <see cref="GridSort{T}"/> instance by appending a further sorting rule.
+    /// </summary>
+    /// <typeparam name="U">The type of the expression's value.</typeparam>
+    /// <param name="expression">An expression defining how a set of <typeparamref name="TGridItem"/> instances are to be sorted.</param>
+    /// <returns>A <see cref="GridSort{T}"/> instance representing the specified sorting rule.</returns>
+    public GridSort<TGridItem> ThenAscending<U>(Expression<Func<TGridItem, U>> expression)
+    {
+        _then ??= new();
+        _thenExpressions ??= new();
+        _then.Add((queryable, asc) => asc ? queryable.ThenBy(expression) : queryable.ThenByDescending(expression));
+        _thenExpressions.Add((expression, true));
+        _cachedPropertyListAscending = null;
+        _cachedPropertyListDescending = null;
+        return this;
+    }
+
+    /// <summary>
+    /// Updates a <see cref="GridSort{T}"/> instance by appending a further sorting rule.
+    /// </summary>
+    /// <typeparam name="U">The type of the expression's value.</typeparam>
+    /// <param name="expression">An expression defining how a set of <typeparamref name="TGridItem"/> instances are to be sorted.</param>
+    /// <returns>A <see cref="GridSort{T}"/> instance representing the specified sorting rule.</returns>
+    public GridSort<TGridItem> ThenDescending<U>(Expression<Func<TGridItem, U>> expression)
+    {
+        _then ??= new();
+        _thenExpressions ??= new();
+        _then.Add((queryable, asc) => asc ? queryable.ThenByDescending(expression) : queryable.ThenBy(expression));
+        _thenExpressions.Add((expression, false));
+        _cachedPropertyListAscending = null;
+        _cachedPropertyListDescending = null;
+        return this;
+    }
+
+    internal IOrderedQueryable<TGridItem> Apply(IQueryable<TGridItem> queryable, bool ascending)
+    {
+        var orderedQueryable = _first(queryable, ascending);
+
+        if (_then is not null)
+        {
+            foreach (var clause in _then)
+            {
+                orderedQueryable = clause(orderedQueryable, ascending);
+            }
+        }
+
+        return orderedQueryable;
+    }
+
+    internal IReadOnlyCollection<SortedProperty> ToPropertyList(bool ascending)
+    {
+        if (ascending)
+        {
+            _cachedPropertyListAscending ??= BuildPropertyList(ascending: true);
+            return _cachedPropertyListAscending;
+        }
+        else
+        {
+            _cachedPropertyListDescending ??= BuildPropertyList(ascending: false);
+            return _cachedPropertyListDescending;
+        }
+    }
+
+    private List<SortedProperty> BuildPropertyList(bool ascending)
+    {
+        var result = new List<SortedProperty>
+        {
+            new SortedProperty { PropertyName = ToPropertyName(_firstExpression.Item1), Direction = (_firstExpression.Item2 ^ ascending) ? SortDirection.Descending : SortDirection.Ascending }
+        };
+
+        if (_thenExpressions is not null)
+        {
+            foreach (var (thenLambda, thenAscending) in _thenExpressions)
+            {
+                result.Add(new SortedProperty { PropertyName = ToPropertyName(thenLambda), Direction = (thenAscending ^ ascending) ? SortDirection.Descending : SortDirection.Ascending });
+            }
+        }
+
+        return result;
+    }
+
+    // Not sure we really want this level of complexity, but it converts expressions like @(c => c.Medals.Gold) to "Medals.Gold"
+    private static string ToPropertyName(LambdaExpression expression)
+    {
+        if (expression.Body is not MemberExpression body)
+        {
+            throw new ArgumentException(ExpressionNotRepresentableMessage);
+        }
+
+        // Handles cases like @(x => x.Name)
+        if (body.Expression is ParameterExpression)
+        {
+            return body.Member.Name;
+        }
+
+        // First work out the length of the string we'll need, so that we can use string.Create
+        var length = body.Member.Name.Length;
+        var node = body;
+        while (node.Expression is not null)
+        {
+            if (node.Expression is MemberExpression parentMember)
+            {
+                length += parentMember.Member.Name.Length + 1;
+                node = parentMember;
+            }
+            else if (node.Expression is ParameterExpression)
+            {
+                break;
+            }
+            else
+            {
+                throw new ArgumentException(ExpressionNotRepresentableMessage);
+            }
+        }
+
+        // Now construct the string
+        return string.Create(length, body, (chars, body) =>
+        {
+            var nextPos = chars.Length;
+            while (body is not null)
+            {
+                nextPos -= body.Member.Name.Length;
+                body.Member.Name.CopyTo(chars[nextPos..]);
+                if (nextPos > 0)
+                {
+                    chars[--nextPos] = '.';
+                }
+                body = (body.Expression as MemberExpression)!;
+            }
+        });
+    }
+}

+ 80 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Columns/PropertyColumn.cs

@@ -0,0 +1,80 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Linq.Expressions;
+using Microsoft.AspNetCore.Components.Rendering;
+
+namespace Microsoft.AspNetCore.Components.QuickGrid;
+
+/// <summary>
+/// Represents a <see cref="QuickGrid{TGridItem}"/> column whose cells display a single value.
+/// </summary>
+/// <typeparam name="TGridItem">The type of data represented by each row in the grid.</typeparam>
+/// <typeparam name="TProp">The type of the value being displayed in the column's cells.</typeparam>
+public class PropertyColumn<TGridItem, TProp> : ColumnBase<TGridItem>
+{
+    private Expression<Func<TGridItem, TProp>>? _lastAssignedProperty;
+    private Func<TGridItem, string?>? _cellTextFunc;
+    private GridSort<TGridItem>? _sortBuilder;
+
+    /// <summary>
+    /// Defines the value to be displayed in this column's cells.
+    /// </summary>
+    [Parameter, EditorRequired] public Expression<Func<TGridItem, TProp>> Property { get; set; } = default!;
+
+    /// <summary>
+    /// Optionally specifies a format string for the value.
+    ///
+    /// Using this requires the <typeparamref name="TProp"/> type to implement <see cref="IFormattable" />.
+    /// </summary>
+    [Parameter] public string? Format { get; set; }
+
+    /// <inheritdoc/>
+    public override GridSort<TGridItem>? SortBy
+    {
+        get => _sortBuilder;
+        set => throw new NotSupportedException($"PropertyColumn generates this member internally. For custom sorting rules, see '{typeof(TemplateColumn<TGridItem>)}'.");
+    }
+
+    /// <inheritdoc />
+    protected override void OnParametersSet()
+    {
+        // We have to do a bit of pre-processing on the lambda expression. Only do that if it's new or changed.
+        if (_lastAssignedProperty != Property)
+        {
+            _lastAssignedProperty = Property;
+            var compiledPropertyExpression = Property.Compile();
+
+            if (!string.IsNullOrEmpty(Format))
+            {
+                // TODO: Consider using reflection to avoid having to box every value just to call IFormattable.ToString
+                // For example, define a method "string Format<U>(Func<TGridItem, U> property) where U: IFormattable", and
+                // then construct the closed type here with U=TProp when we know TProp implements IFormattable
+
+                // If the type is nullable, we're interested in formatting the underlying type
+                var nullableUnderlyingTypeOrNull = Nullable.GetUnderlyingType(typeof(TProp));
+                if (!typeof(IFormattable).IsAssignableFrom(nullableUnderlyingTypeOrNull ?? typeof(TProp)))
+                {
+                    throw new InvalidOperationException($"A '{nameof(Format)}' parameter was supplied, but the type '{typeof(TProp)}' does not implement '{typeof(IFormattable)}'.");
+                }
+
+                _cellTextFunc = item => ((IFormattable?)compiledPropertyExpression!(item))?.ToString(Format, null);
+            }
+            else
+            {
+                _cellTextFunc = item => compiledPropertyExpression!(item)?.ToString();
+            }
+
+            _sortBuilder = GridSort<TGridItem>.ByAscending(Property);
+        }
+
+        if (Title is null && Property.Body is MemberExpression memberExpression)
+        {
+            Title = memberExpression.Member.Name;
+        }
+    }
+
+    /// <inheritdoc />
+    protected internal override void CellContent(RenderTreeBuilder builder, TGridItem item)
+        => builder.AddContent(0, _cellTextFunc!(item));
+}

+ 20 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Columns/SortedProperty.cs

@@ -0,0 +1,20 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components.QuickGrid;
+
+/// <summary>
+/// Holds the name of a property and the direction to sort by.
+/// </summary>
+public readonly struct SortedProperty
+{
+    /// <summary>
+    /// The property name for the sorting rule.
+    /// </summary>
+    public required string PropertyName { get; init; }
+
+    /// <summary>
+    /// The direction to sort by.
+    /// </summary>
+    public SortDirection Direction { get; init; }
+}

+ 31 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Columns/TemplateColumn.cs

@@ -0,0 +1,31 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Components.Rendering;
+
+namespace Microsoft.AspNetCore.Components.QuickGrid;
+
+/// <summary>
+/// Represents a <see cref="QuickGrid{TGridItem}"/> column whose cells render a supplied template.
+/// </summary>
+/// <typeparam name="TGridItem">The type of data represented by each row in the grid.</typeparam>
+public class TemplateColumn<TGridItem> : ColumnBase<TGridItem>
+{
+    private static readonly RenderFragment<TGridItem> EmptyChildContent = _ => builder => { };
+
+    /// <summary>
+    /// Specifies the content to be rendered for each row in the table.
+    /// </summary>
+    [Parameter] public RenderFragment<TGridItem> ChildContent { get; set; } = EmptyChildContent;
+
+    /// <inheritdoc/>
+    [Parameter] public override GridSort<TGridItem>? SortBy { get; set; }
+
+    /// <inheritdoc />
+    protected internal override void CellContent(RenderTreeBuilder builder, TGridItem item)
+        => builder.AddContent(0, ChildContent(item));
+
+    /// <inheritdoc />
+    protected override bool IsSortableByDefault()
+        => SortBy is not null;
+}

+ 13 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/GridItemsProvider.cs

@@ -0,0 +1,13 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components.QuickGrid;
+
+/// <summary>
+/// A callback that provides data for a <see cref="QuickGrid{TGridItem}"/>.
+/// </summary>
+/// <typeparam name="TGridItem">The type of data represented by each row in the grid.</typeparam>
+/// <param name="request">Parameters describing the data being requested.</param>
+/// <returns>A <see cref="ValueTask{GridItemsProviderResult}" /> that gives the data to be displayed.</returns>
+public delegate ValueTask<GridItemsProviderResult<TGridItem>> GridItemsProvider<TGridItem>(
+    GridItemsProviderRequest<TGridItem> request);

+ 70 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/GridItemsProviderRequest.cs

@@ -0,0 +1,70 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Linq;
+
+namespace Microsoft.AspNetCore.Components.QuickGrid;
+
+/// <summary>
+/// Parameters for data to be supplied by a <see cref="QuickGrid{TGridItem}"/>'s <see cref="QuickGrid{TGridItem}.ItemsProvider"/>.
+/// </summary>
+/// <typeparam name="TGridItem">The type of data represented by each row in the grid.</typeparam>
+public readonly struct GridItemsProviderRequest<TGridItem>
+{
+    /// <summary>
+    /// The zero-based index of the first item to be supplied.
+    /// </summary>
+    public int StartIndex { get; init; }
+
+    /// <summary>
+    /// If set, the maximum number of items to be supplied. If not set, the maximum number is unlimited.
+    /// </summary>
+    public int? Count { get; init; }
+
+    /// <summary>
+    /// Specifies which column represents the sort order.
+    ///
+    /// Rather than inferring the sort rules manually, you should normally call either <see cref="ApplySorting(IQueryable{TGridItem})"/>
+    /// or <see cref="GetSortByProperties"/>, since they also account for <see cref="SortByColumn" /> and <see cref="SortByAscending" /> automatically.
+    /// </summary>
+    public ColumnBase<TGridItem>? SortByColumn { get; init; }
+
+    /// <summary>
+    /// Specifies the current sort direction.
+    ///
+    /// Rather than inferring the sort rules manually, you should normally call either <see cref="ApplySorting(IQueryable{TGridItem})"/>
+    /// or <see cref="GetSortByProperties"/>, since they also account for <see cref="SortByColumn" /> and <see cref="SortByAscending" /> automatically.
+    /// </summary>
+    public bool SortByAscending { get; init; }
+
+    /// <summary>
+    /// A token that indicates if the request should be cancelled.
+    /// </summary>
+    public CancellationToken CancellationToken { get; init; }
+
+    internal GridItemsProviderRequest(
+        int startIndex, int? count, ColumnBase<TGridItem>? sortByColumn, bool sortByAscending,
+        CancellationToken cancellationToken)
+    {
+        StartIndex = startIndex;
+        Count = count;
+        SortByColumn = sortByColumn;
+        SortByAscending = sortByAscending;
+        CancellationToken = cancellationToken;
+    }
+
+    /// <summary>
+    /// Applies the request's sorting rules to the supplied <see cref="IQueryable{TGridItem}"/>.
+    /// </summary>
+    /// <param name="source">An <see cref="IQueryable{TGridItem}"/>.</param>
+    /// <returns>A new <see cref="IQueryable{TGridItem}"/> representing the <paramref name="source"/> with sorting rules applied.</returns>
+    public IQueryable<TGridItem> ApplySorting(IQueryable<TGridItem> source) =>
+        SortByColumn?.SortBy?.Apply(source, SortByAscending) ?? source;
+
+    /// <summary>
+    /// Produces a collection of (property name, direction) pairs representing the sorting rules.
+    /// </summary>
+    /// <returns>A collection of (property name, direction) pairs representing the sorting rules</returns>
+    public IReadOnlyCollection<SortedProperty> GetSortByProperties() =>
+        SortByColumn?.SortBy?.ToPropertyList(SortByAscending) ?? Array.Empty<SortedProperty>();
+}

+ 42 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/GridItemsProviderResult.cs

@@ -0,0 +1,42 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components.QuickGrid;
+
+/// <summary>
+/// Holds data being supplied to a <see cref="QuickGrid{TGridItem}"/>'s <see cref="QuickGrid{TGridItem}.ItemsProvider"/>.
+/// </summary>
+/// <typeparam name="TGridItem">The type of data represented by each row in the grid.</typeparam>
+public readonly struct GridItemsProviderResult<TGridItem>
+{
+    /// <summary>
+    /// The items being supplied.
+    /// </summary>
+    public required ICollection<TGridItem> Items { get; init; }
+
+    /// <summary>
+    /// The total number of items that may be displayed in the grid. This normally means the total number of items in the
+    /// underlying data source after applying any filtering that is in effect.
+    ///
+    /// If the grid is paginated, this should include all pages. If the grid is virtualized, this should include the entire scroll range.
+    /// </summary>
+    public int TotalItemCount { get; init; }
+}
+
+/// <summary>
+/// Provides convenience methods for constructing <see cref="GridItemsProviderResult{TGridItem}"/> instances.
+/// </summary>
+public static class GridItemsProviderResult
+{
+    // This is just to provide generic type inference, so you don't have to specify TGridItem yet again.
+
+    /// <summary>
+    /// Supplies an instance of <see cref="GridItemsProviderResult{TGridItem}"/>.
+    /// </summary>
+    /// <typeparam name="TGridItem">The type of data represented by each row in the grid.</typeparam>
+    /// <param name="items">The items being supplied.</param>
+    /// <param name="totalItemCount">The total numer of items that exist. See <see cref="GridItemsProviderResult{TGridItem}.TotalItemCount"/> for details.</param>
+    /// <returns>An instance of <see cref="GridItemsProviderResult{TGridItem}"/>.</returns>
+    public static GridItemsProviderResult<TGridItem> From<TGridItem>(ICollection<TGridItem> items, int totalItemCount)
+        => new() { Items = items, TotalItemCount = totalItemCount };
+}

+ 36 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/IAsyncQueryExecutor.cs

@@ -0,0 +1,36 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Linq;
+
+namespace Microsoft.AspNetCore.Components.QuickGrid;
+
+/// <summary>
+/// Provides methods for asynchronous evaluation of queries against an <see cref="IQueryable{T}" />.
+/// </summary>
+public interface IAsyncQueryExecutor
+{
+    /// <summary>
+    /// Determines whether the <see cref="IQueryable{T}" /> is supported by this <see cref="IAsyncQueryExecutor"/> type.
+    /// </summary>
+    /// <typeparam name="T">The data type.</typeparam>
+    /// <param name="queryable">An <see cref="IQueryable{T}" /> instance.</param>
+    /// <returns>True if this <see cref="IAsyncQueryExecutor"/> instance can perform asynchronous queries for the supplied <paramref name="queryable"/>, otherwise false.</returns>
+    bool IsSupported<T>(IQueryable<T> queryable);
+
+    /// <summary>
+    /// Asynchronously counts the items in the <see cref="IQueryable{T}" />, if supported.
+    /// </summary>
+    /// <typeparam name="T">The data type.</typeparam>
+    /// <param name="queryable">An <see cref="IQueryable{T}" /> instance.</param>
+    /// <returns>The number of items in <paramref name="queryable"/>.</returns>.
+    Task<int> CountAsync<T>(IQueryable<T> queryable);
+
+    /// <summary>
+    /// Asynchronously materializes the <see cref="IQueryable{T}" /> as an array, if supported.
+    /// </summary>
+    /// <typeparam name="T">The data type.</typeparam>
+    /// <param name="queryable">An <see cref="IQueryable{T}" /> instance.</param>
+    /// <returns>The items in the <paramref name="queryable"/>.</returns>.
+    Task<T[]> ToArrayAsync<T>(IQueryable<T> queryable);
+}

+ 58 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Infrastructure/AsyncQueryExecutorSupplier.cs

@@ -0,0 +1,58 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Concurrent;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.AspNetCore.Components.QuickGrid.Infrastructure;
+
+internal static class AsyncQueryExecutorSupplier
+{
+    // The primary goal with this is to ensure that:
+    //  - If you're using EF Core, then we resolve queries efficiently using its ToXyzAsync async extensions and don't
+    //    just fall back on the synchronous IQueryable ToXyz calls
+    //  - ... but without QuickGrid referencing Microsoft.EntityFramework directly. That's because it would bring in
+    //    heavy dependencies you may not be using (and relying on trimming isn't enough, as it's still desirable to have
+    //    heavy unused dependencies for Blazor Server).
+    //
+    // As a side-effect, we have an abstraction IAsyncQueryExecutor that developers could use to plug in their own
+    // mechanism for resolving async queries from other data sources than EF. It's not really a major goal to make this
+    // adapter generally useful beyond EF, but fine if people do have their own uses for it.
+
+    private static readonly ConcurrentDictionary<Type, bool> IsEntityFrameworkProviderTypeCache = new();
+
+    [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2111",
+               Justification = "The reflection is a best effort to warn developers about sync-over-async behavior which can cause thread pool starvation.")]
+    public static IAsyncQueryExecutor? GetAsyncQueryExecutor<T>(IServiceProvider services, IQueryable<T>? queryable)
+    {
+        if (queryable is not null)
+        {
+            var executor = services.GetService<IAsyncQueryExecutor>();
+
+            if (executor is null)
+            {
+                // It's useful to detect if the developer is unaware that they should be using the EF adapter, otherwise
+                // they will likely never notice and simply deploy an inefficient app that blocks threads on each query.
+                var providerType = queryable.Provider?.GetType();
+                if (providerType is not null && IsEntityFrameworkProviderTypeCache.GetOrAdd(providerType, IsEntityFrameworkProviderType))
+                {
+                    throw new InvalidOperationException($"The supplied {nameof(IQueryable)} is provided by Entity Framework. To query it efficiently, you must reference the package Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter and call AddQuickGridEntityFrameworkAdapter on your service collection.");
+                }
+            }
+            else if (executor.IsSupported(queryable))
+            {
+                return executor;
+            }
+        }
+
+        return null;
+    }
+
+    // We have to do this via reflection because the whole point is to avoid any static dependency on EF unless you
+    // reference the adapter. Trimming won't cause us any problems because this is only a way of detecting misconfiguration
+    // so it's sufficient if it can detect the misconfiguration in development.
+    private static bool IsEntityFrameworkProviderType([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type queryableProviderType)
+        => queryableProviderType.GetInterfaces().Any(x => string.Equals(x.FullName, "Microsoft.EntityFrameworkCore.Query.IAsyncQueryProvider", StringComparison.Ordinal)) == true;
+}

+ 62 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Infrastructure/ColumnsCollectedNotifier.cs

@@ -0,0 +1,62 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ComponentModel;
+
+namespace Microsoft.AspNetCore.Components.QuickGrid.Infrastructure;
+
+// One awkwardness of the way QuickGrid collects its list of child columns is that, during OnParametersSetAsync,
+// it only knows about the set of columns that were present on the *previous* render. If it's going to trigger a
+// data load during OnParametersSetAsync, that operation can't depend on the current set of columns as it might
+// have changed, or might even still be empty (i.e., on the first render).
+//
+// Ways this could be resolved:
+//
+// - In the future, we could implement the long-wanted feature of being able to query the contents of a RenderFragment
+//   separately from rendering. Then the whole trick of collection-during-rendering would not be needed.
+// - Or, we could factor out most of QuickGrid's internals into some new component QuickGridCore. The parent component,
+//   QuickGrid, would then only be responsible for collecting columns followed by rendering QuickGridCore. So each time
+//   QuickGridCore renders, we'd already have the latest set of columns
+//    - Drawback: since QuickGrid has public API, it's much messier to have to forward all of that to some new child type.
+//    - However, this is arguably the most correct solution in general (at least until option 1 above is implemented)
+// - Or, we could decide it's enough to fix this on the first render (since that's the only time we're going to guarantee
+//   to apply a default sort order), and then as a special case put in some extra component in the render flow that raises
+//   an event once the columns are first collected.
+//    - This is relatively simple and non-disruptive, though it doesn't cover cases where queries need to be delayed until
+//      after a dynamically-added column is added
+//
+// The final option is what's implemented here. We send the notification via EventCallbackSubscribable so that the async
+// operation and re-rendering follows normal semantics without us having to call StateHasChanged or think about exceptions.
+
+/// <summary>
+/// For internal use only. Do not use.
+/// </summary>
+/// <typeparam name="TGridItem">For internal use only. Do not use.</typeparam>
+[EditorBrowsable(EditorBrowsableState.Never)]
+public sealed class ColumnsCollectedNotifier<TGridItem> : IComponent
+{
+    private bool _isFirstRender = true;
+
+    [CascadingParameter] internal InternalGridContext<TGridItem> InternalGridContext { get; set; } = default!;
+
+    /// <inheritdoc/>
+    public void Attach(RenderHandle renderHandle)
+    {
+        // This component never renders, so we can ignore the renderHandle
+    }
+
+    /// <inheritdoc/>
+    public Task SetParametersAsync(ParameterView parameters)
+    {
+        if (_isFirstRender)
+        {
+            _isFirstRender = false;
+            parameters.SetParameterProperties(this);
+            return InternalGridContext.ColumnsFirstCollected.InvokeCallbacksAsync(null);
+        }
+        else
+        {
+            return Task.CompletedTask;
+        }
+    }
+}

+ 30 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Infrastructure/Defer.cs

@@ -0,0 +1,30 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ComponentModel;
+using Microsoft.AspNetCore.Components.Rendering;
+
+namespace Microsoft.AspNetCore.Components.QuickGrid.Infrastructure;
+
+// This is used by QuickGrid to move its body rendering to the end of the render queue so we can collect
+// the list of child columns first. It has to be public only because it's used from .razor logic.
+
+/// <summary>
+/// For internal use only. Do not use.
+/// </summary>
+[EditorBrowsable(EditorBrowsableState.Never)]
+public sealed class Defer : ComponentBase
+{
+    /// <summary>
+    /// For internal use only. Do not use.
+    /// </summary>
+    [Parameter] public RenderFragment? ChildContent { get; set; }
+
+    /// <summary>
+    /// For internal use only. Do not use.
+    /// </summary>
+    protected override void BuildRenderTree(RenderTreeBuilder builder)
+    {
+        builder.AddContent(0, ChildContent);
+    }
+}

+ 34 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Infrastructure/EventCallbackSubscribable.cs

@@ -0,0 +1,34 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components.QuickGrid.Infrastructure;
+
+/// <summary>
+/// Represents an event that you may subscribe to. This differs from normal C# events in that the handlers
+/// are EventCallback<typeparamref name="T"/>, and so may have async behaviors and cause component re-rendering
+/// while retaining error flow.
+/// </summary>
+/// <typeparam name="T">A type for the eventargs.</typeparam>
+internal sealed class EventCallbackSubscribable<T>
+{
+    private readonly Dictionary<EventCallbackSubscriber<T>, EventCallback<T>> _callbacks = new();
+
+    /// <summary>
+    /// Invokes all the registered callbacks sequentially, in an undefined order.
+    /// </summary>
+    public async Task InvokeCallbacksAsync(T eventArg)
+    {
+        foreach (var callback in _callbacks.Values)
+        {
+            await callback.InvokeAsync(eventArg);
+        }
+    }
+
+    // Don't call this directly - it gets called by EventCallbackSubscription
+    public void Subscribe(EventCallbackSubscriber<T> owner, EventCallback<T> callback)
+        => _callbacks.Add(owner, callback);
+
+    // Don't call this directly - it gets called by EventCallbackSubscription
+    public void Unsubscribe(EventCallbackSubscriber<T> owner)
+        => _callbacks.Remove(owner);
+}

+ 44 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Infrastructure/EventCallbackSubscriber.cs

@@ -0,0 +1,44 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components.QuickGrid.Infrastructure;
+
+/// <summary>
+/// Represents a subscriber that may be subscribe to an <see cref="EventCallbackSubscribable{T}"/>.
+/// The subscription can move between <see cref="EventCallbackSubscribable{T}"/> instances over time,
+/// and automatically unsubscribes from earlier <see cref="EventCallbackSubscribable{T}"/> instances
+/// whenever it moves to a new one.
+/// </summary>
+internal sealed class EventCallbackSubscriber<T> : IDisposable
+{
+    private readonly EventCallback<T> _handler;
+    private EventCallbackSubscribable<T>? _existingSubscription;
+
+    public EventCallbackSubscriber(EventCallback<T> handler)
+    {
+        _handler = handler;
+    }
+
+    /// <summary>
+    /// Creates a subscription on the <paramref name="subscribable"/>, or moves any existing subscription to it
+    /// by first unsubscribing from the previous <see cref="EventCallbackSubscribable{T}"/>.
+    ///
+    /// If the supplied <paramref name="subscribable"/> is null, no new subscription will be created, but any
+    /// existing one will still be unsubscribed.
+    /// </summary>
+    /// <param name="subscribable"></param>
+    public void SubscribeOrMove(EventCallbackSubscribable<T>? subscribable)
+    {
+        if (subscribable != _existingSubscription)
+        {
+            _existingSubscription?.Unsubscribe(this);
+            subscribable?.Subscribe(this, _handler);
+            _existingSubscription = subscribable;
+        }
+    }
+
+    public void Dispose()
+    {
+        _existingSubscription?.Unsubscribe(this);
+    }
+}

+ 12 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Infrastructure/EventHandlers.cs

@@ -0,0 +1,12 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components.QuickGrid.Infrastructure;
+
+/// <summary>
+/// Configures event handlers for <see cref="QuickGrid{TGridItem}"/>.
+/// </summary>
+[EventHandler("onclosecolumnoptions", typeof(EventArgs), enableStopPropagation: true, enablePreventDefault: true)]
+public static class EventHandlers
+{
+}

+ 17 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Infrastructure/InternalGridContext.cs

@@ -0,0 +1,17 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components.QuickGrid.Infrastructure;
+
+// The grid cascades this so that descendant columns can talk back to it. It's an internal type
+// so that it doesn't show up by mistake in unrelated components.
+internal sealed class InternalGridContext<TGridItem>
+{
+    public QuickGrid<TGridItem> Grid { get; }
+    public EventCallbackSubscribable<object?> ColumnsFirstCollected { get; } = new();
+
+    public InternalGridContext(QuickGrid<TGridItem> grid)
+    {
+        Grid = grid;
+    }
+}

+ 24 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Microsoft.AspNetCore.Components.QuickGrid.csproj

@@ -0,0 +1,24 @@
+<Project Sdk="Microsoft.NET.Sdk.Razor">
+
+  <PropertyGroup>
+    <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+    <Description>Provides a simple and convenient data grid component for common grid rendering scenarios and serves as a reference architecture and performance baseline for building data grid components.
+      If you're using Entity Framework Core for your grid IQueryables, consider using the <a href="https://www.nuget.org/packages/Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter">Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter</a> package.
+    </Description>
+    <GenerateDocumentationFile>true</GenerateDocumentationFile>
+    <IsTrimmable>true</IsTrimmable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <SupportedPlatform Include="browser" />
+
+    <!-- Bundle the theme CSS files as if they were scoped, even though they aren't -->
+    <ThemeCssFiles Include="Themes\*.css" />
+    <_CurrentProjectDiscoveredScopedCssFiles Include="@(ThemeCssFiles)" RelativePath="%(Identity)" BasePath="_content/$(AssemblyName)" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <Reference Include="Microsoft.AspNetCore.Components.Web" />
+  </ItemGroup>
+
+</Project>

+ 81 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Pagination/PaginationState.cs

@@ -0,0 +1,81 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Components.QuickGrid.Infrastructure;
+
+namespace Microsoft.AspNetCore.Components.QuickGrid;
+
+/// <summary>
+/// Holds state to represent pagination in a <see cref="QuickGrid{TGridItem}"/>.
+/// </summary>
+public class PaginationState
+{
+    /// <summary>
+    /// Gets or sets the number of items on each page.
+    /// </summary>
+    public int ItemsPerPage { get; set; } = 10;
+
+    /// <summary>
+    /// Gets the current zero-based page index. To set it, call <see cref="SetCurrentPageIndexAsync(int)" />.
+    /// </summary>
+    public int CurrentPageIndex { get; private set; }
+
+    /// <summary>
+    /// Gets the total number of items across all pages, if known. The value will be null until an
+    /// associated <see cref="QuickGrid{TGridItem}"/> assigns a value after loading data.
+    /// </summary>
+    public int? TotalItemCount { get; private set; }
+
+    /// <summary>
+    /// Gets the zero-based index of the last page, if known. The value will be null until <see cref="TotalItemCount"/> is known.
+    /// </summary>
+    public int? LastPageIndex => (TotalItemCount - 1) / ItemsPerPage;
+
+    /// <summary>
+    /// An event that is raised when the total item count has changed.
+    /// </summary>
+    public event EventHandler<int?>? TotalItemCountChanged;
+
+    internal EventCallbackSubscribable<PaginationState> CurrentPageItemsChanged { get; } = new();
+    internal EventCallbackSubscribable<PaginationState> TotalItemCountChangedSubscribable { get; } = new();
+
+    /// <inheritdoc />
+    public override int GetHashCode()
+        => HashCode.Combine(ItemsPerPage, CurrentPageIndex, TotalItemCount);
+
+    /// <summary>
+    /// Sets the current page index, and notifies any associated <see cref="QuickGrid{TGridItem}"/>
+    /// to fetch and render updated data.
+    /// </summary>
+    /// <param name="pageIndex">The new, zero-based page index.</param>
+    /// <returns>A <see cref="Task"/> representing the completion of the operation.</returns>
+    public Task SetCurrentPageIndexAsync(int pageIndex)
+    {
+        CurrentPageIndex = pageIndex;
+        return CurrentPageItemsChanged.InvokeCallbacksAsync(this);
+    }
+
+    // Can be internal because this only needs to be called by QuickGrid itself, not any custom pagination UI components.
+    internal Task SetTotalItemCountAsync(int totalItemCount)
+    {
+        if (totalItemCount == TotalItemCount)
+        {
+            return Task.CompletedTask;
+        }
+
+        TotalItemCount = totalItemCount;
+
+        if (CurrentPageIndex > 0 && CurrentPageIndex > LastPageIndex)
+        {
+            // If the number of items has reduced such that the current page index is no longer valid, move
+            // automatically to the final valid page index and trigger a further data load.
+            return SetCurrentPageIndexAsync(LastPageIndex.Value);
+        }
+        else
+        {
+            // Under normal circumstances, we just want any associated pagination UI to update
+            TotalItemCountChanged?.Invoke(this, TotalItemCount);
+            return TotalItemCountChangedSubscribable.InvokeCallbacksAsync(this);
+        }
+    }
+}

+ 28 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Pagination/Paginator.razor

@@ -0,0 +1,28 @@
+@using Microsoft.AspNetCore.Components.Web
+@namespace Microsoft.AspNetCore.Components.QuickGrid
+
+<div class="paginator">
+    @if (State.TotalItemCount.HasValue)
+    {
+        <div class="summary">
+            @if (SummaryTemplate is not null)
+            {
+                @SummaryTemplate
+            }
+            else
+            {
+                <text><strong>@State.TotalItemCount</strong> items</text>
+            }
+        </div>
+        <nav role="navigation">
+            <button class="go-first" type="button" @onclick="GoFirstAsync" disabled="@(!CanGoBack)" title="Go to first page" aria-title="Go to first page"></button>
+            <button class="go-previous" type="button" @onclick="GoPreviousAsync" disabled="@(!CanGoBack)" title="Go to previous page" aria-title="Go to previous page"></button>
+            <div class="pagination-text">
+                Page <strong>@(State.CurrentPageIndex + 1)</strong>
+                of <strong>@(State.LastPageIndex + 1)</strong>
+            </div>
+            <button class="go-next" type="button" @onclick="GoNextAsync" disabled="@(!CanGoForwards)" title="Go to next page" aria-title="Go to next page"></button>
+            <button class="go-last" type="button" @onclick="GoLastAsync" disabled="@(!CanGoForwards)" title="Go to last page" aria-title="Go to last page"></button>
+        </nav>
+    }
+</div>

+ 52 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Pagination/Paginator.razor.cs

@@ -0,0 +1,52 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Components.QuickGrid.Infrastructure;
+
+namespace Microsoft.AspNetCore.Components.QuickGrid;
+
+/// <summary>
+/// A component that provides a user interface for <see cref="PaginationState"/>.
+/// </summary>
+public partial class Paginator : IDisposable
+{
+    private readonly EventCallbackSubscriber<PaginationState> _totalItemCountChanged;
+
+    /// <summary>
+    /// Specifies the associated <see cref="PaginationState"/>. This parameter is required.
+    /// </summary>
+    [Parameter, EditorRequired] public PaginationState State { get; set; } = default!;
+
+    /// <summary>
+    /// Optionally supplies a template for rendering the page count summary.
+    /// </summary>
+    [Parameter] public RenderFragment? SummaryTemplate { get; set; }
+
+    /// <summary>
+    /// Constructs an instance of <see cref="Paginator" />.
+    /// </summary>
+    public Paginator()
+    {
+        // The "total item count" handler doesn't need to do anything except cause this component to re-render
+        _totalItemCountChanged = new(new EventCallback<PaginationState>(this, null));
+    }
+
+    private Task GoFirstAsync() => GoToPageAsync(0);
+    private Task GoPreviousAsync() => GoToPageAsync(State.CurrentPageIndex - 1);
+    private Task GoNextAsync() => GoToPageAsync(State.CurrentPageIndex + 1);
+    private Task GoLastAsync() => GoToPageAsync(State.LastPageIndex.GetValueOrDefault(0));
+
+    private bool CanGoBack => State.CurrentPageIndex > 0;
+    private bool CanGoForwards => State.CurrentPageIndex < State.LastPageIndex;
+
+    private Task GoToPageAsync(int pageIndex)
+        => State.SetCurrentPageIndexAsync(pageIndex);
+
+    /// <inheritdoc />
+    protected override void OnParametersSet()
+        => _totalItemCountChanged.SubscribeOrMove(State.TotalItemCountChangedSubscribable);
+
+    /// <inheritdoc />
+    public void Dispose()
+        => _totalItemCountChanged.Dispose();
+}

+ 49 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Pagination/Paginator.razor.css

@@ -0,0 +1,49 @@
+.paginator {
+    display: flex;
+    border-top: 1px solid #ccc;
+    margin-top: 0.5rem;
+    padding: 0.25rem 0;
+    align-items: center;
+}
+
+.pagination-text {
+    margin: 0 0.5rem;
+}
+
+nav {
+    display: flex;
+    margin-left: auto;
+    gap: 0.5rem;
+    align-items: center;
+}
+
+    nav button {
+        border: 0;
+        background: none center center / 1rem no-repeat;
+        width: 2rem;
+        height: 2rem;
+    }
+
+    nav button[disabled] {
+        opacity: 0.4;
+    }
+
+    nav button:not([disabled]):hover {
+        background-color: #eee;
+    }
+
+    nav button:not([disabled]):active {
+        background-color: #aaa;
+    }
+
+.go-first, .go-last {
+    background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g transform="rotate(90) scale(0.8)" transform-origin="12 12"><path d="m 2,1.5 l 10,17.5 l 10,-17.5 l -10,7.75 l -10,-7.75 z"/><rect height="2" width="20" y="20.5" x="2"/></g></svg>');
+}
+
+.go-previous, .go-next {
+    background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g transform="rotate(90)" transform-origin="12 12"><path d="M 2 3.25 L 12 20.75 L 22 3.25 L 12 11 z" /></g></svg>');
+}
+
+.go-next, .go-last {
+    transform: scaleX(-1);
+}

+ 1 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/PublicAPI.Shipped.txt

@@ -0,0 +1 @@
+#nullable enable

+ 150 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/PublicAPI.Unshipped.txt

@@ -0,0 +1,150 @@
+#nullable enable
+abstract Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.CellContent(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder! builder, TGridItem item) -> void
+abstract Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.SortBy.get -> Microsoft.AspNetCore.Components.QuickGrid.GridSort<TGridItem>?
+abstract Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.SortBy.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.Align
+Microsoft.AspNetCore.Components.QuickGrid.Align.Center = 1 -> Microsoft.AspNetCore.Components.QuickGrid.Align
+Microsoft.AspNetCore.Components.QuickGrid.Align.End = 2 -> Microsoft.AspNetCore.Components.QuickGrid.Align
+Microsoft.AspNetCore.Components.QuickGrid.Align.Left = 3 -> Microsoft.AspNetCore.Components.QuickGrid.Align
+Microsoft.AspNetCore.Components.QuickGrid.Align.Right = 4 -> Microsoft.AspNetCore.Components.QuickGrid.Align
+Microsoft.AspNetCore.Components.QuickGrid.Align.Start = 0 -> Microsoft.AspNetCore.Components.QuickGrid.Align
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.Align.get -> Microsoft.AspNetCore.Components.QuickGrid.Align
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.Align.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.Class.get -> string?
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.Class.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.ColumnBase() -> void
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.ColumnOptions.get -> Microsoft.AspNetCore.Components.RenderFragment?
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.ColumnOptions.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.Grid.get -> Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>!
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.HeaderContent.get -> Microsoft.AspNetCore.Components.RenderFragment!
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.HeaderContent.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.HeaderTemplate.get -> Microsoft.AspNetCore.Components.RenderFragment<Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>!>?
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.HeaderTemplate.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.InitialSortDirection.get -> Microsoft.AspNetCore.Components.QuickGrid.SortDirection
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.InitialSortDirection.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.IsDefaultSortColumn.get -> bool
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.IsDefaultSortColumn.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.PlaceholderTemplate.get -> Microsoft.AspNetCore.Components.RenderFragment<Microsoft.AspNetCore.Components.Web.Virtualization.PlaceholderContext>?
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.PlaceholderTemplate.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.Sortable.get -> bool?
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.Sortable.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.Title.get -> string?
+Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.Title.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProvider<TGridItem>
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderRequest<TGridItem>
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderRequest<TGridItem>.ApplySorting(System.Linq.IQueryable<TGridItem>! source) -> System.Linq.IQueryable<TGridItem>!
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderRequest<TGridItem>.CancellationToken.get -> System.Threading.CancellationToken
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderRequest<TGridItem>.CancellationToken.init -> void
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderRequest<TGridItem>.Count.get -> int?
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderRequest<TGridItem>.Count.init -> void
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderRequest<TGridItem>.GetSortByProperties() -> System.Collections.Generic.IReadOnlyCollection<Microsoft.AspNetCore.Components.QuickGrid.SortedProperty>!
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderRequest<TGridItem>.GridItemsProviderRequest() -> void
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderRequest<TGridItem>.SortByAscending.get -> bool
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderRequest<TGridItem>.SortByAscending.init -> void
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderRequest<TGridItem>.SortByColumn.get -> Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>?
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderRequest<TGridItem>.SortByColumn.init -> void
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderRequest<TGridItem>.StartIndex.get -> int
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderRequest<TGridItem>.StartIndex.init -> void
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderResult
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderResult<TGridItem>
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderResult<TGridItem>.GridItemsProviderResult() -> void
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderResult<TGridItem>.Items.get -> System.Collections.Generic.ICollection<TGridItem>!
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderResult<TGridItem>.Items.init -> void
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderResult<TGridItem>.TotalItemCount.get -> int
+Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderResult<TGridItem>.TotalItemCount.init -> void
+Microsoft.AspNetCore.Components.QuickGrid.GridSort<TGridItem>
+Microsoft.AspNetCore.Components.QuickGrid.GridSort<TGridItem>.ThenAscending<U>(System.Linq.Expressions.Expression<System.Func<TGridItem, U>!>! expression) -> Microsoft.AspNetCore.Components.QuickGrid.GridSort<TGridItem>!
+Microsoft.AspNetCore.Components.QuickGrid.GridSort<TGridItem>.ThenDescending<U>(System.Linq.Expressions.Expression<System.Func<TGridItem, U>!>! expression) -> Microsoft.AspNetCore.Components.QuickGrid.GridSort<TGridItem>!
+Microsoft.AspNetCore.Components.QuickGrid.IAsyncQueryExecutor
+Microsoft.AspNetCore.Components.QuickGrid.IAsyncQueryExecutor.CountAsync<T>(System.Linq.IQueryable<T>! queryable) -> System.Threading.Tasks.Task<int>!
+Microsoft.AspNetCore.Components.QuickGrid.IAsyncQueryExecutor.IsSupported<T>(System.Linq.IQueryable<T>! queryable) -> bool
+Microsoft.AspNetCore.Components.QuickGrid.IAsyncQueryExecutor.ToArrayAsync<T>(System.Linq.IQueryable<T>! queryable) -> System.Threading.Tasks.Task<T[]!>!
+Microsoft.AspNetCore.Components.QuickGrid.Infrastructure.ColumnsCollectedNotifier<TGridItem>
+Microsoft.AspNetCore.Components.QuickGrid.Infrastructure.ColumnsCollectedNotifier<TGridItem>.Attach(Microsoft.AspNetCore.Components.RenderHandle renderHandle) -> void
+Microsoft.AspNetCore.Components.QuickGrid.Infrastructure.ColumnsCollectedNotifier<TGridItem>.ColumnsCollectedNotifier() -> void
+Microsoft.AspNetCore.Components.QuickGrid.Infrastructure.ColumnsCollectedNotifier<TGridItem>.SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) -> System.Threading.Tasks.Task!
+Microsoft.AspNetCore.Components.QuickGrid.Infrastructure.Defer
+Microsoft.AspNetCore.Components.QuickGrid.Infrastructure.Defer.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment?
+Microsoft.AspNetCore.Components.QuickGrid.Infrastructure.Defer.ChildContent.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.Infrastructure.Defer.Defer() -> void
+Microsoft.AspNetCore.Components.QuickGrid.Infrastructure.EventHandlers
+Microsoft.AspNetCore.Components.QuickGrid.PaginationState
+Microsoft.AspNetCore.Components.QuickGrid.PaginationState.CurrentPageIndex.get -> int
+Microsoft.AspNetCore.Components.QuickGrid.PaginationState.ItemsPerPage.get -> int
+Microsoft.AspNetCore.Components.QuickGrid.PaginationState.ItemsPerPage.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.PaginationState.LastPageIndex.get -> int?
+Microsoft.AspNetCore.Components.QuickGrid.PaginationState.PaginationState() -> void
+Microsoft.AspNetCore.Components.QuickGrid.PaginationState.SetCurrentPageIndexAsync(int pageIndex) -> System.Threading.Tasks.Task!
+Microsoft.AspNetCore.Components.QuickGrid.PaginationState.TotalItemCount.get -> int?
+Microsoft.AspNetCore.Components.QuickGrid.PaginationState.TotalItemCountChanged -> System.EventHandler<int?>?
+Microsoft.AspNetCore.Components.QuickGrid.Paginator
+Microsoft.AspNetCore.Components.QuickGrid.Paginator.Dispose() -> void
+Microsoft.AspNetCore.Components.QuickGrid.Paginator.Paginator() -> void
+Microsoft.AspNetCore.Components.QuickGrid.Paginator.State.get -> Microsoft.AspNetCore.Components.QuickGrid.PaginationState!
+Microsoft.AspNetCore.Components.QuickGrid.Paginator.State.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.Paginator.SummaryTemplate.get -> Microsoft.AspNetCore.Components.RenderFragment?
+Microsoft.AspNetCore.Components.QuickGrid.Paginator.SummaryTemplate.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.PropertyColumn<TGridItem, TProp>
+Microsoft.AspNetCore.Components.QuickGrid.PropertyColumn<TGridItem, TProp>.Format.get -> string?
+Microsoft.AspNetCore.Components.QuickGrid.PropertyColumn<TGridItem, TProp>.Format.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.PropertyColumn<TGridItem, TProp>.Property.get -> System.Linq.Expressions.Expression<System.Func<TGridItem, TProp>!>!
+Microsoft.AspNetCore.Components.QuickGrid.PropertyColumn<TGridItem, TProp>.Property.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.PropertyColumn<TGridItem, TProp>.PropertyColumn() -> void
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment?
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.ChildContent.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.Class.get -> string?
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.Class.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.DisposeAsync() -> System.Threading.Tasks.ValueTask
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.ItemKey.get -> System.Func<TGridItem, object!>!
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.ItemKey.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.Items.get -> System.Linq.IQueryable<TGridItem>?
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.Items.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.ItemSize.get -> float
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.ItemSize.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.ItemsProvider.get -> Microsoft.AspNetCore.Components.QuickGrid.GridItemsProvider<TGridItem>?
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.ItemsProvider.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.Pagination.get -> Microsoft.AspNetCore.Components.QuickGrid.PaginationState?
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.Pagination.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.QuickGrid() -> void
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.RefreshDataAsync() -> System.Threading.Tasks.Task!
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.ShowColumnOptionsAsync(Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>! column) -> System.Threading.Tasks.Task!
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.SortByColumnAsync(Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>! column, Microsoft.AspNetCore.Components.QuickGrid.SortDirection direction = Microsoft.AspNetCore.Components.QuickGrid.SortDirection.Auto) -> System.Threading.Tasks.Task!
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.Theme.get -> string?
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.Theme.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.Virtualize.get -> bool
+Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.Virtualize.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.SortDirection
+Microsoft.AspNetCore.Components.QuickGrid.SortDirection.Ascending = 1 -> Microsoft.AspNetCore.Components.QuickGrid.SortDirection
+Microsoft.AspNetCore.Components.QuickGrid.SortDirection.Auto = 0 -> Microsoft.AspNetCore.Components.QuickGrid.SortDirection
+Microsoft.AspNetCore.Components.QuickGrid.SortDirection.Descending = 2 -> Microsoft.AspNetCore.Components.QuickGrid.SortDirection
+Microsoft.AspNetCore.Components.QuickGrid.SortedProperty
+Microsoft.AspNetCore.Components.QuickGrid.SortedProperty.Direction.get -> Microsoft.AspNetCore.Components.QuickGrid.SortDirection
+Microsoft.AspNetCore.Components.QuickGrid.SortedProperty.Direction.init -> void
+Microsoft.AspNetCore.Components.QuickGrid.SortedProperty.PropertyName.get -> string!
+Microsoft.AspNetCore.Components.QuickGrid.SortedProperty.PropertyName.init -> void
+Microsoft.AspNetCore.Components.QuickGrid.SortedProperty.SortedProperty() -> void
+Microsoft.AspNetCore.Components.QuickGrid.TemplateColumn<TGridItem>
+Microsoft.AspNetCore.Components.QuickGrid.TemplateColumn<TGridItem>.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment<TGridItem>!
+Microsoft.AspNetCore.Components.QuickGrid.TemplateColumn<TGridItem>.ChildContent.set -> void
+Microsoft.AspNetCore.Components.QuickGrid.TemplateColumn<TGridItem>.TemplateColumn() -> void
+override Microsoft.AspNetCore.Components.QuickGrid.PaginationState.GetHashCode() -> int
+override Microsoft.AspNetCore.Components.QuickGrid.Paginator.OnParametersSet() -> void
+override Microsoft.AspNetCore.Components.QuickGrid.PropertyColumn<TGridItem, TProp>.CellContent(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder! builder, TGridItem item) -> void
+override Microsoft.AspNetCore.Components.QuickGrid.PropertyColumn<TGridItem, TProp>.OnParametersSet() -> void
+override Microsoft.AspNetCore.Components.QuickGrid.PropertyColumn<TGridItem, TProp>.SortBy.get -> Microsoft.AspNetCore.Components.QuickGrid.GridSort<TGridItem>?
+override Microsoft.AspNetCore.Components.QuickGrid.PropertyColumn<TGridItem, TProp>.SortBy.set -> void
+override Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.OnAfterRenderAsync(bool firstRender) -> System.Threading.Tasks.Task!
+override Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.OnParametersSetAsync() -> System.Threading.Tasks.Task!
+override Microsoft.AspNetCore.Components.QuickGrid.TemplateColumn<TGridItem>.CellContent(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder! builder, TGridItem item) -> void
+override Microsoft.AspNetCore.Components.QuickGrid.TemplateColumn<TGridItem>.IsSortableByDefault() -> bool
+override Microsoft.AspNetCore.Components.QuickGrid.TemplateColumn<TGridItem>.SortBy.get -> Microsoft.AspNetCore.Components.QuickGrid.GridSort<TGridItem>?
+override Microsoft.AspNetCore.Components.QuickGrid.TemplateColumn<TGridItem>.SortBy.set -> void
+static Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderResult.From<TGridItem>(System.Collections.Generic.ICollection<TGridItem>! items, int totalItemCount) -> Microsoft.AspNetCore.Components.QuickGrid.GridItemsProviderResult<TGridItem>
+static Microsoft.AspNetCore.Components.QuickGrid.GridSort<TGridItem>.ByAscending<U>(System.Linq.Expressions.Expression<System.Func<TGridItem, U>!>! expression) -> Microsoft.AspNetCore.Components.QuickGrid.GridSort<TGridItem>!
+static Microsoft.AspNetCore.Components.QuickGrid.GridSort<TGridItem>.ByDescending<U>(System.Linq.Expressions.Expression<System.Func<TGridItem, U>!>! expression) -> Microsoft.AspNetCore.Components.QuickGrid.GridSort<TGridItem>!
+virtual Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.IsSortableByDefault() -> bool
+~override Microsoft.AspNetCore.Components.QuickGrid.ColumnBase<TGridItem>.BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder __builder) -> void
+~override Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<TGridItem>.BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder __builder) -> void
+~override Microsoft.AspNetCore.Components.QuickGrid.Paginator.BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder __builder) -> void

+ 94 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor

@@ -0,0 +1,94 @@
+@using Microsoft.AspNetCore.Components.QuickGrid.Infrastructure
+@using Microsoft.AspNetCore.Components.Rendering
+@using Microsoft.AspNetCore.Components.Web
+@using Microsoft.AspNetCore.Components.Web.Virtualization
+@typeparam TGridItem
+<CascadingValue TValue="InternalGridContext<TGridItem>" IsFixed="true" Value="@_internalGridContext">
+    @{ StartCollectingColumns(); }
+    @ChildContent
+    <Defer>
+        @{ FinishCollectingColumns(); }
+        <ColumnsCollectedNotifier TGridItem="TGridItem" />
+
+        <table class="@GridClass()" theme="@Theme" aria-rowcount="@(_ariaBodyRowCount + 1)" @ref="_tableReference" @onclosecolumnoptions="CloseColumnOptions">
+            <thead>
+                <tr>
+                    @_renderColumnHeaders
+                </tr>
+            </thead>
+            <tbody>
+                @if (Virtualize)
+                {
+                    <Virtualize @ref="@_virtualizeComponent"
+                        TItem="(int RowIndex, TGridItem Data)"
+                        ItemSize="@ItemSize"
+                        ItemsProvider="@ProvideVirtualizedItems"
+                        ItemContent="@(item => builder => RenderRow(builder, item.RowIndex, item.Data))"
+                        Placeholder="@(placeholderContext => builder => RenderPlaceholderRow(builder, placeholderContext))" />
+                }
+                else
+                {
+                    @_renderNonVirtualizedRows
+                }
+            </tbody>
+        </table>
+    </Defer>
+</CascadingValue>
+
+@code {
+    private void RenderNonVirtualizedRows(RenderTreeBuilder __builder)
+    {
+        var initialRowIndex = 2; // aria-rowindex is 1-based, plus the first row is the header
+        var rowIndex = initialRowIndex; 
+        foreach (var item in _currentNonVirtualizedViewItems)
+        {
+            RenderRow(__builder, rowIndex++, item);
+        }
+
+        // When pagination is enabled, by default ensure we render the exact number of expected rows per page,
+        // even if there aren't enough data items. This avoids the layout jumping on the last page.
+        // Consider making this optional.
+        if (Pagination is not null)
+        {
+            while (rowIndex++ < initialRowIndex + Pagination.ItemsPerPage)
+            {
+                <tr></tr>
+            }
+        }
+    }
+
+    private void RenderRow(RenderTreeBuilder __builder, int rowIndex, TGridItem item)
+    {
+        <tr @key="@(ItemKey(item))" aria-rowindex="@rowIndex">
+            @foreach (var col in _columns)
+            {
+                <td class="@ColumnClass(col)" @key="@col">@{ col.CellContent(__builder, item); }</td>
+            }
+        </tr>
+    }
+
+    private void RenderPlaceholderRow(RenderTreeBuilder __builder, PlaceholderContext placeholderContext)
+    {
+        <tr aria-rowindex="@(placeholderContext.Index + 1)">
+            @foreach (var col in _columns)
+            {
+                <td class="grid-cell-placeholder @ColumnClass(col)" @key="@col">@{ col.RenderPlaceholderContent(__builder, placeholderContext); }</td>
+            }
+        </tr>
+    }
+
+    private void RenderColumnHeaders(RenderTreeBuilder __builder)
+    {
+        foreach (var col in _columns)
+        {
+            <th class="@ColumnHeaderClass(col)" aria-sort="@AriaSortValue(col)" @key="@col" scope="col">
+                <div class="col-header-content">@col.HeaderContent</div>
+
+                @if (col == _displayOptionsForColumn)
+                {
+                    <div class="col-options">@col.ColumnOptions</div>
+                }
+            </th>
+        }
+    }
+}

+ 431 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor.cs

@@ -0,0 +1,431 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Linq;
+using Microsoft.AspNetCore.Components.QuickGrid.Infrastructure;
+using Microsoft.AspNetCore.Components.Web.Virtualization;
+using Microsoft.JSInterop;
+
+namespace Microsoft.AspNetCore.Components.QuickGrid;
+
+/// <summary>
+/// A component that displays a grid.
+/// </summary>
+/// <typeparam name="TGridItem">The type of data represented by each row in the grid.</typeparam>
+[CascadingTypeParameter(nameof(TGridItem))]
+public partial class QuickGrid<TGridItem> : IAsyncDisposable
+{
+    /// <summary>
+    /// A queryable source of data for the grid.
+    ///
+    /// This could be in-memory data converted to queryable using the
+    /// <see cref="System.Linq.Queryable.AsQueryable(System.Collections.IEnumerable)"/> extension method,
+    /// or an EntityFramework DataSet or an <see cref="IQueryable"/> derived from it.
+    ///
+    /// You should supply either <see cref="Items"/> or <see cref="ItemsProvider"/>, but not both.
+    /// </summary>
+    [Parameter] public IQueryable<TGridItem>? Items { get; set; }
+
+    /// <summary>
+    /// A callback that supplies data for the rid.
+    ///
+    /// You should supply either <see cref="Items"/> or <see cref="ItemsProvider"/>, but not both.
+    /// </summary>
+    [Parameter] public GridItemsProvider<TGridItem>? ItemsProvider { get; set; }
+
+    /// <summary>
+    /// An optional CSS class name. If given, this will be included in the class attribute of the rendered table.
+    /// </summary>
+    [Parameter] public string? Class { get; set; }
+
+    /// <summary>
+    /// A theme name, with default value "default". This affects which styling rules match the table.
+    /// </summary>
+    [Parameter] public string? Theme { get; set; } = "default";
+
+    /// <summary>
+    /// Defines the child components of this instance. For example, you may define columns by adding
+    /// components derived from the <see cref="ColumnBase{TGridItem}"/> base class.
+    /// </summary>
+    [Parameter] public RenderFragment? ChildContent { get; set; }
+
+    /// <summary>
+    /// If true, the grid will be rendered with virtualization. This is normally used in conjunction with
+    /// scrolling and causes the grid to fetch and render only the data around the current scroll viewport.
+    /// This can greatly improve the performance when scrolling through large data sets.
+    ///
+    /// If you use <see cref="Virtualize"/>, you should supply a value for <see cref="ItemSize"/> and must
+    /// ensure that every row renders with the same constant height.
+    ///
+    /// Generally it's preferable not to use <see cref="Virtualize"/> if the amount of data being rendered
+    /// is small or if you are using pagination.
+    /// </summary>
+    [Parameter] public bool Virtualize { get; set; }
+
+    /// <summary>
+    /// This is applicable only when using <see cref="Virtualize"/>. It defines an expected height in pixels for
+    /// each row, allowing the virtualization mechanism to fetch the correct number of items to match the display
+    /// size and to ensure accurate scrolling.
+    /// </summary>
+    [Parameter] public float ItemSize { get; set; } = 50;
+
+    /// <summary>
+    /// Optionally defines a value for @key on each rendered row. Typically this should be used to specify a
+    /// unique identifier, such as a primary key value, for each data item.
+    ///
+    /// This allows the grid to preserve the association between row elements and data items based on their
+    /// unique identifiers, even when the TGridItem instances are replaced by new copies (for
+    /// example, after a new query against the underlying data store).
+    ///
+    /// If not set, the @key will be the TGridItem instance itself.
+    /// </summary>
+    [Parameter] public Func<TGridItem, object> ItemKey { get; set; } = x => x!;
+
+    /// <summary>
+    /// Optionally links this <see cref="QuickGrid{TGridItem}"/> instance with a <see cref="PaginationState"/> model,
+    /// causing the grid to fetch and render only the current page of data.
+    ///
+    /// This is normally used in conjunction with a <see cref="Paginator"/> component or some other UI logic
+    /// that displays and updates the supplied <see cref="PaginationState"/> instance.
+    /// </summary>
+    [Parameter] public PaginationState? Pagination { get; set; }
+
+    [Inject] private IServiceProvider Services { get; set; } = default!;
+    [Inject] private IJSRuntime JS { get; set; } = default!;
+
+    private ElementReference _tableReference;
+    private Virtualize<(int, TGridItem)>? _virtualizeComponent;
+    private int _ariaBodyRowCount;
+    private ICollection<TGridItem> _currentNonVirtualizedViewItems = Array.Empty<TGridItem>();
+
+    // IQueryable only exposes synchronous query APIs. IAsyncQueryExecutor is an adapter that lets us invoke any
+    // async query APIs that might be available. We have built-in support for using EF Core's async query APIs.
+    private IAsyncQueryExecutor? _asyncQueryExecutor;
+
+    // We cascade the InternalGridContext to descendants, which in turn call it to add themselves to _columns
+    // This happens on every render so that the column list can be updated dynamically
+    private readonly InternalGridContext<TGridItem> _internalGridContext;
+    private readonly List<ColumnBase<TGridItem>> _columns;
+    private bool _collectingColumns; // Columns might re-render themselves arbitrarily. We only want to capture them at a defined time.
+
+    // Tracking state for options and sorting
+    private ColumnBase<TGridItem>? _displayOptionsForColumn;
+    private ColumnBase<TGridItem>? _sortByColumn;
+    private bool _sortByAscending;
+    private bool _checkColumnOptionsPosition;
+
+    // The associated ES6 module, which uses document-level event listeners
+    private IJSObjectReference? _jsModule;
+    private IJSObjectReference? _jsEventDisposable;
+
+    // Caches of method->delegate conversions
+    private readonly RenderFragment _renderColumnHeaders;
+    private readonly RenderFragment _renderNonVirtualizedRows;
+
+    // We try to minimize the number of times we query the items provider, since queries may be expensive
+    // We only re-query when the developer calls RefreshDataAsync, or if we know something's changed, such
+    // as sort order, the pagination state, or the data source itself. These fields help us detect when
+    // things have changed, and to discard earlier load attempts that were superseded.
+    private int? _lastRefreshedPaginationStateHash;
+    private object? _lastAssignedItemsOrProvider;
+    private CancellationTokenSource? _pendingDataLoadCancellationTokenSource;
+
+    // If the PaginationState mutates, it raises this event. We use it to trigger a re-render.
+    private readonly EventCallbackSubscriber<PaginationState> _currentPageItemsChanged;
+
+    /// <summary>
+    /// Constructs an instance of <see cref="QuickGrid{TGridItem}"/>.
+    /// </summary>
+    public QuickGrid()
+    {
+        _columns = new();
+        _internalGridContext = new(this);
+        _currentPageItemsChanged = new(EventCallback.Factory.Create<PaginationState>(this, RefreshDataCoreAsync));
+        _renderColumnHeaders = RenderColumnHeaders;
+        _renderNonVirtualizedRows = RenderNonVirtualizedRows;
+
+        // As a special case, we don't issue the first data load request until we've collected the initial set of columns
+        // This is so we can apply default sort order (or any future per-column options) before loading data
+        // We use EventCallbackSubscriber to safely hook this async operation into the synchronous rendering flow
+        var columnsFirstCollectedSubscriber = new EventCallbackSubscriber<object?>(
+            EventCallback.Factory.Create<object?>(this, RefreshDataCoreAsync));
+        columnsFirstCollectedSubscriber.SubscribeOrMove(_internalGridContext.ColumnsFirstCollected);
+    }
+
+    /// <inheritdoc />
+    protected override Task OnParametersSetAsync()
+    {
+        // The associated pagination state may have been added/removed/replaced
+        _currentPageItemsChanged.SubscribeOrMove(Pagination?.CurrentPageItemsChanged);
+
+        if (Items is not null && ItemsProvider is not null)
+        {
+            throw new InvalidOperationException($"{nameof(QuickGrid)} requires one of {nameof(Items)} or {nameof(ItemsProvider)}, but both were specified.");
+        }
+
+        // Perform a re-query only if the data source or something else has changed
+        var _newItemsOrItemsProvider = Items ?? (object?)ItemsProvider;
+        var dataSourceHasChanged = _newItemsOrItemsProvider != _lastAssignedItemsOrProvider;
+        if (dataSourceHasChanged)
+        {
+            _lastAssignedItemsOrProvider = _newItemsOrItemsProvider;
+            _asyncQueryExecutor = AsyncQueryExecutorSupplier.GetAsyncQueryExecutor(Services, Items);
+        }
+
+        var mustRefreshData = dataSourceHasChanged
+            || (Pagination?.GetHashCode() != _lastRefreshedPaginationStateHash);
+
+        // We don't want to trigger the first data load until we've collected the initial set of columns,
+        // because they might perform some action like setting the default sort order, so it would be wasteful
+        // to have to re-query immediately
+        return (_columns.Count > 0 && mustRefreshData) ? RefreshDataCoreAsync() : Task.CompletedTask;
+    }
+
+    /// <inheritdoc />
+    protected override async Task OnAfterRenderAsync(bool firstRender)
+    {
+        if (firstRender)
+        {
+            _jsModule = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/Microsoft.AspNetCore.Components.QuickGrid/QuickGrid.razor.js");
+            _jsEventDisposable = await _jsModule.InvokeAsync<IJSObjectReference>("init", _tableReference);
+        }
+
+        if (_checkColumnOptionsPosition && _displayOptionsForColumn is not null)
+        {
+            _checkColumnOptionsPosition = false;
+            _ = _jsModule?.InvokeVoidAsync("checkColumnOptionsPosition", _tableReference).AsTask();
+        }
+    }
+
+    // Invoked by descendant columns at a special time during rendering
+    internal void AddColumn(ColumnBase<TGridItem> column, SortDirection? initialSortDirection, bool isDefaultSortColumn)
+    {
+        if (_collectingColumns)
+        {
+            _columns.Add(column);
+
+            if (isDefaultSortColumn && _sortByColumn is null && initialSortDirection.HasValue)
+            {
+                _sortByColumn = column;
+                _sortByAscending = initialSortDirection.Value != SortDirection.Descending;
+            }
+        }
+    }
+
+    private void StartCollectingColumns()
+    {
+        _columns.Clear();
+        _collectingColumns = true;
+    }
+
+    private void FinishCollectingColumns()
+    {
+        _collectingColumns = false;
+    }
+
+    /// <summary>
+    /// Sets the grid's current sort column to the specified <paramref name="column"/>.
+    /// </summary>
+    /// <param name="column">The column that defines the new sort order.</param>
+    /// <param name="direction">The direction of sorting. If the value is <see cref="SortDirection.Auto"/>, then it will toggle the direction on each call.</param>
+    /// <returns>A <see cref="Task"/> representing the completion of the operation.</returns>
+    public Task SortByColumnAsync(ColumnBase<TGridItem> column, SortDirection direction = SortDirection.Auto)
+    {
+        _sortByAscending = direction switch
+        {
+            SortDirection.Ascending => true,
+            SortDirection.Descending => false,
+            SortDirection.Auto => _sortByColumn != column || !_sortByAscending,
+            _ => throw new NotSupportedException($"Unknown sort direction {direction}"),
+        };
+
+        _sortByColumn = column;
+
+        StateHasChanged(); // We want to see the updated sort order in the header, even before the data query is completed
+        return RefreshDataAsync();
+    }
+
+    /// <summary>
+    /// Displays the <see cref="ColumnBase{TGridItem}.ColumnOptions"/> UI for the specified column, closing any other column
+    /// options UI that was previously displayed.
+    /// </summary>
+    /// <param name="column">The column whose options are to be displayed, if any are available.</param>
+    public Task ShowColumnOptionsAsync(ColumnBase<TGridItem> column)
+    {
+        _displayOptionsForColumn = column;
+        _checkColumnOptionsPosition = true; // Triggers a call to JS to position the options element, apply autofocus, and any other setup
+        StateHasChanged();
+        return Task.CompletedTask;
+    }
+
+    /// <summary>
+    /// Instructs the grid to re-fetch and render the current data from the supplied data source
+    /// (either <see cref="Items"/> or <see cref="ItemsProvider"/>).
+    /// </summary>
+    /// <returns>A <see cref="Task"/> that represents the completion of the operation.</returns>
+    public async Task RefreshDataAsync()
+    {
+        await RefreshDataCoreAsync();
+        StateHasChanged();
+    }
+
+    // Same as RefreshDataAsync, except without forcing a re-render. We use this from OnParametersSetAsync
+    // because in that case there's going to be a re-render anyway.
+    private async Task RefreshDataCoreAsync()
+    {
+        // Move into a "loading" state, cancelling any earlier-but-still-pending load
+        _pendingDataLoadCancellationTokenSource?.Cancel();
+        var thisLoadCts = _pendingDataLoadCancellationTokenSource = new CancellationTokenSource();
+
+        if (_virtualizeComponent is not null)
+        {
+            // If we're using Virtualize, we have to go through its RefreshDataAsync API otherwise:
+            // (1) It won't know to update its own internal state if the provider output has changed
+            // (2) We won't know what slice of data to query for
+            await _virtualizeComponent.RefreshDataAsync();
+            _pendingDataLoadCancellationTokenSource = null;
+        }
+        else
+        {
+            // If we're not using Virtualize, we build and execute a request against the items provider directly
+            _lastRefreshedPaginationStateHash = Pagination?.GetHashCode();
+            var startIndex = Pagination is null ? 0 : (Pagination.CurrentPageIndex * Pagination.ItemsPerPage);
+            var request = new GridItemsProviderRequest<TGridItem>(
+                startIndex, Pagination?.ItemsPerPage, _sortByColumn, _sortByAscending, thisLoadCts.Token);
+            var result = await ResolveItemsRequestAsync(request);
+            if (!thisLoadCts.IsCancellationRequested)
+            {
+                _currentNonVirtualizedViewItems = result.Items;
+                _ariaBodyRowCount = _currentNonVirtualizedViewItems.Count;
+                Pagination?.SetTotalItemCountAsync(result.TotalItemCount);
+                _pendingDataLoadCancellationTokenSource = null;
+            }
+        }
+    }
+
+    // Gets called both by RefreshDataCoreAsync and directly by the Virtualize child component during scrolling
+    private async ValueTask<ItemsProviderResult<(int, TGridItem)>> ProvideVirtualizedItems(ItemsProviderRequest request)
+    {
+        _lastRefreshedPaginationStateHash = Pagination?.GetHashCode();
+
+        // Debounce the requests. This eliminates a lot of redundant queries at the cost of slight lag after interactions.
+        // TODO: Consider making this configurable, or smarter (e.g., doesn't delay on first call in a batch, then the amount
+        // of delay increases if you rapidly issue repeated requests, such as when scrolling a long way)
+        await Task.Delay(100);
+        if (request.CancellationToken.IsCancellationRequested)
+        {
+            return default;
+        }
+
+        // Combine the query parameters from Virtualize with the ones from PaginationState
+        var startIndex = request.StartIndex;
+        var count = request.Count;
+        if (Pagination is not null)
+        {
+            startIndex += Pagination.CurrentPageIndex * Pagination.ItemsPerPage;
+            count = Math.Min(request.Count, Pagination.ItemsPerPage - request.StartIndex);
+        }
+
+        var providerRequest = new GridItemsProviderRequest<TGridItem>(
+            startIndex, count, _sortByColumn, _sortByAscending, request.CancellationToken);
+        var providerResult = await ResolveItemsRequestAsync(providerRequest);
+
+        if (!request.CancellationToken.IsCancellationRequested)
+        {
+            // ARIA's rowcount is part of the UI, so it should reflect what the human user regards as the number of rows in the table,
+            // not the number of physical <tr> elements. For virtualization this means what's in the entire scrollable range, not just
+            // the current viewport. In the case where you're also paginating then it means what's conceptually on the current page.
+            // TODO: This currently assumes we always want to expand the last page to have ItemsPerPage rows, but the experience might
+            //       be better if we let the last page only be as big as its number of actual rows.
+            _ariaBodyRowCount = Pagination is null ? providerResult.TotalItemCount : Pagination.ItemsPerPage;
+
+            Pagination?.SetTotalItemCountAsync(providerResult.TotalItemCount);
+
+            // We're supplying the row index along with each row's data because we need it for aria-rowindex, and we have to account for
+            // the virtualized start index. It might be more performant just to have some _latestQueryRowStartIndex field, but we'd have
+            // to make sure it doesn't get out of sync with the rows being rendered.
+            return new ItemsProviderResult<(int, TGridItem)>(
+                 items: providerResult.Items.Select((x, i) => ValueTuple.Create(i + request.StartIndex + 2, x)),
+                 totalItemCount: _ariaBodyRowCount);
+        }
+
+        return default;
+    }
+
+    // Normalizes all the different ways of configuring a data source so they have common GridItemsProvider-shaped API
+    private async ValueTask<GridItemsProviderResult<TGridItem>> ResolveItemsRequestAsync(GridItemsProviderRequest<TGridItem> request)
+    {
+        if (ItemsProvider is not null)
+        {
+            return await ItemsProvider(request);
+        }
+        else if (Items is not null)
+        {
+            var totalItemCount = _asyncQueryExecutor is null ? Items.Count() : await _asyncQueryExecutor.CountAsync(Items);
+            var result = request.ApplySorting(Items).Skip(request.StartIndex);
+            if (request.Count.HasValue)
+            {
+                result = result.Take(request.Count.Value);
+            }
+            var resultArray = _asyncQueryExecutor is null ? result.ToArray() : await _asyncQueryExecutor.ToArrayAsync(result);
+            return GridItemsProviderResult.From(resultArray, totalItemCount);
+        }
+        else
+        {
+            return GridItemsProviderResult.From(Array.Empty<TGridItem>(), 0);
+        }
+    }
+
+    private string AriaSortValue(ColumnBase<TGridItem> column)
+        => _sortByColumn == column
+            ? (_sortByAscending ? "ascending" : "descending")
+            : "none";
+
+    private string? ColumnHeaderClass(ColumnBase<TGridItem> column)
+        => _sortByColumn == column
+        ? $"{ColumnClass(column)} {(_sortByAscending ? "col-sort-asc" : "col-sort-desc")}"
+        : ColumnClass(column);
+
+    private string GridClass()
+        => $"quickgrid {Class} {(_pendingDataLoadCancellationTokenSource is null ? null : "loading")}";
+
+    private static string? ColumnClass(ColumnBase<TGridItem> column) => column.Align switch
+    {
+        Align.Start => $"col-justify-start {column.Class}",
+        Align.Center => $"col-justify-center {column.Class}",
+        Align.End => $"col-justify-end {column.Class}",
+        Align.Left => $"col-justify-left {column.Class}",
+        Align.Right => $"col-justify-right {column.Class}",
+        _ => column.Class,
+    };
+
+    /// <inheritdoc />
+    public async ValueTask DisposeAsync()
+    {
+        _currentPageItemsChanged.Dispose();
+
+        try
+        {
+            if (_jsEventDisposable is not null)
+            {
+                await _jsEventDisposable.InvokeVoidAsync("stop");
+                await _jsEventDisposable.DisposeAsync();
+            }
+
+            if (_jsModule is not null)
+            {
+                await _jsModule.DisposeAsync();
+            }
+        }
+        catch (JSDisconnectedException)
+        {
+            // The JS side may routinely be gone already if the reason we're disposing is that
+            // the client disconnected. This is not an error.
+        }
+    }
+
+    private void CloseColumnOptions()
+    {
+        _displayOptionsForColumn = null;
+    }
+}

+ 97 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor.css

@@ -0,0 +1,97 @@
+/*
+    TODO: Don't actually used scoped CSS for QuickGrid.razor, because it's so perf-critical we don't even want to
+    add the extra attributes on all the tr/td elements. We can hook everything onto the table.quickgrid class,
+    remembering to be specific about matching closest tr/td only, not any child tables.
+*/
+
+th {
+    position: relative; /* So that col-options appears next to it */
+}
+
+.col-header-content {
+    /* We want the th elements to be display:flex, but they also have to be display:table-cell to avoid breaking the layout.
+       So .col-header-content is an immediate child with display:flex. */
+    position: relative;
+    display: flex;
+    align-items: center;
+}
+
+/* Deep to make it easy for people adding a sort-indicator element in a custom HeaderTemplate */
+th ::deep .sort-indicator {
+    /* Preset width so the column width doen't change as the sort indicator appears/disappears */
+    width: 1rem;
+    height: 1rem;
+    align-self: center;
+    text-align: center;
+}
+
+.col-sort-desc ::deep .sort-indicator, .col-sort-asc ::deep .sort-indicator {
+    background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M 2 3.25 L 12 20.75 L 22 3.25 L 12 10 z" /></svg>');
+}
+
+.col-sort-asc ::deep .sort-indicator {
+    transform: scaleY(-1);
+}
+
+/* Deep to make it easy for people adding a col-options-button element in a custom HeaderTemplate */
+th ::deep .col-options-button {
+    border: none;
+    padding: 0; /* So that even if the text on the button is wide, it gets properly centered */
+    width: 1rem;
+    align-self: stretch;
+    background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="1.5 0 21 24" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16" /></svg>') center center / 1rem no-repeat;
+}
+
+.col-options {
+    position: absolute;
+    background: white;
+    border: 1px solid silver;
+    left: 0;
+    padding: 1rem;
+    z-index: 1;
+}
+
+.col-justify-end .col-options {
+    left: unset;
+    right: 0;
+}
+
+.col-width-draghandle {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    right: 0rem;
+    cursor: ew-resize;
+}
+
+    .col-width-draghandle:after {
+        content: ' ';
+        position: absolute;
+        top: 0;
+        bottom: 0;
+        border-left: 1px solid black;
+    }
+
+td.col-justify-center {
+    text-align: center;
+}
+
+td.col-justify-end {
+    text-align: right;
+}
+
+/* Unfortunately we can't use the :dir pseudoselector due to lack of browser support. Instead we have to rely on
+    the developer setting <html dir="rtl"> to detect if we're in RTL mode. */
+html[dir=rtl] td.col-justify-end {
+    text-align: left;
+}
+
+html[dir=rtl] .col-options {
+    left: unset;
+    right: 0;
+}
+
+html[dir=rtl] .col-justify-end .col-options {
+    right: unset;
+    left: 0;
+}

+ 52 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor.js

@@ -0,0 +1,52 @@
+export function init(tableElement) {
+
+    const bodyClickHandler = event => {
+        const columnOptionsElement = tableElement.tHead.querySelector('.col-options');
+        if (columnOptionsElement && event.composedPath().indexOf(columnOptionsElement) < 0) {
+            tableElement.dispatchEvent(new CustomEvent('closecolumnoptions', { bubbles: true }));
+        }
+    };
+    const keyDownHandler = event => {
+        const columnOptionsElement = tableElement.tHead.querySelector('.col-options');
+        if (columnOptionsElement && event.key === "Escape") {
+            tableElement.dispatchEvent(new CustomEvent('closecolumnoptions', { bubbles: true }));
+        }
+    };
+
+    document.body.addEventListener('click', bodyClickHandler);
+    document.body.addEventListener('mousedown', bodyClickHandler); // Otherwise it seems strange that it doesn't go away until you release the mouse button
+    document.body.addEventListener('keydown', keyDownHandler);
+
+    return {
+        stop: () => {
+            document.body.removeEventListener('click', bodyClickHandler);
+            document.body.removeEventListener('mousedown', bodyClickHandler);
+            document.body.removeEventListener('keydown', keyDownHandler);
+        }
+    };
+}
+
+export function checkColumnOptionsPosition(tableElement) {
+    const colOptions = tableElement.tHead && tableElement.tHead.querySelector('.col-options'); // Only match within *our* thead, not nested tables
+    if (colOptions) {
+        // We want the options popup to be positioned over the grid, not overflowing on either side, because it's possible that
+        // beyond either side is off-screen or outside the scroll range of an ancestor
+        const gridRect = tableElement.getBoundingClientRect();
+        const optionsRect = colOptions.getBoundingClientRect();
+        const leftOverhang = Math.max(0, gridRect.left - optionsRect.left);
+        const rightOverhang = Math.max(0, optionsRect.right - gridRect.right);
+        if (leftOverhang || rightOverhang) {
+            // In the unlikely event that it overhangs both sides, we'll center it
+            const applyOffset = leftOverhang && rightOverhang ? (leftOverhang - rightOverhang) / 2 : (leftOverhang - rightOverhang);
+            colOptions.style.transform = `translateX(${applyOffset}px)`;
+        }
+
+        colOptions.scrollIntoViewIfNeeded();
+
+        const autoFocusElem = colOptions.querySelector('[autofocus]');
+        if (autoFocusElem) {
+            autoFocusElem.focus();
+        }
+    }
+}
+

+ 27 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/SortDirection.cs

@@ -0,0 +1,27 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components.QuickGrid;
+
+/// <summary>
+/// Describes the direction in which a <see cref="QuickGrid{TGridItem}"/> column is sorted.
+/// </summary>
+public enum SortDirection
+{
+    /// <summary>
+    /// Automatic sort order. When used with <see cref="QuickGrid{TGridItem}.SortByColumnAsync(ColumnBase{TGridItem}, SortDirection)"/>,
+    /// the sort order will automatically toggle between <see cref="Ascending"/> and <see cref="Descending"/> on successive calls, and
+    /// resets to <see cref="Ascending"/> whenever the specified column is changed.
+    /// </summary>
+    Auto,
+
+    /// <summary>
+    /// Ascending order.
+    /// </summary>
+    Ascending,
+
+    /// <summary>
+    /// Descending order.
+    /// </summary>
+    Descending,
+}

+ 81 - 0
src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Themes/Default.css

@@ -0,0 +1,81 @@
+.quickgrid[theme=default] {
+    --col-gap: 1rem;
+}
+
+.quickgrid[theme=default] .col-header-content {
+    padding-right: var(--col-gap);
+}
+
+.quickgrid[theme=default] > thead > tr > th {
+    font-weight: normal;
+}
+
+.quickgrid[theme=default].loading > tbody {
+    opacity: 0.25;
+    transition: opacity linear 100ms;
+    transition-delay: 25ms; /* Don't want flicker if the queries are resolving almost immediately */
+}
+
+.quickgrid[theme=default] .col-title {
+    padding: 0.1rem 0.4rem;
+}
+
+    .quickgrid[theme=default] > tbody > tr > td {
+        padding: 0.1rem calc(0.4rem + var(--col-gap)) 0.1rem 0.4rem;
+    }
+
+.quickgrid[theme=default] .col-title {
+    gap: 0.4rem; /* Separate the sort indicator from title text */
+    font-weight: bold;
+}
+
+.quickgrid[theme=default] .sort-indicator {
+    opacity: 0.5;
+}
+
+.quickgrid[theme=default] .col-options-button {
+    width: 1.5rem;
+}
+
+.quickgrid[theme=default] button.col-title:hover, .quickgrid[theme=default] .col-options-button:hover {
+    background-color: rgba(128, 128, 128, 0.2);
+}
+
+.quickgrid[theme=default] button.col-title:active, .quickgrid[theme=default] .col-options-button:active {
+    background-color: rgba(128, 128, 128, 0.5);
+}
+
+    .quickgrid[theme=default] > thead .col-width-draghandle {
+        width: 1rem;
+        right: calc(var(--col-gap)/2 - 0.5rem);
+    }
+
+    .quickgrid[theme=default] > thead .col-width-draghandle:hover {
+        background: rgba(128, 128, 128, 0.2);
+    }
+
+    .quickgrid[theme=default] > thead .col-width-draghandle:active {
+        background: rgba(128, 128, 128, 0.4);
+    }
+
+    .quickgrid[theme=default] > thead .col-width-draghandle:hover:after, .quickgrid[theme=default] > thead .col-width-draghandle:active:after {
+        border-color: black;
+    }
+
+    .quickgrid[theme=default] > thead .col-width-draghandle:after {
+        border-color: #ccc;
+        left: 0.5rem;
+        top: 5px;
+        bottom: 5px;
+    }
+
+.quickgrid[theme=default] .col-options {
+    box-shadow: 0 3px 8px 1px #aaa;
+    border-color: #ddd;
+    border-radius: 0.3rem;
+}
+
+.quickgrid[theme=default] > tbody > tr > td.grid-cell-placeholder:after {
+    content: '\2026';
+    opacity: 0.75;
+}

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
src/Components/Web.JS/dist/Release/blazor.server.js


+ 119 - 0
src/Components/test/E2ETest/Tests/QuickGridTest.cs

@@ -0,0 +1,119 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using BasicTestApp;
+using BasicTestApp.QuickGridTest;
+using Microsoft.AspNetCore.Components.E2ETest;
+using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
+using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
+using Microsoft.AspNetCore.E2ETesting;
+using OpenQA.Selenium;
+using Xunit.Abstractions;
+
+namespace Microsoft.AspNetCore.Components.E2ETests.Tests;
+
+public class QuickGridTest : ServerTestBase<ToggleExecutionModeServerFixture<Program>>
+{
+    protected IWebElement app;
+
+    public QuickGridTest(
+        BrowserFixture browserFixture,
+        ToggleExecutionModeServerFixture<Program> serverFixture,
+        ITestOutputHelper output)
+        : base(browserFixture, serverFixture, output)
+    {
+    }
+
+    protected override void InitializeAsyncCore()
+    {
+        Navigate(ServerPathBase);
+        app = Browser.MountTestComponent<SampleQuickGridComponent>();
+    }
+
+    [Fact]
+    public void CanColumnSortByInt()
+    {
+        var grid = app.FindElement(By.CssSelector("#grid > table"));
+        var idColumnSortButton = grid.FindElement(By.CssSelector("thead > tr > th:nth-child(1) > div > button"));
+
+        // Click twice to sort by descending
+        idColumnSortButton.Click();
+        idColumnSortButton.Click();
+
+        var firstRow = grid.FindElement(By.CssSelector("tbody > tr:nth-child(1)"));
+
+        //Compare first row to expected result
+        Assert.Equal("12381", firstRow.FindElement(By.CssSelector("td:nth-child(1)")).Text);
+        Assert.Equal("Matti", firstRow.FindElement(By.CssSelector("td:nth-child(2)")).Text);
+        Assert.Equal("Karttunen", firstRow.FindElement(By.CssSelector("td:nth-child(3)")).Text);
+        Assert.Equal("1981-06-04", firstRow.FindElement(By.CssSelector("td:nth-child(4)")).Text);
+        Assert.Equal("41", firstRow.FindElement(By.CssSelector("td:nth-child(5)")).Text);
+    }
+
+    [Fact]
+    public void CanColumnSortByString()
+    {
+        var grid = app.FindElement(By.CssSelector("#grid > table"));
+        var firstNameColumnSortButton = grid.FindElement(By.CssSelector("thead > tr > th:nth-child(2) > div > button.col-title"));
+
+        // Click twice to sort by descending
+        firstNameColumnSortButton.Click();
+        firstNameColumnSortButton.Click();
+
+        var firstRow = grid.FindElement(By.CssSelector("tbody > tr:nth-child(1)"));
+
+        //Compare first row to expected result
+        Assert.Equal("12379", firstRow.FindElement(By.CssSelector("td:nth-child(1)")).Text);
+        Assert.Equal("Zbyszek", firstRow.FindElement(By.CssSelector("td:nth-child(2)")).Text);
+        Assert.Equal("Piestrzeniewicz", firstRow.FindElement(By.CssSelector("td:nth-child(3)")).Text);
+        Assert.Equal("1981-04-02", firstRow.FindElement(By.CssSelector("td:nth-child(4)")).Text);
+        Assert.Equal("41", firstRow.FindElement(By.CssSelector("td:nth-child(5)")).Text);
+    }
+
+    [Fact]
+    public void CanColumnSortByDateOnly()
+    {
+        var grid = app.FindElement(By.CssSelector("#grid > table"));
+        var birthDateColumnSortButton = grid.FindElement(By.CssSelector("thead > tr > th:nth-child(4) > div > button"));
+
+        // Click twice to sort by descending
+        birthDateColumnSortButton.Click();
+        birthDateColumnSortButton.Click();
+
+        var firstRow = grid.FindElement(By.CssSelector("tbody > tr:nth-child(1)"));
+
+        //Compare first row to expected result
+        Assert.Equal("12364", firstRow.FindElement(By.CssSelector("td:nth-child(1)")).Text);
+        Assert.Equal("Paolo", firstRow.FindElement(By.CssSelector("td:nth-child(2)")).Text);
+        Assert.Equal("Accorti", firstRow.FindElement(By.CssSelector("td:nth-child(3)")).Text);
+        Assert.Equal("2018-05-18", firstRow.FindElement(By.CssSelector("td:nth-child(4)")).Text);
+        Assert.Equal("4", firstRow.FindElement(By.CssSelector("td:nth-child(5)")).Text);
+    }
+
+    [Fact]
+    public void PaginatorCorrectItemsPerPage()
+    {
+        var grid = app.FindElement(By.ClassName("quickgrid"));
+        var rowCount = grid.FindElements(By.CssSelector("tbody > tr")).Count;
+        Assert.Equal(10, rowCount);
+
+        app.FindElement(By.ClassName("go-next")).Click();
+
+        rowCount = grid.FindElements(By.CssSelector("tbody > tr")).Count;
+        Assert.Equal(10, rowCount);
+    }
+
+    [Fact]
+    public void PaginatorDisplaysCorrectItemCount()
+    {
+        var paginator = app.FindElement(By.ClassName("paginator"));
+
+        var paginatorCount = paginator.FindElement(By.CssSelector("div > strong")).Text;
+        var currentPageNumber = paginator.FindElement(By.CssSelector("nav > div > strong:nth-child(1)")).Text;
+        var totalPageNumber = paginator.FindElement(By.CssSelector("nav > div > strong:nth-child(2)")).Text;
+
+        Assert.Equal("43", paginatorCount);
+        Assert.Equal("1", currentPageNumber);
+        Assert.Equal("5", totalPageNumber);
+    }
+}

+ 1 - 0
src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj

@@ -24,6 +24,7 @@
     <Reference Include="Microsoft.AspNetCore.Components.WebAssembly" />
     <Reference Include="Microsoft.AspNetCore.Components.CustomElements" />
     <Reference Include="Microsoft.AspNetCore.Components.Authorization" />
+    <Reference Include="Microsoft.AspNetCore.Components.QuickGrid" />
     <Reference Include="Microsoft.AspNetCore.SignalR.Client" />
     <Reference Include="Microsoft.Extensions.Logging.Configuration" />
     <Reference Include="Newtonsoft.Json" />

+ 1 - 0
src/Components/test/testassets/BasicTestApp/Index.razor

@@ -80,6 +80,7 @@
         <option value="BasicTestApp.PropertiesChangedHandlerParent">Parent component that changes parameters on child</option>
         <option value="@GetTestServerProjectComponent("Components.TestServer.ProtectedBrowserStorageUsageComponent")">Protected browser storage usage</option>
         <option value="@GetTestServerProjectComponent("Components.TestServer.ProtectedBrowserStorageInjectionComponent")">Protected browser storage injection</option>
+        <option value="BasicTestApp.QuickGridTest.SampleQuickGridComponent">QuickGrid Example</option>
         <option value="BasicTestApp.RazorTemplates">Razor Templates</option>
         <option value="BasicTestApp.Reconnection.ReconnectionComponent">Reconnection server-side blazor</option>
         <option value="BasicTestApp.RedTextComponent">Red text</option>

+ 93 - 0
src/Components/test/testassets/BasicTestApp/QuickGridTest/SampleQuickGridComponent.razor

@@ -0,0 +1,93 @@
+@using Microsoft.AspNetCore.Components.QuickGrid
+
+<h3>Sample QuickGrid Component</h3>
+
+<div id="grid">
+    <QuickGrid Items="@FilteredPeople" Pagination="@pagination">
+        <PropertyColumn Property="@(p => p.PersonId)" Sortable="true" />
+        <PropertyColumn Property="@(p => p.firstName)" Sortable="true">
+                <ColumnOptions>
+                    <div class="search-box">
+                        <input type="search" autofocus @bind="firstNameFilter" @bind:event="oninput" placeholder="First name..." />
+                    </div>
+                </ColumnOptions>
+         </PropertyColumn>
+        <PropertyColumn Property="@(p => p.lastName)" Sortable="true" />
+        <PropertyColumn Property="@(p => p.BirthDate)" Format="yyyy-MM-dd" Sortable="true" />
+        <PropertyColumn Title="Age in years" Property="@(p => ComputeAge(p.BirthDate))" Sortable="true"/>
+    </QuickGrid>
+</div>
+<Paginator State="@pagination" />
+
+@code {
+    record Person(int PersonId, string firstName, string lastName, DateOnly BirthDate);
+    PaginationState pagination = new PaginationState { ItemsPerPage = 10 };
+    string firstNameFilter;
+
+    int ComputeAge(DateOnly birthDate)
+        => DateTime.Now.Year - birthDate.Year - (birthDate.DayOfYear < DateTime.Now.DayOfYear ? 0 : 1);
+
+    IQueryable<Person> FilteredPeople
+    {
+        get
+        {
+            var result = people;
+
+            if (!string.IsNullOrEmpty(firstNameFilter))
+            {
+                result = result.Where(p => p.firstName.Contains(firstNameFilter, StringComparison.CurrentCultureIgnoreCase));
+            }
+
+            return result;
+        }
+    }
+
+    // Changes to this list affect the E2E tests.
+    // If you change this list, you must also update the E2E tests.
+    IQueryable<Person> people = new[]
+    {
+        new Person(11203, "Julie", "Smith", new DateOnly(1958, 10, 10)),
+        new Person(11205, "Nur", "Sari", new DateOnly(1922, 4, 27)),
+        new Person(11898, "Jose", "Hernandez", new DateOnly(2011, 5, 3)),
+        new Person(10895, "Jean", "Martin", new DateOnly(1985, 3, 16)),
+        new Person(10944, "António", "Langa", new DateOnly(1991, 12, 1)),
+        new Person(12130, "Kenji", "Sato", new DateOnly(2004, 1, 9)),
+        new Person(12238, "Sven", "Ottlieb", new DateOnly(1973, 11, 15)),
+        new Person(12345, "Liu", "Wang", new DateOnly(1999, 6, 30)),
+        new Person(12346, "Giovanni", "Rovelli", new DateOnly(2000, 7, 31)),
+        new Person(12347, "Eduardo", "Martins", new DateOnly(2001, 8, 1)),
+        new Person(12348, "Martín", "Sommer", new DateOnly(2002, 9, 2)),
+        new Person(12349, "Victoria", "Ashworth", new DateOnly(2003, 10, 3)),
+        new Person(12350, "Hannah", "Moos", new DateOnly(2004, 11, 4)),
+        new Person(12351, "Palle", "Ibsen", new DateOnly(2005, 12, 5)),
+        new Person(12352, "Lúcia", "Carvalho", new DateOnly(2006, 1, 6)),
+        new Person(12353, "Horst", "Kloss", new DateOnly(2007, 2, 7)),
+        new Person(12354, "Sergio", "Gutiérrez", new DateOnly(2008, 3, 8)),
+        new Person(12355, "Janine", "Labrune", new DateOnly(2009, 4, 9)),
+        new Person(12356, "Ann", "Devon", new DateOnly(2010, 5, 10)),
+        new Person(12357, "Roland", "Mendel", new DateOnly(2011, 6, 11)),
+        new Person(12358, "Aria", "Cruz", new DateOnly(2012, 7, 12)),
+        new Person(12359, "Diego", "Roel", new DateOnly(2001, 8, 13)),
+        new Person(12360, "Martine", "Rancé", new DateOnly(2005, 9, 14)),
+        new Person(12361, "Maria", "Larsson", new DateOnly(1998, 10, 15)),
+        new Person(12362, "Peter", "Lewis", new DateOnly(2016, 11, 16)),
+        new Person(12363, "Carine", "Schmitt", new DateOnly(2017, 12, 13)),
+        new Person(12364, "Paolo", "Accorti", new DateOnly(2018, 5, 18)),
+        new Person(12365, "Lino", "Rodriguez", new DateOnly(1980, 2, 19)),
+        new Person(12367, "Bernardo", "Batista", new DateOnly(1979, 4, 21)),
+        new Person(12368, "Lúcia", "Carvalho", new DateOnly(1976, 5, 22)),
+        new Person(12369, "Guillermo", "Fernández", new DateOnly(1983, 6, 23)),
+        new Person(12370, "Georg", "Pipps", new DateOnly(1982, 7, 24)),
+        new Person(12371, "Mario", "Pontes", new DateOnly(1981, 8, 25)),
+        new Person(12372, "Anabela", "Camino", new DateOnly(1980, 9, 26)),
+        new Person(12380, "Karl", "Jablonski", new DateOnly(1981, 5, 3)),
+        new Person(12381, "Matti", "Karttunen", new DateOnly(1981, 6, 4)),
+        new Person(12373, "Helvetius", "Nagy", new DateOnly(1980, 10, 27)),
+        new Person(12374, "Rita", "Müller", new DateOnly(1980, 11, 28)),
+        new Person(12375, "Pirkko", "Koskitalo", new DateOnly(1980, 12, 29)),
+        new Person(12376, "Paula", "Parente", new DateOnly(1981, 1, 30)),
+        new Person(12377, "Karl", "Jablonski", new DateOnly(1981, 2, 10)),
+        new Person(12378, "Matti", "Karttunen", new DateOnly(1981, 3, 1)),
+        new Person(12379, "Zbyszek", "Piestrzeniewicz", new DateOnly(1981, 4, 2)),
+    }.AsQueryable();
+}

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff