|
An excerpt from Blazor in Action by Chris Sainty This article covers § Using templates to define specific regions of UI § Enhancing templates with generics Read it if you’re a full-stack C# and .NET web developer who wants to learn more about Blazor. |
Take 25% off Blazor in Action by entering fccsainty into the discount code box at checkout at manning.com.
In this article, we’re going to take reusability to the next level. We’ll learn how to leverage templates and generics to make the ultimate reusable components. To give us a practical example, we’ll be enhancing the home page of a website called Blazing Trails with a component that allows the user to toggle the layout between a grid and a table (figure 1).
Figure 1 Shows the home page of Blazing Trails with the final ViewSwitcher
component we’ll be building during this chapter. The component allows the user to toggle between a grid view and a table view of the available trails.
Once we’ve built our ViewSwitcher
component, we’ll finish the chapter by learning about Razor class libraries (RCL). RCLs allow us to bundle up any common components and share them across applications. This can be done via a project reference, or RCLs can be packed and shipped via NuGet—just like any other .NET library.
Defining templates
Templates are a powerful tool when building reusable components. They allow us to specify chunks of markup to be provided by the consumer, which we can then output wherever we wish. We have already used some basic templating when we built the FormSection
and FormFieldSet
components in the previous chapters. In those components, we defined a parameter with a type of RenderFragment
and a name of ChildContent
.
[Parameter] public RenderFragment ChildContent { get; set; }
This is a special convention. Defining a parameter with this specific name and type allows us to capture whatever markup has been specified between the start and end tags of the component. However, for our ViewSwitcher
component, we’re going to need something a little more advanced.
The ViewSwitcher
component allows the user to toggle between a card view and a table view of the available trails. To make this component as reusable as possible, we don’t want to hardcode the markup for either the grid or the table view. Instead, we want to define these as templates that allow the consumer of the component to define these areas for themselves.
Let’s look at the initial markup for the ViewSwitcher
component. For now, we will create this component in the Client project under Features > Home > Shared. See the following listing.
Listing 1 ViewSwitcher.razor: Initial code
<div> <div class="mb-3 text-right"> <div class="btn-group"> <button @onclick="@(() => [CA]_mode = ViewMode.Grid)" title="Grid View" type="button" [CA]class="btn @(_mode == ViewMode.Grid ? "btn-secondary" [CA]: "btn-outline-secondary")"> #A <i class="bi bi-grid-fill"></i> </button> <button @onclick="@(() => [CA]_mode = ViewMode.Table)" title="Table View" type="button" [CA]class="btn @(_mode == ViewMode.Table ? "btn-secondary" [CA]: "btn-outline-secondary")"> #A <i class="bi bi-table"></i> </button> </div> </div> @if (_mode == ViewMode.Grid) { @GridTemplate #B } else if (_mode == ViewMode.Table) { @TableTemplate #C } </div> @code { private ViewMode _mode = ViewMode.Grid; [Parameter, EditorRequired] public RenderFragment GridTemplate { get; set; } [CA]= default!; #D [Parameter, EditorRequired] public RenderFragment TableTemplate { get; set; } [CA]= default!; #E private enum ViewMode { Grid, Table } #F }
#A The two buttons allow the user to toggle between the two views offered by the component.
#B Specifies where the markup provided by the consumer for the GridTemplate should be output
#C Specifies where the markup provided by the consumer for the TableTemplate should be output
#D Defines the GridTemplate parameter
#E Defines the TableTemplate parameter
#F The enum defines the two view modes and avoids using magic strings.
The component starts with some markup that renders two buttons. These buttons allow the user to toggle between the two views offered by the component. To do this, we’re setting the value of _mode
to either Grid
or Table
. The _mode
field is defined in the code block and defaulted to Grid
. The buttons also use a simple expression to apply different CSS classes to highlight which of the modes is currently active.
Depending on which mode is active, the component renders one of two templates defined in the code block, GridTemplate
or TableTemplate
. A template is just a parameter with a type of RenderFragment
.
We’re also going to add some styling for the component. We’ll add a new file called ViewSwitcher.razor.scss and add the following code.
Listing 2 ViewSwitcher.razor.scss
.grid { #A display: grid; grid-template-columns: repeat(3, 288px); grid-column-gap: 123px; grid-row-gap: 75px; } table { #B width: 100%; margin-bottom: 1rem; color: #212529; border-collapse: collapse; ::deep th, ::deep td { padding: .75rem; vertical-align: middle; } ::deep thead tr th { border-bottom: 4px solid var(--brand); border-top: none; } ::deep tbody tr:nth-of-type(odd) { background-color: rgba(0,0,0,.05); } }
#A This class defines the styling for the grid view.
#B This class defines the styling for the table view.
That is all we need for now. Let’s jump over to HomePage.razor
and implement ViewSwitcher
. Then we can run the app and see what everything looks like. We’re going to replace the current code that renders the grid of trails with the code shown in the following listing.
Listing 3 HomePage.razor: Using ViewSwitcher
<ViewSwitcher> <GridTemplate> #A <div class="grid"> @foreach (var trail in _trails) { <TrailCard Trail="trail" OnSelected="HandleTrailSelected" /> } </div> </GridTemplate> <TableTemplate> #B <table class="table table-striped"> <thead> <tr> <th>Name</th> <th>Location</th> <th>Length</th> <th>Time</th> <th></th> </tr> </thead> <tbody> @foreach (var trail in _trails) { <tr> <th scope="col">@trail.Name</th> <td>@trail.Location</td> <td>@(trail.Length)km</td> <td>@trail.TimeFormatted</td> <td class="text-right"> <button @onclick="@(() => [CA]HandleTrailSelected(trail))" title="View" class="btn btn-primary"> <i class="bi bi-binoculars"></i> </button> <button @onclick="@(() => NavManager [CA].NavigateTo($"/edit-trail/{trail.Id}"))" title="Edit" [CA]class="btn btn-outline-secondary"> <i class="bi bi-pencil"></i> </button> </td> </tr> } </tbody> </table> </TableTemplate> </ViewSwitcher>
#A Defines the markup for the GridTemplate
#B Defines the markup for the TableTemplate
To specify the markup for a particular template, we define child elements that match the name of the parameter. In our case, that is GridTemplate
and TableTemplate
. The markup we’ve defined above for the GridTemplate
and TableTemplate
will be output by ViewSwitcher
where we specified the @GridTemplate
and @TableTemplate
expressions.
We can now run the app and see what everything looks like. Figure 2 shows a side-by-side comparison of the two views.
Figure 2 Shows the grid and table views offered by the ViewSwitcher
component
That’s great! We now have the initial version of the component in place. Next, we’re going to introduce generics to ViewSwitcher
.
Enhancing templates with generics
Currently, our component is working well. It allows us to define the markup for the table and grid views and for the user to toggle between them. However, I think we can improve things a bit. Right now, we must define a lot of markup in the HomePage
when we’re using the component. We’re defining a div with a class of .grid
around a foreach
block in the grid template. Then for the table template, we’re providing the entire markup for the table.
As we know we’re going to be displaying a grid or a table, we can bake some of the boilerplate markup into the component. Then when we use the component, we only have to specify the markup and data specific to that usage. To do this, we will introduce generics into our ViewSwitcher
component. The following listing shows the updated code.
Listing 4 ViewSwitcher.razor: Updated to use generics
@typeparam TItem #A // code omitted @if (_mode == ViewMode.Grid) { <div class="grid"> @foreach (var item in Items) { @GridTemplate(item) #B } </div> } else if (_mode == ViewMode.Table) { <table> <thead> #B <tr> #B @HeaderTemplate #B </tr> #B </thead> #B <tbody> @foreach (var item in Items) { <tr> @RowTemplate(item) #C </tr> } </tbody> </table> } // code omitted @code { private ViewMode _mode = ViewMode.Grid; [Parameter, EditorRequired] public IEnumerable<TItem> Items { get; set; } [CA]= default!; #D [Parameter, EditorRequired] public RenderFragment<TItem> GridTemplate { get; [CA]set; } = default!; #C [Parameter, EditorRequired] public RenderFragment HeaderTemplate { get; set; } [CA]= default!; #B [Parameter, EditorRequired] public RenderFragment<TItem> RowTemplate { get; [CA]set; } = default!; #C // code omitted }
#A A type parameter is specified using the typeparam directive.
#B We now only require the header cells to be specified when using the component, rather than all the markup for the head of the table.
#C Defining RenderFragments with a type parameter allows the consumer to use properties of that type when defining a template.
#D The component now accepts a list of items to be displayed.
We start by introducing a type parameter to the component. We do this using the @typeparam
directive. Once we do this, we can reference the type parameter when defining our template parameters in the code block. We’re now stating that the GridTemplate
and RowTemplate
will contain items of type TItem
. When we invoke these RenderFragments
in the markup section, we can pass in an object of type TItem
. These items are coming from the new Items
parameter we’ve created. We’ll see the benefit of this in more detail in a second, when we update the HomePage
, but by defining our template parameters with a type, we’ll be able to access properties of that type when defining the template.
Let’s go and update the HomePage
to work with the changes we’ve made to ViewSwitcher
. The updated code for HomePage.razor
is shown in the following listing.
Listing 5 HomePage.razor: Replace existing ViewSwitcher code
<ViewSwitcher Items="_trails"> #A <GridTemplate> #B <TrailCard Trail="context" [CA]OnSelected="HandleTrailSelected" /> #C </GridTemplate> <HeaderTemplate> #D <th>Name</th> <th>Location</th> <th>Length</th> <th>Time</th> <th></th> </HeaderTemplate> <RowTemplate> <th scope="col">@context.Name</th> #C <td>@context.Location</td> #C <td>@(context.Length)km</td> #C <td>@context.TimeFormatted</td> #C <td class="text-right"> <button @onclick="@(() => [CA]HandleTrailSelected(context))" title="View" [CA]class="btn btn-primary"> #C <i class="bi bi-binoculars"></i> </button> <button @onclick="@(() => [CA]NavManager.NavigateTo($"/edit-trail/{context.Id}"))" [CA]title="Edit" class="btn btn-outline-secondary"> #C <i class="bi bi-pencil"></i> </button> </td> </RowTemplate> </ViewSwitcher>
#A The list of trails is now passed into the ViewSwitcher rather than having to define foreach loops in the templates.
#B The GridTemplate is now cleaner, as we no longer need to define the grid and a foreach loop.
#C In the template that uses RenderFragment<T>, we can now access properties of the object through a variable called context. This allows loads of flexibility when building our markup.
#D The header template allows us to define the columns our table needs, but without all the boilerplate we had before.
The list of trails is now passed into the ViewSwitcher
via the Items
parameter. This means we no longer need to worry about defining foreach
loops in various templates, like before. This has tidied up the GridTemplate
a lot. We only need to define the markup for an individual item now.
As the GridTemplate
is defined as RenderFragment<T>
, we can access any properties of T
in our template. We access these via a special parameter called context
. As the TrailCard
component needs an instance of a Trail
, we can just pass context
to the Trail
parameter. The RowTemplate
shows accessing properties of T
to an even greater extent.
The other change we made was to add in a HeaderTemplate
so we could define the columns of our table without all the extra boilerplate markup we had before. As you can see, we only need to define the individual cells now. This reduces the amount of code we need to write considerably.
This is looking great, but there is one other small improvement we can make to help the readability of our code—the context
parameter. If we were scanning over a component, we would have to pause for a second to understand what context meant in this scenario. In our case, context
is a Trail
. Wouldn’t it be great if it were just called trail
instead? I think so. And the great news is, we can name it whatever we like! The following listing shows the ViewSwitcher
on the HomePage
with a renamed context parameter.
Listing 6 HomePage.razor: Rename context variable
<ViewSwitcher Items="_trails"> <GridTemplate Context="trail"> #A <TrailCard Trail="trail" [CA]OnSelected="HandleTrailSelected" /> #B </GridTemplate> <HeaderTemplate> <th>Name</th> <th>Location</th> <th>Length</th> <th>Time</th> <th></th> </HeaderTemplate> <RowTemplate Context="trail"> #A <th scope="col">@trail.Name</th> <td>@trail.Location</td> #B <td>@(trail.Length)km</td> #B <td>@trail.TimeFormatted</td> #B <td class="text-right"> <button @onclick="@(() => [CA]HandleTrailSelected(trail))" title="View" [CA]class="btn btn-primary"> #B <i class="bi bi-binoculars"></i> </button> <button @onclick="@(() => [CA]NavManager.NavigateTo($"/edit-trail/{trail.Id}"))" [CA]title="Edit" class="btn btn-outline-secondary"> #B <i class="bi bi-pencil"></i> </button> </td> </RowTemplate> </ViewSwitcher>
#A The context parameter can be renamed using the Context attribute.
#B Once renamed, the new name can be used within the template to refer to the object.
We can rename the context
parameter using the Context
attribute on a template. This is available only when the template is defined as RenderFragment<T>
. Once renamed, the new name can be used to refer to the object being displayed in the template. As you can see, this has made the code far more readable and easier to understand at a glance.
We can take this one step further. We can rename the context
parameter at the component level, and all the templates will automatically inherit the name. See the following listing.
Listing 7 HomePage.razor: Rename context at the component level
<ViewSwitcher Items="_trails" Context="trail"> #A <GridTemplate> <TrailCard Trail="trail" [CA]OnSelected="HandleTrailSelected" /> #B </GridTemplate> <HeaderTemplate> <th>Name</th> <th>Location</th> <th>Length</th> <th>Time</th> <th></th> </HeaderTemplate> <RowTemplate> <th scope="col">@trail.Name</th> <td>@trail.Location</td> #B <td>@(trail.Length)km</td> #B <td>@trail.TimeFormatted</td> #B <td class="text-right"> <button @onclick="@(() => [CA]HandleTrailSelected(trail))" title="View" [CA]class="btn btn-primary"> #B <i class="bi bi-binoculars"></i> </button> <button @onclick="@(() => [CA]NavManager.NavigateTo($"/edit-trail/{trail.Id}"))" [CA]title="Edit" class="btn btn-outline-secondary"> #B <i class="bi bi-pencil"></i> </button> </td> </RowTemplate> </ViewSwitcher>
#A The context parameter is renamed at the component level.
#B Once renamed, the new name can be used within the template to refer to the object.
By renaming the context
parameter at the component level, we can remove the individual names from each template.
That’s all for this article. Thanks for reading.